diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 415fb799b..5361bfbcb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -454,3 +454,6 @@ jobs: - name: Check all go/toolchain directives match run: CHECK=true make update-mod-go + + - name: Check module zips exclude fixtures and ports + run: make check/modzip diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml new file mode 100644 index 000000000..8a4dde8c4 --- /dev/null +++ b/.github/workflows/conformance.yaml @@ -0,0 +1,77 @@ +name: Conformance + +# The Rust and JavaScript workflows run only when their own files 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 +# port tests that read them. Keep both events' paths in sync. +on: + push: + branches: + - master + paths: + - ".github/workflows/conformance.yaml" + - "**.go" + - "**/go.mod" + - "**/go.sum" + - "conformance/**" + - "go.work" + - "riverdriver/**/*.sql" + pull_request: + paths: + - ".github/workflows/conformance.yaml" + - "**.go" + - "**/go.mod" + - "**/go.sum" + - "conformance/**" + - "go.work" + - "riverdriver/**/*.sql" + +concurrency: + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +env: + # Keep the cross-run cache small; Cargo still reuses compiled dependencies. + CARGO_INCREMENTAL: "0" + +jobs: + port_fixtures: + name: Port fixture tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-go@v6 + with: + go-version-file: go.work + + - uses: dtolnay/rust-toolchain@stable + id: rust + + - name: Cache Rust dependencies and build artifacts + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + rust/target + key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }} + restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}- + + - uses: ./.github/actions/setup-js + + # 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 diff --git a/.github/workflows/js.yaml b/.github/workflows/js.yaml index f8fe9bb9d..40d1bca74 100644 --- a/.github/workflows/js.yaml +++ b/.github/workflows/js.yaml @@ -10,6 +10,7 @@ on: - ".github/actions/setup-js/**" - ".github/workflows/js.yaml" - "Makefile" + - "conformance/**" - "js/**" - "riverdriver/**/*.sql" tags: ["v*"] @@ -18,6 +19,7 @@ on: - ".github/actions/setup-js/**" - ".github/workflows/js.yaml" - "Makefile" + - "conformance/**" - "js/**" - "riverdriver/**/*.sql" @@ -182,6 +184,15 @@ jobs: with: node-version: ${{ matrix.node-version }} + # Some unit tests read fixtures generated from River's Go + # implementation. + - uses: actions/setup-go@v6 + with: + go-version-file: go.work + + - run: make generate/fixtures + working-directory: . + # The unit tests import the other workspace packages through their # build output, and worker threads run compiled handler modules. - run: pnpm run build:all diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 5d8325749..d524012a3 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -9,6 +9,7 @@ on: paths: - ".github/workflows/rust.yaml" - "Makefile" + - "conformance/**" - "internal/cmd/syncrustmigrations/**" - "riverdriver/riverpgxv5/migration/**" - "riverdriver/riversqlite/migration/**" @@ -17,6 +18,7 @@ on: paths: - ".github/workflows/rust.yaml" - "Makefile" + - "conformance/**" - "internal/cmd/syncrustmigrations/**" - "riverdriver/riverpgxv5/migration/**" - "riverdriver/riversqlite/migration/**" @@ -108,6 +110,10 @@ jobs: rust/target key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }} restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}- + # Tests read fixtures generated from River's Go implementation. + - uses: actions/setup-go@v6 + with: + go-version-file: go.work - name: Check every target and feature run: cargo check --manifest-path rust/Cargo.toml --workspace --all-targets --all-features --locked - name: Unit, doc, and SQLite tests @@ -150,6 +156,10 @@ jobs: rust/target key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }} restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}- + # Tests read fixtures generated from River's Go implementation. + - uses: actions/setup-go@v6 + with: + go-version-file: go.work - name: Create test database run: PGPASSWORD=postgres createdb -h localhost -U postgres river_rust_test - name: Rust unit, SQLite, and PostgreSQL tests diff --git a/.gitignore b/.gitignore index d09a4cac6..1c192c195 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.envrc +/conformance/testdata/ /go.work.sum /internal/cmd/riverbench/riverbench /river diff --git a/Makefile b/Makefile index 880c1ef60..a54a2b176 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,10 @@ SQLC ?= sqlc +.PHONY: check/modzip +check/modzip: ## Check that no Go module zip includes fixtures, testdata, or the Rust or JS ports + go run ./conformance/cmd/checkmodzip ./go.work + .PHONY: db/reset db/reset: ## Drop, create, and migrate dev and test databases db/reset: db/reset/dev @@ -19,11 +23,18 @@ db/reset/test: ## Drop, create, and migrate test databases .PHONY: generate generate: ## Generate generated artifacts +generate: generate/fixtures generate: generate/js-migrations generate: generate/migrations generate: generate/rust-migrations generate: generate/sqlc +# Fixtures are written to conformance/testdata, which is ignored by Git. Port +# test targets that read them depend on this target. +.PHONY: generate/fixtures +generate/fixtures: ## Generate cross-language conformance fixtures from River's Go implementation + go run ./conformance/cmd/generatefixtures + .PHONY: generate/js-migrations generate/js-migrations: ## Sync database migrations to JavaScript pnpm -C js run generate:migrations @@ -139,8 +150,17 @@ RUST_POSTGRES_TESTS_ENV = RUSTFLAGS="$$RUSTFLAGS --cfg river_postgres_tests" \ .PHONY: test/js test/js: ## Run JavaScript unit tests test/js: build/js +test/js: generate/fixtures pnpm -C js run test +# Only the tests that compare JavaScript with fixtures generated from River's +# Go implementation, for checking a Go change against the port. They import +# sources directly and need no build. +.PHONY: test/js/conformance +test/js/conformance: ## Run JavaScript tests that check Go-generated conformance fixtures +test/js/conformance: generate/fixtures + pnpm -C js exec vitest run src/cron.test.ts src/runtime/completion-command.test.ts + # Integration tests use TEST_DATABASE_URL (default # postgres://localhost:5432/river_test), migrated with # `node js/cli/dist/bin.js migrate-up`. @@ -151,6 +171,7 @@ test/js/integration: build/js .PHONY: test/rust test/rust: ## Run Rust unit and SQLite tests, plus PostgreSQL tests when RIVER_RUST_DATABASE_URL is set +test/rust: generate/fixtures @if [ -n "$$RIVER_RUST_DATABASE_URL" ]; then \ cd rust && $(RUST_POSTGRES_TESTS_ENV) cargo test --workspace --all-features --locked; \ elif [ -n "$$CI" ]; then \ @@ -160,13 +181,22 @@ test/rust: ## Run Rust unit and SQLite tests, plus PostgreSQL tests when RIVER_R cd rust && cargo test --workspace --features riverqueue/sqlite,riverqueue-migrate/sqlite --locked; \ fi +# Only the tests that compare Rust with fixtures generated from River's Go +# implementation, for checking a Go change against the port. +.PHONY: test/rust/conformance +test/rust/conformance: ## Run Rust tests that check Go-generated conformance fixtures +test/rust/conformance: generate/fixtures + cd rust && cargo test -p riverqueue --features chrono-tz --lib --test protocol_fixtures --locked + .PHONY: test/rust/postgres test/rust/postgres: ## Run all Rust tests, including PostgreSQL integration tests (requires RIVER_RUST_DATABASE_URL) +test/rust/postgres: generate/fixtures @test -n "$$RIVER_RUST_DATABASE_URL" || { echo "RIVER_RUST_DATABASE_URL is required" >&2; exit 1; } cd rust && $(RUST_POSTGRES_TESTS_ENV) cargo test --workspace --all-features --locked .PHONY: test/rust/sqlite test/rust/sqlite: ## Run Rust unit, doc, and SQLite integration tests without a PostgreSQL database +test/rust/sqlite: generate/fixtures cd rust && cargo test --workspace --features riverqueue/sqlite,riverqueue-migrate/sqlite --locked .PHONY: doc/js @@ -214,6 +244,9 @@ check/rust/package: ## Build and verify publishable crate archives without publi cd rust && package_build_dir=$$(mktemp -d) && \ trap 'rm -rf "$$package_build_dir"' EXIT && \ CARGO_BUILD_BUILD_DIR="$$package_build_dir" cargo package --workspace --allow-dirty --locked + cd rust && for crate in riverqueue riverqueue-cli riverqueue-macros riverqueue-migrate riverqueue-test; do \ + ! cargo package --list --allow-dirty --locked -p $$crate | grep -E '(^|/)(tests|fixtures|testdata)/|\.json$$' | grep -vxF .cargo_vcs_info.json || exit 1; \ + done # The baseline is the latest published riverqueue-v* tag, and # cargo-semver-checks infers the allowed change from the version bump. It diff --git a/conformance/cmd/checkmodzip/main.go b/conformance/cmd/checkmodzip/main.go new file mode 100644 index 000000000..d051602ae --- /dev/null +++ b/conformance/cmd/checkmodzip/main.go @@ -0,0 +1,91 @@ +// Command checkmodzip fails if the module zip of any module in a Go workspace +// would include test fixtures or another language's port. It selects files +// with the same rules the Go module proxy uses, so it catches fixtures that a +// missing nested go.mod would otherwise publish with River. It reads the +// working tree, ignored files included, so it's exact on a clean checkout +// like CI's. +// +// Run it with a make target: +// +// make check/modzip +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/zip" +) + +// conformanceModulePath is this command's own module. It holds the shared +// fixtures by design and is never published, so it isn't checked. +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)/`) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: checkmodzip ") + os.Exit(2) + } + + if err := run(os.Args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(workFilename string) error { + workFileData, err := os.ReadFile(workFilename) //nolint:gosec // a developer-supplied go.work path + if err != nil { + return fmt.Errorf("error reading %s: %w", workFilename, err) + } + + workFile, err := modfile.ParseWork(workFilename, workFileData, nil) + if err != nil { + return fmt.Errorf("error parsing %s: %w", workFilename, err) + } + + var violations []error + for _, use := range workFile.Use { + dir := filepath.Join(filepath.Dir(workFilename), use.Path) + + modFilename := filepath.Join(dir, "go.mod") + modFileData, err := os.ReadFile(modFilename) //nolint:gosec // a module directory listed in go.work + if err != nil { + return fmt.Errorf("error reading %s: %w", modFilename, err) + } + modulePath := modfile.ModulePath(modFileData) + if modulePath == conformanceModulePath { + continue + } + + files, err := zip.CheckDir(dir) + if err != nil { + return fmt.Errorf("error checking module zip for %s: %w", modulePath, err) + } + + for _, path := range files.Valid { + relPath, err := filepath.Rel(dir, path) + if err != nil { + return fmt.Errorf("error making %s relative to %s: %w", path, dir, err) + } + relPath = filepath.ToSlash(relPath) + + if disallowedPathPattern.MatchString(relPath) { + violations = append(violations, fmt.Errorf("%s: module zip would include %s", modulePath, relPath)) + } + } + + fmt.Printf("%s: %d files\n", modulePath, len(files.Valid)) + } + + return errors.Join(violations...) +} diff --git a/conformance/cmd/generatefixtures/cron_schedules.go b/conformance/cmd/generatefixtures/cron_schedules.go new file mode 100644 index 000000000..760be0948 --- /dev/null +++ b/conformance/cmd/generatefixtures/cron_schedules.go @@ -0,0 +1,167 @@ +package main + +import ( + "fmt" + "time" + _ "time/tzdata" // named cron zones resolve the same on every host + + "github.com/robfig/cron/v3" +) + +// cronNextCount is the number of successive occurrences recorded per cron +// case. Each is computed from the previous one, the way the periodic job +// enqueuer advances a schedule. +const cronNextCount = 5 + +type cronSchedules struct { + Comment string `json:"$comment"` + CronCases []cronCase `json:"cron_cases"` + CronInvalid []string `json:"cron_invalid"` + CronNamedZoneCases []cronCase `json:"cron_named_zone_cases"` +} + +type cronCase struct { + Expression string `json:"expression"` + From time.Time `json:"from"` + Name string `json:"name"` + Next []time.Time `json:"next"` +} + +type cronCaseInput struct { + expression string + from time.Time + name string +} + +// makeCronSchedules records next run times from robfig/cron's +// `ParseStandard`, the parser River documents for periodic job schedules. +func makeCronSchedules() (cronSchedules, error) { + fixture := cronSchedules{ + Comment: generatedComment("robfig/cron/v3 ParseStandard, as used for River periodic jobs"), + } + + eastern := time.FixedZone("", -5*60*60) + kolkata := time.FixedZone("", 5*60*60+30*60) + for _, testCase := range []cronCaseInput{ + {expression: "* * * * *", from: referenceNow, name: "every_minute"}, + {expression: "30 * * * *", from: referenceNow, name: "half_past_every_hour"}, + {expression: "0 9 * * 1", from: referenceNow, name: "monday_numeric_weekday"}, + {expression: "0 9 * * mon", from: referenceNow, name: "monday_named_weekday"}, + {expression: "0 0 * * 0", from: referenceNow, name: "sunday_is_zero"}, + {expression: "0 0 * * SUN", from: referenceNow, name: "weekday_names_ignore_case"}, + {expression: "*/15 9-17 * * mon-fri", from: referenceNow, name: "business_hours_steps"}, + {expression: "0 0 1 * *", from: referenceNow, name: "first_of_month"}, + {expression: "0 0 1 jan,JUL *", from: referenceNow, name: "named_months"}, + {expression: "0 0 29 2 *", from: referenceNow, name: "leap_day"}, + {expression: "0 0 30 2 *", from: referenceNow, name: "impossible_date_never_runs"}, + {expression: "0 12 1,15 * 5", from: referenceNow, name: "day_of_month_or_weekday"}, + {expression: "0 12 * * 5", from: referenceNow, name: "wildcard_day_of_month_and_weekday"}, + {expression: "0 12 ? * 5", from: referenceNow, name: "question_mark_wildcard"}, + {expression: "0 12 */2 * 5", from: referenceNow, name: "stepped_day_of_month_or_weekday"}, + {expression: "0 12 */1 * 5", from: referenceNow, name: "unit_step_keeps_wildcard"}, + {expression: "5/15 * * * *", from: referenceNow, name: "start_with_step"}, + {expression: "0-10/5 * * * *", from: referenceNow, name: "range_with_step"}, + {expression: "59 23 31 12 *", from: referenceNow, name: "year_end"}, + {expression: "@hourly", from: referenceNow, name: "descriptor_hourly"}, + {expression: "@daily", from: referenceNow, name: "descriptor_daily"}, + {expression: "@midnight", from: referenceNow, name: "descriptor_midnight"}, + {expression: "@weekly", from: referenceNow, name: "descriptor_weekly"}, + {expression: "@monthly", from: referenceNow, name: "descriptor_monthly"}, + {expression: "@yearly", from: referenceNow, name: "descriptor_yearly"}, + {expression: "@annually", from: referenceNow, name: "descriptor_annually"}, + {expression: "@every 1h30m", from: referenceNow, name: "every_compound_duration"}, + {expression: "@every 1.5h", from: referenceNow, name: "every_fractional_duration"}, + {expression: "@every 90s", from: referenceNow, name: "every_seconds"}, + {expression: "@every 500ms", from: referenceNow, name: "every_rounds_up_to_one_second"}, + {expression: "@every 1500ms", from: referenceNow, name: "every_truncates_subseconds"}, + {expression: "0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "reference_time_offset"}, + {expression: "30 0 * * *", from: time.Date(2026, time.March, 7, 23, 45, 0, 0, kolkata), name: "reference_time_half_hour_offset"}, + {expression: "CRON_TZ=UTC 0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "cron_tz_utc_prefix"}, + {expression: "TZ=UTC 0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "tz_utc_prefix"}, + {expression: " 0 9 * * 1 ", from: referenceNow, name: "extra_whitespace"}, + } { + cronCase, err := makeCronCase(testCase) + if err != nil { + return cronSchedules{}, err + } + fixture.CronCases = append(fixture.CronCases, cronCase) + } + + // IANA zones named in `CRON_TZ=`/`TZ=` prefixes, including daylight saving + // transitions. Kept apart from `cron_cases` because an implementation may + // need an optional time zone database for them. + for _, testCase := range []cronCaseInput{ + {expression: "CRON_TZ=America/New_York 0 9 * * *", from: time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC), name: "new_york_across_dst_start"}, + {expression: "CRON_TZ=America/New_York 30 2 * * *", from: time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC), name: "new_york_skipped_wall_time"}, + {expression: "CRON_TZ=America/New_York 30 1 * * *", from: time.Date(2026, time.October, 30, 12, 0, 0, 0, time.UTC), name: "new_york_repeated_wall_time"}, + {expression: "CRON_TZ=America/New_York 0 * * * *", from: time.Date(2026, time.November, 1, 4, 30, 0, 0, time.UTC), name: "new_york_hourly_across_dst_end"}, + {expression: "CRON_TZ=Europe/London 0 0 * * *", from: time.Date(2026, time.October, 23, 12, 0, 0, 0, time.UTC), name: "london_across_dst_end"}, + {expression: "CRON_TZ=America/Santiago 0 0 * * *", from: time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC), name: "santiago_skipped_midnight"}, + {expression: "CRON_TZ=America/Santiago 0 12 * * *", from: time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC), name: "santiago_day_after_skipped_midnight"}, + {expression: "CRON_TZ=America/Santiago 30 23 * * *", from: time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC), name: "santiago_repeated_hour_before_midnight"}, + {expression: "TZ=Asia/Kolkata 0 9 * * mon", from: time.Date(2026, time.January, 2, 3, 4, 5, 0, eastern), name: "kolkata_tz_prefix"}, + } { + cronCase, err := makeCronCase(testCase) + if err != nil { + return cronSchedules{}, err + } + fixture.CronNamedZoneCases = append(fixture.CronNamedZoneCases, cronCase) + } + + for _, expression := range []string{ + "", + "* * * *", + "* * * * * *", + "0 9 * * 7", + "60 * * * *", + "* 24 * * *", + "* * 0 * *", + "* * 32 * *", + "* * * 0 *", + "* * * 13 *", + "-1 * * * *", + "5-1 * * * *", + "1-2-3 * * * *", + "1/2/3 * * * *", + "*/0 * * * *", + "*/x * * * *", + "0 9 * * funday", + "@every", + "@every 5x", + "@reboot", + "CRON_TZ=Nowhere/Invalid 0 9 * * *", + } { + if _, err := cron.ParseStandard(expression); err == nil { + return cronSchedules{}, fmt.Errorf("invalid cron expression unexpectedly parsed: %q", expression) + } + fixture.CronInvalid = append(fixture.CronInvalid, expression) + } + + return fixture, nil +} + +// makeCronCase records the occurrences Go computes for one cron case, +// stopping early if the schedule never runs again. +func makeCronCase(input cronCaseInput) (cronCase, error) { + schedule, err := cron.ParseStandard(input.expression) + if err != nil { + return cronCase{}, fmt.Errorf("error parsing cron case %s: %w", input.name, err) + } + + next := make([]time.Time, 0, cronNextCount) + current := input.from + for range cronNextCount { + current = schedule.Next(current) + if current.IsZero() { + break + } + next = append(next, current) + } + + return cronCase{ + Expression: input.expression, + From: input.from, + Name: input.name, + Next: next, + }, nil +} diff --git a/conformance/cmd/generatefixtures/main.go b/conformance/cmd/generatefixtures/main.go new file mode 100644 index 000000000..a4125a432 --- /dev/null +++ b/conformance/cmd/generatefixtures/main.go @@ -0,0 +1,103 @@ +// Command generatefixtures writes the language-neutral fixtures in +// conformance/testdata by calling River's Go implementation, so Go stays the +// source of truth for values the Rust and JavaScript ports check in their own +// tests. The fixtures are never committed; the directory is ignored by Git +// and the port test targets regenerate it before running. +// +// Run it from anywhere in the repository with `make generate/fixtures`. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// fixtureDir is the directory of generated fixtures relative to the +// repository root. +const fixtureDir = "conformance/testdata" + +// referenceNow is the fixed clock most fixtures are generated at. +var referenceNow = time.Date(2026, time.January, 2, 3, 4, 5, 678_900_000, time.UTC) //nolint:gochecknoglobals + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + root, err := repoRoot() + if err != nil { + return err + } + + protocolValues, err := makeProtocolValues(root) + if err != nil { + return err + } + uniqueKeys, err := makeUniqueKeys() + if err != nil { + return err + } + cronSchedules, err := makeCronSchedules() + if err != nil { + return err + } + + for _, fixture := range []struct { + name string + value any + }{ + {"cron_schedules.json", cronSchedules}, + {"protocol_values.json", protocolValues}, + {"snooze_counters.json", makeSnoozeCounters()}, + {"unique_keys.json", uniqueKeys}, + } { + contents, err := json.MarshalIndent(fixture.value, "", " ") + if err != nil { + return fmt.Errorf("error encoding %s: %w", fixture.name, err) + } + contents = append(contents, '\n') + + path := filepath.Join(root, fixtureDir, fixture.name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("error creating fixture directory: %w", err) + } + if err := os.WriteFile(path, contents, 0o644); err != nil { //nolint:gosec // generated fixtures are world-readable + return fmt.Errorf("error writing %s: %w", path, err) + } + } + + return nil +} + +// generatedComment is the `$comment` at the top of every fixture. +func generatedComment(source string) string { + return "Generated by `make generate/fixtures` from " + source + ". Do not edit." +} + +// repoRoot returns the nearest directory at or above the working directory +// that contains go.work, so the command runs the same from any subdirectory. +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("error getting working directory: %w", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.work")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", errors.New("go.work not found in the working directory or any parent; run from the River repository") + } + dir = parent + } +} diff --git a/conformance/cmd/generatefixtures/protocol_values.go b/conformance/cmd/generatefixtures/protocol_values.go new file mode 100644 index 000000000..1fa2f71dd --- /dev/null +++ b/conformance/cmd/generatefixtures/protocol_values.go @@ -0,0 +1,268 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "math" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/riverqueue/river/internal/leadership" + "github.com/riverqueue/river/internal/notifier" + "github.com/riverqueue/river/internal/retrypolicy" + "github.com/riverqueue/river/internal/rivercommon" + "github.com/riverqueue/river/riverdriver" + "github.com/riverqueue/river/rivershared/uniquestates" + "github.com/riverqueue/river/rivertype" +) + +type protocolJobState struct { + State rivertype.JobState `json:"state"` + UniqueBit byte `json:"unique_bit"` +} + +type protocolNotification struct { + Fields []jsonField `json:"fields"` + Name string `json:"name"` + Payload json.RawMessage `json:"payload"` + Source string `json:"source"` + Topic string `json:"topic"` +} + +// protocolRetryCase bounds the delay River's default retry policy schedules +// after error_count failures. Ports with seedable jitter may use seed; the +// bounds hold for any seed. +type protocolRetryCase struct { + ErrorCount uint32 `json:"error_count"` + JobID int64 `json:"job_id"` + MaxDelayNS int64 `json:"max_delay_ns"` + MinDelayNS int64 `json:"min_delay_ns"` + Now time.Time `json:"now"` + Seed uint64 `json:"seed"` +} + +type protocolValues struct { + Comment string `json:"$comment"` + AttemptError rivertype.AttemptError `json:"attempt_error"` + JobStates []protocolJobState `json:"job_states"` + MetadataKeys map[string]string `json:"metadata_keys"` + Notifications []protocolNotification `json:"notifications"` + RetryCases []protocolRetryCase `json:"retry_cases"` + Topics map[string]notifier.NotificationTopic `json:"topics"` +} + +// makeProtocolValues records values that River processes must agree on to +// share a database: job states and their unique bits, the attempt error +// encoding, reserved metadata keys, notification topics and payloads, and +// retry delay bounds. root is the repository root, used to read the Go and SQL +// sources that notification payloads are derived from. +func makeProtocolValues(root string) (protocolValues, error) { + fixture := protocolValues{ + Comment: generatedComment("River's job state, metadata key, notification, attempt error, and retry policy definitions"), + AttemptError: rivertype.AttemptError{ + At: referenceNow, + Attempt: 3, + Error: `worker failed: escaped "detail"`, + Trace: "frame one\nframe two", + }, + MetadataKeys: map[string]string{ + "output": rivertype.MetadataKeyOutput, + "periodic_job_id": rivercommon.MetadataKeyPeriodicJobID, + "rescue_count": rivercommon.MetadataKeyRescueCount, + "resumable_cursor": rivercommon.MetadataKeyResumableCursor, + "resumable_step": rivercommon.MetadataKeyResumableStep, + "unique_nonce": riverdriver.UniqueInsertMetadataKey, + }, + Topics: map[string]notifier.NotificationTopic{ + "control": notifier.NotificationTopicControl, + "insert": notifier.NotificationTopicInsert, + "leadership": notifier.NotificationTopicLeadership, + }, + } + + for _, state := range rivertype.JobStates() { + fixture.JobStates = append(fixture.JobStates, protocolJobState{ + State: state, + UniqueBit: uniquestates.UniqueStatesToBitmask([]rivertype.JobState{state}), + }) + } + + notifications, err := makeProtocolNotifications(root) + if err != nil { + return protocolValues{}, err + } + fixture.Notifications = notifications + + for _, testCase := range []struct { + errorCount uint32 + jobID int64 + seed uint64 + }{ + {errorCount: 1, jobID: 42, seed: 0}, + {errorCount: 2, jobID: 42, seed: 123}, + {errorCount: 3, jobID: 9_007_199_254_740_991, seed: math.MaxUint64}, + {errorCount: 11, jobID: 1, seed: 456}, + {errorCount: 309, jobID: 42, seed: 789}, + {errorCount: 310, jobID: 42, seed: 123}, + } { + minDelay, maxDelay := retrypolicy.DelayBounds(int(testCase.errorCount)) + fixture.RetryCases = append(fixture.RetryCases, protocolRetryCase{ + ErrorCount: testCase.errorCount, + JobID: testCase.jobID, + MaxDelayNS: maxDelay.Nanoseconds(), + MinDelayNS: minDelay.Nanoseconds(), + Now: referenceNow, + Seed: testCase.seed, + }) + } + + return fixture, nil +} + +// makeProtocolNotifications derives notification payload goldens from the Go +// payload structs and action constants, and checks that payloads built in SQL +// use the same keys. +func makeProtocolNotifications(root string) ([]protocolNotification, error) { + const ( + electorSource = "internal/leadership/elector.go" + producerSource = "producer.go" + ) + + controlFields, err := sourceStructJSONFields(filepath.Join(root, producerSource), "controlEventPayload") + if err != nil { + return nil, err + } + insertFields, err := sourceStructJSONFields(filepath.Join(root, producerSource), "insertPayload") + if err != nil { + return nil, err + } + leadershipFields, err := sourceStructJSONFields(filepath.Join(root, electorSource), "DBNotification") + if err != nil { + return nil, err + } + controlActions, err := sourceStringConstants(filepath.Join(root, producerSource), "controlAction") + if err != nil { + return nil, err + } + leadershipActions, err := sourceStringConstants(filepath.Join(root, electorSource), "DBNotificationKind") + if err != nil { + return nil, err + } + + const ( + exampleJobID = 42 + exampleLeaderID = "client-1" + exampleQueue = "priority" + ) + + var notifications []protocolNotification + for _, constant := range slices.Sorted(maps.Keys(controlActions)) { + action := controlActions[constant] + values := map[string]any{"action": action, "queue": exampleQueue} + switch action { + case "cancel": + values["job_id"] = exampleJobID + case "metadata_changed": + values["metadata"] = map[string]any{"owner": "candidate"} + } + notification, err := newProtocolNotification(action, string(notifier.NotificationTopicControl), producerSource+":controlEventPayload", controlFields, values) + if err != nil { + return nil, err + } + notifications = append(notifications, notification) + } + + insert, err := newProtocolNotification("insert", string(notifier.NotificationTopicInsert), producerSource+":insertPayload", insertFields, map[string]any{"queue": exampleQueue}) + if err != nil { + return nil, err + } + notifications = append(notifications, insert) + + for _, constant := range slices.Sorted(maps.Keys(leadershipActions)) { + action := leadershipActions[constant] + leaderID := "" + if action == string(leadership.DBNotificationKindResigned) { + leaderID = exampleLeaderID + } + notification, err := newProtocolNotification(action, string(notifier.NotificationTopicLeadership), electorSource+":DBNotification", leadershipFields, map[string]any{"action": action, "leader_id": leaderID}) + if err != nil { + return nil, err + } + notifications = append(notifications, notification) + } + + // Some notifications are built in SQL rather than Go. Their keys must + // match the payload structs consumers decode them into. + for _, sqlSource := range []struct { + name string + path string + query string + }{ + {name: "cancel", path: "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql", query: "JobCancel"}, + {name: "resigned", path: "riverdriver/riverpgxv5/internal/dbsqlc/river_leader.sql", query: "LeaderResign"}, + } { + keys, err := sqlNotificationKeys(filepath.Join(root, sqlSource.path), sqlSource.query) + if err != nil { + return nil, err + } + index := slices.IndexFunc(notifications, func(notification protocolNotification) bool { return notification.Name == sqlSource.name }) + if index < 0 { + return nil, fmt.Errorf("no %s notification to compare with %s", sqlSource.name, sqlSource.query) + } + var payload map[string]any + if err := json.Unmarshal(notifications[index].Payload, &payload); err != nil { + return nil, fmt.Errorf("error decoding %s notification payload: %w", sqlSource.name, err) + } + if expected := slices.Sorted(maps.Keys(payload)); !slices.Equal(expected, keys) { + return nil, fmt.Errorf("%s notification keys %v from %s differ from the Go payload keys %v", sqlSource.name, keys, sqlSource.query, expected) + } + notifications[index].Source += "; " + sqlSource.path + ":" + sqlSource.query + } + + slices.SortFunc(notifications, func(a, b protocolNotification) int { return strings.Compare(a.Name, b.Name) }) + return notifications, nil +} + +// newProtocolNotification encodes values in the payload struct's field order, +// omitting absent omitempty fields as encoding/json does. +func newProtocolNotification(name, topic, source string, fields []jsonField, values map[string]any) (protocolNotification, error) { + for key := range values { + if !slices.ContainsFunc(fields, func(field jsonField) bool { return field.Name == key }) { + return protocolNotification{}, fmt.Errorf("%s notification value %s is not a payload field", name, key) + } + } + + var payload bytes.Buffer + payload.WriteByte('{') + for _, field := range fields { + value, ok := values[field.Name] + if !ok { + if !field.OmitEmpty { + return protocolNotification{}, fmt.Errorf("%s notification has no value for required field %s", name, field.Name) + } + continue + } + + key, err := json.Marshal(field.Name) + if err != nil { + return protocolNotification{}, fmt.Errorf("error encoding %s notification key %s: %w", name, field.Name, err) + } + encoded, err := json.Marshal(value) + if err != nil { + return protocolNotification{}, fmt.Errorf("error encoding %s notification value %s: %w", name, field.Name, err) + } + if payload.Len() > 1 { + payload.WriteByte(',') + } + payload.Write(key) + payload.WriteByte(':') + payload.Write(encoded) + } + payload.WriteByte('}') + + return protocolNotification{Fields: fields, Name: name, Payload: payload.Bytes(), Source: source, Topic: topic}, nil +} diff --git a/conformance/cmd/generatefixtures/snooze_counters.go b/conformance/cmd/generatefixtures/snooze_counters.go new file mode 100644 index 000000000..2fd8a9ec2 --- /dev/null +++ b/conformance/cmd/generatefixtures/snooze_counters.go @@ -0,0 +1,57 @@ +package main + +import ( + "encoding/json" + + "github.com/riverqueue/river/internal/jobexecutor" +) + +type snoozeCounters struct { + Comment string `json:"$comment"` + SnoozeCounters []snoozeCounterCase `json:"snooze_counters"` +} + +type snoozeCounterCase struct { + ExpectedSnoozes int64 `json:"expected_snoozes"` + Metadata json.RawMessage `json:"metadata"` + Name string `json:"name"` +} + +// makeSnoozeCounters records the `snoozes` count the job executor writes when +// a job with the given metadata snoozes, including how it coerces values that +// aren't integers. +func makeSnoozeCounters() snoozeCounters { + fixture := snoozeCounters{ + Comment: generatedComment("jobexecutor.NextSnoozeCount"), + } + + for _, testCase := range []struct { + metadata string + name string + }{ + {metadata: `{}`, name: "absent"}, + {metadata: `{"snoozes":2}`, name: "integer"}, + {metadata: `{"snoozes":2.9}`, name: "fraction_truncates"}, + {metadata: `{"snoozes":-2.5}`, name: "negative_fraction_truncates_toward_zero"}, + {metadata: `{"snoozes":1e3}`, name: "exponent"}, + {metadata: `{"snoozes":9007199254740993}`, name: "beyond_float_precision"}, + {metadata: `{"snoozes":"4"}`, name: "numeric_string"}, + {metadata: `{"snoozes":"-7"}`, name: "negative_numeric_string"}, + {metadata: `{"snoozes":"4.5"}`, name: "fractional_string_is_zero"}, + {metadata: `{"snoozes":" 5"}`, name: "padded_string_is_zero"}, + {metadata: `{"snoozes":"abc"}`, name: "non_numeric_string_is_zero"}, + {metadata: `{"snoozes":true}`, name: "true_is_one"}, + {metadata: `{"snoozes":false}`, name: "false_is_zero"}, + {metadata: `{"snoozes":null}`, name: "null_is_zero"}, + {metadata: `{"snoozes":[3]}`, name: "array_is_zero"}, + {metadata: `{"snoozes":{"count":3}}`, name: "object_is_zero"}, + } { + fixture.SnoozeCounters = append(fixture.SnoozeCounters, snoozeCounterCase{ + ExpectedSnoozes: jobexecutor.NextSnoozeCount([]byte(testCase.metadata)), + Metadata: json.RawMessage(testCase.metadata), + Name: testCase.name, + }) + } + + return fixture +} diff --git a/conformance/cmd/generatefixtures/source.go b/conformance/cmd/generatefixtures/source.go new file mode 100644 index 000000000..b7543384d --- /dev/null +++ b/conformance/cmd/generatefixtures/source.go @@ -0,0 +1,144 @@ +package main + +import ( + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "reflect" + "regexp" + "slices" + "strconv" + "strings" +) + +// jsonField is one field of a Go struct's JSON encoding. +type jsonField struct { + Name string `json:"name"` + OmitEmpty bool `json:"omitempty"` +} + +// sourceStructJSONFields returns a struct's JSON fields in declaration order +// by parsing its source file, so payload shapes of unexported notification +// structs are derived from Go rather than restated by hand. +func sourceStructJSONFields(path, typeName string) ([]jsonField, error) { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + var fields []jsonField + found := false + ast.Inspect(file, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok || spec.Name.Name != typeName { + return true + } + structType, ok := spec.Type.(*ast.StructType) + if !ok { + return false + } + found = true + for _, field := range structType.Fields.List { + if field.Tag == nil { + continue + } + tag, err := strconv.Unquote(field.Tag.Value) + if err != nil { + continue + } + name, options, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ",") + if name == "" || name == "-" { + continue + } + fields = append(fields, jsonField{Name: name, OmitEmpty: slices.Contains(strings.Split(options, ","), "omitempty")}) + } + return false + }) + if !found { + return nil, fmt.Errorf("struct %s not found in %s", typeName, path) + } + if len(fields) == 0 { + return nil, fmt.Errorf("struct %s in %s has no JSON fields", typeName, path) + } + return fields, nil +} + +// sourceStringConstants returns the values of string constants declared with +// the named type in a source file, keyed by constant name. +func sourceStringConstants(path, typeName string) (map[string]string, error) { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + constants := make(map[string]string) + for _, declaration := range file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok || general.Tok != token.CONST { + continue + } + for _, spec := range general.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Values) != len(value.Names) { + continue + } + identifier, ok := value.Type.(*ast.Ident) + if !ok || identifier.Name != typeName { + continue + } + for index, name := range value.Names { + literal, ok := value.Values[index].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + continue + } + unquoted, err := strconv.Unquote(literal.Value) + if err != nil { + return nil, err + } + constants[name.Name] = unquoted + } + } + } + if len(constants) == 0 { + return nil, fmt.Errorf("no %s string constants in %s", typeName, path) + } + return constants, nil +} + +var jsonBuildObjectPattern = regexp.MustCompile(`json_build_object\(([^)]*)\)`) + +// sqlNotificationKeys returns the keys of the json_build_object payload that a +// named sqlc query passes to pg_notify. +func sqlNotificationKeys(path, queryName string) ([]string, error) { + contents, err := os.ReadFile(path) + if err != nil { + return nil, err + } + _, query, found := strings.Cut(string(contents), "-- name: "+queryName+" ") + if !found { + return nil, fmt.Errorf("query %s not found in %s", queryName, path) + } + query, _, _ = strings.Cut(query, "-- name: ") + if !strings.Contains(query, "pg_notify(") { + return nil, fmt.Errorf("query %s in %s sends no notification", queryName, path) + } + match := jsonBuildObjectPattern.FindStringSubmatch(query) + if match == nil { + return nil, fmt.Errorf("query %s in %s builds no JSON payload", queryName, path) + } + arguments := strings.Split(match[1], ",") + if len(arguments)%2 != 0 { + return nil, fmt.Errorf("query %s in %s has an odd json_build_object argument list", queryName, path) + } + keys := make([]string, 0, len(arguments)/2) + for index := 0; index < len(arguments); index += 2 { + key := strings.TrimSpace(arguments[index]) + if !strings.HasPrefix(key, "'") || !strings.HasSuffix(key, "'") { + return nil, errors.New("json_build_object keys must be literals") + } + keys = append(keys, strings.Trim(key, "'")) + } + slices.Sort(keys) + return keys, nil +} diff --git a/conformance/cmd/generatefixtures/unique_keys.go b/conformance/cmd/generatefixtures/unique_keys.go new file mode 100644 index 000000000..193c323eb --- /dev/null +++ b/conformance/cmd/generatefixtures/unique_keys.go @@ -0,0 +1,648 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/riverqueue/river/internal/dbunique" + "github.com/riverqueue/river/rivershared/uniquestates" + "github.com/riverqueue/river/rivertype" +) + +// errorNameRejected is the error name a port must report instead of a key +// for a request River rejects, such as all-args uniqueness over arguments +// that don't encode a JSON object. +const errorNameRejected = "rejected" + +type allArgs struct { + Zeta string `json:"zeta"` + Alpha string `json:"alpha"` + Maximum int64 `json:"maximum"` +} + +func (allArgs) Kind() string { return "conformance_all_args" } + +// collectionsArgs exercises nested values, arrays, and nulls whose wire +// order and representation are preserved in hashed arguments. +type collectionsArgs struct { + Empty []string `json:"empty"` + Labels map[string]string `json:"labels"` + Matrix [][]int `json:"matrix"` + Missing []string `json:"missing"` + Objects []collectionsItem `json:"objects"` + Pointer *string `json:"pointer"` +} + +func (collectionsArgs) Kind() string { return "conformance_all_args" } + +type collectionsItem struct { + // Deliberately non-alphabetical: nested struct wire order is significant. + Zulu string `json:"zulu"` + Alpha *int `json:"alpha"` +} + +type dottedSelectedArgs struct { + At string `json:"@user,omitempty" river:"unique"` + Bang string `json:"!x,omitempty" river:"unique"` + Brace string `json:"{x},omitempty" river:"unique"` + Bracket string `json:"[x],omitempty" river:"unique"` + Colon string `json:":id,omitempty" river:"unique"` + //nolint:tagliatelle // literal dotted names distinguish them from nested paths + Literal string `json:"user.id,omitempty" river:"unique"` + Symbols string `json:"a*b?c#d|e,omitempty" river:"unique"` + User dottedSelectedUser `json:"user"` + Unicode string `json:"é,omitempty" river:"unique"` +} + +func (dottedSelectedArgs) Kind() string { return "conformance_dotted_selected_args" } + +type dottedSelectedUser struct { + ID string `json:"id,omitempty" river:"unique"` +} + +type emptyArgs struct{} + +func (emptyArgs) Kind() string { return "conformance_all_args" } + +// escapingArgs exercises encoding/json string and key escaping, including +// keys that gjson reports unescaped and sjson rewrites while hashing. +type escapingArgs struct { + Angle string `json:"a"` + Controls string `json:"controls"` + HTML string `json:"html"` + Keys map[string]int `json:"keys"` + Separators string `json:"separators"` + Unicode string `json:"unicode"` + UnicodeAmp string `json:"é&"` +} + +func (escapingArgs) Kind() string { return "conformance_all_args" } + +type mapOrderArgs struct{} + +func (mapOrderArgs) Kind() string { return "conformance_all_args" } + +func (mapOrderArgs) MarshalJSON() ([]byte, error) { //nolint:unparam // json.Marshaler requires an error result. + return []byte(`{"2":2,"10":10,"zero":-0,"😀":1,"":2}`), nil +} + +type nestedOrderArgs struct { + Nested struct { + // Deliberately non-alphabetical: nested struct wire order is significant. + Z int `json:"z"` + A int `json:"a"` + } `json:"nested"` +} + +func (nestedOrderArgs) Kind() string { return "conformance_all_args" } + +type numericBoundaryArgs struct { + Exponent float64 `json:"exponent"` + Fraction float64 `json:"fraction"` + Maximum int64 `json:"maximum"` + Minimum int64 `json:"minimum"` + UnsignedMaximum uint64 `json:"unsigned_maximum"` +} + +func (numericBoundaryArgs) Kind() string { return "conformance_numeric_boundaries" } + +// rawAllArgs keeps duplicate members and unusual top-level names intact for +// Go's all-arguments unique-key oracle. +type rawAllArgs struct{ text string } + +func (rawAllArgs) Kind() string { return "conformance_all_args" } + +func (args rawAllArgs) MarshalJSON() ([]byte, error) { //nolint:unparam // json.Marshaler requires an error result. + return []byte(args.text), nil +} + +type selectedAccount struct { + ID string `json:"id,omitempty" river:"unique"` + Ignored string `json:"ignored,omitempty"` + Region string `json:"region,omitempty" river:"unique"` +} + +type selectedArgs struct { + Account selectedAccount `json:"account,omitzero"` + Ignored bool `json:"ignored,omitempty"` + Label string `json:"label,omitempty" river:"unique"` + PathKey string `json:"path/key,omitempty" river:"unique"` +} + +func (selectedArgs) Kind() string { return "conformance_selected_args" } + +// selectedNullArgs selects an explicitly null field, which is retained in the +// hashed arguments, while omitted selected fields are skipped. +type selectedNullArgs struct { + Account selectedAccount `json:"account,omitzero"` + Label *string `json:"label" river:"unique"` + PathKey string `json:"path/key,omitempty" river:"unique"` +} + +func (selectedNullArgs) Kind() string { return "conformance_selected_args" } + +type simpleArgs struct { + ID int64 `json:"id"` +} + +func (simpleArgs) Kind() string { return "conformance_simple" } + +type staticClock struct{ now time.Time } + +func (clock staticClock) Now() time.Time { return clock.now } +func (staticClock) NowOrNil() *time.Time { return nil } + +// timeArgs exercises encoding/json time formatting, which trims fractional +// seconds to their shortest form. +type timeArgs struct { + Fraction time.Time `json:"fraction"` + Micros time.Time `json:"micros"` + Millis time.Time `json:"millis"` + Whole time.Time `json:"whole"` +} + +func (timeArgs) Kind() string { return "conformance_all_args" } + +// typedFloatArgs exercises encoding/json float formatting: 'f' notation +// between 1e-6 and 1e21, exponent notation outside it, and shortest +// round-trip digits for both 64- and 32-bit floats. +type typedFloatArgs struct { + BelowLarge float64 `json:"below_large"` + Large float64 `json:"large"` + LargeBoundary float64 `json:"large_boundary"` + Largest float64 `json:"largest"` + Negative float64 `json:"negative"` + NegativeZero float64 `json:"negative_zero"` + One float64 `json:"one"` + Single float32 `json:"single"` + SingleLarge float32 `json:"single_large"` + SingleSmall float32 `json:"single_small"` + Small float64 `json:"small"` + SmallBoundary float64 `json:"small_boundary"` + Smallest float64 `json:"smallest"` + Tenth float64 `json:"tenth"` +} + +func (typedFloatArgs) Kind() string { return "conformance_all_args" } + +type uniqueKeyCase struct { + Args json.RawMessage `json:"args"` + // ExpectedError is the error name a port must report instead of a key, as + // Go does for all-args uniqueness over arguments that don't encode a JSON + // object. ExpectedSHA256 is empty when it's set. + ExpectedError string `json:"expected_error,omitempty"` + ExpectedSHA256 string `json:"expected_sha256,omitempty"` + ExpectedStateMask byte `json:"expected_state_mask"` + Kind string `json:"kind"` + Name string `json:"name"` + Now time.Time `json:"now"` + Options uniqueKeyOptions `json:"options"` + Queue string `json:"queue"` + ScheduledAt *time.Time `json:"scheduled_at"` + SelectedUniqueComponents [][]string `json:"selected_unique_components,omitempty"` + SelectedUniquePaths []string `json:"selected_unique_paths"` +} + +type uniqueKeyOptions struct { + ByArgs bool `json:"by_args"` + ByPeriodNanos int64 `json:"by_period_nanos"` + ByQueue bool `json:"by_queue"` + ByState []rivertype.JobState `json:"by_state,omitempty"` + ExcludeKind bool `json:"exclude_kind"` +} + +type uniqueKeyReference struct { + args rivertype.JobArgs + expectedError string + name string + now time.Time + opts dbunique.UniqueOpts + queue string + scheduledAt *time.Time + selectedUniquePaths []string + typedOnly bool +} + +type uniqueKeys struct { + Comment string `json:"$comment"` + Cases []uniqueKeyCase `json:"cases"` + + // TypedOnlyCases are goldens for typed arguments whose encoded byte order + // a producer built on dynamic objects can't reproduce, such as a map with + // integer-like keys, which JavaScript objects enumerate first in ascending + // numeric order. Ports with typed serializers assert them in their own + // tests. + TypedOnlyCases []uniqueKeyCase `json:"typed_only_cases"` +} + +// makeUniqueKeys records the unique key hash and unique states bitmask River +// computes for combinations of unique options and job arguments, including +// numeric, escaping, and ordering edge cases in the hashed arguments. +func makeUniqueKeys() (uniqueKeys, error) { + now := referenceNow + scheduledAt := now.Add(2*time.Hour + 17*time.Minute) + validCustomStates := []rivertype.JobState{ + rivertype.JobStateAvailable, + rivertype.JobStateCompleted, + rivertype.JobStatePending, + rivertype.JobStateRunning, + rivertype.JobStateScheduled, + } + dottedSelectedPaths := []string{`\@user`, `\!x`, `\{x\}`, `\[x\]`, `\:id`, "user.id", `user\.id`, `a\*b\?c\#d\|e`, "é"} + + references := []uniqueKeyReference{ + { + args: selectedArgs{}, + name: "all_selected_fields_omitted", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"}, + }, + { + args: selectedArgs{Account: selectedAccount{ID: "acct", Ignored: "irrelevant", Region: "west"}, PathKey: "slash"}, + name: "selected_siblings_and_slash_key", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"}, + }, + { + args: nestedOrderArgs{Nested: struct { + Z int `json:"z"` + A int `json:"a"` + }{Z: 1, A: 2}}, + name: "nested_struct_wire_order", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: allArgs{ + Alpha: "&\u2028line", + Maximum: 9_007_199_254_740_991, + Zeta: "quoted \\\"value\\\" and \\\\ slash", + }, + name: "all_args_sorted_and_escaped", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: mapOrderArgs{}, + name: "map_order_and_negative_zero", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: rawAllArgs{`{"":0,"a.b":1,"@x":2,":lead":3,"!bang":4,"[open":5,"{brace":6,"a\\b":7}`}, + name: "all_args_literal_path_syntax", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: rawAllArgs{`{"a\"b":1,"line\n":2,"é":3,"a", + Controls: "\b\f\n\r\t\x00\x01\x1f\x7f", + HTML: `&`, + Keys: map[string]int{"": 1, "a&b": 2, "é": 3, "é<": 4}, + Separators: "line\u2028paragraph\u2029end", + Unicode: "é😀/\\", + UnicodeAmp: "unicode key", + }, + name: "typed_escaping", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: selectedNullArgs{}, + name: "selected_explicit_null", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"}, + }, + { + args: timeArgs{ + Fraction: time.Date(2026, time.January, 2, 3, 4, 5, 500_000_000, time.UTC), + Micros: time.Date(2026, time.January, 2, 3, 4, 5, 123_456_000, time.UTC), + Millis: time.Date(2026, time.January, 2, 3, 4, 5, 120_000_000, time.UTC), + Whole: time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC), + }, + name: "typed_time_values", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: typedFloatArgs{ + BelowLarge: math.Nextafter(1e21, 0), + Large: 1e20, + LargeBoundary: 1e21, + Largest: math.MaxFloat64, + Negative: -1.5e-9, + NegativeZero: math.Copysign(0, -1), + One: 1, + Single: 1.1, + SingleLarge: 1e21, + SingleSmall: 1e-7, + Small: 1e-7, + SmallBoundary: 1e-6, + Smallest: math.SmallestNonzeroFloat64, + Tenth: 0.1, + }, + name: "typed_float_formatting", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: simpleArgs{ID: 42}, + name: "period_from_now", + now: now, + opts: dbunique.UniqueOpts{ByPeriod: 90 * time.Minute}, + queue: "default", + }, + { + args: simpleArgs{ID: 42}, + name: "period_from_schedule", + now: now, + opts: dbunique.UniqueOpts{ByPeriod: time.Hour}, + queue: "default", + scheduledAt: &scheduledAt, + }, + { + // A process clock outside UTC must produce the same period as UTC. + args: simpleArgs{ID: 42}, + name: "period_from_non_utc_now", + now: now.In(time.FixedZone("UTC-5", -5*60*60)), + opts: dbunique.UniqueOpts{ByPeriod: time.Hour}, + queue: "default", + }, + { + // A half-hour offset puts the local wall-clock hour in a different + // UTC hour, so a local truncation would pick the wrong period. + args: simpleArgs{ID: 42}, + name: "period_from_non_utc_schedule", + now: now, + opts: dbunique.UniqueOpts{ByPeriod: time.Hour}, + queue: "default", + scheduledAt: new(scheduledAt.In(time.FixedZone("UTC+5:30", 5*60*60+30*60))), + }, + { + args: simpleArgs{ID: 42}, + name: "queue_without_kind", + now: now, + opts: dbunique.UniqueOpts{ByQueue: true, ExcludeKind: true}, + queue: "priority_emails", + }, + { + args: simpleArgs{ID: 42}, + name: "all_dimensions_custom_states", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true, ByPeriod: time.Minute, ByQueue: true, ByState: validCustomStates}, + queue: "priority_emails", + scheduledAt: &scheduledAt, + }, + } + + fixture := uniqueKeys{ + Comment: generatedComment("dbunique.UniqueKey and uniquestates.UniqueStatesToBitmask"), + } + for _, reference := range references { + encodedArgs, err := json.Marshal(reference.args) + if err != nil { + return uniqueKeys{}, fmt.Errorf("error encoding args for unique key case %s: %w", reference.name, err) + } + + states := rivertype.UniqueOptsByStateDefault() + if len(reference.opts.ByState) > 0 { + states = reference.opts.ByState + } + + key, err := dbunique.UniqueKey(staticClock{now: reference.now}, &reference.opts, &rivertype.JobInsertParams{ + Args: reference.args, + EncodedArgs: encodedArgs, + Kind: reference.args.Kind(), + Queue: reference.queue, + ScheduledAt: reference.scheduledAt, + UniqueStates: uniquestates.UniqueStatesToBitmask(states), + }) + switch { + case reference.expectedError != "" && err == nil: + return uniqueKeys{}, fmt.Errorf("unique key case %s: expected an error", reference.name) + case reference.expectedError == "" && err != nil: + return uniqueKeys{}, fmt.Errorf("unique key case %s: %w", reference.name, err) + } + + uniqueKeyCase := uniqueKeyCase{ + Args: encodedArgs, + ExpectedError: reference.expectedError, + ExpectedSHA256: hex.EncodeToString(key), + ExpectedStateMask: uniquestates.UniqueStatesToBitmask(states), + Kind: reference.args.Kind(), + Name: reference.name, + Now: reference.now, + Options: uniqueKeyOptions{ + ByArgs: reference.opts.ByArgs, + ByPeriodNanos: reference.opts.ByPeriod.Nanoseconds(), + ByQueue: reference.opts.ByQueue, + ByState: reference.opts.ByState, + ExcludeKind: reference.opts.ExcludeKind, + }, + Queue: reference.queue, + ScheduledAt: reference.scheduledAt, + SelectedUniqueComponents: selectedUniqueComponents(reference.selectedUniquePaths), + SelectedUniquePaths: reference.selectedUniquePaths, + } + if reference.typedOnly { + fixture.TypedOnlyCases = append(fixture.TypedOnlyCases, uniqueKeyCase) + } else { + fixture.Cases = append(fixture.Cases, uniqueKeyCase) + } + } + + return fixture, nil +} + +// selectedUniqueComponents splits gjson-escaped selected unique paths into +// decoded JSON field names, so ports needn't parse gjson's path syntax. Each +// inner slice is one path. +func selectedUniqueComponents(paths []string) [][]string { + if len(paths) == 0 { + return nil + } + + components := make([][]string, 0, len(paths)) + for _, path := range paths { + var ( + part strings.Builder + parts []string + ) + for index := 0; index < len(path); index++ { + switch path[index] { + case '\\': + index++ + if index < len(path) { + part.WriteByte(path[index]) + } + case '.': + parts = append(parts, part.String()) + part.Reset() + default: + part.WriteByte(path[index]) + } + } + parts = append(parts, part.String()) + components = append(components, parts) + } + + return components +} diff --git a/conformance/go.mod b/conformance/go.mod new file mode 100644 index 000000000..96f3084ea --- /dev/null +++ b/conformance/go.mod @@ -0,0 +1,25 @@ +// Cross-language conformance tooling and the Go-generated fixtures that ports +// test against. It's a separate module so none of it ships in River's module +// zip, and it's never tagged or released. +module github.com/riverqueue/river/conformance + +go 1.26.0 + +toolchain go1.26.6 + +require ( + github.com/riverqueue/river v0.49.0 + github.com/riverqueue/river/riverdriver v0.49.0 + github.com/riverqueue/river/rivershared v0.49.0 + github.com/riverqueue/river/rivertype v0.49.0 + github.com/robfig/cron/v3 v3.0.1 + golang.org/x/mod v0.41.0 +) + +require ( + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + golang.org/x/sync v0.23.0 // indirect +) diff --git a/conformance/go.sum b/conformance/go.sum new file mode 100644 index 000000000..6059fab7d --- /dev/null +++ b/conformance/go.sum @@ -0,0 +1,45 @@ +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/riverqueue/river v0.49.0 h1:JUCLFgregbX1Wu+bSTHCjF/WHGdrwGbtz/ddInHfeb0= +github.com/riverqueue/river v0.49.0/go.mod h1:USYb57gpMBXLQm2l7poskAalOPaf5uHjSZgp2AOoYDw= +github.com/riverqueue/river/riverdriver v0.49.0 h1:kSykNQJNB7AeG6kudjB0mThV29PvykoOBIFT8ipCHEc= +github.com/riverqueue/river/riverdriver v0.49.0/go.mod h1:rVUuX/fTF2kAiJjpPT+10Lft8n90kvLqjm6b+rFXaVE= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.49.0 h1:c7YA1plP/nrNS3SEHuKizf4wHx9OQ87Yxuhj3V0eQ50= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.49.0/go.mod h1:o7zkFstM+Fk+mnlOW2sx92mQW4JswSc6zvN191l8Isg= +github.com/riverqueue/river/rivershared v0.49.0 h1:wnCYVwftMiu85kT1JrUPKEgujKkBIWoRtSNZJTUtotY= +github.com/riverqueue/river/rivershared v0.49.0/go.mod h1:E8UzQAdDutFT8rVL1wZeNbuphmIS81TgngU7/1F4U5E= +github.com/riverqueue/river/rivertype v0.49.0 h1:3up3P2DtOqnM2yEYSC1xeK9AjrljOtIfl10yZlaF4aM= +github.com/riverqueue/river/rivertype v0.49.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= diff --git a/go.work b/go.work index 8f7b945da..7f9949533 100644 --- a/go.work +++ b/go.work @@ -5,6 +5,7 @@ toolchain go1.26.6 use ( . ./cmd/river + ./conformance ./riverdriver ./riverdriver/riverdatabasesql ./riverdriver/riverdrivertest diff --git a/internal/jobexecutor/job_executor.go b/internal/jobexecutor/job_executor.go index 3d5b455e3..07a2a0a94 100644 --- a/internal/jobexecutor/job_executor.go +++ b/internal/jobexecutor/job_executor.go @@ -80,6 +80,14 @@ func MetadataUpdatesFromWorkContext(ctx context.Context) (map[string]any, bool) return typedMetadataUpdates, true } +// NextSnoozeCount returns the snooze count recorded on a job that's snoozed +// again: its metadata's current `snoozes` value plus one. The current value is +// read leniently, so a missing, non-numeric, or malformed count reads as zero +// and a fractional one truncates toward zero. +func NextSnoozeCount(metadata []byte) int64 { + return gjson.GetBytes(metadata, "snoozes").Int() + 1 +} + type jobExecutorResult struct { Err error JobArgsUnmarshaled bool @@ -422,13 +430,12 @@ func (e *JobExecutor) reportResult(ctx context.Context, jobRow *rivertype.JobRow ) nextAttemptScheduledAt := e.Time.Now().Add(snoozeErr.Duration) - snoozesValue := gjson.GetBytes(jobRow.Metadata, "snoozes").Int() if res.MetadataUpdates == nil { res.MetadataUpdates = make(map[string]any) } // Set snooze count in the metadata map before marshaling so we avoid // rewriting a potentially large encoded metadata payload. - res.MetadataUpdates["snoozes"] = snoozesValue + 1 + res.MetadataUpdates["snoozes"] = NextSnoozeCount(jobRow.Metadata) metadataUpdatesBytes, err := marshalMetadataUpdates(res.MetadataUpdates) if err != nil { diff --git a/internal/retrypolicy/default.go b/internal/retrypolicy/default.go index cbf0d1bae..3d1d1b5fb 100644 --- a/internal/retrypolicy/default.go +++ b/internal/retrypolicy/default.go @@ -39,6 +39,19 @@ func NextRetryAt(now time.Time, job *rivertype.JobRow) time.Time { return now.Add(secondsAsCappedDuration(retrySeconds(errorCount))) } +// DelayBounds returns the smallest and largest delay NextRetryAt schedules +// for a job with errorCount-1 recorded errors. Delays are errorCount^4 +// seconds with up to 10% jitter either way, capped at the maximum +// time.Duration. +func DelayBounds(errorCount int) (time.Duration, time.Duration) { + base := retrySecondsWithoutJitter(errorCount) + if base == maxDurationSeconds { + return maxDuration, maxDuration + } + return secondsAsCappedDuration(base - base*jitterFraction), + secondsAsCappedDuration(min(base+base*jitterFraction, maxDurationSeconds)) +} + // secondsAsCappedDuration converts seconds to a duration, returning the // maximum duration for values at or above it. Converting an out-of-range // float to an integer is implementation-specific in Go and yields the minimum @@ -54,6 +67,10 @@ func secondsAsCappedDuration(seconds float64) time.Duration { // The maximum value of a duration before it overflows. About 292 years. const maxDuration time.Duration = 1<<63 - 1 +// jitterFraction is the largest fraction of a retry delay that jitter adds +// or removes. +const jitterFraction = 0.1 + // Same as the above, but changed to a float represented in seconds. var maxDurationSeconds = maxDuration.Seconds() //nolint:gochecknoglobals @@ -71,7 +88,7 @@ func retrySeconds(attempt int) float64 { } // Jitter number of seconds +/- 10%. - retrySeconds += retrySeconds * (rand.Float64()*0.2 - 0.1) + retrySeconds += retrySeconds * (rand.Float64()*2*jitterFraction - jitterFraction) // Cap retrySeconds once more in case adding random jitter pushed it over // maxDurationSeconds. (This should never realistically happen, but protect diff --git a/internal/retrypolicy/default_test.go b/internal/retrypolicy/default_test.go index 0ad54e38e..f9ec5c791 100644 --- a/internal/retrypolicy/default_test.go +++ b/internal/retrypolicy/default_test.go @@ -85,6 +85,27 @@ func TestDefault_NextRetry(t *testing.T) { }) } +func TestDelayBounds(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + for _, errorCount := range []int{1, 2, 11, 309, 310, 1_000} { + minDelay, maxDelay := DelayBounds(errorCount) + require.LessOrEqual(t, minDelay, maxDelay) + for range 20 { + delay := NextRetryAt(now, &rivertype.JobRow{Errors: make([]rivertype.AttemptError, errorCount-1)}).Sub(now) + require.GreaterOrEqual(t, delay, minDelay, "error count %d", errorCount) + require.LessOrEqual(t, delay, maxDelay, "error count %d", errorCount) + } + } + minDelay, maxDelay := DelayBounds(1) + require.Equal(t, 900*time.Millisecond, minDelay) + require.Equal(t, 1100*time.Millisecond, maxDelay) + minDelay, maxDelay = DelayBounds(310) + require.Equal(t, time.Duration(math.MaxInt64), minDelay) + require.Equal(t, time.Duration(math.MaxInt64), maxDelay) +} + func TestRetrySeconds(t *testing.T) { t.Parallel() diff --git a/js/docs/development.md b/js/docs/development.md index cb4bfcbb5..894cb94f5 100644 --- a/js/docs/development.md +++ b/js/docs/development.md @@ -197,9 +197,12 @@ formatting, licenses, packed archives and examples, unit tests on Node 26.0.0 and the current Node 26 release, and integration tests on PostgreSQL 14 through 18. -Unit tests compare cron schedules and snooze counting with goldens recorded -from River Go in `src/testdata`, the same values the Rust port checks in its -own fixtures. +Unit tests compare cron schedules and snooze counting with fixtures that +River's Go implementation generates into `conformance/testdata`, the same +files the Rust port reads. They aren't committed: `make test/js` generates +them first, so Go is needed to run the unit tests, and `pnpm run test` needs +a prior `make generate/fixtures` from the repository root. A missing fixture +fails its test. ## Preparing a release diff --git a/js/package.json b/js/package.json index b6daf50cf..7693907be 100644 --- a/js/package.json +++ b/js/package.json @@ -25,7 +25,6 @@ "dist", "src", "!src/**/*.test.ts", - "!src/testdata", "README.md", "LICENSE" ], diff --git a/js/scripts/check-packages.mjs b/js/scripts/check-packages.mjs index f4bc47c0d..0e90d8078 100644 --- a/js/scripts/check-packages.mjs +++ b/js/scripts/check-packages.mjs @@ -26,6 +26,9 @@ import { const execFileAsync = promisify(execFile); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const examplesOnly = process.argv.includes("--examples-only"); +// The only JSON a package may ship: its manifest and runtime migration data. +// Anything else, like Go-generated test goldens, is test data. +const packagedJsonFiles = new Set(["migrations/manifest.json", "package.json"]); const rootPackageJson = JSON.parse( await readFile(join(repositoryRoot, "package.json"), "utf8") ); @@ -184,6 +187,17 @@ async function inspectArchive(packageSpec, archivePath) { [], `${packageSpec.name} does not package test builds` ); + assert.deepEqual( + files.filter( + (file) => + /(?:^|\/)(?:testdata|fixtures?|goldens?)\/|\.tsbuildinfo$/u.test( + file + ) || + (file.endsWith(".json") && !packagedJsonFiles.has(file)) + ), + [], + `${packageSpec.name} does not package test data` + ); const denied = deniedSubstrings(repositoryRoot); for (const file of files) { assertPortableArchivePath(`${packageSpec.name}:${file}`, file); diff --git a/js/src/cron.test.ts b/js/src/cron.test.ts index d5b2f144d..3aaf04375 100644 --- a/js/src/cron.test.ts +++ b/js/src/cron.test.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { @@ -37,8 +38,29 @@ interface CronGoldens { }[]; } -/** River Go's cron goldens, recorded with robfig/cron; see the file. */ -const GOLDENS = new URL("./testdata/cron-goldens.json", import.meta.url); +/** River Go's cron goldens, generated with robfig/cron; see the file. */ +const GOLDENS = new URL( + "../../conformance/testdata/cron_schedules.json", + import.meta.url +); + +/** + * Reads a fixture that `make generate/fixtures` writes from River's Go + * implementation. A missing fixture fails the test rather than skipping it. + */ +async function readFixture(url: URL): Promise { + try { + return await readFile(url, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error( + `missing conformance fixture ${fileURLToPath(url)}; run \`make generate/fixtures\` from the repository root`, + { cause: error } + ); + } + throw error; + } +} /** Up to `count` successive occurrences after `from`, like robfig's tests. */ function occurrences( @@ -110,7 +132,7 @@ const midnight = fieldsSpec({ hour: 1n, minute: 1n }); describe("cron", () => { describe("River Go goldens", () => { const load = async (): Promise => - JSON.parse(await readFile(GOLDENS, "utf8")) as CronGoldens; + JSON.parse(await readFixture(GOLDENS)) as CronGoldens; it("returns robfig's successive occurrences in the reference offset", async () => { const goldens = await load(); diff --git a/js/src/runtime/completion-command.test.ts b/js/src/runtime/completion-command.test.ts index fb95ded76..898df5fa4 100644 --- a/js/src/runtime/completion-command.test.ts +++ b/js/src/runtime/completion-command.test.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; @@ -18,13 +19,34 @@ interface SnoozeCounterCase { readonly name: string; } -/** River Go's snooze counter goldens, recorded from its executor. */ -const GOLDENS = new URL("../testdata/snooze-counters.json", import.meta.url); +/** River Go's snooze counter goldens, generated from its executor. */ +const GOLDENS = new URL( + "../../../conformance/testdata/snooze_counters.json", + import.meta.url +); + +/** + * Reads a fixture that `make generate/fixtures` writes from River's Go + * implementation. A missing fixture fails the test rather than skipping it. + */ +async function readFixture(url: URL): Promise { + try { + return await readFile(url, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error( + `missing conformance fixture ${fileURLToPath(url)}; run \`make generate/fixtures\` from the repository root`, + { cause: error } + ); + } + throw error; + } +} describe("completionCommand", () => { it("counts snoozes like River for Go's executor", async () => { // Parse with River's exact JSON so integers beyond 2^53 stay exact. - const golden = parseJson(await readFile(GOLDENS, "utf8")) as unknown as { + const golden = parseJson(await readFixture(GOLDENS)) as unknown as { readonly snooze_counters: readonly SnoozeCounterCase[]; }; const now = Temporal.Instant.from("2026-09-01T00:00:00Z"); diff --git a/js/src/testdata/cron-goldens.json b/js/src/testdata/cron-goldens.json deleted file mode 100644 index 00038a70c..000000000 --- a/js/src/testdata/cron-goldens.json +++ /dev/null @@ -1,564 +0,0 @@ -{ - "$comment": "River Go's cron schedule goldens, recorded with robfig/cron v3.0.1, the parser River Go uses. They match the Rust port's rust/riverqueue/tests/fixtures/maintenance_values.json.", - "cron_cases": [ - { - "expression": "* * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_minute", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:06:00Z", - "2026-01-02T03:07:00Z", - "2026-01-02T03:08:00Z", - "2026-01-02T03:09:00Z" - ] - }, - { - "expression": "30 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "half_past_every_hour", - "next": [ - "2026-01-02T03:30:00Z", - "2026-01-02T04:30:00Z", - "2026-01-02T05:30:00Z", - "2026-01-02T06:30:00Z", - "2026-01-02T07:30:00Z" - ] - }, - { - "expression": "0 9 * * 1", - "from": "2026-01-02T03:04:05.6789Z", - "name": "monday_numeric_weekday", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - }, - { - "expression": "0 9 * * mon", - "from": "2026-01-02T03:04:05.6789Z", - "name": "monday_named_weekday", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - }, - { - "expression": "0 0 * * 0", - "from": "2026-01-02T03:04:05.6789Z", - "name": "sunday_is_zero", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "0 0 * * SUN", - "from": "2026-01-02T03:04:05.6789Z", - "name": "weekday_names_ignore_case", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "*/15 9-17 * * mon-fri", - "from": "2026-01-02T03:04:05.6789Z", - "name": "business_hours_steps", - "next": [ - "2026-01-02T09:00:00Z", - "2026-01-02T09:15:00Z", - "2026-01-02T09:30:00Z", - "2026-01-02T09:45:00Z", - "2026-01-02T10:00:00Z" - ] - }, - { - "expression": "0 0 1 * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "first_of_month", - "next": [ - "2026-02-01T00:00:00Z", - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - "2026-05-01T00:00:00Z", - "2026-06-01T00:00:00Z" - ] - }, - { - "expression": "0 0 1 jan,JUL *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "named_months", - "next": [ - "2026-07-01T00:00:00Z", - "2027-01-01T00:00:00Z", - "2027-07-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2028-07-01T00:00:00Z" - ] - }, - { - "expression": "0 0 29 2 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "leap_day", - "next": [ - "2028-02-29T00:00:00Z", - "2032-02-29T00:00:00Z", - "2036-02-29T00:00:00Z", - "2040-02-29T00:00:00Z", - "2044-02-29T00:00:00Z" - ] - }, - { - "expression": "0 0 30 2 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "impossible_date_never_runs", - "next": [] - }, - { - "expression": "0 12 1,15 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "day_of_month_or_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-15T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z" - ] - }, - { - "expression": "0 12 * * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "wildcard_day_of_month_and_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "0 12 ? * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "question_mark_wildcard", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "0 12 */2 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "stepped_day_of_month_or_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-03T12:00:00Z", - "2026-01-05T12:00:00Z", - "2026-01-07T12:00:00Z", - "2026-01-09T12:00:00Z" - ] - }, - { - "expression": "0 12 */1 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "unit_step_keeps_wildcard", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "5/15 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "start_with_step", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:20:00Z", - "2026-01-02T03:35:00Z", - "2026-01-02T03:50:00Z", - "2026-01-02T04:05:00Z" - ] - }, - { - "expression": "0-10/5 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "range_with_step", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:10:00Z", - "2026-01-02T04:00:00Z", - "2026-01-02T04:05:00Z", - "2026-01-02T04:10:00Z" - ] - }, - { - "expression": "59 23 31 12 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "year_end", - "next": [ - "2026-12-31T23:59:00Z", - "2027-12-31T23:59:00Z", - "2028-12-31T23:59:00Z", - "2029-12-31T23:59:00Z", - "2030-12-31T23:59:00Z" - ] - }, - { - "expression": "@hourly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_hourly", - "next": [ - "2026-01-02T04:00:00Z", - "2026-01-02T05:00:00Z", - "2026-01-02T06:00:00Z", - "2026-01-02T07:00:00Z", - "2026-01-02T08:00:00Z" - ] - }, - { - "expression": "@daily", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_daily", - "next": [ - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - "2026-01-05T00:00:00Z", - "2026-01-06T00:00:00Z", - "2026-01-07T00:00:00Z" - ] - }, - { - "expression": "@midnight", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_midnight", - "next": [ - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - "2026-01-05T00:00:00Z", - "2026-01-06T00:00:00Z", - "2026-01-07T00:00:00Z" - ] - }, - { - "expression": "@weekly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_weekly", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "@monthly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_monthly", - "next": [ - "2026-02-01T00:00:00Z", - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - "2026-05-01T00:00:00Z", - "2026-06-01T00:00:00Z" - ] - }, - { - "expression": "@yearly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_yearly", - "next": [ - "2027-01-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2029-01-01T00:00:00Z", - "2030-01-01T00:00:00Z", - "2031-01-01T00:00:00Z" - ] - }, - { - "expression": "@annually", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_annually", - "next": [ - "2027-01-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2029-01-01T00:00:00Z", - "2030-01-01T00:00:00Z", - "2031-01-01T00:00:00Z" - ] - }, - { - "expression": "@every 1h30m", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_compound_duration", - "next": [ - "2026-01-02T04:34:05Z", - "2026-01-02T06:04:05Z", - "2026-01-02T07:34:05Z", - "2026-01-02T09:04:05Z", - "2026-01-02T10:34:05Z" - ] - }, - { - "expression": "@every 1.5h", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_fractional_duration", - "next": [ - "2026-01-02T04:34:05Z", - "2026-01-02T06:04:05Z", - "2026-01-02T07:34:05Z", - "2026-01-02T09:04:05Z", - "2026-01-02T10:34:05Z" - ] - }, - { - "expression": "@every 90s", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_seconds", - "next": [ - "2026-01-02T03:05:35Z", - "2026-01-02T03:07:05Z", - "2026-01-02T03:08:35Z", - "2026-01-02T03:10:05Z", - "2026-01-02T03:11:35Z" - ] - }, - { - "expression": "@every 500ms", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_rounds_up_to_one_second", - "next": [ - "2026-01-02T03:04:06Z", - "2026-01-02T03:04:07Z", - "2026-01-02T03:04:08Z", - "2026-01-02T03:04:09Z", - "2026-01-02T03:04:10Z" - ] - }, - { - "expression": "@every 1500ms", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_truncates_subseconds", - "next": [ - "2026-01-02T03:04:06Z", - "2026-01-02T03:04:07Z", - "2026-01-02T03:04:08Z", - "2026-01-02T03:04:09Z", - "2026-01-02T03:04:10Z" - ] - }, - { - "expression": "0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "reference_time_offset", - "next": [ - "2026-03-07T09:00:00-05:00", - "2026-03-08T09:00:00-05:00", - "2026-03-09T09:00:00-05:00", - "2026-03-10T09:00:00-05:00", - "2026-03-11T09:00:00-05:00" - ] - }, - { - "expression": "30 0 * * *", - "from": "2026-03-07T23:45:00+05:30", - "name": "reference_time_half_hour_offset", - "next": [ - "2026-03-08T00:30:00+05:30", - "2026-03-09T00:30:00+05:30", - "2026-03-10T00:30:00+05:30", - "2026-03-11T00:30:00+05:30", - "2026-03-12T00:30:00+05:30" - ] - }, - { - "expression": "CRON_TZ=UTC 0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "cron_tz_utc_prefix", - "next": [ - "2026-03-08T04:00:00-05:00", - "2026-03-09T04:00:00-05:00", - "2026-03-10T04:00:00-05:00", - "2026-03-11T04:00:00-05:00", - "2026-03-12T04:00:00-05:00" - ] - }, - { - "expression": "TZ=UTC 0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "tz_utc_prefix", - "next": [ - "2026-03-08T04:00:00-05:00", - "2026-03-09T04:00:00-05:00", - "2026-03-10T04:00:00-05:00", - "2026-03-11T04:00:00-05:00", - "2026-03-12T04:00:00-05:00" - ] - }, - { - "expression": " 0 9 * * 1 ", - "from": "2026-01-02T03:04:05.6789Z", - "name": "extra_whitespace", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - } - ], - "cron_invalid": [ - "", - "* * * *", - "* * * * * *", - "0 9 * * 7", - "60 * * * *", - "* 24 * * *", - "* * 0 * *", - "* * 32 * *", - "* * * 0 *", - "* * * 13 *", - "-1 * * * *", - "5-1 * * * *", - "1-2-3 * * * *", - "1/2/3 * * * *", - "*/0 * * * *", - "*/x * * * *", - "0 9 * * funday", - "@every", - "@every 5x", - "@reboot", - "CRON_TZ=Nowhere/Invalid 0 9 * * *" - ], - "cron_named_zone_cases": [ - { - "expression": "CRON_TZ=America/New_York 0 9 * * *", - "from": "2026-03-06T12:00:00Z", - "name": "new_york_across_dst_start", - "next": [ - "2026-03-06T14:00:00Z", - "2026-03-07T14:00:00Z", - "2026-03-08T13:00:00Z", - "2026-03-09T13:00:00Z", - "2026-03-10T13:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 30 2 * * *", - "from": "2026-03-06T12:00:00Z", - "name": "new_york_skipped_wall_time", - "next": [ - "2026-03-07T07:30:00Z", - "2026-03-09T06:30:00Z", - "2026-03-10T06:30:00Z", - "2026-03-11T06:30:00Z", - "2026-03-12T06:30:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 30 1 * * *", - "from": "2026-10-30T12:00:00Z", - "name": "new_york_repeated_wall_time", - "next": [ - "2026-10-31T05:30:00Z", - "2026-11-01T05:30:00Z", - "2026-11-01T06:30:00Z", - "2026-11-02T06:30:00Z", - "2026-11-03T06:30:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 0 * * * *", - "from": "2026-11-01T04:30:00Z", - "name": "new_york_hourly_across_dst_end", - "next": [ - "2026-11-01T05:00:00Z", - "2026-11-01T06:00:00Z", - "2026-11-01T07:00:00Z", - "2026-11-01T08:00:00Z", - "2026-11-01T09:00:00Z" - ] - }, - { - "expression": "CRON_TZ=Europe/London 0 0 * * *", - "from": "2026-10-23T12:00:00Z", - "name": "london_across_dst_end", - "next": [ - "2026-10-23T23:00:00Z", - "2026-10-24T23:00:00Z", - "2026-10-26T00:00:00Z", - "2026-10-27T00:00:00Z", - "2026-10-28T00:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 0 0 * * *", - "from": "2026-09-03T12:00:00Z", - "name": "santiago_skipped_midnight", - "next": [ - "2026-09-04T04:00:00Z", - "2026-09-05T04:00:00Z", - "2026-09-07T03:00:00Z", - "2026-09-08T03:00:00Z", - "2026-09-09T03:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 0 12 * * *", - "from": "2026-09-03T12:00:00Z", - "name": "santiago_day_after_skipped_midnight", - "next": [ - "2026-09-03T16:00:00Z", - "2026-09-04T16:00:00Z", - "2026-09-05T16:00:00Z", - "2026-09-06T15:00:00Z", - "2026-09-07T15:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 30 23 * * *", - "from": "2026-04-02T12:00:00Z", - "name": "santiago_repeated_hour_before_midnight", - "next": [ - "2026-04-03T02:30:00Z", - "2026-04-04T02:30:00Z", - "2026-04-05T02:30:00Z", - "2026-04-05T03:30:00Z", - "2026-04-06T03:30:00Z" - ] - }, - { - "expression": "TZ=Asia/Kolkata 0 9 * * mon", - "from": "2026-01-02T03:04:05-05:00", - "name": "kolkata_tz_prefix", - "next": [ - "2026-01-04T22:30:00-05:00", - "2026-01-11T22:30:00-05:00", - "2026-01-18T22:30:00-05:00", - "2026-01-25T22:30:00-05:00", - "2026-02-01T22:30:00-05:00" - ] - } - ] -} diff --git a/js/src/testdata/snooze-counters.json b/js/src/testdata/snooze-counters.json deleted file mode 100644 index 3c9b5439a..000000000 --- a/js/src/testdata/snooze-counters.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "$comment": "River Go's snooze counter goldens, recorded from its executor's rule in internal/jobexecutor/job_executor.go, which reads each count with gjson. They match the Rust port's rust/riverqueue/tests/fixtures/maintenance_values.json.", - "snooze_counters": [ - { - "expected_snoozes": 1, - "metadata": {}, - "name": "absent" - }, - { - "expected_snoozes": 3, - "metadata": { - "snoozes": 2 - }, - "name": "integer" - }, - { - "expected_snoozes": 3, - "metadata": { - "snoozes": 2.9 - }, - "name": "fraction_truncates" - }, - { - "expected_snoozes": -1, - "metadata": { - "snoozes": -2.5 - }, - "name": "negative_fraction_truncates_toward_zero" - }, - { - "expected_snoozes": 1001, - "metadata": { - "snoozes": 1000.0 - }, - "name": "exponent" - }, - { - "expected_snoozes": 9007199254740994, - "metadata": { - "snoozes": 9007199254740993 - }, - "name": "beyond_float_precision" - }, - { - "expected_snoozes": 5, - "metadata": { - "snoozes": "4" - }, - "name": "numeric_string" - }, - { - "expected_snoozes": -6, - "metadata": { - "snoozes": "-7" - }, - "name": "negative_numeric_string" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": "4.5" - }, - "name": "fractional_string_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": " 5" - }, - "name": "padded_string_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": "abc" - }, - "name": "non_numeric_string_is_zero" - }, - { - "expected_snoozes": 2, - "metadata": { - "snoozes": true - }, - "name": "true_is_one" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": false - }, - "name": "false_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": null - }, - "name": "null_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": [3] - }, - "name": "array_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": { - "count": 3 - } - }, - "name": "object_is_zero" - } - ] -} diff --git a/rust/README.md b/rust/README.md index 4bf34c0db..a4a25edb4 100644 --- a/rust/README.md +++ b/rust/README.md @@ -68,9 +68,12 @@ RIVER_RUST_DATABASE_URL=postgres://localhost/river_rust_test \ ``` CI runs unit, doc, and SQLite tests on each supported Rust version, and -PostgreSQL tests against versions 14 through 18. Rust tests include local -reference fixtures for unique keys, retry bounds, and cron schedules; they -run with Cargo without requiring Go or a separate test harness. +PostgreSQL tests against versions 14 through 18. Rust tests check unique +keys, retry bounds, cron schedules, and snooze counts against fixtures that +River's Go implementation generates into `conformance/testdata`, which isn't +committed. The `make test/rust` targets generate them first, so Go is needed +to run the tests; when running `cargo test` directly, run `make +generate/fixtures` beforehand. A missing fixture fails its test. `make check/rust/package` builds the five publishable crate archives and verifies that each one builds from its packaged sources, resolving the diff --git a/rust/riverqueue-cli/Cargo.toml b/rust/riverqueue-cli/Cargo.toml index 34f27a7e3..c7a38623f 100644 --- a/rust/riverqueue-cli/Cargo.toml +++ b/rust/riverqueue-cli/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "LICENSE", "README.md"] [features] default = ["postgres", "sqlite"] diff --git a/rust/riverqueue-macros/Cargo.toml b/rust/riverqueue-macros/Cargo.toml index 28e922d16..4498ce999 100644 --- a/rust/riverqueue-macros/Cargo.toml +++ b/rust/riverqueue-macros/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "LICENSE", "README.md"] [lib] proc-macro = true diff --git a/rust/riverqueue-migrate/Cargo.toml b/rust/riverqueue-migrate/Cargo.toml index 536372b6b..361e5071a 100644 --- a/rust/riverqueue-migrate/Cargo.toml +++ b/rust/riverqueue-migrate/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "examples/**", "migrations/**", "LICENSE", "README.md"] [package.metadata.docs.rs] all-features = true diff --git a/rust/riverqueue-test/Cargo.toml b/rust/riverqueue-test/Cargo.toml index ce72e2415..fc89ed704 100644 --- a/rust/riverqueue-test/Cargo.toml +++ b/rust/riverqueue-test/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "LICENSE", "README.md"] [package.metadata.docs.rs] all-features = true diff --git a/rust/riverqueue/Cargo.toml b/rust/riverqueue/Cargo.toml index 9febe6795..b4457be83 100644 --- a/rust/riverqueue/Cargo.toml +++ b/rust/riverqueue/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "docs/**", "examples/**", "LICENSE", "README.md"] [package.metadata.docs.rs] all-features = true diff --git a/rust/riverqueue/src/client/executor.rs b/rust/riverqueue/src/client/executor.rs index 2aa276516..4df75d75f 100644 --- a/rust/riverqueue/src/client/executor.rs +++ b/rust/riverqueue/src/client/executor.rs @@ -748,7 +748,7 @@ fn go_json_int(value: Option<&serde_json::value::RawValue>) -> i64 { #[cfg(test)] mod go_json_int_tests { - use crate::JobMetadata; + use crate::{JobMetadata, conformance::read_fixture}; use serde::Deserialize; use super::go_json_int; @@ -767,9 +767,7 @@ mod go_json_int_tests { #[test] fn snooze_counter_matches_go_fixture() { - let fixture: Fixture = - serde_json::from_str(include_str!("../../tests/fixtures/maintenance_values.json")) - .unwrap(); + let fixture: Fixture = serde_json::from_str(&read_fixture("snooze_counters.json")).unwrap(); assert!(!fixture.snooze_counters.is_empty()); for case in fixture.snooze_counters { assert_eq!( diff --git a/rust/riverqueue/src/client/tests.rs b/rust/riverqueue/src/client/tests.rs index b34aaad03..35ed8bbae 100644 --- a/rust/riverqueue/src/client/tests.rs +++ b/rust/riverqueue/src/client/tests.rs @@ -1,4 +1,3 @@ -#[cfg(feature = "sqlite")] use serde::Deserialize; use serde_json::Map; @@ -11,7 +10,6 @@ use super::completer::{ }; use super::completer::{persisted_completion_event_kind, with_completion_retries}; use super::executor::scheduled_after; -#[cfg(feature = "sqlite")] use super::notifier::dispatch_notification; use super::*; use crate::{AttemptError, JobEventKind, JobRow, JobState, WorkError, WorkResult}; @@ -122,6 +120,119 @@ fn completion_cleanup_preserves_newer_attempt() { ); } +#[tokio::test] +async fn go_notification_fixtures_dispatch_correctly() { + #[derive(Deserialize)] + struct Fixture { + notifications: Vec, + } + + #[derive(Deserialize)] + struct NotificationFixture { + name: String, + payload: Box, + topic: String, + } + + let fixture: Fixture = + serde_json::from_str(&crate::conformance::read_fixture("protocol_values.json")).unwrap(); + let names = [ + "cancel", + "insert", + "metadata_changed", + "pause", + "request_resign", + "resigned", + "resume", + ]; + assert_eq!(fixture.notifications.len(), names.len()); + + // Dispatch is synchronous and needs no database connection. Exercise it + // in both PostgreSQL-only and SQLite-only builds. + #[cfg(feature = "postgres")] + let pool = sqlx::PgPool::connect_lazy("postgres://localhost/unused").unwrap(); + #[cfg(not(feature = "postgres"))] + let pool = sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(); + + for name in names { + let notification = fixture + .notifications + .iter() + .find(|notification| notification.name == name) + .unwrap_or_else(|| panic!("missing Go notification fixture {name}")); + + // The Go examples target job 42 in queue "priority", with resignations + // from "client-1". A leader ignores its own resignation notification. + for client_id in ["client-1", "observer"] { + let client = Client::builder(pool.clone()).id(client_id).build().unwrap(); + let inner = &client.inner; + let mut leadership = inner.leadership_wakeups.subscribe(); + let mut producer = inner.queue_notifications.subscribe(); + let cancellation = CancellationToken::new(); + let unrelated = CancellationToken::new(); + for (job_id, token) in [(42, &cancellation), (43, &unrelated)] { + register_running_attempt( + &inner.running, + &inner.pending_cancellations, + job_id, + token, + ); + } + + dispatch_notification( + inner, + &inner.queue_notifications, + ¬ification.topic, + notification.payload.get(), + ); + + match name { + "cancel" => {} + "insert" => assert!( + matches!(producer.try_recv(), Ok(RuntimeNotification::Insert(queue)) if queue == "priority"), + "{name}: expected an insert wakeup" + ), + "metadata_changed" | "pause" | "resume" => assert!( + matches!(producer.try_recv(), Ok(RuntimeNotification::QueueControl(queue)) if queue == "priority"), + "{name}: expected a queue control wakeup" + ), + "request_resign" => assert_eq!( + leadership.try_recv().unwrap(), + LeadershipWakeup::RequestResign, + "{name}" + ), + "resigned" if client_id == "client-1" => {} + "resigned" => assert_eq!( + leadership.try_recv().unwrap(), + LeadershipWakeup::Changed, + "{name}" + ), + _ => unreachable!(), + } + assert_eq!(cancellation.is_cancelled(), name == "cancel", "{name}"); + assert!( + !unrelated.is_cancelled(), + "{name}: cancelled an unrelated job" + ); + assert!( + matches!( + producer.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), + "{name}: unexpected producer notification" + ); + assert!( + matches!( + leadership.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), + "{name}: unexpected leadership notification" + ); + } + } + pool.close().await; +} + #[test] fn pending_cancellation_reaches_fetched_attempt() { let job_id = 42; diff --git a/rust/riverqueue/src/conformance.rs b/rust/riverqueue/src/conformance.rs new file mode 100644 index 000000000..a041d7faf --- /dev/null +++ b/rust/riverqueue/src/conformance.rs @@ -0,0 +1,27 @@ +//! Access to the fixtures River's Go implementation generates for the ports' +//! conformance tests. + +use std::{io::ErrorKind, path::Path}; + +/// Reads `name` from `conformance/testdata`, where `make generate/fixtures` +/// writes fixtures produced by River's Go implementation. +/// +/// # Panics +/// +/// Panics when the fixture can't be read, so a missing fixture fails the test +/// rather than skipping it. +pub(crate) fn read_fixture(name: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../conformance/testdata") + .join(name); + std::fs::read_to_string(&path).unwrap_or_else(|error| match error.kind() { + ErrorKind::NotFound => panic!( + "missing conformance fixture {}; run `make generate/fixtures` from the repository root", + path.display() + ), + _ => panic!( + "error reading conformance fixture {}: {error}", + path.display() + ), + }) +} diff --git a/rust/riverqueue/src/lib.rs b/rust/riverqueue/src/lib.rs index e84ec151c..24455ee7e 100644 --- a/rust/riverqueue/src/lib.rs +++ b/rust/riverqueue/src/lib.rs @@ -22,6 +22,8 @@ extern crate self as riverqueue; #[doc(hidden)] pub mod __private; mod client; +#[cfg(test)] +mod conformance; pub mod database; pub mod encoding; pub mod error; diff --git a/rust/riverqueue/src/periodic/cron.rs b/rust/riverqueue/src/periodic/cron.rs index eb027b280..89941d355 100644 --- a/rust/riverqueue/src/periodic/cron.rs +++ b/rust/riverqueue/src/periodic/cron.rs @@ -715,6 +715,7 @@ mod tests { use serde::Deserialize; use super::{CronSchedule, CronTimeZone, PeriodicSchedule}; + use crate::conformance::read_fixture; #[derive(Deserialize)] #[expect( @@ -736,7 +737,7 @@ mod tests { } fn fixture() -> Fixture { - serde_json::from_str(include_str!("../../tests/fixtures/maintenance_values.json")).unwrap() + serde_json::from_str(&read_fixture("cron_schedules.json")).unwrap() } fn assert_matches_go(case: &CronCase) { diff --git a/rust/riverqueue/src/unique.rs b/rust/riverqueue/src/unique.rs index 33e5602cc..623b12976 100644 --- a/rust/riverqueue/src/unique.rs +++ b/rust/riverqueue/src/unique.rs @@ -474,7 +474,10 @@ mod tests { use serde::{Deserialize, Serialize}; use super::*; - use crate::{JobArgs, JobArgs as JobArgsDerive, JobState, encoding::encode_args}; + use crate::{ + JobArgs, JobArgs as JobArgsDerive, JobState, conformance::read_fixture, + encoding::encode_args, + }; #[derive(Deserialize)] struct Fixture { @@ -537,7 +540,7 @@ mod tests { } fn fixture() -> Fixture { - serde_json::from_str(include_str!("../tests/fixtures/unique_keys.json")).unwrap() + serde_json::from_str(&read_fixture("unique_keys.json")).unwrap() } fn golden(name: &str) -> FixtureCase { diff --git a/rust/riverqueue/tests/fixtures/maintenance_values.json b/rust/riverqueue/tests/fixtures/maintenance_values.json deleted file mode 100644 index 1b69ceb1c..000000000 --- a/rust/riverqueue/tests/fixtures/maintenance_values.json +++ /dev/null @@ -1,679 +0,0 @@ -{ - "cron_cases": [ - { - "expression": "* * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_minute", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:06:00Z", - "2026-01-02T03:07:00Z", - "2026-01-02T03:08:00Z", - "2026-01-02T03:09:00Z" - ] - }, - { - "expression": "30 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "half_past_every_hour", - "next": [ - "2026-01-02T03:30:00Z", - "2026-01-02T04:30:00Z", - "2026-01-02T05:30:00Z", - "2026-01-02T06:30:00Z", - "2026-01-02T07:30:00Z" - ] - }, - { - "expression": "0 9 * * 1", - "from": "2026-01-02T03:04:05.6789Z", - "name": "monday_numeric_weekday", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - }, - { - "expression": "0 9 * * mon", - "from": "2026-01-02T03:04:05.6789Z", - "name": "monday_named_weekday", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - }, - { - "expression": "0 0 * * 0", - "from": "2026-01-02T03:04:05.6789Z", - "name": "sunday_is_zero", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "0 0 * * SUN", - "from": "2026-01-02T03:04:05.6789Z", - "name": "weekday_names_ignore_case", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "*/15 9-17 * * mon-fri", - "from": "2026-01-02T03:04:05.6789Z", - "name": "business_hours_steps", - "next": [ - "2026-01-02T09:00:00Z", - "2026-01-02T09:15:00Z", - "2026-01-02T09:30:00Z", - "2026-01-02T09:45:00Z", - "2026-01-02T10:00:00Z" - ] - }, - { - "expression": "0 0 1 * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "first_of_month", - "next": [ - "2026-02-01T00:00:00Z", - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - "2026-05-01T00:00:00Z", - "2026-06-01T00:00:00Z" - ] - }, - { - "expression": "0 0 1 jan,JUL *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "named_months", - "next": [ - "2026-07-01T00:00:00Z", - "2027-01-01T00:00:00Z", - "2027-07-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2028-07-01T00:00:00Z" - ] - }, - { - "expression": "0 0 29 2 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "leap_day", - "next": [ - "2028-02-29T00:00:00Z", - "2032-02-29T00:00:00Z", - "2036-02-29T00:00:00Z", - "2040-02-29T00:00:00Z", - "2044-02-29T00:00:00Z" - ] - }, - { - "expression": "0 0 30 2 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "impossible_date_never_runs", - "next": [] - }, - { - "expression": "0 12 1,15 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "day_of_month_or_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-15T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z" - ] - }, - { - "expression": "0 12 * * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "wildcard_day_of_month_and_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "0 12 ? * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "question_mark_wildcard", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "0 12 */2 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "stepped_day_of_month_or_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-03T12:00:00Z", - "2026-01-05T12:00:00Z", - "2026-01-07T12:00:00Z", - "2026-01-09T12:00:00Z" - ] - }, - { - "expression": "0 12 */1 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "unit_step_keeps_wildcard", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "5/15 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "start_with_step", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:20:00Z", - "2026-01-02T03:35:00Z", - "2026-01-02T03:50:00Z", - "2026-01-02T04:05:00Z" - ] - }, - { - "expression": "0-10/5 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "range_with_step", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:10:00Z", - "2026-01-02T04:00:00Z", - "2026-01-02T04:05:00Z", - "2026-01-02T04:10:00Z" - ] - }, - { - "expression": "59 23 31 12 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "year_end", - "next": [ - "2026-12-31T23:59:00Z", - "2027-12-31T23:59:00Z", - "2028-12-31T23:59:00Z", - "2029-12-31T23:59:00Z", - "2030-12-31T23:59:00Z" - ] - }, - { - "expression": "@hourly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_hourly", - "next": [ - "2026-01-02T04:00:00Z", - "2026-01-02T05:00:00Z", - "2026-01-02T06:00:00Z", - "2026-01-02T07:00:00Z", - "2026-01-02T08:00:00Z" - ] - }, - { - "expression": "@daily", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_daily", - "next": [ - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - "2026-01-05T00:00:00Z", - "2026-01-06T00:00:00Z", - "2026-01-07T00:00:00Z" - ] - }, - { - "expression": "@midnight", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_midnight", - "next": [ - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - "2026-01-05T00:00:00Z", - "2026-01-06T00:00:00Z", - "2026-01-07T00:00:00Z" - ] - }, - { - "expression": "@weekly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_weekly", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "@monthly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_monthly", - "next": [ - "2026-02-01T00:00:00Z", - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - "2026-05-01T00:00:00Z", - "2026-06-01T00:00:00Z" - ] - }, - { - "expression": "@yearly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_yearly", - "next": [ - "2027-01-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2029-01-01T00:00:00Z", - "2030-01-01T00:00:00Z", - "2031-01-01T00:00:00Z" - ] - }, - { - "expression": "@annually", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_annually", - "next": [ - "2027-01-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2029-01-01T00:00:00Z", - "2030-01-01T00:00:00Z", - "2031-01-01T00:00:00Z" - ] - }, - { - "expression": "@every 1h30m", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_compound_duration", - "next": [ - "2026-01-02T04:34:05Z", - "2026-01-02T06:04:05Z", - "2026-01-02T07:34:05Z", - "2026-01-02T09:04:05Z", - "2026-01-02T10:34:05Z" - ] - }, - { - "expression": "@every 1.5h", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_fractional_duration", - "next": [ - "2026-01-02T04:34:05Z", - "2026-01-02T06:04:05Z", - "2026-01-02T07:34:05Z", - "2026-01-02T09:04:05Z", - "2026-01-02T10:34:05Z" - ] - }, - { - "expression": "@every 90s", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_seconds", - "next": [ - "2026-01-02T03:05:35Z", - "2026-01-02T03:07:05Z", - "2026-01-02T03:08:35Z", - "2026-01-02T03:10:05Z", - "2026-01-02T03:11:35Z" - ] - }, - { - "expression": "@every 500ms", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_rounds_up_to_one_second", - "next": [ - "2026-01-02T03:04:06Z", - "2026-01-02T03:04:07Z", - "2026-01-02T03:04:08Z", - "2026-01-02T03:04:09Z", - "2026-01-02T03:04:10Z" - ] - }, - { - "expression": "@every 1500ms", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_truncates_subseconds", - "next": [ - "2026-01-02T03:04:06Z", - "2026-01-02T03:04:07Z", - "2026-01-02T03:04:08Z", - "2026-01-02T03:04:09Z", - "2026-01-02T03:04:10Z" - ] - }, - { - "expression": "0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "reference_time_offset", - "next": [ - "2026-03-07T09:00:00-05:00", - "2026-03-08T09:00:00-05:00", - "2026-03-09T09:00:00-05:00", - "2026-03-10T09:00:00-05:00", - "2026-03-11T09:00:00-05:00" - ] - }, - { - "expression": "30 0 * * *", - "from": "2026-03-07T23:45:00+05:30", - "name": "reference_time_half_hour_offset", - "next": [ - "2026-03-08T00:30:00+05:30", - "2026-03-09T00:30:00+05:30", - "2026-03-10T00:30:00+05:30", - "2026-03-11T00:30:00+05:30", - "2026-03-12T00:30:00+05:30" - ] - }, - { - "expression": "CRON_TZ=UTC 0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "cron_tz_utc_prefix", - "next": [ - "2026-03-08T04:00:00-05:00", - "2026-03-09T04:00:00-05:00", - "2026-03-10T04:00:00-05:00", - "2026-03-11T04:00:00-05:00", - "2026-03-12T04:00:00-05:00" - ] - }, - { - "expression": "TZ=UTC 0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "tz_utc_prefix", - "next": [ - "2026-03-08T04:00:00-05:00", - "2026-03-09T04:00:00-05:00", - "2026-03-10T04:00:00-05:00", - "2026-03-11T04:00:00-05:00", - "2026-03-12T04:00:00-05:00" - ] - }, - { - "expression": " 0 9 * * 1 ", - "from": "2026-01-02T03:04:05.6789Z", - "name": "extra_whitespace", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - } - ], - "cron_invalid": [ - "", - "* * * *", - "* * * * * *", - "0 9 * * 7", - "60 * * * *", - "* 24 * * *", - "* * 0 * *", - "* * 32 * *", - "* * * 0 *", - "* * * 13 *", - "-1 * * * *", - "5-1 * * * *", - "1-2-3 * * * *", - "1/2/3 * * * *", - "*/0 * * * *", - "*/x * * * *", - "0 9 * * funday", - "@every", - "@every 5x", - "@reboot", - "CRON_TZ=Nowhere/Invalid 0 9 * * *" - ], - "cron_named_zone_cases": [ - { - "expression": "CRON_TZ=America/New_York 0 9 * * *", - "from": "2026-03-06T12:00:00Z", - "name": "new_york_across_dst_start", - "next": [ - "2026-03-06T14:00:00Z", - "2026-03-07T14:00:00Z", - "2026-03-08T13:00:00Z", - "2026-03-09T13:00:00Z", - "2026-03-10T13:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 30 2 * * *", - "from": "2026-03-06T12:00:00Z", - "name": "new_york_skipped_wall_time", - "next": [ - "2026-03-07T07:30:00Z", - "2026-03-09T06:30:00Z", - "2026-03-10T06:30:00Z", - "2026-03-11T06:30:00Z", - "2026-03-12T06:30:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 30 1 * * *", - "from": "2026-10-30T12:00:00Z", - "name": "new_york_repeated_wall_time", - "next": [ - "2026-10-31T05:30:00Z", - "2026-11-01T05:30:00Z", - "2026-11-01T06:30:00Z", - "2026-11-02T06:30:00Z", - "2026-11-03T06:30:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 0 * * * *", - "from": "2026-11-01T04:30:00Z", - "name": "new_york_hourly_across_dst_end", - "next": [ - "2026-11-01T05:00:00Z", - "2026-11-01T06:00:00Z", - "2026-11-01T07:00:00Z", - "2026-11-01T08:00:00Z", - "2026-11-01T09:00:00Z" - ] - }, - { - "expression": "CRON_TZ=Europe/London 0 0 * * *", - "from": "2026-10-23T12:00:00Z", - "name": "london_across_dst_end", - "next": [ - "2026-10-23T23:00:00Z", - "2026-10-24T23:00:00Z", - "2026-10-26T00:00:00Z", - "2026-10-27T00:00:00Z", - "2026-10-28T00:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 0 0 * * *", - "from": "2026-09-03T12:00:00Z", - "name": "santiago_skipped_midnight", - "next": [ - "2026-09-04T04:00:00Z", - "2026-09-05T04:00:00Z", - "2026-09-07T03:00:00Z", - "2026-09-08T03:00:00Z", - "2026-09-09T03:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 0 12 * * *", - "from": "2026-09-03T12:00:00Z", - "name": "santiago_day_after_skipped_midnight", - "next": [ - "2026-09-03T16:00:00Z", - "2026-09-04T16:00:00Z", - "2026-09-05T16:00:00Z", - "2026-09-06T15:00:00Z", - "2026-09-07T15:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 30 23 * * *", - "from": "2026-04-02T12:00:00Z", - "name": "santiago_repeated_hour_before_midnight", - "next": [ - "2026-04-03T02:30:00Z", - "2026-04-04T02:30:00Z", - "2026-04-05T02:30:00Z", - "2026-04-05T03:30:00Z", - "2026-04-06T03:30:00Z" - ] - }, - { - "expression": "TZ=Asia/Kolkata 0 9 * * mon", - "from": "2026-01-02T03:04:05-05:00", - "name": "kolkata_tz_prefix", - "next": [ - "2026-01-04T22:30:00-05:00", - "2026-01-11T22:30:00-05:00", - "2026-01-18T22:30:00-05:00", - "2026-01-25T22:30:00-05:00", - "2026-02-01T22:30:00-05:00" - ] - } - ], - "snooze_counters": [ - { - "expected_snoozes": 1, - "metadata": {}, - "name": "absent" - }, - { - "expected_snoozes": 3, - "metadata": { - "snoozes": 2 - }, - "name": "integer" - }, - { - "expected_snoozes": 3, - "metadata": { - "snoozes": 2.9 - }, - "name": "fraction_truncates" - }, - { - "expected_snoozes": -1, - "metadata": { - "snoozes": -2.5 - }, - "name": "negative_fraction_truncates_toward_zero" - }, - { - "expected_snoozes": 1001, - "metadata": { - "snoozes": 1e3 - }, - "name": "exponent" - }, - { - "expected_snoozes": 9007199254740994, - "metadata": { - "snoozes": 9007199254740993 - }, - "name": "beyond_float_precision" - }, - { - "expected_snoozes": 5, - "metadata": { - "snoozes": "4" - }, - "name": "numeric_string" - }, - { - "expected_snoozes": -6, - "metadata": { - "snoozes": "-7" - }, - "name": "negative_numeric_string" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": "4.5" - }, - "name": "fractional_string_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": " 5" - }, - "name": "padded_string_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": "abc" - }, - "name": "non_numeric_string_is_zero" - }, - { - "expected_snoozes": 2, - "metadata": { - "snoozes": true - }, - "name": "true_is_one" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": false - }, - "name": "false_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": null - }, - "name": "null_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": [ - 3 - ] - }, - "name": "array_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": { - "count": 3 - } - }, - "name": "object_is_zero" - } - ] -} diff --git a/rust/riverqueue/tests/fixtures/protocol_values.json b/rust/riverqueue/tests/fixtures/protocol_values.json deleted file mode 100644 index 15fb9a779..000000000 --- a/rust/riverqueue/tests/fixtures/protocol_values.json +++ /dev/null @@ -1,317 +0,0 @@ -{ - "attempt_error": { - "at": "2026-01-02T03:04:05.6789Z", - "attempt": 3, - "error": "worker failed: escaped \"detail\"", - "trace": "frame one\nframe two" - }, - "job_states": [ - { - "state": "available", - "unique_bit": 1 - }, - { - "state": "cancelled", - "unique_bit": 2 - }, - { - "state": "completed", - "unique_bit": 4 - }, - { - "state": "discarded", - "unique_bit": 8 - }, - { - "state": "pending", - "unique_bit": 16 - }, - { - "state": "retryable", - "unique_bit": 32 - }, - { - "state": "running", - "unique_bit": 64 - }, - { - "state": "scheduled", - "unique_bit": 128 - } - ], - "metadata_keys": { - "output": "output", - "periodic_job_id": "river:periodic_job_id", - "rescue_count": "river:rescue_count", - "resumable_cursor": "river:resumable_cursor", - "resumable_step": "river:resumable_step", - "unique_nonce": "river:unique_nonce" - }, - "notifications": [ - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "job_id", - "omitempty": true - }, - { - "name": "metadata", - "omitempty": true - }, - { - "name": "queue", - "omitempty": false - } - ], - "name": "cancel", - "payload": { - "action": "cancel", - "job_id": 42, - "queue": "priority" - }, - "source": "producer.go:controlEventPayload; riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobCancel", - "topic": "river_control" - }, - { - "fields": [ - { - "name": "queue", - "omitempty": false - } - ], - "name": "insert", - "payload": { - "queue": "priority" - }, - "source": "producer.go:insertPayload", - "topic": "river_insert" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "job_id", - "omitempty": true - }, - { - "name": "metadata", - "omitempty": true - }, - { - "name": "queue", - "omitempty": false - } - ], - "name": "metadata_changed", - "payload": { - "action": "metadata_changed", - "metadata": { - "owner": "candidate" - }, - "queue": "priority" - }, - "source": "producer.go:controlEventPayload", - "topic": "river_control" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "job_id", - "omitempty": true - }, - { - "name": "metadata", - "omitempty": true - }, - { - "name": "queue", - "omitempty": false - } - ], - "name": "pause", - "payload": { - "action": "pause", - "queue": "priority" - }, - "source": "producer.go:controlEventPayload", - "topic": "river_control" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "leader_id", - "omitempty": false - } - ], - "name": "request_resign", - "payload": { - "action": "request_resign", - "leader_id": "" - }, - "source": "internal/leadership/elector.go:DBNotification", - "topic": "river_leadership" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "leader_id", - "omitempty": false - } - ], - "name": "resigned", - "payload": { - "action": "resigned", - "leader_id": "client-1" - }, - "source": "internal/leadership/elector.go:DBNotification; riverdriver/riverpgxv5/internal/dbsqlc/river_leader.sql:LeaderResign", - "topic": "river_leadership" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "job_id", - "omitempty": true - }, - { - "name": "metadata", - "omitempty": true - }, - { - "name": "queue", - "omitempty": false - } - ], - "name": "resume", - "payload": { - "action": "resume", - "queue": "priority" - }, - "source": "producer.go:controlEventPayload", - "topic": "river_control" - } - ], - "reserved_metadata_keys": [ - { - "applicability": "protocol_visible", - "key": "cancel_attempted_at" - }, - { - "applicability": "protocol_visible", - "key": "output" - }, - { - "applicability": "protocol_visible", - "key": "periodic" - }, - { - "applicability": "protocol_visible", - "key": "river:log" - }, - { - "applicability": "protocol_visible", - "key": "river:periodic_job_id" - }, - { - "applicability": "protocol_visible", - "key": "river:rescue_count" - }, - { - "applicability": "protocol_visible", - "key": "river:resumable_cursor" - }, - { - "applicability": "protocol_visible", - "key": "river:resumable_step" - }, - { - "applicability": "protocol_visible", - "key": "river:unique_nonce" - }, - { - "applicability": "protocol_visible", - "key": "snoozes" - }, - { - "applicability": "protocol_visible", - "key": "unique_key_conflict" - } - ], - "retry_cases": [ - { - "error_count": 1, - "job_id": 42, - "max_delay_ns": 1100000000, - "min_delay_ns": 900000000, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 0 - }, - { - "error_count": 2, - "job_id": 42, - "max_delay_ns": 17600000000, - "min_delay_ns": 14400000000, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 123 - }, - { - "error_count": 3, - "job_id": 9007199254740991, - "max_delay_ns": 89100000000, - "min_delay_ns": 72900000000, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 18446744073709551615 - }, - { - "error_count": 11, - "job_id": 1, - "max_delay_ns": 16105100000000, - "min_delay_ns": 13176900000000, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 456 - }, - { - "error_count": 309, - "job_id": 42, - "max_delay_ns": 9223372036854775807, - "min_delay_ns": 8204959224899999744, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 789 - }, - { - "error_count": 310, - "job_id": 42, - "max_delay_ns": 9223372036854775807, - "min_delay_ns": 9223372036854775807, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 123 - } - ], - "topics": { - "control": "river_control", - "insert": "river_insert", - "leadership": "river_leadership" - } -} diff --git a/rust/riverqueue/tests/fixtures/unique_keys.json b/rust/riverqueue/tests/fixtures/unique_keys.json deleted file mode 100644 index 066f4d86e..000000000 --- a/rust/riverqueue/tests/fixtures/unique_keys.json +++ /dev/null @@ -1,948 +0,0 @@ -{ - "cases": [ - { - "args": {}, - "expected_sha256": "23aa86692d9807ab10e433e378f1c0804573f5e345818461b919322dd381b4c3", - "expected_state_mask": 245, - "kind": "conformance_selected_args", - "name": "all_selected_fields_omitted", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "account", - "id" - ], - [ - "account", - "region" - ], - [ - "label" - ], - [ - "path/key" - ] - ], - "selected_unique_paths": [ - "account.id", - "account.region", - "label", - "path/key" - ] - }, - { - "args": { - "account": { - "id": "acct", - "ignored": "irrelevant", - "region": "west" - }, - "path/key": "slash" - }, - "expected_sha256": "7d62e81ac25cfa2dec69ad5a41e0b78188ee1b299bed329b453da6b3abca70bd", - "expected_state_mask": 245, - "kind": "conformance_selected_args", - "name": "selected_siblings_and_slash_key", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "account", - "id" - ], - [ - "account", - "region" - ], - [ - "label" - ], - [ - "path/key" - ] - ], - "selected_unique_paths": [ - "account.id", - "account.region", - "label", - "path/key" - ] - }, - { - "args": { - "nested": { - "z": 1, - "a": 2 - } - }, - "expected_sha256": "46ff499cb031d0458bb00ef87e7b83321eefb6e7534497c279c4ae0f474cdde0", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "nested_struct_wire_order", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "zeta": "quoted \\\"value\\\" and \\\\ slash", - "alpha": "\u003calpha\u003e\u0026\u2028line", - "maximum": 9007199254740991 - }, - "expected_sha256": "7a84c62c8d470ca388a0a1e41c311b9eb1ea21f7b88157ceb876fc82e698b6af", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_sorted_and_escaped", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "2": 2, - "10": 10, - "zero": -0, - "😀": 1, - "": 2 - }, - "expected_sha256": "fcdf33e0c39c1fc7e956876345a985f2418bd69c6e4d6a5c794abf1e78cdfdb6", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "map_order_and_negative_zero", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "": 0, - "a.b": 1, - "@x": 2, - ":lead": 3, - "!bang": 4, - "[open": 5, - "{brace": 6, - "a\\b": 7 - }, - "expected_sha256": "1d254dda1efe1009ffb205ede791d481545d66abb8542e85f0d895415b05cdf9", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_literal_path_syntax", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "a\"b": 1, - "line\n": 2, - "é": 3, - "a\u003cb": 4, - "a\u0026b": 5, - "a\u2028b": 6 - }, - "expected_sha256": "bab84635792449d758b18950f6f014bfb16d26430e798d84afee9fc9cb793163", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_escaped_key_encoding", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": [], - "expected_sha256": "fe05a58ddb79a8d4544da962582d9a290d59788c920afd3597da3a62e3c1b0ac", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_empty_array", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": [ - 1 - ], - "expected_error": "rejected", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_array_rejected", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": null, - "expected_error": "rejected", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_null_rejected", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": "args", - "expected_error": "rejected", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_scalar_rejected", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "exponent": 1e+100, - "fraction": 1.25, - "maximum": 9223372036854775807, - "minimum": -9223372036854775808, - "unsigned_maximum": 18446744073709551615 - }, - "expected_sha256": "2c1533b3ab43068407d14e82ddb34a295a51375ae3a27fef6931123f07677f38", - "expected_state_mask": 245, - "kind": "conformance_numeric_boundaries", - "name": "numeric_boundaries", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "account": { - "id": "acct-123", - "ignored": "not selected" - }, - "ignored": true, - "label": "selected" - }, - "expected_sha256": "6130dc4f753402d1faeb6bbc3e6c21415245bb282ad1fd16bcbfeebde525e726", - "expected_state_mask": 245, - "kind": "conformance_selected_args", - "name": "selected_nested_args", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "account", - "id" - ], - [ - "account", - "region" - ], - [ - "label" - ], - [ - "path/key" - ] - ], - "selected_unique_paths": [ - "account.id", - "account.region", - "label", - "path/key" - ] - }, - { - "args": { - "user.id": "literal", - "user": {} - }, - "expected_sha256": "7d478fa6978b3fbb5c326d90fd10c2eab663cf2c9c2e1e3ddcce032989a05cdb", - "expected_state_mask": 245, - "kind": "conformance_dotted_selected_args", - "name": "selected_literal_dotted_name", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "@user" - ], - [ - "!x" - ], - [ - "{x}" - ], - [ - "[x]" - ], - [ - ":id" - ], - [ - "user", - "id" - ], - [ - "user.id" - ], - [ - "a*b?c#d|e" - ], - [ - "é" - ] - ], - "selected_unique_paths": [ - "\\@user", - "\\!x", - "\\{x\\}", - "\\[x\\]", - "\\:id", - "user.id", - "user\\.id", - "a\\*b\\?c\\#d\\|e", - "é" - ] - }, - { - "args": { - "user": { - "id": "nested" - } - }, - "expected_sha256": "6fd34aa5a46274e4f7d159063be43c2c02e90438b84dab208c8c5209f94dda25", - "expected_state_mask": 245, - "kind": "conformance_dotted_selected_args", - "name": "selected_nested_dotted_path", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "@user" - ], - [ - "!x" - ], - [ - "{x}" - ], - [ - "[x]" - ], - [ - ":id" - ], - [ - "user", - "id" - ], - [ - "user.id" - ], - [ - "a*b?c#d|e" - ], - [ - "é" - ] - ], - "selected_unique_paths": [ - "\\@user", - "\\!x", - "\\{x\\}", - "\\[x\\]", - "\\:id", - "user.id", - "user\\.id", - "a\\*b\\?c\\#d\\|e", - "é" - ] - }, - { - "args": { - "user": {}, - "é": "café" - }, - "expected_sha256": "28513f484784e6b0fe8aed6cc1fadb04498f43305b74619aa56e701a2feff578", - "expected_state_mask": 245, - "kind": "conformance_dotted_selected_args", - "name": "selected_unicode_field_name", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "@user" - ], - [ - "!x" - ], - [ - "{x}" - ], - [ - "[x]" - ], - [ - ":id" - ], - [ - "user", - "id" - ], - [ - "user.id" - ], - [ - "a*b?c#d|e" - ], - [ - "é" - ] - ], - "selected_unique_paths": [ - "\\@user", - "\\!x", - "\\{x\\}", - "\\[x\\]", - "\\:id", - "user.id", - "user\\.id", - "a\\*b\\?c\\#d\\|e", - "é" - ] - }, - { - "args": { - "@user": "at", - "!x": "bang", - "{x}": "brace", - "[x]": "bracket", - ":id": "colon", - "a*b?c#d|e": "symbols", - "user": {} - }, - "expected_sha256": "d00ff085218024d7059a4556b24af92cbe57744935e2d37ff644b149fae4c2f3", - "expected_state_mask": 245, - "kind": "conformance_dotted_selected_args", - "name": "selected_punctuation_field_names", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "@user" - ], - [ - "!x" - ], - [ - "{x}" - ], - [ - "[x]" - ], - [ - ":id" - ], - [ - "user", - "id" - ], - [ - "user.id" - ], - [ - "a*b?c#d|e" - ], - [ - "é" - ] - ], - "selected_unique_paths": [ - "\\@user", - "\\!x", - "\\{x\\}", - "\\[x\\]", - "\\:id", - "user.id", - "user\\.id", - "a\\*b\\?c\\#d\\|e", - "é" - ] - }, - { - "args": { - "empty": [], - "labels": { - "alpha": "first", - "k10": "ten", - "k2": "two", - "zulu": "last" - }, - "matrix": [ - [ - 3, - 1 - ], - [], - [ - 2 - ] - ], - "missing": null, - "objects": [ - { - "zulu": "z", - "alpha": 1 - }, - { - "zulu": "y", - "alpha": null - } - ], - "pointer": null - }, - "expected_sha256": "66d457888b4b71f0a0041251f75d494e1ac543717283d84633a274648559306d", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_collections_and_nulls", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": {}, - "expected_sha256": "fe05a58ddb79a8d4544da962582d9a290d59788c920afd3597da3a62e3c1b0ac", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_empty_args", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "a\u003cb\u003e": "\u003cangle\u003e", - "controls": "\b\f\n\r\t\u0000\u0001\u001f", - "html": "\u003ca href=\"x\"\u003e\u0026amp;\u003c/a\u003e", - "keys": { - "\u003ck\u003e": 1, - "a\u0026b": 2, - "é": 3, - "é\u003c": 4 - }, - "separators": "line\u2028paragraph\u2029end", - "unicode": "é😀/\\", - "é\u0026": "unicode key" - }, - "expected_sha256": "a4c2a164225cba2e3ae56edbc11258e49a7325d73a9b70e5c9b47a51963d00c5", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_escaping", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "label": null - }, - "expected_sha256": "d137d7c4f1e3f8369037b1890357655b4fa1978a329bcf51435f9f0b54abcab7", - "expected_state_mask": 245, - "kind": "conformance_selected_args", - "name": "selected_explicit_null", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "account", - "id" - ], - [ - "account", - "region" - ], - [ - "label" - ], - [ - "path/key" - ] - ], - "selected_unique_paths": [ - "account.id", - "account.region", - "label", - "path/key" - ] - }, - { - "args": { - "fraction": "2026-01-02T03:04:05.5Z", - "micros": "2026-01-02T03:04:05.123456Z", - "millis": "2026-01-02T03:04:05.12Z", - "whole": "2026-01-02T03:04:05Z" - }, - "expected_sha256": "18b38780de3019cc75d49ff24a74eecdcff99fda0bb36c0244a35f262b15322b", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_time_values", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "below_large": 999999999999999900000, - "large": 100000000000000000000, - "large_boundary": 1e+21, - "largest": 1.7976931348623157e+308, - "negative": -1.5e-9, - "negative_zero": -0, - "one": 1, - "single": 1.1, - "single_large": 1e+21, - "single_small": 1e-7, - "small": 1e-7, - "small_boundary": 0.000001, - "smallest": 5e-324, - "tenth": 0.1 - }, - "expected_sha256": "dc330477ebe8bf2bc3402476365fcfe22c82253f744481c49a23f760f059cad4", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_float_formatting", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "5396f06a082abd7a929915135ebd363a9a47d800176b03ce7736f93a5ba9e22e", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "period_from_now", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": false, - "by_period_nanos": 5400000000000, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "b7f3c49952996b760b8b3ff6cf48f426e03a6ef0f004fb6faa51725365cf309a", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "period_from_schedule", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": false, - "by_period_nanos": 3600000000000, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": "2026-01-02T05:21:05.6789Z", - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "5396f06a082abd7a929915135ebd363a9a47d800176b03ce7736f93a5ba9e22e", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "period_from_non_utc_now", - "now": "2026-01-01T22:04:05.6789-05:00", - "options": { - "by_args": false, - "by_period_nanos": 3600000000000, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "b7f3c49952996b760b8b3ff6cf48f426e03a6ef0f004fb6faa51725365cf309a", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "period_from_non_utc_schedule", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": false, - "by_period_nanos": 3600000000000, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": "2026-01-02T10:51:05.6789+05:30", - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "d20ce47da8e8015e68b020bbca2b17494139519ae05f8985e5992db4d8dd8a09", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "queue_without_kind", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": false, - "by_period_nanos": 0, - "by_queue": true, - "exclude_kind": true - }, - "queue": "priority_emails", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "6f20262e7b1fa9beaf98255f23030800376636484a98b20c2c04a303bab5a8d5", - "expected_state_mask": 213, - "kind": "conformance_simple", - "name": "all_dimensions_custom_states", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 60000000000, - "by_queue": true, - "by_state": [ - "available", - "completed", - "pending", - "running", - "scheduled" - ], - "exclude_kind": false - }, - "queue": "priority_emails", - "scheduled_at": "2026-01-02T05:21:05.6789Z", - "selected_unique_paths": null - } - ], - "typed_only_cases": [ - { - "args": { - "a": 1, - "a": 2, - "b": 3 - }, - "expected_sha256": "f3568e94e18a68ce633bd444fe9ec448382740bcf02b83b210cb68e74220fb5f", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_duplicate_top_level_keys", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "empty": [], - "labels": { - "10": "ten", - "2": "two", - "alpha": "first", - "zulu": "last" - }, - "matrix": [], - "missing": null, - "objects": [], - "pointer": null - }, - "expected_sha256": "38406019aea1ea67f81186d845e983b197b2d919ded5eca871e4fef0c8b0ad30", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_integer_like_map_keys", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - } - ] -} diff --git a/rust/riverqueue/tests/protocol_fixtures.rs b/rust/riverqueue/tests/protocol_fixtures.rs index 82f645aa5..d58679a47 100644 --- a/rust/riverqueue/tests/protocol_fixtures.rs +++ b/rust/riverqueue/tests/protocol_fixtures.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{io::ErrorKind, path::Path, time::Duration}; use chrono::{DateTime, Utc}; use riverqueue::{ @@ -18,18 +18,10 @@ struct Fixture { attempt_error: AttemptError, job_states: Vec, metadata_keys: Map, - notifications: Vec, retry_cases: Vec, topics: Map, } -#[derive(Deserialize)] -struct NotificationFixture { - name: String, - payload: Map, - topic: String, -} - #[derive(Deserialize)] struct RetryFixture { error_count: usize, @@ -46,10 +38,28 @@ struct StateFixture { unique_bit: u8, } +/// Reads `name` from `conformance/testdata`, where `make generate/fixtures` +/// writes fixtures produced by River's Go implementation. A missing fixture +/// fails the test rather than skipping it. +fn read_fixture(name: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../conformance/testdata") + .join(name); + std::fs::read_to_string(&path).unwrap_or_else(|error| match error.kind() { + ErrorKind::NotFound => panic!( + "missing conformance fixture {}; run `make generate/fixtures` from the repository root", + path.display() + ), + _ => panic!( + "error reading conformance fixture {}: {error}", + path.display() + ), + }) +} + #[test] fn go_protocol_values_match_rust() { - let fixture: Fixture = - serde_json::from_str(include_str!("fixtures/protocol_values.json")).unwrap(); + let fixture: Fixture = serde_json::from_str(&read_fixture("protocol_values.json")).unwrap(); assert_eq!(fixture.attempt_error.attempt, 3); assert!(fixture.attempt_error.error.contains("escaped")); @@ -70,19 +80,6 @@ fn go_protocol_values_match_rust() { assert_eq!(fixture.topics["control"], NOTIFICATION_TOPIC_CONTROL); assert_eq!(fixture.topics["insert"], NOTIFICATION_TOPIC_INSERT); assert_eq!(fixture.topics["leadership"], NOTIFICATION_TOPIC_LEADERSHIP); - for notification in fixture.notifications { - assert_ne!(notification.name, ""); - assert!(notification.payload.contains_key("action") || notification.name == "insert"); - assert!( - [ - NOTIFICATION_TOPIC_CONTROL, - NOTIFICATION_TOPIC_INSERT, - NOTIFICATION_TOPIC_LEADERSHIP, - ] - .contains(¬ification.topic.as_str()) - ); - } - for test_case in fixture.retry_cases { let row = retry_row(test_case.job_id, test_case.now, test_case.error_count - 1); let delay = DefaultRetryPolicy::with_seed(test_case.seed).next_retry(