diff --git a/.github/actions/setup-java/action.yaml b/.github/actions/setup-java/action.yaml new file mode 100644 index 000000000..709f5b3cf --- /dev/null +++ b/.github/actions/setup-java/action.yaml @@ -0,0 +1,17 @@ +name: Set up Java workspace +description: Install a supported JDK and cache Maven dependencies for the Java workspace. + +inputs: + java-version: + default: "25" + description: JDK version to use. + +runs: + using: composite + steps: + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: ${{ inputs.java-version }} + cache: maven + cache-dependency-path: "java/**/pom.xml" diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index 04899d01f..4b2abb030 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -1,6 +1,6 @@ name: Conformance -# The Rust and JavaScript workflows run only when their own files change, but +# The port workflows run only when their own files or dependencies change, but # their fixture tests compare against values generated from River's Go code. # This job closes that gap: when Go code, SQL queries the generator reads, or # the generator itself changes, it regenerates the fixtures and runs only the @@ -11,6 +11,7 @@ on: - master paths: - ".github/workflows/conformance.yaml" + - "Makefile" - "**.go" - "**/go.mod" - "**/go.sum" @@ -20,6 +21,7 @@ on: pull_request: paths: - ".github/workflows/conformance.yaml" + - "Makefile" - "**.go" - "**/go.mod" - "**/go.sum" @@ -69,9 +71,16 @@ jobs: - uses: ./.github/actions/setup-js + - uses: ./.github/actions/setup-java + with: + java-version: "21" + # Each target generates the fixtures before running its tests. - name: Rust fixture tests run: make test/rust/conformance - name: JavaScript fixture tests run: make test/js/conformance + + - name: Java fixture tests + run: make test/java/conformance diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml new file mode 100644 index 000000000..ff482e0ca --- /dev/null +++ b/.github/workflows/java.yaml @@ -0,0 +1,132 @@ +name: Java + +# Filter the workflow so unrelated changes don't add skipped jobs to CI. +on: + push: + branches: + - master + paths: + - ".github/actions/setup-java/**" + - ".github/workflows/java.yaml" + - "Makefile" + - "conformance/**" + - "java/**" + - "riverdriver/riverpgxv5/migration/**" + - "riverdriver/riversqlite/migration/**" + pull_request: + paths: + - ".github/actions/setup-java/**" + - ".github/workflows/java.yaml" + - "Makefile" + - "conformance/**" + - "java/**" + - "riverdriver/riverpgxv5/migration/**" + - "riverdriver/riversqlite/migration/**" + workflow_dispatch: + +concurrency: + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +jobs: + quality: + name: Quality and package archives + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-java + + - uses: actions/setup-go@v6 + with: + go-version-file: go.work + + # Install once; lint/java invokes the linter for each standalone Go tool. + - name: Set up Go linter + uses: golangci/golangci-lint-action@v9 + with: + args: --help + version: v2.13.1 + + - name: Compile, check formatting, and lint maintenance tools + run: make lint/java + + - name: Test Go maintenance tools + run: make test/java/tools + + - name: Verify canonical migration mirror + run: make verify/java-migrations + + - name: Check publishable archives + run: make check/java/package + + java_versions: + name: Test (Java ${{ matrix.java-version }}, SQLite) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + java-version: ["21", "25"] + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-java + with: + java-version: ${{ matrix.java-version }} + + - uses: actions/setup-go@v6 + with: + go-version-file: go.work + + - name: Unit, SQLite, and executable CLI tests + run: make test/java/sqlite + + postgres: + name: Test (PostgreSQL ${{ matrix.postgres-version }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + postgres-version: [14, 15, 16, 17, 18] + env: + RIVER_TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/river_test?sslmode=disable + + services: + postgres: + image: postgres:${{ matrix.postgres-version }} + env: + POSTGRES_DB: river_test + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd "pg_isready -U postgres -d river_test" + --health-interval 2s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-java + + - uses: actions/setup-go@v6 + with: + go-version-file: go.work + + - name: PostgreSQL client, worker, and migration tests + run: make test/java/postgres diff --git a/Makefile b/Makefile index 170de5979..4cfeb5593 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ .DEFAULT_GOAL := help SQLC ?= sqlc +MVN ?= mvn .PHONY: check/modzip -check/modzip: ## Check that no Go module zip includes fixtures, testdata, or the Rust or JS ports +check/modzip: ## Check that no Go module zip includes fixtures, testdata, or another language's port go run ./conformance/cmd/checkmodzip ./go.work .PHONY: db/reset @@ -24,6 +25,7 @@ db/reset/test: ## Drop, create, and migrate test databases .PHONY: generate generate: ## Generate generated artifacts generate: generate/fixtures +generate: generate/java-migrations generate: generate/js-migrations generate: generate/migrations generate: generate/rust-migrations @@ -35,6 +37,10 @@ generate: generate/sqlc generate/fixtures: ## Generate cross-language conformance fixtures from River's Go implementation go run ./conformance/cmd/generatefixtures +.PHONY: generate/java-migrations +generate/java-migrations: ## Sync database migrations to Java + go run ./java/bin/sync-migrations/main.go + .PHONY: generate/js-migrations generate/js-migrations: ## Sync database migrations to JavaScript pnpm -C js run generate:migrations @@ -107,6 +113,23 @@ lint/rust: ## Run Rust formatting and clippy checks, including single-backend bu 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 +# Java targets stay separate so Go-only contributors do not need a JDK or Maven. +.PHONY: build/java +build/java: ## Build the Java library, CLI, and development adapter (JDK 21+) + $(MVN) --batch-mode --no-transfer-progress -f java/pom.xml package -DskipTests + +.PHONY: lint/java +lint/java: ## Compile Java, check formatting, and lint Go maintenance tools +lint/java: lint/java/tools + $(MVN) --batch-mode --no-transfer-progress -f java/pom.xml verify -DskipTests + +# Java's module boundary excludes it from Go package discovery. Pass the tool +# files explicitly so they use this workspace's Go version and test dependencies. +.PHONY: lint/java/tools +lint/java/tools: ## Lint Java's Go maintenance tools + golangci-lint run --fix ./java/bin/check-packages/*.go + golangci-lint run --fix ./java/bin/sync-migrations/*.go + # JavaScript targets, like the Rust ones, are separate from `lint` and `test` # and need Node.js 26 and pnpm; they delegate to the workspace's own scripts. # Run `pnpm -C js install` first. @@ -144,6 +167,34 @@ 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" +.PHONY: test/java +test/java: ## Run Java tests, executable CLI tests, and formatting checks +test/java: generate/fixtures verify/java-migrations +test/java: test/java/tools + $(MVN) --batch-mode --no-transfer-progress -f java/pom.xml verify + +# Fixture comparisons use temporary SQLite databases, with no PostgreSQL or legacy adapter. +.PHONY: test/java/conformance +test/java/conformance: ## Run Java tests that check Go-generated conformance fixtures +test/java/conformance: generate/fixtures + $(MVN) --batch-mode --no-transfer-progress -f java/pom.xml -pl river test -Dgroups=conformance -Driver.test.database=sqlite + +.PHONY: test/java/postgres +test/java/postgres: ## Run Java tests with PostgreSQL client and worker coverage (requires RIVER_TEST_DATABASE_URL) +test/java/postgres: generate/fixtures verify/java-migrations + @test -n "$$RIVER_TEST_DATABASE_URL" || { echo "RIVER_TEST_DATABASE_URL is required" >&2; exit 1; } + $(MVN) --batch-mode --no-transfer-progress -f java/pom.xml verify -Driver.test.database=postgres + +.PHONY: test/java/sqlite +test/java/sqlite: ## Run Java unit, SQLite, and executable CLI tests without PostgreSQL +test/java/sqlite: generate/fixtures verify/java-migrations + $(MVN) --batch-mode --no-transfer-progress -f java/pom.xml verify -Driver.test.database=sqlite -DexcludedGroups=postgres + +.PHONY: test/java/tools +test/java/tools: ## Test Java's Go maintenance tools + go test ./java/bin/check-packages/*.go + go test ./java/bin/sync-migrations/*.go + # 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. @@ -218,6 +269,11 @@ doc/rust: ## Build Rust API documentation, compiled examples, and doctests for e 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/java/package +check/java/package: ## Build and verify publishable Maven archives without publishing +check/java/package: build/java + go run ./java/bin/check-packages/main.go + .PHONY: check/js/dependencies check/js/dependencies: ## Audit JavaScript advisories and production dependency licenses pnpm -C js audit @@ -304,11 +360,16 @@ update-mod-version: ## Update River packages in all submodules to $VERSION .PHONY: verify verify: ## Verify generated artifacts +verify: verify/java-migrations verify: verify/js-migrations verify: verify/migrations verify: verify/rust-migrations verify: verify/sqlc +.PHONY: verify/java-migrations +verify/java-migrations: ## Verify Java migrations match the canonical migrations + go run ./java/bin/sync-migrations/main.go -check + .PHONY: verify/js-migrations verify/js-migrations: ## Verify JavaScript migrations match the canonical migrations pnpm -C js run verify:migrations diff --git a/conformance/cmd/checkmodzip/main.go b/conformance/cmd/checkmodzip/main.go index d051602ae..a002d83ff 100644 --- a/conformance/cmd/checkmodzip/main.go +++ b/conformance/cmd/checkmodzip/main.go @@ -27,8 +27,8 @@ const conformanceModulePath = "github.com/riverqueue/river/conformance" // disallowedPathPattern matches paths, relative to a module's root, that must // never be published: fixture and testdata directories, JSON files, and the -// conformance, JavaScript, and Rust trees. -var disallowedPathPattern = regexp.MustCompile(`(^|/)(fixtures?|testdata)/|\.json$|^(conformance|js|rust)/`) +// conformance, Java, JavaScript, and Rust trees. +var disallowedPathPattern = regexp.MustCompile(`(^|/)(fixtures?|testdata)/|\.json$|^(conformance|java|js|rust)/`) func main() { if len(os.Args) != 2 { diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 000000000..b9752d435 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +.conformance/ +__pycache__/ diff --git a/java/API.md b/java/API.md new file mode 100644 index 000000000..e047f08eb --- /dev/null +++ b/java/API.md @@ -0,0 +1,100 @@ +# Java API review + +Reviewed against the exported APIs in this River checkout and the pinned Go +conformance reference. This is an application API comparison, not a claim that +Java exposes every Go helper. Stored values and protocol behavior still follow Go. + +| Go surface | Java surface | Reason for the shape | +| --- | --- | --- | +| `NewClient`, `Config`, `Start`, `Stop`, `StopAndCancel` | `Client`, `Workers.Builder`, `start`, `stop`, `stopAndCancel` | A client can insert without starting anything. A running `Workers` owns threads and implements `AutoCloseable`. | +| `JobArgs`, `Job[T]`, worker interfaces and registration | `JobType`, `Job`, `add(type, lambda)` | Records need no River interface; explicit kinds stay stable when Java classes are renamed. | +| `Insert`, `InsertTx`, `InsertMany`, `InsertManyTx` | `insert`, `insertMany`, connection-first overloads | Arguments are typed; results contain persisted `Job` rows, including cross-kind duplicates. Mixed batches use `JobType.submission`. | +| `InsertOpts`, `UniqueOpts`, struct tags | `InsertOptions`, `Unique`, `JobType.uniqueBy` | Named builders and fluent options avoid positional flags. Explicit JSON paths replace struct tags. | +| `JobGet`, `JobList`, cursor/order/filter params | `get`, `JobQuery`, `JobQuery.Page` | Raw reads use `JsonNode`; `get(id, type)` checks the kind and decodes a known argument type. Pagination remains explicit. | +| `JobCancel`, `JobDelete`, `JobDeleteMany`, `JobRetry` and transaction variants | `cancel`, `delete`, `deleteMany`, `retry`, connection-first overloads | Short operation names are unambiguous on `Client`; transaction ownership stays visible in the argument list. | +| `JobUpdate` output parameter, `RecordOutput` | `Client.output`, `WorkContext.output` | Names describe the supported operation; attempt output is buffered until completion. | +| `JobCompleteTx` | `Client.complete(connection, job)`, `WorkContext.complete(connection)` | Completion keeps `Job` and commits with application changes. Only a running attempt can be completed. A committed completion remains authoritative if the handler subsequently throws. | +| Context client, cancellation, cancel/snooze errors | `WorkContext.client`, cancellation methods, `cancel`, `discard`, `snooze` | Java has no Go context parameter convention. Control methods end the attempt; virtual-thread interruption supplements cooperative cancellation. | +| Hooks and middleware | `Extension` | Default methods group related callbacks. JDBC hooks and middleware may throw checked exceptions; insert middleware receives a standard `Callable`. | +| Retry policy | `RetryPolicy` | A lambda receives the full job and returns `Duration`; the default uses the same error-count backoff and jitter. | +| `Queues`, queue CRUD/control APIs | `Workers.addQueue/removeQueue`, `Client.queues()` | Runtime concurrency and persisted queue state have separate owners. Reads and mutations support caller transactions. | +| `Subscribe`, `EventKind`, `Event` | `Workers.subscribe`, `EventKind`, `Event`, `Subscription` | Enum filters and an `AutoCloseable` subscription replace channels. Callbacks are synchronous; queue events include their queue. | +| Periodic jobs and schedules | `Workers.Builder.periodic`, `Schedule` | Java time types and a functional scheduling interface; cron implementation details are package-private. | +| Leadership notifications and maintenance config | `requestResign`, worker builder settings | Coordination stays in the database; operational durations use `Duration`. | +| `rivermigrate` | `Migrator`, `Direction`, `Options`, executable CLI | Named options replace booleans and sentinel target versions. Down defaults to one migration; explicit target zero removes the line. | +| Driver, pool, transaction integration | `Database`, JDBC `DataSource`/`Connection` | Use existing Java database pools. Database drivers remain optional library dependencies. | +| Errors and row types | `RiverException.Code`, `Job.State`, nested result records | Exceptions carry failures; enums model closed sets; records keep related values together. Missing rows throw `NOT_FOUND`. | + +## Changes made before release + +- Removed no-op `Client.close()`. Close `Workers` and the application-owned pool. +- Added typed retrieval and typed transactional completion, including running-state validation. +- Unified bulk insertion around typed homogeneous lists and mixed-kind submissions, + with both owned and caller-owned transactions. +- Made insertion results contain persisted JSON rows so a duplicate can safely + refer to a different kind or argument schema, matching Go's insertion result. +- Added savepoint protection to queue controls and connection-based reads, and a + public transaction callback overload for grouping operations in a savepoint. +- Allowed checked failures from insert hooks and middleware; SQL failures retain + their cause under `RiverException.Code.DATABASE`. +- Replaced string event kinds with enums, added subscription filters and queue + payloads, and made subscription closure free of checked exceptions. +- Made repeated stop calls continue to wait after a timeout. A graceful-stop + timeout leaves attempts running, as in Go; `stopAndCancel` explicitly escalates it. +- Changed retry callbacks to receive the full job, enabling kind- and metadata-specific policies. +- Added named migration options and fluent uniqueness state/kind overrides. +- Kept destructive reset and retention helpers behind the internal driver seam. + `Plugin`, `Client.Driver`, protocol JSON helpers, and explicitly marked companion + methods are not stable application extension APIs. + +Options copy collections and metadata on construction; `InsertOptions.metadata()` +and `JobQuery.metadata()` also return copies. Job/result JSON values are snapshots, +not database-backed objects. Builders are mutable and should be confined to +configuration code. + +## Runtime and option review + +- Lists now default to ascending job ID order, matching the Go implementation and + keeping pagination independent of rescheduling. Explicit time ordering remains + available with `JobQuery.Order.TIME`. +- Attempt timeouts are supervised independently of fetching and continue during + graceful stop. Finished handlers are no longer cancelled while their completion + is awaiting a database commit; an interrupted handler cannot abandon completion + retries or strand its worker slot. +- Worker durations and snoozes must fit in signed 64-bit nanoseconds, matching Go's + duration range. Invalid worker settings fail during configuration, and an invalid + snooze fails the attempt normally instead of trapping it in completion retries. +- An explicit `rescueAfter` cannot be shorter than `jobTimeout`. The default is one + hour, plus a positive explicitly configured job timeout, following Go and Rust. +- `JobType.uniqueBy("account_id", "address")` selects top-level JSON fields without + nested lists. The existing component-list overload handles nested paths and + retains literal dots in field names. +- Reusing a worker builder generates a separate runtime ID on each `start()`; + an explicit `id` remains the caller's responsibility to keep unique. +- Observer and subscriber failures are reported without losing a worker slot, + changing the attempt outcome, or preventing delivery to the remaining callbacks. + Failures in the error handler fall back to the system logger. These callbacks + remain synchronous and must finish quickly. +- Retry policies run only when the attempt will be retried, matching Go's terminal + state handling. Throwing policies and invalid delays are reported and use the + default backoff; they cannot prevent completion from being persisted. +- Query metadata is copied on access so reusable filters cannot change through + an exposed JSON node. + +## Remaining Go API gaps + +These are implementation limits, not claims that Java conventions require a +smaller feature set: + +- OSS periodic definitions cannot be added or removed on a running runtime and + currently carry fixed arguments rather than a constructor invoked each time. +- Events do not yet include Go's per-job timing statistics. Java supplies callback + delivery rather than a channel buffer and its associated subscription options. +- Resumable progress is buffered until the attempt ends; transactional checkpoint + helpers are not exposed. The Go logging middleware and worker-test harness have + no packaged Java equivalents. +- Some maintenance configuration is coarser: reindexing accepts an interval, + polling policy belongs to a runtime, and insert defaults belong to a job type. + +See [differences](DIFFERENCES.md), the [feature inventory](conformance/feature-inventory.json), +and [validation](VALIDATION.md) for protocol coverage and existing limitations. diff --git a/java/DIFFERENCES.md b/java/DIFFERENCES.md new file mode 100644 index 000000000..46ab16b16 --- /dev/null +++ b/java/DIFFERENCES.md @@ -0,0 +1,70 @@ +# Intentional differences from Go + +- Java 21 is the minimum version. Records, lambdas, `Instant`, `Duration`, JDBC + transactions, and virtual threads are the native API; no Go-shaped worker + inheritance hierarchy is required. +- Job kinds are explicit `JobType` values, independent of Java class names. + Unique argument fields are explicit paths instead of Go struct tags. +- JSON uses Jackson 3 and snake_case record properties. Applications must use + compatible JSON field names in every implementation sharing a job kind. +- A `DataSource` and its pool belong to the application. `Database.connect` + uses DriverManager without pooling; callers wanting pooled connections supply + a data source. River-owned JDBC connections are closed after each transaction. +- Caller-owned transactions use JDBC savepoints. Hooks run within those + savepoints, so a failed River operation leaves the outer transaction usable. +- Cancellation is cooperative and followed by interruption after the configured + stuck threshold. The JVM has no safe way to kill an arbitrary uncooperative + thread. Such a handler retains its slot until it returns; handlers should use + interruptible I/O or inspect their `WorkContext` cancellation signal. +- Transaction variants use connection-first overloads instead of `Tx` suffixes. + Storage failures are unchecked `RiverException` values with a stable code and + original cause; JDBC callbacks may throw checked exceptions. +- Runtime events use enum kinds and are process-local callbacks. They are not a durable delivery + mechanism. A callback should finish quickly, and callback failures are sent to + the runtime error handler. +- Retry policies return relative `Duration` values. Exceptions and null, negative, + or overflowing retry delays are reported and fall back to the default backoff. +- Explicit retries that update a job send a standard insert notification in the + same transaction, so workers wake after commit. Go currently leaves discovery + of explicitly retried jobs to polling. +- Short rescued retries become `available` with their retry timestamp and notify + the queue once due. This avoids waiting for Java's combined maintenance cadence + when `serviceInterval` is long; Go rescues into `retryable` for its scheduler. +- Java's `serviceInterval` controls elections, maintenance, and queue heartbeats. + It must be shorter than the one-day queue retention period; Go reports queue + heartbeats separately, every ten minutes by default. +- SQL is kept in dialect-specific resource catalogs. The Go sqlc driver seam is + not part of the Java API. +- SQLite follows Go's JSONB storage and millisecond timestamp representation; + PostgreSQL uses microsecond timestamps. These are shared storage contracts, + not configurable Java serialization choices. + +No intentional differences in stored uniqueness hashes, job state values, reserved +metadata, migration versions, or notification payloads are allowed. Any remaining +conformance failure in these areas is a defect, not an API design choice. + +The following are current API limitations, not differences required by Java +conventions. The [API review](API.md) maps the complete application surface. + +OSS periodic registrations and polling policies belong to a worker configuration; +start a new runtime to change its periodic definitions, or use separate runtimes +for queues with different polling intervals. Fetch wakeups are coalesced without +a separate cooldown setting. Reindexing supports the daily UTC default or a +duration override, rather than an arbitrary cron expression. Job-type defaults +replace process-wide insert defaults. + +Insertion is independent of worker registration so a client can enqueue jobs +owned by another language. New job kinds always use Go's validated kind syntax; +the legacy bypass is omitted. `Workers.stop()` (also called by `close()`) and +`Workers.stopAndCancel()` wait for attempts to finish instead of exposing a +Go-style stopped channel. `Workers.Builder.stopTimeout` configures their waits. + +The [feature inventory](conformance/feature-inventory.json) accounts for all 304 +entries in the pinned upstream inventory, including internal driver details +that are not application APIs. + +Java has no bundled `riverlog` middleware or `rivertest.Worker` harness. Applications +can write the shared log metadata format explicitly and test with JUnit against +an isolated database. Resumable steps buffer progress until the attempt ends; +Go's transactional resumable checkpoint helpers are not yet exposed. See the +[README examples](README.md#features) for these API limitations. diff --git a/java/Makefile b/java/Makefile new file mode 100644 index 000000000..66c39bc8c --- /dev/null +++ b/java/Makefile @@ -0,0 +1,19 @@ +.PHONY: test test/conformance test/postgres test/sqlite test/tools lint lint/tools format package +test: + $(MAKE) -C .. test/java +test/conformance: + $(MAKE) -C .. test/java/conformance +test/postgres: + $(MAKE) -C .. test/java/postgres +test/sqlite: + $(MAKE) -C .. test/java/sqlite +test/tools: + $(MAKE) -C .. test/java/tools +lint: + $(MAKE) -C .. lint/java +lint/tools: + $(MAKE) -C .. lint/java/tools +format: + mvn spotless:apply +package: + $(MAKE) -C .. check/java/package diff --git a/java/README.md b/java/README.md new file mode 100644 index 000000000..94034305c --- /dev/null +++ b/java/README.md @@ -0,0 +1,778 @@ +# River for Java + +Prerelease Java 21 client and worker runtime for River's PostgreSQL and SQLite +schemas. Jobs are ordinary River jobs: another language can insert, cancel, +retry, or work them using the same database. + +## Quick start + +Define job arguments as a record, give the job a stable kind, and register a +worker lambda. This complete `Example.java` starts a client, inserts a job, +waits for its completion, and stops the workers. Set `DATABASE_URL` to a +PostgreSQL URL or a file-backed SQLite JDBC URL. + +```java +import com.riverqueue.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class Example { + record SendEmail(String address, String subject) {} + + public static void main(String[] args) throws Exception { + var database = Database.connect(System.getenv("DATABASE_URL")); + var client = new Client(database); + new Migrator(database).migrate(); + + var completed = new CompletableFuture(); + try (var workers = client.workers() + .queue("default", 10) + .add(JobType.of("send_email", SendEmail.class), context -> { + System.out.println("Sending " + context.args().subject() + " to " + context.args().address()); + context.output("sent"); + }) + .start(); + var subscription = workers.subscribe(completed::complete, Workers.EventKind.JOB_COMPLETED)) { + client.insert(JobType.of("send_email", SendEmail.class), + new SendEmail("hello@example.com", "Welcome")); + System.out.println("Completed job " + completed.get(30, TimeUnit.SECONDS).job().id()); + } + } +} +``` + +Run this demonstration against a development database with no other workers. +The worker prints a message; replace its body with your mail service. In a +service, keep `Workers` open until the application stops. Each active job runs +on a virtual thread; queue limits bound concurrent jobs. In production, run +migrations as a deployment step using the CLI below. + +## Installation + +The Maven artifact is `com.riverqueue:river:0.48.0-alpha.1`. To build from source, +run `make generate/fixtures` from the repository root, then `mvn install` from +`java/`, with `JAVA_HOME` pointing to JDK 21 or 25. Source tests need Go to generate +their reference fixtures; applications do not need Go. The pinned formatter does +not run on JDK 27. Add the JDBC driver for your database to your application. +Jackson 3 is included transitively. + +```xml + + com.riverqueue + river + 0.48.0-alpha.1 + +``` + +Job kinds are explicit, stable wire names independent of Java class names. +Record properties use snake_case in JSON by default, so `accountId` becomes +`account_id`. Match kinds and JSON fields across languages sharing a job. + +Use a pooled `DataSource` in applications. `Database.connect(url)` is a small +DriverManager convenience for scripts and tests and opens a physical connection +for each operation. River never closes an application-owned data source. An +[insert-only client](https://riverqueue.com/docs/insert-only-clients) is simply a +`Client` instance without a worker runtime; inserting needs no start or stop call. + +## Migration CLI + +The `river-cli` executable JAR includes PostgreSQL and SQLite JDBC drivers and +runs with Java 21 or newer. It needs no Go installation or application classpath. +Build it as described under [Installation](#installation), then run: + +```sh +RIVER_CLI=cli/target/river-cli-0.48.0-alpha.1-all.jar +export DATABASE_URL=postgres://localhost/myapp +# For SQLite: export DATABASE_URL=jdbc:sqlite:/absolute/path/myapp.db + +java -jar "$RIVER_CLI" --help +java -jar "$RIVER_CLI" migrate-list +java -jar "$RIVER_CLI" migrate-up --dry-run +java -jar "$RIVER_CLI" migrate-up +``` + +The Maven executable artifact is `com.riverqueue:river-cli` with version +`0.48.0-alpha.1` and classifier `all`. Copy that single JAR into your deployment image to run +migrations before starting workers. `--database-url URL` overrides `DATABASE_URL`. +`--schema background_jobs` selects a PostgreSQL schema; an actual migration up +creates it if needed. Listing and dry runs do not create schemas or migration tables. + +| Command | Behavior | +| --- | --- | +| `migrate-up` | Applies all pending migrations, committing each separately. | +| `migrate-down` | Reverses one migration by default; may remove tables and data. | +| `migrate-list` | Lists bundled versions and whether each is applied. | +| `migrate-get` | Exports canonical SQL without connecting to a database. | +| `version` | Prints the CLI version; `--version` works too. | + +Use `--target-version N` to stop at a version, `--max-steps N` to limit the count, +and `--dry-run --show-sql` to inspect a planned change. On `migrate-down`, target +version `0` explicitly removes the entire migration line. A zero `--max-steps` +uses the command's default: all pending up migrations or one down migration. + +```sh +java -jar "$RIVER_CLI" migrate-down --max-steps 2 --dry-run --show-sql +java -jar "$RIVER_CLI" migrate-up --target-version 8 +java -jar "$RIVER_CLI" migrate-get --version 3 --up > river_3.up.sql +java -jar "$RIVER_CLI" migrate-get --driver sqlite --all --up > river_sqlite.up.sql +``` + +SQL export defaults to PostgreSQL without a database URL. Use `--driver sqlite` +for SQLite, or select the dialect through the URL. Export accepts comma-separated +`--version` values or `--all`; `--exclude-version 1` excludes migration history +setup when exporting for another migration framework. Exit codes are `0` for +success, `1` for an operation failure, and `2` for invalid arguments. + +The [migration history](https://riverqueue.com/docs/migrations) is shared with Go +and the other ports. Use the CLI version matching your Java library. The Java +API remains available for embedded use: + +```java +var result = new Migrator(database).migrate(); +System.out.println("Applied migrations: " + result.applied()); +var preview = new Migrator(database).migrate(Migrator.Direction.DOWN, + Migrator.Options.defaults().maxSteps(2).dryRun(true)); +``` + +## Transactional enqueueing + +[Enqueue in the application's transaction](https://riverqueue.com/docs/transactional-enqueueing) +so the job and application writes commit or roll back together. Pass a JDBC +connection with auto-commit disabled. River uses savepoints and never commits, +rolls back the outer transaction, or closes that connection. Insert hooks and +middleware run inside the same transaction. + +```java +try (var connection = applicationDataSource.getConnection()) { + connection.setAutoCommit(false); + try { + accounts.create(connection, account); + client.insert(connection, email, new SendEmail(account.email(), "Welcome")); + connection.commit(); + } catch (Exception failure) { + connection.rollback(); + throw failure; + } +} +``` + +`client.transaction(connection -> ...)` manages a new transaction for River-owned +operations. `client.transaction(existingConnection, connection -> ...)` groups +multiple operations under one savepoint in an existing transaction. Neither +`Client` nor `Database` needs closing; the application owns its pool. Workers should be [idempotent](https://riverqueue.com/docs/reliable-workers): +process failure can cause an attempt to run again after its external effects +have succeeded. + +## Inserting many jobs + +[Bulk insertion](https://riverqueue.com/docs/inserting-many-jobs) inserts a list atomically. +For an existing JDBC transaction, use the connection-first overload of either +form below. Homogeneous batches take a common job type. Mixed batches use +submissions with their own kind and options. Both return insertion results +containing the persisted `Job` values. + +```java +var inserted = client.insertMany(email, List.of( + new SendEmail("one@example.com", "Welcome"), + new SendEmail("two@example.com", "Welcome"))); + +record GenerateReport(long accountId) {} +var report = JobType.of("generate_report", GenerateReport.class); +var mixed = client.insertMany(List.of( + email.submission(new SendEmail("one@example.com", "Welcome")), + report.submission(new GenerateReport(42), InsertOptions.builder().queue("reports").build()))); +``` + +## Reading typed jobs + +`client.get(id)` returns `Job` because an ID does not identify a Java +argument class. Supply a job type when it is known: + +```java +Job job = client.get(jobId, email); +System.out.println(job.args().address()); +``` + +Typed retrieval checks the kind and decodes the arguments. +`context.complete(connection)` also retains its `Job` argument type. +Insertion results contain `Job` because uniqueness can return an +existing job with another kind or argument schema. The returned arguments are +the persisted values, including any additional fields written by another language. +See the [API comparison](API.md) for the Go mapping and prerelease API changes. + +Lists default to ascending job ID order, matching Go. Keep the filters and order +unchanged when continuing from a page's cursor: + +```java +var query = JobQuery.builder().kinds("send_email").limit(50).build(); +var first = client.list(query); +if (!first.jobs().isEmpty()) { + var next = client.list(query.after(first.cursor())); +} +``` + +Use `order(JobQuery.Order.TIME)` for ordering by the timestamp appropriate to the +selected state, or `SCHEDULED_AT` or `FINALIZED_AT` for an explicit timestamp. + +## Job retries + +A thrown exception triggers [retries](https://riverqueue.com/docs/job-retries) +until `maxAttempts` is exhausted, when the job becomes discarded. The default +policy uses River's quartic backoff with jitter. Configure a different policy +on the worker builder, or explicitly retry an existing job with `client.retry(id)`. +An explicit retry that updates a job notifies its queue after commit. Use +`client.retry(connection, id)` to make the retry part of an existing transaction. +A retry policy receives the full job snapshot before the current failure is +appended to `errors`; use `job.errors().size() + 1` to include that failure. +It runs only when another attempt is possible. Return a nonnegative `Duration`; +if the policy throws, returns null or a negative delay, or overflows the retry +time, River reports the problem and uses its default backoff. + +```java +var retryingEmail = email.withDefaults(InsertOptions.builder().maxAttempts(10).build()); +var workerConfig = client.workers() + .queue("default", 10) + .retryPolicy(job -> Duration.ofSeconds(Math.min(300, 5L * (job.errors().size() + 1)))) + .add(retryingEmail, context -> + mailer.send(context.args().address(), context.args().subject())); +client.insert(retryingEmail, new SendEmail("hello@example.com", "Welcome")); +``` + +Call `workerConfig.start()` and keep the runtime open as in the first example. +Job-type defaults apply when inserting through that `JobType`; another producer +must set its own compatible options. + +## Features + +These sections follow the non-Pro entries in the River documentation's +[Features sidebar](https://riverqueue.com/docs). Snippets are independent and +reuse `client` and `database` from the quick start, with imports from +`com.riverqueue`, `java.time`, and `java.util`. Handler examples configure a builder; +start it after registering your handlers. `mailer`, `accounts`, and `deliveries` +are application services, `applicationDataSource` is your JDBC connection pool, +`application.awaitStop()` waits for your service to stop, and `jobId` is an +existing job's ID. + +```java +var email = JobType.of("send_email", SendEmail.class); +var workerConfig = client.workers().queue("default", 10); +``` + +### Cancelling jobs + +[Cancel](https://riverqueue.com/docs/cancelling-jobs) an enqueued or running job +with `client.cancel(id)`. Running attempts receive a cooperative cancellation +signal. A handler can cancel itself permanently with `context.cancel(reason)`; +this ends the attempt without further retries. + +```java +client.cancel(jobId); + +workerConfig.add(email, context -> { + context.checkCancelled(); + if (context.args().address().isBlank()) context.cancel("Missing address"); + mailer.send(context.args().address(), context.args().subject()); +}); +``` + +Long-running handlers should check cancellation between operations and use +interruptible I/O. `context.awaitCancellation()` waits for the signal. +As in Go, a normal return reports success even if cancellation or a timeout was +requested. Call `context.checkCancelled()` when abandoning unfinished work. + +### Getting the client within workers + +[`context.client()`](https://riverqueue.com/docs/context-client) gives a handler +its client, including configured extensions. Use its transaction overloads when +enqueueing follow-up work that must commit with other writes or job completion. + +```java +record Welcome(String address) {} +var welcome = JobType.of("welcome", Welcome.class); +workerConfig.add(welcome, context -> + context.client().insert(email, new SendEmail(context.args().address(), "Welcome"))); +``` + +### Error and panic handling + +Java exceptions and uncaught `Error`s are handled as failed attempts; River +persists failures and retries according to policy. For +[application error reporting](https://riverqueue.com/docs/error-handling), attach +an `Extension.afterWork` hook. `Workers.Builder.errorHandler` separately receives +runtime failures, such as database or subscriber errors. +Failures in observers or event subscribers are reported without changing the +job's outcome or preventing other callbacks from running. If the runtime error +handler itself throws, River logs both failures and continues. + +```java +var monitoredClient = client.withExtension(new Extension() { + @Override + public void afterWork(WorkContext context, Throwable failure) { + if (failure != null) { + System.getLogger("jobs").log(System.Logger.Level.ERROR, + "Job " + context.job().id() + " failed", failure); + } + } +}); +var workerConfig = monitoredClient.workers() + .queue("default", 10) + .errorHandler(failure -> + System.getLogger("river").log(System.Logger.Level.ERROR, "Runtime failure", failure)) + .add(email, context -> mailer.send(context.args().address(), context.args().subject())); +``` + +Hooks also see the control exceptions used for snoozing and cancellation. Keep +reporting hooks fast and avoid throwing from them. + +### Job-persisted logging + +[Job logs](https://riverqueue.com/docs/job-logging) live in `river:log` metadata +as an array of `{attempt, log}` objects. Java has no bundled equivalent of Go's +`riverlog` middleware yet. You can explicitly write that shared format; this +example appends a fixed message and retains the last ten attempt entries. + +```java +workerConfig.add(email, context -> { + var logs = new ArrayList(); + context.job().metadata().path("river:log").forEach(logs::add); + try { + mailer.send(context.args().address(), context.args().subject()); + } finally { + logs.add(Map.of("attempt", context.job().attempt(), "log", "Email attempt finished\n")); + context.metadata("river:log", logs.subList(Math.max(0, logs.size() - 10), logs.size())); + } +}); +``` + +Metadata is saved when the attempt finishes, including on failure. General-purpose +logging integrations should also bound each entry's byte size; this snippet does +not capture SLF4J or `System.Logger` output automatically. + +### Multiple queues + +[Queues](https://riverqueue.com/docs/multiple-queues) isolate concurrency for +different workloads. Set a queue at insertion and configure its worker count. +The count is local to each runtime; it is not a cluster-wide concurrency limit. + +```java +var workerConfig = client.workers() + .queue("default", 10) + .queue("mail", 4) + .add(email, context -> mailer.send(context.args().address(), context.args().subject())); +client.insert(email, new SendEmail("hello@example.com", "Welcome"), + InsertOptions.builder().queue("mail").build()); +``` + +A running runtime also supports `workers.addQueue(name, count)` and +`workers.removeQueue(name)`, which drains that queue's active attempts. +Use `fetchOnlyKnownKinds(true)` when sharing queues with other language workers +that handle different kinds. + +### Pausing queues + +[Pausing](https://riverqueue.com/docs/pausing-queues) stops new fetches across +clients sharing the database; active attempts may finish. Inserts continue while +a queue is paused. Queues must already exist, for example after starting workers. + +```java +client.queues().pause("mail"); +// Resume after the maintenance window. +client.queues().resume("mail"); +``` + +Both operations also accept a JDBC transaction as their first argument. + +### Periodic and cron jobs + +Register [periodic jobs](https://riverqueue.com/docs/periodic-jobs) on every +potential leader with matching schedules and arguments. Only the elected leader +enqueues them. The final boolean enables insertion whenever that client becomes +leader. Five-field cron expressions support explicit time zones. + +```java +var workerConfig = client.workers() + .queue("default", 10) + .add(email, context -> mailer.send(context.args().address(), context.args().subject())) + .periodic("hourly-status", Schedule.every(Duration.ofHours(1)), email, + new SendEmail("ops@example.com", "Hourly status"), InsertOptions.defaults(), true) + .periodic("daily-report", Schedule.cron("CRON_TZ=America/Chicago 0 9 * * *"), email, + new SendEmail("ops@example.com", "Daily report"), InsertOptions.defaults(), false); +``` + +Periodic IDs use the same syntax as job kinds and must fit within 127 UTF-8 bytes. + +OSS schedules are kept in memory and can miss occurrences during downtime or +leader changes. Periodic definitions are fixed when the Java runtime starts; +restart it to change them. + +Interval schedules retain their planned cadence when maintenance runs late. +Each job's `scheduled_at` defaults to its planned occurrence; an explicit +`scheduledAt` option takes precedence. + +### Recorded output + +[Output](https://riverqueue.com/docs/recorded-output) is a JSON value stored under +`metadata.output`. Set it in a worker with `context.output`; other languages and +River UI can read the result once the attempt finishes. + +```java +workerConfig.add(email, context -> { + mailer.send(context.args().address(), context.args().subject()); + context.output(Map.of("delivered", true, "address", context.args().address())); +}); + +var output = client.get(jobId).metadata().path("output"); +``` + +### Resumable jobs + +[Named steps](https://riverqueue.com/docs/resumable-jobs) let retries skip work +completed before an earlier failure. Keep step names and ordering stable across +deployments and languages; anything outside a step runs on every attempt. + +```java +workerConfig.add(email, context -> { + context.step("send", () -> + mailer.send(context.args().address(), context.args().subject())); + context.step("record-delivery", () -> deliveries.record(context.job().id())); +}); +``` + +For loops, `context.stepWithCursor(name, cursor -> ...)` receives a saved JSON +cursor, and `context.cursor(value)` updates it. Java buffers step progress until +the attempt ends; it does not yet expose Go's transactional checkpoint helpers. +A process crash can lose buffered progress, so steps must remain idempotent. + +### Scheduled jobs + +[Schedule a job](https://riverqueue.com/docs/scheduled-jobs) by supplying an +`Instant`. The leader's scheduler makes it available once due; execution also +depends on scheduler cadence and queue capacity. + +```java +client.insert(email, new SendEmail("hello@example.com", "Reminder"), + InsertOptions.builder() + .scheduledAt(Instant.now().plus(Duration.ofHours(3))) + .build()); +``` + +### Snoozing jobs + +[Snooze](https://riverqueue.com/docs/snoozing-jobs) when work should wait without +consuming a retry. `context.snooze` ends the current attempt and schedules the +same job to run again; code after the call does not execute on that attempt. + +```java +workerConfig.add(email, context -> { + if (!mailer.isReady()) context.snooze(Duration.ofMinutes(5)); + mailer.send(context.args().address(), context.args().subject()); +}); +``` + +### Subscriptions + +[Subscribe](https://riverqueue.com/docs/subscriptions) to events from a running +`Workers` instance. Callbacks are process-local, run synchronously, and should +finish quickly. They are not a durable stream of all events in the cluster. +Close the subscription to unregister it. + +```java +try (var subscription = workers.subscribe( + event -> System.out.println("Completed job " + event.job().id()), + Workers.EventKind.JOB_COMPLETED)) { + application.awaitStop(); +} +``` + +Event kinds are `Workers.EventKind` values. Omit the filter to receive every kind. +Job events carry `event.job()`; queue events carry `event.queue()` and a null job. +The enum's `value()` retains River's lower-case protocol spelling. + +### Testing + +Use [isolated database tests](https://riverqueue.com/docs/testing) with JUnit and +signals instead of sleeps. This complete test method uses a fresh SQLite file, +subscribes before inserting, and waits for committed completion. Add the SQLite +JDBC driver and JUnit Jupiter to the test classpath. + +```java +@org.junit.jupiter.api.Test +void completes(@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) throws Exception { + record Echo(String message) {} + var echo = JobType.of("echo", Echo.class); + var database = Database.connect("jdbc:sqlite:" + directory.resolve("river.db")); + new Migrator(database).migrate(); + var client = new Client(database); + var completed = new java.util.concurrent.CompletableFuture(); + + try (var workers = client.workers() + .queue("default", 1) + .add(echo, context -> context.output(context.args().message())) + .start(); + var subscription = workers.subscribe(event -> { + if (event.kind() == Workers.EventKind.JOB_COMPLETED) completed.complete(event); + })) { + var inserted = client.insert(echo, new Echo("hello")); + var event = completed.get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(inserted.job().id(), event.job().id()); + org.junit.jupiter.api.Assertions.assertEquals(Job.State.COMPLETED, event.job().state()); + org.junit.jupiter.api.Assertions.assertEquals("hello", event.job().metadata().path("output").asString()); + } +} +``` + +`new Client(database, clock)` accepts an injected `Clock` for deterministic insert +timestamps. The worker runtime uses wall time. Java does not currently provide +a separate equivalent of Go's `rivertest.Worker` harness. + +### Transactional job completion + +[Complete a job atomically](https://riverqueue.com/docs/transactional-job-completion) +with application writes using `context.complete(connection)`. If the transaction +rolls back, neither change survives. Return from the handler after committing; +external effects such as sending email cannot participate in a JDBC transaction. + +```java +workerConfig.add(email, context -> { + context.transaction(connection -> { + deliveries.record(connection, context.job().id()); + context.output(Map.of("recorded", true)); + context.complete(connection); + return null; + }); +}); +``` + +### Unique jobs + +[Uniqueness](https://riverqueue.com/docs/unique-jobs) can combine arguments, +queue, time period, and job states. A duplicate insert returns the existing job +with `uniqueSkippedAsDuplicate()` set. The key format is shared with Go and the +other ports. + +```java +var options = InsertOptions.builder() + .unique(Unique.args().perQueue().per(Duration.ofHours(1))) + .build(); +var inserted = client.insert(email, new SendEmail("hello@example.com", "Welcome"), options); +boolean duplicate = inserted.uniqueSkippedAsDuplicate(); +``` + +A period is an aligned time bucket, not a sliding delay since the last insertion. +Use `email.uniqueBy("address")` to hash only selected top-level JSON fields, +and insert with that returned `JobType`. Nested fields use component lists, +such as `email.uniqueBy(List.of(List.of("account", "id")))`. Default unique states +include completed jobs, so a retained completed row can still prevent insertion. + +### Work functions + +[Workers can be functions](https://riverqueue.com/docs/work-functions). Java's +`Workers.Handler` accepts lambdas and method references, keeping arguments, +registration, and behavior together without a worker subclass for every kind. + +```java +Workers.Handler sendEmail = context -> + mailer.send(context.args().address(), context.args().subject()); +workerConfig.add(email, sendEmail); +``` + +## SQLite + +Use a [file database](https://riverqueue.com/docs/sqlite) shared by every +process. Connections opened by River enable WAL, foreign keys, and a five-second +busy timeout. Configure application-created connections equivalently. + +```java +var database = Database.connect("jdbc:sqlite:/var/lib/myapp/river.db"); +new Migrator(database).migrate(); +var client = new Client(database); +``` + +SQLite permits one writer at a time: keep application transactions short, and +insert using that transaction's connection. Do not open another writing +connection while holding an application write transaction. In-memory databases +with independent connections are not suitable for this runtime. + +## Alternate schema + +For PostgreSQL, select an [alternate schema](https://riverqueue.com/docs/alternate-schema) +on the `Database` used by both migrations and clients. All collaborating clients +must target the same schema. The migrator creates it if needed. + +```java +var jobsDatabase = database.withSchema("background_jobs"); +new Migrator(jobsDatabase).migrate(); +var jobsClient = new Client(jobsDatabase); +``` + +## Stopping workers + +For [graceful stopping](https://riverqueue.com/docs/graceful-shutdown), +`workers.stop()` stops fetching and waits for active attempts. `close()` does the +same, making try-with-resources convenient. `workers.stopAndCancel()` also +requests cancellation of active attempts. Both wait for handlers to return. +A graceful-stop timeout leaves attempts running; call `stop()` again to keep +waiting or `stopAndCancel()` to request cancellation. Attempt timeouts continue +to apply during graceful stop. Once a handler returns, River retains its worker +slot until completion is committed, retrying transient database failures even +if the handler left its thread interrupted. + +A normal handler return reports success, including after a shutdown signal. +If a handler exits with work unfinished, call `context.checkCancelled()` or +propagate `InterruptedException`; shutdown interruption requeues the job without +consuming an attempt. `awaitCancellation()` only waits for the signal. + +```java +var workers = client.workers() + .queue("default", 10) + .stopTimeout(Duration.ofSeconds(30)) + .add(email, context -> mailer.send(context.args().address(), context.args().subject())) + .start(); +try { + application.awaitStop(); +} finally { + workers.stop(); +} +``` + +The timeout bounds each stop wait and a queue-removal drain; a timeout throws a +`RiverException`. Cancellation is cooperative, followed by interruption after +the configured stuck threshold. The JVM cannot forcibly terminate code that +ignores interrupts; handlers must cooperate with cancellation. + +## Leader election and maintenance + +[Leader election](https://riverqueue.com/docs/leader-election) coordinates Java +and other River clients through the database. The leader runs periodic insertion +and [maintenance](https://riverqueue.com/docs/maintenance-services): scheduling +due jobs, rescuing abandoned attempts, cleaning expired jobs and queues, and +reindexing configured PostgreSQL indexes. Leadership is enabled by default. + +`rescueAfter` must not be shorter than `jobTimeout`, so another client does not +rescue an attempt that is still allowed to run. Its default is one hour, plus an +explicitly configured positive job timeout. Worker durations and snooze delays +must fit in signed 64-bit nanoseconds, the same range as Go's `time.Duration`. + +```java +var workerConfig = client.workers() + .queue("default", 10) + .retention(Duration.ofDays(1), Duration.ofDays(1), Duration.ofDays(7)) + .add(email, context -> mailer.send(context.args().address(), context.args().subject())); +``` + +Retention arguments are cancelled, completed, and discarded durations, in that +order. Negative durations retain that state indefinitely. If a runtime uses +`leadership(false)`, another eligible client must run maintenance; periodic +registrations require leadership. `workers.isLeader()` reports the local status. +Leadership leases last `serviceInterval` plus ten seconds, matching Go's renewal +margin even when the interval is increased. +`serviceInterval` also controls maintenance and queue heartbeats. It must be +positive and shorter than one day, the retention period for inactive queues. +Short retries and snoozes, including rescued retries, use timed fetch wake-ups +when they are due before the next maintenance interval. Rescued jobs also notify +their queue's peers when due. + +## Development + +From the repository root, with Go (the version in `go.work`), JDK 21 or 25, +and Maven installed: + +```sh +make test/java/conformance +make test/java/sqlite +RIVER_TEST_DATABASE_URL=postgres://localhost/river_test make test/java/postgres +make lint/java +make check/java/package +make verify/java-migrations +make check/modzip +``` + +`test/java` runs library and executable CLI tests and checks formatting. Client +and worker tests use PostgreSQL when `RIVER_TEST_DATABASE_URL` is set, with a +fresh schema for each test that is removed afterward. Otherwise they use SQLite. +`test/java/postgres` requires that URL; `test/java/sqlite` explicitly uses SQLite +and excludes PostgreSQL-only tests, even if a URL is set in your environment. +`test/java/conformance` needs no external database: it runs the JUnit tests tagged +`conformance`, using temporary SQLite databases for storage checks even if a +PostgreSQL URL is set. The full SQLite and PostgreSQL targets run these checks +against their selected backend. These targets first generate fresh fixtures +directly from this checkout's Go implementation, just like +`test/js/conformance` and `test/rust/conformance`. + +Java reads all four files in `conformance/testdata`: uniqueness hashes, cron +schedules, snooze counters, and protocol values (states, metadata keys, +notification payloads, attempt errors, and retry bounds). These files are ignored +by Git and read at test time. Missing fixtures fail with instructions to run +`make generate/fixtures`; there are no bundled fallback goldens. To use Maven or +an IDE directly, generate the fixtures first. `make test` from `java/` delegates +to the root target; `mvn spotless:apply` formats Java sources. + +Notification tests check both encoding and dispatch: targeted cancellation, queue +wakeups, leadership signals, and ignoring a client's own resignation. +Database-backed tests also verify topic routing, recovery after malformed +payloads, and immediate leadership wakeup after a peer resigns. + +Uniqueness tests include the raw JSON fixtures for duplicate keys and integer-like +map keys. Protocol tests exercise both retry jitter boundaries, decode stored +states and uniqueness bits, and verify periodic IDs, insert nonces, resumable +checkpoints, output, and rescue counters using Go's metadata keys. + +`make generate/java-migrations` syncs SQL and the migration catalog from the +canonical Go drivers. `verify/java-migrations` detects changed, missing, or extra +migrations. `check/java/package` checks the library and CLI archives, including +source JARs, for development content and verifies bundled runtime resources. +The Go maintenance tools live in `java/bin/`. `make test/java` and `make lint/java` +include their tests and lint checks; `make test/java/tools` and +`make lint/java/tools` run only the tool checks. From `java/`, use `make test/tools` +and `make lint/tools`. These checks run in Java CI, separately from the root +Go-only `make test` and `make lint` targets. +The legacy adapter is not installed or deployed as a Maven artifact. +`java/go.mod` excludes this directory from Go module archives and package +discovery. The Make targets invoke the Go tools by file from the root workspace; +the module is not part of `go.work` and must not be tagged as a Go module. + +The Java CI workflow follows the Rust and JavaScript layout: a quality/package +job, a JDK 21/25 matrix running unit and SQLite tests, and a PostgreSQL 14–18 +matrix running client, worker, and migration tests on JDK 25. All jobs use the +shared Java setup action and Maven dependency caching. The whole workflow is +filtered to changes in Java, its build configuration, fixtures, and canonical +migrations. Go changes run the smaller fixture suite in the shared Conformance +workflow alongside Rust and JavaScript. + +### Legacy cross-process harness + +The original interoperability adapter remains available for broader storage, +runtime, and multi-engine scenarios. It uses the historical harness pinned in +`java/conformance/reference-revision`, which is separate from the current +Go-generated fixtures and is not part of `master`'s conformance module. + +From `java/`, using only disposable databases (the harness resets job tables): + +```sh +RIVER_CONFORMANCE_DATABASE_URL=postgres://localhost/river_java_conformance \ + python3 conformance/bin/run.py postgres +python3 conformance/bin/run.py sqlite +``` + +The runner extracts that revision into ignored build storage and registers the +Java candidate there. `--reference /path/to/checkout` uses an existing harness. +`--refresh` opts into the old reference branch's current head; review its +contract and profiles before updating the pin. + +See [intentional differences](DIFFERENCES.md) for the Java API and lifecycle +choices. Passing a conformance profile demonstrates the scenarios in that +profile; it is not a claim about untested behavior or performance. + +The full peer matrix uses `multi` with `RIVER_CONFORMANCE_PEER_FILE` set to a +colon-separated list of Rust and JS candidate descriptors, and +`RIVERQUEUE_JS_ROOT` pointing to the JS checkout. `multi-soak` also requires +`RIVER_CONFORMANCE_MULTI_ENGINE_SOAK_DURATION=5m` (or longer). The upstream +harness currently has a PostgreSQL soak; SQLite endurance validation repeats +`TestMultiEngineSQLiteConformance` with `-count=5`. + +`python3 bin/import-reference.py --check /path/to/reference` verifies the legacy +adapter contract. Omit `--check` to import it and record its hash. This command +does not overwrite the current migrations or generated fixtures. + +See [validation results](VALIDATION.md) for the tested revisions and remaining +limitations. diff --git a/java/VALIDATION.md b/java/VALIDATION.md new file mode 100644 index 000000000..b1eb1bb5e --- /dev/null +++ b/java/VALIDATION.md @@ -0,0 +1,215 @@ +# Validation + +The original port and API review were validated locally on macOS arm64 with +JDK 25, Maven, PostgreSQL 18, and the SQLite JDBC driver pinned in `pom.xml`. +The adapters also ran on JDK 27. The build now targets Java 21; the Java 21 +compatibility trial is recorded below. Formatting works on JDK 21 and 25. + +References: + +- River Go/Rust: `eb16420fed22ce479f4843f0accd4c4bfba0885e` from + `bg/plan-interoperable-rust-river-port`. +- River JS: `696c67b606c1202eee221f0718b15ee433260bdf` from + `bg/interoperable-js-port`. + +| Check | Result | +|---|---| +| Maven native tests and formatting | Passed, 112 library tests and 7 CLI tests | +| Insert-only profile | Passed | +| Full PostgreSQL profile, maintenance, resilience | Passed | +| SQLite storage/runtime and resilience | Passed | +| Go + Java + Rust + JS on PostgreSQL and SQLite | Passed | +| Same-host enqueue, worker, and mixed performance gate | Passed with the original throughput and p95 limits | +| Four-engine PostgreSQL soak | Passed, five minutes | +| Four-engine SQLite endurance | Passed, five repetitions of the upstream multi-engine suite | + +The native tests include exact unique-key goldens, cron/maintenance goldens, +transaction rollback and hook atomicity, transactional completion, queue drain, +and filtering claims to registered job kinds. The shared harness exercises +cross-process state transitions, notifications, reconnection, leadership, +rescue, periodic insertion, cancellation races, migrations, and raw row equality. + +`conformance/scenario-coverage.json` maps shared scenarios to their harness +implementations. `conformance/feature-inventory.json` preserves all 304 upstream +inventory entries and identifies their Java surfaces and API differences. +`conformance/reference-sources.json` now records only the legacy adapter contract +hash; migrations and fixture tests follow the current checkout as described below. + +These results cover the pinned contracts and finite soak runs. They are not a +proof of every possible workload, nor a production deployment or a published +Maven release. The separate Pro report records a known Go-reference SQLite +failure; no Go source was changed to suppress it. + +## Migration CLI validation + +The CLI tests cover environment/argument handling, offline SQL export, dry runs, +targets and step limits, and launching the executable JAR without an external +classpath. Library tests cover concurrent SQLite initialization, migration +rollback, legacy migration history, and PostgreSQL schema creation and removal. +All were run with PostgreSQL enabled; no tests were skipped. + +After the migration CLI changes, the PostgreSQL mixed conformance suite and both +SQLite storage/runtime suites passed against the pinned Go reference. The +README quick start was compiled and run; its remaining Java examples compiled, +and its SQLite testing example ran successfully. + +## Public API review validation + +The API review added regression coverage for typed retrieval and mixed-kind +batches, checked JDBC hook failure recovery, queue notification atomicity, +committed completion followed by a handler exception, and repeated graceful-stop +timeouts without implicit cancellation, and job-aware retry policies. All 112 library and 7 CLI tests passed +with PostgreSQL enabled and no skips; Maven's formatting checks passed. + +The PostgreSQL maintenance, mixed-engine, and resilience suites and the SQLite +storage, runtime, and resilience suites were rerun against the pinned Go +reference. All README Java blocks compiled; the quickstart and SQLite JUnit +example executed successfully. The Rust/JS matrix, performance, and soak entries +above record the earlier port validation and were not repeated for this API pass. + +## Java 21 compatibility trial + +Validated on macOS arm64 with Temurin 21.0.12.1 and OpenJDK 25.0.1. On both +JDKs, the full Maven build and formatting checks passed: 114 library tests and +7 CLI tests, with PostgreSQL enabled and no skips. The library and executable +CLI target Java 21 (class file version 65), including when built on JDK 25. + +The changes replace unnamed `_` variables with named parameters and use +`ReentrantLock` for queue and leadership operations that can block. Java 21 +pins a virtual thread's carrier when it blocks inside a monitor. Two regression +tests launch a JVM with exactly one carrier and use latches to test blocking +claims and leadership callbacks. Both failed before the lock change and pass +after it. No public API or dependency changes were needed. + +With both `JAVA_HOME` and `PATH` selecting JDK 21, the PostgreSQL maintenance, +mixed-engine, and resilience suites and the SQLite storage, runtime, and +resilience suites passed against the pinned Go reference. All README Java +blocks compiled on JDK 21; the quickstart and SQLite JUnit example executed. +The Java CI workflow now tests JDK 21 and 25 with its existing path filters. + +Rust/JS peer, performance, and soak runs were not repeated for this trial; +the earlier results above do not establish their behavior on JDK 21. + +## Runtime and option review (2026-10-05) + +The full Maven build and formatting checks passed on Temurin 21.0.12.1 and +OpenJDK 25.0.1: 126 library tests and 7 CLI tests, with PostgreSQL enabled and +no skips. Eleven regression cases reproduced failures before the fixes and +passed afterward. They cover throwing observers/subscribers/error handlers, +throwing or invalid retry policies, terminal attempts bypassing retry policies, +distinct IDs when reusing a builder, and immutable query metadata. A further +test verifies the `uniqueBy(String...)` overload against stored uniqueness keys, +including literal dots in field names. + +On JDK 21, the PostgreSQL maintenance, mixed-engine, and resilience suites and +the SQLite storage, runtime, and resilience suites passed against the pinned Go +reference. Rust/JS peer, performance, and soak suites were not repeated for this +review. No Go code or shared SQL was changed. + +## Current conformance and CI alignment (2026-10-06) + +Rebased onto `master` at `4add77d2`, the merged +[PR #1451](https://github.com/riverqueue/river/pull/1451). Java reads all four +Go-generated fixtures from `conformance/testdata`, without committed or classpath +copies. The Java branch leaves `js/` and `rust/` identical to this reference. + +| Check | Result | +| --- | --- | +| `make test/java/conformance` on JDK 21 | Passed, 142 fixture tests, no database required | +| PostgreSQL 14, 15, 16, 17, and 18 on JDK 25 | Passed, 175 library tests and 7 CLI tests per version, no skips | +| PostgreSQL 18 on JDK 21 | Passed, 175 library tests and 7 CLI tests, no skips | +| SQLite on JDK 21 and 25 | Passed, 174 library tests and 7 CLI tests per JDK, no skips | +| `make test/rust/conformance` | Passed, 127 tests, including notification dispatch | +| Go `make test` and `make lint` | Passed in the working checkout | +| Migration mirror, formatting, and five publishable JARs | Passed | +| Go module archives | Passed in a clean snapshot excluding unrelated untracked `rust-demo/` artifacts | +| Java and shared conformance workflows | Passed actionlint | + +The recent Rust dispatcher test is mirrored in Java: all seven notification +fixtures are dispatched for both the sending client and an observer. Assertions +cover targeted cancellation, unrelated jobs, queue signals, leadership signals, +and ignoring self-resignation. Additional checks cover pending cancellation and +prevent a control payload on another topic from cancelling a job. + +Both JDBC listeners now preserve topics when dispatching. Peer resignations wake +leadership election immediately; self-resignations are ignored. Database-backed +tests use readiness signals to verify topic routing, delivery after malformed +JSON, and leadership wakeup with a one-hour poll interval. Both regressions +failed when the previous topic routing and producer-only wakeup behavior was +restored in an isolated copy, and pass with the fixes on both backends. + +Earlier fixture checks also exposed and fixed Java's treatment of boolean snooze +metadata: Go counts `true` as one before incrementing. Cron cases with no next +occurrence explicitly assert that the schedule is exhausted. + +The CI layout separates quality/package checks, a JDK 21/25 SQLite matrix, and a +PostgreSQL 14–18 matrix on JDK 25. Client and worker tests share assertions across +backends. PostgreSQL tests create and remove a schema per case; the version +matrix left no test schemas behind. The SQLite runs used an unreachable +PostgreSQL URL to verify that the explicit SQLite target remains independent. +The PostgreSQL target fails without its required database URL. + +Negative checks during the alignment verified missing-fixture diagnostics, +missing/changed/extra migration detection, rejection of test/fixture/adapter +content in JARs, and Go archive rejection when `java/go.mod` is removed. A +synthetic next canonical migration verified that synchronization updates the +runtime catalog as well as SQL. Fixture generation produced deterministic bytes. + +These are local executions of CI commands. Hosted workflows, the historical +cross-process harness, multi-engine storage/runtime suites, and soaks were not +rerun for this alignment. Earlier results remain tied to their recorded revisions. + +## Prerelease API and runtime review (2026-10-06) + +The review aligned default list ordering with Go's ascending ID order and added +18 regression cases for pagination, timeout supervision, durable completion, +duration validation, and rescue configuration. + +| Check | Result | +| --- | --- | +| `make test/java/sqlite` on JDK 21 and 25 | Passed, 192 library tests and 7 CLI tests per JDK, no skips | +| `make test/java/postgres` on JDK 21 and 25, local PostgreSQL | Passed, 193 library tests and 7 CLI tests per JDK, no skips | +| `make lint/java` and `make check/java/package` on JDK 21 | Passed, including adapter compilation and all five JARs | + +Tests hold the producer heartbeat or completion transaction behind latches to +verify that timeouts remain independent of dispatch and that a finished handler +cannot be cancelled during acknowledgement. A transient completion failure after +an interrupted handler verifies retry and subsequent reuse of its worker slot. +An oversized snooze must fail the attempt normally rather than strand it. + +The new regression cases also ran against the previous production sources in an +isolated copy. They detected the prior ordering, cancellation, completion retry, +timeout, duration, and rescue defects; the ordinary and disabled-timeout rescue +defaults continued to pass. No Go, Rust, JavaScript, or Pro source was changed. +The historical multi-engine and soak suites were not rerun for this review. + +## Conformance review after rebase (2026-10-06) + +Compared the Java port with `master` at `f36a6452`, including the JavaScript +conformance additions in PR #1460. The fixture generators and Rust conformance +tests are unchanged since the previous alignment. Java now consumes both unique +fixture groups, including duplicate top-level keys and integer-like map keys, +and exercises retry jitter boundaries, fixed attempt-error semantics, and +resumable checkpoint metadata. + +New storage tests read Go attempt errors and uniqueness bits from real rows and +verify periodic IDs, insert nonces, output, and rescue counters against the +generated metadata keys. The conformance target selects temporary SQLite +databases explicitly; the full database targets run these same assertions on +their selected backend. + +The rescue test exposed a SQLite snapshot-upgrade failure when another writer +committed between selecting and updating a stuck job. Rescue now reserves the +writer before selecting, using the existing lock query. The test attempts a +competing write during retry selection and verifies that it is excluded. In an +isolated copy, this assertion fails against the previous implementation. + +| Check | Result | +| --- | --- | +| `make test/java/conformance` on JDK 21 | Passed, 148 tests, with an unreachable PostgreSQL URL | +| `make test/java/sqlite` on JDK 21 and 25 | Passed, 198 library tests and 7 CLI tests per JDK, no skips | +| `make test/java/postgres` on JDK 21 and 25, local PostgreSQL | Passed, 199 library tests and 7 CLI tests per JDK, no skips | +| `make lint/java` and `make check/java/package` on JDK 21 | Passed, including migration verification and all five JARs | + +No Go, Rust, JavaScript, or shared fixture source was changed. PostgreSQL's +version matrix and the historical multi-engine and soak suites were not rerun. diff --git a/java/bin/check-packages/main.go b/java/bin/check-packages/main.go new file mode 100644 index 000000000..ee1ed072e --- /dev/null +++ b/java/bin/check-packages/main.go @@ -0,0 +1,113 @@ +// Command check-packages checks Maven archives for development files and +// missing runtime resources. Run it from the repository root with +// `make check/java/package`. +package main + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +var forbiddenPath = regexp.MustCompile(`(^|/)(fixtures?|testdata|tests|conformance)/|(?:Test|Tests)(?:\$[^/]*)?\.(class|java)$`) + +func main() { + if err := run("java"); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func checkArchive(path, resources string) error { + archive, err := zip.OpenReader(path) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + defer archive.Close() + + names := make(map[string]bool, len(archive.File)) + for _, file := range archive.File { + name := file.Name + if forbiddenPath.MatchString(name) || (strings.HasPrefix(name, "com/riverqueue/") && strings.HasSuffix(name, ".json")) { + return fmt.Errorf("%s contains development content: %s", path, name) + } + names[name] = true + } + if resources == "" { + return nil + } + + resourceFS := os.DirFS(resources) + if err := fs.WalkDir(resourceFS, ".", func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + expected, err := fs.ReadFile(resourceFS, name) + if err != nil { + return err + } + if !names[name] { + return fmt.Errorf("missing runtime resource %s", name) + } + actual, err := fs.ReadFile(archive, name) + if err != nil { + return fmt.Errorf("read runtime resource %s: %w", name, err) + } + if !bytes.Equal(actual, expected) { + return fmt.Errorf("stale runtime resource %s", name) + } + return nil + }); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if !names["com/riverqueue/Client.class"] { + return fmt.Errorf("%s: missing Client class", path) + } + return nil +} + +func run(root string) error { + contents, err := os.ReadFile(filepath.Join(root, "pom.xml")) + if err != nil { + return fmt.Errorf("read Maven project: %w", err) + } + var project struct { + Version string `xml:"version"` + } + if err := xml.Unmarshal(contents, &project); err != nil { + return fmt.Errorf("parse Maven project: %w", err) + } + if project.Version == "" { + return fmt.Errorf("%s/pom.xml: missing project version", root) + } + + for _, artifact := range []struct { + path string + runtime bool + }{ + {"river/target/river-" + project.Version + ".jar", true}, + {"river/target/river-" + project.Version + "-sources.jar", false}, + {"cli/target/river-cli.jar", false}, + {"cli/target/river-cli-sources.jar", false}, + {"cli/target/river-cli-" + project.Version + "-all.jar", true}, + } { + var resources string + if artifact.runtime { + resources = filepath.Join(root, "river/src/main/resources") + } + if err := checkArchive(filepath.Join(root, artifact.path), resources); err != nil { + return err + } + fmt.Printf("Java package verified: %s\n", artifact.path) + } + return nil +} diff --git a/java/bin/check-packages/main_test.go b/java/bin/check-packages/main_test.go new file mode 100644 index 000000000..65b76b96e --- /dev/null +++ b/java/bin/check-packages/main_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckArchive(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + extra string + missingClass bool + resource string + wantError string + }{ + {name: "Conformance", extra: "com/riverqueue/conformance/Adapter.class", wantError: "development content"}, + {name: "Fixture", extra: "fixtures/golden.json", wantError: "development content"}, + {name: "InnerTestClass", extra: "com/riverqueue/ClientTest$Helper.class", wantError: "development content"}, + {name: "JSON", extra: "com/riverqueue/protocol.json", wantError: "development content"}, + {name: "MissingClient", missingClass: true, resource: "SELECT 1;", wantError: "missing Client class"}, + {name: "MissingResource", wantError: "missing runtime resource"}, + {name: "StaleResource", resource: "SELECT 2;", wantError: "stale runtime resource"}, + {name: "TestSource", extra: "com/riverqueue/ClientTests.java", wantError: "development content"}, + {name: "Valid", resource: "SELECT 1;"}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + resources := filepath.Join(root, "resources") + require.NoError(t, os.MkdirAll(filepath.Join(resources, "com/riverqueue"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(resources, "com/riverqueue/postgres.sql"), []byte("SELECT 1;"), 0o600)) + path := filepath.Join(root, "river.jar") + file, err := os.Create(path) + require.NoError(t, err) + t.Cleanup(func() { _ = file.Close() }) + archive := zip.NewWriter(file) + contents := map[string]string{} + if !testCase.missingClass { + contents["com/riverqueue/Client.class"] = "bytecode" + } + if testCase.resource != "" { + contents["com/riverqueue/postgres.sql"] = testCase.resource + } + if testCase.extra != "" { + contents[testCase.extra] = "development content" + } + for name, content := range contents { + writer, err := archive.Create(name) + require.NoError(t, err) + _, err = writer.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, archive.Close()) + require.NoError(t, file.Close()) + + err = checkArchive(path, resources) + if testCase.wantError == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, testCase.wantError) + } + }) + } +} diff --git a/java/bin/import-reference.py b/java/bin/import-reference.py new file mode 100644 index 000000000..79916cadf --- /dev/null +++ b/java/bin/import-reference.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Import the legacy adapter contract from a pinned cross-process harness checkout.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('reference', type=Path) +parser.add_argument('--check', action='store_true', help='Verify the vendored bytes without changing them') +args = parser.parse_args() +reference = args.reference.resolve() +sources = {} + + +def copy(source, destination): + if args.check: + if source.read_bytes() != destination.read_bytes(): + raise RuntimeError(f'Reference mismatch: {destination}') + else: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + sources[str(source.relative_to(reference))] = hashlib.sha256(source.read_bytes()).hexdigest() + + +copy(reference / 'conformance/adapter/contract.json', root / 'conformance/src/main/resources/contract.json') +if not args.check: + (root / 'conformance/reference-sources.json').write_text(json.dumps(sources, indent=2, sort_keys=True) + '\n') diff --git a/java/bin/sync-migrations/main.go b/java/bin/sync-migrations/main.go new file mode 100644 index 000000000..391604c8f --- /dev/null +++ b/java/bin/sync-migrations/main.go @@ -0,0 +1,126 @@ +// Command sync-migrations mirrors River's canonical database migrations +// and generates the Java migration catalog. Run it from the repository root +// with `make generate/java-migrations` or `make verify/java-migrations`. +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io/fs" + "maps" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +func main() { + check := flag.Bool("check", false, "check generated files without writing") + flag.Parse() + if flag.NArg() != 0 { + fmt.Fprintln(os.Stderr, "usage: sync-migrations [-check]") + os.Exit(2) + } + if err := run(".", *check); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(root string, check bool) error { + destination := filepath.Join(root, "java/river/src/main/resources/com/riverqueue/migration") + expected := make(map[string][]byte) + var names []string + for _, database := range []struct { + dialect string + driver string + }{ + {"postgres", "riverpgxv5"}, + {"sqlite", "riversqlite"}, + } { + source := filepath.Join(root, "riverdriver", database.driver, "migration/main") + paths, err := filepath.Glob(filepath.Join(source, "*.up.sql")) + if err != nil { + return err + } + if len(paths) == 0 { + return fmt.Errorf("no canonical migrations in %s", source) + } + current := make([]string, 0, len(paths)) + for i, path := range paths { + name := strings.TrimSuffix(filepath.Base(path), ".up.sql") + prefix, _, _ := strings.Cut(name, "_") + version, err := strconv.Atoi(prefix) + if err != nil || version != i+1 { + return fmt.Errorf("migration versions must be consecutive in %s: %s", source, name) + } + current = append(current, name) + for _, direction := range []string{"up", "down"} { + filename := name + "." + direction + ".sql" + contents, err := os.ReadFile(filepath.Join(source, filename)) + if err != nil { + return fmt.Errorf("read canonical migration: %w", err) + } + expected[filepath.Join(destination, database.dialect, filename)] = contents + } + } + if names != nil && !slices.Equal(current, names) { + return errors.New("PostgreSQL and SQLite migration names differ; review the Java catalog") + } + names = current + } + expected[filepath.Join(destination, "index.txt")] = []byte(strings.Join(names, "\n") + "\n") + + var stale, extra []string + for _, path := range slices.Sorted(maps.Keys(expected)) { + actual, err := os.ReadFile(path) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read migration mirror: %w", err) + } + if err != nil || !bytes.Equal(actual, expected[path]) { + stale = append(stale, path) + } + } + if err := filepath.WalkDir(destination, func(path string, entry fs.DirEntry, err error) error { + if path == destination && errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !entry.IsDir() && strings.HasSuffix(path, ".sql") { + if _, ok := expected[path]; !ok { + extra = append(extra, path) + } + } + return nil + }); err != nil { + return fmt.Errorf("scan migration mirror: %w", err) + } + + if check { + if len(stale)+len(extra) > 0 { + return fmt.Errorf("migration mirror differs; run `make generate/java-migrations`:\n%s", strings.Join(append(stale, extra...), "\n")) + } + fmt.Printf("Java migrations: %d versions per backend, verified\n", len(names)) + return nil + } + for _, path := range stale { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create migration directory: %w", err) + } + if err := os.WriteFile(path, expected[path], 0o644); err != nil { //nolint:gosec // Generated repository artifacts are intentionally world-readable. + return fmt.Errorf("write migration mirror: %w", err) + } + } + for _, path := range extra { + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove stale migration: %w", err) + } + } + fmt.Printf("Java migrations: %d versions per backend, synced\n", len(names)) + return nil +} diff --git a/java/bin/sync-migrations/main_test.go b/java/bin/sync-migrations/main_test.go new file mode 100644 index 000000000..6db5a1d7c --- /dev/null +++ b/java/bin/sync-migrations/main_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRun(t *testing.T) { + t.Parallel() + + type testBundle struct { + destination string + root string + sources []string + } + setup := func(t *testing.T) *testBundle { + t.Helper() + + root := t.TempDir() + bundle := &testBundle{ + destination: filepath.Join(root, "java/river/src/main/resources/com/riverqueue/migration"), + root: root, + } + for _, driver := range []string{"riverpgxv5", "riversqlite"} { + source := filepath.Join(root, "riverdriver", driver, "migration/main") + require.NoError(t, os.MkdirAll(source, 0o755)) + for _, direction := range []string{"up", "down"} { + require.NoError(t, os.WriteFile(filepath.Join(source, "001_first."+direction+".sql"), []byte(driver+" "+direction+"\n"), 0o600)) + } + bundle.sources = append(bundle.sources, source) + } + return bundle + } + + t.Run("CheckAndRepairDrift", func(t *testing.T) { + t.Parallel() + + bundle := setup(t) + + require.ErrorContains(t, run(bundle.root, true), "migration mirror differs") + require.NoDirExists(t, bundle.destination) + require.NoError(t, run(bundle.root, false)) + require.NoError(t, run(bundle.root, true)) + index, err := os.ReadFile(filepath.Join(bundle.destination, "index.txt")) + require.NoError(t, err) + require.Equal(t, "001_first\n", string(index)) + + missing := filepath.Join(bundle.destination, "postgres/001_first.down.sql") + stale := filepath.Join(bundle.destination, "sqlite/001_first.up.sql") + extra := filepath.Join(bundle.destination, "sqlite/999_extra.up.sql") + require.NoError(t, os.Remove(missing)) + require.NoError(t, os.WriteFile(stale, []byte("stale"), 0o600)) + require.NoError(t, os.WriteFile(extra, []byte("extra"), 0o600)) + + err = run(bundle.root, true) + for _, path := range []string{missing, stale, extra} { + require.ErrorContains(t, err, path) + } + require.NoFileExists(t, missing) + contents, err := os.ReadFile(stale) + require.NoError(t, err) + require.Equal(t, "stale", string(contents)) + require.FileExists(t, extra) + + require.NoError(t, run(bundle.root, false)) + require.NoError(t, run(bundle.root, true)) + require.NoFileExists(t, extra) + contents, err = os.ReadFile(stale) + require.NoError(t, err) + require.Equal(t, "riversqlite up\n", string(contents)) + }) + + t.Run("InvalidSourcesDoNotWrite", func(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + rename string + wantError string + }{ + {name: "DifferentNames", rename: "001_other", wantError: "migration names differ"}, + {name: "MissingDown", wantError: "read canonical migration"}, + {name: "Nonconsecutive", rename: "002_first", wantError: "must be consecutive"}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + bundle := setup(t) + source := bundle.sources[1] + + if testCase.rename == "" { + require.NoError(t, os.Remove(filepath.Join(source, "001_first.down.sql"))) + } else { + for _, direction := range []string{"up", "down"} { + require.NoError(t, os.Rename(filepath.Join(source, "001_first."+direction+".sql"), filepath.Join(source, testCase.rename+"."+direction+".sql"))) + } + } + require.ErrorContains(t, run(bundle.root, false), testCase.wantError) + require.NoDirExists(t, bundle.destination) + }) + } + }) +} diff --git a/java/cli/pom.xml b/java/cli/pom.xml new file mode 100644 index 000000000..76e14a5ea --- /dev/null +++ b/java/cli/pom.xml @@ -0,0 +1,37 @@ + + 4.0.0 + com.riverqueueriver-java0.48.0-alpha.1../pom.xml + river-cli0.48.0-alpha.1 + River Java migration CLI + Mozilla Public License 2.0https://mozilla.org/MPL/2.0/repo + + com.riverqueueriver0.48.0-alpha.1 + org.postgresqlpostgresql + org.xerialsqlite-jdbc + org.junit.jupiterjunit-jupitertest + + + river-cli + + + org.apache.maven.pluginsmaven-shade-plugin3.6.1 + packageshade + false + trueall + + + com.riverqueue.cli.MigrationCli + ${project.version}ALL-UNNAMED + + + + *:*META-INF/*.SFMETA-INF/*.DSAMETA-INF/*.RSAmodule-info.classMETA-INF/versions/*/module-info.class + + + org.apache.maven.pluginsmaven-failsafe-plugin3.5.4 + --enable-native-access=ALL-UNNAMED + integration-testverify + + + + diff --git a/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java b/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java new file mode 100644 index 000000000..d1aeae458 --- /dev/null +++ b/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java @@ -0,0 +1,298 @@ +package com.riverqueue.cli; + +import com.riverqueue.Database; +import com.riverqueue.Migrator; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; + +/** Migration command runner shared with the matched River Pro CLI release. */ +public final class MigrationCli { + private static final Set CONNECTION = Set.of("database-url", "driver", "line", "schema"); + private static final Set FLAGS = Set.of("all", "down", "dry-run", "show-sql", "up"); + private static final Set VALUES = + Set.of( + "database-url", + "driver", + "exclude-version", + "line", + "max-steps", + "schema", + "target-version", + "version"); + + private MigrationCli() {} + + private static Database database( + Options options, Map environment, boolean offline) { + String url = options.values.getOrDefault("database-url", environment.get("DATABASE_URL")); + String driver = options.values.get("driver"); + if (driver != null && !Set.of("postgres", "sqlite").contains(driver)) + throw new Usage("--driver must be postgres or sqlite"); + if (offline && driver != null && !options.values.containsKey("database-url")) url = null; + if (url == null || url.isBlank()) { + if (!offline) throw new Usage("Set DATABASE_URL or pass --database-url"); + url = "sqlite".equals(driver) ? "jdbc:sqlite::memory:" : "jdbc:postgresql://localhost/unused"; + } + if (!url.startsWith("postgres://") + && !url.startsWith("postgresql://") + && !url.startsWith("jdbc:postgresql:") + && !url.startsWith("sqlite:") + && !url.startsWith("jdbc:sqlite:")) + throw new Usage("Expected a PostgreSQL URL or a SQLite JDBC URL"); + Database database; + try { + database = + Database.connect(url, "river-java-cli") + .withSchema(options.values.getOrDefault("schema", "")); + } catch (IllegalArgumentException error) { + throw new Usage("Invalid database URL or schema"); + } + if (driver != null + && !driver.equals(database.dialect() == Database.Dialect.SQLITE ? "sqlite" : "postgres")) + throw new Usage("--driver does not match the database URL"); + return database; + } + + private static void export(Options options, Migrator migrator, PrintWriter out) { + if (options.flags.contains("up") == options.flags.contains("down")) + throw new Usage("migrate-get requires exactly one of --up or --down"); + if (options.flags.contains("all") == options.values.containsKey("version")) + throw new Usage("migrate-get requires exactly one of --version or --all"); + if (options.values.containsKey("exclude-version") && !options.flags.contains("all")) + throw new Usage("--exclude-version requires --all"); + boolean down = options.flags.contains("down"); + var versions = new ArrayList(); + if (options.flags.contains("all")) { + var excluded = numbers(options.values.getOrDefault("exclude-version", "")); + for (int version = 1; version <= migrator.latest(); version++) + if (!excluded.contains(version)) versions.add(version); + if (down) java.util.Collections.reverse(versions); + } else versions.addAll(numbers(options.values.get("version"))); + if (versions.stream().anyMatch(version -> version > migrator.latest())) + throw new Usage("Unknown migration version"); + // Validate every requested version before writing SQL that may be piped into another tool. + var scripts = + versions.stream() + .map( + version -> + migrator.sql(version, down ? Migrator.Direction.DOWN : Migrator.Direction.UP)) + .toList(); + for (int i = 0; i < versions.size(); i++) { + out.printf("-- River migration %03d [%s]%n", versions.get(i), down ? "down" : "up"); + out.println(scripts.get(i).strip()); + out.println(); + } + } + + private static void help(PrintWriter out, String name, List lines) { + out.printf("Usage: %s [options]%n%n", name); + out.println("Commands:"); + out.println(" migrate-up Apply pending migrations (all by default)"); + out.println(" migrate-down Reverse migrations (one by default; removes schema/data)"); + out.println(" migrate-list List migration versions and their applied status"); + out.println(" migrate-get Print canonical SQL without connecting to a database"); + out.println(" version Print CLI version"); + out.println(); + out.println("Connection options:"); + out.println(" --database-url URL PostgreSQL or SQLite JDBC URL; defaults to DATABASE_URL"); + out.println( + " --driver DRIVER postgres or sqlite; selects dialect for offline SQL export"); + out.println(" --schema NAME PostgreSQL schema (default: connection's current schema)"); + out.printf( + " --line NAME Migration line: %s (default: main)%n", String.join(", ", lines)); + out.println(); + out.println("migrate-up / migrate-down:"); + out.println(" --target-version N Stop at N; down to 0 removes the entire line"); + out.println(" --max-steps N Limit migration count (0 means the command default)"); + out.println(" --dry-run Preview without applying migrations or creating schemas"); + out.println(" --show-sql Include SQL for the applied or planned migrations"); + out.println(); + out.println("migrate-get:"); + out.println(" --up | --down Required direction"); + out.println(" --version N[,N...] Select versions, in the supplied order"); + out.println(" --all Select all versions, in migration order"); + out.println(" --exclude-version N Exclude comma-separated versions when using --all"); + out.println(); + out.println("Use --help for help and --version for the CLI version."); + out.println("Exit codes: 0 success, 1 operation failed, 2 invalid usage."); + } + + /** Launches the OSS migration CLI. */ + public static void main(String[] args) { + String version = MigrationCli.class.getPackage().getImplementationVersion(); + System.exit( + run( + args, + System.getenv(), + new PrintWriter(System.out, true), + new PrintWriter(System.err, true), + "river", + version == null ? "development" : version, + List.of("main"), + (database, line) -> new Migrator(database))); + } + + private static int number(String value, String name) { + try { + int result = Integer.parseInt(value); + if (result < 0) throw new NumberFormatException(); + return result; + } catch (NumberFormatException error) { + throw new Usage(name + " must be a nonnegative integer"); + } + } + + private static List numbers(String source) { + if (source.isEmpty()) return List.of(); + var result = new ArrayList(); + for (String entry : source.split(",", -1)) { + int version = number(entry, "Migration version"); + if (version == 0 || result.contains(version)) + throw new Usage("Migration versions must be positive and distinct"); + result.add(version); + } + return result; + } + + private static Options parse(String[] args) { + String command = args[0]; + Set allowed = new HashSet<>(CONNECTION); + switch (command) { + case "migrate-down", "migrate-up" -> + allowed.addAll(Set.of("dry-run", "max-steps", "show-sql", "target-version")); + case "migrate-get" -> + allowed.addAll(Set.of("all", "down", "exclude-version", "up", "version")); + case "migrate-list" -> {} + default -> throw new Usage("Unknown command: " + command); + } + var flags = new HashSet(); + var values = new HashMap(); + for (int i = 1; i < args.length; i++) { + if (!args[i].startsWith("--")) throw new Usage("Expected an option, got: " + args[i]); + String[] option = args[i].substring(2).split("=", 2); + String name = option[0]; + if (!allowed.contains(name)) + throw new Usage("Unsupported option for " + command + ": --" + name); + if (flags.contains(name) || values.containsKey(name)) + throw new Usage("Repeated option: --" + name); + if (FLAGS.contains(name)) { + if (option.length != 1) throw new Usage("--" + name + " does not take a value"); + flags.add(name); + } else if (VALUES.contains(name)) { + String value; + if (option.length == 2) value = option[1]; + else { + if (++i == args.length || args[i].startsWith("--")) + throw new Usage("Missing value for --" + name); + value = args[i]; + } + if (value.isBlank()) throw new Usage("Missing value for --" + name); + values.put(name, value); + } + } + return new Options(command, Set.copyOf(flags), Map.copyOf(values)); + } + + /** + * Runs a CLI invocation with explicit I/O and a migration-line factory; does not terminate the + * JVM. + */ + public static int run( + String[] args, + Map environment, + PrintWriter out, + PrintWriter err, + String name, + String version, + List lines, + BiFunction factory) { + String databaseUrl = environment.get("DATABASE_URL"); + try { + if (args.length == 0 + || Arrays.asList(args).contains("--help") + || Arrays.asList(args).contains("-h")) { + help(out, name, lines); + return 0; + } + if (args.length == 1 && (args[0].equals("version") || args[0].equals("--version"))) { + out.println(name + " " + version); + return 0; + } + var options = parse(args); + String line = options.values.getOrDefault("line", "main"); + if (!lines.contains(line)) throw new Usage("Unknown migration line: " + line); + databaseUrl = options.values.getOrDefault("database-url", databaseUrl); + var database = database(options, environment, options.command.equals("migrate-get")); + var migrator = factory.apply(database, line); + switch (options.command) { + case "migrate-get" -> export(options, migrator, out); + case "migrate-list" -> { + out.println("VERSION STATE NAME"); + for (var migration : migrator.list()) + out.printf( + "%03d %-7s %s%n", + migration.version(), migration.applied() ? "applied" : "pending", migration.name()); + } + default -> { + boolean down = options.command.equals("migrate-down"); + boolean targetSet = options.values.containsKey("target-version"); + int target = + targetSet + ? number(options.values.get("target-version"), "--target-version") + : down ? 0 : migrator.latest(); + if (target > migrator.latest()) throw new Usage("Unknown migration version: " + target); + int steps = number(options.values.getOrDefault("max-steps", "0"), "--max-steps"); + if (steps == 0) steps = down && !targetSet ? 1 : Integer.MAX_VALUE; + boolean dryRun = options.flags.contains("dry-run"); + var result = + migrator.migrate( + down ? Migrator.Direction.DOWN : Migrator.Direction.UP, + Migrator.Options.defaults().targetVersion(target).maxSteps(steps).dryRun(dryRun)); + for (int applied : result.applied()) { + out.printf( + "%s %s %03d [%s]%n", + dryRun ? "Would apply" : "Applied", line, applied, down ? "down" : "up"); + if (options.flags.contains("show-sql")) + out.println( + migrator + .sql(applied, down ? Migrator.Direction.DOWN : Migrator.Direction.UP) + .strip()); + } + if (result.applied().isEmpty()) out.println("No migrations to apply"); + } + } + return 0; + } catch (Usage error) { + err.println("Error: " + error.getMessage()); + err.println("Run " + name + " --help for usage"); + return 2; + } catch (Exception error) { + String message = + error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage(); + if (databaseUrl != null && !databaseUrl.isEmpty()) + message = message.replace(databaseUrl, ""); + err.println("Error: " + message); + return 1; + } finally { + out.flush(); + err.flush(); + } + } + + private record Options(String command, Set flags, Map values) {} + + private static final class Usage extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + Usage(String message) { + super(message); + } + } +} diff --git a/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java b/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java new file mode 100644 index 000000000..a4b0296bf --- /dev/null +++ b/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java @@ -0,0 +1,45 @@ +package com.riverqueue.cli; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ExecutableIT { + @TempDir Path directory; + + private String command(String... args) throws Exception { + var command = + new ArrayList<>( + List.of( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-jar", + "target/river-cli-0.48.0-alpha.1-all.jar")); + command.addAll(List.of(args)); + var output = directory.resolve("process.log").toFile(); + var builder = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(output); + builder.environment().put("DATABASE_URL", "jdbc:sqlite:" + directory.resolve("river.db")); + var process = builder.start(); + try { + assertTrue(process.waitFor(30, TimeUnit.SECONDS), "CLI timed out"); + String text = java.nio.file.Files.readString(output.toPath()); + assertEquals(0, process.exitValue(), text); + return text; + } finally { + process.destroyForcibly(); + } + } + + @Test + void bundledJarRunsWithoutExternalClasspath() throws Exception { + assertTrue(command("--version").contains("0.48.0-alpha.1")); + assertTrue(command("migrate-up").contains("008 [up]")); + assertTrue(command("migrate-list").contains("applied")); + assertTrue(command("migrate-down", "--target-version", "0").contains("001 [down]")); + assertFalse(command("migrate-list").contains("applied")); + } +} diff --git a/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java b/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java new file mode 100644 index 000000000..f4fa310b7 --- /dev/null +++ b/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java @@ -0,0 +1,137 @@ +package com.riverqueue.cli; + +import static org.junit.jupiter.api.Assertions.*; + +import com.riverqueue.Database; +import com.riverqueue.Migrator; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MigrationCliTest { + @TempDir Path directory; + + private Result run(Map environment, String... args) { + var out = new StringWriter(); + var err = new StringWriter(); + int exit = + MigrationCli.run( + args, + environment, + new PrintWriter(out), + new PrintWriter(err), + "river", + "test-version", + List.of("main"), + (database, line) -> new Migrator(database)); + return new Result(exit, out.toString(), err.toString()); + } + + @Test + void environmentAndExplicitDatabaseSelection() { + String first = "jdbc:sqlite:" + directory.resolve("first.db"); + String second = "jdbc:sqlite:" + directory.resolve("second.db"); + var environment = Map.of("DATABASE_URL", first); + assertEquals(0, run(environment, "migrate-up", "--target-version=4").exit()); + assertEquals(0, run(environment, "migrate-up", "--database-url", second).exit()); + assertEquals( + 4, + new Migrator(Database.connect(first)) + .list().stream().filter(Migrator.Status::applied).count()); + assertEquals( + 8, + new Migrator(Database.connect(second)) + .list().stream().filter(Migrator.Status::applied).count()); + assertTrue(run(environment, "migrate-list").out().contains("pending")); + } + + @Test + void helpAndVersionDoNotRequireDatabase() { + assertEquals(0, run(Map.of()).exit()); + assertTrue(run(Map.of(), "migrate-up", "--help").out().contains("--dry-run")); + assertEquals("river test-version\n", run(Map.of(), "--version").out()); + } + + @Test + void invalidUsageDoesNotCreateDatabase() { + Path file = directory.resolve("unused.db"); + var environment = Map.of("DATABASE_URL", "jdbc:sqlite:" + file); + for (String[] args : + List.of( + new String[] {"migrate-up", "--target-version", "999"}, + new String[] {"migrate-up", "--max-steps", "-1"}, + new String[] {"migrate-list", "--dry-run"}, + new String[] {"migrate-up", "--dry-run=false"}, + new String[] {"migrate-up", "--line", "pro"}, + new String[] {"migrate-up", "--schema", "bad-schema"}, + new String[] {"migrate-up", "--driver", "postgres"}, + new String[] {"migrate-up", "--database-url"}, + new String[] {"migrate-get", "--all", "--up", "--down"}, + new String[] {"migrate-get", "--version", "1,999", "--up"})) { + var result = run(environment, args); + assertEquals(2, result.exit(), result.err()); + assertTrue(result.out().isEmpty()); + } + assertFalse(java.nio.file.Files.exists(file)); + assertEquals(2, run(Map.of(), "migrate-up").exit()); + } + + @Test + void offlineSqlExportSelectsDialectDirectionAndSchema() { + var sqlite = + run( + Map.of("DATABASE_URL", "postgres://localhost:1/unreachable"), + "migrate-get", + "--driver", + "sqlite", + "--version", + "6", + "--up"); + assertEquals(0, sqlite.exit(), sqlite.err()); + assertTrue(sqlite.out().contains("river_job")); + var postgres = + run( + Map.of(), + "migrate-get", + "--all", + "--exclude-version", + "1", + "--down", + "--schema", + "jobs"); + assertEquals(0, postgres.exit(), postgres.err()); + assertTrue(postgres.out().startsWith("-- River migration 008 [down]")); + assertTrue(postgres.out().contains("\"jobs\".")); + assertFalse(postgres.out().contains("migration 001 [down]")); + } + + @Test + void operationFailureHasNonzeroExit() { + var result = run(Map.of("DATABASE_URL", "jdbc:sqlite:" + directory), "migrate-up"); + assertEquals(1, result.exit()); + assertTrue(result.err().contains("Error:")); + assertTrue(result.out().isEmpty()); + } + + @Test + void targetsLimitsAndDryRunsPreserveExpectedHistory() { + var environment = Map.of("DATABASE_URL", "jdbc:sqlite:" + directory.resolve("river.db")); + assertEquals(0, run(environment, "migrate-up", "--dry-run", "--show-sql").exit()); + assertFalse(run(environment, "migrate-list").out().contains("applied")); + assertEquals(0, run(environment, "migrate-up").exit()); + assertTrue(run(environment, "migrate-down").out().contains("008 [down]")); + assertEquals(0, run(environment, "migrate-down", "--max-steps", "2").exit()); + assertEquals( + 5, + new Migrator(Database.connect(environment.get("DATABASE_URL"))) + .list().stream().filter(Migrator.Status::applied).count()); + assertEquals(0, run(environment, "migrate-down", "--target-version", "0").exit()); + assertFalse(run(environment, "migrate-list").out().contains("applied")); + } + + private record Result(int exit, String out, String err) {} +} diff --git a/java/conformance/bin/adapter b/java/conformance/bin/adapter new file mode 100755 index 000000000..8ead5bcb8 --- /dev/null +++ b/java/conformance/bin/adapter @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +snapshot=$(cat "$root/.conformance/adapter-path") +exec java --enable-native-access=ALL-UNNAMED -jar "$snapshot" diff --git a/java/conformance/bin/build.py b/java/conformance/bin/build.py new file mode 100644 index 000000000..5b980a06f --- /dev/null +++ b/java/conformance/bin/build.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Serialize Maven builds and publish immutable adapter artifacts atomically.""" +import fcntl +import hashlib +from pathlib import Path +import shutil +import subprocess +import zipfile + +root = Path(__file__).resolve().parents[2] +artifacts = root / ".conformance" / "artifacts" +artifacts.mkdir(parents=True, exist_ok=True) +with (artifacts.parent / "build.lock").open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + subprocess.run(["mvn", "-q", "-f", str(root / "pom.xml"), "package", "-DskipTests"], check=True) + jar = root / "conformance/target/river-conformance-0.48.0-alpha.1.jar" + # A stale shaded jar can otherwise silently retain an older dependency's classes. + with zipfile.ZipFile(root / "river/target/river-0.48.0-alpha.1.jar") as library, zipfile.ZipFile(jar) as bundled: + for entry in library.namelist(): + if entry.startswith("com/riverqueue/") and not entry.endswith("/"): + if library.read(entry) != bundled.read(entry): + raise RuntimeError(f"Adapter contains a stale River resource: {entry}") + digest = hashlib.sha256(jar.read_bytes()).hexdigest() + snapshot = artifacts / f"{digest}.jar" + if not snapshot.exists(): + temporary = artifacts / "adapter.tmp" + shutil.copyfile(jar, temporary) + temporary.replace(snapshot) + pointer = artifacts.parent / "adapter-path.tmp" + pointer.write_text(str(snapshot) + "\n") + pointer.replace(artifacts.parent / "adapter-path") diff --git a/java/conformance/bin/run.py b/java/conformance/bin/run.py new file mode 100644 index 000000000..0fa9a33ca --- /dev/null +++ b/java/conformance/bin/run.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Run the upstream harness without modifying the application's Go checkout.""" + +import argparse +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile + + +ROOT = Path(__file__).resolve().parents[2] +SUITES = { + "postgres": "test/conformance", + "insert": "test/conformance/insert-only", + "sqlite": "test/conformance/sqlite", + "multi": "test/conformance/multi-engine", + "performance": "test/conformance/performance", + "soak": "test/conformance/soak", + "multi-soak": "test/conformance/multi-engine/soak", +} + + +def run(*command, **kwargs): + return subprocess.run(command, check=True, **kwargs) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("suite", choices=SUITES) + parser.add_argument("--reference", type=Path, help="Use an existing conformance checkout") + parser.add_argument("--refresh", action="store_true", help="Fetch and use the current reference branch") + args = parser.parse_args() + revision_file = ROOT / "conformance/reference-revision" + revision = revision_file.read_text().strip() + if args.refresh: + run("git", "fetch", "origin", "bg/plan-interoperable-rust-river-port", cwd=ROOT.parent) + revision = subprocess.check_output( + ["git", "rev-parse", "origin/bg/plan-interoperable-rust-river-port"], + cwd=ROOT.parent, text=True, + ).strip() + reference = args.reference or ROOT / ".conformance" / "reference" / revision + if not reference.exists(): + try: + archive = subprocess.check_output(["git", "archive", revision], cwd=ROOT.parent) + except subprocess.CalledProcessError: + run("git", "fetch", "origin", "bg/plan-interoperable-rust-river-port", cwd=ROOT.parent) + archive = subprocess.check_output(["git", "archive", revision], cwd=ROOT.parent) + reference.mkdir(parents=True) + with tarfile.open(fileobj=io.BytesIO(archive)) as bundle: + bundle.extractall(reference, filter="data") + manifest_path = reference / "conformance/manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["implementations"]["java"] = { + "package": "com.riverqueue:river", "registry": "maven", "version": "0.48.0-alpha.1" + } + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") + environment = os.environ | { + "RIVER_JAVA_ROOT": str(ROOT), + "RIVER_CONFORMANCE_CANDIDATE_FILE": str(ROOT / "conformance/candidate.json"), + "RIVER_CONFORMANCE_REQUIRED": "1", + } + if args.suite == "performance": + environment["RIVER_CONFORMANCE_PERFORMANCE"] = "1" + if "soak" in args.suite: + environment["RIVER_CONFORMANCE_SOAK"] = "1" + print(f"Reference: {revision}; suite: {args.suite}", flush=True) + run("make", SUITES[args.suite], cwd=reference, env=environment) + + +if __name__ == "__main__": + main() diff --git a/java/conformance/candidate.json b/java/conformance/candidate.json new file mode 100644 index 000000000..11db53e39 --- /dev/null +++ b/java/conformance/candidate.json @@ -0,0 +1,23 @@ +{ + "application_name": "river-conformance-java", + "build_command": [ + "python3", + "${RIVER_JAVA_ROOT}/conformance/bin/build.py" + ], + "command": [ + "${RIVER_JAVA_ROOT}/conformance/bin/adapter" + ], + "implementation": "java", + "profiles": [ + "insert-only-v1", + "portable-storage-v1", + "postgres-full-v1", + "sqlite-runtime-v1" + ], + "version": "0.48.0-alpha.1", + "start_options": [ + "elect_interval_ms", + "rescuer_interval_ms", + "scheduler_interval_ms" + ] +} diff --git a/java/conformance/feature-inventory.json b/java/conformance/feature-inventory.json new file mode 100644 index 000000000..bac637647 --- /dev/null +++ b/java/conformance/feature-inventory.json @@ -0,0 +1,3276 @@ +{ + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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" + ], + "source": "client.go:river.Client.JobRetry", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database", + "java_difference": "OSS periodic definitions belong to a runtime configuration; change them by starting a new worker runtime. Pro durable definitions can be registered on Pro." + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database", + "java_difference": "Workers.close and stop await shutdown directly; applications can run either through CompletableFuture if they need a completion handle." + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Client, Workers, Queues, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database", + "java_difference": "Wakeups are coalesced; Java does not expose a separate fetch cooldown knob." + }, + { + "applicability": "api_equivalent", + "area": "config", + "detail": "bool", + "id": "config.FetchOnlyKnownKinds", + "rationale": "Restricts a client's claims to the kinds of its registered workers, including aliases, so jobs of other kinds stay available without using attempts. The filter is local to the claiming client; implementations expose an equivalent option, and the jobs it leaves are ordinary available rows any client may work.", + "source": "client.go:river.Config.FetchOnlyKnownKinds", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database", + "java_difference": "Uses System.Logger and Workers.Builder.errorHandler instead of a logger object on the client." + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database", + "java_difference": "Configure insert defaults on JobType instead of a process-wide client default." + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database", + "java_difference": "Supports a duration override or the default daily UTC schedule; arbitrary reindex cron schedules are not exposed." + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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.", + "scenarios": [ + "candidate_process_kill_reference_rescue", + "process_kill_restart_and_rescue", + "reference_process_kill_candidate_rescue" + ], + "source": "client.go:river.Config.RescueStuckJobsAfter", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database", + "java_difference": "New JobType values require the Go-compatible kind format; no legacy validation bypass is exposed." + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database", + "java_difference": "Inserts are independent of worker registrations, which permits inserting jobs owned by other languages." + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Workers.Builder, JobType, Extension, Database" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "applicability": "driver_specific", + "area": "driver", + "detail": "func() int", + "id": "driver.ExecutorJobCompletionConcurrency.JobSetStateIfRunningManyConcurrency", + "rationale": "Optional Go driver capability controlling concurrent completion batches; local performance tuning only.", + "source": "riverdriver/river_driver_interface.go:riverdriver.ExecutorJobCompletionConcurrency.JobSetStateIfRunningManyConcurrency", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Database, Sql, Migrator (internal JDBC implementation)" + }, + { + "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", + "java_surface": "Workers.Event and Workers.subscribe" + }, + { + "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", + "java_surface": "Workers.Event and Workers.subscribe" + }, + { + "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", + "java_surface": "Workers.Event and Workers.subscribe" + }, + { + "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", + "java_surface": "Workers.Event and Workers.subscribe" + }, + { + "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", + "java_surface": "Workers.Event and Workers.subscribe" + }, + { + "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", + "java_surface": "Workers.Event and Workers.subscribe" + }, + { + "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", + "java_surface": "Workers.Event and Workers.subscribe" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "applicability": "internal", + "area": "extension", + "detail": "func(ctx context.Context, exec riverdriver.Executor, 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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "applicability": "internal", + "area": "extension", + "detail": "func() int", + "id": "extension.riverpilot.PilotJobCompletionConcurrency.JobSetStateIfRunningManyConcurrency", + "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.PilotJobCompletionConcurrency.JobSetStateIfRunningManyConcurrency", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "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", + "java_surface": "Extension and Plugin (internal Pro seam)" + }, + { + "applicability": "protocol_visible", + "area": "insert_opts", + "detail": "int", + "id": "insert_opts.MaxAttempts", + "rationale": "Persisted max_attempts decides between retry and discard.", + "scenarios": [ + "mixed_unknown_kind_error", + "single_implementation_worker_outcomes" + ], + "source": "insert_opts.go:river.InsertOpts.MaxAttempts", + "java_surface": "InsertOptions" + }, + { + "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", + "java_surface": "InsertOptions" + }, + { + "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", + "java_surface": "InsertOptions" + }, + { + "applicability": "protocol_visible", + "area": "insert_opts", + "detail": "int", + "id": "insert_opts.Priority", + "rationale": "Persisted priority; affects fetch order.", + "scenarios": [ + "differential_job_crud", + "typed_batch_insertion" + ], + "source": "insert_opts.go:river.InsertOpts.Priority", + "java_surface": "InsertOptions" + }, + { + "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", + "java_surface": "InsertOptions" + }, + { + "applicability": "protocol_visible", + "area": "insert_opts", + "detail": "time.Time", + "id": "insert_opts.ScheduledAt", + "rationale": "Persisted scheduled_at; future times insert as scheduled.", + "scenarios": [ + "clock_boundary_scheduling", + "differential_job_list_filters_and_cursors" + ], + "source": "insert_opts.go:river.InsertOpts.ScheduledAt", + "java_surface": "InsertOptions" + }, + { + "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", + "java_surface": "InsertOptions" + }, + { + "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", + "java_surface": "InsertOptions" + }, + { + "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", + "java_surface": "JobQuery and Client.deleteMany" + }, + { + "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", + "java_surface": "JobQuery and Client.deleteMany" + }, + { + "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", + "java_surface": "JobQuery and Client.deleteMany" + }, + { + "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", + "java_surface": "JobQuery and Client.deleteMany" + }, + { + "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", + "java_surface": "JobQuery and Client.deleteMany" + }, + { + "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", + "java_surface": "JobQuery and Client.deleteMany" + }, + { + "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", + "java_surface": "JobQuery and Client.deleteMany" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "JobQuery" + }, + { + "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", + "java_surface": "Job.State" + }, + { + "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", + "java_surface": "Job.State" + }, + { + "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", + "java_surface": "Job.State" + }, + { + "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", + "java_surface": "Job.State" + }, + { + "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", + "java_surface": "Job.State" + }, + { + "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", + "java_surface": "Job.State" + }, + { + "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", + "java_surface": "Job.State" + }, + { + "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", + "java_surface": "Job.State" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "WorkContext, Workers, Client" + }, + { + "applicability": "internal", + "area": "metadata_key", + "detail": "sql:json_literal", + "id": "metadata_key.unique_key_conflict", + "rationale": "Diagnostic marker the scheduler writes when it discards a scheduled or retryable job whose unique key conflicts with a live job. No reader acts on it and no shared scenario covers scheduler-time unique conflicts yet; promote to protocol_visible when one exists.", + "source": "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobSchedule, riverdriver/riversqlite/internal/dbsqlc/river_job.sql:JobScheduleSetDiscarded", + "java_surface": "WorkContext, Workers, Client" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Migrator and vendored migration resources" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Client, Queues, Workers" + }, + { + "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", + "java_surface": "Workers.Builder.periodic" + }, + { + "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", + "java_surface": "Workers.Builder.periodic" + }, + { + "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", + "java_surface": "Workers.Builder.queue and Workers.addQueue", + "java_difference": "Wakeups are coalesced; Java does not expose a separate per-queue cooldown knob." + }, + { + "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", + "java_surface": "Workers.Builder.queue and Workers.addQueue", + "java_difference": "Polling is configured per worker runtime; use separate runtimes for different polling policies." + }, + { + "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", + "java_surface": "Workers.Builder.queue and Workers.addQueue" + }, + { + "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", + "java_surface": "Queues.list" + }, + { + "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", + "java_surface": "Unique" + }, + { + "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", + "java_surface": "Unique" + }, + { + "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", + "java_surface": "Unique" + }, + { + "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", + "java_surface": "Unique" + }, + { + "applicability": "protocol_visible", + "area": "unique_opts", + "detail": "bool", + "id": "unique_opts.ExcludeKind", + "rationale": "Omits kind from the persisted unique_key hash input.", + "scenarios": [ + "unique_hash_goldens" + ], + "source": "insert_opts.go:river.UniqueOpts.ExcludeKind", + "java_surface": "Unique" + } + ], + "protocol_revision": 1, + "reference_revision": "eb16420fed22ce479f4843f0accd4c4bfba0885e", + "description": "Accounts for the upstream inventory in Java terms. Upstream applicability and scenario links are preserved; validation results are recorded separately, and Java API differences are explicit." +} diff --git a/java/conformance/pom.xml b/java/conformance/pom.xml new file mode 100644 index 000000000..c03b1ac56 --- /dev/null +++ b/java/conformance/pom.xml @@ -0,0 +1,14 @@ + + 4.0.0 + com.riverqueueriver-java0.48.0-alpha.1 + river-conformance + + truetrue + + com.zaxxerHikariCP7.1.0 + com.riverqueueriver${project.version} + org.postgresqlpostgresql + org.xerialsqlite-jdbc + + org.apache.maven.pluginsmaven-shade-plugin3.6.1packageshadefalsecom.riverqueue.conformance.Adapter*:*META-INF/*.SFMETA-INF/*.DSAMETA-INF/*.RSA + diff --git a/java/conformance/reference-revision b/java/conformance/reference-revision new file mode 100644 index 000000000..3a56987ce --- /dev/null +++ b/java/conformance/reference-revision @@ -0,0 +1 @@ +eb16420fed22ce479f4843f0accd4c4bfba0885e diff --git a/java/conformance/reference-sources.json b/java/conformance/reference-sources.json new file mode 100644 index 000000000..2e15ce67a --- /dev/null +++ b/java/conformance/reference-sources.json @@ -0,0 +1,3 @@ +{ + "conformance/adapter/contract.json": "e3f89335e605f8cd1a20757fcfdde0f80b754dc525079b10b191126c30a609d8" +} diff --git a/java/conformance/scenario-coverage.json b/java/conformance/scenario-coverage.json new file mode 100644 index 000000000..f21c9e1c3 --- /dev/null +++ b/java/conformance/scenario-coverage.json @@ -0,0 +1,1862 @@ +{ + "reference_revision": "eb16420fed22ce479f4843f0accd4c4bfba0885e", + "description": "Evidence map, not a claim that every scenario passed. Empty native_tests means coverage currently comes from the upstream harness.", + "scenarios": { + "adapter_handshake_and_capabilities": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyPostgresHandshakes" + } + ], + "native_tests": [] + }, + "barrier_wait_and_release": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyBarrierWaitAndRelease" + } + ], + "native_tests": [] + }, + "bulk_delete_safety": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyBulkDeleteSafety" + } + ], + "native_tests": [] + }, + "candidate_insert_reference_work": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyInsertThenWork" + } + ], + "native_tests": [] + }, + "candidate_migrator_reference_runtime": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyMigratorRuntime" + } + ], + "native_tests": [] + }, + "candidate_process_kill_reference_rescue": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyProcessKillCrossEngineRescue" + } + ], + "native_tests": [] + }, + "claim_time_cancellation": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/interop_scenarios_test.go", + "symbol": "verifyClaimTimeCancellation" + } + ], + "native_tests": [] + }, + "claimed_row_decode_isolation": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceConformance" + } + ], + "native_tests": [] + }, + "clock_boundary_scheduling": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyClockBoundaries" + } + ], + "native_tests": [] + }, + "completion_batching": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyCompletionBatching" + } + ], + "native_tests": [] + }, + "completion_row_lock_wait": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceConformance" + } + ], + "native_tests": [] + }, + "completion_transient_failure_retry": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceConformance" + } + ], + "native_tests": [] + }, + "cooperative_remote_cancellation": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyCooperativeRemoteCancellation" + } + ], + "native_tests": [] + }, + "cron_schedule_goldens": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifyCronScheduleGoldens" + } + ], + "native_tests": [ + "river/src/test/java/com/riverqueue/ScheduleTest.java#goGoldens" + ] + }, + "cross_language_cancel_retry_race": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyConcurrentCancelRetryRace" + } + ], + "native_tests": [] + }, + "cross_language_unique_conflict": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyConcurrentUniqueConflicts" + } + ], + "native_tests": [] + }, + "custom_schema_candidate_migrate_reference_work": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyCustomSchema" + } + ], + "native_tests": [] + }, + "custom_schema_reference_migrate_candidate_work": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyCustomSchema" + } + ], + "native_tests": [] + }, + "database_unavailable_reconnect": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceConformance" + } + ], + "native_tests": [] + }, + "default_retry_policy_schedule": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyDefaultRetrySchedule" + } + ], + "native_tests": [] + }, + "deterministic_retry_clock_rng": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyDeterministicControls" + } + ], + "native_tests": [] + }, + "differential_job_crud": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyDifferentialJobCRUD" + } + ], + "native_tests": [] + }, + "differential_job_list_filters_and_cursors": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyDifferentialListCursors" + } + ], + "native_tests": [] + }, + "differential_queue_crud": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyDifferentialQueueCRUD" + } + ], + "native_tests": [] + }, + "dynamic_queue_add_reconfigure_remove": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyDynamicQueues" + } + ], + "native_tests": [] + }, + "error_handler_cancel_override": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyErrorHandlerCancel" + } + ], + "native_tests": [] + }, + "extension_hook_middleware_order": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyExtensionOrder" + } + ], + "native_tests": [] + }, + "external_terminal_completion_race": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyExternalTerminalCompletionRace" + } + ], + "native_tests": [] + }, + "hard_shutdown_soft_stop_classification": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceConformance" + } + ], + "native_tests": [] + }, + "historical_migration_down_up": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyHistoricalMigrations" + } + ], + "native_tests": [] + }, + "ignored_cancellation_hard_abort": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyIgnoredCancellationHardAbort" + } + ], + "native_tests": [] + }, + "insert_only_insert_notification": { + "profiles": [ + "insert-only" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyInsertNotificationWakeup" + } + ], + "native_tests": [] + }, + "insert_only_insert_reference_work": { + "profiles": [ + "insert-only" + ], + "shared_harness": [ + { + "path": "conformance/harness/insert_only_test.go", + "symbol": "verifyInsertOnlyInsert" + } + ], + "native_tests": [] + }, + "insert_only_profile_handshake": { + "profiles": [ + "insert-only" + ], + "shared_harness": [ + { + "path": "conformance/harness/insert_only_test.go", + "symbol": "verifyInsertOnlyHandshake" + } + ], + "native_tests": [] + }, + "insert_only_transactional_insert": { + "profiles": [ + "insert-only" + ], + "shared_harness": [ + { + "path": "conformance/harness/insert_only_test.go", + "symbol": "verifyInsertOnlyTransactions" + } + ], + "native_tests": [] + }, + "insert_only_typed_batch": { + "profiles": [ + "insert-only" + ], + "shared_harness": [ + { + "path": "conformance/harness/insert_only_test.go", + "symbol": "verifyInsertOnlyBatch" + } + ], + "native_tests": [] + }, + "insert_only_unique_insert": { + "profiles": [ + "insert-only" + ], + "shared_harness": [ + { + "path": "conformance/harness/insert_only_test.go", + "symbol": "verifyInsertOnlyUnique" + } + ], + "native_tests": [] + }, + "job_cleaner_queue_filters": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyJobCleanerQueueFilters" + } + ], + "native_tests": [] + }, + "job_list_cursor_interchange": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyJobListCursorInterchange" + } + ], + "native_tests": [] + }, + "job_row_round_trip_all_fields": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyJobRowRoundTrip" + } + ], + "native_tests": [] + }, + "leader_election_disabled_both_directions": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyLeaderElectionDisabled" + } + ], + "native_tests": [] + }, + "leadership_renewal_under_slow_maintenance": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifyRenewalUnderSlowMaintenance" + } + ], + "native_tests": [] + }, + "leadership_same_client_id_term_replacement": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifySameClientIDTermReplacement" + } + ], + "native_tests": [] + }, + "listener_backend_disconnect_reconnect": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyListenerReconnect" + } + ], + "native_tests": [] + }, + "lost_notification_poll_recovery": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyLostNotificationPollRecovery" + } + ], + "native_tests": [] + }, + "maintenance_job_cleaner_retention": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifyJobCleanerRetention" + } + ], + "native_tests": [] + }, + "maintenance_queue_cleaner_keeps_active_queues": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifyQueueCleaner" + } + ], + "native_tests": [] + }, + "maintenance_reindexer_skips_artifacts": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifyReindexer" + } + ], + "native_tests": [] + }, + "maintenance_rescuer_full_batch_of_unexpired_jobs": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifyRescuerFullBatch" + } + ], + "native_tests": [] + }, + "maintenance_rescuer_stale_selection": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifyRescuerStaleSelection" + } + ], + "native_tests": [] + }, + "migration_mixed_case_schema": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "TestMaintenanceConformance" + } + ], + "native_tests": [] + }, + "mixed_connection_pool_bound": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/performance_test.go", + "symbol": "TestMixedSoak" + } + ], + "native_tests": [] + }, + "mixed_leader_death_failover_both_directions": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyLeaderDeathFailover" + } + ], + "native_tests": [] + }, + "mixed_leader_failover_both_directions": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyGracefulLeaderFailover" + } + ], + "native_tests": [] + }, + "mixed_request_resign_terms": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyLeadershipRequestLifecycle" + } + ], + "native_tests": [] + }, + "mixed_skip_locked_competition": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifySkipLockedCompetition" + } + ], + "native_tests": [] + }, + "mixed_soak": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/performance_test.go", + "symbol": "TestMixedSoak" + } + ], + "native_tests": [] + }, + "mixed_unknown_kind_error": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyUnknownKind" + } + ], + "native_tests": [] + }, + "multi_engine_competition": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "TestMultiEngineConformance" + } + ], + "native_tests": [] + }, + "multi_engine_directed_candidate_work_notification_cancellation": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "verifyDirectedCandidateWork" + } + ], + "native_tests": [] + }, + "multi_engine_fault_recovery": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "TestMultiEngineConformance" + } + ], + "native_tests": [] + }, + "multi_engine_job_list_cursor_interchange": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyJobListCursorInterchange" + } + ], + "native_tests": [] + }, + "multi_engine_leader_election_disabled": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyLeaderElectionDisabled" + } + ], + "native_tests": [] + }, + "multi_engine_leader_failover": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "TestMultiEngineConformance" + } + ], + "native_tests": [] + }, + "multi_engine_process_kill_rescue_failover": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "verifyCrossEngineProcessKillRescue" + } + ], + "native_tests": [] + }, + "multi_engine_release_performance": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "TestMultiEnginePerformanceGate" + } + ], + "native_tests": [] + }, + "multi_engine_resource_bound": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "assertMultiEngineConnectionBounds" + } + ], + "native_tests": [] + }, + "multi_engine_resumable_cursor": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resumable_test.go", + "symbol": "verifyResumableInteroperability" + } + ], + "native_tests": [] + }, + "multi_engine_soak": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "TestMultiEngineSoak" + } + ], + "native_tests": [] + }, + "multi_engine_sqlite_candidate_pairs": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/multi_engine_test.go", + "symbol": "verifySQLiteCandidatePair" + } + ], + "native_tests": [] + }, + "notification_only_wakeups": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyInsertNotificationWakeup" + } + ], + "native_tests": [] + }, + "notification_payloads": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/interop_scenarios_test.go", + "symbol": "verifyNotificationPayloads" + } + ], + "native_tests": [] + }, + "panic_attempt_trace": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyPanicAttemptTrace" + } + ], + "native_tests": [] + }, + "pause_resume_notification": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyPauseResumeNotification" + } + ], + "native_tests": [] + }, + "periodic_due_job_available": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "verifyPeriodicDueJobAvailable" + } + ], + "native_tests": [] + }, + "periodic_run_on_start": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyPeriodicRunOnStart" + } + ], + "native_tests": [] + }, + "periodic_unique_cross_engine": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/interop_scenarios_test.go", + "symbol": "verifyUniquePeriodicJob" + } + ], + "native_tests": [] + }, + "poll_only_remote_cancellation": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyPollOnlyRemoteCancellation" + } + ], + "native_tests": [] + }, + "pool_pressure_completion": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyPoolPressure" + } + ], + "native_tests": [] + }, + "process_kill_restart_and_rescue": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyProcessKillRestartAndRescue" + } + ], + "native_tests": [] + }, + "queue_names_and_unknown_queue_control": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/maintenance_test.go", + "symbol": "TestMaintenanceConformance" + } + ], + "native_tests": [] + }, + "reference_insert_candidate_work": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyInsertThenWork" + } + ], + "native_tests": [] + }, + "reference_migrator_candidate_runtime": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyMigratorRuntime" + } + ], + "native_tests": [] + }, + "reference_process_kill_candidate_rescue": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyProcessKillCrossEngineRescue" + } + ], + "native_tests": [] + }, + "refetched_attempt_cancellation": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyRefetchedAttemptCancellation" + } + ], + "native_tests": [] + }, + "release_enqueue_performance": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/performance_test.go", + "symbol": "TestPerformanceGate" + } + ], + "native_tests": [] + }, + "release_mixed_performance": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/performance_test.go", + "symbol": "TestPerformanceGate" + } + ], + "native_tests": [] + }, + "release_worker_performance": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/performance_test.go", + "symbol": "TestPerformanceGate" + } + ], + "native_tests": [] + }, + "remote_cancel_notification": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyRemoteCancelNotification" + } + ], + "native_tests": [] + }, + "remote_queue_subscription_events": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyRemoteQueueSubscriptionEvents" + } + ], + "native_tests": [] + }, + "reserved_metadata_cross_engine": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyReservedMetadata" + } + ], + "native_tests": [] + }, + "resumable_cross_engine_cursor": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resumable_test.go", + "symbol": "verifyResumableInteroperability" + } + ], + "native_tests": [] + }, + "resumable_retry": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyResumableRetry" + } + ], + "native_tests": [] + }, + "resumable_validation": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resumable_test.go", + "symbol": "verifyResumableValidation" + } + ], + "native_tests": [] + }, + "rolling_deployment_same_protocol": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyRollingDeployment" + } + ], + "native_tests": [] + }, + "shutdown_after_cancel_attempt": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceConformance" + } + ], + "native_tests": [] + }, + "simulated_yugabyte_polling": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/yugabyte_test.go", + "symbol": "verifySimulatedYugabyte" + } + ], + "native_tests": [] + }, + "single_implementation_worker_outcomes": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyWorkerOutcomes" + } + ], + "native_tests": [] + }, + "snooze_once_metadata_transition": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifySnoozeTransition" + } + ], + "native_tests": [] + }, + "sqlite_batch_atomicity": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyBatchInsertion" + } + ], + "native_tests": [] + }, + "sqlite_deterministic_retry_unique": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyDeterministicControls" + }, + { + "path": "conformance/harness/unique_test.go", + "symbol": "verifyUniqueKeyGoldens" + } + ], + "native_tests": [] + }, + "sqlite_insert_get_unique_cross_language": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteCrossLanguageInsertion" + } + ], + "native_tests": [] + }, + "sqlite_job_crud": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyDifferentialJobCRUD" + } + ], + "native_tests": [] + }, + "sqlite_job_rows": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/job_rows_test.go", + "symbol": "verifySQLiteJobRows" + } + ], + "native_tests": [] + }, + "sqlite_migration_cross_language": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteMigrations" + } + ], + "native_tests": [] + }, + "sqlite_profile_handshake": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifyProfileHandshakes" + } + ], + "native_tests": [] + }, + "sqlite_runtime_attempted_by_ordering": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteAttemptedByHistory" + } + ], + "native_tests": [] + }, + "sqlite_runtime_claim_time_cancellation": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/interop_scenarios_test.go", + "symbol": "verifyClaimTimeCancellation" + } + ], + "native_tests": [] + }, + "sqlite_runtime_competing_workers": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteCompetingWorkers" + } + ], + "native_tests": [] + }, + "sqlite_runtime_completion_under_writer_lock": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceSQLiteConformance" + } + ], + "native_tests": [] + }, + "sqlite_runtime_cross_language_work": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteCrossLanguageWork" + } + ], + "native_tests": [] + }, + "sqlite_runtime_extensions_resumable_subscriptions": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteAdvancedRuntime" + } + ], + "native_tests": [] + }, + "sqlite_runtime_external_terminal_completion_race": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyExternalTerminalCompletionRace" + } + ], + "native_tests": [] + }, + "sqlite_runtime_go_integer_ranges": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceSQLiteConformance" + } + ], + "native_tests": [] + }, + "sqlite_runtime_invalid_json_columns": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/resilience_test.go", + "symbol": "TestResilienceSQLiteConformance" + } + ], + "native_tests": [] + }, + "sqlite_runtime_job_cleaner_queue_filters": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyJobCleanerQueueFilters" + } + ], + "native_tests": [] + }, + "sqlite_runtime_job_list_cursor_interchange": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyJobListCursorInterchange" + } + ], + "native_tests": [] + }, + "sqlite_runtime_job_rows": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/job_rows_test.go", + "symbol": "verifySQLiteWorkedJobRows" + }, + { + "path": "conformance/harness/job_rows_test.go", + "symbol": "verifySQLiteRuntimeJobRows" + } + ], + "native_tests": [] + }, + "sqlite_runtime_leader_election_disabled": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyLeaderElectionDisabled" + } + ], + "native_tests": [] + }, + "sqlite_runtime_leadership_failover": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteLeadershipFailover" + } + ], + "native_tests": [] + }, + "sqlite_runtime_lifecycle_shutdown": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteLifecycle" + } + ], + "native_tests": [] + }, + "sqlite_runtime_notification_payloads": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/interop_scenarios_test.go", + "symbol": "verifyNotificationPayloads" + } + ], + "native_tests": [] + }, + "sqlite_runtime_notification_wakeups": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyPauseResumeNotification" + } + ], + "native_tests": [] + }, + "sqlite_runtime_periodic_scheduler": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLitePeriodicScheduler" + } + ], + "native_tests": [] + }, + "sqlite_runtime_periodic_unique": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/interop_scenarios_test.go", + "symbol": "verifyUniquePeriodicJob" + } + ], + "native_tests": [] + }, + "sqlite_runtime_poll_only_recovery": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLitePollOnly" + } + ], + "native_tests": [] + }, + "sqlite_runtime_profile_handshake": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifyProfileHandshakes" + } + ], + "native_tests": [] + }, + "sqlite_runtime_queue_crud_reconfigure_pause": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteQueues" + } + ], + "native_tests": [] + }, + "sqlite_runtime_remote_cancellation": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyRemoteCancelNotification" + }, + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteCancelNotifications" + } + ], + "native_tests": [] + }, + "sqlite_runtime_remote_queue_subscription_events": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyRemoteQueueSubscriptionEvents" + } + ], + "native_tests": [] + }, + "sqlite_runtime_resumable_cross_engine_cursor": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/resumable_test.go", + "symbol": "verifyResumableInteroperability" + } + ], + "native_tests": [] + }, + "sqlite_runtime_resumable_validation": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/resumable_test.go", + "symbol": "verifyResumableValidation" + } + ], + "native_tests": [] + }, + "sqlite_runtime_transactional_notification": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteTransactionalNotification" + } + ], + "native_tests": [] + }, + "sqlite_runtime_unknown_kind_error": { + "profiles": [ + "sqlite-runtime" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyUnknownKind" + } + ], + "native_tests": [] + }, + "sqlite_timestamp_rounding_ordering": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteTimestampEncoding" + } + ], + "native_tests": [] + }, + "sqlite_transactions": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/sqlite_test.go", + "symbol": "verifySQLiteTransactions" + } + ], + "native_tests": [] + }, + "sqlite_unique_column_bytes": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/interop_scenarios_test.go", + "symbol": "verifyUniqueColumnBytes" + } + ], + "native_tests": [] + }, + "sqlite_unsafe_int64_job_ids_rpc_list_cursors": { + "profiles": [ + "sqlite-storage" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyUnsafeInt64JobIDs" + } + ], + "native_tests": [] + }, + "stuck_job_detection": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/lifecycle_scenarios_test.go", + "symbol": "verifyStuckJobDetection" + } + ], + "native_tests": [] + }, + "timeout_cancellation": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyTimeoutCancellation" + } + ], + "native_tests": [] + }, + "transaction_abort_rollback_visibility": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyTransactionAbortRollback" + } + ], + "native_tests": [] + }, + "transaction_commit_visibility": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyTransactionCommitVisibility" + } + ], + "native_tests": [ + "river/src/test/java/com/riverqueue/ClientTest.java#applicationTransactionOwnsCommitAndRollback" + ] + }, + "transaction_rollback_visibility": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyTransactionRollbackVisibility" + } + ], + "native_tests": [ + "river/src/test/java/com/riverqueue/ClientTest.java#applicationTransactionOwnsCommitAndRollback" + ] + }, + "transactional_batch_insertion": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyTransactionalBatchInsertion" + } + ], + "native_tests": [] + }, + "transactional_completion": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/runtime_scenarios_test.go", + "symbol": "verifyTransactionalCompletion" + } + ], + "native_tests": [] + }, + "transactional_cross_language_cancel": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/mixed_test.go", + "symbol": "verifyTransactionalCrossLanguageCancel" + } + ], + "native_tests": [] + }, + "transactional_crud_commit_rollback": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyTransactionalJobCRUD" + } + ], + "native_tests": [] + }, + "transactional_insert_notification_commit_only": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/coordination_scenarios_test.go", + "symbol": "verifyTransactionalNotificationWakeups" + } + ], + "native_tests": [] + }, + "transactional_queue_operations": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyTransactionalQueueOperations" + } + ], + "native_tests": [] + }, + "typed_batch_insertion": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyBatchInsertion" + } + ], + "native_tests": [] + }, + "unique_column_bytes": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/interop_scenarios_test.go", + "symbol": "verifyUniqueColumnBytes" + } + ], + "native_tests": [] + }, + "unique_hash_goldens": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/unique_test.go", + "symbol": "verifyUniqueKeyGoldens" + } + ], + "native_tests": [ + "river/src/test/java/com/riverqueue/UniqueTest.java#goGoldens" + ] + }, + "unsafe_int64_job_ids_rpc_list_cursors": { + "profiles": [ + "core" + ], + "shared_harness": [ + { + "path": "conformance/harness/storage_scenarios_test.go", + "symbol": "verifyUnsafeInt64JobIDs" + } + ], + "native_tests": [] + } + } +} diff --git a/java/conformance/src/main/java/com/riverqueue/conformance/Adapter.java b/java/conformance/src/main/java/com/riverqueue/conformance/Adapter.java new file mode 100644 index 000000000..e91a1c4e9 --- /dev/null +++ b/java/conformance/src/main/java/com/riverqueue/conformance/Adapter.java @@ -0,0 +1,666 @@ +package com.riverqueue.conformance; + +import com.riverqueue.Client; +import com.riverqueue.Database; +import com.riverqueue.InsertOptions; +import com.riverqueue.Job; +import com.riverqueue.JobQuery; +import com.riverqueue.JobType; +import com.riverqueue.Json; +import com.riverqueue.Migrator; +import com.riverqueue.RetryPolicy; +import com.riverqueue.RiverException; +import com.riverqueue.Schedule; +import com.riverqueue.Unique; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +/** Private process adapter for the shared River conformance contract. */ +public final class Adapter implements AutoCloseable { + private static final JobType ECHO = JobType.of("conformance_echo", Echo.class); + private static final List INSERT_METHODS = + List.of( + "handshake", + "insert", + "insert_many", + "tx_begin", + "tx_commit", + "tx_insert", + "tx_insert_many", + "tx_rollback", + "unique_key"); + private final String applicationName = + System.getenv().getOrDefault("RIVER_CONFORMANCE_APPLICATION_NAME", "river-conformance-java"); + private final JsonNode contract; + private final Database database; + private final com.zaxxer.hikari.HikariDataSource pool; + private final List methods; + private final JsonNode capabilities; + private final String profile; + private RetryPolicy.Default retryPolicy = RetryPolicy.defaults(); + private final RuntimeAdapter runtime = new RuntimeAdapter(); + private Client river; + private final Map transactions = new HashMap<>(); + + private Adapter() throws Exception { + String url = System.getenv("RIVER_CONFORMANCE_DATABASE_URL"); + boolean sqlite = + System.getenv() + .getOrDefault("RIVER_CONFORMANCE_DATABASE_KIND", "postgres") + .equals("sqlite"); + var source = + Database.connect( + sqlite && !url.startsWith("jdbc:") && !url.startsWith("sqlite:") + ? "sqlite:" + url + : url, + applicationName); + pool = Connections.pool(source); + database = new Database(pool, source.dialect()); + river = new Client(database).withExtension(runtime.instrumentation()); + try (var input = Adapter.class.getResourceAsStream("/contract.json")) { + contract = Json.MAPPER.readTree(input); + } + if (sqlite) { + boolean full = + System.getenv().getOrDefault("RIVER_CONFORMANCE_PROFILE", "").equals("sqlite-runtime-v1"); + try (var input = + Adapter.class.getResourceAsStream( + full ? "/sqlite-runtime-profile.json" : "/sqlite-profile.json")) { + var declaration = Json.MAPPER.readTree(input); + methods = strings(declaration.path("methods")); + capabilities = declaration.path("capabilities"); + profile = full ? "sqlite-runtime-v1" : "portable-storage-v1"; + } + } else if (System.getenv() + .getOrDefault("RIVER_CONFORMANCE_PROFILE", "") + .equals("insert-only-v1")) { + methods = INSERT_METHODS; + capabilities = Json.tree(List.of("insert", "lifecycle", "transactions", "unique_jobs")); + profile = "insert-only-v1"; + } else { + var all = new ArrayList(); + for (var method : contract.path("methods")) all.add(method.path("name").asString()); + all.sort(String::compareTo); + methods = List.copyOf(all); + try (var input = Adapter.class.getResourceAsStream("/manifest.json")) { + var supported = new ArrayList(); + for (var entry : Json.MAPPER.readTree(input).path("capabilities").properties()) + if (entry.getValue().asString().equals("complete")) supported.add(entry.getKey()); + supported.sort(String::compareTo); + capabilities = Json.tree(supported); + } + profile = "postgres-full-v1"; + } + } + + public static void main(String[] args) throws Exception { + try (var adapter = new Adapter(); + var input = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { + for (String line; (line = input.readLine()) != null; ) { + JsonNode id = Json.MAPPER.nullNode(); + ObjectNode response = Json.object().put("jsonrpc", "2.0"); + try { + JsonNode request; + try { + request = Json.parse(line); + } catch (Exception error) { + throw new Failure(-32700, "Invalid JSON"); + } + if (!request.isObject() + || !request.path("jsonrpc").asString("").equals("2.0") + || !request.path("method").isString() + || !request.has("id")) throw new Failure(-32600, "Invalid JSON-RPC request"); + id = request.path("id"); + String method = request.path("method").asString(); + if (!adapter.methods.contains(method)) + throw new Failure(-32601, "Method not in profile: " + method); + var params = request.has("params") ? request.path("params") : Json.object(); + for (var entry : adapter.contract.path("methods")) + if (entry.path("name").asString().equals(method)) + adapter.validate(entry.path("params"), params); + String rawParams = Json.members(line).getOrDefault("params", "{}"); + response.set("result", adapter.handle(method, params, rawParams)); + } catch (Exception error) { + int code = + switch (error) { + case Failure failure -> failure.code; + case RiverException riverError -> + switch (riverError.code()) { + case DATABASE -> -32003; + case NOT_FOUND -> -32001; + case REJECTED -> -32002; + case UNSUPPORTED -> -32004; + }; + case SQLException ignored -> -32003; + case IllegalArgumentException ignored -> -32002; + case java.time.DateTimeException ignored -> -32002; + default -> -32000; + }; + if (code == -32000) error.printStackTrace(System.err); + response.set( + "error", + Json.object() + .put("code", code) + .put( + "message", + error.getMessage() == null ? error.toString() : error.getMessage())); + } + response.set("id", id); + System.out.println(Json.encode(response)); + System.out.flush(); + } + } + } + + @Override + public void close() throws SQLException { + runtime.stop(true); + for (var connection : transactions.values()) { + try { + connection.rollback(); + } finally { + connection.close(); + } + } + pool.close(); + } + + private JsonNode handle(String method, JsonNode params, String rawParams) throws Exception { + return switch (method) { + case "benchmark_enqueue" -> { + int count = params.path("jobs").asInt(); + if (count < 1) throw new IllegalArgumentException("jobs must be positive"); + long[] latencies = new long[count]; + long started = System.nanoTime(); + for (int i = 0; i < count; i++) { + long before = System.nanoTime(); + river.insert(ECHO, new Echo("", 0, "benchmark-enqueue-" + i)); + latencies[i] = System.nanoTime() - before; + } + long elapsed = System.nanoTime() - started; + java.util.Arrays.sort(latencies); + yield Json.tree( + Map.of( + "duration_ns", + elapsed, + "p95_ns", + latencies[Math.max(0, (count * 95 + 99) / 100 - 1)])); + } + case "handshake" -> + Json.tree( + Map.ofEntries( + Map.entry("adapter_version", 20), + Map.entry("application_name", applicationName), + Map.entry( + "backend", + database.dialect() == Database.Dialect.POSTGRES ? "postgres" : "sqlite"), + Map.entry("capabilities", capabilities), + Map.entry("implementation", "java"), + Map.entry("implementation_version", "0.48.0-alpha.1"), + Map.entry("methods", methods), + Map.entry("migration_lines", Map.of("main", 8)), + Map.entry("profile", profile), + Map.entry("protocol_revision", 1))); + case "clock_set" -> { + river = + new Client( + database, + Clock.fixed(Instant.parse(params.path("now").asString()), ZoneOffset.UTC)); + yield Json.object(); + } + case "rng_seed" -> { + retryPolicy = + RetryPolicy.defaults( + new java.util.Random( + new java.math.BigInteger(params.path("seed").toString()).longValue())); + yield Json.object(); + } + case "retry_delay" -> + Json.object() + .put("delay_ns", retryPolicy.delay(params.path("error_count").asInt()).toNanos()); + case "cron_next" -> { + var schedule = Schedule.cron(params.path("expression").asString()); + var next = OffsetDateTime.parse(params.path("from").asString()); + var times = new ArrayList(); + for (int i = 0; i < params.path("count").asInt(); i++) { + var found = schedule.next(next); + if (found.isEmpty()) break; + next = found.get(); + times.add(java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(next)); + } + yield Json.tree(Map.of("next", times)); + } + case "migrate" -> { + boolean down = params.path("direction").asString("up").equals("down"); + Integer target = + params.has("target_version") ? params.path("target_version").asInt() : null; + if (target != null) { + if (target == -1) target = 0; + else if (down && target == 0) target = null; + } + Integer steps = params.has("max_steps") ? params.path("max_steps").asInt() : null; + // The adapter protocol's zero downward target means exactly one version, independently + // of its step limit. The public Java API uses an omitted target and named options. + if (down && target == null) steps = 1; + yield Json.tree( + new Migrator(database.withSchema(params.path("schema").asString(""))) + .migrate( + down ? Migrator.Direction.DOWN : Migrator.Direction.UP, + new Migrator.Options(target, steps, params.path("dry_run").asBoolean(false)))); + } + case "reset" -> { + runtime.stop(true); + river.driver().reset(); + river = new Client(database).withExtension(runtime.instrumentation()); + yield Json.object(); + } + case "get" -> normalized(scoped(params).get(params.path("id").asLong())); + case "tx_get" -> normalized(river.get(transaction(params), params.path("id").asLong())); + case "list" -> page(river.list(query(params))); + case "tx_list" -> page(river.list(transaction(params), query(params))); + case "cancel" -> normalized(river.cancel(params.path("id").asLong())); + case "tx_cancel" -> normalized(river.cancel(transaction(params), params.path("id").asLong())); + case "delete" -> normalized(river.delete(params.path("id").asLong())); + case "tx_delete" -> normalized(river.delete(transaction(params), params.path("id").asLong())); + case "retry" -> normalized(river.retry(params.path("id").asLong())); + case "tx_retry" -> normalized(river.retry(transaction(params), params.path("id").asLong())); + case "update" -> + normalized( + river.transaction( + c -> + params.has("output") + ? river.output(c, params.path("id").asLong(), params.path("output")) + : river.get(c, params.path("id").asLong()))); + case "tx_update" -> + normalized( + params.has("output") + ? river.output( + transaction(params), params.path("id").asLong(), params.path("output")) + : river.get(transaction(params), params.path("id").asLong())); + case "delete_many" -> + Json.object() + .set( + "jobs", + Json.tree( + river + .transaction( + c -> + river.deleteMany( + c, query(params), params.path("all").asBoolean(false))) + .stream() + .map(Adapter::normalized) + .toList())); + case "delete_finalized" -> + Json.object() + .put( + "deleted", + (int) + river.transaction( + c -> + river + .driver() + .deleteFinalized( + c, + Instant.parse(params.path("before").asString()), + params.path("limit").asInt(), + strings(params.path("queues_excluded")), + params.path("queues_included").isArray() + ? strings(params.path("queues_included")) + : null))); + case "tx_delete_many" -> + Json.object() + .set( + "jobs", + Json.tree( + river + .deleteMany( + transaction(params), + query(params), + params.path("all").asBoolean(false)) + .stream() + .map(Adapter::normalized) + .toList())); + case "raw_insert_exact_json", "raw_job_exact_json", "raw_job_row", "raw_job_timestamps" -> + RawStorage.handle(database, method, params); + case "barrier_create", + "barrier_release", + "queue_add", + "queue_remove", + "runtime_stats", + "start", + "stop", + "wait", + "work" -> + runtime.handle(scoped(params), method, params); + case "queue_get" -> Json.tree(river.queues().get(params.path("name").asString())); + case "queue_list" -> + Json.tree(Map.of("queues", river.queues().list(params.path("limit").asInt(100)))); + case "queue_pause" -> { + river.queues().pause(params.path("name").asString()); + yield Json.object(); + } + case "queue_resume" -> { + river.queues().resume(params.path("name").asString()); + yield Json.object(); + } + case "queue_update" -> + Json.tree( + river + .queues() + .update( + params.path("name").asString(), + params.has("metadata") ? params.path("metadata") : null)); + case "tx_queue_get" -> + Json.tree(river.queues().get(transaction(params), params.path("name").asString())); + case "tx_queue_list" -> + Json.tree( + Map.of( + "queues", + river.queues().list(transaction(params), params.path("limit").asInt(100)))); + case "tx_queue_pause" -> { + river.queues().pause(transaction(params), params.path("name").asString()); + yield Json.object(); + } + case "tx_queue_resume" -> { + river.queues().resume(transaction(params), params.path("name").asString()); + yield Json.object(); + } + case "tx_queue_update" -> + Json.tree( + river + .queues() + .update( + transaction(params), + params.path("name").asString(), + params.has("metadata") ? params.path("metadata") : null)); + case "tx_fail" -> { + try (var statement = transaction(params).createStatement()) { + statement.execute("SELECT 1/0"); + } + yield Json.object(); + } + case "request_resign" -> { + if (params.has("handle")) river.requestResign(transaction(params)); + else river.requestResign(); + yield Json.object(); + } + case "leader", + "listener_count", + "connection_count", + "fault_disconnect_application", + "fault_disconnect_listeners", + "fault_expire_leader", + "raw_insert_no_notify", + "raw_finalize", + "raw_notifications", + "raw_replace_json_text", + "raw_insert_full_row" -> + RawStorage.handle(database, method, params); + case "insert" -> normalized(insert(null, params).job()); + case "insert_many" -> river.transaction(c -> insertMany(c, params.path("jobs"))); + case "tx_begin" -> { + String handle = params.path("handle").asString(); + if (transactions.containsKey(handle)) + throw new Failure(-32002, "Transaction already exists"); + var connection = database.connection(); + connection.setAutoCommit(false); + transactions.put(handle, connection); + yield Json.object(); + } + case "tx_insert" -> normalized(insert(transaction(params), params.path("job")).job()); + case "tx_insert_many" -> insertMany(transaction(params), params.path("jobs")); + case "tx_commit", "tx_rollback" -> { + var connection = transaction(params); + transactions.remove(params.path("handle").asString()); + try (connection) { + if (method.equals("tx_commit")) connection.commit(); + else connection.rollback(); + } + yield Json.object(); + } + case "unique_key" -> { + var options = unique(params.path("options"), true); + var paths = new ArrayList>(); + for (var path : params.path("selected_unique_components")) { + var components = new ArrayList(); + for (var component : path) components.add(component.asString()); + paths.add(components); + } + String key = + options.key( + params.path("kind").asString(), + Json.members(rawParams).get("args"), + paths, + Instant.parse(params.path("now").asString()), + params.path("queue").asString(), + params.path("scheduled_at").isString() + ? Instant.parse(params.path("scheduled_at").asString()) + : null); + if (key == null) throw new Failure(-32002, "Uniqueness is disabled"); + yield Json.object().put("sha256", key).put("state_mask", options.stateMask()); + } + default -> throw new Failure(-32601, "Unknown method"); + }; + } + + private Job.InsertResult insert(Connection connection, JsonNode params) { + var args = + new Echo( + params.path("behavior").asString(""), + params.path("duration_ms").asLong(0), + params.path("message").asString("")); + var options = options(params.path("opts")); + return connection == null + ? scoped(params).insert(ECHO, args, options) + : river.insert(connection, ECHO, args, options); + } + + private JsonNode insertMany(Connection connection, JsonNode jobs) throws SQLException { + if (jobs.isEmpty()) throw new IllegalArgumentException("Cannot insert an empty batch"); + var savepoint = connection.setSavepoint(); + try { + var results = Json.MAPPER.createArrayNode(); + var keys = new HashSet(); + for (var params : jobs) { + var result = insert(connection, params); + if (result.job().uniqueKey() != null && !keys.add(result.job().uniqueKey())) + throw new IllegalArgumentException("Batch repeats a unique key"); + var value = + Json.object().put("unique_skipped_as_duplicate", result.uniqueSkippedAsDuplicate()); + value.set("job", normalized(result.job())); + results.add(value); + } + connection.releaseSavepoint(savepoint); + return Json.object().set("results", results); + } catch (Exception error) { + connection.rollback(savepoint); + connection.releaseSavepoint(savepoint); + throw error; + } + } + + static JsonNode normalized(Job job) { + var value = (ObjectNode) Json.tree(job); + if (value.path("metadata") instanceof ObjectNode metadata) + metadata.remove("river:unique_nonce"); + if (!fitsDouble(value.path("metadata"))) value.putNull("metadata"); + value.put("state", job.state().value()); + if (job.uniqueStates() != null) + value.set( + "unique_states", Json.tree(job.uniqueStates().stream().map(Job.State::value).toList())); + return value; + } + + private Client scoped(JsonNode params) { + return params.path("schema").asString("").isEmpty() + ? river + : new Client(database.withSchema(params.path("schema").asString())) + .withExtension(runtime.instrumentation()); + } + + private static boolean fitsDouble(JsonNode value) { + if (value.isNumber()) { + try { + return Double.isFinite(value.asDouble()); + } catch (RuntimeException e) { + return false; + } + } + for (var child : value) if (!fitsDouble(child)) return false; + return true; + } + + private static JsonNode page(JobQuery.Page page) { + return Json.object() + .put("cursor", page.cursor()) + .set("jobs", Json.tree(page.jobs().stream().map(Adapter::normalized).toList())); + } + + private static JobQuery query(JsonNode params) { + var ids = new ArrayList(); + for (var id : params.path("ids")) ids.add(id.asLong()); + var priorities = new ArrayList(); + for (var priority : params.path("priorities")) priorities.add(priority.asInt()); + return new JobQuery( + params.path("after").asString(null), + params.path("direction").asString("asc").equals("desc"), + ids, + strings(params.path("kinds")), + params.path("limit").asInt(100), + params.has("metadata") ? params.path("metadata") : Json.object(), + JobQuery.Order.valueOf( + params.path("order_by").asString("time").toUpperCase(java.util.Locale.ROOT)), + priorities, + strings(params.path("queues")), + strings(params.path("states")).stream().map(Job.State::of).toList(), + strings(params.path("tags_all")), + strings(params.path("tags_any"))); + } + + private static List strings(JsonNode values) { + var result = new ArrayList(); + for (var value : values) result.add(value.asString()); + return List.copyOf(result); + } + + private static InsertOptions options(JsonNode value) { + var builder = InsertOptions.builder(); + if (value.has("max_attempts")) builder.maxAttempts(value.path("max_attempts").asInt()); + if (value.has("metadata")) builder.metadata(value.path("metadata")); + if (value.has("pending")) builder.pending(value.path("pending").asBoolean()); + if (value.has("priority")) builder.priority(value.path("priority").asInt()); + if (value.has("queue")) builder.queue(value.path("queue").asString()); + if (value.has("scheduled_at")) + builder.scheduledAt(Instant.parse(value.path("scheduled_at").asString())); + if (value.has("tags")) { + var tags = new ArrayList(); + for (var tag : value.path("tags")) tags.add(tag.asString()); + builder.tags(tags.toArray(String[]::new)); + } + if (value.has("unique")) builder.unique(unique(value.path("unique"), false)); + return builder.build(); + } + + private Connection transaction(JsonNode params) { + var connection = transactions.get(params.path("handle").asString()); + if (connection == null) throw new Failure(-32001, "Transaction not found"); + return connection; + } + + private static Unique unique(JsonNode value, boolean nanos) { + var states = value.has("by_state") ? new HashSet() : null; + if (states != null) + for (var state : value.path("by_state")) states.add(Job.State.of(state.asString())); + long period = value.path(nanos ? "by_period_nanos" : "by_period_ms").asLong(0); + return new Unique( + value.path("by_args").asBoolean(false), + period == 0 ? null : nanos ? Duration.ofNanos(period) : Duration.ofMillis(period), + value.path("by_queue").asBoolean(false), + states, + value.path("exclude_kind").asBoolean(false)); + } + + private void validate(JsonNode schema, JsonNode value) { + if (schema.has("$ref")) { + validate(contract.at(schema.path("$ref").asString().substring(1)), value); + return; + } + var type = schema.path("type"); + if (!type.isMissingNode()) { + var types = new ArrayList(); + if (type.isString()) types.add(type.asString()); + else for (var t : type) types.add(t.asString()); + boolean valid = + types.stream() + .anyMatch( + t -> + switch (t) { + case "object" -> value.isObject(); + case "array" -> value.isArray(); + case "string" -> value.isString(); + case "integer" -> value.isIntegralNumber(); + case "number" -> value.isNumber(); + case "boolean" -> value.isBoolean(); + case "null" -> value.isNull(); + default -> false; + }); + if (!valid) throw new Failure(-32602, "Invalid parameter type"); + } + if (schema.has("enum")) { + boolean found = false; + for (var item : schema.path("enum")) if (item.equals(value)) found = true; + if (!found) throw new Failure(-32602, "Invalid enum parameter"); + } + if (value.isObject()) { + for (var field : schema.path("required")) + if (!value.has(field.asString())) + throw new Failure(-32602, "Missing parameter: " + field.asString()); + for (var entry : value.properties()) { + var child = schema.path("properties").path(entry.getKey()); + if (child.isMissingNode()) { + if (schema.has("additionalProperties") + && schema.path("additionalProperties").isBoolean() + && !schema.path("additionalProperties").asBoolean()) + throw new Failure(-32602, "Unknown parameter: " + entry.getKey()); + } else validate(child, entry.getValue()); + } + } + if (value.isArray() && schema.has("items")) + for (var item : value) validate(schema.path("items"), item); + if (value.isNumber() + && schema.has("minimum") + && value.asDouble() < schema.path("minimum").asDouble()) + throw new Failure(-32602, "Parameter below minimum"); + if (value.isString() + && schema.has("minLength") + && value.asString().length() < schema.path("minLength").asInt()) + throw new Failure(-32602, "Parameter is too short"); + } + + public record Echo(String behavior, long durationMs, String message) {} + + static final class Failure extends RuntimeException { + private static final long serialVersionUID = 1L; + final int code; + + Failure(int code, String message) { + super(message); + this.code = code; + } + } +} diff --git a/java/conformance/src/main/java/com/riverqueue/conformance/Connections.java b/java/conformance/src/main/java/com/riverqueue/conformance/Connections.java new file mode 100644 index 000000000..31eb4a063 --- /dev/null +++ b/java/conformance/src/main/java/com/riverqueue/conformance/Connections.java @@ -0,0 +1,72 @@ +package com.riverqueue.conformance; + +import com.riverqueue.Database; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.logging.Logger; +import javax.sql.DataSource; + +/** Exercises the same application-owned pooling integration used in deployments. */ +final class Connections implements DataSource { + private final Database source; + + private Connections(Database source) { + this.source = source; + } + + static HikariDataSource pool(Database source) { + var config = new HikariConfig(); + config.setDataSource(new Connections(source)); + config.setMaximumPoolSize(8); + config.setMinimumIdle(0); + config.setConnectionTimeout(10_000); + config.setInitializationFailTimeout(-1); + return new HikariDataSource(config); + } + + @Override + public Connection getConnection() throws SQLException { + return source.connection(); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + throw new SQLFeatureNotSupportedException("Use the configured database credentials"); + } + + @Override + public PrintWriter getLogWriter() { + return null; + } + + @Override + public int getLoginTimeout() { + return 0; + } + + @Override + public Logger getParentLogger() { + return Logger.getLogger("com.riverqueue"); + } + + @Override + public boolean isWrapperFor(Class type) { + return type.isInstance(this); + } + + @Override + public void setLogWriter(PrintWriter writer) {} + + @Override + public void setLoginTimeout(int seconds) {} + + @Override + public T unwrap(Class type) throws SQLException { + if (type.isInstance(this)) return type.cast(this); + throw new SQLException("Not a wrapper for " + type.getName()); + } +} diff --git a/java/conformance/src/main/java/com/riverqueue/conformance/RawStorage.java b/java/conformance/src/main/java/com/riverqueue/conformance/RawStorage.java new file mode 100644 index 000000000..e58fdce26 --- /dev/null +++ b/java/conformance/src/main/java/com/riverqueue/conformance/RawStorage.java @@ -0,0 +1,287 @@ +package com.riverqueue.conformance; + +import com.riverqueue.Client; +import com.riverqueue.Database; +import com.riverqueue.Json; +import java.sql.SQLException; +import java.util.HexFormat; +import java.util.List; +import tools.jackson.databind.JsonNode; + +/** Deliberate out-of-band SQL for the harness's storage and fault probes. */ +final class RawStorage { + private RawStorage() {} + + static JsonNode handle(Database database, String method, JsonNode params) throws SQLException { + boolean sqlite = database.dialect() == Database.Dialect.SQLITE; + try (var connection = database.connection()) { + if (method.equals("raw_insert_full_row")) { + String sql = + """ + 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[?::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 + """; + long id; + try (var statement = connection.prepareStatement(sql)) { + statement.setString( + 1, + Json.encode( + new com.riverqueue.Job.AttemptError( + java.time.Instant.parse("2026-01-02T03:04:06.123456Z"), + 3, + "worker failed: escaped \"detail\"", + "frame one\nframe two"))); + try (var rows = statement.executeQuery()) { + rows.next(); + id = rows.getLong(1); + } + } + return Adapter.normalized(new Client(database).get(id)); + } + if (method.equals("leader")) { + try (var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT leader_id,elected_at FROM river_leader")) { + var value = Json.object().putNull("leader_id").putNull("elected_at"); + if (rows.next()) { + value.put("leader_id", rows.getString(1)); + value.set("elected_at", Json.tree(Database.instant(rows.getString(2)))); + } + return value; + } + } + if (method.equals("request_resign")) { + try (var statement = + connection.prepareStatement( + sqlite + ? "INSERT INTO river_notification(topic,payload) VALUES ('river_leadership',?)" + : "SELECT pg_notify(current_schema()||'.river_leadership',?)")) { + statement.setString(1, "{\"action\":\"request_resign\",\"leader_id\":\"\"}"); + statement.execute(); + return Json.object(); + } + } + if (method.equals("fault_expire_leader")) { + try (var statement = connection.createStatement()) { + statement.executeUpdate("UPDATE river_leader SET expires_at='2000-01-01 00:00:00'"); + } + return Json.object(); + } + if (method.equals("connection_count") || method.equals("listener_count")) { + try (var statement = + connection.prepareStatement( + "SELECT count(*) FROM pg_stat_activity WHERE application_name=?" + + (method.equals("listener_count") ? " AND query LIKE 'LISTEN%'" : ""))) { + statement.setString( + 1, + System.getenv() + .getOrDefault("RIVER_CONFORMANCE_APPLICATION_NAME", "river-conformance-java")); + try (var rows = statement.executeQuery()) { + rows.next(); + return Json.object().put("count", rows.getLong(1)); + } + } + } + if (method.equals("fault_disconnect_application") + || method.equals("fault_disconnect_listeners")) { + String name = + method.equals("fault_disconnect_application") + ? params.path("application_name").asString() + : System.getenv() + .getOrDefault("RIVER_CONFORMANCE_APPLICATION_NAME", "river-conformance-java"); + if (!name.startsWith("river-conformance-")) + throw new IllegalArgumentException("Only conformance connections may be terminated"); + try (var statement = + connection.prepareStatement( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE application_name=? AND pid<>pg_backend_pid()" + + (method.equals("fault_disconnect_listeners") + ? " AND query LIKE 'LISTEN%'" + : ""))) { + statement.setString(1, name); + int count = 0; + try (var rows = statement.executeQuery()) { + while (rows.next()) count++; + } + return Json.object().put("count", count); + } + } + if (method.equals("raw_notifications")) { + var notifications = Json.MAPPER.createArrayNode(); + try (var statement = + connection.prepareStatement( + "SELECT id,payload,topic FROM river_notification WHERE id>? ORDER BY id")) { + statement.setLong(1, params.path("after_id").asLong()); + try (var rows = statement.executeQuery()) { + while (rows.next()) + notifications.add( + Json.object() + .put("id", rows.getLong(1)) + .put("payload", rows.getString(2)) + .put("payload_type", "text") + .put("topic", rows.getString(3))); + } + } + return Json.object().set("notifications", notifications); + } + if (method.equals("raw_insert_no_notify")) { + String args = + Json.encode( + new Adapter.Echo( + params.path("behavior").asString(""), + params.path("duration_ms").asLong(0), + params.path("message").asString(""))); + long id; + try (var statement = + connection.prepareStatement( + "INSERT INTO river_job(args,kind,max_attempts) VALUES (" + + (sqlite ? "jsonb(?)" : "?::jsonb") + + ",?,?) RETURNING id")) { + statement.setString(1, args); + statement.setString(2, params.path("kind").asString("conformance_echo")); + statement.setInt(3, params.path("opts").path("max_attempts").asInt(25)); + try (var rows = statement.executeQuery()) { + rows.next(); + id = rows.getLong(1); + } + } + return Adapter.normalized(new Client(database).get(id)); + } + if (method.equals("raw_finalize")) { + String error = + "{\"at\":\"2026-02-03T04:05:06.789Z\",\"attempt\":1,\"error\":\"external discard\",\"trace\":\"external trace\"}"; + String sql = + "UPDATE river_job SET state=" + + (sqlite ? "?" : "?::river_job_state") + + ",finalized_at=" + + (sqlite ? "datetime('now','subsec')" : "now()") + + ",metadata=" + + (sqlite ? "jsonb_patch(metadata,jsonb(?))" : "metadata||?::jsonb") + + ",errors=" + + (params.path("state").asString().equals("completed") + ? (sqlite + ? "CASE WHEN ? IS NULL THEN errors ELSE errors END" + : "CASE WHEN ?::text IS NULL THEN errors ELSE errors END") + : (sqlite + ? "jsonb_insert(coalesce(errors,jsonb('[]')),'$[#]',jsonb(?))" + : "array_append(errors,?::jsonb)")) + + " WHERE id=? AND state='running'"; + try (var statement = connection.prepareStatement(sql)) { + statement.setString(1, params.path("state").asString()); + statement.setString( + 2, params.has("metadata") ? Json.encode(params.path("metadata")) : "{}"); + statement.setString(3, error); + statement.setLong(4, params.path("id").asLong()); + statement.executeUpdate(); + } + return Adapter.normalized(new Client(database).get(params.path("id").asLong())); + } + if (method.equals("raw_replace_json_text")) { + String column = params.path("column").asString(); + if (!List.of("args", "attempted_by", "errors", "metadata", "tags").contains(column)) + throw new IllegalArgumentException("Invalid JSON column"); + var result = Json.object(); + try (var statement = + connection.prepareStatement( + "SELECT CASE WHEN typeof(" + + column + + ")='text' THEN " + + column + + " ELSE json(" + + column + + ") END,typeof(" + + column + + ") FROM river_job WHERE id=?")) { + statement.setLong(1, params.path("id").asLong()); + try (var rows = statement.executeQuery()) { + rows.next(); + result.put("previous", rows.getString(1)).put("previous_type", rows.getString(2)); + } + } + try (var statement = + connection.prepareStatement("UPDATE river_job SET " + column + "=? WHERE id=?")) { + statement.setString(1, params.path("text").asString(null)); + statement.setLong(2, params.path("id").asLong()); + statement.executeUpdate(); + } + return result; + } + if (method.equals("raw_insert_exact_json")) { + String sql = + sqlite + ? "INSERT INTO river_job (id,args,kind,metadata) VALUES (?,jsonb(?),'conformance_exact_json',jsonb(?)) RETURNING id" + : "INSERT INTO river_job (id,args,kind,metadata) VALUES (coalesce(?,nextval('river_job_id_seq')),?::jsonb,'conformance_exact_json',?::jsonb) RETURNING id"; + try (var statement = connection.prepareStatement(sql)) { + statement.setObject(1, params.has("id") ? params.path("id").asLong() : null); + statement.setString( + 2, "{\"decimal\":0.12345678901234567890123456789,\"integer\":9223372036854775807}"); + statement.setString( + 3, params.path("metadata_json").asString("{\"negative\":-9223372036854775808}")); + try (var rows = statement.executeQuery()) { + rows.next(); + return Json.object().put("id", rows.getLong(1)); + } + } + } + String projection = "*"; + if (sqlite) + projection = + "*, json(args) AS args_text,json(metadata) AS metadata_text,json(attempted_by) AS attempted_by_text,json(errors) AS errors_text,json(tags) AS tags_text,typeof(unique_key) AS key_type,typeof(unique_states) AS states_type"; + else + projection = + "*,args::text AS args_text,metadata::text AS metadata_text,array_to_json(attempted_by)::text AS attempted_by_text,array_to_json(errors)::text AS errors_text,array_to_json(tags)::text AS tags_text,pg_typeof(unique_key)::text AS key_type,pg_typeof(unique_states)::text AS states_type"; + try (var statement = + connection.prepareStatement("SELECT " + projection + " FROM river_job WHERE id=?")) { + statement.setLong(1, params.path("id").asLong()); + try (var rows = statement.executeQuery()) { + if (!rows.next()) throw new IllegalArgumentException("Raw job not found"); + var result = Json.object(); + if (method.equals("raw_job_exact_json")) { + for (String column : List.of("args", "metadata")) + for (var entry : Json.members(rows.getString(column + "_text")).entrySet()) + if (List.of( + "decimal", + "integer", + "negative", + "big_integer", + "beyond_float", + "long_decimal") + .contains(entry.getKey())) result.put(entry.getKey(), entry.getValue()); + return result; + } + if (method.equals("raw_job_timestamps")) + return result + .put("created_at", rows.getString("created_at")) + .put("scheduled_at", rows.getString("scheduled_at")); + var binary = Json.object(); + for (String column : List.of("args", "attempted_by", "errors", "metadata", "tags")) { + result.put(column, rows.getString(column + "_text")); + if (sqlite) { + byte[] data = rows.getBytes(column); + binary.put( + column, data == null ? null : HexFormat.of().withUpperCase().formatHex(data)); + } + } + for (String column : + List.of("attempted_at", "created_at", "finalized_at", "scheduled_at")) + result.put(column, rows.getString(column)); + byte[] key = rows.getBytes("unique_key"); + result.put( + "unique_key", key == null ? null : HexFormat.of().withUpperCase().formatHex(key)); + result.put("unique_key_type", key == null || !sqlite ? null : rows.getString("key_type")); + result.put("unique_states", rows.getString("unique_states")); + result.put( + "unique_states_type", + rows.getString("unique_states") == null || !sqlite + ? null + : rows.getString("states_type")); + result.set("jsonb", sqlite ? binary : Json.MAPPER.nullNode()); + return result; + } + } + } + } +} diff --git a/java/conformance/src/main/java/com/riverqueue/conformance/RuntimeAdapter.java b/java/conformance/src/main/java/com/riverqueue/conformance/RuntimeAdapter.java new file mode 100644 index 000000000..1607678ec --- /dev/null +++ b/java/conformance/src/main/java/com/riverqueue/conformance/RuntimeAdapter.java @@ -0,0 +1,352 @@ +package com.riverqueue.conformance; + +import com.riverqueue.Client; +import com.riverqueue.Extension; +import com.riverqueue.InsertOptions; +import com.riverqueue.Job; +import com.riverqueue.JobType; +import com.riverqueue.Json; +import com.riverqueue.RiverException; +import com.riverqueue.Schedule; +import com.riverqueue.Unique; +import com.riverqueue.WorkContext; +import com.riverqueue.Workers; +import java.sql.Connection; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import tools.jackson.databind.JsonNode; + +/** Real worker registrations and instrumentation used only by conformance. */ +final class RuntimeAdapter { + private static final JobType ECHO = + JobType.of("conformance_echo", Adapter.Echo.class); + private final Map barriers = new ConcurrentHashMap<>(); + private Probe probe = new Probe(); + private Workers workers; + private volatile CountDownLatch claimBarrier; + private final java.util.concurrent.atomic.AtomicBoolean firstClaim = + new java.util.concurrent.atomic.AtomicBoolean(); + + JsonNode handle(Client river, String method, JsonNode params) throws Exception { + return switch (method) { + case "barrier_create" -> { + if (barriers.putIfAbsent(params.path("name").asString(), new CountDownLatch(1)) != null) + throw new IllegalArgumentException("Barrier already exists"); + yield Json.object(); + } + case "barrier_release" -> { + barrier(params.path("name").asString()).countDown(); + yield Json.object(); + } + case "queue_add" -> { + requireWorkers() + .addQueue(params.path("name").asString(), params.path("max_workers").asInt(1)); + yield Json.object(); + } + case "queue_remove" -> { + requireWorkers().removeQueue(params.path("name").asString()); + yield Json.object(); + } + case "runtime_stats" -> probe.snapshot(); + case "start" -> { + start(river, params); + yield Json.object(); + } + case "stop" -> { + stop(params.path("cancel").asBoolean(false)); + yield Json.object(); + } + case "wait" -> waitFor(river, params); + case "work" -> { + if (workers != null) throw new IllegalArgumentException("Client already running"); + var job = river.get(params.path("id").asLong()); + var start = + Json.object() + .put("client_id", params.path("client_id").asString("java-conformance-worker")) + .put("queue", job.queue()); + start(river, start); + try { + yield waitFor(river, params); + } finally { + stop(false); + } + } + default -> throw new IllegalArgumentException("Unknown runtime method: " + method); + }; + } + + Extension instrumentation() { + return new Extension() { + @Override + public void afterClaim(Job job) throws Exception { + if (claimBarrier != null && firstClaim.compareAndSet(false, true)) claimBarrier.await(); + } + + @Override + public void beforeInsert( + Connection connection, JobType type, Object args, InsertOptions options) { + if (probe.instrumented) probe.trace.add("hook:insert_begin"); + } + + @Override + public void beforeWork(WorkContext context) { + if (probe.instrumented) probe.trace.add("hook:work_begin"); + } + + @Override + public void afterWork(WorkContext context, Throwable failure) { + if (probe.instrumented) probe.trace.add("hook:work_end"); + } + + @Override + public T insert(Connection connection, Callable next) throws Exception { + if (probe.instrumented) probe.trace.add("middleware:insert_before"); + T result = next.call(); + if (probe.instrumented) probe.trace.add("middleware:insert_after"); + return result; + } + + @Override + public void periodicStarted() { + probe.periodicStarts.incrementAndGet(); + if (probe.instrumented) probe.trace.add("hook:periodic_start"); + } + + @Override + public void work(WorkContext context, WorkContext.Step next) throws Exception { + if (probe.instrumented) probe.trace.add("middleware:work_before"); + try { + next.run(); + } finally { + if (probe.instrumented) probe.trace.add("middleware:work_after"); + } + } + }; + } + + void stop(boolean cancel) { + if (workers == null) return; + if (claimBarrier != null) claimBarrier.countDown(); + Workers previous = workers; + workers = null; + if (cancel) previous.stopAndCancel(); + else previous.stop(); + } + + private CountDownLatch barrier(String name) { + var value = barriers.get(name); + if (value == null) + throw new RiverException(RiverException.Code.NOT_FOUND, "Barrier not found: " + name); + return value; + } + + private Workers requireWorkers() { + if (workers == null) throw new IllegalArgumentException("No client running"); + return workers; + } + + private void start(Client river, JsonNode params) { + if (workers != null) throw new IllegalArgumentException("Client already running"); + probe = new Probe(); + probe.instrumented = params.path("instrumented").asBoolean(false); + claimBarrier = + params.has("claim_barrier") ? barriers.get(params.path("claim_barrier").asString()) : null; + if (params.has("claim_barrier") && claimBarrier == null) + throw new Adapter.Failure(-32602, "Unknown claim barrier"); + firstClaim.set(false); + boolean periodic = params.path("periodic_run_on_start").asBoolean(false); + boolean unique = params.path("periodic_unique").asBoolean(false); + if (unique && !periodic) + throw new IllegalArgumentException("periodic_unique requires periodic_run_on_start"); + var builder = + river + .workers() + .id(params.path("client_id").asString()) + .queue(params.path("queue").asString("default"), params.path("max_workers").asInt(1)) + .add(ECHO, this::work) + .pollOnly(params.path("poll_only").asBoolean(false)) + .pollInterval(Duration.ofMillis(params.path("fetch_poll_interval_ms").asLong(10))) + .leadership(!params.path("leader_election_disabled").asBoolean(false)) + .maintenanceInterval( + Duration.ofMillis(params.path("scheduler_interval_ms").asLong(5000))) + .serviceInterval(Duration.ofMillis(params.path("elect_interval_ms").asLong(250))) + .stuckThreshold(Duration.ofMillis(params.path("job_stuck_threshold_ms").asLong(5000))) + .observe( + name -> { + if (name.equals("stuck_job")) probe.stuckJobs.incrementAndGet(); + }) + .cancelOnError(params.path("error_handler_cancel").asBoolean(false)); + builder.retention( + Duration.ofMillis(params.path("cancelled_job_retention_ms").asLong(86400000)), + Duration.ofMillis(params.path("completed_job_retention_ms").asLong(86400000)), + Duration.ofMillis(params.path("discarded_job_retention_ms").asLong(604800000))); + if (params.has("reindexer_index_names")) { + var names = new java.util.ArrayList(); + for (var name : params.path("reindexer_index_names")) names.add(name.asString()); + builder.reindex( + names, + params.has("reindexer_interval_ms") + ? Duration.ofMillis(params.path("reindexer_interval_ms").asLong()) + : null); + } + if (params.has("retry_delay_ms")) + builder.retryPolicy(ignored -> Duration.ofMillis(params.path("retry_delay_ms").asLong())); + if (params.path("job_timeout_disabled").asBoolean(false)) + builder.jobTimeout(Duration.ofMillis(-1)); + else if (params.has("job_timeout_ms")) + builder.jobTimeout(Duration.ofMillis(params.path("job_timeout_ms").asLong())); + if (params.has("rescue_after_ms")) + builder.rescueAfter(Duration.ofMillis(params.path("rescue_after_ms").asLong())); + if (periodic) { + var opts = + InsertOptions.builder().unique(unique ? Unique.args().perQueue() : Unique.none()).build(); + builder.periodic( + "conformance-periodic", + Schedule.every(Duration.ofHours(1)), + ECHO, + new Adapter.Echo("", 0, "periodic run on start"), + opts, + true); + if (unique) + builder.periodic( + "conformance-periodic-marker", + Schedule.every(Duration.ofHours(1)), + ECHO, + new Adapter.Echo("", 0, "periodic marker"), + InsertOptions.defaults(), + true); + } + workers = builder.start(); + workers.subscribe( + event -> { + probe.events.add(event.kind().value()); + if (params.path("error_handler_cancel").asBoolean(false) + && event.kind() == Workers.EventKind.JOB_CANCELLED) + probe.errorHandlerCalls.incrementAndGet(); + }); + } + + private JsonNode waitFor(Client river, JsonNode params) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos(); + while (System.nanoTime() < deadline) { + var job = river.get(params.path("id").asLong()); + boolean match = params.has("states") ? false : job.state().isFinalized(); + for (var state : params.path("states")) + if (job.state().value().equals(state.asString())) match = true; + if (match) return Adapter.normalized(job); + TimeUnit.MILLISECONDS.sleep(5); + } + throw new RiverException(RiverException.Code.REJECTED, "Timed out waiting for job state"); + } + + private void work(WorkContext context) throws Exception { + var args = context.args(); + switch (args.behavior()) { + case "barrier_wait", "barrier_output" -> { + barrier(args.message()).await(); + if (args.behavior().equals("barrier_output")) context.output(Map.of("race", "worker")); + } + case "cancel" -> context.cancel("conformance cancel"); + case "cancel_error" -> { + context.awaitCancellation(); + throw new IllegalStateException("conformance failure after cancellation"); + } + case "cancel_panic" -> { + context.awaitCancellation(); + throw new AssertionError("conformance panic after cancellation"); + } + case "cooperative_cancel", "snooze_then_cancel" -> { + if (args.behavior().equals("snooze_then_cancel") + && !context.job().metadata().has("snoozes")) + context.snooze(Duration.ofMillis(Math.max(1, args.durationMs()))); + if (context.isCancelled()) probe.cancelledAtStart.incrementAndGet(); + context.awaitCancellation(); + context.checkCancelled(); + } + case "discard" -> context.discard("conformance discard"); + case "error" -> throw new IllegalStateException("conformance retryable error"); + case "ignored_cancel" -> new CountDownLatch(1).await(); + case "output" -> context.output(Map.of("message", args.message())); + case "panic" -> throw new AssertionError("conformance worker panic"); + case "sleep" -> TimeUnit.MILLISECONDS.sleep(args.durationMs()); + case "snooze_once" -> { + if (!context.job().metadata().has("snoozes")) + context.snooze(Duration.ofMillis(Math.max(1, args.durationMs()))); + } + case "resumable", "resumable_duplicate" -> { + context.step("first", () -> probe.resumableFirstRuns.incrementAndGet()); + context.step( + args.behavior().equals("resumable_duplicate") ? "first" : "second", + () -> { + probe.resumableSecondRuns.incrementAndGet(); + if (context.job().attempt() == 1) + throw new IllegalStateException("fail second resumable step once"); + }); + } + case "resumable_cursor" -> { + context.step("first", () -> context.metadata("first_attempt", context.job().attempt())); + context.stepWithCursor( + "second", + cursor -> { + if (context.job().attempt() == 1) { + context.cursor(7); + throw new IllegalStateException("retry with cursor"); + } + if (cursor.asLong(0) != 7) throw new IllegalStateException("expected cursor 7"); + context.metadata("cursor_observed", 7); + }); + context.step( + "third", + () -> { + if (context.job().attempt() == 2) + throw new IllegalStateException("retry after consuming cursor"); + }); + } + case "transactional_complete" -> { + context.metadata("transactional_completion", true); + context.transaction(c -> context.complete(c)); + } + default -> {} + } + } + + private static final class Probe { + final AtomicInteger cancelledAtStart = new AtomicInteger(); + final AtomicInteger errorHandlerCalls = new AtomicInteger(); + final List events = new CopyOnWriteArrayList<>(); + boolean instrumented; + final AtomicInteger periodicStarts = new AtomicInteger(); + final AtomicInteger resumableFirstRuns = new AtomicInteger(); + final AtomicInteger resumableSecondRuns = new AtomicInteger(); + final AtomicInteger stuckJobs = new AtomicInteger(); + final List trace = new CopyOnWriteArrayList<>(); + + JsonNode snapshot() { + return Json.tree( + Map.of( + "cancelled_at_start", + cancelledAtStart.get(), + "error_handler_calls", + errorHandlerCalls.get(), + "events", + events, + "periodic_starts", + periodicStarts.get(), + "resumable_first_runs", + resumableFirstRuns.get(), + "resumable_second_runs", + resumableSecondRuns.get(), + "stuck_jobs", + stuckJobs.get(), + "trace", + trace)); + } + } +} diff --git a/java/conformance/src/main/resources/contract.json b/java/conformance/src/main/resources/contract.json new file mode 100644 index 000000000..d6392de71 --- /dev/null +++ b/java/conformance/src/main/resources/contract.json @@ -0,0 +1,2820 @@ +{ + "$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": 20, + "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": "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_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" + } + }, + "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/java/conformance/src/main/resources/manifest.json b/java/conformance/src/main/resources/manifest.json new file mode 100644 index 000000000..99a4058e4 --- /dev/null +++ b/java/conformance/src/main/resources/manifest.json @@ -0,0 +1,61 @@ +{ + "$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.48.0" + }, + "javascript": { + "package": "riverqueue", + "registry": "npm", + "version": "0.48.0-alpha.1" + }, + "rust": { + "package": "riverqueue", + "registry": "crates.io", + "version": "0.48.0-alpha.1" + }, + "java": { + "package": "com.riverqueue:river", + "registry": "maven", + "version": "0.48.0-alpha.1" + } + }, + "migration": { + "latest": 8, + "line": "main" + }, + "protocol_revision": 1 +} diff --git a/java/conformance/src/main/resources/sqlite-profile.json b/java/conformance/src/main/resources/sqlite-profile.json new file mode 100644 index 000000000..1923c5757 --- /dev/null +++ b/java/conformance/src/main/resources/sqlite-profile.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/java/conformance/src/main/resources/sqlite-runtime-profile.json b/java/conformance/src/main/resources/sqlite-runtime-profile.json new file mode 100644 index 000000000..a64ab1970 --- /dev/null +++ b/java/conformance/src/main/resources/sqlite-runtime-profile.json @@ -0,0 +1,93 @@ +{ + "$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", + "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/java/conformance/validation-results.json b/java/conformance/validation-results.json new file mode 100644 index 000000000..9d3a3c84d --- /dev/null +++ b/java/conformance/validation-results.json @@ -0,0 +1,41 @@ +{ + "reference_revision": "eb16420fed22ce479f4843f0accd4c4bfba0885e", + "checks": { + "postgres": { + "passed": true, + "summary": [ + "ok \tgithub.com/riverqueue/river/conformance/harness\t96.844s" + ] + }, + "sqlite": { + "passed": true, + "summary": [ + "ok \tgithub.com/riverqueue/river/conformance/harness\t43.905s" + ] + }, + "performance": { + "passed": true, + "summary": [ + "ok \tgithub.com/riverqueue/river/conformance/harness\t7.736s" + ] + }, + "multi": { + "passed": true, + "summary": [ + "ok \tgithub.com/riverqueue/river/conformance/harness\t42.020s" + ] + }, + "multi-soak": { + "passed": true, + "summary": [ + "ok \tgithub.com/riverqueue/river/conformance/harness\t306.372s" + ] + }, + "sqlite-soak": { + "passed": true, + "summary": [ + "ok \tgithub.com/riverqueue/river/conformance/harness\t447.344s" + ] + } + } +} diff --git a/java/go.mod b/java/go.mod new file mode 100644 index 000000000..46fd4d1b1 --- /dev/null +++ b/java/go.mod @@ -0,0 +1,5 @@ +// This boundary keeps java/ out of River's Go module zip and package discovery. +// Go maintenance tools in bin/ are run and tested by file from the root workspace. +// This module is not part of go.work. Do not tag it: +// a java/vX.Y.Z tag would be interpreted as a version of this module. +module github.com/riverqueue/river/java diff --git a/java/pom.xml b/java/pom.xml new file mode 100644 index 000000000..0e33e32b7 --- /dev/null +++ b/java/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + com.riverqueue + river-java + 0.48.0-alpha.1 + pom + River for Java + rivercliconformance + + 21 + UTF-8 + + + tools.jackson.corejackson-databind3.2.3 + org.postgresqlpostgresql42.7.9 + org.xerialsqlite-jdbc3.53.4.0 + org.junit.jupiterjunit-jupiter5.13.4 + + + + org.apache.maven.pluginsmaven-jar-plugin3.5.1true + org.apache.maven.pluginsmaven-compiler-plugin3.14.1truetrue-Xlint:all + org.apache.maven.pluginsmaven-surefire-plugin3.5.4--enable-native-access=ALL-UNNAMED + org.apache.maven.pluginsmaven-source-plugin3.3.1jar-no-fork + com.diffplug.spotlessspotless-maven-plugin2.44.31.28.0verifycheck + + diff --git a/java/river/pom.xml b/java/river/pom.xml new file mode 100644 index 000000000..0e160e03e --- /dev/null +++ b/java/river/pom.xml @@ -0,0 +1,13 @@ + + 4.0.0 + com.riverqueueriver-java0.48.0-alpha.1 + river + Transactional jobs interoperable with River Go + Mozilla Public License 2.0https://mozilla.org/MPL/2.0/repo + + tools.jackson.corejackson-databind + org.postgresqlpostgresqltrue + org.xerialsqlite-jdbctrue + org.junit.jupiterjunit-jupitertest + + diff --git a/java/river/src/main/java/com/riverqueue/Client.java b/java/river/src/main/java/com/riverqueue/Client.java new file mode 100644 index 000000000..95eaea082 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Client.java @@ -0,0 +1,926 @@ +package com.riverqueue; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import tools.jackson.databind.JsonNode; + +/** + * Transactional job storage. Connection overloads use the caller's transaction and never commit or + * close it; other overloads own a transaction. Close the {@link Workers} returned by {@link + * Workers.Builder#start()} to stop execution. The client owns no resources to close. + */ +public final class Client { + private final java.util.concurrent.CopyOnWriteArrayList insertListeners = + new java.util.concurrent.CopyOnWriteArrayList<>(); + private final ThreadLocal ownedCommit = new ThreadLocal<>(); + private final Plugin plugin; + private final Clock clock; + private final Database database; + private final List extensions; + + public Client(Database database) { + this(database, Clock.systemUTC()); + } + + public Client(Database database, Clock clock) { + this(database, clock, List.of(), new Plugin() {}); + } + + private Client(Database database, Clock clock, List extensions, Plugin plugin) { + this.plugin = plugin; + this.database = Objects.requireNonNull(database); + this.clock = Objects.requireNonNull(clock); + this.extensions = List.copyOf(extensions); + } + + public Database database() { + return database; + } + + public Queues queues() { + return new Queues(this); + } + + public Workers.Builder workers() { + return new Workers.Builder(this); + } + + public Client withExtension(Extension extension) { + var all = new ArrayList<>(extensions); + all.add(extension); + return new Client(database, clock, all, plugin); + } + + /** Internal integration point used by River Pro; not a stable application API. */ + public Client withPlugin(Plugin plugin) { + return new Client(database, clock, extensions, Objects.requireNonNull(plugin)); + } + + Runnable onInsertCommit(Runnable listener) { + insertListeners.add(listener); + return () -> insertListeners.remove(listener); + } + + private static final class Commit { + final Connection connection; + boolean inserted; + + Commit(Connection connection) { + this.connection = connection; + } + } + + Plugin plugin() { + return plugin; + } + + /** Internal driver access for the matched River Pro release. */ + public Driver driver() { + return new Driver(); + } + + public final class Driver { + private Driver() {} + + /** Internal conformance reset; deletes all core River data. */ + public void reset() { + Client.this.reset(); + } + + /** Internal retention operation used by the conformance adapter. */ + public int deleteFinalized( + Connection connection, + Instant before, + int limit, + List excluded, + List included) { + return Client.this.deleteFinalized(connection, before, limit, excluded, included); + } + + public Object timestamp(Instant value) { + return database.timestamp(value); + } + + public String prefix() { + return database.prefix(); + } + + public Job read(ResultSet rows) throws SQLException { + return Client.this.read(rows); + } + + public Decoded readPartial(ResultSet rows) throws SQLException { + return Client.this.readPartial(rows); + } + + public String query(String name) { + return Sql.query(database, name); + } + + public java.sql.PreparedStatement prepare(Connection c, String sql, Object... params) + throws SQLException { + return Sql.prepare(c, sql, params); + } + + public void notify(Connection c, String topic, String payload) throws SQLException { + Client.this.notify(c, topic, payload); + } + + public Database database() { + return database; + } + + /** + * Reinsert a persisted job through insertion middleware, preserving its unique key and dates. + */ + public Job.InsertResult reinsert(Connection connection, Job original) { + var type = JobType.of(original.kind(), JsonNode.class); + var options = + new InsertOptions( + original.maxAttempts(), + original.metadata(), + false, + original.priority(), + original.queue(), + original.scheduledAt(), + original.tags(), + Unique.none()); + return atomic( + connection, + c -> + insertMiddleware( + c, + 0, + () -> { + for (var extension : extensions) + extension.beforeInsert(c, type, original.args(), options); + return insertOne(c, type, original.args(), options, original, null); + })); + } + } + + List extensions() { + return extensions; + } + + public Job cancel(long id) { + return transaction(c -> cancel(c, id)); + } + + public Job cancel(Connection connection, long id) { + return atomic( + connection, + c -> { + var before = locked(c, id); + if (before.state().isFinalized()) return before; + try (var statement = + Sql.prepare( + c, + Sql.query(database, "cancel"), + database.timestamp(clock.instant()), + Json.encode(clock.instant()), + id); + var rows = statement.executeQuery()) { + if (!rows.next()) return get(c, id); + var job = read(rows); + plugin.afterStateChange(c, driver(), job); + notify(c, Protocol.cancel(id, job.queue())); + return job; + } + }); + } + + public Job delete(long id) { + return transaction(c -> delete(c, id)); + } + + public Job delete(Connection connection, long id) { + return atomic( + connection, + c -> { + var job = locked(c, id); + if (job.state() == Job.State.RUNNING) + throw new RiverException(RiverException.Code.REJECTED, "Cannot delete a running job"); + try (var statement = Sql.prepare(c, Sql.query(database, "delete"), id); + var rows = statement.executeQuery()) { + rows.next(); + var deleted = read(rows); + plugin.afterDelete(c, driver(), deleted); + return deleted; + } + }); + } + + private int deleteFinalized( + Connection connection, + Instant before, + int limit, + List excluded, + List included) { + if (limit < 1) throw new IllegalArgumentException("Cleaner limit must be positive"); + try (var statement = + Sql.prepare( + connection, + Sql.query(database, "delete_finalized"), + database.timestamp(before), + Json.encode(excluded), + included == null, + Json.encode(included == null ? List.of() : included), + limit)) { + return statement.executeUpdate(); + } catch (SQLException e) { + throw databaseError("Delete finalized jobs", e); + } + } + + /** Completes a running attempt in the caller's transaction, preserving its argument type. */ + public Job complete(Connection connection, Job job) { + return complete(connection, job, java.util.Map.of()); + } + + /** Completes a running attempt and merges metadata in the caller's transaction. */ + public Job complete(Connection connection, Job job, Object metadata) { + Objects.requireNonNull(job, "job"); + if (job.state() != Job.State.RUNNING) throw new IllegalArgumentException("Job must be running"); + var updates = metadata == null ? Json.object() : Json.tree(metadata); + if (!updates.isObject()) throw new IllegalArgumentException("Metadata must be a JSON object"); + return atomic( + connection, + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(database, "complete"), + "completed", + database.timestamp(clock.instant()), + "completed", + database.timestamp(clock.instant()), + database.timestamp(job.scheduledAt()), + job.attempt(), + Json.encode(updates), + null, + null, + job.id(), + job.attempt(), + database.timestamp(job.attemptedAt())); + var rows = statement.executeQuery()) { + if (!rows.next()) return get(c, job.id()).mapArgs(ignored -> job.args()); + var completed = read(rows); + plugin.afterStateChange(c, driver(), completed); + return completed.mapArgs(ignored -> job.args()); + } + }); + } + + public List> deleteMany(JobQuery query, boolean all) { + return transaction(connection -> deleteMany(connection, query, all)); + } + + public List> deleteMany(Connection connection, JobQuery query, boolean all) { + if (!all && !query.hasFilters()) + throw new IllegalArgumentException("Bulk deletion requires filters or explicit all=true"); + return atomic( + connection, + c -> { + // Reserve SQLite's writer before listing so another commit cannot invalidate the + // snapshot. + if (database.dialect() == Database.Dialect.SQLITE) + try (var statement = Sql.prepare(c, Sql.query(database, "writer_lock"))) { + statement.executeUpdate(); + } + var result = new ArrayList>(); + for (var job : list(c, query).jobs()) + try (var statement = Sql.prepare(c, Sql.query(database, "delete"), job.id()); + var rows = statement.executeQuery()) { + // A PostgreSQL worker may have claimed or deleted the job since it was listed. + if (!rows.next()) continue; + var deleted = read(rows); + plugin.afterDelete(c, driver(), deleted); + result.add(deleted); + } + return List.copyOf(result); + }); + } + + public Job get(long id) { + return transaction(c -> get(c, id)); + } + + /** Loads and decodes a job, rejecting a kind that does not match the supplied type. */ + public Job get(long id, JobType type) { + return transaction(connection -> get(connection, id, type)); + } + + /** Loads and decodes a job using the caller's connection. */ + public Job get(Connection connection, long id, JobType type) { + var job = get(connection, id); + if (!job.kind().equals(type.kind())) + throw new IllegalArgumentException( + "Expected job kind " + type.kind() + ", got " + job.kind()); + return typed(job, type); + } + + public Job get(Connection connection, long id) { + return atomic( + connection, + transaction -> { + try (var statement = Sql.prepare(transaction, Sql.query(database, "get"), id); + var rows = statement.executeQuery()) { + if (!rows.next()) + throw new RiverException(RiverException.Code.NOT_FOUND, "Job " + id + " not found"); + return read(rows); + } catch (SQLException e) { + throw databaseError("Get job", e); + } + }); + } + + public Job.InsertResult insert(JobType type, A args) { + return insert(type, args, InsertOptions.defaults()); + } + + public Job.InsertResult insert(JobType type, A args, InsertOptions options) { + return transaction(c -> insert(c, type, args, options)); + } + + public Job.InsertResult insert(Connection connection, JobType type, A args) { + return insert(connection, type, args, InsertOptions.defaults()); + } + + public Job.InsertResult insert( + Connection connection, JobType type, A args, InsertOptions options) { + return insert(connection, type, args, options, null); + } + + Job.InsertResult insert( + Connection connection, + JobType type, + A args, + InsertOptions options, + Instant scheduledDefault) { + return atomic( + connection, + c -> { + return insertMiddleware( + c, + 0, + () -> { + for (var extension : extensions) extension.beforeInsert(c, type, args, options); + return insertOne(c, type, args, options, null, scheduledDefault); + }); + }); + } + + private T insertMiddleware( + Connection connection, int index, java.util.concurrent.Callable next) throws Exception { + return index == extensions.size() + ? next.call() + : extensions + .get(index) + .insert(connection, () -> insertMiddleware(connection, index + 1, next)); + } + + /** Inserts jobs of one kind atomically, inheriting the job type's defaults. */ + public List insertMany(JobType type, List args) { + return insertMany(type, args, InsertOptions.defaults()); + } + + /** Inserts jobs of one kind with common options in a new transaction. */ + public List insertMany( + JobType type, List args, InsertOptions options) { + return transaction(c -> insertMany(c, type, args, options)); + } + + /** Inserts jobs of one kind atomically in the caller's transaction. */ + public List insertMany( + Connection connection, JobType type, List args) { + return insertMany(connection, type, args, InsertOptions.defaults()); + } + + /** Inserts jobs of one kind with common options in the caller's transaction. */ + public List insertMany( + Connection connection, JobType type, List args, InsertOptions options) { + return insertBatch(connection, args, (c, arg) -> insertWithHooks(c, type, arg, options)); + } + + /** Inserts mixed job kinds and per-job options atomically, in input order. */ + public List insertMany(List> submissions) { + return transaction(c -> insertMany(c, submissions)); + } + + /** Inserts mixed job kinds and per-job options inside the caller's transaction. */ + public List insertMany( + Connection connection, List> submissions) { + return insertBatch(connection, submissions, (c, submission) -> insertSubmission(c, submission)); + } + + private Job.InsertResult insertWithHooks( + Connection connection, JobType type, A args, InsertOptions options) { + try { + for (var extension : extensions) extension.beforeInsert(connection, type, args, options); + } catch (Exception error) { + throw propagate(error); + } + return insertOne(connection, type, args, options); + } + + private Job.InsertResult insertSubmission(Connection connection, Submission value) { + return insertWithHooks(connection, value.type(), value.args(), value.options()); + } + + private List insertBatch( + Connection connection, + List values, + java.util.function.BiFunction insert) { + if (values.isEmpty()) throw new IllegalArgumentException("Cannot insert an empty batch"); + return atomic( + connection, + c -> + insertMiddleware( + c, + 0, + () -> { + var results = new ArrayList(); + var keys = new java.util.HashSet(); + for (var value : values) { + var result = insert.apply(c, value); + if (result.job().uniqueKey() != null && !keys.add(result.job().uniqueKey())) + throw new RiverException( + RiverException.Code.REJECTED, "Batch repeats a unique key"); + results.add(result); + } + return List.copyOf(results); + })); + } + + private Job.InsertResult insertOne( + Connection connection, JobType type, A args, InsertOptions options) { + return insertOne(connection, type, args, options, null, null); + } + + private Job.InsertResult insertOne( + Connection connection, + JobType type, + A args, + InsertOptions options, + Job original, + Instant scheduledDefault) { + var resolved = options.resolve(type.defaults()); + validate(resolved); + String encodedArgs = Json.encode(args); + try { + var prepared = + plugin.prepare(connection, driver(), new Plugin.Insert(type, encodedArgs, resolved)); + encodedArgs = prepared.args(); + resolved = prepared.options(); + validate(resolved); + } catch (Exception error) { + throw propagate(error); + } + Instant now = clock.instant(); + Instant scheduled = + resolved.scheduledAt() != null + ? resolved.scheduledAt() + : scheduledDefault != null ? scheduledDefault : now; + String state = + original != null + ? "available" + : resolved.pending() + ? "pending" + : resolved.scheduledAt() != null ? "scheduled" : "available"; + String key = + original != null + ? original.uniqueKey() + : resolved + .unique() + .key( + type.kind(), + encodedArgs, + type.uniqueFields(), + now, + resolved.queue(), + resolved.scheduledAt()); + int stateMask = + original == null || original.uniqueStates() == null + ? resolved.unique().stateMask() + : original.uniqueStates().stream().mapToInt(Job.State::bit).reduce(0, (a, b) -> a | b); + boolean postgres = database.dialect() == Database.Dialect.POSTGRES; + try { + if (!postgres && key != null && (stateMask & Job.State.of(state).bit()) != 0) { + try (var statement = Sql.prepare(connection, Sql.query(database, "writer_lock"))) { + statement.executeUpdate(); + } + try (var statement = + Sql.prepare( + connection, Sql.query(database, "unique_get"), HexFormat.of().parseHex(key)); + var rows = statement.executeQuery()) { + if (rows.next()) return new Job.InsertResult(read(rows), true); + } + } + Object tags = + postgres + ? connection.createArrayOf("text", resolved.tags().toArray()) + : Json.encode(resolved.tags()); + Object mask = + key == null + ? null + : postgres + ? String.format("%8s", Integer.toBinaryString(stateMask)).replace(' ', '0') + : stateMask; + var metadata = resolved.metadata().deepCopy(); + boolean nonce = database.usesUniqueNonce(connection); + if (nonce) + ((tools.jackson.databind.node.ObjectNode) metadata) + .put( + Protocol.METADATA_UNIQUE_NONCE, + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16)); + Object[] params = { + encodedArgs, + database.timestamp(original == null ? now : original.createdAt()), + type.kind(), + resolved.maxAttempts(), + Json.encode(metadata), + resolved.priority(), + resolved.queue(), + database.timestamp(scheduled), + state, + tags, + key == null ? null : HexFormat.of().parseHex(key), + mask + }; + String insertSql = Sql.query(database, "insert"); + if (postgres) + insertSql = + insertSql.replace( + "{duplicate}", + Sql.query(database, nonce ? "insert_duplicate_nonce" : "insert_duplicate_xmax")); + try (var statement = Sql.prepare(connection, insertSql, params); + var rows = statement.executeQuery()) { + if (!rows.next()) + throw new RiverException(RiverException.Code.REJECTED, "Insertion returned no row"); + var job = read(rows); + boolean duplicate = + nonce + ? !job.metadata() + .path(Protocol.METADATA_UNIQUE_NONCE) + .equals(metadata.path(Protocol.METADATA_UNIQUE_NONCE)) + : rows.getBoolean("duplicate"); + if (!duplicate) plugin.afterInsert(connection, driver(), job); + if (!duplicate && job.state() == Job.State.AVAILABLE) notifyInsert(connection, job.queue()); + return new Job.InsertResult(job, duplicate); + } finally { + if (tags instanceof java.sql.Array array) array.free(); + } + } catch (Exception e) { + throw propagate(e); + } + } + + public JobQuery.Page list(JobQuery query) { + return transaction(c -> list(c, query)); + } + + public JobQuery.Page list(Connection connection, JobQuery query) { + return atomic( + connection, + transaction -> { + var params = new ArrayList(); + String sql = query.sql(database, params); + try (var statement = Sql.prepare(transaction, sql, params.toArray()); + var rows = statement.executeQuery()) { + var jobs = new ArrayList>(); + while (rows.next()) jobs.add(read(rows)); + return new JobQuery.Page( + jobs.isEmpty() ? null : query.cursor(jobs.getLast()), List.copyOf(jobs)); + } catch (SQLException e) { + throw databaseError("List jobs", e); + } + }); + } + + private Job locked(Connection connection, long id) throws SQLException { + if (database.dialect() == Database.Dialect.SQLITE) + try (var statement = Sql.prepare(connection, Sql.query(database, "writer_lock"))) { + statement.executeUpdate(); + } + try (var statement = Sql.prepare(connection, Sql.query(database, "lock_get"), id); + var rows = statement.executeQuery()) { + if (!rows.next()) + throw new RiverException(RiverException.Code.NOT_FOUND, "Job " + id + " not found"); + return read(rows); + } + } + + /** Requests that the current leader resign after this transaction commits. */ + public void requestResign(Connection connection) { + atomic( + connection, + c -> { + notify(c, Protocol.requestResign()); + return null; + }); + } + + /** Requests a new leadership election. */ + public void requestResign() { + transaction( + c -> { + requestResign(c); + return null; + }); + } + + void notify(Connection connection, String topic, String payload) throws SQLException { + var commit = ownedCommit.get(); + if (commit != null && commit.connection == connection && topic.equals(Protocol.TOPIC_INSERT)) + commit.inserted = true; + if (!database.supportsNotifications(connection)) return; + try (var statement = Sql.prepare(connection, Sql.query(database, "notify"), topic, payload)) { + statement.execute(); + } + } + + void notify(Connection connection, Protocol.Notification notification) throws SQLException { + notify(connection, notification.topic(), notification.payload()); + } + + void notifyInsert(Connection connection, String queue) throws SQLException { + notify(connection, Protocol.insert(queue)); + } + + public Job output(long id, Object output) { + return transaction(connection -> output(connection, id, output)); + } + + public Job output(Connection connection, long id, Object output) { + return atomic( + connection, + c -> { + try (var statement = + Sql.prepare(c, Sql.query(database, "output"), Json.encode(output), id); + var rows = statement.executeQuery()) { + if (!rows.next()) + throw new RiverException(RiverException.Code.NOT_FOUND, "Job " + id + " not found"); + return read(rows); + } + }); + } + + public Job retry(long id) { + return transaction(c -> retry(c, id)); + } + + public Job retry(Connection connection, long id) { + return atomic( + connection, + c -> { + var before = locked(c, id); + try (var statement = + Sql.prepare( + c, + Sql.query(database, "retry"), + database.timestamp(clock.instant()), + id, + database.timestamp(clock.instant())); + var rows = statement.executeQuery()) { + boolean updated = rows.next(); + var job = updated ? read(rows) : before; + plugin.afterStateChange(c, driver(), job); + if (updated) notifyInsert(c, job.queue()); + return job; + } + }); + } + + private void reset() { + transaction( + c -> { + for (var name : List.of("jobs", "queues", "leader", "notifications")) + try (var statement = Sql.prepare(c, Sql.query(database, "reset_" + name))) { + statement.executeUpdate(); + } + return null; + }); + } + + /** Runs a callback in a new transaction, committing on success and rolling back on failure. */ + public T transaction(Transaction action) { + try (var connection = database.connection()) { + connection.setAutoCommit(false); + var previous = ownedCommit.get(); + var commit = new Commit(connection); + ownedCommit.set(commit); + try { + T result = action.run(connection); + connection.commit(); + // JDBC batches incoming notifications. Wake this client's workers immediately after a + // successful commit; cross-process and caller-owned transactions still use the database. + if (commit.inserted) insertListeners.forEach(Runnable::run); + return result; + } catch (Throwable e) { + try { + connection.rollback(); + } catch (SQLException rollback) { + e.addSuppressed(rollback); + } + throw propagate(e); + } finally { + if (previous == null) ownedCommit.remove(); + else ownedCommit.set(previous); + } + } catch (SQLException e) { + throw databaseError("River transaction", e); + } + } + + /** + * Runs an atomic operation inside an existing transaction using a savepoint. The connection must + * have auto-commit disabled. Failure rolls back only this operation; the caller remains + * responsible for committing, rolling back, and closing the connection. + */ + public T transaction(Connection connection, Transaction action) { + return atomic(connection, action); + } + + private T atomic(Connection connection, Transaction action) { + try { + if (connection.getAutoCommit()) + throw new IllegalArgumentException("Caller connection must have autoCommit disabled"); + var savepoint = connection.setSavepoint(); + var commit = ownedCommit.get(); + if (commit != null && commit.connection != connection) commit = null; + boolean inserted = commit != null && commit.inserted; + try { + T result = action.run(connection); + connection.releaseSavepoint(savepoint); + return result; + } catch (Throwable e) { + // Roll back local wake-ups along with the database notifications in this savepoint. + if (commit != null) commit.inserted = inserted; + try { + connection.rollback(savepoint); + connection.releaseSavepoint(savepoint); + } catch (SQLException rollback) { + e.addSuppressed(rollback); + } + throw propagate(e); + } + } catch (SQLException e) { + throw databaseError("River operation savepoint", e); + } + } + + private static RuntimeException propagate(Throwable e) { + if (e instanceof Error fatal) throw fatal; + return e instanceof RuntimeException runtime + ? runtime + : e instanceof SQLException sql + ? databaseError("Database operation", sql) + : new RiverException(RiverException.Code.REJECTED, "Transaction callback failed", e); + } + + static RiverException databaseError(String action, SQLException error) { + return new RiverException( + RiverException.Code.DATABASE, action + ": " + error.getMessage(), error); + } + + Job typed(Job job, JobType type) { + return job.mapArgs(ignored -> Json.decode(plugin.decode(job), type.argsType())); + } + + Job read(ResultSet rows) throws SQLException { + var decoded = readPartial(rows); + if (decoded.failure() != null) throw decoded.failure(); + return decoded.job(); + } + + public record Decoded(Job job, RuntimeException failure) {} + + Decoded readPartial(ResultSet rows) throws SQLException { + var failures = new ArrayList(); + var columns = new java.util.HashMap(); + for (String column : List.of("args", "attempted_by", "errors", "metadata", "tags")) { + String raw; + if (rows.getObject(column) instanceof java.sql.Array array) { + try { + var values = (Object[]) array.getArray(); + var json = Json.MAPPER.createArrayNode(); + for (var value : values) + json.add(column.equals("errors") ? Json.parse(value.toString()) : Json.tree(value)); + raw = Json.encode(json); + } finally { + array.free(); + } + } else raw = rows.getString(column); + try { + columns.put( + column, + Json.parse( + raw == null + ? (column.equals("metadata") || column.equals("args") ? "{}" : "[]") + : raw)); + } catch (RuntimeException error) { + failures.add(column + ": " + error.getMessage()); + columns.put(column, Json.object()); + } + } + var errors = new ArrayList(); + for (var error : columns.get("errors")) { + Instant at = Instant.parse("0001-01-01T00:00:00Z"); + if (error.path("at").isString()) + try { + at = Instant.parse(error.path("at").asString()); + } catch (java.time.DateTimeException ignored) { + } + String message = error.isObject() ? errorText(error.path("error")) : errorText(error); + errors.add( + new Job.AttemptError( + at, error.path("attempt").asInt(0), message, errorText(error.path("trace")))); + } + String bits = rows.getString("unique_states"); + int mask = + bits == null + ? 0 + : Integer.parseInt(bits, database.dialect() == Database.Dialect.POSTGRES ? 2 : 10); + byte[] key = rows.getBytes("unique_key"); + var job = + new Job<>( + rows.getLong("id"), + columns.get("args"), + rows.getInt("attempt"), + Database.instant(rows.getString("attempted_at")), + strings(columns.get("attempted_by")), + Database.instant(rows.getString("created_at")), + errors, + Database.instant(rows.getString("finalized_at")), + rows.getString("kind"), + rows.getInt("max_attempts"), + columns.get("metadata"), + rows.getInt("priority"), + rows.getString("queue"), + Database.instant(rows.getString("scheduled_at")), + Job.State.of(rows.getString("state")), + strings(columns.get("tags")), + key == null ? null : HexFormat.of().formatHex(key), + bits == null + ? null + : Arrays.stream(Job.State.values()) + .filter(state -> (mask & state.bit()) != 0) + .toList()); + return new Decoded( + job, + failures.isEmpty() + ? null + : new IllegalArgumentException( + "job row couldn't be decoded: " + String.join("; ", failures))); + } + + private static String errorText(JsonNode value) { + return value.isMissingNode() || value.isNull() + ? "" + : value.isString() ? value.asString() : Json.encode(value); + } + + private static List strings(JsonNode values) { + var result = new ArrayList(); + for (var value : values) result.add(value.asString()); + return result; + } + + static void validate(InsertOptions options) { + if (options.maxAttempts() < 1 || options.maxAttempts() > 32767) + throw new IllegalArgumentException("maxAttempts must be between 1 and 32767"); + if (options.priority() < 1 || options.priority() > 4) + throw new IllegalArgumentException("priority must be between 1 and 4"); + if (options.queue().length() > 64 || !options.queue().matches("[a-z0-9]+([_|-]?[a-z0-9]+)*")) + throw new IllegalArgumentException("Invalid queue name"); + if (!options.metadata().isObject()) + throw new IllegalArgumentException("metadata must be a JSON object"); + for (var tag : options.tags()) + if (tag.length() > 255 || !tag.matches("[a-zA-Z0-9_][a-zA-Z0-9_-]+[a-zA-Z0-9_]")) + throw new IllegalArgumentException("Invalid tag: " + tag); + } + + /** A job and its individual options in an atomic batch. */ + public record Submission(JobType type, A args, InsertOptions options) { + public Submission { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(args, "args"); + Objects.requireNonNull(options, "options"); + } + } + + /** A callback run in the same JDBC transaction as the application's writes. */ + @FunctionalInterface + public interface Transaction { + T run(Connection connection) throws Exception; + } +} diff --git a/java/river/src/main/java/com/riverqueue/CronSchedule.java b/java/river/src/main/java/com/riverqueue/CronSchedule.java new file mode 100644 index 000000000..99f53eef8 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/CronSchedule.java @@ -0,0 +1,170 @@ +package com.riverqueue; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.BitSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; + +final class CronSchedule implements Schedule { + private final Field days; + private final Field hours; + private final Duration interval; + private final Field minutes; + private final Field months; + private final Field weekdays; + private final ZoneId zone; + + CronSchedule(String expression) { + expression = expression.strip(); + ZoneId explicitZone = null; + if (expression.startsWith("CRON_TZ=") || expression.startsWith("TZ=")) { + int separator = expression.indexOf(' '); + if (separator < 0) + throw new IllegalArgumentException("Missing cron expression after time zone"); + explicitZone = ZoneId.of(expression.substring(expression.indexOf('=') + 1, separator)); + expression = expression.substring(separator + 1).strip(); + } + zone = explicitZone; + if (expression.startsWith("@every ")) { + interval = duration(expression.substring(7)); + days = hours = minutes = months = weekdays = null; + return; + } + interval = null; + expression = + switch (expression) { + case "@annually", "@yearly" -> "0 0 1 1 *"; + case "@monthly" -> "0 0 1 * *"; + case "@weekly" -> "0 0 * * 0"; + case "@daily", "@midnight" -> "0 0 * * *"; + case "@hourly" -> "0 * * * *"; + default -> expression; + }; + String[] fields = expression.split("\\s+"); + if (fields.length != 5) throw new IllegalArgumentException("Cron requires five fields"); + minutes = field(fields[0], 0, 59, List.of()); + hours = field(fields[1], 0, 23, List.of()); + days = field(fields[2], 1, 31, List.of()); + months = + field( + fields[3], + 1, + 12, + List.of( + "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", + "dec")); + weekdays = field(fields[4], 0, 6, List.of("sun", "mon", "tue", "wed", "thu", "fri", "sat")); + } + + @Override + public Optional next(OffsetDateTime after) { + if (interval != null) return Optional.of(after.truncatedTo(ChronoUnit.SECONDS).plus(interval)); + var current = + after + .atZoneSameInstant(zone == null ? after.getOffset() : zone) + .truncatedTo(ChronoUnit.MINUTES) + .plusMinutes(1); + int limit = current.getYear() + 5; + while (current.getYear() <= limit) { + if (!months.values.get(current.getMonthValue())) { + current = current.plusMonths(1).withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS); + continue; + } + boolean day = days.values.get(current.getDayOfMonth()); + boolean weekday = weekdays.values.get(current.getDayOfWeek().getValue() % 7); + if (!(days.wildcard || weekdays.wildcard ? day && weekday : day || weekday)) { + current = current.plusDays(1).truncatedTo(ChronoUnit.DAYS); + continue; + } + if (!hours.values.get(current.getHour())) { + current = current.plusHours(1).truncatedTo(ChronoUnit.HOURS); + continue; + } + if (!minutes.values.get(current.getMinute())) { + current = current.plusMinutes(1); + continue; + } + return Optional.of(current.toOffsetDateTime().withOffsetSameInstant(after.getOffset())); + } + return Optional.empty(); + } + + private static Duration duration(String expression) { + // Go accepts a sign on the whole duration, not on individual components. + boolean negative = expression.startsWith("-"); + if (negative || expression.startsWith("+")) expression = expression.substring(1); + if (expression.equals("0")) return Duration.ofSeconds(1); + var pattern = Pattern.compile("((?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+))(ns|us|µs|μs|ms|s|m|h)"); + var matcher = pattern.matcher(expression); + var nanos = BigDecimal.ZERO; + int end = 0; + while (matcher.find()) { + if (matcher.start() != end) throw new IllegalArgumentException("Invalid cron duration"); + long scale = + switch (matcher.group(2)) { + case "ns" -> 1; + case "us", "µs", "μs" -> 1000; + case "ms" -> 1000000; + case "s" -> 1000000000; + case "m" -> 60000000000L; + case "h" -> 3600000000000L; + default -> throw new AssertionError(); + }; + nanos = + nanos.add( + new BigDecimal(matcher.group(1)) + .multiply(BigDecimal.valueOf(scale)) + .setScale(0, java.math.RoundingMode.DOWN)); + end = matcher.end(); + } + if (end == 0 || end != expression.length()) + throw new IllegalArgumentException("Invalid cron duration"); + try { + long value = (negative ? nanos.negate() : nanos).longValueExact(); + return Duration.ofSeconds(Math.max(1, value / 1000000000)); + } catch (ArithmeticException error) { + throw new IllegalArgumentException( + "Cron duration must fit in signed 64-bit nanoseconds", error); + } + } + + private static Field field(String value, int min, int max, List names) { + var bits = new BitSet(); + boolean wildcard = false; + for (String term : value.toLowerCase(Locale.ROOT).split(",", -1)) { + String[] stepped = term.split("/", -1); + if (stepped.length > 2) throw new IllegalArgumentException("Invalid cron step"); + long step = stepped.length == 2 ? Long.parseLong(stepped[1]) : 1; + if (step < 1) throw new IllegalArgumentException("Cron step must be positive"); + String[] range = stepped[0].split("-", -1); + if (range.length > 2) throw new IllegalArgumentException("Invalid cron range"); + int start; + int end; + if (stepped[0].equals("*") || stepped[0].equals("?")) { + start = min; + end = max; + wildcard |= step == 1; + } else { + start = number(range[0], names, min); + end = range.length == 2 ? number(range[1], names, min) : stepped.length == 2 ? max : start; + } + if (start < min || end > max || start > end) + throw new IllegalArgumentException("Cron value out of range"); + for (int v = start; v <= end; v += (int) Math.min(step, max + 1L)) bits.set(v); + } + return new Field(bits, wildcard); + } + + private static int number(String value, List names, int offset) { + int index = names.indexOf(value); + return index < 0 ? Integer.parseInt(value) : index + offset; + } + + private record Field(BitSet values, boolean wildcard) {} +} diff --git a/java/river/src/main/java/com/riverqueue/Database.java b/java/river/src/main/java/com/riverqueue/Database.java new file mode 100644 index 000000000..a7fc047bd --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Database.java @@ -0,0 +1,180 @@ +package com.riverqueue; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Properties; +import javax.sql.DataSource; + +/** JDBC integration; applications may supply their existing connection pool. */ +public final class Database { + private final Connector connector; + private final Dialect dialect; + private final String schema; + private volatile Capabilities capabilities; + + public Database(DataSource source, Dialect dialect) { + this(source::getConnection, dialect, ""); + } + + private Database(Connector connector, Dialect dialect, String schema) { + this.connector = connector; + this.dialect = dialect; + this.schema = schema; + if (!schema.isEmpty() && !schema.matches("[a-zA-Z_][a-zA-Z0-9_]{0,45}")) + throw new IllegalArgumentException("Invalid schema name"); + if (dialect == Dialect.SQLITE && !schema.isEmpty()) + throw new IllegalArgumentException("SQLite does not support custom schemas"); + } + + public static Database connect(String url) { + return connect(url, "river-java"); + } + + public static Database connect(String url, String applicationName) { + if (url.startsWith("sqlite:") || url.startsWith("jdbc:sqlite:")) { + String jdbc = url.startsWith("jdbc:") ? url : "jdbc:" + url; + return new Database(() -> DriverManager.getConnection(jdbc), Dialect.SQLITE, ""); + } + String jdbc; + var properties = new Properties(); + properties.setProperty("ApplicationName", applicationName); + properties.setProperty("user", System.getProperty("user.name")); + if (url.startsWith("jdbc:")) jdbc = url; + else { + URI uri = URI.create(url); + if (uri.getRawUserInfo() != null) { + // URI credentials use percent encoding; URLDecoder otherwise turns literal '+' into spaces. + String[] auth = uri.getRawUserInfo().replace("+", "%2B").split(":", 2); + properties.setProperty("user", URLDecoder.decode(auth[0], StandardCharsets.UTF_8)); + if (auth.length > 1) + properties.setProperty("password", URLDecoder.decode(auth[1], StandardCharsets.UTF_8)); + } + jdbc = + "jdbc:postgresql://" + + uri.getHost() + + (uri.getPort() < 0 ? "" : ":" + uri.getPort()) + + uri.getRawPath() + + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery()); + } + String target = jdbc; + return new Database( + () -> DriverManager.getConnection(target, properties), Dialect.POSTGRES, ""); + } + + public Connection connection() throws SQLException { + var connection = connector.open(); + try { + if (dialect == Dialect.SQLITE) { + try (var statement = connection.createStatement()) { + statement.execute("PRAGMA busy_timeout=5000"); + statement.execute("PRAGMA foreign_keys=ON"); + // Changing journal mode can return BUSY immediately despite busy_timeout when two + // processes open a fresh database. Retry initialization within the same five-second + // limit. + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); + while (true) { + try { + try (var rows = statement.executeQuery("PRAGMA journal_mode")) { + if (rows.next() && rows.getString(1).equalsIgnoreCase("wal")) break; + } + statement.execute("PRAGMA journal_mode=WAL"); + break; + } catch (SQLException error) { + if ((error.getErrorCode() != 5 && error.getErrorCode() != 6) + || System.nanoTime() >= deadline) throw error; + try { + Thread.sleep(25); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new SQLException("Interrupted configuring SQLite journal mode", interrupted); + } + } + } + } + } + return connection; + } catch (SQLException error) { + connection.close(); + throw error; + } + } + + public Dialect dialect() { + return dialect; + } + + public String schema() { + return schema; + } + + boolean supportsNotifications(Connection connection) throws SQLException { + return dialect == Dialect.SQLITE || capabilities(connection).notifications(); + } + + boolean usesUniqueNonce(Connection connection) throws SQLException { + return dialect == Dialect.SQLITE || capabilities(connection).uniqueNonce(); + } + + private Capabilities capabilities(Connection connection) throws SQLException { + var detected = capabilities; + if (detected == null) + try (var statement = Sql.prepare(connection, Sql.query(this, "database_version")); + var rows = statement.executeQuery()) { + rows.next(); + String version = rows.getString(1).toLowerCase(java.util.Locale.ROOT); + boolean yugabyte = version.contains("yugabyte") || version.contains("-yb"); + detected = new Capabilities(!yugabyte || rows.getBoolean(2), yugabyte); + capabilities = detected; + } + return detected; + } + + public Database withSchema(String name) { + return new Database(connector, dialect, name); + } + + String prefix() { + return schema.isEmpty() ? "" : '"' + schema + "\"."; + } + + Object timestamp(Instant value) { + if (value == null) return null; + if (dialect == Dialect.POSTGRES) + return OffsetDateTime.ofInstant(value.truncatedTo(ChronoUnit.MICROS), ZoneOffset.UTC); + value = value.plusNanos(500000).truncatedTo(ChronoUnit.MILLIS); + return DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSS") + .withZone(ZoneOffset.UTC) + .format(value); + } + + public static Instant instant(String value) { + if (value == null) return null; + String normalized = value.replace(' ', 'T'); + if (!normalized.endsWith("Z") + && !normalized.substring(10).contains("+") + && !normalized.substring(10).contains("-")) normalized += "Z"; + if (normalized.matches(".*[+-][0-9]{2}$")) normalized += ":00"; + return OffsetDateTime.parse(normalized).toInstant(); + } + + private record Capabilities(boolean notifications, boolean uniqueNonce) {} + + @FunctionalInterface + private interface Connector { + Connection open() throws SQLException; + } + + public enum Dialect { + POSTGRES, + SQLITE + } +} diff --git a/java/river/src/main/java/com/riverqueue/Extension.java b/java/river/src/main/java/com/riverqueue/Extension.java new file mode 100644 index 000000000..a1b54bef4 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Extension.java @@ -0,0 +1,27 @@ +package com.riverqueue; + +import java.sql.Connection; +import java.util.concurrent.Callable; + +/** Transaction-scoped insert hooks and attempt-scoped worker middleware. */ +public interface Extension { + default void beforeInsert( + Connection connection, JobType type, Object args, InsertOptions options) + throws Exception {} + + default void beforeWork(WorkContext context) throws Exception {} + + default void afterWork(WorkContext context, Throwable failure) throws Exception {} + + default void afterClaim(Job job) throws Exception {} + + default T insert(Connection connection, Callable next) throws Exception { + return next.call(); + } + + default void periodicStarted() {} + + default void work(WorkContext context, WorkContext.Step next) throws Exception { + next.run(); + } +} diff --git a/java/river/src/main/java/com/riverqueue/InsertOptions.java b/java/river/src/main/java/com/riverqueue/InsertOptions.java new file mode 100644 index 000000000..c27a72923 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/InsertOptions.java @@ -0,0 +1,107 @@ +package com.riverqueue; + +import java.time.Instant; +import java.util.List; +import tools.jackson.databind.JsonNode; + +/** Immutable per-insert overrides; unspecified fields inherit the job type's defaults. */ +public record InsertOptions( + Integer maxAttempts, + JsonNode metadata, + Boolean pending, + Integer priority, + String queue, + Instant scheduledAt, + List tags, + Unique unique) { + public InsertOptions { + metadata = metadata == null ? null : metadata.deepCopy(); + tags = tags == null ? null : List.copyOf(tags); + } + + @Override + public JsonNode metadata() { + return metadata == null ? null : metadata.deepCopy(); + } + + public static Builder builder() { + return new Builder(); + } + + public static InsertOptions defaults() { + return builder().build(); + } + + InsertOptions resolve(InsertOptions fallback) { + return new InsertOptions( + first(maxAttempts, fallback.maxAttempts, 25), + first(metadata, fallback.metadata, Json.object()), + first(pending, fallback.pending, false), + first(priority, fallback.priority, 1), + first(queue, fallback.queue, "default"), + first(scheduledAt, fallback.scheduledAt, null), + first(tags, fallback.tags, List.of()), + first(unique, fallback.unique, Unique.none())); + } + + private static T first(T value, T fallback, T defaultValue) { + return value != null ? value : fallback != null ? fallback : defaultValue; + } + + /** Fluent construction avoids long positional option lists. */ + public static final class Builder { + private Integer maxAttempts; + private JsonNode metadata; + private Boolean pending; + private Integer priority; + private String queue; + private Instant scheduledAt; + private List tags; + private Unique unique; + + public InsertOptions build() { + return new InsertOptions( + maxAttempts, metadata, pending, priority, queue, scheduledAt, tags, unique); + } + + public Builder maxAttempts(int value) { + maxAttempts = value; + return this; + } + + public Builder metadata(Object value) { + metadata = Json.tree(value); + return this; + } + + public Builder pending(boolean value) { + pending = value; + return this; + } + + public Builder priority(int value) { + priority = value; + return this; + } + + public Builder queue(String value) { + queue = value; + return this; + } + + public Builder scheduledAt(Instant value) { + scheduledAt = value; + return this; + } + + public Builder tags(String... value) { + tags = List.of(value); + return this; + } + + public Builder unique(Unique value) { + unique = value; + return this; + } + } +} diff --git a/java/river/src/main/java/com/riverqueue/Job.java b/java/river/src/main/java/com/riverqueue/Job.java new file mode 100644 index 000000000..597c33778 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Job.java @@ -0,0 +1,95 @@ +package com.riverqueue; + +import java.time.Instant; +import java.util.List; +import tools.jackson.databind.JsonNode; + +/** A persisted job and its decoded arguments. IDs retain all 64 bits. */ +public record Job( + long id, + A args, + int attempt, + Instant attemptedAt, + List attemptedBy, + Instant createdAt, + List errors, + Instant finalizedAt, + String kind, + int maxAttempts, + JsonNode metadata, + int priority, + String queue, + Instant scheduledAt, + State state, + List tags, + String uniqueKey, + List uniqueStates) { + public Job { + attemptedBy = List.copyOf(attemptedBy); + errors = List.copyOf(errors); + metadata = metadata.deepCopy(); + tags = List.copyOf(tags); + uniqueStates = uniqueStates == null ? null : List.copyOf(uniqueStates); + } + + /** Returns a snapshot with converted arguments and the same persisted job fields. */ + public Job mapArgs(java.util.function.Function mapper) { + return new Job<>( + id, + mapper.apply(args), + attempt, + attemptedAt, + attemptedBy, + createdAt, + errors, + finalizedAt, + kind, + maxAttempts, + metadata, + priority, + queue, + scheduledAt, + state, + tags, + uniqueKey, + uniqueStates); + } + + /** One failed attempt, in River's persisted error format. */ + public record AttemptError(Instant at, int attempt, String error, String trace) {} + + /** + * The persisted insertion result. Arguments remain JSON because uniqueness may return an existing + * job of a different kind or argument schema. + */ + public record InsertResult(Job job, boolean uniqueSkippedAsDuplicate) {} + + /** Names and bit positions are part of the cross-language database protocol. */ + public enum State { + AVAILABLE, + CANCELLED, + COMPLETED, + DISCARDED, + PENDING, + RETRYABLE, + RUNNING, + SCHEDULED; + + public int bit() { + return 1 << ordinal(); + } + + public boolean isFinalized() { + return this == CANCELLED || this == COMPLETED || this == DISCARDED; + } + + @com.fasterxml.jackson.annotation.JsonValue + public String value() { + return name().toLowerCase(java.util.Locale.ROOT); + } + + public static State of(String value) { + return valueOf(value.toUpperCase(java.util.Locale.ROOT)); + } + } +} diff --git a/java/river/src/main/java/com/riverqueue/JobQuery.java b/java/river/src/main/java/com/riverqueue/JobQuery.java new file mode 100644 index 000000000..63f284d6e --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/JobQuery.java @@ -0,0 +1,389 @@ +package com.riverqueue; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import tools.jackson.databind.JsonNode; + +/** Immutable job filters and portable pagination cursors; defaults to ascending job ID order. */ +public record JobQuery( + String after, + boolean descending, + List ids, + List kinds, + int limit, + JsonNode metadata, + Order order, + List priorities, + List queues, + List states, + List tagsAll, + List tagsAny) { + public JobQuery { + ids = List.copyOf(ids); + kinds = List.copyOf(kinds); + priorities = List.copyOf(priorities); + queues = List.copyOf(queues); + states = List.copyOf(states); + tagsAll = List.copyOf(tagsAll); + tagsAny = List.copyOf(tagsAny); + metadata = metadata.deepCopy(); + if (limit < 1 || limit > 10000) + throw new IllegalArgumentException("List limit must be between 1 and 10000"); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public JsonNode metadata() { + return metadata.deepCopy(); + } + + /** Fluent construction for combined filters without a positional argument list. */ + public static final class Builder { + private String after; + private boolean descending; + private List ids = List.of(); + private List kinds = List.of(); + private int limit = 100; + private JsonNode metadata = Json.object(); + private Order order = Order.ID; + private List priorities = List.of(); + private List queues = List.of(); + private List states = List.of(); + private List tagsAll = List.of(); + private List tagsAny = List.of(); + + private Builder() {} + + public Builder after(String value) { + after = value; + return this; + } + + public JobQuery build() { + return new JobQuery( + after, + descending, + ids, + kinds, + limit, + metadata, + order, + priorities, + queues, + states, + tagsAll, + tagsAny); + } + + public Builder descending(boolean value) { + descending = value; + return this; + } + + public Builder ids(long... values) { + ids = java.util.Arrays.stream(values).boxed().toList(); + return this; + } + + public Builder kinds(String... values) { + kinds = List.of(values); + return this; + } + + public Builder limit(int value) { + limit = value; + return this; + } + + public Builder metadata(Object value) { + metadata = Json.tree(value); + return this; + } + + public Builder order(Order value) { + order = java.util.Objects.requireNonNull(value); + return this; + } + + public Builder priorities(Integer... values) { + priorities = List.of(values); + return this; + } + + public Builder queues(String... values) { + queues = List.of(values); + return this; + } + + public Builder states(Job.State... values) { + states = List.of(values); + return this; + } + + public Builder tagsAll(String... values) { + tagsAll = List.of(values); + return this; + } + + public Builder tagsAny(String... values) { + tagsAny = List.of(values); + return this; + } + } + + public static JobQuery all() { + return new JobQuery( + null, + false, + List.of(), + List.of(), + 100, + Json.object(), + Order.ID, + List.of(), + List.of(), + List.of(), + List.of(), + List.of()); + } + + public JobQuery after(String value) { + return new JobQuery( + value, + descending, + ids, + kinds, + limit, + metadata, + order, + priorities, + queues, + states, + tagsAll, + tagsAny); + } + + public JobQuery limit(int value) { + return new JobQuery( + after, + descending, + ids, + kinds, + value, + metadata, + order, + priorities, + queues, + states, + tagsAll, + tagsAny); + } + + public JobQuery states(Job.State... value) { + return new JobQuery( + after, + descending, + ids, + kinds, + limit, + metadata, + order, + priorities, + queues, + List.of(value), + tagsAll, + tagsAny); + } + + boolean hasFilters() { + return !ids.isEmpty() + || !kinds.isEmpty() + || hasMetadataFilter() + || !priorities.isEmpty() + || !queues.isEmpty() + || !states.isEmpty() + || !tagsAll.isEmpty() + || !tagsAny.isEmpty(); + } + + private boolean hasMetadataFilter() { + return !metadata.isObject() || !metadata.isEmpty(); + } + + private static String sqliteMetadata( + Database database, + List parameters, + JsonNode wanted, + String type, + String value, + int depth) { + // Array containment is unordered, but each wanted object must match one complete element. + // Correlated predicates retain that boundary instead of comparing independent JSON paths. + String query; + if (wanted.isContainer()) { + var children = new ArrayList(); + String alias = "metadata_" + depth; + if (wanted.isObject()) { + for (var entry : wanted.properties()) { + parameters.add(entry.getKey()); + children.add( + Sql.query(database, "filter_metadata_member") + .replace( + "{predicate}", + sqliteMetadata( + database, + parameters, + entry.getValue(), + alias + ".type", + alias + ".value", + depth + 1))); + } + } else { + for (var element : wanted) { + children.add( + Sql.query(database, "filter_metadata_element") + .replace( + "{predicate}", + sqliteMetadata( + database, + parameters, + element, + alias + ".type", + alias + ".value", + depth + 1))); + } + } + query = + Sql.query(database, "filter_metadata_container") + .replace("{children}", children.isEmpty() ? "true" : String.join(" AND ", children)) + .replace("{kind}", wanted.isObject() ? "object" : "array") + .replace("{alias}", alias); + } else { + parameters.add(Json.encode(wanted)); + query = Sql.query(database, "filter_metadata_scalar"); + } + return query.replace("{type}", type).replace("{value}", value); + } + + String timeField() { + return switch (order) { + case ID -> ""; + case FINALIZED_AT -> "finalized_at"; + case SCHEDULED_AT -> "scheduled_at"; + case TIME -> + states.isEmpty() + ? "scheduled_at" + : switch (states.getFirst()) { + case AVAILABLE, PENDING, RETRYABLE, SCHEDULED -> "scheduled_at"; + case RUNNING -> "attempted_at"; + case CANCELLED, COMPLETED, DISCARDED -> "finalized_at"; + }; + }; + } + + String cursor(Job job) { + Instant time = + switch (timeField()) { + case "created_at" -> job.createdAt(); + case "scheduled_at" -> job.scheduledAt(); + case "attempted_at" -> job.attemptedAt(); + case "finalized_at" -> job.finalizedAt(); + default -> null; + }; + var cursor = + Json.object() + .put("id", job.id()) + .put("kind", job.kind()) + .put("queue", job.queue()) + .put("sort_field", order.name().toLowerCase(java.util.Locale.ROOT)); + cursor.set("time", Json.tree(time == null ? Instant.parse("0001-01-01T00:00:00Z") : time)); + return Base64.getUrlEncoder() + .encodeToString(Json.encode(cursor).getBytes(StandardCharsets.UTF_8)); + } + + String sql(Database database, List parameters) { + var filters = new ArrayList(); + filter(database, filters, parameters, "ids", ids); + filter(database, filters, parameters, "kinds", kinds); + filter(database, filters, parameters, "priorities", priorities); + filter(database, filters, parameters, "queues", queues); + filter(database, filters, parameters, "states", states.stream().map(Job.State::value).toList()); + filter(database, filters, parameters, "tags_all", tagsAll); + filter(database, filters, parameters, "tags_any", tagsAny); + if (hasMetadataFilter()) { + if (database.dialect() == Database.Dialect.SQLITE) + filters.add( + sqliteMetadata(database, parameters, metadata, "json_type(metadata)", "metadata", 0)); + else { + filters.add(Sql.query(database, "filter_metadata")); + parameters.add(Json.encode(metadata)); + } + } + String field = timeField(); + if (after != null) { + JsonNode cursor; + try { + cursor = + Json.parse(new String(Base64.getUrlDecoder().decode(after), StandardCharsets.UTF_8)); + } catch (RuntimeException e) { + throw new IllegalArgumentException("Invalid job cursor", e); + } + long id = cursor.path("id").asLong(); + Instant time = Instant.parse(cursor.path("time").asString()); + boolean zero = time.equals(Instant.parse("0001-01-01T00:00:00Z")); + boolean nullable = field.equals("finalized_at") || field.equals("attempted_at"); + String query; + if (field.isEmpty() || (zero && !nullable)) { + query = Sql.query(database, "cursor_id"); + parameters.add(id); + } else if (zero) { + query = Sql.query(database, descending ? "cursor_null_desc" : "cursor_null_asc"); + parameters.add(id); + } else { + query = + Sql.query(database, nullable && !descending ? "cursor_time_nullable" : "cursor_time"); + parameters.add(database.timestamp(time)); + parameters.add(database.timestamp(time)); + parameters.add(id); + } + filters.add(query.replace("{field}", field).replace("{comparison}", descending ? "<" : ">")); + } + String sort = descending ? "DESC" : "ASC"; + String orderSQL = + (field.isEmpty() + ? "" + : field + " " + sort + (descending ? " NULLS FIRST, " : " NULLS LAST, ")) + + "id " + + sort; + parameters.add(limit); + return Sql.query(database, "list") + .replace("{where}", filters.isEmpty() ? "true" : String.join(" AND ", filters)) + .replace("{order}", orderSQL); + } + + private static void filter( + Database database, List clauses, List params, String name, List values) { + if (values.isEmpty()) return; + clauses.add(Sql.query(database, "filter_" + name)); + params.add(Json.encode(values)); + } + + public enum Order { + FINALIZED_AT, + ID, + SCHEDULED_AT, + TIME + } + + public record Page(String cursor, List> jobs) { + public Page { + jobs = List.copyOf(jobs); + } + } +} diff --git a/java/river/src/main/java/com/riverqueue/JobType.java b/java/river/src/main/java/com/riverqueue/JobType.java new file mode 100644 index 000000000..22d3b17b2 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/JobType.java @@ -0,0 +1,47 @@ +package com.riverqueue; + +import java.util.List; +import java.util.Objects; + +/** An explicit stable wire kind and Java argument type, usually a record. */ +public record JobType( + String kind, Class argsType, InsertOptions defaults, List> uniqueFields) { + public JobType { + Objects.requireNonNull(kind); + Objects.requireNonNull(argsType); + Objects.requireNonNull(defaults); + Objects.requireNonNull(uniqueFields); + uniqueFields = uniqueFields.stream().map(List::copyOf).toList(); + // Match the kind_length constraint enforced by both shared Go database schemas. + if (kind.length() > 127 || !Protocol.USER_SPECIFIED_ID_OR_KIND.matcher(kind).matches()) + throw new IllegalArgumentException("Invalid job kind: " + kind); + } + + public static JobType of(String kind, Class argsType) { + return new JobType<>(kind, argsType, InsertOptions.defaults(), List.of()); + } + + /** Describes one job for a mixed-kind bulk insertion. */ + public Client.Submission submission(A args) { + return submission(args, InsertOptions.defaults()); + } + + /** Describes one job and its insertion overrides for a bulk insertion. */ + public Client.Submission submission(A args, InsertOptions options) { + return new Client.Submission<>(this, args, options); + } + + public JobType withDefaults(InsertOptions options) { + return new JobType<>(kind, argsType, options, uniqueFields); + } + + /** Selects nested JSON paths for argument uniqueness, with each path expressed as components. */ + public JobType uniqueBy(List> fields) { + return new JobType<>(kind, argsType, defaults, fields); + } + + /** Selects top-level JSON fields for argument uniqueness; dots are literal field characters. */ + public JobType uniqueBy(String... fields) { + return uniqueBy(java.util.Arrays.stream(fields).map(List::of).toList()); + } +} diff --git a/java/river/src/main/java/com/riverqueue/Json.java b/java/river/src/main/java/com/riverqueue/Json.java new file mode 100644 index 000000000..c2324fb72 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Json.java @@ -0,0 +1,116 @@ +package com.riverqueue; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeFormatterBuilder; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.node.ObjectNode; + +/** Shared JSON encoding. Raw argument values are preserved when calculating unique keys. */ +public final class Json { + public static final JsonMapper MAPPER = + JsonMapper.builder() + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .enable(tools.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .addModule( + new SimpleModule() + .addSerializer( + Instant.class, + new ValueSerializer() { + @Override + public void serialize( + Instant value, JsonGenerator generator, SerializationContext context) { + generator.writeString( + new DateTimeFormatterBuilder() + .appendInstant(-1) + .toFormatter() + .format(value)); + } + })) + .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + public static final Comparator UTF8_ORDER = + (a, b) -> + java.util.Arrays.compareUnsigned( + a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + + private Json() {} + + public static T decode(JsonNode value, Class type) { + return MAPPER.treeToValue(value, type); + } + + public static String encode(Object value) { + return goEscapes(MAPPER.writeValueAsString(value)); + } + + public static ObjectNode object() { + return MAPPER.createObjectNode(); + } + + public static JsonNode parse(String value) { + return MAPPER.readTree(value); + } + + public static JsonNode tree(Object value) { + return MAPPER.valueToTree(value); + } + + public static String goEscapes(String value) { + return value + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029"); + } + + public static Map members(String source) { + var result = new LinkedHashMap(); + try (var parser = MAPPER.createParser(source)) { + if (parser.nextToken() != JsonToken.START_OBJECT) + throw new IllegalArgumentException("Unique args must encode a JSON object"); + while (parser.nextToken() != JsonToken.END_OBJECT) { + String key = parser.currentName(); + parser.nextToken(); + int start = (int) parser.currentTokenLocation().getCharOffset(); + parser.skipChildren(); + if (parser.currentToken() == JsonToken.VALUE_STRING) parser.getString(); + int end = (int) parser.currentLocation().getCharOffset(); + result.putIfAbsent(key, source.substring(start, end)); + } + } + return result; + } + + public static String compact(String source) { + var result = new StringBuilder(); + boolean quoted = false; + boolean escaped = false; + for (int i = 0; i < source.length(); i++) { + char c = source.charAt(i); + if (quoted || !Character.isWhitespace(c)) result.append(c); + if (escaped) escaped = false; + else if (quoted && c == '\\') escaped = true; + else if (c == '"') quoted = !quoted; + } + return result.toString(); + } + + static String sjsonKey(String value) { + return value.chars().allMatch(c -> c >= 32 && c <= 127 && c != '"' && c != '\\') + ? '"' + value + '"' + : encode(value); + } +} diff --git a/java/river/src/main/java/com/riverqueue/Migrator.java b/java/river/src/main/java/com/riverqueue/Migrator.java new file mode 100644 index 000000000..8e9a8cb4e --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Migrator.java @@ -0,0 +1,299 @@ +package com.riverqueue; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Applies the exact, versioned River migration line shared with Go. */ +public final class Migrator { + private static final List NAMES = Sql.resource("migration/index.txt").lines().toList(); + public static final int LATEST = NAMES.size(); + private final Database database; + private final String line; + private final List migrations; + + public Migrator(Database database) { + this(database, "main", mainMigrations(database.dialect())); + } + + /** Internal companion migration seam used by the matched River Pro release. */ + public Migrator(Database database, String line, List migrations) { + this.database = Objects.requireNonNull(database); + if (line == null || !line.matches("[a-z][a-z0-9_]*")) + throw new IllegalArgumentException("Invalid migration line"); + this.line = line; + this.migrations = List.copyOf(migrations); + if (migrations.isEmpty()) throw new IllegalArgumentException("Migration line is empty"); + for (int i = 0; i < migrations.size(); i++) { + if (migrations.get(i).version() != i + 1) + throw new IllegalArgumentException("Migration versions must be consecutive starting at 1"); + } + } + + private void apply(Connection connection, Migration migration, boolean down) throws SQLException { + int version = migration.version(); + boolean main = line.equals("main"); + if (down && (!main || version > 1)) { + String query = main && version < 5 ? "migration_delete_legacy" : "migration_line_delete"; + Object[] params = main && version < 5 ? new Object[] {version} : new Object[] {line, version}; + try (var statement = Sql.prepare(connection, Sql.query(database, query), params)) { + statement.executeUpdate(); + } + } + execute(connection, sql(version, down ? Direction.DOWN : Direction.UP)); + if (!down) { + String query = main && version < 5 ? "migration_insert_legacy" : "migration_line_insert"; + Object[] params = main && version < 5 ? new Object[] {version} : new Object[] {line, version}; + try (var statement = Sql.prepare(connection, Sql.query(database, query), params)) { + statement.executeUpdate(); + } + } + } + + private boolean booleanQuery(Connection connection, String name) throws SQLException { + try (var statement = Sql.prepare(connection, Sql.query(database, name)); + var rows = statement.executeQuery()) { + rows.next(); + return rows.getBoolean(1); + } + } + + private void execute(Connection connection, String sql) throws SQLException { + try (var statement = connection.createStatement()) { + // Xerial's executeUpdate executes the entire script, including trigger bodies. + if (database.dialect() == Database.Dialect.SQLITE) statement.executeUpdate(sql); + else statement.execute(sql); + } + } + + private List existing(Connection connection) throws SQLException { + if (!booleanQuery(connection, "migration_exists")) return List.of(); + boolean hasLine = booleanQuery(connection, "migration_has_line"); + if (!hasLine && !line.equals("main")) return List.of(); + var versions = new ArrayList(); + try (var statement = + Sql.prepare( + connection, + Sql.query( + database, hasLine ? "migration_line_versions" : "migration_versions_legacy"), + hasLine ? new Object[] {line} : new Object[0]); + var rows = statement.executeQuery()) { + while (rows.next()) versions.add(rows.getInt(1)); + } + return List.copyOf(versions); + } + + /** Returns the latest version bundled for this migration line. */ + public int latest() { + return migrations.size(); + } + + /** Lists available and applied migrations without creating a schema or migration table. */ + public List list() { + try (var connection = database.connection()) { + var existing = existing(connection); + var result = new ArrayList(); + for (var migration : migrations) + result.add( + new Status( + migration.version(), migration.name(), existing.contains(migration.version()))); + for (int version : existing) + if (version > latest()) result.add(new Status(version, "(unknown version)", true)); + return List.copyOf(result); + } catch (SQLException error) { + throw Client.databaseError("List " + line + " migrations", error); + } + } + + private static List mainMigrations(Database.Dialect dialect) { + String directory = + "migration/" + (dialect == Database.Dialect.POSTGRES ? "postgres/" : "sqlite/"); + var migrations = new ArrayList(); + for (int i = 0; i < NAMES.size(); i++) { + String name = NAMES.get(i); + migrations.add( + new Migration( + i + 1, + name.substring(4), + Sql.resource(directory + name + ".up.sql"), + Sql.resource(directory + name + ".down.sql"))); + } + return List.copyOf(migrations); + } + + /** Applies all pending migrations for this line, committing each migration separately. */ + public Result migrate() { + return migrate(Direction.UP); + } + + /** Migrates up to the latest version, or down by one version. */ + public Result migrate(Direction direction) { + return migrate(direction, Options.defaults()); + } + + /** + * Applies or previews migrations. Target version zero removes all migrations when moving down. + */ + public Result migrate(Direction direction, Options options) { + Objects.requireNonNull(direction, "direction"); + Objects.requireNonNull(options, "options"); + boolean down = direction == Direction.DOWN; + boolean dryRun = options.dryRun(); + int target = options.targetVersion() == null ? (down ? 0 : latest()) : options.targetVersion(); + int maxSteps = + options.maxSteps() == null + ? (down && options.targetVersion() == null ? 1 : Integer.MAX_VALUE) + : options.maxSteps(); + if (target > latest()) + throw new IllegalArgumentException("Unknown migration version: " + target); + try (var connection = database.connection()) { + if (dryRun) { + var existing = existing(connection); + return new Result(plan(existing, down, target, maxSteps), existing, valid(existing)); + } + Runnable restoreTransactionMode = () -> {}; + if (database.dialect() == Database.Dialect.SQLITE) { + // Reserve the writer before reading migration history to serialize concurrent migrators. + var config = connection.unwrap(org.sqlite.SQLiteConnection.class).getConnectionConfig(); + var previousMode = config.getTransactionMode(); + restoreTransactionMode = () -> config.setTransactionMode(previousMode); + config.setTransactionMode(org.sqlite.SQLiteConfig.TransactionMode.IMMEDIATE); + } + boolean locked = false; + try { + connection.setAutoCommit(false); + if (database.dialect() == Database.Dialect.POSTGRES) { + execute(connection, Sql.query(database, "migration_lock")); + locked = true; + } + var existing = existing(connection); + var planned = plan(existing, down, target, maxSteps); + if (!planned.isEmpty()) { + if (!line.equals("main")) { + var main = new Migrator(database).existing(connection); + if (!main.contains(LATEST)) + throw new IllegalArgumentException( + "Migrate the main line to version " + LATEST + " first"); + } else if (down + && booleanQuery(connection, "migration_has_line") + && booleanQuery(connection, "migration_other_lines")) { + throw new IllegalArgumentException("Migrate other lines down before the main line"); + } + if (!database.schema().isEmpty() && !down) + execute(connection, Sql.query(database, "create_schema")); + } + var applied = new ArrayList(); + for (int version : planned) { + // A commit releases SQLite's writer lock. Another migrator may finish a planned + // version before this connection starts its next immediate transaction. + if (database.dialect() == Database.Dialect.SQLITE) { + boolean present = existing(connection).contains(version); + if (down ? !present : present) continue; + } + apply(connection, migrations.get(version - 1), down); + connection.commit(); + applied.add(version); + } + var actual = existing(connection); + connection.commit(); + return new Result(applied, actual, valid(actual)); + } catch (Exception error) { + connection.rollback(); + throw error; + } finally { + restoreTransactionMode.run(); + if (locked) execute(connection, Sql.query(database, "migration_unlock")); + } + } catch (SQLException error) { + throw Client.databaseError("Migrate " + line, error); + } + } + + private List plan(List existing, boolean down, int target, int maxSteps) { + if (existing.stream().anyMatch(version -> version > latest())) + throw new IllegalArgumentException( + "Database contains newer " + line + " migrations; upgrade the CLI"); + var versions = new ArrayList(); + for (int version = 1; version <= latest(); version++) { + if (down + ? existing.contains(version) && version > target + : !existing.contains(version) && version <= target) versions.add(version); + } + if (down) Collections.reverse(versions); + return List.copyOf(versions.subList(0, Math.min(maxSteps, versions.size()))); + } + + /** + * Returns canonical migration SQL with the selected schema substituted; no connection is opened. + */ + public String sql(int version, Direction direction) { + Objects.requireNonNull(direction, "direction"); + if (version < 1 || version > latest()) + throw new IllegalArgumentException("Unknown migration version: " + version); + var migration = migrations.get(version - 1); + return (direction == Direction.DOWN ? migration.downSql() : migration.upSql()) + .replace("/* TEMPLATE: schema */", database.prefix()); + } + + private boolean valid(List existing) { + return existing.equals(migrations.stream().map(Migration::version).toList()); + } + + /** Direction in which to apply the migration line. */ + public enum Direction { + DOWN, + UP + } + + /** Optional target and step limit; omitted values use the direction's defaults. */ + public record Options(Integer targetVersion, Integer maxSteps, boolean dryRun) { + public Options { + if (targetVersion != null && targetVersion < 0) + throw new IllegalArgumentException("Target version must be nonnegative"); + if (maxSteps != null && maxSteps < 1) + throw new IllegalArgumentException("Maximum steps must be positive"); + } + + public static Options defaults() { + return new Options(null, null, false); + } + + public Options dryRun(boolean value) { + return new Options(targetVersion, maxSteps, value); + } + + public Options maxSteps(int value) { + return new Options(targetVersion, value, dryRun); + } + + public Options targetVersion(int value) { + return new Options(value, maxSteps, dryRun); + } + } + + /** One canonical migration and its forward and reverse SQL scripts. */ + public record Migration(int version, String name, String upSql, String downSql) { + public Migration { + Objects.requireNonNull(name); + Objects.requireNonNull(upSql); + Objects.requireNonNull(downSql); + } + } + + /** + * Versions applied (or planned in a dry run), persisted versions, and whether the line is + * current. + */ + public record Result(List applied, List existing, boolean valid) { + public Result { + applied = List.copyOf(applied); + existing = List.copyOf(existing); + } + } + + /** A migration's identity and whether it is recorded in the database. */ + public record Status(int version, String name, boolean applied) {} +} diff --git a/java/river/src/main/java/com/riverqueue/Plugin.java b/java/river/src/main/java/com/riverqueue/Plugin.java new file mode 100644 index 000000000..c8662bf9d --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Plugin.java @@ -0,0 +1,111 @@ +package com.riverqueue; + +import java.sql.Connection; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import tools.jackson.databind.JsonNode; + +/** + * Internal transactional storage seam for River Pro. This interface is released in lockstep with + * River Pro and is not a stable application extension API; applications should use {@link + * Extension}. + */ +public interface Plugin { + default void afterInsert(Connection connection, Client.Driver driver, Job job) + throws Exception {} + + default void afterDelete(Connection connection, Client.Driver driver, Job job) + throws Exception {} + + default void afterStateChange(Connection connection, Client.Driver driver, Job job) + throws Exception {} + + default void afterAttempt(Connection connection, Client.Driver driver, Job original) + throws Exception {} + + default void afterPeerClaim( + Connection connection, Client.Driver driver, List jobs) throws Exception {} + + default List claim( + Connection connection, + Client.Driver driver, + Claim claim, + Client.Transaction> next) + throws Exception { + return next.run(connection); + } + + default boolean clean(Client river, Instant now) { + return false; + } + + default boolean clean( + Client river, + Instant now, + java.time.Duration cancelled, + java.time.Duration completed, + java.time.Duration discarded) { + return clean(river, now); + } + + default JsonNode decode(Job job) { + return job.args(); + } + + default void maintain(Client river, Instant now) {} + + default Insert prepare(Connection connection, Client.Driver driver, Insert insert) + throws Exception { + return insert; + } + + default void producer(Connection connection, Client.Driver driver, Producer producer) + throws Exception {} + + default void leadershipStarted() {} + + default int workerLimit(String queue, int configured) { + return configured; + } + + default void starting(boolean leadership) {} + + default void starting( + boolean leadership, + java.time.Duration cancelled, + java.time.Duration completed, + java.time.Duration discarded) { + starting(leadership); + } + + default boolean rescue(Client river, Rescue rescue) { + return false; + } + + record Rescue( + Instant now, + Instant horizon, + Set activeIds, + Set kinds, + boolean timeoutDisabled, + RetryPolicy retryPolicy) {} + + record Claim( + String clientId, + String queue, + int limit, + Instant now, + Set kinds, + boolean fetchOnlyKnownKinds) {} + + record Insert(JobType type, String args, InsertOptions options) {} + + record Producer( + String clientId, + String queue, + int maxWorkers, + List> active, + boolean paused, + Instant now) {} +} diff --git a/java/river/src/main/java/com/riverqueue/Protocol.java b/java/river/src/main/java/com/riverqueue/Protocol.java new file mode 100644 index 000000000..1e0a1da43 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Protocol.java @@ -0,0 +1,109 @@ +package com.riverqueue; + +import java.util.Map; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +/** Stored and transmitted values shared with the other River implementations. */ +final class Protocol { + static final String METADATA_OUTPUT = "output"; + static final String METADATA_PERIODIC_JOB_ID = "river:periodic_job_id"; + static final String METADATA_RESCUE_COUNT = "river:rescue_count"; + static final String METADATA_RESUMABLE_CURSOR = "river:resumable_cursor"; + static final String METADATA_RESUMABLE_STEP = "river:resumable_step"; + static final String METADATA_UNIQUE_NONCE = "river:unique_nonce"; + + static final String TOPIC_CONTROL = "river_control"; + static final String TOPIC_INSERT = "river_insert"; + static final String TOPIC_LEADERSHIP = "river_leadership"; + + static final Pattern USER_SPECIFIED_ID_OR_KIND = + Pattern.compile("[a-zA-Z0-9_][a-zA-Z0-9_\\-\\[\\]<>/.·:+]+"); + + private Protocol() {} + + static Notification cancel(long id, String queue) { + return new Notification( + TOPIC_CONTROL, Json.encode(Map.of("action", "cancel", "job_id", id, "queue", queue))); + } + + static Notification insert(String queue) { + return new Notification(TOPIC_INSERT, Json.encode(Map.of("queue", queue))); + } + + static Notification queueMetadataChanged(String queue, Object metadata) { + return new Notification( + TOPIC_CONTROL, + Json.encode(Map.of("action", "metadata_changed", "metadata", metadata, "queue", queue))); + } + + static Notification queuePause(String queue, boolean pause) { + return new Notification( + TOPIC_CONTROL, Json.encode(Map.of("action", pause ? "pause" : "resume", "queue", queue))); + } + + static Notification requestResign() { + return new Notification( + TOPIC_LEADERSHIP, Json.encode(Map.of("action", "request_resign", "leader_id", ""))); + } + + static Notification resigned(String id) { + return new Notification( + TOPIC_LEADERSHIP, Json.encode(Map.of("action", "resigned", "leader_id", id))); + } + + record Notification(String topic, String payload) {} + + /** Shared by both database listeners; dispatching itself requires no database I/O. */ + record Dispatcher( + String clientId, + Map> active, + Map pendingCancellation, + Consumer queues, + Consumer leadership) { + void dispatch(String topic, String payload) { + if (!topic.equals(TOPIC_CONTROL) + && !topic.equals(TOPIC_INSERT) + && !topic.equals(TOPIC_LEADERSHIP)) return; + var value = Json.parse(payload); + String action = value.path("action").asString(""); + switch (topic) { + case TOPIC_CONTROL -> { + if (action.equals("cancel") && value.path("job_id").isIntegralNumber()) { + long jobId = value.path("job_id").asLong(); + // Publish pending cancellation first so an attempt registered concurrently sees it. + pendingCancellation.put(jobId, System.nanoTime()); + var context = active.get(jobId); + if (context != null) { + context.requestCancellation(WorkContext.Cancellation.REMOTE); + pendingCancellation.remove(jobId); + } + } else if ((action.equals("metadata_changed") + || action.equals("pause") + || action.equals("resume")) + && value.path("queue").isString()) { + queues.accept(new QueueNotice(value.path("queue").asString(), action)); + } + } + case TOPIC_INSERT -> { + if (value.path("queue").isString()) + queues.accept(new QueueNotice(value.path("queue").asString(), "insert")); + } + case TOPIC_LEADERSHIP -> { + if (action.equals("request_resign")) leadership.accept(LeadershipNotice.REQUEST_RESIGN); + else if (action.equals("resigned") + && !clientId.equals(value.path("leader_id").asString())) + leadership.accept(LeadershipNotice.CHANGED); + } + default -> {} + } + } + } + + enum LeadershipNotice { + CHANGED, + REQUEST_RESIGN + } + + record QueueNotice(String queue, String action) {} +} diff --git a/java/river/src/main/java/com/riverqueue/Queues.java b/java/river/src/main/java/com/riverqueue/Queues.java new file mode 100644 index 000000000..d8fbc4dc7 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Queues.java @@ -0,0 +1,167 @@ +package com.riverqueue; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import tools.jackson.databind.JsonNode; + +/** Persistent queue controls, including transactional pause and resume. */ +public final class Queues { + private final Client river; + + Queues(Client river) { + this.river = river; + } + + public Queue get(String name) { + return river.transaction(c -> get(c, name)); + } + + public Queue get(Connection connection, String name) { + return river.transaction( + connection, + transaction -> { + try (var statement = + Sql.prepare(transaction, Sql.query(river.database(), "queue_get"), name); + var rows = statement.executeQuery()) { + if (!rows.next()) + throw new RiverException(RiverException.Code.NOT_FOUND, "Queue not found: " + name); + return read(rows); + } catch (SQLException e) { + throw Client.databaseError("Get queue", e); + } + }); + } + + /** Lists up to 100 queues. */ + public List list() { + return list(100); + } + + /** Lists up to 100 queues using the caller's connection. */ + public List list(Connection connection) { + return list(connection, 100); + } + + public List list(int limit) { + return river.transaction(c -> list(c, limit)); + } + + public List list(Connection connection, int limit) { + return river.transaction( + connection, + transaction -> { + if (limit < 1 || limit > 10000) throw new IllegalArgumentException("Invalid queue limit"); + try (var statement = + Sql.prepare(transaction, Sql.query(river.database(), "queue_list"), limit); + var rows = statement.executeQuery()) { + var result = new ArrayList(); + while (rows.next()) result.add(read(rows)); + return List.copyOf(result); + } catch (SQLException e) { + throw Client.databaseError("List queues", e); + } + }); + } + + public void pause(String name) { + river.transaction( + c -> { + pause(c, name); + return null; + }); + } + + public void pause(Connection connection, String name) { + river.transaction( + connection, + c -> { + control(c, name, true); + return null; + }); + } + + public void resume(String name) { + river.transaction( + c -> { + resume(c, name); + return null; + }); + } + + public void resume(Connection connection, String name) { + river.transaction( + connection, + c -> { + control(c, name, false); + return null; + }); + } + + public Queue update(String name, Object metadata) { + return river.transaction(c -> update(c, name, metadata)); + } + + public Queue update(Connection connection, String name, Object metadata) { + return river.transaction(connection, c -> updateWithinTransaction(c, name, metadata)); + } + + private Queue updateWithinTransaction(Connection connection, String name, Object metadata) { + if (metadata == null) return get(connection, name); + var value = Json.tree(metadata); + if (!value.isObject()) throw new IllegalArgumentException("Queue metadata must be an object"); + try (var statement = + Sql.prepare( + connection, + Sql.query(river.database(), "queue_update"), + Json.encode(metadata), + river.database().timestamp(Instant.now()), + name); + var rows = statement.executeQuery()) { + if (!rows.next()) + throw new RiverException(RiverException.Code.NOT_FOUND, "Queue not found: " + name); + var queue = read(rows); + river.notify(connection, Protocol.queueMetadataChanged(name, metadata)); + return queue; + } catch (SQLException e) { + throw Client.databaseError("Update queue", e); + } + } + + private void control(Connection connection, String name, boolean pause) { + var now = river.database().timestamp(Instant.now()); + try (var statement = + Sql.prepare( + connection, + Sql.query(river.database(), pause ? "queue_pause" : "queue_resume"), + now, + name, + name)) { + if (statement.executeUpdate() == 0 && !name.equals("*")) + throw new RiverException(RiverException.Code.NOT_FOUND, "Queue not found: " + name); + river.notify(connection, Protocol.queuePause(name, pause)); + } catch (SQLException e) { + throw Client.databaseError("Control queue", e); + } + } + + private static Queue read(ResultSet rows) throws SQLException { + return new Queue( + rows.getString("name"), + Database.instant(rows.getString("created_at")), + Json.parse(rows.getString("metadata")), + Database.instant(rows.getString("paused_at")), + Database.instant(rows.getString("updated_at"))); + } + + /** A snapshot of a persistent queue. */ + public record Queue( + String name, Instant createdAt, JsonNode metadata, Instant pausedAt, Instant updatedAt) { + public Queue { + metadata = metadata.deepCopy(); + } + } +} diff --git a/java/river/src/main/java/com/riverqueue/RetryPolicy.java b/java/river/src/main/java/com/riverqueue/RetryPolicy.java new file mode 100644 index 000000000..7912515f6 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/RetryPolicy.java @@ -0,0 +1,45 @@ +package com.riverqueue; + +import java.time.Duration; +import java.util.Objects; +import java.util.random.RandomGenerator; + +/** + * Chooses a nonnegative retry delay from the failed attempt's snapshot, before its new error is + * appended. Called only when another attempt is possible. A thrown failure, null, negative delay, + * or unrepresentable retry time is reported and replaced with the default policy's delay. + */ +@FunctionalInterface +public interface RetryPolicy { + Duration delay(Job job); + + static Default defaults() { + return defaults(RandomGenerator.getDefault()); + } + + static Default defaults(RandomGenerator random) { + return new Default(random); + } + + /** River's quartic backoff and jitter, excluding snoozes from the failure count. */ + final class Default implements RetryPolicy { + private final RandomGenerator random; + + private Default(RandomGenerator random) { + this.random = Objects.requireNonNull(random, "random"); + } + + @Override + public Duration delay(Job job) { + return delay(job.errors().size() + 1); + } + + /** Computes the same backoff from an explicit failure count, including the current failure. */ + public synchronized Duration delay(int errorCount) { + double seconds = Math.pow(Math.max(1, errorCount), 4); + if (seconds * 1e9 >= Long.MAX_VALUE) return Duration.ofNanos(Long.MAX_VALUE); + return Duration.ofNanos( + (long) Math.min(Long.MAX_VALUE, seconds * 1e9 * (0.9 + random.nextDouble() * 0.2))); + } + } +} diff --git a/java/river/src/main/java/com/riverqueue/RiverException.java b/java/river/src/main/java/com/riverqueue/RiverException.java new file mode 100644 index 000000000..b8a48bbca --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/RiverException.java @@ -0,0 +1,29 @@ +package com.riverqueue; + +/** A failure in River validation, storage, or execution. */ +public class RiverException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final Code code; + + public RiverException(Code code, String message) { + super(message); + this.code = code; + } + + public RiverException(Code code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + public Code code() { + return code; + } + + /** Stable categories suitable for application error handling. */ + public enum Code { + DATABASE, + NOT_FOUND, + REJECTED, + UNSUPPORTED + } +} diff --git a/java/river/src/main/java/com/riverqueue/Schedule.java b/java/river/src/main/java/com/riverqueue/Schedule.java new file mode 100644 index 000000000..a55c2999d --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Schedule.java @@ -0,0 +1,22 @@ +package com.riverqueue; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Optional; + +/** A schedule evaluated strictly after a reference time. */ +@FunctionalInterface +public interface Schedule { + Optional next(OffsetDateTime after); + + /** Parses River Go's five-field cron syntax, including descriptors and time zones. */ + static Schedule cron(String expression) { + return new CronSchedule(expression); + } + + static Schedule every(Duration interval) { + if (interval.isNegative() || interval.isZero()) + throw new IllegalArgumentException("Interval must be positive"); + return after -> Optional.of(after.plus(interval)); + } +} diff --git a/java/river/src/main/java/com/riverqueue/Sql.java b/java/river/src/main/java/com/riverqueue/Sql.java new file mode 100644 index 000000000..bac27d866 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Sql.java @@ -0,0 +1,61 @@ +package com.riverqueue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +/** Internal query catalog; runtime SQL lives beside the migration resources. */ +final class Sql { + private static final Map> QUERIES = + Map.of( + Database.Dialect.POSTGRES, + load("postgres.sql"), + Database.Dialect.SQLITE, + load("sqlite.sql")); + + private Sql() {} + + static PreparedStatement prepare(Connection connection, String sql, Object... params) + throws SQLException { + var statement = connection.prepareStatement(sql); + try { + for (int i = 0; i < params.length; i++) statement.setObject(i + 1, params[i]); + return statement; + } catch (SQLException e) { + statement.close(); + throw e; + } + } + + static String query(Database database, String name) { + String query = QUERIES.get(database.dialect()).get(name); + if (query == null) throw new IllegalArgumentException("Unknown query: " + name); + return query + .replace("{schema}", database.prefix()) + .replace("{schema_name}", database.schema()) + .replace("{columns}", QUERIES.get(database.dialect()).get("columns")); + } + + static String resource(String path) { + try (var stream = Sql.class.getResourceAsStream(path)) { + if (stream == null) throw new IllegalArgumentException("Missing SQL resource: " + path); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static Map load(String name) { + var result = new HashMap(); + for (String section : resource(name).split("(?m)^-- name: ")) { + int newline = section.indexOf('\n'); + if (newline > 0) + result.put(section.substring(0, newline).trim(), section.substring(newline + 1).strip()); + } + return Map.copyOf(result); + } +} diff --git a/java/river/src/main/java/com/riverqueue/Unique.java b/java/river/src/main/java/com/riverqueue/Unique.java new file mode 100644 index 000000000..bac8da519 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Unique.java @@ -0,0 +1,210 @@ +package com.riverqueue; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** River's uniqueness dimensions and byte-compatible SHA-256 protocol. */ +public record Unique( + boolean byArgs, + Duration byPeriod, + boolean byQueue, + Set byState, + boolean excludeKind) { + public Unique { + byState = byState == null ? null : Set.copyOf(byState); + if (byPeriod != null && byPeriod.compareTo(Duration.ofSeconds(1)) < 0) + throw new IllegalArgumentException("Unique period must be at least one second"); + if (excludeKind && !byArgs && !byQueue && byPeriod == null) + throw new IllegalArgumentException("Excluding kind requires another uniqueness dimension"); + if (byState != null + && !byState.isEmpty() + && !byState.containsAll( + Set.of(Job.State.AVAILABLE, Job.State.PENDING, Job.State.RUNNING, Job.State.SCHEDULED))) + throw new IllegalArgumentException( + "Unique states must include available, pending, running, scheduled"); + } + + public static Unique args() { + return new Unique(true, null, false, null, false); + } + + public static Unique none() { + return new Unique(false, null, false, null, false); + } + + public Unique per(Duration value) { + return new Unique(byArgs, value, byQueue, byState, excludeKind); + } + + public Unique perQueue() { + return new Unique(byArgs, byPeriod, true, byState, excludeKind); + } + + /** + * Overrides the states in which jobs remain unique, subject to River's required active states. + */ + public Unique states(Job.State... values) { + return new Unique(byArgs, byPeriod, byQueue, Set.of(values), excludeKind); + } + + /** Shares uniqueness across kinds; another uniqueness dimension must be enabled. */ + public Unique excludeKind(boolean value) { + return new Unique(byArgs, byPeriod, byQueue, byState, value); + } + + public boolean enabled() { + return byArgs || byPeriod != null || byQueue || byState != null || excludeKind; + } + + public int stateMask() { + return byState == null || byState.isEmpty() + ? 245 + : byState.stream().mapToInt(Job.State::bit).reduce(0, (a, b) -> a | b); + } + + public List states() { + return java.util.Arrays.stream(Job.State.values()) + .filter(s -> (stateMask() & s.bit()) != 0) + .toList(); + } + + public String key( + String kind, + String encodedArgs, + List> fields, + Instant now, + String queue, + Instant scheduledAt) { + if (!enabled()) return null; + var input = new StringBuilder(); + if (!excludeKind) input.append("&kind=").append(kind); + if (byArgs) input.append("&args=").append(arguments(encodedArgs, fields)); + if (byPeriod != null) { + Instant time = scheduledAt == null ? now : scheduledAt; + var nanos = + BigInteger.valueOf(time.getEpochSecond()) + .add(BigInteger.valueOf(62135596800L)) + .multiply(BigInteger.valueOf(1000000000)) + .add(BigInteger.valueOf(time.getNano())); + var period = + BigInteger.valueOf(byPeriod.getSeconds()) + .multiply(BigInteger.valueOf(1000000000)) + .add(BigInteger.valueOf(byPeriod.getNano())); + var truncated = nanos.subtract(nanos.mod(period)); + var seconds = truncated.divideAndRemainder(BigInteger.valueOf(1000000000)); + input + .append("&period=") + .append( + Instant.ofEpochSecond( + seconds[0].longValueExact() - 62135596800L, seconds[1].longValue()) + .truncatedTo(ChronoUnit.SECONDS)); + } + if (byQueue) input.append("&queue=").append(queue); + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(input.toString().getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + private static String arguments(String source, List> paths) { + if (paths.isEmpty() && source.matches("\\s*\\[\\s*]\\s*")) return "{}"; + Map members = Json.members(source); + var selected = new LinkedHashMap(); + if (paths.isEmpty()) { + members.keySet().stream() + .sorted(Json.UTF8_ORDER) + .forEach(k -> selected.put(k, members.get(k))); + } else { + var sorted = + paths.stream() + .distinct() + .sorted( + Comparator., String>comparing( + path -> String.join(".", path), Json.UTF8_ORDER) + .thenComparing(Unique::escapedPath, Json.UTF8_ORDER)) + .toList(); + for (var path : sorted) { + if (path.isEmpty() || path.stream().anyMatch(p -> p.isEmpty() || p.matches("[0-9]+|-1"))) + throw new IllegalArgumentException("Invalid unique field path"); + for (var other : sorted) + if (other.size() > path.size() && other.subList(0, path.size()).equals(path)) + throw new IllegalArgumentException("Overlapping unique field paths"); + String current = source; + for (String part : path) { + if (current == null || !current.stripLeading().startsWith("{")) { + current = null; + break; + } + current = Json.members(current).get(part); + } + if (current != null) select(selected, path, current); + } + if (selected.isEmpty()) return ""; + } + return write(selected); + } + + // Go breaks ties between dot-joined names with their escaped gjson/sjson paths. + private static String escapedPath(List path) { + var parts = new ArrayList(); + for (String part : path) { + var escaped = new StringBuilder(); + if (part.startsWith(":")) escaped.append('\\'); + for (int i = 0; i < part.length(); i++) { + char c = part.charAt(i); + if (!(c >= 'a' && c <= 'z') + && !(c >= 'A' && c <= 'Z') + && !(c >= '0' && c <= '9') + && c > ' ' + && c <= '~' + && c != '_' + && c != '-' + && c != ':') escaped.append('\\'); + escaped.append(c); + } + parts.add(escaped.toString()); + } + return String.join(".", parts); + } + + @SuppressWarnings("unchecked") + private static void select(Map into, List path, String value) { + if (path.size() == 1) into.put(path.getFirst(), value); + else + select( + (Map) + into.computeIfAbsent(path.getFirst(), ignored -> new LinkedHashMap()), + path.subList(1, path.size()), + value); + } + + private static String write(Map values) { + var result = new ArrayList(); + for (var entry : values.entrySet()) { + String encoded; + if (entry.getValue() instanceof Map map) { + var nested = new LinkedHashMap(); + map.forEach((k, v) -> nested.put((String) k, v)); + encoded = write(nested); + } else encoded = (String) entry.getValue(); + result.add(Json.sjsonKey(entry.getKey()) + ":" + encoded); + } + return "{" + String.join(",", result) + "}"; + } +} diff --git a/java/river/src/main/java/com/riverqueue/WorkContext.java b/java/river/src/main/java/com/riverqueue/WorkContext.java new file mode 100644 index 000000000..3a7be3cac --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/WorkContext.java @@ -0,0 +1,282 @@ +package com.riverqueue; + +import java.sql.Connection; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +/** Per-attempt arguments, cancellation, output, and resumable checkpoints. */ +public final class WorkContext { + private final CancellationState cancellation; + private String completedStep; + private String currentStep; + private final ObjectNode cursors; + private Exception stepFailure; + private final Job job; + private final ObjectNode metadata = Json.object(); + private final Client client; + private Workers runtime; + final java.util.Map peers; + private final Set steps = new HashSet<>(); + private boolean resumeMatched; + + WorkContext(Client client, Job job) { + this(client, job, true); + } + + WorkContext(Client client, Job job, boolean validate) { + this(client, job, validate, new CancellationState()); + } + + private WorkContext(Client client, Job job, boolean validate, CancellationState cancellation) { + this.cancellation = cancellation; + this.client = client; + this.job = job; + peers = cancellation.peers; + JsonNode saved = job.metadata().path(Protocol.METADATA_RESUMABLE_STEP); + if (validate && !saved.isMissingNode() && !saved.isString()) + throw new IllegalArgumentException("Invalid resumable step metadata"); + resumeMatched = saved.asString("").isEmpty(); + var cursors = job.metadata().path(Protocol.METADATA_RESUMABLE_CURSOR); + if (validate && cursors.isArray()) + throw new IllegalArgumentException("Invalid resumable cursor metadata"); + this.cursors = cursors.isObject() ? (ObjectNode) cursors.deepCopy() : Json.object(); + } + + public A args() { + return job.args(); + } + + /** Internal companion-attempt seam used by River Pro's batch worker. */ + public java.util.List> claimPeers( + JobType type, Client.Transaction> claim) { + if (runtime == null) throw new IllegalStateException("No worker runtime"); + return runtime.claimPeers(this, type, claim); + } + + /** Internal companion-attempt seam used by River Pro's batch worker. */ + public void completePeer(WorkContext peer, Exception failure) { + if (runtime == null) throw new IllegalStateException("No worker runtime"); + runtime.completePeer(this, peer, failure); + } + + void attach(Workers runtime, WorkContext parent) { + this.runtime = runtime; + if (parent != null && parent.cancellation() != null) requestCancellation(parent.cancellation()); + } + + WorkContext withDecodedJob(Job decoded) { + // Notifications and supervision may still hold the provisional context during decoding. + var context = new WorkContext<>(client, decoded, true, cancellation); + context.runtime = runtime; + return context; + } + + record Peer(Job row, WorkContext context) {} + + /** + * Waits for the cancellation signal. Call {@link #checkCancelled()} afterward if leaving work + * unfinished; returning normally from a handler during shutdown reports success. + */ + public void awaitCancellation() throws InterruptedException { + cancellation.signal.await(); + } + + /** + * Waits for cancellation for at most the given duration. As with {@link #awaitCancellation()}, + * the signal alone does not throw; call {@link #checkCancelled()} if leaving work unfinished. + */ + public boolean awaitCancellation(Duration duration) throws InterruptedException { + return cancellation.signal.await(duration.toNanos(), java.util.concurrent.TimeUnit.NANOSECONDS); + } + + public void cancel(String reason) { + throw new Control(Job.State.CANCELLED, null, reason); + } + + public Cancellation cancellation() { + return cancellation.cause.get(); + } + + /** + * Throws if cancellation was requested. Propagating this interruption from a handler during + * shutdown requeues the job without consuming an attempt. + */ + public void checkCancelled() throws InterruptedException { + if (isCancelled()) throw new InterruptedException("Job cancellation requested"); + } + + void checkRuntimeCancellation() { + if (isCancelled()) throw new IllegalStateException("Batch attempt cancelled"); + } + + /** Completes this attempt atomically with application writes in the supplied transaction. */ + public Job complete(Connection transaction) { + return client.complete(transaction, job, metadataUpdates()); + } + + public void discard(String reason) { + throw new Control(Job.State.DISCARDED, null, reason); + } + + public boolean isCancelled() { + return cancellation.cause.get() != null; + } + + public Job job() { + return job; + } + + public synchronized void metadata(String key, Object value) { + metadata.set(key, Json.tree(value)); + } + + synchronized ObjectNode metadataUpdates() { + return metadata.deepCopy(); + } + + synchronized Exception finish(Exception failure) { + synchronized (cancellation) { + cancellation.finished = true; + if (cancellation.forced) + failure = + new IllegalStateException("Worker ignored cancellation beyond the stuck threshold"); + } + if (stepFailure != null) failure = stepFailure; + if (failure == null && !resumeMatched) + failure = new IllegalArgumentException("Saved resumable step was not found"); + if (failure != null && completedStep != null) { + metadata(Protocol.METADATA_RESUMABLE_STEP, completedStep); + if (!cursors.isEmpty() || job.metadata().has(Protocol.METADATA_RESUMABLE_CURSOR)) + metadata(Protocol.METADATA_RESUMABLE_CURSOR, cursors.isEmpty() ? null : cursors); + } + return failure; + } + + public void output(Object value) { + metadata(Protocol.METADATA_OUTPUT, value); + } + + public Client client() { + return client; + } + + void requestCancellation(Cancellation value) { + synchronized (cancellation) { + if (!cancellation.finished && cancellation.cause.compareAndSet(null, value)) { + cancellation.cancelledAt = System.nanoTime(); + cancellation.signal.countDown(); + peers.values().forEach(peer -> peer.context.requestCancellation(value)); + } + } + } + + boolean forceIfStuck(Duration threshold) { + synchronized (cancellation) { + if (cancellation.finished + || cancellation.cause.get() == null + || cancellation.cancelledAt == 0 + || cancellation.forced + || System.nanoTime() - cancellation.cancelledAt < threshold.toNanos()) return false; + cancellation.forced = true; + return true; + } + } + + public void snooze(Duration duration) { + if (duration.isNegative()) + throw new IllegalArgumentException("Snooze duration cannot be negative"); + try { + duration.toNanos(); + } catch (ArithmeticException error) { + throw new IllegalArgumentException( + "Snooze duration must fit in signed 64-bit nanoseconds", error); + } + throw new Control(null, duration, "snoozed"); + } + + public void step(String name, Step action) throws Exception { + executeStep(name, false, ignored -> action.run()); + } + + public void stepWithCursor(String name, CursorStep action) throws Exception { + executeStep(name, true, action); + } + + public void cursor(Object value) { + if (currentStep == null) + throw new IllegalStateException("Cursor requires an active resumable step"); + cursors.set(currentStep, Json.tree(value)); + } + + private void executeStep(String name, boolean withCursor, CursorStep action) throws Exception { + if (stepFailure != null) throw stepFailure; + if (name.isEmpty() || !steps.add(name)) { + stepFailure = new IllegalArgumentException("duplicate resumable step or empty name: " + name); + throw stepFailure; + } + if (!resumeMatched) { + if (job.metadata().path(Protocol.METADATA_RESUMABLE_STEP).asString().equals(name)) { + resumeMatched = true; + completedStep = name; + } + if (!resumeMatched || !withCursor || !cursors.has(name)) return; + } + currentStep = name; + try { + action.run(cursors.path(name)); + completedStep = name; + cursors.remove(name); + } catch (Exception failure) { + stepFailure = failure; + throw failure; + } finally { + currentStep = null; + } + } + + public T transaction(Client.Transaction action) { + return client.transaction(action); + } + + public enum Cancellation { + REMOTE, + SHUTDOWN, + TIMEOUT + } + + private static final class CancellationState { + long cancelledAt; + final AtomicReference cause = new AtomicReference<>(); + boolean finished; + boolean forced; + final java.util.Map peers = new java.util.concurrent.ConcurrentHashMap<>(); + final CountDownLatch signal = new CountDownLatch(1); + } + + static final class Control extends RuntimeException { + private static final long serialVersionUID = 1L; + final Duration delay; + final Job.State state; + + Control(Job.State state, Duration delay, String reason) { + super(reason); + this.state = state; + this.delay = delay; + } + } + + @FunctionalInterface + public interface Step { + void run() throws Exception; + } + + @FunctionalInterface + public interface CursorStep { + void run(JsonNode cursor) throws Exception; + } +} diff --git a/java/river/src/main/java/com/riverqueue/Workers.java b/java/river/src/main/java/com/riverqueue/Workers.java new file mode 100644 index 000000000..fa9ca6578 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/Workers.java @@ -0,0 +1,1716 @@ +package com.riverqueue; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import org.postgresql.PGConnection; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +/** A bounded worker runtime using one virtual thread per active job. */ +public final class Workers implements AutoCloseable { + private static final Duration QUEUE_RETENTION = Duration.ofDays(1); + + private final Map> active = new ConcurrentHashMap<>(); + private final Map activeThreads = new ConcurrentHashMap<>(); + private final Map pendingCancellation = new ConcurrentHashMap<>(); + private final AtomicBoolean maintenanceBusy = new AtomicBoolean(); + private volatile Instant nextReindex = + Instant.now() + .atOffset(java.time.ZoneOffset.UTC) + .toLocalDate() + .plusDays(1) + .atStartOfDay() + .toInstant(java.time.ZoneOffset.UTC); + private final Builder config; + private final RetryPolicy defaultRetryPolicy = RetryPolicy.defaults(); + private final java.util.concurrent.BlockingQueue completions = + new java.util.concurrent.LinkedBlockingQueue<>(); + private final java.util.concurrent.CountDownLatch dispatcherStopped = + new java.util.concurrent.CountDownLatch(1); + // Claims may commit after stop's active-map scan; launch must see the cancellation request. + private volatile boolean cancelOnStop; + private boolean stopFinalized; + private final ReentrantLock stopLock = new ReentrantLock(); + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + private final PriorityBlockingQueue fetchWakeups = new PriorityBlockingQueue<>(); + private final String id; + private final Protocol.Dispatcher notifications; + // Blocking under monitor locks pins virtual threads on Java 21; use locks that allow yielding. + private final ReentrantLock leadershipLock = new ReentrantLock(); + private volatile LeadershipTerm leaderTerm; + private Instant initializedTerm; + private Instant resigningTerm; + private long leaderEligibleAt = System.nanoTime(); + private volatile Connection listener; + private final Map queues = new ConcurrentHashMap<>(); + private final Map periodicNext = new ConcurrentHashMap<>(); + private final java.util.Set pausedQueues = ConcurrentHashMap.newKeySet(); + private final ReentrantLock queueLock = new ReentrantLock(); + private final Condition queueChanged = queueLock.newCondition(); + private final java.util.Set drainingQueues = ConcurrentHashMap.newKeySet(); + private final Semaphore leadershipWake = new Semaphore(0); + private final Client river; + private final Runnable unsubscribeInsert; + private final AtomicBoolean running = new AtomicBoolean(true); + private final List> subscribers = new CopyOnWriteArrayList<>(); + private final Semaphore wake = new Semaphore(0); + + private Workers(Builder config) { + this.config = config; + river = config.river; + id = config.id == null ? "java-" + UUID.randomUUID() : config.id; + notifications = + new Protocol.Dispatcher( + id, active, pendingCancellation, this::queueNotification, this::leadershipNotification); + queues.putAll(config.queues); + if (queues.isEmpty() || config.handlers.isEmpty()) + throw new IllegalArgumentException("Workers require queues and handlers"); + if (config.reindexInterval != null) nextReindex = Instant.now().plus(config.reindexInterval); + for (String queue : queues.keySet()) heartbeat(queue); + unsubscribeInsert = river.onInsertCommit(wake::release); + if (!config.pollOnly) executor.submit(this::listen); + executor.submit(this::completeBatches); + executor.submit(this::supervise); + executor.submit(this::dispatch); + if (config.leadership) executor.submit(this::elect); + } + + public void addQueue(String name, int maxWorkers) { + validateQueue(name, maxWorkers); + queueLock.lock(); + try { + if (drainingQueues.contains(name)) + throw new IllegalStateException("Queue is draining: " + name); + queues.put(name, maxWorkers); + heartbeat(name); + } finally { + queueLock.unlock(); + } + wake.release(); + } + + @Override + public void close() { + stop(); + } + + public String id() { + return id; + } + + /** Reports whether this runtime still trusts its committed leadership lease. */ + public boolean isLeader() { + return isLeader(System.nanoTime()); + } + + boolean isLeader(long now) { + var term = leaderTerm; + return running.get() && term != null && term.active(now); + } + + public void removeQueue(String name) { + queueLock.lock(); + try { + if (!queues.containsKey(name) || !drainingQueues.add(name)) + throw new RiverException(RiverException.Code.NOT_FOUND, "Queue not configured: " + name); + try { + // Keep the producer lease alive until every committed claim has settled. + long deadline = System.nanoTime() + config.stopTimeout.toNanos(); + while (active.values().stream().anyMatch(c -> c.job().queue().equals(name))) { + long left = deadline - System.nanoTime(); + if (left <= 0) + throw new RiverException(RiverException.Code.REJECTED, "Queue did not drain: " + name); + queueChanged.awaitNanos(left); + } + queues.remove(name); + heartbeat(name); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new RiverException( + RiverException.Code.REJECTED, "Interrupted draining queue: " + name, error); + } finally { + drainingQueues.remove(name); + } + } finally { + queueLock.unlock(); + } + } + + /** + * Stops fetching jobs and waits for active attempts to finish. A timeout leaves attempts running; + * call again to continue waiting, or use {@link #stopAndCancel()} to request cancellation. + */ + public void stop() { + stop(false); + } + + private void stop(boolean cancel) { + if (running.getAndSet(false)) { + unsubscribeInsert.run(); + leadershipWake.release(); + } + if (cancel) cancelOnStop = true; + long deadline = System.nanoTime() + config.stopTimeout.toNanos(); + if (cancel) + active.values().forEach(c -> c.requestCancellation(WorkContext.Cancellation.SHUTDOWN)); + wake.release(); + try { + if (!dispatcherStopped.await(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) + throw new RiverException( + RiverException.Code.REJECTED, "Dispatcher did not stop within the stop deadline"); + executor.shutdown(); + if (!executor.awaitTermination( + Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) + throw new RiverException( + RiverException.Code.REJECTED, "Workers have not stopped within the stop deadline"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RiverException(RiverException.Code.REJECTED, "Interrupted waiting for workers", e); + } + stopLock.lock(); + try { + if (!stopFinalized) { + RuntimeException failure = null; + try { + queues.keySet().forEach(this::heartbeat); + } catch (RuntimeException error) { + failure = error; + } + try { + resign(); + } catch (RuntimeException error) { + if (failure == null) failure = error; + else failure.addSuppressed(error); + } + if (failure != null) throw failure; + stopFinalized = true; + } + } finally { + stopLock.unlock(); + } + } + + /** Stops fetching, requests cancellation of active attempts, and waits for them to finish. */ + public void stopAndCancel() { + stop(true); + } + + /** Subscribes to the requested event kinds, or all kinds when none are supplied. */ + public Subscription subscribe(Consumer subscriber, EventKind... kinds) { + Objects.requireNonNull(subscriber, "subscriber"); + Set selected = Set.copyOf(List.of(kinds)); + Consumer filtered = + event -> { + if (selected.isEmpty() || selected.contains(event.kind())) subscriber.accept(event); + }; + subscribers.add(filtered); + return () -> subscribers.remove(filtered); + } + + private void dispatch() { + long nextPoll = 0; + long nextMaintenance = 0; + long nextCancelPoll = 0; + while (running.get()) { + try { + long now = System.nanoTime(); + boolean notified = wake.drainPermits() > 0; + if (wakeDueFetches()) notified = true; + if (notified || now >= nextPoll) { + queueLock.lock(); + try { + for (var queue : queues.entrySet()) { + if (drainingQueues.contains(queue.getKey())) continue; + int available = + river.plugin().workerLimit(queue.getKey(), queue.getValue()) + - (int) + active.values().stream() + .filter(c -> c.job().queue().equals(queue.getKey())) + .count(); + if (available > 0) claim(queue.getKey(), available); + } + } finally { + queueLock.unlock(); + } + nextPoll = now + config.pollInterval.toNanos(); + } + if (now >= nextMaintenance) { + queues.keySet().forEach(this::heartbeat); + nextMaintenance = now + config.serviceInterval.toNanos(); + } + if (now >= nextCancelPoll) { + river.transaction( + c -> { + try (var statement = + Sql.prepare(c, Sql.query(river.database(), "cancel_requested")); + var rows = statement.executeQuery()) { + while (rows.next()) { + var context = active.get(rows.getLong(1)); + if (context != null) + context.requestCancellation(WorkContext.Cancellation.REMOTE); + } + } + return null; + }); + pendingCancellation + .entrySet() + .removeIf(entry -> now - entry.getValue() > Duration.ofMinutes(1).toNanos()); + nextCancelPoll = now + TimeUnit.MILLISECONDS.toNanos(50); + } + if (wake.tryAcquire(10, TimeUnit.MILLISECONDS)) wake.release(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + // A failed claim must not consume an insert notification or a timed retry. + nextPoll = 0; + report(e); + try { + TimeUnit.MILLISECONDS.sleep(100); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + break; + } + } + } + dispatcherStopped.countDown(); + } + + private record FetchWakeup(Instant scheduledAt, String notifyQueue) + implements Comparable { + @Override + public int compareTo(FetchWakeup other) { + return scheduledAt.compareTo(other.scheduledAt); + } + } + + private boolean wakeDueFetches() { + Instant now = Instant.now(); + var ready = new ArrayList(); + FetchWakeup due; + while ((due = fetchWakeups.peek()) != null && !due.scheduledAt().isAfter(now)) + ready.add(fetchWakeups.poll()); + var notifyQueues = new java.util.HashSet(); + for (var wakeup : ready) + if (wakeup.notifyQueue() != null) notifyQueues.add(wakeup.notifyQueue()); + if (!notifyQueues.isEmpty()) { + try { + // Rescued jobs may belong to queues consumed only by peers. Notify once due so a peer + // doesn't fetch too early and then wait for its next periodic poll. + river.transaction( + connection -> { + for (String queue : notifyQueues) river.notifyInsert(connection, queue); + return null; + }); + } catch (RuntimeException error) { + fetchWakeups.addAll(ready); + throw error; + } + } + return !ready.isEmpty(); + } + + private void claim(String queue, int count) { + List jobs = + river.transaction( + c -> + river + .plugin() + .claim( + c, + river.driver(), + new Plugin.Claim( + id, + queue, + count, + Instant.now(), + java.util.Set.copyOf(config.handlers.keySet()), + config.fetchOnlyKnownKinds), + ignored -> { + var result = new ArrayList(); + try (var statement = + Sql.prepare( + c, + Sql.query( + river.database(), + config.fetchOnlyKnownKinds ? "claim_known" : "claim"), + config.fetchOnlyKnownKinds + ? new Object[] { + river.database().timestamp(Instant.now()), + id, + queue, + river.database().timestamp(Instant.now()), + Json.encode(config.handlers.keySet()), + count + } + : new Object[] { + river.database().timestamp(Instant.now()), + id, + queue, + river.database().timestamp(Instant.now()), + count + }); + var rows = statement.executeQuery()) { + while (rows.next()) result.add(river.readPartial(rows)); + } + return result; + })); + for (var decoded : jobs) launch(decoded, config.handlers.get(decoded.job().kind())); + } + + private void launch(Client.Decoded decoded, Registration registration) { + var row = decoded.job(); + WorkContext context; + try { + if (decoded.failure() != null) throw decoded.failure(); + if (registration == null) + throw new IllegalArgumentException( + "job kind is not registered in the client's Workers bundle: " + row.kind()); + context = new WorkContext<>(river, river.typed(row, registration.type)); + } catch (Exception e) { + // Invalid jobs own committed claims too: retain their slots and retry acknowledgement. + launch( + row, + new WorkContext<>(river, row, false), + () -> { + throw e; + }); + return; + } + launch( + row, + context, + () -> { + for (var extension : river.extensions()) extension.afterClaim(row); + workMiddleware( + context, + 0, + () -> { + Throwable workFailure = null; + try { + for (var extension : river.extensions()) extension.beforeWork(context); + registration.handler.work(context); + } catch (Exception | Error e) { + workFailure = e; + throw e; + } finally { + for (var extension : river.extensions()) + extension.afterWork(context, workFailure); + } + }); + }); + } + + private void launch(Job row, WorkContext context, WorkContext.Step work) { + context.attach(this, null); + active.put(row.id(), context); + if (row.metadata().has("cancel_attempted_at")) + context.requestCancellation(WorkContext.Cancellation.REMOTE); + if (pendingCancellation.remove(row.id()) != null) + context.requestCancellation(WorkContext.Cancellation.REMOTE); + if (cancelOnStop) context.requestCancellation(WorkContext.Cancellation.SHUTDOWN); + executor.submit( + () -> { + activeThreads.put(row.id(), Thread.currentThread()); + Exception failure = null; + try { + observe("work_begin"); + work.run(); + } catch (Exception e) { + failure = e; + } catch (Throwable e) { + failure = new RuntimeException("Worker threw " + e, e); + } finally { + observe("work_end"); + } + // Interruption belongs to the handler, not to the durable acknowledgement that follows. + // Stop supervising its thread while retaining the slot until completion is committed. + failure = context.finish(failure); + activeThreads.remove(row.id()); + Thread.interrupted(); + // A claimed job keeps its slot until completion is durably acknowledged. + while (true) { + try { + for (var peer : List.copyOf(context.peers.values())) + completePeer( + context, + peer.context(), + failure == null + ? new IllegalStateException("Batch worker did not provide a peer outcome") + : failure); + complete(row, context, failure); + break; + } catch (Exception e) { + report(e); + try { + TimeUnit.MILLISECONDS.sleep(100); + } catch (InterruptedException interrupted) { + // A cancellation already in flight must not abandon this claimed job. + } + } + } + queueLock.lock(); + try { + active.remove(row.id(), context); + queueChanged.signalAll(); + } finally { + queueLock.unlock(); + } + wake.release(); + }); + } + + private void workMiddleware(WorkContext context, int index, WorkContext.Step next) + throws Exception { + if (index == river.extensions().size()) next.run(); + else + river.extensions().get(index).work(context, () -> workMiddleware(context, index + 1, next)); + } + + List> claimPeers( + WorkContext parent, JobType type, Client.Transaction> operation) { + parent.checkRuntimeCancellation(); + var rows = + river.transaction( + connection -> { + var claimed = operation.run(connection); + river.plugin().afterPeerClaim(connection, river.driver(), claimed); + return claimed; + }); + // Register every committed claim before decoding any arguments. A decoding failure must still + // leave all claimed jobs owned by this attempt so its failure settles the entire batch. + for (var decoded : rows) { + var context = new WorkContext<>(river, decoded.job(), false); + parent.peers.put(decoded.job().id(), new WorkContext.Peer(decoded.job(), context)); + active.put(decoded.job().id(), context); + // Publish before checking the parent so a concurrent cancellation cannot miss both. + context.attach(this, parent); + } + var contexts = new ArrayList>(); + for (var decoded : rows) { + if (decoded.failure() != null) throw decoded.failure(); + var provisional = parent.peers.get(decoded.job().id()).context(); + var context = provisional.withDecodedJob(river.typed(decoded.job(), type)); + parent.peers.put(decoded.job().id(), new WorkContext.Peer(decoded.job(), context)); + active.put(decoded.job().id(), context); + contexts.add(context); + } + return List.copyOf(contexts); + } + + void completePeer(WorkContext parent, WorkContext context, Exception failure) { + var peer = parent.peers.get(context.job().id()); + if (peer == null || peer.context() != context) + throw new IllegalArgumentException("Peer is not owned by this batch attempt"); + if (parent.cancellation() != null) context.requestCancellation(parent.cancellation()); + complete(peer.row(), context, context.finish(failure)); + parent.peers.remove(context.job().id(), peer); + queueLock.lock(); + try { + active.remove(context.job().id(), context); + queueChanged.signalAll(); + } finally { + queueLock.unlock(); + } + } + + private void complete(Job original, WorkContext context, Exception failure) { + Instant now = Instant.now(); + var updates = context == null ? Json.object() : context.metadataUpdates(); + Job.State state = Job.State.COMPLETED; + Instant scheduled = original.scheduledAt(); + int attempt = original.attempt(); + EventKind event = EventKind.JOB_COMPLETED; + String error = null; + if (failure != null + && context != null + && context.cancellation() == WorkContext.Cancellation.REMOTE) { + state = Job.State.CANCELLED; + error = "JobCancelError: job cancelled remotely"; + event = EventKind.JOB_CANCELLED; + } else if (failure instanceof WorkContext.Control control) { + if (control.delay != null) { + scheduled = now.plus(control.delay); + state = bypassScheduler(control.delay) ? Job.State.AVAILABLE : Job.State.SCHEDULED; + attempt--; + updates.put("snoozes", nextSnoozeCount(original.metadata())); + event = EventKind.JOB_SNOOZED; + } else { + state = control.state; + error = control.getMessage(); + event = state == Job.State.CANCELLED ? EventKind.JOB_CANCELLED : EventKind.JOB_FAILED; + } + } else if (failure instanceof InterruptedException + && context != null + && context.cancellation() == WorkContext.Cancellation.SHUTDOWN) { + state = Job.State.AVAILABLE; + scheduled = now; + attempt--; + event = EventKind.JOB_INTERRUPTED; + } else if (failure != null) { + error = failure.getMessage() == null ? failure.toString() : failure.getMessage(); + boolean cancelled = config.cancelOnError; + state = + cancelled + ? Job.State.CANCELLED + : original.attempt() >= original.maxAttempts() + ? Job.State.DISCARDED + : Job.State.RETRYABLE; + if (state == Job.State.RETRYABLE) { + Duration delay = retryDelay(original, now); + scheduled = now.plus(delay); + if (bypassScheduler(delay)) state = Job.State.AVAILABLE; + } + event = cancelled ? EventKind.JOB_CANCELLED : EventKind.JOB_FAILED; + } + String trace = ""; + if (failure != null && failure.getCause() instanceof Error) { + var text = new java.io.StringWriter(); + failure.printStackTrace(new java.io.PrintWriter(text)); + trace = text.toString(); + } + String errors = + error == null + ? null + : Json.encode( + new Job.AttemptError(original.attemptedAt(), original.attempt(), error, trace)); + var done = new java.util.concurrent.CompletableFuture>(); + completions.add( + new Completion( + original, + new Object[] { + state.value(), + river.database().timestamp(now), + state.value(), + river.database().timestamp(state.isFinalized() ? now : null), + river.database().timestamp(scheduled), + attempt, + Json.encode(updates), + errors, + errors, + original.id(), + original.attempt(), + river.database().timestamp(original.attemptedAt()) + }, + done)); + var updated = done.join(); + // An ephemeral job may already have been completed and deleted in the application's + // transaction. + if (updated == null) return; + // Short retries and snoozes bypass the scheduler. The immediate completion wake-up may + // precede scheduled_at, so also wake fetching when the persisted timestamp becomes due. + if (updated.state() == Job.State.AVAILABLE) + fetchWakeups.add(new FetchWakeup(updated.scheduledAt(), null)); + // Another process may have changed the attempt. Match Go's events to the persisted state. + event = + switch (updated.state()) { + case AVAILABLE -> + switch (event) { + case JOB_INTERRUPTED, JOB_SNOOZED -> event; + default -> EventKind.JOB_FAILED; + }; + case CANCELLED -> EventKind.JOB_CANCELLED; + case COMPLETED -> EventKind.JOB_COMPLETED; + case DISCARDED, RETRYABLE -> EventKind.JOB_FAILED; + case SCHEDULED -> EventKind.JOB_SNOOZED; + case PENDING, RUNNING -> null; + }; + if (event != null) publish(new Event(event, updated)); + } + + private boolean bypassScheduler(Duration delay) { + // Maintenance runs from election ticks. Longer service intervals must not strand short + // delays in states that only the scheduler can make available. + return delay.compareTo(config.maintenanceInterval) <= 0 + || delay.compareTo(config.serviceInterval) <= 0; + } + + private void completeBatches() { + while (running.get() + || dispatcherStopped.getCount() != 0 + || !active.isEmpty() + || !completions.isEmpty()) { + try { + var first = completions.poll(20, TimeUnit.MILLISECONDS); + if (first == null) continue; + var batch = new ArrayList(); + batch.add(first); + completions.drainTo(batch, 999); + try { + var results = + river.transaction( + c -> { + var rows = new ArrayList>(); + for (var completion : batch) + try (var statement = + Sql.prepare( + c, Sql.query(river.database(), "complete"), completion.params); + var result = statement.executeQuery()) { + if (result.next()) rows.add(river.readPartial(result).job()); + else { + try { + rows.add(river.get(c, completion.original.id())); + } catch (RiverException error) { + if (error.code() != RiverException.Code.NOT_FOUND) throw error; + rows.add(null); + } + } + } + for (var job : rows) + if (job != null) river.plugin().afterStateChange(c, river.driver(), job); + for (var completion : batch) + river.plugin().afterAttempt(c, river.driver(), completion.original); + return rows; + }); + for (int i = 0; i < batch.size(); i++) batch.get(i).done.complete(results.get(i)); + } catch (Exception e) { + for (var completion : batch) completion.done.completeExceptionally(e); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void heartbeat(String queue) { + river.transaction( + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "queue_heartbeat"), + queue, + river.database().timestamp(Instant.now()), + river.database().timestamp(Instant.now()))) { + statement.executeUpdate(); + } + river + .plugin() + .producer( + c, + river.driver(), + new Plugin.Producer( + id, + queue, + queues.getOrDefault(queue, config.queues.getOrDefault(queue, 1)), + active.values().stream().>map(WorkContext::job).toList(), + !running.get() || !queues.containsKey(queue), + Instant.now())); + return null; + }); + } + + private void listen() { + if (river.database().dialect() == Database.Dialect.SQLITE) { + listenSqlite(); + return; + } + while (running.get()) { + try (var connection = river.database().connection()) { + if (!river.database().supportsNotifications(connection)) return; + listener = connection; + String schema = river.database().schema(); + if (schema.isEmpty()) + try (var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT current_schema()")) { + rows.next(); + schema = rows.getString(1); + } + for (String topic : + List.of(Protocol.TOPIC_INSERT, Protocol.TOPIC_CONTROL, Protocol.TOPIC_LEADERSHIP)) + try (var statement = connection.createStatement()) { + statement.execute("LISTEN \"" + schema.replace("\"", "\"\"") + "." + topic + "\""); + } + wake.release(); + observe("listen_ready"); + var postgres = connection.unwrap(PGConnection.class); + try { + while (running.get()) { + var notifications = postgres.getNotifications(25); + if (notifications != null) + for (var notification : notifications) + if (notification.getName().startsWith(schema + ".")) + deliver( + notification.getName().substring(schema.length() + 1), + notification.getParameter()); + } + } finally { + try (var statement = connection.createStatement()) { + statement.execute("UNLISTEN *"); + } + } + } catch (Exception e) { + if (running.get()) { + report(e); + try { + TimeUnit.MILLISECONDS.sleep(100); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } finally { + listener = null; + } + } + } + + private void deliver(String topic, String payload) { + // An unrelated publisher must not discard the remaining notifications in this batch. + try { + notifications.dispatch(topic, payload); + } catch (RuntimeException error) { + report(error); + } + } + + private void listenSqlite() { + long after = 0; + boolean initialized = false; + while (running.get()) { + try { + if (!initialized) { + after = + river.transaction( + c -> { + try (var statement = + Sql.prepare(c, Sql.query(river.database(), "notification_cursor")); + var rows = statement.executeQuery()) { + rows.next(); + return rows.getLong(1); + } + }); + initialized = true; + // The initial cursor skips earlier notifications, including inserts since the first + // fetch. Poll again once listening starts, as the PostgreSQL listener does. + wake.release(); + observe("listen_ready"); + } + final long cursor = after; + var messages = + river.transaction( + c -> { + var result = new ArrayList(); + try (var statement = + Sql.prepare(c, Sql.query(river.database(), "notifications"), cursor); + var rows = statement.executeQuery()) { + while (rows.next()) + result.add( + new Notification( + rows.getLong("id"), + rows.getString("topic"), + rows.getString("payload"))); + } + return result; + }); + // Release the read snapshot before a control message starts a write transaction. + for (var message : messages) { + after = message.id(); + deliver(message.topic(), message.payload()); + } + } catch (Exception e) { + report(e); + } + try { + TimeUnit.MILLISECONDS.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private record Notification(long id, String topic, String payload) {} + + private void leadershipNotification(Protocol.LeadershipNotice notice) { + if (notice == Protocol.LeadershipNotice.REQUEST_RESIGN) { + try { + if (isLeader()) resign(); + } finally { + leadershipWake.release(); + } + } else leadershipWake.release(); + } + + private void queueNotification(Protocol.QueueNotice notice) { + String action = notice.action(); + if (action.equals("pause") || action.equals("resume")) { + String queue = notice.queue(); + for (String name : queues.keySet()) + if (queue.equals("*") || queue.equals(name)) { + boolean changed = + action.equals("pause") ? pausedQueues.add(name) : pausedQueues.remove(name); + if (changed) + publish( + new Event( + action.equals("pause") ? EventKind.QUEUE_PAUSED : EventKind.QUEUE_RESUMED, + null, + river.queues().get(name))); + } + } + wake.release(); + } + + private void elect() { + while (running.get()) { + leadershipWake.drainPermits(); + long delay = config.serviceInterval.toNanos(); + try { + maintain(); + observe("leadership_check"); + } catch (Exception error) { + report(error); + delay = Math.min(delay, TimeUnit.SECONDS.toNanos(1)); + } + if (!running.get()) return; + try { + leadershipWake.tryAcquire(delay, TimeUnit.NANOSECONDS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void maintain() { + leadershipLock.lock(); + try { + if (resigningTerm != null) resign(); + if (System.nanoTime() - leaderEligibleAt < 0) return; + long attemptStarted = System.nanoTime(); + Instant now = Instant.now(); + Instant expiresAt = now.plus(config.serviceInterval).plusSeconds(10); + var previousTerm = leaderTerm; + if (previousTerm != null && !previousTerm.active(attemptStarted)) { + resign(); + return; + } + Instant elected = + river.transaction( + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "leader_expire"), + river.database().timestamp(now))) { + statement.executeUpdate(); + } + if (previousTerm != null) { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "leader_renew"), + river.database().timestamp(expiresAt), + id, + river.database().timestamp(previousTerm.electedAt()), + river.database().timestamp(now))) { + if (statement.executeUpdate() > 0) return previousTerm.electedAt(); + } + } + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "leader_elect"), + id, + river.database().timestamp(now), + river.database().timestamp(expiresAt)); + var rows = statement.executeQuery()) { + return rows.next() ? Database.instant(rows.getString("elected_at")) : null; + } + }); + // A lease is ours only after commit, including when regaining an expired term. + // Like Go, stop trusting it one second before its database expiry, measuring elapsed + // time from before the transaction so pool waits and slow commits consume the lease. + leaderTerm = + elected == null + ? null + : new LeadershipTerm( + elected, attemptStarted + config.serviceInterval.plusSeconds(9).toNanos()); + if (elected == null) return; + long committedAt = System.nanoTime(); + if (!leaderTerm.active(committedAt) + || (previousTerm != null && !previousTerm.active(committedAt))) { + resign(); + return; + } + if (!running.get()) return; + if (!elected.equals(initializedTerm)) { + periodicNext.clear(); + river.plugin().leadershipStarted(); + river.extensions().forEach(Extension::periodicStarted); + initializedTerm = elected; + } + Instant term = elected; + if (running.get() && maintenanceBusy.compareAndSet(false, true)) + try { + executor.submit( + () -> { + try { + services(now, term); + } catch (Exception error) { + report(error); + } finally { + maintenanceBusy.set(false); + } + }); + } catch (java.util.concurrent.RejectedExecutionException error) { + maintenanceBusy.set(false); + // Stop can close the executor after the running check while election is finishing. + if (running.get()) throw error; + } + } finally { + leadershipLock.unlock(); + } + } + + private void services(Instant now, Instant term) throws SQLException { + for (var periodic : config.periodic) { + if (!ownsTerm(term)) return; + Instant next = periodicNext.get(periodic.id); + if (next == null) + next = + periodic.runOnStart + ? now + : periodic + .schedule + .next(now.atOffset(java.time.ZoneOffset.UTC)) + .map(java.time.OffsetDateTime::toInstant) + .orElse(Instant.MAX); + if (!next.isAfter(now)) { + Instant following = + periodic + .schedule + .next(next.atOffset(java.time.ZoneOffset.UTC)) + .map(java.time.OffsetDateTime::toInstant) + .orElse(Instant.MAX); + if (!ownsTerm(term)) return; + periodic.insert(river, next); + next = following; + } + if (!ownsTerm(term)) return; + periodicNext.put(periodic.id, next); + } + if (!ownsTerm(term)) return; + schedule(now); + if (!ownsTerm(term)) return; + river.plugin().maintain(river, now); + if (!ownsTerm(term)) return; + rescue(now, term); + if (!ownsTerm(term)) return; + cleanNotifications(now); + if (!ownsTerm(term)) return; + clean(now); + if (!now.isBefore(nextReindex) && ownsTerm(term)) { + nextReindex = + config.reindexInterval == null + ? now.atOffset(java.time.ZoneOffset.UTC) + .toLocalDate() + .plusDays(1) + .atStartOfDay() + .toInstant(java.time.ZoneOffset.UTC) + : now.plus(config.reindexInterval); + reindex(); + } + } + + private boolean ownsTerm(Instant term) { + var current = leaderTerm; + return running.get() + && current != null + && term.equals(current.electedAt()) + && current.active(System.nanoTime()); + } + + private record LeadershipTerm(Instant electedAt, long trustedUntil) { + boolean active(long now) { + return trustedUntil - now > 0; + } + } + + private void clean(Instant now) { + if (river + .plugin() + .clean( + river, + now, + config.cancelledRetention, + config.completedRetention, + config.discardedRetention)) return; + river.transaction( + c -> { + Object cancelled = + config.cancelledRetention.isNegative() + ? null + : river.database().timestamp(now.minus(config.cancelledRetention)); + Object completed = + config.completedRetention.isNegative() + ? null + : river.database().timestamp(now.minus(config.completedRetention)); + Object discarded = + config.discardedRetention.isNegative() + ? null + : river.database().timestamp(now.minus(config.discardedRetention)); + try (var statement = + Sql.prepare( + c, Sql.query(river.database(), "clean_jobs"), cancelled, completed, discarded)) { + statement.executeUpdate(); + } + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "clean_queues"), + river.database().timestamp(now.minus(QUEUE_RETENTION)))) { + statement.executeUpdate(); + } + return null; + }); + } + + private void cleanNotifications(Instant now) { + if (river.database().dialect() != Database.Dialect.SQLITE) return; + river.transaction( + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "clean_notifications"), + river.database().timestamp(now.minus(Duration.ofMinutes(5))))) { + return statement.executeUpdate(); + } + }); + } + + private void reindex() throws SQLException { + if (river.database().dialect() != Database.Dialect.POSTGRES) return; + try (var c = river.database().connection()) { + for (String index : config.reindexNames) { + String qualified; + try (var statement = + Sql.prepare(c, Sql.query(river.database(), "reindex_candidate"), index, index); + var rows = statement.executeQuery()) { + if (!rows.next()) continue; + qualified = rows.getString(1); + } + try (var statement = c.createStatement()) { + statement.setQueryTimeout(300); + statement.execute(Sql.query(river.database(), "reindex").replace("{index}", qualified)); + } + } + } + } + + private void schedule(Instant now) { + river.transaction( + c -> { + if (river.database().dialect() == Database.Dialect.SQLITE) + try (var statement = Sql.prepare(c, Sql.query(river.database(), "writer_lock"))) { + statement.executeUpdate(); + } + var due = new ArrayList(); + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "schedule_select"), + river.database().timestamp(now)); + var rows = statement.executeQuery()) { + while (rows.next()) + due.add(new Scheduled(rows.getLong("id"), rows.getBytes("unique_key"))); + } + for (var job : due) { + boolean collision = false; + if (job.key() != null) + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "schedule_collision"), + job.id(), + job.key()); + var rows = statement.executeQuery()) { + collision = rows.next(); + } + var savepoint = c.setSavepoint(); + try { + if (!collision) + try (var statement = + Sql.prepare( + c, Sql.query(river.database(), "schedule_available"), job.id()); + var rows = statement.executeQuery()) { + if (rows.next()) river.notifyInsert(c, rows.getString(1)); + } + } catch (SQLException error) { + if (!"23505".equals(error.getSQLState())) throw error; + c.rollback(savepoint); + collision = true; + } finally { + c.releaseSavepoint(savepoint); + } + if (collision) + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "schedule_discard"), + river.database().timestamp(now), + job.id())) { + statement.executeUpdate(); + } + } + return null; + }); + } + + private record Scheduled(long id, byte[] key) {} + + private void rescue(Instant now, Instant term) { + if (river + .plugin() + .rescue( + river, + new Plugin.Rescue( + now, + now.minus(config.rescueAfter), + java.util.Set.copyOf(active.keySet()), + java.util.Set.copyOf(config.handlers.keySet()), + config.jobTimeout.isNegative(), + config.retryPolicy))) return; + long after = 0; + while (ownsTerm(term)) { + final long cursor = after; + var ready = new ArrayList(); + var batch = + river.transaction( + c -> { + // Reserve the SQLite writer before reading: another commit would make this + // snapshot impossible to upgrade when persisting the rescue. + if (river.database().dialect() == Database.Dialect.SQLITE) + try (var statement = Sql.prepare(c, Sql.query(river.database(), "writer_lock"))) { + statement.executeUpdate(); + } + var stuck = new ArrayList>(); + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "rescue_select"), + river.database().timestamp(now.minus(config.rescueAfter)), + cursor); + var rows = statement.executeQuery()) { + while (rows.next()) stuck.add(river.read(rows)); + } + for (var job : stuck) { + if (active.containsKey(job.id()) + || (config.jobTimeout.isNegative() + && config.handlers.containsKey(job.kind()))) continue; + boolean cancel = false; + if (job.metadata().path("cancel_attempted_at").isString()) + try { + cancel = + !Instant.parse(job.metadata().path("cancel_attempted_at").asString()) + .equals(Instant.parse("0001-01-01T00:00:00Z")); + } catch (RuntimeException ignored) { + } + Job.State state = + cancel + ? Job.State.CANCELLED + : !config.handlers.containsKey(job.kind()) + || job.attempt() >= job.maxAttempts() + ? Job.State.DISCARDED + : Job.State.RETRYABLE; + Instant scheduled = job.scheduledAt(); + if (!state.isFinalized()) { + Duration delay = retryDelay(job, now); + scheduled = now.plus(delay); + if (bypassScheduler(delay)) state = Job.State.AVAILABLE; + } + String error = + Json.encode( + new Job.AttemptError( + now, job.attempt(), "Stuck job rescued by JobRescuer", "")); + String metadata = + Json.encode( + Map.of( + Protocol.METADATA_RESCUE_COUNT, + job.metadata().path(Protocol.METADATA_RESCUE_COUNT).asLong(0) + 1)); + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "rescue_update"), + state.value(), + river.database().timestamp(state.isFinalized() ? now : null), + river.database().timestamp(scheduled), + job.attempt(), + metadata, + error, + error, + job.id(), + job.attempt(), + river.database().timestamp(job.attemptedAt())); + var rows = statement.executeQuery()) { + if (rows.next() && state == Job.State.AVAILABLE) + ready.add( + new FetchWakeup( + Database.instant(rows.getString("scheduled_at")), job.queue())); + } + } + return stuck; + }); + // Only committed rescues may wake fetching; use the database's timestamp precision. + fetchWakeups.addAll(ready); + if (!ready.isEmpty()) wake.release(); + if (batch.size() < 1000) break; + after = batch.getLast().id(); + } + } + + private void supervise() { + while (running.get() || dispatcherStopped.getCount() != 0 || !active.isEmpty()) { + for (var context : active.values()) { + // Attempts retain their deadlines while fetching is blocked or gracefully stopping. + if (!config.jobTimeout.isNegative() + && Duration.between(context.job().attemptedAt(), Instant.now()) + .compareTo(config.jobTimeout) + >= 0) context.requestCancellation(WorkContext.Cancellation.TIMEOUT); + if (context.forceIfStuck(config.stuckThreshold)) { + observe("stuck_job"); + var thread = activeThreads.get(context.job().id()); + if (thread != null) thread.interrupt(); + } + } + try { + TimeUnit.MILLISECONDS.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void observe(String event) { + for (var observer : config.observers) + try { + observer.accept(event); + } catch (Throwable error) { + report(error); + } + } + + private void publish(Event event) { + for (var subscriber : subscribers) + try { + subscriber.accept(event); + } catch (Throwable e) { + report(e); + } + } + + private void report(Throwable error) { + try { + config.errorHandler.accept(error); + } catch (Throwable handlerFailure) { + // Reporting failures must not strand claimed jobs or terminate a background service. + var logger = System.getLogger("com.riverqueue"); + logger.log(System.Logger.Level.WARNING, "River runtime failure", error); + logger.log(System.Logger.Level.WARNING, "River error handler failed", handlerFailure); + } + } + + private Duration retryDelay(Job job, Instant now) { + try { + var delay = Objects.requireNonNull(config.retryPolicy.delay(job), "Retry delay"); + if (delay.isNegative()) + throw new IllegalArgumentException("Retry delay must not be negative"); + now.plus(delay); + return delay; + } catch (Throwable error) { + report(new IllegalArgumentException("Retry policy failed; using the default delay", error)); + return defaultRetryPolicy.delay(job); + } + } + + private void resign() { + leadershipLock.lock(); + try { + if (leaderTerm != null) { + resigningTerm = leaderTerm.electedAt(); + leaderTerm = null; + initializedTerm = null; + leaderEligibleAt = System.nanoTime() + config.serviceInterval.toNanos(); + } + // Stop local services immediately, but retain the identity until deletion commits so a + // failed resignation can be retried without waiting for the lease to expire. + Instant elected = resigningTerm; + if (elected == null) return; + river.transaction( + c -> { + try (var statement = + Sql.prepare( + c, + Sql.query(river.database(), "leader_resign"), + id, + river.database().timestamp(elected))) { + if (statement.executeUpdate() > 0) river.notify(c, Protocol.resigned(id)); + } + return null; + }); + resigningTerm = null; + } finally { + leadershipLock.unlock(); + } + } + + static long nextSnoozeCount(JsonNode metadata) { + var value = metadata.path("snoozes"); + // Go's gjson treats true as one; Jackson's numeric coercion does not. + return (value.isBoolean() ? (value.asBoolean() ? 1 : 0) : value.asLong(0)) + 1; + } + + private static void validateQueue(String name, int workers) { + if (workers < 1) throw new IllegalArgumentException("maxWorkers must be positive"); + Client.validate(InsertOptions.builder().queue(name).build().resolve(InsertOptions.defaults())); + } + + /** Runtime configuration; no worker-specific implementation classes are needed. */ + public static final class Builder { + private Duration cancelledRetention = Duration.ofDays(1); + private Duration completedRetention = Duration.ofDays(1); + private Duration discardedRetention = Duration.ofDays(7); + private Duration reindexInterval; + private List reindexNames = + List.of( + "river_job_args_index", + "river_job_kind", + "river_job_metadata_index", + "river_job_pkey", + "river_job_prioritized_fetching_index", + "river_job_state_and_finalized_at_index", + "river_job_unique_idx"); + private boolean cancelOnError; + private boolean fetchOnlyKnownKinds; + private Consumer errorHandler = + error -> + System.getLogger("com.riverqueue") + .log(System.Logger.Level.WARNING, "River runtime failure", error); + private final Map> handlers = new java.util.LinkedHashMap<>(); + private String id; + private Duration jobTimeout = Duration.ofMinutes(1); + private boolean jobTimeoutConfigured; + private boolean leadership = true; + private Duration maintenanceInterval = Duration.ofSeconds(5); + private final List> observers = new ArrayList<>(); + private final List> periodic = new ArrayList<>(); + private Duration pollInterval = Duration.ofSeconds(1); + private boolean pollOnly; + private final Map queues = new java.util.LinkedHashMap<>(); + private RetryPolicy retryPolicy = RetryPolicy.defaults(); + private Duration rescueAfter; + private final Client river; + private Duration stopTimeout = Duration.ofSeconds(30); + private Duration serviceInterval = Duration.ofSeconds(1); + private Duration stuckThreshold = Duration.ofSeconds(5); + + Builder(Client river) { + this.river = river; + } + + private Builder(Builder source) { + this(source.river); + cancelledRetention = source.cancelledRetention; + completedRetention = source.completedRetention; + discardedRetention = source.discardedRetention; + reindexInterval = source.reindexInterval; + reindexNames = source.reindexNames; + cancelOnError = source.cancelOnError; + fetchOnlyKnownKinds = source.fetchOnlyKnownKinds; + errorHandler = source.errorHandler; + handlers.putAll(source.handlers); + id = source.id; + jobTimeout = source.jobTimeout; + jobTimeoutConfigured = source.jobTimeoutConfigured; + leadership = source.leadership; + maintenanceInterval = source.maintenanceInterval; + observers.addAll(source.observers); + periodic.addAll(source.periodic); + pollInterval = source.pollInterval; + pollOnly = source.pollOnly; + queues.putAll(source.queues); + retryPolicy = source.retryPolicy; + rescueAfter = source.rescueAfter; + stopTimeout = source.stopTimeout; + serviceInterval = source.serviceInterval; + stuckThreshold = source.stuckThreshold; + } + + public Builder retention(Duration cancelled, Duration completed, Duration discarded) { + cancelledRetention = duration(cancelled, "cancelled retention"); + completedRetention = duration(completed, "completed retention"); + discardedRetention = duration(discarded, "discarded retention"); + return this; + } + + public Builder reindex(List names, Duration interval) { + reindexNames = List.copyOf(names); + reindexInterval = interval == null ? null : positive(interval, "reindex interval"); + return this; + } + + public Builder cancelOnError(boolean value) { + cancelOnError = value; + return this; + } + + public Builder fetchOnlyKnownKinds(boolean value) { + fetchOnlyKnownKinds = value; + return this; + } + + /** Reports runtime failures. Failures in this callback are logged without stopping workers. */ + public Builder errorHandler(Consumer value) { + errorHandler = Objects.requireNonNull(value, "errorHandler"); + return this; + } + + /** Adds a worker handler for a job type. Each job kind may be added only once. */ + public Builder add(JobType type, Handler handler) { + Objects.requireNonNull(handler, "handler"); + if (handlers.putIfAbsent(type.kind(), new Registration<>(type, handler)) != null) + throw new IllegalArgumentException("Duplicate worker kind"); + return this; + } + + /** Overrides the runtime ID (at most 100 UTF-8 bytes). Each start otherwise generates an ID. */ + public Builder id(String value) { + if (value.isEmpty() || value.getBytes(StandardCharsets.UTF_8).length > 100) + throw new IllegalArgumentException("Invalid client ID"); + id = value; + return this; + } + + /** Sets the attempt timeout; a negative duration disables it, including during stop. */ + public Builder jobTimeout(Duration value) { + duration(value, "jobTimeout"); + if (value.isZero()) + throw new IllegalArgumentException( + "Job timeout must be positive or negative to disable it"); + jobTimeout = value; + jobTimeoutConfigured = true; + return this; + } + + public Builder leadership(boolean value) { + leadership = value; + return this; + } + + public Builder maintenanceInterval(Duration value) { + maintenanceInterval = positive(value, "maintenanceInterval"); + return this; + } + + /** Observes runtime activity. Callback failures are reported without affecting execution. */ + public Builder observe(Consumer value) { + observers.add(Objects.requireNonNull(value, "observer")); + return this; + } + + public Builder periodic( + String id, + Schedule schedule, + JobType type, + A args, + InsertOptions options, + boolean runOnStart) { + if (id == null + || id.getBytes(StandardCharsets.UTF_8).length > 127 + || !Protocol.USER_SPECIFIED_ID_OR_KIND.matcher(id).matches()) + throw new IllegalArgumentException("Invalid periodic job ID"); + if (periodic.stream().anyMatch(job -> job.id().equals(id))) + throw new IllegalArgumentException("Duplicate periodic job ID: " + id); + periodic.add( + new Periodic<>( + id, + Objects.requireNonNull(schedule), + Objects.requireNonNull(type), + Objects.requireNonNull(args), + Objects.requireNonNull(options), + runOnStart)); + return this; + } + + public Builder pollInterval(Duration value) { + pollInterval = positive(value, "pollInterval"); + return this; + } + + public Builder pollOnly(boolean value) { + pollOnly = value; + return this; + } + + public Builder queue(String name, int maxWorkers) { + validateQueue(name, maxWorkers); + queues.put(name, maxWorkers); + return this; + } + + public Builder retryPolicy(RetryPolicy value) { + retryPolicy = Objects.requireNonNull(value, "retryPolicy"); + return this; + } + + /** + * Sets when abandoned jobs may be rescued. Must not be shorter than the attempt timeout. + * Defaults to one hour, plus an explicitly configured positive job timeout. + */ + public Builder rescueAfter(Duration value) { + rescueAfter = positive(value, "rescueAfter"); + return this; + } + + /** + * Sets the election and heartbeat interval; leadership leases include ten seconds of margin. + * Must be positive and shorter than one day so active queues do not expire between heartbeats. + */ + public Builder serviceInterval(Duration value) { + positive(value, "serviceInterval"); + if (value.compareTo(QUEUE_RETENTION) >= 0) + throw new IllegalArgumentException( + "serviceInterval must be shorter than the queue retention period of one day"); + serviceInterval = value; + return this; + } + + public Builder stopTimeout(Duration value) { + stopTimeout = positive(value, "stopTimeout"); + return this; + } + + public Builder stuckThreshold(Duration value) { + stuckThreshold = positive(value, "stuckThreshold"); + return this; + } + + private static Duration positive(Duration value, String name) { + duration(value, name); + if (value.isNegative() || value.isZero()) + throw new IllegalArgumentException(name + " must be positive"); + return value; + } + + private static Duration duration(Duration value, String name) { + Objects.requireNonNull(value, name); + try { + value.toNanos(); + } catch (ArithmeticException error) { + throw new IllegalArgumentException(name + " must fit in signed 64-bit nanoseconds", error); + } + return value; + } + + public Workers start() { + if (!leadership && !periodic.isEmpty()) + throw new IllegalArgumentException("Periodic jobs require leader election"); + var config = new Builder(this); + if (config.rescueAfter == null) + config.rescueAfter = + positive( + !jobTimeoutConfigured || jobTimeout.isNegative() + ? Duration.ofHours(1) + : jobTimeout.plus(Duration.ofHours(1)), + "rescueAfter"); + if (config.rescueAfter.compareTo(jobTimeout) < 0) + throw new IllegalArgumentException("rescueAfter must not be less than jobTimeout"); + river + .plugin() + .starting(leadership, cancelledRetention, completedRetention, discardedRetention); + return new Workers(config); + } + } + + /** A job or queue event. The payload corresponding to the event kind is non-null. */ + public record Event(EventKind kind, Job job, Queues.Queue queue) { + public Event(EventKind kind, Job job) { + this(kind, job, null); + } + } + + /** Event names shared with Go and the conformance protocol. */ + public enum EventKind { + JOB_CANCELLED, + JOB_COMPLETED, + JOB_FAILED, + JOB_INTERRUPTED, + JOB_SNOOZED, + QUEUE_PAUSED, + QUEUE_RESUMED; + + @com.fasterxml.jackson.annotation.JsonValue + public String value() { + return name().toLowerCase(java.util.Locale.ROOT); + } + } + + /** An idempotent subscription handle whose close method cannot throw a checked exception. */ + @FunctionalInterface + public interface Subscription extends AutoCloseable { + @Override + void close(); + } + + private record Completion( + Job original, + Object[] params, + java.util.concurrent.CompletableFuture> done) {} + + @FunctionalInterface + public interface Handler { + void work(WorkContext context) throws Exception; + } + + private record Registration(JobType type, Handler handler) {} + + private record Periodic( + String id, + Schedule schedule, + JobType type, + A args, + InsertOptions options, + boolean runOnStart) { + void insert(Client river, Instant scheduledAt) { + var base = options.resolve(type.defaults()); + var metadata = (ObjectNode) base.metadata().deepCopy(); + metadata.put("periodic", true).put(Protocol.METADATA_PERIODIC_JOB_ID, id); + var options = + new InsertOptions( + base.maxAttempts(), + metadata, + base.pending(), + base.priority(), + base.queue(), + base.scheduledAt(), + base.tags(), + base.unique()); + // A periodic occurrence supplies the default date without turning an available job into + // a scheduled one or overriding an explicitly configured date, matching Go's enqueuer. + river.transaction(c -> river.insert(c, type, args, options, scheduledAt)); + } + } +} diff --git a/java/river/src/main/java/com/riverqueue/package-info.java b/java/river/src/main/java/com/riverqueue/package-info.java new file mode 100644 index 000000000..3e3658b85 --- /dev/null +++ b/java/river/src/main/java/com/riverqueue/package-info.java @@ -0,0 +1,20 @@ +/** + * Transactional River jobs on PostgreSQL and SQLite. Define a {@link com.riverqueue.JobType}, + * insert through a {@link com.riverqueue.Client}, and register worker lambdas through {@link + * com.riverqueue.Workers.Builder}. Only a running {@code Workers} instance owns resources; close it + * or call {@code stop()} when the application stops. + * + *

Connection-first overloads require an existing JDBC transaction with auto-commit disabled. + * They use savepoints and leave commit, rollback, and close to the caller. Other overloads open and + * close their own connections. Database errors become {@link com.riverqueue.RiverException}; + * application callbacks may throw checked exceptions, including {@link java.sql.SQLException}. + * + *

Builders are mutable configuration objects. Jobs are database snapshots; changing their JSON + * values does not update the database. Record components may be null where River has no + * corresponding value, such as a job's finalization time. Missing jobs throw an exception with code + * {@code NOT_FOUND}. + * + *

{@link com.riverqueue.Plugin}, {@link com.riverqueue.Client.Driver}, and members explicitly + * marked internal are integration seams for the matching Pro release, not stable application APIs. + */ +package com.riverqueue; diff --git a/java/river/src/main/resources/com/riverqueue/migration/index.txt b/java/river/src/main/resources/com/riverqueue/migration/index.txt new file mode 100644 index 000000000..d10dffdb6 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/index.txt @@ -0,0 +1,8 @@ +001_create_river_migration +002_initial_schema +003_river_job_tags_non_null +004_pending_and_more +005_migration_unique_client +006_bulk_unique +007_notification_outbox_sqlite_jsonb_and_sql_cleanup +008_job_id_autoincrement diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/001_create_river_migration.down.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/001_create_river_migration.down.sql new file mode 100644 index 000000000..8bfe82027 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/001_create_river_migration.down.sql @@ -0,0 +1 @@ +DROP TABLE /* TEMPLATE: schema */river_migration; \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/001_create_river_migration.up.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/001_create_river_migration.up.sql new file mode 100644 index 000000000..27006d562 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/001_create_river_migration.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE /* TEMPLATE: schema */river_migration( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT NOW(), + version bigint NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX ON /* TEMPLATE: schema */river_migration USING btree(version); \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/002_initial_schema.down.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/002_initial_schema.down.sql new file mode 100644 index 000000000..d334d8a65 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/002_initial_schema.down.sql @@ -0,0 +1,5 @@ +DROP TABLE /* TEMPLATE: schema */river_job; +DROP FUNCTION /* TEMPLATE: schema */river_job_notify; +DROP TYPE /* TEMPLATE: schema */river_job_state; + +DROP TABLE /* TEMPLATE: schema */river_leader; \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/002_initial_schema.up.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/002_initial_schema.up.sql new file mode 100644 index 000000000..7fbca71b4 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/002_initial_schema.up.sql @@ -0,0 +1,96 @@ +CREATE TYPE /* TEMPLATE: schema */river_job_state AS ENUM( + 'available', + 'cancelled', + 'completed', + 'discarded', + 'retryable', + 'running', + 'scheduled' +); + +CREATE TABLE /* TEMPLATE: schema */river_job( + -- 8 bytes + id bigserial PRIMARY KEY, + + -- 8 bytes (4 bytes + 2 bytes + 2 bytes) + -- + -- `state` is kept near the top of the table for operator convenience -- when + -- looking at jobs with `SELECT *` it'll appear first after ID. The other two + -- fields aren't as important but are kept adjacent to `state` for alignment + -- to get an 8-byte block. + state /* TEMPLATE: schema */river_job_state NOT NULL DEFAULT 'available', + attempt smallint NOT NULL DEFAULT 0, + max_attempts smallint NOT NULL, + + -- 8 bytes each (no alignment needed) + attempted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + finalized_at timestamptz, + scheduled_at timestamptz NOT NULL DEFAULT NOW(), + + -- 2 bytes (some wasted padding probably) + priority smallint NOT NULL DEFAULT 1, + + -- types stored out-of-band + args jsonb, + attempted_by text[], + errors jsonb[], + kind text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}', + queue text NOT NULL DEFAULT 'default', + tags varchar(255)[], + + CONSTRAINT finalized_or_finalized_at_null CHECK ((state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL), + CONSTRAINT max_attempts_is_positive CHECK (max_attempts > 0), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (char_length(queue) > 0 AND char_length(queue) < 128), + CONSTRAINT kind_length CHECK (char_length(kind) > 0 AND char_length(kind) < 128) +); + +-- We may want to consider adding another property here after `kind` if it seems +-- like it'd be useful for something. +CREATE INDEX river_job_kind ON /* TEMPLATE: schema */river_job USING btree(kind); + +CREATE INDEX river_job_state_and_finalized_at_index ON /* TEMPLATE: schema */river_job USING btree(state, finalized_at) WHERE finalized_at IS NOT NULL; + +CREATE INDEX river_job_prioritized_fetching_index ON /* TEMPLATE: schema */river_job USING btree(state, queue, priority, scheduled_at, id); + +CREATE INDEX river_job_args_index ON /* TEMPLATE: schema */river_job USING GIN(args); + +CREATE INDEX river_job_metadata_index ON /* TEMPLATE: schema */river_job USING GIN(metadata); + +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_notify() + RETURNS TRIGGER + AS $$ +DECLARE + payload json; +BEGIN + IF NEW.state = 'available' THEN + -- Notify will coalesce duplicate notifications within a transaction, so + -- keep these payloads generalized: + payload = json_build_object('queue', NEW.queue); + PERFORM + pg_notify('river_insert', payload::text); + END IF; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER river_notify + AFTER INSERT ON /* TEMPLATE: schema */river_job + FOR EACH ROW + EXECUTE PROCEDURE /* TEMPLATE: schema */river_job_notify(); + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_leader( + -- 8 bytes each (no alignment needed) + elected_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + + -- types stored out-of-band + leader_id text NOT NULL, + name text PRIMARY KEY, + + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT leader_id_length CHECK (char_length(leader_id) > 0 AND char_length(leader_id) < 128) +); diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/003_river_job_tags_non_null.down.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/003_river_job_tags_non_null.down.sql new file mode 100644 index 000000000..acef65cb9 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/003_river_job_tags_non_null.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN tags DROP NOT NULL, + ALTER COLUMN tags DROP DEFAULT; diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/003_river_job_tags_non_null.up.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/003_river_job_tags_non_null.up.sql new file mode 100644 index 000000000..0a472dde4 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/003_river_job_tags_non_null.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN tags SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET tags = '{}' WHERE tags IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN tags SET NOT NULL; diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/004_pending_and_more.down.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/004_pending_and_more.down.sql new file mode 100644 index 000000000..1b7ec7e84 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/004_pending_and_more.down.sql @@ -0,0 +1,42 @@ +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args DROP NOT NULL; + +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata DROP NOT NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata DROP DEFAULT; + +-- It is not possible to safely remove 'pending' from the river_job_state enum, +-- so leave it in place. + +ALTER TABLE /* TEMPLATE: schema */river_job DROP CONSTRAINT finalized_or_finalized_at_null; +ALTER TABLE /* TEMPLATE: schema */river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK ( + (state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL +); + +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_notify() + RETURNS TRIGGER + AS $$ +DECLARE + payload json; +BEGIN + IF NEW.state = 'available' THEN + -- Notify will coalesce duplicate notifications within a transaction, so + -- keep these payloads generalized: + payload = json_build_object('queue', NEW.queue); + PERFORM + pg_notify('river_insert', payload::text); + END IF; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER river_notify + AFTER INSERT ON /* TEMPLATE: schema */river_job + FOR EACH ROW + EXECUTE PROCEDURE /* TEMPLATE: schema */river_job_notify(); + +DROP TABLE /* TEMPLATE: schema */river_queue; + +ALTER TABLE /* TEMPLATE: schema */river_leader + ALTER COLUMN name DROP DEFAULT, + DROP CONSTRAINT name_length, + ADD CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128); \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/004_pending_and_more.up.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/004_pending_and_more.up.sql new file mode 100644 index 000000000..9f5e47bb1 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/004_pending_and_more.up.sql @@ -0,0 +1,45 @@ +-- The args column never had a NOT NULL constraint or default value at the +-- database level, though we tried to ensure one at the application level. +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET args = '{}' WHERE args IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args SET NOT NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args DROP DEFAULT; + +-- The metadata column never had a NOT NULL constraint or default value at the +-- database level, though we tried to ensure one at the application level. +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET metadata = '{}' WHERE metadata IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata SET NOT NULL; + +-- The 'pending' job state will be used for upcoming functionality: +ALTER TYPE /* TEMPLATE: schema */river_job_state ADD VALUE IF NOT EXISTS 'pending' AFTER 'discarded'; + +ALTER TABLE /* TEMPLATE: schema */river_job DROP CONSTRAINT finalized_or_finalized_at_null; +ALTER TABLE /* TEMPLATE: schema */river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) +); + +DROP TRIGGER river_notify ON /* TEMPLATE: schema */river_job; +DROP FUNCTION /* TEMPLATE: schema */river_job_notify; + +-- +-- Create table `river_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}' ::jsonb, + paused_at timestamptz, + updated_at timestamptz NOT NULL +); + +-- +-- Alter `river_leader` to add a default value of 'default` to `name`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_leader + ALTER COLUMN name SET DEFAULT 'default', + DROP CONSTRAINT name_length, + ADD CONSTRAINT name_length CHECK (name = 'default'); \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/005_migration_unique_client.down.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/005_migration_unique_client.down.sql new file mode 100644 index 000000000..b8e041d54 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/005_migration_unique_client.down.sql @@ -0,0 +1,57 @@ +-- +-- Revert to migration table based only on `(version)`. +-- +-- If any non-main migrations are present, 005 is considered irreversible. +-- + +DO +$body$ +BEGIN + -- Tolerate users who may be using their own migration system rather than + -- River's. If they are, they will have skipped version 001 containing + -- `CREATE TABLE river_migration`, so this table won't exist. + IF (SELECT to_regclass('/* TEMPLATE: schema */river_migration') IS NOT NULL) THEN + IF EXISTS ( + SELECT * + FROM /* TEMPLATE: schema */river_migration + WHERE line <> 'main' + ) THEN + RAISE EXCEPTION 'Found non-main migration lines in the database; version 005 migration is irreversible because it would result in loss of migration information.'; + END IF; + + ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + + CREATE TABLE /* TEMPLATE: schema */river_migration( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT NOW(), + version bigint NOT NULL, + CONSTRAINT version CHECK (version >= 1) + ); + + CREATE UNIQUE INDEX ON /* TEMPLATE: schema */river_migration USING btree(version); + + INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, version) + SELECT created_at, version + FROM /* TEMPLATE: schema */river_migration_old; + + DROP TABLE /* TEMPLATE: schema */river_migration_old; + END IF; +END; +$body$ +LANGUAGE 'plpgsql'; + +-- +-- Drop `river_job.unique_key`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN unique_key; + +-- +-- Drop `river_client` and derivative. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/005_migration_unique_client.up.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/005_migration_unique_client.up.sql new file mode 100644 index 000000000..e0f1711ec --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/005_migration_unique_client.up.sql @@ -0,0 +1,79 @@ +-- +-- Rebuild the migration table so it's based on `(line, version)`. +-- + +DO +$body$ +BEGIN + -- Tolerate users who may be using their own migration system rather than + -- River's. If they are, they will have skipped version 001 containing + -- `CREATE TABLE river_migration`, so this table won't exist. + IF (SELECT to_regclass('/* TEMPLATE: schema */river_migration') IS NOT NULL) THEN + ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + + CREATE TABLE /* TEMPLATE: schema */river_migration( + line TEXT NOT NULL, + version bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + CONSTRAINT line_length CHECK (char_length(line) > 0 AND char_length(line) < 128), + CONSTRAINT version_gte_1 CHECK (version >= 1), + PRIMARY KEY (line, version) + ); + + INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, line, version) + SELECT created_at, 'main', version + FROM /* TEMPLATE: schema */river_migration_old; + + DROP TABLE /* TEMPLATE: schema */river_migration_old; + END IF; +END; +$body$ +LANGUAGE 'plpgsql'; + +-- +-- Add `river_job.unique_key` and bring up an index on it. +-- + +-- These statements use `IF NOT EXISTS` to allow users with a `river_job` table +-- of non-trivial size to build the index `CONCURRENTLY` out of band of this +-- migration, then follow by completing the migration. +ALTER TABLE /* TEMPLATE: schema */river_job + ADD COLUMN IF NOT EXISTS unique_key bytea; + +CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON /* TEMPLATE: schema */river_job (kind, unique_key) WHERE unique_key IS NOT NULL; + +-- +-- Create `river_client` and derivative. +-- +-- This feature hasn't quite yet been implemented, but we're taking advantage of +-- the migration to add the schema early so that we can add it later without an +-- additional migration. +-- + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}', + paused_at timestamptz, + updated_at timestamptz NOT NULL, + CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128) +); + +-- Differs from `river_queue` in that it tracks the queue state for a particular +-- active client. +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES /* TEMPLATE: schema */river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + max_workers bigint NOT NULL DEFAULT 0, + metadata jsonb NOT NULL DEFAULT '{}', + num_jobs_completed bigint NOT NULL DEFAULT 0, + num_jobs_running bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/006_bulk_unique.down.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/006_bulk_unique.down.sql new file mode 100644 index 000000000..26cd84345 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/006_bulk_unique.down.sql @@ -0,0 +1,16 @@ + +-- +-- Drop `river_job.unique_states` and its index. +-- + +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN unique_states; + +CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON /* TEMPLATE: schema */river_job (kind, unique_key) WHERE unique_key IS NOT NULL; + +-- +-- Drop `river_job_state_in_bitmask` function. +-- +DROP FUNCTION /* TEMPLATE: schema */river_job_state_in_bitmask; diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/006_bulk_unique.up.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/006_bulk_unique.up.sql new file mode 100644 index 000000000..ef96a19f9 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/006_bulk_unique.up.sql @@ -0,0 +1,40 @@ +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_state_in_bitmask(bitmask BIT(8), state /* TEMPLATE: schema */river_job_state) +RETURNS boolean +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE state + WHEN 'available' THEN get_bit(bitmask, 7) + WHEN 'cancelled' THEN get_bit(bitmask, 6) + WHEN 'completed' THEN get_bit(bitmask, 5) + WHEN 'discarded' THEN get_bit(bitmask, 4) + WHEN 'pending' THEN get_bit(bitmask, 3) + WHEN 'retryable' THEN get_bit(bitmask, 2) + WHEN 'running' THEN get_bit(bitmask, 1) + WHEN 'scheduled' THEN get_bit(bitmask, 0) + ELSE 0 + END = 1; +$$; + +-- +-- Add `river_job.unique_states` and bring up an index on it. +-- +-- This column may exist already if users manually created the column and index +-- as instructed in the changelog so the index could be created `CONCURRENTLY`. +-- +ALTER TABLE /* TEMPLATE: schema */river_job ADD COLUMN IF NOT EXISTS unique_states BIT(8); + +-- This statement uses `IF NOT EXISTS` to allow users with a `river_job` table +-- of non-trivial size to build the index `CONCURRENTLY` out of band of this +-- migration, then follow by completing the migration. +CREATE UNIQUE INDEX IF NOT EXISTS river_job_unique_idx ON /* TEMPLATE: schema */river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND /* TEMPLATE: schema */river_job_state_in_bitmask(unique_states, state); + +-- Remove the old unique index. Users who are actively using the unique jobs +-- feature and who wish to avoid deploy downtime may want od drop this in a +-- subsequent migration once all jobs using the old unique system have been +-- completed (i.e. no more rows with non-null unique_key and null +-- unique_states). +DROP INDEX /* TEMPLATE: schema */river_job_kind_unique_key_idx; diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql new file mode 100644 index 000000000..bed717f87 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql @@ -0,0 +1,56 @@ +-- +-- SQL cleanup rollback. +-- + +-- +-- Add back unused tables `river_client` and `river_client_queue`. +-- + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}', + paused_at timestamptz, + updated_at timestamptz NOT NULL, + CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128) +); + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES /* TEMPLATE: schema */river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + max_workers bigint NOT NULL DEFAULT 0, + metadata jsonb NOT NULL DEFAULT '{}', + num_jobs_completed bigint NOT NULL DEFAULT 0, + num_jobs_running bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +-- +-- Revert addition of `DEFAULT 25` to `river_job.max_attempts`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN max_attempts DROP DEFAULT; + +-- +-- Changes `river_queue.updated_at` to revert the default of `CURRENT_TIMESTAMP`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue + ALTER COLUMN updated_at DROP DEFAULT; + +-- +-- SQLite JSONB conversion rollback. +-- +-- No-op. PostgreSQL already stores River JSON columns as jsonb. + +-- +-- Notification outbox rollback. +-- + +DROP TABLE /* TEMPLATE: schema */river_notification; diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql new file mode 100644 index 000000000..39e3249c9 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql @@ -0,0 +1,44 @@ +-- +-- Notification outbox. +-- + +CREATE TABLE /* TEMPLATE: schema */river_notification ( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now(), + payload text NOT NULL, + topic text NOT NULL, + CONSTRAINT topic_length CHECK (length(topic) > 0 AND length(topic) < 128) +); + +CREATE INDEX river_notification_created_at_idx ON /* TEMPLATE: schema */river_notification (created_at); +CREATE INDEX river_notification_topic_id_idx ON /* TEMPLATE: schema */river_notification (topic, id); + +-- +-- SQLite JSONB conversion. +-- +-- No-op. PostgreSQL already stores River JSON columns as jsonb. + +-- +-- SQL cleanup. +-- + +-- +-- Drop unused tables `river_client` and `river_client_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; + +-- +-- Adds `DEFAULT 25` to `river_job.max_attempts`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN max_attempts SET DEFAULT 25; + +-- +-- Changes `river_queue.updated_at` to have a default of `CURRENT_TIMESTAMP`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue + ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/008_job_id_autoincrement.down.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/008_job_id_autoincrement.down.sql new file mode 100644 index 000000000..695357bb8 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/008_job_id_autoincrement.down.sql @@ -0,0 +1,3 @@ +-- No-op. PostgreSQL sequences already prevent automatically generated job IDs +-- from being reused. +SELECT 1; diff --git a/java/river/src/main/resources/com/riverqueue/migration/postgres/008_job_id_autoincrement.up.sql b/java/river/src/main/resources/com/riverqueue/migration/postgres/008_job_id_autoincrement.up.sql new file mode 100644 index 000000000..695357bb8 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/postgres/008_job_id_autoincrement.up.sql @@ -0,0 +1,3 @@ +-- No-op. PostgreSQL sequences already prevent automatically generated job IDs +-- from being reused. +SELECT 1; diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/001_create_river_migration.down.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/001_create_river_migration.down.sql new file mode 100644 index 000000000..8bfe82027 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/001_create_river_migration.down.sql @@ -0,0 +1 @@ +DROP TABLE /* TEMPLATE: schema */river_migration; \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/001_create_river_migration.up.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/001_create_river_migration.up.sql new file mode 100644 index 000000000..bdaf09339 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/001_create_river_migration.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE /* TEMPLATE: schema */river_migration ( + id integer PRIMARY KEY, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + version integer NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_migration_version_idx ON river_migration (version); \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/002_initial_schema.down.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/002_initial_schema.down.sql new file mode 100644 index 000000000..cbdd56dea --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/002_initial_schema.down.sql @@ -0,0 +1,8 @@ +-- +-- Normally `river_job` and `river_job_notify()` are dropped here, but since +-- SQLite was added well after 002 came about, we push that to version 006 index. +-- + +DROP TABLE /* TEMPLATE: schema */river_job; + +DROP TABLE /* TEMPLATE: schema */river_leader; \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/002_initial_schema.up.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/002_initial_schema.up.sql new file mode 100644 index 000000000..043facf29 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/002_initial_schema.up.sql @@ -0,0 +1,19 @@ +-- +-- Normally `river_job` and `river_job_notify()` are raised here, but since +-- SQLite was added well after 002 came about, we push that to version 006 index. +-- + +-- Dummy `river_job` table so that there's something to truncate in tests when +-- migrated to this version specifically. +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY +); + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL, + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/003_river_job_tags_non_null.down.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/003_river_job_tags_non_null.down.sql new file mode 100644 index 000000000..8d314cf06 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/003_river_job_tags_non_null.down.sql @@ -0,0 +1,6 @@ +-- +-- Normally `river_job.tags` is set back to nullable here, but since SQLite was +-- added well after 003 came about, we push that to version 006 index. +-- + +SELECT 1; diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/003_river_job_tags_non_null.up.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/003_river_job_tags_non_null.up.sql new file mode 100644 index 000000000..d4e1e2404 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/003_river_job_tags_non_null.up.sql @@ -0,0 +1,6 @@ +-- +-- Normally `river_job.tags` is set to `NOT NULL` with a `DEFAULT` here, but since +-- SQLite was added well after 003 came about, we push that to version 006 index. +-- + +SELECT 1; diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/004_pending_and_more.down.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/004_pending_and_more.down.sql new file mode 100644 index 000000000..c64554441 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/004_pending_and_more.down.sql @@ -0,0 +1,26 @@ +-- +-- Normally, args and metadata both become `NOT NULL`, `pending` is added, and +-- the constraint `finalized_at` is changed, but because SQLite was added later, +-- we've just pushed all of this to an initial `river_job` creation in 006. +-- + +-- +-- Drop `river_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_queue; + +-- +-- Reverse changes to `river_leader`. +-- + +DROP TABLE /* TEMPLATE: schema */river_leader; + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL, + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/004_pending_and_more.up.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/004_pending_and_more.up.sql new file mode 100644 index 000000000..254e1f7a3 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/004_pending_and_more.up.sql @@ -0,0 +1,33 @@ +-- +-- Normally, args and metadata both become `NOT NULL`, `pending` is added, and +-- the constraint `finalized_at` is changed, but because SQLite was added later, +-- we've just pushed all of this to an initial `river_job` creation in 006. +-- + +-- +-- Create table `river_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL +); + +-- +-- Alter `river_leader` to add a default value of 'default` to `name`. SQLite +-- doesn't allow schema modifications, so this redefines the table entirely. +-- + +DROP TABLE /* TEMPLATE: schema */river_leader; + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL DEFAULT 'default' CHECK (name = 'default'), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/005_migration_unique_client.down.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/005_migration_unique_client.down.sql new file mode 100644 index 000000000..d94787d3f --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/005_migration_unique_client.down.sql @@ -0,0 +1,37 @@ +-- +-- Revert to migration table based only on `(version)`. +-- +-- If any non-main migrations are present, 005 is considered irreversible. +-- + +ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + +CREATE TABLE /* TEMPLATE: schema */river_migration ( + id integer PRIMARY KEY, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + version integer NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_migration_version_idx ON river_migration (version); + +INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, version) +SELECT created_at, version +FROM /* TEMPLATE: schema */river_migration_old; + +DROP TABLE /* TEMPLATE: schema */river_migration_old; + +-- +-- Normally, `unique_key` and an index are added here, but because SQLite was +-- added later, we've just pushed all of this to an initial `river_job` creation +-- in 006. +-- + +-- +-- Drop `river_client` and derivative. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/005_migration_unique_client.up.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/005_migration_unique_client.up.sql new file mode 100644 index 000000000..dc3273349 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/005_migration_unique_client.up.sql @@ -0,0 +1,64 @@ +-- +-- Rebuild the migration table so it's based on `(line, version)`. +-- + +DROP INDEX /* TEMPLATE: schema */river_migration_version_idx; + +ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + +CREATE TABLE /* TEMPLATE: schema */river_migration ( + line text NOT NULL, + version integer NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT line_length CHECK (length(line) > 0 AND length(line) < 128), + CONSTRAINT version_gte_1 CHECK (version >= 1), + PRIMARY KEY (line, version) +); + +INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, line, version) +SELECT created_at, 'main', version +FROM /* TEMPLATE: schema */river_migration_old; + +DROP TABLE /* TEMPLATE: schema */river_migration_old; + +-- +-- Normally, `unique_key` and an index are added here, but because SQLite was +-- added later, we've just pushed all of this to an initial `river_job` creation +-- in 006. +-- + +-- +-- Create `river_client` and derivative. +-- +-- This feature hasn't quite yet been implemented, but we're taking advantage of +-- the migration to add the schema early so that we can add it later without an +-- additional migration. +-- + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +-- Differs from `river_queue` in that it tracks the queue state for a particular +-- active client. +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (json('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); \ No newline at end of file diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/006_bulk_unique.down.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/006_bulk_unique.down.sql new file mode 100644 index 000000000..a8d273f84 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/006_bulk_unique.down.sql @@ -0,0 +1,7 @@ +DROP TABLE /* TEMPLATE: schema */river_job; + +-- Dummy `river_job` table so that there's something to truncate in tests when +-- migrated to this version specifically. +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY +); diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/006_bulk_unique.up.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/006_bulk_unique.up.sql new file mode 100644 index 000000000..528a4680e --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/006_bulk_unique.up.sql @@ -0,0 +1,63 @@ +-- Only drops the trivial `river_job` we created in 002 which puts a placeholder +-- in place so that the right tables exist in the right versions. We don't +-- bother migrating any job data because it's not possible to have had any real +-- jobs by that point because this version (006) preexists the addition of SQLite. +DROP TABLE /* TEMPLATE: schema */river_job; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT '{}', + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (json('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (json('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +-- All these indexes are normally brought up in version 002. +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); + +-- Not raised because SQLite doesn't support Gin indexes. These aren't used in +-- River anyway. +-- CREATE INDEX river_job_args_index ON /* TEMPLATE: schema */river_job USING GIN(args); +-- CREATE INDEX river_job_metadata_index ON /* TEMPLATE: schema */river_job USING GIN(metadata); + +-- SQLite doesn't support SQL functions, so where the bit extraction logic below +-- goes in the `river_job_state_in_bitmask` function in Postgres, here it's +-- baked right into the index. Use of helpers that don't exist in SQLite like +-- `get_bit` are also dropped by necessity. +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql new file mode 100644 index 000000000..1e3bcffb0 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql @@ -0,0 +1,255 @@ +-- +-- SQL cleanup rollback. +-- + +-- +-- Add back unused tables `river_client` and `river_client_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +-- +-- SQLite JSONB conversion rollback. +-- +-- Convert JSONB binary columns back to JSON text format and restore json() +-- defaults. The `river_job` rebuild also reverts the addition of `DEFAULT 25` +-- to `river_job.max_attempts`. +-- +-- SQLite doesn't allow `ALTER TABLE ADD COLUMN` with non-constant defaults like +-- `json('{}')`, so rebuild each affected table instead. +-- + +-- +-- river_job +-- + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT '{}', + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (json('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (json('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + json(args), + attempt, + attempted_at, + CASE WHEN attempted_by IS NULL THEN NULL ELSE json(attempted_by) END, + created_at, + CASE WHEN errors IS NULL THEN NULL ELSE json(errors) END, + finalized_at, + kind, + max_attempts, + json(metadata), + priority, + queue, + state, + scheduled_at, + json(tags), + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; + +-- +-- river_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue RENAME TO river_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL +); + +INSERT INTO /* TEMPLATE: schema */river_queue ( + name, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + name, + created_at, + json(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_queue_old; + +-- +-- river_client +-- + +ALTER TABLE /* TEMPLATE: schema */river_client RENAME TO river_client_old; + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +INSERT INTO /* TEMPLATE: schema */river_client ( + id, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + id, + created_at, + json(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_client_old; + +-- +-- river_client_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_client_queue RENAME TO river_client_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (json('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +INSERT INTO /* TEMPLATE: schema */river_client_queue ( + river_client_id, + name, + created_at, + max_workers, + metadata, + num_jobs_completed, + num_jobs_running, + updated_at +) +SELECT + river_client_id, + name, + created_at, + max_workers, + json(metadata), + num_jobs_completed, + num_jobs_running, + updated_at +FROM /* TEMPLATE: schema */river_client_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_client_queue_old; +DROP TABLE /* TEMPLATE: schema */river_client_old; + +-- +-- Notification outbox rollback. +-- + +DROP TABLE /* TEMPLATE: schema */river_notification; diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql new file mode 100644 index 000000000..b1ca9479e --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql @@ -0,0 +1,261 @@ +-- +-- Notification outbox. +-- + +CREATE TABLE /* TEMPLATE: schema */river_notification ( + id integer PRIMARY KEY AUTOINCREMENT, + created_at timestamp NOT NULL DEFAULT (datetime('now', 'subsec')), + payload text NOT NULL, + topic text NOT NULL, + CONSTRAINT topic_length CHECK (length(topic) > 0 AND length(topic) < 128) +); + +CREATE INDEX /* TEMPLATE: schema */river_notification_created_at_idx ON river_notification (created_at); +CREATE INDEX /* TEMPLATE: schema */river_notification_topic_id_idx ON river_notification (topic, id); + +-- +-- SQLite JSONB conversion. +-- +-- Convert JSON text columns to JSONB binary format for more efficient storage +-- and processing, and update column defaults from json() to jsonb(). +-- +-- SQLite doesn't allow `ALTER TABLE ADD COLUMN` with non-constant defaults like +-- `jsonb('{}')`, so rebuild each affected table instead. +-- + +-- +-- river_job +-- + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + jsonb(args), + attempt, + attempted_at, + CASE WHEN attempted_by IS NULL THEN NULL ELSE jsonb(attempted_by) END, + created_at, + CASE WHEN errors IS NULL THEN NULL ELSE jsonb(errors) END, + finalized_at, + kind, + max_attempts, + jsonb(metadata), + priority, + queue, + state, + scheduled_at, + jsonb(tags), + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; + +-- +-- river_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue RENAME TO river_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO /* TEMPLATE: schema */river_queue ( + name, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + name, + created_at, + jsonb(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_queue_old; + +-- +-- river_client +-- + +ALTER TABLE /* TEMPLATE: schema */river_client RENAME TO river_client_old; + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +INSERT INTO /* TEMPLATE: schema */river_client ( + id, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + id, + created_at, + jsonb(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_client_old; + +-- +-- river_client_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_client_queue RENAME TO river_client_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +INSERT INTO /* TEMPLATE: schema */river_client_queue ( + river_client_id, + name, + created_at, + max_workers, + metadata, + num_jobs_completed, + num_jobs_running, + updated_at +) +SELECT + river_client_id, + name, + created_at, + max_workers, + jsonb(metadata), + num_jobs_completed, + num_jobs_running, + updated_at +FROM /* TEMPLATE: schema */river_client_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_client_queue_old; +DROP TABLE /* TEMPLATE: schema */river_client_old; + +-- +-- SQL cleanup. +-- + +-- +-- Drop unused tables `river_client` and `river_client_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; + +-- +-- Adds `DEFAULT 25` to `river_job.max_attempts`. +-- + +-- This may look odd in that we're adding a brand new column, but it's because +-- SQLite doesn't support anything beyond the most trivial DDL. + +ALTER TABLE /* TEMPLATE: schema */river_job + RENAME COLUMN max_attempts TO max_attempts_old; + +ALTER TABLE /* TEMPLATE: schema */river_job + ADD COLUMN max_attempts integer NOT NULL DEFAULT 25; + +UPDATE /* TEMPLATE: schema */river_job +SET max_attempts = max_attempts_old; + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN max_attempts_old; diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/008_job_id_autoincrement.down.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/008_job_id_autoincrement.down.sql new file mode 100644 index 000000000..aad4f366b --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/008_job_id_autoincrement.down.sql @@ -0,0 +1,121 @@ +-- Rebuild river_job to restore SQLite's default ROWID allocation behavior. + +-- Rebuilding river_job would discard schema installed by River Pro. Check +-- schema objects instead of migration records to also catch manually applied +-- Pro migrations and the legacy workflow migration line. +CREATE TEMP TABLE river_job_pro_schema_guard ( + id integer NOT NULL +); + +CREATE TEMP TRIGGER river_job_pro_schema_guard_enforce + BEFORE INSERT ON river_job_pro_schema_guard + WHEN EXISTS ( + SELECT 1 + FROM /* TEMPLATE: schema */sqlite_master + WHERE name IN ('river_job_sequence', 'river_job_workflow_scheduling', 'river_workflow') + ) +BEGIN + SELECT RAISE(ABORT, 'River SQLite migration 008 cannot run while River Pro schema is installed'); +END; + +INSERT INTO river_job_pro_schema_guard (id) VALUES (1); + +DROP TRIGGER river_job_pro_schema_guard_enforce; +DROP TABLE river_job_pro_schema_guard; + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL DEFAULT 25, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/java/river/src/main/resources/com/riverqueue/migration/sqlite/008_job_id_autoincrement.up.sql b/java/river/src/main/resources/com/riverqueue/migration/sqlite/008_job_id_autoincrement.up.sql new file mode 100644 index 000000000..c7de15deb --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/migration/sqlite/008_job_id_autoincrement.up.sql @@ -0,0 +1,123 @@ +-- Rebuild river_job so automatically generated IDs are never reused after the +-- job holding the largest ID is deleted. Unlike PostgreSQL sequences, SQLite's +-- default ROWID allocator may otherwise reuse that deleted ID. + +-- Rebuilding river_job would discard schema installed by River Pro. Check +-- schema objects instead of migration records to also catch manually applied +-- Pro migrations and the legacy workflow migration line. +CREATE TEMP TABLE river_job_pro_schema_guard ( + id integer NOT NULL +); + +CREATE TEMP TRIGGER river_job_pro_schema_guard_enforce + BEFORE INSERT ON river_job_pro_schema_guard + WHEN EXISTS ( + SELECT 1 + FROM /* TEMPLATE: schema */sqlite_master + WHERE name IN ('river_job_sequence', 'river_job_workflow_scheduling', 'river_workflow') + ) +BEGIN + SELECT RAISE(ABORT, 'River SQLite migration 008 cannot run while River Pro schema is installed'); +END; + +INSERT INTO river_job_pro_schema_guard (id) VALUES (1); + +DROP TRIGGER river_job_pro_schema_guard_enforce; +DROP TABLE river_job_pro_schema_guard; + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY AUTOINCREMENT, + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL DEFAULT 25, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/java/river/src/main/resources/com/riverqueue/postgres.sql b/java/river/src/main/resources/com/riverqueue/postgres.sql new file mode 100644 index 000000000..eb40b944a --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/postgres.sql @@ -0,0 +1,220 @@ +-- name: columns +id, args, attempt, attempted_at, array_to_json(attempted_by) AS attempted_by, +created_at, array_to_json(errors) AS errors, finalized_at, kind, max_attempts, +metadata, priority, queue, scheduled_at, state, array_to_json(tags) AS tags, +unique_key, unique_states + +-- name: get +SELECT {columns} FROM {schema}river_job WHERE id = ? + +-- name: insert +INSERT INTO {schema}river_job +(args, created_at, kind, max_attempts, metadata, priority, queue, scheduled_at, state, tags, unique_key, unique_states) +VALUES (?::jsonb, ?, ?, ?, ?::jsonb, ?, ?, ?, ?::{schema}river_job_state, ?, ?, ?::bit(8)) +ON CONFLICT (unique_key) WHERE unique_key IS NOT NULL AND unique_states IS NOT NULL +AND {schema}river_job_state_in_bitmask(unique_states, state) +-- Keep the existing kind, which may differ when uniqueness excludes kind. +DO UPDATE SET kind = river_job.kind +RETURNING {columns}, {duplicate} AS duplicate + +-- name: insert_duplicate_nonce +false +-- name: insert_duplicate_xmax +(xmax != 0) + +-- name: notify +SELECT pg_notify(coalesce(nullif('{schema_name}',''),current_schema()) || '.' || ?, ?) + +-- name: migration_insert +INSERT INTO {schema}river_migration (line,version) VALUES ('main',?) +-- name: migration_insert_legacy +INSERT INTO {schema}river_migration (version) VALUES (?) +-- name: migration_delete +DELETE FROM {schema}river_migration WHERE line='main' AND version=? +-- name: migration_delete_legacy +DELETE FROM {schema}river_migration WHERE version=? +-- name: migration_versions +SELECT version FROM {schema}river_migration WHERE line='main' ORDER BY version +-- name: migration_versions_legacy +SELECT version FROM {schema}river_migration ORDER BY version +-- name: list +SELECT {columns} FROM {schema}river_job WHERE {where} ORDER BY {order} LIMIT ? +-- name: cursor_id +(id {comparison} ?) +-- name: cursor_null_asc +({field} IS NULL AND id > ?) +-- name: cursor_null_desc +({field} IS NOT NULL OR id < ?) +-- name: cursor_time +({field} {comparison} ? OR ({field} = ? AND id {comparison} ?)) +-- name: cursor_time_nullable +({field} > ? OR ({field} = ? AND id > ?) OR {field} IS NULL) +-- name: delete +DELETE FROM {schema}river_job WHERE id=? AND state != 'running' RETURNING {columns} +-- name: retry +UPDATE {schema}river_job SET state='available', finalized_at=NULL, +max_attempts=CASE WHEN attempt=max_attempts THEN max_attempts+1 ELSE max_attempts END, scheduled_at=? +WHERE id=? AND state != 'running' AND NOT (state='available' AND scheduled_at < ?) RETURNING {columns} +-- name: reset_jobs +DELETE FROM {schema}river_job +-- name: reset_queues +DELETE FROM {schema}river_queue +-- name: reset_leader +DELETE FROM {schema}river_leader +-- name: reset_notifications +DELETE FROM {schema}river_notification + +-- name: create_schema +CREATE SCHEMA IF NOT EXISTS "{schema_name}" +-- name: migration_lock +SELECT pg_advisory_lock(1789812276) +-- name: migration_exists +SELECT to_regclass('{schema}river_migration') IS NOT NULL +-- name: migration_has_line +SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=coalesce(nullif('{schema_name}',''),current_schema()) AND table_name='river_migration' AND column_name='line') +-- name: lock_get +SELECT {columns} FROM {schema}river_job WHERE id=? FOR UPDATE +-- name: cancel +UPDATE {schema}river_job SET state=CASE WHEN state='running' THEN state ELSE 'cancelled' END, +finalized_at=CASE WHEN state='running' THEN finalized_at ELSE ? END, +metadata=jsonb_set(metadata,'{cancel_attempted_at}',?::jsonb,true) +WHERE id=? AND state NOT IN ('cancelled','completed','discarded') AND finalized_at IS NULL RETURNING {columns} +-- name: output +UPDATE {schema}river_job SET metadata=jsonb_set(metadata,'{output}',?::jsonb,true) WHERE id=? RETURNING {columns} +-- name: filter_metadata +metadata @> ?::jsonb +-- name: filter_tags_all +tags @> ARRAY(SELECT jsonb_array_elements_text(?::jsonb))::varchar[] +-- name: filter_tags_any +tags && ARRAY(SELECT jsonb_array_elements_text(?::jsonb))::varchar[] + +-- name: filter_ids +id IN (SELECT jsonb_array_elements_text(?::jsonb)::bigint) + +-- name: filter_kinds +kind IN (SELECT jsonb_array_elements_text(?::jsonb)::text) + +-- name: filter_priorities +priority IN (SELECT jsonb_array_elements_text(?::jsonb)::integer) + +-- name: filter_queues +queue IN (SELECT jsonb_array_elements_text(?::jsonb)::text) + +-- name: filter_states +state::text IN (SELECT jsonb_array_elements_text(?::jsonb)::text) + +-- name: queue_pause +UPDATE {schema}river_queue SET updated_at=CASE WHEN paused_at IS NULL THEN ? ELSE updated_at END, +paused_at=coalesce(paused_at, CURRENT_TIMESTAMP) WHERE (?='*' OR name=?) +-- name: queue_resume +UPDATE {schema}river_queue SET updated_at=CASE WHEN paused_at IS NOT NULL THEN ? ELSE updated_at END, +paused_at=NULL WHERE (?='*' OR name=?) +-- name: queue_heartbeat +INSERT INTO {schema}river_queue (name,created_at,updated_at) VALUES (?,?,?) ON CONFLICT(name) DO UPDATE SET updated_at=excluded.updated_at +-- name: leader_get +SELECT leader_id,elected_at FROM {schema}river_leader WHERE name='default' +-- name: leader_elect +INSERT INTO {schema}river_leader (leader_id,elected_at,expires_at) VALUES (?,?,?) ON CONFLICT(name) DO NOTHING RETURNING elected_at +-- name: leader_expire +DELETE FROM {schema}river_leader WHERE expires_at < ? +-- name: leader_renew +UPDATE {schema}river_leader SET expires_at=? WHERE leader_id=? AND elected_at=? AND expires_at >= ? +-- name: leader_resign +DELETE FROM {schema}river_leader WHERE leader_id=? AND elected_at=? +-- name: schedule +UPDATE {schema}river_job SET state='available' WHERE state IN ('scheduled','retryable') AND scheduled_at<=? RETURNING queue +-- name: notifications +SELECT id,payload FROM {schema}river_notification WHERE id>? ORDER BY id + +-- name: queue_get +SELECT name,created_at,metadata,paused_at,updated_at FROM {schema}river_queue WHERE name=? +-- name: queue_list +SELECT name,created_at,metadata,paused_at,updated_at FROM {schema}river_queue ORDER BY name LIMIT ? +-- name: queue_update +UPDATE {schema}river_queue SET metadata=?::jsonb,updated_at=? WHERE name=? +RETURNING name,created_at,metadata,paused_at,updated_at + +-- name: claim +UPDATE {schema}river_job SET state='running',attempt=attempt+1,attempted_at=?, +attempted_by=array_append(CASE WHEN array_length(attempted_by,1)>=100 THEN attempted_by[array_length(attempted_by,1)-98:] ELSE attempted_by END,?::text) +WHERE id IN (SELECT id FROM {schema}river_job WHERE queue=? AND state='available' AND scheduled_at<=? +AND NOT EXISTS(SELECT 1 FROM {schema}river_queue q WHERE q.name=river_job.queue AND paused_at IS NOT NULL) +ORDER BY priority,scheduled_at,id LIMIT ? FOR UPDATE SKIP LOCKED) RETURNING {columns} +-- name: claim_known +UPDATE {schema}river_job SET state='running',attempt=attempt+1,attempted_at=?, +attempted_by=array_append(CASE WHEN array_length(attempted_by,1)>=100 THEN attempted_by[array_length(attempted_by,1)-98:] ELSE attempted_by END,?::text) +WHERE id IN (SELECT id FROM {schema}river_job WHERE queue=? AND state='available' AND scheduled_at<=? +AND NOT EXISTS(SELECT 1 FROM {schema}river_queue q WHERE q.name=river_job.queue AND paused_at IS NOT NULL) +AND kind IN (SELECT jsonb_array_elements_text(?::jsonb)) +ORDER BY priority,scheduled_at,id LIMIT ? FOR UPDATE SKIP LOCKED) RETURNING {columns} +-- name: complete +WITH choice AS (SELECT ?::text AS target_state, ?::timestamptz AS now) +UPDATE {schema}river_job SET +state=CASE WHEN state != 'running' THEN state WHEN (SELECT target_state FROM choice) IN ('available','retryable','scheduled') AND metadata->>'cancel_attempted_at' IS NOT NULL THEN 'cancelled' ELSE ?::{schema}river_job_state END, +finalized_at=CASE WHEN state != 'running' THEN finalized_at WHEN (SELECT target_state FROM choice) IN ('available','retryable','scheduled') AND metadata->>'cancel_attempted_at' IS NOT NULL THEN (SELECT now FROM choice) ELSE ? END, +scheduled_at=CASE WHEN state != 'running' THEN scheduled_at WHEN (SELECT target_state FROM choice) IN ('available','retryable','scheduled') AND metadata->>'cancel_attempted_at' IS NOT NULL THEN scheduled_at ELSE ? END, +attempt=CASE WHEN state != 'running' THEN attempt WHEN (SELECT target_state FROM choice) IN ('available','retryable','scheduled') AND metadata->>'cancel_attempted_at' IS NOT NULL THEN attempt ELSE ? END, +metadata=coalesce(metadata || nullif(?::jsonb,'{}'::jsonb),metadata), +errors=CASE WHEN state != 'running' OR ?::text IS NULL THEN errors ELSE array_append(errors,?::jsonb) END +WHERE id=? AND attempt=? AND attempted_at=? RETURNING {columns} + +-- name: migration_unlock +SELECT pg_advisory_unlock(1789812276) + +-- name: delete_finalized +DELETE FROM {schema}river_job WHERE id IN (SELECT id FROM {schema}river_job +WHERE state IN ('cancelled','completed','discarded') AND finalized_at < ? +AND queue NOT IN (SELECT jsonb_array_elements_text(?::jsonb)) AND (? OR queue IN (SELECT jsonb_array_elements_text(?::jsonb))) ORDER BY id LIMIT ?) +-- name: rescue_select +SELECT {columns} FROM {schema}river_job WHERE state='running' AND attempted_at? ORDER BY id LIMIT 1000 FOR UPDATE SKIP LOCKED + +-- name: database_version +SELECT version(), coalesce(current_setting('yb_enable_listen_notify', true), 'off')::boolean + +-- name: cancel_requested +SELECT id FROM {schema}river_job WHERE state='running' AND metadata->>'cancel_attempted_at' IS NOT NULL + +-- name: notification_cursor +SELECT coalesce(max(id),0) FROM {schema}river_notification +-- name: schedule_select +SELECT id,unique_key FROM {schema}river_job WHERE state IN ('scheduled','retryable') AND scheduled_at<=? ORDER BY priority,scheduled_at,id LIMIT 10000 FOR UPDATE +-- name: schedule_collision +SELECT id FROM {schema}river_job WHERE id<>? AND unique_key=? AND unique_states IS NOT NULL AND {schema}river_job_state_in_bitmask(unique_states,state) +-- name: schedule_available +UPDATE {schema}river_job SET state='available' WHERE id=? AND state IN ('scheduled','retryable') RETURNING queue +-- name: schedule_discard +UPDATE {schema}river_job SET state='discarded',finalized_at=?,metadata=metadata || '{"unique_key_conflict":"scheduler_discarded"}'::jsonb WHERE id=? AND state IN ('scheduled','retryable') + +-- name: clean_jobs +DELETE FROM {schema}river_job WHERE id IN (SELECT id FROM {schema}river_job WHERE +(state='cancelled' AND finalized_at'main') diff --git a/java/river/src/main/resources/com/riverqueue/sqlite.sql b/java/river/src/main/resources/com/riverqueue/sqlite.sql new file mode 100644 index 000000000..92fd01617 --- /dev/null +++ b/java/river/src/main/resources/com/riverqueue/sqlite.sql @@ -0,0 +1,221 @@ +-- name: columns +id, CASE WHEN typeof(args)='text' AND NOT json_valid(args) THEN args ELSE json(args) END AS args, attempt, attempted_at, CASE WHEN typeof(attempted_by)='text' AND NOT json_valid(attempted_by) THEN attempted_by ELSE json(attempted_by) END AS attempted_by, +created_at, CASE WHEN typeof(errors)='text' AND NOT json_valid(errors) THEN errors ELSE json(errors) END AS errors, finalized_at, kind, max_attempts, +CASE WHEN typeof(metadata)='text' AND NOT json_valid(metadata) THEN metadata ELSE json(metadata) END AS metadata, priority, queue, scheduled_at, state, CASE WHEN typeof(tags)='text' AND NOT json_valid(tags) THEN tags ELSE json(tags) END AS tags, +unique_key, unique_states + +-- name: get +SELECT {columns} FROM {schema}river_job WHERE id = ? + +-- name: insert +INSERT INTO {schema}river_job +(args, created_at, kind, max_attempts, metadata, priority, queue, scheduled_at, state, tags, unique_key, unique_states) +VALUES (jsonb(?), ?, ?, ?, jsonb(?), ?, ?, ?, ?, jsonb(?), ?, ?) +ON CONFLICT (unique_key) WHERE unique_key IS NOT NULL AND unique_states IS NOT NULL +AND CASE state +WHEN 'available' THEN unique_states & (1 << 0) WHEN 'cancelled' THEN unique_states & (1 << 1) +WHEN 'completed' THEN unique_states & (1 << 2) WHEN 'discarded' THEN unique_states & (1 << 3) +WHEN 'pending' THEN unique_states & (1 << 4) WHEN 'retryable' THEN unique_states & (1 << 5) +WHEN 'running' THEN unique_states & (1 << 6) WHEN 'scheduled' THEN unique_states & (1 << 7) ELSE 0 END >= 1 +-- Keep the existing kind, which may differ when uniqueness excludes kind. +DO UPDATE SET kind = river_job.kind +RETURNING {columns} + +-- name: notify +INSERT INTO river_notification (topic, payload) VALUES (?, ?) + +-- name: migration_insert +INSERT INTO {schema}river_migration (line,version) VALUES ('main',?) +-- name: migration_insert_legacy +INSERT INTO {schema}river_migration (version) VALUES (?) +-- name: migration_delete +DELETE FROM {schema}river_migration WHERE line='main' AND version=? +-- name: migration_delete_legacy +DELETE FROM {schema}river_migration WHERE version=? +-- name: migration_versions +SELECT version FROM {schema}river_migration WHERE line='main' ORDER BY version +-- name: migration_versions_legacy +SELECT version FROM {schema}river_migration ORDER BY version +-- name: list +SELECT {columns} FROM {schema}river_job WHERE {where} ORDER BY {order} LIMIT ? +-- name: cursor_id +(id {comparison} ?) +-- name: cursor_null_asc +({field} IS NULL AND id > ?) +-- name: cursor_null_desc +({field} IS NOT NULL OR id < ?) +-- name: cursor_time +({field} {comparison} ? OR ({field} = ? AND id {comparison} ?)) +-- name: cursor_time_nullable +({field} > ? OR ({field} = ? AND id > ?) OR {field} IS NULL) +-- name: delete +DELETE FROM {schema}river_job WHERE id=? AND state != 'running' RETURNING {columns} +-- name: retry +UPDATE {schema}river_job SET state='available', finalized_at=NULL, +max_attempts=CASE WHEN attempt=max_attempts THEN max_attempts+1 ELSE max_attempts END, scheduled_at=? +WHERE id=? AND state != 'running' AND NOT (state='available' AND scheduled_at < ?) RETURNING {columns} +-- name: reset_jobs +DELETE FROM {schema}river_job +-- name: reset_queues +DELETE FROM {schema}river_queue +-- name: reset_leader +DELETE FROM {schema}river_leader +-- name: reset_notifications +DELETE FROM {schema}river_notification + +-- name: migration_exists +SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='river_migration') +-- name: migration_has_line +SELECT EXISTS(SELECT 1 FROM pragma_table_info('river_migration') WHERE name='line') +-- name: lock_get +SELECT {columns} FROM river_job WHERE id=? +-- name: writer_lock +UPDATE river_job SET id=id WHERE false +-- name: unique_get +SELECT {columns} FROM river_job WHERE unique_key=? AND CASE state +WHEN 'available' THEN unique_states & (1 << 0) WHEN 'cancelled' THEN unique_states & (1 << 1) +WHEN 'completed' THEN unique_states & (1 << 2) WHEN 'discarded' THEN unique_states & (1 << 3) +WHEN 'pending' THEN unique_states & (1 << 4) WHEN 'retryable' THEN unique_states & (1 << 5) +WHEN 'running' THEN unique_states & (1 << 6) WHEN 'scheduled' THEN unique_states & (1 << 7) ELSE 0 END >= 1 +-- name: cancel +UPDATE river_job SET state=CASE WHEN state='running' THEN state ELSE 'cancelled' END, +finalized_at=CASE WHEN state='running' THEN finalized_at ELSE ? END, +metadata=jsonb_set(metadata,'$.cancel_attempted_at',jsonb(?)) +WHERE id=? AND state NOT IN ('cancelled','completed','discarded') AND finalized_at IS NULL RETURNING {columns} +-- name: output +UPDATE river_job SET metadata=jsonb_set(metadata,'$.output',jsonb(?)) WHERE id=? RETURNING {columns} +-- name: filter_metadata_container +({type}='{kind}' AND {children}) +-- name: filter_metadata_element +EXISTS(SELECT 1 FROM json_each(CASE WHEN {type}='{kind}' THEN {value} END) AS {alias} WHERE {predicate}) +-- name: filter_metadata_member +EXISTS(SELECT 1 FROM json_each(CASE WHEN {type}='{kind}' THEN {value} END) AS {alias} WHERE {alias}.key=? AND {predicate}) +-- name: filter_metadata_scalar +EXISTS(SELECT 1 FROM json_tree(?) AS wanted WHERE wanted.parent IS NULL +AND ({type}=wanted.type OR ({type} IN ('integer','real') AND wanted.type IN ('integer','real'))) +AND {value} IS wanted.value) +-- name: filter_tags_all +NOT EXISTS(SELECT 1 FROM json_each(?) wanted WHERE wanted.value NOT IN (SELECT value FROM json_each(tags))) +-- name: filter_tags_any +EXISTS(SELECT 1 FROM json_each(?) wanted WHERE wanted.value IN (SELECT value FROM json_each(tags))) + +-- name: filter_ids +id IN (SELECT value FROM json_each(?)) + +-- name: filter_kinds +kind IN (SELECT value FROM json_each(?)) + +-- name: filter_priorities +priority IN (SELECT value FROM json_each(?)) + +-- name: filter_queues +queue IN (SELECT value FROM json_each(?)) + +-- name: filter_states +state IN (SELECT value FROM json_each(?)) + +-- name: queue_pause +UPDATE {schema}river_queue SET updated_at=CASE WHEN paused_at IS NULL THEN ? ELSE updated_at END, +paused_at=coalesce(paused_at, CURRENT_TIMESTAMP) WHERE (?='*' OR name=?) +-- name: queue_resume +UPDATE {schema}river_queue SET updated_at=CASE WHEN paused_at IS NOT NULL THEN ? ELSE updated_at END, +paused_at=NULL WHERE (?='*' OR name=?) +-- name: queue_heartbeat +INSERT INTO {schema}river_queue (name,created_at,updated_at) VALUES (?,?,?) ON CONFLICT(name) DO UPDATE SET updated_at=excluded.updated_at +-- name: leader_get +SELECT leader_id,elected_at FROM {schema}river_leader WHERE name='default' +-- name: leader_elect +INSERT INTO {schema}river_leader (leader_id,elected_at,expires_at) VALUES (?,?,?) ON CONFLICT(name) DO NOTHING RETURNING elected_at +-- name: leader_expire +DELETE FROM {schema}river_leader WHERE expires_at < ? +-- name: leader_renew +UPDATE {schema}river_leader SET expires_at=? WHERE leader_id=? AND elected_at=? AND expires_at >= ? +-- name: leader_resign +DELETE FROM {schema}river_leader WHERE leader_id=? AND elected_at=? +-- name: schedule +UPDATE {schema}river_job SET state='available' WHERE state IN ('scheduled','retryable') AND scheduled_at<=? RETURNING queue +-- name: notifications +SELECT id,topic,payload FROM {schema}river_notification WHERE id>? ORDER BY id + +-- name: queue_get +SELECT name,created_at,CASE WHEN typeof(metadata)='text' AND NOT json_valid(metadata) THEN metadata ELSE json(metadata) END AS metadata,paused_at,updated_at FROM {schema}river_queue WHERE name=? +-- name: queue_list +SELECT name,created_at,CASE WHEN typeof(metadata)='text' AND NOT json_valid(metadata) THEN metadata ELSE json(metadata) END AS metadata,paused_at,updated_at FROM {schema}river_queue ORDER BY name LIMIT ? +-- name: queue_update +UPDATE {schema}river_queue SET metadata=jsonb(?),updated_at=? WHERE name=? +RETURNING name,created_at,CASE WHEN typeof(metadata)='text' AND NOT json_valid(metadata) THEN metadata ELSE json(metadata) END AS metadata,paused_at,updated_at + +-- name: claim +UPDATE river_job SET state='running',attempt=attempt+1,attempted_at=?, +attempted_by=CASE WHEN typeof(attempted_by)='text' AND NOT json_valid(attempted_by) THEN attempted_by ELSE jsonb_insert(CASE WHEN json_array_length(attempted_by)>=100 THEN jsonb_remove(attempted_by,'$[0]') ELSE coalesce(attempted_by,jsonb('[]')) END,'$[#]',?) END +WHERE id IN (SELECT id FROM river_job WHERE queue=? AND state='available' AND scheduled_at<=? +AND NOT EXISTS(SELECT 1 FROM river_queue q WHERE q.name=river_job.queue AND paused_at IS NOT NULL) +ORDER BY priority,scheduled_at,id LIMIT ?) RETURNING {columns} +-- name: claim_known +UPDATE river_job SET state='running',attempt=attempt+1,attempted_at=?, +attempted_by=CASE WHEN typeof(attempted_by)='text' AND NOT json_valid(attempted_by) THEN attempted_by ELSE jsonb_insert(CASE WHEN json_array_length(attempted_by)>=100 THEN jsonb_remove(attempted_by,'$[0]') ELSE coalesce(attempted_by,jsonb('[]')) END,'$[#]',?) END +WHERE id IN (SELECT id FROM river_job WHERE queue=? AND state='available' AND scheduled_at<=? +AND NOT EXISTS(SELECT 1 FROM river_queue q WHERE q.name=river_job.queue AND paused_at IS NOT NULL) +AND kind IN (SELECT value FROM json_each(?)) +ORDER BY priority,scheduled_at,id LIMIT ?) RETURNING {columns} +-- name: complete +WITH choice AS (SELECT ? AS target_state, ? AS now) +UPDATE river_job SET +state=CASE WHEN state != 'running' THEN state WHEN (SELECT target_state FROM choice) IN ('available','retryable','scheduled') AND (CASE WHEN typeof(metadata)<>'text' OR json_valid(metadata) THEN json_extract(metadata,'$.cancel_attempted_at') END) IS NOT NULL THEN 'cancelled' ELSE ? END, +finalized_at=CASE WHEN state != 'running' THEN finalized_at WHEN (SELECT target_state FROM choice) IN ('available','retryable','scheduled') AND (CASE WHEN typeof(metadata)<>'text' OR json_valid(metadata) THEN json_extract(metadata,'$.cancel_attempted_at') END) IS NOT NULL THEN (SELECT now FROM choice) ELSE ? END, +scheduled_at=CASE WHEN state != 'running' THEN scheduled_at WHEN (SELECT target_state FROM choice) IN ('available','retryable','scheduled') AND (CASE WHEN typeof(metadata)<>'text' OR json_valid(metadata) THEN json_extract(metadata,'$.cancel_attempted_at') END) IS NOT NULL THEN scheduled_at ELSE ? END, +attempt=CASE WHEN state != 'running' THEN attempt WHEN (SELECT target_state FROM choice) IN ('available','retryable','scheduled') AND (CASE WHEN typeof(metadata)<>'text' OR json_valid(metadata) THEN json_extract(metadata,'$.cancel_attempted_at') END) IS NOT NULL THEN attempt ELSE ? END, +metadata=CASE WHEN typeof(metadata)='text' AND NOT json_valid(metadata) THEN metadata ELSE jsonb_patch(metadata,jsonb(?)) END, +errors=CASE WHEN state != 'running' OR ? IS NULL THEN errors ELSE jsonb_insert(CASE WHEN typeof(errors)='text' AND NOT json_valid(errors) THEN jsonb_array(errors) WHEN coalesce(json_type(errors),'array') <> 'array' THEN jsonb_array(json(errors)) ELSE coalesce(errors,jsonb('[]')) END,'$[#]',jsonb(?)) END +WHERE id=? AND attempt=? AND attempted_at=? RETURNING {columns} + +-- name: delete_finalized +DELETE FROM {schema}river_job WHERE id IN (SELECT id FROM {schema}river_job +WHERE state IN ('cancelled','completed','discarded') AND finalized_at < ? +AND queue NOT IN (SELECT value FROM json_each(?)) AND (? OR queue IN (SELECT value FROM json_each(?))) ORDER BY id LIMIT ?) +-- name: rescue_select +SELECT {columns} FROM {schema}river_job WHERE state='running' AND attempted_at? ORDER BY id LIMIT 1000 + +-- name: cancel_requested +SELECT id FROM river_job WHERE state='running' AND (CASE WHEN typeof(metadata)<>'text' OR json_valid(metadata) THEN json_extract(metadata,'$.cancel_attempted_at') END) IS NOT NULL + +-- name: notification_cursor +SELECT coalesce(max(id),0) FROM {schema}river_notification +-- name: schedule_select +SELECT id,unique_key FROM {schema}river_job WHERE state IN ('scheduled','retryable') AND scheduled_at<=? ORDER BY priority,scheduled_at,id LIMIT 10000 +-- name: schedule_collision +SELECT id FROM {schema}river_job WHERE id<>? AND unique_key=? AND unique_states IS NOT NULL AND CASE state WHEN 'available' THEN unique_states & 1 WHEN 'cancelled' THEN unique_states & 2 WHEN 'completed' THEN unique_states & 4 WHEN 'discarded' THEN unique_states & 8 WHEN 'pending' THEN unique_states & 16 WHEN 'retryable' THEN unique_states & 32 WHEN 'running' THEN unique_states & 64 WHEN 'scheduled' THEN unique_states & 128 ELSE 0 END >= 1 +-- name: schedule_available +UPDATE {schema}river_job SET state='available' WHERE id=? AND state IN ('scheduled','retryable') RETURNING queue +-- name: schedule_discard +UPDATE {schema}river_job SET state='discarded',finalized_at=?,metadata=jsonb_patch(metadata,'{"unique_key_conflict":"scheduler_discarded"}') WHERE id=? AND state IN ('scheduled','retryable') + +-- name: clean_jobs +DELETE FROM {schema}river_job WHERE id IN (SELECT id FROM {schema}river_job WHERE +(state='cancelled' AND finalized_at 'array' THEN jsonb_array(json(errors)) ELSE coalesce(errors,jsonb('[]')) END,'$[#]',jsonb(?)) END +WHERE state='running' AND id=? AND attempt=? AND attempted_at=? RETURNING {columns} +-- name: periodic_scheduled_at +UPDATE river_job SET scheduled_at=? WHERE id=? + +-- name: migration_line_delete +DELETE FROM {schema}river_migration WHERE line=? AND version=? +-- name: migration_line_insert +INSERT INTO {schema}river_migration (line,version) VALUES (?,?) +-- name: migration_line_versions +SELECT version FROM {schema}river_migration WHERE line=? ORDER BY version +-- name: migration_other_lines +SELECT EXISTS(SELECT 1 FROM {schema}river_migration WHERE line<>'main') diff --git a/java/river/src/test/java/com/riverqueue/BulkDeleteTest.java b/java/river/src/test/java/com/riverqueue/BulkDeleteTest.java new file mode 100644 index 000000000..ab9e0ebce --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/BulkDeleteTest.java @@ -0,0 +1,227 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.file.Path; +import java.sql.Connection; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import tools.jackson.databind.JsonNode; + +class BulkDeleteTest { + private static final JobType TYPE = JobType.of("bulk_delete", String.class); + @TempDir Path directory; + @RegisterExtension final TestDatabase databases = new TestDatabase(); + + private Client client() { + return new Client(databases.open(directory.resolve("river.db"))); + } + + @ParameterizedTest + @ValueSource( + strings = { + "ids", + "kinds", + "metadata", + "priorities", + "queues", + "states", + "tagsAll", + "tagsAny" + }) + void acceptsEachFilter(String filter) { + var client = client(); + var matching = + client + .insert( + TYPE, + "matching", + InsertOptions.builder() + .metadata(Map.of("tenant", "chosen")) + .pending(true) + .priority(1) + .queue("chosen") + .tags("chosen") + .build()) + .job(); + var other = + client + .insert( + JobType.of("other", String.class), + "other", + InsertOptions.builder() + .metadata(Map.of("tenant", "other")) + .priority(4) + .tags("other") + .build()) + .job(); + var query = JobQuery.builder(); + switch (filter) { + case "ids" -> query.ids(matching.id()); + case "kinds" -> query.kinds(TYPE.kind()); + case "metadata" -> query.metadata(Map.of("tenant", "chosen")); + case "priorities" -> query.priorities(1); + case "queues" -> query.queues("chosen"); + case "states" -> query.states(Job.State.PENDING); + case "tagsAll" -> query.tagsAll("chosen"); + case "tagsAny" -> query.tagsAny("chosen"); + default -> throw new AssertionError(filter); + } + assertEquals( + List.of(matching.id()), + client.deleteMany(query.build(), false).stream().map(Job::id).toList()); + assertEquals( + List.of(other.id()), client.list(JobQuery.all()).jobs().stream().map(Job::id).toList()); + } + + @Test + void callerRollbackRestoresDeletedJobs() throws Exception { + var client = client(); + var job = client.insert(TYPE, "rollback").job(); + try (var connection = client.database().connection()) { + connection.setAutoCommit(false); + assertEquals( + 1, client.deleteMany(connection, JobQuery.builder().ids(job.id()).build(), false).size()); + assertTrue(client.list(connection, JobQuery.all()).jobs().isEmpty()); + connection.rollback(); + assertEquals(job.id(), client.get(job.id()).id()); + } + } + + @Test + void hookFailureRollsBackWholeBatch() throws Exception { + var base = client(); + var first = base.insert(TYPE, "first").job(); + var second = base.insert(TYPE, "second").job(); + var client = + base.withPlugin( + new Plugin() { + @Override + public void afterDelete( + Connection connection, Client.Driver driver, Job job) { + if (job.id() == second.id()) throw new IllegalStateException("reject deletion"); + } + }); + try (var connection = client.database().connection()) { + connection.setAutoCommit(false); + assertThrows( + IllegalStateException.class, + () -> + client.deleteMany(connection, JobQuery.builder().kinds(TYPE.kind()).build(), false)); + assertEquals( + List.of(first.id(), second.id()), + client.list(connection, JobQuery.all()).jobs().stream().map(Job::id).toList()); + connection.commit(); + } + assertEquals(2, base.list(JobQuery.all()).jobs().size()); + } + + @ParameterizedTest + @Tag("postgres") + @ValueSource(strings = {"claim", "delete"}) + void ignoresJobsChangedAfterListing(String change) { + var base = client(); + assumeTrue(base.database().dialect() == Database.Dialect.POSTGRES); + var first = base.insert(TYPE, "first").job(); + var raced = base.insert(TYPE, "raced").job(); + var last = base.insert(TYPE, "last").job(); + var deleted = new ArrayList(); + var client = + base.withPlugin( + new Plugin() { + @Override + public void afterDelete( + Connection connection, Client.Driver driver, Job job) { + deleted.add(job.id()); + if (job.id() != first.id()) return; + // The batch has listed all three rows. Commit a peer's change before it reaches the + // second. + if (change.equals("delete")) base.delete(raced.id()); + else + base.transaction( + peer -> { + try (var statement = + Sql.prepare( + peer, + "UPDATE " + + base.database().prefix() + + "river_job SET state='running', attempt=1, attempted_at=? WHERE id=?", + base.database().timestamp(Instant.now()), + raced.id())) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + } + }); + assertEquals( + List.of(first.id(), last.id()), + client.deleteMany(JobQuery.builder().kinds(TYPE.kind()).build(), false).stream() + .map(Job::id) + .toList()); + assertEquals(List.of(first.id(), last.id()), deleted); + if (change.equals("claim")) assertEquals(Job.State.RUNNING, base.get(raced.id()).state()); + else assertTrue(base.list(JobQuery.all()).jobs().isEmpty()); + } + + @Test + void rejectsUnfilteredDeletionUnlessExplicit() { + var client = client(); + var job = client.insert(TYPE, "kept").job(); + assertThrows(IllegalArgumentException.class, () -> client.deleteMany(JobQuery.all(), false)); + assertThrows( + IllegalArgumentException.class, + () -> + client.deleteMany( + JobQuery.builder() + .metadata(Map.of()) + .tagsAll() + .tagsAny() + .priorities() + .limit(1) + .descending(true) + .build(), + false)); + assertEquals(job.id(), client.get(job.id()).id()); + assertEquals(1, client.deleteMany(JobQuery.all(), true).size()); + } + + @Test + void skipsRunningJobsAndPreservesSingleDeleteRejection() { + var client = client(); + var running = client.insert(TYPE, "running").job(); + var available = client.insert(TYPE, "available").job(); + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + client.database().prefix() + + "river_job SET state='running', attempt=1, attempted_at=? WHERE id=?", + client.database().timestamp(Instant.now()), + running.id())) { + statement.executeUpdate(); + } + return null; + }); + assertEquals( + RiverException.Code.REJECTED, + assertThrows(RiverException.class, () -> client.delete(running.id())).code()); + assertEquals( + List.of(available.id()), + client.deleteMany(JobQuery.builder().kinds(TYPE.kind()).build(), false).stream() + .map(Job::id) + .toList()); + assertEquals(Job.State.RUNNING, client.get(running.id()).state()); + } +} diff --git a/java/river/src/test/java/com/riverqueue/ClientTest.java b/java/river/src/test/java/com/riverqueue/ClientTest.java new file mode 100644 index 000000000..fadbe9574 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/ClientTest.java @@ -0,0 +1,712 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class ClientTest { + private static final JobType EMAIL = JobType.of("email", Email.class); + private static final Instant NOW = Instant.parse("2026-01-02T03:04:05Z"); + @TempDir Path directory; + @RegisterExtension final TestDatabase databases = new TestDatabase(); + + private Client database() { + var database = databases.open(directory.resolve("river.db")); + return new Client(database, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void applicationTransactionOwnsCommitAndRollback() throws Exception { + var river = database(); + try (var connection = river.database().connection()) { + connection.setAutoCommit(false); + long rolledBack = river.insert(connection, EMAIL, new Email("one@example.com")).job().id(); + assertEquals(rolledBack, river.get(connection, rolledBack).id()); + assertThrows(RiverException.class, () -> river.get(rolledBack)); + connection.rollback(); + assertThrows(RiverException.class, () -> river.get(rolledBack)); + long committed = river.insert(connection, EMAIL, new Email("two@example.com")).job().id(); + connection.commit(); + assertEquals(committed, river.get(committed).id()); + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + } + } + + @Test + void crossKindUniqueConflictsPreserveTheIncumbent() { + var client = database(); + var firstType = JobType.of("original_kind", tools.jackson.databind.JsonNode.class); + var secondType = JobType.of("attempted_kind", tools.jackson.databind.JsonNode.class); + var unique = Unique.none().perQueue().excludeKind(true); + var original = + client.insert( + firstType, + Json.parse("{\"original\":true}"), + InsertOptions.builder() + .unique(unique) + .metadata(java.util.Map.of("source", "original")) + .priority(1) + .tags("original") + .build()); + var duplicate = + client.insert( + secondType, + Json.parse("{\"attempted\":true}"), + InsertOptions.builder() + .unique(unique) + .metadata(java.util.Map.of("source", "attempted")) + .priority(4) + .tags("attempted") + .build()); + assertFalse(original.uniqueSkippedAsDuplicate()); + assertTrue(duplicate.uniqueSkippedAsDuplicate()); + assertEquals(original.job(), duplicate.job()); + assertEquals(original.job(), client.get(original.job().id())); + assertEquals(1, client.list(JobQuery.all()).jobs().size()); + } + + @ParameterizedTest + @ValueSource(strings = {"single", "bulk", "mixed"}) + void crossKindUniqueConflictsWithDifferentArgumentSchemas(String operation) { + record Existing(String value) {} + record Requested(List value) {} + var client = database(); + var existingType = JobType.of("existing_schema", Existing.class); + var requestedType = JobType.of("requested_schema", Requested.class); + var options = + InsertOptions.builder().unique(Unique.none().perQueue().excludeKind(true)).build(); + var original = client.insert(existingType, new Existing("original"), options).job(); + var args = new Requested(List.of(1, 2)); + var duplicate = + switch (operation) { + case "single" -> client.insert(requestedType, args, options); + case "bulk" -> client.insertMany(requestedType, List.of(args), options).getFirst(); + default -> client.insertMany(List.of(requestedType.submission(args, options))).getFirst(); + }; + assertTrue(duplicate.uniqueSkippedAsDuplicate()); + assertEquals(original.id(), duplicate.job().id()); + assertEquals(original.kind(), duplicate.job().kind()); + assertEquals(Json.tree(new Existing("original")), Json.tree(duplicate.job().args())); + assertEquals(1, client.list(JobQuery.all()).jobs().size()); + } + + @ParameterizedTest + @ValueSource(strings = {"single", "bulk", "mixed"}) + void duplicateResultsPreserveUnfamiliarArgumentFields(String operation) { + var client = database(); + var options = InsertOptions.builder().unique(Unique.none().perQueue()).build(); + var args = Json.parse("{\"address\":\"original@example.com\",\"peer_field\":{\"version\":2}}"); + var original = + client + .insert(JobType.of("email", tools.jackson.databind.JsonNode.class), args, options) + .job(); + var attempted = new Email("attempted@example.com"); + var duplicate = + switch (operation) { + case "single" -> client.insert(EMAIL, attempted, options); + case "bulk" -> client.insertMany(EMAIL, List.of(attempted), options).getFirst(); + default -> client.insertMany(List.of(EMAIL.submission(attempted, options))).getFirst(); + }; + assertTrue(duplicate.uniqueSkippedAsDuplicate()); + assertEquals(original, duplicate.job()); + assertEquals(args, duplicate.job().args()); + } + + @ParameterizedTest + @EnumSource( + value = Job.State.class, + names = {"AVAILABLE", "PENDING", "RETRYABLE", "SCHEDULED"}) + void delayedCompletionPreservesARescheduledJob(Job.State state) { + var client = database(); + var database = client.database(); + var inserted = client.insert(EMAIL, new Email("delayed@example.com")).job(); + var claimed = + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + Sql.query(database, "claim"), + database.timestamp(NOW), + "old-worker", + "default", + database.timestamp(NOW), + 1); + var rows = statement.executeQuery()) { + assertTrue(rows.next()); + return client.read(rows); + } + }); + client.transaction( + connection -> { + // Simulate rescue or an operator rescheduling the attempt before it acknowledges. + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + database.prefix() + + "river_job SET state='" + + state.value() + + "', scheduled_at=? WHERE id=?", + database.timestamp(NOW.plusSeconds(3600)), + inserted.id())) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + var before = client.get(inserted.id()); + var completed = client.transaction(connection -> client.complete(connection, claimed)); + assertEquals(before, completed); + assertEquals(before, client.get(inserted.id())); + } + + @Test + void failedBatchRollsBackWithoutAbortingApplicationTransaction() throws Exception { + var river = database(); + var options = InsertOptions.builder().unique(Unique.args()).build(); + try (var connection = river.database().connection()) { + connection.setAutoCommit(false); + assertThrows( + RiverException.class, + () -> + river.insertMany( + connection, + List.of( + EMAIL.submission(new Email("duplicate@example.com"), options), + EMAIL.submission(new Email("duplicate@example.com"), options)))); + var kept = river.insert(connection, EMAIL, new Email("kept@example.com")); + connection.commit(); + assertEquals( + List.of(kept.job().id()), + river.list(JobQuery.all()).jobs().stream().map(Job::id).toList()); + } + } + + @Test + void failingHookRollsBackItsApplicationWrites() { + var base = database(); + base.transaction( + c -> { + try (var statement = c.createStatement()) { + statement.execute("CREATE TABLE " + base.database().prefix() + "audit (message TEXT)"); + } + return null; + }); + var river = + base.withExtension( + new Extension() { + @Override + public void beforeInsert( + Connection connection, JobType type, Object args, InsertOptions options) { + try (var statement = connection.createStatement()) { + statement.executeUpdate( + "INSERT INTO " + base.database().prefix() + "audit VALUES ('attempt')"); + } catch (java.sql.SQLException e) { + throw new IllegalStateException(e); + } + throw new IllegalStateException("reject"); + } + }); + assertThrows( + IllegalStateException.class, () -> river.insert(EMAIL, new Email("one@example.com"))); + assertThrows( + IllegalStateException.class, + () -> river.insertMany(EMAIL, List.of(new Email("two@example.com")))); + assertEquals( + Integer.valueOf(0), + base.transaction( + c -> { + try (var statement = c.createStatement(); + var rows = + statement.executeQuery( + "SELECT count(*) FROM " + base.database().prefix() + "audit")) { + rows.next(); + return rows.getInt(1); + } + })); + } + + @Test + void fetchOnlyKnownKindsLeavesPeerJobsAvailable() throws Exception { + var river = database(); + var peer = JobType.of("peer_only", Email.class); + var unknown = river.insert(peer, new Email("peer@example.com")).job(); + var worked = new CountDownLatch(1); + try (var workers = + river + .workers() + .queue("default", 4) + .leadership(false) + .pollOnly(true) + .fetchOnlyKnownKinds(true) + .add(EMAIL, context -> worked.countDown()) + .start()) { + river.insert(EMAIL, new Email("java@example.com")); + assertTrue(worked.await(10, TimeUnit.SECONDS)); + } + assertEquals(Job.State.AVAILABLE, river.get(unknown.id()).state()); + assertEquals(0, river.get(unknown.id()).attempt()); + } + + @Test + void removingQueueDrainsItsCommittedClaims() throws Exception { + var river = database(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var removed = new CountDownLatch(1); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + var first = river.insert(EMAIL, new Email("first@example.com")).job(); + var second = river.insert(EMAIL, new Email("second@example.com")).job(); + try (var workers = + river + .workers() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .add( + EMAIL, + context -> { + calls.incrementAndGet(); + entered.countDown(); + release.await(); + }) + .start()) { + assertTrue(entered.await(10, TimeUnit.SECONDS)); + var removal = + CompletableFuture.runAsync( + () -> { + workers.removeQueue("default"); + removed.countDown(); + }); + try { + assertFalse(removed.await(100, TimeUnit.MILLISECONDS), "Removal must await the active job"); + } finally { + release.countDown(); + } + removal.get(10, TimeUnit.SECONDS); + assertEquals(1, calls.get()); + assertEquals(Job.State.COMPLETED, river.get(first.id()).state()); + assertEquals(Job.State.AVAILABLE, river.get(second.id()).state()); + } + } + + @Test + void workerCompletionCommitsApplicationChangesWithJob() throws Exception { + var river = database(); + river.transaction( + c -> { + try (var statement = c.createStatement()) { + statement.execute( + "CREATE TABLE " + river.database().prefix() + "deliveries (email TEXT)"); + } + return null; + }); + var release = new CountDownLatch(1); + var done = new CompletableFuture(); + var failed = new CompletableFuture(); + try (var workers = + river + .workers() + .queue("default", 1) + .leadership(false) + .pollInterval(Duration.ofMillis(10)) + .errorHandler(failed::complete) + .add( + EMAIL, + context -> { + release.await(); + context.transaction( + c -> { + try (var statement = + c.prepareStatement( + "INSERT INTO " + + river.database().prefix() + + "deliveries VALUES (?)")) { + statement.setString(1, context.args().address()); + statement.executeUpdate(); + } + context.output("delivered"); + return context.complete(c); + }); + }) + .start()) { + workers.subscribe( + event -> { + if (event.kind() == Workers.EventKind.JOB_COMPLETED) done.complete(event); + }); + var inserted = river.insert(EMAIL, new Email("one@example.com")); + release.countDown(); + var completed = done.get(10, TimeUnit.SECONDS); + assertEquals(inserted.job().id(), completed.job().id()); + assertEquals(Job.State.COMPLETED, river.get(inserted.job().id()).state()); + assertEquals( + "delivered", river.get(inserted.job().id()).metadata().path("output").asString()); + assertEquals( + Integer.valueOf(1), + river.transaction( + c -> { + try (var statement = c.createStatement(); + var rows = + statement.executeQuery( + "SELECT count(*) FROM " + river.database().prefix() + "deliveries")) { + rows.next(); + return rows.getInt(1); + } + })); + assertFalse(failed.isDone(), () -> String.valueOf(failed.getNow(null))); + } + } + + @Test + void defaultListingUsesStableIdOrder() { + var river = database(); + var first = + river + .insert( + EMAIL, + new Email("later@example.com"), + InsertOptions.builder().scheduledAt(NOW.plusSeconds(3600)).build()) + .job(); + var second = river.insert(EMAIL, new Email("now@example.com")).job(); + var page = river.list(JobQuery.all().limit(1)); + assertEquals(first.id(), page.jobs().getFirst().id()); + assertEquals( + second.id(), + river + .list(JobQuery.builder().after(page.cursor()).limit(1).build()) + .jobs() + .getFirst() + .id()); + assertEquals( + second.id(), + river + .list(JobQuery.builder().order(JobQuery.Order.TIME).limit(1).build()) + .jobs() + .getFirst() + .id()); + } + + @Test + void typedGetAndMixedBatchRetainArgumentKinds() throws Exception { + var river = database(); + var report = JobType.of("report", Report.class); + var results = + river.insertMany( + List.of( + EMAIL.submission(new Email("one@example.com")), + report.submission( + new Report(42), InsertOptions.builder().queue("reports").build()))); + Job email = river.get(results.get(0).job().id(), EMAIL); + Job typedReport = river.get(results.get(1).job().id(), report); + assertEquals(new Email("one@example.com"), email.args()); + assertEquals(new Report(42), typedReport.args()); + assertEquals("reports", typedReport.queue()); + assertThrows(IllegalArgumentException.class, () -> river.get(email.id(), report)); + try (var connection = river.database().connection()) { + connection.setAutoCommit(false); + assertThrows(IllegalArgumentException.class, () -> river.complete(connection, email)); + var inserted = + river.insertMany( + connection, + EMAIL, + List.of(new Email("rolled-back@example.com")), + InsertOptions.builder().queue("mail").build()); + assertEquals("mail", inserted.getFirst().job().queue()); + connection.rollback(); + assertThrows(RiverException.class, () -> river.get(inserted.getFirst().job().id())); + } + } + + @Test + void checkedHookFailureRollsBackInsideCallerTransaction() throws Exception { + var base = database(); + var river = + base.withExtension( + new Extension() { + @Override + public void beforeInsert( + Connection connection, JobType type, Object args, InsertOptions options) + throws java.sql.SQLException { + try (var statement = connection.createStatement()) { + statement.execute( + "CREATE TABLE " + base.database().prefix() + "hook_write (id INTEGER)"); + } + throw new java.sql.SQLException("hook rejected"); + } + }); + try (var connection = river.database().connection()) { + connection.setAutoCommit(false); + var failure = + assertThrows( + RiverException.class, + () -> + river.insertMany( + connection, List.of(EMAIL.submission(new Email("rejected@example.com"))))); + assertEquals(RiverException.Code.DATABASE, failure.code()); + assertInstanceOf(java.sql.SQLException.class, failure.getCause()); + try (var rows = + connection + .getMetaData() + .getTables( + null, + river.database().schema().isEmpty() ? null : river.database().schema(), + "hook_write", + null)) { + assertFalse(rows.next()); + } + var kept = base.insert(connection, EMAIL, new Email("kept@example.com")).job(); + connection.commit(); + assertEquals( + List.of(kept.id()), base.list(JobQuery.all()).jobs().stream().map(Job::id).toList()); + } + } + + @Test + void sqliteQueueNotificationFailureRollsBackQueueMutation() throws Exception { + var river = + new Client( + TestDatabase.sqlite(directory.resolve("river.db")), Clock.fixed(NOW, ZoneOffset.UTC)); + try (var workers = + river + .workers() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .add(EMAIL, ignored -> {}) + .start(); + var connection = river.database().connection()) { + connection.setAutoCommit(false); + try (var statement = connection.createStatement()) { + statement.execute( + "CREATE TRIGGER reject_control BEFORE INSERT ON river_notification " + + "WHEN NEW.topic = 'river_control' BEGIN SELECT RAISE(ABORT, 'reject control'); END"); + } + assertThrows(RiverException.class, () -> river.queues().pause(connection, "default")); + assertNull(river.queues().get(connection, "default").pausedAt()); + assertThrows( + RiverException.class, + () -> river.queues().update(connection, "default", java.util.Map.of("changed", true))); + assertFalse(river.queues().get(connection, "default").metadata().has("changed")); + var kept = river.insert(connection, EMAIL, new Email("kept@example.com")).job(); + connection.commit(); + assertEquals(kept.id(), river.get(kept.id()).id()); + } + } + + @Test + void committedCompletionWinsOverSubsequentHandlerFailure() throws Exception { + var river = database(); + var event = new CompletableFuture(); + var failedEvents = new java.util.concurrent.atomic.AtomicInteger(); + try (var workers = + river + .workers() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .add( + EMAIL, + context -> { + Job completed = context.transaction(context::complete); + assertEquals(context.args(), completed.args()); + assertEquals(Job.State.COMPLETED, completed.state()); + throw new IllegalStateException("failure after committed completion"); + }) + .start(); + var subscription = workers.subscribe(event::complete, Workers.EventKind.JOB_COMPLETED); + var failures = + workers.subscribe( + ignored -> failedEvents.incrementAndGet(), Workers.EventKind.JOB_FAILED)) { + var inserted = river.insert(EMAIL, new Email("one@example.com")); + assertEquals(inserted.job().id(), event.get(10, TimeUnit.SECONDS).job().id()); + } + assertEquals(0, failedEvents.get()); + } + + @Test + void retryPolicyReceivesFailedAttemptSnapshot() throws Exception { + var river = database(); + var observed = new CompletableFuture>(); + var failed = new CompletableFuture(); + try (var workers = + river + .workers() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .retryPolicy( + job -> { + observed.complete(job); + return Duration.ofDays(1); + }) + .add( + EMAIL, + ignored -> { + throw new IllegalStateException("retry me"); + }) + .start(); + var subscription = workers.subscribe(failed::complete, Workers.EventKind.JOB_FAILED)) { + var inserted = + river.insert( + EMAIL, + new Email("retry@example.com"), + InsertOptions.builder().metadata(java.util.Map.of("policy", "slow")).build()); + var job = observed.get(10, TimeUnit.SECONDS); + assertEquals(inserted.job().id(), job.id()); + assertEquals("email", job.kind()); + assertEquals(1, job.attempt()); + assertTrue(job.errors().isEmpty()); + assertEquals("slow", job.metadata().path("policy").asString()); + assertEquals(Job.State.RETRYABLE, failed.get(10, TimeUnit.SECONDS).job().state()); + } + } + + @Test + void jobKindLengthMatchesDatabaseConstraint() throws Exception { + var client = database(); + var type = JobType.of("k".repeat(127), Email.class); + String oversized = "k".repeat(128); + assertThrows(IllegalArgumentException.class, () -> JobType.of(oversized, Email.class)); + var completed = new CompletableFuture(); + var worked = new CompletableFuture>(); + try (var workers = + client + .workers() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .add(type, context -> worked.complete(context.job())) + .start(); + var subscription = + workers.subscribe(completed::complete, Workers.EventKind.JOB_COMPLETED)) { + var args = new Email("long-kind@example.com"); + var inserted = client.insert(type, args).job(); + assertEquals(type.kind(), inserted.kind()); + assertEquals(args, client.get(inserted.id(), type).args()); + assertEquals(type.kind(), worked.get(10, TimeUnit.SECONDS).kind()); + assertEquals(args, worked.get().args()); + assertEquals(inserted.id(), completed.get(10, TimeUnit.SECONDS).job().id()); + var error = + assertThrows( + RiverException.class, + () -> + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + client.database().prefix() + + "river_job SET kind=? WHERE id=?", + oversized, + inserted.id())) { + statement.executeUpdate(); + } + return null; + })); + assertEquals(RiverException.Code.DATABASE, error.code()); + assertTrue(error.getMessage().contains("kind_length")); + } + } + + @Test + void stopAfterTimeoutStillWaitsForActiveWorker() throws Exception { + var acknowledging = new CountDownLatch(1); + var releaseAcknowledgement = new CountDownLatch(1); + var river = + database() + .withPlugin( + new Plugin() { + @Override + public void afterAttempt( + Connection connection, + Client.Driver driver, + Job job) + throws InterruptedException { + acknowledging.countDown(); + releaseAcknowledgement.await(); + } + }); + var entered = new CompletableFuture>(); + var release = new CountDownLatch(1); + var workers = + river + .workers() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .stopTimeout(Duration.ofMillis(100)) + .stuckThreshold(Duration.ofMinutes(1)) + .add( + EMAIL, + context -> { + entered.complete(context); + release.await(); + }) + .start(); + try { + river.insert(EMAIL, new Email("one@example.com")); + var context = entered.get(10, TimeUnit.SECONDS); + assertThrows(RiverException.class, workers::stop); + assertNull(context.cancellation(), "Graceful stop timeout must not cancel the attempt"); + assertThrows(RiverException.class, workers::stop); + assertNull(context.cancellation(), "Graceful stop timeout must not cancel the attempt"); + release.countDown(); + assertTrue(acknowledging.await(10, TimeUnit.SECONDS)); + // Returning from the handler does not finish stopping until its completion commits. + assertThrows(RiverException.class, workers::stop); + } finally { + release.countDown(); + releaseAcknowledgement.countDown(); + // The deliberately short deadline also applies to cleanup. Keep waiting for durable + // completion and background services without requiring CI to finish them within 100 ms. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (true) { + try { + workers.stopAndCancel(); + break; + } catch (RiverException error) { + if (!error.getMessage().equals("Workers have not stopped within the stop deadline") + || System.nanoTime() >= deadline) throw error; + } + } + } + assertEquals(Job.State.COMPLETED, river.list(JobQuery.all()).jobs().getFirst().state()); + } + + @Test + void uniqueByFieldsSelectsJsonNamesWithoutSplittingDots() { + var river = database(); + var type = + JobType.of("field_unique", tools.jackson.databind.JsonNode.class) + .uniqueBy("account.id") + .withDefaults(InsertOptions.builder().unique(Unique.args()).build()); + var first = river.insert(type, Json.parse("{\"account.id\":1,\"account\":{\"id\":2}}")); + var duplicate = river.insert(type, Json.parse("{\"account.id\":1,\"account\":{\"id\":3}}")); + var different = river.insert(type, Json.parse("{\"account.id\":2,\"account\":{\"id\":2}}")); + assertTrue(duplicate.uniqueSkippedAsDuplicate()); + assertEquals(first.job().id(), duplicate.job().id()); + assertFalse(different.uniqueSkippedAsDuplicate()); + assertNotEquals(first.job().id(), different.job().id()); + } + + record Report(int accountId) {} + + record Email(String address) {} +} diff --git a/java/river/src/test/java/com/riverqueue/Conformance.java b/java/river/src/test/java/com/riverqueue/Conformance.java new file mode 100644 index 000000000..d04830535 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/Conformance.java @@ -0,0 +1,32 @@ +package com.riverqueue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import tools.jackson.databind.JsonNode; + +final class Conformance { + private Conformance() {} + + static JsonNode fixture(String name) throws IOException { + return Json.parse(read(name)); + } + + static String read(String name) throws IOException { + // Resolve from the checkout, including when tests run directly from an IDE. + var root = Path.of("").toAbsolutePath(); + while (root != null && !Files.isRegularFile(root.resolve("go.work"))) root = root.getParent(); + if (root == null) throw new IOException("Cannot find the River repository's go.work"); + var path = root.resolve("conformance/testdata").resolve(name); + try { + return Files.readString(path); + } catch (NoSuchFileException e) { + throw new IOException( + "Missing conformance fixture " + + path + + "; run `make generate/fixtures` from the repository root", + e); + } + } +} diff --git a/java/river/src/test/java/com/riverqueue/DatabaseTest.java b/java/river/src/test/java/com/riverqueue/DatabaseTest.java new file mode 100644 index 000000000..dca1cfcaa --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/DatabaseTest.java @@ -0,0 +1,59 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Collections; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.parallel.Isolated; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +@Isolated("Captures JDBC connection properties without opening a database connection") +class DatabaseTest { + @ParameterizedTest + @CsvSource({ + "alice+tag:p+ass, alice+tag, p+ass", + "alice%2Btag:p%2Bass, alice+tag, p+ass", + "alice%20tag:p%20ass, alice tag, p ass", + "alice%3Atag:p%40ss%3A%2F%25, alice:tag, p@ss:/%", + "alice%252Btag:p%252Bass, alice%2Btag, p%2Bass", + "caf%C3%A9:p%C3%A4ss, café, päss" + }) + void postgresUriPreservesCredentials(String userInfo, String user, String password) + throws Exception { + var originalDrivers = Collections.list(DriverManager.getDrivers()); + var captured = new AtomicReference(); + var target = new AtomicReference(); + var driver = + new org.postgresql.Driver() { + @Override + public Connection connect(String url, Properties properties) throws SQLException { + captured.set(properties); + target.set(url); + throw new SQLException("Captured connection attempt"); + } + }; + try { + // DriverManager tries drivers in registration order; keep the real drivers from connecting. + for (var original : originalDrivers) DriverManager.deregisterDriver(original); + DriverManager.registerDriver(driver); + var database = + Database.connect( + "postgres://" + userInfo + "@localhost:5432/river_test?sslmode=require", "uri-test"); + assertEquals( + "Captured connection attempt", + assertThrows(SQLException.class, database::connection).getMessage()); + assertEquals(user, captured.get().getProperty("user")); + assertEquals(password, captured.get().getProperty("password")); + assertEquals("uri-test", captured.get().getProperty("ApplicationName")); + assertEquals("jdbc:postgresql://localhost:5432/river_test?sslmode=require", target.get()); + } finally { + DriverManager.deregisterDriver(driver); + for (var original : originalDrivers) DriverManager.registerDriver(original); + } + } +} diff --git a/java/river/src/test/java/com/riverqueue/IdentifierTest.java b/java/river/src/test/java/com/riverqueue/IdentifierTest.java new file mode 100644 index 000000000..9bc2613a1 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/IdentifierTest.java @@ -0,0 +1,99 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class IdentifierTest { + private static final JobType TYPE = JobType.of("identifier_test", String.class); + private final Workers.Builder workers = + new Client(Database.connect("jdbc:sqlite::memory:")).workers(); + + @ParameterizedTest + @ValueSource( + strings = { + "ab", + "_0", + "kind123", + "with.dot", + "with:colon", + "with+plus", + "with-hyphen", + "with_underscore", + "with[brackets]", + "with", + "with/slash", + "JobArgsReflectKind[github.com/riverqueue/river.JobArgs·12]" + }) + void acceptsSharedKindAndPeriodicIdSyntax(String value) { + assertDoesNotThrow(() -> JobType.of(value, String.class)); + assertDoesNotThrow(() -> periodic(value)); + } + + @Test + void clientIdLengthCountsUtf8Bytes() { + assertDoesNotThrow(() -> workers.id("x".repeat(100))); + assertThrows(IllegalArgumentException.class, () -> workers.id("x".repeat(101))); + assertDoesNotThrow(() -> workers.id("é".repeat(50))); + assertThrows(IllegalArgumentException.class, () -> workers.id("é".repeat(51))); + assertDoesNotThrow(() -> workers.id("😀".repeat(25))); + assertThrows(IllegalArgumentException.class, () -> workers.id("😀".repeat(26))); + } + + @Test + void duplicatePeriodicIdsAreRejected() { + periodic("daily-report"); + assertThrows(IllegalArgumentException.class, () -> periodic("daily-report")); + } + + @Test + void jobKindLengthCountsCharacters() { + assertDoesNotThrow(() -> JobType.of("k".repeat(127), String.class)); + assertThrows(IllegalArgumentException.class, () -> JobType.of("k".repeat(128), String.class)); + assertDoesNotThrow(() -> JobType.of("k" + "·".repeat(126), String.class)); + assertThrows( + IllegalArgumentException.class, () -> JobType.of("k" + "·".repeat(127), String.class)); + } + + @Test + void periodicIdLengthCountsUtf8Bytes() { + assertDoesNotThrow(() -> periodic("p".repeat(127))); + assertThrows(IllegalArgumentException.class, () -> periodic("p".repeat(128))); + assertDoesNotThrow(() -> periodic("p" + "·".repeat(63))); + assertThrows(IllegalArgumentException.class, () -> periodic("pp" + "·".repeat(63))); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource( + strings = { + "a", + "_", + " ", + "daily report", + "daily,report", + ":daily", + "·daily", + "daily!report", + "daily\nreport", + "daily-report\n", + "daily\treport", + "dáily", + "éxample", + "daily😀" + }) + void rejectsInvalidKindAndPeriodicIdSyntax(String value) { + if (value != null) + assertThrows(IllegalArgumentException.class, () -> JobType.of(value, String.class)); + assertThrows(IllegalArgumentException.class, () -> periodic(value)); + } + + private void periodic(String id) { + workers.periodic( + id, Schedule.every(Duration.ofDays(1)), TYPE, "args", InsertOptions.defaults(), false); + } +} diff --git a/java/river/src/test/java/com/riverqueue/JobQueryTest.java b/java/river/src/test/java/com/riverqueue/JobQueryTest.java new file mode 100644 index 000000000..ec1a0927b --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/JobQueryTest.java @@ -0,0 +1,106 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import tools.jackson.databind.node.ObjectNode; + +class JobQueryTest { + @TempDir Path directory; + @RegisterExtension final TestDatabase databases = new TestDatabase(); + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "{\"value\":true}|{\"value\":1}|false", + "{\"value\":false}|{\"value\":0}|false", + "{\"value\":1}|{\"value\":true}|false", + "{\"value\":1}|{\"value\":1.0}|true", + "{\"value\":\"1\"}|{\"value\":1}|false", + "{\"value\":\"text\"}|{\"value\":{\"key\":1}}|false", + "{\"value\":\"text\"}|{\"value\":[1]}|false", + "{\"value\":[{\"a\":1},{\"b\":2}]}|{\"value\":[{\"b\":2},{\"a\":1}]}|true", + "{\"value\":[1,2]}|{\"value\":[2,1]}|true", + "{\"value\":[1]}|{\"value\":[1,1]}|true", + "{\"value\":[[1]]}|{\"value\":[1]}|false", + "{\"value\":[1]}|{\"value\":1}|false", + "{\"value\":[{\"a\":1,\"b\":2}]}|{\"value\":[{\"b\":2}]}|true", + "{\"value\":[{\"a\":1},{\"b\":2}]}|{\"value\":[{\"a\":1,\"b\":2}]}|false", + "{\"a.b\":{\"c d\":null}}|{\"a.b\":{\"c d\":null}}|true", + "{\"value\":1}|[]|false", + "{\"value\":1}|1|false", + "{\"value\":1}|true|false", + "{\"value\":1}|null|false" + }) + void metadataContainmentPreservesJsonSemantics(String stored, String fragment, boolean matches) { + var client = new Client(databases.open(directory.resolve("river.db"))); + var inserted = + client + .insert( + JobType.of("metadata_filter", String.class), + "test", + InsertOptions.builder().metadata(Json.parse(stored)).build()) + .job(); + var query = JobQuery.builder().metadata(Json.parse(fragment)).build(); + var expected = matches ? List.of(inserted.id()) : List.of(); + assertEquals(expected, client.list(query).jobs().stream().map(Job::id).toList()); + assertEquals(expected, client.deleteMany(query, false).stream().map(Job::id).toList()); + assertEquals(matches ? 0 : 1, client.list(JobQuery.all()).jobs().size()); + } + + @ParameterizedTest + @CsvSource({"object,false", "array,false", "object,true", "array,true"}) + void metadataFiltersRequireEmptyContainers(String container, boolean nested) { + var client = new Client(databases.open(directory.resolve("river.db"))); + var type = JobType.of("metadata_filter", String.class); + String empty = container.equals("object") ? "{}" : "[]"; + String populated = container.equals("object") ? "{\"id\":1}" : "[1]"; + String wrongType = container.equals("object") ? "[]" : "{}"; + var expected = new ArrayList(); + var remaining = new ArrayList(); + for (String value : + List.of(empty, populated, wrongType, "null", "1", "true", "\"text\"", "missing")) { + var metadata = Json.object().put("plan", "pro"); + if (!value.equals("missing")) + metadata.set( + "tenant", + nested ? Json.object().set("settings", Json.parse(value)) : Json.parse(value)); + long id = + client.insert(type, value, InsertOptions.builder().metadata(metadata).build()).job().id(); + (value.equals(empty) || value.equals(populated) ? expected : remaining).add(id); + } + if (nested) { + var metadata = Json.object().put("plan", "pro").set("tenant", Json.object()); + remaining.add( + client + .insert( + type, "missing nested key", InsertOptions.builder().metadata(metadata).build()) + .job() + .id()); + } + var filter = Json.object().put("plan", "pro"); + filter.set( + "tenant", nested ? Json.object().set("settings", Json.parse(empty)) : Json.parse(empty)); + var query = JobQuery.builder().metadata(filter).build(); + assertEquals(expected, client.list(query).jobs().stream().map(Job::id).toList()); + assertEquals(expected, client.deleteMany(query, false).stream().map(Job::id).toList()); + assertEquals(remaining, client.list(JobQuery.all()).jobs().stream().map(Job::id).toList()); + } + + @Test + void metadataFilterIsImmutable() { + var source = Json.object().set("tenant", Json.object().put("id", "original")); + var query = JobQuery.builder().metadata(source).build(); + ((ObjectNode) source.path("tenant")).put("id", "changed source"); + ((ObjectNode) query.metadata().path("tenant")).put("id", "changed accessor"); + assertEquals("original", query.metadata().path("tenant").path("id").asString()); + } +} diff --git a/java/river/src/test/java/com/riverqueue/LeadershipTest.java b/java/river/src/test/java/com/riverqueue/LeadershipTest.java new file mode 100644 index 000000000..6e4bfcc1e --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/LeadershipTest.java @@ -0,0 +1,448 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class LeadershipTest { + private static final JobType TYPE = JobType.of("leadership_test", String.class); + @TempDir Path directory; + @RegisterExtension final TestDatabase databases = new TestDatabase(); + + private static Database failLeadershipCommit(Database database, AtomicBoolean fail) { + var source = + (DataSource) + Proxy.newProxyInstance( + DataSource.class.getClassLoader(), + new Class[] {DataSource.class}, + (proxy, method, args) -> { + if (!method.getName().equals("getConnection")) + throw new UnsupportedOperationException(); + var connection = database.connection(); + var election = new AtomicBoolean(); + return Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] {Connection.class}, + (ignored, operation, arguments) -> { + if (operation.getName().equals("prepareStatement") + && ((String) arguments[0]).contains("river_leader")) election.set(true); + if (operation.getName().equals("commit") + && election.get() + && fail.compareAndSet(true, false)) + throw new SQLException("leadership commit failed"); + try { + return operation.invoke(connection, arguments); + } catch (InvocationTargetException error) { + throw error.getCause(); + } + }); + }); + return new Database(source, database.dialect()).withSchema(database.schema()); + } + + @Test + void failedElectionCommitDoesNotPublishLeadership() throws Exception { + var database = databases.open(directory.resolve("river.db")); + var failCommit = new AtomicBoolean(true); + var errors = new LinkedBlockingQueue(); + var started = new CountDownLatch(1); + var reported = new CompletableFuture(); + var client = + new Client(failLeadershipCommit(database, failCommit)) + .withExtension( + new Extension() { + @Override + public void periodicStarted() { + started.countDown(); + } + }); + try (var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler( + error -> { + errors.add(error); + reported.join(); + }) + .add(TYPE, context -> {}) + .start()) { + try { + assertNotNull(errors.poll(5, TimeUnit.SECONDS)); + assertFalse(failCommit.get()); + assertFalse(workers.isLeader()); + assertEquals(1, started.getCount()); + assertEquals(0, leaderCount(new Client(database))); + } finally { + reported.complete(null); + } + } + } + + private static int leaderCount(Client client) { + return client.transaction( + connection -> { + try (var statement = connection.createStatement(); + var rows = + statement.executeQuery( + "SELECT count(*) FROM " + client.database().prefix() + "river_leader")) { + rows.next(); + return rows.getInt(1); + } + }); + } + + private static Lease lease(Client client) { + return client.transaction( + connection -> { + try (var statement = connection.createStatement(); + var rows = + statement.executeQuery( + "SELECT elected_at, expires_at FROM " + + client.database().prefix() + + "river_leader")) { + assertTrue(rows.next()); + return new Lease( + Database.instant(rows.getString(1)), Database.instant(rows.getString(2))); + } + }); + } + + @ParameterizedTest + @ValueSource(longs = {10, 3600, 86399}) + void leaseCoversTheRenewalInterval(long seconds) throws Exception { + var client = new Client(databases.open(directory.resolve("river.db"))); + var database = client.database(); + var checked = new LinkedBlockingQueue(); + var errors = new LinkedBlockingQueue(); + var ready = new CountDownLatch(1); + var interval = Duration.ofSeconds(seconds); + try (var workers = + client + .workers() + .queue("default", 1) + .serviceInterval(interval) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) ready.countDown(); + if (event.equals("leadership_check")) checked.add(event); + }) + .add(TYPE, context -> {}) + .start()) { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + assertNotNull(checked.poll(5, TimeUnit.SECONDS)); + assertTrue(workers.isLeader()); + var original = lease(client); + assertEquals( + interval.plusSeconds(10), Duration.between(original.electedAt(), original.expiresAt())); + assertFalse( + workers.isLeader(System.nanoTime() + interval.plusSeconds(10).toNanos()), + "A lease must not be trusted indefinitely when renewal stalls"); + + client.transaction( + connection -> { + // Shorten the persisted lease to distinguish renewal from the original acquisition. + try (var statement = + Sql.prepare( + connection, + "UPDATE " + database.prefix() + "river_leader SET expires_at=?", + database.timestamp(Instant.now().plusSeconds(5)))) { + assertEquals(1, statement.executeUpdate()); + } + client.notify(connection, Protocol.resigned("peer")); + return null; + }); + assertNotNull(checked.poll(5, TimeUnit.SECONDS)); + var renewed = lease(client); + assertEquals(original.electedAt(), renewed.electedAt()); + assertFalse(renewed.expiresAt().isBefore(original.expiresAt())); + assertTrue(workers.isLeader()); + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } + + @Test + void reacquisitionStartsANewTerm() throws Exception { + var client = new Client(databases.open(directory.resolve("river.db"))); + var starts = new LinkedBlockingQueue(); + var errors = new LinkedBlockingQueue(); + client = + client.withExtension( + new Extension() { + @Override + public void periodicStarted() { + starts.add(true); + } + }); + try (var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .serviceInterval(Duration.ofMillis(20)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .add(TYPE, context -> {}) + .start()) { + assertNotNull(starts.poll(5, TimeUnit.SECONDS)); + var original = lease(client); + var database = client.database(); + client.transaction( + connection -> { + try (var statement = + Sql.prepare(connection, "DELETE FROM " + database.prefix() + "river_leader")) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + assertNotNull( + starts.poll(5, TimeUnit.SECONDS), "A newly acquired term must rerun leadership hooks"); + assertNotEquals(original.electedAt(), lease(client).electedAt()); + assertTrue(workers.isLeader()); + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } + + @Test + void resignationWakesPeersOnBothDatabases() throws Exception { + var client = new Client(databases.open(directory.resolve("river.db"))); + var ready = new CountDownLatch(2); + var elected = new CountDownLatch(1); + var firstCheck = new CountDownLatch(1); + var secondCheck = new CountDownLatch(1); + var errors = new LinkedBlockingQueue(); + var first = + client + .workers() + .queue("default", 1) + .id("first") + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) ready.countDown(); + if (event.equals("leadership_check")) firstCheck.countDown(); + }) + .add(TYPE, context -> {}) + .start(); + try (first) { + assertTrue(firstCheck.await(5, TimeUnit.SECONDS)); + var follower = + client.withExtension( + new Extension() { + @Override + public void periodicStarted() { + elected.countDown(); + } + }); + try (var second = + follower + .workers() + .queue("default", 1) + .id("second") + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) ready.countDown(); + if (event.equals("leadership_check")) secondCheck.countDown(); + }) + .add(TYPE, context -> {}) + .start()) { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + assertTrue(secondCheck.await(5, TimeUnit.SECONDS)); + assertTrue(first.isLeader()); + assertFalse(second.isLeader()); + client.requestResign(); + assertTrue( + elected.await(5, TimeUnit.SECONDS), "Resignation must wake the follower immediately"); + assertFalse(first.isLeader()); + assertTrue(second.isLeader()); + } + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } + + @Test + void resignedTermDoesNotContinueMaintenance() throws Exception { + var base = new Client(databases.open(directory.resolve("river.db"))); + var entered = new CountDownLatch(1); + var ready = new CountDownLatch(1); + var release = new CompletableFuture(); + var rescues = new AtomicInteger(); + var errors = new LinkedBlockingQueue(); + var blocked = + base.withPlugin( + new Plugin() { + @Override + public void maintain(Client client, Instant now) { + entered.countDown(); + release.join(); + } + + @Override + public boolean rescue(Client client, Rescue request) { + rescues.incrementAndGet(); + return true; + } + }); + var first = + blocked + .workers() + .queue("default", 1) + .id("old-leader") + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) ready.countDown(); + }) + .add(TYPE, context -> {}) + .start(); + try { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var elected = new CountDownLatch(1); + var peer = + base.withExtension( + new Extension() { + @Override + public void periodicStarted() { + elected.countDown(); + } + }); + try (var second = + peer.workers() + .queue("default", 1) + .id("new-leader") + .pollOnly(true) + .serviceInterval(Duration.ofMillis(20)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .add(TYPE, context -> {}) + .start()) { + base.requestResign(); + assertTrue(elected.await(5, TimeUnit.SECONDS)); + assertFalse(first.isLeader()); + assertTrue(second.isLeader()); + release.complete(null); + first.stop(); + assertEquals(0, rescues.get(), "A resigned term must not start another maintenance stage"); + } + } finally { + release.complete(null); + first.stop(); + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } + + @Test + void startupHooksAreRetriedAfterFailure() throws Exception { + var attempts = new AtomicInteger(); + var started = new CountDownLatch(1); + var errors = new LinkedBlockingQueue(); + var client = + new Client(databases.open(directory.resolve("river.db"))) + .withExtension( + new Extension() { + @Override + public void periodicStarted() { + if (attempts.incrementAndGet() == 1) + throw new IllegalStateException("startup failed"); + started.countDown(); + } + }); + try (var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .serviceInterval(Duration.ofMillis(20)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .add(TYPE, context -> {}) + .start()) { + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertTrue(workers.isLeader()); + assertEquals(2, attempts.get()); + assertEquals("startup failed", errors.remove().getMessage()); + assertTrue(errors.isEmpty()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"heartbeat", "resign"}) + void stopRetriesFailedFinalization(String failure) throws Exception { + var database = databases.open(directory.resolve("river.db")); + var failCommit = new AtomicBoolean(); + var failHeartbeat = new AtomicBoolean(failure.equals("heartbeat")); + var stoppingThread = Thread.currentThread(); + var checked = new CountDownLatch(1); + var client = + new Client(failLeadershipCommit(database, failCommit)) + .withPlugin( + new Plugin() { + @Override + public void producer( + Connection connection, Client.Driver driver, Producer producer) + throws SQLException { + if (producer.paused() + && Thread.currentThread() == stoppingThread + && failHeartbeat.compareAndSet(true, false)) + throw new SQLException("final heartbeat failed"); + } + }); + var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .observe( + event -> { + if (event.equals("leadership_check")) checked.countDown(); + }) + .add(TYPE, context -> {}) + .start(); + try { + assertTrue(checked.await(5, TimeUnit.SECONDS)); + failCommit.set(failure.equals("resign")); + assertThrows(RiverException.class, workers::stop); + workers.stop(); + assertFalse(workers.isLeader()); + assertEquals( + 0, leaderCount(new Client(database)), "Repeated stop must finish releasing leadership"); + } finally { + workers.stop(); + } + } + + private record Lease(Instant electedAt, Instant expiresAt) {} +} diff --git a/java/river/src/test/java/com/riverqueue/MigratorTest.java b/java/river/src/test/java/com/riverqueue/MigratorTest.java new file mode 100644 index 000000000..159e2b480 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/MigratorTest.java @@ -0,0 +1,253 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MigratorTest { + @TempDir Path directory; + + private Database database() { + return Database.connect("jdbc:sqlite:" + directory.resolve("migration.db")); + } + + @ParameterizedTest + @CsvSource({"UP,false", "UP,true", "DOWN,false", "DOWN,true"}) + void concurrentMigratorsRecheckHistoryBetweenCommits( + Migrator.Direction direction, boolean limited) { + var database = database(); + boolean down = direction == Migrator.Direction.DOWN; + if (down) new Migrator(database).migrate(); + int steps = limited ? 2 : Migrator.LATEST; + var options = + Migrator.Options.defaults().targetVersion(down ? 0 : Migrator.LATEST).maxSteps(steps); + var peer = new AtomicReference(); + var interleaved = + interleaveFirstCommit( + database, + () -> peer.set(new Migrator(database).migrate(direction, options.maxSteps(steps - 1)))); + + var result = new Migrator(interleaved).migrate(direction, options); + + assertNotNull(peer.get()); + assertEquals(steps - 1, peer.get().applied().size()); + assertEquals(List.of(down ? Migrator.LATEST : 1), result.applied()); + assertEquals( + IntStream.rangeClosed(1, down ? Migrator.LATEST - steps : steps).boxed().toList(), + result.existing()); + assertEquals(!down && !limited, result.valid()); + } + + @Test + void concurrentMigratorsShareHistory() throws Exception { + var database = database(); + var ready = new CountDownLatch(2); + var start = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var results = + java.util.stream.IntStream.range(0, 2) + .mapToObj( + ignored -> + executor.submit( + () -> { + ready.countDown(); + assertTrue(start.await(5, TimeUnit.SECONDS)); + return new Migrator(database).migrate(); + })) + .toList(); + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + int applied = 0; + for (var result : results) applied += result.get(10, TimeUnit.SECONDS).applied().size(); + assertEquals(Migrator.LATEST, applied); + } + assertTrue(new Migrator(database).list().stream().allMatch(Migrator.Status::applied)); + } + + @Test + void failedMigrationRollsBackDdlAndHistory() throws Exception { + var database = database(); + new Migrator(database).migrate(); + var migrator = + new Migrator( + database, + "test", + List.of( + new Migrator.Migration( + 1, + "broken", + "CREATE TABLE migration_probe (id integer); INSERT INTO missing_table VALUES (1);", + "DROP TABLE migration_probe;"))); + assertThrows(RiverException.class, migrator::migrate); + try (var connection = database.connection(); + var statement = connection.createStatement(); + var rows = + statement.executeQuery( + "SELECT count(*) FROM sqlite_master WHERE name='migration_probe'")) { + assertTrue(rows.next()); + assertEquals(0, rows.getInt(1)); + } + assertFalse(migrator.list().getFirst().applied()); + } + + private static Database interleaveFirstCommit(Database database, Runnable peer) { + var first = new AtomicBoolean(true); + var source = + new org.sqlite.SQLiteDataSource() { + @Override + public Connection getConnection() throws SQLException { + var connection = database.connection(); + return (Connection) + Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] {Connection.class}, + (proxy, method, args) -> { + if (method.getName().equals("commit") && first.compareAndSet(true, false)) { + // Xerial releases the writer before beginning the next transaction. Run + // the peer in that gap so the interleaving never depends on scheduling. + connection.setAutoCommit(true); + try { + peer.run(); + } finally { + connection.setAutoCommit(false); + } + return null; + } + try { + return method.invoke(connection, args); + } catch (InvocationTargetException error) { + throw error.getCause(); + } + }); + } + }; + return new Database(source, Database.Dialect.SQLITE); + } + + @ParameterizedTest + @ValueSource(strings = {"cli_", "RiverJobs_"}) + @Tag("postgres") + @EnabledIfEnvironmentVariable(named = "RIVER_TEST_DATABASE_URL", matches = ".+") + void postgresSchemaDryRunAndRoundTrip(String prefix) throws Exception { + String schema = prefix + UUID.randomUUID().toString().replace("-", ""); + var database = Database.connect(System.getenv("RIVER_TEST_DATABASE_URL")).withSchema(schema); + var migrator = new Migrator(database); + try { + assertEquals( + Migrator.LATEST, + migrator + .migrate( + Migrator.Direction.UP, + Migrator.Options.defaults() + .targetVersion(Migrator.LATEST) + .maxSteps(Integer.MAX_VALUE) + .dryRun(true)) + .applied() + .size()); + try (var connection = database.connection(); + var statement = + connection.prepareStatement( + "SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name=?)")) { + statement.setString(1, schema); + try (var rows = statement.executeQuery()) { + assertTrue(rows.next()); + assertFalse(rows.getBoolean(1)); + } + } + assertTrue(migrator.migrate().valid()); + assertTrue(migrator.list().stream().allMatch(Migrator.Status::applied)); + assertEquals( + List.of(8, 7, 6, 5, 4, 3, 2, 1), + migrator + .migrate( + Migrator.Direction.DOWN, + Migrator.Options.defaults() + .targetVersion(0) + .maxSteps(Integer.MAX_VALUE) + .dryRun(false)) + .applied()); + assertTrue(migrator.list().stream().noneMatch(Migrator.Status::applied)); + } finally { + try (var connection = database.connection(); + var statement = connection.createStatement()) { + statement.execute("DROP SCHEMA IF EXISTS \"" + schema + "\" CASCADE"); + } + } + } + + @Test + void sqliteDryRunAndLegacyHistoryRoundTrip() throws Exception { + var database = database(); + var migrator = new Migrator(database); + assertEquals(8, migrator.list().size()); + assertEquals( + 8, + migrator + .migrate( + Migrator.Direction.UP, + Migrator.Options.defaults() + .targetVersion(8) + .maxSteps(Integer.MAX_VALUE) + .dryRun(true)) + .applied() + .size()); + try (var connection = database.connection(); + var statement = connection.createStatement(); + var rows = + statement.executeQuery( + "SELECT count(*) FROM sqlite_master WHERE name='river_migration'")) { + assertTrue(rows.next()); + assertEquals(0, rows.getInt(1)); + } + assertEquals( + List.of(1, 2, 3, 4), + migrator + .migrate( + Migrator.Direction.UP, + Migrator.Options.defaults() + .targetVersion(4) + .maxSteps(Integer.MAX_VALUE) + .dryRun(false)) + .applied()); + assertEquals(4, migrator.list().stream().filter(Migrator.Status::applied).count()); + assertTrue(migrator.migrate().valid()); + assertEquals( + List.of(8, 7, 6), + migrator + .migrate( + Migrator.Direction.DOWN, + Migrator.Options.defaults().targetVersion(0).maxSteps(3).dryRun(false)) + .applied()); + assertEquals( + List.of(5, 4, 3, 2, 1), + migrator + .migrate( + Migrator.Direction.DOWN, + Migrator.Options.defaults() + .targetVersion(0) + .maxSteps(Integer.MAX_VALUE) + .dryRun(false)) + .applied()); + assertTrue(migrator.list().stream().noneMatch(Migrator.Status::applied)); + assertTrue(migrator.migrate().valid()); + } +} diff --git a/java/river/src/test/java/com/riverqueue/NotificationCleanupTest.java b/java/river/src/test/java/com/riverqueue/NotificationCleanupTest.java new file mode 100644 index 000000000..a53a157a0 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/NotificationCleanupTest.java @@ -0,0 +1,128 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class NotificationCleanupTest { + @TempDir Path directory; + + @Test + void deletesOldestRowsInBoundedBatchesAndRetainsCutoff() { + var database = TestDatabase.sqlite(directory.resolve("river.db")); + var client = new Client(database); + Instant cutoff = Instant.parse("2026-01-02T03:04:05Z"); + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + "INSERT INTO river_notification (id, created_at, topic, payload) VALUES (?, ?, 'test', '{}')")) { + for (int id = 1; id <= 1003; id++) { + Instant createdAt = + switch (id) { + case 1 -> cutoff.minusMillis(1); + case 1002 -> cutoff; + case 1003 -> cutoff.plusMillis(1); + default -> cutoff.minusSeconds(1); + }; + statement.setInt(1, id); + statement.setObject(2, database.timestamp(createdAt)); + statement.addBatch(); + } + statement.executeBatch(); + } + return null; + }); + assertEquals(1000, clean(client, cutoff)); + assertEquals(List.of(1L, 1002L, 1003L), ids(client)); + assertEquals(1, clean(client, cutoff)); + assertEquals(0, clean(client, cutoff)); + assertEquals(List.of(1002L, 1003L), ids(client)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void leaderCleansNotificationsEvenWhenPluginOwnsJobCleaning(boolean pluginOwnsCleaning) + throws Exception { + var database = TestDatabase.sqlite(directory.resolve("river.db")); + var base = new Client(database); + base.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + "INSERT INTO river_notification (id, created_at, topic, payload) VALUES (?, ?, 'test', '{}')")) { + statement.setInt(1, 1); + statement.setObject(2, database.timestamp(Instant.EPOCH)); + statement.executeUpdate(); + statement.setInt(1, 2); + statement.setObject(2, database.timestamp(Instant.now().plus(Duration.ofDays(1)))); + statement.executeUpdate(); + } + return null; + }); + var cleaned = new CompletableFuture>(); + var errors = new LinkedBlockingQueue(); + var client = + base.withPlugin( + new Plugin() { + @Override + public boolean clean(Client river, Instant now) { + // The next maintenance stage observes the notification cleanup's committed result. + cleaned.complete(ids(river)); + return pluginOwnsCleaning; + } + }); + try (var workers = + client + .workers() + .queue("default", 1) + .add(JobType.of("unused", String.class), context -> {}) + .pollOnly(true) + .serviceInterval(Duration.ofHours(1)) + .errorHandler(errors::add) + .start()) { + assertEquals(List.of(2L), cleaned.get(5, TimeUnit.SECONDS)); + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } + + private static int clean(Client client, Instant cutoff) { + return client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + Sql.query(client.database(), "clean_notifications"), + client.database().timestamp(cutoff))) { + return statement.executeUpdate(); + } + }); + } + + private static List ids(Client client) { + return client.transaction( + connection -> { + var result = new ArrayList(); + try (var statement = connection.createStatement(); + var rows = + statement.executeQuery( + "SELECT id FROM river_notification WHERE topic='test' ORDER BY id")) { + while (rows.next()) result.add(rows.getLong(1)); + } + return result; + }); + } +} diff --git a/java/river/src/test/java/com/riverqueue/NotificationTest.java b/java/river/src/test/java/com/riverqueue/NotificationTest.java new file mode 100644 index 000000000..0cf20201e --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/NotificationTest.java @@ -0,0 +1,113 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NotificationTest { + @TempDir Path directory; + + @Test + void sqliteListenerRecoversFromAnInitialDatabaseFailure() throws Exception { + var database = TestDatabase.sqlite(directory.resolve("river.db")); + var fail = new AtomicBoolean(true); + var source = + (DataSource) + Proxy.newProxyInstance( + DataSource.class.getClassLoader(), + new Class[] {DataSource.class}, + (proxy, method, args) -> { + if (!method.getName().equals("getConnection")) + throw new UnsupportedOperationException(); + var connection = database.connection(); + return Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] {Connection.class}, + (ignored, operation, arguments) -> { + if (operation.getName().equals("prepareStatement") + && arguments[0].equals(Sql.query(database, "notification_cursor")) + && fail.compareAndSet(true, false)) + throw new SQLException("temporary cursor failure"); + try { + return operation.invoke(connection, arguments); + } catch (InvocationTargetException error) { + throw error.getCause(); + } + }); + }); + var initiallyFetched = new CompletableFuture(); + var client = + new Client(new Database(source, Database.Dialect.SQLITE)) + .withPlugin( + new Plugin() { + @Override + public List claim( + Connection connection, + Client.Driver driver, + Claim claim, + Client.Transaction> next) + throws Exception { + var rows = next.run(connection); + if (rows.isEmpty()) initiallyFetched.complete(null); + return rows; + } + }); + var type = JobType.of("notification_test", String.class); + var worked = new CompletableFuture>(); + var retry = new CompletableFuture(); + var ready = new CompletableFuture(); + var paused = new CompletableFuture(); + var errors = new LinkedBlockingQueue(); + try (var workers = + client + .workers() + .queue("default", 1) + .leadership(false) + .pollInterval(Duration.ofHours(1)) + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler( + error -> { + errors.add(error); + retry.join(); + }) + .observe( + event -> { + if (event.equals("listen_ready")) ready.complete(null); + }) + .add(type, context -> worked.complete(context.job())) + .start(); + var subscription = workers.subscribe(paused::complete, Workers.EventKind.QUEUE_PAUSED)) { + try { + var error = errors.poll(5, TimeUnit.SECONDS); + assertNotNull(error); + assertTrue(error.getMessage().contains("temporary cursor failure")); + initiallyFetched.get(5, TimeUnit.SECONDS); + var peer = new Client(database); + var inserted = peer.insert(type, "during startup").job(); + retry.complete(null); + ready.get(5, TimeUnit.SECONDS); + assertFalse(fail.get()); + assertEquals(inserted.id(), worked.get(5, TimeUnit.SECONDS).id()); + peer.queues().pause("default"); + assertEquals("default", paused.get(5, TimeUnit.SECONDS).queue().name()); + } finally { + retry.complete(null); + } + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } +} diff --git a/java/river/src/test/java/com/riverqueue/PeerCancellationTest.java b/java/river/src/test/java/com/riverqueue/PeerCancellationTest.java new file mode 100644 index 000000000..0f5698572 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/PeerCancellationTest.java @@ -0,0 +1,132 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import tools.jackson.databind.JsonNode; + +class PeerCancellationTest { + private static final JobType TYPE = JobType.of("peer_cancellation", String.class); + @RegisterExtension final TestDatabase databases = new TestDatabase(); + @TempDir Path directory; + + @ParameterizedTest + @CsvSource({ + "REMOTE,before_decode", + "REMOTE,after_decode", + "SHUTDOWN,before_decode", + "SHUTDOWN,after_decode", + "SHUTDOWN,forced", + "TIMEOUT,before_decode", + "TIMEOUT,after_decode" + }) + void cancellationDuringArgumentDecodingReachesTheTypedPeer( + WorkContext.Cancellation cause, String when) throws Exception { + var decoding = new CompletableFuture(); + var decode = new CompletableFuture(); + var database = databases.open(directory.resolve("river.db")); + var client = + new Client(database) + .withPlugin( + new Plugin() { + @Override + public JsonNode decode(Job job) { + if (job.queue().equals("peers")) { + decoding.complete(null); + decode.join(); + } + return job.args(); + } + }); + var inserted = + client.insert(TYPE, "peer", InsertOptions.builder().queue("peers").build()).job(); + var parent = new CompletableFuture>(); + var typed = new CompletableFuture>(); + var finish = new CompletableFuture(); + var completed = new CompletableFuture(); + var errors = new LinkedBlockingQueue(); + var workers = + client + .workers() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .add( + TYPE, + context -> { + parent.complete(context); + var peers = + context.claimPeers( + TYPE, + connection -> { + var jobs = new ArrayList(); + var now = database.timestamp(Instant.now()); + try (var statement = + Sql.prepare( + connection, + Sql.query(database, "claim"), + now, + "batch", + "peers", + now, + 1); + var rows = statement.executeQuery()) { + while (rows.next()) jobs.add(client.readPartial(rows)); + } + return jobs; + }); + var peer = peers.getFirst(); + typed.complete(peer); + finish.join(); + context.completePeer(peer, new InterruptedException("peer interrupted")); + completed.complete(null); + }) + .start(); + try { + client.insert(TYPE, "parent"); + decoding.get(5, TimeUnit.SECONDS); + var provisional = parent.get(5, TimeUnit.SECONDS).peers.get(inserted.id()).context(); + if (!when.equals("after_decode")) provisional.requestCancellation(cause); + if (when.equals("forced")) assertTrue(provisional.forceIfStuck(Duration.ZERO)); + decode.complete(null); + var context = typed.get(5, TimeUnit.SECONDS); + if (when.equals("after_decode")) provisional.requestCancellation(cause); + assertEquals(cause, context.cancellation()); + assertTrue(context.awaitCancellation(Duration.ZERO)); + finish.complete(null); + completed.get(5, TimeUnit.SECONDS); + assertFalse( + provisional.forceIfStuck(Duration.ZERO), "Finished attempts must stop being supervised"); + workers.stop(); + var job = client.get(inserted.id()); + if (when.equals("forced")) { + assertEquals(1, job.attempt()); + assertEquals(1, job.errors().size()); + assertTrue(job.errors().getFirst().error().contains("ignored cancellation")); + } else if (cause == WorkContext.Cancellation.REMOTE) + assertEquals(Job.State.CANCELLED, job.state()); + else if (cause == WorkContext.Cancellation.SHUTDOWN) { + assertEquals(Job.State.AVAILABLE, job.state()); + assertEquals(0, job.attempt()); + assertTrue(job.errors().isEmpty()); + } else assertEquals(1, job.errors().size()); + } finally { + decode.complete(null); + finish.complete(null); + workers.stopAndCancel(); + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } +} diff --git a/java/river/src/test/java/com/riverqueue/PostgresCapabilitiesTest.java b/java/river/src/test/java/com/riverqueue/PostgresCapabilitiesTest.java new file mode 100644 index 000000000..4c79bb630 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/PostgresCapabilitiesTest.java @@ -0,0 +1,86 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@Tag("postgres") +@EnabledIfEnvironmentVariable(named = "RIVER_TEST_DATABASE_URL", matches = ".+") +class PostgresCapabilitiesTest { + private static final JobType TYPE = JobType.of("yugabyte_test", Args.class); + @RegisterExtension final TestDatabase databases = new TestDatabase(); + @TempDir Path directory; + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void yugabyteInsertsUseNoncesWithoutPostgresSystemColumns(boolean notificationsEnabled) { + var database = databases.open(directory.resolve("river.db")); + Assumptions.assumeTrue(database.dialect() == Database.Dialect.POSTGRES); + var notified = new AtomicInteger(); + var source = + (DataSource) + Proxy.newProxyInstance( + DataSource.class.getClassLoader(), + new Class[] {DataSource.class}, + (proxy, method, args) -> { + if (!method.getName().equals("getConnection")) + throw new UnsupportedOperationException(); + var connection = database.connection(); + return Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] {Connection.class}, + (ignored, operation, arguments) -> { + if (operation.getName().equals("prepareStatement")) { + var sql = (String) arguments[0]; + if (sql.equals(Sql.query(database, "database_version"))) + return connection.prepareStatement( + "SELECT 'PostgreSQL 15.12-YB-2025.2.3.0', " + notificationsEnabled); + if (sql.contains("xmax")) + throw new SQLException("YugabyteDB has no xmax column"); + if (sql.contains("pg_notify")) notified.incrementAndGet(); + } + try { + return operation.invoke(connection, arguments); + } catch (InvocationTargetException error) { + throw error.getCause(); + } + }); + }); + var client = new Client(new Database(source, database.dialect()).withSchema(database.schema())); + var plain = client.insert(TYPE, new Args("plain")); + assertFalse(plain.uniqueSkippedAsDuplicate()); + assertTrue(plain.job().metadata().hasNonNull(Protocol.METADATA_UNIQUE_NONCE)); + + var options = InsertOptions.builder().unique(Unique.args()).build(); + var original = client.insert(TYPE, new Args("unique"), options); + var duplicate = client.insert(TYPE, new Args("unique"), options); + assertFalse(original.uniqueSkippedAsDuplicate()); + assertTrue(duplicate.uniqueSkippedAsDuplicate()); + assertEquals(original.job(), duplicate.job()); + + var batch = client.insertMany(TYPE, List.of(new Args("one"), new Args("two")), options); + var repeated = client.insertMany(TYPE, List.of(new Args("one"), new Args("two")), options); + assertTrue(batch.stream().noneMatch(Job.InsertResult::uniqueSkippedAsDuplicate)); + assertTrue(repeated.stream().allMatch(Job.InsertResult::uniqueSkippedAsDuplicate)); + assertEquals( + batch.stream().map(Job.InsertResult::job).toList(), + repeated.stream().map(Job.InsertResult::job).toList()); + assertEquals(notificationsEnabled ? 4 : 0, notified.get()); + } + + private record Args(String value) {} +} diff --git a/java/river/src/test/java/com/riverqueue/ProtocolStorageTest.java b/java/river/src/test/java/com/riverqueue/ProtocolStorageTest.java new file mode 100644 index 000000000..e7206c185 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/ProtocolStorageTest.java @@ -0,0 +1,207 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import tools.jackson.databind.JsonNode; + +@Tag("conformance") +class ProtocolStorageTest { + private static final JobType TYPE = JobType.of("conformance", String.class); + @TempDir Path directory; + @RegisterExtension final TestDatabase databases = new TestDatabase(); + + private Client client() { + return new Client(databases.open(directory.resolve("river.db"))); + } + + @Test + void periodicMetadata() throws Exception { + var keys = Conformance.fixture("protocol_values.json").required("metadata_keys"); + var client = client(); + var claimed = new CompletableFuture>(); + var errors = new LinkedBlockingQueue(); + try (var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .errorHandler(errors::add) + .add(TYPE, context -> claimed.complete(context.job())) + .periodic( + "conformance_periodic", + Schedule.every(Duration.ofDays(1)), + TYPE, + "periodic", + InsertOptions.builder().metadata(Map.of("source", "conformance")).build(), + true) + .start()) { + var job = claimed.get(5, TimeUnit.SECONDS); + var metadata = client.get(job.id()).metadata(); + assertTrue(metadata.required("periodic").asBoolean()); + assertEquals( + "conformance_periodic", + metadata.required(keys.required("periodic_job_id").asString()).asString()); + assertEquals("conformance", metadata.required("source").asString()); + if (!errors.isEmpty()) throw new AssertionError("Unexpected worker failure", errors.peek()); + } + } + + @Test + void rescueCountAndOutput() throws Exception { + var fixture = Conformance.fixture("protocol_values.json"); + var keys = fixture.required("metadata_keys"); + String rescueKey = keys.required("rescue_count").asString(); + var maintained = new CompletableFuture(); + var client = + client() + .withPlugin( + new Plugin() { + @Override + public boolean clean(Client client, Instant now) { + // Maintenance calls clean only after rescue has committed. + maintained.complete(null); + return true; + } + }); + var inserted = + client + .insert(TYPE, "rescue", InsertOptions.builder().metadata(Map.of(rescueKey, 2)).build()) + .job(); + var database = client.database(); + boolean postgres = database.dialect() == Database.Dialect.POSTGRES; + if (!postgres) { + var nonce = inserted.metadata().required(keys.required("unique_nonce").asString()); + assertTrue(nonce.isString()); + assertFalse(nonce.asString().isEmpty()); + } + client.transaction( + connection -> { + // Seed an abandoned Go attempt without waiting for a real worker to become stuck. + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + database.prefix() + + "river_job SET state = 'running', attempt = 3, attempted_at = ?, errors = " + + (postgres ? "ARRAY[?::jsonb]" : "json_array(json(?))") + + " WHERE id = ?", + database.timestamp(Instant.EPOCH), + Json.encode(fixture.required("attempt_error")), + inserted.id())) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + assertEquals( + fixture.required("attempt_error"), + Json.tree(client.get(inserted.id()).errors().getFirst())); + + var completed = new CompletableFuture(); + var writeConflict = new CompletableFuture(); + var errors = new LinkedBlockingQueue(); + try (var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .errorHandler(errors::add) + .retryPolicy( + job -> { + if (!postgres) { + // Retry selection is between rescue's read and update. A second writer + // must already be excluded, or its commit would invalidate that snapshot. + try (var connection = database.connection(); + var statement = connection.createStatement()) { + statement.execute("PRAGMA busy_timeout=0"); + statement.executeUpdate( + "UPDATE river_job SET priority = 2 WHERE id = " + job.id()); + writeConflict.complete(null); + } catch (java.sql.SQLException error) { + writeConflict.complete(error); + } + } + return Duration.ofDays(1); + }) + .add(TYPE, context -> context.output(Map.of("ok", true))) + .start(); + var subscription = + workers.subscribe(completed::complete, Workers.EventKind.JOB_COMPLETED)) { + if (!postgres) { + var conflict = writeConflict.get(5, TimeUnit.SECONDS); + assertNotNull(conflict, "Rescue must reserve the writer before selecting jobs"); + assertEquals(5, conflict.getErrorCode(), "Expected SQLITE_BUSY for the competing writer"); + } + maintained.get(5, TimeUnit.SECONDS); + var rescued = client.get(inserted.id()); + assertEquals(Job.State.RETRYABLE, rescued.state()); + assertEquals(3, rescued.metadata().required(rescueKey).asInt()); + assertEquals(2, rescued.errors().size()); + assertEquals(fixture.required("attempt_error"), Json.tree(rescued.errors().getFirst())); + + client.retry(inserted.id()); + assertEquals(inserted.id(), completed.get(5, TimeUnit.SECONDS).job().id()); + var job = client.get(inserted.id()); + assertEquals(Job.State.COMPLETED, job.state()); + assertEquals( + Json.tree(Map.of("ok", true)), + job.metadata().required(keys.required("output").asString())); + assertEquals(3, job.metadata().required(rescueKey).asInt()); + assertEquals(rescued.errors(), job.errors()); + if (!errors.isEmpty()) throw new AssertionError("Unexpected worker failure", errors.peek()); + } + } + + @Test + void storedStatesAndUniqueBits() throws Exception { + var client = client(); + var database = client.database(); + boolean postgres = database.dialect() == Database.Dialect.POSTGRES; + var inserted = client.insert(TYPE, "state").job(); + for (JsonNode fixture : Conformance.fixture("protocol_values.json").required("job_states")) { + String state = fixture.required("state").asString(); + int bit = fixture.required("unique_bit").asInt(); + Object mask = + postgres ? String.format("%8s", Integer.toBinaryString(bit)).replace(' ', '0') : bit; + var finalizedAt = + List.of("cancelled", "completed", "discarded").contains(state) + ? inserted.createdAt() + : null; + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + database.prefix() + + "river_job SET state = " + + (postgres ? "?::" + database.prefix() + "river_job_state" : "?") + + ", finalized_at = ?, unique_states = " + + (postgres ? "?::bit(8)" : "?") + + " WHERE id = ?", + state, + database.timestamp(finalizedAt), + mask, + inserted.id())) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + var job = client.get(inserted.id()); + assertEquals(state, job.state().value()); + assertEquals(List.of(job.state()), job.uniqueStates()); + assertEquals(bit, job.uniqueStates().getFirst().bit()); + } + } +} diff --git a/java/river/src/test/java/com/riverqueue/ProtocolTest.java b/java/river/src/test/java/com/riverqueue/ProtocolTest.java new file mode 100644 index 000000000..eb0424058 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/ProtocolTest.java @@ -0,0 +1,362 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import tools.jackson.databind.JsonNode; + +@Tag("conformance") +class ProtocolTest { + @Test + void attemptError() throws Exception { + var expected = Conformance.fixture("protocol_values.json").required("attempt_error"); + var error = Json.decode(expected, Job.AttemptError.class); + // Independent expectations catch renamed fields that a decoder might silently ignore. + assertEquals( + new Job.AttemptError( + Instant.parse("2026-01-02T03:04:05.6789Z"), + 3, + "worker failed: escaped \"detail\"", + "frame one\nframe two"), + error); + assertEquals(expected, Json.tree(error)); + } + + @Test + void metadataKeysAndTopics() throws Exception { + var fixture = Conformance.fixture("protocol_values.json"); + assertEquals( + fixture.required("metadata_keys"), + Json.tree( + Map.of( + "output", Protocol.METADATA_OUTPUT, + "periodic_job_id", Protocol.METADATA_PERIODIC_JOB_ID, + "rescue_count", Protocol.METADATA_RESCUE_COUNT, + "resumable_cursor", Protocol.METADATA_RESUMABLE_CURSOR, + "resumable_step", Protocol.METADATA_RESUMABLE_STEP, + "unique_nonce", Protocol.METADATA_UNIQUE_NONCE))); + assertEquals( + fixture.required("topics"), + Json.tree( + Map.of( + "control", Protocol.TOPIC_CONTROL, + "insert", Protocol.TOPIC_INSERT, + "leadership", Protocol.TOPIC_LEADERSHIP))); + } + + @TestFactory + Stream notificationDispatch() throws Exception { + var fixtures = Conformance.fixture("protocol_values.json").required("notifications"); + var names = + java.util.Set.of( + "cancel", + "insert", + "metadata_changed", + "pause", + "request_resign", + "resigned", + "resume"); + assertEquals(names.size(), fixtures.size()); + var seen = new java.util.HashSet(); + var tests = new ArrayList(); + for (var fixture : fixtures) { + String name = fixture.required("name").asString(); + assertTrue(seen.add(name), "Duplicate notification fixture: " + name); + assertTrue(names.contains(name), "Uncovered Go notification: " + name); + for (String clientId : List.of("client-1", "observer")) { + tests.add( + DynamicTest.dynamicTest( + name + " received by " + clientId, + () -> { + var target = context(42); + var unrelated = context(43); + var queues = new ArrayList(); + var leadership = new ArrayList(); + var pending = new HashMap(); + var dispatcher = + new Protocol.Dispatcher( + clientId, + Map.of(42L, target, 43L, unrelated), + pending, + queues::add, + leadership::add); + dispatcher.dispatch( + fixture.required("topic").asString(), + Json.encode(fixture.required("payload"))); + + assertEquals( + name.equals("cancel") ? WorkContext.Cancellation.REMOTE : null, + target.cancellation()); + assertNull(unrelated.cancellation(), "Notification must not cancel another job"); + assertTrue(pending.isEmpty(), "A registered attempt consumes its cancellation"); + switch (name) { + case "insert", "metadata_changed", "pause", "resume" -> { + assertEquals(List.of(new Protocol.QueueNotice("priority", name)), queues); + assertTrue(leadership.isEmpty()); + } + case "request_resign" -> { + assertTrue(queues.isEmpty()); + assertEquals(List.of(Protocol.LeadershipNotice.REQUEST_RESIGN), leadership); + } + case "resigned" -> { + assertTrue(queues.isEmpty()); + assertEquals( + clientId.equals("client-1") + ? List.of() + : List.of(Protocol.LeadershipNotice.CHANGED), + leadership); + } + case "cancel" -> { + assertTrue(queues.isEmpty()); + assertTrue(leadership.isEmpty()); + } + default -> fail("Uncovered Go notification: " + name); + } + })); + } + } + return tests.stream(); + } + + @Test + void notificationTopicsAndPendingCancellation() throws Exception { + var fixture = Conformance.fixture("protocol_values.json").required("notifications"); + var cancel = + java.util.stream.StreamSupport.stream(fixture.spliterator(), false) + .filter(value -> value.required("name").asString().equals("cancel")) + .findFirst() + .orElseThrow(); + var unrelated = context(43); + var pending = new HashMap(); + var queues = new ArrayList(); + var leadership = new ArrayList(); + var dispatcher = + new Protocol.Dispatcher( + "observer", Map.of(43L, unrelated), pending, queues::add, leadership::add); + String payload = Json.encode(cancel.required("payload")); + dispatcher.dispatch("unrelated", payload); + dispatcher.dispatch(Protocol.TOPIC_LEADERSHIP, payload); + assertTrue(pending.isEmpty()); + assertTrue(queues.isEmpty()); + assertTrue(leadership.isEmpty()); + + dispatcher.dispatch(Protocol.TOPIC_INSERT, payload); + assertEquals(List.of(new Protocol.QueueNotice("priority", "insert")), queues); + assertTrue( + pending.isEmpty(), "A control payload on the insert topic must not cancel an attempt"); + queues.clear(); + + dispatcher.dispatch(cancel.required("topic").asString(), payload); + assertEquals(java.util.Set.of(42L), pending.keySet()); + assertNull(unrelated.cancellation()); + assertTrue(queues.isEmpty()); + assertTrue(leadership.isEmpty()); + } + + private static WorkContext context(long id) { + return context(id, Json.object()); + } + + private static WorkContext context(long id, JsonNode metadata) { + var now = Instant.parse("2026-01-02T03:04:05Z"); + var job = + new Job<>( + id, + "fixture", + 1, + now, + List.of("fixture"), + now, + List.of(), + null, + "fixture", + 25, + metadata, + 1, + "priority", + now, + Job.State.RUNNING, + List.of(), + null, + null); + // A fixture test must not open a connection, even when constructing a work context. + return new WorkContext<>( + new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")), job); + } + + @TestFactory + Stream notificationEncoding() throws Exception { + var notifications = + Map.of( + "cancel", Protocol.cancel(42, "priority"), + "insert", Protocol.insert("priority"), + "metadata_changed", + Protocol.queueMetadataChanged("priority", Map.of("owner", "candidate")), + "pause", Protocol.queuePause("priority", true), + "request_resign", Protocol.requestResign(), + "resigned", Protocol.resigned("client-1"), + "resume", Protocol.queuePause("priority", false)); + var fixtures = Conformance.fixture("protocol_values.json").required("notifications"); + assertEquals(notifications.size(), fixtures.size()); + var tests = new ArrayList(); + for (var fixture : fixtures) { + String name = fixture.required("name").asString(); + tests.add( + DynamicTest.dynamicTest( + name, + () -> { + var notification = notifications.get(name); + assertNotNull(notification, "Uncovered Go notification: " + name); + assertEquals(fixture.required("topic").asString(), notification.topic()); + assertEquals(fixture.required("payload"), Json.parse(notification.payload())); + })); + } + return tests.stream(); + } + + @TestFactory + Stream retryBounds() throws Exception { + var fixtures = Conformance.fixture("protocol_values.json").required("retry_cases"); + assertFalse(fixtures.isEmpty()); + var tests = new ArrayList(); + for (var fixture : fixtures) { + int count = fixture.required("error_count").asInt(); + tests.add( + DynamicTest.dynamicTest( + "error count " + count, + () -> { + var now = Instant.parse(fixture.required("now").asString()); + var error = new Job.AttemptError(now, 1, "previous failure", ""); + // Attempts include snoozes, whereas the retry policy counts only failures. + var job = + new Job<>( + fixture.required("job_id").asLong(), + Json.object(), + 999, + now, + java.util.List.of("fixture"), + now, + Collections.nCopies(count - 1, error), + null, + "fixture_retry", + 1000, + Json.object().put("snoozes", 50), + 1, + "default", + now, + Job.State.RETRYABLE, + java.util.List.of(), + null, + null); + var policy = + RetryPolicy.defaults( + new Random( + new java.math.BigInteger(fixture.required("seed").asString()) + .longValue())); + long min = fixture.required("min_delay_ns").asLong(); + long max = fixture.required("max_delay_ns").asLong(); + // Seeded samples alone need not exercise either jitter boundary or the duration + // cap. + for (double jitter : new double[] {0, 0.5, Math.nextDown(1.0)}) { + var boundaryPolicy = + RetryPolicy.defaults( + new Random(0) { + @Override + public double nextDouble() { + return jitter; + } + }); + long delay = boundaryPolicy.delay(job).toNanos(); + assertTrue( + delay >= min && delay <= max, + "Jitter " + + jitter + + ": delay " + + delay + + " outside Go bounds " + + min + + ".." + + max); + } + for (int sample = 0; sample < 100; sample++) { + long delay = policy.delay(job).toNanos(); + assertTrue( + delay >= min && delay <= max, + "Delay " + delay + " outside Go bounds " + min + ".." + max); + } + })); + } + return tests.stream(); + } + + @Test + void resumableMetadata() throws Exception { + var keys = Conformance.fixture("protocol_values.json").required("metadata_keys"); + String cursorKey = keys.required("resumable_cursor").asString(); + String stepKey = keys.required("resumable_step").asString(); + var context = + context( + 42, + Json.tree( + Map.of(cursorKey, Map.of("process", Map.of("offset", 2)), stepKey, "process"))); + context.step("before", () -> fail("A completed step must not run again")); + var failure = new Exception("retry"); + assertSame( + failure, + assertThrows( + Exception.class, + () -> + context.stepWithCursor( + "process", + cursor -> { + assertEquals(Json.tree(Map.of("offset", 2)), cursor); + context.cursor(Map.of("offset", 3)); + throw failure; + }))); + assertSame(failure, context.finish(failure)); + assertEquals( + Json.tree(Map.of(cursorKey, Map.of("process", Map.of("offset", 3)), stepKey, "process")), + context.metadataUpdates()); + } + + @TestFactory + Stream snoozeCounters() throws Exception { + var fixtures = Conformance.fixture("snooze_counters.json").required("snooze_counters"); + assertFalse(fixtures.isEmpty()); + var tests = new ArrayList(); + for (var fixture : fixtures) + tests.add( + DynamicTest.dynamicTest( + fixture.required("name").asString(), + () -> + assertEquals( + fixture.required("expected_snoozes").asLong(), + Workers.nextSnoozeCount(fixture.required("metadata"))))); + return tests.stream(); + } + + @Test + void states() throws Exception { + var states = Conformance.fixture("protocol_values.json").required("job_states"); + assertEquals(Job.State.values().length, states.size()); + var seen = java.util.EnumSet.noneOf(Job.State.class); + for (var fixture : states) { + String value = fixture.required("state").asString(); + var state = Job.State.of(value); + assertTrue(seen.add(state)); + assertEquals(value, state.value()); + assertEquals(fixture.required("unique_bit").asInt(), state.bit()); + } + } +} diff --git a/java/river/src/test/java/com/riverqueue/RescueTest.java b/java/river/src/test/java/com/riverqueue/RescueTest.java new file mode 100644 index 000000000..dc424600b --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/RescueTest.java @@ -0,0 +1,170 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.sql.DataSource; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RescueTest { + private static final JobType TYPE = JobType.of("rescue_test", String.class); + @RegisterExtension final TestDatabase databases = new TestDatabase(); + @TempDir Path directory; + + @ParameterizedTest + @ValueSource(strings = {"local", "peer", "peer_claim_retry", "peer_notification_retry"}) + void shortRescuedRetriesWakeFetchingBeforeMaintenance(String mode) throws Exception { + boolean peer = !mode.equals("local"); + var database = databases.open(directory.resolve("river.db")); + var maintained = new CompletableFuture(); + var failNotification = new AtomicBoolean(mode.equals("peer_notification_retry")); + var failClaim = new AtomicBoolean(mode.equals("peer_claim_retry")); + var client = + new Client(failingNotifications(database, maintained, failNotification)) + .withPlugin( + new Plugin() { + @Override + public boolean clean(Client client, Instant now) { + maintained.complete(null); + return true; + } + }); + var inserted = client.insert(TYPE, "abandoned").job(); + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + database.prefix() + + "river_job SET state='running', attempt=1, attempted_at=? WHERE id=?", + database.timestamp(Instant.EPOCH), + inserted.id())) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + var worked = new CompletableFuture>(); + var errors = new LinkedBlockingQueue(); + var listening = new CompletableFuture(); + var fetched = new CompletableFuture(); + var peerClient = + new Client(database) + .withPlugin( + new Plugin() { + @Override + public List claim( + Connection connection, + Client.Driver driver, + Claim claim, + Client.Transaction> next) + throws Exception { + listening.get(5, TimeUnit.SECONDS); + var rows = next.run(connection); + if (!rows.isEmpty() && failClaim.compareAndSet(true, false)) + throw new SQLException("claim temporarily unavailable"); + fetched.complete(null); + return rows; + } + }); + try (var follower = + peer + ? peerClient + .workers() + .queue("default", 1) + .leadership(false) + .pollInterval(Duration.ofHours(1)) + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) listening.complete(null); + }) + .add(TYPE, context -> worked.complete(context.job())) + .start() + : null) { + if (peer) fetched.get(5, TimeUnit.SECONDS); + try (var workers = + client + .workers() + .queue(peer ? "other" : "default", 1) + .pollOnly(true) + .pollInterval(Duration.ofHours(1)) + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .retryPolicy(job -> Duration.ofSeconds(1)) + .errorHandler(errors::add) + .add(TYPE, context -> worked.complete(context.job())) + .start()) { + maintained.get(5, TimeUnit.SECONDS); + var rescued = client.get(inserted.id()); + assertEquals(Job.State.AVAILABLE, rescued.state()); + var attempt = worked.get(5, TimeUnit.SECONDS); + assertEquals(inserted.id(), attempt.id()); + assertEquals(2, attempt.attempt()); + assertFalse(attempt.attemptedAt().isBefore(rescued.scheduledAt())); + assertEquals(1, attempt.metadata().path(Protocol.METADATA_RESCUE_COUNT).asInt()); + assertEquals(1, attempt.errors().size()); + } + } + assertFalse(failNotification.get()); + assertFalse(failClaim.get()); + if (mode.equals("peer_claim_retry")) { + var error = errors.poll(); + assertNotNull(error); + assertTrue(error.getMessage().contains("claim temporarily unavailable")); + } + if (mode.equals("peer_notification_retry")) { + var error = errors.poll(); + assertNotNull(error); + assertTrue(error.getMessage().contains("notification temporarily unavailable")); + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } + + private static Database failingNotifications( + Database database, CompletableFuture maintained, AtomicBoolean fail) { + if (!fail.get()) return database; + var source = + (DataSource) + Proxy.newProxyInstance( + DataSource.class.getClassLoader(), + new Class[] {DataSource.class}, + (proxy, method, args) -> { + if (!method.getName().equals("getConnection")) + throw new UnsupportedOperationException(); + var connection = database.connection(); + return Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] {Connection.class}, + (ignored, operation, arguments) -> { + if (operation.getName().equals("prepareStatement") + && arguments[0].equals(Sql.query(database, "notify")) + && maintained.isDone() + && fail.compareAndSet(true, false)) + throw new SQLException("notification temporarily unavailable"); + try { + return operation.invoke(connection, arguments); + } catch (InvocationTargetException error) { + throw error.getCause(); + } + }); + }); + return new Database(source, database.dialect()).withSchema(database.schema()); + } +} diff --git a/java/river/src/test/java/com/riverqueue/RetryTest.java b/java/river/src/test/java/com/riverqueue/RetryTest.java new file mode 100644 index 000000000..ef5a0418b --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/RetryTest.java @@ -0,0 +1,219 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import tools.jackson.databind.JsonNode; + +class RetryTest { + private static final Instant NOW = Instant.parse("2026-01-02T03:04:05Z"); + private static final JobType TYPE = JobType.of("retry_test", String.class); + @RegisterExtension final TestDatabase databases = new TestDatabase(); + @TempDir Path directory; + + @ParameterizedTest + @EnumSource(Job.State.class) + void retryNotifiesOnlyWhenTheRowChanges(Job.State state) { + var database = databases.open(directory.resolve("river.db")); + var client = new Client(database, Clock.fixed(NOW, ZoneOffset.UTC)); + var inserted = client.insert(TYPE, "retry").job(); + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + database.prefix() + + "river_job SET state='" + + state.value() + + "', attempt=1, max_attempts=1, finalized_at=?, scheduled_at=? WHERE id=?", + database.timestamp(state.isFinalized() ? NOW : null), + database.timestamp( + state == Job.State.AVAILABLE ? NOW.minusSeconds(1) : NOW.plusSeconds(3600)), + inserted.id())) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + var before = client.get(inserted.id()); + var notifications = new AtomicInteger(); + client.onInsertCommit(notifications::incrementAndGet); + var retried = client.retry(inserted.id()); + if (state == Job.State.AVAILABLE || state == Job.State.RUNNING) { + assertEquals(before, retried); + assertEquals(0, notifications.get()); + } else { + assertEquals(Job.State.AVAILABLE, retried.state()); + assertEquals(NOW, retried.scheduledAt()); + assertNull(retried.finalizedAt()); + assertEquals(2, retried.maxAttempts()); + assertEquals(1, notifications.get()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"local", "peer", "transaction"}) + void retryWakesWorkersAfterCommit(String mode) throws Exception { + var database = databases.open(directory.resolve("river.db")); + var warmed = new CompletableFuture(); + var idle = new CompletableFuture(); + var client = + new Client(database) + .withPlugin( + new Plugin() { + @Override + public void afterAttempt( + Connection connection, Client.Driver driver, Job original) { + if (original.args().asString().equals("warmup")) warmed.complete(null); + } + + @Override + public List claim( + Connection connection, + Client.Driver driver, + Claim claim, + Client.Transaction> next) + throws Exception { + var rows = next.run(connection); + if (warmed.isDone() && rows.isEmpty()) idle.complete(null); + return rows; + } + }); + var inserted = + client + .insert( + TYPE, + "retry", + InsertOptions.builder() + .queue("urgent") + .scheduledAt(Instant.now().plusSeconds(3600)) + .build()) + .job(); + client.insert(TYPE, "warmup", InsertOptions.builder().queue("urgent").build()); + var listening = new CompletableFuture(); + if (mode.equals("local")) listening.complete(null); + var worked = new CompletableFuture>(); + var errors = new LinkedBlockingQueue(); + try (var workers = + client + .workers() + .queue("urgent", 1) + .leadership(false) + .pollOnly(mode.equals("local")) + .pollInterval(Duration.ofHours(1)) + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .observe( + event -> { + if (event.equals("listen_ready")) listening.complete(null); + }) + .errorHandler(errors::add) + .add( + TYPE, + context -> { + listening.get(5, TimeUnit.SECONDS); + if (context.args().equals("retry")) worked.complete(context.job()); + }) + .start()) { + // Completing the warmup drains startup wake-ups before testing retry's notification. + idle.get(5, TimeUnit.SECONDS); + var actor = mode.equals("local") ? client : new Client(database); + if (mode.equals("transaction")) { + try (var connection = database.connection()) { + connection.setAutoCommit(false); + assertEquals(Job.State.AVAILABLE, actor.retry(connection, inserted.id()).state()); + assertEquals(Job.State.SCHEDULED, client.get(inserted.id()).state()); + assertFalse(worked.isDone()); + connection.rollback(); + assertEquals(Job.State.SCHEDULED, client.get(inserted.id()).state()); + actor.retry(connection, inserted.id()); + connection.commit(); + } + } else actor.retry(inserted.id()); + assertEquals(inserted.id(), worked.get(5, TimeUnit.SECONDS).id()); + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void rolledBackRetriesPreserveEarlierCommittedNotifications(boolean earlierInsert) { + var client = new Client(databases.open(directory.resolve("river.db"))); + var inserted = + client + .insert( + TYPE, + "retry", + InsertOptions.builder().scheduledAt(Instant.now().plusSeconds(3600)).build()) + .job(); + var notifications = new AtomicInteger(); + client.onInsertCommit(notifications::incrementAndGet); + client.transaction( + connection -> { + if (earlierInsert) client.insert(connection, TYPE, "kept"); + assertThrows( + IllegalStateException.class, + () -> + client.transaction( + connection, + nested -> { + client.retry(nested, inserted.id()); + throw new IllegalStateException("roll back retry"); + })); + assertEquals(inserted, client.get(connection, inserted.id())); + assertEquals(0, notifications.get(), "Callbacks must wait for the outer commit"); + return null; + }); + assertEquals(inserted, client.get(inserted.id())); + assertEquals(earlierInsert ? 1 : 0, notifications.get()); + } + + @Test + void sqliteNotificationFailureRollsBackTheRetry() { + var database = TestDatabase.sqlite(directory.resolve("river.db")); + var client = new Client(database); + var inserted = + client + .insert( + TYPE, + "retry", + InsertOptions.builder().scheduledAt(Instant.now().plusSeconds(3600)).build()) + .job(); + var notifications = new AtomicInteger(); + client.onInsertCommit(notifications::incrementAndGet); + client.transaction( + connection -> { + try (var statement = connection.createStatement()) { + statement.execute( + "CREATE TRIGGER reject_insert BEFORE INSERT ON river_notification " + + "WHEN NEW.topic = 'river_insert' BEGIN SELECT RAISE(ABORT, 'reject insert'); END"); + } + assertThrows(RiverException.class, () -> client.retry(connection, inserted.id())); + assertEquals(inserted, client.get(connection, inserted.id())); + client.output(connection, inserted.id(), "outer transaction remains usable"); + return null; + }); + var job = client.get(inserted.id()); + assertEquals(Job.State.SCHEDULED, job.state()); + assertEquals( + "outer transaction remains usable", + job.metadata().path(Protocol.METADATA_OUTPUT).asString()); + assertEquals(0, notifications.get()); + } +} diff --git a/java/river/src/test/java/com/riverqueue/ScheduleTest.java b/java/river/src/test/java/com/riverqueue/ScheduleTest.java new file mode 100644 index 000000000..a796b5cf2 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/ScheduleTest.java @@ -0,0 +1,79 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class ScheduleTest { + @ParameterizedTest + @CsvSource({ + "0,1", + "+0,1", + "-0,1", + "-1h90m,1", + "+1h30m,5400", + "9223372036854775807ns,9223372036", + "-9223372036854775808ns,1" + }) + void everyDurationMatchesGo(String value, long seconds) { + var from = OffsetDateTime.parse("2026-01-02T03:04:05Z"); + assertEquals( + from.plusSeconds(seconds), Schedule.cron("@every " + value).next(from).orElseThrow()); + } + + @ParameterizedTest + @ValueSource( + strings = {"1h-30m", "1h+30m", "9223372036854775808ns", "-9223372036854775809ns", "2562048h"}) + void everyRejectsInvalidGoDurations(String value) { + assertThrows(IllegalArgumentException.class, () -> Schedule.cron("@every " + value)); + } + + @TestFactory + @Tag("conformance") + Stream goFixtures() throws Exception { + var tests = new ArrayList(); + var fixture = Conformance.fixture("cron_schedules.json"); + for (String group : new String[] {"cron_cases", "cron_named_zone_cases"}) + for (var value : fixture.required(group)) { + tests.add( + DynamicTest.dynamicTest( + value.required("name").asString(), + () -> { + var schedule = Schedule.cron(value.required("expression").asString()); + var from = OffsetDateTime.parse(value.required("from").asString()); + if (value.required("next").isEmpty()) assertTrue(schedule.next(from).isEmpty()); + for (var expected : value.required("next")) { + from = schedule.next(from).orElseThrow(); + assertEquals( + OffsetDateTime.parse(expected.asString()).toInstant(), from.toInstant()); + if (group.equals("cron_cases")) + assertEquals( + OffsetDateTime.parse(expected.asString()).getOffset(), from.getOffset()); + } + })); + } + for (var invalid : fixture.required("cron_invalid")) + tests.add( + DynamicTest.dynamicTest( + "reject " + invalid.asString(), + () -> assertThrows(RuntimeException.class, () -> Schedule.cron(invalid.asString())))); + return tests.stream(); + } + + @ParameterizedTest + @ValueSource(longs = {2147483647L, 2147483648L, Long.MAX_VALUE}) + void largeStepsDoNotOverflow(long step) { + var from = OffsetDateTime.parse("2026-01-02T03:04:05Z"); + assertEquals( + OffsetDateTime.parse("2026-01-02T03:05:00Z"), + Schedule.cron("5/" + step + " * * * *").next(from).orElseThrow()); + } +} diff --git a/java/river/src/test/java/com/riverqueue/SchedulingTest.java b/java/river/src/test/java/com/riverqueue/SchedulingTest.java new file mode 100644 index 000000000..1e452c35e --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/SchedulingTest.java @@ -0,0 +1,116 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import tools.jackson.databind.JsonNode; + +class SchedulingTest { + private static final JobType TYPE = JobType.of("periodic_test", String.class); + @RegisterExtension final TestDatabase databases = new TestDatabase(); + @TempDir Path directory; + + @Test + void failedScheduleDoesNotInsertTheOccurrenceTwice() throws Exception { + var calls = new AtomicInteger(); + var maintained = new CompletableFuture(); + var errors = new LinkedBlockingQueue(); + var client = + new Client(databases.open(directory.resolve("river.db"))) + .withPlugin( + new Plugin() { + @Override + public void maintain(Client client, Instant now) { + if (calls.get() >= 3) maintained.complete(null); + } + }); + Schedule schedule = + after -> + switch (calls.incrementAndGet()) { + case 1 -> Optional.of(after.plusNanos(1)); + case 2 -> throw new IllegalStateException("schedule failed"); + default -> Optional.of(after.plusDays(1)); + }; + try (var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .serviceInterval(Duration.ofMillis(20)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .add(TYPE, context -> {}) + .periodic( + "retry", + schedule, + TYPE, + "args", + InsertOptions.builder().queue("periodic").build(), + false) + .start()) { + maintained.get(5, TimeUnit.SECONDS); + assertEquals(1, client.list(JobQuery.all()).jobs().size()); + assertEquals("schedule failed", errors.remove().getMessage()); + assertTrue(errors.isEmpty()); + } + } + + @Test + void periodicSchedulesKeepTheirPlannedCadence() throws Exception { + var inserted = new AtomicReference>(); + var client = + new Client(databases.open(directory.resolve("river.db"))) + .withPlugin( + new Plugin() { + @Override + public void afterInsert( + Connection connection, Client.Driver driver, Job job) { + inserted.set(job); + } + }); + var planned = new AtomicReference(); + var nextFrom = new CompletableFuture(); + var worked = new CompletableFuture>(); + var errors = new LinkedBlockingQueue(); + Schedule schedule = + after -> { + if (planned.compareAndSet(null, after.plusNanos(1))) return Optional.of(planned.get()); + nextFrom.complete(after); + return Optional.of(after.plusDays(1)); + }; + try (var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .serviceInterval(Duration.ofMillis(20)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .add(TYPE, context -> worked.complete(context.job())) + .periodic("cadence", schedule, TYPE, "args", InsertOptions.defaults(), false) + .start()) { + var actual = nextFrom.get(5, TimeUnit.SECONDS); + assertEquals(planned.get(), actual); + var job = worked.get(5, TimeUnit.SECONDS); + assertEquals(Job.State.AVAILABLE, inserted.get().state()); + assertEquals( + client.database().timestamp(planned.get().toInstant()), + client.database().timestamp(job.scheduledAt()), + "Periodic rows must retain the occurrence's planned timestamp"); + } + assertTrue(errors.isEmpty(), () -> "Unexpected worker errors: " + errors); + } +} diff --git a/java/river/src/test/java/com/riverqueue/TestDatabase.java b/java/river/src/test/java/com/riverqueue/TestDatabase.java new file mode 100644 index 000000000..b85726ff5 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/TestDatabase.java @@ -0,0 +1,45 @@ +package com.riverqueue; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** Isolates each test in a SQLite file or a PostgreSQL schema owned by that test. */ +final class TestDatabase implements AfterEachCallback { + private final List databases = new ArrayList<>(); + + @Override + public void afterEach(ExtensionContext context) throws Exception { + for (var database : databases) + try (var connection = database.connection(); + var statement = connection.createStatement()) { + statement.execute("DROP SCHEMA IF EXISTS \"" + database.schema() + "\" CASCADE"); + } + } + + Database open(Path file) { + String url = System.getenv("RIVER_TEST_DATABASE_URL"); + String backend = System.getProperty("river.test.database", url == null ? "sqlite" : "postgres"); + if (backend.equals("sqlite")) return sqlite(file); + if (!backend.equals("postgres")) + throw new IllegalArgumentException("Unknown test backend: " + backend); + if (url == null || url.isBlank()) + throw new IllegalStateException("RIVER_TEST_DATABASE_URL is required for PostgreSQL tests"); + var database = + Database.connect(url).withSchema("java_" + UUID.randomUUID().toString().replace("-", "")); + if (database.dialect() != Database.Dialect.POSTGRES) + throw new IllegalArgumentException("RIVER_TEST_DATABASE_URL must select PostgreSQL"); + databases.add(database); + new Migrator(database).migrate(); + return database; + } + + static Database sqlite(Path file) { + var database = Database.connect("jdbc:sqlite:" + file); + new Migrator(database).migrate(); + return database; + } +} diff --git a/java/river/src/test/java/com/riverqueue/UniqueTest.java b/java/river/src/test/java/com/riverqueue/UniqueTest.java new file mode 100644 index 000000000..83f99387a --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/UniqueTest.java @@ -0,0 +1,133 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +class UniqueTest { + @TestFactory + @Tag("conformance") + Stream goFixtures() throws Exception { + String raw = Conformance.read("unique_keys.json"); + var cases = new ArrayList(); + // Preserve number tokens and nested argument order from the generated Go fixture. + var groups = Json.members(raw); + for (String group : List.of("cases", "typed_only_cases")) { + String array = groups.get(group); + assertNotNull(array, "Missing unique-key fixture group: " + group); + assertFalse(Json.parse(array).isEmpty(), "Empty unique-key fixture group: " + group); + try (var parser = Json.MAPPER.createParser(array)) { + assertEquals(tools.jackson.core.JsonToken.START_ARRAY, parser.nextToken()); + while (parser.nextToken() != tools.jackson.core.JsonToken.END_ARRAY) { + int start = (int) parser.currentTokenLocation().getCharOffset(); + parser.skipChildren(); + cases.add(array.substring(start, (int) parser.currentLocation().getCharOffset())); + } + } + } + return cases.stream() + .map( + source -> { + var value = Json.parse(source); + return DynamicTest.dynamicTest( + value.required("name").asString(), + () -> { + var options = value.required("options"); + var states = options.has("by_state") ? new HashSet() : null; + if (states != null) + for (var state : options.path("by_state")) + states.add(Job.State.of(state.asString())); + long nanos = options.required("by_period_nanos").asLong(); + var unique = + new Unique( + options.required("by_args").asBoolean(), + nanos == 0 ? null : Duration.ofNanos(nanos), + options.required("by_queue").asBoolean(), + states, + options.required("exclude_kind").asBoolean()); + var paths = new ArrayList>(); + for (var path : value.path("selected_unique_components")) { + var components = new ArrayList(); + for (var component : path) components.add(component.asString()); + paths.add(components); + } + var scheduledAt = + value.required("scheduled_at").isString() + ? Instant.parse(value.required("scheduled_at").asString()) + : null; + org.junit.jupiter.api.function.ThrowingSupplier key = + () -> + unique.key( + value.required("kind").asString(), + Json.compact(Json.members(source).get("args")), + paths, + Instant.parse(value.required("now").asString()), + value.required("queue").asString(), + scheduledAt); + if (value.has("expected_error")) { + assertEquals("rejected", value.required("expected_error").asString()); + assertThrows(IllegalArgumentException.class, key::get); + } else { + assertEquals(value.required("expected_sha256").asString(), key.get()); + Collections.reverse(paths); + assertEquals(value.required("expected_sha256").asString(), key.get()); + assertEquals( + value.required("expected_state_mask").asInt(), unique.stateMask()); + } + }); + }); + } + + @Test + void ordersLiteralAndNestedPathsIndependentlyOfInputOrder() { + var unique = Unique.args(); + String args = "{\"a.b\":1,\"a\":{\"b\":2}}"; + var paths = new ArrayList<>(List.of(List.of("a.b"), List.of("a", "b"))); + // Go orders the nested path a.b before the escaped literal path a\\.b. + String expected = + unique.key( + "paths", "{\"a\":{\"b\":2},\"a.b\":1}", List.of(), Instant.EPOCH, "default", null); + assertEquals(expected, unique.key("paths", args, paths, Instant.EPOCH, "default", null)); + Collections.reverse(paths); + assertEquals(expected, unique.key("paths", args, paths, Instant.EPOCH, "default", null)); + } + + @Test + void preservesGoPrimaryPathOrdering() throws Exception { + var unique = Unique.args(); + String args = "{\"a\":{\"z\":2},\"a-\":1}"; + var paths = new ArrayList<>(List.of(List.of("a", "z"), List.of("a-"))); + // Go sorts the joined names a- before a.z, unlike a component-by-component comparison. + String expected = + HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest( + "&kind=paths&args={\"a-\":1,\"a\":{\"z\":2}}" + .getBytes(StandardCharsets.UTF_8))); + assertEquals(expected, unique.key("paths", args, paths, Instant.EPOCH, "default", null)); + Collections.reverse(paths); + assertEquals(expected, unique.key("paths", args, paths, Instant.EPOCH, "default", null)); + } + + @Test + void preservesRawTokensAndFirstDuplicate() { + assertEquals("1e2", Json.members("{\"a\":1e2,\"a\":2}").get("a")); + assertEquals("\"abc\"", Json.members("{\"a\":\"abc\"}").get("a")); + assertEquals( + "[1, {\"z\":1, \"y\":2}]", Json.members("{\"a\":[1, {\"z\":1, \"y\":2}]}").get("a")); + } +} diff --git a/java/river/src/test/java/com/riverqueue/VirtualThreadTest.java b/java/river/src/test/java/com/riverqueue/VirtualThreadTest.java new file mode 100644 index 000000000..b0a32c231 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/VirtualThreadTest.java @@ -0,0 +1,123 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VirtualThreadTest { + @TempDir Path directory; + + @Test + void blockingClaimDoesNotStarveOtherVirtualThreads() throws Exception { + probe("claim"); + } + + @Test + void blockingLeadershipDoesNotStarveOtherVirtualThreads() throws Exception { + probe("leadership"); + } + + private void probe(String scenario) throws Exception { + // One carrier makes monitor pinning deterministic, independent of the host's CPU count. + var output = directory.resolve("probe.log"); + var process = + new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--enable-native-access=ALL-UNNAMED", + "-Djdk.virtualThreadScheduler.parallelism=1", + "-Djdk.virtualThreadScheduler.maxPoolSize=1", + "-cp", + System.getProperty( + "surefire.test.class.path", System.getProperty("java.class.path")), + Probe.class.getName(), + scenario, + directory.resolve("river.db").toString()) + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start(); + try { + assertTrue(process.waitFor(30, TimeUnit.SECONDS), () -> "Probe timed out: " + scenario); + assertEquals( + 0, + process.exitValue(), + () -> { + try { + return Files.readString(output); + } catch (java.io.IOException error) { + throw new java.io.UncheckedIOException(error); + } + }); + } finally { + process.destroyForcibly(); + } + } + + public static final class Probe { + public static void main(String[] args) throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var database = Database.connect("jdbc:sqlite:" + args[1]); + new Migrator(database).migrate(); + var client = + new Client(database) + .withPlugin( + new Plugin() { + @Override + public List claim( + Connection connection, + Client.Driver driver, + Claim claim, + Client.Transaction> next) + throws Exception { + if (args[0].equals("claim")) { + entered.countDown(); + release.await(); + } + return next.run(connection); + } + }) + .withExtension( + new Extension() { + @Override + public void periodicStarted() { + if (!args[0].equals("leadership")) return; + entered.countDown(); + try { + release.await(); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(error); + } + } + }); + try (var workers = + client + .workers() + .queue("default", 1) + .pollOnly(true) + .leadership(args[0].equals("leadership")) + .stopTimeout(Duration.ofSeconds(5)) + .add(JobType.of("noop", String.class), context -> {}) + .start()) { + try { + if (!entered.await(10, TimeUnit.SECONDS)) + throw new IllegalStateException("Probe did not enter " + args[0]); + var unblock = Thread.startVirtualThread(release::countDown); + if (!unblock.join(Duration.ofSeconds(5))) + throw new IllegalStateException( + "Blocked " + args[0] + " pinned the only carrier thread"); + } finally { + release.countDown(); + } + } + } + } +} diff --git a/java/river/src/test/java/com/riverqueue/WorkersTest.java b/java/river/src/test/java/com/riverqueue/WorkersTest.java new file mode 100644 index 000000000..7281055f1 --- /dev/null +++ b/java/river/src/test/java/com/riverqueue/WorkersTest.java @@ -0,0 +1,869 @@ +package com.riverqueue; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class WorkersTest { + private static final JobType TYPE = JobType.of("runtime_test", String.class); + @TempDir Path directory; + @RegisterExtension final TestDatabase databases = new TestDatabase(); + + private Client client() { + var database = databases.open(directory.resolve("river.db")); + return new Client(database); + } + + private Workers.Builder workers(Client client) { + return client + .workers() + .queue("default", 1) + .leadership(false) + .pollOnly(true) + .stopTimeout(Duration.ofSeconds(1)); + } + + @Test + void builderGeneratesAnIdForEachRuntime() { + var builder = workers(client()).add(TYPE, context -> {}); + try (var first = builder.start(); + var second = builder.start()) { + assertNotEquals(first.id(), second.id()); + } + try (var explicit = builder.id("chosen-id").start()) { + assertEquals("chosen-id", explicit.id()); + } + } + + @ParameterizedTest + @CsvSource({ + "PT0.000000001S,true", + "PT1H,true", + "PT23H59M59.999999999S,true", + "PT24H,false", + "PT24H0.000000001S,false", + "PT48H,false" + }) + void builderRequiresServiceIntervalsBelowQueueRetention(String value, boolean valid) { + var builder = new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")).workers(); + var interval = Duration.parse(value); + if (valid) assertSame(builder, builder.serviceInterval(interval)); + else + assertEquals( + "serviceInterval must be shorter than the queue retention period of one day", + assertThrows(IllegalArgumentException.class, () -> builder.serviceInterval(interval)) + .getMessage()); + } + + @ParameterizedTest + @CsvSource({"REMOTE,false", "REMOTE,true", "TIMEOUT,false", "TIMEOUT,true"}) + void cancellationRespectsTheHandlersOutcome(WorkContext.Cancellation cause, boolean interrupted) + throws Exception { + var client = client(); + var entered = new CompletableFuture>(); + var outcome = new CompletableFuture(); + var reported = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(reported::add) + .add( + TYPE, + context -> { + entered.complete(context); + context.awaitCancellation(); + if (interrupted) context.checkCancelled(); + }) + .start(); + var subscription = workers.subscribe(outcome::complete)) { + var inserted = + client.insert(TYPE, "cancel", InsertOptions.builder().maxAttempts(1).build()).job(); + var context = entered.get(5, TimeUnit.SECONDS); + if (cause == WorkContext.Cancellation.REMOTE) client.cancel(inserted.id()); + else context.requestCancellation(cause); + var event = outcome.get(5, TimeUnit.SECONDS); + var expected = + !interrupted + ? Job.State.COMPLETED + : cause == WorkContext.Cancellation.REMOTE + ? Job.State.CANCELLED + : Job.State.DISCARDED; + assertEquals(cause, context.cancellation()); + assertEquals(expected, event.job().state()); + var job = client.get(inserted.id()); + assertEquals(expected, job.state()); + assertEquals(interrupted ? 1 : 0, job.errors().size()); + assertEquals(1, job.attempt()); + assertNotNull(job.finalizedAt()); + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @ParameterizedTest + @EnumSource( + value = Job.State.class, + names = {"AVAILABLE", "PENDING", "RETRYABLE", "RUNNING", "SCHEDULED"}) + void completionEventsReflectPersistedState(Job.State state) throws Exception { + var client = client(); + var database = client.database(); + var changed = new CompletableFuture(); + var events = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(reported::add) + .add( + TYPE, + context -> { + context.transaction( + connection -> { + // A rescuer or another attempt changed this row before the old handler + // returned. + try (var statement = + Sql.prepare( + connection, + "UPDATE " + + database.prefix() + + "river_job SET state='" + + state.value() + + "', scheduled_at=?, attempt=? WHERE id=?", + database.timestamp(Instant.now().plusSeconds(3600)), + state == Job.State.RUNNING ? 2 : 1, + context.job().id())) { + assertEquals(1, statement.executeUpdate()); + } + return null; + }); + changed.complete(null); + }) + .start(); + var subscription = workers.subscribe(events::add)) { + var job = client.insert(TYPE, "changed").job(); + changed.get(5, TimeUnit.SECONDS); + workers.stop(); + assertEquals(state, client.get(job.id()).state()); + var expected = + switch (state) { + case AVAILABLE, RETRYABLE -> Workers.EventKind.JOB_FAILED; + case SCHEDULED -> Workers.EventKind.JOB_SNOOZED; + default -> null; + }; + if (expected == null) assertTrue(events.isEmpty()); + else { + assertEquals(1, events.size()); + assertEquals(expected, events.element().kind()); + assertEquals(state, events.element().job().state()); + } + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @Test + void completionRetriesSurviveAnInterruptedHandler() throws Exception { + var attempts = new AtomicInteger(); + var client = + client() + .withPlugin( + new Plugin() { + @Override + public void afterAttempt( + Connection connection, + Client.Driver driver, + Job job) + throws SQLException { + if (attempts.getAndIncrement() == 0) + throw new SQLException("transient completion failure"); + } + }); + var completed = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .errorHandler(reported::add) + .add(TYPE, context -> Thread.currentThread().interrupt()) + .start(); + var subscription = workers.subscribe(completed::add, Workers.EventKind.JOB_COMPLETED)) { + for (int i = 0; i < 2; i++) { + var job = client.insert(TYPE, "job " + i).job(); + var event = completed.poll(5, TimeUnit.SECONDS); + assertNotNull( + event, "An interrupted handler must not strand completion or its worker slot"); + assertEquals(job.id(), event.job().id()); + assertEquals(Job.State.COMPLETED, client.get(job.id()).state()); + } + assertEquals(3, attempts.get()); + assertEquals(1, reported.size()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"args", "kind", "row"}) + void completionRetriesSurviveInvalidJobs(String invalid) throws Exception { + var attempts = new AtomicInteger(); + var client = + client() + .withPlugin( + new Plugin() { + @Override + public void afterAttempt( + Connection connection, + Client.Driver driver, + Job job) + throws SQLException { + if (job.args().isObject() && attempts.getAndIncrement() == 0) + throw new SQLException("transient completion failure"); + } + + @Override + public java.util.List claim( + Connection connection, + Client.Driver driver, + Plugin.Claim claim, + Client.Transaction> next) + throws Exception { + var rows = next.run(connection); + return rows.stream() + .map( + row -> + invalid.equals("row") && row.job().args().isObject() + ? new Client.Decoded( + row.job(), new IllegalArgumentException("invalid row")) + : row) + .toList(); + } + }); + var type = + JobType.of( + invalid.equals("kind") ? "unknown_kind" : TYPE.kind(), + tools.jackson.databind.JsonNode.class); + var handled = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + var finalized = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .queue("default", 2) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(reported::add) + .add(TYPE, context -> handled.add(context.job().id())) + .start(); + var subscription = workers.subscribe(finalized::add)) { + var jobs = + client.insertMany( + java.util.List.of( + type.submission(Json.object(), InsertOptions.builder().maxAttempts(1).build()), + TYPE.submission("valid"))); + var bad = jobs.getFirst().job(); + var good = jobs.getLast().job(); + assertNotNull(finalized.poll(5, TimeUnit.SECONDS), "The first claimed job must settle"); + assertNotNull(finalized.poll(5, TimeUnit.SECONDS), "Every committed claim must settle"); + workers.stop(); + assertEquals(java.util.List.of(good.id()), java.util.List.copyOf(handled)); + assertEquals(Job.State.DISCARDED, client.get(bad.id()).state()); + assertEquals(Job.State.COMPLETED, client.get(good.id()).state()); + assertEquals(1, client.get(bad.id()).errors().size()); + assertEquals(2, attempts.get()); + // A rolled-back completion batch may report the same failure for both claims. + assertFalse(reported.isEmpty()); + assertTrue( + reported.stream() + .allMatch(error -> error.toString().contains("transient completion failure"))); + } + } + + @Test + void completedHandlerIsNotCancelledWhileAcknowledging() throws Exception { + var acknowledging = new CountDownLatch(1); + var release = new CountDownLatch(1); + var context = new CompletableFuture>(); + var client = + client() + .withPlugin( + new Plugin() { + @Override + public void afterAttempt( + Connection connection, + Client.Driver driver, + Job job) + throws InterruptedException { + acknowledging.countDown(); + release.await(); + } + }); + var completed = new CompletableFuture(); + var workers = workers(client).add(TYPE, context::complete).start(); + try (var subscription = workers.subscribe(completed::complete)) { + var job = client.insert(TYPE, "acknowledge").job(); + assertTrue(acknowledging.await(5, TimeUnit.SECONDS)); + // Stop requests cancellation while the handler's successful outcome is awaiting commit. + assertThrows(RiverException.class, workers::stopAndCancel); + assertNull(context.get(5, TimeUnit.SECONDS).cancellation()); + release.countDown(); + assertEquals(Workers.EventKind.JOB_COMPLETED, completed.get(5, TimeUnit.SECONDS).kind()); + assertEquals(Job.State.COMPLETED, client.get(job.id()).state()); + } finally { + release.countDown(); + workers.stopAndCancel(); + } + } + + @Test + void jobTimeoutDoesNotDependOnTheDispatcher() throws Exception { + var blocked = new CountDownLatch(1); + var release = new CountDownLatch(1); + var client = + client() + .withPlugin( + new Plugin() { + @Override + public void producer( + Connection connection, Client.Driver driver, Producer producer) + throws InterruptedException { + if (!producer.active().isEmpty()) { + blocked.countDown(); + release.await(); + } + } + }); + var cancelled = new CompletableFuture(); + var failed = new CompletableFuture(); + var workers = + workers(client) + .jobTimeout(Duration.ofNanos(1)) + .serviceInterval(Duration.ofNanos(1)) + .stuckThreshold(Duration.ofMinutes(1)) + .add( + TYPE, + context -> { + context.awaitCancellation(); + cancelled.complete(context.cancellation()); + release.await(); + context.checkCancelled(); + }) + .start(); + try (var subscription = workers.subscribe(failed::complete)) { + client.insert(TYPE, "timeout", InsertOptions.builder().maxAttempts(1).build()); + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + assertEquals(WorkContext.Cancellation.TIMEOUT, cancelled.get(5, TimeUnit.SECONDS)); + release.countDown(); + assertEquals(Job.State.DISCARDED, failed.get(5, TimeUnit.SECONDS).job().state()); + } finally { + release.countDown(); + workers.stopAndCancel(); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "job", + "poll", + "service", + "stop", + "stuck", + "rescue", + "maintenance", + "reindex", + "retention" + }) + void oversizedDurationsFailDuringConfiguration(String option) { + var builder = new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")).workers(); + var oversized = Duration.ofSeconds(Long.MAX_VALUE); + assertThrows( + IllegalArgumentException.class, + () -> { + switch (option) { + case "job" -> builder.jobTimeout(oversized); + case "poll" -> builder.pollInterval(oversized); + case "service" -> builder.serviceInterval(oversized); + case "stop" -> builder.stopTimeout(oversized); + case "stuck" -> builder.stuckThreshold(oversized); + case "rescue" -> builder.rescueAfter(oversized); + case "maintenance" -> builder.maintenanceInterval(oversized); + case "reindex" -> builder.reindex(java.util.List.of(), oversized); + case "retention" -> builder.retention(oversized, Duration.ZERO, Duration.ZERO); + default -> throw new AssertionError(option); + } + }); + } + + @Test + void oversizedSnoozeFailsTheAttemptInsteadOfStrandingIt() throws Exception { + var client = client(); + var failed = new CompletableFuture(); + try (var workers = + workers(client) + .add(TYPE, context -> context.snooze(Duration.ofSeconds(Long.MAX_VALUE))) + .start(); + var subscription = workers.subscribe(failed::complete)) { + var job = client.insert(TYPE, "snooze", InsertOptions.builder().maxAttempts(1).build()).job(); + var event = failed.get(5, TimeUnit.SECONDS); + assertEquals(job.id(), event.job().id()); + assertEquals(Job.State.DISCARDED, event.job().state()); + assertTrue(event.job().errors().getFirst().error().contains("Snooze duration")); + } + } + + @Test + void rescueCannotPreemptAConfiguredTimeout() { + var builder = + new Client(Database.connect("jdbc:postgresql://127.0.0.1:1/unused")) + .workers() + .queue("default", 1) + .add(TYPE, context -> {}) + .jobTimeout(Duration.ofHours(2)) + .rescueAfter(Duration.ofHours(1)); + assertEquals( + "rescueAfter must not be less than jobTimeout", + assertThrows(IllegalArgumentException.class, builder::start).getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 0, 120}) + void rescueDefaultAccountsForTheJobTimeout(int timeoutMinutes) throws Exception { + var rescue = new CompletableFuture(); + var client = + client() + .withPlugin( + new Plugin() { + @Override + public boolean rescue(Client client, Rescue request) { + rescue.complete(request); + return true; + } + }); + var builder = workers(client).leadership(true).add(TYPE, context -> {}); + if (timeoutMinutes != 0) builder.jobTimeout(Duration.ofMinutes(timeoutMinutes)); + try (var workers = builder.start()) { + var request = rescue.get(5, TimeUnit.SECONDS); + assertEquals( + Duration.ofMinutes(60 + Math.max(0, timeoutMinutes)), + Duration.between(request.horizon(), request.now())); + } + } + + @Test + void goQueueNotificationsRespectTopicsAndSurviveMalformedPayloads() throws Exception { + var client = client(); + var ready = new CountDownLatch(1); + var errors = new LinkedBlockingQueue(); + var events = new LinkedBlockingQueue(); + var pause = notification("pause"); + var resume = notification("resume"); + String insertTopic = notification("insert").required("topic").asString(); + try (var workers = + client + .workers() + .queue("priority", 1) + .leadership(false) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) ready.countDown(); + }) + .add(TYPE, context -> {}) + .start(); + var subscription = + workers.subscribe( + events::add, Workers.EventKind.QUEUE_PAUSED, Workers.EventKind.QUEUE_RESUMED)) { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + client.transaction( + connection -> { + client.driver().notify(connection, insertTopic, Json.encode(pause.required("payload"))); + // The later malformed notification acts as a barrier: the preceding one was dispatched. + client.driver().notify(connection, pause.required("topic").asString(), "{"); + return null; + }); + assertNotNull(errors.poll(5, TimeUnit.SECONDS)); + assertTrue(events.isEmpty(), "A control payload on the insert topic must not pause a queue"); + + for (var fixture : java.util.List.of(pause, resume)) { + client.transaction( + connection -> { + client + .driver() + .notify( + connection, + fixture.required("topic").asString(), + Json.encode(fixture.required("payload"))); + return null; + }); + var event = events.poll(5, TimeUnit.SECONDS); + assertNotNull(event, "Malformed JSON must not stop later notification delivery"); + assertEquals( + fixture == pause ? Workers.EventKind.QUEUE_PAUSED : Workers.EventKind.QUEUE_RESUMED, + event.kind()); + assertEquals("priority", event.queue().name()); + } + assertTrue(events.isEmpty()); + assertTrue(errors.isEmpty()); + } + } + + @Test + void goResignationWakesLeadershipBeforeNextPoll() throws Exception { + var client = client(); + var ready = new CountDownLatch(1); + var checked = new CountDownLatch(1); + var elected = new CountDownLatch(1); + var errors = new LinkedBlockingQueue(); + var fixture = notification("resigned"); + var leaderId = fixture.required("payload").required("leader_id").asString(); + Instant previousTerm = + client.transaction( + connection -> { + var now = Instant.now(); + try (var statement = + Sql.prepare( + connection, + Sql.query(client.database(), "leader_elect"), + leaderId, + client.database().timestamp(now), + client.database().timestamp(now.plusSeconds(3600))); + var rows = statement.executeQuery()) { + assertTrue(rows.next()); + return Database.instant(rows.getString("elected_at")); + } + }); + var observer = + client.withExtension( + new Extension() { + @Override + public void periodicStarted() { + elected.countDown(); + } + }); + try (var workers = + observer + .workers() + .id("observer") + .queue("priority", 1) + .serviceInterval(Duration.ofHours(1)) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(errors::add) + .observe( + event -> { + if (event.equals("listen_ready")) ready.countDown(); + if (event.equals("leadership_check")) checked.countDown(); + }) + .add(TYPE, context -> {}) + .start()) { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + assertTrue(checked.await(5, TimeUnit.SECONDS)); + assertFalse(workers.isLeader()); + client.transaction( + connection -> { + try (var statement = + Sql.prepare( + connection, + Sql.query(client.database(), "leader_resign"), + leaderId, + client.database().timestamp(previousTerm))) { + assertEquals(1, statement.executeUpdate()); + } + client + .driver() + .notify( + connection, + fixture.required("topic").asString(), + Json.encode(fixture.required("payload"))); + return null; + }); + assertTrue( + elected.await(5, TimeUnit.SECONDS), + "Peer resignation must wake election without waiting for its poll interval"); + assertTrue(workers.isLeader()); + assertTrue(errors.isEmpty(), () -> errors.toString()); + } + } + + private static tools.jackson.databind.JsonNode notification(String name) throws Exception { + for (var fixture : Conformance.fixture("protocol_values.json").required("notifications")) + if (fixture.required("name").asString().equals(name)) return fixture; + throw new AssertionError("Missing Go notification fixture: " + name); + } + + @ParameterizedTest + @ValueSource(strings = {"observer", "subscriber", "errorHandler"}) + void callbackFailuresDoNotLoseWorkerSlots(String callback) throws Exception { + var client = client(); + var completed = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + var handled = new AtomicInteger(); + var observedEnds = new AtomicInteger(); + try (var workers = + workers(client) + .errorHandler( + error -> { + reported.add(error); + if (callback.equals("errorHandler")) + throw new IllegalStateException("error handler"); + }) + .observe( + event -> { + if (callback.equals("observer") && event.equals("work_end")) + throw new AssertionError("observer"); + }) + .observe( + event -> { + if (event.equals("work_end")) observedEnds.incrementAndGet(); + }) + .add(TYPE, context -> handled.incrementAndGet()) + .start(); + var broken = + workers.subscribe( + event -> { + if (callback.equals("subscriber")) throw new AssertionError("subscriber"); + if (callback.equals("errorHandler")) + throw new IllegalArgumentException("subscriber"); + }); + var subscription = workers.subscribe(completed::add, Workers.EventKind.JOB_COMPLETED)) { + for (int i = 0; i < 2; i++) { + var inserted = client.insert(TYPE, "job " + i); + var event = completed.poll(5, TimeUnit.SECONDS); + assertNotNull(event, "Callback failure must not strand the attempt or its worker slot"); + assertEquals(inserted.job().id(), event.job().id()); + assertEquals(Job.State.COMPLETED, client.get(event.job().id()).state()); + } + } + assertEquals(2, handled.get()); + assertEquals(2, observedEnds.get()); + assertEquals(2, reported.size()); + } + + @ParameterizedTest + @ValueSource(strings = {"throw", "null", "negative", "overflow"}) + void invalidRetryPoliciesFallBack(String policy) throws Exception { + var client = client(); + var failed = new LinkedBlockingQueue(); + var reported = new LinkedBlockingQueue(); + try (var workers = + workers(client) + .errorHandler(reported::add) + .maintenanceInterval(Duration.ofMillis(1)) + .serviceInterval(Duration.ofMillis(1)) + .retryPolicy( + job -> + switch (policy) { + case "throw" -> throw new IllegalStateException("retry policy"); + case "null" -> null; + case "negative" -> Duration.ofSeconds(-1); + default -> Duration.ofSeconds(Long.MAX_VALUE); + }) + .add( + TYPE, + context -> { + throw new IllegalStateException("worker failed"); + }) + .start(); + var subscription = workers.subscribe(failed::add, Workers.EventKind.JOB_FAILED)) { + client.insert(TYPE, "retry"); + var event = failed.poll(5, TimeUnit.SECONDS); + assertNotNull(event, "A broken retry policy must not strand the attempt"); + assertEquals(Job.State.RETRYABLE, event.job().state()); + assertTrue(event.job().scheduledAt().isAfter(event.job().attemptedAt())); + assertEquals("worker failed", event.job().errors().getFirst().error()); + assertEquals(1, reported.size()); + } + } + + @ParameterizedTest + @CsvSource({"false,1,5000", "true,1,5000", "false,3600,1", "true,3600,1"}) + void shortDelaysWakeFetchingBeforeThePollInterval( + boolean snooze, long serviceSeconds, long maintenanceMillis) throws Exception { + var client = client(); + var attempts = new AtomicInteger(); + var deferred = new CompletableFuture(); + var completed = new CompletableFuture(); + var reported = new LinkedBlockingQueue(); + var delay = Duration.ofSeconds(1); + try (var workers = + workers(client) + .pollInterval(Duration.ofMinutes(1)) + .maintenanceInterval(Duration.ofMillis(maintenanceMillis)) + .serviceInterval(Duration.ofSeconds(serviceSeconds)) + .retryPolicy(job -> delay) + .errorHandler(reported::add) + .add( + TYPE, + context -> { + if (attempts.incrementAndGet() == 1) { + if (snooze) context.snooze(delay); + throw new IllegalStateException("retry"); + } + }) + .start(); + var subscription = + workers.subscribe( + event -> { + if (event.kind() == Workers.EventKind.JOB_COMPLETED) completed.complete(event); + else deferred.complete(event); + })) { + var inserted = client.insert(TYPE, "short delay").job(); + var pending = deferred.get(5, TimeUnit.SECONDS); + assertEquals( + snooze ? Workers.EventKind.JOB_SNOOZED : Workers.EventKind.JOB_FAILED, pending.kind()); + assertEquals(Job.State.AVAILABLE, pending.job().state()); + var done = completed.get(10, TimeUnit.SECONDS).job(); + assertEquals(inserted.id(), done.id()); + assertFalse(done.attemptedAt().isBefore(pending.job().scheduledAt())); + assertEquals(snooze ? 1 : 2, done.attempt()); + assertEquals(2, attempts.get()); + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void shutdownRespectsTheHandlersOutcome(boolean interrupted) throws Exception { + var client = client(); + var entered = new CompletableFuture>(); + var outcome = new CompletableFuture(); + var reported = new LinkedBlockingQueue(); + var workers = + workers(client) + .stopTimeout(Duration.ofSeconds(5)) + .errorHandler(reported::add) + .add( + TYPE, + context -> { + entered.complete(context); + context.awaitCancellation(); + if (interrupted) context.checkCancelled(); + }) + .start(); + try (var subscription = workers.subscribe(outcome::complete)) { + var inserted = + client.insert(TYPE, "stop", InsertOptions.builder().maxAttempts(1).build()).job(); + var context = entered.get(5, TimeUnit.SECONDS); + workers.stopAndCancel(); + assertEquals(WorkContext.Cancellation.SHUTDOWN, context.cancellation()); + var event = outcome.get(5, TimeUnit.SECONDS); + assertEquals( + interrupted ? Workers.EventKind.JOB_INTERRUPTED : Workers.EventKind.JOB_COMPLETED, + event.kind()); + var job = client.get(inserted.id()); + assertEquals(interrupted ? Job.State.AVAILABLE : Job.State.COMPLETED, job.state()); + assertEquals(interrupted ? 0 : 1, job.attempt()); + assertTrue(job.errors().isEmpty()); + if (interrupted) assertNull(job.finalizedAt()); + else assertNotNull(job.finalizedAt()); + } finally { + workers.stopAndCancel(); + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void stoppingDuringAClaimPreservesCancellation(boolean cancel) throws Exception { + var claimed = new CountDownLatch(1); + var commit = new CountDownLatch(1); + var client = + client() + .withPlugin( + new Plugin() { + @Override + public java.util.List claim( + Connection connection, + Client.Driver driver, + Claim claim, + Client.Transaction> next) + throws Exception { + var rows = next.run(connection); + if (!rows.isEmpty()) { + claimed.countDown(); + commit.await(); + } + return rows; + } + }); + var entered = new CompletableFuture>(); + var outcome = new CompletableFuture(); + var reported = new LinkedBlockingQueue(); + var workers = + workers(client) + .errorHandler(reported::add) + .add( + TYPE, + context -> { + entered.complete(context); + context.checkCancelled(); + }) + .start(); + try (var subscription = workers.subscribe(outcome::complete)) { + var inserted = client.insert(TYPE, "late claim").job(); + assertTrue(claimed.await(5, TimeUnit.SECONDS)); + // Let the active-map scan finish while the claim is still uncommitted and unregistered. + var error = + assertThrows( + RiverException.class, + () -> { + if (cancel) workers.stopAndCancel(); + else workers.stop(); + }); + assertTrue(error.getMessage().contains("Dispatcher did not stop")); + commit.countDown(); + var context = entered.get(5, TimeUnit.SECONDS); + assertEquals(cancel ? WorkContext.Cancellation.SHUTDOWN : null, context.cancellation()); + var event = outcome.get(5, TimeUnit.SECONDS); + assertEquals( + cancel ? Workers.EventKind.JOB_INTERRUPTED : Workers.EventKind.JOB_COMPLETED, + event.kind()); + workers.stop(); + var job = client.get(inserted.id()); + assertEquals(cancel ? Job.State.AVAILABLE : Job.State.COMPLETED, job.state()); + assertEquals(cancel ? 0 : 1, job.attempt()); + assertTrue(job.errors().isEmpty()); + } finally { + commit.countDown(); + workers.stopAndCancel(); + } + assertTrue(reported.isEmpty(), () -> "Unexpected worker errors: " + reported); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void terminalFailuresDoNotConsultRetryPolicy(boolean cancel) throws Exception { + var client = client(); + var failed = new LinkedBlockingQueue(); + var policyCalls = new AtomicInteger(); + try (var workers = + workers(client) + .cancelOnError(cancel) + .retryPolicy( + job -> { + policyCalls.incrementAndGet(); + return Duration.ofDays(1); + }) + .add( + TYPE, + context -> { + throw new IllegalStateException("worker failed"); + }) + .start(); + var subscription = workers.subscribe(failed::add)) { + client.insert(TYPE, "terminal", InsertOptions.builder().maxAttempts(1).build()); + var event = failed.poll(5, TimeUnit.SECONDS); + assertNotNull(event); + assertEquals(cancel ? Job.State.CANCELLED : Job.State.DISCARDED, event.job().state()); + assertEquals(0, policyCalls.get()); + } + } +}