diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index 4b2abb030..2a78115c2 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -84,3 +84,11 @@ jobs: - name: Java fixture tests run: make test/java/conformance + + - uses: ruby/setup-ruby@v1 + with: + working-directory: ruby + bundler-cache: true + + - name: Ruby fixture tests + run: make test/ruby/conformance diff --git a/.github/workflows/ruby.yaml b/.github/workflows/ruby.yaml new file mode 100644 index 000000000..baa307091 --- /dev/null +++ b/.github/workflows/ruby.yaml @@ -0,0 +1,209 @@ +name: Ruby + +# Filter the workflow so unrelated changes create no skipped matrix jobs. +# Keep both events' paths in sync. +on: + push: + branches: [master] + paths: + - ".github/workflows/ruby.yaml" + - "Makefile" + - "conformance/**" + - "riverdriver/**/*.sql" + - "ruby/**" + tags: ["riverqueue-ruby-v*"] + pull_request: + paths: + - ".github/workflows/ruby.yaml" + - "Makefile" + - "conformance/**" + - "riverdriver/**/*.sql" + - "ruby/**" + +concurrency: + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +defaults: + run: + working-directory: ruby + +jobs: + checks: + # Check coverage from the two backends without adding another CI job. + needs: [test, test_sqlite] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + working-directory: ruby + bundler-cache: true + - run: bundle exec standardrb + - run: make frozen-string-literals type-check verify build + - uses: actions/download-artifact@v8 + with: + pattern: ruby-coverage-* + path: ruby/coverage/ci + - run: bundle exec ruby scripts/check_coverage.rb + + test: + name: test (${{ matrix.ruby }}, ${{ matrix.postgres }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + # Latest Ruby against all supported PostgreSQL versions. + - ruby: "4.0" + postgres: 18 + primary: true + - ruby: "4.0" + postgres: 17 + - ruby: "4.0" + postgres: 16 + - ruby: "4.0" + postgres: 15 + - ruby: "4.0" + postgres: 14 + + # Older Ruby versions against the latest PostgreSQL only. + - ruby: "3.4" + postgres: 18 + - ruby: "3.3" + postgres: 18 + - ruby: "3.2" + postgres: 18 + env: + RIVER_REQUIRE_DATABASES: "1" + TEST_DATABASE: postgres + TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/river_test?sslmode=disable + services: + postgres: + image: postgres:${{ matrix.postgres }} + env: + POSTGRES_DB: river_test + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 2s + --health-timeout 5s + --health-retries 5 + ports: ["5432:5432"] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + working-directory: ruby + bundler-cache: true + - uses: actions/setup-go@v7 + with: + go-version-file: go.work + - run: make generate/fixtures + - run: make spec/core + - run: bundle install + working-directory: ruby/driver/riverqueue-activerecord + - run: make spec/activerecord + - run: bundle install + working-directory: ruby/driver/riverqueue-sequel + - run: make spec/sequel + - uses: actions/upload-artifact@v7 + if: matrix.primary + with: + name: ruby-coverage-postgres + path: ruby/**/coverage/.resultset.json + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + + test_sqlite: + name: test_sqlite (4.0) + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + RIVER_REQUIRE_DATABASES: "1" + TEST_DATABASE: sqlite + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0" + working-directory: ruby + bundler-cache: true + - uses: actions/setup-go@v7 + with: + go-version-file: go.work + - run: make generate/fixtures + - run: make spec/core + - run: bundle install + working-directory: ruby/driver/riverqueue-activerecord + - run: make spec/activerecord + - run: bundle install + working-directory: ruby/driver/riverqueue-sequel + - run: make spec/sequel + - uses: actions/upload-artifact@v7 + with: + name: ruby-coverage-sqlite + path: ruby/**/coverage/.resultset.json + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + + rails: + name: rails (4.0) + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + BUNDLE_FROZEN: "false" + RIVER_REQUIRE_DATABASES: "1" + TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/river_test?sslmode=disable + services: + postgres: + image: postgres:18 + env: + POSTGRES_DB: river_test + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 2s + --health-timeout 5s + --health-retries 5 + ports: ["5432:5432"] + defaults: + run: + working-directory: ruby/rails/riverqueue-rails + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0" + working-directory: ruby/rails/riverqueue-rails + # Reuse the runner, PostgreSQL service, and installed gems across versions. + - name: Test Rails 7.2 + env: + RAILS_VERSION: "~> 7.2.0" + run: bundle install && bundle exec rspec + - name: Test Rails 8.0 + if: success() || failure() + env: + RAILS_VERSION: "~> 8.0.0" + run: bundle install && bundle exec rspec + - name: Test Rails 8.1 + if: success() || failure() + env: + RAILS_VERSION: "~> 8.1.0" + run: bundle install && bundle exec rspec diff --git a/Makefile b/Makefile index 4cfeb5593..e9f131d41 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,7 @@ generate: generate/fixtures generate: generate/java-migrations generate: generate/js-migrations generate: generate/migrations +generate: generate/ruby-migrations generate: generate/rust-migrations generate: generate/sqlc @@ -363,6 +364,7 @@ verify: ## Verify generated artifacts verify: verify/java-migrations verify: verify/js-migrations verify: verify/migrations +verify: verify/ruby-migrations verify: verify/rust-migrations verify: verify/sqlc @@ -387,3 +389,28 @@ verify/sqlc: ## Verify generated sqlc cd riverdriver/riverdatabasesql/internal/dbsqlc && $(SQLC) diff cd riverdriver/riverpgxv5/internal/dbsqlc && $(SQLC) diff cd riverdriver/riversqlite/internal/dbsqlc && $(SQLC) diff + +# Ruby targets are separate from Go's default targets. Install gems with +# `make -C ruby install` first. +.PHONY: build/ruby lint/ruby test/ruby test/ruby/conformance typecheck/ruby +build/ruby: ## Build the Ruby gems without publishing + $(MAKE) -C ruby build + +lint/ruby: ## Run Ruby style checks + $(MAKE) -C ruby lint + +test/ruby: ## Run Ruby core, Active Record, Sequel, and Rails tests + $(MAKE) -C ruby test + +test/ruby/conformance: ## Run Ruby tests that check Go-generated conformance fixtures + $(MAKE) -C ruby test/conformance + +typecheck/ruby: ## Check Ruby types with Steep + $(MAKE) -C ruby type-check + +.PHONY: generate/ruby-migrations verify/ruby-migrations +generate/ruby-migrations: ## Sync database migrations to Ruby + ruby ruby/scripts/sync_migrations.rb + +verify/ruby-migrations: ## Verify Ruby migrations match the canonical migrations + ruby ruby/scripts/sync_migrations.rb --check diff --git a/conformance/cmd/checkmodzip/main.go b/conformance/cmd/checkmodzip/main.go index a002d83ff..8e9b13085 100644 --- a/conformance/cmd/checkmodzip/main.go +++ b/conformance/cmd/checkmodzip/main.go @@ -27,8 +27,8 @@ const conformanceModulePath = "github.com/riverqueue/river/conformance" // disallowedPathPattern matches paths, relative to a module's root, that must // never be published: fixture and testdata directories, JSON files, and the -// conformance, Java, JavaScript, and Rust trees. -var disallowedPathPattern = regexp.MustCompile(`(^|/)(fixtures?|testdata)/|\.json$|^(conformance|java|js|rust)/`) +// conformance, Java, JavaScript, Ruby, and Rust trees. +var disallowedPathPattern = regexp.MustCompile(`(^|/)(fixtures?|testdata)/|\.json$|^(conformance|java|js|ruby|rust)/`) func main() { if len(os.Args) != 2 { diff --git a/docs/development.md b/docs/development.md index b3df5b714..cf50d1256 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,8 @@ # River development +The [Ruby](../ruby/), [Rust](../rust/), and [JavaScript](../js/) ports have +separate development instructions and `make test/` targets. + ## Run tests Raise the test database: diff --git a/ruby/.gitignore b/ruby/.gitignore new file mode 100644 index 000000000..7db4e0529 --- /dev/null +++ b/ruby/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.gem +coverage/ +.bundle/ +vendor/bundle/ diff --git a/ruby/.rubocop-frozen-string-literal.yaml b/ruby/.rubocop-frozen-string-literal.yaml new file mode 100644 index 000000000..9b58f7fa0 --- /dev/null +++ b/ruby/.rubocop-frozen-string-literal.yaml @@ -0,0 +1,10 @@ +AllCops: + NewCops: disable + Exclude: + - ".ruby-lsp/**/*" + - "**/coverage/**/*" + - "**/vendor/**/*" + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always diff --git a/ruby/.tool-versions b/ruby/.tool-versions new file mode 100644 index 000000000..b77860ba9 --- /dev/null +++ b/ruby/.tool-versions @@ -0,0 +1 @@ +ruby 4.0 diff --git a/ruby/CHANGELOG.md b/ruby/CHANGELOG.md new file mode 100644 index 000000000..3cb39c8d4 --- /dev/null +++ b/ruby/CHANGELOG.md @@ -0,0 +1,172 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Expanded checks against Go-generated conformance fixtures to cover cron schedules, snooze counters, all shared metadata keys, and queue-control and leadership notification emission through both SQL drivers. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Added `CRON_TZ=` / `TZ=` prefixes, `?` wildcards, and Go-style `@every` durations to `PeriodicCron`. [PR #1463](https://github.com/riverqueue/river/pull/1463). + +### Fixed + +- Externally claimed jobs honor worker retry hooks, including Active Job's retry policy, and use their claim timestamps for queue-wait statistics and attempt errors. Worker initialization failures are logged without losing the reported work error. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Externally reported cancellation, snooze, and interruption signals follow normal worker finalization instead of being recorded as ordinary failures. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Graceful shutdown and queue removal continue observing remote cancellation until active jobs finish, including when worker timeouts are disabled or cancellation polling temporarily fails. Draining waits respect polling intervals and wake when the last attempt finishes. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Blocking lifecycle calls from the client's own runtime threads fail immediately instead of waiting for themselves. Workers can still request nonblocking stop. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Inserts reject states other than `available`, `pending`, and `scheduled`, preventing stranded `running` jobs without attempt timestamps. Insertion hooks are checked after all callbacks run, and invalid batches roll back hook writes. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- The Sequel driver preserves application `ArgumentError` exceptions raised inside SQLite transactions after rolling back, including validation errors from insertion hooks. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Job inserts and updates reject attempt limits above 32,767 consistently across databases. Attempt updates and manual retries enforce the portable counter range, and claims tolerate counters already at the limit. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Wildcard queue pause and resume publish events for every affected queue, including beyond 100 queues. Events use the update's snapshots so concurrent changes cannot replace their contents or introduce unrelated queues. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Manually retrying a cancelled job clears the old cancellation request so its next attempt can complete. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Cancellation wins atomically over discarded attempts and other state transitions, including the last attempt or a worker that disables retries. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Stuck-job rescue falls back to the default retry policy per job when an application policy fails or returns an invalid time, allowing recovery and cleanup to continue. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Clients without consumer queues run configured periodic jobs and maintenance services. Adding periodic jobs starts maintenance when needed; registration is rejected when leader election is disabled. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Bulk deletion excludes running jobs before applying its limit and locks eligible PostgreSQL rows so concurrent retries cannot cause deletion of jobs that no longer match the filters. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Stuck-job rescue respects longer or disabled client and worker timeouts and scans past protected attempts without consuming the rescue limit. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Snoozing and rescue tolerate JSON counters that overflow SQLite's floating-point representation. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Snoozing tolerates nonnumeric metadata counters instead of failing on booleans or collections. Ruby-specific recovery cases are tested independently of the shared Go fixtures. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Periodic jobs now carry `periodic: true` and, when named, `river:periodic_job_id`, while preserving constructor options and application metadata. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Queue pause, resume, metadata changes, and leader resignation now broadcast notifications for clients in other languages. Notifications commit and roll back with the corresponding database change. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Cron schedules include both occurrences of repeated daylight-saving times when given a local reference time. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Job insertion rejects invalid queue names, nonpositive attempt limits, and nonzero uniqueness periods shorter than one second before writing any jobs. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Custom maintenance service failures are logged without blocking other services, stuck-job rescue, or cleanup. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Job updates reject non-object metadata before writing to the database, preserving the existing job when invalid metadata is supplied. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Job completion atomically checks for cancellation so a concurrent cancellation cannot be overwritten by completion, including when finalization hooks are configured. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Job updates reject nonpositive attempt limits consistently on PostgreSQL and SQLite. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Job lifecycle events report worker execution and completion durations separately using a monotonic clock. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Finalization hooks that delete jobs honor concurrent cancellation and record the cancelled attempt instead of deleting it. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Queue metadata writes and job metadata merges reject non-object values before changing persisted data. Job updates also reject non-string worker IDs in attempt histories. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Unique inserts with `exclude_kind` preserve the existing job's kind when a different kind conflicts, on both databases and through both drivers. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Retrying, snoozing, and interrupting jobs preserve the original queue-wait duration in lifecycle events. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Invalid rescue counters no longer prevent other stuck jobs in the batch from being rescued. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- SQLite retains only the newest 100 worker IDs when claiming a job, matching PostgreSQL. [PR #1463](https://github.com/riverqueue/river/pull/1463). +- Attempt-error serialization supports frozen timestamps and preserves the caller's timezone. [PR #1463](https://github.com/riverqueue/river/pull/1463). + +## [0.12.0] - 2026-10-01 + +### Added + +- Add a full Ruby client for River with Go-compatible job insertion and execution on PostgreSQL, SQLite, and YugabyteDB through ActiveRecord or Sequel. Includes workers, retries, cancellation, periodic and resumable jobs, job-persisted logging, job administration, migration and worker CLIs, and testing helpers. Rails and Active Job integration is available through `riverqueue-rails`, with workflows, batches, sequences, concurrency controls, and other advanced features in the separately distributed `riverqueue-pro` gem. [PR #70](https://github.com/riverqueue/riverqueue-ruby/pull/70). + +## [0.11.0] - 2026-09-02 + +### Added + +- Add SQLite support to the ActiveRecord and Sequel drivers, including current River JSONB storage, atomic bulk inserts, unique jobs, and notification-outbox writes. [PR #67](https://github.com/riverqueue/riverqueue-ruby/pull/67). + +### Changed + +- Stop pulling in `pg` as a hard dependency of either driver. Applications now select their database adapter by including `pg` for PostgreSQL or `sqlite3` for SQLite. [PR #67](https://github.com/riverqueue/riverqueue-ruby/pull/67). + +## [0.10.1] - 2026-04-09 + +### Fixed + +- Fix ActiveRecord JSONB args. [PR #60](https://github.com/riverqueue/riverqueue-ruby/pull/60). + +## [0.10.0] - 2026-03-29 + +### Changed + +- Upgrade to Ruby 4.0. [PR #53](https://github.com/riverqueue/riverqueue-ruby/pull/53). + +## [0.9.1] - 2025-10-21 + +### Changed + +- Minor README fixes. [PR #50](https://github.com/riverqueue/riverqueue-ruby/pull/50). +- Periodic gem update (including some security upgrades in dependencies). [PR #51](https://github.com/riverqueue/riverqueue-ruby/pull/51). + +## [0.9.0] - 2025-04-11 + +### Changed + +- `by_period` uniqueness is now based off a job's `scheduled_at` instead of the current time if it has a value. [PR #39](https://github.com/riverqueue/riverqueue-ruby/pull/39). + +## Fixed + +- Correct some mistakes in the readme that referenced `SimpleArgs` instead of `SortArgs`. [PR #44](https://github.com/riverqueue/riverqueue-ruby/pull/44). + +## [0.8.0] - 2024-12-19 + +⚠️ Version 0.8.0 contains breaking changes to transition to River's new unique jobs implementation and to enable broader, more flexible application of unique jobs. Detailed notes on the implementation are contained in [the original River PR](https://github.com/riverqueue/river/pull/590), and the notes below include short summaries of the ways this impacts this client specifically. + +Users should upgrade backends to River v0.12.0 before upgrading this library in order to ensure a seamless transition of all in-flight jobs. Afterward, the latest River version may be used. + +### Breaking + +- **Breaking change:** The return type of `Client#insert_many` has been changed. Rather than returning just the number of rows inserted, it returns an array of all the `InsertResult` values for each inserted row. Unique conflicts which are skipped as duplicates are indicated in the same fashion as single inserts (the `unique_skipped_as_duplicated` attribute), and in such cases the conflicting row will be returned instead. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). +- **Breaking change:** Unique jobs no longer allow total customization of their states when using the `by_state` option. The pending, scheduled, available, and running states are required whenever customizing this list. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). + +### Added + +- The `UniqueOpts` class gains an `exclude_kind` option for cases where uniqueness needs to be guaranteed across multiple job types. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). +- Unique jobs utilizing `by_args` can now also opt to have a subset of the job's arguments considered for uniqueness. For example, you could choose to consider only the `customer_id` field while ignoring the other fields: + + ```ruby + UniqueOpts.new(by_args: ["customer_id"]) + ``` + + Any fields considered in uniqueness are also sorted alphabetically in order to guarantee a consistent result across implementations, even if the encoded JSON isn't sorted consistently. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). + +### Changed + +- Unique jobs have been improved to allow bulk insertion of unique jobs via `Client#insert_many`. + + This updated implementation is significantly faster due to the removal of advisory locks in favor of an index-backed uniqueness system, while allowing some flexibility in which job states are considered. However, not all states may be removed from consideration when using the `by_state` option; pending, scheduled, available, and running states are required whenever customizing this list. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). + +- Update REXML dependency. [PR #28](https://github.com/riverqueue/riverqueue-ruby/pull/36). + +## [0.7.0] - 2024-08-30 + +### Changed + +- Now compatible with "fast path" unique job insertion that uses a unique index instead of advisory lock and fetch [as introduced in River #451](https://github.com/riverqueue/river/pull/451). [PR #28](https://github.com/riverqueue/riverqueue-ruby/pull/28). + +## [0.6.1] - 2024-08-21 + +### Fixed + +- Fix source files not being correctly included in built Ruby gems. [PR #26](https://github.com/riverqueue/riverqueue-ruby/pull/26). + +## [0.6.0] - 2024-07-06 + +### Changed + +- Advisory lock prefixes are now checked to make sure they fit inside of four bytes. [PR #24](https://github.com/riverqueue/riverqueue-ruby/pull/24). + +## [0.5.0] - 2024-07-05 + +### Changed + +- Tag format is now checked on insert. Tags should be no more than 255 characters and match the regex `/\A[\w][\w\-]+[\w]\z/`. [PR #22](https://github.com/riverqueue/riverqueue-ruby/pull/22). +- Returned jobs now have a `metadata` property. [PR #21](https://github.com/riverqueue/riverqueue-ruby/pull/22). + +## [0.4.0] - 2024-04-28 + +### Changed + +- Implement the FNV (Fowler–Noll–Vo) hashing algorithm in the project and drop dependency on the `fnv-hash` gem. [PR #14](https://github.com/riverqueue/riverqueue-ruby/pull/14). + +## [0.3.0] - 2024-04-27 + +### Added + +- Implement unique job insertion. [PR #10](https://github.com/riverqueue/riverqueue-ruby/pull/10). + +## [0.2.0] - 2024-04-27 + +### Added + +- Implement `#insert_many` for batch job insertion. [PR #5](https://github.com/riverqueue/riverqueue-ruby/pull/5). + +## [0.1.0] - 2024-04-25 + +### Added + +- Initial implementation that supports inserting jobs using either ActiveRecord or Sequel. [PR #1](https://github.com/riverqueue/riverqueue-ruby/pull/1). diff --git a/ruby/Gemfile b/ruby/Gemfile new file mode 100644 index 000000000..494f5a94e --- /dev/null +++ b/ruby/Gemfile @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +group :development, :test do + gem "standard" + gem "steep" +end + +group :test do + gem "debug" + gem "fugit", "~> 1.13", require: false + gem "minitest", require: false + gem "pg" + gem "rspec-core" + gem "rspec-expectations" + gem "riverqueue-activerecord", path: "driver/riverqueue-activerecord" + gem "riverqueue-sequel", path: "driver/riverqueue-sequel" + gem "simplecov", require: false + gem "sqlite3" +end diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock new file mode 100644 index 000000000..c485b7455 --- /dev/null +++ b/ruby/Gemfile.lock @@ -0,0 +1,217 @@ +PATH + remote: . + specs: + riverqueue (0.12.0) + logger (> 0, < 1000) + optparse (> 0, < 1000) + securerandom (> 0, < 1000) + timeout (> 0, < 1000) + +PATH + remote: driver/riverqueue-activerecord + specs: + riverqueue-activerecord (0.12.0) + activerecord (> 0, < 1000) + activesupport (> 0, < 1000) + riverqueue (= 0.12.0) + +PATH + remote: driver/riverqueue-sequel + specs: + riverqueue-sequel (0.12.0) + riverqueue (= 0.12.0) + sequel (> 0, < 1000) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) + timeout (>= 0.4.0) + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.0) + concurrent-ruby (1.3.7) + connection_pool (3.0.2) + csv (3.3.5) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.6.2) + docile (1.4.1) + drb (2.2.3) + erb (6.0.4) + et-orbi (1.4.2) + tzinfo + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + fileutils (1.8.0) + fugit (1.13.0) + et-orbi (~> 1.4) + raabro (~> 1.4) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.3) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + listen (3.10.0) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + mutex_m (0.3.0) + optparse (0.8.1) + parallel (1.27.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-linux) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + raabro (1.5.0) + racc (1.8.1) + rainbow (3.1.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbs (3.10.4) + logger + tsort + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.84.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + sequel (5.102.0) + bigdecimal + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + sqlite3 (2.9.6-arm64-darwin) + sqlite3 (2.9.6-x86_64-linux-gnu) + standard (1.54.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.84.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + steep (1.10.0) + activesupport (>= 5.1) + concurrent-ruby (>= 1.1.10) + csv (>= 3.0.9) + fileutils (>= 1.1.0) + json (>= 2.1.0) + language_server-protocol (>= 3.17.0.4, < 4.0) + listen (~> 3.0) + logger (>= 1.3.0) + mutex_m (>= 0.3.0) + parser (>= 3.1) + rainbow (>= 2.2.2, < 4.0) + rbs (~> 3.9) + securerandom (>= 0.1) + strscan (>= 1.0.0) + terminal-table (>= 2, < 5) + uri (>= 0.12.0) + stringio (3.2.0) + strscan (3.1.7) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + timeout (0.6.1) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + +PLATFORMS + arm64-darwin-25 + x86_64-linux + +DEPENDENCIES + debug + fugit (~> 1.13) + minitest + pg + riverqueue! + riverqueue-activerecord! + riverqueue-sequel! + rspec-core + rspec-expectations + simplecov + sqlite3 + standard + steep + +BUNDLED WITH + 4.0.9 diff --git a/ruby/LICENSE b/ruby/LICENSE new file mode 100644 index 000000000..2f8ed188e --- /dev/null +++ b/ruby/LICENSE @@ -0,0 +1,374 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/ruby/Makefile b/ruby/Makefile new file mode 100644 index 000000000..0b30b2f8c --- /dev/null +++ b/ruby/Makefile @@ -0,0 +1,94 @@ +.DEFAULT_GOAL := help + +TEST_JOBS ?= 4 + +# Conformance fixtures always come from this checkout's Go implementation. +.PHONY: generate/fixtures test/conformance +generate/fixtures: + $(MAKE) -C .. generate/fixtures + +test/conformance: generate/fixtures ## Run the Go-generated fixture checks without PostgreSQL + bundle exec rspec spec/conformance_spec.rb + +.PHONY: test/yugabyte +test/yugabyte: ## Run both SQL adapters against a real Yugabyte database + @test -n "$(YUGABYTE_DATABASE_URL)" || { echo "Set YUGABYTE_DATABASE_URL to a disposable test database"; exit 1; } + cd driver/riverqueue-activerecord && TEST_DATABASE_URL="$(YUGABYTE_DATABASE_URL)" RIVER_YUGABYTE_TEST=1 RIVER_REQUIRE_DATABASES=1 RIVERQUEUE_ROOT_TEST_SUITE=1 bundle exec rspec spec/yugabyte_spec.rb + cd driver/riverqueue-sequel && TEST_DATABASE_URL="$(YUGABYTE_DATABASE_URL)" RIVER_YUGABYTE_TEST=1 RIVER_REQUIRE_DATABASES=1 RIVERQUEUE_ROOT_TEST_SUITE=1 bundle exec rspec spec/yugabyte_spec.rb + +# Looks at comments using ## on targets and uses them to produce a help output. +.PHONY: help +help: ALIGN=14 +help: ## Print this message + @awk -F ': .*## ' -- "/^[^':]+: .*## /"' { printf "'$$(tput bold)'%-$(ALIGN)s'$$(tput sgr0)' %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +.PHONY: install +install: ## Run `bundle install` on gem and all subgems + bundle install + cd driver/riverqueue-activerecord && bundle install + cd driver/riverqueue-sequel && bundle install + cd rails/riverqueue-rails && bundle install + +.PHONY: lint +lint: standardrb frozen-string-literals ## Run linters on gem and all subgems + +.PHONY: rspec +rspec: spec + +.PHONY: spec +spec: generate/fixtures + $(MAKE) -j$(TEST_JOBS) spec/all + +.PHONY: spec/all spec/core spec/activerecord spec/sequel spec/rails +spec/all: spec/core spec/activerecord spec/sequel spec/rails + +spec/core: + bundle exec rspec + +spec/activerecord: + cd driver/riverqueue-activerecord && bundle exec rspec + +spec/sequel: + cd driver/riverqueue-sequel && bundle exec rspec + +spec/rails: + cd rails/riverqueue-rails && bundle exec rspec + +.PHONY: standardrb +standardrb: + bundle exec standardrb --fix + cd driver/riverqueue-activerecord && bundle exec standardrb --fix + cd driver/riverqueue-sequel && bundle exec standardrb --fix + cd rails/riverqueue-rails && bundle exec standardrb --fix + +.PHONY: frozen-string-literals +frozen-string-literals: + bundle exec rubocop --config .rubocop-frozen-string-literal.yaml --only Style/FrozenStringLiteralComment + +.PHONY: steep +steep: + bundle exec steep check + +.PHONY: test +test: spec ## Run test suite (rspec) on gem and all subgems + +.PHONY: type-check +type-check: steep ## Run type check with Steep + +.PHONY: update +update: ## Run `bundle update` on gem and all subgems + bundle update + cd driver/riverqueue-activerecord && bundle update + cd driver/riverqueue-sequel && bundle update + cd rails/riverqueue-rails && bundle update + +.PHONY: verify +verify: ## Verify bundled migrations against the canonical Go migrations + ruby scripts/sync_migrations.rb --check + +.PHONY: build +build: ## Build the four public gems without publishing + gem build riverqueue.gemspec + cd driver/riverqueue-activerecord && gem build riverqueue-activerecord.gemspec + cd driver/riverqueue-sequel && gem build riverqueue-sequel.gemspec + cd rails/riverqueue-rails && gem build riverqueue-rails.gemspec diff --git a/ruby/README.md b/ruby/README.md new file mode 100644 index 000000000..8d9fcefb2 --- /dev/null +++ b/ruby/README.md @@ -0,0 +1,22 @@ +# River for Ruby + +A PostgreSQL and SQLite job queue that shares River's schema with the Go, Rust, +and JavaScript clients. Includes Active Record and Sequel drivers, plus Rails +and Active Job integration. + +- [Usage and configuration](docs/README.md) +- [Development and releases](docs/development.md) +- [Conformance coverage](docs/conformance.md) +- [Migrations](docs/migrations.md) + +From the repository root: + +```sh +make -C ruby install +createdb river_test +RIVER_REQUIRE_DATABASES=1 make test/ruby +make lint/ruby typecheck/ruby +``` + +Ruby 3.2 or later is required. `make test/ruby/conformance` runs only the +Go-generated fixture checks and needs no PostgreSQL server. diff --git a/ruby/Steepfile b/ruby/Steepfile new file mode 100644 index 000000000..646ec8d48 --- /dev/null +++ b/ruby/Steepfile @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +D = Steep::Diagnostic + +target :lib do + check "lib" + + library "digest" + library "json" + library "logger" + library "optparse" + library "securerandom" + library "socket" + library "time" + library "timeout" + + signature "sig" + + configure_code_diagnostics(D::Ruby.strict) +end diff --git a/ruby/docs/README.md b/ruby/docs/README.md new file mode 100644 index 000000000..46a69c45c --- /dev/null +++ b/ruby/docs/README.md @@ -0,0 +1,812 @@ +# River client for Ruby [![Build Status](https://github.com/riverqueue/river/actions/workflows/ruby.yaml/badge.svg)](https://github.com/riverqueue/river/actions/workflows/ruby.yaml) [![Gem Version](https://badge.fury.io/rb/riverqueue.svg)](https://badge.fury.io/rb/riverqueue) + +A Ruby client for [River](https://github.com/riverqueue/river), packaged in the [`riverqueue` gem](https://rubygems.org/gems/riverqueue). It inserts and works jobs using River's canonical database schema and state machine, so Ruby and Go clients can safely share a River database. Separate queues are recommended when each language recognizes different job kinds. + +## Installation + +Moving an existing application? See [Migrating from Sidekiq](migrating_from_sidekiq.md). + +Add one River driver and only the database adapter your application uses. The driver brings in `riverqueue`: + +```ruby +# Sequel +gem "riverqueue-sequel" +gem "pg" # or: gem "sqlite3" + +# Active Record +gem "riverqueue-activerecord" +gem "pg" # or: gem "sqlite3" +``` + +Apply River's canonical migrations before using the client. See [Schema and migrations](#schema-and-migrations). + +Both SQL drivers also support [YugabyteDB](yugabyte.md), automatically detecting +its uniqueness and notification capabilities. + +## Schema and migrations + +Run migrations before inserting jobs or starting workers. The bundled `river` +command uses Go's canonical PostgreSQL and SQLite migrations; no Go installation +is needed. After installing a River driver and its database adapter, create your +application's PostgreSQL database and run: + +```sh +export DATABASE_URL=postgres://localhost/my_app + +bundle exec river migrate-status +bundle exec river migrate-up --dry-run +bundle exec river migrate-up +``` + +`migrate-up` applies all pending migrations and is safe to rerun: already-applied +versions are skipped, including migrations applied by Go. `--dry-run` previews +the plan without changing the database. Pass `--database-url URL` to override +`DATABASE_URL`. + +For SQLite, ensure the parent directory exists, then use a SQLite URL: + +```sh +# With riverqueue-sequel: +bundle exec river migrate-up --database-url sqlite://storage/river.sqlite3 +``` + +With `riverqueue-activerecord`, use `sqlite3://storage/river.sqlite3` instead. +The command auto-detects the installed River driver, preferring Sequel when +both are in your bundle. + +For River Pro, install `riverqueue-pro`, apply the main migrations above, then +run `bundle exec river migrate-up --line pro` against the same database. + +See the [migration guide](migrations.md) for the Ruby API, PostgreSQL schemas, +target versions, downgrade precautions, and existing SQLite Pro installations. +Test schema snapshots under `spec/support` are test-only and must not be used +to provision production databases. + +## Basic usage + +Define JSON-serializable job arguments and a worker with the same `kind`, register the worker on a queue, and start the client: + +```ruby +require "riverqueue-sequel" + +class SortArgs + attr_reader :strings + + def initialize(strings:) + @strings = strings + end + + def kind = "sort" + def to_json = JSON.generate(strings: strings) +end + +class SortWorker + def self.kind = "sort" + + def work(job) + job.output = {strings: job.args.fetch("strings").sort} + end +end + +client = River::Client.new( + River::Driver::Sequel.new(DB), + config: River::Config.new( + queues: {ruby: 10}, + workers: River::Workers.new.add(SortWorker) + ) +).start + +result = client.insert( + SortArgs.new(strings: %w[whale tiger bear]), + queue: :ruby +) +result.job # River::JobRow + +client.stop +``` + +Job arguments must respond to `#kind` and `#to_json`. They may also return default options from `#insert_opts`; options passed directly to `#insert` take precedence. Workers receive a `River::Job`, which delegates persisted attributes like `id`, `args`, `attempt`, and `metadata` to its `River::JobRow`. + +Use strings for `#kind` definitions, and symbols for identifiers such as queues, +states, periodic job IDs, and resumable step names. Both forms are accepted. +Persisted job attributes and JSON object keys remain strings, preserving +compatibility with Go clients. +Examples omit parentheses on simple calls and `do...end` blocks, retaining them +for nested expressions and `{ ... }` blocks where they make binding clear. + +## Core features + +### [Accessing the client from workers](https://riverqueue.com/docs/context-client) + +Every running `River::Job` exposes the client that claimed it. Workers can use +`job.client` to insert follow-up work or call other client APIs without relying +on a global: + +```ruby +def work(job) + result = process(job.args) + job.client.insert NotifyArgs.new(result_id: result.id) +end +``` + +### [Job insertion and options](https://riverqueue.com/docs/inserting-and-working-jobs) + +Insertion keywords control the queue, priority, maximum attempts, schedule, +tags, metadata, and uniqueness of a job. Priority `1` is highest. +`max_attempts` must be between 1 and 32,767 on every database. +An explicit `state` must be `:available`, `:pending`, or `:scheduled` (strings +are also accepted). Other states raise `ArgumentError` before any jobs are +inserted, including when an invalid state appears in a batch. Without an +explicit state, jobs are available immediately or scheduled when `scheduled_at` +is in the future. + +```ruby +result = client.insert(args, + max_attempts: 10, + metadata: {trace_id: trace_id}, + priority: 1, + queue: :critical, + tags: %w[billing customer-42] +) +``` + +For reusable options, pass `insert_opts: River::InsertOpts.new(...)` instead. +Do not mix an options object with keyword options in the same call. Options +supplied at insertion override argument-level `#insert_opts` defaults; metadata +is merged, with call-site values winning. + +For simple jobs, `River::JobArgsHash.new(:kind, hash)` avoids defining an argument class. + +### [Transactional enqueueing](https://riverqueue.com/docs/transactional-enqueueing) + +Insertion hooks and middleware execute inside the insertion transaction. If +they raise, their database writes and the enqueue roll back together. PostgreSQL +insert notifications are delivered only when the surrounding transaction commits. + +Inserts automatically join a transaction opened through the same Active Record connection or Sequel database object. A rollback also rolls back the job: + +The ActiveRecord driver defaults to `ActiveRecord::Base`. Use +`River::Driver::ActiveRecord.new(connection_class: ApplicationRecord)` to select +an abstract connection class. Queries, inserts, transactions, runtime operations, +and migrations all use its pool. Each driver has an isolated internal model and +does not inherit application scopes or callbacks. Transactions on a different +connection do not roll back River inserts. + +```ruby +DB.transaction do + save_order + client.insert FulfillOrderArgs.new(order_id: order.id) +end +``` + +The equivalent works inside `ActiveRecord::Base.transaction` with the Active Record driver. + +### [Bulk insertion](https://riverqueue.com/docs/inserting-many-jobs) + +`#insert_many` inserts a batch atomically and returns one `River::JobInsertResult` per input. Use `River::InsertManyParams` when jobs need different options: + +```ruby +results = client.insert_many([ + SortArgs.new(strings: %w[c b a]), + River::InsertManyParams.new( + SortArgs.new(strings: %w[z y x]), + queue: :bulk + ) +]) +``` + +### [Scheduled jobs](https://riverqueue.com/docs/scheduled-jobs) + +Set `scheduled_at` to keep a job from becoming available before a future UTC time. River's maintenance leader promotes it when due: + +```ruby +client.insert args, scheduled_at: Time.now.utc + 3600 +``` + +### [Unique jobs](https://riverqueue.com/docs/unique-jobs) + +`River::UniqueOpts` can make a kind unique by all or selected arguments, time period, queue, and state. A conflict returns the existing job with `result.unique_skipped_as_duplicate?` true. The older `unique_skipped_as_duplicated` reader remains available as a compatibility alias. + +```ruby +result = client.insert(args, + unique_opts: River::UniqueOpts.new( + by_args: [:account_id], + by_period: 15 * 60, + by_queue: true + ) +) +``` + +Custom `by_state` sets must contain `:available`, `:pending`, `:running`, and `:scheduled`. Set `exclude_kind: true` to enforce the same key across multiple job kinds. +It requires `by_args`, `by_queue`, or a nonzero `by_period`; without another +key dimension, insertion raises `ArgumentError`. Setting `by_state` alone +does not satisfy this requirement. + +Use nested arrays to select nested unique fields, for example +`by_args: [[:account, :id], :region]`. A string such as `"account.id"` selects a +literal top-level key. For cross-language deduplication, producers must agree +on encoded argument values: escaping, number representations, and nested key +order affect the hash even when the decoded JSON is equivalent. + +### [Reliable execution and stuck jobs](https://riverqueue.com/docs/reliable-workers) + +Claims and state transitions are atomic in the database. If a process disappears while working, the elected maintenance client checks for stale running jobs after an hour, retrying or discarding them according to their attempt count. Rescue respects longer client and worker timeouts; disabling a job's timeout also disables automatic timeout-based rescue for it. Cancellation requests still take precedence. Keep worker registrations and timeout configuration consistent across maintenance clients. `attempted_by`, attempt errors, and final state remain in the canonical River row for inspection by Ruby, Go, or River UI. + +### [Job retries](https://riverqueue.com/docs/job-retries) + +An exception normally moves a job to `retryable`; exhausting `max_attempts` moves it to `discarded`. Workers may choose an absolute retry time, or a client-wide policy may calculate it: + +```ruby +class APIWorker + def self.kind = "api" + def work(job) = call_api(job.args) + def next_retry(_job, _error) = Time.now.utc + 30 +end + +config = River::Config.new( + retry_policy: MyRetryPolicy.new # responds to next_retry(job, error, now:) +) +``` + +Use `client.job_retry(job_id)` to make a non-running job available immediately. +This clears its previous cancellation request. Once a job has reached 32,767 +attempts, requesting another retry raises `ArgumentError` without changing it. + +A worker may implement `retry?(job, error)` and return false to discard a +reported error immediately, without reducing the attempt budget used for crash +recovery. The Rails integration uses this to let Active Job own application retries. +Jobs claimed and finished by batch extensions honor the registered worker's +`retry?` and `next_retry` hooks too, including workers registered as classes. +If a retry hook or policy raises, River logs the callback error and falls back to +retrying with the default backoff. The original work error is still recorded. +Stuck-job rescue also falls back per job when a retry policy raises or returns +an invalid time, allowing other rescues and cleanup to continue. + +### [Error handling and timeouts](https://riverqueue.com/docs/error-handling) + +Errors are recorded on the job with their attempt, message, timestamp, and trace. `error_handler` may return `:cancel` or `true` to cancel instead of retrying. `job_timeout` defaults to 60 seconds; a worker-specific `timeout(job)` may override it, return `nil` to disable it, or return `0` to use the client default. + +A stored job that cannot be decoded fails with `River::JobRowDecodeError` before +worker hooks or middleware run. Its attempt uses the client's error handler and +retry policy; healthy jobs in the same fetch still run. Administrative reads +raise the same exception, with readable fields available through `error.job`. +Corrupt values remain stored for diagnosis, except that malformed error history +is wrapped in an array so the new failure can be appended. + +```ruby +config = River::Config.new( + error_handler: ->(error, _job) { :cancel if error.is_a?(PermanentError) }, + job_timeout: 30 +) +``` + +### [Cancelling jobs](https://riverqueue.com/docs/cancelling-jobs) + +Cancel a job externally with `client.job_cancel(id)`. Available jobs finalize immediately; running workers are interrupted after the runtime observes the cancellation marker. A worker can cancel itself by raising the error returned from `River.job_cancel`. + +```ruby +client.job_cancel job_id + +def work(job) + raise River.job_cancel("account closed") if account_closed?(job) +end +``` + +`River.job_cancel` also accepts an exception, which is retained as the +`River::JobCancelError` cause. The error class is public for rescue clauses and +test assertions. + +### [Snoozing jobs](https://riverqueue.com/docs/snoozing-jobs) + +Raise the error returned by `River.job_snooze` to reschedule without consuming an attempt. Short snoozes become immediately fetchable after their delay; longer ones are promoted by maintenance. `River::JobSnoozeError` remains public for rescue clauses and test assertions. + +```ruby +def work(job) + raise River.job_snooze(30) unless dependency_ready?(job) +end +``` + +### [Multiple queues](https://riverqueue.com/docs/multiple-queues) + +Queues isolate throughput and set independent thread concurrency. Each queue has a producer thread, and claimed jobs run in worker threads up to `max_workers`. + +```ruby +config = River::Config.new(queues: { + bulk: River::QueueConfig.new( + fetch_cooldown: 0.2, + fetch_poll_interval: 1.0, + max_workers: 4 + ), + critical: River::QueueConfig.new(max_workers: 20) +}) +``` + +Queues may also be added and removed at runtime with `client.queue_add(name, config)` and `client.queue_remove(name)`. +Queue removal stops fetching and keeps observing cancellation requests until its +active jobs finish. Removing a queue from one of its own workers raises +`ThreadError`, because that worker cannot wait for itself to finish. + +### [Pausing queues](https://riverqueue.com/docs/pausing-queues) + +Pausing is persisted, so every client sharing the database observes it. Pass `"*"` to affect all queues. + +```ruby +client.queue_pause :bulk +client.queue_resume :bulk + +client.queue_pause "*" +client.queue_resume "*" +``` + +Use `queue_get`, `queue_list`, and `queue_update` to inspect queues and attach metadata. +Pause and resume events contain a snapshot of each affected queue. Wildcard +operations publish events for every affected queue; size subscription buffers +to accommodate the number of events you expect to receive. + +### Rails and Active Job + +Install the separate `riverqueue-rails` gem for `config.active_job.queue_adapter = :river`, +Active Job/Action Mailer execution, Rails context handling, and `bin/jobs start`. +See the [Rails integration guide](../rails/riverqueue-rails/README.md) for setup, +transactional enqueueing, retry semantics, and supported Rails versions. + +### [Periodic jobs](https://riverqueue.com/docs/periodic-jobs) + +Register a schedule and a factory block that returns job arguments, +`[arguments, insert_options]`, or `nil` to skip that run. The block runs when +the job is due, not during registration. A reusable callable can be passed as +`constructor:` instead; don't supply both. Core periodic schedules live in the +client process; River Pro adds durable schedules. + +A started client can produce periodic jobs with `queues: {}` while other clients +consume them. Jobs added through `client.periodic_jobs` also start maintenance +when needed. Periodic registration requires leader election to be enabled, +including jobs registered after startup. + +```ruby +cleanup = River::PeriodicJob.new( + id: :cleanup, + run_on_start: true, + schedule: River::PeriodicInterval.new(3600) +) do + [CleanupArgs.new, River::InsertOpts.new(queue: :maintenance)] +end + +config = River::Config.new(periodic_jobs: [cleanup]) +handle = client.periodic_jobs.add(another_periodic_job) +client.periodic_jobs.remove handle +``` + +`client.periodic_jobs` returns a `River::PeriodicJobBundle`. Removing a handle +returns the removed `PeriodicJob`, or `nil` when absent. `clear` removes all +registrations and returns the bundle. +`add_many` registers a batch atomically: duplicate IDs or invalid schedules leave +the registry unchanged. + +Inserted jobs carry `periodic: true` in metadata and, when the registration has +an ID, `river:periodic_job_id`. Constructor options and application metadata are +preserved. + +Schedules can be callbacks (`schedule: ->(now) { now + 300 }`) or objects +implementing `next(time)`. They must return a `Time` strictly after the supplied +time; they do not execute the job themselves. A schedule that raises at runtime +is logged and retried on the next maintenance pass without blocking other jobs. + +For calendar schedules, add `gem "fugit", "~> 1.13"` to your Gemfile: + +```ruby +cleanup = River::PeriodicJob.new( + id: :weekday_cleanup, + schedule: River::PeriodicCron.new("0 9 * * 1-5", timezone: "America/New_York") +) { CleanupArgs.new } +``` + +`PeriodicCron` parses once and loads Fugit only when constructed. Fugit is not +a runtime dependency of the River gem. The timezone defaults explicitly to UTC; +set `timezone:` or use a `CRON_TZ=` / `TZ=` prefix, which takes precedence. +Five-field cron, optional seconds, and aliases such as `@daily` use Fugit's +syntax; `?` is also accepted as a wildcard. `@every 1h30m` uses Go duration +syntax, rounded down to whole seconds with a one-second minimum. Unlike +`PeriodicInterval`, these intervals align to whole-second boundaries. +Results are UTC `Time` objects. Nonexistent spring-forward times are skipped; +both occurrences of a repeated fall-back time are scheduled. See +[conformance differences](conformance.md) before sharing schedules across +languages. Cron does not replay missed occurrences after downtime. + +For a one-time date, insert a job with +`client.insert(args, scheduled_at: Time.utc(2026, 9, 20, 9))` instead of registering +a periodic job. This stores the scheduled job immediately in the database. + +### [Resumable jobs](https://riverqueue.com/docs/resumable-jobs) + +Long jobs can checkpoint idempotent steps and cursor progress. On retry, River skips completed steps and resumes a cursor step from its last recorded value using the same metadata format as Go. + +```ruby +def work(job) + job.resumable_step :download do + download(job.args) + end + + job.resumable_step_cursor :rows, default: 0 do |last_row| + import_rows(after: last_row) do |row| + job.resumable_set_cursor row.id + end + end +end +``` + +Step exceptions propagate immediately: later code in the worker does not run +unless it explicitly rescues the error. Middleware and error hooks see the same +exception as for ordinary work. +Step names must be unique within an invocation, and steps cannot be nested: +skipping an outer step on retry would make an inner checkpoint unreachable. + +`job.resumable_checkpoint(cursor: value)` writes a checkpoint immediately. Omit +`cursor:` to checkpoint the current step with any cursor already recorded. For +atomic application writes, put the transaction **inside** the step and let +rollback errors propagate out of it: + +```ruby +job.resumable_step_cursor :rows, default: 0 do |last_row| + import_rows(after: last_row) do |row| + job.client.driver.transaction do + save_row(row) + job.resumable_checkpoint cursor: row.id + end + end +end +``` + +Use the same database connection for `save_row` and the checkpoint. A rolled-back +checkpoint is not replayed when the attempt fails; retries use the last committed +progress. Do not swallow rollback errors or wrap an entire completed step in a +transaction that may subsequently roll back. As with ordinary jobs, steps must +remain idempotent. + +### [Recorded output and metadata](https://riverqueue.com/docs/recorded-output) + +Assign `job.output` to store JSON-compatible output under `metadata["output"]`. Use `job.update_metadata` for other metadata that should be committed with the attempt's final transition. + +Values are validated and copied when assigned. Invalid JSON fails the attempt +normally without preventing its error from being recorded. `job.metadata` and +`job.metadata_updates` return snapshots; use `update_metadata` to persist changes. + +```ruby +def work(job) + job.update_metadata provider_request_id: request_id + job.output = {imported: 42} +end +``` + +### Job-persisted logging + +Add `River::JobPersistedLogging::Plugin` to save worker logs with each job attempt, +using the same `metadata["river:log"]` format as Go's `riverlog` and River UI. This +is a core feature; it does not require Pro or additional migrations. + +```ruby +config = River::Config.new( + plugins: [River::JobPersistedLogging::Plugin.new], + workers: workers, + queues: {default: 10} +) + +def work(job) + job.logger.info "Starting import" + job.logger.warn "Skipped a malformed row" +end +``` + +`job.logger` is a fresh standard Ruby `Logger` at INFO level for each attempt. +Calling it without the plugin raises a configuration error. To customize +formatting, severity, or use another logger, supply a factory block: + +```ruby +logging = River::JobPersistedLogging::Plugin.new( + max_size_bytes: 256 * 1024, + max_total_bytes: 1024 * 1024 +) do |writer| + Logger.new(writer, level: :debug, + formatter: ->(severity, time, _progname, message) { + JSON.generate(level: severity, time: time.utc.iso8601, message: message) + "\n" + }) +end +``` + +The factory runs once per attempt and receives a thread-safe, bounded writer +supporting `write` and `close`. Return a new logger that writes to it. Only this +logger's output is captured: the plugin does not replace `Config#logger`, +`Rails.logger`, Active Job's logger, or redirect stdout/stderr. Pass `job.logger` +to application code that should contribute to the job log. Put the plugin +before other work middleware if that middleware also needs `job.logger`. + +Logs are saved with the attempt's final state transition, including failures, +timeouts, snoozes, cancellations, and graceful interruption. Entries have the +shape `{"attempt": 1, "log": "..."}` and append across retries. Empty attempts +add nothing. No separate database write is made for each log line. Logs are not +live-streamed; a process crash or failed finalization can lose the current +attempt's buffered logs. Jobs deleted by an ephemeral-job plugin retain no logs. + +The default capture limit is 2 MiB per attempt; excess bytes are discarded as +they are written. The default history limit is 8 MiB of serialized JSON, capped +at 64 MiB. Oldest entries are dropped first, but the newest entry is always +retained even if it alone exceeds the history limit. Both settings must be +positive integers. Invalid/incomplete UTF-8 sequences and NUL bytes are removed +so the log is safe to store. Truncation, dropped history, and malformed existing +log metadata are reported through `Config#logger`; malformed history is left +unchanged. Join any child threads before returning from work; writes after +capture closes are ignored. Avoid logging secrets: logs live in job metadata +and follow the job's retention policy. + +### Plugins + +Plugins provide one ordered configuration point for lifecycle callbacks and +wrapping middleware. These are two distinct extension styles even though both +are registered through `Config#plugins`: + +- A **hook** runs at one specific lifecycle point and then returns. Hooks are + appropriate for observing or making a small change at that point. +- **Middleware** wraps a complete insertion or work operation. It can run code + before and after the inner operation, and must call `operation.call` to let + that operation continue. + +A plugin may implement any combination of these methods: + +| Style | Method | When it runs | +| ---------- | -------------------------------- | -------------------------------------------------------------------------------------------- | +| Hook | `insert_begin(params)` | Before each job is inserted; `params` may be modified. | +| Hook | `insert_end(result)` | After each job is inserted. | +| Hook | `work_begin(job)` | After a job is claimed, immediately before its worker runs. | +| Hook | `work_end(job, error)` | After the worker returns or raises; `error` is `nil` on success. | +| Hook | `job_finalize(job, state)` | Before successful finalization; returning `:delete` deletes the job instead of retaining it. | +| Middleware | `insert_many(params, operation)` | Around one insertion call; `params` is an array even for `Client#insert`. | +| Middleware | `work(job, operation)` | Around the work hooks and worker for one claimed job. | + +A single plugin can provide both styles. For example, it might use +`insert_begin` to add metadata and `work` to time the complete work operation. +After insertion hooks run, each job must still have an initial state of +`available`, `pending`, or `scheduled`. Invalid states roll back the batch +and any database writes made by its hooks. + +```ruby +class TimingPlugin + def work(job, operation) + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + operation.call + ensure + Metrics.observe( + job.kind, + Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + ) + end +end + +config = River::Config.new(plugins: [AuditPlugin.new, TimingPlugin.new]) +``` + +Plugins earlier in the list are the outermost wrappers. Begin callbacks run in +configuration order, while `insert_end` callbacks run in reverse order. In +effect, work execution is nested as: middleware before, `work_begin`, worker, +`work_end`, middleware after. + +### [Subscriptions](https://riverqueue.com/docs/subscriptions) + +Subscribe to job and queue events for logging or metrics. Subscriptions are +bounded and drop new events rather than blocking workers when their buffer is +full. `subscription.close` unregisters it from the client and wakes waiting +readers; closing more than once is safe. Buffered events remain readable, then +`each` ends and blocking `pop` calls return `nil`. Non-blocking `pop(true)` raises +`ThreadError` whenever no event is available, including after closure. + +```ruby +subscription = client.subscribe( + :job_completed, + :job_failed, + buffer_size: 1_000 +) + +subscription.each { |event| consume(event) } +subscription.close +``` + +Events include completed, failed, cancelled, snoozed, and interrupted jobs, plus paused and resumed queues. + +### Job administration + +The client can fetch, filter, update, cancel, retry, and delete jobs. Lists return +a `JobListCursor` in `last_cursor`. Pass it as `after`, preserving the same filters +and ordering, to fetch the next page. Cursors retain both the sort value and ID, +so timestamp ordering handles ties and continues working if the cursor job is +deleted. Null timestamps sort last in either direction. For ID ordering only, +`after_id` is also available as a shortcut with an integer ID. + +```ruby +list_options = { + limit: 100, + queues: [:bulk], + sort_by: :scheduled_at, + states: [:discarded], + tags_any: ["billing"] +} +page = client.job_list(**list_options) + +next_page = client.job_list(**list_options, after: page.last_cursor) +client.job_update job_id, max_attempts: 50 +client.job_delete_many states: [:cancelled] +``` + +Reusable `JobListParams` and `JobUpdateParams` objects are also accepted as +positional arguments, instead of keywords. For updates, omitted fields remain +unchanged; an explicit `nil` clears a nullable field. Metadata must be a Hash; +use `metadata: {}` to clear it. Values inside the Hash may be `nil`. +Updated `max_attempts` must be between 1 and 32,767; `attempt` must be between +0 and 32,767. Claims cap the attempt counter at 32,767 so an administratively +requeued job cannot prevent other jobs from being claimed. + +Bulk deletion requires at least one filter and never deletes running jobs. +Metadata filters compare complete JSON values at each supplied top-level key, +including nested objects and arrays. Numbers, strings, and booleans remain +distinct; a null value matches a present JSON null, not a missing key. + +### [Leader election](https://riverqueue.com/docs/leader-election) + +Running clients coordinate through the canonical `river_leader` table. Only the +current leader performs database-wide scheduling, stuck-job rescue, retention, +and custom maintenance, and another client can take over after its lease +expires. + +Set `leader_election_disabled: true` for a worker-only client. It continues +fetching and executing jobs, including queues added after startup, but never +runs maintenance. Another client in the same database/schema must remain +eligible to lead. Periodic jobs cannot be configured or modified on a client +with leader election disabled. + +For clients that share a queue but implement different job kinds, set +`fetch_only_known_kinds: true`. Only registered kinds and aliases are claimed; +other jobs remain available without consuming attempts. Register workers before +starting the client. An empty registry fetches nothing. This option affects +fetching only; combine it with `leader_election_disabled` when another client +should own database-wide maintenance. Both options default to `false`. + +### [Maintenance services and retention](https://riverqueue.com/docs/maintenance-services) + +The maintenance leader promotes scheduled jobs, rescues stuck work, deletes +finalized rows, and runs custom services. Retention is configured in seconds; +use `nil` or `-1` to retain a state indefinitely. + +```ruby +config = River::Config.new( + cancelled_job_retention_period: 86_400, + completed_job_retention_period: 86_400, + discarded_job_retention_period: 7 * 86_400, + maintenance_services: [MyMaintenanceService.new] +) +``` + +A custom service implements `run(client, driver, now)` and runs only while this client holds leadership. +Service errors are logged without interrupting other services, stuck-job rescue, +or cleanup. A failed service is retried on the next scheduled maintenance pass. + +On SQLite, the leader also removes notification outbox entries older than five +minutes in bounded batches. Cancellation requests write Go-compatible control +notifications in the same transaction as the job update; Ruby workers continue +to observe cancellation through polling. + +### [Renaming job kinds](https://riverqueue.com/docs/renaming-jobs) + +Register old names as aliases while producers migrate to a new kind. All aliases resolve to the same worker: + +```ruby +workers = River::Workers.new.add(NewReportWorker, aliases: [:old_report]) +``` + +Keep aliases registered until no jobs with the old kind remain. + +Use `workers[:kind]` for a lookup that returns `nil` when missing, or +`workers.fetch(:kind)` to raise `KeyError`. Like `Hash#fetch`, it also accepts +an explicit default (`workers.fetch(:kind, nil)`) or a fallback block. + +### [Stopping gracefully](https://riverqueue.com/docs/graceful-shutdown) + +For a dedicated foreground worker process with application boot and signal +handling, use the [worker command](./workers.md): + +```sh +bundle exec river worker --config config/river.rb --stop-timeout 30 +# Rails, from the application root: +RAILS_ENV=production bundle exec river worker --rails +``` + +The Ruby configuration file must return an unstarted client. The following client +methods are for applications managing their own runtime lifecycle: + +`client.stop` stops fetching and waits for active jobs to finish. `client.stop_and_cancel` interrupts active worker threads and returns their jobs to `available` without consuming the interrupted attempt. +While draining, the client continues polling for cancellation requests from +other clients, including for workers with no timeout. Transient polling errors +are retried until the active attempts finish. + +Blocking stop calls from the client's own workers or other runtime threads +raise `ThreadError`. A worker can request shutdown with `client.stop(wait: false)` +and then finish its work. + +Use `client.stop(wait: false)` to request stop and return immediately without +interrupting active workers. Call `client.stop` later to wait for draining and +finish cleanup. Until that waiting call completes, `started?` remains true and +`stopped?` remains false. In-flight fetches or maintenance operations may finish. + +### [Insert-only clients](https://riverqueue.com/docs/insert-only-clients) + +A client with no configured queues can insert and administer jobs without starting worker or maintenance threads: + +```ruby +client = River::Client.new(driver, config: River::Config.new(queues: {})) +client.insert args +``` + +## Threads and Ractors + +Queue producers, maintenance, and jobs run in threads. River keeps core constants shareable and mutable runtime state per client. Tests exercise insertion, uniqueness, resumable work, events, periodic scheduling, and worker threads inside a non-main Ractor using an in-memory test driver. This is groundwork for Ractor support, not a guarantee that the supplied database drivers work in Ractors. + +Load River and worker definitions in the main Ractor before spawning others. Each Ractor must create and own its clients, configuration, worker instances, callbacks, and database connections; do not share live clients or pools across Ractors. Custom argument encoders should prefer `JSON.generate` to `JSON.dump`, which depends on mutable global options. + +Database drivers, Active Record, Sequel, and optional dependencies such as Fugit still need their own Ractor compatibility. Job timeouts also depend on Ruby and the `timeout` gem: the test suite exercises them on Ruby 4 with `timeout` 0.6.1; tests on older Rubies disable job timeouts. `WorkerRunner` handles process-wide signals and must run on the main thread of the main Ractor. + +Ruby 4.0.2 with `timeout` 0.6.1 can intermittently deadlock during VM shutdown +while terminating Ractors and their timeout helper threads, even after work +finishes successfully. This reproduces without River. Tests bypass that shutdown +path only in their disposable subprocesses, after checking both Ractors' results. +Keep production workers on the thread-based runtime until the Ruby and driver +limitations are resolved. + +## RBS and type checking + +The gem bundles [RBS files](https://github.com/riverqueue/river/tree/master/ruby/sig) for tools such as [Steep](https://github.com/soutaro/steep) and other RBS-compatible type checkers. + +## Drivers + +### Active Record + +```ruby +require "riverqueue-activerecord" + +ActiveRecord::Base.establish_connection("postgres://...") +client = River::Client.new(River::Driver::ActiveRecord.new) +``` + +### Sequel + +```ruby +require "riverqueue-sequel" + +DB = Sequel.connect("postgres://...") +client = River::Client.new(River::Driver::Sequel.new(DB)) +``` + +Neither driver installs `pg` or `sqlite3`; the application chooses its adapter. + +## Testing + +For database-backed insertion assertions and synchronous worker tests, see +[Testing River jobs](./testing.md). Helpers ship in the core gem; RSpec and +Minitest integrations are optional and explicitly loaded. + +## River Pro + +River Pro is kept in the separate, privately distributed `riverqueue-pro` gem, so possession of that package is the access boundary. It is not included in the MPL-2.0 core gem. Its implementation and documentation live in the private `riverqueue-ruby-pro` repository; see that repository's README for configuration and feature examples. + +## Current differences from the Go client + +Ruby checks Go-generated fixtures for unique keys, cron, snooze counters, and +shared protocol values. +See [conformance coverage and differences](conformance.md); this is not yet a +full mixed-language runtime suite. + +The Ruby client does not currently provide dedicated OpenTelemetry/metrics +integrations or +transactional job completion alongside application writes. Plugins and +subscriptions provide integration points for telemetry. Job execution uses Ruby +worker objects rather than Go's work-function API. + +## Development + +See [development](./development.md). diff --git a/ruby/docs/conformance.md b/ruby/docs/conformance.md new file mode 100644 index 000000000..19ab485ba --- /dev/null +++ b/ruby/docs/conformance.md @@ -0,0 +1,72 @@ +# Ruby conformance + +Ruby reads fixtures generated from this checkout's Go implementation, alongside +Rust and JavaScript. From the repository root: + +```sh +make -C ruby install +make test/ruby/conformance +``` + +This generates `conformance/testdata/` and runs `ruby/spec/conformance_spec.rb`. +No PostgreSQL server, pinned upstream checkout, protocol adapter, or separately +maintained golden files are needed. Missing fixtures fail with instructions to +regenerate them. The regular Ruby suite includes these checks too. + +The fixture checks cover: + +- All unique-key cases, including typed-only cases, raw JSON tokens, selected + fields, periods, queues, and state masks. +- Job state names and unique bits, attempt-error encoding/decoding, retry bounds, + and all six shared metadata keys, including actual output, rescue, periodic, + unique insertion, and resumable-step persistence. +- All snooze-counter fixtures through real worker attempts and both SQL drivers. +- Cron occurrences, named zones, daylight-saving transitions, interval schedules, + and invalid expressions, with the Ruby API differences below checked explicitly. +- Insertion, cancellation, queue pause/resume/metadata, and leadership resignation + notification encoding through both SQL drivers, using in-memory SQLite and + the bundled canonical migrations. + +The Ruby workflow runs on Ruby changes. The shared Conformance workflow also +runs these tests when Go code changes. The ordinary driver suites retain +PostgreSQL notification delivery/commit-ordering tests and shared PostgreSQL / +SQLite insertion, transaction, uniqueness, and worker tests. + +This replaces the old `insert-only-v1` adapter and its pinned external harness. +It checks shared data formats, not live mixed-language worker execution. Adapter +handshake and request-schema tests were specific to that removed harness; their +protocol is not part of the Ruby API. + +## Coverage gaps and API differences + +Ruby's runtime still polls job and queue state rather than consuming PostgreSQL +LISTEN events or the SQLite outbox. The fixture target checks notification +emission, not dispatch. Ruby does not emit or handle `request_resign`; other +clients' requests therefore cannot force a Ruby leader to resign. Completing +that coverage would require a notification listener and runtime dispatch path. +The ordinary driver suites verify PostgreSQL delivery, commit ordering, and +rollback for queue controls and resignations as well as insertion/cancellation. + +Shared snooze-counter fixtures cover non-negative integers and absent counters. +Recovery from other JSON values is implementation-specific. Ruby's shared driver +tests cover its lenient conversion rules separately on PostgreSQL and SQLite. + +Cron retains Ruby's existing Fugit extensions: six fields with seconds, Sunday +as 7, hour 24, and descending ranges. These four expressions are accepted in +Ruby and rejected in Go; the fixture suite explicitly asserts the difference. +Fugit rejects impossible calendar dates at construction instead of returning +Go's zero time. Ruby's default calendar zone remains UTC; tests explicitly pass +the reference time's fixed offset to model Go's time-location API. `CRON_TZ=` / +`TZ=` prefixes, `?`, and `@every` duration rounding now follow Go for the shared +fixtures. No new dependencies were added. + +Selected unique fields use `by_args: [:field, [:nested, :field]]`, rather than Go +struct tags. Dotted string keys are literal. Cross-language unique keys require +the same encoded argument values, including escaping, numeric tokens, and +nested ordering; Ruby does not override the application's JSON serialization. + +Transactions are connection-scoped blocks. Workers use exceptions and thread +interruption. List cursors encode the selected field explicitly. Historical +error timestamps accept Ruby's `Time.parse` formats, and metadata must decode +to an object. Live interoperability across these boundaries is not covered by +the fixture target. diff --git a/ruby/docs/development.md b/ruby/docs/development.md new file mode 100644 index 000000000..d19b93355 --- /dev/null +++ b/ruby/docs/development.md @@ -0,0 +1,165 @@ +# riverqueue-ruby development + +All commands on this page run from `ruby/`, unless specified otherwise. + +## Install dependencies + +```shell +$ bundle install +$ pushd driver/riverqueue-activerecord && bundle install && popd +$ pushd driver/riverqueue-sequel && bundle install && popd +$ pushd rails/riverqueue-rails && bundle install && popd +``` + +The private Pro gem is developed and tested separately in `riverqueue-ruby-pro`. +Public install, test, and lint targets do not traverse sibling checkouts. + +Keep the root lockfile usable on both macOS and Linux when updating dependencies. +Run `bundle lock --add-platform x86_64-linux` and commit the resulting lockfile; +CI uses frozen dependency installation and cannot add missing platforms itself. + +## Run tests + +Create a test database: + +```shell +$ createdb river_test +``` + +Run the core, SQL driver packages, and Rails integration: + +```shell +$ RIVER_REQUIRE_DATABASES=1 make test +``` + +Package suites run in separate processes, four at a time by default. Set +`TEST_JOBS=1` for serial execution, or choose another concurrency with +`make test TEST_JOBS=2`. Each package retains its own coverage checks. + +CI runs Ruby 4.0 against PostgreSQL 14–18, and Ruby 3.2–3.4 against PostgreSQL 18. +SQLite and conformance checks run separately on Ruby 4.0. One Rails job tests +Rails 7.2, 8.0, and 8.1 on Ruby 4.0, reusing its PostgreSQL service and gems. + +Use `TEST_DATABASE=postgres` or `TEST_DATABASE=sqlite` to select a backend; +the default is `all`. CI merges the core and driver coverage reports from +PostgreSQL and SQLite to enforce 100% line and branch coverage. Filtered local +runs collect coverage without enforcing that threshold individually. + +Real database tests run by default. `RIVER_REQUIRE_DATABASES=1` requires both +the selected databases to be available instead of permitting local skips. CI +also requires them. Set `TEST_DATABASE_URL` to override the PostgreSQL test +database URL. Tests create and migrate disposable schemas with the bundled SQL; +the database user must be able to create and drop schemas. No existing River tables are needed. The Go toolchain from `../go.work` is +required to generate the conformance fixtures before running core tests. + +Rollback-wrapped tests share an empty schema for the suite, then drop it on exit. +Each example rolls back its writes instead of deleting shared tables, so test +speed is independent of existing data in `public`, which is left untouched. + +Both driver packages run the same insertion and runtime contracts from +`spec/driver_shared_examples.rb` and `spec/driver_runtime_shared_examples.rb` +against PostgreSQL and SQLite. These cover job state transitions, scheduling, +rescue, metadata, filtering, deletion, transactions, queues, and leadership. +Adapter-specific conversion tests remain in each driver's suite. + +The driver suites also exercise Go-style Yugabyte capability simulations on +PostgreSQL. For actual YSQL storage/transaction checks, run `make test/yugabyte` +with `YUGABYTE_DATABASE_URL` pointing to a disposable database. See +[Yugabyte verification](yugabyte.md#verification). + +`spec/client_driver_shared_examples.rb` additionally starts real worker threads +for each combination, testing transaction visibility and rollback, committed +bulk insertion, output, retries, and exhausted jobs. These tests need committed +data, so they use disposable PostgreSQL schemas and temporary file-backed SQLite +databases, initialized by batching the bundled canonical migration SQL, not +the shared public job tables. Migration tests still use `River::Migrator`, +covering upgrades, downgrades, legacy history, rollback, and populated data. See [migrations](migrations.md) +for synchronizing the SQL with upstream Go. + +`bundle exec rspec spec` from `ruby/` runs only the core suite; use `make test` +for both SQL adapters and Rails. Generate fixtures first with +`make generate/fixtures` when invoking RSpec directly. + +## Conformance and migrations + +`make test/conformance` checks shared Go-generated fixtures without PostgreSQL. +See [conformance](conformance.md) for coverage and known parity gaps. + +`make verify` checks bundled PostgreSQL and SQLite migrations against the +canonical files in `../riverdriver/`, including filenames, bytes, license, and +manifest checksums. It requires only Ruby, not installed gems or a database. +Run `ruby scripts/sync_migrations.rb` to update the bundle after Go migrations +change, or `make generate/ruby-migrations` from the repository root. + +## Run lint + +```shell +$ bundle exec standardrb --fix +``` + +## Run type check (Steep) + +```shell +$ bundle exec steep check +``` + +## Code coverage + +The core and driver suites require 100% line and branch coverage of production +code; shared test files are excluded. Run the suite and open +`coverage/index.html` to find lines or branches that weren't covered: + +```shell +$ bundle exec rspec spec +$ open coverage/index.html +``` + +## Publish gems + +The Pro gem is released separately from the private `riverqueue-ruby-pro` +repository. Follow its README; do not include it in the public release below. + +1. Choose a version, run scripts to update the versions in each gemspec file, build each gem, and `bundle install` which will update its `Gemfile.lock` with the new version: + + ```shell + git checkout master && git pull --rebase + export VERSION=v0.x.0 + + ruby scripts/update_gemspec_version.rb riverqueue.gemspec + ruby scripts/update_gemspec_version.rb driver/riverqueue-activerecord/riverqueue-activerecord.gemspec + ruby scripts/update_gemspec_version.rb driver/riverqueue-sequel/riverqueue-sequel.gemspec + ruby scripts/update_gemspec_version.rb rails/riverqueue-rails/riverqueue-rails.gemspec + + gem build riverqueue.gemspec + pushd driver/riverqueue-activerecord && gem build riverqueue-activerecord.gemspec && popd + pushd driver/riverqueue-sequel && gem build riverqueue-sequel.gemspec && popd + pushd rails/riverqueue-rails && gem build riverqueue-rails.gemspec && popd + + bundle install + pushd driver/riverqueue-activerecord && bundle install && popd + pushd driver/riverqueue-sequel && bundle install && popd + pushd rails/riverqueue-rails && bundle install && popd + + git checkout -b $USER-ruby-$VERSION + ``` + +2. Update `CHANGELOG.md` to include the new version and open a pull request with those changes and the ones to the gemspecs and `Gemfile.lock`s above. + +3. After the PR is merged, pull master and rebuild all gems with `make build`. + Push those rebuilt archives, then tag the release with the Ruby-specific prefix + (plain `v*` tags are reserved for Go): + + ```shell + git pull origin master + make build + + gem push riverqueue-${VERSION#v}.gem + pushd driver/riverqueue-activerecord && gem push riverqueue-activerecord-${VERSION#v}.gem && popd + pushd driver/riverqueue-sequel && gem push riverqueue-sequel-${VERSION#v}.gem && popd + pushd rails/riverqueue-rails && gem push riverqueue-rails-${VERSION#v}.gem && popd + + git tag riverqueue-ruby-$VERSION + git push origin riverqueue-ruby-$VERSION + ``` + +4. Cut a new GitHub release by visiting [new release](https://github.com/riverqueue/river/releases/new), selecting the new tag, and copying in the version's `CHANGELOG.md` content as the release body. diff --git a/ruby/docs/migrating_from_sidekiq.md b/ruby/docs/migrating_from_sidekiq.md new file mode 100644 index 000000000..a11785332 --- /dev/null +++ b/ruby/docs/migrating_from_sidekiq.md @@ -0,0 +1,733 @@ +# Migrating from Sidekiq to River + +This guide migrates a Ruby application from Sidekiq to the Ruby River client in +this repository. It covers producers, workers, configuration, tests, and queued +work. It targets the current repository API, including worker-runtime features. +Pin and verify a release or repository revision containing these APIs +before changing an application; do not assume an older installed gem supports +them. Sidekiq references were checked on September 7, 2026. + +River stores jobs in PostgreSQL or SQLite and can insert them in the same +transaction as application records. Workers run in Ruby threads. Redis payloads +are not River rows: changing gems alone does not move existing jobs. + +## Why migrate to River? + +For applications already using PostgreSQL or SQLite, River offers: + +- **Atomic enqueueing:** application changes and jobs commit or roll back + together in one database transaction. Sidekiq's + [transactional push](https://github.com/sidekiq/sidekiq/wiki/Advanced-Options#transactional-push) + waits until commit, but the subsequent Redis write is separate. River removes + that gap. See [transactional enqueueing](https://riverqueue.com/docs/transactional-enqueueing). +- **Less infrastructure:** reuse your application database instead of operating + Redis for the job queue. Redis may still be needed for unrelated features. +- **SQL visibility:** inspect job arguments, attempts, errors, and retained + results with ordinary SQL, and correlate them with application records. +- **More built into the core client:** unique jobs, periodic intervals, snoozing + without consuming attempts, resumable checkpoints, and recorded output reduce + the need for application glue or extra extensions. See the + [Ruby feature guide](README.md#core-features). +- **A path between Ruby and Go:** clients can share River's schema and exchange + jobs using compatible kinds and JSON arguments, allowing workers to move + between languages incrementally. + +This is not a claim of higher throughput or exactly-once execution: jobs still +need idempotent behavior, and queue traffic adds database load. Check the +compatibility gaps below before migrating. + +## Instructions for an automated migration + +Work through the numbered sections in order. Use the Ruby source linked below +as the API authority; Go documentation describes concepts but its method names +are not Ruby methods. Code examples use application-owned names such as +`FulfillOrder` and `AppJobs`; create or adapt them explicitly. + +Before editing, produce a migration inventory with one row per job class: + +| Existing class | Argument schema | Queue | Retry policy | Producers | Middleware/context | Destination kind | Cutover policy | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `FulfillOrderJob` | `order_id` integer | `orders` | 5 retries | Checkout service | Rails executor | `fulfill_order` | Drain old jobs | + +Classify every item as **direct rewrite**, **semantic change**, **requires River +Pro**, or **application implementation required**. Record unresolved behavior +instead of inventing a River API. The separate `riverqueue-rails` gem provides +an Active Job adapter and worker entry point (see section 8). Core River has no +Sidekiq compatibility module, `perform_async`, Sidekiq-style YAML loader, or +fake/inline testing mode. The native-worker examples below do not require the +Rails integration gem. + +Keep the existing system runnable during migration. Separate code conversion +from the production cutover and from any Redis data transfer. + +## 1. Inventory the application + +Search source, tests, initializers, scripts, deployment manifests, and dependencies: + +```sh +rg -n 'Sidekiq|sidekiq|perform_async|perform_in\b|perform_at\b|perform_bulk|push_bulk' . +rg -n 'perform_later|deliver_later|queue_adapter|retry_on|discard_on|queue_as' . +rg -n 'unique_for|unique_until|sidekiq_options|sidekiq_retry_in|sidekiq_retries_exhausted' . +rg -n 'sidekiq-cron|sidekiq-scheduler|sidekiq-unique-jobs|REDIS_URL' . +``` + +Also inventory scheduled, retrying, dead, and in-flight jobs; dynamically chosen +queues/classes; cron schedules and time zones; batch callbacks; rate limits; +tenant/locale/tracing context; and code storing Sidekiq JIDs. Include producers +outside the main repository. Determine whether Redis serves other application +features before removing its infrastructure. + +[Sidekiq's feature list](https://sidekiq.org/) distinguishes OSS, Pro, and +Enterprise. Record which features the application actually uses, including +third-party extensions, rather than mapping the purchased edition as a whole. + +## 2. Install a driver and provision the schema + +For an Active Record application using PostgreSQL: + +```ruby +# Gemfile: keep Sidekiq while it drains existing work. +gem "riverqueue-activerecord" +gem "pg" +``` + +For Sequel, use `riverqueue-sequel`. For SQLite, use `sqlite3` instead of `pg`. +Each driver depends on `riverqueue` but leaves database gem selection to the +application. Use the same database as the business records when atomic enqueueing +is required. + +Apply the bundled [canonical River migrations](migrations.md) without installing Go: + +```sh +bundle exec river migrate-up --database-url postgres://localhost/my_app +``` + +Use a gem version containing the required migrations. Provision +both development and isolated test databases before running the examples. +Do not copy `spec/support` schema fixtures into production or recreate River +tables through Rails models. Pro additionally requires its canonical migration +line and separately distributed gem. + +## 3. Convert a job and its producers + +Sidekiq invokes `perform` with positional JSON arguments. River resolves an +explicit kind through a worker registry and passes one `River::Job` to `work`. +See [Sidekiq Getting Started](https://github.com/sidekiq/sidekiq/wiki/Getting-Started) +and River's [worker implementation](../lib/worker.rb). + +Before: + +```ruby +class FulfillOrderJob + include Sidekiq::Job + sidekiq_options queue: "orders", retry: 5 + + def perform(order_id) + FulfillOrder.call(order_id) + end +end + +jid = FulfillOrderJob.perform_async(42) +``` + +After, put each class in a correspondingly named file loaded by your application: + +```ruby +class FulfillOrderArgs + def initialize(order_id:) + @order_id = order_id + end + + def kind = "fulfill_order" + def to_json = JSON.generate(order_id: @order_id) + + def insert_opts + River::InsertOpts.new(max_attempts: 6, queue: :orders) + end +end + +class FulfillOrderWorker + def self.kind = "fulfill_order" + + def work(job) + FulfillOrder.call(job.args.fetch("order_id")) + end +end +``` + +Keep `FulfillOrder.call` as the application's idempotent business operation. +Register the worker class, not an instance, when you want a new instance for each +attempt. Registered instances and plugin instances are shared across threads. + +Create an insertion client in the web process after its database connection is +configured. This Rails initializer defines an application-owned accessor: + +```ruby +# config/initializers/river.rb +require "riverqueue-activerecord" + +module AppJobs + def self.client + @client ||= River::Client.new(River::Driver::ActiveRecord.new) + end +end +``` + +This accessor is for a normal web-process boot; initialize clients after forking +and avoid sharing clients or pools between processes or Ractors. Worker processes +use the separately configured client in section 5. + +```ruby +result = AppJobs.client.insert(FulfillOrderArgs.new(order_id: 42)) +job_id = result.job.id +``` + +The return value is `River::JobInsertResult`, and `job.id` is a database integer, +not a Sidekiq JID string. Update API contracts, stored references, cancellation +endpoints, and logs accordingly. For a simpler producer without an argument class: + +```ruby +result = AppJobs.client.insert( + River::JobArgsHash.new(:fulfill_order, {order_id: 42}), + insert_opts: River::InsertOpts.new(max_attempts: 6, queue: :orders) +) +``` + +`JobArgsHash` does not inherit `FulfillOrderArgs#insert_opts`. Supply the intended +options whenever using it. Worker classes do not supply insertion defaults. + +Use string keys when reading decoded JSON. Explicitly translate each old +positional argument to a named field; do not put the complete Sidekiq envelope +under River `args`. Keep IDs and simple JSON values rather than model instances, +GlobalID wrappers, or arbitrary Ruby objects. Preserve idempotency: both systems +can execute work more than once after failures. See +[Sidekiq Best Practices](https://github.com/sidekiq/sidekiq/wiki/Best-Practices). + +## 4. Translate enqueueing and transaction boundaries + +| Sidekiq operation | Ruby River operation | +| --- | --- | +| `perform_async(...)` | `client.insert(args)` | +| `perform_in(seconds, ...)` | `InsertOpts.new(scheduled_at: Time.now.utc + seconds)` | +| `perform_at(time, ...)` | `InsertOpts.new(scheduled_at: time.getutc)`; convert numeric epochs with `Time.at` | +| `.set(queue: ...).perform_async(...)` | `client.insert(args, insert_opts: River::InsertOpts.new(queue: ...))` | +| `perform_bulk` / `push_bulk` | `client.insert_many` with args or `River::InsertManyParams` | +| Enqueue children inside `perform` | `job.client.insert(...)` inside `work` | + +```ruby +client = AppJobs.client +client.insert( + FulfillOrderArgs.new(order_id: 42), + insert_opts: River::InsertOpts.new(scheduled_at: Time.now.utc + 300) +) + +results = client.insert_many([42, 43].map do |order_id| + River::InsertManyParams.new( + FulfillOrderArgs.new(order_id: order_id), + insert_opts: River::InsertOpts.new(queue: :orders) + ) +end) +job_ids = results.map { |result| result.job.id } +``` + +Scheduled jobs need a running maintenance leader and a consumer for their queue. +Scheduling sets an earliest execution time, not an exact deadline. Chunk very +large bulk imports; each call is atomic, but several calls are not one transaction +unless explicitly wrapped. + +Put application writes and River insertion in the same transaction: + +```ruby +ActiveRecord::Base.transaction do + order = Order.create!(status: "pending") + AppJobs.client.insert FulfillOrderArgs.new(order_id: order.id) +end +``` + +Sequel's equivalent is `DB.transaction` with a River driver constructed from +that same `DB`. Sharing a URL alone is insufficient: the insertion must use the +same transaction connection. Audit multi-database and sharded applications +explicitly. Do not move enqueueing into `after_commit` when the intent is one +atomic write. A remote service call still cannot join this transaction, and this +Ruby client does not atomically commit business writes with job completion. + +## 5. Start and stop a worker process + +Sidekiq queue weights and strict queue ordering do not translate into River queue +worker counts. River allocates concurrent work separately to each queue; priority +`1` through `4` orders jobs within a queue. Ten workers on each of three queues +permits thirty concurrent jobs per client. Multiple processes multiply that +capacity. See [Sidekiq Advanced Options](https://github.com/sidekiq/sidekiq/wiki/Advanced-Options). + +Use `river worker` for a dedicated process with application boot, signal handling, +and a stop deadline. With `riverqueue-rails`, configure queues and native +workers in `config.river.configure` (see section 8), then run: + +```sh +RAILS_ENV=production bundle exec river worker --rails --stop-timeout 30 +``` + +Alternatively, this core-gem configuration uses one queue. Place the worker and args +classes above in eager-loaded application paths. The plugin wraps application +work in the [Rails executor](https://guides.rubyonrails.org/threading_and_code_execution.html) +when using the core gem directly. The optional `riverqueue-rails` package supplies +this execution wrapping and a worker entry point automatically (see section 8). +Use this configuration with eager-loaded code; development reload support requires +separate integration. + +```ruby +# config/river.rb +# frozen_string_literal: true + +require_relative "../config/environment" +require "riverqueue-activerecord" +Rails.application.eager_load! + +class RailsExecutionPlugin + def work(_job, operation) + Rails.application.executor.wrap { operation.call } + end +end + +River::Client.new( + River::Driver::ActiveRecord.new, + config: River::Config.new( + job_timeout: 300, + logger: Rails.logger, + plugins: [RailsExecutionPlugin.new], + queues: {orders: 10}, + workers: River::Workers.new.add(FulfillOrderWorker) + ) +) +``` + +Run it under your process supervisor: + +```sh +RAILS_ENV=production bundle exec river worker --config config/river.rb --stop-timeout 30 +``` + +The file must return an unstarted client; the command starts it and keeps the +main thread alive. TERM/INT stop fetching and drain active attempts. TSTP requests +`stop(wait: false)` without exiting; send TERM afterward to finish stop. +The graceful deadline triggers interruption, followed by five seconds for +finalization before forced exit. Allow a longer termination window in your +supervisor. See [Dedicated worker processes](./workers.md) for exit statuses, +recovery, and rolling replacements. Do not keep your old application-owned signal +loop when adopting this command. + +Budget database connections for workers, queue producers, maintenance, and any +web traffic sharing a pool. Load-test the intended process count and concurrency; +do not set pool size equal to job concurrency without allowing overhead. Monitor +database contention, particularly when using SQLite. Create clients after any +prefork step. Starting a client in every web initializer starts consumers in +every web process; use insert-only clients there unless that is intentional. + +## 6. Translate retries, cancellation, and timeouts + +Sidekiq's integer `retry: n` counts retries after the first execution. River's +`max_attempts` counts all attempts. For fresh jobs, use `n + 1`; Sidekiq's default +25 retries corresponds to 26 attempts, while River defaults to 25 attempts. +Retry schedules also differ. See +[Sidekiq Error Handling](https://github.com/sidekiq/sidekiq/wiki/Error-Handling). + +| Existing behavior | Migration decision | +| --- | --- | +| `retry: 5` | `max_attempts: 6` on insertion | +| `retry: 0` or `retry: false` | `max_attempts: 1` prevents retries; River retains a failed job as `discarded`, so deletion/dead-set behavior needs a separate decision | +| `sidekiq_retry_in` | Implement `next_retry(job, error)` returning an absolute `Time`, not delay seconds | +| `sidekiq_retries_exhausted`, death handlers | Implement durable terminal-failure handling in application code; there is no matching callback registration API | +| Permanent cancellation | Raise `River.job_cancel(reason)` from work | +| Dependency not ready | Raise `River.job_snooze(seconds)` to reschedule without consuming an attempt | + +For a fixed retry delay: + +```ruby +class FulfillOrderWorker + def next_retry(_job, _error) + Time.now.utc + 60 + end +end +``` + +The runtime records raised errors. Do not rescue an error and silently return if +the job should retry: a normal return means success. River's default timeout is +60 seconds. Review existing job durations explicitly; configure `job_timeout`, +or implement `timeout(job)` returning seconds, `nil` to disable, or `0` to inherit +the client default. Use network-client timeouts as well. + +An error reporter must avoid accidentally requesting cancellation: + +```ruby +error_handler = lambda do |error, job| + ErrorReporter.capture(error, job_id: job.id, kind: job.kind) + nil # Returning true or :cancel tells River to cancel the job. +end +config = River::Config.new(error_handler: error_handler) +``` + +Core River keeps discarded jobs in `river_job`; configure retention deliberately. +Defaults are one day for completed/cancelled jobs and seven days for discarded +jobs. `nil` or `-1` retains a state indefinitely. River Pro dead-letter storage +is separate and requires Pro configuration. Neither retention model should be +assumed equivalent to the Sidekiq Dead set. + +## 7. Replace middleware and execution context + +Sidekiq has separate client and server middleware chains. River accepts plugin +instances through `Config.new(plugins: [...])`; see +[Sidekiq Middleware](https://github.com/sidekiq/sidekiq/wiki/Middleware) and +[River's plugin signatures](README.md#plugins). + +| Purpose | River plugin method | +| --- | --- | +| Modify each insertion's metadata | Hook: `insert_begin(params)` | +| Observe each insertion result | Hook: `insert_end(result)` | +| Wrap insertion | Middleware: `insert_many(params, operation)` | +| Restore context before work | Hook: `work_begin(job)` | +| Observe return/error | Hook: `work_end(job, error)` | +| Wrap work and clean up context | Middleware: `work(job, operation)` | + +```ruby +class LocalePlugin + def insert_begin(params) + params.metadata["locale"] = I18n.locale.to_s + end + + def work(job, operation) + I18n.with_locale(job.metadata.fetch("locale", I18n.default_locale)) do + operation.call + end + end +end +``` + +Register insertion plugins on every producer, including clients used by workers +to enqueue children. Register work plugins on consumers. Earlier plugins wrap +later plugins; for Rails, put `RailsExecutionPlugin` before application context +plugins. Keep attempt-specific context out of shared instance variables and +restore thread-local context even on exceptions. + +Middleware must call `operation.call` to continue. Do not port a Sidekiq +middleware veto by returning early from River work middleware: normal return can +mark a job completed without executing its worker. Use explicit cancellation or +application validation. `insert_end` is not an after-commit notification; it can +run inside an outer transaction that later rolls back. + +Subscriptions are useful for local telemetry, but events are bounded, may be +dropped, and are not a durable cross-process callback system. Use persisted +application records or workflow tasks for essential follow-up actions. + +## 8. Handle Active Job and Action Mailer explicitly + +An Active Record driver alone is not an Active Job adapter. To keep existing +Active Job and Action Mailer jobs, install `riverqueue-rails` and run: + +```sh +bin/rails generate river:install +bin/rails river:migrate +bin/jobs start +``` + +The installer configures `config.active_job.queue_adapter = :river`. Existing +`perform_later` and `deliver_later` then use River. Review the generated queue +configuration and the [Rails integration guide](../rails/riverqueue-rails/README.md) +before cutover. In particular, Active Job owns application retries: unhandled +exceptions discard a River delivery without an additional backend retry cycle. +Disable after-commit deferral if relying on same-connection atomic enqueueing. +The integration preserves Active Job serialization, callbacks, GlobalID, locale, +and execution context; it does not import existing Redis jobs. + +Alternatively, keep Active Job on its existing backend during migration, or +extract its business operation into a native River worker. Translate callbacks, +`retry_on`, `discard_on`, GlobalID deserialization, locale, and queue naming +deliberately. For email, enqueue a native River job containing recipient/model +IDs and call `deliver_now` from that worker. Calling `deliver_later` there would +enqueue another Active Job rather than finish the email in River. + +## 9. Map recurring, unique, and commercial features + +### Recurring schedules + +For an hourly interval, configure a periodic job on the worker client: + +```ruby +periodic = River::PeriodicJob.new( + id: :hourly_order_reconciliation, + constructor: -> { + [River::JobArgsHash.new("reconcile_orders", {}), + River::InsertOpts.new(queue: :orders)] + }, + schedule: River::PeriodicInterval.new(3600) +) +config = River::Config.new( + periodic_jobs: [periodic], + queues: {orders: 10}, + workers: workers # Also register a worker for "reconcile_orders". +) +``` + +Pass this config to the consuming client. An interval is not a cron expression: +for calendar schedules, add `gem "fugit", "~> 1.13"` and use +`River::PeriodicCron.new("0 9 * * 1-5", timezone: "America/New_York")` as the +`schedule:`. Fugit is optional and loaded only when constructing this helper; +the default timezone is UTC. Custom schedules can still respond to `next(time)` +or be callables returning the next `Time` after the supplied time. +Test daylight-saving transitions and missed runs. Core schedules live in memory; +Pro offers durable scheduling. Configure compatible schedules on clients eligible +for leadership. Disable the old recurring producer when enabling its replacement +so both schedulers do not enqueue the same occurrence. + +### Uniqueness + +Sidekiq Enterprise `unique_for` is a lock TTL. River `by_period` uses time buckets +derived from scheduling time; it is not a sliding lock TTL. `unique_until: :start` +also has no direct mapping because River requires `running` among custom unique +states. See [Sidekiq uniqueness](https://github.com/sidekiq/sidekiq/wiki/Ent-Unique-Jobs). + +For uniqueness while equivalent work remains unfinished: + +```ruby +unique = River::UniqueOpts.new( + by_args: true, + by_queue: true, + by_state: %w[available pending running scheduled retryable] +) +result = AppJobs.client.insert( + FulfillOrderArgs.new(order_id: 42), + unique_opts: unique +) +duplicate = result.unique_skipped_as_duplicate? +``` + +This deliberately excludes completed jobs; River's default unique state set +includes them until retention removes them. A duplicate returns the existing +row, which callers must not treat as newly inserted. Uniqueness does not make +external side effects exactly once and does not deduplicate across Redis and SQL. + +### Sidekiq Pro batches + +Sidekiq batches coordinate a collection of jobs and callbacks. River Pro workflows +are the closer abstraction; `River::Pro::BatchWorker` instead processes multiple +jobs in a single `work_many` invocation. See +[Sidekiq Batches](https://github.com/sidekiq/sidekiq/wiki/Batches). + +With the privately distributed `riverqueue-pro` installed and its migrations +applied, a fan-out followed by a success task looks like: + +```ruby +require "riverqueue-pro" + +# Register "import_row" and "finish_import" workers before starting this client. +pro_client = River::Pro::Client.new( + River::Driver::ActiveRecord.new, + config: River::Pro::Config.new( + core: River::Config.new(queues: {imports: 10}, workers: workers) + ) +) +workflow = River::Pro.workflow name: "import" do |flow| + tasks = [101, 102].map do |row_id| + flow.add( + "row_#{row_id}", + River::JobArgsHash.new(:import_row, {row_id: row_id}), + queue: :imports + ) + end + flow.add( + :finish, + River::JobArgsHash.new(:finish_import, {import_id: 7}), + after: tasks, + queue: :imports + ) +end +pro_client.insert_many workflow.jobs +``` + +A consuming Pro client must be running. By default, cancelled/discarded +dependencies prevent the success task from running. Sidekiq's `complete` +callback means all jobs have run once, which differs from dependency finalization; +its `death` callback also requires explicit redesign. Test retry, cancellation, +dynamic task addition, empty input, and terminal-failure behavior before replacing +a production batch. + +### Other feature decisions + +| Sidekiq feature or extension | River migration path | +| --- | --- | +| Enterprise rate limiting | Application rate limiter; Pro concurrency limits bound simultaneous jobs, not requests per second | +| Enterprise periodic scheduling | Core periodic jobs or Pro durable periodic jobs; review calendar semantics | +| Enterprise encryption | Pro `EncryptPlugin` with an application encryptor; decode and re-encode old payloads explicitly | +| Expiring jobs | Application deadline check with explicit cancellation; retention and execution timeouts do not expire queued jobs | +| Long-running iteration/checkpoints | Core resumable steps/cursors; checkpoint formats require explicit translation | +| Sequential work | Pro sequences, with explicit grouping and failure policy | +| Web UI and metrics | Deploy River UI separately and integrate plugins/subscriptions; no `Sidekiq::Web` Rack mount replacement in this gem | +| Multi-process supervision, rolling restarts | Application deployment supervisor and River stop policy | + +For exact Pro APIs and distribution requirements, see the +README in the private `riverqueue-ruby-pro` repository. +It is a separate package and may not be present in a public checkout. Do not +assume that a Sidekiq commercial feature has identical behavior in River Pro. + +## 10. Rewrite tests against persisted behavior + +River has no equivalent of Sidekiq's fake/inline harness, including the newer +`Sidekiq.testing!` API. See [Sidekiq Testing](https://github.com/sidekiq/sidekiq/wiki/Testing). +Unit-test business operations directly, then test enqueueing, rollback, and +execution against an isolated, migrated database matching production's adapter. + +The core gem includes [database-backed testing helpers](./testing.md), with +optional RSpec and Minitest integrations. These assert new persisted rows (not +existing rows returned by uniqueness) and can execute one real attempt on the +calling thread: + +```ruby +require "riverqueue/testing/rspec" + +RSpec.configure { |config| config.include River::Testing::RSpec } + +expect { enqueue_order(42) }.to insert_job( + AppJobs.client, + args: {"order_id" => 42}, + kind: "fulfill_order" +) + +row = AppJobs.client.insert(FulfillOrderArgs.new(order_id: 42)).job +result = River::Testing.perform_job(AppJobs.client, row.id) +expect(result).to have_attributes(error: nil, outcome: :completed) +``` + +Use a stopped client with registered workers and an isolated database. Unlike +threaded execution, synchronous helpers can see jobs in the caller's transaction +when the driver uses that same connection. They bypass queue capacity/pause and +do not run maintenance or periodic producers. Keep threaded smoke tests for +those runtime behaviors. + +An RSpec insertion test, using the application accessor from section 3: + +```ruby +it "enqueues the expected payload and options" do + result = AppJobs.client.insert(FulfillOrderArgs.new(order_id: 42)) + row = AppJobs.client.job_get(result.job.id) + expect(row).to have_attributes( + args: {"order_id" => 42}, + kind: "fulfill_order", + max_attempts: 6, + queue: "orders" + ) +end +``` + +An execution smoke test that exercises the real runtime without application +business dependencies: + +```ruby +class MigrationProbeWorker + def self.kind = "migration_probe" + def work(job) = job.output = {seen: job.args.fetch("value")} +end + +it "works a committed job" do + client = River::Client.new( + River::Driver::ActiveRecord.new, + config: River::Config.new( + queues: {migration_test: 1}, + workers: River::Workers.new.add(MigrationProbeWorker) + ) + ) + result = client.insert( + River::JobArgsHash.new(:migration_probe, {value: 42}), + insert_opts: River::InsertOpts.new(queue: :migration_test) + ) + begin + client.start + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5 + loop do + row = client.job_get(result.job.id) + break if row.state == River::JOB_STATE_COMPLETED + raise "job did not complete: #{row.state}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + sleep(0.01) + end + expect(client.job_get(result.job.id).metadata.fetch("output")).to eq("seen" => 42) + ensure + client.stop_and_cancel + end +end +``` + +Disable transactional fixtures for threaded execution tests: other connections +cannot see an uncommitted insertion. Clean up only the isolated test database +after stopping clients. For SQLite, use a temporary file shared by pool +connections rather than separate per-connection in-memory databases. + +Also assert transaction rollback removes the job; failures consume the intended +attempt budget; snoozes do not; schedules do not run early; queue isolation and +uniqueness work; context resets on errors; and stop allows recovery. Test +business idempotency by invoking the same logical operation twice. A direct +`worker.work(job)` unit test does not verify runtime retry or finalization. + +## 11. Cut over existing work and preserve rollback + +Prefer draining Sidekiq while routing new logical work to River, one job kind at +a time. Both consumers may run during the transition, but each enqueue decision +must choose one backend. Keep legacy classes available for old Redis payloads. +If strict ordering matters, finish the old stream before enabling the new one. + +1. Deploy schema, River consumers, converted workers, and a producer routing + switch that initially selects Sidekiq. +2. Verify River with a small canary workload, then switch selected producers. + Include worker-created children and recurring producers in that switch. +3. Drain old ready and in-flight jobs. Account separately for scheduled jobs, + retries, dead jobs, and commercial batch state; an empty ready queue is not + proof that Redis no longer contains relevant work. +4. Keep Sidekiq consumers and necessary schedules available until their assigned + work is finished or explicitly transferred. Track counts and failures by + backend and job kind. +5. Remove Sidekiq dependencies, routes, initializers, deployment processes, and + Redis-only job infrastructure only after that inventory is reconciled. + +If long-lived scheduled/retry work must be transferred, build a separate, +restartable importer using the [Sidekiq public API](https://github.com/sidekiq/sidekiq/wiki/API) +and `River::Client#insert`. There is no atomic transaction spanning Redis and SQL. +Use this transfer protocol: + +1. Stop producers, schedulers, and consumers that can mutate the selected source + jobs, and reconcile in-flight work. Export a stable inventory before deleting + anything. API enumeration of a live queue can race with mutations. +2. Map each allowlisted Sidekiq class to an explicit River kind, argument + transformation, queue, and attempt policy. Reject unknown classes and wrapped + Active Job, encrypted, or batch payloads until a dedicated conversion exists. +3. In one SQL transaction, insert the River job and an application-owned transfer + receipt protected by a unique constraint on source identity (include Redis + namespace/cluster plus JID). Preserve the JID in metadata for correlation. + Keep receipts independently of River job retention. +4. Commit SQL before acknowledging/removing that exact Redis job. On restart, + consult the receipt instead of enqueueing it again. Handle uniqueness results + explicitly; a returned existing River job is not automatically proof that the + intended source job was transferred. +5. Verify receipts against both source inventory and destination jobs, including + source jobs removed after the commit. Do not blindly replay the export. + +Preserve future execution times, and make a deliberate choice about remaining +attempts for retries; do not apply the fresh-job `n + 1` rule to an already +partially executed job. Archive dead jobs or move them into a reviewed manual +retry process rather than making every dead job immediately runnable. Do not +write Sidekiq error histories directly into River's schema. In-flight commercial +batches generally need to finish in Sidekiq or be rebuilt as reviewed workflows. + +For rollback, switch new producers back to Sidekiq while keeping a River consumer +available for work already committed there, or explicitly pause that work and +plan its recovery. Re-enqueueing all River jobs in Redis can duplicate completed +side effects. Keep shared business idempotency keys stable across both backends. + +## Completion criteria + +The migration is complete when every inventory row has a verified destination +and behavior, producer paths choose the intended backend, the deployed consumers +cover all target queues and kinds, integration tests pass on the production +adapter, and old queued/scheduled/retrying work is reconciled. Confirm retry +budgets, timeouts, retention, pool capacity, recurring schedules, and stop +under representative load before removing the old system. + +For further Ruby API details, use the [main guide](README.md), +[client](../lib/client.rb), [configuration](../lib/config.rb), +[insertion options](../lib/insert_opts.rb), and [runtime](../lib/client_runtime.rb). diff --git a/ruby/docs/migrations.md b/ruby/docs/migrations.md new file mode 100644 index 000000000..2640c11be --- /dev/null +++ b/ruby/docs/migrations.md @@ -0,0 +1,118 @@ +# Schema migrations + +River Ruby includes a migration API and the `river` command. Neither needs +Go installed. The gems bundle byte-for-byte copies of the PostgreSQL and SQLite +SQL from Go's River drivers, pinned to release v0.48.0 through migration 008; +only the upstream schema template placeholders are substituted when executing +them. + +## Command line + +Install `riverqueue-sequel` or `riverqueue-activerecord` and the application's +database gem (`pg` or `sqlite3`). The command auto-detects the River driver gem +available in your bundle, preferring Sequel when both are available. This choice +does not need to match your application's driver: both run the same migrations. +Create the database first, then run: + +```sh +bundle exec river migrate-status --database-url postgres://localhost/my_app +bundle exec river migrate-up --database-url postgres://localhost/my_app + +# SQLite, using Sequel: +bundle exec river migrate-up --database-url sqlite://storage/river.sqlite3 +``` + +`DATABASE_URL` supplies the URL when `--database-url` is omitted. ActiveRecord +uses `sqlite3:` URLs instead of Sequel's `sqlite:` URLs. To target an existing +PostgreSQL schema, pass `--schema jobs`; otherwise the connection's current +schema is used. Schema names must be simple SQL identifiers. SQLite always +uses its main schema. + +Up applies all pending versions. `--target N` stops at version N; `--steps N` +limits the number applied. `--dry-run` lists the plan without executing SQL. +Down defaults to one version and requires explicit confirmation: + +```sh +bundle exec river migrate-down --database-url postgres://localhost/my_app --dry-run +bundle exec river migrate-down --database-url postgres://localhost/my_app --yes +``` + +Down migrations can delete jobs and other data. Back up the database and stop +workers first. `--target 0 --yes` removes the complete selected migration line. + +## Ruby API + +```ruby +driver = River::Driver::Sequel.new(DB) +migrator = River::Migrator.new(driver) + +migrator.status # Version/name records with an applied boolean. +migrator.migrate # Up to the newest bundled version. +migrator.migrate(dry_run: true, target: 8) +migrator.migrate(direction: :down) # One version; no interactive confirmation. +``` + +The API accepts either driver and optionally `schema:` for PostgreSQL. `migrate` +returns the migration records applied (or planned in dry-run mode), with their +version, name, and original up/down SQL. Each version's SQL and history update +commit in their own transaction. A failing version rolls back while earlier +versions remain committed, so rerunning resumes safely. Do not wrap the migrator +in an application transaction: some PostgreSQL changes require a real commit +before the next version can run. + +An up migration targeting an already-applied version does nothing, even when +newer migrations are pending. + +## Go compatibility and Pro + +The migrator reads and writes Go's `river_migration` history, including the +legacy pre-version-5 format. Either language can continue from the other's +applied versions. Unknown newer versions and gaps in history cause an error +rather than guessing what SQL to run. It does not baseline an existing schema +that has no River migration history. + +Pro SQL is distributed only inside `riverqueue-pro`, not the public core gem. +SQLite main migration 008 rebuilds `river_job` with `AUTOINCREMENT` so deleted +job IDs are not reused. Its upgrade and downgrade refuse to run while Pro's +workflow/sequence schema is installed; coordinate that upgrade with Pro before +running it on an existing Pro database. PostgreSQL migration 008 is a no-op. + +Migrate main first, then Pro: + +```sh +bundle exec river migrate-up --database-url postgres://localhost/my_app +bundle exec river migrate-up --database-url postgres://localhost/my_app --line pro +``` + +```ruby +require "riverqueue-pro" + +River::Migrator.new(driver).migrate +River::Pro::Migrator.new(driver).migrate +``` + +Remove other migration lines before downgrading main. Run only one migration +process at a time across languages. Ruby migrators take a PostgreSQL advisory +lock per schema; SQLite takes a write lock per version and checks for concurrent +history changes. These are not shared locks with Go's migration runner. + +## Updating the bundled SQL + +`migration/manifest.json` records each canonical file's SHA-256. From `ruby/`, +synchronize or verify against the Go migrations in this same checkout: + +```sh +ruby scripts/sync_migrations.rb +make verify +``` + +Verification checks SQL contents, filenames, license, and manifest checksums. +CI uses the same working-tree comparison; no separate checkout is needed. + +Pro migrations have their own manifest and sync script in the private +`riverqueue-ruby-pro` repository. Run that repository's `make verify` against +the Go Pro checkout; the public sync script handles only the main migrations. + +Review the upstream changes and run the complete test matrix before publishing. +Do not edit the copied SQL independently. Upstream changes are bundled in gem +releases; users do not need the Go repositories or network access at runtime. diff --git a/ruby/docs/testing.md b/ruby/docs/testing.md new file mode 100644 index 000000000..e6afa9647 --- /dev/null +++ b/ruby/docs/testing.md @@ -0,0 +1,173 @@ +# Testing River jobs + +Testing helpers ship in `riverqueue`. They use your real PostgreSQL or SQLite +database through either driver; there is no fake queue, global testing mode, +implicit migration, or automatic cleanup. Require them explicitly: + +```ruby +require "riverqueue/testing" +``` + +Use an isolated test database/schema and a stopped client with registered workers. +Do not run background consumers or concurrent tests against the same job rows. +Your test suite owns migrations and cleanup. Transactional tests work when the +client uses the same database connection as the test transaction: synchronous +execution stays on the calling thread. Sharing a database URL is not sufficient. + +## RSpec + +Add `rspec-expectations` (or `rspec`) to your test bundle, then: + +```ruby +require "riverqueue/testing/rspec" + +RSpec.configure do |config| + config.include River::Testing::RSpec +end + +expect { enqueue_order(42) }.to insert_job( + client, + args: {"order_id" => 42}, + kind: :fulfill_order, + queue: :orders +) + +expect { enqueue_orders }.to insert_jobs(client, count: 3, kind: :fulfill_order) +expect { ignore_duplicate_order }.not_to insert_job(client) +``` + +Attributes support composable RSpec expectations, including nested hash/array +matchers and time tolerances. Literal hashes still require all their keys to +match; use `a_hash_including` for a subset. JSON object keys are strings. +Identifier filters (`kind:`, `queue:`, and `state:`) accept symbols or strings; +literal JSON values and nested matchers are not coerced. + +```ruby +expect { enqueue_order(42) }.to insert_job( + client, + args: a_hash_including("order_id" => 42), + scheduled_at: be_within(1).of(expected_time) +) + +expect { enqueue_orders }.to insert_jobs(client, kind: :fulfill_order).at_least(2) +expect { enqueue_orders }.to insert_jobs(client).at_most(5) +expect { enqueue_orders }.to insert_jobs(client).exactly(3) +``` + +`insert_job` and `insert_jobs` default to exactly one matching new row. `count:` +on `insert_jobs` remains shorthand for an exact count. Count chains accept +nonnegative integers; the last chain determines the count requirement. Other, +nonmatching jobs do not affect the count. Negation means **zero** matching rows, +not merely failure of the positive count requirement—even with `at_most` or +`at_least`. Use a positive `.exactly(0)` to assert a zero count explicitly. + +Use `have_job` to inspect existing persisted jobs rather than insertions inside +a block. It defaults to at least one match and supports the same count chains: + +```ruby +expect(client).to have_job(kind: :fulfill_order, queue: :orders) +expect(client).to have_job(args: a_hash_including("order_id" => 42)).exactly(1) +expect(client).not_to have_job(state: :discarded) + +expect { enqueue_order_and_notification }.to( + insert_job(client, kind: :fulfill_order) + .and(insert_job(client, kind: :notify_order)) +) +``` + +`have_job` includes every job state unless filtered with `state:`; it does not +mean only waiting jobs. Insertion matchers support block expectations and +`have_job` supports value expectations. Compound insertion expectations execute +the application block once. Matchers only read persisted rows; they do not run +workers or clean up data. `River::Testing.jobs(client)` also returns all rows +through paginated reads for custom assertions. + +These richer comparisons are RSpec-only. Framework-neutral and Minitest +insertion assertions use the same identifier normalization, but otherwise retain +exact attribute equality and exact counts. + +## Minitest and other frameworks + +Add `minitest` to your test bundle, then explicitly include its integration: + +```ruby +require "minitest/autorun" +require "riverqueue/testing/minitest" + +class OrderTest < Minitest::Test + include River::Testing::Minitest + + def test_enqueue + row = assert_job_inserted client, args: {"order_id" => 42}, kind: :fulfill_order do + enqueue_order(42) + end + assert_equal "orders", row.queue + + assert_jobs_inserted(client, count: 3) { enqueue_orders } + assert_no_jobs_inserted(client) { ignore_duplicate_order } + end +end +``` + +`client`, `enqueue_order`, and `enqueue_orders` above are application/test fixture +methods. The integration uses Minitest's assertion counts and failure reporting; +it does not enable autorun itself. Other frameworks can include +`River::Testing::Assertions` (failures raise `River::Testing::AssertionError`) or +use `River::Testing.inserted_jobs(client, **attributes) { ... }` directly. + +Insertion checks compare row IDs before and after the block. A uniqueness conflict +returning an existing row does not count as an insertion. Rows deleted or rolled +back before the block ends cannot be observed. Exceptions from your block propagate. + +## Execute one real attempt + +```ruby +result = River::Testing.perform_job(client, row.id) + +expect(result).to have_attributes(error: nil, outcome: :completed) +expect(result.job).to have_attributes(attempt: 1, state: "completed") +``` + +This claims the specified job atomically, then uses the real runtime, including +plugins, timeout, retries, error handling, snoozes, cancellation, resumable steps, +metadata/output persistence, and finalization. It does not start consumer threads, +maintenance services, or periodic producers. The client cannot be started or used +for another synchronous attempt until execution finishes. + +`ExecutionResult` contains `id`, `error` (the original exception, or `nil`), `job` +(the row after execution, or `nil` if deleted), and `outcome`: `:completed`, +`:retried`, `:discarded`, `:cancelled`, `:snoozed`, `:interrupted`, or `:deleted`. +Worker errors are returned, not re-raised; assert the outcome so a failed worker +cannot accidentally pass your test. Database/finalization failures can still raise. + +Minitest and framework-neutral assertion modules also provide +`assert_job_completed(result)`, `assert_job_cancelled(result)`, and +`assert_job_discarded(result)`; each returns the result on success. + +Only available, scheduled, or retryable jobs are eligible. Future jobs require an +explicit override; this does not change their original `scheduled_at`: + +```ruby +result = River::Testing.perform_job(client, row.id, allow_scheduled: true) +``` + +Direct execution bypasses queue pause, worker capacity, and Pro concurrency claim +controls. It is a worker test helper, not a scheduler simulator. Pro plugins may +have additional effects (such as a batch worker claiming additional jobs). +Use regular threaded integration tests for these behaviors and maintenance-driven +workflow progression. With Rails, continue to use `ActiveJob::TestHelper` for +adapter-independent tests; use these helpers with a real River adapter/client to +test persistence and execution. + +## Drain a queue + +```ruby +results = River::Testing.drain(client, max_jobs: 20, queue: :orders) +expect(results.map(&:outcome)).to all(eq(:completed)) +``` + +Draining runs due jobs sequentially in priority, scheduled-time, then ID order, +including jobs inserted by workers. It does not sleep for future jobs or run +maintenance. Due scheduled/retryable jobs can be claimed directly. The default +limit is 100 attempts; if runnable work remains at the limit, it raises +`River::Testing::DrainLimitError`. Attempts already performed remain persisted. diff --git a/ruby/docs/workers.md b/ruby/docs/workers.md new file mode 100644 index 000000000..49f8b596c --- /dev/null +++ b/ruby/docs/workers.md @@ -0,0 +1,126 @@ +# Dedicated worker processes + +Run River consumers separately from web processes with `river worker`. The +command owns one foreground process: it boots your application, starts a client, +handles stop signals, and waits for workers. It does not daemonize, fork +children, run migrations, or supervise replacement processes. + +## Plain Ruby + +Install `riverqueue`, your driver gem, and the database gem that driver needs. +Apply [migrations](./migrations.md) before starting consumers. Create a Ruby +configuration file whose **last expression returns an unstarted client**: + +```ruby +# config/river.rb +require_relative "../app" # Boots your application and defines SortWorker. +require "riverqueue-sequel" + +River::Client.new( + River::Driver::Sequel.new( + Sequel.connect(ENV.fetch("DATABASE_URL"), max_connections: 20) + ), + config: River::Config.new( + queues: {ruby: 10}, + workers: River::Workers.new.add(SortWorker) + ) +) +``` + +Run from your application's root directory: + +```sh +bundle exec river worker --config config/river.rb +``` + +Do not call `.start` in the configuration file or install your own signal +handlers. Configure at least one queue and register its workers. Producers must +insert into the same database/schema and queue names. Ten workers means up to +ten concurrent job threads in this process, not ten OS processes. + +The configuration file is evaluated as Ruby, not parsed as data; use only trusted +application code. It can return an ActiveRecord-backed client or a +`River::Pro::Client` instead. Pro must be installed and required by the application; +the command does not load it automatically. Pool sizing is application-specific: +allow connections for queue producers, maintenance, and application database work +as well as worker threads. The example's pool size is illustrative. + +## Rails + +Install and configure [riverqueue-rails](../rails/riverqueue-rails/README.md), then +run from the Rails application root: + +```sh +RAILS_ENV=production bundle exec river worker --rails +``` + +This boots `config/environment.rb` and builds a consumer from +`Rails.application.config.river`, including the configured connection class, +Active Job worker, and Rails execution wrapping. Configure native workers through +the same integration when needed. Keep web processes insertion-only; do not start +a consumer in a web initializer. + +The generated `bin/jobs start` also invokes the shared runner. Use the `river` +command for the CLI options shown here. Do not combine `--rails` with `--config`. + +## Stopping + +```sh +bundle exec river worker --config config/river.rb --stop-timeout 60 +``` + +The graceful stop timeout defaults to 30 seconds for plain Ruby. Rails uses +`config.river.stop_timeout`; `--stop-timeout` overrides it. The value must +be finite and nonnegative; zero skips the grace period. It is separate from the +per-job execution timeout. + +| Signal / event | Behavior | +|-----------------------|------------------------------------------------------------------------------------------| +| `SIGTSTP` | Calls `client.stop(wait: false)`: stops accepting work but keeps the process alive. | +| `SIGTERM` / `SIGINT` | Requests a stop, waits for active attempts, then exits. | +| Another stop signal | Skips the remaining grace period and interrupts active attempts. | +| Grace period expires | Interrupts active attempts and allows five seconds for finalization. | +| Finalization expires | Forces process exit with status 1, bypassing `at_exit` handlers. | + +After interruption, a further stop signal can force exit before the finalization +window ends. In-flight fetches or maintenance operations may finish after stop +is requested. `SIGTSTP` does not pause queues in the database, does not start the +stop deadline, and does not exit automatically when work drains. Send +`SIGTERM` afterward to complete stop; there is no signal to resume fetching. + +Attempts successfully finalized as interrupted become available again without +consuming the attempt. If the process dies before finalization, running jobs are +left for normal stuck-job recovery. Workers must tolerate retries; stop cannot +roll back external side effects. Keep an external supervisor's kill deadline as +a backstop for uninterruptible native calls or blocked application code. + +For `river worker`, a clean drain exits with status 0. Boot failures, detected +runtime-thread failures, and interrupted stops return status 1; forced +termination also exits with status 1. Lifecycle messages go to standard output; +worker logging uses the client's configured logger. + +## Deployment + +Configure your deployment platform's worker command as one of the commands above. +Run multiple independent instances for multiple processes; queue concurrency is +per process. A shell wrapper should use `exec` so signals reach the worker: + +```sh +exec bundle exec river worker --config config/river.rb --stop-timeout 30 +``` + +Give the supervisor more than the graceful timeout plus the five-second +finalization window before it forcibly kills the process, with additional margin +for scheduling and cleanup. Migrate once as a deployment step, not on each worker +boot. Configure restart policy in the supervisor, accounting for status 1 after +an interrupted stop. + +For a rolling replacement, start new instances, confirm startup and database +connectivity, then send `SIGTERM` to old instances and allow them to drain. An +optional earlier `SIGTSTP` stops old instances from accepting more work. The +`River worker: ready pid=...` log means the initial database check and client start +have completed; it is not a health endpoint or a guarantee of ongoing queue health. + +River does not provide a multiprocess supervisor, rolling-restart coordinator, +PID-file management, or a readiness probe server. Those remain deployment-platform +responsibilities. diff --git a/ruby/docs/yugabyte.md b/ruby/docs/yugabyte.md new file mode 100644 index 000000000..31428507b --- /dev/null +++ b/ruby/docs/yugabyte.md @@ -0,0 +1,77 @@ +# YugabyteDB + +Both SQL drivers support YugabyteDB through its PostgreSQL-compatible YSQL +endpoint. Use the `pg` gem and the usual PostgreSQL connection configuration; +no separate River driver or client flag is required. Apply the bundled +[PostgreSQL migrations](migrations.md). + +```ruby +db = Sequel.connect("postgres://yugabyte@localhost:5433/my_app") +client = River::Client.new(River::Driver::Sequel.new(db)) + +# Or Active Record: +ActiveRecord::Base.establish_connection("postgres://yugabyte@localhost:5433/my_app") +client = River::Client.new(River::Driver::ActiveRecord.new) +``` + +## Capability detection + +Ruby follows Go's database detection and unique-insert strategies: + +| Server | Duplicate detection | +| --- | --- | +| YugabyteDB | A random nonce in reserved metadata `river:unique_nonce` | +| PostgreSQL 18+ | `OLD.id IS NOT NULL` in `RETURNING` | +| Earlier PostgreSQL | `xmax != 0` | + +Yugabyte's product identification takes precedence over its PostgreSQL version. +The nonce is generated per returning insert batch, just as in Go's PostgreSQL +drivers. Existing jobs without a nonce are still recognized as duplicates; +application metadata is preserved, apart from the reserved nonce key. + +Detection happens at worker startup or lazily for insert-only clients. A +successful result is cached for each connection pool used by a driver, without +holding a lock across database I/O. Failed detection can be retried. Active +Record roles/shards use separate cache entries. Create a new driver after +changing server capabilities or notification settings. + +## Notifications and transactions + +If `yb_enable_listen_notify` is absent or false, the drivers omit native +notifications. Ruby workers already poll for jobs, queue settings, and remote +cancellations, so no additional polling option is necessary. Cancellation +markers and inserted jobs become visible to other connections only when the +application transaction commits; rollback leaves neither behind. + +When native notifications are enabled, insertion and cancellation publish the +same commit-bound notifications as Go. Ruby still consumes changes by polling; +it does not yet implement a notification listener. + +Native notifications require YugabyteDB 2025.2.3 or later and +`ysql_yb_enable_listen_notify=true` on both Masters and TServers. Follow +[Yugabyte's LISTEN/NOTIFY setup](https://docs.yugabyte.com/stable/api/ysql/the-sql-language/statements/cmd_listen_notify/), +including its replication prerequisites. This is optional, not a prerequisite +for using River. + +Transactions are delegated to Active Record or Sequel, with River-owned writes +and hooks remaining in the transaction. Database errors propagate; River does +not replay application transactions or hooks automatically. + +## Verification + +Normal driver tests mirror Go's PostgreSQL-based Yugabyte simulations, covering +an absent, disabled, or enabled notification setting. Disabled simulations make +`pg_notify` raise to detect accidental broadcasts. These do not emulate +Yugabyte's storage or transaction semantics. + +Run the real-database tests separately with a disposable YSQL database: + +```sh +make test/yugabyte YUGABYTE_DATABASE_URL=postgres://yugabyte@localhost:5433/river_test +``` + +The target requires a database and tests both adapters in temporary schemas, +including canonical migrations, batch uniqueness, metadata, transaction +rollback, and remote cancellation. For a server with native notifications +configured, also set `YUGABYTE_LISTEN_NOTIFY_ENABLED=1`; this additionally checks +notification delivery and commit/rollback behavior. diff --git a/ruby/driver/riverqueue-activerecord/Gemfile b/ruby/driver/riverqueue-activerecord/Gemfile new file mode 100644 index 000000000..fbff1c6b2 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/Gemfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +group :development, :test do + gem "riverqueue", path: "../.." + gem "standard" +end + +group :test do + gem "debug" + gem "pg" + gem "rspec-core" + gem "rspec-expectations" + gem "simplecov", require: false + gem "sqlite3" +end diff --git a/ruby/driver/riverqueue-activerecord/Gemfile.lock b/ruby/driver/riverqueue-activerecord/Gemfile.lock new file mode 100644 index 000000000..3d3dc2413 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/Gemfile.lock @@ -0,0 +1,166 @@ +PATH + remote: ../.. + specs: + riverqueue (0.12.0) + logger (> 0, < 1000) + optparse (> 0, < 1000) + securerandom (> 0, < 1000) + timeout (> 0, < 1000) + +PATH + remote: . + specs: + riverqueue-activerecord (0.12.0) + activerecord (> 0, < 1000) + activesupport (> 0, < 1000) + riverqueue (= 0.12.0) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) + timeout (>= 0.4.0) + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.0) + concurrent-ruby (1.3.7) + connection_pool (3.0.2) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.6.2) + docile (1.4.1) + drb (2.2.3) + erb (6.0.4) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.3) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + optparse (0.8.1) + parallel (1.27.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-linux) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + racc (1.8.1) + rainbow (3.1.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.84.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + sqlite3 (2.9.5-arm64-darwin) + sqlite3 (2.9.5-x86_64-linux-gnu) + standard (1.54.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.84.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + stringio (3.2.0) + timeout (0.6.1) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + +PLATFORMS + arm64-darwin-22 + arm64-darwin-23 + arm64-darwin-24 + arm64-darwin-25 + x86_64-linux + +DEPENDENCIES + debug + pg + riverqueue! + riverqueue-activerecord! + rspec-core + rspec-expectations + simplecov + sqlite3 + standard + +BUNDLED WITH + 4.0.9 diff --git a/ruby/driver/riverqueue-activerecord/README.md b/ruby/driver/riverqueue-activerecord/README.md new file mode 100644 index 000000000..f5546bb07 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/README.md @@ -0,0 +1,3 @@ +# riverqueue-activerecord + +See the [ActiveRecord driver documentation](./docs/README.md). diff --git a/ruby/driver/riverqueue-activerecord/docs/README.md b/ruby/driver/riverqueue-activerecord/docs/README.md new file mode 100644 index 000000000..aa51a8a01 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/docs/README.md @@ -0,0 +1,50 @@ +# riverqueue-activerecord + +[ActiveRecord](https://guides.rubyonrails.org/active_record_basics.html) driver for [River](https://github.com/riverqueue/river)'s [`riverqueue` gem for Ruby](https://rubygems.org/gems/riverqueue). PostgreSQL and SQLite are supported. + +Add this driver and only the database adapter used by the application to +`Gemfile`. The driver pulls in the core gem: + +```ruby +gem "riverqueue-activerecord" +gem "pg" # or: gem "sqlite3" +``` + +For PostgreSQL, add `pg` to `Gemfile`: + +```ruby +gem "pg" +``` + +Then initialize a client with ActiveRecord's established connection: + +```ruby +ActiveRecord::Base.establish_connection("postgres://localhost/my_app") +client = River::Client.new(River::Driver::ActiveRecord.new) +``` + +For SQLite, add `sqlite3` to `Gemfile`: + +```ruby +gem "sqlite3" +``` + +Then initialize a client with an SQLite connection: + +```ruby +ActiveRecord::Base.establish_connection( + adapter: "sqlite3", + database: "storage/river.sqlite3", + timeout: 5_000 +) +client = River::Client.new(River::Driver::ActiveRecord.new) +``` + +Use current River migrations to create and update the SQLite database. + +YugabyteDB is supported through the PostgreSQL adapter, with automatic capability +detection. See [YugabyteDB setup and behavior](../../../docs/yugabyte.md). + +## Development + +See [development](./development.md). diff --git a/ruby/driver/riverqueue-activerecord/docs/development.md b/ruby/driver/riverqueue-activerecord/docs/development.md new file mode 100644 index 000000000..c9b88ad9a --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/docs/development.md @@ -0,0 +1,46 @@ +# riverqueue-ruby development + +## Install dependencies + +```shell +$ bundle install +``` +## Run tests + +Create a test database: + +```shell +$ createdb river_test +``` + +Tests migrate their own disposable schemas using the bundled SQL and leave +existing tables untouched. The database user needs permission to create and drop +schemas. Set `TEST_DATABASE_URL` to use a different PostgreSQL database. + +Generate shared fixtures from the repository root with `make generate/fixtures` +before invoking the driver specs directly. + +Run all specs: + +```shell +$ bundle exec rspec spec +``` + +## Run lint + +```shell +$ standardrb --fix +``` + +## Code coverage + +Running the entire test suite will produce a coverage report, and will fail if line and branch coverage is below 100%. Run the suite and open `coverage/index.html` to find lines or branches that weren't covered: + +```shell +$ bundle exec rspec spec +$ open coverage/index.html +``` + +## Publish a new gem + +Release all four gems together using the [Ruby release instructions](../../../docs/development.md#publish-gems). diff --git a/ruby/driver/riverqueue-activerecord/lib/driver.rb b/ruby/driver/riverqueue-activerecord/lib/driver.rb new file mode 100644 index 000000000..b99a2225a --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/lib/driver.rb @@ -0,0 +1,374 @@ +# frozen_string_literal: true + +require "securerandom" + +module River::Driver + # Provides an ActiveRecord driver for River supporting PostgreSQL, YugabyteDB, + # and SQLite. + # + # Used in conjunction with a River client like: + # + # ActiveRecord::Base.establish_connection("postgres://...") + # client = River::Client.new(River::Driver::ActiveRecord.new) + # + class ActiveRecord + include River::Driver::Runtime + + # Connection class whose pool and transaction context this driver uses. + attr_reader :connection_class + + # Uses an established PostgreSQL or SQLite connection. The class must be + # ActiveRecord::Base or an abstract Active Record class. Routing follows its + # current role/shard; configure consumers explicitly for each database. + def initialize(connection_class: ::ActiveRecord::Base) + unless connection_class.is_a?(Class) && connection_class <= ::ActiveRecord::Base + raise ArgumentError, "connection_class must be an Active Record class" + end + + unless connection_class == ::ActiveRecord::Base || connection_class.abstract_class? + raise ArgumentError, "connection_class must be abstract" + end + + @connection_class = connection_class + @postgres_capabilities_cache = PostgresCapabilities::Cache.new + @is_sqlite = connection_class.connection.adapter_name.downcase.include?("sqlite") + # Do not inherit application scopes, callbacks, or schema caches. Each + # driver owns its model, but all operations use the selected class's pool. + @job_model = Class.new(::ActiveRecord::Base) do + self.table_name = "river_job" + define_singleton_method(:connection_pool) { connection_class.connection_pool } + + def self.dangerous_attribute_method?(method_name) + return false if method_name == "errors" + + super + end + + def errors = {} + end + end + + def job_get_by_id(id) + if @is_sqlite + row = sqlite_job_rows("WHERE id = ? LIMIT 1", [id]).first + row ? sqlite_to_job_row_from_raw(row) : nil + else + row = @job_model.find_by(id: id) + row ? to_job_row_from_model(row) : nil + end + end + + def job_insert(insert_params) + job_insert_many([insert_params]).first + end + + def job_insert_many(insert_params_many) + return [] if insert_params_many.empty? + + return sqlite_job_insert_many(insert_params_many) if @is_sqlite + + transaction do + results = postgres_job_insert_many(insert_params_many) + postgres_notify_insert(insert_params_many) + results + end + end + + def job_list(params = :all) + return super unless params == :all + + runtime_job_rows("ORDER BY id") + end + + def rollback_exception + ::ActiveRecord::Rollback + end + + # Backend used by River::Migrator. + def migration_backend = @is_sqlite ? :sqlite : :postgresql + + # Pins a raw connection for migration SQL and refreshes cached model columns. + def migration_connection + @connection_class.connection_pool.with_connection do |connection| + raise River::Error, "migrations cannot run inside an application transaction" if connection.transaction_open? + + begin + yield connection.raw_connection + ensure + @job_model.reset_column_information + end + end + end + + # Runs the block in a new Active Record transaction or savepoint and returns + # the block's result. + def transaction(&) + @connection_class.transaction(requires_new: true, &) + end + + SQLITE_CONFLICT_WHERE = <<~SQL.chomp.freeze + unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1 + SQL + + # SQLite 3.45+ may store JSON as binary JSONB. Always project JSON columns + # through json() so this driver can read both the current JSONB format and + # the text JSON used by River migrations through version 006. Cast times to + # text so ActiveRecord doesn't interpret timezone-less SQLite timestamps in + # the process timezone. Flag 10 validates JSONB contents, not just its header, + # so damaged blobs reach the row decoder without aborting the SQL query. + SQLITE_JOB_COLUMNS = <<~SQL.chomp.freeze + id, + CASE WHEN json_valid(args, 10) THEN json(args) ELSE args END AS args, + attempt, + CAST(attempted_at AS text) AS attempted_at, + CASE WHEN json_valid(attempted_by, 10) THEN json(attempted_by) ELSE attempted_by END AS attempted_by, + CAST(created_at AS text) AS created_at, + CASE WHEN json_valid(errors, 10) THEN json(errors) ELSE errors END AS errors, + CAST(finalized_at AS text) AS finalized_at, + kind, + max_attempts, + CASE WHEN json_valid(metadata, 10) THEN json(metadata) ELSE metadata END AS metadata, + priority, + queue, + state, + CAST(scheduled_at AS text) AS scheduled_at, + CASE WHEN json_valid(tags, 10) THEN json(tags) ELSE tags END AS tags, + unique_key, + unique_states + SQL + + UNIQUE_INSERT_METADATA_KEY = "river:unique_nonce" + + private_constant :SQLITE_CONFLICT_WHERE, :SQLITE_JOB_COLUMNS, :UNIQUE_INSERT_METADATA_KEY + + private def format_time(time) + time.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N") + end + + private def postgres_insert_params_to_hash(insert_params, nonce) + metadata = insert_params.metadata || {} + metadata = metadata.merge(UNIQUE_INSERT_METADATA_KEY => nonce) if nonce + { + args: JSON.parse(insert_params.encoded_args), + kind: insert_params.kind, + max_attempts: insert_params.max_attempts, + metadata: metadata, + priority: insert_params.priority, + queue: insert_params.queue, + scheduled_at: insert_params.scheduled_at, + state: insert_params.state, + tags: insert_params.tags || [], + unique_key: insert_params.unique_key, + unique_states: insert_params.unique_states + } + end + + private def postgres_job_insert_many(insert_params_many) + capabilities = postgres_capabilities + nonce = SecureRandom.hex(8) if capabilities.unique_insert_mode == :metadata_nonce + res = @job_model.upsert_all( + insert_params_many.map { |param| postgres_insert_params_to_hash(param, nonce) }, + on_duplicate: Arel.sql("kind = river_job.kind"), + returning: Arel.sql("*, #{capabilities.unique_insert_sql} AS unique_skipped_as_duplicate"), + + # It'd be nice to specify this as `(kind, unique_key) WHERE unique_key + # IS NOT NULL` like we do elsewhere, but in its pure ingenuity, fucking + # ActiveRecord tries to look up a unique index instead of letting + # Postgres handle that, and of course it doesn't support a `WHERE` + # clause. The workaround is to target the index name instead of columns. + unique_by: "river_job_unique_idx" + ) + postgres_to_insert_results(res, nonce) + end + + private def postgres_to_insert_results(res, nonce) + res.rows.map do |row| + job, duplicate = postgres_to_job_row_from_raw(row, res.columns, res.column_types) + [job, nonce ? job.metadata[UNIQUE_INSERT_METADATA_KEY] != nonce : duplicate] + end + end + + private def postgres_to_job_row_from_model(river_job) + # Read attributes directly because Active Record shadows the errors column. + runtime_decode_postgres_job(river_job.attributes) + end + + # Upserts bypass model casting; normalize their values before shared decoding. + private def postgres_to_job_row_from_raw(row, columns, column_types) + river_job = {} + + row.each_with_index do |val, i| + river_job[columns[i]] = column_types[i].deserialize(val) + end + + [runtime_decode_postgres_job(river_job), river_job["unique_skipped_as_duplicate"]] + end + + private def runtime_connection_pool + @connection_class.connection_pool + end + + private def runtime_execute(sql) + @connection_class.connection.execute(sql) + end + + private def runtime_job_list_without_params + job_list(:all) + end + + private def runtime_job_rows(suffix) + if @is_sqlite + sqlite_job_rows(suffix).map { |row| sqlite_to_job_row_from_raw(row) } + else + @job_model.find_by_sql("SELECT * FROM river_job #{suffix}").map { |row| to_job_row_from_model(row) } + end + end + + private def runtime_postgres? + !@is_sqlite + end + + private def runtime_query_rows(sql) + if @is_sqlite + @connection_class.connection.raw_connection.execute(sql).to_a + else + @connection_class.connection.select_all(sql).to_a + end + end + + private def runtime_quote(value) + @connection_class.connection.quote(value) + end + + private def runtime_unique_violation_class + ::ActiveRecord::RecordNotUnique + end + + private def runtime_value(row, key) + row[key.to_s] || row[key] + end + + private def sqlite_insert_params_to_hash(insert_params, nonce) + { + args: JSON.parse(insert_params.encoded_args), + kind: insert_params.kind, + max_attempts: insert_params.max_attempts, + metadata: {UNIQUE_INSERT_METADATA_KEY => nonce}, + priority: insert_params.priority, + queue: insert_params.queue, + scheduled_at: insert_params.scheduled_at ? format_time(insert_params.scheduled_at) : nil, + state: insert_params.state, + tags: insert_params.tags || [], + unique_key: insert_params.unique_key&.unpack1("H*"), + unique_states: insert_params.unique_states&.to_i(2) + }.tap { |values| values[:metadata] = (insert_params.metadata || {}).merge(values[:metadata]) } + end + + # River's current SQLite driver uses json_each to make a batch a single, + # atomic statement. The JSON columns are converted to SQLite JSONB here, + # matching migration 007 and newer River databases. + private def sqlite_job_insert_many(insert_params_many) + nonces = sqlite_insert_nonces(insert_params_many) + @connection_class.transaction(requires_new: true) do + jobs = insert_params_many.zip(nonces).map { |param, nonce| sqlite_insert_params_to_hash(param, nonce) } + inserted_nonces = nonces.to_h { |nonce| [nonce, true] } + + sql = <<~SQL + INSERT INTO river_job ( + args, + created_at, + kind, + max_attempts, + metadata, + priority, + queue, + scheduled_at, + state, + tags, + unique_key, + unique_states + ) + SELECT + jsonb(json_extract(value, '$.args')), + datetime('now', 'subsec'), + cast(json_extract(value, '$.kind') AS text), + cast(json_extract(value, '$.max_attempts') AS integer), + jsonb(json_extract(value, '$.metadata')), + cast(json_extract(value, '$.priority') AS integer), + cast(json_extract(value, '$.queue') AS text), + coalesce(cast(json_extract(value, '$.scheduled_at') AS text), datetime('now', 'subsec')), + cast(json_extract(value, '$.state') AS text), + jsonb(json_extract(value, '$.tags')), + CASE + WHEN length(cast(json_extract(value, '$.unique_key') AS text)) = 0 THEN NULL + ELSE unhex(cast(json_extract(value, '$.unique_key') AS text)) + END, + nullif(cast(json_extract(value, '$.unique_states') AS integer), 0) + FROM json_each(cast(? AS blob)) + WHERE true + ON CONFLICT (unique_key) WHERE #{SQLITE_CONFLICT_WHERE} + DO UPDATE SET kind = river_job.kind + RETURNING #{SQLITE_JOB_COLUMNS} + SQL + + rows = @connection_class.connection.raw_connection.execute(sql, [JSON.generate(jobs)]) + sqlite_notify_insert(insert_params_many) + + rows.map do |row| + metadata = JSON.parse(row["metadata"]) + [sqlite_to_job_row_from_raw(row), !inserted_nonces.key?(metadata[UNIQUE_INSERT_METADATA_KEY])] + end + end + end + + private def sqlite_job_rows(suffix, binds = []) + sql = "SELECT #{SQLITE_JOB_COLUMNS} FROM river_job #{suffix}" + @connection_class.connection.raw_connection.execute(sql, binds) + end + + private def sqlite_notify_insert(insert_params_many) + queues = insert_params_many + .select { |param| param.state == ::River::JOB_STATE_AVAILABLE } + .map(&:queue) + .uniq + return if queues.empty? + + notifications = queues.map do |queue| + {payload: JSON.generate({queue: queue}), topic: "river_insert"} + end + + @connection_class.connection.raw_connection.execute(<<~SQL, [JSON.generate(notifications)]) + INSERT INTO river_notification (payload, topic) + SELECT + json_extract(value, '$.payload'), + json_extract(value, '$.topic') + FROM json_each(cast(? AS blob)) + SQL + end + + private def sqlite_to_job_row_from_model(river_job) + row = sqlite_job_rows("WHERE id = ? LIMIT 1", [river_job.id]).first + sqlite_to_job_row_from_raw(row) + end + + private def sqlite_to_job_row_from_raw(row) + runtime_decode_sqlite_job(row) + end + + private def to_job_row_from_model(river_job) + @is_sqlite ? sqlite_to_job_row_from_model(river_job) : postgres_to_job_row_from_model(river_job) + end + end +end diff --git a/ruby/driver/riverqueue-activerecord/lib/riverqueue-activerecord.rb b/ruby/driver/riverqueue-activerecord/lib/riverqueue-activerecord.rb new file mode 100644 index 000000000..ec10611fb --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/lib/riverqueue-activerecord.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "active_record" +require "riverqueue" + +require_relative "driver" + +module River +end diff --git a/ruby/driver/riverqueue-activerecord/riverqueue-activerecord.gemspec b/ruby/driver/riverqueue-activerecord/riverqueue-activerecord.gemspec new file mode 100644 index 000000000..9fe9f835a --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/riverqueue-activerecord.gemspec @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +Gem::Specification.new do |s| + s.name = "riverqueue-activerecord" + s.version = "0.12.0" + s.summary = "ActiveRecord PostgreSQL and SQLite driver for the River Ruby gem." + s.description = "ActiveRecord PostgreSQL and SQLite driver for inserting and working River jobs in Ruby." + s.authors = ["Blake Gentry", "Brandur Leach"] + s.email = "brandur@brandur.org" + s.files = Dir.glob("lib/**/*") + s.homepage = "https://riverqueue.com" + s.license = "MPL-2.0" + s.required_ruby_version = ">= 3.2" + # The stupid version bounds are used to silence Ruby's extremely obnoxious warnings. + s.add_dependency "activerecord", "> 0", "< 1000" + s.add_dependency "activesupport", "> 0", "< 1000" # required for ActiveRecord to load properly + s.add_dependency "riverqueue", "= #{s.version}" +end diff --git a/ruby/driver/riverqueue-activerecord/spec/client_spec.rb b/ruby/driver/riverqueue-activerecord/spec/client_spec.rb new file mode 100644 index 000000000..cb9586b28 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/spec/client_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/support/client_test_database" +require_relative "../../../spec/client_driver_shared_examples" +require_relative "../../../spec/worker_process_shared_examples" +require_relative "../../../spec/insert_notification_shared_examples" +require_relative "../../../spec/row_decoding_shared_examples" + +RSpec.describe "ActiveRecord client integration" do + [:postgres, :sqlite].each do |adapter| + context "with #{adapter}", database: adapter do + before { skip "PostgreSQL unavailable" if adapter == :postgres && !PG_AVAILABLE } + + around do |example| + if adapter == :postgres && !PG_AVAILABLE + example.run + else + ClientTestDatabase.with_active_record(adapter) do |driver| + @driver = driver + example.run + end + end + end + + it_behaves_like "client driver end to end" + it_behaves_like "PostgreSQL state update races" if adapter == :postgres + it_behaves_like "SQLite corrupt job runtime" if adapter == :sqlite + it_behaves_like "PostgreSQL insert notifications" if adapter == :postgres + it_behaves_like "PostgreSQL cancellation notifications" if adapter == :postgres + it_behaves_like "PostgreSQL leadership notifications" if adapter == :postgres + it_behaves_like "PostgreSQL queue control notifications" if adapter == :postgres + it_behaves_like "SQLite cancellation notifications" if adapter == :sqlite + it_behaves_like "SQL scheduling concurrency" if adapter == :postgres + it_behaves_like "PostgreSQL finalized job list plans" if adapter == :postgres + it_behaves_like "PostgreSQL rescue concurrency" if adapter == :postgres + it_behaves_like "dedicated worker process" + end + end +end diff --git a/ruby/driver/riverqueue-activerecord/spec/connection_class_spec.rb b/ruby/driver/riverqueue-activerecord/spec/connection_class_spec.rb new file mode 100644 index 000000000..db59a5655 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/spec/connection_class_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/support/client_test_database" +require_relative "../../../spec/support/connection_class_test_database" + +class SelectedRiverConnection < ActiveRecord::Base + self.abstract_class = true + # The internal River model must not inherit application default scopes. + default_scope { where(application_only_column: "not a river column") } +end + +RSpec.describe "Active Record connection selection" do + [nil, Object, "ActiveRecord::Base", Class.new(ActiveRecord::Base)].each_with_index do |value, index| + it "rejects invalid connection class #{index}" do + expect { River::Driver::ActiveRecord.new(connection_class: value) }.to raise_error(ArgumentError) + end + end + + [:sqlite, :postgres].each do |backend| + next if backend == :postgres && !PG_AVAILABLE + + context "with #{backend}", database: backend do + around do |example| + ClientTestDatabase.with_active_record(backend) do |primary| + ConnectionClassTestDatabase.with_class(SelectedRiverConnection, backend) do |selected| + @primary = primary + @selected = selected + River::Migrator.new(selected).migrate + @client = River::Client.new(selected) + example.run + end + end + end + + it "isolates job models, writes, reads, and runtime operations" do + first = @client.insert(River::JobArgsHash.new("selected", {})).job + other = River::Client.new(@primary).insert(River::JobArgsHash.new("primary", {})).job + + expect(@selected.connection_class).to eq(SelectedRiverConnection) + expect(@selected.job_list.map(&:kind)).to eq(["selected"]) + expect(@primary.job_list.map(&:kind)).to eq(["primary"]) + expect(@client.job_get(first.id).kind).to eq("selected") + expect(@primary.job_get_by_id(other.id).kind).to eq("primary") + @selected.queue_upsert("selected") + + expect(@primary.queue_get("selected")).to be_nil + @client.job_cancel(first.id) + + expect(@client.job_get(first.id).state).to eq("cancelled") + expect(@primary.job_get_by_id(other.id).state).to eq("available") + @selected.transaction do + @client.insert(River::JobArgsHash.new("rollback", {})) + raise ActiveRecord::Rollback + end + + expect(@selected.job_list.map(&:kind)).to eq(["selected"]) + end + + it "joins the selected connection's outer transaction" do + SelectedRiverConnection.transaction do + @client.insert(River::JobArgsHash.new("rollback", {})) + + expect(@selected.job_list.length).to eq(1) + expect(@primary.job_list).to be_empty + raise ActiveRecord::Rollback + end + + expect(@selected.job_list).to be_empty + end + + if backend == :postgres + it "detects capabilities again when the selected class replaces its pool" do + capabilities = @selected.postgres_capabilities + configuration = SelectedRiverConnection.connection_db_config.configuration_hash + SelectedRiverConnection.remove_connection + SelectedRiverConnection.establish_connection(configuration) + + expect(@selected.postgres_capabilities).not_to equal(capabilities) + expect(@selected.postgres_capabilities.unique_insert_mode).to eq(capabilities.unique_insert_mode) + expect(@client.insert(River::JobArgsHash.new(:reconnected, {})).job.kind).to eq("reconnected") + end + end + + it "does not claim atomicity with an unrelated Base transaction" do + ActiveRecord::Base.transaction do + @client.insert(River::JobArgsHash.new("committed", {})) + raise ActiveRecord::Rollback + end + + expect(@selected.job_list.map(&:kind)).to eq(["committed"]) + end + + it "guards migrations against transactions on the selected connection only" do + SelectedRiverConnection.transaction do + expect { River::Migrator.new(@selected).migrate }.to raise_error(River::Error, /transaction/) + end + + ActiveRecord::Base.transaction do + expect { River::Migrator.new(@selected).migrate }.not_to raise_error + end + end + end + end +end diff --git a/ruby/driver/riverqueue-activerecord/spec/driver_spec.rb b/ruby/driver/riverqueue-activerecord/spec/driver_spec.rb new file mode 100644 index 000000000..e20e1f224 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/spec/driver_spec.rb @@ -0,0 +1,395 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/driver_shared_examples" +require_relative "../../../spec/row_decoding_shared_examples" + +RSpec.describe River::Driver::ActiveRecord do + before do + if ENV["RIVER_DEBUG"] == "1" || ENV["RIVER_DEBUG"] == "true" + ActiveRecord::Base.logger = Logger.new($stdout) + end + end + + { + "PostgreSQL" => {adapter: :postgres, available: PG_AVAILABLE}, + "SQLite" => {adapter: :sqlite, available: true} + }.each do |name, config| + next unless config[:available] + + context "with #{name}", database: config[:adapter] do + before(:all) do + if config[:adapter] == :sqlite + switch_to_sqlite! + else + switch_to_postgres! + end + end + + after(:all) do + switch_to_postgres! if config[:adapter] == :sqlite && PG_AVAILABLE + end + + around(:each) { |ex| test_transaction(&ex) } + + let!(:driver) { River::Driver::ActiveRecord.new } + let(:client) { River::Client.new(driver) } + + it_behaves_like "driver shared examples" + it_behaves_like "historical attempt error decoding" + it_behaves_like "SQLite corrupt job isolation" if config[:adapter] == :sqlite + + describe "client inserts" do + it "persists SQLite JSON columns as JSONB objects" do + next unless config[:adapter] == :sqlite + + insert_res = client.insert(SimpleArgs.new(job_num: 1)) + + row = ActiveRecord::Base.connection.exec_query(<<~SQL).first + SELECT + json(args) AS args, + json_type(args) AS args_type, + typeof(args) AS args_storage_type, + typeof(metadata) AS metadata_storage_type, + typeof(tags) AS tags_storage_type, + CAST(created_at AS text) AS created_at, + CAST(scheduled_at AS text) AS scheduled_at + FROM river_job + WHERE id = #{insert_res.job.id} + SQL + + expect(row["args_type"]).to eq("object") + expect(JSON.parse(row["args"])).to eq({"job_num" => 1}) + expect(row.values_at("args_storage_type", "metadata_storage_type", "tags_storage_type")).to eq(["blob", "blob", "blob"]) + expect(row["created_at"]).to match(/\.\d{3}\z/) + expect(row["scheduled_at"]).to match(/\.\d{3}\z/) + expect(insert_res.job.errors).to eq([]) + end + + it "persists PostgreSQL args as a JSON object rather than a JSON string" do + next unless config[:adapter] == :postgres + + insert_res = client.insert(SimpleArgs.new(job_num: 1)) + + row = ActiveRecord::Base.connection.exec_query(<<~SQL).first + SELECT args, jsonb_typeof(args) AS args_type + FROM river_job + WHERE id = #{insert_res.job.id} + SQL + + expect(row["args_type"]).to eq("object") + expect(JSON.parse(row["args"])).to eq({"job_num" => 1}) + end + + it "inserts a SQLite batch atomically" do + next unless config[:adapter] == :sqlite + + expect do + client.insert_many([ + SimpleArgs.new(job_num: 1), + River::InsertManyParams.new( + SimpleArgs.new(job_num: 2), + insert_opts: River::InsertOpts.new(priority: 0) + ) + ]) + end.to raise_error(SQLite3::ConstraintException) + + expect(driver.job_list).to be_empty + end + + it "notifies each available SQLite queue once per batch" do + next unless config[:adapter] == :sqlite + + client.insert_many([ + SimpleArgs.new(job_num: 1), + SimpleArgs.new(job_num: 2) + ]) + + rows = ActiveRecord::Base.connection.exec_query(<<~SQL).to_a + SELECT payload, topic FROM river_notification ORDER BY id + SQL + expect(rows).to contain_exactly( + {"payload" => JSON.dump({queue: River::QUEUE_DEFAULT}), "topic" => "river_insert"} + ) + end + + it "handles an empty SQLite batch" do + next unless config[:adapter] == :sqlite + + expect(driver.job_insert_many([])).to eq([]) + end + + it "defaults a missing SQLite scheduled_at" do + next unless config[:adapter] == :sqlite + + params = River::Driver::JobInsertParams.new( + encoded_args: JSON.dump({job_num: 1}), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: nil, + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + + job, = driver.job_insert(params) + + expect(job.scheduled_at).to be_within(2).of(Time.now.utc) + end + + it "rounds SQLite timestamps to three fractional digits" do + next unless config[:adapter] == :sqlite + + time = Time.utc(2026, 8, 31, 12, 34, 56) + 0.1236 + + expect(driver.send(:format_time, time)).to eq("2026-08-31 12:34:56.124") + end + end + + describe "#to_job_row_from_model" do + it "converts a database record to `River::JobRow` with minimal properties" do + if config[:adapter] == :sqlite + ActiveRecord::Base.connection.execute( + ActiveRecord::Base.sanitize_sql_array([ + "INSERT INTO river_job (args, kind, max_attempts) VALUES (?, ?, ?)", + '{"job_num":1}', "simple", River::MAX_ATTEMPTS_DEFAULT + ]) + ) + else + driver.instance_variable_get(:@job_model).create( + id: 1, + args: {"job_num" => 1}, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + state: River::JOB_STATE_AVAILABLE + ) + end + + river_job = driver.instance_variable_get(:@job_model).first + job_row = driver.send(:to_job_row_from_model, river_job) + + expect(job_row).to be_an_instance_of(River::JobRow) + expect(job_row).to have_attributes( + id: be_a(Integer), + args: {"job_num" => 1}, + attempt: 0, + attempted_at: nil, + attempted_by: nil, + created_at: river_job.created_at.getutc, + finalized_at: nil, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: river_job.scheduled_at.getutc, + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + end + + it "converts a database record to `River::JobRow` with all properties" do + now = Time.now.utc + now_str = (config[:adapter] == :sqlite) ? now.iso8601(3) : now.strftime("%Y-%m-%d %H:%M:%S.%3N") + + if config[:adapter] == :sqlite + # Use raw SQLite connection to avoid binary encoding issues with + # sanitize_sql_array. + ActiveRecord::Base.connection.raw_connection.execute( + "INSERT INTO river_job (attempt, attempted_at, attempted_by, created_at, args, finalized_at, kind, max_attempts, priority, queue, scheduled_at, state, tags, unique_key) VALUES (?, ?, jsonb(?), ?, jsonb(?), ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?)", + [1, now_str, JSON.dump(["client1"]), now_str, '{"job_num":1}', now_str, "simple", + River::MAX_ATTEMPTS_DEFAULT, River::PRIORITY_DEFAULT, River::QUEUE_DEFAULT, + now_str, River::JOB_STATE_COMPLETED, JSON.dump(["tag1"]), + Digest::SHA256.digest("unique_key_str")] + ) + else + driver.instance_variable_get(:@job_model).create( + id: 1, + args: {"job_num" => 1}, + attempt: 1, + attempted_at: now, + attempted_by: ["client1"], + created_at: now, + finalized_at: now, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: now, + state: River::JOB_STATE_COMPLETED, + tags: ["tag1"], + unique_key: Digest::SHA256.digest("unique_key_str") + ) + end + + river_job = driver.instance_variable_get(:@job_model).first + job_row = driver.send(:to_job_row_from_model, river_job) + + expect(job_row).to be_an_instance_of(River::JobRow) + expect(job_row).to have_attributes( + id: be_a(Integer), + args: {"job_num" => 1}, + attempt: 1, + attempted_at: be_within(2).of(now.getutc), + attempted_by: ["client1"], + created_at: be_within(2).of(now.getutc), + finalized_at: be_within(2).of(now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(now.getutc), + state: River::JOB_STATE_COMPLETED, + tags: ["tag1"], + unique_key: Digest::SHA256.digest("unique_key_str") + ) + end + + it "with errors" do + now = Time.now.utc + + if config[:adapter] == :sqlite + ActiveRecord::Base.connection.execute( + ActiveRecord::Base.sanitize_sql_array([ + "INSERT INTO river_job (args, errors, kind, max_attempts, state) VALUES (?, ?, ?, ?, ?)", + '{"job_num":1}', + JSON.dump([{at: now.iso8601, attempt: 1, error: "job failure", trace: "error trace"}]), + "simple", River::MAX_ATTEMPTS_DEFAULT, River::JOB_STATE_AVAILABLE + ]) + ) + else + driver.instance_variable_get(:@job_model).create( + args: {"job_num" => 1}, + errors: [{at: now.iso8601, attempt: 1, error: "job failure", trace: "error trace"}], + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + state: River::JOB_STATE_AVAILABLE + ) + end + + river_job = driver.instance_variable_get(:@job_model).first + job_row = driver.send(:to_job_row_from_model, river_job) + + expect(job_row.errors.count).to be(1) + expect(job_row.errors[0]).to be_an_instance_of(River::AttemptError) + expect(job_row.errors[0]).to have_attributes( + at: now.floor(0), + attempt: 1, + error: "job failure", + trace: "error trace" + ) + end + end + + # PostgreSQL-only: test the raw row conversion used by upsert_all + next unless config[:adapter] == :postgres + + describe "#postgres_to_job_row_from_raw" do + it "converts a database record to `River::JobRow` with minimal properties" do + res = driver.instance_variable_get(:@job_model).insert({ + id: 1, + args: {"job_num" => 1}, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT + }, returning: Arel.sql("*, false AS unique_skipped_as_duplicate")) + + job_row, skipped_as_duplicate = driver.send(:postgres_to_job_row_from_raw, res.rows[0], res.columns, res.column_types) + + expect(job_row).to be_an_instance_of(River::JobRow) + expect(job_row).to have_attributes( + id: 1, + args: {"job_num" => 1}, + attempt: 0, + attempted_at: nil, + attempted_by: nil, + created_at: be_within(2).of(Time.now.getutc), + finalized_at: nil, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now.getutc), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + expect(skipped_as_duplicate).to be(false) + end + + it "converts a database record to `River::JobRow` with all properties" do + now = Time.now + res = driver.instance_variable_get(:@job_model).insert({ + id: 1, + args: {"job_num" => 1}, + attempt: 1, + attempted_at: now, + attempted_by: ["client1"], + created_at: now, + finalized_at: now, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: now, + state: River::JOB_STATE_COMPLETED, + tags: ["tag1"], + unique_key: Digest::SHA256.digest("unique_key_str") + }, returning: Arel.sql("*, true AS unique_skipped_as_duplicate")) + + job_row, skipped_as_duplicate = driver.send(:postgres_to_job_row_from_raw, res.rows[0], res.columns, res.column_types) + + expect(job_row).to be_an_instance_of(River::JobRow) + expect(job_row).to have_attributes( + id: 1, + args: {"job_num" => 1}, + attempt: 1, + attempted_at: be_within(2).of(now.getutc), + attempted_by: ["client1"], + created_at: be_within(2).of(now.getutc), + finalized_at: be_within(2).of(now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(now.getutc), + state: River::JOB_STATE_COMPLETED, + tags: ["tag1"], + unique_key: Digest::SHA256.digest("unique_key_str") + ) + expect(skipped_as_duplicate).to be(true) + end + + it "with errors" do + now = Time.now.utc + res = driver.instance_variable_get(:@job_model).insert({ + args: {"job_num" => 1}, + errors: [ + { + at: now.iso8601, + attempt: 1, + error: "job failure", + trace: "error trace" + } + ], + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + state: River::JOB_STATE_AVAILABLE + }, returning: Arel.sql("*, false AS unique_skipped_as_duplicate")) + + job_row, skipped_as_duplicate = driver.send(:postgres_to_job_row_from_raw, res.rows[0], res.columns, res.column_types) + + expect(job_row.errors.count).to be(1) + expect(job_row.errors[0]).to be_an_instance_of(River::AttemptError) + expect(job_row.errors[0]).to have_attributes( + at: now.floor(0), + attempt: 1, + error: "job failure", + trace: "error trace" + ) + expect(skipped_as_duplicate).to be(false) + end + end + end + end +end diff --git a/ruby/driver/riverqueue-activerecord/spec/migrator_spec.rb b/ruby/driver/riverqueue-activerecord/spec/migrator_spec.rb new file mode 100644 index 000000000..0e2045ee4 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/spec/migrator_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/support/client_test_database" +require_relative "../../../spec/migrator_shared_examples" +require_relative "../../../spec/migration_cli_shared_examples" + +RSpec.describe "ActiveRecord migrations" do + it_behaves_like "migration command", "activerecord" + [:postgres, :sqlite].each do |adapter| + context "with #{adapter}", database: adapter do + around do |example| + skip "PostgreSQL unavailable" if adapter == :postgres && !PG_AVAILABLE + + ClientTestDatabase.with_active_record(adapter, migrate: false) do |driver| + @driver = driver + example.run + end + end + + it_behaves_like "canonical migrations", adapter + end + end +end diff --git a/ruby/driver/riverqueue-activerecord/spec/spec_helper.rb b/ruby/driver/riverqueue-activerecord/spec/spec_helper.rb new file mode 100644 index 000000000..a2cf238ef --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/spec/spec_helper.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative "../../../spec/support/test_database" +require "active_record" +require "debug" +require "securerandom" +require_relative "../../../spec/support/river_sqlite_schema_fixture" + +POSTGRES_TEST_CONFIG = if RiverTestDatabase.enabled?(:postgres) + begin + ActiveRecord::Base.establish_connection(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test") + ActiveRecord::Base.connection.execute("SELECT 1") + ActiveRecord::Base.connection_db_config.configuration_hash.merge(schema_search_path: "river_activerecord_test_#{SecureRandom.hex(8)}") + rescue => e + raise if ENV["CI"] == "true" || ENV["RIVER_REQUIRE_DATABASES"] == "1" + + warn "PostgreSQL not available, skipping PostgreSQL tests: #{e.message}" + nil + end +end + +PG_AVAILABLE = !POSTGRES_TEST_CONFIG.nil? + +def test_transaction + ActiveRecord::Base.transaction do + yield + raise ActiveRecord::Rollback + end +end + +def switch_to_sqlite! + ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") + RiverSQLiteSchemaFixture.load(ActiveRecord::Base.connection.raw_connection) +end + +def switch_to_postgres! + ActiveRecord::Base.establish_connection(POSTGRES_TEST_CONFIG) +end + +unless ENV["RIVERQUEUE_ROOT_TEST_SUITE"] + require "simplecov" + SimpleCov.start do + add_filter "/spec/" + enable_coverage :branch + command_name RiverTestDatabase::BACKEND + minimum_coverage branch: 100, line: 100 if RiverTestDatabase::BACKEND == "all" + end +end + +require "riverqueue" +require "riverqueue-activerecord" + +if PG_AVAILABLE + # Keep rollback-wrapped examples independent of data in the developer's + # database, without repeatedly deleting and restoring it for every example. + switch_to_postgres! + schema = POSTGRES_TEST_CONFIG.fetch(:schema_search_path) + ActiveRecord::Base.connection.execute("CREATE SCHEMA #{schema}") + at_exit do + switch_to_postgres! + ActiveRecord::Base.connection.execute("DROP SCHEMA #{schema} CASCADE") + ensure + ActiveRecord::Base.connection_pool.disconnect! + end + River::Migrator.new(River::Driver::ActiveRecord.new).migrate +end + +# Client helpers restore this connection after each isolated SQLite database. +switch_to_sqlite! unless PG_AVAILABLE diff --git a/ruby/driver/riverqueue-activerecord/spec/yugabyte_spec.rb b/ruby/driver/riverqueue-activerecord/spec/yugabyte_spec.rb new file mode 100644 index 000000000..81c9e7832 --- /dev/null +++ b/ruby/driver/riverqueue-activerecord/spec/yugabyte_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/support/client_test_database" +require_relative "../../../spec/support/yugabyte_test_database" +require_relative "../../../spec/yugabyte_shared_examples" +require_relative "../../../spec/insert_notification_shared_examples" + +RSpec.describe "ActiveRecord Yugabyte compatibility", database: :postgres do + let(:new_driver) { -> { River::Driver::ActiveRecord.new } } + + if ENV["RIVER_YUGABYTE_TEST"] == "1" + around do |example| + ClientTestDatabase.with_active_record(:postgres) do |driver| + @driver = driver + example.run + end + end + it_behaves_like "Yugabyte driver compatibility", ENV["YUGABYTE_LISTEN_NOTIFY_ENABLED"] == "1" + if ENV["YUGABYTE_LISTEN_NOTIFY_ENABLED"] == "1" + it_behaves_like "PostgreSQL insert notifications" + it_behaves_like "PostgreSQL cancellation notifications" + end + elsif PG_AVAILABLE + [nil, false, true].each do |notifications| + context "with notification setting #{notifications.inspect}" do + around do |example| + ClientTestDatabase.with_active_record(:postgres, pg_catalog_last: true) do |driver| + @driver = driver + YugabyteTestDatabase.simulate(driver, notifications: notifications) + example.run + end + end + it_behaves_like "Yugabyte driver compatibility", notifications + if notifications + it_behaves_like "PostgreSQL insert notifications" + it_behaves_like "PostgreSQL cancellation notifications" + end + end + end + end +end diff --git a/ruby/driver/riverqueue-sequel/Gemfile b/ruby/driver/riverqueue-sequel/Gemfile new file mode 100644 index 000000000..606177f7c --- /dev/null +++ b/ruby/driver/riverqueue-sequel/Gemfile @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +group :development, :test do + gem "riverqueue", path: "../.." + gem "standard" +end + +group :test do + gem "pg" + gem "rspec-core" + gem "rspec-expectations" + gem "simplecov", require: false + gem "sqlite3" +end diff --git a/ruby/driver/riverqueue-sequel/Gemfile.lock b/ruby/driver/riverqueue-sequel/Gemfile.lock new file mode 100644 index 000000000..1bfebf22b --- /dev/null +++ b/ruby/driver/riverqueue-sequel/Gemfile.lock @@ -0,0 +1,110 @@ +PATH + remote: ../.. + specs: + riverqueue (0.12.0) + logger (> 0, < 1000) + optparse (> 0, < 1000) + securerandom (> 0, < 1000) + timeout (> 0, < 1000) + +PATH + remote: . + specs: + riverqueue-sequel (0.12.0) + riverqueue (= 0.12.0) + sequel (> 0, < 1000) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + bigdecimal (4.1.0) + diff-lcs (1.6.2) + docile (1.4.1) + json (2.19.9) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + optparse (0.8.1) + parallel (1.27.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-linux) + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + regexp_parser (2.11.3) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.84.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + sequel (5.102.0) + bigdecimal + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + sqlite3 (2.9.5-arm64-darwin) + sqlite3 (2.9.5-x86_64-linux-gnu) + standard (1.54.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.84.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + timeout (0.6.1) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + arm64-darwin-22 + arm64-darwin-23 + arm64-darwin-24 + arm64-darwin-25 + x86_64-linux + +DEPENDENCIES + pg + riverqueue! + riverqueue-sequel! + rspec-core + rspec-expectations + simplecov + sqlite3 + standard + +BUNDLED WITH + 4.0.9 diff --git a/ruby/driver/riverqueue-sequel/docs/README.md b/ruby/driver/riverqueue-sequel/docs/README.md new file mode 100644 index 000000000..7afbad883 --- /dev/null +++ b/ruby/driver/riverqueue-sequel/docs/README.md @@ -0,0 +1,46 @@ +# riverqueue-sequel + +[Sequel](https://sequel.jeremyevans.net/) driver for [River](https://github.com/riverqueue/river)'s [`riverqueue` gem for Ruby](https://rubygems.org/gems/riverqueue). PostgreSQL and SQLite are supported. + +Add this driver and only the database adapter used by the application to +`Gemfile`. The driver pulls in the core gem: + +```ruby +gem "riverqueue-sequel" +gem "pg" # or: gem "sqlite3" +``` + +For PostgreSQL, add `pg` to `Gemfile`: + +```ruby +gem "pg" +``` + +Then initialize a client with a Sequel database: + +```ruby +db = Sequel.connect("postgres://localhost/my_app") +client = River::Client.new(River::Driver::Sequel.new(db)) +``` + +For SQLite, add `sqlite3` to `Gemfile`: + +```ruby +gem "sqlite3" +``` + +Then initialize a client with an SQLite database: + +```ruby +db = Sequel.connect("sqlite://storage/river.sqlite3", timeout: 5_000) +client = River::Client.new(River::Driver::Sequel.new(db)) +``` + +Use current River migrations to create and update the SQLite database. + +YugabyteDB is supported through the PostgreSQL adapter, with automatic capability +detection. See [YugabyteDB setup and behavior](../../../docs/yugabyte.md). + +## Development + +See [development](./development.md). diff --git a/ruby/driver/riverqueue-sequel/docs/development.md b/ruby/driver/riverqueue-sequel/docs/development.md new file mode 100644 index 000000000..c9b88ad9a --- /dev/null +++ b/ruby/driver/riverqueue-sequel/docs/development.md @@ -0,0 +1,46 @@ +# riverqueue-ruby development + +## Install dependencies + +```shell +$ bundle install +``` +## Run tests + +Create a test database: + +```shell +$ createdb river_test +``` + +Tests migrate their own disposable schemas using the bundled SQL and leave +existing tables untouched. The database user needs permission to create and drop +schemas. Set `TEST_DATABASE_URL` to use a different PostgreSQL database. + +Generate shared fixtures from the repository root with `make generate/fixtures` +before invoking the driver specs directly. + +Run all specs: + +```shell +$ bundle exec rspec spec +``` + +## Run lint + +```shell +$ standardrb --fix +``` + +## Code coverage + +Running the entire test suite will produce a coverage report, and will fail if line and branch coverage is below 100%. Run the suite and open `coverage/index.html` to find lines or branches that weren't covered: + +```shell +$ bundle exec rspec spec +$ open coverage/index.html +``` + +## Publish a new gem + +Release all four gems together using the [Ruby release instructions](../../../docs/development.md#publish-gems). diff --git a/ruby/driver/riverqueue-sequel/lib/driver.rb b/ruby/driver/riverqueue-sequel/lib/driver.rb new file mode 100644 index 000000000..b6878370a --- /dev/null +++ b/ruby/driver/riverqueue-sequel/lib/driver.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +require "securerandom" + +module River::Driver + # Provides a Sequel driver for River supporting PostgreSQL, YugabyteDB, and SQLite. + # + # Used in conjunction with a River client like: + # + # DB = Sequel.connect("postgres://...") + # client = River::Client.new(River::Driver::Sequel.new(DB)) + # + # Or with SQLite: + # + # DB = Sequel.connect("sqlite://path/to/river.db") + # client = River::Client.new(River::Driver::Sequel.new(DB)) + # + class Sequel + include River::Driver::Runtime + + # Creates a driver backed by a connected Sequel::Database for PostgreSQL or + # SQLite. + def initialize(db) + @db = db + @postgres_capabilities_cache = PostgresCapabilities::Cache.new + @is_sqlite = (db.database_type == :sqlite) + + unless @is_sqlite + db.extension(:pg_array) + db.extension(:pg_json) + end + end + + def job_get_by_id(id) + if @is_sqlite + row = sqlite_job_rows("WHERE id = ? LIMIT 1", id).first + row ? sqlite_to_job_row_from_raw(row) : nil + else + row = @db[:river_job].where(id: id).first + row ? to_job_row(row) : nil + end + end + + def job_insert(insert_params) + job_insert_many([insert_params]).first + end + + def job_insert_many(insert_params_array) + return [] if insert_params_array.empty? + + return sqlite_job_insert_many(insert_params_array) if @is_sqlite + + transaction do + results = postgres_job_insert_many(insert_params_array) + postgres_notify_insert(insert_params_array) + results + end + end + + def job_list(params = :all) + return super unless params == :all + + runtime_job_rows("ORDER BY id") + end + + def rollback_exception + ::Sequel::Rollback + end + + # Backend used by River::Migrator. + def migration_backend = @is_sqlite ? :sqlite : :postgresql + + # Pins a raw connection for the complete migration operation. + def migration_connection + @db.synchronize do |connection| + raise River::Error, "migrations cannot run inside an application transaction" if @db.in_transaction? + + yield connection + end + end + + # Runs the block in a Sequel transaction or savepoint and returns the + # block's result. + def transaction + block_error = nil + @db.transaction(savepoint: true) do + yield + rescue ArgumentError => error + block_error = error + raise + end + rescue ::Sequel::DatabaseError => error + # SQLite treats ArgumentError as an adapter error, even when it came + # from an application callback. Preserve that error after rollback. + raise block_error if block_error && error.wrapped_exception.equal?(block_error) + + raise + end + + SQLITE_CONFLICT_WHERE = <<~SQL.chomp.freeze + unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1 + SQL + + # SQLite 3.45+ may store JSON as binary JSONB. Always project JSON columns + # through json() so this driver can read both the current JSONB format and + # the text JSON used by River migrations through version 006. Cast times to + # text so Sequel doesn't interpret timezone-less SQLite timestamps in the + # process timezone. Flag 10 validates JSONB contents, not just its header, + # so damaged blobs reach the row decoder without aborting the SQL query. + SQLITE_JOB_COLUMNS = <<~SQL.chomp.freeze + id, + CASE WHEN json_valid(args, 10) THEN json(args) ELSE args END AS args, + attempt, + CAST(attempted_at AS text) AS attempted_at, + CASE WHEN json_valid(attempted_by, 10) THEN json(attempted_by) ELSE attempted_by END AS attempted_by, + CAST(created_at AS text) AS created_at, + CASE WHEN json_valid(errors, 10) THEN json(errors) ELSE errors END AS errors, + CAST(finalized_at AS text) AS finalized_at, + kind, + max_attempts, + CASE WHEN json_valid(metadata, 10) THEN json(metadata) ELSE metadata END AS metadata, + priority, + queue, + state, + CAST(scheduled_at AS text) AS scheduled_at, + CASE WHEN json_valid(tags, 10) THEN json(tags) ELSE tags END AS tags, + unique_key, + unique_states + SQL + + UNIQUE_INSERT_METADATA_KEY = "river:unique_nonce" + + private_constant :SQLITE_CONFLICT_WHERE, :SQLITE_JOB_COLUMNS, :UNIQUE_INSERT_METADATA_KEY + + private def format_time(time) + time.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N") + end + + private def postgres_insert_params_to_hash(insert_params, nonce) + metadata = insert_params.metadata || {} + metadata = metadata.merge(UNIQUE_INSERT_METADATA_KEY => nonce) if nonce + { + args: insert_params.encoded_args, + kind: insert_params.kind, + max_attempts: insert_params.max_attempts, + metadata: ::Sequel.pg_jsonb(metadata), + priority: insert_params.priority, + queue: insert_params.queue, + scheduled_at: insert_params.scheduled_at, + state: insert_params.state, + tags: ::Sequel.pg_array(insert_params.tags || [], :text), + unique_key: insert_params.unique_key ? ::Sequel.blob(insert_params.unique_key) : nil, + unique_states: insert_params.unique_states + } + end + + private def postgres_job_insert_many(insert_params_array) + capabilities = postgres_capabilities + nonce = SecureRandom.hex(8) if capabilities.unique_insert_mode == :metadata_nonce + @db[:river_job] + .insert_conflict( + conflict_where: ::Sequel.lit( + "unique_key IS NOT NULL AND unique_states IS NOT NULL AND river_job_state_in_bitmask(unique_states, state)" + ), + target: [:unique_key], + update: {kind: ::Sequel[:river_job][:kind]} + ) + .returning(::Sequel.lit("*, #{capabilities.unique_insert_sql} AS unique_skipped_as_duplicate")) + .multi_insert(insert_params_array.map { |p| postgres_insert_params_to_hash(p, nonce) }) + .map do |row| + job = to_job_row(row) + [job, nonce ? job.metadata[UNIQUE_INSERT_METADATA_KEY] != nonce : row[:unique_skipped_as_duplicate]] + end + end + + private def postgres_to_job_row(river_job) + runtime_decode_postgres_job(river_job) + end + + private def runtime_connection_pool + @db + end + + private def runtime_execute(sql) + @db.run(sql) + end + + private def runtime_job_list_without_params + job_list(:all) + end + + private def runtime_job_rows(suffix) + if @is_sqlite + sqlite_job_rows(suffix).map { |row| sqlite_to_job_row_from_raw(row) } + else + @db.fetch("SELECT * FROM river_job #{suffix}").map { |row| to_job_row(row) } + end + end + + private def runtime_postgres? + !@is_sqlite + end + + private def runtime_query_rows(sql) + @db.fetch(sql).all + end + + private def runtime_quote(value) + @db.literal(value) + end + + private def runtime_unique_violation_class + ::Sequel::UniqueConstraintViolation + end + + private def runtime_value(row, key) + row[key] || row[key.to_s] + end + + private def sqlite_insert_params_to_hash(insert_params, nonce) + { + args: JSON.parse(insert_params.encoded_args), + kind: insert_params.kind, + max_attempts: insert_params.max_attempts, + metadata: {UNIQUE_INSERT_METADATA_KEY => nonce}, + priority: insert_params.priority, + queue: insert_params.queue, + scheduled_at: insert_params.scheduled_at ? format_time(insert_params.scheduled_at) : nil, + state: insert_params.state, + tags: insert_params.tags || [], + unique_key: insert_params.unique_key&.unpack1("H*"), + unique_states: insert_params.unique_states&.to_i(2) + }.tap { |values| values[:metadata] = (insert_params.metadata || {}).merge(values[:metadata]) } + end + + # River's current SQLite driver uses json_each to make a batch a single, + # atomic statement. The JSON columns are converted to SQLite JSONB here, + # matching migration 007 and newer River databases. + private def sqlite_job_insert_many(insert_params_array) + nonces = sqlite_insert_nonces(insert_params_array) + @db.transaction(savepoint: true) do + jobs = insert_params_array.zip(nonces).map { |param, nonce| sqlite_insert_params_to_hash(param, nonce) } + inserted_nonces = nonces.to_h { |nonce| [nonce, true] } + + sql = <<~SQL + INSERT INTO river_job ( + args, + created_at, + kind, + max_attempts, + metadata, + priority, + queue, + scheduled_at, + state, + tags, + unique_key, + unique_states + ) + SELECT + jsonb(json_extract(value, '$.args')), + datetime('now', 'subsec'), + cast(json_extract(value, '$.kind') AS text), + cast(json_extract(value, '$.max_attempts') AS integer), + jsonb(json_extract(value, '$.metadata')), + cast(json_extract(value, '$.priority') AS integer), + cast(json_extract(value, '$.queue') AS text), + coalesce(cast(json_extract(value, '$.scheduled_at') AS text), datetime('now', 'subsec')), + cast(json_extract(value, '$.state') AS text), + jsonb(json_extract(value, '$.tags')), + CASE + WHEN length(cast(json_extract(value, '$.unique_key') AS text)) = 0 THEN NULL + ELSE unhex(cast(json_extract(value, '$.unique_key') AS text)) + END, + nullif(cast(json_extract(value, '$.unique_states') AS integer), 0) + FROM json_each(cast(? AS blob)) + WHERE true + ON CONFLICT (unique_key) WHERE #{SQLITE_CONFLICT_WHERE} + DO UPDATE SET kind = river_job.kind + RETURNING #{SQLITE_JOB_COLUMNS} + SQL + + rows = @db.fetch(sql, JSON.generate(jobs)).all + sqlite_notify_insert(insert_params_array) + + rows.map do |row| + metadata = JSON.parse(row[:metadata]) + [sqlite_to_job_row_from_raw(row), !inserted_nonces.key?(metadata[UNIQUE_INSERT_METADATA_KEY])] + end + end + end + + private def sqlite_job_rows(suffix, *binds) + @db.fetch("SELECT #{SQLITE_JOB_COLUMNS} FROM river_job #{suffix}", *binds).all + end + + private def sqlite_notify_insert(insert_params_array) + queues = insert_params_array + .select { |param| param.state == ::River::JOB_STATE_AVAILABLE } + .map(&:queue) + .uniq + return if queues.empty? + + @db[:river_notification].multi_insert(queues.map do |queue| + {payload: JSON.generate({queue: queue}), topic: "river_insert"} + end) + end + + private def sqlite_to_job_row_from_raw(row) + runtime_decode_sqlite_job(row) + end + + private def to_job_row(river_job) + if @is_sqlite + row = sqlite_job_rows("WHERE id = ? LIMIT 1", river_job[:id]).first + sqlite_to_job_row_from_raw(row) + else + postgres_to_job_row(river_job) + end + end + end +end diff --git a/ruby/driver/riverqueue-sequel/lib/riverqueue-sequel.rb b/ruby/driver/riverqueue-sequel/lib/riverqueue-sequel.rb new file mode 100644 index 000000000..4e9c3248f --- /dev/null +++ b/ruby/driver/riverqueue-sequel/lib/riverqueue-sequel.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "sequel" +require "riverqueue" + +require_relative "driver" + +module River +end diff --git a/ruby/driver/riverqueue-sequel/riverqueue-sequel.gemspec b/ruby/driver/riverqueue-sequel/riverqueue-sequel.gemspec new file mode 100644 index 000000000..ff2c4c204 --- /dev/null +++ b/ruby/driver/riverqueue-sequel/riverqueue-sequel.gemspec @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Gem::Specification.new do |s| + s.name = "riverqueue-sequel" + s.version = "0.12.0" + s.summary = "Sequel PostgreSQL and SQLite driver for the River Ruby gem." + s.description = "Sequel PostgreSQL and SQLite driver for inserting and working River jobs in Ruby." + s.authors = ["Blake Gentry", "Brandur Leach"] + s.email = "brandur@brandur.org" + s.files = Dir.glob("lib/**/*") + s.homepage = "https://riverqueue.com" + s.license = "MPL-2.0" + s.required_ruby_version = ">= 3.2" + # The stupid version bounds are used to silence Ruby's extremely obnoxious warnings. + s.add_dependency "sequel", "> 0", "< 1000" + s.add_dependency "riverqueue", "= #{s.version}" +end diff --git a/ruby/driver/riverqueue-sequel/spec/client_spec.rb b/ruby/driver/riverqueue-sequel/spec/client_spec.rb new file mode 100644 index 000000000..9b6baae6c --- /dev/null +++ b/ruby/driver/riverqueue-sequel/spec/client_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/support/client_test_database" +require_relative "../../../spec/client_driver_shared_examples" +require_relative "../../../spec/worker_process_shared_examples" +require_relative "../../../spec/insert_notification_shared_examples" +require_relative "../../../spec/row_decoding_shared_examples" + +RSpec.describe "Sequel client integration" do + [:postgres, :sqlite].each do |adapter| + context "with #{adapter}", database: adapter do + before { skip "PostgreSQL unavailable" if adapter == :postgres && !DB } + + around do |example| + if adapter == :postgres && !DB + example.run + else + ClientTestDatabase.with_sequel(adapter) do |driver| + @driver = driver + example.run + end + end + end + + it_behaves_like "client driver end to end" + it_behaves_like "PostgreSQL state update races" if adapter == :postgres + it_behaves_like "SQLite corrupt job runtime" if adapter == :sqlite + it_behaves_like "PostgreSQL insert notifications" if adapter == :postgres + it_behaves_like "PostgreSQL cancellation notifications" if adapter == :postgres + it_behaves_like "PostgreSQL leadership notifications" if adapter == :postgres + it_behaves_like "PostgreSQL queue control notifications" if adapter == :postgres + it_behaves_like "SQLite cancellation notifications" if adapter == :sqlite + it_behaves_like "SQL scheduling concurrency" if adapter == :postgres + it_behaves_like "PostgreSQL finalized job list plans" if adapter == :postgres + it_behaves_like "PostgreSQL rescue concurrency" if adapter == :postgres + it_behaves_like "dedicated worker process" + end + end +end diff --git a/ruby/driver/riverqueue-sequel/spec/driver_spec.rb b/ruby/driver/riverqueue-sequel/spec/driver_spec.rb new file mode 100644 index 000000000..f9b3b2840 --- /dev/null +++ b/ruby/driver/riverqueue-sequel/spec/driver_spec.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/driver_shared_examples" +require_relative "../../../spec/row_decoding_shared_examples" + +RSpec.describe River::Driver::Sequel do + if DB + context "with PostgreSQL", database: :postgres do + around(:each) { |ex| test_transaction(&ex) } + + let!(:driver) { River::Driver::Sequel.new(DB) } + let(:client) { River::Client.new(driver) } + + it_behaves_like "driver shared examples" + it_behaves_like "historical attempt error decoding" + + describe "client inserts" do + it "persists args as a JSON object rather than a JSON string" do + insert_res = client.insert(SimpleArgs.new(job_num: 1)) + + row = DB.fetch(<<~SQL, insert_res.job.id).first + SELECT args, jsonb_typeof(args) AS args_type + FROM river_job + WHERE id = ? + SQL + + expect(row[:args_type]).to eq("object") + expect(row[:args].to_h).to eq({"job_num" => 1}) + end + end + + describe "#to_job_row (PostgreSQL)" do + it "converts a database record to `River::JobRow` with minimal properties" do + river_job = DB[:river_job].returning.insert_select({ + id: 1, + args: %({"job_num":1}), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT + }) + + job_row = driver.send(:to_job_row, river_job) + + expect(job_row).to be_an_instance_of(River::JobRow) + expect(job_row).to have_attributes( + id: 1, + args: {"job_num" => 1}, + attempt: 0, + attempted_at: nil, + attempted_by: nil, + created_at: be_within(2).of(Time.now.getutc), + finalized_at: nil, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now.getutc), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + end + + it "converts a database record to `River::JobRow` with all properties" do + now = Time.now + river_job = DB[:river_job].returning.insert_select({ + id: 1, + args: %({"job_num":1}), + attempt: 1, + attempted_at: now, + attempted_by: ::Sequel.pg_array(["client1"]), + created_at: now, + finalized_at: now, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: now, + state: River::JOB_STATE_COMPLETED, + tags: ::Sequel.pg_array(["tag1"]), + unique_key: ::Sequel.blob(Digest::SHA256.digest("unique_key_str")) + }) + + job_row = driver.send(:to_job_row, river_job) + + expect(job_row).to be_an_instance_of(River::JobRow) + expect(job_row).to have_attributes( + id: 1, + args: {"job_num" => 1}, + attempt: 1, + attempted_at: be_within(2).of(now.getutc), + attempted_by: ["client1"], + created_at: be_within(2).of(now.getutc), + finalized_at: be_within(2).of(now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(now.getutc), + state: River::JOB_STATE_COMPLETED, + tags: ["tag1"], + unique_key: Digest::SHA256.digest("unique_key_str") + ) + end + + it "with errors" do + now = Time.now.utc + river_job = DB[:river_job].returning.insert_select({ + args: %({"job_num":1}), + errors: ::Sequel.pg_array([ + ::Sequel.pg_json_wrap({ + at: now, + attempt: 1, + error: "job failure", + trace: "error trace" + }) + ]), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + state: River::JOB_STATE_AVAILABLE + }) + + job_row = driver.send(:to_job_row, river_job) + + expect(job_row.errors.count).to be(1) + expect(job_row.errors[0]).to be_an_instance_of(River::AttemptError) + expect(job_row.errors[0]).to have_attributes( + at: now.floor(0), + attempt: 1, + error: "job failure", + trace: "error trace" + ) + end + end + end + end + + next unless SQLITE_DB + + context "with SQLite", database: :sqlite do + around(:each) { |ex| sqlite_test_transaction(&ex) } + + let!(:driver) { River::Driver::Sequel.new(SQLITE_DB) } + let(:client) { River::Client.new(driver) } + + it_behaves_like "driver shared examples" + it_behaves_like "historical attempt error decoding" + it_behaves_like "SQLite corrupt job isolation" + + describe "client inserts" do + it "persists JSON columns as JSONB objects" do + insert_res = client.insert(SimpleArgs.new(job_num: 1)) + + row = SQLITE_DB.fetch(<<~SQL, insert_res.job.id).first + SELECT + json(args) AS args, + json_type(args) AS args_type, + typeof(args) AS args_storage_type, + typeof(metadata) AS metadata_storage_type, + typeof(tags) AS tags_storage_type, + CAST(created_at AS text) AS created_at, + CAST(scheduled_at AS text) AS scheduled_at + FROM river_job + WHERE id = ? + SQL + + expect(row[:args_type]).to eq("object") + expect(JSON.parse(row[:args])).to eq({"job_num" => 1}) + expect(row.values_at(:args_storage_type, :metadata_storage_type, :tags_storage_type)).to eq(["blob", "blob", "blob"]) + expect(row[:created_at]).to match(/\.\d{3}\z/) + expect(row[:scheduled_at]).to match(/\.\d{3}\z/) + expect(insert_res.job.errors).to eq([]) + end + + it "inserts a batch atomically" do + expect do + client.insert_many([ + SimpleArgs.new(job_num: 1), + River::InsertManyParams.new( + SimpleArgs.new(job_num: 2), + insert_opts: River::InsertOpts.new(priority: 0) + ) + ]) + end.to raise_error(Sequel::CheckConstraintViolation) + + expect(driver.job_list).to be_empty + end + + it "notifies each available queue once per batch" do + client.insert_many([ + SimpleArgs.new(job_num: 1), + SimpleArgs.new(job_num: 2) + ]) + + rows = SQLITE_DB[:river_notification].order(:id).select(:payload, :topic).all + + expect(rows).to contain_exactly( + {payload: JSON.dump({queue: River::QUEUE_DEFAULT}), topic: "river_insert"} + ) + end + + it "handles an empty batch" do + expect(driver.job_insert_many([])).to eq([]) + end + + it "defaults a missing scheduled_at" do + params = River::Driver::JobInsertParams.new( + encoded_args: JSON.dump({job_num: 1}), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: nil, + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + + job, = driver.job_insert(params) + + expect(job.scheduled_at).to be_within(2).of(Time.now.utc) + end + + it "rounds timestamps to three fractional digits" do + time = Time.utc(2026, 8, 31, 12, 34, 56) + 0.1236 + expect(driver.send(:format_time, time)).to eq("2026-08-31 12:34:56.124") + end + end + + describe "#to_job_row (SQLite)" do + it "converts a database record to `River::JobRow` with minimal properties" do + SQLITE_DB[:river_job].insert( + args: Sequel.function(:jsonb, %({"job_num":1})), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT + ) + river_job = SQLITE_DB[:river_job].first + + job_row = driver.send(:to_job_row, river_job) + + expect(job_row).to be_an_instance_of(River::JobRow) + expect(job_row).to have_attributes( + id: be_a(Integer), + args: {"job_num" => 1}, + attempt: 0, + attempted_at: nil, + attempted_by: nil, + created_at: be_within(2).of(Time.now.getutc), + finalized_at: nil, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now.getutc), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + end + + it "converts a database record to `River::JobRow` with all properties" do + now = Time.now.utc + now_str = now.iso8601(3) + + SQLITE_DB[:river_job].insert( + args: Sequel.function(:jsonb, %({"job_num":1})), + attempt: 1, + attempted_at: now_str, + attempted_by: Sequel.function(:jsonb, JSON.dump(["client1"])), + created_at: now_str, + finalized_at: now_str, + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: now_str, + state: River::JOB_STATE_COMPLETED, + tags: Sequel.function(:jsonb, JSON.dump(["tag1"])), + unique_key: ::Sequel.blob(Digest::SHA256.digest("unique_key_str")) + ) + river_job = SQLITE_DB[:river_job].first + + job_row = driver.send(:to_job_row, river_job) + + expect(job_row).to be_an_instance_of(River::JobRow) + expect(job_row).to have_attributes( + id: be_a(Integer), + args: {"job_num" => 1}, + attempt: 1, + attempted_at: be_within(2).of(now.getutc), + attempted_by: ["client1"], + created_at: be_within(2).of(now.getutc), + finalized_at: be_within(2).of(now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(now.getutc), + state: River::JOB_STATE_COMPLETED, + tags: ["tag1"], + unique_key: Digest::SHA256.digest("unique_key_str") + ) + end + + it "with errors" do + now = Time.now.utc + + SQLITE_DB[:river_job].insert( + args: Sequel.function(:jsonb, %({"job_num":1})), + errors: Sequel.function(:jsonb, JSON.dump([{ + at: now.iso8601, + attempt: 1, + error: "job failure", + trace: "error trace" + }])), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + state: River::JOB_STATE_AVAILABLE + ) + river_job = SQLITE_DB[:river_job].first + + job_row = driver.send(:to_job_row, river_job) + + expect(job_row.errors.count).to be(1) + expect(job_row.errors[0]).to be_an_instance_of(River::AttemptError) + expect(job_row.errors[0]).to have_attributes( + at: now.floor(0), + attempt: 1, + error: "job failure", + trace: "error trace" + ) + end + end + end +end diff --git a/ruby/driver/riverqueue-sequel/spec/migrator_spec.rb b/ruby/driver/riverqueue-sequel/spec/migrator_spec.rb new file mode 100644 index 000000000..c68b4daab --- /dev/null +++ b/ruby/driver/riverqueue-sequel/spec/migrator_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/support/client_test_database" +require_relative "../../../spec/migrator_shared_examples" +require_relative "../../../spec/migration_cli_shared_examples" + +RSpec.describe "Sequel migrations" do + it_behaves_like "migration command", "sequel" + [:postgres, :sqlite].each do |adapter| + context "with #{adapter}", database: adapter do + around do |example| + skip "PostgreSQL unavailable" if adapter == :postgres && !DB + + ClientTestDatabase.with_sequel(adapter, migrate: false) do |driver| + @driver = driver + example.run + end + end + + it_behaves_like "canonical migrations", adapter + end + end +end diff --git a/ruby/driver/riverqueue-sequel/spec/spec_helper.rb b/ruby/driver/riverqueue-sequel/spec/spec_helper.rb new file mode 100644 index 000000000..d2c2c4c0a --- /dev/null +++ b/ruby/driver/riverqueue-sequel/spec/spec_helper.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative "../../../spec/support/test_database" +require "sequel" +require "securerandom" +require_relative "../../../spec/support/river_sqlite_schema_fixture" + +SEQUEL_TEST_SCHEMA = "river_sequel_test_#{SecureRandom.hex(8)}" + +DB = if RiverTestDatabase.enabled?(:postgres) + begin + Sequel.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test", search_path: SEQUEL_TEST_SCHEMA) + rescue => e + raise if ENV["CI"] == "true" || ENV["RIVER_REQUIRE_DATABASES"] == "1" + + warn "PostgreSQL not available, skipping PostgreSQL tests: #{e.message}" + nil + end +end + +SQLITE_DB = if RiverTestDatabase.enabled?(:sqlite) + begin + require "sqlite3" + Sequel.sqlite.tap do |db| + db.synchronize { |connection| RiverSQLiteSchemaFixture.load(connection) } + end + rescue LoadError + raise if ENV["CI"] == "true" || ENV["RIVER_REQUIRE_DATABASES"] == "1" + + warn "sqlite3 gem not available, skipping SQLite tests" + nil + end +end + +def test_transaction + DB.transaction do + yield + raise Sequel::Rollback + end +end + +def sqlite_test_transaction + SQLITE_DB.transaction do + yield + raise Sequel::Rollback + end +end + +def available_test_database + DB || SQLITE_DB +end + +def available_test_transaction(&) + if DB + test_transaction(&) + elsif SQLITE_DB + sqlite_test_transaction(&) + else + skip "PostgreSQL and SQLite are unavailable" + end +end + +unless ENV["RIVERQUEUE_ROOT_TEST_SUITE"] + require "simplecov" + SimpleCov.start do + add_filter "/spec/" + enable_coverage :branch + command_name RiverTestDatabase::BACKEND + minimum_coverage branch: 100, line: 100 if RiverTestDatabase::BACKEND == "all" + end +end + +require "riverqueue" +require "riverqueue-sequel" + +if DB + # An empty, suite-local schema makes rollback sufficient between examples. + # Deleting and rolling back shared tables repeatedly scans any developer data. + DB.run("CREATE SCHEMA #{SEQUEL_TEST_SCHEMA}") + at_exit do + DB.run("DROP SCHEMA #{SEQUEL_TEST_SCHEMA} CASCADE") + ensure + DB.disconnect + end + River::Migrator.new(River::Driver::Sequel.new(DB)).migrate +end diff --git a/ruby/driver/riverqueue-sequel/spec/yugabyte_spec.rb b/ruby/driver/riverqueue-sequel/spec/yugabyte_spec.rb new file mode 100644 index 000000000..b102be139 --- /dev/null +++ b/ruby/driver/riverqueue-sequel/spec/yugabyte_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/support/client_test_database" +require_relative "../../../spec/support/yugabyte_test_database" +require_relative "../../../spec/yugabyte_shared_examples" +require_relative "../../../spec/insert_notification_shared_examples" + +RSpec.describe "Sequel Yugabyte compatibility", database: :postgres do + let(:new_driver) { -> { River::Driver::Sequel.new(@driver.send(:runtime_connection_pool)) } } + + if ENV["RIVER_YUGABYTE_TEST"] == "1" + around do |example| + ClientTestDatabase.with_sequel(:postgres) do |driver| + @driver = driver + example.run + end + end + it_behaves_like "Yugabyte driver compatibility", ENV["YUGABYTE_LISTEN_NOTIFY_ENABLED"] == "1" + if ENV["YUGABYTE_LISTEN_NOTIFY_ENABLED"] == "1" + it_behaves_like "PostgreSQL insert notifications" + it_behaves_like "PostgreSQL cancellation notifications" + end + elsif DB + [nil, false, true].each do |notifications| + context "with notification setting #{notifications.inspect}" do + around do |example| + ClientTestDatabase.with_sequel(:postgres, pg_catalog_last: true) do |driver| + @driver = driver + YugabyteTestDatabase.simulate(driver, notifications: notifications) + example.run + end + end + it_behaves_like "Yugabyte driver compatibility", notifications + if notifications + it_behaves_like "PostgreSQL insert notifications" + it_behaves_like "PostgreSQL cancellation notifications" + end + end + end + end +end diff --git a/ruby/exe/river b/ruby/exe/river new file mode 100755 index 000000000..e61e0463e --- /dev/null +++ b/ruby/exe/river @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "riverqueue" +require_relative "../lib/cli" + +exit River::CLI.run(ARGV) diff --git a/ruby/go.mod b/ruby/go.mod new file mode 100644 index 000000000..be4bc069c --- /dev/null +++ b/ruby/go.mod @@ -0,0 +1,3 @@ +// This stub module keeps ruby/ out of Go module archives and Go tooling. +// It contains no Go code and must not be tagged or added to go.work. +module github.com/riverqueue/river/ruby diff --git a/ruby/lib/cli.rb b/ruby/lib/cli.rb new file mode 100644 index 000000000..778f23857 --- /dev/null +++ b/ruby/lib/cli.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "optparse" +require_relative "migration_cli" +require_relative "worker_runner" + +module River + # Shared command dispatch for migrations and dedicated worker processes. + module CLI + # Runs a River command and returns its process exit status. + def self.run(argv, err: $stderr, out: $stdout) + return worker(argv.drop(1), out: out) if argv.first == "worker" + + if argv.empty? || %w[-h --help].include?(argv.first) + out.puts("Usage: river worker|migrate-up|migrate-down|migrate-status [options]") + return 0 + end + + MigrationCLI.run(argv, err: err, out: out) + rescue StandardError, LoadError, SyntaxError => error + err.puts("Worker failed: #{error.class}: #{error.message}") + 1 + end + + def self.worker(argv, out:) + options = {} #: Hash[Symbol, untyped] + parser = OptionParser.new do |args| + args.banner = "Usage: river worker --config FILE | --rails [options]" + args.on("--config FILE", "Ruby file returning an unstarted River client") { |value| options[:config] = value } + args.on("--rails", "Boot config/environment.rb in the current directory") { options[:rails] = true } + args.on("--stop-timeout SECONDS", Float) { |value| options[:stop_timeout] = value } + args.on("-h", "--help") do + out.puts(args) + return 0 + end + end + + args = argv.dup + parser.parse!(args) + raise ArgumentError, parser.banner unless args.empty? && (!!options[:config] ^ !!options[:rails]) + + if options[:rails] + require File.expand_path("config/environment.rb") + require "riverqueue-rails" + Object.const_get("River::Rails::Runner").start(out: out, stop_timeout: options[:stop_timeout]) + else + path = File.expand_path(options[:config]) + client = TOPLEVEL_BINDING.eval(File.read(path), path) + raise ArgumentError, "configuration must return a River::Client" unless client.is_a?(River::Client) + + WorkerRunner.new(client, out: out, stop_timeout: options.fetch(:stop_timeout, 30)).run + end + end + end +end diff --git a/ruby/lib/client.rb b/ruby/lib/client.rb new file mode 100644 index 000000000..02c3dae8b --- /dev/null +++ b/ruby/lib/client.rb @@ -0,0 +1,593 @@ +# frozen_string_literal: true + +require "digest/sha2" +require "time" + +module River + # Default number of maximum attempts for a job. + MAX_ATTEMPTS_DEFAULT = 25 + + # Largest attempt limit supported by all River databases. + MAX_ATTEMPTS_LIMIT = 32_767 + + # Default priority for a job. + PRIORITY_DEFAULT = 1 + + # Default queue for a job. + QUEUE_DEFAULT = "default" + + # Provides a River client for inserting and working jobs. + # + # Used in conjunction with a River driver like: + # + # DB = Sequel.connect(...) + # client = River::Client.new(River::Driver::Sequel.new(DB)) + # + # River drivers are found in separate gems like `riverqueue-sequel` to help + # minimize transient dependencies. + class Client + # Configuration used by this client. + attr_reader :config + + # Database driver used by this client. + attr_reader :driver + + # Creates a client backed by the given database driver. Pass a Config to + # customize workers, queues, plugins, and runtime behavior. + def initialize(driver, config: nil) + @config = config || Config.new + @driver = driver + @runtime = ClientRuntime.new(self, driver, @config) + @time_now_utc = -> { Time.now.utc } # for test time stubbing + end + + # Internal extension point used by separately packaged batch workers after + # they atomically claim additional jobs alongside the batch leader. + # Errors honor the registered worker's retry hooks and River's cancellation, + # snooze, and interruption signals. Timings use the row's claim timestamp. + def __finish_claimed_job(row, error = nil) + @runtime.finish_claimed(row, error) + end + + def __perform_job(id, allow_scheduled: false) + @runtime.perform_job(id, allow_scheduled: allow_scheduled) + end + + def __interrupt_workers = @runtime.interrupt_workers + + def __runtime_healthy? = @runtime.healthy? + + # Returns the client ID recorded on jobs while this client works them. + def id = config.id + + # Inserts a new job for work given a job args implementation and insertion + # options (which may be omitted). + # + # With job args only: + # + # insert_res = client.insert(SimpleArgs.new(job_num: 1)) + # insert_res.job # inserted job row + # + # With keyword options: + # + # insert_res = client.insert(SimpleArgs.new(job_num: 1), queue: :high_priority) + # insert_res.job # inserted job row + # + # Job arg implementations are expected to respond to: + # + # * `#kind`: A symbol or string identifying the job kind in the database. + # * `#to_json`: Encodes the args to JSON for persistence in the database. + # Must match encoding an args struct on the Go side to be workable. + # + # They may also respond to `#insert_opts` which is expected to return an + # `InsertOpts` that contains options that will apply to all jobs of this + # kind. Insertion options provided as an argument to `#insert` override + # those returned by job args. + # + # For example: + # + # class SimpleArgs + # attr_accessor :job_num + # + # def initialize(job_num:) + # self.job_num = job_num + # end + # + # def kind = "simple" + # + # def to_json = JSON.generate({job_num: job_num}) + # end + # + # See also JobArgsHash for an easy way to insert a job from a hash. + # + # Returns an instance of JobInsertResult. A reusable InsertOpts may instead + # be supplied as +insert_opts:+; do not combine it with keyword options. + # @type method insert: (jobArgs, ?insert_opts: InsertOpts?, **untyped) -> JobInsertResult + def insert(args, insert_opts: nil, **options) + raise ArgumentError, "use insert_opts or keyword options, not both" if insert_opts && !options.empty? + insert_opts ||= options.empty? ? EMPTY_INSERT_OPTS : InsertOpts.new(**options) + insert_params = make_insert_params(args, insert_opts) + run_insert_plugins([insert_params]) { [insert_and_check_unique_job(insert_params)] }.first + end + + # Inserts many new jobs as part of a single batch operation for improved + # efficiency. + # + # Takes an array of job args or InsertManyParams which encapsulate job args + # and a paired InsertOpts. + # + # With job args: + # + # insert_results = client.insert_many([ + # SimpleArgs.new(job_num: 1), + # SimpleArgs.new(job_num: 2) + # ]) + # + # With InsertManyParams: + # + # insert_results = client.insert_many([ + # River::InsertManyParams.new(SimpleArgs.new(job_num: 1), max_attempts: 5), + # River::InsertManyParams.new(SimpleArgs.new(job_num: 2), queue: :high_priority) + # ]) + # + # Job arg implementations are expected to respond to: + # + # * `#kind`: A symbol or string identifying the job kind in the database. + # * `#to_json`: Encodes the args to JSON for persistence in the database. + # Must match encoding an args struct on the Go side to be workable. + # + # For example: + # + # class SimpleArgs + # attr_accessor :job_num + # + # def initialize(job_num:) + # self.job_num = job_num + # end + # + # def kind = "simple" + # + # def to_json = JSON.generate({job_num: job_num}) + # end + # + # See also JobArgsHash for an easy way to insert a job from a hash. + # + # Returns one JobInsertResult for each argument, in input order. + # Raises ArgumentError if the batch is empty. + # A batch cannot contain repeated unique keys in their enforced states; + # the whole batch fails instead of partially inserting it. + def insert_many(args) + raise ArgumentError, "no jobs to insert" if args.empty? + + all_params = args.map do |arg| + if arg.is_a?(InsertManyParams) + make_insert_params(arg.args, arg.insert_opts || EMPTY_INSERT_OPTS) + else # jobArgs + make_insert_params(arg, EMPTY_INSERT_OPTS) + end + end + + run_insert_plugins(all_params) do + @driver.job_insert_many(all_params) + .map do |job, unique_skipped_as_duplicate| + JobInsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate) + end + end + end + + # Cancels a job by ID and returns its updated JobRow. + # + # Raises NotFoundError if the job does not exist. + def job_cancel(id) + @driver.job_cancel(id) || raise(NotFoundError, "job not found: #{id}") + end + + # Deletes a job by ID and returns its former JobRow. + # + # Raises NotFoundError if the job does not exist and JobRunningError if it + # is currently running. + def job_delete(id) + job = @driver.job_delete(id) || raise(NotFoundError, "job not found: #{id}") + raise JobRunningError, "running jobs cannot be deleted" if job.state == JOB_STATE_RUNNING + + job + end + + # Deletes jobs matching the supplied filters and returns a + # JobDeleteManyResult. At least one filter is required. Accepts keyword + # filters or a reusable JobListParams, but not both. + # @type method job_delete_many: (?JobListParams?, **untyped) -> untyped + def job_delete_many(params = nil, **filters) + raise ArgumentError, "use params or keyword filters, not both" if params && !filters.empty? + params ||= JobListParams.new(**filters) + raise ArgumentError, "delete with no filters is not allowed" unless params.filters? + + JobDeleteManyResult.new(@driver.job_delete_many(params)) + end + + # Fetches a job by ID. + # + # Raises NotFoundError if the job does not exist. + def job_get(id) + @driver.job_get_by_id(id) || raise(NotFoundError, "job not found: #{id}") + end + + # Lists jobs matching the supplied filters and ordering. + # + # The returned JobListResult includes a cursor suitable for the next page. + # Accepts keyword filters or a reusable JobListParams, but not both. + # @type method job_list: (?JobListParams?, **untyped) -> untyped + def job_list(params = nil, **filters) + raise ArgumentError, "use params or keyword filters, not both" if params && !filters.empty? + params ||= JobListParams.new(**filters) + jobs = @driver.job_list(params) + last = jobs.last + cursor = last && JobListCursor.new(id: last.id, sort_by: params.sort_by, sort_order: params.sort_order, value: last.public_send(params.sort_by)) + JobListResult.new(jobs, cursor) + end + + # Makes a non-running job immediately available for another attempt and + # returns its updated JobRow. Raises NotFoundError if it does not exist. + # Clears any cancellation request from its previous attempt. Raises + # ArgumentError if another attempt would exceed MAX_ATTEMPTS_LIMIT. + def job_retry(id) + @driver.job_retry(id) || raise(NotFoundError, "job not found: #{id}") + end + + # Applies keyword attributes or a JobUpdateParams to a job and returns its + # updated JobRow. Explicit nil clears nullable fields; omitted fields are + # unchanged. Metadata must be a Hash; use an empty Hash to clear it. + # attempt must be between 0 and MAX_ATTEMPTS_LIMIT (32,767); + # max_attempts must be between 1 and MAX_ATTEMPTS_LIMIT. + # Raises NotFoundError if the job does not exist. + # @type method job_update: (Integer, ?JobUpdateParams?, **untyped) -> JobRow + def job_update(id, params = nil, **attributes) + raise ArgumentError, "use params or keyword attributes, not both" if params && !attributes.empty? + params ||= JobUpdateParams.new(**attributes) + @driver.job_update(id, params) || raise(NotFoundError, "job not found: #{id}") + end + + # Returns the live PeriodicJobBundle used to add and remove periodic jobs. + def periodic_jobs + raise ArgumentError, "cannot modify periodic jobs when leader_election_disabled is true" if @config.leader_election_disabled + + @runtime.periodic_jobs + end + + # Adds a queue to the client and returns self. If the client is running, it + # begins working the queue immediately. + def queue_add(name, queue_config) + @runtime.queue_add(name.to_s, queue_config) + self + end + + # Fetches a queue by name. + # + # Raises NotFoundError if the queue does not exist. + def queue_get(name) + @driver.queue_get(name.to_s) || raise(NotFoundError, "queue not found: #{name}") + end + + # Lists up to +max+ known queues. + def queue_list(max: 100) + QueueListResult.new(@driver.queue_list(max: max)) + end + + # Pauses a named queue, or all queues when +name+ is +"*"+. + def queue_pause(name) + name = name.to_s + queues = @driver.queue_pause(name) + @runtime.wake + queues.each do |queue| + @runtime.publish_queue(EVENT_QUEUE_PAUSED, queue) + end + + true + end + + # Removes a configured queue, waits for active jobs in it to finish, and + # returns self. + # Raises ThreadError if called from that queue's own worker or producer. + def queue_remove(name) + @runtime.queue_remove(name.to_s) + self + end + + # Resumes a named queue, or all queues when +name+ is +"*"+. + def queue_resume(name) + name = name.to_s + queues = @driver.queue_resume(name) + @runtime.wake + queues.each do |queue| + @runtime.publish_queue(EVENT_QUEUE_RESUMED, queue) + end + + true + end + + # Replaces a queue's metadata and returns the updated Queue. + def queue_update(name, metadata:) + @driver.queue_update(name.to_s, metadata: metadata) || raise(NotFoundError, "queue not found: #{name}") + end + + # Starts polling configured queues and working jobs in background threads. + # Returns self. + def start + @runtime.start + self + end + + # Returns true while the client runtime is started. + def started? = @runtime.started? + + # Stops fetching and producing periodic jobs, waits for active jobs to + # finish, and returns self. Pass wait: false to request stop without + # waiting; call stop again to finish draining and release resources. + # In-flight fetches or maintenance operations may finish. Until a waiting + # stop completes, started? remains true and stopped? remains false. + # Raises ThreadError for a waiting call from this client's runtime threads. + def stop(wait: true) + @runtime.stop(wait: wait) + self + end + + # Stops fetching new jobs, interrupts active work, and returns self. + # Raises ThreadError if called from this client's own runtime threads. + def stop_and_cancel + @runtime.stop(cancel: true) + self + end + + # Returns true when the client runtime is fully stopped. + def stopped? = @runtime.stopped? + + # Subscribes to event kinds and returns a Subscription. + # + # Call Subscription#close when the subscription is no longer needed. + def subscribe(*kinds, buffer_size: 100) + @runtime.subscribe(kinds, buffer_size: buffer_size) + end + + # Default states that are used during a unique insert. Can be overridden by + # setting UniqueOpts#by_state. + DEFAULT_UNIQUE_STATES = [ + JOB_STATE_AVAILABLE, + JOB_STATE_COMPLETED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED + ].freeze + + EMPTY_INSERT_OPTS = InsertOpts.new.freeze + + INITIAL_STATES = [JOB_STATE_AVAILABLE, JOB_STATE_PENDING, JOB_STATE_SCHEDULED].freeze + + REQUIRED_UNIQUE_STATES = [ + JOB_STATE_AVAILABLE, + JOB_STATE_PENDING, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED + ].freeze + + TAG_RE = /\A\w[\w-]+\w\z/ + + private_constant :DEFAULT_UNIQUE_STATES, :EMPTY_INSERT_OPTS, :INITIAL_STATES, :REQUIRED_UNIQUE_STATES, :TAG_RE + + private def insert_and_check_unique_job(insert_params) + job, unique_skipped_as_duplicate = @driver.job_insert(insert_params) + JobInsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate) + end + + private def make_insert_params(args, insert_opts) + raise ArgumentError, "args should respond to `#kind`" if !args.respond_to?(:kind) + + # ~all objects in Ruby respond to `#to_json`, so check non-nil instead. + args_json = args.to_json + raise ArgumentError, "args should return non-nil from `#to_json`" if !args_json + + args_insert_opts = if args.respond_to?(:insert_opts) + args_with_insert_opts = args #: _JobArgsWithInsertOpts # rubocop:disable Layout/LeadingCommentSpace + args_with_insert_opts.insert_opts || EMPTY_INSERT_OPTS + else + EMPTY_INSERT_OPTS + end + + max_attempts = insert_opts.max_attempts || args_insert_opts.max_attempts || MAX_ATTEMPTS_DEFAULT + raise ArgumentError, "max_attempts must be greater than zero" unless max_attempts > 0 + raise ArgumentError, "max_attempts must not exceed #{MAX_ATTEMPTS_LIMIT}" if max_attempts > MAX_ATTEMPTS_LIMIT + + queue = (insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT).to_s + raise ArgumentError, "invalid queue name: #{queue.inspect}" unless queue.match?(QUEUE_NAME_REGEX) && queue.length < 128 + + scheduled_at = insert_opts.scheduled_at || args_insert_opts.scheduled_at + now = @time_now_utc.call + state = validate_insert_state(insert_opts.state || args_insert_opts.state || ((scheduled_at && scheduled_at > now) ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE)) + + insert_params = Driver::JobInsertParams.new( + args: args, + encoded_args: args_json, + kind: args.kind.to_s, + max_attempts: max_attempts, + metadata: (args_insert_opts.metadata || {}).merge(insert_opts.metadata || {}), + priority: insert_opts.priority || args_insert_opts.priority || PRIORITY_DEFAULT, + queue: queue, + scheduled_at: scheduled_at&.getutc || now, + state: state, + tags: validate_tags(insert_opts.tags || args_insert_opts.tags || []) + ) + + unique_opts = insert_opts.unique_opts || args_insert_opts.unique_opts + if unique_opts && !unique_opts.empty? + unique_key, unique_states = make_unique_key_and_bitmask(insert_params, unique_opts) + insert_params.unique_key = unique_key + insert_params.unique_states = unique_states + end + + insert_params + end + + private def make_unique_key_and_bitmask(insert_params, unique_opts) + unique_key = "" + + # It's extremely important here that this unique key format and algorithm + # match the one in the main River library _exactly_. Don't change them + # unless they're updated everywhere. + unless unique_opts.exclude_kind + unique_key += "&kind=#{insert_params.kind}" + end + + if unique_opts.by_args + encoded_args = UniqueArgs.encode(insert_params.encoded_args, unique_opts.by_args) + unique_key += "&args=#{encoded_args}" + end + + if unique_opts.by_period && unique_opts.by_period != 0 + raise ArgumentError, "by_period should not be less than 1 second" if unique_opts.by_period < 1 + + lower_period_bound = truncate_time(insert_params.scheduled_at || @time_now_utc.call, unique_opts.by_period).utc + + unique_key += "&period=#{lower_period_bound.strftime("%FT%TZ")}" + end + + if unique_opts.by_queue + unique_key += "&queue=#{insert_params.queue}" + end + + # Without kind or another dimension, every job would share the same key. + raise ArgumentError, "exclude_kind requires by_args, by_queue, or by_period" if unique_key.empty? + + unique_key_hash = Digest::SHA256.digest(unique_key) + states = unique_opts.by_state + unique_states = validate_unique_states((states && !states.empty?) ? states : DEFAULT_UNIQUE_STATES) + + [unique_key_hash, UniqueBitmask.from_states(unique_states)] + end + + private def run_insert_plugins(all_params, &insert_operation) + unless config.plugins.any? { |plugin| plugin.respond_to?(:insert_begin) || plugin.respond_to?(:insert_end) || plugin.respond_to?(:insert_many) } + results = insert_operation.call + @runtime.wake + return results + end + + operation = -> do + all_params.each do |insert_params| + config.plugins.each do |plugin| + plugin.insert_begin(insert_params) if plugin.respond_to?(:insert_begin) + end + end + + # Hooks may change any job in the batch, including an earlier entry. + # Validate the final states only after all insertion hooks have run. + all_params.each { |params| params.state = validate_insert_state(params.state) } + + results = insert_operation.call + results.each do |result| + config.plugins.reverse_each do |plugin| + plugin.insert_end(result) if plugin.respond_to?(:insert_end) + end + end + + results + end + + config.plugins.reverse_each do |plugin| + next unless plugin.respond_to?(:insert_many) + + next_operation = operation + operation = -> { plugin.insert_many(all_params, next_operation) } + end + + results = @driver.transaction { operation.call } + @runtime.wake + results + end + + # Truncates the given time down to the interval. For example: + # + # Thu Jan 15 21:26:36 UTC 2024 @ 15 minutes -> + # Thu Jan 15 21:15:00 UTC 2024 + private def truncate_time(time, interval_seconds) + # Go truncates from year 1, not the Unix epoch. Rational arithmetic also + # avoids floating-point rounding across a period boundary. + epoch_offset = 62_135_596_800 + interval = interval_seconds.to_r + Time.at(((time.to_r + epoch_offset) / interval).floor * interval - epoch_offset) + end + + # Moves an integer that may occupy the entire uint64 space to one that's + # bounded within int64. Allows overflow. + private def uint64_to_int64(int) + [int].pack("Q").unpack1("q") #: Integer # rubocop:disable Layout/LeadingCommentSpace + end + + private def validate_insert_state(state) + state = state.to_s #: jobStateInitial # rubocop:disable Layout/LeadingCommentSpace + raise ArgumentError, "invalid insertion state: #{state.inspect}; must be available, pending, or scheduled" unless INITIAL_STATES.include?(state) + + state + end + + private def validate_tags(tags) + tags.each do |tag| + raise ArgumentError, "tags should be 255 characters or less" if tag.length > 255 + raise ArgumentError, "tag should match regex #{TAG_RE.inspect}" unless TAG_RE.match(tag) + end + end + + private def validate_unique_states(states) + states = states.map(&:to_s) #: Array[jobStateAll] # rubocop:disable Layout/LeadingCommentSpace + REQUIRED_UNIQUE_STATES.each do |required_state| + raise ArgumentError, "by_state should include required state #{required_state}" unless states.include?(required_state) + end + + states + end + end + + # A single job to insert that's part of an #insert_many batch insert. Unlike + # sending raw job args, supports an InsertOpts to pair with the job. + class InsertManyParams + # Job args to insert. + attr_reader :args + + # Insertion options to use with the insert. + attr_reader :insert_opts + + # Pairs job arguments with per-job keyword options for Client#insert_many. + # A reusable InsertOpts may be supplied instead as +insert_opts:+. + # @type method initialize: (jobArgs, ?insert_opts: InsertOpts?, **untyped) -> void + def initialize(args, insert_opts: nil, **options) + raise ArgumentError, "use insert_opts or keyword options, not both" if insert_opts && !options.empty? + + @args = args + @insert_opts = insert_opts || (InsertOpts.new(**options) unless options.empty?) + end + end + + # Result of a single insertion. + class JobInsertResult + # Inserted job row, or an existing job row if insert was skipped due to a + # previously existing unique job. + attr_reader :job + + # True if for a unique job, the insertion was skipped due to an equivalent + # job matching unique property already being present. + def unique_skipped_as_duplicate? = @unique_skipped_as_duplicate + + # Compatibility with the spelling used in earlier releases. + alias_method :unique_skipped_as_duplicated, :unique_skipped_as_duplicate? + + # Creates an insertion result. Applications normally receive instances from + # Client#insert or Client#insert_many. The keyword retains its original + # spelling for compatibility with earlier releases. + def initialize(job, unique_skipped_as_duplicated:) + @job = job + @unique_skipped_as_duplicate = unique_skipped_as_duplicated + end + end + + # Compatibility with the insertion result class name used in earlier releases. + InsertResult = JobInsertResult +end diff --git a/ruby/lib/client_runtime.rb b/ruby/lib/client_runtime.rb new file mode 100644 index 000000000..dbf82328b --- /dev/null +++ b/ruby/lib/client_runtime.rb @@ -0,0 +1,721 @@ +# frozen_string_literal: true + +require "timeout" + +module River + class ClientRuntime + class Interrupted < StandardError; end + + # Keep wall time for queue latency and error timestamps, and monotonic time + # for execution and completion durations. Externally executed jobs start + # with no locally observed execution time. Snapshot queue latency before + # retries, snoozes, or application code change the job's schedule. + class JobTiming + attr_accessor :finished_monotonic + attr_reader :queue_wait_duration, :started_at, :started_monotonic + + def initialize(started_at, started_monotonic, scheduled_at) + @queue_wait_duration = started_at - scheduled_at + @started_at = started_at + @finished_monotonic = @started_monotonic = started_monotonic + end + end + + attr_reader :periodic_jobs + + def initialize(client, driver, config) + @client = client + @condition = ConditionVariable.new + @config = config + @driver = driver + @mutex = Mutex.new + @producer_threads = {} + @queue_configs = config.queues.dup + @removed_queues = {} + @running = {} + @started = false + @stopped = true + @subscriptions = [] + @threads = [] + @periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs, + enabled: !config.leader_election_disabled, wake: method(:periodic_jobs_changed)) + end + + def finish_claimed(row, error = nil) + timing = JobTiming.new(row.attempted_at || Time.now.utc, monotonic_now, row.scheduled_at) + job = Job.new(@client, row) + case error + when nil + now = Time.now.utc + completed = @driver.job_complete(id: row.id, finalized_at: now, now: now) + case completed + when :cancelled + finish_failed(row, job, JobCancelError.new, timing, cancelled: true) + else + publish(EVENT_JOB_COMPLETED, completed, timing) if completed + end + when JobCancelError + finish_failed(row, job, error, timing, cancelled: true) + when JobSnoozeError + finish_snoozed(row, job, error, timing) + when Interrupted + finish_interrupted(row, job, timing) + else + worker = begin + resolve_worker(row.kind) if !row.__decode_error && @config.workers.include?(row.kind) + rescue => worker_error + @config.logger.error("River worker initialization failed during finalization: #{worker_error.full_message}") + nil + end + finish_failed(row, job, error, timing, worker: worker) + end + end + + def perform_job(id, allow_scheduled: false) + @mutex.synchronize do + raise ClientAlreadyStartedError, "synchronous execution requires an idle, stopped client" if @started || @performing + + @performing = true + @stop_requested = false + end + + begin + row = @driver.job_claim(id: id, allow_scheduled: allow_scheduled, attempted_by: @config.id) + raise ArgumentError, "job #{id} is missing, not runnable, or scheduled in the future" unless row + + @mutex.synchronize { @running[row.id] = {queue: row.queue, thread: Thread.current, working: false} } + outcome, error = execute(row) + final_row = begin + @driver.job_get_by_id(id) + rescue JobRowDecodeError => decode_error + decode_error.job + end + [final_row, error, outcome] + ensure + @mutex.synchronize { @performing = false } + end + end + + def publish_queue(kind, queue) + event = Event.new(kind, nil, queue, nil) + @mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) } + end + + def healthy? + @mutex.synchronize do + @stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } && (!@maintenance_thread || @maintenance_thread.alive?)) + end + end + + def interrupt_workers + @mutex.synchronize do + @running.values.each { |entry| entry[:thread].raise(Interrupted) if entry[:working] } + end + end + + def queue_add(name, queue_config) + name = name.to_s + queue_config = QueueConfig.new(max_workers: queue_config) unless queue_config.is_a?(QueueConfig) + raise ArgumentError, "invalid queue name: #{name.inspect}" unless name.match?(QUEUE_NAME_REGEX) && name.length < 128 + + queue_config.resolved_fetch_poll_interval(@config) + should_start = @mutex.synchronize do + raise ArgumentError, "queue is already configured: #{name}" if @queue_configs.key?(name) + + @queue_configs[name] = queue_config + @removed_queues.delete(name) + @started && !@stop_requested + end + + if should_start + begin + start_producer(name, queue_config) + rescue + @mutex.synchronize do + if @queue_configs[name].equal?(queue_config) && !@producer_threads.key?(name) + @queue_configs.delete(name) + end + end + raise + end + start_maintenance + end + + wake + queue_config + end + + def queue_remove(name) + name = name.to_s + producer = @mutex.synchronize do + raise NotFoundError, "queue is not configured: #{name}" unless @queue_configs.key?(name) + if @producer_threads[name] == Thread.current || @running.any? { |_id, entry| entry[:queue] == name && entry[:thread] == Thread.current } + raise ThreadError, "cannot remove a queue from its own worker or producer" + end + + @removed_queues[name] = true + @condition.broadcast + @producer_threads[name] + end + + producer&.join + running = @mutex.synchronize do + @running.values.filter_map { |entry| entry[:thread] if entry[:queue] == name } + end + running.each(&:join) + + @mutex.synchronize do + @queue_configs.delete(name) + @producer_threads.delete(name) + end + + true + end + + def start + @driver.init_driver if @driver.respond_to?(:init_driver) + @mutex.synchronize do + raise ClientAlreadyStartedError, "client is already started" if @started || @performing + + @started = true + @stop_requested = false + @stopped = false + end + + begin + queues = @mutex.synchronize { @queue_configs.dup } + queues.each { |name, queue_config| start_producer(name, queue_config) } + start_maintenance unless @queue_configs.empty? && @periodic_jobs.empty? && @config.maintenance_services.empty? + + self + rescue + stop(cancel: true) + raise + end + end + + def started? + @mutex.synchronize { @started } + end + + def stop(cancel: false, wait: true) + threads = @mutex.synchronize do + return self if @stopped + if wait && (@threads.include?(Thread.current) || @running.any? { |_id, entry| entry[:thread] == Thread.current }) + raise ThreadError, "cannot wait for client stop from its own runtime thread; use wait: false" + end + + @stop_requested = true + @condition.broadcast + @running.values.each { |entry| entry[:thread].raise(Interrupted) if cancel && entry[:working] } + @threads.dup + end + + return self unless wait + + threads.each(&:join) + + running_threads = @mutex.synchronize { @running.values.map { |entry| entry[:thread] } } + running_threads.each(&:join) + + @driver.leader_release(@config.id) unless @config.leader_election_disabled + @mutex.synchronize do + @started = false + @stopped = true + @threads.clear + @producer_threads.clear + @maintenance_thread = nil + end + + self + end + + def stopped? + @mutex.synchronize { @stopped } + end + + def subscribe(kinds, buffer_size: 100) + subscription = Subscription.new( + kinds, + buffer_size: buffer_size, + on_close: method(:remove_subscription) + ) + @mutex.synchronize { @subscriptions << subscription } + subscription + end + + def wake + @mutex.synchronize { @condition.broadcast } + end + + private def begin_work(id) + should_interrupt = @mutex.synchronize do + entry = @running.fetch(id) + if @stop_requested + true + else + entry[:working] = true + false + end + end + + raise Interrupted if should_interrupt + end + + private def check_remote_cancellations(queue) + entries = @mutex.synchronize { @running.select { |_id, entry| entry[:queue] == queue && entry[:working] } } + return if entries.empty? + + @driver.job_get_cancelled_ids(entries.keys).each do |id| + entries.fetch(id)[:thread].raise(JobCancelError) + end + end + + private def error_handler_cancel?(error, job) + return false unless @config.error_handler + + result = if @config.error_handler.respond_to?(:handle_error) + @config.error_handler.handle_error(error, job) + else + @config.error_handler.call(error, job) + end + result == :cancel || result == true + rescue => handler_error + @config.logger.error("River error handler failed: #{handler_error.full_message}") + false + end + + private def execute(row) + timing = JobTiming.new(Time.now.utc, monotonic_now, row.scheduled_at) + job = Job.new(@client, row) + worker = nil #: untyped + begin + begin + raise row.__decode_error if row.__decode_error + + worker = resolve_worker(row.kind) + begin_work(row.id) + Thread.handle_interrupt(Interrupted => :immediate, JobCancelError => :immediate) do + invoke_worker(worker, job) + end + ensure + timing.finished_monotonic = monotonic_now + finish_work(row.id) + end + + finalize_hooks = @config.plugins.any? { |plugin| plugin.respond_to?(:job_finalize) } + if finalize_hooks + raise JobCancelError if @driver.job_get_cancelled_ids([row.id]).include?(row.id) + + if invoke_plugins(:job_finalize, job, JOB_STATE_COMPLETED).include?(:delete) + raise JobCancelError if @driver.job_delete_if_running(row.id) == :cancelled + + return [:deleted, nil] + end + end + + completed_at = Time.now.utc + completed = @driver.job_complete(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at) + case completed + when :cancelled then raise JobCancelError + else publish(EVENT_JOB_COMPLETED, completed, timing) if completed + end + + [:completed, nil] + rescue JobSnoozeError => error + job.__capture_resumable_metadata! + [finish_snoozed(row, job, error, timing), error] + rescue JobCancelError => error + job.__capture_resumable_metadata! + finish_failed(row, job, error, timing, cancelled: true) + [:cancelled, error] + rescue Interrupted => error + job.__capture_resumable_metadata! + [finish_interrupted(row, job, timing), error] + rescue => error + job.__capture_resumable_metadata! + [finish_failed(row, job, error, timing, worker: worker), error] + ensure + @mutex.synchronize do + @running.delete(row.id) + @condition.broadcast + end + end + end + + private def finish_failed(row, job, error, timing, cancelled: false, worker: nil) + cancelled ||= error_handler_cancel?(error, job) + now = Time.now.utc + attempt_error = AttemptError.new( + at: timing.started_at, + attempt: row.attempt, + error: error.message, + trace: Array(error.backtrace).join("\n") + ) + final = cancelled || row.attempt >= row.max_attempts || !retry_allowed?(worker, job, error) + state = if cancelled + JOB_STATE_CANCELLED + elsif final + JOB_STATE_DISCARDED + else + JOB_STATE_RETRYABLE + end + scheduled_at = final ? nil : next_retry(row, error, now, worker: worker) + state = JOB_STATE_AVAILABLE if scheduled_at && scheduled_at <= now + 5 + + updated = @driver.job_set_state_if_running( + id: row.id, + error: attempt_error, + finalized_at: final ? now : nil, + metadata: job.metadata_updates, + now: now, + scheduled_at: scheduled_at, + state: state + ) + event = cancelled ? EVENT_JOB_CANCELLED : EVENT_JOB_FAILED + publish(event, updated, timing) if updated + + case updated&.state || state + when JOB_STATE_CANCELLED then :cancelled + when JOB_STATE_DISCARDED then :discarded + else :retried + end + end + + private def finish_interrupted(row, job, timing) + interrupted = @driver.job_set_state_if_running( + id: row.id, + attempt: [row.attempt - 1, 0].max, + metadata: job.metadata_updates, + scheduled_at: Time.now.utc, + state: JOB_STATE_AVAILABLE + ) + publish(EVENT_JOB_INTERRUPTED, interrupted, timing) if interrupted + + (interrupted&.state == JOB_STATE_CANCELLED) ? :cancelled : :interrupted + end + + private def finish_snoozed(row, job, error, timing) + scheduled_at = Time.now.utc + error.duration + state = (error.duration <= 5) ? JOB_STATE_AVAILABLE : JOB_STATE_SCHEDULED + metadata = job.metadata_updates.merge("snoozes" => next_snooze_count(row.metadata["snoozes"])) + updated = @driver.job_set_state_if_running( + id: row.id, + attempt: [row.attempt - 1, 0].max, + metadata: metadata, + scheduled_at: scheduled_at, + state: state + ) + publish(EVENT_JOB_SNOOZED, updated, timing) if updated + (updated&.state == JOB_STATE_CANCELLED) ? :cancelled : :snoozed + end + + private def finish_work(id) + @mutex.synchronize do + entry = @running[id] + entry[:working] = false if entry + end + end + + private def invoke_plugins(name, ...) + @config.plugins.filter_map { |plugin| plugin.public_send(name, ...) if plugin.respond_to?(name) } + end + + private def invoke_worker(worker, job) + return perform_work(worker, job) if @config.plugins.empty? + + operation = -> do + error = nil + begin + invoke_plugins(:work_begin, job) + perform_work(worker, job) + rescue => error + raise + ensure + invoke_plugins(:work_end, job, error) + end + end + @config.plugins.reverse_each do |plugin| + next unless plugin.respond_to?(:work) + + next_operation = operation + operation = -> { plugin.work(job, next_operation) } + end + + operation.call + end + + private def launch(row) + gate = ::Queue.new + thread = Thread.new do + gate.pop + begin + Thread.handle_interrupt(Interrupted => :never, JobCancelError => :never) { execute(row) } + rescue Interrupted, JobCancelError + # A late asynchronous interrupt may become pending after work has + # already finalized. The database transition won that race. + end + end + + @mutex.synchronize { @running[row.id] = {queue: row.queue, thread: thread, working: false} } + gate.push(true) + end + + private def maintenance_loop + leader = false + next_schedule = next_rescue = next_cleanup = Time.at(0) + until stopping? + now = Time.now.utc + leader = leader ? @driver.leader_renew(@config.id, now: now) : @driver.leader_acquire(@config.id, now: now) + if leader + if now >= next_schedule + @driver.job_schedule(now: now) + run_periodic(now) + @config.maintenance_services.each do |service| + service.run(@client, @driver, now) + rescue => error + @config.logger.error("River maintenance service failed (#{service.class}): #{error.full_message}") + end + next_schedule = now + 5 + end + + if now >= next_rescue + @driver.job_rescue_stuck(horizon: now - 3_600, logger: @config.logger, now: now, + rescue_if: method(:rescue_job?), retry_policy: @config.retry_policy) + next_rescue = now + 30 + end + + if now >= next_cleanup + @driver.job_delete_finalized(now: now, retention: { + JOB_STATE_CANCELLED => @config.cancelled_job_retention_period, + JOB_STATE_COMPLETED => @config.completed_job_retention_period, + JOB_STATE_DISCARDED => @config.discarded_job_retention_period + }) + @driver.notification_delete_before(horizon: now - 300) if @driver.respond_to?(:notification_delete_before) + next_cleanup = now + 30 + end + end + + wait(5) + end + rescue => error + @config.logger.error("River maintenance stopped: #{error.full_message}") + wait(5) + retry unless stopping? + end + + private def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + private def next_retry(row, error, now, worker: nil) + worker ||= @config.workers[row.kind] unless row.__decode_error + custom = worker.next_retry(row, error) if worker.respond_to?(:next_retry) + + retry_at = custom || @config.retry_policy.next_retry(row, error, now: now) + raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time) + + (retry_at < now) ? DefaultClientRetryPolicy.new.next_retry(row, error, now: now) : retry_at + rescue => retry_error + @config.logger.error("River retry scheduling failed; using default backoff: #{retry_error.full_message}") + DefaultClientRetryPolicy.new.next_retry(row, error, now: now) + end + + # Preserve Ruby's lenient counter conversion without calling to_i on + # booleans/collections, or accepting only a prefix of a numeric string. + # Only canonical non-negative integers are part of the shared protocol. + private def next_snooze_count(value) + count = case value + when Integer then value + when Float then value.finite? ? value.to_i : 0 + when String then /\A-?[0-9]+\z/.match?(value) ? value.to_i : 0 + when true then 1 + else 0 + end + # Go stores and increments a signed 64-bit integer. + ((count + 1 + (1 << 63)) % (1 << 64)) - (1 << 63) + end + + private def perform_work(worker, job) + timeout = worker_timeout(worker, job) + result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job) + job.__finish_resumable_work! + result + end + + private def periodic_jobs_changed + start_maintenance + wake + end + + private def producer_loop(queue, queue_config) + cooldown = queue_config.resolved_fetch_cooldown(@config) + # Capture registrations (including aliases) at startup, not construction. + fetch_options = {} #: Hash[Symbol, Array[String]] + fetch_options[:kinds] = @config.workers.kinds if @config.fetch_only_known_kinds + last_fetch = 0.0 + poll_interval = queue_config.resolved_fetch_poll_interval(@config) + loop do + draining = queue_stopping?(queue) + break if draining && running_count(queue).zero? + + check_remote_cancellations(queue) + if draining + wait_for_running_jobs(queue, poll_interval) + next + end + + capacity = queue_config.max_workers - running_count(queue) + if capacity.positive? + # Inserts, completions, and spurious condition wakes must not bypass + # the minimum interval between database fetches. + while (sleep_for = cooldown - (monotonic_now - last_fetch)).positive? && !queue_stopping?(queue) + wait(sleep_for) + end + next if queue_stopping?(queue) + + # A pause may arrive during cooldown; only read the queue immediately + # before fetching, and avoid the read entirely when all slots are busy. + unless @driver.queue_get(queue)&.paused_at + jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue, **fetch_options) + last_fetch = monotonic_now + jobs.each { |job| launch(job) } + next unless jobs.empty? + end + end + + wait(poll_interval) + end + rescue => error + @config.logger.error("River producer for #{queue.inspect} stopped: #{error.full_message}") + if queue_stopping?(queue) + wait_for_running_jobs(queue, poll_interval || @config.fetch_poll_interval) + else + wait(poll_interval || @config.fetch_poll_interval) + end + retry unless queue_stopping?(queue) && running_count(queue).zero? + end + + private def publish(kind, job, timing) + # Another actor may have changed the row after our update. Pending, + # running, and unknown states do not represent a settled attempt. + return unless %w[available cancelled completed discarded retryable scheduled].include?(job.state) + + kind = EVENT_JOB_CANCELLED if job.state == JOB_STATE_CANCELLED + stats = JobStatistics.new( + monotonic_now - timing.finished_monotonic, + timing.queue_wait_duration, + timing.finished_monotonic - timing.started_monotonic + ) + event = Event.new(kind, job, nil, stats) + @mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) } + end + + private def queue_stopping?(queue) + @mutex.synchronize { @stop_requested || @removed_queues[queue] } + end + + private def remove_subscription(subscription) + @mutex.synchronize { @subscriptions.delete(subscription) } + end + + private def rescue_job?(row, now) + return true if row.__decode_error + + worker = @config.workers[row.kind] + worker = worker.new if worker.is_a?(Class) + timeout = worker_timeout(worker, Job.new(@client, row)) + attempted_at = row.attempted_at #: Time + !timeout.nil? && now - attempted_at >= timeout + rescue => error + @config.logger.error("River rescue timeout check failed; rescuing attempt: #{error.full_message}") + true + end + + private def resolve_worker(kind) + worker = @config.workers[kind] + raise UnknownJobKindError, kind unless worker + worker.is_a?(Class) ? worker.new : worker + end + + private def retry_allowed?(worker, job, error) + !worker.respond_to?(:retry?) || worker.retry?(job, error) + rescue => retry_error + @config.logger.error("River retry? hook failed; allowing retry: #{retry_error.full_message}") + true + end + + private def run_periodic(now) + due = @periodic_jobs.due(now) do |job, error| + @config.logger.error("River periodic job schedule failed (id=#{job.id.inspect}): #{error.full_message}") + end + due.each do |periodic_job| + value = periodic_job.constructor.call + next unless value + + args, opts = value.is_a?(Array) ? value : [value, nil] + opts = (opts || InsertOpts.new).dup + opts.metadata = (opts.metadata || {}).merge("periodic" => true) + opts.metadata["river:periodic_job_id"] = periodic_job.id if periodic_job.id && !periodic_job.id.empty? + @client.insert(args, insert_opts: opts) + rescue => error + @config.logger.error("River periodic job failed to insert: #{error.full_message}") + end + end + + private def running_count(queue) + @mutex.synchronize { @running.count { |_id, entry| entry[:queue] == queue } } + end + + private def start_maintenance + return if @config.leader_election_disabled + + @mutex.synchronize do + return unless @started && !@stop_requested + return if @maintenance_thread&.alive? + + thread = Thread.new { maintenance_loop } + @maintenance_thread = thread + @threads << thread + end + end + + private def start_producer(name, queue_config) + @driver.queue_upsert(name) + @mutex.synchronize do + return if @stop_requested || @removed_queues[name] || @producer_threads.key?(name) + + # Shutdown must see every launched thread, including a producer whose + # database setup overlapped a stop or queue removal. + thread = Thread.new { producer_loop(name, queue_config) } + @producer_threads[name] = thread + @threads << thread + end + end + + private def stopping? + @mutex.synchronize { @stop_requested } + end + + private def wait(duration) + @mutex.synchronize { @condition.wait(@mutex, duration) unless @stop_requested } + end + + private def wait_for_running_jobs(queue, duration) + @mutex.synchronize do + # Draining still needs a poll interval after stop is requested. Check + # under the same mutex as completion so the last job's wake isn't lost. + @condition.wait(@mutex, duration) if @running.any? { |_id, entry| entry[:queue] == queue } + end + end + + private def worker_timeout(worker, job) + timeout = worker.respond_to?(:timeout) ? worker.timeout(job) : @config.job_timeout + timeout = Float(timeout) unless timeout.nil? + timeout = @config.job_timeout if timeout == 0 + raise ArgumentError, "worker timeout must be finite and nonnegative, or nil" if timeout && (!timeout.finite? || timeout.negative?) + + timeout + end + end +end diff --git a/ruby/lib/config.rb b/ruby/lib/config.rb new file mode 100644 index 000000000..7fd698a81 --- /dev/null +++ b/ruby/lib/config.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "logger" +require "securerandom" +require "socket" + +module River + FETCH_COOLDOWN_DEFAULT = 0.1 + FETCH_POLL_INTERVAL_DEFAULT = 1.0 + JOB_TIMEOUT_DEFAULT = 60.0 + QUEUE_NAME_REGEX = /\A[a-zA-Z0-9_\-:.]+\z/ + QUEUE_NUM_WORKERS_MAX = 10_000 + + # Concurrency and polling behavior for one configured queue. + class QueueConfig + # Normalized concurrency and polling settings for the queue. + attr_reader :fetch_cooldown, :fetch_poll_interval, :max_workers + + # Configures worker concurrency and optional polling behavior for one queue. + def initialize(max_workers:, fetch_cooldown: nil, fetch_poll_interval: nil) + @fetch_cooldown = fetch_cooldown.nil? ? nil : Float(fetch_cooldown) + @fetch_poll_interval = fetch_poll_interval.nil? ? nil : Float(fetch_poll_interval) + @max_workers = Integer(max_workers) + + raise ArgumentError, "fetch intervals must be finite" unless [@fetch_cooldown, @fetch_poll_interval].compact.all?(&:finite?) + raise ArgumentError, "max_workers must be between 1 and #{QUEUE_NUM_WORKERS_MAX}" unless (1..QUEUE_NUM_WORKERS_MAX).cover?(@max_workers) + raise ArgumentError, "fetch_cooldown must be zero or greater" if @fetch_cooldown&.negative? + raise ArgumentError, "fetch_poll_interval must be zero or greater" if @fetch_poll_interval&.negative? + end + + def resolved_fetch_cooldown(config) + fetch_cooldown || config.fetch_cooldown + end + + def resolved_fetch_poll_interval(config) + value = fetch_poll_interval || config.fetch_poll_interval + raise ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown" if value < resolved_fetch_cooldown(config) + + value + end + end + + # Complete configuration for a River client and its worker runtime. + class Config + # Configured values used by Client and its runtime. + attr_reader :cancelled_job_retention_period, :completed_job_retention_period, + :discarded_job_retention_period, :error_handler, :fetch_cooldown, + :fetch_only_known_kinds, :fetch_poll_interval, :id, :job_timeout, + :leader_election_disabled, :logger, :maintenance_services, + :periodic_jobs, :plugins, :queues, :retry_policy, :workers + + # Creates a client configuration. + # + # Configure queues and workers here to enable job processing; a client with + # neither may still be used for insertion and administrative operations. + # +fetch_only_known_kinds+ leaves unregistered kinds untouched in shared + # queues. +leader_election_disabled+ prevents this client's maintenance; + # another eligible client must run scheduling, rescue, and cleanup. + def initialize( + queues: {}, + workers: Workers.new, + id: nil, + fetch_cooldown: FETCH_COOLDOWN_DEFAULT, + fetch_only_known_kinds: false, + fetch_poll_interval: FETCH_POLL_INTERVAL_DEFAULT, + job_timeout: JOB_TIMEOUT_DEFAULT, + leader_election_disabled: false, + retry_policy: DefaultClientRetryPolicy.new, + error_handler: nil, + plugins: [], + maintenance_services: [], + periodic_jobs: [], + cancelled_job_retention_period: 86_400, + completed_job_retention_period: 86_400, + discarded_job_retention_period: 604_800, + logger: nil + ) + @cancelled_job_retention_period = retention(cancelled_job_retention_period) + @completed_job_retention_period = retention(completed_job_retention_period) + @discarded_job_retention_period = retention(discarded_job_retention_period) + @error_handler = error_handler + @fetch_cooldown = Float(fetch_cooldown) + @fetch_only_known_kinds = fetch_only_known_kinds + @fetch_poll_interval = Float(fetch_poll_interval) + @id = id || "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(6)}" + @job_timeout = job_timeout.nil? ? nil : Float(job_timeout) + @leader_election_disabled = leader_election_disabled + @logger = logger || Logger.new($stdout, level: Logger::WARN) + @maintenance_services = maintenance_services.dup.freeze + @periodic_jobs = periodic_jobs.dup.freeze + @plugins = plugins.dup.freeze + @queues = normalize_queues(queues).freeze + @retry_policy = retry_policy + @workers = workers + + validate + end + + # Returns a new Config with the supplied values replacing this config's + # corresponding settings. + def with(**overrides) + Config.new(id: id, + cancelled_job_retention_period: cancelled_job_retention_period, + completed_job_retention_period: completed_job_retention_period, + discarded_job_retention_period: discarded_job_retention_period, + error_handler: error_handler, + fetch_cooldown: fetch_cooldown, + fetch_only_known_kinds: fetch_only_known_kinds, + fetch_poll_interval: fetch_poll_interval, + job_timeout: job_timeout, + leader_election_disabled: leader_election_disabled, + logger: logger, + maintenance_services: maintenance_services, + periodic_jobs: periodic_jobs, + plugins: plugins, + queues: queues, + retry_policy: retry_policy, + workers: workers, **overrides) + end + + private def normalize_queues(queues) + queues.to_h do |name, queue_config| + config = queue_config.is_a?(QueueConfig) ? queue_config : QueueConfig.new(max_workers: queue_config) + [name.to_s, config] + end + end + + private def retention(value) + return nil if value.nil? || value == -1 + + seconds = Float(value) + raise ArgumentError, "retention must be finite and nonnegative, or nil/-1 to disable" unless seconds.finite? && seconds >= 0 + + seconds + end + + private def validate + raise ArgumentError, "fetch intervals and job_timeout must be finite" unless [fetch_cooldown, fetch_poll_interval, job_timeout].compact.all?(&:finite?) + raise ArgumentError, "id must be between 1 and 127 characters" unless (1...128).cover?(id.length) + raise ArgumentError, "fetch_cooldown must be at least 0.001 seconds" if fetch_cooldown < 0.001 + raise ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown" if fetch_poll_interval < fetch_cooldown + raise ArgumentError, "job_timeout must be greater than zero or nil" if job_timeout && job_timeout <= 0 + raise ArgumentError, "retry_policy must respond to next_retry" unless retry_policy.respond_to?(:next_retry) + raise ArgumentError, "workers must be a River::Workers" unless workers.is_a?(Workers) + raise ArgumentError, "periodic_jobs must be empty when leader_election_disabled is true" if leader_election_disabled && !periodic_jobs.empty? + + queues.each do |name, queue_config| + raise ArgumentError, "invalid queue name: #{name.inspect}" unless name.match?(QUEUE_NAME_REGEX) && name.length < 128 + + queue_config.resolved_fetch_poll_interval(self) + end + end + end +end diff --git a/ruby/lib/driver.rb b/ruby/lib/driver.rb new file mode 100644 index 000000000..3be96401c --- /dev/null +++ b/ruby/lib/driver.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module River + # Contains an interface used by the top-level River module to interface with + # its driver implementations. All types and methods in this module should be + # considered to be for internal use only and subject to change. API stability + # is not guaranteed. + module Driver + # Insert parameters for a job. This is sent to underlying drivers and is meant + # for internal use only. Its interface is subject to change. + class JobInsertParams + attr_accessor :args + attr_accessor :encoded_args + attr_accessor :kind + attr_accessor :max_attempts + attr_accessor :metadata + attr_accessor :priority + attr_accessor :queue + attr_accessor :scheduled_at + attr_accessor :state + attr_accessor :tags + attr_accessor :unique_key + attr_accessor :unique_states + + def initialize( + encoded_args:, + kind:, + max_attempts:, + priority:, queue:, scheduled_at:, state:, tags:, metadata: {}, + unique_key: nil, + unique_states: nil, + args: nil + ) + self.args = args + self.encoded_args = encoded_args + self.kind = kind + self.max_attempts = max_attempts + self.metadata = metadata + self.priority = priority + self.queue = queue + self.scheduled_at = scheduled_at + self.state = state + self.tags = tags + self.unique_key = unique_key + self.unique_states = unique_states + end + end + end +end + +require_relative "driver/postgres_capabilities" +require_relative "driver/job_row_decoder" +require_relative "driver/runtime" diff --git a/ruby/lib/driver/job_row_decoder.rb b/ruby/lib/driver/job_row_decoder.rb new file mode 100644 index 000000000..9bcabaa9c --- /dev/null +++ b/ruby/lib/driver/job_row_decoder.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "time" + +module River::Driver + # Tolerance applies only to database reads, not application JSON encoding. + # Historical error entries must not make an otherwise valid job unreadable. + class JobRowDecoder + ZERO_TIME = Time.utc(1).freeze + private_constant :ZERO_TIME + + def self.attempt_error(value) + value = value.__getobj__ if value.respond_to?(:__getobj__) + fields = value.is_a?(Hash) ? value : {"error" => value} + River::AttemptError.new( + at: error_time(fields["at"]), + attempt: error_integer(fields["attempt"]), + error: error_string(fields["error"]), + trace: error_string(fields["trace"]) + ) + end + + def self.error_time(value) + return ZERO_TIME unless value.is_a?(String) + + # Keep Ruby's existing acceptance of PostgreSQL-style timestamps too. + Time.parse(value).utc + rescue ArgumentError + ZERO_TIME + end + + def self.error_integer(value) + return value if value.is_a?(Integer) + + if value.is_a?(String) + integer = Integer(value, 10, exception: false) + return integer if integer + + value = Float(value, exception: false) + end + (value.is_a?(Float) && value.finite? && value.abs <= 2**53 && value == value.truncate) ? value.to_i : 0 + end + + def self.error_string(value) + case value + when nil then "" + when String then value + else JSON.generate(value) + end + end + + def initialize + @errors = [] + end + + def json(field, raw, type: nil, strings: false, default: nil) + return default if raw.nil? + + decoded(field, JSON.parse(raw), type: type, strings: strings, default: default) + rescue JSON::ParserError, TypeError => error + @errors << "#{field}: #{error.message}" + default + end + + def decoded(field, value, type: nil, strings: false, default: nil) + # Sequel's PostgreSQL JSON and array wrappers delegate to Ruby values. + value = value.__getobj__ if value.respond_to?(:__getobj__) + return default if value.nil? + + raise TypeError, "expected #{type}" if type && !value.is_a?(type) + raise TypeError, "expected an array of strings" if strings && !value.all? { |item| item.is_a?(String) } + + value + rescue TypeError => error + @errors << "#{field}: #{error.message}" + default + end + + def finish(job) + raise River::JobRowDecodeError.new(job, @errors.join("; ")) unless @errors.empty? + + job + end + end +end diff --git a/ruby/lib/driver/postgres_capabilities.rb b/ruby/lib/driver/postgres_capabilities.rb new file mode 100644 index 000000000..25548ad05 --- /dev/null +++ b/ruby/lib/driver/postgres_capabilities.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module River::Driver + # PostgreSQL-compatible servers differ in conflict detection and notification + # support. Keep these rules aligned with Go's PostgresCapabilities. + class PostgresCapabilities + attr_reader :supports_listen_notify, :unique_insert_mode, :unique_insert_sql + + def initialize(product:, version_num:, yb_listen_notify_enabled:) + product = product.downcase + yugabyte = product.include?("-yb") || product.include?("yugabyte") + @supports_listen_notify = !yugabyte || yb_listen_notify_enabled + @unique_insert_mode, @unique_insert_sql = if yugabyte + [:metadata_nonce, "false"] + elsif version_num >= 180_000 + [:returning_old, "(OLD.id IS NOT NULL)"] + else + [:xmax, "(xmax != 0)"] + end + freeze + end + + # Scope detection to a pool: Active Record may route one driver to different + # roles/shards. Failures are never cached, and no lock spans database I/O: + # another caller may already hold the pool's only connection. + class Cache + def initialize + @mutex = Mutex.new + @values = {} + end + + def fetch(pool) + cached = @mutex.synchronize { @values[pool] } + return cached if cached + + detected = yield + @mutex.synchronize { @values[pool] ||= detected } + end + end + end +end diff --git a/ruby/lib/driver/runtime.rb b/ruby/lib/driver/runtime.rb new file mode 100644 index 000000000..3e1faced4 --- /dev/null +++ b/ruby/lib/driver/runtime.rb @@ -0,0 +1,772 @@ +# frozen_string_literal: true + +require "time" + +module River::Driver + # Database operations used by River's worker runtime. Driver gems implement a + # small set of raw SQL primitives and include this module so the state machine + # stays identical across ActiveRecord and Sequel. + module Runtime + def init_driver + postgres_capabilities if runtime_postgres? + end + + def postgres_capabilities + @postgres_capabilities_cache.fetch(runtime_connection_pool) do + row = runtime_query_rows(<<~SQL).first + SELECT version()::text AS product, + current_setting('server_version_num')::int AS version_num, + coalesce(current_setting('yb_enable_listen_notify', true), 'off')::boolean AS yb_listen_notify_enabled + SQL + PostgresCapabilities.new( + product: runtime_value(row, :product), + version_num: runtime_value(row, :version_num), + yb_listen_notify_enabled: runtime_value(row, :yb_listen_notify_enabled) == true + ) + end + end + + # Notifications and rows must commit together. The insertion drivers call + # this inside their transaction; PostgreSQL defers delivery until commit. + private def postgres_notify_insert(params) + queues = params.select { |param| param.state == River::JOB_STATE_AVAILABLE }.map(&:queue).uniq + return if queues.empty? + return unless postgres_capabilities.supports_listen_notify + + payloads = queues.map { |queue| "(#{runtime_quote(JSON.generate(queue: queue))})" }.join(",") + runtime_execute("SELECT pg_notify(current_schema() || '.river_insert', payload) FROM (VALUES #{payloads}) AS notifications(payload)") + end + + def job_cancel(id, now: Time.now.utc) + original_id = Integer(id) + transaction do + updated_id = runtime_returning_ids(<<~SQL).first + UPDATE river_job + SET state = CASE WHEN state = 'running' THEN state ELSE 'cancelled' END, + finalized_at = CASE WHEN state = 'running' THEN finalized_at ELSE #{runtime_time(now)} END, + metadata = #{runtime_merge_metadata("cancel_attempted_at" => now.iso8601(6))} + WHERE id = #{original_id} + AND state NOT IN ('cancelled', 'completed', 'discarded') + AND finalized_at IS NULL + RETURNING id + SQL + row = job_get_by_id(updated_id || original_id) + # Other engines interrupt running workers through this notification; + # the durable marker remains Ruby's polling fallback. + runtime_notify("river_control", action: "cancel", job_id: row.id, queue: row.queue) if updated_id + row + end + end + + def job_claim(id:, attempted_by:, allow_scheduled: false, now: Time.now.utc) + predicate = "id = #{Integer(id)} AND state IN ('available', 'retryable', 'scheduled')" + predicate += " AND scheduled_at <= #{runtime_time(now)}" unless allow_scheduled + + runtime_claim_jobs(predicate, 1, attempted_by, now).first + end + + # The cancellation predicate must be part of the completion update so it is + # checked against the row we lock, including concurrent cancellations. + # :cancelled asks the runtime to apply its normal cancellation/error hooks. + def job_complete(id:, finalized_at:, metadata: nil, now: Time.now.utc) + id = Integer(id) + assignments = ["state = 'completed'", "finalized_at = #{runtime_time(finalized_at)}"] + assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty? + + updated_id = runtime_returning_ids(<<~SQL).first + UPDATE river_job SET #{assignments.join(", ")} + WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted}) + RETURNING id + SQL + return runtime_read_job(updated_id) if updated_id + + :cancelled if job_get_cancelled_ids([id]).include?(id) + end + + def job_delete(id) + existing = job_get_by_id(id) + return nil unless existing + return existing if existing.state == River::JOB_STATE_RUNNING + + deleted_id = runtime_returning_ids("DELETE FROM river_job WHERE id = #{Integer(id)} AND state != 'running' RETURNING id").first + deleted_id ? existing : job_get_by_id(id) + end + + def job_delete_finalized(retention:, now: Time.now.utc, max: 1_000) + clauses = retention.filter_map do |state, seconds| + next unless seconds + + "(state = #{runtime_quote(state.to_s)} AND finalized_at < #{runtime_time(now - seconds)})" + end + + return 0 if clauses.empty? + + ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i } + SELECT id FROM river_job WHERE #{clauses.join(" OR ")} ORDER BY id LIMIT #{Integer(max)} + SQL + return 0 if ids.empty? + + runtime_returning_ids(<<~SQL).length + DELETE FROM river_job + WHERE id IN (#{ids.join(",")}) AND (#{clauses.join(" OR ")}) + RETURNING id + SQL + end + + # A finalization hook's deletion must honor the same cancellation marker as + # completion. Return :cancelled so the runtime can record the cancellation. + def job_delete_if_running(id) + id = Integer(id) + deleted = runtime_returning_ids(<<~SQL).any? + DELETE FROM river_job + WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted}) + RETURNING id + SQL + return true if deleted + return :cancelled if job_get_cancelled_ids([id]).include?(id) + + false + end + + def job_delete_many(params) + transaction do + jobs = runtime_job_list(params, for_delete: true) + next [] if jobs.empty? + + ids = jobs.map(&:id) + deleted = runtime_returning_ids(<<~SQL) + DELETE FROM river_job + WHERE id IN (#{ids.join(",")}) AND state != 'running' + RETURNING id + SQL + jobs.select { |job| deleted.include?(job.id) } + end + end + + def job_get_available(queue:, max:, attempted_by:, kinds: nil, now: Time.now.utc) + return [] if kinds&.empty? + + predicate = "state = 'available' AND queue = #{runtime_quote(queue)} AND scheduled_at <= #{runtime_time(now)}" + predicate += " AND #{runtime_in_clause("kind", kinds)}" if kinds + runtime_claim_jobs(predicate, max, attempted_by, now) + end + + def job_get_cancelled_ids(ids) + return [] if ids.empty? + + runtime_query_rows("SELECT id FROM river_job WHERE id IN (#{ids.map { |id| Integer(id) }.join(",")}) AND (#{runtime_cancel_attempted}) ORDER BY id") + .map { |row| runtime_value(row, :id).to_i } + end + + def job_list(params = nil) + params ||= River::JobListParams.new + return runtime_job_list_without_params if params == :all + + runtime_job_list(params) + end + + def job_metadata_merge(id, metadata) + updated_id = runtime_returning_ids(<<~SQL).first + UPDATE river_job + SET metadata = #{runtime_merge_metadata(metadata)} + WHERE id = #{Integer(id)} + RETURNING id + SQL + updated_id ? job_get_by_id(updated_id) : nil + end + + def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000, logger: nil, rescue_if: nil) + max = Integer(max) + transaction do + # Lock each candidate until its transition finishes. Continue past jobs + # that are still within their timeouts without consuming the rescue limit. + after_id = rescued = 0 + lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : "" + while rescued < max + ids = runtime_returning_ids(<<~SQL) + SELECT id FROM river_job + WHERE state = 'running' AND attempted_at < #{runtime_time(horizon)} AND id > #{after_id} + ORDER BY id LIMIT #{max - rescued} #{lock} + SQL + break if ids.empty? + + after_id = ids.last + ids.each do |id| + job = runtime_read_job(id) + cancelled = job.metadata.key?("cancel_attempted_at") + next if !cancelled && rescue_if && !rescue_if.call(job, now) + final = cancelled || job.attempt >= job.max_attempts + state = if cancelled + River::JOB_STATE_CANCELLED + elsif final + River::JOB_STATE_DISCARDED + else + River::JOB_STATE_RETRYABLE + end + error = River::AttemptError.new(at: now, attempt: job.attempt, error: "Stuck job rescued by River", trace: "") + rescue_count = job.metadata["river:rescue_count"] + rescue_count = case rescue_count + when Integer then rescue_count + when Float then rescue_count.finite? ? rescue_count.to_i : 0 + else 0 + end + job_set_state_if_running( + id: job.id, + error: error, + finalized_at: final ? now : nil, + metadata: {"river:rescue_count" => rescue_count + 1}, + now: now, + scheduled_at: final ? nil : runtime_rescue_retry(job, error, retry_policy, now, logger), + state: state + ) + rescued += 1 + end + end + + rescued + end + end + + def job_retry(id, now: Time.now.utc) + updated_id, job = transaction do + # Check the counter in the write itself so a concurrent claim cannot + # advance it between validation and the update. + updated_id = runtime_returning_ids(<<~SQL).first + UPDATE river_job + SET state = 'available', + max_attempts = CASE WHEN attempt = max_attempts THEN max_attempts + 1 ELSE max_attempts END, + metadata = #{runtime_postgres? ? "metadata - 'cancel_attempted_at'" : "jsonb_remove(metadata, '$.cancel_attempted_at')"}, + finalized_at = NULL, + scheduled_at = #{runtime_time(now)} + WHERE id = #{Integer(id)} + AND state != 'running' + AND (state != 'available' OR scheduled_at > #{runtime_time(now)}) + AND attempt < #{River::MAX_ATTEMPTS_LIMIT} + RETURNING id + SQL + [updated_id, job_get_by_id(updated_id || id)] + end + if !updated_id && job && job.state != "running" && (job.state != "available" || job.scheduled_at > now) && job.attempt >= River::MAX_ATTEMPTS_LIMIT + raise ArgumentError, "cannot retry a job with #{River::MAX_ATTEMPTS_LIMIT} or more attempts" + end + job + end + + def job_schedule(now: Time.now.utc, max: 1_000) + transaction do + # Hold each selected row until its transition (including uniqueness + # conflict handling) finishes. A concurrent retry may change its due time. + lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : "" + ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i } + SELECT id FROM river_job + WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= #{runtime_time(now)} + ORDER BY priority, scheduled_at, id + LIMIT #{Integer(max)} #{lock} + SQL + ids.each do |id| + transaction do + runtime_execute("UPDATE river_job SET state = 'available' WHERE id = #{id} AND state IN ('retryable', 'scheduled')") + end + rescue runtime_unique_violation_class + runtime_execute(<<~SQL) + UPDATE river_job + SET state = 'discarded', finalized_at = #{runtime_time(now)}, + metadata = #{runtime_merge_metadata("unique_key_conflict" => "scheduler_discarded")} + WHERE id = #{id} + SQL + end + + ids.length + end + end + + def job_set_state_if_running(id:, state:, now: Time.now.utc, attempt: nil, + error: nil, finalized_at: nil, metadata: nil, scheduled_at: nil) + state = state.to_s + # Cancellation wins over every attempt outcome, including exhausted + # retries, direct completion, and transitions requested by extensions. + cancel_path = runtime_cancel_attempted + + assignments = [] #: Array[String] + assignments << "attempt = CASE WHEN NOT (#{cancel_path}) THEN #{Integer(attempt)} ELSE attempt END" unless attempt.nil? + assignments << "errors = #{runtime_append_error(error)}" if error + + assignments << "finalized_at = CASE WHEN #{cancel_path} THEN #{runtime_time(now)} ELSE #{runtime_nullable_time(finalized_at)} END" + assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty? + assignments << "scheduled_at = CASE WHEN NOT (#{cancel_path}) THEN #{runtime_time(scheduled_at)} ELSE scheduled_at END" if scheduled_at + + assignments << "state = CASE WHEN #{cancel_path} THEN 'cancelled' ELSE #{runtime_state(state)} END" + + id = runtime_returning_ids(<<~SQL).first + UPDATE river_job + SET #{assignments.join(",\n ")} + WHERE id = #{Integer(id)} AND state = 'running' + RETURNING id + SQL + id ? runtime_read_job(id) : nil + end + + def job_update(id, params) + assignments = params.each.map do |field, value| + raise ArgumentError, "unknown update field: #{field}" unless River::JobUpdateParams.method_defined?(field) + + "#{field} = #{runtime_update_value(field, value)}" + end + + return job_get_by_id(id) if assignments.empty? + + updated_id = runtime_returning_ids(<<~SQL).first + UPDATE river_job SET #{assignments.join(", ")} + WHERE id = #{Integer(id)} + RETURNING id + SQL + updated_id ? job_get_by_id(updated_id) : nil + end + + def leader_acquire(id, ttl: 30, now: Time.now.utc) + transaction do + runtime_execute("DELETE FROM river_leader WHERE expires_at < #{runtime_time(now)}") + runtime_execute(<<~SQL) + INSERT INTO river_leader (leader_id, elected_at, expires_at) + VALUES (#{runtime_quote(id)}, #{runtime_time(now)}, #{runtime_time(now + ttl)}) + ON CONFLICT (name) DO NOTHING + SQL + runtime_query_rows("SELECT leader_id FROM river_leader WHERE leader_id = #{runtime_quote(id)}").any? + end + end + + def leader_release(id) + transaction do + deleted = runtime_query_rows("DELETE FROM river_leader WHERE leader_id = #{runtime_quote(id)} RETURNING leader_id") + runtime_notify("river_leadership", action: "resigned", leader_id: id) unless deleted.empty? + end + end + + def leader_renew(id, ttl: 30, now: Time.now.utc) + runtime_query_rows(<<~SQL).any? + UPDATE river_leader SET expires_at = #{runtime_time(now + ttl)} + WHERE leader_id = #{runtime_quote(id)} AND expires_at >= #{runtime_time(now)} + RETURNING leader_id + SQL + end + + # Bounded batches release SQLite's writer lock between maintenance passes. + def notification_delete_before(horizon:, max: 10_000) + return 0 if runtime_postgres? + + runtime_returning_ids(<<~SQL).length + DELETE FROM river_notification + WHERE id IN ( + SELECT id FROM river_notification + WHERE created_at < #{runtime_time(horizon)} + ORDER BY created_at, id LIMIT #{Integer(max)} + ) + RETURNING id + SQL + end + + def queue_get(name) + row = runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue WHERE name = #{runtime_quote(name)}").first + runtime_queue_from_row(row) + end + + def queue_list(max: 100) + runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue ORDER BY name LIMIT #{Integer(max)}") + .map { |row| runtime_queue_from_row(row) } + end + + def queue_pause(name, now: Time.now.utc) + transaction do + filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}" + changed = runtime_query_rows(<<~SQL) + UPDATE river_queue + SET paused_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE paused_at END, + updated_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE updated_at END + WHERE #{filter} + RETURNING #{runtime_queue_columns} + SQL + runtime_notify("river_control", action: "pause", queue: name) unless changed.empty? + changed.map { |row| runtime_queue_from_row(row) } + end + end + + def queue_resume(name, now: Time.now.utc) + transaction do + filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}" + changed = runtime_query_rows(<<~SQL) + UPDATE river_queue + SET updated_at = CASE WHEN paused_at IS NOT NULL THEN #{runtime_time(now)} ELSE updated_at END, + paused_at = NULL + WHERE #{filter} + RETURNING #{runtime_queue_columns} + SQL + runtime_notify("river_control", action: "resume", queue: name) unless changed.empty? + changed.map { |row| runtime_queue_from_row(row) } + end + end + + def queue_update(name, metadata:, now: Time.now.utc) + raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash) + + transaction do + row = runtime_query_rows(<<~SQL).first + UPDATE river_queue SET metadata = #{runtime_json(metadata)}, updated_at = #{runtime_time(now)} + WHERE name = #{runtime_quote(name)} RETURNING name + SQL + if row + runtime_notify("river_control", action: "metadata_changed", queue: name, metadata: metadata) + queue_get(name) + end + end + end + + def queue_upsert(name, metadata: {}, now: Time.now.utc) + raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash) + + runtime_execute(<<~SQL) + INSERT INTO river_queue (name, created_at, metadata, updated_at) + VALUES (#{runtime_quote(name)}, #{runtime_time(now)}, #{runtime_json(metadata)}, #{runtime_time(now)}) + ON CONFLICT (name) DO UPDATE SET updated_at = excluded.updated_at + SQL + queue_get(name) + end + + private def runtime_append_error(error) + value = error.respond_to?(:to_h) ? error.to_h : error + if runtime_postgres? + "array_append(errors, #{runtime_json(value)})" + else + encoded = "json(#{runtime_quote(JSON.generate(value))})" + # Damaged JSONB may contain invalid UTF-8. Preserve its bytes as hex + # rather than creating an unreadable JSON string by casting it to text. + <<~SQL + CASE WHEN NOT json_valid(errors, 10) AND errors IS NOT NULL THEN + jsonb(json_array(CASE WHEN typeof(errors) = 'blob' THEN 'invalid JSONB: ' || hex(errors) ELSE errors END, #{encoded})) + WHEN coalesce(json_type(errors), 'array') <> 'array' THEN jsonb(json_array(json(errors), #{encoded})) + ELSE jsonb(json_insert(json(coalesce(errors, jsonb('[]'))), '$[#]', #{encoded})) END + SQL + end + end + + private def runtime_cancel_attempted + runtime_postgres? ? "metadata ? 'cancel_attempted_at'" : "(CASE WHEN json_valid(metadata, 10) THEN metadata -> 'cancel_attempted_at' END) IS NOT NULL" + end + + private def runtime_claim_jobs(predicate, max, attempted_by, now) + transaction do + attempted_by_sql = if runtime_postgres? + "array_append(CASE WHEN cardinality(attempted_by) >= 100 THEN attempted_by[(cardinality(attempted_by) - 98):] ELSE attempted_by END, #{runtime_quote(attempted_by)})" + else + # Preserve corrupt history so decoding can fail this attempt instead + # of silently repairing it or aborting the entire claim batch. Trim + # to the newest 99 entries before appending, preserving JSON types. + <<~SQL + CASE WHEN NOT json_valid(attempted_by, 10) AND attempted_by IS NOT NULL THEN attempted_by + ELSE jsonb(json_insert(json( + CASE WHEN json_array_length(attempted_by) >= 100 THEN ( + SELECT json_group_array(json(value)) FROM ( + SELECT attempted_by -> ('$[' || key || ']') AS value FROM json_each(attempted_by) + WHERE key >= json_array_length(attempted_by) - 99 ORDER BY key + ) + ) ELSE coalesce(attempted_by, jsonb('[]')) END + ), '$[#]', #{runtime_quote(attempted_by)})) END + SQL + end + + lock_clause = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : "" + # An administratively requeued job may already be at the counter limit. + # Saturate instead of letting that row abort the entire claim batch. + ids = runtime_returning_ids(<<~SQL) + UPDATE river_job + SET attempt = CASE WHEN attempt < #{River::MAX_ATTEMPTS_LIMIT} THEN attempt + 1 ELSE attempt END, + attempted_at = #{runtime_time(now)}, + attempted_by = #{attempted_by_sql}, + state = 'running' + WHERE id IN ( + SELECT id FROM river_job + WHERE #{predicate} + ORDER BY priority ASC, scheduled_at ASC, id ASC + LIMIT #{Integer(max)} + #{lock_clause} + ) + RETURNING id + SQL + ids.map { |id| runtime_read_job(id) } + end + end + + private def runtime_read_job(id) + job_get_by_id(id) + rescue River::JobRowDecodeError => error + error.job + end + + private def runtime_decode_postgres_job(row) + row = row.transform_keys(&:to_sym) + decoder = JobRowDecoder.new + decoder.finish(River::JobRow.new( + id: row[:id], args: decoder.decoded(:args, row[:args], default: {}), + attempt: row[:attempt], attempted_at: row[:attempted_at]&.getutc, + attempted_by: decoder.decoded(:attempted_by, row[:attempted_by], type: Array, strings: true), + created_at: row[:created_at].getutc, + errors: row[:errors]&.map { |error| JobRowDecoder.attempt_error(error) }, + finalized_at: row[:finalized_at]&.getutc, kind: row[:kind], max_attempts: row[:max_attempts], + metadata: decoder.decoded(:metadata, row[:metadata], type: Hash, default: {}), + priority: row[:priority], queue: row[:queue], scheduled_at: row[:scheduled_at].getutc, + state: row[:state], tags: decoder.decoded(:tags, row[:tags], type: Array, strings: true), + unique_key: row[:unique_key]&.to_s, + unique_states: row[:unique_states] ? River::UniqueBitmask.to_states(row[:unique_states].to_i(2)) : nil + )) + end + + private def runtime_decode_sqlite_job(row) + row = row.transform_keys(&:to_sym) + decoder = JobRowDecoder.new + errors = decoder.json(:errors, row[:errors], type: Array, default: []) + decoder.finish(River::JobRow.new( + id: row[:id], args: decoder.json(:args, row[:args], default: {}), + attempt: row[:attempt], attempted_at: runtime_parse_time(row[:attempted_at]), + attempted_by: decoder.json(:attempted_by, row[:attempted_by], type: Array, strings: true), + created_at: runtime_parse_time(row[:created_at]), + errors: errors.map { |error| JobRowDecoder.attempt_error(error) }, + finalized_at: runtime_parse_time(row[:finalized_at]), kind: row[:kind], max_attempts: row[:max_attempts], + metadata: decoder.json(:metadata, row[:metadata], type: Hash, default: {}), + priority: row[:priority], queue: row[:queue], scheduled_at: runtime_parse_time(row[:scheduled_at]), + state: row[:state], tags: decoder.json(:tags, row[:tags], type: Array, strings: true), + unique_key: row[:unique_key]&.to_s, + unique_states: row[:unique_states] ? River::UniqueBitmask.to_states(row[:unique_states]) : nil + )) + end + + private def runtime_in_clause(column, values) + "#{column} IN (#{values.map { |value| runtime_quote(value) }.join(",")})" + end + + private def runtime_notify(topic, payload) + encoded = runtime_quote(JSON.generate(payload)) + if runtime_postgres? + return unless postgres_capabilities.supports_listen_notify + + runtime_execute("SELECT pg_notify(current_schema() || '.' || #{runtime_quote(topic)}, #{encoded})") + else + runtime_execute("INSERT INTO river_notification (topic, payload) VALUES (#{runtime_quote(topic)}, #{encoded})") + end + end + + private def sqlite_insert_nonces(params) + keys = {} #: Hash[String, bool] + params.map do |param| + key = param.unique_key + if key && !key.empty? && River::UniqueBitmask.to_states((param.unique_states || "0").to_i(2)).include?(param.state) + # PostgreSQL rejects batches that upsert the same indexed row twice. + raise ArgumentError, "unique key appears more than once in batch" if keys[key] + + keys[key] = true + end + SecureRandom.hex(8) + end + end + + private def runtime_cursor_clause(params) + cursor = params.after + comparison = (params.sort_order == :asc) ? ">" : "<" + id_clause = "id #{comparison} #{Integer(cursor.id)}" + return id_clause if params.sort_by == :id + + column = params.sort_by + return "(#{column} IS NULL AND #{id_clause})" if cursor.value.nil? + + value = runtime_time(cursor.value) + "(#{column} IS NULL OR #{column} #{comparison} #{value} OR (#{column} = #{value} AND #{id_clause}))" + end + + private def runtime_job_list(params, for_delete: false) + clauses = [] #: Array[String] + clauses << "state != 'running'" if for_delete + clauses << runtime_cursor_clause(params) if params.after + clauses << "id #{(params.sort_order == :asc) ? ">" : "<"} #{Integer(params.after_id)}" if params.after_id + clauses << runtime_in_clause("id", params.ids.map { |value| Integer(value) }) if params.ids&.any? + clauses << runtime_in_clause("kind", params.kinds) if params.kinds&.any? + clauses << runtime_in_clause("priority", params.priorities.map { |value| Integer(value) }) if params.priorities&.any? + clauses << runtime_in_clause("queue", params.queues) if params.queues&.any? + clauses << runtime_in_clause("state", params.states) if params.states&.any? + + finalized = params.sort_by == :finalized_at && params.states&.length == 1 && + %w[cancelled completed discarded].include?(params.states.first) + # Schemas require finalized timestamps for terminal states. Spell this out + # so PostgreSQL can use the partial (state, finalized_at) index. + clauses << "finalized_at IS NOT NULL" if finalized + # Explicit NULLS LAST prevents a backward index scan, even when there are + # no nulls. Only request it for timestamps that can actually be null. + null_order = (params.sort_by == :finalized_at && !finalized) ? " NULLS LAST" : "" + + params.metadata&.each { |key, value| clauses << runtime_metadata_equals(key, value) } + Array(params.tags_all).each { |tag| clauses << runtime_tag_contains(tag) } + if params.tags_any&.any? + clauses << "(" + params.tags_any.map { |tag| runtime_tag_contains(tag) }.join(" OR ") + ")" + end + + where = clauses.empty? ? "" : "WHERE #{clauses.join(" AND ")}" + runtime_job_rows(<<~SQL) + #{where} + ORDER BY #{params.sort_by} #{params.sort_order.to_s.upcase}#{null_order}, id #{params.sort_order.to_s.upcase} + LIMIT #{params.limit} + #{"FOR UPDATE SKIP LOCKED" if for_delete && runtime_postgres?} + SQL + end + + private def runtime_json(value) + encoded = value.is_a?(String) ? value : JSON.generate(value) + runtime_postgres? ? "#{runtime_quote(encoded)}::jsonb" : "jsonb(#{runtime_quote(encoded)})" + end + + private def runtime_merge_metadata(metadata) + raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash) + + if runtime_postgres? + "metadata || #{runtime_json(metadata)}" + else + # PostgreSQL's || replaces top-level values, including JSON null. + # JSON Merge Patch would recursively merge objects and delete nulls. + merged = metadata.reduce("metadata") do |expression, (key, value)| + path = runtime_quote("$.#{JSON.generate(key.to_s)}") + "jsonb_set(#{expression}, #{path}, jsonb(#{runtime_quote(JSON.generate(value))}))" + end + "CASE WHEN json_valid(metadata, 10) THEN #{merged} ELSE metadata END" + end + end + + private def runtime_metadata_equals(key, value) + encoded = runtime_quote(JSON.generate(value)) + if runtime_postgres? + "metadata -> #{runtime_quote(key.to_s)} = #{encoded}::jsonb" + else + # Compare JSON trees so object key order is immaterial, while strings, + # booleans, null, and missing keys remain distinct. json_each also treats + # the requested metadata key literally instead of as a JSON path. + columns = "fullkey, CASE WHEN type IN ('integer', 'real') THEN 'number' ELSE type END, atom" + actual = <<~SQL + SELECT #{columns} FROM json_tree(CASE entry.type + WHEN 'text' THEN json_quote(entry.value) + WHEN 'null' THEN 'null' + WHEN 'true' THEN 'true' + WHEN 'false' THEN 'false' + ELSE entry.value END) + SQL + expected = "SELECT #{columns} FROM json_tree(#{encoded})" + <<~SQL + EXISTS (SELECT 1 FROM json_each(metadata) AS entry + WHERE entry.key = #{runtime_quote(key.to_s)} + AND NOT EXISTS (#{actual} EXCEPT #{expected}) + AND NOT EXISTS (#{expected} EXCEPT #{actual})) + SQL + end + end + + private def runtime_nullable_time(value) + value ? runtime_time(value) : "NULL" + end + + private def runtime_parse_json(value) + value.is_a?(String) ? JSON.parse(value) : value.to_h + end + + private def runtime_parse_time(value) + return nil unless value + + if value.respond_to?(:getutc) + value.getutc + else + Time.parse(value.to_s + (value.to_s.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/) ? "" : " UTC")).utc + end + end + + private def runtime_queue_columns + runtime_postgres? ? "name, created_at, metadata, paused_at, updated_at" : "name, CAST(created_at AS text) AS created_at, json(metadata) AS metadata, CAST(paused_at AS text) AS paused_at, CAST(updated_at AS text) AS updated_at" + end + + private def runtime_queue_from_row(row) + return nil unless row + + River::Queue.new( + runtime_value(row, :name), + runtime_parse_time(runtime_value(row, :created_at)), + runtime_parse_json(runtime_value(row, :metadata)), + runtime_parse_time(runtime_value(row, :paused_at)), + runtime_parse_time(runtime_value(row, :updated_at)) + ) + end + + private def runtime_rescue_retry(job, error, retry_policy, now, logger) + retry_at = retry_policy.next_retry(job, error, now: now) + raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time) + raise ArgumentError, "next_retry must not return a past Time" if retry_at < now + + retry_at + rescue => retry_error + logger&.error("River rescue retry scheduling failed; using default backoff: #{retry_error.full_message}") + River::DefaultClientRetryPolicy.new.next_retry(job, error, now: now) + end + + private def runtime_returning_ids(sql) + runtime_query_rows(sql).map { |row| runtime_value(row, :id).to_i } + end + + private def runtime_state(value) + runtime_postgres? ? "#{runtime_quote(value)}::river_job_state" : runtime_quote(value) + end + + private def runtime_tag_contains(tag) + if runtime_postgres? + "tags @> ARRAY[#{runtime_quote(tag)}]::varchar[]" + else + "EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE value = #{runtime_quote(tag)})" + end + end + + private def runtime_time(value) + raise ArgumentError, "time cannot be nil" unless value + + cast = runtime_postgres? ? "::timestamptz" : "" + encoded = if runtime_postgres? + value.getutc.iso8601(6) + else + value.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N") + end + "#{runtime_quote(encoded)}#{cast}" + end + + private def runtime_update_value(field, value) + case field + when :attempt + attempt = Integer(value) + raise ArgumentError, "attempt must be between 0 and #{River::MAX_ATTEMPTS_LIMIT}" unless (0..River::MAX_ATTEMPTS_LIMIT).cover?(attempt) + + attempt.to_s + when :attempted_at, :finalized_at + value ? runtime_time(value) : "NULL" + when :attempted_by + values = Array(value) #: Array[untyped] + raise ArgumentError, "attempted_by must contain only Strings" unless values.all? { |item| item.is_a?(String) } + + runtime_postgres? ? "ARRAY[#{values.map { |item| runtime_quote(item) }.join(",")}]::text[]" : runtime_json(values) + when :errors + input_values = Array(value) #: Array[untyped] + values = input_values.map { |error| error.respond_to?(:to_h) ? error.to_h : error } + runtime_postgres? ? "ARRAY[#{values.map { |item| runtime_json(item) }.join(",")}]::jsonb[]" : runtime_json(values) + when :max_attempts + max_attempts = Integer(value) + raise ArgumentError, "max_attempts must be greater than zero" unless max_attempts > 0 + raise ArgumentError, "max_attempts must not exceed #{River::MAX_ATTEMPTS_LIMIT}" if max_attempts > River::MAX_ATTEMPTS_LIMIT + + max_attempts.to_s + when :metadata + raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash) + + runtime_json(value) + when :state + runtime_state(value) + end + end + end +end diff --git a/ruby/lib/errors.rb b/ruby/lib/errors.rb new file mode 100644 index 000000000..a2d0b9e85 --- /dev/null +++ b/ruby/lib/errors.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module River + class Error < StandardError; end + + class NotFoundError < Error; end + + class JobRunningError < Error; end + + # A stored job contains fields that cannot be decoded. Administrative reads + # raise this error; consumers fail this job's attempt without blocking peers. + class JobRowDecodeError < Error + attr_reader :job + + def initialize(job, detail) + @job = job + super("job row #{job.id} couldn't be decoded: #{detail}") + job.__decode_error = self + end + end + + class JobCancelError < Error + # Original exception that caused cancellation, when supplied. + attr_reader :cause + + def initialize(message = "job cancelled", cause: nil) + @cause = cause + super(message) + end + end + + class JobSnoozeError < Error + # Number of seconds for which the job should be snoozed. + attr_reader :duration + + def initialize(duration) + @duration = Float(duration, exception: true) #: Float + raise ArgumentError, "duration must be finite" unless @duration.finite? + raise ArgumentError, "duration must be zero or greater" if @duration.negative? + + super("job snoozed for #{@duration} seconds") + end + end + + class UnknownJobKindError < Error + # Unregistered job kind encountered by the runtime. + attr_reader :kind + + def initialize(kind) + @kind = kind + super("unknown job kind: #{kind}") + end + end + + class ClientNotStartedError < Error; end + + class ClientAlreadyStartedError < Error; end + + # Returns an exception that tells River to cancel the current job. + # + # An Exception argument is retained as the cancellation's cause; any other + # non-nil argument is used as its message. Raise the result from a worker: + # + # raise River.job_cancel("account closed") + def self.job_cancel(error = nil) + case error + when Exception + JobCancelError.new(error.message, cause: error) + when nil + JobCancelError.new + else + JobCancelError.new(error.to_s) + end + end + + # Returns an exception that tells River to reschedule the current job after + # +duration+ seconds without treating the attempt as an error. + # + # raise River.job_snooze(30) + def self.job_snooze(duration) + JobSnoozeError.new(duration) + end +end diff --git a/ruby/lib/event.rb b/ruby/lib/event.rb new file mode 100644 index 000000000..4a95363fa --- /dev/null +++ b/ruby/lib/event.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module River + EVENT_JOB_CANCELLED = :job_cancelled + EVENT_JOB_COMPLETED = :job_completed + EVENT_JOB_FAILED = :job_failed + EVENT_JOB_INTERRUPTED = :job_interrupted + EVENT_JOB_SNOOZED = :job_snoozed + EVENT_QUEUE_PAUSED = :queue_paused + EVENT_QUEUE_RESUMED = :queue_resumed + + # Event emitted by a Client and delivered through a Subscription. + Event = Data.define(:kind, :job, :queue, :stats) + + # Timing statistics attached to job lifecycle events, in seconds. + # run_duration measures worker execution including work hooks; + # complete_duration measures finalization after execution finishes. + # queue_wait_duration uses the schedule at the start of execution, before + # retries or snoozes change it. + # Execution and completion durations use a monotonic clock. Externally + # executed jobs have zero run_duration because this runtime does not observe + # their execution; their queue wait ends at the row's attempted_at timestamp. + JobStatistics = Data.define(:complete_duration, :queue_wait_duration, :run_duration) + + # Bounded stream of selected Client lifecycle events. + class Subscription + # Creates an event subscription. Applications normally receive instances + # from Client#subscribe. + def initialize(kinds, buffer_size: 100, on_close: nil) + @closed = false + @kinds = kinds.map(&:to_sym).freeze + @mutex = Mutex.new + @on_close = on_close + @queue = SizedQueue.new(buffer_size) + end + + # Closes the subscription and releases it from its client. Closing more than + # once is safe. Buffered events remain readable; blocked readers wake once + # the buffer is drained. + def close + on_close = @mutex.synchronize do + return if @closed + + @closed = true + @queue.close + @on_close.tap { @on_close = nil } + end + + on_close&.call(self) + nil + end + + # Yields events as they arrive until the subscription is closed. Returns an + # Enumerator when no block is given. + def each + return enum_for(:each) unless block_given? + + loop do + event = @queue.pop + break if event.nil? + + yield event + end + end + + # Removes and returns the next queued value, blocking unless +non_block+ is + # true. A blocking pop returns nil once the subscription is closed and its + # buffered events have been drained. + # + # A non-blocking pop raises ThreadError when no event is available. + def pop(non_block = false) + @queue.pop(non_block) + end + + def publish(event) + @mutex.synchronize do + return if @closed || !@kinds.include?(event.kind) + + @queue.push(event, true) + end + rescue ThreadError + nil + end + end +end diff --git a/ruby/lib/insert_opts.rb b/ruby/lib/insert_opts.rb new file mode 100644 index 000000000..5cc55d278 --- /dev/null +++ b/ruby/lib/insert_opts.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +module River + # Options for job insertion, and which can be provided by implementing + # #insert_opts on job args, or specified as a parameter on #insert or + # #insert_many. + class InsertOpts + # The maximum number of total attempts (including both the original run and + # all retries) before a job is abandoned and set as discarded. + # Must be between 1 and MAX_ATTEMPTS_LIMIT (32,767). + # Defaults to MAX_ATTEMPTS_DEFAULT. + attr_accessor :max_attempts + + # Arbitrary metadata to merge into the persisted job. River and River Pro + # reserve keys prefixed with `river:` and workflow metadata keys. + attr_accessor :metadata + + # The priority of the job, with 1 being the highest priority and 4 being the + # lowest. When fetching available jobs to work, the highest priority jobs + # will always be fetched before any lower priority jobs are fetched. Note + # that if your workers are swamped with more high-priority jobs then they + # can handle, lower priority jobs may not be fetched. + # + # Defaults to PRIORITY_DEFAULT. + attr_accessor :priority + + # The name of the job queue in which to insert the job, as a symbol or string. + # Must contain 1 to 127 ASCII letters, digits, underscores, hyphens, colons, or periods. + # + # Defaults to QUEUE_DEFAULT. + attr_accessor :queue + + # A time in future at which to schedule the job (i.e. in cases where it + # shouldn't be run immediately). The job is guaranteed not to run before + # this time, but may run slightly after depending on the number of other + # scheduled jobs and how busy the queue is. + # + # Use of this option generally only makes sense when passing options into + # Insert rather than when a job args is returning `#insert_opts`, however, + # it will work in both cases. + attr_accessor :scheduled_at + + # Initial state. Primarily intended for extensions such as workflows and + # sequences, which insert blocked jobs as `:pending`. Accepts only + # `available`, `pending`, or `scheduled`, as symbols or strings. + attr_accessor :state + + # An arbitrary list of keywords to add to the job. They have no functional + # behavior and are meant entirely as a user-specified construct to help + # group and categorize jobs. + # + # If tags are specified from both a job args override and from options on + # Insert, the latter takes precedence. Tags are not merged. + attr_accessor :tags + + # Options relating to job uniqueness. No unique options means that the job + # is never treated as unique. + attr_accessor :unique_opts + + # Creates options that override the defaults for a single job insertion. + def initialize( + max_attempts: nil, + metadata: nil, + priority: nil, + queue: nil, + scheduled_at: nil, + state: nil, + tags: nil, + unique_opts: nil + ) + self.max_attempts = max_attempts + self.metadata = metadata + self.priority = priority + self.queue = queue + self.scheduled_at = scheduled_at + self.state = state + self.tags = tags + self.unique_opts = unique_opts + end + end + + # Parameters for uniqueness for a job. + # + # If all properties are nil, no uniqueness is enforced. As each property is + # initialized, it's added as a dimension on the uniqueness matrix, and with + # any property on, the job's kind counts toward uniqueness unless #exclude_kind + # is enabled. + # + # So for example, if only #by_queue is on, then for the given job kind, only a + # single instance is allowed in any given queue, regardless of other + # properties on the job. If both #by_args and #by_queue are on, then for the + # given job kind, a single instance is allowed for each combination of args + # and queues. If either args or queue is changed on a new job, it's allowed to + # be inserted as a new job. + class UniqueOpts + # Empty options don't enable uniqueness. Zero periods and empty state lists + # have the same meaning as their unset equivalents in the Go client. + def empty? + !by_args && !by_queue && !exclude_kind && (!by_period || by_period == 0) && (!by_state || by_state.empty?) + end + + # Indicates that uniqueness should be enforced for any specific instance of + # encoded args for a job. An array of symbol or string keys selects only + # those top-level arguments. Nested arrays select paths, for example + # +by_args: [[:account, :id], :label]+. Dots in a string key are literal. + # + # Default is false, meaning that as long as any other unique property is + # enabled, uniqueness will be enforced for a kind regardless of input args. + attr_accessor :by_args + + # Defines uniqueness within a given period. The effective scheduled time + # (or current time for unscheduled jobs) is rounded down to a period boundary + # in UTC. Equivalent times in different zones produce the same unique key. + # + # The period should be specified in seconds. So a job that's unique every 15 + # minute period would have a value of 900. Nonzero periods must be at least + # one second; zero disables period-based uniqueness. + # + # Default is no unique period, meaning that as long as any other unique + # property is enabled, uniqueness will be enforced across all jobs of the + # kind in the database, regardless of when they were scheduled. + attr_accessor :by_period + + # Indicates that uniqueness should be enforced within each queue. + # + # Default is false, meaning that as long as any other unique property is + # enabled, uniqueness will be enforced for a kind across all queues. + attr_accessor :by_queue + + # Indicates that uniqueness should be enforced across any of the states in + # the given set. For example, if the given states were `(scheduled, + # running)` then a new job could be inserted even if one of the same kind + # was already being worked by the queue (new jobs are inserted as + # `available`). + # + # Unlike other unique options, ByState gets a default when it's not set for + # user convenience. The default is equivalent to: + # + # by_state: %i[available completed pending running retryable scheduled] + # + # With this setting, any jobs of the same kind that have been cancelled or + # discarded, but not yet cleaned out by the system, won't count towards the + # uniqueness of a new insert. + # + # The pending, scheduled, available, and running states are required when + # customizing this list. State names accept symbols or strings. + attr_accessor :by_state + + # Indicates that the job kind should not be considered for uniqueness. This + # is useful when you want to enforce uniqueness based on other properties + # across multiple worker types. + # + # Requires #by_args, #by_queue, or a nonzero #by_period. Otherwise every job + # would share the same key, so insertion raises ArgumentError. #by_state + # alone does not supply a key dimension. + attr_accessor :exclude_kind + + # Creates a set of dimensions used to determine whether an inserted job is + # unique. + def initialize( + by_args: nil, + by_period: nil, + by_queue: nil, + by_state: nil, + exclude_kind: nil + ) + self.by_args = by_args + self.by_period = by_period + self.by_queue = by_queue + self.by_state = by_state + self.exclude_kind = exclude_kind + end + end +end diff --git a/ruby/lib/job.rb b/ruby/lib/job.rb new file mode 100644 index 000000000..3ba3d2ce2 --- /dev/null +++ b/ruby/lib/job.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +module River + JOB_STATE_AVAILABLE = "available" + JOB_STATE_CANCELLED = "cancelled" + JOB_STATE_COMPLETED = "completed" + JOB_STATE_DISCARDED = "discarded" + JOB_STATE_PENDING = "pending" + JOB_STATE_RETRYABLE = "retryable" + JOB_STATE_RUNNING = "running" + JOB_STATE_SCHEDULED = "scheduled" + + # Provides a way of creating a job args from a simple Ruby hash for a quick + # way to insert a job without having to define a class. The first argument is + # a kind (symbol or string) identifying the job in the database and the second is a + # hash that will be encoded to JSON. + # + # For example: + # + # insert_res = client.insert(River::JobArgsHash.new(:job_kind, { + # job_num: 1 + # })) + class JobArgsHash + # Job kind persisted to the database. + attr_reader :kind + + # Creates job arguments with a database kind and JSON-compatible hash. + def initialize(kind, hash) + raise ArgumentError, "kind should be non-nil" if !kind + raise ArgumentError, "hash should be non-nil" if !hash + + @hash = hash + @kind = kind.to_s + end + + # Encodes the argument hash for insertion. + def to_json + # JSON.dump reads mutable global options that are inaccessible in Ractors. + JSON.generate(@hash) + end + end + + # JobRow contains the properties of a job that are persisted to the database. + class JobRow + # Internal driver/runtime diagnostic, not a persisted column. + attr_accessor :__decode_error + + # The job's args as a hash decoded from JSON. + attr_accessor :args + + # The attempt number of the job. Jobs are inserted at 0, the number is + # incremented to 1 the first time work its worked, and may increment further + # if it's either snoozed or errors. + attr_accessor :attempt + + # The time that the job was last worked. Starts out as `nil` on a new + # insert. + attr_accessor :attempted_at + + # The set of worker IDs that have worked this job. A worker ID differs + # between different programs, but is shared by all executors within any + # given one. (i.e. Different Go processes have different IDs, but IDs are + # shared within any given process.) A process generates a new ID based on + # host and current time when it starts up. + attr_accessor :attempted_by + + # When the job record was created. + attr_accessor :created_at + + # A set of errors that occurred when the job was worked, one for each + # attempt. Ordered from earliest error to the latest error. + attr_accessor :errors + + # The time at which the job was "finalized", meaning it was either completed + # successfully or errored for the last time such that it'll no longer be + # retried. + attr_accessor :finalized_at + + # ID of the job. Generated as part of a Postgres sequence and generally + # ascending in nature, but there may be gaps in it as transactions roll + # back. + attr_accessor :id + + # Kind uniquely identifies the type of job and instructs which worker + # should work it. It is set at insertion time via `#kind` on job args. + attr_accessor :kind + + # The maximum number of attempts that the job will be tried before it errors + # for the last time and will no longer be worked. + attr_accessor :max_attempts + + # Arbitrary metadata associated with the job. + attr_accessor :metadata + + # The priority of the job, with 1 being the highest priority and 4 being the + # lowest. When fetching available jobs to work, the highest priority jobs + # will always be fetched before any lower priority jobs are fetched. Note + # that if your workers are swamped with more high-priority jobs then they + # can handle, lower priority jobs may not be fetched. + attr_accessor :priority + + # The name of the queue where the job will be worked. Queues can be + # configured independently and be used to isolate jobs. + attr_accessor :queue + + # When the job is scheduled to become available to be worked. Jobs default + # to running immediately, but may be scheduled for the future when they're + # inserted. They may also be scheduled for later because they were snoozed + # or because they errored and have additional retry attempts remaining. + attr_accessor :scheduled_at + + # The state of job like `available` or `completed`. Jobs are `available` + # when they're first inserted. + attr_accessor :state + + # Tags are an arbitrary list of keywords to add to the job. They have no + # functional behavior and are meant entirely as a user-specified construct + # to help group and categorize jobs. + attr_accessor :tags + + # A unique key for the job within its kind that's used for unique job + # insertions. It's generated by hashing an inserted job's unique opts + # configuration. + attr_accessor :unique_key + + # A list of states that the job must be in to be considered for uniqueness. + attr_accessor :unique_states + + def initialize( + id:, + args:, + attempt:, + created_at:, + kind:, + max_attempts:, + metadata:, + priority:, + queue:, + scheduled_at:, + state:, + + # nullable/optional + attempted_at: nil, + attempted_by: nil, + errors: nil, + finalized_at: nil, + tags: nil, + unique_key: nil, + unique_states: nil + ) + self.id = id + self.args = args + self.attempt = attempt + self.attempted_at = attempted_at + self.attempted_by = attempted_by + self.created_at = created_at + self.errors = errors + self.finalized_at = finalized_at + self.kind = kind + self.max_attempts = max_attempts + self.metadata = metadata + self.priority = priority + self.queue = queue + self.scheduled_at = scheduled_at + self.state = state + self.tags = tags + self.unique_key = unique_key + self.unique_states = unique_states + end + end + + # A failed job work attempt containing information about the error or panic + # that occurred. + class AttemptError + # The time at which the error occurred. Unreadable historical timestamps + # become Time.utc(1), matching Go's zero time. + attr_accessor :at + + # The attempt number on which the error occurred (maps to #attempt on a job + # row). + attr_accessor :attempt + + # Contains the stringified error of an error returned from a job or a panic + # value in case of a panic. + attr_accessor :error + + # Contains a stack trace from a job that panicked. The trace is produced by + # invoking `debug.Trace()` in Go. + attr_accessor :trace + + def initialize( + at:, + attempt:, + error:, + trace: + ) + self.at = at + self.attempt = attempt + self.error = error + self.trace = trace + end + + # Returns the database-compatible representation of this attempt error. + def to_h + {at: at.getutc.iso8601(6), attempt: attempt, error: error, trace: trace} + end + end +end diff --git a/ruby/lib/job_persisted_logging/plugin.rb b/ruby/lib/job_persisted_logging/plugin.rb new file mode 100644 index 000000000..57cb4d6f3 --- /dev/null +++ b/ruby/lib/job_persisted_logging/plugin.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "logger" + +module River + module JobPersistedLogging + # Captures job.logger output and persists it with the attempt's state change, + # including failed attempts. Uses Go riverlog's metadata format for River UI. + # Register through Config#plugins, preferably before other work middleware. + class Plugin + # Limits are positive byte counts. History is capped at 64 MiB, dropping + # oldest entries first, but always retaining the newest attempt. An optional + # block receives a bounded writer and must return a fresh logger per attempt. + # By default this is a standard Ruby Logger at INFO level. + def initialize(max_size_bytes: 2 * 1024 * 1024, max_total_bytes: 8 * 1024 * 1024, &logger_factory) + {max_size_bytes: max_size_bytes, max_total_bytes: max_total_bytes}.each do |name, value| + raise ArgumentError, "#{name} must be a positive integer" unless value.is_a?(Integer) && value.positive? + end + + @logger_factory = logger_factory + @max_size_bytes = max_size_bytes + @max_total_bytes = [max_total_bytes, 64 * 1024 * 1024].min + end + + # Wraps the work hooks and worker without changing their return value or + # exception. No extra database round trip is needed to save the log. + def work(job, operation) + buffer = Buffer.new(@max_size_bytes) + factory = @logger_factory + logger = factory ? factory.call(buffer) : Logger.new(buffer, level: Logger::INFO) + begin + job.__with_logger(logger) { operation.call } + ensure + persist_log(job, buffer) + end + end + + private def persist_log(job, buffer) + log, truncated = buffer.finish + return if log.empty? + + history = job.metadata.fetch("river:log", []) + raise TypeError, '"river:log" value is not an array' unless history.is_a?(Array) + + entries = [{"attempt" => job.row.attempt, "log" => log}] + size = JSON.generate(entries.first).bytesize + 2 # Array brackets. + # Walk only the newest suffix that fits; never repeatedly serialize or + # shift the entire history. Keep the latest entry even if it exceeds the cap. + history.reverse_each do |entry| + entry_size = JSON.generate(entry).bytesize + 1 # Separator comma. + break if size + entry_size > @max_total_bytes + + entries << entry + size += entry_size + end + job.update_metadata("river:log" => entries.reverse) + + job.client.config.logger.warn("River job log truncated to #{@max_size_bytes} bytes for job #{job.row.id}") if truncated + dropped = history.length + 1 - entries.length + job.client.config.logger.warn("River job log dropped #{dropped} oldest entries for job #{job.row.id}") if dropped.positive? + rescue JSON::JSONError, TypeError => error + # Corrupt history must not hide the worker's original error or turn + # otherwise successful work into a failure. + job.client.config.logger.error("River job log could not be persisted for job #{job.row.id}: #{error.message}") + end + + # A per-attempt, thread-safe writer. Bound memory while writing, rather than + # accumulating an unbounded StringIO and truncating only when work finishes. + class Buffer + def initialize(limit) + @closed = false + @data = String.new(encoding: Encoding::BINARY) + @limit = limit + @mutex = Mutex.new + @truncated = false + end + + def close + @mutex.synchronize { @closed = true } + nil + end + + def finish + @mutex.synchronize do + @closed = true + # Drop invalid/incomplete UTF-8 sequences (including a character cut + # at the byte limit) and NULs, which PostgreSQL JSONB cannot represent. + [@data.dup.force_encoding(Encoding::UTF_8).scrub("").delete("\0"), @truncated] + end + end + + def write(message) + message = message.to_s + @mutex.synchronize do + unless @closed + remaining = @limit - @data.bytesize + chunk = message.byteslice(0, remaining) #: String + @data << chunk.force_encoding(Encoding::BINARY) + @truncated ||= message.bytesize > remaining + end + end + message.bytesize + end + end + private_constant :Buffer + end + end +end diff --git a/ruby/lib/migration_cli.rb b/ruby/lib/migration_cli.rb new file mode 100644 index 000000000..cbf4d999d --- /dev/null +++ b/ruby/lib/migration_cli.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "optparse" +require_relative "riverqueue" + +module River + # Minimal command-line interface to the Ruby migration API. + module MigrationCLI + def self.run(argv, err: $stderr, out: $stdout) + # @type var database: untyped + # @type var active_record_base: untyped + options = {line: "main"} #: Hash[Symbol, untyped] + parser = OptionParser.new do |args| + args.banner = "Usage: river migrate-up|migrate-down|migrate-status [options]" + args.on("--database-url URL", "Defaults to DATABASE_URL") { |value| options[:url] = value } + args.on("--line NAME", %w[main pro]) { |value| options[:line] = value } + args.on("--schema NAME") { |value| options[:schema] = value } + args.on("--steps N", Integer) { |value| options[:steps] = value } + args.on("--target VERSION", Integer) { |value| options[:target] = value } + args.on("--dry-run") { options[:dry_run] = true } + args.on("--yes", "Confirm destructive down migrations") { options[:yes] = true } + args.on("-h", "--help") { + out.puts(args) + return 0 + } + end + + args = argv.dup + parser.parse!(args) + command = args.shift + raise ArgumentError, parser.banner unless %w[migrate-up migrate-down migrate-status].include?(command) && args.empty? + + url = options[:url] || ENV["DATABASE_URL"] + raise ArgumentError, "provide --database-url or DATABASE_URL" if url.nil? || url.empty? + + if command == "migrate-down" && !options[:yes] && !options[:dry_run] + raise ArgumentError, "down migrations may delete data; pass --yes to confirm" + end + + adapter = detect_driver + + if options[:line] == "pro" + require "riverqueue-pro" + end + + migrator_class = (options[:line] == "pro") ? River.const_get(:Pro).const_get(:Migrator) : River::Migrator + if adapter == "activerecord" + require "riverqueue-activerecord" + active_record_base = Object.const_get("ActiveRecord::Base") + active_record_base.establish_connection(url) + driver = River::Driver.const_get(:ActiveRecord).new + else + require "riverqueue-sequel" + database = Object.const_get(:Sequel).connect(url) + driver = River::Driver.const_get(:Sequel).new(database) + end + + migrator = migrator_class.new(driver, schema: options[:schema]) + if command == "migrate-status" + migrator.status.each { |migration| out.puts("#{migration.applied ? "applied" : "pending"} #{migration.version.to_s.rjust(3, "0")} #{migration.name}") } + else + migrations = migrator.migrate(direction: (command == "migrate-up") ? :up : :down, + dry_run: !!options[:dry_run], steps: options[:steps], target: options[:target]) + migrations.each { |migration| out.puts("#{options[:dry_run] ? "planned" : "applied"} #{migration.version.to_s.rjust(3, "0")} #{migration.name}") } + end + + 0 + rescue StandardError, LoadError => error + # Do not echo connection exception messages, which may contain credentials. + message = (error.is_a?(ArgumentError) || error.is_a?(OptionParser::ParseError) || error.is_a?(River::Error)) ? error.message : error.class.name + err.puts("Migration failed: #{message}") + 1 + ensure + database&.disconnect + active_record_base&.connection_pool&.disconnect! + end + + def self.detect_driver + driver = %w[sequel activerecord].find do |driver| + !Gem::Specification.find_all_by_name("riverqueue-#{driver}").empty? + end + + unless driver + raise ArgumentError, "install riverqueue-activerecord or riverqueue-sequel in your bundle to run migrations" + end + driver + end + private_class_method :detect_driver + end +end diff --git a/ruby/lib/migrator.rb b/ruby/lib/migrator.rb new file mode 100644 index 000000000..62202317a --- /dev/null +++ b/ruby/lib/migrator.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +module River + # Runs River's canonical SQL migrations, committing each version separately. + # Use a dedicated migration connection and stop workers before downgrading. + class Migrator + Migration = Data.define(:version, :name, :sql_up, :sql_down) + Status = Data.define(:version, :name, :applied) + + attr_reader :migrations + + # Creates a migrator for an installed River driver. PostgreSQL schemas must + # already exist and use simple SQL identifiers; SQLite uses its main schema. + # The optional migrations_path is a root containing backend/line/*.sql. + def initialize(driver, line: "main", migrations_path: File.expand_path("../migration", __dir__.to_s), schema: nil) + @backend = driver.migration_backend + @driver = driver + raise ArgumentError, "unsupported migration backend" unless [:postgresql, :sqlite].include?(@backend) + raise ArgumentError, "invalid migration line" unless line.match?(/\A[a-z][a-z0-9_]*\z/) + raise ArgumentError, "SQLite does not support a migration schema option" if @backend == :sqlite && schema + + @line = line + @mutex = Mutex.new + @requested_schema = schema + + directory = File.join(migrations_path, @backend.to_s, line) + @migrations = Dir.glob(File.join(directory, "*.up.sql")).sort.map do |path| + version, name = File.basename(path).delete_suffix(".up.sql").split("_", 2) + Migration.new(Integer(version.to_s, 10), name, File.read(path).freeze, File.read(path.sub(/\.up\.sql\z/, ".down.sql")).freeze) + end.freeze + unless @migrations.any? && @migrations.map(&:version) == (1..@migrations.length).to_a + raise ArgumentError, "migration files must contain contiguous versions starting at 1" + end + end + + # Applies pending migrations up (all by default) or down (one by default). + # target is the version to end at; target: 0 removes the migration line. + # An up target that is already applied is a no-op. + # steps limits the number applied. dry_run returns the plan without writes. + # Returns Migration objects for the versions applied or planned. + def migrate(direction: :up, dry_run: false, steps: nil, target: nil) + raise ArgumentError, "direction must be up or down" unless [:up, :down].include?(direction) + raise ArgumentError, "steps must be positive" if steps && (!steps.is_a?(Integer) || steps <= 0) + raise ArgumentError, "target must be a bundled version or zero" if target && (!target.is_a?(Integer) || !(0..migrations.length).cover?(target)) + + session(lock: !dry_run) do + existing = existing_versions + current = existing.last || 0 + destination = target || ((direction == :up) ? migrations.length : 0) + if direction == :down && destination > current + raise ArgumentError, "target is in the opposite direction" + end + + plan = if direction == :up + migrations.select { |migration| migration.version > current && migration.version <= destination } + else + migrations.reverse.select { |migration| migration.version <= current && migration.version > destination } + end + limit = steps || ((direction == :down && target.nil?) ? 1 : plan.length) + plan = plan.first(limit) + + unless dry_run + plan.each do |migration| + execute((@backend == :sqlite) ? "BEGIN IMMEDIATE" : "BEGIN") + committed = false + begin + raise River::Error, "migration state changed concurrently; rerun migration" unless existing_versions == existing + + if direction == :down && @line == "main" && line_column? + raise River::Error, "remove non-main migration lines before downgrading main" if query("SELECT version FROM #{table} WHERE line <> 'main'").any? + end + + sql = (direction == :up) ? migration.sql_up : migration.sql_down + execute(sql.gsub("/* TEMPLATE: schema */", @schema ? %("#{@schema}".) : "")) + + if direction == :up + columns, values = line_column? ? ["line, version", "'#{@line}', #{migration.version}"] : ["version", migration.version.to_s] + execute("INSERT INTO #{table} (#{columns}) VALUES (#{values})") + existing += [migration.version] + else + unless @line == "main" && migration.version == 1 + filter = line_column? ? " AND line = '#{@line}'" : "" + execute("DELETE FROM #{table} WHERE version = #{migration.version}#{filter}") + end + + existing -= [migration.version] + end + + execute("COMMIT") + committed = true + ensure + execute("ROLLBACK") unless committed + end + end + end + + plan + end + end + + # Returns the bundled versions with their database-applied status. + def status + session do + existing = existing_versions + migrations.map { |migration| Status.new(migration.version, migration.name, existing.include?(migration.version)) } + end + end + + private def execute(sql) + if @backend == :postgresql + @connection.exec(sql) + else + @connection.execute_batch(sql) + end + end + + private def existing_versions + exists = if @backend == :postgresql + query("SELECT to_regclass('#{table}') AS name").first.fetch("name") + else + query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'river_migration'").any? + end + if @line != "main" + unless exists && line_column? && query("SELECT version FROM #{table} WHERE line = 'main' AND version >= 7").any? + raise River::Error, "migrate main to version 7 or later before migrating #{@line}" + end + end + + return [] unless exists + + filter = line_column? ? " WHERE line = '#{@line}'" : "" + versions = query("SELECT version FROM #{table}#{filter} ORDER BY version").map { |row| row.fetch("version").to_i } + unless versions == (1..versions.length).to_a && versions.all? { |version| version <= migrations.length } + raise River::Error, "database migration history is incomplete or newer than this gem" + end + + versions + end + + private def line_column? + if @backend == :postgresql + query("SELECT column_name FROM information_schema.columns WHERE table_schema = '#{@schema}' AND table_name = 'river_migration' AND column_name = 'line'").any? + else + query("PRAGMA table_info(river_migration)").any? { |row| row.fetch("name") == "line" } + end + end + + private def query(sql) + if @backend == :postgresql + @connection.exec(sql).to_a + else + rows = @connection.execute2(sql) + columns = rows.shift + rows.map { |row| row.is_a?(Hash) ? row : columns.zip(row).to_h } + end + end + + private def session(lock: false) + @mutex.synchronize do + @driver.migration_connection do |connection| + @connection = connection + @schema = if @backend == :postgresql + @requested_schema || query("SELECT current_schema() AS name").first.fetch("name") + end + if @backend == :postgresql + raise ArgumentError, "schema must be a simple SQL identifier" unless @schema&.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/) + + if lock + locked = query("SELECT pg_try_advisory_lock(hashtext(current_database()), hashtext('river_migrate:#{@schema}')) AS locked").first.fetch("locked") + raise River::Error, "another Ruby migrator holds the schema lock" unless [true, "t"].include?(locked) + end + end + + begin + yield + ensure + if @backend == :postgresql && lock + query("SELECT pg_advisory_unlock(hashtext(current_database()), hashtext('river_migrate:#{@schema}'))") + end + end + end + end + end + + private def table + @schema ? %("#{@schema}".river_migration) : "river_migration" + end + end +end diff --git a/ruby/lib/params.rb b/ruby/lib/params.rb new file mode 100644 index 000000000..27a95ab20 --- /dev/null +++ b/ruby/lib/params.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module River + # Result of Client#job_delete_many. + JobDeleteManyResult = Data.define(:jobs) + + # Position in a job listing, including the ordering value so pagination still + # works after the job at the end of the previous page has been deleted. + JobListCursor = Data.define(:id, :sort_by, :sort_order, :value) + + # A page of jobs and the cursor for fetching the next page. + JobListResult = Data.define(:jobs, :last_cursor) + + # A River queue record persisted in the database. + Queue = Data.define(:name, :created_at, :metadata, :paused_at, :updated_at) + + # Result of Client#queue_list. + QueueListResult = Data.define(:queues) + + # Filtering, ordering, and pagination parameters for Client#job_list and + # Client#job_delete_many. + class JobListParams + # Normalized filtering, pagination, and ordering values. + attr_reader :after, :after_id, :ids, :kinds, :limit, :metadata, :priorities, :queues, :sort_by, + :sort_order, :states, :tags_all, :tags_any + + # Creates job-list parameters. Pass JobListResult#last_cursor as +after+ and + # keep the same filters and ordering for subsequent pages. +after_id+ is a + # shortcut for ID-ordered listings. Null timestamps always sort last. + # Kind, queue, and state filters accept symbols or strings. + def initialize(after: nil, after_id: nil, ids: nil, kinds: nil, limit: 100, priorities: nil, + metadata: nil, queues: nil, sort_by: :id, sort_order: :asc, states: nil, tags_all: nil, tags_any: nil) + @after = after + @after_id = after_id.nil? ? nil : Integer(after_id) + @ids = ids + @kinds = kinds&.map(&:to_s) + @limit = Integer(limit) + @metadata = metadata + @priorities = priorities + @queues = queues&.map(&:to_s) + @sort_by = sort_by.to_sym + @sort_order = sort_order.to_sym + @states = states&.map(&:to_s) + @tags_all = tags_all + @tags_any = tags_any + + raise ArgumentError, "limit must be between 1 and 10,000" unless (1..10_000).cover?(@limit) + raise ArgumentError, "invalid sort field" unless [:id, :scheduled_at, :finalized_at].include?(@sort_by) + raise ArgumentError, "invalid sort order" unless [:asc, :desc].include?(@sort_order) + raise ArgumentError, "use either after or after_id" if after && after_id + raise ArgumentError, "after_id requires sorting by id; use after for timestamp ordering" if after_id && @sort_by != :id + if after && (!after.is_a?(JobListCursor) || after.sort_by != @sort_by || after.sort_order != @sort_order) + raise ArgumentError, "after must be a JobListCursor with the same ordering" + end + end + + def filters? + [after, after_id, ids, kinds, metadata, priorities, queues, states, tags_all, tags_any].any? do |value| + value.respond_to?(:empty?) ? !value.empty? : !value.nil? + end + end + end + + # Fields to change with Client#job_update. Omitted fields are left unchanged; + # explicitly passing nil clears nullable fields. + class JobUpdateParams + UNSET = Object.new.freeze + + # Values to apply, with omitted fields represented internally by UNSET. + attr_reader :attempt, :attempted_at, :attempted_by, :errors, :finalized_at, + :max_attempts, :metadata, :state + + # Creates a partial set of updates for a persisted job. + def initialize(attempt: UNSET, attempted_at: UNSET, attempted_by: UNSET, + errors: UNSET, finalized_at: UNSET, max_attempts: UNSET, metadata: UNSET, + state: UNSET) + @attempt = attempt + @attempted_at = attempted_at + @attempted_by = attempted_by + @errors = errors + @finalized_at = finalized_at + @max_attempts = max_attempts + @metadata = metadata + @state = state.is_a?(Symbol) ? state.to_s : state + end + + def each + return enum_for(:each) unless block_given? + + instance_variables.each do |ivar| + value = instance_variable_get(ivar) + yield ivar.to_s.delete_prefix("@").to_sym, value unless value.equal?(UNSET) + end + end + end +end diff --git a/ruby/lib/periodic_cron.rb b/ruby/lib/periodic_cron.rb new file mode 100644 index 000000000..08805cc6e --- /dev/null +++ b/ruby/lib/periodic_cron.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module River + # A cron schedule for PeriodicJob. Add the optional +fugit+ gem to use it. + class PeriodicCron + # Parses a cron expression once. +timezone+ defaults to UTC; use an IANA + # name or a fixed offset for local schedules. A CRON_TZ= or TZ= prefix takes + # precedence. Supports Go's @every durations, rounded down to whole seconds + # with a one-second minimum. Fugit is loaded only on construction. + def initialize(expression, timezone: "UTC") + require "fugit" + + raise ArgumentError, "cron expression must be a String" unless expression.is_a?(String) + raise ArgumentError, "timezone must be a nonempty name without whitespace" unless timezone.is_a?(String) && /\A\S+\z/.match?(timezone) + + expression = expression.strip + if (prefix = /\A(?:CRON_TZ|TZ)=(\S+)\s+(.+)\z/.match(expression)) + timezone = prefix[1].to_s + expression = prefix[2].to_s + end + # Validate the zone even for interval schedules, which don't use it. + cron_class = Object.const_get(:Fugit).const_get(:Cron) + cron_class.do_parse("* * * * * #{timezone}") + @interval = nil + if expression.start_with?("@every ") + @interval = parse_interval(expression.delete_prefix("@every ")) + @cron = nil + else + @cron = cron_class.do_parse("#{expression.tr("?", "*")} #{timezone}") + end + end + + # Returns a UTC Time strictly after +time+. Interval schedules align to + # whole seconds, as Go does. Calendar and daylight-saving rules use Fugit. + def next(time) + interval = @interval + return Time.at(time.to_i + interval).utc if interval + + # Keep the reference in UTC so Fugit does not skip a repeated local wall + # time when daylight saving ends. The schedule owns the calendar zone. + @cron.next_time(time.getutc).to_t.getutc + end + + private def parse_interval(duration) + # Go's time.ParseDuration syntax, deliberately excluding Fugit's extra + # units (days/months) and natural-language interval expressions. + sign = duration.start_with?("-") ? -1 : 1 + duration = duration.sub(/\A[+-]/, "") + parts = duration.scan(/((?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))(ns|us|µs|μs|ms|s|m|h)/) #: Array[[String, String]] # rubocop:disable Layout/LeadingCommentSpace + valid = duration == "0" || (!parts.empty? && parts.map(&:join).join == duration) + raise ArgumentError, "invalid @every duration" unless valid + units = {"ns" => 1, "us" => 1_000, "µs" => 1_000, "μs" => 1_000, "ms" => 1_000_000, "s" => 1_000_000_000, "m" => 60_000_000_000, "h" => 3_600_000_000_000} + nanos = parts.sum { |number, unit| (number.to_r * units.fetch(unit)).to_i } * sign + raise ArgumentError, "@every duration exceeds Go's duration range" unless (-(1 << 63)...(1 << 63)).cover?(nanos) + + [nanos.div(1_000_000_000), 1].max + end + end +end diff --git a/ruby/lib/periodic_job.rb b/ruby/lib/periodic_job.rb new file mode 100644 index 000000000..85aad7970 --- /dev/null +++ b/ruby/lib/periodic_job.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module River + # Definition of a recurring job and the schedule used to construct it. + class PeriodicJob + # Callable that produces job arguments, or an arguments/options pair. + attr_reader :constructor + + # Stable string identifier used for removal and durable Pro scheduling. + attr_reader :id + + # Whether the job should run immediately when its scheduler starts. + attr_reader :run_on_start + + # Object or callable that computes the next run time. + attr_reader :schedule + + # Creates a periodic job. +schedule+ may respond to +next(time)+ or be a + # callable, and must return a Time strictly after its argument. Supply a + # factory block or a +constructor+ callable (not both), + # invoked whenever the job is due. +id+ accepts a symbol or string; nil + # leaves the registration anonymous. + def initialize(schedule:, constructor: nil, id: nil, run_on_start: false, &block) + raise ArgumentError, "use constructor or a block, not both" if constructor && block + + @constructor = constructor || block + raise ArgumentError, "a factory block or callable constructor is required" unless @constructor.respond_to?(:call) + raise ArgumentError, "schedule must respond to next or call" unless schedule.respond_to?(:next) || schedule.respond_to?(:call) + @id = id&.to_s + @run_on_start = run_on_start + @schedule = schedule + end + + # Returns the next scheduled Time after +now+. + def next_at(now) + next_time = schedule.respond_to?(:next) ? schedule.next(now) : schedule.call(now) + raise ArgumentError, "schedule must return a future Time" unless next_time.is_a?(Time) && next_time > now + next_time + end + end + + # A fixed-duration schedule suitable for PeriodicJob. + class PeriodicInterval + # Creates an interval measured in seconds. + def initialize(seconds) + @seconds = Float(seconds, exception: true) #: Float + raise ArgumentError, "period must be finite" unless @seconds.finite? + raise ArgumentError, "period must be greater than zero" unless @seconds.positive? + end + + # Returns the next Time one interval after +time+. + def next(time) + time + @seconds + end + end + + # Thread-safe collection used to change a client's periodic jobs at runtime. + class PeriodicJobBundle + def initialize(jobs, wake:, enabled: true) + @enabled = enabled + @jobs = {} + @mutex = Mutex.new + @next_handle = 0 + @wake = wake + add_many(jobs) + end + + # Adds a periodic job and returns a handle that can be passed to #remove. + def add(job) + add_many([job]).fetch(0) + end + + # Adds periodic jobs atomically and returns their handles in input order. + # Invalid schedules or duplicate IDs leave the registry unchanged. + def add_many(jobs) + raise ArgumentError, "periodic jobs require leader election" if !@enabled && !jobs.empty? + + now = Time.now.utc + entries = jobs.map { |job| {job: job, next_at: job.run_on_start ? now : job.next_at(now)} } + handles = @mutex.synchronize do + ids = @jobs.values.to_h { |entry| [entry[:job].id, true] } + jobs.each do |job| + next unless job.id + raise ArgumentError, "periodic job ID is already registered: #{job.id}" if ids[job.id] + ids[job.id] = true + end + entries.map do |entry| + @next_handle += 1 + @jobs[@next_handle] = entry + @next_handle + end + end + @wake.call unless handles.empty? + handles + end + + # Removes all periodic jobs from the bundle and returns self. + def clear + @mutex.synchronize { @jobs.clear } + self + end + + def due(now) + # Application schedules may reenter the registry, so evaluate outside its + # lock and only advance registrations that haven't been removed or claimed. + entries = @mutex.synchronize { @jobs.map { |handle, entry| [handle, entry, entry[:next_at]] } } + entries.filter_map do |handle, entry, next_at| + next if next_at > now + + following = entry[:job].next_at(now) + @mutex.synchronize do + next unless @jobs[handle].equal?(entry) && entry[:next_at] == next_at + entry[:next_at] = following + entry[:job] + end + rescue => error + raise unless block_given? + yield(entry[:job], error) + nil + end + end + + # Returns true when the bundle has no registered periodic jobs. + def empty? + @mutex.synchronize { @jobs.empty? } + end + + # Removes the job associated with +handle+, returning the PeriodicJob or + # nil when no such handle exists. + def remove(handle) + @mutex.synchronize { @jobs.delete(handle)&.fetch(:job) } + end + + # Removes the periodic job with +id+. Returns whether a job was removed. + def remove_by_id(id) + id = id&.to_s + @mutex.synchronize do + pair = @jobs.find { |_handle, entry| entry[:job].id == id } + !!(pair && @jobs.delete(pair.first)) + end + end + end +end diff --git a/ruby/lib/resumable.rb b/ruby/lib/resumable.rb new file mode 100644 index 000000000..f5c91029a --- /dev/null +++ b/ruby/lib/resumable.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module River + RESUMABLE_CURSOR_METADATA_KEY = "river:resumable_cursor" + RESUMABLE_STEP_METADATA_KEY = "river:resumable_step" + + # Execution state for resumable steps. Applications normally interact with + # this through Job#resumable_step and Job#resumable_step_cursor. + class ResumableState + attr_reader :all_step_names + attr_accessor :completed_step + attr_reader :cursors + attr_accessor :cursors_dirty + attr_accessor :resume_matched + attr_reader :resume_step + attr_accessor :step_name + + def initialize(metadata) + @all_step_names = {} + @completed_step = nil + @cursors = (metadata[RESUMABLE_CURSOR_METADATA_KEY] || {}).dup + @cursors_dirty = false + @resume_step = metadata[RESUMABLE_STEP_METADATA_KEY] + @step_name = nil + + @resume_matched = @resume_step.to_s.empty? + end + + def register(name) + raise Error, "duplicate resumable step name #{name.inspect}" if all_step_names.key?(name) + + all_step_names[name] = true + end + end + + class Job + # Internal runtime boundary: attach progress only to attempts that did not + # complete, matching River Go's persisted metadata format. + def __capture_resumable_metadata! + if @resumable_state.cursors_dirty + @metadata_updates[RESUMABLE_CURSOR_METADATA_KEY] = @resumable_state.cursors.empty? ? nil : @resumable_state.cursors.dup + end + + if @resumable_state.completed_step + @metadata_updates[RESUMABLE_STEP_METADATA_KEY] = @resumable_state.completed_step + end + end + + # Internal runtime boundary: validate that the recorded resume point still + # exists in the worker after it has returned. + def __finish_resumable_work! + if @resumable_state.resume_step && !@resumable_state.resume_matched + raise Error, "resumable step #{@resumable_state.resume_step.inspect} not found in worker" + end + end + + # Immediately checkpoints the current step and cursor progress. Wrap the + # call in Driver#transaction alongside application writes when an atomic + # checkpoint is needed. + def resumable_checkpoint(cursor: RESUMABLE_CURSOR_UNSET) + step_name = @resumable_state.step_name + raise Error, "resumable step can only be persisted inside a resumable step" unless step_name + raise Error, "job must be running" unless row.state == JOB_STATE_RUNNING + + cursors = @resumable_state.cursors.dup + cursors[step_name] = JSON.parse(JSON.generate(cursor)) unless cursor.equal?(RESUMABLE_CURSOR_UNSET) + updates = { + RESUMABLE_STEP_METADATA_KEY => step_name, + RESUMABLE_CURSOR_METADATA_KEY => cursors.empty? ? nil : cursors + } + + updated = client.driver.job_metadata_merge(row.id, updates) || raise(NotFoundError, "job not found: #{row.id}") + # The database owns checkpointed progress, including rollback of an + # enclosing application transaction. Only progress made after this write + # belongs in the attempt's deferred updates; replaying the checkpoint on + # failure could otherwise commit progress whose application writes rolled back. + @resumable_state.cursors.replace(cursors) + @resumable_state.cursors_dirty = false + @resumable_state.completed_step = nil + @row = updated + end + + # Records JSON-compatible cursor data for the current step. It is persisted + # with the failed attempt so that its retry can continue from this value. + def resumable_set_cursor(cursor) + step_name = @resumable_state.step_name + raise Error, "resumable cursor can only be set inside a resumable step" unless step_name + + @resumable_state.cursors[step_name] = JSON.parse(JSON.generate(cursor)) + @resumable_state.cursors_dirty = true + cursor + end + + # Runs a named step, skipping it on retry when a previous attempt already + # completed it. Names accept symbols or strings and must be unique within a + # worker invocation. Steps cannot be nested: a nested resume point would be + # unreachable when its enclosing step is skipped on retry. Persisted + # checkpoints always use string names. + # Exceptions propagate immediately, just as they do outside a step. + def resumable_step(name, &block) + run_resumable_step(name.to_s, cursor: false, default: nil, &block) + end + + # Runs a named step with the cursor saved by #resumable_set_cursor during a + # previous failed attempt. Names accept symbols or strings. + def resumable_step_cursor(name, default: nil, &block) + run_resumable_step(name.to_s, cursor: true, default: default, &block) + end + + RESUMABLE_CURSOR_UNSET = Object.new.freeze + private_constant :RESUMABLE_CURSOR_UNSET + + private def initialize_resumable_state + @resumable_state = ResumableState.new(row.metadata) + end + + private def run_resumable_step(name, cursor:, default:) + raise Error, "resumable steps cannot be nested" if @resumable_state.step_name + raise ArgumentError, "resumable step name must be non-empty" if name.empty? + @resumable_state.register(name) + + unless @resumable_state.resume_matched + if name == @resumable_state.resume_step + @resumable_state.completed_step = name + @resumable_state.resume_matched = true + return unless cursor && @resumable_state.cursors.key?(name) + else + return + end + end + + @resumable_state.step_name = name + begin + value = (cursor && @resumable_state.cursors.key?(name)) ? @resumable_state.cursors[name] : default + result = cursor ? yield(value) : yield + @resumable_state.completed_step = name + if cursor && @resumable_state.cursors.key?(name) + @resumable_state.cursors.delete(name) + @resumable_state.cursors_dirty = true + end + + result + ensure + @resumable_state.step_name = nil + end + end + end +end diff --git a/ruby/lib/riverqueue.rb b/ruby/lib/riverqueue.rb new file mode 100644 index 000000000..74cbe9642 --- /dev/null +++ b/ruby/lib/riverqueue.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "json" +require "securerandom" + +require_relative "errors" +require_relative "insert_opts" +require_relative "job" +require_relative "worker" +require_relative "job_persisted_logging/plugin" +require_relative "resumable" +require_relative "config" +require_relative "event" +require_relative "params" +require_relative "periodic_cron" +require_relative "periodic_job" +require_relative "client_runtime" + +require_relative "client" +require_relative "worker_runner" +require_relative "driver" +require_relative "unique_bitmask" +require_relative "unique_args" +require_relative "migrator" + +module River +end diff --git a/ruby/lib/riverqueue/testing.rb b/ruby/lib/riverqueue/testing.rb new file mode 100644 index 000000000..56a81ea49 --- /dev/null +++ b/ruby/lib/riverqueue/testing.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "riverqueue" + +module River + # Database-backed test helpers. Require explicitly; no test framework is loaded. + module Testing + class AssertionError < StandardError; end + class DrainLimitError < StandardError; end + + JOB_ATTRIBUTES = %i[id args attempt attempted_at attempted_by created_at errors finalized_at kind max_attempts metadata priority queue scheduled_at state tags unique_key unique_states].freeze + + ExecutionResult = Data.define(:id, :error, :job, :outcome) + + # Framework-neutral assertions, also used by the optional integrations. + module Assertions + # Asserts that a synchronous attempt was cancelled and returns its result. + def assert_job_cancelled(result) + river_assert(result.outcome == :cancelled, "Expected cancelled River job, got #{result.outcome}: #{result.error.inspect}") + result + end + + # Asserts that a synchronous attempt completed and returns its result. + def assert_job_completed(result) + river_assert(result.outcome == :completed, "Expected completed River job, got #{result.outcome}: #{result.error.inspect}") + result + end + + # Asserts that a synchronous attempt exhausted retries and returns its result. + def assert_job_discarded(result) + river_assert(result.outcome == :discarded, "Expected discarded River job, got #{result.outcome}: #{result.error.inspect}") + result + end + + # Asserts that exactly one matching new row was inserted by the block and + # returns it. Attributes (including args) use exact equality. + def assert_job_inserted(client, **attributes, &block) + assert_jobs_inserted(client, count: 1, **attributes, &block).fetch(0) + end + + # Asserts the number of matching new rows, returning those rows. Existing + # rows returned by uniqueness checks do not count as insertions. + def assert_jobs_inserted(client, count:, **attributes, &block) + raise ArgumentError, "count must be a nonnegative integer" unless count.is_a?(Integer) && count >= 0 + + rows = Testing.inserted_jobs(client, **attributes, &block) + message = "Expected #{count} new River jobs matching #{attributes.inspect}, got #{rows.length}: #{rows.map(&:id).inspect}" + river_assert(rows.length == count, message) + rows + end + + # Asserts that the block inserted no matching rows. + def assert_no_jobs_inserted(client, **attributes, &block) + assert_jobs_inserted(client, count: 0, **attributes, &block) + end + + private def river_assert(condition, message) + raise AssertionError, message unless condition + end + end + + class << self + # Runs eligible jobs in priority order, including jobs inserted by workers. + # Never sleeps or starts maintenance. Raises if runnable work remains after + # max_jobs attempts. Use an isolated database with no background consumers. + def drain(client, queue:, max_jobs: 100) + raise ArgumentError, "max_jobs must be a positive integer" unless max_jobs.is_a?(Integer) && max_jobs.positive? + queue = queue.to_s + # @type var results: Array[ExecutionResult] + results = [] + loop do + now = Time.now.utc + row = jobs(client).select { |job| job.queue == queue && %w[available retryable scheduled].include?(job.state) && job.scheduled_at <= now } + .min_by { |job| [job.priority, job.scheduled_at, job.id] } + + return results unless row + raise DrainLimitError, "River drain reached #{max_jobs} attempts with runnable jobs remaining in #{queue.inspect}" if results.length == max_jobs + + results << perform_job(client, row.id) + end + end + + # Returns matching rows newly persisted by a block, using an ID snapshot + # rather than a count or sequence high-water mark. Rolls back no data. + def inserted_jobs(client, **attributes) + raise ArgumentError, "a block is required" unless block_given? + + attributes = normalize_attributes(attributes) + + before = jobs(client).to_h { |job| [job.id, true] } + yield + jobs(client).select do |job| + !before.key?(job.id) && attributes.all? { |key, value| job.public_send(key) == value } + end + end + + # Returns all persisted jobs, in every state, using paginated reads. + # Requires an isolated test database without concurrent consumers. + def jobs(client) + # @type var result: Array[JobRow] + result = [] + # @type var cursor: Integer? + cursor = nil + loop do + page = client.job_list(JobListParams.new(after_id: cursor, limit: 100)).jobs + result.concat(page) + return result if page.length < 100 + + cursor = page.fetch(-1).id + end + end + + # Internal shared normalization for assertions and RSpec matchers. Only + # symbolic identifiers are coerced; JSON values and matchers stay intact. + def normalize_attributes(attributes) + unknown = attributes.keys - JOB_ATTRIBUTES + raise ArgumentError, "unknown job attributes: #{unknown.join(", ")}" unless unknown.empty? + + attributes.to_h do |key, value| + [key, (value.is_a?(Symbol) && %i[kind queue state].include?(key)) ? value.to_s : value] + end + end + + # Performs one attempt on the calling thread using the actual runtime, + # returning the persisted row, original exception, and symbolic outcome. + # Future jobs require allow_scheduled: true; terminal/running/pending jobs + # cannot be claimed. This intentionally bypasses queue pause and capacity. + def perform_job(client, id, allow_scheduled: false) + job, error, outcome = client.__perform_job(id, allow_scheduled: allow_scheduled) + ExecutionResult.new(id: id, error: error, job: job, outcome: outcome) + end + end + end +end diff --git a/ruby/lib/riverqueue/testing/minitest.rb b/ruby/lib/riverqueue/testing/minitest.rb new file mode 100644 index 000000000..8ef408324 --- /dev/null +++ b/ruby/lib/riverqueue/testing/minitest.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "minitest" +require "riverqueue/testing" + +module River + module Testing + # Include in Minitest::Test (or an individual test class) to use River + # assertions with Minitest's failure reporting and assertion count. + module Minitest + include Assertions + + private def river_assert(condition, message) + assert(condition, message) + end + end + end +end diff --git a/ruby/lib/riverqueue/testing/rspec.rb b/ruby/lib/riverqueue/testing/rspec.rb new file mode 100644 index 000000000..aa585b0b9 --- /dev/null +++ b/ruby/lib/riverqueue/testing/rspec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "rspec/expectations" +require "riverqueue/testing" + +module River + module Testing + # Include through RSpec.configure { |c| c.include River::Testing::RSpec }. + module RSpec + # Matches a client with at least one persisted job matching the attributes. + # Includes all job states unless state: is specified. + def have_job(**attributes) + JobMatcher.new(1, attributes).at_least(1) + end + + # Matches a block inserting exactly one matching persisted job. + def insert_job(client, **attributes) + insert_jobs(client, **attributes) + end + + # Matches a block inserting count matching rows (one by default). + # Attributes accept composable RSpec matchers. Negation always requires zero + # matching rows, regardless of the configured positive count. + def insert_jobs(client, count: 1, **attributes) + InsertionMatcher.new(client, count, attributes) + end + + class JobMatcher + include ::RSpec::Matchers::Composable + + def initialize(count, attributes) + @attributes = Testing.normalize_attributes(attributes) + exactly(count) + end + + # Requires at least count matching jobs. + def at_least(count) + set_count(count, :>=, "at least") + end + + # Requires at most count matching jobs. + def at_most(count) + set_count(count, :<=, "at most") + end + + def description = "#{verb} #{@count_description} #{@count} River jobs matching #{surface_descriptions_in(@attributes).inspect}" + + def does_not_match?(actual) + matching_rows(actual).empty? + end + + # Requires exactly count matching jobs. + def exactly(count) + set_count(count, :==, "exactly") + end + + def failure_message = "Expected to #{description}, got #{@rows.length}: #{@rows.map(&:id).inspect}" + + def failure_message_when_negated = "Expected no matching River jobs for #{surface_descriptions_in(@attributes).inspect}, got #{@rows.length}: #{@rows.map(&:id).inspect}" + + def matches?(actual) + matching_rows(actual).length.public_send(@comparison, @count) + end + + def supports_block_expectations? = false + + def supports_value_expectations? = true + + private def candidates(actual) + Testing.jobs(actual) + end + + private def matching_rows(actual) + @rows = candidates(actual).select do |row| + @attributes.all? { |key, expected| values_match?(expected, row.public_send(key)) } + end + end + + private def set_count(count, comparison, description) + raise ArgumentError, "count must be a nonnegative integer" unless count.is_a?(Integer) && count >= 0 + + @comparison = comparison + @count = count + @count_description = description + self + end + + private def verb = "have" + end + + class InsertionMatcher < JobMatcher + def initialize(client, count, attributes) + @client = client + super(count, attributes) + end + + def supports_block_expectations? = true + + def supports_value_expectations? = false + + private def candidates(operation) + Testing.inserted_jobs(@client, &operation) + end + + private def verb = "insert" + end + end + end +end diff --git a/ruby/lib/unique_args.rb b/ruby/lib/unique_args.rb new file mode 100644 index 000000000..ffa442039 --- /dev/null +++ b/ruby/lib/unique_args.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module River + # Internal codec for the argument portion of Go-compatible unique keys. + # Only top-level keys are sorted. Decoding and re-encoding values would lose + # number lexemes, escaping, duplicate keys, and nested object ordering. + module UniqueArgs + def self.encode(json, fields) + value = JSON.parse(json) + if !fields.is_a?(Array) || fields.empty? + return "{}" if value.is_a?(Array) && value.empty? + raise ArgumentError, "unique args must encode a JSON object" unless value.is_a?(Hash) + + return encode_object(members(json).sort.to_h) + end + + paths = fields.map do |field| + path = Array(field).map(&:to_s) + raise ArgumentError, "unique argument paths must not be empty" if path.empty? + + path + end + selected = {} #: Hash[String, untyped] + paths.sort_by { |path| [path.join("."), path.map { |part| part.gsub(/([\\.])/) { |char| "\\" + char } }.join(".")] }.each do |path| + raw = json + path.each do |part| + raw = members(raw)[part] + break unless raw + end + next unless raw + + target = selected + covered = false + path.take(path.length - 1).each do |part| + if target[part].is_a?(String) + covered = true + break # Selecting a whole object already includes its children. + end + target = (target[part] ||= {}) + end + target[path.last] = raw unless covered + end + # Go's selected-field builder starts with nil bytes, not an empty object. + selected.empty? ? "" : encode_object(selected) + end + + # The caller validates JSON first. Values remain slices of the original + # string; this is a token walk, not a second JSON decoder. Go takes the first + # occurrence of a duplicate top-level key. + def self.members(json) + return {} unless json.lstrip.start_with?("{") + + result = {} #: Hash[String, String] + depth = 0 + key = "" + start = nil #: Integer? + expecting_value = false + # Anchoring prevents repeated searches through an unterminated string. + # \K keeps leading whitespace out of the offsets used to slice raw values. + json.scan(/\G\s*\K(?:"(?:[^"\\]|\\.)*"|[{}\[\],:]|[^\s{}\[\],:"]+)/) do |scanned| + token = scanned #: String + match = Regexp.last_match #: MatchData + if depth == 1 + if start && [",", "}"].include?(token) + raw = json[start...match.begin(0)] #: String + result[key] ||= raw.rstrip + start = nil + elsif expecting_value + start = match.begin(0) + expecting_value = false + elsif token == ":" + expecting_value = true + elsif token.start_with?('"') && !start + key = JSON.parse(token) + end + end + depth += 1 if token == "{" || token == "[" + depth -= 1 if token == "}" || token == "]" + end + result + end + + def self.encode_object(object) + "{" + object.map { |key, value| "#{encode_key(key)}:#{value.is_a?(Hash) ? encode_object(value) : value}" }.join(",") + "}" + end + + # Match sjson's fast path for printable ASCII keys and encoding/json's + # HTML escaping for all other keys. Values are never passed through here. + def self.encode_key(key) + return '"' + key + '"' unless key.match?(/[\x00-\x1f\u0080-\u{10ffff}"\\]/) + + JSON.generate(key).gsub(/[<>&\u2028\u2029]/) { |char| "\\u%04x" % char.ord } + end + + private_class_method :encode_object, :encode_key + end +end diff --git a/ruby/lib/unique_bitmask.rb b/ruby/lib/unique_bitmask.rb new file mode 100644 index 000000000..5a7b3232e --- /dev/null +++ b/ruby/lib/unique_bitmask.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module River + class UniqueBitmask + def self.from_states(states) + val = 0 + + states.each do |state| + bit_index = JOB_STATE_BIT_POSITIONS[state] + + bit_position = 7 - (bit_index % 8) + val |= 1 << bit_position + end + + format("%08b", val) + end + + def self.to_states(mask) + states = [] #: Array[jobStateAll] # rubocop:disable Layout/LeadingCommentSpace + + JOB_STATE_BIT_POSITIONS.each do |state, bit_index| + bit_position = 7 - (bit_index % 8) + if (mask & (1 << bit_position)) != 0 + states << state + end + end + + states.sort + end + + JOB_STATE_BIT_POSITIONS = { + ::River::JOB_STATE_AVAILABLE => 7, + ::River::JOB_STATE_CANCELLED => 6, + ::River::JOB_STATE_COMPLETED => 5, + ::River::JOB_STATE_DISCARDED => 4, + ::River::JOB_STATE_PENDING => 3, + ::River::JOB_STATE_RETRYABLE => 2, + ::River::JOB_STATE_RUNNING => 1, + ::River::JOB_STATE_SCHEDULED => 0 + }.freeze + private_constant :JOB_STATE_BIT_POSITIONS + end +end diff --git a/ruby/lib/worker.rb b/ruby/lib/worker.rb new file mode 100644 index 000000000..0db37e2ec --- /dev/null +++ b/ruby/lib/worker.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +module River + # Registry that maps job kinds to worker objects. + class Workers + # Creates an empty worker registry. + def initialize + @workers = {} + end + + # Registers a worker for a job kind and optional aliases, returning self. + # + # With one argument, the kind is read from +worker.kind+ or + # +worker.class.kind+. Pass a kind and worker separately to override it. + # Kinds and aliases accept symbols or strings. + def add(kind_or_worker, worker = nil, aliases: []) + if worker + kind = kind_or_worker.to_s + else + worker = kind_or_worker + kind = worker.respond_to?(:kind) ? worker.kind.to_s : worker.class.kind.to_s + end + + candidates = [kind] + aliases.map(&:to_s) + candidates.each do |candidate| + raise ArgumentError, "worker for kind #{candidate.inspect} is already registered" if @workers.key?(candidate) + end + + candidates.each do |candidate| + @workers[candidate] = worker + end + + self + end + + # Returns the worker registered for +kind+, or nil when none is registered. + def [](kind) + @workers[kind.to_s] + end + + # Returns a registered worker. Like Hash#fetch, raises KeyError when missing + # unless a default argument or fallback block is supplied. + def fetch(kind, ...) + workers = @workers #: untyped + workers.fetch(kind.to_s, ...) + end + + # Returns true if a worker is registered for +kind+. + def include?(kind) + @workers.key?(kind.to_s) + end + + # Returns the registered job kinds, including aliases. + def kinds + @workers.keys.freeze + end + end + + # A job being worked, with access to its persisted row and attempt-local + # metadata changes. + class Job + # Client working this job. + attr_reader :client + + # Persisted JobRow claimed for this attempt. + attr_reader :row + + def initialize(client, row) + @client = client + @logger = nil + @metadata_updates = {} + @row = row + initialize_resumable_state + end + + # Returns the job arguments decoded from their persisted JSON. + def args = row.args + + # Returns the attempt-local logger supplied by JobPersistedLogging::Plugin. + # Output is saved to job metadata when the attempt finishes, not streamed to the DB. + def logger + @logger || raise(Error, "job.logger requires River::JobPersistedLogging::Plugin in Config#plugins") + end + + # Internal middleware boundary; never replaces the client's process logger. + def __with_logger(logger) + previous = @logger + @logger = logger + begin + yield + ensure + @logger = previous + end + end + + # Returns a snapshot of persisted metadata merged with this attempt's changes. + def metadata = JSON.parse(JSON.generate(row.metadata.merge(@metadata_updates))) + + # Returns a copy of metadata changes waiting to be persisted when work + # finishes. + def metadata_updates + JSON.parse(JSON.generate(@metadata_updates)) + end + + def method_missing(name, ...) + return row.public_send(name, ...) if row.respond_to?(name) + + super + end + + # Stores a worker result under the conventional +"output"+ metadata key. + def output=(value) + update_metadata("output" => value) + end + + def respond_to_missing?(name, include_private = false) + row.respond_to?(name, include_private) || super + end + + # Merges JSON-compatible values into the job metadata to be persisted when + # work finishes. Values are validated and copied immediately, so invalid JSON + # fails the work without also preventing its failure from being recorded. + # Keys are converted to strings. Returns self. + def update_metadata(values) + @metadata_updates.merge!(JSON.parse(JSON.generate(values.transform_keys(&:to_s)))) + self + end + end + + # Default retry schedule used when a worker does not provide its own policy. + class DefaultClientRetryPolicy + # Match Go's maximum signed 64-bit nanosecond duration exactly. + MAX_DELAY = Rational((1 << 63) - 1, 1_000_000_000) + private_constant :MAX_DELAY + + # Creates River's default quartic-backoff retry policy. A Random source may + # be injected to make jitter deterministic. + def initialize(random: Random) + @random = random + end + + # Returns the Time at which +job+ should next be attempted. + def next_retry(job, _error = nil, now: Time.now.utc) + error_count = Array(job.errors).length + 1 + seconds = Integer(error_count**4) + return now + MAX_DELAY if seconds >= MAX_DELAY + + delay = seconds + (seconds * (@random.rand * 0.2 - 0.1)) + now + [delay, MAX_DELAY].min + end + end +end diff --git a/ruby/lib/worker_runner.rb b/ruby/lib/worker_runner.rb new file mode 100644 index 000000000..f6760088d --- /dev/null +++ b/ruby/lib/worker_runner.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "io/wait" + +module River + # Owns a dedicated worker process. Call run on its main thread, after boot. + # A supervisor should restart failed processes. An unresponsive stop ends + # the process with exit!(1), bypassing application at_exit handlers. + class WorkerRunner + # Grace and finalization deadlines are finite, nonnegative seconds. + def initialize(client, finalization_timeout: 5, out: $stdout, stop_timeout: 30) + @client = client + @finalization_timeout = Float(finalization_timeout, exception: true) #: Float + @out = out + @stop_timeout = Float(stop_timeout, exception: true) #: Float + [@finalization_timeout, @stop_timeout].each do |value| + raise ArgumentError, "stop deadlines must be finite and nonnegative" unless value.finite? && value >= 0 + end + end + + # Starts the client and blocks until stopped. Returns 0 for a clean drain, + # 1 for a runtime failure or interrupted stop. Restores signal handlers. + def run + raise ArgumentError, "worker runner must run on the main thread of the main Ractor" unless Thread.current == Thread.main && Ractor.current == Ractor.main + raise ArgumentError, "worker runner requires a stopped client" if @client.started? + raise ArgumentError, "configure at least one worker queue" if @client.config.queues.empty? + + reader, writer = IO.pipe + handlers = {} #: Hash[String, untyped] + %w[INT TERM TSTP].each do |signal| + handlers[signal] = Signal.trap(signal) { writer.write_nonblock((signal == "TSTP") ? "Q" : "S", exception: false) } + end + + begin + log("starting") + @client.job_list(JobListParams.new(limit: 1)) + @client.start + log("ready pid=#{Process.pid}") + + supervise(reader) + ensure + handlers.each { |signal, handler| Signal.trap(signal, handler) } + reader.close + writer.close + end + end + + private def log(message) + @out.puts("River worker: #{message}") + @out.flush + end + + private def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + private def supervise(reader) + deadline = 0.0 + failed = false + interrupted = false + stopper = nil #: Thread? + loop do + signals = reader.wait_readable(0.1) ? reader.read_nonblock(4_096) : "" + if signals.include?("Q") + @client.stop(wait: false) + log("stop requested; no new work will be fetched") + end + + unless @client.__runtime_healthy? + log("runtime thread exited unexpectedly") + failed = true + end + + if !stopper && (signals.include?("S") || failed) + @client.stop(wait: false) + log("draining active attempts") + deadline = monotonic_now + @stop_timeout + stopper = Thread.new { @client.stop } + stopper.report_on_exception = false + # Consume only the first stop request; a second escalates immediately. + signals = signals.sub("S", "") + end + + next unless stopper + + if stopper.join(0) + stopper.value + log("stopped") + return (failed || interrupted) ? 1 : 0 + end + + next unless signals.include?("S") || monotonic_now >= deadline + + if interrupted + log("finalization deadline exceeded; forcing process exit") + Process.exit!(1) + end + + interrupted = true + log("interrupting active attempts") + @client.__interrupt_workers + deadline = monotonic_now + @finalization_timeout + end + end + end +end diff --git a/ruby/migration/LICENSE b/ruby/migration/LICENSE new file mode 100644 index 000000000..2f8ed188e --- /dev/null +++ b/ruby/migration/LICENSE @@ -0,0 +1,374 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/ruby/migration/README.md b/ruby/migration/README.md new file mode 100644 index 000000000..8c0e38e1f --- /dev/null +++ b/ruby/migration/README.md @@ -0,0 +1,13 @@ +# Canonical River migrations + +The SQL files in this directory are unmodified copies from +https://github.com/riverqueue/river, distributed under the upstream Mozilla +Public License 2.0 (see LICENSE). The Ruby implementation remains licensed +under the repository's MPL-2.0 license. + +The bundle includes main migrations through version 008. +manifest.json records the SHA-256 of every SQL file in the repository's +riverdriver/ directory. +Verification compares against this checkout, not a pinned upstream revision. +Update or verify these files with scripts/sync_migrations.rb; do not edit SQL +here independently of upstream. Private Pro migrations are not included here. diff --git a/ruby/migration/manifest.json b/ruby/migration/manifest.json new file mode 100644 index 000000000..b3c2ff62c --- /dev/null +++ b/ruby/migration/manifest.json @@ -0,0 +1,37 @@ +{ + "files": { + "postgresql/main/001_create_river_migration.down.sql": "34c87dc594bf7520bc3ae69f6f0da8d2d9a472616ab38b37e63d4e3838da06d2", + "postgresql/main/001_create_river_migration.up.sql": "79def9ab1643beee7776c499559ec199a03b5b26036c122dc3ba13ec3d078dc0", + "postgresql/main/002_initial_schema.down.sql": "8e7e73755b3e9cd1d46f0dffeadd427b86af13cea2f41f3d30af1624329db9b9", + "postgresql/main/002_initial_schema.up.sql": "8915c00d08ed98625865c705b6fd0bd14c113b7cdd0cb218ee894eca1d32ad03", + "postgresql/main/003_river_job_tags_non_null.down.sql": "bca44f6f0e926411c9e26e7ce2598bbdb5102b286f380135d9a5bcd96a77cbb8", + "postgresql/main/003_river_job_tags_non_null.up.sql": "dedb183bb302c005bc72caf2901ff693bbab11413308c5e0567ddffb51e667ef", + "postgresql/main/004_pending_and_more.down.sql": "91b5ced7b9d707a0de73f5b312596935950b70229f58aa9bf3ca362aa7a408c8", + "postgresql/main/004_pending_and_more.up.sql": "3f7418b0cf78ede9a9ec730bdfc4389a84e05989531b205fc4ece0d2bb10e390", + "postgresql/main/005_migration_unique_client.down.sql": "de84dca49a5d618d2a4973b13a69830fbebb0f9635babfa50c6f19577193425b", + "postgresql/main/005_migration_unique_client.up.sql": "b760f487152c7d92102869d46b8a64dc1e2094d5675e690ffbe52a747eee8431", + "postgresql/main/006_bulk_unique.down.sql": "726483f6e5aa7dd02cdd974cd7bf716973a8d0a97ba6dbc5dc5304aaf54c7ad7", + "postgresql/main/006_bulk_unique.up.sql": "3b133f7ce4662d3dc8bd4a57628e0e116a300b2635a79315557aa1369849f0fb", + "postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql": "9131aae235187dbdaaa822dab2a475a884e917d9af05e3c98fb95c152eaa769a", + "postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql": "47ec8031b88e69004de2def5bc3109d969f71ee4c33a1e7dac2fb8c9dd19182d", + "postgresql/main/008_job_id_autoincrement.down.sql": "0c3750a947d6494db07d56f5d3735a5e49a2cbfa1f7a09771227c31c8147bf70", + "postgresql/main/008_job_id_autoincrement.up.sql": "0c3750a947d6494db07d56f5d3735a5e49a2cbfa1f7a09771227c31c8147bf70", + "sqlite/main/001_create_river_migration.down.sql": "34c87dc594bf7520bc3ae69f6f0da8d2d9a472616ab38b37e63d4e3838da06d2", + "sqlite/main/001_create_river_migration.up.sql": "d15597cb0bb884fb0727d2a29ad8313842708b55fd561a5fe62e37aad5f34298", + "sqlite/main/002_initial_schema.down.sql": "900508ba08d0ca3c8451eb2854cd9ab837166ef55736393524253b6228438470", + "sqlite/main/002_initial_schema.up.sql": "58bc64db39fa813ab1eee92b5c3f6e4463f88ac1de85df731d959cdede7d4f35", + "sqlite/main/003_river_job_tags_non_null.down.sql": "223eb849addf451228e7f057c2e29b005aaf63ddf25c51a7a088426d139d9dd9", + "sqlite/main/003_river_job_tags_non_null.up.sql": "ae9961ea15b2fbe88298c687dd524ada29e018f8fbc493db517e988d9d0b61c2", + "sqlite/main/004_pending_and_more.down.sql": "28065bbe82dbaa187d8705861d8fec558f210eb739feba63e7913a09a5e9ae3f", + "sqlite/main/004_pending_and_more.up.sql": "8c11c8d2bf63200e2cfe58dca1e5d30131fed0f99cb74146b3116fa1286a93f5", + "sqlite/main/005_migration_unique_client.down.sql": "9960dc49a2293a9bdbdb32ca61dc2971cde5b49ac658ef21ed7f96ec6e011997", + "sqlite/main/005_migration_unique_client.up.sql": "67c32e81494b62baf1e6b0fb6025e882a7b1be7c9ed2236799d17d00912213c4", + "sqlite/main/006_bulk_unique.down.sql": "b9e778134d15e815cf0694f06444f738cde072c2d297978eb30d7bd7bdb2802c", + "sqlite/main/006_bulk_unique.up.sql": "96713f4832bcf9343df30b62c6c35b03daca23aa9a02adbd7eb0091556df3659", + "sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql": "55bffeb528b40dffc0cbec2f22d1463aef37a8b3d7977f83729cc505bb77c745", + "sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql": "441a05e1d9aa4f151877ccf725b0b0a86f27013442297d4b621c05676270d0b0", + "sqlite/main/008_job_id_autoincrement.down.sql": "04871283fe5d4cab4ac70da28d8509aa7ba765994d528c2dcc7ebe0ee596710c", + "sqlite/main/008_job_id_autoincrement.up.sql": "049c9bf615f24a326bcc31ddc87b46f76e3de11e3eea3b2b9e1358131b3f3bed" + }, + "source": "riverdriver/" +} diff --git a/ruby/migration/postgresql/main/001_create_river_migration.down.sql b/ruby/migration/postgresql/main/001_create_river_migration.down.sql new file mode 100644 index 000000000..8bfe82027 --- /dev/null +++ b/ruby/migration/postgresql/main/001_create_river_migration.down.sql @@ -0,0 +1 @@ +DROP TABLE /* TEMPLATE: schema */river_migration; \ No newline at end of file diff --git a/ruby/migration/postgresql/main/001_create_river_migration.up.sql b/ruby/migration/postgresql/main/001_create_river_migration.up.sql new file mode 100644 index 000000000..27006d562 --- /dev/null +++ b/ruby/migration/postgresql/main/001_create_river_migration.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE /* TEMPLATE: schema */river_migration( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT NOW(), + version bigint NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX ON /* TEMPLATE: schema */river_migration USING btree(version); \ No newline at end of file diff --git a/ruby/migration/postgresql/main/002_initial_schema.down.sql b/ruby/migration/postgresql/main/002_initial_schema.down.sql new file mode 100644 index 000000000..d334d8a65 --- /dev/null +++ b/ruby/migration/postgresql/main/002_initial_schema.down.sql @@ -0,0 +1,5 @@ +DROP TABLE /* TEMPLATE: schema */river_job; +DROP FUNCTION /* TEMPLATE: schema */river_job_notify; +DROP TYPE /* TEMPLATE: schema */river_job_state; + +DROP TABLE /* TEMPLATE: schema */river_leader; \ No newline at end of file diff --git a/ruby/migration/postgresql/main/002_initial_schema.up.sql b/ruby/migration/postgresql/main/002_initial_schema.up.sql new file mode 100644 index 000000000..7fbca71b4 --- /dev/null +++ b/ruby/migration/postgresql/main/002_initial_schema.up.sql @@ -0,0 +1,96 @@ +CREATE TYPE /* TEMPLATE: schema */river_job_state AS ENUM( + 'available', + 'cancelled', + 'completed', + 'discarded', + 'retryable', + 'running', + 'scheduled' +); + +CREATE TABLE /* TEMPLATE: schema */river_job( + -- 8 bytes + id bigserial PRIMARY KEY, + + -- 8 bytes (4 bytes + 2 bytes + 2 bytes) + -- + -- `state` is kept near the top of the table for operator convenience -- when + -- looking at jobs with `SELECT *` it'll appear first after ID. The other two + -- fields aren't as important but are kept adjacent to `state` for alignment + -- to get an 8-byte block. + state /* TEMPLATE: schema */river_job_state NOT NULL DEFAULT 'available', + attempt smallint NOT NULL DEFAULT 0, + max_attempts smallint NOT NULL, + + -- 8 bytes each (no alignment needed) + attempted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + finalized_at timestamptz, + scheduled_at timestamptz NOT NULL DEFAULT NOW(), + + -- 2 bytes (some wasted padding probably) + priority smallint NOT NULL DEFAULT 1, + + -- types stored out-of-band + args jsonb, + attempted_by text[], + errors jsonb[], + kind text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}', + queue text NOT NULL DEFAULT 'default', + tags varchar(255)[], + + CONSTRAINT finalized_or_finalized_at_null CHECK ((state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL), + CONSTRAINT max_attempts_is_positive CHECK (max_attempts > 0), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (char_length(queue) > 0 AND char_length(queue) < 128), + CONSTRAINT kind_length CHECK (char_length(kind) > 0 AND char_length(kind) < 128) +); + +-- We may want to consider adding another property here after `kind` if it seems +-- like it'd be useful for something. +CREATE INDEX river_job_kind ON /* TEMPLATE: schema */river_job USING btree(kind); + +CREATE INDEX river_job_state_and_finalized_at_index ON /* TEMPLATE: schema */river_job USING btree(state, finalized_at) WHERE finalized_at IS NOT NULL; + +CREATE INDEX river_job_prioritized_fetching_index ON /* TEMPLATE: schema */river_job USING btree(state, queue, priority, scheduled_at, id); + +CREATE INDEX river_job_args_index ON /* TEMPLATE: schema */river_job USING GIN(args); + +CREATE INDEX river_job_metadata_index ON /* TEMPLATE: schema */river_job USING GIN(metadata); + +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_notify() + RETURNS TRIGGER + AS $$ +DECLARE + payload json; +BEGIN + IF NEW.state = 'available' THEN + -- Notify will coalesce duplicate notifications within a transaction, so + -- keep these payloads generalized: + payload = json_build_object('queue', NEW.queue); + PERFORM + pg_notify('river_insert', payload::text); + END IF; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER river_notify + AFTER INSERT ON /* TEMPLATE: schema */river_job + FOR EACH ROW + EXECUTE PROCEDURE /* TEMPLATE: schema */river_job_notify(); + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_leader( + -- 8 bytes each (no alignment needed) + elected_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + + -- types stored out-of-band + leader_id text NOT NULL, + name text PRIMARY KEY, + + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT leader_id_length CHECK (char_length(leader_id) > 0 AND char_length(leader_id) < 128) +); diff --git a/ruby/migration/postgresql/main/003_river_job_tags_non_null.down.sql b/ruby/migration/postgresql/main/003_river_job_tags_non_null.down.sql new file mode 100644 index 000000000..acef65cb9 --- /dev/null +++ b/ruby/migration/postgresql/main/003_river_job_tags_non_null.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN tags DROP NOT NULL, + ALTER COLUMN tags DROP DEFAULT; diff --git a/ruby/migration/postgresql/main/003_river_job_tags_non_null.up.sql b/ruby/migration/postgresql/main/003_river_job_tags_non_null.up.sql new file mode 100644 index 000000000..0a472dde4 --- /dev/null +++ b/ruby/migration/postgresql/main/003_river_job_tags_non_null.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN tags SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET tags = '{}' WHERE tags IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN tags SET NOT NULL; diff --git a/ruby/migration/postgresql/main/004_pending_and_more.down.sql b/ruby/migration/postgresql/main/004_pending_and_more.down.sql new file mode 100644 index 000000000..1b7ec7e84 --- /dev/null +++ b/ruby/migration/postgresql/main/004_pending_and_more.down.sql @@ -0,0 +1,42 @@ +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args DROP NOT NULL; + +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata DROP NOT NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata DROP DEFAULT; + +-- It is not possible to safely remove 'pending' from the river_job_state enum, +-- so leave it in place. + +ALTER TABLE /* TEMPLATE: schema */river_job DROP CONSTRAINT finalized_or_finalized_at_null; +ALTER TABLE /* TEMPLATE: schema */river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK ( + (state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL +); + +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_notify() + RETURNS TRIGGER + AS $$ +DECLARE + payload json; +BEGIN + IF NEW.state = 'available' THEN + -- Notify will coalesce duplicate notifications within a transaction, so + -- keep these payloads generalized: + payload = json_build_object('queue', NEW.queue); + PERFORM + pg_notify('river_insert', payload::text); + END IF; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER river_notify + AFTER INSERT ON /* TEMPLATE: schema */river_job + FOR EACH ROW + EXECUTE PROCEDURE /* TEMPLATE: schema */river_job_notify(); + +DROP TABLE /* TEMPLATE: schema */river_queue; + +ALTER TABLE /* TEMPLATE: schema */river_leader + ALTER COLUMN name DROP DEFAULT, + DROP CONSTRAINT name_length, + ADD CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128); \ No newline at end of file diff --git a/ruby/migration/postgresql/main/004_pending_and_more.up.sql b/ruby/migration/postgresql/main/004_pending_and_more.up.sql new file mode 100644 index 000000000..9f5e47bb1 --- /dev/null +++ b/ruby/migration/postgresql/main/004_pending_and_more.up.sql @@ -0,0 +1,45 @@ +-- The args column never had a NOT NULL constraint or default value at the +-- database level, though we tried to ensure one at the application level. +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET args = '{}' WHERE args IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args SET NOT NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args DROP DEFAULT; + +-- The metadata column never had a NOT NULL constraint or default value at the +-- database level, though we tried to ensure one at the application level. +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET metadata = '{}' WHERE metadata IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata SET NOT NULL; + +-- The 'pending' job state will be used for upcoming functionality: +ALTER TYPE /* TEMPLATE: schema */river_job_state ADD VALUE IF NOT EXISTS 'pending' AFTER 'discarded'; + +ALTER TABLE /* TEMPLATE: schema */river_job DROP CONSTRAINT finalized_or_finalized_at_null; +ALTER TABLE /* TEMPLATE: schema */river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) +); + +DROP TRIGGER river_notify ON /* TEMPLATE: schema */river_job; +DROP FUNCTION /* TEMPLATE: schema */river_job_notify; + +-- +-- Create table `river_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}' ::jsonb, + paused_at timestamptz, + updated_at timestamptz NOT NULL +); + +-- +-- Alter `river_leader` to add a default value of 'default` to `name`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_leader + ALTER COLUMN name SET DEFAULT 'default', + DROP CONSTRAINT name_length, + ADD CONSTRAINT name_length CHECK (name = 'default'); \ No newline at end of file diff --git a/ruby/migration/postgresql/main/005_migration_unique_client.down.sql b/ruby/migration/postgresql/main/005_migration_unique_client.down.sql new file mode 100644 index 000000000..b8e041d54 --- /dev/null +++ b/ruby/migration/postgresql/main/005_migration_unique_client.down.sql @@ -0,0 +1,57 @@ +-- +-- Revert to migration table based only on `(version)`. +-- +-- If any non-main migrations are present, 005 is considered irreversible. +-- + +DO +$body$ +BEGIN + -- Tolerate users who may be using their own migration system rather than + -- River's. If they are, they will have skipped version 001 containing + -- `CREATE TABLE river_migration`, so this table won't exist. + IF (SELECT to_regclass('/* TEMPLATE: schema */river_migration') IS NOT NULL) THEN + IF EXISTS ( + SELECT * + FROM /* TEMPLATE: schema */river_migration + WHERE line <> 'main' + ) THEN + RAISE EXCEPTION 'Found non-main migration lines in the database; version 005 migration is irreversible because it would result in loss of migration information.'; + END IF; + + ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + + CREATE TABLE /* TEMPLATE: schema */river_migration( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT NOW(), + version bigint NOT NULL, + CONSTRAINT version CHECK (version >= 1) + ); + + CREATE UNIQUE INDEX ON /* TEMPLATE: schema */river_migration USING btree(version); + + INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, version) + SELECT created_at, version + FROM /* TEMPLATE: schema */river_migration_old; + + DROP TABLE /* TEMPLATE: schema */river_migration_old; + END IF; +END; +$body$ +LANGUAGE 'plpgsql'; + +-- +-- Drop `river_job.unique_key`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN unique_key; + +-- +-- Drop `river_client` and derivative. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; diff --git a/ruby/migration/postgresql/main/005_migration_unique_client.up.sql b/ruby/migration/postgresql/main/005_migration_unique_client.up.sql new file mode 100644 index 000000000..e0f1711ec --- /dev/null +++ b/ruby/migration/postgresql/main/005_migration_unique_client.up.sql @@ -0,0 +1,79 @@ +-- +-- Rebuild the migration table so it's based on `(line, version)`. +-- + +DO +$body$ +BEGIN + -- Tolerate users who may be using their own migration system rather than + -- River's. If they are, they will have skipped version 001 containing + -- `CREATE TABLE river_migration`, so this table won't exist. + IF (SELECT to_regclass('/* TEMPLATE: schema */river_migration') IS NOT NULL) THEN + ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + + CREATE TABLE /* TEMPLATE: schema */river_migration( + line TEXT NOT NULL, + version bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + CONSTRAINT line_length CHECK (char_length(line) > 0 AND char_length(line) < 128), + CONSTRAINT version_gte_1 CHECK (version >= 1), + PRIMARY KEY (line, version) + ); + + INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, line, version) + SELECT created_at, 'main', version + FROM /* TEMPLATE: schema */river_migration_old; + + DROP TABLE /* TEMPLATE: schema */river_migration_old; + END IF; +END; +$body$ +LANGUAGE 'plpgsql'; + +-- +-- Add `river_job.unique_key` and bring up an index on it. +-- + +-- These statements use `IF NOT EXISTS` to allow users with a `river_job` table +-- of non-trivial size to build the index `CONCURRENTLY` out of band of this +-- migration, then follow by completing the migration. +ALTER TABLE /* TEMPLATE: schema */river_job + ADD COLUMN IF NOT EXISTS unique_key bytea; + +CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON /* TEMPLATE: schema */river_job (kind, unique_key) WHERE unique_key IS NOT NULL; + +-- +-- Create `river_client` and derivative. +-- +-- This feature hasn't quite yet been implemented, but we're taking advantage of +-- the migration to add the schema early so that we can add it later without an +-- additional migration. +-- + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}', + paused_at timestamptz, + updated_at timestamptz NOT NULL, + CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128) +); + +-- Differs from `river_queue` in that it tracks the queue state for a particular +-- active client. +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES /* TEMPLATE: schema */river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + max_workers bigint NOT NULL DEFAULT 0, + metadata jsonb NOT NULL DEFAULT '{}', + num_jobs_completed bigint NOT NULL DEFAULT 0, + num_jobs_running bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); \ No newline at end of file diff --git a/ruby/migration/postgresql/main/006_bulk_unique.down.sql b/ruby/migration/postgresql/main/006_bulk_unique.down.sql new file mode 100644 index 000000000..26cd84345 --- /dev/null +++ b/ruby/migration/postgresql/main/006_bulk_unique.down.sql @@ -0,0 +1,16 @@ + +-- +-- Drop `river_job.unique_states` and its index. +-- + +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN unique_states; + +CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON /* TEMPLATE: schema */river_job (kind, unique_key) WHERE unique_key IS NOT NULL; + +-- +-- Drop `river_job_state_in_bitmask` function. +-- +DROP FUNCTION /* TEMPLATE: schema */river_job_state_in_bitmask; diff --git a/ruby/migration/postgresql/main/006_bulk_unique.up.sql b/ruby/migration/postgresql/main/006_bulk_unique.up.sql new file mode 100644 index 000000000..ef96a19f9 --- /dev/null +++ b/ruby/migration/postgresql/main/006_bulk_unique.up.sql @@ -0,0 +1,40 @@ +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_state_in_bitmask(bitmask BIT(8), state /* TEMPLATE: schema */river_job_state) +RETURNS boolean +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE state + WHEN 'available' THEN get_bit(bitmask, 7) + WHEN 'cancelled' THEN get_bit(bitmask, 6) + WHEN 'completed' THEN get_bit(bitmask, 5) + WHEN 'discarded' THEN get_bit(bitmask, 4) + WHEN 'pending' THEN get_bit(bitmask, 3) + WHEN 'retryable' THEN get_bit(bitmask, 2) + WHEN 'running' THEN get_bit(bitmask, 1) + WHEN 'scheduled' THEN get_bit(bitmask, 0) + ELSE 0 + END = 1; +$$; + +-- +-- Add `river_job.unique_states` and bring up an index on it. +-- +-- This column may exist already if users manually created the column and index +-- as instructed in the changelog so the index could be created `CONCURRENTLY`. +-- +ALTER TABLE /* TEMPLATE: schema */river_job ADD COLUMN IF NOT EXISTS unique_states BIT(8); + +-- This statement uses `IF NOT EXISTS` to allow users with a `river_job` table +-- of non-trivial size to build the index `CONCURRENTLY` out of band of this +-- migration, then follow by completing the migration. +CREATE UNIQUE INDEX IF NOT EXISTS river_job_unique_idx ON /* TEMPLATE: schema */river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND /* TEMPLATE: schema */river_job_state_in_bitmask(unique_states, state); + +-- Remove the old unique index. Users who are actively using the unique jobs +-- feature and who wish to avoid deploy downtime may want od drop this in a +-- subsequent migration once all jobs using the old unique system have been +-- completed (i.e. no more rows with non-null unique_key and null +-- unique_states). +DROP INDEX /* TEMPLATE: schema */river_job_kind_unique_key_idx; diff --git a/ruby/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql b/ruby/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql new file mode 100644 index 000000000..bed717f87 --- /dev/null +++ b/ruby/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql @@ -0,0 +1,56 @@ +-- +-- SQL cleanup rollback. +-- + +-- +-- Add back unused tables `river_client` and `river_client_queue`. +-- + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}', + paused_at timestamptz, + updated_at timestamptz NOT NULL, + CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128) +); + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES /* TEMPLATE: schema */river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + max_workers bigint NOT NULL DEFAULT 0, + metadata jsonb NOT NULL DEFAULT '{}', + num_jobs_completed bigint NOT NULL DEFAULT 0, + num_jobs_running bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +-- +-- Revert addition of `DEFAULT 25` to `river_job.max_attempts`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN max_attempts DROP DEFAULT; + +-- +-- Changes `river_queue.updated_at` to revert the default of `CURRENT_TIMESTAMP`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue + ALTER COLUMN updated_at DROP DEFAULT; + +-- +-- SQLite JSONB conversion rollback. +-- +-- No-op. PostgreSQL already stores River JSON columns as jsonb. + +-- +-- Notification outbox rollback. +-- + +DROP TABLE /* TEMPLATE: schema */river_notification; diff --git a/ruby/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql b/ruby/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql new file mode 100644 index 000000000..39e3249c9 --- /dev/null +++ b/ruby/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql @@ -0,0 +1,44 @@ +-- +-- Notification outbox. +-- + +CREATE TABLE /* TEMPLATE: schema */river_notification ( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now(), + payload text NOT NULL, + topic text NOT NULL, + CONSTRAINT topic_length CHECK (length(topic) > 0 AND length(topic) < 128) +); + +CREATE INDEX river_notification_created_at_idx ON /* TEMPLATE: schema */river_notification (created_at); +CREATE INDEX river_notification_topic_id_idx ON /* TEMPLATE: schema */river_notification (topic, id); + +-- +-- SQLite JSONB conversion. +-- +-- No-op. PostgreSQL already stores River JSON columns as jsonb. + +-- +-- SQL cleanup. +-- + +-- +-- Drop unused tables `river_client` and `river_client_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; + +-- +-- Adds `DEFAULT 25` to `river_job.max_attempts`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN max_attempts SET DEFAULT 25; + +-- +-- Changes `river_queue.updated_at` to have a default of `CURRENT_TIMESTAMP`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue + ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; diff --git a/ruby/migration/postgresql/main/008_job_id_autoincrement.down.sql b/ruby/migration/postgresql/main/008_job_id_autoincrement.down.sql new file mode 100644 index 000000000..695357bb8 --- /dev/null +++ b/ruby/migration/postgresql/main/008_job_id_autoincrement.down.sql @@ -0,0 +1,3 @@ +-- No-op. PostgreSQL sequences already prevent automatically generated job IDs +-- from being reused. +SELECT 1; diff --git a/ruby/migration/postgresql/main/008_job_id_autoincrement.up.sql b/ruby/migration/postgresql/main/008_job_id_autoincrement.up.sql new file mode 100644 index 000000000..695357bb8 --- /dev/null +++ b/ruby/migration/postgresql/main/008_job_id_autoincrement.up.sql @@ -0,0 +1,3 @@ +-- No-op. PostgreSQL sequences already prevent automatically generated job IDs +-- from being reused. +SELECT 1; diff --git a/ruby/migration/sqlite/main/001_create_river_migration.down.sql b/ruby/migration/sqlite/main/001_create_river_migration.down.sql new file mode 100644 index 000000000..8bfe82027 --- /dev/null +++ b/ruby/migration/sqlite/main/001_create_river_migration.down.sql @@ -0,0 +1 @@ +DROP TABLE /* TEMPLATE: schema */river_migration; \ No newline at end of file diff --git a/ruby/migration/sqlite/main/001_create_river_migration.up.sql b/ruby/migration/sqlite/main/001_create_river_migration.up.sql new file mode 100644 index 000000000..bdaf09339 --- /dev/null +++ b/ruby/migration/sqlite/main/001_create_river_migration.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE /* TEMPLATE: schema */river_migration ( + id integer PRIMARY KEY, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + version integer NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_migration_version_idx ON river_migration (version); \ No newline at end of file diff --git a/ruby/migration/sqlite/main/002_initial_schema.down.sql b/ruby/migration/sqlite/main/002_initial_schema.down.sql new file mode 100644 index 000000000..cbdd56dea --- /dev/null +++ b/ruby/migration/sqlite/main/002_initial_schema.down.sql @@ -0,0 +1,8 @@ +-- +-- Normally `river_job` and `river_job_notify()` are dropped here, but since +-- SQLite was added well after 002 came about, we push that to version 006 index. +-- + +DROP TABLE /* TEMPLATE: schema */river_job; + +DROP TABLE /* TEMPLATE: schema */river_leader; \ No newline at end of file diff --git a/ruby/migration/sqlite/main/002_initial_schema.up.sql b/ruby/migration/sqlite/main/002_initial_schema.up.sql new file mode 100644 index 000000000..043facf29 --- /dev/null +++ b/ruby/migration/sqlite/main/002_initial_schema.up.sql @@ -0,0 +1,19 @@ +-- +-- Normally `river_job` and `river_job_notify()` are raised here, but since +-- SQLite was added well after 002 came about, we push that to version 006 index. +-- + +-- Dummy `river_job` table so that there's something to truncate in tests when +-- migrated to this version specifically. +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY +); + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL, + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); diff --git a/ruby/migration/sqlite/main/003_river_job_tags_non_null.down.sql b/ruby/migration/sqlite/main/003_river_job_tags_non_null.down.sql new file mode 100644 index 000000000..8d314cf06 --- /dev/null +++ b/ruby/migration/sqlite/main/003_river_job_tags_non_null.down.sql @@ -0,0 +1,6 @@ +-- +-- Normally `river_job.tags` is set back to nullable here, but since SQLite was +-- added well after 003 came about, we push that to version 006 index. +-- + +SELECT 1; diff --git a/ruby/migration/sqlite/main/003_river_job_tags_non_null.up.sql b/ruby/migration/sqlite/main/003_river_job_tags_non_null.up.sql new file mode 100644 index 000000000..d4e1e2404 --- /dev/null +++ b/ruby/migration/sqlite/main/003_river_job_tags_non_null.up.sql @@ -0,0 +1,6 @@ +-- +-- Normally `river_job.tags` is set to `NOT NULL` with a `DEFAULT` here, but since +-- SQLite was added well after 003 came about, we push that to version 006 index. +-- + +SELECT 1; diff --git a/ruby/migration/sqlite/main/004_pending_and_more.down.sql b/ruby/migration/sqlite/main/004_pending_and_more.down.sql new file mode 100644 index 000000000..c64554441 --- /dev/null +++ b/ruby/migration/sqlite/main/004_pending_and_more.down.sql @@ -0,0 +1,26 @@ +-- +-- Normally, args and metadata both become `NOT NULL`, `pending` is added, and +-- the constraint `finalized_at` is changed, but because SQLite was added later, +-- we've just pushed all of this to an initial `river_job` creation in 006. +-- + +-- +-- Drop `river_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_queue; + +-- +-- Reverse changes to `river_leader`. +-- + +DROP TABLE /* TEMPLATE: schema */river_leader; + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL, + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); \ No newline at end of file diff --git a/ruby/migration/sqlite/main/004_pending_and_more.up.sql b/ruby/migration/sqlite/main/004_pending_and_more.up.sql new file mode 100644 index 000000000..254e1f7a3 --- /dev/null +++ b/ruby/migration/sqlite/main/004_pending_and_more.up.sql @@ -0,0 +1,33 @@ +-- +-- Normally, args and metadata both become `NOT NULL`, `pending` is added, and +-- the constraint `finalized_at` is changed, but because SQLite was added later, +-- we've just pushed all of this to an initial `river_job` creation in 006. +-- + +-- +-- Create table `river_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL +); + +-- +-- Alter `river_leader` to add a default value of 'default` to `name`. SQLite +-- doesn't allow schema modifications, so this redefines the table entirely. +-- + +DROP TABLE /* TEMPLATE: schema */river_leader; + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL DEFAULT 'default' CHECK (name = 'default'), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); \ No newline at end of file diff --git a/ruby/migration/sqlite/main/005_migration_unique_client.down.sql b/ruby/migration/sqlite/main/005_migration_unique_client.down.sql new file mode 100644 index 000000000..d94787d3f --- /dev/null +++ b/ruby/migration/sqlite/main/005_migration_unique_client.down.sql @@ -0,0 +1,37 @@ +-- +-- Revert to migration table based only on `(version)`. +-- +-- If any non-main migrations are present, 005 is considered irreversible. +-- + +ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + +CREATE TABLE /* TEMPLATE: schema */river_migration ( + id integer PRIMARY KEY, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + version integer NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_migration_version_idx ON river_migration (version); + +INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, version) +SELECT created_at, version +FROM /* TEMPLATE: schema */river_migration_old; + +DROP TABLE /* TEMPLATE: schema */river_migration_old; + +-- +-- Normally, `unique_key` and an index are added here, but because SQLite was +-- added later, we've just pushed all of this to an initial `river_job` creation +-- in 006. +-- + +-- +-- Drop `river_client` and derivative. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; diff --git a/ruby/migration/sqlite/main/005_migration_unique_client.up.sql b/ruby/migration/sqlite/main/005_migration_unique_client.up.sql new file mode 100644 index 000000000..dc3273349 --- /dev/null +++ b/ruby/migration/sqlite/main/005_migration_unique_client.up.sql @@ -0,0 +1,64 @@ +-- +-- Rebuild the migration table so it's based on `(line, version)`. +-- + +DROP INDEX /* TEMPLATE: schema */river_migration_version_idx; + +ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + +CREATE TABLE /* TEMPLATE: schema */river_migration ( + line text NOT NULL, + version integer NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT line_length CHECK (length(line) > 0 AND length(line) < 128), + CONSTRAINT version_gte_1 CHECK (version >= 1), + PRIMARY KEY (line, version) +); + +INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, line, version) +SELECT created_at, 'main', version +FROM /* TEMPLATE: schema */river_migration_old; + +DROP TABLE /* TEMPLATE: schema */river_migration_old; + +-- +-- Normally, `unique_key` and an index are added here, but because SQLite was +-- added later, we've just pushed all of this to an initial `river_job` creation +-- in 006. +-- + +-- +-- Create `river_client` and derivative. +-- +-- This feature hasn't quite yet been implemented, but we're taking advantage of +-- the migration to add the schema early so that we can add it later without an +-- additional migration. +-- + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +-- Differs from `river_queue` in that it tracks the queue state for a particular +-- active client. +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (json('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); \ No newline at end of file diff --git a/ruby/migration/sqlite/main/006_bulk_unique.down.sql b/ruby/migration/sqlite/main/006_bulk_unique.down.sql new file mode 100644 index 000000000..a8d273f84 --- /dev/null +++ b/ruby/migration/sqlite/main/006_bulk_unique.down.sql @@ -0,0 +1,7 @@ +DROP TABLE /* TEMPLATE: schema */river_job; + +-- Dummy `river_job` table so that there's something to truncate in tests when +-- migrated to this version specifically. +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY +); diff --git a/ruby/migration/sqlite/main/006_bulk_unique.up.sql b/ruby/migration/sqlite/main/006_bulk_unique.up.sql new file mode 100644 index 000000000..528a4680e --- /dev/null +++ b/ruby/migration/sqlite/main/006_bulk_unique.up.sql @@ -0,0 +1,63 @@ +-- Only drops the trivial `river_job` we created in 002 which puts a placeholder +-- in place so that the right tables exist in the right versions. We don't +-- bother migrating any job data because it's not possible to have had any real +-- jobs by that point because this version (006) preexists the addition of SQLite. +DROP TABLE /* TEMPLATE: schema */river_job; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT '{}', + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (json('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (json('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +-- All these indexes are normally brought up in version 002. +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); + +-- Not raised because SQLite doesn't support Gin indexes. These aren't used in +-- River anyway. +-- CREATE INDEX river_job_args_index ON /* TEMPLATE: schema */river_job USING GIN(args); +-- CREATE INDEX river_job_metadata_index ON /* TEMPLATE: schema */river_job USING GIN(metadata); + +-- SQLite doesn't support SQL functions, so where the bit extraction logic below +-- goes in the `river_job_state_in_bitmask` function in Postgres, here it's +-- baked right into the index. Use of helpers that don't exist in SQLite like +-- `get_bit` are also dropped by necessity. +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/ruby/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql b/ruby/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql new file mode 100644 index 000000000..1e3bcffb0 --- /dev/null +++ b/ruby/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql @@ -0,0 +1,255 @@ +-- +-- SQL cleanup rollback. +-- + +-- +-- Add back unused tables `river_client` and `river_client_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +-- +-- SQLite JSONB conversion rollback. +-- +-- Convert JSONB binary columns back to JSON text format and restore json() +-- defaults. The `river_job` rebuild also reverts the addition of `DEFAULT 25` +-- to `river_job.max_attempts`. +-- +-- SQLite doesn't allow `ALTER TABLE ADD COLUMN` with non-constant defaults like +-- `json('{}')`, so rebuild each affected table instead. +-- + +-- +-- river_job +-- + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT '{}', + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (json('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (json('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + json(args), + attempt, + attempted_at, + CASE WHEN attempted_by IS NULL THEN NULL ELSE json(attempted_by) END, + created_at, + CASE WHEN errors IS NULL THEN NULL ELSE json(errors) END, + finalized_at, + kind, + max_attempts, + json(metadata), + priority, + queue, + state, + scheduled_at, + json(tags), + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; + +-- +-- river_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue RENAME TO river_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL +); + +INSERT INTO /* TEMPLATE: schema */river_queue ( + name, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + name, + created_at, + json(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_queue_old; + +-- +-- river_client +-- + +ALTER TABLE /* TEMPLATE: schema */river_client RENAME TO river_client_old; + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +INSERT INTO /* TEMPLATE: schema */river_client ( + id, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + id, + created_at, + json(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_client_old; + +-- +-- river_client_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_client_queue RENAME TO river_client_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (json('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +INSERT INTO /* TEMPLATE: schema */river_client_queue ( + river_client_id, + name, + created_at, + max_workers, + metadata, + num_jobs_completed, + num_jobs_running, + updated_at +) +SELECT + river_client_id, + name, + created_at, + max_workers, + json(metadata), + num_jobs_completed, + num_jobs_running, + updated_at +FROM /* TEMPLATE: schema */river_client_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_client_queue_old; +DROP TABLE /* TEMPLATE: schema */river_client_old; + +-- +-- Notification outbox rollback. +-- + +DROP TABLE /* TEMPLATE: schema */river_notification; diff --git a/ruby/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql b/ruby/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql new file mode 100644 index 000000000..b1ca9479e --- /dev/null +++ b/ruby/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql @@ -0,0 +1,261 @@ +-- +-- Notification outbox. +-- + +CREATE TABLE /* TEMPLATE: schema */river_notification ( + id integer PRIMARY KEY AUTOINCREMENT, + created_at timestamp NOT NULL DEFAULT (datetime('now', 'subsec')), + payload text NOT NULL, + topic text NOT NULL, + CONSTRAINT topic_length CHECK (length(topic) > 0 AND length(topic) < 128) +); + +CREATE INDEX /* TEMPLATE: schema */river_notification_created_at_idx ON river_notification (created_at); +CREATE INDEX /* TEMPLATE: schema */river_notification_topic_id_idx ON river_notification (topic, id); + +-- +-- SQLite JSONB conversion. +-- +-- Convert JSON text columns to JSONB binary format for more efficient storage +-- and processing, and update column defaults from json() to jsonb(). +-- +-- SQLite doesn't allow `ALTER TABLE ADD COLUMN` with non-constant defaults like +-- `jsonb('{}')`, so rebuild each affected table instead. +-- + +-- +-- river_job +-- + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + jsonb(args), + attempt, + attempted_at, + CASE WHEN attempted_by IS NULL THEN NULL ELSE jsonb(attempted_by) END, + created_at, + CASE WHEN errors IS NULL THEN NULL ELSE jsonb(errors) END, + finalized_at, + kind, + max_attempts, + jsonb(metadata), + priority, + queue, + state, + scheduled_at, + jsonb(tags), + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; + +-- +-- river_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue RENAME TO river_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO /* TEMPLATE: schema */river_queue ( + name, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + name, + created_at, + jsonb(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_queue_old; + +-- +-- river_client +-- + +ALTER TABLE /* TEMPLATE: schema */river_client RENAME TO river_client_old; + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +INSERT INTO /* TEMPLATE: schema */river_client ( + id, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + id, + created_at, + jsonb(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_client_old; + +-- +-- river_client_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_client_queue RENAME TO river_client_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +INSERT INTO /* TEMPLATE: schema */river_client_queue ( + river_client_id, + name, + created_at, + max_workers, + metadata, + num_jobs_completed, + num_jobs_running, + updated_at +) +SELECT + river_client_id, + name, + created_at, + max_workers, + jsonb(metadata), + num_jobs_completed, + num_jobs_running, + updated_at +FROM /* TEMPLATE: schema */river_client_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_client_queue_old; +DROP TABLE /* TEMPLATE: schema */river_client_old; + +-- +-- SQL cleanup. +-- + +-- +-- Drop unused tables `river_client` and `river_client_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; + +-- +-- Adds `DEFAULT 25` to `river_job.max_attempts`. +-- + +-- This may look odd in that we're adding a brand new column, but it's because +-- SQLite doesn't support anything beyond the most trivial DDL. + +ALTER TABLE /* TEMPLATE: schema */river_job + RENAME COLUMN max_attempts TO max_attempts_old; + +ALTER TABLE /* TEMPLATE: schema */river_job + ADD COLUMN max_attempts integer NOT NULL DEFAULT 25; + +UPDATE /* TEMPLATE: schema */river_job +SET max_attempts = max_attempts_old; + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN max_attempts_old; diff --git a/ruby/migration/sqlite/main/008_job_id_autoincrement.down.sql b/ruby/migration/sqlite/main/008_job_id_autoincrement.down.sql new file mode 100644 index 000000000..aad4f366b --- /dev/null +++ b/ruby/migration/sqlite/main/008_job_id_autoincrement.down.sql @@ -0,0 +1,121 @@ +-- Rebuild river_job to restore SQLite's default ROWID allocation behavior. + +-- Rebuilding river_job would discard schema installed by River Pro. Check +-- schema objects instead of migration records to also catch manually applied +-- Pro migrations and the legacy workflow migration line. +CREATE TEMP TABLE river_job_pro_schema_guard ( + id integer NOT NULL +); + +CREATE TEMP TRIGGER river_job_pro_schema_guard_enforce + BEFORE INSERT ON river_job_pro_schema_guard + WHEN EXISTS ( + SELECT 1 + FROM /* TEMPLATE: schema */sqlite_master + WHERE name IN ('river_job_sequence', 'river_job_workflow_scheduling', 'river_workflow') + ) +BEGIN + SELECT RAISE(ABORT, 'River SQLite migration 008 cannot run while River Pro schema is installed'); +END; + +INSERT INTO river_job_pro_schema_guard (id) VALUES (1); + +DROP TRIGGER river_job_pro_schema_guard_enforce; +DROP TABLE river_job_pro_schema_guard; + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL DEFAULT 25, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/ruby/migration/sqlite/main/008_job_id_autoincrement.up.sql b/ruby/migration/sqlite/main/008_job_id_autoincrement.up.sql new file mode 100644 index 000000000..c7de15deb --- /dev/null +++ b/ruby/migration/sqlite/main/008_job_id_autoincrement.up.sql @@ -0,0 +1,123 @@ +-- Rebuild river_job so automatically generated IDs are never reused after the +-- job holding the largest ID is deleted. Unlike PostgreSQL sequences, SQLite's +-- default ROWID allocator may otherwise reuse that deleted ID. + +-- Rebuilding river_job would discard schema installed by River Pro. Check +-- schema objects instead of migration records to also catch manually applied +-- Pro migrations and the legacy workflow migration line. +CREATE TEMP TABLE river_job_pro_schema_guard ( + id integer NOT NULL +); + +CREATE TEMP TRIGGER river_job_pro_schema_guard_enforce + BEFORE INSERT ON river_job_pro_schema_guard + WHEN EXISTS ( + SELECT 1 + FROM /* TEMPLATE: schema */sqlite_master + WHERE name IN ('river_job_sequence', 'river_job_workflow_scheduling', 'river_workflow') + ) +BEGIN + SELECT RAISE(ABORT, 'River SQLite migration 008 cannot run while River Pro schema is installed'); +END; + +INSERT INTO river_job_pro_schema_guard (id) VALUES (1); + +DROP TRIGGER river_job_pro_schema_guard_enforce; +DROP TABLE river_job_pro_schema_guard; + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY AUTOINCREMENT, + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL DEFAULT 25, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/ruby/rails/riverqueue-rails/Gemfile b/ruby/rails/riverqueue-rails/Gemfile new file mode 100644 index 000000000..88e1d7875 --- /dev/null +++ b/ruby/rails/riverqueue-rails/Gemfile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +gemspec + +gem "riverqueue", path: "../.." +gem "riverqueue-activerecord", path: "../../driver/riverqueue-activerecord" +%w[actionmailer activejob activerecord railties].each do |name| + gem name, ENV.fetch("RAILS_VERSION", ">= 7.2"), "< 8.2" +end +gem "pg" +gem "rspec-core" +gem "rspec-expectations" +gem "sqlite3" +gem "standard" diff --git a/ruby/rails/riverqueue-rails/Gemfile.lock b/ruby/rails/riverqueue-rails/Gemfile.lock new file mode 100644 index 000000000..bce2047de --- /dev/null +++ b/ruby/rails/riverqueue-rails/Gemfile.lock @@ -0,0 +1,379 @@ +PATH + remote: ../../driver/riverqueue-activerecord + specs: + riverqueue-activerecord (0.12.0) + activerecord (> 0, < 1000) + activesupport (> 0, < 1000) + riverqueue (= 0.12.0) + +PATH + remote: ../.. + specs: + riverqueue (0.12.0) + logger (> 0, < 1000) + optparse (> 0, < 1000) + securerandom (> 0, < 1000) + timeout (> 0, < 1000) + +PATH + remote: . + specs: + riverqueue-rails (0.12.0) + activejob (>= 7.2, < 8.2) + railties (>= 7.2, < 8.2) + riverqueue-activerecord (= 0.12.0) + +GEM + remote: https://rubygems.org/ + specs: + actionmailer (8.1.3.1) + actionpack (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3.1) + actionview (= 8.1.3.1) + activesupport (= 8.1.3.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actionview (8.1.3.1) + activesupport (= 8.1.3.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.3.6) + activemodel (8.1.3.1) + activesupport (= 8.1.3.1) + activerecord (8.1.3.1) + activemodel (= 8.1.3.1) + activesupport (= 8.1.3.1) + timeout (>= 0.4.0) + activesupport (8.1.3.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.2) + builder (3.3.0) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + date (3.5.1) + diff-lcs (1.6.2) + drb (2.2.3) + erb (6.0.7) + erubi (1.13.1) + globalid (1.4.0) + activesupport (>= 6.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.21.2) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + mini_mime (1.1.5) + minitest (5.27.0) + net-imap (0.6.7) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-smtp (0.5.1) + net-protocol + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + optparse (0.8.1) + parallel (2.2.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + racc (1.8.1) + rack (3.2.7) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.7.0) + io-console (~> 0.5) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.88.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + sqlite3 (2.9.6-aarch64-linux-gnu) + sqlite3 (2.9.6-aarch64-linux-musl) + sqlite3 (2.9.6-arm-linux-gnu) + sqlite3 (2.9.6-arm-linux-musl) + sqlite3 (2.9.6-arm64-darwin) + sqlite3 (2.9.6-x86_64-darwin) + sqlite3 (2.9.6-x86_64-linux-gnu) + sqlite3 (2.9.6-x86_64-linux-musl) + standard (1.56.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.88.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + thor (1.5.0) + timeout (0.6.1) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + actionmailer (>= 7.2, < 8.2) + activejob (>= 7.2, < 8.2) + activerecord (>= 7.2, < 8.2) + pg + railties (>= 7.2, < 8.2) + riverqueue! + riverqueue-activerecord! + riverqueue-rails! + rspec-core + rspec-expectations + sqlite3 + standard + +CHECKSUMS + actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c + actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 + actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a + activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 + activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b + activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + net-imap (0.6.7) sha256=b5c9573be975d856de252ee851871724da66aa2d449b2482d5690bd12bc23660 + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + riverqueue (0.12.0) + riverqueue-activerecord (0.12.0) + riverqueue-rails (0.12.0) + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c + sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9 + sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec + sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33 + sqlite3 (2.9.6-arm64-darwin) sha256=849b5d7f795e60fe25076d62c72dd722beb45b3850b516ad978d60ee848ec15b + sqlite3 (2.9.6-x86_64-darwin) sha256=b5842fea77781c14da03fa7bc0feb82db03a69e135affcb6f5399cbd2797a5f3 + sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634 + sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf + standard (1.56.0) sha256=ae2af4d9669589162ac69ed5ef59dcf9f346d4afc81f7e62b84339310dfcb787 + standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b + standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + +BUNDLED WITH + 4.0.9 diff --git a/ruby/rails/riverqueue-rails/README.md b/ruby/rails/riverqueue-rails/README.md new file mode 100644 index 000000000..5f878506d --- /dev/null +++ b/ruby/rails/riverqueue-rails/README.md @@ -0,0 +1,191 @@ +# River for Rails + +`riverqueue-rails` integrates Active Job with River's existing PostgreSQL/SQLite +runtime. It supports Rails 7.2, 8.0, and 8.1 and lives separately from the +Rails-independent core gem. Database gems remain application-selected. + +## Install + +```ruby +gem "riverqueue-rails" +gem "pg" # Or sqlite3. +``` + +```sh +bin/rails generate river:install +bin/rails river:migrate +bin/jobs start +``` + +The generator installs `config/initializers/river.rb` and `bin/jobs`. Migration +tasks use the canonical bundled Go SQL through `River::Migrator`; no separate +Rails schema is created. `bin/rails river:status` shows migration status. +Migrate explicitly during deployment, not on every application boot. + +## Configuration + +```ruby +Rails.application.configure do + config.active_job.queue_adapter = :river unless Rails.env.test? + config.river.stop_timeout = 30 + + config.river.configure do + River::Config.new( + job_timeout: 300, + logger: Rails.logger, + queues: {default: 10, mailers: 5} + ) + end +end +``` + +The block runs lazily after application boot and again when constructing a +consumer. It must return a fresh `River::Config`. The integration registers the +reserved `active_job` worker and installs Rails execution wrapping automatically. +Web processes only insert; `river worker --rails` or `bin/jobs start` starts consumers. Configure all +actual queue names, including Active Job queue prefixes and Action Mailer queues. +Counts are per queue, per process. Size the database pool for application work, +producers, and maintenance as well as consumer threads. + +Clients are rebuilt after a fork; Rails/the process server remains responsible +for its connection-pool fork lifecycle. Do not share clients across Ractors. + +## Dedicated worker process + +From the Rails application root: + +```sh +RAILS_ENV=production bundle exec river worker --rails +# Override config.river.stop_timeout for this process: +RAILS_ENV=production bundle exec river worker --rails --stop-timeout 60 +``` + +The command boots Rails and builds a consumer using the configuration above. +Do not start it in a web initializer. Migrate before starting consumers, and use +an external supervisor for process counts and restarts. + +TERM/INT request stop and drain active attempts. At the graceful deadline, +the runner interrupts attempts and allows five seconds for finalization before +forcing exit. TSTP requests `stop(wait: false)` but keeps the process alive until +a later TERM/INT. Successfully finalized interruptions make jobs available again; +forced termination may leave jobs for stuck-job recovery. Give the supervisor a +termination window longer than the grace period plus finalization and a margin. + +See [Dedicated worker processes](../../docs/workers.md) for signal behavior, +exit statuses, pool sizing, and rolling replacements. The generated `bin/jobs +start` delegates to the same runner; CLI options belong to `river worker`. + +## Jobs and mail + +```ruby +class FulfillOrderJob < ApplicationJob + queue_as :default + retry_on PaymentGateway::Unavailable, attempts: 5, wait: 30.seconds + discard_on ActiveJob::DeserializationError + + def perform(order) + FulfillOrder.call(order) + end +end + +FulfillOrderJob.perform_later order +FulfillOrderJob.set(wait: 10.minutes).perform_later order +OrderMailer.receipt(order).deliver_later +``` + +Active Job handles serialization, GlobalID, callbacks, locale, timezone, and +custom serializers. Bulk `ActiveJob.perform_all_later` uses River's atomic bulk +insertion. Priorities must be `nil` (River priority 1) or integers 1 through 4. +Unsupported priorities raise instead of silently changing meaning. + +## Transactions + +The default connection class is `ActiveRecord::Base`. Select a different abstract +Active Record class for producers, consumers, and migration tasks together: + +```ruby +Rails.application.configure do + config.river.connection_class = "ApplicationRecord" +end +``` + +Prefer a class name in Rails initializers: it is resolved lazily after boot and +the insertion client is rebuilt if Rails reloads the class. An actual class +object is also accepted. Configure its database through ordinary Active Record +`establish_connection` or `connects_to` configuration. Restart consumers after +changing connection configuration. + +Without the Rails integration, select the class directly on the driver: + +```ruby +River::Driver::ActiveRecord.new(connection_class: ApplicationRecord) +``` + +Insert jobs in the selected connection's transaction for atomicity. Sharing a +database URL alone does not provide atomicity across connections. A dedicated +queue connection class/database is supported, but does not commit atomically +with application writes on another connection. + +Shard routing and worker management remain explicit. Inserts follow the selected +class's current Active Record role/shard; consumers need their own appropriate +connection configuration. A request's `connected_to` block is not propagated to +worker threads. Use the same database backend/schema layout across roles/shards +accessed by a driver; no automatic shard discovery or cross-shard transactions +are provided. + +For Rails 8.x, disable after-commit deferral explicitly when relying on atomicity: + +```ruby +class ApplicationJob < ActiveJob::Base + self.enqueue_after_transaction_commit = false +end + +ApplicationRecord.transaction do + order = Order.create!(status: "pending") + FulfillOrderJob.perform_later(order) +end +``` + +The Rails 7.2 adapter default is immediate insertion; its explicit policies use +`:never` and `:always` instead of Rails 8's `false` and `true`. Explicit application +after-commit policies are respected on every supported version, but lose the +atomic-write guarantee. A returned provider ID does not prove an outer transaction +has committed. Job completion and business writes are not automatically atomic. + +## Retries and observability + +Active Job owns application retries. `retry_on` creates a new River row with the +same Active Job UUID, while `provider_job_id` becomes the new River row ID string. +Unhandled errors, including exhausted `retry_on`, discard the current row without +an additional River retry cycle. Native River workers retain their normal policy. +The 25-attempt River budget is retained for crash recovery; stuck claims are +rescued by normal River maintenance (currently after an hour). + +River interruption, cancellation, and snooze exceptions bypass Active Job's +retry/discard handlers, including `retry_on StandardError`. Avoid swallowing +these exceptions in broad Ruby `rescue` blocks inside application code. + +Handled retries/discards complete the current delivery row. Its metadata records +`active_job_outcome` (`retried` or `discarded`), and retried deliveries record +`active_job_retry_id`. A completed delivery need not mean the logical job succeeded. +Active Job logging and notifications remain available. Rows store a versioned +Active Job envelope under the reserved `active_job` kind; Go can share the schema, +but Ruby must execute these jobs. Use native River kinds for cross-language jobs. + +Retry enqueueing and predecessor finalization are not atomic. Crashes can cause +duplicates; jobs must remain idempotent. Rails' executor/reloader wraps each work +attempt and periodic constructor, cleaning context and connections. Active Job +classes are resolved per execution. Restart workers when changing native River +worker/plugin registrations or periodic configuration; these retain Ruby objects. + +## Testing + +Keep Rails' `:test` adapter and `ActiveJob::TestHelper` for ordinary application +tests. Use `:river` and a migrated isolated database for persistence/runtime +integration tests. Disable transactional fixtures for threaded worker tests. +The repository's `make test` includes this gem's PostgreSQL and SQLite tests. +`RIVER_REQUIRE_DATABASES=1 make test` makes missing PostgreSQL an error. + +Outside a Rails app, instantiate `ActiveJob::QueueAdapters::RiverAdapter.new(client:)` +and register `River::Rails::Worker` on your consuming River client. Rails-specific +boot, configuration, and execution wrapping are then your responsibility. diff --git a/ruby/rails/riverqueue-rails/lib/active_job/queue_adapters/river_adapter.rb b/ruby/rails/riverqueue-rails/lib/active_job/queue_adapters/river_adapter.rb new file mode 100644 index 000000000..22d8225ab --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/active_job/queue_adapters/river_adapter.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module ActiveJob + module QueueAdapters + # Persists Active Job envelopes using the application's River client. + class RiverAdapter < AbstractAdapter + # An explicit client also allows using the adapter without a Rails app. + def initialize(client: nil) + @client = client + end + + # Inserts an immediate Active Job delivery. + def enqueue(job) + enqueue_at(job, nil) + end + + # Inserts the whole batch atomically and assigns provider IDs on success. + def enqueue_all(jobs) + results = client.insert_many(jobs.map { |job| params(job, job.scheduled_at) }) + jobs.zip(results).each do |job, result| + job.provider_job_id = result.job.id.to_s + job.successfully_enqueued = true + record_retry(job) + end + + results.length + end + + # Inserts a delivery scheduled at an epoch timestamp (nil means now). + def enqueue_at(job, timestamp) + insertion = params(job, timestamp && Time.at(timestamp).utc) + result = client.insert(insertion.args, insert_opts: insertion.insert_opts) + job.provider_job_id = result.job.id.to_s + record_retry(job) + result + end + + # Rails 7.2 adapter default: preserve same-connection atomic insertion. + def enqueue_after_transaction_commit? = false + + private def client + @client || ::Rails.application.config.river.client + end + + private def params(job, scheduled_at) + priority = job.priority + valid_priority = priority.nil? || (priority.is_a?(Integer) && (1..4).cover?(priority)) + unless valid_priority + raise ArgumentError, "River Active Job priority must be nil or an integer from 1 to 4" + end + + River::InsertManyParams.new( + River::JobArgsHash.new("active_job", {"job" => job.serialize, "version" => 1}), + max_attempts: 25, priority: priority || 1, + queue: job.queue_name, scheduled_at: scheduled_at + ) + end + + private def record_retry(job) + current = ActiveSupport::IsolatedExecutionState[:river_active_job] + if current && current.args.fetch("job").fetch("job_id") == job.job_id + current.update_metadata("active_job_outcome" => "retried", "active_job_retry_id" => job.provider_job_id) + end + end + end + end +end diff --git a/ruby/rails/riverqueue-rails/lib/generators/river/install_generator.rb b/ruby/rails/riverqueue-rails/lib/generators/river/install_generator.rb new file mode 100644 index 000000000..2726a4fd9 --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/generators/river/install_generator.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails/generators" + +module River + module Generators + # Installs configuration and the explicitly started worker entry point. + class InstallGenerator < ::Rails::Generators::Base + source_root File.expand_path("templates", __dir__) + + def install + copy_file "river.rb", "config/initializers/river.rb" + copy_file "jobs", "bin/jobs" + chmod "bin/jobs", 0o755 + end + end + end +end diff --git a/ruby/rails/riverqueue-rails/lib/generators/river/templates/jobs b/ruby/rails/riverqueue-rails/lib/generators/river/templates/jobs new file mode 100644 index 000000000..b7ef8d96a --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/generators/river/templates/jobs @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../config/environment" +require "riverqueue-rails" + +abort "Usage: bin/jobs start" unless ARGV == ["start"] +exit River::Rails::Runner.start diff --git a/ruby/rails/riverqueue-rails/lib/generators/river/templates/river.rb b/ruby/rails/riverqueue-rails/lib/generators/river/templates/river.rb new file mode 100644 index 000000000..e805ac728 --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/generators/river/templates/river.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +Rails.application.configure do + # Optional: select an abstract connection class by name, resolved after boot. + # config.river.connection_class = "ApplicationRecord" + config.active_job.queue_adapter = :river unless Rails.env.test? + + config.river.configure do + River::Config.new( + job_timeout: 300, + logger: Rails.logger, + queues: {default: 10, mailers: 5} + ) + end +end + +# For atomic enqueueing on the same Active Record connection, set +# self.enqueue_after_transaction_commit = false in ApplicationJob (Rails 8.x). +# Explicit after-commit deferral is respected, but is not an atomic SQL write. diff --git a/ruby/rails/riverqueue-rails/lib/river/rails/configuration.rb b/ruby/rails/riverqueue-rails/lib/river/rails/configuration.rb new file mode 100644 index 000000000..4d578c0ae --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/river/rails/configuration.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module River + module Rails + # Application-owned configuration and per-process insertion client. + class Configuration + # Seconds allowed for graceful termination before attempts are interrupted. + attr_accessor :stop_timeout + + def initialize + @connection_class = "ActiveRecord::Base" + @factory = -> { River::Config.new(logger: ::Rails.logger, queues: {"default" => 10}) } + @mutex = Mutex.new + @pid = Process.pid + @stop_timeout = 30 + end + + # Builds a consumer. Called after Rails boot, never in a web initializer. + def build_client + core = @factory.call + workers = River::Workers.new + core.workers.kinds.each { |kind| workers.add(kind, core.workers.fetch(kind)) } + workers.add(Worker) + periodic = core.periodic_jobs.map do |job| + River::PeriodicJob.new(id: job.id, + constructor: -> { ::Rails.application.reloader.wrap { job.constructor.call } }, + run_on_start: job.run_on_start, schedule: job.schedule) + end + + River::Client.new(build_driver, + config: core.with(periodic_jobs: periodic, plugins: [ExecutionPlugin.new] + core.plugins, workers: workers)) + end + + # Builds a driver for the configured connection class, including migrations. + def build_driver + River::Driver::ActiveRecord.new(connection_class: connection_class) + end + + # Returns a lazy insertion-only client for this process. + def client + if @pid != Process.pid + @client = nil + @mutex = Mutex.new + @pid = Process.pid + end + + @mutex.synchronize do + klass = connection_class + @client = nil if @client && !@client.driver.connection_class.equal?(klass) + + @client ||= River::Client.new(River::Driver::ActiveRecord.new(connection_class: klass), + config: @factory.call.with(periodic_jobs: [], queues: {})) + end + end + + # Resolves names lazily so Rails can reload application connection classes. + def connection_class + @connection_class.is_a?(String) ? @connection_class.constantize : @connection_class + end + + # Selects an abstract Active Record class or its name. Prefer a name in + # initializers to avoid retaining a reloadable class object. + def connection_class=(value) + @mutex.synchronize do + @connection_class = value + @client = nil + end + end + + # Supplies a block returning River::Config after application boot. + def configure(&block) + raise ArgumentError, "configuration block required" unless block + + @factory = block + @client = nil + end + end + + # Wraps each attempt in Rails' reload-safe execution boundary. + class ExecutionPlugin + def work(_job, operation) + ::Rails.application.reloader.wrap { operation.call } + end + end + end +end diff --git a/ruby/rails/riverqueue-rails/lib/river/rails/railtie.rb b/ruby/rails/riverqueue-rails/lib/river/rails/railtie.rb new file mode 100644 index 000000000..b62a46c31 --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/river/rails/railtie.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails/railtie" + +module River + module Rails + class Railtie < ::Rails::Railtie + config.river = Configuration.new + + rake_tasks do + namespace :river do + desc "Apply River's canonical database migrations" + task migrate: :environment do + River::Migrator.new(::Rails.application.config.river.build_driver).migrate + end + + desc "Show River migration status" + task status: :environment do + River::Migrator.new(::Rails.application.config.river.build_driver).status.each do |migration| + puts "#{migration.applied ? "applied" : "pending"} #{migration.version} #{migration.name}" + end + end + end + end + end + end +end diff --git a/ruby/rails/riverqueue-rails/lib/river/rails/runner.rb b/ruby/rails/riverqueue-rails/lib/river/rails/runner.rb new file mode 100644 index 000000000..5124035bb --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/river/rails/runner.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module River + module Rails + # Runs one worker process; an external supervisor owns process restarts. + class Runner + # Boot Rails before calling. TERM/INT stop fetching and drain active jobs. + def self.start(out: $stdout, stop_timeout: nil) + config = ::Rails.application.config.river + River::WorkerRunner.new(config.build_client, out: out, + stop_timeout: stop_timeout || config.stop_timeout).run + end + end + end +end diff --git a/ruby/rails/riverqueue-rails/lib/river/rails/worker.rb b/ruby/rails/riverqueue-rails/lib/river/rails/worker.rb new file mode 100644 index 000000000..42f2926cf --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/river/rails/worker.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module River + module Rails + # Keep River's control-flow exceptions out of Active Job retry/discard + # handlers, including broad retry_on StandardError declarations. + module ExecutionControl + def rescue_with_handler(exception) + if ActiveSupport::IsolatedExecutionState[:river_active_job] && + [River::ClientRuntime::Interrupted, River::JobCancelError, River::JobSnoozeError].any? { |type| exception.is_a?(type) } + raise exception + end + + super + end + end + + ActiveJob::Base.prepend(ExecutionControl) + + # Dispatches serialized jobs through Active Job's own execution machinery. + class Worker + def self.kind = "active_job" + + # Active Job owns retries for reported errors; River still rescues crashes. + def retry?(_job, _error) = false + + def work(job) + previous = ActiveSupport::IsolatedExecutionState[:river_active_job] + ActiveSupport::IsolatedExecutionState[:river_active_job] = job + raise ArgumentError, "Unsupported Active Job envelope version" unless job.args.fetch("version") == 1 + + payload = job.args.fetch("job").merge("provider_job_id" => job.id.to_s) + ActiveJob::Base.execute(payload) + ensure + ActiveSupport::IsolatedExecutionState[:river_active_job] = previous + end + end + + ActiveSupport::Notifications.subscribe("discard.active_job") do |*, payload| + job = ActiveSupport::IsolatedExecutionState[:river_active_job] + if job && payload.fetch(:job).job_id == job.args.fetch("job").fetch("job_id") + job.update_metadata("active_job_outcome" => "discarded") + end + end + end +end diff --git a/ruby/rails/riverqueue-rails/lib/riverqueue-rails.rb b/ruby/rails/riverqueue-rails/lib/riverqueue-rails.rb new file mode 100644 index 000000000..6f075632a --- /dev/null +++ b/ruby/rails/riverqueue-rails/lib/riverqueue-rails.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "active_job" +require "riverqueue-activerecord" +require_relative "river/rails/configuration" +require_relative "river/rails/worker" +require_relative "river/rails/runner" +require_relative "active_job/queue_adapters/river_adapter" +require_relative "river/rails/railtie" diff --git a/ruby/rails/riverqueue-rails/riverqueue-rails.gemspec b/ruby/rails/riverqueue-rails/riverqueue-rails.gemspec new file mode 100644 index 000000000..b550a1f26 --- /dev/null +++ b/ruby/rails/riverqueue-rails/riverqueue-rails.gemspec @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Gem::Specification.new do |s| + s.name = "riverqueue-rails" + s.version = "0.12.0" + s.summary = "Active Job and Rails integration for River." + s.authors = ["Blake Gentry", "Brandur Leach"] + s.files = Dir.glob("lib/**/*") + ["README.md"] + s.homepage = "https://riverqueue.com" + s.license = "MPL-2.0" + s.required_ruby_version = ">= 3.2" + s.add_dependency "activejob", ">= 7.2", "< 8.2" + s.add_dependency "railties", ">= 7.2", "< 8.2" + s.add_dependency "riverqueue-activerecord", "= #{s.version}" +end diff --git a/ruby/rails/riverqueue-rails/spec/configuration_spec.rb b/ruby/rails/riverqueue-rails/spec/configuration_spec.rb new file mode 100644 index 000000000..12e48d166 --- /dev/null +++ b/ruby/rails/riverqueue-rails/spec/configuration_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "spec_helper" +require "generators/river/install_generator" + +RSpec.describe River::Rails::Configuration do + it "requires a configuration block" do + expect { described_class.new.configure }.to raise_error(ArgumentError, /block/) + end + + it "does not construct configuration until a client is requested" do + calls = 0 + settings = described_class.new + settings.configure { + calls += 1 + River::Config.new + } + + expect(calls).to eq(0) + expect(settings.client).to equal(settings.client) + expect(calls).to eq(1) + expect(settings.client.started?).to be false + end + + it "builds independent consumer registries without modifying the application registry" do + registry = River::Workers.new + settings = described_class.new + settings.configure { River::Config.new(workers: registry) } + + expect(settings.build_client).to be_a(River::Client) + expect(settings.build_client).to be_a(River::Client) + expect(registry.kinds).to be_empty + end + + it "rejects registration of the reserved Active Job kind" do + settings = described_class.new + settings.configure { River::Config.new(workers: River::Workers.new.add("active_job", Object.new)) } + + expect { settings.build_client }.to raise_error(ArgumentError, /already registered/) + end + + it "rebuilds the insertion client after a fork" do + settings = described_class.new + original = settings.client + reader, writer = IO.pipe + pid = fork do + reader.close + writer.write((!settings.client.equal?(original)).to_s) + writer.close + exit! 0 + end + + writer.close + + expect(reader.read).to eq("true") + Process.wait(pid) + reader.close + end +end + +RSpec.describe River::Generators::InstallGenerator do + it "installs a Ruby configuration and executable worker command" do + Dir.mktmpdir("river-generator-") do |directory| + described_class.start([], destination_root: directory, shell: Thor::Shell::Basic.new) + + expect(File.read(File.join(directory, "config/initializers/river.rb"))).to include("queue_adapter = :river") + command = File.join(directory, "bin/jobs") + + expect(File.executable?(command)).to be true + expect(File.read(command)).to include("River::Rails::Runner.start") + end + end +end diff --git a/ruby/rails/riverqueue-rails/spec/connection_class_spec.rb b/ruby/rails/riverqueue-rails/spec/connection_class_spec.rb new file mode 100644 index 000000000..e06fbc3ba --- /dev/null +++ b/ruby/rails/riverqueue-rails/spec/connection_class_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../spec/support/connection_class_test_database" + +class RailsSelectedConnection < ActiveRecord::Base + self.abstract_class = true +end + +class SelectedConnectionJob < ActiveJob::Base + self.enqueue_after_transaction_commit = (ActiveJob.gem_version >= Gem::Version.new("8.0")) ? false : :never + def perform = nil +end + +RSpec.describe "Rails connection class configuration", database: :sqlite do + around do |example| + ClientTestDatabase.with_active_record(:sqlite) do |primary| + ConnectionClassTestDatabase.with_class(RailsSelectedConnection, :sqlite) do |selected| + previous = Rails.application.config.river + @primary = primary + @selected = selected + @settings = River::Rails::Configuration.new + @settings.connection_class = "RailsSelectedConnection" + Rails.application.config.river = @settings + ActiveJob::Base.queue_adapter = :river + example.run + ensure + @consumer&.stop_and_cancel + Rails.application.config.river = previous + end + end + end + + it "routes migrations, producers, and consumers to the selected database" do + require "rake" + Rails.application.load_tasks unless Rake::Task.task_defined?("river:migrate") + + Rake::Task["river:migrate"].execute + + expect { Rake::Task["river:status"].execute }.to output(/applied 7/).to_stdout + expect(@settings.connection_class).to eq(RailsSelectedConnection) + SelectedConnectionJob.perform_later + @consumer = @settings.build_client.start + wait_until { @selected.job_list.first.state == "completed" } + + expect(@primary.job_list).to be_empty + end + + it "rolls back Active Job insertion with the selected transaction" do + River::Migrator.new(@settings.build_driver).migrate + RailsSelectedConnection.transaction do + SelectedConnectionJob.perform_later + + expect(@selected.job_list.length).to eq(1) + raise ActiveRecord::Rollback + end + + expect(@selected.job_list).to be_empty + end + + it "invalidates the insertion client when connection selection changes" do + original = @settings.client + @settings.connection_class = ActiveRecord::Base + + expect(@settings.client).not_to equal(original) + expect(@settings.client.driver.connection_class).to eq(ActiveRecord::Base) + end + + it "re-resolves a named class when Rails replaces its constant" do + original = @settings.client + old_class = RailsSelectedConnection + Object.send(:remove_const, :RailsSelectedConnection) + Object.const_set(:RailsSelectedConnection, Class.new(ActiveRecord::Base) { self.abstract_class = true }) + + expect(@settings.client).not_to equal(original) + expect(@settings.client.driver.connection_class).to equal(RailsSelectedConnection) + ensure + Object.send(:remove_const, :RailsSelectedConnection) + Object.const_set(:RailsSelectedConnection, old_class) + end +end diff --git a/ruby/rails/riverqueue-rails/spec/dummy/app/jobs/reloadable_job.rb b/ruby/rails/riverqueue-rails/spec/dummy/app/jobs/reloadable_job.rb new file mode 100644 index 000000000..57dbdeb5e --- /dev/null +++ b/ruby/rails/riverqueue-rails/spec/dummy/app/jobs/reloadable_job.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ReloadableJob < ActiveJob::Base + def perform + IntegrationJob.seen << self.class.object_id + end +end diff --git a/ruby/rails/riverqueue-rails/spec/dummy/config/database.yml b/ruby/rails/riverqueue-rails/spec/dummy/config/database.yml new file mode 100644 index 000000000..63c21ddc6 --- /dev/null +++ b/ruby/rails/riverqueue-rails/spec/dummy/config/database.yml @@ -0,0 +1,6 @@ +development: + adapter: sqlite3 + database: ":memory:" +test: + adapter: sqlite3 + database: ":memory:" diff --git a/ruby/rails/riverqueue-rails/spec/integration_spec.rb b/ruby/rails/riverqueue-rails/spec/integration_spec.rb new file mode 100644 index 000000000..6e8ae0717 --- /dev/null +++ b/ruby/rails/riverqueue-rails/spec/integration_spec.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require "spec_helper" + +class IntegrationRecord < ActiveRecord::Base + include GlobalID::Identification +end + +class IntegrationCurrent < ActiveSupport::CurrentAttributes + attribute :customer +end + +class IntegrationCallbackJob < ActiveJob::Base + before_perform { IntegrationJob.seen << :before } + after_perform { IntegrationJob.seen << :after } + + def perform + IntegrationJob.seen << [:work, IntegrationCurrent.customer] + IntegrationCurrent.customer = "must be cleared" + end +end + +class IntegrationJob < ActiveJob::Base + self.enqueue_after_transaction_commit = (ActiveJob.gem_version >= Gem::Version.new("8.0")) ? false : :never + class_attribute :seen, default: Queue.new + + def perform(value, flag: false) + self.class.seen << [value, flag, job_id, provider_job_id, I18n.locale, Time.zone&.name] + end +end + +class IntegrationRetryJob < IntegrationJob + retry_on ArgumentError, attempts: 2, wait: 0 + + def perform(*) + self.class.seen << executions + raise ArgumentError, "retry me" + end +end + +class IntegrationDiscardJob < IntegrationJob + discard_on ArgumentError + + def perform(*) + raise ArgumentError, "discard me" + end +end + +class IntegrationBrokenJob < IntegrationJob + def perform(*) + raise "unhandled" + end +end + +class IntegrationInterruptibleJob < IntegrationJob + retry_on StandardError, attempts: 3, wait: 0 + + def perform(action) + case action + when "cancel" + raise River.job_cancel("cancelled") + when "snooze" + raise River.job_snooze(60) + else + self.class.seen << :started + sleep 60 + end + end +end + +class IntegrationMailer < ActionMailer::Base + default from: "river@example.test" + + def receipt + mail(body: "Thanks", subject: "Receipt", to: "customer@example.test") + end +end + +adapters = RiverTestDatabase.enabled?(:sqlite) ? [:sqlite] : [] +if RiverTestDatabase.enabled?(:postgres) + begin + require "pg" + PG.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test").close + adapters << :postgres + rescue PG::Error => error + raise if ENV["CI"] == "true" || ENV["RIVER_REQUIRE_DATABASES"] == "1" + + warn "Skipping Rails PostgreSQL tests: #{error.message}" + end +end + +adapters.each do |backend| + RSpec.describe "Rails integration with #{backend}", database: backend do + around do |example| + ClientTestDatabase.with_active_record(backend) do |driver| + @driver = driver + previous = Rails.application.config.river + settings = River::Rails::Configuration.new + settings.configure do + River::Config.new(fetch_cooldown: 0.001, fetch_poll_interval: 0.01, + logger: Rails.logger, queues: {"default" => 3, "mailers" => 1}) + end + + Rails.application.config.river = settings + ActiveJob::Base.queue_adapter = :river + IntegrationJob.seen = Queue.new + ActionMailer::Base.deliveries.clear + example.run + ensure + @consumer&.stop_and_cancel + Rails.application.config.river = previous + end + end + + it "boots the adapter without starting consumers" do + expect(ActiveJob::Base.queue_adapter).to be_a(ActiveJob::QueueAdapters::RiverAdapter) + expect(Rails.application.config.river.client.started?).to be false + end + + it "resolves Active Job classes again after Rails reloads" do + old_class = ReloadableJob + ReloadableJob.perform_later + Rails.application.reloader.reload! + + expect(ReloadableJob).not_to equal(old_class) + work_until { rows.first.state == "completed" } + + expect(IntegrationJob.seen.pop).to eq(ReloadableJob.object_id) + end + + it "exposes canonical migration and status Rails tasks" do + require "rake" + Rails.application.load_tasks unless Rake::Task.task_defined?("river:migrate") + + expect { Rake::Task["river:migrate"].execute }.not_to raise_error + expect { Rake::Task["river:status"].execute }.to output(/applied 7/).to_stdout + end + + it "inserts and executes serialized keyword arguments, IDs, and context" do + job = Time.use_zone("Asia/Tokyo") { IntegrationJob.perform_later("hello", flag: true) } + + expect(rows.first).to have_attributes(kind: "active_job", max_attempts: 25, priority: 1, queue: "default") + work_until { rows.first.state == "completed" } + + expect(IntegrationJob.seen.pop).to eq(["hello", true, job.job_id, job.provider_job_id, :en, "Asia/Tokyo"]) + end + + it "inserts in the application's transaction and rolls back together" do + ActiveRecord::Base.transaction do + IntegrationJob.perform_later("rollback") + + expect(rows.length).to eq(1) + raise ActiveRecord::Rollback + end + + expect(rows).to be_empty + end + + it "preserves delayed execution timestamps" do + time = Time.now.utc + 3600 + IntegrationJob.set(wait_until: time).perform_later("later") + + expect(rows.first).to have_attributes(scheduled_at: be_within(0.001).of(time), state: "scheduled") + end + + it "respects an explicit after-commit policy" do + original = IntegrationJob.enqueue_after_transaction_commit + IntegrationJob.enqueue_after_transaction_commit = (ActiveJob.gem_version >= Gem::Version.new("8.0")) ? true : :always + ActiveRecord::Base.transaction do + IntegrationJob.perform_later("after commit") + expect(rows).to be_empty + end + + expect(rows.length).to eq(1) + ensure + IntegrationJob.enqueue_after_transaction_commit = original + end + + it "does not enqueue deferred work on rollback" do + original = IntegrationJob.enqueue_after_transaction_commit + IntegrationJob.enqueue_after_transaction_commit = (ActiveJob.gem_version >= Gem::Version.new("8.0")) ? true : :always + ActiveRecord::Base.transaction do + IntegrationJob.perform_later("rollback") + raise ActiveRecord::Rollback + end + + expect(rows).to be_empty + ensure + IntegrationJob.enqueue_after_transaction_commit = original + end + + it "preserves callbacks and resets CurrentAttributes after execution" do + IntegrationCallbackJob.perform_later + work_until { rows.first.state == "completed" } + + expect(3.times.map { IntegrationJob.seen.pop }).to eq([:before, [:work, nil], :after]) + expect(IntegrationCurrent.customer).to be_nil + end + + it "supports bulk enqueueing and provider IDs" do + jobs = [IntegrationJob.new("one"), IntegrationJob.new("two")] + ActiveJob.perform_all_later(jobs) + + expect(rows.length).to eq(2) + expect(jobs.map(&:provider_job_id)).to eq(rows.map { |row| row.id.to_s }) + expect(jobs.all?(&:successfully_enqueued?)).to be true + end + + it "preserves delayed timestamps in a bulk enqueue" do + job = IntegrationJob.new("later") + job.scheduled_at = Time.now.utc + 3600 + ActiveJob.perform_all_later([job]) + + expect(rows.first).to have_attributes(scheduled_at: be_within(0.001).of(job.scheduled_at), state: "scheduled") + end + + it "does not partially insert a batch with an invalid job" do + valid = IntegrationJob.new("valid") + invalid = IntegrationJob.new("invalid") + invalid.priority = 9 + + expect { ActiveJob.perform_all_later([valid, invalid]) }.to raise_error(ArgumentError) + expect(rows).to be_empty + expect(valid.provider_job_id).to be_nil + end + + it "rejects invalid priorities without silently clamping" do + expect { IntegrationJob.set(priority: 0).perform_later("bad") }.to raise_error(ArgumentError, /priority/) + expect(rows).to be_empty + end + + it "preserves supported priorities and explicit queues" do + IntegrationJob.set(priority: 4, queue: "custom").perform_later("low") + expect(rows.first).to have_attributes(priority: 4, queue: "custom") + end + + it "lets Active Job own retries without multiplying the retry budget" do + first = IntegrationRetryJob.perform_later + work_until do + # Promote the due Active Job retry without waiting for the maintenance + # loop's five-second tick. Both attempts still run in real workers. + @driver.job_schedule + rows.length == 2 && rows.any? { |row| row.state == "discarded" } + end + + expect(rows.map(&:state)).to eq(%w[completed discarded]) + expect(rows.map { |row| row.args.fetch("job").fetch("job_id") }.uniq).to eq([first.job_id]) + expect(rows.first.metadata.fetch("active_job_outcome")).to eq("retried") + expect(IntegrationJob.seen.size).to eq(2) + end + + it "records handled discard outcomes" do + IntegrationDiscardJob.perform_later + work_until { rows.first.state == "completed" } + + expect(rows.first.metadata.fetch("active_job_outcome")).to eq("discarded") + end + + it "discards unhandled errors immediately" do + IntegrationBrokenJob.perform_later + work_until { rows.first.state == "discarded" } + + expect(rows.first).to have_attributes(attempt: 1, state: "discarded") + end + + it "discards errors reported for externally claimed Active Jobs" do + IntegrationBrokenJob.perform_later + client = Rails.application.config.river.build_client + claimed = @driver.job_claim(id: rows.first.id, attempted_by: "external") + + client.__finish_claimed_job(claimed, RuntimeError.new("external work failed")) + + expect(rows).to contain_exactly(have_attributes( + attempt: 1, errors: contain_exactly(have_attributes(error: "external work failed")), state: "discarded" + )) + end + + it "recovers a claimed job after a simulated crash" do + IntegrationJob.perform_later("rescue") + @driver.job_get_available(attempted_by: "crashed", max: 1, queue: "default") + @driver.job_rescue_stuck(horizon: Time.now.utc + 1, retry_policy: River::DefaultClientRetryPolicy.new) + + expect(rows.first).to have_attributes(attempt: 1, state: "retryable") + end + + it "interrupts work without triggering a broad Active Job retry handler" do + IntegrationInterruptibleJob.perform_later("wait") + @consumer = Rails.application.config.river.build_client.start + wait_until { !IntegrationJob.seen.empty? } + @consumer.stop_and_cancel + + expect(rows.length).to eq(1) + expect(rows.first).to have_attributes(attempt: 0, state: "available") + end + + it "does not turn River cancellation into an Active Job retry" do + IntegrationInterruptibleJob.perform_later("cancel") + work_until { rows.first.state == "cancelled" } + + expect(rows.length).to eq(1) + end + + it "does not turn River snoozing into an Active Job retry" do + IntegrationInterruptibleJob.perform_later("snooze") + work_until { rows.first.state == "scheduled" } + + expect(rows.length).to eq(1) + expect(rows.first.attempt).to eq(0) + end + + it "deserializes GlobalIDs through Active Job" do + ActiveRecord::Base.connection.create_table(:integration_records) { |table| table.string :name } + IntegrationRecord.reset_column_information + record = IntegrationRecord.create!(name: "customer") + IntegrationJob.perform_later(record) + work_until { rows.first.state == "completed" } + + expect(IntegrationJob.seen.pop.first).to eq(record) + end + + it "discards missing GlobalIDs without an extra backend retry cycle" do + ActiveRecord::Base.connection.create_table(:integration_records) { |table| table.string :name } + IntegrationRecord.reset_column_information + record = IntegrationRecord.create!(name: "deleted") + IntegrationJob.perform_later(record) + record.destroy! + work_until { rows.first.state == "discarded" } + + expect(rows.first).to have_attributes(attempt: 1, state: "discarded") + end + + it "delivers Action Mailer jobs" do + IntegrationMailer.receipt.deliver_later + work_until { rows.first.state == "completed" } + + expect(ActionMailer::Base.deliveries.map(&:subject)).to eq(["Receipt"]) + end + + it "rejects unknown envelope versions" do + client = Rails.application.config.river.client + client.insert(River::JobArgsHash.new("active_job", {"job" => {}, "version" => 999})) + work_until { rows.first.state == "discarded" } + + expect(rows.first.errors.last.error).to include("Unsupported Active Job envelope") + end + end +end diff --git a/ruby/rails/riverqueue-rails/spec/runner_spec.rb b/ruby/rails/riverqueue-rails/spec/runner_spec.rb new file mode 100644 index 000000000..5d4a85035 --- /dev/null +++ b/ruby/rails/riverqueue-rails/spec/runner_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open3" + +RSpec.describe River::Rails::Runner do + ["TERM", "INT"].each do |signal| + [true, false].each do |drains| + it "handles #{signal} with #{drains ? "graceful draining" : "deadline cancellation"} and restores handlers" do + # Isolate actual OS signals from RSpec. The fake client keeps this test + # deterministic; database worker execution is exercised in integration_spec. + output, error, status = Open3.capture3(RbConfig.ruby, "-Ilib", "-e", <<~RUBY) + require "riverqueue" + require "river/rails/runner" + module Rails; end + require #{File.expand_path("../../../spec/support/runner_test_client", __dir__).inspect} + client = RunnerTestClient.new(signals: [#{signal.inspect}], stall: #{!drains}) + settings = Struct.new(:stop_timeout).new(0.01) + settings.define_singleton_method(:build_client) { client } + app = Struct.new(:config).new(Struct.new(:river).new(settings)) + Rails.define_singleton_method(:application) { app } + original = proc {} + Signal.trap(#{signal.inspect}, original) + result = River::Rails::Runner.start + abort "Wrong exit status: \#{result}" unless result == #{drains ? 0 : 1} + abort "Handler not restored" unless Signal.trap(#{signal.inspect}, "DEFAULT").equal?(original) + RUBY + expect(status.success?).to be(true), error + expect(output).to include(drains ? "stopped" : "interrupting active attempts") + end + end + end +end diff --git a/ruby/rails/riverqueue-rails/spec/spec_helper.rb b/ruby/rails/riverqueue-rails/spec/spec_helper.rb new file mode 100644 index 000000000..67d495673 --- /dev/null +++ b/ruby/rails/riverqueue-rails/spec/spec_helper.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "../../../spec/support/test_database" +require "rails" +require "active_record/railtie" +require "active_job/railtie" +require "action_mailer/railtie" +require "riverqueue-rails" +require "tmpdir" +require_relative "../../../spec/support/client_test_database" + +class RiverTestApplication < Rails::Application + config.root = File.expand_path("dummy", __dir__) + config.eager_load = false + config.secret_key_base = "river-test-secret" + config.logger = Logger.new(File::NULL) + config.active_support.deprecation = :stderr + config.action_mailer.delivery_method = :test + config.action_mailer.perform_deliveries = true + config.action_mailer.default_url_options = {host: "example.test"} +end + +Rails.application.initialize! +ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") +GlobalID.app = "river-test" +ActiveJob::Base.logger = Logger.new(File::NULL) + +module RiverIntegrationHelpers + def wait_until + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10 + until yield + raise "Timed out waiting for River" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + + sleep 0.01 + end + end + + def rows + @driver.job_list + end + + def work_until + @consumer = Rails.application.config.river.build_client.start + wait_until { yield } + ensure + @consumer&.stop_and_cancel + end +end + +RSpec.configure do |config| + config.include RiverIntegrationHelpers +end diff --git a/ruby/riverqueue.gemspec b/ruby/riverqueue.gemspec new file mode 100644 index 000000000..827e5f85e --- /dev/null +++ b/ruby/riverqueue.gemspec @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +Gem::Specification.new do |s| + s.name = "riverqueue" + s.version = "0.12.0" + s.summary = "A fast, reliable job queue for Ruby backed by PostgreSQL or SQLite." + s.description = "Insert and work River jobs in Ruby using the same schema and state machine as River's Go client. Use with riverqueue-activerecord or riverqueue-sequel." + s.authors = ["Blake Gentry", "Brandur Leach"] + s.email = "brandur@brandur.org" + s.files = Dir.glob("{exe,lib,migration,sig}/**/*") + ["CHANGELOG.md", "LICENSE", "README.md", "docs/README.md", "docs/conformance.md", "docs/migrations.md", "docs/testing.md", "docs/workers.md"] + s.bindir = "exe" + s.executables = ["river"] + s.homepage = "https://riverqueue.com" + s.license = "MPL-2.0" + s.required_ruby_version = ">= 3.2" + s.require_path = %(lib) + s.metadata = { + "bug_tracker_uri" => "https://github.com/riverqueue/river/issues", + "changelog_uri" => "https://github.com/riverqueue/river/blob/master/ruby/CHANGELOG.md", + "rubygems_mfa_required" => "true", + "source_code_uri" => "https://github.com/riverqueue/river/tree/master/ruby" + } + + # Standard-library components distributed as gems on modern Ruby. + s.add_dependency "logger", "> 0", "< 1000" + s.add_dependency "optparse", "> 0", "< 1000" + s.add_dependency "securerandom", "> 0", "< 1000" + s.add_dependency "timeout", "> 0", "< 1000" +end diff --git a/ruby/scripts/check_coverage.rb b/ruby/scripts/check_coverage.rb new file mode 100644 index 000000000..4d6137758 --- /dev/null +++ b/ruby/scripts/check_coverage.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "simplecov" + +# Both backend jobs must supply every package's report; a missing artifact must +# not turn into an apparently complete report for only one package or backend. +reports = %w[postgres sqlite].product([".", "driver/riverqueue-activerecord", "driver/riverqueue-sequel"]).map do |backend, package| + path = "coverage/ci/ruby-coverage-#{backend}/#{package}/coverage/.resultset.json" + abort "Missing coverage report: #{path}" unless File.file?(path) + path +end + +SimpleCov.collate(reports) do + enable_coverage :branch + minimum_coverage branch: 100, line: 100 +end diff --git a/ruby/scripts/sync_migrations.rb b/ruby/scripts/sync_migrations.rb new file mode 100644 index 000000000..9e366eaba --- /dev/null +++ b/ruby/scripts/sync_migrations.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "json" + +# Copies upstream SQL verbatim. --check verifies both file names and bytes. +check = ARGV.delete("--check") + +source = File.expand_path(ARGV.fetch(0, File.expand_path("../..", __dir__))) +root = File.expand_path("..", __dir__) +destination = File.join(root, "migration") +line = "main" +drivers = {"postgresql" => "riverdriver/riverpgxv5", "sqlite" => "riverdriver/riversqlite"} + +files = {} +drivers.each do |backend, directory| + upstream = Dir.glob(File.join(source, directory, "migration", line, "*.sql")).sort + abort "No migrations found for #{backend}" if upstream.empty? + + upstream.each do |path| + relative = File.join(backend, line, File.basename(path)) + target = File.join(destination, relative) + + if check + abort "Migration differs: #{relative}" unless File.file?(target) && File.binread(target) == File.binread(path) + else + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.cp(path, target) + end + + files[relative] = Digest::SHA256.file(path).hexdigest + end +end + +extra = Dir.glob(File.join(destination, "**/*.sql")).map { |path| path.delete_prefix("#{destination}/") } - files.keys +abort "Unexpected migrations: #{extra.join(", ")}" unless extra.empty? + +license = File.join(source, "LICENSE") +target = File.join(destination, "LICENSE") + +if check + abort "Upstream migration license differs" unless File.binread(target) == File.binread(license) +else + FileUtils.cp(license, target) +end + +manifest = {"files" => files, "source" => "riverdriver/"} +manifest_path = File.join(destination, "manifest.json") +if check + abort "Manifest differs" unless JSON.parse(File.read(manifest_path)) == manifest +else + File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") +end + +puts "#{check ? "Verified" : "Copied"} #{files.size} #{line} migration files" diff --git a/ruby/scripts/update_gemspec_version.rb b/ruby/scripts/update_gemspec_version.rb new file mode 100644 index 000000000..a567ea10f --- /dev/null +++ b/ruby/scripts/update_gemspec_version.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# +# Updates the version in a gemspec file since doing it from the shell is a total +# pain. +# + +file = ARGV[0] || abort("failure: need one argument, which is a gemspec filename") +version = ENV["VERSION"] || abort("failure: need VERSION") + +file_data = File.read(file) + +version = version[1..] # strip `v` from the beginning of the string + +updated_file_data = file_data.gsub(%r{^(\W+)s\.version = "[\d.]+"$}, %(\\1s.version = "#{version}")) + +abort("failure: nothing changed in file") if file_data == updated_file_data + +File.write(file, updated_file_data) diff --git a/ruby/sig/client.rbs b/ruby/sig/client.rbs new file mode 100644 index 000000000..b0652d648 --- /dev/null +++ b/ruby/sig/client.rbs @@ -0,0 +1,98 @@ +module River + MAX_ATTEMPTS_DEFAULT: Integer + MAX_ATTEMPTS_LIMIT: Integer + PRIORITY_DEFAULT: Integer + QUEUE_DEFAULT: String + + class Client + @config: Config + @driver: _Driver + @runtime: ClientRuntime + @time_now_utc: ^() -> Time + + attr_reader config: Config + attr_reader driver: _Driver + + def initialize: (_Driver driver, ?config: Config?) -> void + def __finish_claimed_job: (JobRow, ?Exception?) -> untyped + def __perform_job: (Integer, ?allow_scheduled: bool) -> untyped + def __interrupt_workers: () -> untyped + def __runtime_healthy?: () -> bool + def id: () -> String + def insert: (jobArgs, ?insert_opts: InsertOpts?, ?max_attempts: Integer?, + ?metadata: Hash[String | Symbol, untyped]?, ?priority: Integer?, ?queue: (String | Symbol)?, + ?scheduled_at: Time?, ?state: jobStateInitialInput?, ?tags: Array[String]?, ?unique_opts: UniqueOpts?) -> JobInsertResult + def insert_many: (Array[jobArgs | InsertManyParams]) -> Array[JobInsertResult] + def job_cancel: (Integer) -> JobRow + def job_delete: (Integer) -> JobRow + def job_delete_many: (?JobListParams?, ?after: untyped, ?after_id: untyped, + ?ids: untyped, ?kinds: untyped, ?limit: untyped, ?metadata: untyped, ?priorities: untyped, + ?queues: untyped, ?sort_by: untyped, ?sort_order: untyped, ?states: untyped, + ?tags_all: untyped, ?tags_any: untyped) -> untyped + def job_get: (Integer) -> JobRow + def job_list: (?JobListParams?, ?after: untyped, ?after_id: untyped, + ?ids: untyped, ?kinds: untyped, ?limit: untyped, ?metadata: untyped, ?priorities: untyped, + ?queues: untyped, ?sort_by: untyped, ?sort_order: untyped, ?states: untyped, + ?tags_all: untyped, ?tags_any: untyped) -> untyped + def job_retry: (Integer) -> JobRow + def job_update: (Integer, ?JobUpdateParams?, ?attempt: untyped, ?attempted_at: untyped, + ?attempted_by: untyped, ?errors: untyped, ?finalized_at: untyped, ?max_attempts: untyped, + ?metadata: untyped, ?state: untyped) -> JobRow + def periodic_jobs: () -> PeriodicJobBundle + def queue_add: (String | Symbol, QueueConfig | Integer) -> self + def queue_get: (String | Symbol) -> Queue + def queue_list: (?max: Integer) -> untyped + def queue_pause: (String | Symbol) -> bool + def queue_remove: (String | Symbol) -> self + def queue_resume: (String | Symbol) -> bool + def queue_update: (String | Symbol, metadata: Hash[untyped, untyped]) -> Queue + def start: () -> self + def started?: () -> bool + def stop: (?wait: bool) -> self + def stop_and_cancel: () -> self + def stopped?: () -> bool + def subscribe: (*Symbol | String kinds, ?buffer_size: Integer) -> Subscription + + DEFAULT_UNIQUE_STATES: Array[jobStateAll] + EMPTY_INSERT_OPTS: InsertOpts + INITIAL_STATES: Array[jobStateInitial] + REQUIRED_UNIQUE_STATES: Array[jobStateAll] + TAG_RE: Regexp + + private def insert_and_check_unique_job: (Driver::JobInsertParams) -> JobInsertResult + private def make_insert_params: (jobArgs, InsertOpts) -> Driver::JobInsertParams + private def make_unique_key_and_bitmask: (Driver::JobInsertParams, UniqueOpts) -> [String, String] + private def run_insert_plugins: (Array[Driver::JobInsertParams]) { () -> Array[JobInsertResult] } -> Array[JobInsertResult] + private def truncate_time: (Time, Integer | Rational) -> Time + private def uint64_to_int64: (Integer) -> Integer + private def validate_insert_state: (String | Symbol) -> jobStateInitial + private def validate_tags: (Array[String]) -> Array[String] + private def validate_unique_states: (Array[jobStateInput]) -> Array[jobStateAll] + end + + class InsertManyParams + @args: jobArgs + @insert_opts: InsertOpts? + + attr_reader args: jobArgs + attr_reader insert_opts: InsertOpts? + + def initialize: (jobArgs job, ?insert_opts: InsertOpts?, ?max_attempts: Integer?, + ?metadata: Hash[String | Symbol, untyped]?, ?priority: Integer?, ?queue: (String | Symbol)?, + ?scheduled_at: Time?, ?state: jobStateInitialInput?, ?tags: Array[String]?, ?unique_opts: UniqueOpts?) -> void + def is_a?: (Class) -> bool + end + + class JobInsertResult + @job: JobRow + @unique_skipped_as_duplicate: bool + + attr_reader job: JobRow + def unique_skipped_as_duplicate?: () -> bool + alias unique_skipped_as_duplicated unique_skipped_as_duplicate? + + def initialize: (JobRow job, unique_skipped_as_duplicated: bool) -> void + end + + class InsertResult = JobInsertResult +end diff --git a/ruby/sig/driver.rbs b/ruby/sig/driver.rbs new file mode 100644 index 000000000..00853c97a --- /dev/null +++ b/ruby/sig/driver.rbs @@ -0,0 +1,172 @@ +module River + interface _Driver + def respond_to?: (Symbol, ?bool) -> bool + def init_driver: () -> void + def migration_backend: () -> Symbol + def migration_connection: [A] () { (untyped) -> A } -> A + def advisory_lock: (Integer) -> void + def job_cancel: (Integer, ?now: Time) -> JobRow? + def job_delete: (Integer) -> JobRow? + def job_delete_finalized: (**untyped) -> Integer + def job_delete_if_running: (Integer) -> (bool | :cancelled) + def job_delete_many: (JobListParams) -> Array[JobRow] + def job_get_available: (queue: String, max: Integer, attempted_by: String, ?kinds: Array[String]?, ?now: Time) -> Array[JobRow] + def job_claim: (id: Integer, attempted_by: String, ?allow_scheduled: bool, ?now: Time) -> JobRow? + def job_get_cancelled_ids: (Array[Integer]) -> Array[Integer] + def job_complete: (id: Integer, finalized_at: Time, ?metadata: Hash[untyped, untyped]?, ?now: Time) -> (JobRow | :cancelled)? + def job_get_by_kind_and_unique_properties: (Driver::JobGetByKindAndUniquePropertiesParam) -> JobRow? + def job_insert: (Driver::JobInsertParams) -> [JobRow, bool] + def job_insert_many: (Array[Driver::JobInsertParams]) -> Array[[JobRow, bool]] + def job_metadata_merge: (Integer, Hash[untyped, untyped]) -> JobRow? + def job_rescue_stuck: (**untyped) -> Integer + def job_retry: (Integer, ?now: Time) -> JobRow? + def job_schedule: (?now: Time, ?max: Integer) -> Integer + def job_set_state_if_running: (**untyped) -> JobRow? + def job_update: (Integer, JobUpdateParams) -> JobRow? + def leader_acquire: (String, ?ttl: Integer, ?now: Time) -> bool + def leader_release: (String) -> untyped + def leader_renew: (String, ?ttl: Integer, ?now: Time) -> bool + def notification_delete_before: (horizon: Time, ?max: Integer) -> Integer + def queue_get: (String) -> Queue? + def queue_list: (?max: Integer) -> Array[Queue] + def queue_pause: (String, ?now: Time) -> Array[Queue] + def queue_resume: (String, ?now: Time) -> Array[Queue] + def queue_update: (String, metadata: Hash[untyped, untyped], ?now: Time) -> Queue? + def queue_upsert: (String, ?metadata: Hash[untyped, untyped], ?now: Time) -> Queue? + def transaction: [T] () { () -> T } -> T + + # this set of methods is used only in tests + def advisory_lock_try: (Integer) -> bool + def job_get_by_id: (Integer) -> JobRow? + def job_list: (?JobListParams | :all) -> Array[JobRow] + def rollback_exception: -> Exception + end + + module Driver + class JobRowDecoder + ZERO_TIME: Time + @errors: Array[String] + def self.attempt_error: (untyped) -> AttemptError + def self.error_time: (untyped) -> Time + def self.error_integer: (untyped) -> Integer + def self.error_string: (untyped) -> String + def initialize: () -> void + def json: (Symbol, untyped, ?type: Class?, ?strings: bool, ?default: untyped) -> untyped + def decoded: (Symbol, untyped, ?type: Class?, ?strings: bool, ?default: untyped) -> untyped + def finish: (JobRow) -> JobRow + end + + class PostgresCapabilities + attr_reader supports_listen_notify: bool + attr_reader unique_insert_mode: :metadata_nonce | :returning_old | :xmax + attr_reader unique_insert_sql: String + def initialize: (product: String, version_num: Integer, yb_listen_notify_enabled: bool) -> void + + class Cache + @mutex: Mutex + @values: Hash[untyped, PostgresCapabilities] + def initialize: () -> void + def fetch: (untyped) { () -> PostgresCapabilities } -> PostgresCapabilities + end + end + + class JobGetByKindAndUniquePropertiesParam + attr_accessor created_at: [Time, Time]? + attr_accessor encoded_args: String? + attr_accessor kind: String + attr_accessor queue: String? + attr_accessor state: Array[jobStateAll]? + + def initialize: (kind: String, ?created_at: [Time, Time]?, ?encoded_args: String?, ?queue: String?, ?state: Array[jobStateAll]?) -> void + end + + class JobInsertParams + attr_accessor args: untyped + attr_accessor encoded_args: String + attr_accessor kind: String + attr_accessor max_attempts: Integer + attr_accessor metadata: Hash[untyped, untyped] + attr_accessor priority: Integer + attr_accessor queue: String + attr_accessor scheduled_at: Time? + attr_accessor state: jobStateAll + attr_accessor tags: Array[String]? + attr_accessor unique_key: String? + attr_accessor unique_states: String? + + def initialize: (?args: untyped, encoded_args: String, kind: String, max_attempts: Integer, ?metadata: Hash[untyped, untyped], priority: Integer, queue: String, scheduled_at: Time?, state: jobStateAll, tags: Array[String]?, ?unique_key: String?, ?unique_states: String?) -> void + end + + module Runtime + def runtime_read_job: (untyped) -> untyped + def runtime_decode_sqlite_job: (untyped) -> JobRow + def runtime_decode_postgres_job: (untyped) -> JobRow + @postgres_capabilities_cache: PostgresCapabilities::Cache + def init_driver: () -> void + def postgres_capabilities: () -> PostgresCapabilities + def postgres_notify_insert: (Array[JobInsertParams]) -> void + def job_get_cancelled_ids: (Array[Integer]) -> Array[Integer] + def job_complete: (id: untyped, finalized_at: untyped, ?metadata: untyped, ?now: untyped) -> untyped + def job_cancel: (untyped, ?now: untyped) -> untyped + def job_delete: (untyped) -> untyped + def job_delete_finalized: (retention: untyped, ?now: untyped, ?max: untyped) -> untyped + def job_delete_if_running: (untyped) -> untyped + def job_delete_many: (untyped) -> untyped + def job_get_available: (queue: untyped, max: untyped, attempted_by: untyped, ?kinds: untyped, ?now: untyped) -> untyped + def job_claim: (id: untyped, attempted_by: untyped, ?allow_scheduled: untyped, ?now: untyped) -> untyped + private def runtime_claim_jobs: (untyped, untyped, untyped, untyped) -> untyped + def job_list: (?untyped) -> untyped + def job_metadata_merge: (untyped, untyped) -> untyped + def job_rescue_stuck: (horizon: untyped, retry_policy: untyped, ?now: untyped, ?max: untyped, ?logger: untyped, ?rescue_if: untyped) -> untyped + def job_retry: (untyped, ?now: untyped) -> untyped + def job_schedule: (?now: untyped, ?max: untyped) -> untyped + def job_set_state_if_running: (id: untyped, state: untyped, ?now: untyped, ?attempt: untyped, ?error: untyped, ?finalized_at: untyped, ?metadata: untyped, ?scheduled_at: untyped) -> untyped + def job_update: (untyped, untyped) -> untyped + def leader_acquire: (untyped, ?ttl: untyped, ?now: untyped) -> untyped + def leader_release: (untyped) -> untyped + def leader_renew: (untyped, ?ttl: untyped, ?now: untyped) -> untyped + def notification_delete_before: (horizon: untyped, ?max: untyped) -> untyped + def queue_get: (untyped) -> untyped + def queue_list: (?max: untyped) -> untyped + def queue_pause: (untyped, ?now: untyped) -> untyped + def queue_resume: (untyped, ?now: untyped) -> untyped + def queue_update: (untyped, metadata: untyped, ?now: untyped) -> untyped + def queue_upsert: (untyped, ?metadata: untyped, ?now: untyped) -> untyped + + private + + def job_get_by_id: (untyped) -> untyped + def runtime_append_error: (untyped) -> untyped + def runtime_cancel_attempted: () -> untyped + def runtime_connection_pool: () -> untyped + def runtime_cursor_clause: (untyped) -> String + def runtime_execute: (String) -> untyped + def runtime_in_clause: (untyped, untyped) -> untyped + def runtime_job_list_without_params: () -> untyped + def runtime_job_list: (JobListParams, ?for_delete: bool) -> Array[JobRow] + def runtime_job_rows: (String) -> untyped + def runtime_json: (untyped) -> untyped + def runtime_merge_metadata: (untyped) -> untyped + def runtime_metadata_equals: (untyped, untyped) -> untyped + def runtime_nullable_time: (untyped) -> untyped + def runtime_notify: (String, untyped) -> untyped + def sqlite_insert_nonces: (Array[JobInsertParams]) -> Array[String] + def runtime_parse_json: (untyped) -> untyped + def runtime_parse_time: (untyped) -> untyped + def runtime_postgres?: () -> bool + def runtime_query_rows: (String) -> untyped + def runtime_queue_columns: () -> untyped + def runtime_queue_from_row: (untyped) -> untyped + def runtime_quote: (untyped) -> String + def runtime_rescue_retry: (untyped, untyped, untyped, untyped, untyped) -> Time + def runtime_returning_ids: (untyped) -> untyped + def runtime_state: (untyped) -> untyped + def runtime_tag_contains: (untyped) -> untyped + def runtime_time: (untyped) -> untyped + def runtime_unique_violation_class: () -> singleton(Exception) + def runtime_update_value: (untyped, untyped) -> untyped + def runtime_value: (untyped, untyped) -> untyped + def transaction: () { () -> untyped } -> untyped + end + end +end diff --git a/ruby/sig/insert_opts.rbs b/ruby/sig/insert_opts.rbs new file mode 100644 index 000000000..14c7fe854 --- /dev/null +++ b/ruby/sig/insert_opts.rbs @@ -0,0 +1,25 @@ +module River + class InsertOpts + attr_accessor max_attempts: Integer? + attr_accessor metadata: Hash[String | Symbol, untyped]? + attr_accessor priority: Integer? + attr_accessor queue: (String | Symbol)? + attr_accessor scheduled_at: Time? + attr_accessor state: jobStateInitialInput? + attr_accessor tags: Array[String]? + attr_accessor unique_opts: UniqueOpts? + + def initialize: (?max_attempts: Integer?, ?metadata: Hash[String | Symbol, untyped]?, ?priority: Integer?, ?queue: (String | Symbol)?, ?scheduled_at: Time?, ?state: jobStateInitialInput?, ?tags: Array[String]?, ?unique_opts: UniqueOpts?) -> void + end + + class UniqueOpts + def empty?: () -> bool + attr_accessor by_args: bool? | Array[String | Symbol | Array[String | Symbol]]? + attr_accessor by_period: (Integer | Rational)? + attr_accessor by_queue: bool? + attr_accessor by_state: Array[jobStateInput]? + attr_accessor exclude_kind: bool? + + def initialize: (?by_args: bool? | Array[String | Symbol | Array[String | Symbol]]?, ?by_period: (Integer | Rational)?, ?by_queue: bool?, ?by_state: Array[jobStateInput]?, ?exclude_kind: bool?) -> void + end +end diff --git a/ruby/sig/job.rbs b/ruby/sig/job.rbs new file mode 100644 index 000000000..57eff2f93 --- /dev/null +++ b/ruby/sig/job.rbs @@ -0,0 +1,74 @@ +module River + JOB_STATE_AVAILABLE: "available" + JOB_STATE_CANCELLED: "cancelled" + JOB_STATE_COMPLETED: "completed" + JOB_STATE_DISCARDED: "discarded" + JOB_STATE_PENDING: "pending" + JOB_STATE_RETRYABLE: "retryable" + JOB_STATE_RUNNING: "running" + JOB_STATE_SCHEDULED: "scheduled" + + type jobStateAll = "available" | "cancelled" | "completed" | "discarded" | "pending" | "retryable" | "running" | "scheduled" + type jobStateInitial = "available" | "pending" | "scheduled" + type jobStateInitialInput = jobStateInitial | :available | :pending | :scheduled + type jobStateInput = jobStateAll | :available | :cancelled | :completed | :discarded | :pending | :retryable | :running | :scheduled + + interface _JobArgs + def is_a?: (Class) -> bool + def kind: () -> (String | Symbol) + def respond_to?: (Symbol) -> bool + def to_json: () -> String + end + + interface _JobArgsWithInsertOpts + include _JobArgs + + def insert_opts: () -> InsertOpts? + end + + type jobArgs = _JobArgs | _JobArgsWithInsertOpts + + class JobArgsHash + @hash: Hash[String | Symbol, untyped] + @kind: String + + attr_reader kind: String + + def initialize: (String | Symbol kind, Hash[String | Symbol, untyped] hash) -> void + def to_json: () -> String + end + + class JobRow + attr_accessor __decode_error: JobRowDecodeError? + attr_accessor args: Hash[String, untyped] + attr_accessor attempt: Integer + attr_accessor attempted_at: Time? + attr_accessor attempted_by: Array[String]? + attr_accessor created_at: Time + attr_accessor errors: Array[AttemptError]? + attr_accessor finalized_at: Time? + attr_accessor id: Integer + attr_accessor kind: String + attr_accessor max_attempts: Integer + attr_accessor metadata: Hash[String, untyped] + attr_accessor priority: Integer + attr_accessor queue: String + attr_accessor scheduled_at: Time + attr_accessor state: jobStateAll + attr_accessor tags: Array[String]? + attr_accessor unique_key: String? + attr_accessor unique_states: Array[jobStateAll]? + + def initialize: (id: Integer, args: Hash[String, untyped], attempt: Integer, ?attempted_at: Time?, ?attempted_by: Array[String]?, created_at: Time, ?errors: Array[AttemptError]?, ?finalized_at: Time?, kind: String, max_attempts: Integer, metadata: Hash[String, untyped], priority: Integer, queue: String, scheduled_at: Time, state: jobStateAll, ?tags: Array[String]?, ?unique_key: String?, ?unique_states: Array[jobStateAll]?) -> void + end + + class AttemptError + attr_accessor at: Time + attr_accessor attempt: Integer + attr_accessor error: String + attr_accessor trace: String + + def initialize: (at: Time, attempt: Integer, error: String, trace: String) -> void + def to_h: () -> Hash[Symbol, untyped] + end +end diff --git a/ruby/sig/job_persisted_logging/plugin.rbs b/ruby/sig/job_persisted_logging/plugin.rbs new file mode 100644 index 000000000..48b9deab1 --- /dev/null +++ b/ruby/sig/job_persisted_logging/plugin.rbs @@ -0,0 +1,29 @@ +module River + module JobPersistedLogging + class Plugin + @logger_factory: (^(Buffer) -> untyped)? + @max_size_bytes: Integer + @max_total_bytes: Integer + + def initialize: (?max_size_bytes: Integer, ?max_total_bytes: Integer) ?{ (Buffer) -> untyped } -> void + def work: [T] (Job, ^() -> T) -> T + + private + + def persist_log: (Job, Buffer) -> void + + class Buffer + @closed: bool + @data: String + @limit: Integer + @mutex: Mutex + @truncated: bool + + def initialize: (Integer) -> void + def close: () -> nil + def finish: () -> [String, bool] + def write: (_ToS) -> Integer + end + end + end +end diff --git a/ruby/sig/migrator.rbs b/ruby/sig/migrator.rbs new file mode 100644 index 000000000..e283453b0 --- /dev/null +++ b/ruby/sig/migrator.rbs @@ -0,0 +1,44 @@ +class Gem::Specification + def self.find_all_by_name: (String, *untyped) -> Array[Gem::Specification] +end + +module River + module MigrationCLI + def self.detect_driver: () -> String + def self.run: (Array[String], ?err: untyped, ?out: untyped) -> Integer + end + class Migrator + class Migration < Data + attr_reader version: Integer + attr_reader name: String + attr_reader sql_up: String + attr_reader sql_down: String + def self.new: (Integer, untyped, String, String) -> instance + end + class Status < Data + attr_reader version: Integer + attr_reader name: String + attr_reader applied: bool + def self.new: (Integer, String, bool) -> instance + end + @backend: Symbol + @connection: untyped + @driver: untyped + @line: String + @migrations: Array[Migration] + @mutex: Mutex + @requested_schema: untyped + @schema: untyped + attr_reader migrations: Array[Migration] + def initialize: (untyped, ?line: String, ?migrations_path: String, ?schema: untyped) -> void + def migrate: (?direction: Symbol, ?dry_run: bool, ?steps: untyped, ?target: untyped) -> Array[Migration] + def status: () -> Array[Status] + private + def execute: (String) -> untyped + def existing_versions: () -> Array[Integer] + def line_column?: () -> bool + def query: (String) -> untyped + def session: [A] (?lock: bool) { () -> A } -> A + def table: () -> String + end +end diff --git a/ruby/sig/riverqueue.rbs b/ruby/sig/riverqueue.rbs new file mode 100644 index 000000000..6ad50d41b --- /dev/null +++ b/ruby/sig/riverqueue.rbs @@ -0,0 +1,2 @@ +module River +end diff --git a/ruby/sig/runtime.rbs b/ruby/sig/runtime.rbs new file mode 100644 index 000000000..bbb21cf83 --- /dev/null +++ b/ruby/sig/runtime.rbs @@ -0,0 +1,402 @@ +class Thread + def self.handle_interrupt: [T] (Hash[Class, Symbol]) { () -> T } -> T +end + +module River + FETCH_COOLDOWN_DEFAULT: Float + FETCH_POLL_INTERVAL_DEFAULT: Float + JOB_TIMEOUT_DEFAULT: Float + QUEUE_NAME_REGEX: Regexp + QUEUE_NUM_WORKERS_MAX: Integer + + EVENT_JOB_CANCELLED: Symbol + EVENT_JOB_COMPLETED: Symbol + EVENT_JOB_FAILED: Symbol + EVENT_JOB_INTERRUPTED: Symbol + EVENT_JOB_SNOOZED: Symbol + EVENT_QUEUE_PAUSED: Symbol + EVENT_QUEUE_RESUMED: Symbol + + Event: untyped + JobDeleteManyResult: untyped + JobListCursor: untyped + JobListResult: untyped + JobStatistics: untyped + QueueListResult: untyped + class Queue + def initialize: (String, Time, Hash[untyped, untyped], Time?, Time) -> void + def created_at: () -> Time + def metadata: () -> Hash[untyped, untyped] + def name: () -> String + def paused_at: () -> Time? + def updated_at: () -> Time + end + + class Error < StandardError + end + + class NotFoundError < Error + end + + class JobRunningError < Error + end + + class JobRowDecodeError < Error + attr_reader job: JobRow + def initialize: (JobRow, String) -> void + end + + class JobCancelError < Error + @cause: untyped + attr_reader cause: untyped + def initialize: (?String, ?cause: untyped) -> void + end + + class JobSnoozeError < Error + @duration: Float + attr_reader duration: Float + def initialize: (Numeric) -> void + end + + class UnknownJobKindError < Error + @kind: untyped + attr_reader kind: untyped + def initialize: (untyped) -> void + end + + class ClientNotStartedError < Error + end + + class ClientAlreadyStartedError < Error + end + + def self.job_cancel: (?(String | Exception)?) -> JobCancelError + def self.job_snooze: (Numeric) -> JobSnoozeError + + class Workers + @workers: Hash[String, untyped] + def initialize: () -> void + def add: (untyped, ?untyped, ?aliases: Array[untyped]) -> self + def []: (String | Symbol) -> untyped + def fetch: (String | Symbol) -> untyped + | (String | Symbol, untyped) -> untyped + | (String | Symbol) { (String) -> untyped } -> untyped + def include?: (String | Symbol) -> bool + def kinds: () -> Array[String] + end + + class Job + RESUMABLE_CURSOR_UNSET: Object + @client: Client + @logger: untyped + @metadata_updates: Hash[String, untyped] + @resumable_state: ResumableState + @row: JobRow + attr_reader client: Client + attr_reader row: JobRow + def initialize: (Client, JobRow) -> void + def __capture_resumable_metadata!: () -> untyped + def __finish_resumable_work!: () -> untyped + def __with_logger: [T] (untyped logger) { () -> T } -> T + def args: () -> untyped + def logger: () -> untyped + def metadata: () -> Hash[untyped, untyped] + def metadata_updates: () -> Hash[String, untyped] + def method_missing: (Symbol, *untyped, **untyped) -> untyped + def output=: (untyped) -> untyped + def resumable_checkpoint: (?cursor: untyped) -> JobRow + def respond_to_missing?: (Symbol, ?bool) -> bool + def resumable_set_cursor: (untyped) -> untyped + def resumable_step: (String | Symbol) { () -> untyped } -> untyped + def resumable_step_cursor: (String | Symbol, ?default: untyped) { (untyped) -> untyped } -> untyped + def update_metadata: (Hash[untyped, untyped]) -> self + + private + + def initialize_resumable_state: () -> ResumableState + def run_resumable_step: (String, cursor: bool, default: untyped) { () -> untyped } -> untyped + | (String, cursor: bool, default: untyped) { (untyped) -> untyped } -> untyped + end + + RESUMABLE_CURSOR_METADATA_KEY: String + RESUMABLE_STEP_METADATA_KEY: String + + class ResumableState + attr_reader all_step_names: Hash[String, bool] + attr_accessor completed_step: String? + attr_reader cursors: Hash[String, untyped] + attr_accessor cursors_dirty: bool + attr_accessor resume_matched: bool + attr_reader resume_step: String? + attr_accessor step_name: String? + def initialize: (Hash[String, untyped]) -> void + def register: (String) -> bool + end + + class DefaultClientRetryPolicy + MAX_DELAY: Rational + @random: untyped + def initialize: (?random: untyped) -> void + def next_retry: (untyped, ?untyped, ?now: Time) -> Time + end + + class QueueConfig + @fetch_cooldown: Float? + @fetch_poll_interval: Float? + @max_workers: Integer + attr_reader fetch_cooldown: Float? + attr_reader fetch_poll_interval: Float? + attr_reader max_workers: Integer + def initialize: (max_workers: Integer, ?fetch_cooldown: Numeric?, ?fetch_poll_interval: Numeric?) -> void + def resolved_fetch_cooldown: (Config) -> Float + def resolved_fetch_poll_interval: (Config) -> Float + end + + class Config + @cancelled_job_retention_period: Float? + @completed_job_retention_period: Float? + @discarded_job_retention_period: Float? + @error_handler: untyped + @fetch_cooldown: Float + @fetch_only_known_kinds: bool + @fetch_poll_interval: Float + @id: String + @job_timeout: Float? + @leader_election_disabled: bool + @logger: untyped + @maintenance_services: Array[untyped] + @periodic_jobs: Array[untyped] + @plugins: Array[untyped] + @queues: Hash[String, QueueConfig] + @retry_policy: untyped + @workers: Workers + + attr_reader cancelled_job_retention_period: Float? + attr_reader completed_job_retention_period: Float? + attr_reader discarded_job_retention_period: Float? + attr_reader error_handler: untyped + attr_reader fetch_cooldown: Float + attr_reader fetch_only_known_kinds: bool + attr_reader fetch_poll_interval: Float + attr_reader id: String + attr_reader job_timeout: Float? + attr_reader leader_election_disabled: bool + attr_reader logger: untyped + attr_reader maintenance_services: Array[untyped] + attr_reader periodic_jobs: Array[untyped] + attr_reader plugins: Array[untyped] + attr_reader queues: Hash[String, QueueConfig] + attr_reader retry_policy: untyped + attr_reader workers: Workers + + def initialize: (?queues: untyped, ?workers: untyped, ?id: untyped, + ?fetch_cooldown: untyped, ?fetch_only_known_kinds: bool, ?fetch_poll_interval: untyped, ?job_timeout: untyped, + ?leader_election_disabled: bool, + ?retry_policy: untyped, ?error_handler: untyped, ?plugins: untyped, + ?maintenance_services: untyped, ?periodic_jobs: untyped, + ?cancelled_job_retention_period: untyped, ?completed_job_retention_period: untyped, + ?discarded_job_retention_period: untyped, ?logger: untyped) -> void + def with: (**untyped) -> Config + + private + + def normalize_queues: (untyped) -> Hash[String, QueueConfig] + def retention: (untyped) -> Float? + def validate: () -> void + end + + class Subscription + @closed: bool + @kinds: Array[Symbol] + @mutex: Mutex + @on_close: untyped + @queue: untyped + def initialize: (Array[Symbol | String], ?buffer_size: Integer, ?on_close: untyped) -> void + def close: () -> untyped + def each: () { (untyped) -> void } -> untyped + | () -> Enumerator[untyped, untyped] + def pop: (?bool) -> untyped + def publish: (untyped) -> untyped + end + + class JobListParams + @after: untyped + @after_id: untyped + @ids: untyped + @kinds: untyped + @limit: Integer + @metadata: untyped + @priorities: untyped + @queues: untyped + @sort_by: Symbol + @sort_order: Symbol + @states: untyped + @tags_all: untyped + @tags_any: untyped + attr_reader after_id: untyped + attr_reader after: untyped + attr_reader ids: untyped + attr_reader kinds: untyped + attr_reader limit: Integer + attr_reader metadata: untyped + attr_reader priorities: untyped + attr_reader queues: untyped + attr_reader sort_by: Symbol + attr_reader sort_order: Symbol + attr_reader states: untyped + attr_reader tags_all: untyped + attr_reader tags_any: untyped + def initialize: (?after: untyped, ?after_id: untyped, ?ids: untyped, ?kinds: untyped, ?limit: untyped, + ?priorities: untyped, ?metadata: untyped, ?queues: untyped, ?sort_by: untyped, + ?sort_order: untyped, ?states: untyped, ?tags_all: untyped, ?tags_any: untyped) -> void + def filters?: () -> bool + end + + class JobUpdateParams + UNSET: Object + @attempt: untyped + @attempted_at: untyped + @attempted_by: untyped + @errors: untyped + @finalized_at: untyped + @max_attempts: untyped + @metadata: untyped + @state: untyped + attr_reader attempt: untyped + attr_reader attempted_at: untyped + attr_reader attempted_by: untyped + attr_reader errors: untyped + attr_reader finalized_at: untyped + attr_reader max_attempts: untyped + attr_reader metadata: untyped + attr_reader state: untyped + def initialize: (?attempt: untyped, ?attempted_at: untyped, ?attempted_by: untyped, + ?errors: untyped, ?finalized_at: untyped, ?max_attempts: untyped, + ?metadata: untyped, ?state: untyped) -> void + def each: () { (Symbol, untyped) -> void } -> untyped + | () -> Enumerator[[Symbol, untyped], untyped] + end + + class PeriodicJob + attr_reader constructor: untyped + attr_reader id: String? + attr_reader run_on_start: bool + attr_reader schedule: untyped + def initialize: (schedule: untyped, ?constructor: untyped, ?id: (String | Symbol)?, ?run_on_start: bool) ?{ () -> untyped } -> void + def next_at: (Time) -> Time + end + + class PeriodicCron + @cron: untyped + @interval: Integer? + def initialize: (String, ?timezone: String) -> void + def next: (Time) -> Time + private + def parse_interval: (String) -> Integer + end + + class PeriodicInterval + @seconds: Float + def initialize: (Numeric) -> void + def next: (Time) -> Time + end + + class PeriodicJobBundle + @enabled: bool + @jobs: Hash[Integer, untyped] + @mutex: Mutex + @next_handle: Integer + @wake: untyped + def initialize: (Array[PeriodicJob], wake: untyped, ?enabled: bool) -> void + def add: (PeriodicJob) -> Integer + def add_many: (Array[PeriodicJob]) -> Array[Integer] + def clear: () -> self + def due: (Time) ?{ (PeriodicJob, StandardError) -> void } -> Array[PeriodicJob] + def empty?: () -> bool + def remove: (Integer) -> PeriodicJob? + def remove_by_id: (untyped) -> bool + end + + class ClientRuntime + class Interrupted < StandardError + end + + class JobTiming + attr_accessor finished_monotonic: Float + attr_reader queue_wait_duration: Float + attr_reader started_at: Time + attr_reader started_monotonic: Float + + def initialize: (Time, Float, Time) -> void + end + + @client: Client + @condition: ConditionVariable + @config: Config + @driver: _Driver + @maintenance_thread: Thread? + @mutex: Mutex + @periodic_jobs: PeriodicJobBundle + @performing: bool? + @producer_threads: Hash[String, Thread] + @queue_configs: Hash[String, QueueConfig] + @removed_queues: Hash[String, bool] + @running: Hash[Integer, untyped] + @started: bool + @stop_requested: bool + @stopped: bool + @subscriptions: Array[Subscription] + @threads: Array[Thread] + + attr_reader periodic_jobs: PeriodicJobBundle + def initialize: (Client, _Driver, Config) -> void + def finish_claimed: (JobRow, ?Exception?) -> untyped + def healthy?: () -> bool + def interrupt_workers: () -> untyped + def perform_job: (Integer, ?allow_scheduled: bool) -> untyped + def publish_queue: (Symbol, Queue) -> untyped + def queue_add: (String, QueueConfig | Integer) -> QueueConfig + def queue_remove: (String) -> bool + def start: () -> self + def started?: () -> bool + def stop: (?cancel: bool, ?wait: bool) -> self + def stopped?: () -> bool + def subscribe: (Array[Symbol | String], ?buffer_size: Integer) -> Subscription + def wake: () -> untyped + + private + + def begin_work: (Integer) -> untyped + def check_remote_cancellations: (String) -> untyped + def error_handler_cancel?: (Exception, Job) -> bool + def execute: (JobRow) -> untyped + def finish_failed: (JobRow, Job, Exception, JobTiming, ?cancelled: bool, ?worker: untyped) -> untyped + def finish_interrupted: (JobRow, Job, JobTiming) -> untyped + def finish_snoozed: (JobRow, Job, JobSnoozeError, JobTiming) -> untyped + def finish_work: (Integer) -> untyped + def invoke_plugins: (Symbol, *untyped) -> Array[untyped] + def invoke_worker: (untyped, Job) -> untyped + def launch: (JobRow) -> untyped + def maintenance_loop: () -> untyped + def monotonic_now: () -> Float + def next_retry: (JobRow, Exception, Time, ?worker: untyped) -> Time + def next_snooze_count: (untyped) -> Integer + def retry_allowed?: (untyped, Job, Exception) -> untyped + def perform_work: (untyped, Job) -> untyped + def periodic_jobs_changed: () -> untyped + def producer_loop: (String, QueueConfig) -> untyped + def publish: (Symbol, JobRow, JobTiming) -> untyped + def queue_stopping?: (String) -> bool + def remove_subscription: (Subscription) -> Subscription? + def rescue_job?: (JobRow, Time) -> bool + def resolve_worker: (String) -> untyped + def run_periodic: (Time) -> untyped + def running_count: (String) -> Integer + def start_maintenance: () -> untyped + def start_producer: (String, QueueConfig) -> untyped + def stopping?: () -> bool + def wait: (Float | Integer) -> untyped + def wait_for_running_jobs: (String, Float | Integer) -> untyped + def worker_timeout: (untyped, Job) -> Float? + end +end diff --git a/ruby/sig/testing.rbs b/ruby/sig/testing.rbs new file mode 100644 index 000000000..deb67e50e --- /dev/null +++ b/ruby/sig/testing.rbs @@ -0,0 +1,95 @@ +module RSpec + module Matchers + module Composable + def and: (untyped) -> untyped + def or: (untyped) -> untyped + private def surface_descriptions_in: (untyped) -> untyped + private def values_match?: (untyped, untyped) -> bool + end + end +end + +module River + module Testing + class AssertionError < StandardError + end + class DrainLimitError < StandardError + end + + JOB_ATTRIBUTES: Array[Symbol] + + class ExecutionResult < Data + attr_reader id: Integer + attr_reader error: Exception? + attr_reader job: JobRow? + attr_reader outcome: Symbol + def self.new: (id: Integer, error: Exception?, job: JobRow?, outcome: Symbol) -> instance + end + + def self.drain: (Client, queue: String | Symbol, ?max_jobs: Integer) -> Array[ExecutionResult] + def self.normalize_attributes: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped] + def self.inserted_jobs: (Client, **untyped) ?{ () -> untyped } -> Array[JobRow] + def self.perform_job: (Client, Integer, ?allow_scheduled: bool) -> ExecutionResult + def self.jobs: (Client) -> Array[JobRow] + + module Assertions + include Kernel + def assert_job_cancelled: (ExecutionResult) -> ExecutionResult + def assert_job_completed: (ExecutionResult) -> ExecutionResult + def assert_job_discarded: (ExecutionResult) -> ExecutionResult + def assert_job_inserted: (Client, **untyped) ?{ () -> untyped } -> JobRow + def assert_jobs_inserted: (Client, count: Integer, **untyped) ?{ () -> untyped } -> Array[JobRow] + def assert_no_jobs_inserted: (Client, **untyped) ?{ () -> untyped } -> Array[JobRow] + private def river_assert: (bool, String) -> untyped + end + + interface _MinitestAssertions + def assert: (untyped, String) -> untyped + end + + module Minitest : Object, _MinitestAssertions + include Assertions + private def river_assert: (bool, String) -> untyped + end + + module RSpec + include Kernel + def have_job: (**untyped) -> JobMatcher + def insert_job: (Client, **untyped) -> InsertionMatcher + def insert_jobs: (Client, ?count: Integer, **untyped) -> InsertionMatcher + + class JobMatcher + include ::RSpec::Matchers::Composable + @attributes: Hash[Symbol, untyped] + @comparison: Symbol + @count: Integer + @count_description: String + @rows: Array[JobRow] + def initialize: (Integer, Hash[Symbol, untyped]) -> void + def at_least: (Integer) -> self + def at_most: (Integer) -> self + def description: () -> String + def does_not_match?: (untyped) -> bool + def exactly: (Integer) -> self + def failure_message: () -> String + def failure_message_when_negated: () -> String + def matches?: (untyped) -> bool + def supports_block_expectations?: () -> bool + def supports_value_expectations?: () -> bool + private def candidates: (untyped) -> Array[JobRow] + private def matching_rows: (untyped) -> Array[JobRow] + private def set_count: (Integer, Symbol, String) -> self + private def verb: () -> String + end + + class InsertionMatcher < JobMatcher + @client: Client + def initialize: (Client, Integer, Hash[Symbol, untyped]) -> void + def supports_block_expectations?: () -> bool + def supports_value_expectations?: () -> bool + private def candidates: (untyped) -> Array[JobRow] + private def verb: () -> String + end + end + end +end diff --git a/ruby/sig/unique_args.rbs b/ruby/sig/unique_args.rbs new file mode 100644 index 000000000..fe5fdeda2 --- /dev/null +++ b/ruby/sig/unique_args.rbs @@ -0,0 +1,8 @@ +module River + module UniqueArgs + def self.encode: (String json, bool? | Array[String | Symbol | Array[String | Symbol]] fields) -> String + def self.members: (String json) -> Hash[String, String] + def self.encode_object: (Hash[String, untyped] object) -> String + def self.encode_key: (String key) -> String + end +end diff --git a/ruby/sig/unique_bitmask.rbs b/ruby/sig/unique_bitmask.rbs new file mode 100644 index 000000000..94caa1985 --- /dev/null +++ b/ruby/sig/unique_bitmask.rbs @@ -0,0 +1,8 @@ +module River + class UniqueBitmask + def self.from_states: (Array[jobStateAll]) -> String + def self.to_states: (Integer) -> Array[jobStateAll] + + JOB_STATE_BIT_POSITIONS: Hash[jobStateAll, Integer] + end +end diff --git a/ruby/sig/worker_runner.rbs b/ruby/sig/worker_runner.rbs new file mode 100644 index 000000000..e78dac28a --- /dev/null +++ b/ruby/sig/worker_runner.rbs @@ -0,0 +1,22 @@ +module Process + def self.exit!: (?Integer) -> bot +end + +module River + module CLI + def self.run: (Array[String], ?err: untyped, ?out: untyped) -> Integer + def self.worker: (Array[String], out: untyped) -> Integer + end + + class WorkerRunner + @client: Client + @finalization_timeout: Float + @out: untyped + @stop_timeout: Float + def initialize: (Client, ?finalization_timeout: Numeric, ?out: untyped, ?stop_timeout: Numeric) -> void + def run: () -> Integer + private def log: (String) -> void + private def monotonic_now: () -> Float + private def supervise: (IO) -> Integer + end +end diff --git a/ruby/spec/cli_spec.rb b/ruby/spec/cli_spec.rb new file mode 100644 index 000000000..27a122eff --- /dev/null +++ b/ruby/spec/cli_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "spec_helper" +require "stringio" +require "tmpdir" +require_relative "../lib/cli" +require_relative "support/runner_test_client" + +RSpec.describe River::CLI do + let(:err) { StringIO.new } + let(:out) { StringIO.new } + + def run(*args) + described_class.run(args, err: err, out: out) + end + + it "prints command and worker help without booting an application" do + expect(run).to eq(0) + expect(run("--help")).to eq(0) + expect(run("-h")).to eq(0) + expect(run("worker", "--help")).to eq(0) + expect(out.string).to include("migrate-up", "--stop-timeout", "--rails") + expect(err.string).to eq("") + end + + it "dispatches migration commands to the migration CLI" do + expect(run("migrate-status", "--help")).to eq(0) + expect(out.string).to include("--database-url") + expect(run("unknown")).to eq(1) + end + + it "rejects missing, conflicting, and unknown worker options" do + [[], ["--rails", "--config", "unused"], ["--rails", "extra"], ["--shutdown-timeout", "1"], ["--stop-timeout", "bad"]].each do |options| + expect(run("worker", *options)).to eq(1) + end + expect(err.string).to include("Worker failed") + end + + it "evaluates trusted config files and supports default and explicit stop timeouts" do + Dir.mktmpdir("river-cli-") do |directory| + path = File.join(directory, "river.rb") + File.write(path, "RunnerTestClient.new") + expect(run("worker", "--config", path)).to eq(0) + expect(run("worker", "--config", path, "--stop-timeout", "2")).to eq(0) + expect(run("worker", "--config", path, "--stop-timeout", "-1")).to eq(1) + + File.write(path, "Object.new") + expect(run("worker", "--config", path)).to eq(1) + expect(err.string).to include("configuration must return a River::Client") + File.write(path, "invalid ruby (") + expect(run("worker", "--config", path)).to eq(1) + expect(err.string).to include("SyntaxError") + expect(run("worker", "--config", File.join(directory, "missing.rb"))).to eq(1) + end + end + + it "boots the Rails environment before loading and invoking the optional integration" do + Dir.mktmpdir("river-cli-rails-") do |directory| + Dir.mkdir(File.join(directory, "config")) + environment = File.join(directory, "config/environment.rb") + integration = File.join(directory, "riverqueue-rails.rb") + File.write(environment, "module River; module Rails; end; end") + File.write(integration, <<~'RUBY') + raise "environment not booted" unless defined?(River::Rails) + class River::Rails::Runner + def self.start(out:, stop_timeout:) + out.puts("Rails timeout: #{stop_timeout.inspect}") + 0 + end + end + RUBY + $LOAD_PATH.unshift(directory) + Dir.chdir(directory) do + expect(run("worker", "--rails")).to eq(0) + expect(run("worker", "--rails", "--stop-timeout", "2")).to eq(0) + end + expect(out.string).to include("Rails timeout: nil", "Rails timeout: 2.0") + ensure + $LOAD_PATH.delete(directory) + $LOADED_FEATURES.delete(environment) + $LOADED_FEATURES.delete(integration) + River.send(:remove_const, :Rails) + end + end +end diff --git a/ruby/spec/client_admin_spec.rb b/ruby/spec/client_admin_spec.rb new file mode 100644 index 000000000..17d080230 --- /dev/null +++ b/ruby/spec/client_admin_spec.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" + +class AdminArgs + def initialize(value) + @value = value + end + + def kind = "admin" + + def to_json = JSON.dump(value: @value) +end + +RSpec.describe "River client job administration" do + around(:each) { |example| available_test_transaction(&example) } + + let(:driver) { River::Driver::Sequel.new(available_test_database) } + let(:client) { River::Client.new(driver) } + + def insert_job(value = 1, **options) + client.insert(AdminArgs.new(value), insert_opts: River::InsertOpts.new(**options)).job + end + + def claim(job) + driver.job_get_available(attempted_by: "worker-test", max: 1, queue: job.queue).first + end + + it "gets an inserted job" do + inserted = insert_job + + expect(client.job_get(inserted.id)).to have_attributes(id: inserted.id, args: {"value" => 1}) + end + + it "raises when getting an unknown job" do + expect { client.job_get(-1) }.to raise_error(River::NotFoundError, "job not found: -1") + end + + it "updates mutable job fields" do + inserted = insert_job + attempted_at = Time.now.utc - 10 + error = River::AttemptError.new(at: attempted_at, attempt: 2, error: "failed", trace: "trace") + + updated = client.job_update(inserted.id, River::JobUpdateParams.new( + attempt: 2, + attempted_at: attempted_at, + attempted_by: ["worker-one"], + errors: [error], + max_attempts: 5, + metadata: {"tenant" => "one"}, + state: River::JOB_STATE_RETRYABLE + )) + + expect(updated).to have_attributes( + attempt: 2, + attempted_at: be_within(0.001).of(attempted_at), + attempted_by: ["worker-one"], + max_attempts: 5, + metadata: {"tenant" => "one"}, + state: River::JOB_STATE_RETRYABLE + ) + expect(updated.errors.first).to have_attributes(attempt: 2, error: "failed") + end + + it "returns the unchanged job for an empty update" do + inserted = insert_job + + expect(client.job_update(inserted.id, River::JobUpdateParams.new)).to have_attributes(id: inserted.id) + end + + it "raises when updating an unknown job" do + expect { client.job_update(-1, River::JobUpdateParams.new(attempt: 1)) } + .to raise_error(River::NotFoundError, "job not found: -1") + end + + it "cancels an available job immediately" do + cancelled = client.job_cancel(insert_job.id) + + expect(cancelled).to have_attributes( + finalized_at: be_a(Time), + metadata: include("cancel_attempted_at"), + state: River::JOB_STATE_CANCELLED + ) + end + + it "marks a running job for remote cancellation without finalizing it" do + running = claim(insert_job) + cancelled = client.job_cancel(running.id) + + expect(cancelled).to have_attributes(finalized_at: nil, state: River::JOB_STATE_RUNNING) + expect(cancelled.metadata).to include("cancel_attempted_at") + end + + it "leaves an already completed job unchanged when cancellation is requested" do + job = insert_job + running = claim(job) + driver.job_set_state_if_running(id: running.id, finalized_at: Time.now.utc, state: River::JOB_STATE_COMPLETED) + + expect(client.job_cancel(job.id)).to have_attributes(state: River::JOB_STATE_COMPLETED) + end + + it "raises when cancelling an unknown job" do + expect { client.job_cancel(-1) }.to raise_error(River::NotFoundError, "job not found: -1") + end + + it "deletes a non-running job" do + inserted = insert_job + + expect(client.job_delete(inserted.id)).to have_attributes(id: inserted.id) + expect { client.job_get(inserted.id) }.to raise_error(River::NotFoundError) + end + + it "refuses to delete a running job" do + running = claim(insert_job) + + expect { client.job_delete(running.id) } + .to raise_error(River::JobRunningError, "running jobs cannot be deleted") + expect(client.job_get(running.id)).to have_attributes(state: River::JOB_STATE_RUNNING) + end + + it "raises when deleting an unknown job" do + expect { client.job_delete(-1) }.to raise_error(River::NotFoundError, "job not found: -1") + end + + it "deletes matching jobs in bulk while preserving running jobs" do + first = insert_job(1) + running = claim(first) + second = insert_job(2) + + result = client.job_delete_many(River::JobListParams.new(kinds: ["admin"])) + + expect(result.jobs.map(&:id)).to eq([second.id]) + expect(client.job_get(running.id)).to have_attributes(state: River::JOB_STATE_RUNNING) + expect { client.job_get(second.id) }.to raise_error(River::NotFoundError) + end + + it "requires a filter for bulk deletion" do + expect { client.job_delete_many(River::JobListParams.new) } + .to raise_error(ArgumentError, "delete with no filters is not allowed") + expect { client.job_delete_many(nil) } + .to raise_error(ArgumentError, "delete with no filters is not allowed") + end + + it "retries a finalized job and increases an exhausted max-attempt count" do + inserted = insert_job + client.job_update(inserted.id, River::JobUpdateParams.new( + attempt: 3, + finalized_at: Time.now.utc, + max_attempts: 3, + state: River::JOB_STATE_DISCARDED + )) + + retried = client.job_retry(inserted.id) + + expect(retried).to have_attributes(attempt: 3, finalized_at: nil, max_attempts: 4, state: River::JOB_STATE_AVAILABLE) + end + + it "does not retry a running job" do + running = claim(insert_job) + + expect(client.job_retry(running.id)).to have_attributes(state: River::JOB_STATE_RUNNING) + end + + it "raises when retrying an unknown job" do + expect { client.job_retry(-1) }.to raise_error(River::NotFoundError, "job not found: -1") + end + + it "returns jobs with a pagination cursor" do + first = insert_job(1) + second = insert_job(2) + + page = client.job_list(River::JobListParams.new(limit: 1)) + next_page = client.job_list(River::JobListParams.new(after: page.last_cursor)) + + expect(page.jobs.map(&:id)).to eq([first.id]) + expect(page.last_cursor).to have_attributes(id: first.id, sort_by: :id, sort_order: :asc, value: first.id) + expect(next_page.jobs.map(&:id)).to eq([second.id]) + end + + it "returns a nil cursor for an empty job list" do + result = client.job_list + + expect(result).to have_attributes( + jobs: be_empty, + last_cursor: be_nil + ) + end +end + +RSpec.describe "River client queue administration" do + around(:each) { |example| available_test_transaction(&example) } + + let(:driver) { River::Driver::Sequel.new(available_test_database) } + let(:client) { River::Client.new(driver) } + + it "gets, lists, and updates queues" do + driver.queue_upsert("beta") + driver.queue_upsert("alpha") + + expect(client.queue_get("alpha")).to have_attributes(metadata: {}, name: "alpha") + expect(client.queue_list(max: 1).queues.map(&:name)).to eq(["alpha"]) + expect(client.queue_update("alpha", metadata: {"team" => "ruby"}).metadata).to eq("team" => "ruby") + end + + it "raises for missing queue lookups and updates" do + expect { client.queue_get("missing") }.to raise_error(River::NotFoundError, "queue not found: missing") + expect { client.queue_update("missing", metadata: {}) } + .to raise_error(River::NotFoundError, "queue not found: missing") + end + + it "pauses and resumes a named queue and publishes events" do + driver.queue_upsert("one") + subscription = client.subscribe(River::EVENT_QUEUE_PAUSED, River::EVENT_QUEUE_RESUMED) + + expect(client.queue_pause("one")).to be true + expect(client.queue_get("one").paused_at).to be_a(Time) + expect(subscription.pop(true)).to have_attributes(kind: River::EVENT_QUEUE_PAUSED, queue: have_attributes(name: "one")) + + expect(client.queue_resume("one")).to be true + expect(client.queue_get("one").paused_at).to be_nil + expect(subscription.pop(true)).to have_attributes(kind: River::EVENT_QUEUE_RESUMED, queue: have_attributes(name: "one")) + end + + it "pauses and resumes all queues" do + driver.queue_upsert("one") + driver.queue_upsert("two") + + client.queue_pause("*") + + expect(client.queue_list.queues).to all(have_attributes(paused_at: be_a(Time))) + client.queue_resume("*") + + expect(client.queue_list.queues).to all(have_attributes(paused_at: nil)) + end + + it "treats pausing an unknown queue as an idempotent operation" do + subscription = client.subscribe(River::EVENT_QUEUE_PAUSED) + + expect(client.queue_pause("missing")).to be true + expect { subscription.pop(true) }.to raise_error(ThreadError) + end +end diff --git a/ruby/spec/client_driver_shared_examples.rb b/ruby/spec/client_driver_shared_examples.rb new file mode 100644 index 000000000..eb4cb286b --- /dev/null +++ b/ruby/spec/client_driver_shared_examples.rb @@ -0,0 +1,556 @@ +# frozen_string_literal: true + +require "timeout" +require "riverqueue/testing" +require_relative "runtime_draining_shared_examples" +require_relative "runtime_finishing_shared_examples" + +RSpec.shared_examples "PostgreSQL state update races" do + it "skips a concurrent retry when selecting jobs for bulk deletion" do + client = River::Client.new(@driver) + first, second = 2.times.map do + row = client.insert(River::JobArgsHash.new(:race, {})).job + client.job_update(row.id, finalized_at: Time.now.utc, state: "discarded") + end + locked, release = Queue.new, Queue.new + retrier = Thread.new do + @driver.transaction do + client.job_retry(first.id) + locked << true + release.pop + end + end + Timeout.timeout(5) { locked.pop } + deleter = Thread.new do + @driver.transaction do + @driver.send(:runtime_execute, "SET LOCAL statement_timeout = '5s'") + client.job_delete_many(states: ["discarded"], limit: 1).jobs + end + end + + expect(Timeout.timeout(3) { deleter.value }).to contain_exactly(have_attributes(id: second.id)) + release << true + retrier.value + expect(client.job_get(first.id).state).to eq("available") + ensure + release << true if release + retrier&.join + deleter&.join + end + + [:job_complete, :job_delete_if_running, :job_discard].each do |operation| + it "honors a cancellation committed while #{operation} waits for the row lock" do + client = River::Client.new(@driver) + row = client.insert(River::JobArgsHash.new(:race, {})).job + @driver.job_claim(id: row.id, attempted_by: "test") + locked, release, started = Queue.new, Queue.new, Queue.new + canceller = Thread.new do + @driver.transaction do + client.job_cancel(row.id) + locked << true + release.pop + end + end + Timeout.timeout(5) { locked.pop } + completer = Thread.new do + @driver.transaction do + @driver.send(:runtime_execute, "SET LOCAL statement_timeout = '5s'") + pid = @driver.send(:runtime_query_rows, "SELECT pg_backend_pid() AS pid").first.values.first + started << pid + case operation + when :job_complete + @driver.job_complete(id: row.id, finalized_at: Time.now.utc) + when :job_delete_if_running + @driver.job_delete_if_running(row.id) + else + @driver.job_set_state_if_running(id: row.id, finalized_at: Time.now.utc, state: "discarded") + end + end + end + pid = Timeout.timeout(5) { started.pop } + Timeout.timeout(5) do + loop do + blockers = @driver.send(:runtime_query_rows, "SELECT cardinality(pg_blocking_pids(#{Integer(pid)})) AS count").first.values.first + break if blockers.positive? + + Thread.pass + end + end + release << true + + result = Timeout.timeout(5) { completer.value } + if operation == :job_discard + expect(result).to have_attributes(state: "cancelled", finalized_at: be_a(Time)) + else + expect(result).to eq(:cancelled) + end + expect(client.job_get(row.id)).to have_attributes( + state: (operation == :job_discard) ? "cancelled" : "running", metadata: include("cancel_attempted_at") + ) + ensure + release << true if release + canceller&.join + completer&.join + end + end + + [:job_cancel, :job_retry].each do |operation| + it "returns the committed row when #{operation} waits behind the same operation" do + client = River::Client.new(@driver) + row = client.insert(River::JobArgsHash.new(:race, {})).job + client.job_cancel(row.id) if operation == :job_retry + locked, release, started = Queue.new, Queue.new, Queue.new + winner = Thread.new do + @driver.transaction do + updated = client.public_send(operation, row.id) + locked << updated + release.pop + updated + end + end + committed = Timeout.timeout(5) { locked.pop } + loser = Thread.new do + @driver.transaction do + @driver.send(:runtime_execute, "SET LOCAL statement_timeout = '5s'") + pid = @driver.send(:runtime_query_rows, "SELECT pg_backend_pid() AS pid").first.values.first + started << pid + client.public_send(operation, row.id) + end + end + pid = Timeout.timeout(5) { started.pop } + # Observe the actual lock wait; merely starting a thread doesn't establish + # that its statement snapshot predates the winning transaction's commit. + Timeout.timeout(5) do + loop do + blockers = @driver.send(:runtime_query_rows, "SELECT cardinality(pg_blocking_pids(#{Integer(pid)})) AS count").first.values.first + break if blockers.positive? + + Thread.pass + end + end + release << true + actual = Timeout.timeout(5) { loser.value } + expect(actual).to have_attributes(id: row.id, state: committed.state, + finalized_at: committed.finalized_at, scheduled_at: committed.scheduled_at) + expect(winner.value.state).to eq(committed.state) + ensure + release << true if release + winner&.join + loser&.join + end + end +end + +RSpec.shared_examples "PostgreSQL finalized job list plans" do + it "uses the finalized-time index for single-state listings in both directions" do + @driver.send(:runtime_execute, <<~SQL) + INSERT INTO river_job (state, kind, args, finalized_at) + SELECT (ARRAY['cancelled', 'completed', 'discarded'])[1 + n % 3]::river_job_state, + 'list_plan', '{}', now() + n * interval '1 millisecond' + FROM generate_series(1, 10000) n + SQL + @driver.send(:runtime_execute, "ANALYZE river_job") + plans = [] + original = @driver.method(:runtime_job_rows) + @driver.define_singleton_method(:runtime_job_rows) do |suffix| + plans << runtime_query_rows("EXPLAIN SELECT * FROM river_job #{suffix}").map { |row| row.values.join }.join("\n") + original.call(suffix) + end + + %w[cancelled completed discarded].product([:asc, :desc]).each do |state, order| + jobs = @driver.job_list(River::JobListParams.new(states: [state], sort_by: :finalized_at, sort_order: order, limit: 10)) + expect(jobs.length).to eq(10) + expect(plans.last).to include("Index Scan", "river_job_state_and_finalized_at_index") + end + end +end + +RSpec.shared_examples "PostgreSQL rescue concurrency" do + [:complete, :reclaim].each do |change| + it "preserves a concurrent #{change} and rescues another stuck job in the batch" do + now = Time.now.utc + client = River::Client.new(@driver) + first, second = 2.times.map do + row = client.insert(River::JobArgsHash.new("rescue_test", {}), + insert_opts: River::InsertOpts.new(scheduled_at: now - 120, state: "available")).job + @driver.job_claim(id: row.id, attempted_by: "old-worker", now: now - 120) + end + locked = Queue.new + release = Queue.new + writer = Thread.new do + @driver.transaction do + if change == :complete + @driver.job_complete(id: first.id, finalized_at: now, metadata: {"output" => "done"}, now: now) + else + @driver.job_set_state_if_running(id: first.id, scheduled_at: now, state: "available", now: now) + @driver.job_claim(id: first.id, attempted_by: "new-worker", now: now) + end + expected = @driver.send(:runtime_query_rows, "SELECT * FROM river_job WHERE id = #{first.id}") + locked << expected + release.pop(timeout: 5) + end + end + + expected = Timeout.timeout(5) { locked.pop } + expect(@driver.job_rescue_stuck(horizon: now - 60, max: 1, now: now, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(1) + expect(@driver.job_get_by_id(second.id).state).to eq("retryable") + release << true + writer.value + expect(@driver.send(:runtime_query_rows, "SELECT * FROM river_job WHERE id = #{first.id}")).to eq(expected) + expect(@driver.job_rescue_stuck(horizon: now - 60, now: now, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(0) + ensure + release << true if release + writer&.join + end + end +end + +RSpec.shared_examples "SQL scheduling concurrency" do + it "skips locked jobs without overwriting a concurrent reschedule" do + client = River::Client.new(@driver) + now = Time.now.utc + first, second = [now - 2, now - 1].map do |scheduled_at| + client.insert(River::JobArgsHash.new("driver_e2e", {"value" => 1}), + insert_opts: River::InsertOpts.new(scheduled_at: scheduled_at, state: "scheduled")).job + end + locked = Queue.new + release = Queue.new + writer = Thread.new do + @driver.transaction do + @driver.send(:runtime_execute, "UPDATE river_job SET scheduled_at = #{@driver.send(:runtime_time, now + 60)} WHERE id = #{first.id}") + locked << true + release.pop(timeout: 5) + end + end + + Timeout.timeout(5) { locked.pop } + expect(@driver.job_schedule(now: now, max: 1)).to eq(1) + expect(@driver.job_get_by_id(second.id).state).to eq("available") + release << true + writer.value + expect(@driver.job_get_by_id(first.id)).to have_attributes(state: "scheduled", scheduled_at: be_within(0.001).of(now + 60)) + expect(@driver.job_schedule(now: now)).to eq(0) + ensure + release << true if release + writer&.join + end +end + +RSpec.shared_examples "client driver end to end" do + it_behaves_like "cancellation while draining" + it_behaves_like "externally claimed job finalization" + [false, true].product([false, true]).each do |with_cursor, rollback| + it "resumes after a #{rollback ? "rolled-back" : "committed"} #{with_cursor ? "cursor" : "step"} checkpoint" do + received = [] + worker.define_method(:work) do |job| + job.resumable_step(:prepare) {} + operation = ->(cursor = nil) do + received << cursor + job.client.driver.transaction do + job.client.insert(River::JobArgsHash.new("checkpoint_child", {})) unless cursor == 42 + with_cursor ? job.resumable_checkpoint(cursor: 42) : job.resumable_checkpoint + raise "rollback checkpoint" if rollback && job.attempt == 1 + end + raise "retry after commit" if job.attempt == 1 + end + if with_cursor + job.resumable_step_cursor(:import, default: 0, &operation) + else + job.resumable_step(:import, &operation) + end + end + row = e2e_insert + first = River::Testing.perform_job(client, row.id) + expect(first.outcome).to eq(:retried) + expect(first.job.metadata[River::RESUMABLE_STEP_METADATA_KEY]).to eq(rollback ? nil : "import") + expect(client.job_list(River::JobListParams.new(kinds: ["checkpoint_child"])).jobs.length).to eq(rollback ? 0 : 1) + + client.job_retry row.id + expect(River::Testing.perform_job(client, row.id).outcome).to eq(:completed) + expected_cursors = if with_cursor + [0, rollback ? 0 : 42] + else + rollback ? [nil, nil] : [nil] + end + expect(received).to eq(expected_cursors) + expect(client.job_list(River::JobListParams.new(kinds: ["checkpoint_child"])).jobs.length).to eq(1) + end + end + + it "accepts symbolic identifiers for insertion, uniqueness, filtering, and updates" do + states = %i[available pending running scheduled].freeze + args_keys = [:account_id].freeze + opts = River::InsertOpts.new(queue: :imports, state: :pending, + unique_opts: River::UniqueOpts.new(by_args: args_keys, by_state: states, by_queue: true)) + first, second = client.insert_many([1, 2].map do |account_id| + River::InsertManyParams.new(River::JobArgsHash.new(:import, {account_id: account_id}), insert_opts: opts) + end).map(&:job) + duplicate = client.insert(River::JobArgsHash.new("import", {account_id: 1, ignored: true}), + insert_opts: River::InsertOpts.new(queue: "imports", state: "pending", + unique_opts: River::UniqueOpts.new(by_args: ["account_id"], by_state: states.map(&:to_s), by_queue: true))) + + expect(first).to have_attributes(kind: "import", queue: "imports", state: "pending") + expect(second.id).not_to eq(first.id) + expect(duplicate.unique_skipped_as_duplicate?).to be true + expect(duplicate.job.id).to eq(first.id) + filters = {kinds: [:import].freeze, queues: [:imports].freeze, states: [:pending].freeze} + expect(client.job_list(River::JobListParams.new(**filters)).jobs.map(&:id)).to eq([first.id, second.id]) + expect(client.job_update(first.id, River::JobUpdateParams.new(state: :available)).state).to eq("available") + expect(client.job_delete_many(River::JobListParams.new(**filters)).jobs.map(&:id)).to eq([second.id]) + expect(opts.state).to eq(:pending) + end + + it "accepts symbols in queue administration and publishes queue events" do + @driver.queue_upsert("imports") + subscription = client.subscribe(:queue_paused, :queue_resumed) + + expect(client.queue_get(:imports).name).to eq("imports") + expect(client.queue_update(:imports, metadata: {team: "data"}).metadata).to eq("team" => "data") + client.queue_pause :imports + expect(client.queue_get(:imports).paused_at).to be_a(Time) + expect(subscription.pop.kind).to eq(:queue_paused) + client.queue_resume :imports + expect(client.queue_get(:imports).paused_at).to be_nil + expect(subscription.pop.kind).to eq(:queue_resumed) + client.queue_add :imports, 1 + expect(client.queue_remove(:imports)).to equal(client) + ensure + subscription&.close + end + + let(:worker) do + Class.new do + def self.kind = "driver_e2e" + + def next_retry(_job, _error) = Time.now.utc + 0.05 + + def work(job) + raise "permanent failure" if job.args["fail"] + raise "temporary failure" if job.args["retry"] && job.attempt == 1 + + job.output = {"value" => job.args.fetch("value") * 2} + end + end + end + + let(:plugins) { [] } + + let(:client) do + River::Client.new(@driver, config: River::Config.new( + fetch_cooldown: 0.001, + fetch_poll_interval: 0.01, + queues: {"driver_e2e" => 2}, + plugins: plugins, + workers: River::Workers.new.add(worker) + )) + end + + after { client.stop_and_cancel if @driver } + + def e2e_insert(**args) + client.insert(River::JobArgsHash.new("driver_e2e", args), insert_opts: River::InsertOpts.new(queue: "driver_e2e")).job + end + + def next_event(subscription) + Timeout.timeout(5) { subscription.pop } + end + + context "with job-persisted logging" do + let(:plugins) { [River::JobPersistedLogging::Plugin.new] } + + it "appends retry logs to Go-format history and includes them in completion events" do + worker.define_method(:work) do |job| + job.logger << "attempt #{job.attempt}\n" + job.update_metadata application: "kept" + raise "retry" if job.attempt == 1 + end + previous = {"attempt" => 0, "log" => "from Go\n"} + row = client.insert(River::JobArgsHash.new("driver_e2e", {}), insert_opts: River::InsertOpts.new( + metadata: {"river:log" => [previous], "unrelated" => true} + )).job + subscription = client.subscribe(:job_failed, :job_completed) + + first = River::Testing.perform_job(client, row.id) + expect(first.outcome).to eq(:retried) + expect(next_event(subscription).job.metadata.fetch("river:log")).to eq([ + previous, {"attempt" => 1, "log" => "attempt 1\n"} + ]) + client.job_retry(row.id) + second = River::Testing.perform_job(client, row.id) + expect(second.outcome).to eq(:completed) + expected = [previous, {"attempt" => 1, "log" => "attempt 1\n"}, {"attempt" => 2, "log" => "attempt 2\n"}] + expect(next_event(subscription).job.metadata.fetch("river:log")).to eq(expected) + expect(client.job_get(row.id).metadata).to include("river:log" => expected, "application" => "kept", "unrelated" => true) + ensure + subscription&.close + end + + {cancelled: River.job_cancel("cancel"), snoozed: River.job_snooze(10), discarded: RuntimeError.new("fail")}.each do |outcome, error| + it "persists logs when work is #{outcome}" do + worker.define_method(:work) do |job| + job.logger << "before #{outcome}\n" + raise error + end + row = client.insert(River::JobArgsHash.new("driver_e2e", {}), insert_opts: River::InsertOpts.new(max_attempts: 1)).job + result = River::Testing.perform_job(client, row.id) + + expect(result.outcome).to eq(outcome) + expect(result.error).to equal(error) + expect(client.job_get(row.id).metadata.fetch("river:log")).to eq([{"attempt" => 1, "log" => "before #{outcome}\n"}]) + end + end + + it "persists logs when a worker times out" do + worker.define_method(:timeout) { |_job| 0.01 } + worker.define_method(:work) do |job| + job.logger << "before timeout\n" + sleep(60) + end + row = e2e_insert + result = River::Testing.perform_job(client, row.id) + + expect(result.error).to be_a(Timeout::Error) + expect(result.outcome).to eq(:retried) + expect(client.job_get(row.id).metadata.fetch("river:log")).to eq([{"attempt" => 1, "log" => "before timeout\n"}]) + end + + it "persists logs during an interrupted shutdown with the original attempt number" do + entered = Queue.new + worker.define_method(:work) do |job| + job.logger << "before interruption\n" + entered << true + sleep(60) + end + row = e2e_insert + client.start + Timeout.timeout(5) { entered.pop } + client.stop_and_cancel + + stored = client.job_get(row.id) + expect(stored).to have_attributes(state: "available", attempt: 0) + expect(stored.metadata.fetch("river:log")).to eq([{"attempt" => 1, "log" => "before interruption\n"}]) + end + end + + it "asserts insertions and executes synchronously inside the caller's transaction" do + row = nil + @driver.transaction do + inserted = River::Testing.inserted_jobs(client, args: {"value" => 9}, kind: "driver_e2e") { e2e_insert(value: 9) } + + expect(inserted.length).to eq(1) + row = inserted.first + result = River::Testing.perform_job(client, row.id) + + expect(result).to have_attributes(id: row.id, error: nil, outcome: :completed) + expect(result.job).to have_attributes( + attempt: 1, + attempted_by: [client.id], + metadata: include("output" => {"value" => 18}), + state: "completed" + ) + raise @driver.rollback_exception + end + + expect(@driver.job_get_by_id(row.id)).to be_nil + end + + it "drains only the selected queue and returns real worker errors" do + worker.define_method(:next_retry) { |_job, _error| Time.now.utc + 3_600 } + [{"value" => 2}, {"fail" => true, "value" => 3}].each do |args| + client.insert(River::JobArgsHash.new("driver_e2e", args), insert_opts: River::InsertOpts.new( + queue: "driver_e2e", scheduled_at: Time.now.utc - 1, state: "available" + )) + end + + client.insert(River::JobArgsHash.new("driver_e2e", {"value" => 4})) + results = River::Testing.drain(client, queue: "driver_e2e") + + expect(results.map(&:outcome)).to eq([:completed, :retried]) + expect(results.last.error).to have_attributes(message: "permanent failure") + expect(client.job_list(River::JobListParams.new(queues: ["default"])).jobs.first.state).to eq("available") + end + + it "rolls back enqueues and hides uncommitted jobs from another connection" do + rolled_back = nil + @driver.transaction do + rolled_back = e2e_insert(value: 1) + observed = Thread.new { @driver.job_get_by_id(rolled_back.id) }.value + + expect(observed).to be_nil + raise @driver.rollback_exception + end + + expect(@driver.job_get_by_id(rolled_back.id)).to be_nil + expect(client.job_list.jobs).to be_empty + end + + it "works committed bulk inserts in background threads and persists output" do + subscription = client.subscribe(River::EVENT_JOB_COMPLETED) + rows = @driver.transaction do + client.insert_many((1..3).map do |value| + River::InsertManyParams.new(River::JobArgsHash.new("driver_e2e", {"value" => value}), + insert_opts: River::InsertOpts.new(queue: "driver_e2e")) + end).map(&:job) + end + + client.start + events = rows.map { next_event(subscription) } + + expect(events.map { |event| event.job.id }).to match_array(rows.map(&:id)) + rows.each do |row| + expect(client.job_get(row.id)).to have_attributes( + id: row.id, + attempt: 1, + attempted_by: [client.id], + finalized_at: be_a(Time), + metadata: include("output" => {"value" => row.args.fetch("value") * 2}), + state: River::JOB_STATE_COMPLETED + ) + end + + client.stop + + expect(client).to be_stopped + ensure + subscription&.close + end + + it "retries a failed attempt, records its error, and then completes" do + subscription = client.subscribe(River::EVENT_JOB_COMPLETED, River::EVENT_JOB_FAILED) + row = e2e_insert(retry: true, value: 7) + client.start + + expect(next_event(subscription)).to have_attributes(kind: River::EVENT_JOB_FAILED) + expect(next_event(subscription)).to have_attributes(kind: River::EVENT_JOB_COMPLETED) + expect(client.job_get(row.id)).to have_attributes( + attempt: 2, + errors: contain_exactly(have_attributes(attempt: 1, error: "temporary failure")), + metadata: include("output" => {"value" => 14}), + state: River::JOB_STATE_COMPLETED + ) + ensure + subscription&.close + end + + it "discards exhausted jobs and supports retry and cancellation administration" do + subscription = client.subscribe(River::EVENT_JOB_FAILED) + row = client.insert(River::JobArgsHash.new("driver_e2e", {"fail" => true}), + insert_opts: River::InsertOpts.new(max_attempts: 1, queue: "driver_e2e")).job + client.start + + expect(next_event(subscription).job).to have_attributes( + id: row.id, + errors: contain_exactly(have_attributes(error: "permanent failure")), + finalized_at: be_a(Time), + state: River::JOB_STATE_DISCARDED + ) + client.stop + + expect(client.job_retry(row.id)).to have_attributes(finalized_at: nil, max_attempts: 2, state: River::JOB_STATE_AVAILABLE) + expect(client.job_cancel(row.id)).to have_attributes(finalized_at: be_a(Time), state: River::JOB_STATE_CANCELLED) + expect(client.job_delete(row.id)).to have_attributes(id: row.id) + expect(@driver.job_get_by_id(row.id)).to be_nil + ensure + subscription&.close + end +end diff --git a/ruby/spec/client_runtime_branch_spec.rb b/ruby/spec/client_runtime_branch_spec.rb new file mode 100644 index 000000000..f8f4662b5 --- /dev/null +++ b/ruby/spec/client_runtime_branch_spec.rb @@ -0,0 +1,806 @@ +# frozen_string_literal: true + +require "spec_helper" +require "stringio" + +RSpec.describe River::ClientRuntime do + it "leaves startup retryable when capability detection fails before any workers start" do + attempts, releases = 0, 0 + driver = Object.new + driver.define_singleton_method(:init_driver) do + attempts += 1 + raise "detection failed" if attempts == 1 + end + driver.define_singleton_method(:leader_release) { |_id| releases += 1 } + value = runtime(driver: driver) + + expect { value.start }.to raise_error("detection failed") + expect(value).not_to be_started + expect(value).to be_stopped + expect(releases).to eq(0) + expect(value.start).to equal(value) + value.stop + expect(releases).to eq(1) + end + + def row(id: 1, metadata: {}, max_attempts: 1) + River::JobRow.new( + id: id, + args: {}, + attempt: 1, + created_at: Time.now.utc, + kind: "branch_worker", + max_attempts: max_attempts, + metadata: metadata, + priority: 1, + queue: "branch", + scheduled_at: Time.now.utc, + state: River::JOB_STATE_RUNNING + ) + end + + def config(worker: Object.new, queues: {}) + River::Config.new( + id: "branch-runtime", + logger: Logger.new(StringIO.new), + queues: queues, + workers: River::Workers.new.add("branch_worker", worker) + ) + end + + def runtime(driver: Object.new, worker: Object.new, queues: {}) + described_class.new(Object.new, driver, config(queues: queues, worker: worker)) + end + + def execute(runtime, value) + runtime.instance_variable_set( + :@running, + value.id => {queue: value.queue, thread: Thread.current, working: false} + ) + runtime.send(:execute, value) + end + + describe "event timings" do + it "counts finalization hooks as completion time" do + clock = 100.0 + worker = Object.new + worker.define_singleton_method(:work) { |_job| clock += 2 } + plugin = Object.new + plugin.define_singleton_method(:job_finalize) { |_job, _state| clock += 3 } + updated = row + driver = Object.new + driver.define_singleton_method(:job_get_cancelled_ids) { |_ids| [] } + driver.define_singleton_method(:job_complete) do |**| + clock += 4 + updated.state = "completed" + updated + end + value = described_class.new(Object.new, driver, config(worker: worker).with(plugins: [plugin])) + value.define_singleton_method(:monotonic_now) { clock } + subscription = value.subscribe([:job_completed]) + + execute(value, updated) + + expect(subscription.pop(true).stats).to have_attributes(complete_duration: 7.0, run_duration: 2.0) + end + + [ + [nil, :job_completed], + [River::JobCancelError.new, :job_cancelled], + [River::ClientRuntime::Interrupted.new, :job_interrupted], + [River::JobSnoozeError.new(10), :job_snoozed], + [RuntimeError.new("failed"), :job_failed] + ].each do |error, event_kind| + it "separates worker and completion durations for #{event_kind}" do + clock = 100.0 + worker = Object.new + worker.define_singleton_method(:work) do |_job| + clock += 2 + raise error if error + end + updated = row(max_attempts: 25) + updated.scheduled_at = Time.now.utc - 60 + driver = Object.new + driver.define_singleton_method(:job_complete) do |**| + clock += 3 + updated.state = "completed" + updated + end + driver.define_singleton_method(:job_set_state_if_running) do |**params| + clock += 3 + updated.scheduled_at = params[:scheduled_at] if params[:scheduled_at] + updated.state = params.fetch(:state) + updated + end + value = runtime(driver: driver, worker: worker) + value.define_singleton_method(:monotonic_now) { clock } + subscription = value.subscribe([event_kind]) + + execute(value, updated) + + expect(subscription.pop(true).stats).to have_attributes( + complete_duration: 3.0, queue_wait_duration: be_within(1).of(60), run_duration: 2.0 + ) + end + end + + [nil, RuntimeError.new("failed")].each do |error| + it "measures finalization of externally claimed jobs with error #{error.inspect}" do + clock = 100.0 + updated = row + updated.scheduled_at = Time.now.utc - 60 + driver = Object.new + driver.define_singleton_method(:job_complete) do |**| + clock += 3 + updated.state = "completed" + updated + end + driver.define_singleton_method(:job_set_state_if_running) do |**params| + clock += 3 + updated.state = params.fetch(:state) + updated + end + value = runtime(driver: driver) + value.define_singleton_method(:monotonic_now) { clock } + subscription = value.subscribe([error ? :job_failed : :job_completed]) + + value.finish_claimed(updated, error) + + expect(subscription.pop(true).stats).to have_attributes( + complete_duration: 3.0, queue_wait_duration: be_within(1).of(60), run_duration: 0.0 + ) + end + end + end + + it "detects dead runtime threads, excluding intentionally removed or stopped producers" do + value = runtime + thread = Object.new + alive = true + thread.define_singleton_method(:alive?) { alive } + value.instance_variable_set(:@producer_threads, {"branch" => thread}) + expect(value.healthy?).to be true + alive = false + expect(value.healthy?).to be false + value.instance_variable_set(:@removed_queues, {"branch" => true}) + expect(value.healthy?).to be true + value.instance_variable_set(:@maintenance_thread, thread) + expect(value.healthy?).to be false + alive = true + expect(value.healthy?).to be true + alive = false + value.instance_variable_set(:@stop_requested, true) + expect(value.healthy?).to be true + end + + it "interrupts only working attempts through the client runner extension" do + value = runtime + errors = [] + thread = Object.new + thread.define_singleton_method(:raise) { |error| errors << error } + value.instance_variable_set(:@running, {1 => {thread: thread, working: true}, 2 => {thread: thread, working: false}}) + client = River::Client.new(Object.new) + client.instance_variable_set(:@runtime, value) + client.__interrupt_workers + expect(errors).to eq([River::ClientRuntime::Interrupted]) + expect(client.__runtime_healthy?).to be true + end + + [true, false].each do |retry_error| + it "honors worker retry? returning #{retry_error}" do + worker = Object.new + worker.define_singleton_method(:retry?) { |_job, _error| retry_error } + worker.define_singleton_method(:next_retry) { |_job, _error| Time.now.utc + 60 } + updates = [] + driver = Object.new + driver.define_singleton_method(:job_set_state_if_running) { |**params| + updates << params + nil + } + + value = row(max_attempts: 25) + timing = described_class::JobTiming.new(Time.now.utc, 0.0, value.scheduled_at) + runtime(driver: driver, worker: worker).send(:finish_failed, value, + River::Job.new(Object.new, value), RuntimeError.new("failed"), timing, worker: worker) + + expect(updates.last[:state]).to eq(retry_error ? River::JOB_STATE_RETRYABLE : River::JOB_STATE_DISCARDED) + end + end + + it "does not publish completion when an externally claimed job lost its running state" do + driver = Object.new + driver.define_singleton_method(:job_complete) { |**| nil } + + expect(runtime(driver: driver).finish_claimed(row)).to be_nil + end + + it "rejects dynamic periodic registration when leader election is disabled" do + value = described_class.new(Object.new, Object.new, config.with(leader_election_disabled: true)) + periodic = River::PeriodicJob.new(schedule: River::PeriodicInterval.new(60)) {} + value.start + + expect { value.periodic_jobs.add(periodic) }.to raise_error(ArgumentError, "periodic jobs require leader election") + expect(value.periodic_jobs).to be_empty + expect(value.periodic_jobs.add_many([])).to eq([]) + ensure + value&.stop + end + + it "keeps one maintenance thread when periodic jobs are added to a running client" do + driver = Object.new + driver.define_singleton_method(:leader_release) { |_| } + value = runtime(driver: driver) + entered, release = Queue.new, Queue.new + value.define_singleton_method(:maintenance_loop) do + entered << true + release.pop + end + value.start + periodic = River::PeriodicJob.new(schedule: River::PeriodicInterval.new(60)) {} + value.periodic_jobs.add(periodic) + Timeout.timeout(3) { entered.pop } + thread = value.instance_variable_get(:@maintenance_thread) + + value.periodic_jobs.add(periodic) + + expect(value.instance_variable_get(:@threads)).to eq([thread]) + expect(entered).to be_empty + ensure + release << true if release + value&.stop + end + + it "starts a producer and maintenance when adding a queue to a running client" do + value = runtime + calls = [] + value.instance_variable_set(:@started, true) + value.instance_variable_set(:@stop_requested, false) + value.define_singleton_method(:start_producer) { |name, queue_config| calls << [:producer, name, queue_config] } + value.define_singleton_method(:start_maintenance) { calls << [:maintenance] } + + queue_config = value.queue_add("dynamic", 2) + + expect(calls).to eq([[:producer, "dynamic", queue_config], [:maintenance]]) + end + + it "allows retrying a queue addition after its database setup fails" do + driver = Object.new + attempts = 0 + driver.define_singleton_method(:queue_upsert) do |_name| + attempts += 1 + raise "queue setup failed" if attempts == 1 + end + value = runtime(driver: driver) + value.instance_variable_set(:@started, true) + value.define_singleton_method(:producer_loop) { |*_args| } + value.define_singleton_method(:start_maintenance) {} + + expect { value.queue_add("dynamic", 1) }.to raise_error("queue setup failed") + expect { value.queue_add("dynamic", 1) }.not_to raise_error + expect(value.queue_remove("dynamic")).to be true + end + + it "does not launch services after a stop during database setup" do + driver = Object.new + value = runtime(driver: driver, queues: {branch: 1}) + driver.define_singleton_method(:queue_upsert) { |_| value.stop } + driver.define_singleton_method(:leader_release) { |_| } + value.define_singleton_method(:producer_loop) { |*_args| } + value.define_singleton_method(:maintenance_loop) {} + + value.start + + expect(value).to be_stopped + expect(value.instance_variable_get(:@threads)).to be_empty + end + + [:started, :replaced].each do |change| + it "preserves a queue #{change} concurrently with a failed addition" do + driver = Object.new + value = runtime(driver: driver) + value.instance_variable_set(:@started, true) + value.define_singleton_method(:producer_loop) { |*_args| } + value.define_singleton_method(:start_maintenance) {} + queue_config = River::QueueConfig.new(max_workers: 1) + attempts = 0 + driver.define_singleton_method(:queue_upsert) do |name| + attempts += 1 + if attempts == 1 + if change == :started + value.send(:start_producer, name, queue_config) + else + value.queue_remove(name) + value.queue_add(name, 2) + end + raise "original setup failed" + end + end + + expect { value.queue_add("dynamic", queue_config) }.to raise_error("original setup failed") + expect(value.queue_remove("dynamic")).to be true + end + end + + it "does not launch a producer for a queue removed during database setup" do + driver = Object.new + value = runtime(driver: driver, queues: {branch: 1}) + driver.define_singleton_method(:queue_upsert) { |_| value.queue_remove("branch") } + value.define_singleton_method(:producer_loop) { |*_args| } + + value.start + + expect(value.instance_variable_get(:@threads)).to be_empty + end + + it "allows queue changes during startup without launching a producer twice" do + driver = Object.new + value = runtime(driver: driver, queues: {branch: 1}) + driver.define_singleton_method(:queue_upsert) { |name| value.queue_add("dynamic", 1) if name == "branch" } + value.define_singleton_method(:producer_loop) { |*_args| } + value.define_singleton_method(:start_maintenance) {} + + value.start + value.send(:start_producer, "dynamic", River::QueueConfig.new(max_workers: 1)) + expect(value.instance_variable_get(:@threads).length).to eq(2) + value.queue_remove("branch") + value.queue_remove("dynamic") + end + + it "joins only work belonging to a queue as it is removed" do + value = runtime(queues: {keep: 1, remove: 1}) + joins = [] + producer = Object.new + producer.define_singleton_method(:join) { joins << :producer } + removed_worker = Object.new + removed_worker.define_singleton_method(:join) { joins << :removed_worker } + kept_worker = Object.new + kept_worker.define_singleton_method(:join) { joins << :kept_worker } + value.instance_variable_set(:@producer_threads, {"remove" => producer}) + value.instance_variable_set( + :@running, + { + 1 => {queue: "remove", thread: removed_worker, working: true}, + 2 => {queue: "keep", thread: kept_worker, working: true} + } + ) + + expect(value.queue_remove("remove")).to be true + expect(joins).to eq([:producer, :removed_worker]) + end + + it "rejects blocking lifecycle calls from a producer before requesting shutdown" do + value = runtime(queues: {branch: 1}) + value.instance_variable_set(:@stopped, false) + value.instance_variable_set(:@producer_threads, {"branch" => Thread.current}) + value.instance_variable_set(:@threads, [Thread.current]) + + expect { value.queue_remove("branch") }.to raise_error(ThreadError, /own worker or producer/) + expect { value.stop }.to raise_error(ThreadError, /wait: false/) + expect(value.send(:queue_stopping?, "branch")).to be_falsey + end + + it "handles a temporarily failing producer and retries until stopped" do + driver = Object.new + attempts = 0 + driver.define_singleton_method(:queue_get) do |_queue| + attempts += 1 + raise "temporary producer failure" + end + value = runtime(driver: driver) + value.define_singleton_method(:wait) { |_duration| @stop_requested = true if attempts == 2 } + + expect { value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1)) }.not_to raise_error + expect(attempts).to eq(2) + end + + it "does not retry a failed producer after its queue stops" do + driver = Object.new + attempts = 0 + driver.define_singleton_method(:queue_get) do |_queue| + attempts += 1 + raise "terminal producer failure" + end + value = runtime(driver: driver) + value.define_singleton_method(:wait) { |_duration| @stop_requested = true } + + expect { value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1)) }.not_to raise_error + expect(attempts).to eq(1) + end + + it "can poll a queue before its persisted queue row is visible" do + driver = Object.new + driver.define_singleton_method(:queue_get) { |_queue| nil } + fetched = false + driver.define_singleton_method(:job_get_available) do |**| + fetched = true + [] + end + value = runtime(driver: driver) + value.define_singleton_method(:wait) { |_duration| @stop_requested = true } + + value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1)) + + expect(fetched).to be true + end + + it "honors fetch cooldown even when inserts or completions wake the producer early" do + now = 100.0 + fetched_at = [] + waits = [] + job = row + driver = Object.new + driver.define_singleton_method(:queue_get) { |_| nil } + driver.define_singleton_method(:job_get_available) do |**| + fetched_at << now + [job] + end + value = runtime(driver: driver) + value.define_singleton_method(:monotonic_now) { now } + value.define_singleton_method(:launch) { |_| @stop_requested = true if fetched_at.length == 2 } + value.define_singleton_method(:wait) do |duration| + waits << duration + now += (waits.length == 1) ? 0.25 : duration + end + + value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1, fetch_cooldown: 1, fetch_poll_interval: 1)) + + expect(fetched_at).to eq([100.0, 101.0]) + expect(waits).to eq([1.0, 0.75]) + end + + it "stops a producer woken during its fetch cooldown without another fetch" do + now = 100.0 + fetched_at = [] + job = row + driver = Object.new + driver.define_singleton_method(:queue_get) { |_| nil } + driver.define_singleton_method(:job_get_available) do |**| + fetched_at << now + [job] + end + value = runtime(driver: driver) + value.define_singleton_method(:monotonic_now) { now } + value.define_singleton_method(:launch) { |_| } + value.define_singleton_method(:wait) { |_| @stop_requested = true } + + value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1, fetch_cooldown: 1, fetch_poll_interval: 1)) + + expect(fetched_at).to eq([100.0]) + end + + it "keeps observing active jobs when shutdown interrupts the fetch cooldown" do + checked = [] + fetched = 0 + job = row + driver = Object.new + driver.define_singleton_method(:job_get_cancelled_ids) { |ids| + checked << ids + [] + } + driver.define_singleton_method(:queue_get) { |_| nil } + driver.define_singleton_method(:job_get_available) { |**| + fetched += 1 + [job] + } + value = runtime(driver: driver) + value.define_singleton_method(:monotonic_now) { 100.0 } + value.define_singleton_method(:launch) do |job| + @running[job.id] = {queue: "branch", thread: Thread.current, working: true} + end + value.define_singleton_method(:wait) { |_| @stop_requested = true } + value.define_singleton_method(:wait_for_running_jobs) { |_, _| @running.clear } + + value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 2, fetch_cooldown: 1, fetch_poll_interval: 1)) + + expect(fetched).to eq(1) + expect(checked).to eq([[1], [1]]) + end + + it "rechecks queue pause state after waiting for its fetch cooldown" do + now = 100.0 + paused_at = nil + fetched_at = [] + job = row + driver = Object.new + driver.define_singleton_method(:queue_get) { |_| Struct.new(:paused_at).new(paused_at) } + driver.define_singleton_method(:job_get_available) do |**| + fetched_at << now + [job] + end + value = runtime(driver: driver) + value.define_singleton_method(:monotonic_now) { now } + value.define_singleton_method(:launch) { |_| @stop_requested = true if fetched_at.length == 2 } + value.define_singleton_method(:wait) do |duration| + @stop_requested = true if paused_at + paused_at = Time.now.utc + now += duration + end + + value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1, fetch_cooldown: 1, fetch_poll_interval: 1)) + + expect(fetched_at).to eq([100.0]) + end + + it "skips maintenance work while another client holds leadership" do + driver = Object.new + driver.define_singleton_method(:leader_acquire) { |_id, **| false } + value = runtime(driver: driver) + checks = 0 + value.define_singleton_method(:stopping?) do + checks += 1 + checks >= 2 + end + + value.define_singleton_method(:wait) { |_duration| } + + value.send(:maintenance_loop) + + expect(checks).to eq(2) + end + + it "supports drivers without a notification outbox cleaner" do + calls = [] + driver = Object.new + driver.define_singleton_method(:leader_acquire) { |*_args, **_options| true } + [:job_schedule, :job_rescue_stuck, :job_delete_finalized].each do |method| + driver.define_singleton_method(method) { |**_options| calls << method } + end + value = runtime(driver: driver) + value.define_singleton_method(:wait) { |_duration| @stop_requested = true } + + value.send(:maintenance_loop) + expect(calls).to eq([:job_schedule, :job_rescue_stuck, :job_delete_finalized]) + end + + it "retries a temporarily failing maintenance loop" do + driver = Object.new + driver.define_singleton_method(:leader_acquire) { |_id, **| raise "temporary maintenance failure" } + value = runtime(driver: driver) + checks = 0 + value.define_singleton_method(:stopping?) do + checks += 1 + checks >= 3 + end + + value.define_singleton_method(:wait) { |_duration| } + + expect { value.send(:maintenance_loop) }.not_to raise_error + expect(checks).to eq(3) + end + + it "does not retry failed maintenance after stop begins" do + driver = Object.new + driver.define_singleton_method(:leader_acquire) { |_id, **| raise "terminal maintenance failure" } + value = runtime(driver: driver) + checks = 0 + value.define_singleton_method(:stopping?) do + checks += 1 + checks >= 2 + end + + value.define_singleton_method(:wait) { |_duration| } + + expect { value.send(:maintenance_loop) }.not_to raise_error + expect(checks).to eq(2) + end + + it "isolates a failing service while continuing maintenance and renewing leadership" do + calls = [] + output = StringIO.new + driver = Object.new + [:leader_acquire, :leader_renew].each do |method| + driver.define_singleton_method(method) do |*_args, **_options| + calls << method + true + end + end + [:job_schedule, :job_rescue_stuck, :job_delete_finalized, :notification_delete_before].each do |method| + driver.define_singleton_method(method) { |**_options| calls << method } + end + service = Object.new + service.define_singleton_method(:run) do |*_args| + calls << :broken_service + raise "persistent service failure" + end + healthy_service = Object.new + healthy_service.define_singleton_method(:run) { |*_args| calls << :healthy_service } + value = described_class.new(Object.new, driver, config.with(logger: Logger.new(output), maintenance_services: [service, healthy_service])) + waits = 0 + value.define_singleton_method(:wait) do |_duration| + waits += 1 + @stop_requested = true if waits == 2 + end + + value.send(:maintenance_loop) + + expect(calls).to eq([ + :leader_acquire, :job_schedule, :broken_service, :healthy_service, + :job_rescue_stuck, :job_delete_finalized, :notification_delete_before, :leader_renew + ]) + expect(output.string).to include("River maintenance service failed", "persistent service failure") + end + + it "logs and isolates a periodic constructor failure" do + output = StringIO.new + periodic = River::PeriodicJob.new( + constructor: -> { raise "periodic failed" }, + run_on_start: true, + schedule: River::PeriodicInterval.new(60) + ) + runtime_config = config.with(logger: Logger.new(output), periodic_jobs: [periodic]) + value = described_class.new(Object.new, Object.new, runtime_config) + + expect { value.send(:run_periodic, Time.now.utc + 1) }.not_to raise_error + expect(output.string).to include("River periodic job failed to insert", "periodic failed") + end + + it "logs a broken periodic schedule and still inserts healthy jobs" do + output = StringIO.new + broken = River::PeriodicJob.new(schedule: ->(_) { raise "bad schedule" }, run_on_start: true) {} + good = River::PeriodicJob.new(schedule: River::PeriodicInterval.new(60), run_on_start: true) { :args } + inserted = [] + client = Object.new + client.define_singleton_method(:insert) { |args, **| inserted << args } + value = described_class.new(client, Object.new, config.with(logger: Logger.new(output), periodic_jobs: [broken, good])) + + value.send(:run_periodic, Time.now.utc + 1) + + expect(inserted).to eq([:args]) + expect(output.string).to include("River periodic job schedule failed", "bad schedule") + end + + it "handles a job deleted after successful work" do + worker = Class.new { + def work(_job) + end + } + + driver = Object.new + driver.define_singleton_method(:job_complete) { |**| nil } + driver.define_singleton_method(:job_set_state_if_running) { |**| nil } + value = runtime(driver: driver, worker: worker) + + expect { execute(value, row) }.not_to raise_error + end + + it "uses the completion operation without fetching a full job" do + worker = Class.new { + def work(_job) + end + } + + driver = Object.new + checked = [] + driver.define_singleton_method(:job_complete) { |**params| + checked << params[:id] + nil + } + driver.define_singleton_method(:job_set_state_if_running) { |**| nil } + value = runtime(driver: driver, worker: worker) + + expect(execute(value, row)).to eq([:completed, nil]) + expect(checked).to eq([1]) + end + + it "turns a post-work cancellation marker into a cancelled attempt" do + worker = Class.new { + def work(_job) + end + } + + driver = Object.new + driver.define_singleton_method(:job_complete) { |**| :cancelled } + driver.define_singleton_method(:job_set_state_if_running) { |**| nil } + value = runtime(driver: driver, worker: worker) + + expect(execute(value, row).first).to eq(:cancelled) + end + + it "handles an interrupt after another actor has already transitioned the job" do + worker = Class.new { def work(_job) = raise(River::ClientRuntime::Interrupted) } + driver = Object.new + driver.define_singleton_method(:job_set_state_if_running) { |**| nil } + value = runtime(driver: driver, worker: worker) + + expect { execute(value, row) }.not_to raise_error + end + + it "handles a snooze after another actor has already transitioned the job" do + worker = Class.new { def work(_job) = raise(River.job_snooze(10)) } + driver = Object.new + driver.define_singleton_method(:job_set_state_if_running) { |**| nil } + value = runtime(driver: driver, worker: worker) + + expect { execute(value, row) }.not_to raise_error + end + + it "handles a failure after another actor has already transitioned the job" do + worker = Class.new { def work(_job) = raise("failed") } + driver = Object.new + driver.define_singleton_method(:job_set_state_if_running) { |**| nil } + value = runtime(driver: driver, worker: worker) + + expect { execute(value, row) }.not_to raise_error + end + + it "checks cancellation in one batch, excluding inactive work and other queues" do + raised = [] + fake_thread = Object.new + fake_thread.define_singleton_method(:raise) { |error| raised << error } + checked = [] + driver = Object.new + driver.define_singleton_method(:job_get_cancelled_ids) { |ids| + checked << ids + [3] + } + value = runtime(driver: driver) + value.instance_variable_set( + :@running, + { + 1 => {queue: "other", thread: fake_thread, working: true}, + 2 => {queue: "branch", thread: fake_thread, working: false}, + 3 => {queue: "branch", thread: fake_thread, working: true} + } + ) + + value.send(:check_remote_cancellations, "branch") + + expect(raised).to eq([River::JobCancelError]) + expect(checked).to eq([[3]]) + end + + it "interrupts work that begins after stop was requested" do + value = runtime + value.instance_variable_set(:@stop_requested, true) + value.instance_variable_set( + :@running, + 1 => {queue: "branch", thread: Thread.current, working: false} + ) + + expect { value.send(:begin_work, 1) }.to raise_error(River::ClientRuntime::Interrupted) + end + + it "tolerates work disappearing before its working flag is cleared" do + value = runtime + + expect(value.send(:finish_work, 999)).to be_nil + end + + it "does not start a second live maintenance thread" do + value = runtime + release = Queue.new + thread = Thread.new { release.pop } + value.instance_variable_set(:@maintenance_thread, thread) + value.instance_variable_set(:@threads, [thread]) + + expect(value.send(:start_maintenance)).to be_nil + expect(value.instance_variable_get(:@threads)).to eq([thread]) + ensure + release << true + thread&.join + end + + it "does not enter a condition wait once stop is requested" do + value = runtime + value.instance_variable_set(:@stop_requested, true) + + expect(value.send(:wait, 0)).to be_nil + end + + it "waits during shutdown only while the draining queue still has active attempts" do + waits = [] + condition = Object.new + condition.define_singleton_method(:wait) { |mutex, duration| waits << [mutex.owned?, duration] } + value = runtime + value.instance_variable_set(:@condition, condition) + value.instance_variable_set(:@stop_requested, true) + running = {1 => {queue: "branch"}, 2 => {queue: "other"}} + value.instance_variable_set(:@running, running) + + value.send(:wait_for_running_jobs, "branch", 2) + running.delete(1) + value.send(:wait_for_running_jobs, "branch", 2) + + expect(waits).to eq([[true, 2]]) + end +end diff --git a/ruby/spec/client_spec.rb b/ruby/spec/client_spec.rb new file mode 100644 index 000000000..d9fe3a346 --- /dev/null +++ b/ruby/spec/client_spec.rb @@ -0,0 +1,735 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" + +class SimpleArgs + attr_accessor :job_num + + def initialize(job_num:) + self.job_num = job_num + end + + def kind = "simple" + + def to_json = JSON.dump({job_num: job_num}) +end + +# Lets us test job-specific insertion opts by making `#insert_opts` an accessor. +# Real args that make use of this functionality will probably want to make +# `#insert_opts` a non-accessor method instead. +class SimpleArgsWithInsertOpts < SimpleArgs + attr_accessor :insert_opts +end + +class ComplexArgs + attr_accessor :customer_id + attr_accessor :order_id + attr_accessor :trace_id + attr_accessor :email + + def initialize(customer_id:, order_id:, trace_id:, email:) + self.customer_id = customer_id + self.order_id = order_id + self.trace_id = trace_id + self.email = email + end + + def kind = "complex" + + # intentionally not sorted alphabetically so we can ensure that the JSON + # used in the unique key is sorted. + def to_json = JSON.dump({order_id: order_id, customer_id: customer_id, trace_id: trace_id, email: email}) +end + +# I originally had this top-level client test set up so that it was using a mock +# driver, but it just turned out to be too horribly unsustainable. Adding +# anything new required careful mock engineering, and even once done, we weren't +# getting good guarantees that the right things were happening because it wasn't +# end to end. We now use the real Sequel driver, with the only question being +# whether we should maybe move all these tests into the common driver shared +# examples so that all drivers get the full barrage. +RSpec.describe River::Client do + around(:each) { |ex| available_test_transaction(&ex) } + + let!(:driver) { River::Driver::Sequel.new(available_test_database) } + let(:client) { River::Client.new(driver) } + + describe "#insert" do + it "normalizes symbolic kinds and argument-level options before calling plugins and drivers" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + def args.kind = :simple + args.insert_opts = River::InsertOpts.new(queue: :critical, state: :pending) + plugin = Object.new + observed = nil + plugin.define_singleton_method(:insert_begin) { |params| observed = [params.kind, params.queue, params.state] } + client = River::Client.new(driver, config: River::Config.new(plugins: [plugin])) + + row = client.insert(args).job + + expect(observed).to eq(%w[simple critical pending]) + expect(row).to have_attributes(kind: "simple", queue: "critical", state: "pending") + end + + it "inserts a job with defaults" do + insert_res = client.insert(SimpleArgs.new(job_num: 1)) + + expect(insert_res).to be_a(River::JobInsertResult) + expect(insert_res.job).to have_attributes( + args: {"job_num" => 1}, + attempt: 0, + created_at: be_within(2).of(Time.now), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + end + + it "schedules a job" do + target_time = Time.now + 1 * 3600 + + insert_res = client.insert( + SimpleArgs.new(job_num: 1), + insert_opts: River::InsertOpts.new(scheduled_at: target_time) + ) + + expect(insert_res.job).to have_attributes( + scheduled_at: be_within(2).of(target_time), + state: River::JOB_STATE_SCHEDULED + ) + + # Expect all inserted timestamps to go to UTC. + expect(insert_res.job.scheduled_at.utc?).to be true + end + + it "inserts with job insert opts" do + job_args = SimpleArgsWithInsertOpts.new(job_num: 1) + job_args.insert_opts = River::InsertOpts.new( + max_attempts: 23, + priority: 2, + queue: "job_custom_queue", + tags: ["job_custom"] + ) + + insert_res = client.insert(job_args) + + expect(insert_res.job).to have_attributes( + max_attempts: 23, + priority: 2, + queue: "job_custom_queue", + tags: ["job_custom"] + ) + end + + it "inserts with insert opts" do + # We set job insert opts in this spec too so that we can verify that the + # options passed at insertion time take precedence. + job_args = SimpleArgsWithInsertOpts.new(job_num: 1) + job_args.insert_opts = River::InsertOpts.new( + max_attempts: 23, + priority: 2, + queue: "job_custom_queue", + tags: ["job_custom"] + ) + + insert_res = client.insert(job_args, insert_opts: River::InsertOpts.new( + max_attempts: 17, + priority: 3, + queue: "my_queue", + tags: ["custom"] + )) + + expect(insert_res.job).to have_attributes( + max_attempts: 17, + priority: 3, + queue: "my_queue", + tags: ["custom"] + ) + end + + it "inserts with job args hash" do + insert_res = client.insert(River::JobArgsHash.new("hash_kind", { + job_num: 1 + })) + expect(insert_res.job).to have_attributes( + args: {"job_num" => 1}, + kind: "hash_kind" + ) + end + + it "errors if args don't respond to #kind" do + args_klass = Class.new do + def to_json = {} + end + + expect do + client.insert(args_klass.new) + end.to raise_error(ArgumentError, "args should respond to `#kind`") + end + + it "errors if args return nil from #to_json" do + args_klass = Class.new do + def kind = "args_kind" + + def to_json = nil + end + + expect do + client.insert(args_klass.new) + end.to raise_error(ArgumentError, "args should return non-nil from `#to_json`") + end + + it "raises error if tags are too long" do + expect do + client.insert(SimpleArgs.new(job_num: 1), insert_opts: River::InsertOpts.new( + tags: ["a" * 256] + )) + end.to raise_error(ArgumentError, "tags should be 255 characters or less") + end + + it "raises error if tags are misformatted" do + expect do + client.insert(SimpleArgs.new(job_num: 1), insert_opts: River::InsertOpts.new( + tags: ["no,commas,allowed"] + )) + end.to raise_error(ArgumentError, 'tag should match regex /\A\w[\w-]+\w\z/') + end + + describe "queue validation" do + ["billing-queue:V2.high_priority", "a" * 127, :critical].each do |queue| + it "accepts queue #{queue.inspect}" do + expect(client.insert(SimpleArgs.new(job_num: 1), queue: queue).job.queue).to eq(queue.to_s) + end + end + + it "allows an explicit queue to override an invalid argument-level queue" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new(queue: "billing queue") + + expect(client.insert(args, queue: "billing").job.queue).to eq("billing") + end + + ["", "billing queue", "billing/queue", "billing\n", "a" * 128, :"billing queue"].each do |queue| + it "rejects queue #{queue.inspect}" do + expect { client.insert(SimpleArgs.new(job_num: 1), queue: queue) } + .to raise_error(ArgumentError, "invalid queue name: #{queue.to_s.inspect}") + expect(client.job_list.jobs).to be_empty + end + end + + it "rejects an invalid argument-level queue" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new(queue: "billing queue") + + expect { client.insert(args) }.to raise_error(ArgumentError, 'invalid queue name: "billing queue"') + expect(client.job_list.jobs).to be_empty + end + end + + def check_bigint_bounds(int) + raise "lock key shouldn't be larger than Postgres bigint max (9223372036854775807); was: #{int}" if int > 9223372036854775807 + raise "lock key shouldn't be smaller than Postgres bigint min (-9223372036854775808); was: #{int}" if int < -9223372036854775808 + + int + end + + # These unique insertion specs are pretty mock heavy, but each of the + # individual drivers has their own unique insert tests that make sure to do + # a full round trip. + describe "unique opts" do + let(:now) { Time.now.utc } + before { client.instance_variable_set(:@time_now_utc, -> { now }) } + + describe "period validation" do + [1, Rational(3, 2)].each do |period| + it "accepts a period of #{period} seconds" do + result = client.insert(SimpleArgs.new(job_num: 1), unique_opts: River::UniqueOpts.new(by_period: period)) + boundary = client.send(:truncate_time, now, period).utc.strftime("%FT%TZ") + + expect(result.job.unique_key).to eq(Digest::SHA256.digest("&kind=simple&period=#{boundary}")) + end + end + + it "allows explicit unique options to override an invalid argument-level period" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_period: 0.5)) + + expect(client.insert(args, unique_opts: River::UniqueOpts.new(by_period: 1)).job.unique_key).not_to be_nil + end + + [nil, 0].each do |period| + it "omits the period dimension for #{period.inspect}" do + result = client.insert(SimpleArgs.new(job_num: 1), unique_opts: River::UniqueOpts.new(by_period: period)) + expect(result.job.unique_key).to be_nil + + result = client.insert(SimpleArgs.new(job_num: 2), unique_opts: River::UniqueOpts.new(by_period: period, by_queue: true)) + expect(result.job.unique_key).to eq(Digest::SHA256.digest("&kind=simple&queue=default")) + end + end + + [-1, -0.5, 0.5, Rational(999, 1000)].each do |period| + it "rejects a period of #{period} seconds" do + expect { client.insert(SimpleArgs.new(job_num: 1), unique_opts: River::UniqueOpts.new(by_period: period)) } + .to raise_error(ArgumentError, "by_period should not be less than 1 second") + expect(client.job_list.jobs).to be_empty + end + end + + it "rejects an invalid argument-level period" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_period: 0.5)) + + expect { client.insert(args) }.to raise_error(ArgumentError, "by_period should not be less than 1 second") + expect(client.job_list.jobs).to be_empty + end + end + + it "inserts a new unique job with minimal options" do + job_args = SimpleArgsWithInsertOpts.new(job_num: 1) + job_args.insert_opts = River::InsertOpts.new( + unique_opts: River::UniqueOpts.new( + by_queue: true + ) + ) + + insert_res = client.insert(job_args) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(false) + ) + + unique_key_str = "&kind=#{insert_res.job.kind}" \ + "&queue=#{River::QUEUE_DEFAULT}" + + expect(insert_res.job).to have_attributes( + unique_key: Digest::SHA256.digest(unique_key_str), + unique_states: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_COMPLETED, River::JOB_STATE_PENDING, River::JOB_STATE_RETRYABLE, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] + ) + + insert_res = client.insert(job_args) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(true) + ) + end + + it "inserts a new unique job with custom states" do + job_args = SimpleArgsWithInsertOpts.new(job_num: 1) + job_args.insert_opts = River::InsertOpts.new( + unique_opts: River::UniqueOpts.new( + by_queue: true, + by_state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] + ) + ) + + insert_res = client.insert(job_args) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(false) + ) + + lock_str = "&kind=#{job_args.kind}" \ + "&queue=#{River::QUEUE_DEFAULT}" + + expect(insert_res.job).to have_attributes( + unique_key: Digest::SHA256.digest(lock_str), + unique_states: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] + ) + + insert_res = client.insert(job_args) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(true) + ) + end + + it "inserts a new unique job with all options" do + job_args = ComplexArgs.new(customer_id: 1, email: "john@example.com", order_id: 2, trace_id: 3) + insert_opts = River::InsertOpts.new( + unique_opts: River::UniqueOpts.new( + by_args: true, + by_period: 15 * 60, + by_queue: true, + by_state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_CANCELLED, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED], + exclude_kind: true + ) + ) + + insert_res = client.insert(job_args, insert_opts: insert_opts) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(false) + ) + + sorted_json = {customer_id: 1, email: "john@example.com", order_id: 2, trace_id: 3} + unique_key_str = "&args=#{JSON.dump(sorted_json)}" \ + "&period=#{client.send(:truncate_time, now, 15 * 60).utc.strftime("%FT%TZ")}" \ + "&queue=#{River::QUEUE_DEFAULT}" + + expect(insert_res.job).to have_attributes( + unique_key: Digest::SHA256.digest(unique_key_str), + unique_states: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_CANCELLED, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] + ) + + insert_res = client.insert(job_args, insert_opts: insert_opts) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(true) + ) + end + + it "inserts a new unique job with custom by_args" do + job_args = ComplexArgs.new(customer_id: 1, email: "john@example.com", order_id: 2, trace_id: 3) + insert_opts = River::InsertOpts.new( + unique_opts: River::UniqueOpts.new(by_args: ["customer_id", "order_id"]) + ) + + insert_res = client.insert(job_args, insert_opts: insert_opts) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(false) + ) + original_job_id = insert_res.job.id + + unique_key_str = "&kind=complex&args=#{JSON.dump({customer_id: 1, order_id: 2})}" + + expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(unique_key_str)) + + insert_res = client.insert(job_args, insert_opts: insert_opts) + + expect(insert_res.job).to_not be_nil + expect(insert_res).to have_attributes( + job: have_attributes(id: original_job_id), + unique_skipped_as_duplicate?: be(true) + ) + + # Change just the customer ID and the job should be unique again. + job_args.customer_id = 2 + insert_res = client.insert(job_args, insert_opts: insert_opts) + + expect(insert_res.job).to_not be_nil + expect(insert_res.job.id).to_not eq(original_job_id) + expect(insert_res.unique_skipped_as_duplicate?).to be false + end + + it "inserts a new unique job with period determined from `scheduled_at`" do + job_args = ComplexArgs.new(customer_id: 1, email: "john@example.com", order_id: 2, trace_id: 3) + insert_opts = River::InsertOpts.new( + scheduled_at: now + 3600, + unique_opts: River::UniqueOpts.new( + by_period: 15 * 60 + ) + ) + + insert_res = client.insert(job_args, insert_opts: insert_opts) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(false) + ) + + unique_key_str = "&kind=#{insert_res.job.kind}" \ + "&period=#{client.send(:truncate_time, now + 3600, 15 * 60).utc.strftime("%FT%TZ")}" + + expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(unique_key_str)) + end + + it "skips unique check if unique opts empty" do + job_args = SimpleArgsWithInsertOpts.new(job_num: 1) + job_args.insert_opts = River::InsertOpts.new( + unique_opts: River::UniqueOpts.new + ) + + insert_res = client.insert(job_args) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(false) + ) + end + + it "errors if any of the required unique states are removed from a custom by_states list" do + default_states = [River::JOB_STATE_AVAILABLE, River::JOB_STATE_COMPLETED, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_RETRYABLE, River::JOB_STATE_SCHEDULED] + required_states = [River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] + required_states.each do |state| + job_args = SimpleArgsWithInsertOpts.new(job_num: 1) + job_args.insert_opts = River::InsertOpts.new( + unique_opts: River::UniqueOpts.new( + by_state: default_states - [state] + ) + ) + + expect do + client.insert(job_args) + end.to raise_error(ArgumentError, "by_state should include required state #{state}") + end + end + end + end + + describe "#insert_many" do + describe "option validation" do + [ + [River::InsertOpts.new(max_attempts: 0), "max_attempts must be greater than zero"], + [River::InsertOpts.new(max_attempts: -1), "max_attempts must be greater than zero"], + [River::InsertOpts.new(queue: "billing queue"), 'invalid queue name: "billing queue"'], + [River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_period: -1)), "by_period should not be less than 1 second"], + [River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_period: 0.5)), "by_period should not be less than 1 second"] + ].each do |options, error| + it "rejects argument-level options with #{error} without inserting any jobs" do + args = SimpleArgsWithInsertOpts.new(job_num: 2) + args.insert_opts = options + + expect { client.insert_many([SimpleArgs.new(job_num: 1), args]) }.to raise_error(ArgumentError, error) + expect(client.job_list.jobs).to be_empty + end + + it "rejects explicit options with #{error} without inserting any jobs" do + expect do + client.insert_many([ + SimpleArgs.new(job_num: 1), + River::InsertManyParams.new(SimpleArgs.new(job_num: 2), insert_opts: options) + ]) + end.to raise_error(ArgumentError, error) + expect(client.job_list.jobs).to be_empty + end + end + end + + it "inserts jobs from jobArgs with defaults" do + results = client.insert_many([ + SimpleArgs.new(job_num: 1), + SimpleArgs.new(job_num: 2) + ]) + + expect(results.length).to eq(2) + expect(results[0].job).to have_attributes(args: {"job_num" => 1}) + expect(results[1].job).to have_attributes(args: {"job_num" => 2}) + + jobs = driver.job_list + + expect(jobs.count).to be 2 + + expect(jobs[0]).to have_attributes( + args: {"job_num" => 1}, + attempt: 0, + created_at: be_within(2).of(Time.now), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + + expect(jobs[1]).to have_attributes( + args: {"job_num" => 2}, + attempt: 0, + created_at: be_within(2).of(Time.now), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + end + + it "inserts jobs from InsertManyParams with defaults" do + results = client.insert_many([ + River::InsertManyParams.new(SimpleArgs.new(job_num: 1)), + River::InsertManyParams.new(SimpleArgs.new(job_num: 2)) + ]) + + expect(results.length).to eq(2) + expect(results[0].job).to have_attributes(args: {"job_num" => 1}) + expect(results[1].job).to have_attributes(args: {"job_num" => 2}) + + jobs = driver.job_list + + expect(jobs.count).to be 2 + + expect(jobs[0]).to have_attributes( + args: {"job_num" => 1}, + attempt: 0, + created_at: be_within(2).of(Time.now), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + + expect(jobs[1]).to have_attributes( + args: {"job_num" => 2}, + attempt: 0, + created_at: be_within(2).of(Time.now), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + end + + it "inserts jobs with insert opts" do + # First, insert a job which will cause a duplicate conflict with the bulk + # insert so the bulk insert's row gets skipped. + dupe_job_args = SimpleArgsWithInsertOpts.new(job_num: 0) + dupe_job_args.insert_opts = River::InsertOpts.new( + queue: "job_to_duplicate", + unique_opts: River::UniqueOpts.new( + by_queue: true + ) + ) + + insert_res = client.insert(dupe_job_args) + + expect(insert_res.job).to_not be_nil + + # We set job insert opts in this spec too so that we can verify that the + # options passed at insertion time take precedence. + args1 = SimpleArgsWithInsertOpts.new(job_num: 1) + args1.insert_opts = River::InsertOpts.new( + max_attempts: 23, + priority: 2, + queue: "job_custom_queue_1", + tags: ["job_custom_1"] + ) + args2 = SimpleArgsWithInsertOpts.new(job_num: 2) + args2.insert_opts = River::InsertOpts.new( + max_attempts: 24, + priority: 3, + queue: "job_custom_queue_2", + tags: ["job_custom_2"] + ) + args3 = SimpleArgsWithInsertOpts.new(job_num: 3) + args3.insert_opts = River::InsertOpts.new( + queue: "to_duplicate", # duplicate by queue, will be skipped + tags: ["job_custom_3"], + unique_opts: River::UniqueOpts.new( + by_queue: true + ) + ) + + results = client.insert_many([ + River::InsertManyParams.new(args1, insert_opts: River::InsertOpts.new( + max_attempts: 17, + priority: 3, + queue: "my_queue_1", + tags: ["custom_1"] + )), + River::InsertManyParams.new(args2, insert_opts: River::InsertOpts.new( + max_attempts: 18, + priority: 4, + queue: "my_queue_2", + tags: ["custom_2"] + )), + River::InsertManyParams.new(args3, insert_opts: River::InsertOpts.new( + queue: "job_to_duplicate", # duplicate by queue, will be skipped + tags: ["custom_3"], + unique_opts: River::UniqueOpts.new( + by_queue: true + ) + )) + ]) + + expect(results.length).to eq(3) # all rows returned, including skipped duplicates + expect(results[0].job).to have_attributes(tags: ["custom_1"]) + expect(results[1].job).to have_attributes(tags: ["custom_2"]) + expect(results[2]).to have_attributes( + job: have_attributes( + id: insert_res.job.id, + tags: [] + ), + unique_skipped_as_duplicate?: (be true) + ) + + jobs = driver.job_list + + expect(jobs.count).to be 3 + + expect(jobs[0]).to have_attributes(queue: "job_to_duplicate") + + expect(jobs[1]).to have_attributes( + max_attempts: 17, + priority: 3, + queue: "my_queue_1", + tags: ["custom_1"] + ) + + expect(jobs[2]).to have_attributes( + max_attempts: 18, + priority: 4, + queue: "my_queue_2", + tags: ["custom_2"] + ) + end + end + + describe River::Client.const_get(:DEFAULT_UNIQUE_STATES) do + it "should be sorted" do + expect(River::Client.const_get(:DEFAULT_UNIQUE_STATES)).to eq(River::Client.const_get(:DEFAULT_UNIQUE_STATES).sort) + end + end + + describe "#truncate_time" do + it "truncates times to nearest interval" do + expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 1 * 60).utc).to eq(Time.parse("Thu Jan 15 21:26:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing + expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 5 * 60).utc).to eq(Time.parse("Thu Jan 15 21:25:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing + expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 15 * 60).utc).to eq(Time.parse("Thu Jan 15 21:15:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing + expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 1 * 60 * 60).utc).to eq(Time.parse("Thu Jan 15 21:00:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing + expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 5 * 60 * 60).utc).to eq(Time.parse("Thu Jan 15 19:00:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing + expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 24 * 60 * 60).utc).to eq(Time.parse("Thu Jan 15 00:00:00 UTC 2024")) + end + end + + describe "#uint64_to_int64" do + it "converts between integer types" do + expect(client.send(:uint64_to_int64, 123456)).to eq(123456) + expect(client.send(:uint64_to_int64, 13977996710702069744)).to eq(-4468747363007481872) + end + end +end + +RSpec.describe River::InsertManyParams do + it "initializes" do + job_args = SimpleArgs.new(job_num: 1) + + params = River::InsertManyParams.new(job_args) + + expect(params).to have_attributes( + args: job_args, + insert_opts: be_nil + ) + end + + it "initializes with insert opts" do + job_args = SimpleArgs.new(job_num: 1) + insert_opts = River::InsertOpts.new(queue: "other") + + params = River::InsertManyParams.new(job_args, insert_opts: insert_opts) + + expect(params).to have_attributes( + args: job_args, + insert_opts: insert_opts + ) + end +end diff --git a/ruby/spec/config_spec.rb b/ruby/spec/config_spec.rb new file mode 100644 index 000000000..82da8cc2c --- /dev/null +++ b/ruby/spec/config_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe River::QueueConfig do + %i[fetch_cooldown fetch_poll_interval].product([Float::NAN, Float::INFINITY, -Float::INFINITY]).each do |name, value| + it "rejects #{name}=#{value}" do + expect { described_class.new(max_workers: 1, **{name => value}) } + .to raise_error(ArgumentError, /finite/) + end + end + + let(:config) { River::Config.new(fetch_cooldown: 0.25, fetch_poll_interval: 1.5) } + + it "coerces numeric settings" do + queue = described_class.new(fetch_cooldown: "0.5", fetch_poll_interval: "2", max_workers: "3") + + expect(queue).to have_attributes(fetch_cooldown: 0.5, fetch_poll_interval: 2.0, max_workers: 3) + end + + it "inherits unspecified timing settings from the client configuration" do + queue = described_class.new(max_workers: 1) + + expect(queue.resolved_fetch_cooldown(config)).to eq(0.25) + expect(queue.resolved_fetch_poll_interval(config)).to eq(1.5) + end + + it "uses queue-specific timing settings when provided" do + queue = described_class.new(fetch_cooldown: 0.5, fetch_poll_interval: 0.75, max_workers: 1) + + expect(queue.resolved_fetch_cooldown(config)).to eq(0.5) + expect(queue.resolved_fetch_poll_interval(config)).to eq(0.75) + end + + [0, River::QUEUE_NUM_WORKERS_MAX + 1].each do |max_workers| + it "rejects max_workers=#{max_workers}" do + expect { described_class.new(max_workers: max_workers) } + .to raise_error(ArgumentError, /max_workers must be between/) + end + end + + it "rejects a negative fetch cooldown" do + expect { described_class.new(fetch_cooldown: -0.1, max_workers: 1) } + .to raise_error(ArgumentError, "fetch_cooldown must be zero or greater") + end + + it "rejects a negative fetch poll interval" do + expect { described_class.new(fetch_poll_interval: -0.1, max_workers: 1) } + .to raise_error(ArgumentError, "fetch_poll_interval must be zero or greater") + end + + it "rejects an effective poll interval shorter than the cooldown" do + queue = described_class.new(fetch_cooldown: 2, max_workers: 1) + + expect { queue.resolved_fetch_poll_interval(config) } + .to raise_error(ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown") + end +end + +RSpec.describe River::Config do + %i[fetch_cooldown fetch_poll_interval job_timeout].product([Float::NAN, Float::INFINITY, -Float::INFINITY]).each do |name, value| + it "rejects #{name}=#{value}" do + expect { described_class.new(**{name => value}) }.to raise_error(ArgumentError, /finite/) + end + end + + %i[cancelled_job_retention_period completed_job_retention_period discarded_job_retention_period].product([Float::NAN, Float::INFINITY, -2]).each do |name, value| + it "rejects #{name}=#{value}" do + expect { described_class.new(**{name => value}) }.to raise_error(ArgumentError, /retention must be finite and nonnegative/) + end + end + + it "copies its maintenance service list without freezing the caller's array" do + service = Object.new + services = [service] + config = described_class.new(maintenance_services: services) + services.clear + + expect(config.maintenance_services).to eq([service]) + expect(config.maintenance_services).to be_frozen + end + + it "provides usable defaults" do + config = described_class.new + + expect(config.id).to be_a(String).and have_attributes(length: be_between(1, 127)) + expect(config).to have_attributes( + fetch_cooldown: River::FETCH_COOLDOWN_DEFAULT, + fetch_only_known_kinds: false, + fetch_poll_interval: River::FETCH_POLL_INTERVAL_DEFAULT, + job_timeout: River::JOB_TIMEOUT_DEFAULT, + leader_election_disabled: false, + plugins: [], + queues: {}, + workers: be_a(River::Workers) + ) + expect(config.retry_policy).to respond_to(:next_retry) + end + + it "normalizes queue names and integer worker counts" do + config = described_class.new(queues: {:default => 2, "other" => River::QueueConfig.new(max_workers: 3)}) + + expect(config.queues.keys).to eq(%w[default other]) + expect(config.queues.fetch("default").max_workers).to eq(2) + expect(config.queues.fetch("other").max_workers).to eq(3) + end + + it "normalizes disabled retention values" do + config = described_class.new( + cancelled_job_retention_period: nil, + completed_job_retention_period: -1, + discarded_job_retention_period: "60" + ) + + expect(config).to have_attributes( + cancelled_job_retention_period: be_nil, + completed_job_retention_period: be_nil, + discarded_job_retention_period: 60.0 + ) + end + + it "copies configuration with overrides while preserving other values" do + workers = River::Workers.new + plugin = Object.new + original = described_class.new( + id: "client-one", job_timeout: nil, plugins: [plugin], + fetch_only_known_kinds: true, leader_election_disabled: true, + queues: {default: 2}, workers: workers + ) + copy = original.with(id: "client-two", fetch_poll_interval: 2) + + expect(copy).to have_attributes(id: "client-two", fetch_poll_interval: 2.0, job_timeout: nil, workers: workers) + expect(copy).to have_attributes(fetch_only_known_kinds: true, leader_election_disabled: true) + expect(copy.queues.keys).to eq(["default"]) + expect(copy.plugins).to eq([plugin]) + expect(original.id).to eq("client-one") + end + + it "copies and freezes its plugins list" do + plugins = [Object.new] + config = described_class.new(plugins: plugins) + plugins.clear + + expect(config.plugins.length).to eq(1) + expect(config.plugins).to be_frozen + end + + it "rejects periodic jobs on a client that cannot become leader" do + expect { described_class.new(leader_election_disabled: true, periodic_jobs: [Object.new]) } + .to raise_error(ArgumentError, /periodic_jobs must be empty/) + end + + ["", "a" * 128].each do |id| + it "rejects an ID with length #{id.length}" do + expect { described_class.new(id: id) } + .to raise_error(ArgumentError, "id must be between 1 and 127 characters") + end + end + + it "rejects a fetch cooldown below one millisecond" do + expect { described_class.new(fetch_cooldown: 0) } + .to raise_error(ArgumentError, "fetch_cooldown must be at least 0.001 seconds") + end + + it "rejects a poll interval shorter than the cooldown" do + expect { described_class.new(fetch_cooldown: 1, fetch_poll_interval: 0.5) } + .to raise_error(ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown") + end + + it "allows a nil job timeout" do + expect(described_class.new(job_timeout: nil).job_timeout).to be_nil + end + + it "rejects non-positive job timeouts" do + expect { described_class.new(job_timeout: 0) } + .to raise_error(ArgumentError, "job_timeout must be greater than zero or nil") + end + + it "rejects a retry policy without next_retry" do + expect { described_class.new(retry_policy: Object.new) } + .to raise_error(ArgumentError, "retry_policy must respond to next_retry") + end + + it "rejects a non-Workers registry" do + expect { described_class.new(workers: {}) } + .to raise_error(ArgumentError, "workers must be a River::Workers") + end + + ["", "not valid", "a" * 128].each do |name| + it "rejects invalid queue name #{name.inspect}" do + expect { described_class.new(queues: {name => 1}) } + .to raise_error(ArgumentError, /invalid queue name/) + end + end + + it "validates queue-specific timing against client defaults" do + queue = River::QueueConfig.new(fetch_poll_interval: 0.1, max_workers: 1) + + expect { described_class.new(fetch_cooldown: 0.2, queues: {default: queue}) } + .to raise_error(ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown") + end +end diff --git a/ruby/spec/conformance_spec.rb b/ruby/spec/conformance_spec.rb new file mode 100644 index 000000000..b2b40ce93 --- /dev/null +++ b/ruby/spec/conformance_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require "spec_helper" + +# The fixture-backed checks run once, alongside SQLite, without a Go setup in +# every PostgreSQL matrix entry. +return unless RiverTestDatabase.enabled?(:sqlite) +require_relative "support/conformance_fixtures" +require_relative "support/river_test_schema" +require "riverqueue-activerecord" +require "riverqueue-sequel" +require "riverqueue/testing" + +class ConformanceRecord < ActiveRecord::Base + self.abstract_class = true +end + +RSpec.describe "Go-generated conformance fixtures" do + fixture_source = RiverConformanceFixtures.read("unique_keys") + # Preserve raw argument tokens, including duplicate keys and negative zero. + # The Go generator uses four spaces for each case's root object. + cases = fixture_source.scan(/^ \{.*?^ \}/m) + expected_count = JSON.parse(fixture_source).values_at("cases", "typed_only_cases").sum(&:length) + raise "fixture case extraction is incomplete" unless expected_count.positive? && cases.length == expected_count + + cases.each do |raw| + raw = raw.gsub(/"(?:[^"\\]|\\.)*"|\s+/m) { |token| token.start_with?('"') ? token : "" } + fixture = JSON.parse(raw) + it "matches Go's #{fixture.fetch("name")} golden through insertion preparation" do + args = Struct.new(:kind, :encoded) { def to_json = encoded }.new(fixture.fetch("kind"), River::UniqueArgs.members(raw).fetch("args")) + dimensions = fixture.fetch("options") + options = River::InsertOpts.new(queue: fixture.fetch("queue"), + scheduled_at: fixture["scheduled_at"] && Time.iso8601(fixture.fetch("scheduled_at")), + unique_opts: River::UniqueOpts.new( + by_args: dimensions.fetch("by_args") && (fixture["selected_unique_components"] || true), + by_period: dimensions.fetch("by_period_nanos").positive? ? Rational(dimensions.fetch("by_period_nanos"), 1_000_000_000) : nil, + by_queue: dimensions.fetch("by_queue"), by_state: dimensions["by_state"], exclude_kind: dimensions.fetch("exclude_kind") + )) + client = River::Client.new(Object.new) + client.instance_variable_set(:@time_now_utc, -> { Time.iso8601(fixture.fetch("now")) }) + if fixture["expected_error"] + expect { client.send(:make_insert_params, args, options) }.to raise_error(ArgumentError, "unique args must encode a JSON object") + else + prepared = client.send(:make_insert_params, args, options) + expect(prepared.unique_key.unpack1("H*")).to eq(fixture.fetch("expected_sha256")) + expect(prepared.unique_states.to_i(2)).to eq(fixture.fetch("expected_state_mask")) + end + end + end + + cron = RiverConformanceFixtures.load("cron_schedules") + cron.values_at("cron_cases", "cron_named_zone_cases").flatten.each do |fixture| + it "matches Go's cron schedule #{fixture.fetch("name")}" do + from = Time.iso8601(fixture.fetch("from")) + # Ruby supplies the default calendar zone explicitly; Go uses the + # location on its reference time. Prefixes override this in both APIs. + if fixture.fetch("next").empty? + # Fugit rejects impossible dates at construction instead of returning + # Go's zero time. Assert that difference rather than skipping the case. + expect { River::PeriodicCron.new(fixture.fetch("expression")) }.to raise_error(ArgumentError) + else + schedule = River::PeriodicCron.new(fixture.fetch("expression"), timezone: from.strftime("%:z")) + fixture.fetch("next").each do |expected| + from = schedule.next(from) + expect(from).to eq(Time.iso8601(expected)) + end + end + end + end + + # Retain documented Ruby/Fugit extensions. Account for them explicitly so + # every new Go rejection must be considered, rather than silently skipped. + ruby_cron_extensions = ["* * * * * *", "0 9 * * 7", "* 24 * * *", "5-1 * * * *"] + cron.fetch("cron_invalid").each do |expression| + it "checks Go's invalid cron expression #{expression.inspect}" do + if ruby_cron_extensions.include?(expression) + expect(River::PeriodicCron.new(expression).next(Time.utc(2026))).to be_a(Time) + else + expect { River::PeriodicCron.new(expression) }.to raise_error(ArgumentError) + end + end + end + + protocol = RiverConformanceFixtures.load("protocol_values") + + it "accounts for every protocol key and notification in the Go fixtures" do + expect(protocol.fetch("metadata_keys").keys).to match_array(%w[output periodic_job_id rescue_count resumable_cursor resumable_step unique_nonce]) + expect(protocol.fetch("notifications").map { |fixture| fixture.fetch("name") }).to match_array(%w[cancel insert metadata_changed pause request_resign resigned resume]) + expect(protocol.fetch("topics").keys).to match_array(%w[control insert leadership]) + expect(protocol.fetch("job_states")).not_to be_empty + expect(protocol.fetch("retry_cases")).not_to be_empty + expect(cron.fetch("cron_invalid")).to include(*ruby_cron_extensions) + expect(cron.fetch("cron_cases")).not_to be_empty + expect(cron.fetch("cron_named_zone_cases")).not_to be_empty + expect(RiverConformanceFixtures.load("snooze_counters").fetch("snooze_counters")).not_to be_empty + end + + protocol.fetch("job_states").each do |fixture| + it "matches the unique state bit for #{fixture.fetch("state")}" do + state = fixture.fetch("state") + bit = fixture.fetch("unique_bit") + expect(River::UniqueBitmask.from_states([state]).to_i(2)).to eq(bit) + expect(River::UniqueBitmask.to_states(bit)).to eq([state]) + expect(River.const_get("JOB_STATE_#{state.upcase}")).to eq(state) + end + end + + it "round trips Go's attempt error through the production decoder" do + fixture = protocol.fetch("attempt_error") + error = River::Driver::JobRowDecoder.attempt_error(fixture) + expect(error).to have_attributes(at: Time.iso8601(fixture.fetch("at")), + attempt: fixture.fetch("attempt"), error: fixture.fetch("error"), trace: fixture.fetch("trace")) + encoded = JSON.parse(JSON.generate(error.to_h)) + expect(Time.iso8601(encoded.delete("at"))).to eq(Time.iso8601(fixture.fetch("at"))) + expect(encoded).to eq(fixture.except("at")) + end + + protocol.fetch("retry_cases").each do |fixture| + it "matches retry bounds for #{fixture.fetch("error_count")} errors" do + now = Time.iso8601(fixture.fetch("now")) + row = Struct.new(:errors).new(Array.new(fixture.fetch("error_count") - 1)) + policy = River::DefaultClientRetryPolicy.new(random: Random.new(fixture.fetch("seed"))) + delay = (policy.next_retry(row, now: now).to_r - now.to_r) * 1_000_000_000 + expect(delay).to be_between(fixture.fetch("min_delay_ns"), fixture.fetch("max_delay_ns")).inclusive + end + end + + it "uses Go's resumable metadata keys" do + keys = protocol.fetch("metadata_keys") + expect(River::RESUMABLE_CURSOR_METADATA_KEY).to eq(keys.fetch("resumable_cursor")) + expect(River::RESUMABLE_STEP_METADATA_KEY).to eq(keys.fetch("resumable_step")) + end + + # These exercise actual insertion/cancellation and the SQL outbox in both + # drivers without requiring a server. PostgreSQL delivery/commit ordering is + # separately exercised by the shared driver specs. + %w[activerecord sequel].each do |adapter| + context "#{adapter} persisted protocol" do + around do |example| + if adapter == "activerecord" + ConformanceRecord.establish_connection(adapter: "sqlite3", database: ":memory:") + @driver = River::Driver::ActiveRecord.new(connection_class: ConformanceRecord) + else + database = Sequel.sqlite + @driver = River::Driver::Sequel.new(database) + end + RiverTestSchema.load(@driver) + @client = River::Client.new(@driver) + example.run + ensure + (adapter == "activerecord") ? ConformanceRecord.remove_connection : database&.disconnect + end + + # Ruby emits these messages; its polling runtime does not consume + # request_resign or the other notification fixtures yet. + %w[cancel insert metadata_changed pause resigned resume].each do |name| + fixture = protocol.fetch("notifications").find { |notification| notification.fetch("name") == name } + raise "Missing #{name} notification fixture" unless fixture + + it "emits Go's #{name} topic and payload" do + # Operation arguments are independent of the fixture's wire keys and + # action names so format changes fail instead of changing our input. + case name + when "cancel", "insert" + row = @client.insert(River::JobArgsHash.new(:notify, {}), queue: "priority").job + if name == "cancel" + @driver.send(:runtime_execute, "UPDATE river_job SET id = 42 WHERE id = #{row.id}") + @driver.job_claim(id: 42, attempted_by: "conformance") + @client.job_cancel(42) + end + when "metadata_changed", "pause", "resume" + @driver.queue_upsert("priority") + case name + when "metadata_changed" then @driver.queue_update("priority", metadata: {"owner" => "candidate"}) + when "pause" then @driver.queue_pause("priority") + when "resume" + @driver.queue_pause("priority") + @driver.queue_resume("priority") + end + when "resigned" + @driver.leader_acquire("client-1") + @driver.leader_release("client-1") + end + notification = @driver.send(:runtime_query_rows, "SELECT topic, payload FROM river_notification ORDER BY id DESC LIMIT 1").first + expect(@driver.send(:runtime_value, notification, :topic)).to eq(fixture.fetch("topic")) + topic = if name == "resigned" + "leadership" + else + ((name == "insert") ? "insert" : "control") + end + expect(@driver.send(:runtime_value, notification, :topic)).to eq(protocol.fetch("topics").fetch(topic)) + expect(JSON.parse(@driver.send(:runtime_value, notification, :payload))).to eq(fixture.fetch("payload")) + end + end + + it "rolls back queue controls, resignation, and their outbox messages together" do + @driver.queue_upsert("priority") + @driver.leader_acquire("client-1") + @driver.transaction do + @driver.queue_pause("priority") + @driver.queue_resume("priority") + @driver.queue_update("priority", metadata: {"owner" => "rolled back"}) + @driver.leader_release("client-1") + expect(@driver.send(:runtime_query_rows, "SELECT id FROM river_notification").size).to eq(4) + raise @driver.rollback_exception + end + expect(@driver.queue_get("priority")).to have_attributes(paused_at: nil, metadata: {}) + expect(@driver.leader_renew("client-1")).to be true + expect(@driver.send(:runtime_query_rows, "SELECT id FROM river_notification")).to be_empty + end + + RiverConformanceFixtures.load("snooze_counters").fetch("snooze_counters").each do |fixture| + it "persists Go's snooze counter for #{fixture.fetch("name")}" do + worker = Class.new { def work(_job) = raise(River.job_snooze(60)) } + client = River::Client.new(@driver, config: River::Config.new(workers: River::Workers.new.add(:snooze, worker))) + row = client.insert(River::JobArgsHash.new(:snooze, {}), metadata: fixture.fetch("metadata")).job + result = River::Testing.perform_job(client, row.id) + expect(result).to have_attributes(outcome: :snoozed, error: be_a(River::JobSnoozeError)) + expect(result.job).to have_attributes(attempt: 0, state: "scheduled") + expect(result.job.metadata.fetch("snoozes")).to eq(fixture.fetch("expected_snoozes")) + end + end + + it "persists output and Go's rescue counter" do + keys = protocol.fetch("metadata_keys") + now = Time.now.utc + worker = Class.new { def work(job) = job.output = {"ok" => true} } + client = River::Client.new(@driver, config: River::Config.new(workers: River::Workers.new.add(:output, worker))) + row = client.insert(River::JobArgsHash.new(:output, {}), scheduled_at: now - 120, + metadata: {keys.fetch("rescue_count") => 2}).job + @driver.job_claim(id: row.id, attempted_by: "crashed", now: now - 60) + expect(@driver.job_rescue_stuck(horizon: now - 30, now: now, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(1) + result = River::Testing.perform_job(client, row.id, allow_scheduled: true) + expect(result).to have_attributes(outcome: :completed, error: nil) + expect(result.job.metadata).to include(keys.fetch("output") => {"ok" => true}, keys.fetch("rescue_count") => 3) + end + + it "stores Go's unique insert nonce and preserves it on a duplicate" do + key = protocol.fetch("metadata_keys").fetch("unique_nonce") + args = River::JobArgsHash.new(:unique, {}) + first = @client.insert(args, unique_opts: River::UniqueOpts.new(by_args: true)) + duplicate = @client.insert(args, unique_opts: River::UniqueOpts.new(by_args: true)) + expect(first.job.metadata.fetch(key)).to be_a(String) + expect(first.job.metadata.fetch(key)).not_to be_empty + expect(duplicate.job.metadata.fetch(key)).to eq(first.job.metadata.fetch(key)) + end + + [nil, "", "conformance_periodic"].each do |id| + it "marks periodic jobs with ID #{id.inspect} without mutating constructor options" do + metadata = {"owner" => "ruby", "periodic" => false}.freeze + opts = River::InsertOpts.new(metadata: metadata, queue: "priority").freeze + args = River::JobArgsHash.new(:periodic, {}) + args.define_singleton_method(:insert_opts) { River::InsertOpts.new(metadata: {"from_args" => true}) } + periodic = River::PeriodicJob.new(id: id, schedule: River::PeriodicInterval.new(60), run_on_start: true) { [args, opts] } + client = River::Client.new(@driver, config: River::Config.new(periodic_jobs: [periodic])) + client.instance_variable_get(:@runtime).send(:run_periodic, Time.now.utc + 1) + row = client.job_list.jobs.fetch(0) + expect(row.queue).to eq("priority") + expect(row.metadata).to include("owner" => "ruby", "periodic" => true, "from_args" => true) + key = protocol.fetch("metadata_keys").fetch("periodic_job_id") + if id && !id.empty? + expect(row.metadata.fetch(key)).to eq(id) + else + expect(row.metadata).not_to have_key(key) + end + expect(opts.metadata).to equal(metadata) + expect(metadata).to eq("owner" => "ruby", "periodic" => false) + end + end + + it "resumes from and persists Go's step and cursor metadata" do + keys = protocol.fetch("metadata_keys") + calls = [] + worker = Object.new + worker.define_singleton_method(:work) do |job| + job.resumable_step("before") { calls << :before } + job.resumable_step_cursor("process") do |cursor| + calls << cursor + job.resumable_set_cursor("offset" => 3) + raise "retry" + end + end + client = River::Client.new(@driver, config: River::Config.new(workers: River::Workers.new.add(:resumable, worker))) + row = client.insert(River::JobArgsHash.new(:resumable, {}), metadata: { + keys.fetch("resumable_step") => "process", keys.fetch("resumable_cursor") => {"process" => {"offset" => 2}} + }).job + result = River::Testing.perform_job(client, row.id) + expect(result).to have_attributes(outcome: :retried, error: have_attributes(message: "retry")) + expect(calls).to eq([{"offset" => 2}]) + expect(result.job.metadata).to include(keys.fetch("resumable_step") => "process", + keys.fetch("resumable_cursor") => {"process" => {"offset" => 3}}) + end + end + end +end diff --git a/ruby/spec/driver_runtime_feature_spec.rb b/ruby/spec/driver_runtime_feature_spec.rb new file mode 100644 index 000000000..4b3e3eec1 --- /dev/null +++ b/ruby/spec/driver_runtime_feature_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" +require_relative "driver_runtime_shared_examples" + +RSpec.describe "River driver runtime contracts" do + around(:each) { |example| available_test_transaction(&example) } + + let(:driver) { River::Driver::Sequel.new(available_test_database) } + + it_behaves_like "driver job state machine" + it_behaves_like "driver queue and leadership state" +end + +RSpec.describe "SQLite numeric metadata counters", database: :sqlite do + around(:each) { |example| sqlite_test_transaction(&example) } + + let(:driver) { River::Driver::Sequel.new(SQLITE_DB) } + + it_behaves_like "overflowing metadata counters" +end + +RSpec.describe "River shared driver runtime edge cases", database: :sqlite do + let(:postgres_driver) do + River::Driver::Sequel.new(SQLITE_DB).tap do |driver| + driver.define_singleton_method(:runtime_postgres?) { true } + end + end + + let(:sqlite_driver) { River::Driver::Sequel.new(SQLITE_DB) } + + it "does not issue PostgreSQL notifications when Yugabyte disables them" do + capabilities = River::Driver::PostgresCapabilities.new(product: "YugabyteDB", version_num: 150_012, yb_listen_notify_enabled: false) + postgres_driver.define_singleton_method(:postgres_capabilities) { capabilities } + postgres_driver.define_singleton_method(:runtime_execute) { |_sql| raise "unexpected notification" } + params = Struct.new(:state, :queue).new("available", "default") + + expect { postgres_driver.init_driver }.not_to raise_error + expect { postgres_driver.send(:postgres_notify_insert, [params]) }.not_to raise_error + expect { postgres_driver.send(:runtime_notify, "river_control", {action: "cancel"}) }.not_to raise_error + end + + it "does not use the SQLite notification cleaner on PostgreSQL" do + expect(postgres_driver.notification_delete_before(horizon: Time.now.utc)).to eq(0) + end + + it "returns a freshly read job if deletion loses a race" do + existing = Struct.new(:state).new(River::JOB_STATE_AVAILABLE) + replacement = Struct.new(:state).new(River::JOB_STATE_RUNNING) + reads = [existing, replacement] + driver = Object.new.extend(River::Driver::Runtime) + driver.define_singleton_method(:job_get_by_id) { |_id| reads.shift } + driver.define_singleton_method(:runtime_returning_ids) { |_sql| [] } + + expect(driver.job_delete(1)).to equal(replacement) + end + + it "uses the shared unfiltered-list implementation" do + expected = [Object.new] + driver = Object.new.extend(River::Driver::Runtime) + driver.define_singleton_method(:runtime_job_list_without_params) { expected } + + expect(driver.job_list(:all)).to equal(expected) + end + + it "builds PostgreSQL claim SQL with row locking and array history" do + sql = nil + driver = Object.new.extend(River::Driver::Runtime) + driver.define_singleton_method(:runtime_postgres?) { true } + driver.define_singleton_method(:runtime_quote) { |value| "'#{value}'" } + driver.define_singleton_method(:transaction) { |&block| block.call } + driver.define_singleton_method(:runtime_returning_ids) do |statement| + sql = statement + [] + end + + expect(driver.job_get_available(attempted_by: "client", max: 2, queue: "work")).to eq([]) + expect(sql).to include("array_append", "FOR UPDATE SKIP LOCKED") + end + + it "rejects nil runtime timestamps" do + expect { postgres_driver.send(:runtime_time, nil) }.to raise_error(ArgumentError, "time cannot be nil") + end + + it "serializes timestamps without mutating the caller's timezone" do + time = Time.new(2026, 1, 2, 3, 4, 5.123, "+05:30") + [postgres_driver, sqlite_driver].each do |driver| + driver.send(:runtime_time, time) + expect(time.utc_offset).to eq(19_800) + expect(driver.send(:runtime_time, time.freeze)).to include("2026-01-01") + end + end + + it "accepts pre-encoded runtime JSON and raw errors" do + expect(postgres_driver.send(:runtime_json, '{"ready":true}')).to include('{"ready":true}') + expect(postgres_driver.send(:runtime_append_error, 7)).to include("7") + end + + it "returns nil for an unsupported internal update field" do + expect(postgres_driver.send(:runtime_update_value, :unknown, true)).to be_nil + end + + it "serializes nil timestamps as SQL NULL" do + expect(postgres_driver.send(:runtime_update_value, :attempted_at, nil)).to eq("NULL") + expect(postgres_driver.send(:runtime_update_value, :finalized_at, nil)).to eq("NULL") + end + + it "serializes attempted-by and raw errors for SQLite" do + attempted_by = sqlite_driver.send(:runtime_update_value, :attempted_by, ["one"]) + errors = sqlite_driver.send(:runtime_update_value, :errors, [7]) + + expect(attempted_by).to include("one") + expect(errors).to include("7") + end + + it "renders every shared PostgreSQL-specific SQL value" do + expect(postgres_driver.send(:runtime_tag_contains, "tag")).to include("tags @>") + expect(postgres_driver.send(:runtime_metadata_equals, :tenant, 7)).to include("metadata ->", "::jsonb") + expect(postgres_driver.send(:runtime_state, River::JOB_STATE_AVAILABLE)).to end_with("::river_job_state") + expect(postgres_driver.send(:runtime_time, Time.utc(2026, 1, 2))).to end_with("::timestamptz") + expect(postgres_driver.send(:runtime_merge_metadata, {"ready" => true})).to include("metadata ||") + expect(postgres_driver.send(:runtime_cancel_attempted)).to eq("metadata ? 'cancel_attempted_at'") + expect(postgres_driver.send(:runtime_update_value, :attempted_by, ["one"])).to include("ARRAY[", "::text[]") + expect(postgres_driver.send(:runtime_update_value, :errors, [7])).to include("ARRAY[", "::jsonb[]") + expect(postgres_driver.send(:runtime_queue_columns)).to eq("name, created_at, metadata, paused_at, updated_at") + end + + it "accepts already-decoded JSON and Time values from PostgreSQL adapters" do + metadata = {"ready" => true} + time = Time.utc(2026, 1, 2, 3, 4, 5) + + expect(postgres_driver.send(:runtime_parse_json, metadata)).to eq(metadata) + expect(postgres_driver.send(:runtime_parse_time, time)).to eq(time) + end + + it "parses timestamps that already carry a UTC suffix" do + parsed = postgres_driver.send(:runtime_parse_time, "2026-01-02T03:04:05Z") + + expect(parsed).to eq(Time.utc(2026, 1, 2, 3, 4, 5)) + end +end diff --git a/ruby/spec/driver_runtime_shared_examples.rb b/ruby/spec/driver_runtime_shared_examples.rb new file mode 100644 index 000000000..72bf178de --- /dev/null +++ b/ruby/spec/driver_runtime_shared_examples.rb @@ -0,0 +1,1299 @@ +# frozen_string_literal: true + +class DriverRuntimeArgs + def initialize(value) + @value = value + end + + def kind = "driver_runtime" + + def to_json = JSON.dump(value: @value) +end + +RSpec.shared_examples "overflowing metadata counters" do + let(:client) { River::Client.new(driver) } + let(:now) { Time.utc(2026, 1, 2, 3, 4, 5) } + + it "rescues a job whose JSON rescue counter overflows a Ruby float" do + inserted = client.insert(DriverRuntimeArgs.new(1), scheduled_at: now - 120).job + encoded = driver.send(:runtime_json, '{"river:rescue_count":1e999}') + driver.send(:runtime_execute, "UPDATE river_job SET metadata = #{encoded} WHERE id = #{inserted.id}") + driver.job_claim(id: inserted.id, attempted_by: "worker", now: now - 120) + + expect(driver.job_rescue_stuck(horizon: now - 60, now: now, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(1) + # PostgreSQL expands the exponent into an exact integer; SQLite retains it + # as a floating-point value that Ruby decodes as infinity. + expected = driver.send(:runtime_postgres?) ? 10**999 + 1 : 1 + expect(client.job_get(inserted.id)).to have_attributes(state: "retryable", metadata: include("river:rescue_count" => expected)) + end + + it "snoozes a job whose JSON snooze counter overflows a Ruby float" do + worker = Object.new + worker.define_singleton_method(:work) { |_job| raise River.job_snooze(60) } + client = River::Client.new(driver, config: River::Config.new(workers: River::Workers.new.add("driver_runtime", worker))) + inserted = client.insert(DriverRuntimeArgs.new(1)).job + encoded = driver.send(:runtime_json, '{"snoozes":1e999}') + driver.send(:runtime_execute, "UPDATE river_job SET metadata = #{encoded} WHERE id = #{inserted.id}") + + row, _error, outcome = client.__perform_job(inserted.id) + + expect(outcome).to eq(:snoozed) + expect(row).to have_attributes(state: "scheduled", metadata: include("snoozes" => 1)) + end +end + +RSpec.shared_examples "driver job state machine" do + it_behaves_like "overflowing metadata counters" + + let(:client) { River::Client.new(driver) } + let(:now) { Time.utc(2026, 1, 2, 3, 4, 5) } + + describe "insertion states" do + %i[insert_begin insert_many].each do |hook| + %i[insert insert_many].each do |operation| + it "accepts pending states set by #{hook} during #{operation}" do + plugin = Object.new + if hook == :insert_begin + plugin.define_singleton_method(hook) { |params| params.state = :pending } + else + plugin.define_singleton_method(hook) do |params, insert_operation| + params.each { |param| param.state = :pending } + insert_operation.call + end + end + hooked_client = River::Client.new(driver, config: River::Config.new(plugins: [plugin])) + + results = if operation == :insert + [hooked_client.insert(DriverRuntimeArgs.new(1))] + else + hooked_client.insert_many([DriverRuntimeArgs.new(1), DriverRuntimeArgs.new(2)]) + end + + expect(results.map(&:job)).to all(have_attributes(attempt: 0, attempted_at: nil, state: "pending")) + end + + it "rejects states changed by #{hook} during #{operation} and rolls back hook writes" do + plain_client = client + plugin = Object.new + if hook == :insert_begin + seen = [] + plugin.define_singleton_method(hook) do |params| + plain_client.insert(DriverRuntimeArgs.new(99)) + seen << params + seen.first.state = "running" if seen.length == ((operation == :insert) ? 1 : 2) + end + else + plugin.define_singleton_method(hook) do |params, insert_operation| + plain_client.insert(DriverRuntimeArgs.new(99)) + params.first.state = "running" + insert_operation.call + end + end + hooked_client = River::Client.new(driver, config: River::Config.new(plugins: [plugin])) + + expect do + if operation == :insert + hooked_client.insert(DriverRuntimeArgs.new(1)) + else + hooked_client.insert_many([DriverRuntimeArgs.new(1), DriverRuntimeArgs.new(2)]) + end + end.to raise_error(ArgumentError, 'invalid insertion state: "running"; must be available, pending, or scheduled') + expect(plain_client.job_list.jobs).to be_empty + expect(plain_client.insert(DriverRuntimeArgs.new(3)).job.state).to eq("available") + end + end + end + + %w[available pending scheduled].each do |state| + [state, state.to_sym].each do |value| + it "accepts #{value.inspect} as an initial state with no attempt history" do + args = DriverRuntimeArgs.new(1) + args.define_singleton_method(:insert_opts) { River::InsertOpts.new(state: :running) } + inserted = client.insert(args, state: value).job + batch = client.insert_many([River::InsertManyParams.new(args, state: value)]).first.job + + expect([inserted, batch]).to all(have_attributes(attempt: 0, attempted_at: nil, finalized_at: nil, state: state)) + end + end + end + + %w[cancelled completed discarded retryable running unknown].push("").each do |state| + [state, state.to_sym].each do |value| + it "rejects #{value.inspect} before inserting any jobs" do + args = DriverRuntimeArgs.new(1) + args.define_singleton_method(:insert_opts) { River::InsertOpts.new(state: value) } + message = "invalid insertion state: #{state.inspect}; must be available, pending, or scheduled" + + expect { client.insert(args) }.to raise_error(ArgumentError, message) + expect { client.insert(DriverRuntimeArgs.new(1), state: value) }.to raise_error(ArgumentError, message) + expect { client.insert(DriverRuntimeArgs.new(1), insert_opts: args.insert_opts) }.to raise_error(ArgumentError, message) + expect { client.insert_many([DriverRuntimeArgs.new(2), args]) }.to raise_error(ArgumentError, message) + expect do + client.insert_many([DriverRuntimeArgs.new(2), River::InsertManyParams.new(DriverRuntimeArgs.new(1), state: value)]) + end.to raise_error(ArgumentError, message) + expect(client.job_list.jobs).to be_empty + end + end + end + end + + it "preserves application argument errors and rolls back their transaction writes" do + failure = ArgumentError.new("invalid application argument") + + expect do + driver.transaction do + client.insert(DriverRuntimeArgs.new(1)) + raise failure + end + end.to raise_error { |error| expect(error).to equal(failure) } + expect(client.job_list.jobs).to be_empty + expect(client.insert(DriverRuntimeArgs.new(2)).job.state).to eq("available") + end + + it "completes running jobs, merges metadata, and ignores missing or finished jobs" do + row = insert_job(1, metadata: {"original" => true}, scheduled_at: now - 1) + driver.job_claim(id: row.id, attempted_by: "test", now: now) + expect(driver.job_complete(id: row.id.to_s, finalized_at: now, metadata: {"result" => 42}, now: now)).to have_attributes( + id: row.id, metadata: include("original" => true, "result" => 42), state: "completed" + ) + expect(driver.job_complete(id: row.id, finalized_at: now)).to be_nil + expect(driver.job_complete(id: -1, finalized_at: now)).to be_nil + end + + it "leaves cancellation-marked jobs for normal cancellation handling" do + row = insert_job(1, scheduled_at: now - 1) + driver.job_claim(id: row.id, attempted_by: "test", now: now) + driver.job_metadata_merge(row.id, "cancel_attempted_at" => nil) + expect(driver.job_complete(id: row.id, finalized_at: now)).to eq(:cancelled) + expect(driver.job_get_by_id(row.id).state).to eq("running") + driver.transaction do + expect(driver.job_complete(id: row.id, finalized_at: now)).to eq(:cancelled) + raise driver.rollback_exception + end + end + + it "preserves a cancellation committed immediately before the completion update" do + row = insert_job(1, scheduled_at: now - 1) + driver.job_claim(id: row.id, attempted_by: "test", now: now) + cancel_before_write = true + driver.define_singleton_method(:runtime_returning_ids) do |sql| + if cancel_before_write + cancel_before_write = false + job_cancel(row.id) + end + super(sql) + end + + expect(driver.job_complete(id: row.id, finalized_at: now, metadata: {"output" => "done"}, now: now)).to eq(:cancelled) + expect(driver.job_get_by_id(row.id)).to have_attributes( + finalized_at: nil, metadata: include("cancel_attempted_at"), state: "running" + ) + expect(driver.job_get_by_id(row.id).metadata).not_to have_key("output") + end + + it "preserves completion when cancellation arrives afterward" do + row = insert_job(1, scheduled_at: now - 1) + driver.job_claim(id: row.id, attempted_by: "test", now: now) + completed = driver.job_complete(id: row.id, finalized_at: now, now: now) + + expect(driver.job_cancel(row.id, now: now + 1)).to have_attributes( + finalized_at: completed.finalized_at, metadata: completed.metadata, state: "completed" + ) + end + + [false, true].each do |finalize_hooks| + it "finalizes a cancellation racing with a worker's completion with finalize hooks #{finalize_hooks}" do + plugin = Object.new + plugin.define_singleton_method(:job_finalize) { |_job, _state| } if finalize_hooks + worker = Class.new { + def work(_job) + end + } + client = River::Client.new(driver, config: River::Config.new( + plugins: [plugin], workers: River::Workers.new.add("driver_runtime", worker) + )) + row = insert_job(1) + driver.define_singleton_method(:job_complete) do |**params| + job_cancel(params.fetch(:id)) + super(**params) + end + subscription = client.subscribe(:job_cancelled, :job_completed) + + completed, error, outcome = client.__perform_job(row.id) + + expect(completed).to have_attributes(state: "cancelled", finalized_at: be_a(Time)) + expect(error).to be_a(River::JobCancelError) + expect(outcome).to eq(:cancelled) + expect(subscription.pop(true)).to have_attributes(kind: :job_cancelled, job: have_attributes(state: "cancelled")) + expect { subscription.pop(true) }.to raise_error(ThreadError) + end + end + + it "honors cancellation when finalizing an externally claimed job" do + row = insert_job(1, scheduled_at: now - 1) + claimed = driver.job_claim(id: row.id, attempted_by: "test", now: now) + driver.job_cancel(row.id, now: now) + subscription = client.subscribe(:job_cancelled, :job_completed) + + client.__finish_claimed_job(claimed) + + expect(client.job_get(row.id)).to have_attributes(state: "cancelled", finalized_at: be_a(Time)) + expect(subscription.pop(true).kind).to eq(:job_cancelled) + expect { subscription.pop(true) }.to raise_error(ThreadError) + end + + it "preserves a cancellation committed immediately before a finalization delete" do + row = insert_job(1, scheduled_at: now - 1) + driver.job_claim(id: row.id, attempted_by: "test", now: now) + cancel_before_write = true + driver.define_singleton_method(:runtime_returning_ids) do |sql| + if cancel_before_write + cancel_before_write = false + job_cancel(row.id) + end + super(sql) + end + + expect(driver.job_delete_if_running(row.id)).to eq(:cancelled) + expect(driver.job_get_by_id(row.id)).to have_attributes(state: "running", metadata: include("cancel_attempted_at")) + end + + it "finalizes cancellation when a deleting finalization hook races with cancellation" do + plugin = Object.new + plugin.define_singleton_method(:job_finalize) do |job, _state| + job.client.job_cancel(job.row.id) + :delete + end + worker = Class.new { + def work(_job) + end + } + client = River::Client.new(driver, config: River::Config.new( + plugins: [plugin], workers: River::Workers.new.add("driver_runtime", worker) + )) + row = insert_job(1) + subscription = client.subscribe(:job_cancelled, :job_completed) + + completed, error, outcome = client.__perform_job(row.id) + + expect(completed).to have_attributes(state: "cancelled", finalized_at: be_a(Time)) + expect(error).to be_a(River::JobCancelError) + expect(outcome).to eq(:cancelled) + expect(subscription.pop(true).kind).to eq(:job_cancelled) + expect { subscription.pop(true) }.to raise_error(ThreadError) + end + + it "rolls back transactional completion" do + row = insert_job(1, scheduled_at: now - 1) + driver.job_claim(id: row.id, attempted_by: "test", now: now) + driver.transaction do + expect(driver.job_complete(id: row.id, finalized_at: now).state).to eq("completed") + raise driver.rollback_exception + end + expect(driver.job_get_by_id(row.id).state).to eq("running") + end + + it "looks up cancellation markers for a batch, excluding missing and uncancelled jobs" do + cancelled = insert_job(1) + unmarked = insert_job(2) + null_marker = insert_job(3) + driver.job_cancel(cancelled.id) + driver.job_metadata_merge(null_marker.id, "cancel_attempted_at" => nil) + expect(driver.job_get_cancelled_ids([])).to eq([]) + expect(driver.job_get_cancelled_ids([cancelled.id, unmarked.id, null_marker.id, -1, cancelled.id])).to contain_exactly(cancelled.id, null_marker.id) + driver.transaction do + driver.job_metadata_merge(unmarked.id, "cancel_attempted_at" => "now") + expect(driver.job_get_cancelled_ids([unmarked.id])).to eq([unmarked.id]) + raise driver.rollback_exception + end + expect(driver.job_get_cancelled_ids([unmarked.id])).to eq([]) + end + + [:id, :scheduled_at, :finalized_at].product([:asc, :desc]).each do |sort_by, sort_order| + it "paginates #{sort_by} #{sort_order} through ties, nulls, and deleted cursor jobs" do + jobs = [20, 10, 20, 0, 30, 10].map.with_index do |offset, i| + row = insert_job(i, scheduled_at: now + offset) + driver.job_update(row.id, River::JobUpdateParams.new(state: "completed", finalized_at: now + offset)) if i < 4 + driver.job_get_by_id(row.id) + end + present, missing = jobs.partition { |job| !job.public_send(sort_by).nil? } + expected = present.sort_by { |job| [job.public_send(sort_by), job.id] } + expected.reverse! if sort_order == :desc + missing.sort_by!(&:id) + missing.reverse! if sort_order == :desc + expected.concat(missing) + + cursor = nil + actual = [] + jobs.length.times do + page = client.job_list(River::JobListParams.new(after: cursor, ids: jobs.map(&:id), limit: 1, sort_by: sort_by, sort_order: sort_order)) + expect(page.jobs.length).to eq(1) + actual << page.jobs.first.id + cursor = page.last_cursor + driver.job_delete(page.jobs.first.id) if actual.length.odd? + end + expect(actual).to eq(expected.map(&:id)) + expect(client.job_list(River::JobListParams.new(after: cursor, ids: jobs.map(&:id), sort_by: sort_by, sort_order: sort_order)).jobs).to be_empty + end + end + + it "lists complete rows without per-job lookups" do + row = insert_job(1) + driver.define_singleton_method(:job_get_by_id) { |_id| raise "listing must use one snapshot" } + + expect(client.job_list(River::JobListParams.new(ids: [row.id])).jobs).to contain_exactly(have_attributes(id: row.id, args: row.args)) + end + + [nil, "cancelled", "completed", "discarded"].product([:asc, :desc]).each do |state, sort_order| + it "paginates #{state || "scheduled"} timestamps #{sort_order} with millisecond ties and reusable timezone cursors" do + sort_by = state ? :finalized_at : :scheduled_at + jobs = [123, 124, 123, 122].map do |milliseconds| + timestamp = now + Rational(milliseconds, 1_000) + row = insert_job(1, scheduled_at: timestamp, metadata: {"tenant" => 42}) + driver.job_update(row.id, River::JobUpdateParams.new(state: state, finalized_at: timestamp)) if state + row + end + expected = jobs.sort_by { |job| [job.scheduled_at, job.id] }.map(&:id) + expected.reverse! if sort_order == :desc + cursor = nil + actual = [] + jobs.length.times do + params = River::JobListParams.new(after: cursor, limit: 1, metadata: {tenant: 42}, + sort_by: sort_by, sort_order: sort_order, states: state ? [state] : nil).freeze + page = client.job_list(params) + expect(page.jobs.length).to eq(1) + expect(client.job_list(params).jobs.map(&:id)).to eq(page.jobs.map(&:id)) + actual << page.jobs.first.id + cursor = page.last_cursor.with(value: page.last_cursor.value.getlocal("+05:30").freeze) + end + expect(actual).to eq(expected) + expect(cursor.value.utc_offset).to eq(19_800) + end + end + + it "matches metadata as JSON values, including nulls, nested objects, and literal keys" do + values = [nil, false, true, 1, 1.5, "1", "true", "null", [], {}, [1, {"a" => 2}], {"a" => 1, "b" => [false, nil]}] + rows = values.map { |value| insert_job(1, metadata: {"value" => value}) } + insert_job(1, metadata: {}) + values.zip(rows).each do |value, row| + expect(driver.job_list(River::JobListParams.new(metadata: {value: value})).map(&:id)).to eq([row.id]) + end + expect(driver.job_list(River::JobListParams.new(metadata: {value: 1.0})).map(&:id)).to eq([rows[3].id]) + expect(driver.job_list(River::JobListParams.new(metadata: {value: {"b" => [false, nil], "a" => 1}})).map(&:id)).to eq([rows.last.id]) + + key = "literal.key[0]\"\\" + row = insert_job(1, metadata: {key => "quoted\"\\value"}) + expect(driver.job_list(River::JobListParams.new(metadata: {key => "quoted\"\\value"})).map(&:id)).to eq([row.id]) + end + + it "claims only the requested eligible job and requires explicit early execution" do + other = insert_job(1, scheduled_at: now - 1) + future = insert_job(2, scheduled_at: now + 60, state: River::JOB_STATE_SCHEDULED) + + expect(driver.job_claim(id: future.id, attempted_by: "test", now: now)).to be_nil + claimed = driver.job_claim(id: future.id, allow_scheduled: true, attempted_by: "test", now: now) + + expect(claimed).to have_attributes( + id: future.id, attempt: 1, attempted_at: be_within(0.001).of(now), attempted_by: ["test"], scheduled_at: be_within(0.001).of(now + 60), state: "running" + ) + expect(driver.job_get_by_id(other.id).state).to eq("available") + expect(driver.job_claim(id: future.id, allow_scheduled: true, attempted_by: "test", now: now)).to be_nil + expect(driver.job_claim(id: -1, attempted_by: "test", now: now)).to be_nil + expect(driver.job_claim(id: other.id, attempted_by: "test", now: now).id).to eq(other.id) + end + + it "claims due scheduled and retryable jobs, but never pending or terminal jobs" do + %w[scheduled retryable pending cancelled completed discarded].each do |state| + row = insert_job(1, scheduled_at: now - 1, state: "pending") + driver.job_update(row.id, River::JobUpdateParams.new( + finalized_at: %w[cancelled completed discarded].include?(state) ? now : nil, + state: state + )) + claimed = driver.job_claim(id: row.id, attempted_by: "test", now: now) + if %w[scheduled retryable].include?(state) + expect(claimed).to have_attributes(id: row.id, attempt: 1, state: "running") + else + expect(claimed).to be_nil + end + end + end + + def insert_job(value, **options) + if options.key?(:scheduled_at) && !options.key?(:state) + options[:state] = River::JOB_STATE_AVAILABLE + end + + client.insert(DriverRuntimeArgs.new(value), insert_opts: River::InsertOpts.new(**options)).job + end + + it "cancels waiting jobs and leaves already finalized jobs unchanged" do + job = insert_job(1) + + expect(driver.job_cancel(job.id, now: now)).to have_attributes( + id: job.id, finalized_at: be_within(0.001).of(now), state: River::JOB_STATE_CANCELLED + ) + expect(driver.job_cancel(job.id, now: now + 10)).to have_attributes(finalized_at: be_within(0.001).of(now)) + expect(driver.job_cancel(-1)).to be_nil + end + + it "deletes waiting jobs but protects running jobs" do + waiting = insert_job(1, scheduled_at: now + 60) + running = insert_job(2, scheduled_at: now - 1) + driver.job_get_available(attempted_by: "worker", max: 1, now: now, queue: "default") + + expect(driver.job_delete(waiting.id)).to have_attributes(id: waiting.id) + expect(driver.job_get_by_id(waiting.id)).to be_nil + expect(driver.job_delete(running.id)).to have_attributes(id: running.id, state: River::JOB_STATE_RUNNING) + expect(driver.job_delete(-1)).to be_nil + expect(driver.job_delete_if_running(running.id)).to be true + expect(driver.job_delete_if_running(running.id)).to be false + end + + it "applies the bulk deletion limit after excluding running jobs" do + running = insert_job(1, scheduled_at: now - 1) + driver.job_claim(id: running.id, attempted_by: "worker", now: now) + waiting = insert_job(2) + + expect(client.job_delete_many(queues: ["default"], limit: 1).jobs).to contain_exactly(have_attributes(id: waiting.id)) + expect(client.job_get(running.id).state).to eq("running") + end + + it "bulk deletes only matching non-running jobs and returns their rows" do + waiting = insert_job(1, queue: "one", scheduled_at: now + 60) + running = insert_job(2, queue: "one", scheduled_at: now - 1) + untouched = insert_job(3, queue: "two") + driver.job_get_available(attempted_by: "worker", max: 1, now: now, queue: "one") + + expect(driver.job_delete_many(River::JobListParams.new(queues: ["one"]))).to contain_exactly(have_attributes(id: waiting.id)) + expect(driver.job_list.map(&:id)).to match_array([running.id, untouched.id]) + expect(driver.job_delete_if_running(untouched.id)).to be false + end + + it "retries exhausted finalized jobs but does not reset active attempts" do + job = insert_job(1, max_attempts: 1, scheduled_at: now - 1) + driver.job_get_available(attempted_by: "worker", max: 1, now: now, queue: "default") + + expect(driver.job_retry(job.id, now: now)).to have_attributes(attempt: 1, state: River::JOB_STATE_RUNNING) + driver.job_set_state_if_running(id: job.id, finalized_at: now, state: River::JOB_STATE_DISCARDED) + + expect(driver.job_retry(job.id, now: now + 10)).to have_attributes( + attempt: 1, finalized_at: nil, max_attempts: 2, + scheduled_at: be_within(0.001).of(now + 10), state: River::JOB_STATE_AVAILABLE + ) + expect(driver.job_retry(-1)).to be_nil + end + + it "rejects manual retries after the last representable attempt without changing the job" do + job = insert_job(1, max_attempts: 32_767) + original = client.job_update(job.id, attempt: 32_767, finalized_at: now, state: :discarded) + + expect { client.job_retry(job.id) }.to raise_error(ArgumentError, "cannot retry a job with 32767 or more attempts") + expect(client.job_get(job.id)).to have_attributes( + attempt: original.attempt, finalized_at: original.finalized_at, max_attempts: original.max_attempts, + scheduled_at: original.scheduled_at, state: original.state + ) + expect(insert_job(2).id).to be > job.id + end + + it "allows the last representable retry and leaves running jobs alone at the limit" do + job = insert_job(1, max_attempts: 32_766) + client.job_update(job.id, attempt: 32_766, finalized_at: now, state: :discarded) + + expect(driver.job_retry(job.id, now: now)).to have_attributes(attempt: 32_766, max_attempts: 32_767) + expect(driver.job_claim(id: job.id, attempted_by: "worker", now: now)).to have_attributes(attempt: 32_767, state: "running") + expect(client.job_retry(job.id)).to have_attributes(attempt: 32_767, state: "running") + end + + it "claims other available jobs even when one attempt counter is already at the limit" do + exhausted = insert_job(1, max_attempts: 32_767, scheduled_at: now - 1) + healthy = insert_job(2, scheduled_at: now - 1) + client.job_update(exhausted.id, attempt: 32_767) + + expect(driver.job_get_available(queue: "default", max: 2, attempted_by: "worker", now: now)).to contain_exactly( + have_attributes(id: exhausted.id, attempt: 32_767, state: "running"), + have_attributes(id: healthy.id, attempt: 1, state: "running") + ) + end + + it "leaves due available jobs unchanged and makes future available jobs immediately runnable" do + due = insert_job(1, scheduled_at: now - 10) + future = insert_job(2, scheduled_at: now + 10) + + expect(driver.job_retry(due.id, now: now)).to have_attributes(scheduled_at: due.scheduled_at, state: "available") + expect(driver.job_retry(future.id, now: now)).to have_attributes(scheduled_at: be_within(0.001).of(now), state: "available") + end + + [false, true].each do |running| + it "allows a manually retried cancellation to complete after cancellation while running=#{running}" do + worker = Object.new + worker.define_singleton_method(:work) { |_job| } + client = River::Client.new(driver, config: River::Config.new(workers: River::Workers.new.add("driver_runtime", worker))) + job = client.insert(DriverRuntimeArgs.new(1), metadata: {"nested" => {"keep" => true}}).job + driver.job_claim(id: job.id, attempted_by: "worker") if running + client.job_cancel(job.id) + if running + expect(client.job_retry(job.id)).to have_attributes(state: "running", metadata: include("cancel_attempted_at")) + driver.job_set_state_if_running(id: job.id, finalized_at: Time.now.utc, state: "cancelled") + end + + expect(client.job_retry(job.id)).to have_attributes(metadata: job.metadata, state: "available") + completed, error, outcome = client.__perform_job(job.id) + + expect(completed.state).to eq("completed") + expect(error).to be_nil + expect(outcome).to eq(:completed) + end + end + + describe "job metadata merges" do + [nil, [], [1], "null", "{}", 1, false].each do |metadata| + it "rejects #{metadata.inspect} without changing the persisted job" do + job = insert_job(1, metadata: {"original" => true}) + + expect { driver.job_metadata_merge(job.id, metadata) } + .to raise_error(ArgumentError, "metadata must be a Hash") + expect(client.job_get(job.id)).to have_attributes(metadata: job.metadata) + end + end + end + + describe "job update attempt history" do + it "accepts string histories and clears histories with nil" do + job = insert_job(1) + + expect(client.job_update(job.id, attempted_by: "worker").attempted_by).to eq(["worker"]) + expect(client.job_update(job.id, attempted_by: nil).attempted_by).to eq([]) + end + + [[1], [nil], [false], [["worker"]], [{"id" => "worker"}]].each do |attempted_by| + it "rejects #{attempted_by.inspect} without changing the persisted job" do + job = insert_job(1) + + expect { client.job_update(job.id, attempt: 2, attempted_by: attempted_by) } + .to raise_error(ArgumentError, "attempted_by must contain only Strings") + expect(client.job_get(job.id)).to have_attributes(attempt: job.attempt, attempted_by: job.attempted_by) + end + end + end + + describe "job update attempt limits" do + it "accepts the largest portable limit for inserts and updates" do + job = insert_job(1, max_attempts: 32_767) + + expect(job.max_attempts).to eq(32_767) + expect(client.job_update(job.id, max_attempts: "32767").max_attempts).to eq(32_767) + end + + it "accepts positive limits and preserves zero attempts" do + job = insert_job(1) + + expect(client.job_update(job.id, attempt: 0, max_attempts: "1")) + .to have_attributes(attempt: 0, max_attempts: 1) + expect(client.job_update(job.id, max_attempts: 10).max_attempts).to eq(10) + end + + [32_768, 2**64].each do |max_attempts| + it "rejects #{max_attempts} attempts in inserts before writing any jobs" do + args = DriverRuntimeArgs.new(1) + args.define_singleton_method(:insert_opts) { River::InsertOpts.new(max_attempts: max_attempts) } + message = "max_attempts must not exceed 32767" + + expect { client.insert(args) }.to raise_error(ArgumentError, message) + expect { client.insert(DriverRuntimeArgs.new(1), max_attempts: max_attempts) }.to raise_error(ArgumentError, message) + expect { client.insert(DriverRuntimeArgs.new(1), insert_opts: args.insert_opts) }.to raise_error(ArgumentError, message) + expect { client.insert_many([DriverRuntimeArgs.new(2), args]) }.to raise_error(ArgumentError, message) + expect do + client.insert_many([DriverRuntimeArgs.new(2), River::InsertManyParams.new(DriverRuntimeArgs.new(1), max_attempts: max_attempts)]) + end.to raise_error(ArgumentError, message) + expect(client.job_list.jobs).to be_empty + end + + [max_attempts, max_attempts.to_s].each do |value| + it "rejects updating max_attempts to #{value.inspect} without changing the job" do + job = insert_job(1) + + expect { client.job_update(job.id, attempt: 2, max_attempts: value) } + .to raise_error(ArgumentError, "max_attempts must not exceed 32767") + expect(client.job_get(job.id)).to have_attributes(attempt: 0, max_attempts: job.max_attempts) + end + end + end + + [-1, 32_768, 2**64].each do |attempt| + it "rejects out-of-range attempt #{attempt} without changing the job" do + job = insert_job(1) + + expect { client.job_update(job.id, attempt: attempt, max_attempts: 10) } + .to raise_error(ArgumentError, "attempt must be between 0 and 32767") + expect(client.job_get(job.id)).to have_attributes(attempt: 0, max_attempts: job.max_attempts) + end + end + + [0, -1, "0", "-1"].each do |max_attempts| + it "rejects #{max_attempts.inspect} without changing the persisted job" do + job = insert_job(1) + + expect { driver.job_update(job.id, River::JobUpdateParams.new(attempt: 2, max_attempts: max_attempts)) } + .to raise_error(ArgumentError, "max_attempts must be greater than zero") + expect(client.job_get(job.id)).to have_attributes(attempt: 0, max_attempts: job.max_attempts) + + expect { client.job_update(job.id, attempt: 2, max_attempts: max_attempts) } + .to raise_error(ArgumentError, "max_attempts must be greater than zero") + expect(client.job_get(job.id)).to have_attributes(attempt: 0, max_attempts: job.max_attempts) + end + end + end + + describe "job update metadata" do + it "accepts empty objects and null values inside objects" do + job = insert_job(1, metadata: {"original" => true}) + + expect(client.job_update(job.id, metadata: {}).metadata).to eq({}) + expect(client.job_update(job.id, metadata: {"nullable" => nil}).metadata).to eq("nullable" => nil) + expect(client.job_update(job.id, attempt: 1).metadata).to eq("nullable" => nil) + end + + [nil, [], "null", '{"valid":true}', 1, false].each do |metadata| + it "rejects #{metadata.inspect} without changing the persisted job" do + job = insert_job(1, metadata: {"original" => true}) + + expect { driver.job_update(job.id, River::JobUpdateParams.new(attempt: 2, metadata: metadata)) } + .to raise_error(ArgumentError, "metadata must be a Hash") + expect(client.job_get(job.id)).to have_attributes(attempt: 0, metadata: job.metadata) + + expect { client.job_update(job.id, attempt: 2, metadata: metadata) } + .to raise_error(ArgumentError, "metadata must be a Hash") + expect(client.job_get(job.id)).to have_attributes(attempt: 0, metadata: job.metadata) + end + end + end + + describe "snooze counter recovery" do + # The shared Go fixtures only specify canonical non-negative integers. + # Keep Ruby's recovery behavior covered independently of those fixtures. + [ + ["array", [3], 1], + ["false", false, 1], + ["fractional number", 2.9, 3], + ["fractional string", "4.5", 1], + ["negative fractional number", -2.5, -1], + ["negative numeric string", "-7", -6], + ["null", nil, 1], + ["numeric string", "4", 5], + ["object", {"count" => 3}, 1], + ["padded string", " 5", 1], + ["scientific number", 1e3, 1001], + ["true", true, 2], + ["unparseable string", "abc", 1] + ].each do |name, counter, expected| + it "snoozes with #{name} metadata without consuming an attempt" do + worker = Class.new { def work(_job) = raise(River.job_snooze(60)) } + client = River::Client.new(driver, config: River::Config.new(workers: River::Workers.new.add("driver_runtime", worker))) + inserted = client.insert(DriverRuntimeArgs.new(1), metadata: {"kept" => true, "snoozes" => counter}).job + + row, error, outcome = client.__perform_job(inserted.id) + + expect(outcome).to eq(:snoozed) + expect(error).to be_a(River::JobSnoozeError) + expect(row).to have_attributes(attempt: 0, state: "scheduled", metadata: inserted.metadata.merge("snoozes" => expected)) + expect(Array(row.errors)).to be_empty + end + end + end + + it "round trips attempt errors, metadata, and nullable update fields" do + job = insert_job(1) + error = River::AttemptError.new(at: now, attempt: 1, error: "failure", trace: "worker.rb:42") + updated = driver.job_update(job.id, River::JobUpdateParams.new( + attempt: 1, attempted_at: now, attempted_by: ["one", "two"], errors: [error], + metadata: {"nested" => {"ready" => true}} + )) + + expect(updated).to have_attributes( + attempt: 1, attempted_at: be_within(0.001).of(now), attempted_by: ["one", "two"], + errors: contain_exactly(have_attributes(at: be_within(0.001).of(now), attempt: 1, error: "failure", trace: "worker.rb:42")), + metadata: {"nested" => {"ready" => true}} + ) + expect(updated.attempted_by).to be_an_instance_of(Array) + expect(updated.metadata).to be_an_instance_of(Hash) + expect(driver.job_update(job.id, River::JobUpdateParams.new(attempted_at: nil))).to have_attributes(attempted_at: nil, attempted_by: ["one", "two"]) + expect(driver.job_update(job.id, River::JobUpdateParams.new)).to have_attributes(id: job.id) + expect(driver.job_update(-1, River::JobUpdateParams.new(attempt: 1))).to be_nil + end + + it "completes running jobs with merged metadata and ignores repeated completion" do + job = insert_job(1, metadata: {"keep" => true}, scheduled_at: now - 1) + driver.job_get_available(attempted_by: "worker", max: 1, now: now, queue: "default") + completed = driver.job_set_state_if_running(id: job.id, finalized_at: now, + metadata: {"output" => {"value" => 2}}, state: River::JOB_STATE_COMPLETED) + + expect(completed).to have_attributes( + finalized_at: be_within(0.001).of(now), + metadata: include("keep" => true, "output" => {"value" => 2}), + state: River::JOB_STATE_COMPLETED + ) + expect(driver.job_set_state_if_running(id: job.id, state: River::JOB_STATE_AVAILABLE)).to be_nil + end + + it "paginates job IDs in both directions with consistent filters" do + jobs = (1..3).map { |value| insert_job(value) } + + expect(driver.job_list(River::JobListParams.new(after_id: jobs[0].id, limit: 1))).to contain_exactly(have_attributes(id: jobs[1].id)) + expect(driver.job_list(River::JobListParams.new(after_id: jobs[2].id, limit: 1, sort_order: :desc))).to contain_exactly(have_attributes(id: jobs[1].id)) + expect(driver.job_list(River::JobListParams.new(ids: [jobs[0].id], kinds: ["driver_runtime"], states: [River::JOB_STATE_AVAILABLE]))) + .to contain_exactly(have_attributes(id: jobs[0].id)) + end + + [0, 99, 100, 105].each do |history_size| + it "keeps the newest 100 worker IDs when claiming a job with #{history_size} prior IDs" do + job = insert_job(1, scheduled_at: now - 1) + history = Array.new(history_size) { |index| "worker-#{index}" } + client.job_update(job.id, attempted_by: history) + + claimed = driver.job_get_available(attempted_by: "new-worker", max: 1, now: now, queue: "default").first + + expect(claimed.attempted_by).to eq((history + ["new-worker"]).last(100)) + expect(client.job_get(job.id).attempted_by).to eq(claimed.attempted_by) + end + end + + it "claims only eligible jobs from the requested queue" do + eligible = insert_job(1, queue: "work", scheduled_at: now - 1, state: River::JOB_STATE_AVAILABLE) + future = insert_job(2, queue: "work", scheduled_at: now + 60, state: River::JOB_STATE_AVAILABLE) + other_queue = insert_job(3, queue: "other", scheduled_at: now - 1, state: River::JOB_STATE_AVAILABLE) + cancelled = insert_job(4, queue: "work", scheduled_at: now - 1, state: River::JOB_STATE_AVAILABLE) + client.job_update(cancelled.id, River::JobUpdateParams.new(finalized_at: now, state: River::JOB_STATE_CANCELLED)) + + claimed = driver.job_get_available(attempted_by: "worker", max: 10, now: now, queue: "work") + + expect(claimed.map(&:id)).to eq([eligible.id]) + expect(claimed.first).to have_attributes(attempt: 1, attempted_by: ["worker"], state: River::JOB_STATE_RUNNING) + expect(driver.job_get_by_id(future.id)).to have_attributes(state: River::JOB_STATE_AVAILABLE) + expect(driver.job_get_by_id(other_queue.id)).to have_attributes(state: River::JOB_STATE_AVAILABLE) + expect(driver.job_get_by_id(cancelled.id)).to have_attributes(state: River::JOB_STATE_CANCELLED) + end + + it "claims by priority, scheduled time, and ID while respecting max" do + low = insert_job(1, priority: 4, queue: "work", scheduled_at: now - 30) + later = insert_job(2, priority: 1, queue: "work", scheduled_at: now - 10) + earlier = insert_job(3, priority: 1, queue: "work", scheduled_at: now - 20) + + first_claim = driver.job_get_available(attempted_by: "worker", max: 1, now: now, queue: "work") + second_claim = driver.job_get_available(attempted_by: "worker", max: 1, now: now, queue: "work") + + expect(first_claim.map(&:id)).to eq([earlier.id]) + expect(second_claim.map(&:id)).to eq([later.id]) + expect(driver.job_get_by_id(low.id)).to have_attributes(state: River::JOB_STATE_AVAILABLE) + end + + it "does not transition a job that is no longer running" do + inserted = insert_job(1) + + expect(driver.job_set_state_if_running(id: inserted.id, state: River::JOB_STATE_COMPLETED)).to be_nil + expect(driver.job_get_by_id(inserted.id)).to have_attributes(state: River::JOB_STATE_AVAILABLE) + end + + %w[available cancelled completed discarded pending retryable scheduled].each do |state| + it "honors cancellation in an atomic transition to #{state}" do + inserted = insert_job(1, scheduled_at: now - 1) + driver.job_claim(id: inserted.id, attempted_by: "worker", now: now) + driver.job_cancel(inserted.id, now: now) + error = River::AttemptError.new(at: now, attempt: 1, error: "worker failed", trace: "") + + updated = driver.job_set_state_if_running( + id: inserted.id, attempt: 0, error: error, finalized_at: now + 10, + now: now, scheduled_at: now + 60, state: state + ) + + expect(updated).to have_attributes( + finalized_at: be_within(0.001).of(now), state: "cancelled", + scheduled_at: inserted.scheduled_at, attempt: 1, + errors: contain_exactly(have_attributes(error: "worker failed")) + ) + end + end + + [1, 25].each do |max_attempts| + it "reports cancellation when a failed attempt races with discard at max_attempts #{max_attempts}" do + worker = Object.new + worker.define_singleton_method(:work) { |_job| raise "worker failed" } + worker.define_singleton_method(:retry?) { |*_args| false } + client = River::Client.new(driver, config: River::Config.new( + workers: River::Workers.new.add("driver_runtime", worker) + )) + inserted = insert_job(1, max_attempts: max_attempts) + driver.define_singleton_method(:job_set_state_if_running) do |**params| + job_cancel(params.fetch(:id)) + super(**params) + end + subscription = client.subscribe(:job_cancelled, :job_failed) + + row, error, outcome = client.__perform_job(inserted.id) + + expect(row).to have_attributes(state: "cancelled", finalized_at: be_a(Time)) + expect(error.message).to eq("worker failed") + expect(outcome).to eq(:cancelled) + expect(subscription.pop(true).kind).to eq(:job_cancelled) + expect { subscription.pop(true) }.to raise_error(ThreadError) + end + end + + it "lets a remote cancellation win over a retry transition" do + inserted = insert_job(1, scheduled_at: now - 1) + running = driver.job_get_available(attempted_by: "worker", max: 1, now: now, queue: inserted.queue).first + driver.job_cancel(running.id, now: now) + + updated = driver.job_set_state_if_running( + id: running.id, + now: now, + scheduled_at: now + 60, + state: River::JOB_STATE_RETRYABLE + ) + + expect(updated).to have_attributes(finalized_at: be_within(0.001).of(now), state: River::JOB_STATE_CANCELLED) + end + + it "promotes due scheduled and retryable jobs up to max" do + scheduled = insert_job(1, scheduled_at: now - 2, state: River::JOB_STATE_SCHEDULED) + retryable = insert_job(2, scheduled_at: now - 1) + client.job_update(retryable.id, state: River::JOB_STATE_RETRYABLE) + future = insert_job(3, scheduled_at: now + 60, state: River::JOB_STATE_SCHEDULED) + + expect(driver.job_schedule(max: 1, now: now)).to eq(1) + expect(driver.job_get_by_id(scheduled.id)).to have_attributes(state: River::JOB_STATE_AVAILABLE) + expect(driver.job_get_by_id(retryable.id)).to have_attributes(state: River::JOB_STATE_RETRYABLE) + expect(driver.job_get_by_id(future.id)).to have_attributes(state: River::JOB_STATE_SCHEDULED) + end + + it "discards a scheduled job whose unique key conflicts during promotion" do + states = [ + River::JOB_STATE_AVAILABLE, + River::JOB_STATE_PENDING, + River::JOB_STATE_RUNNING, + River::JOB_STATE_SCHEDULED + ] + unique = River::UniqueOpts.new(by_queue: true, by_state: states) + retryable = insert_job(2, scheduled_at: now - 1, unique_opts: unique) + client.job_update(retryable.id, state: River::JOB_STATE_RETRYABLE) + insert_job(1, unique_opts: unique) + + expect(driver.job_schedule(now: now)).to eq(1) + discarded = driver.job_get_by_id(retryable.id) + + expect(discarded).to have_attributes( + finalized_at: be_within(0.001).of(now), + state: River::JOB_STATE_DISCARDED + ) + expect(discarded.metadata.to_h).to include("unique_key_conflict" => "scheduler_discarded") + end + + it "continues past ineligible rescue candidates without consuming the limit" do + jobs = 3.times.map do |index| + inserted = insert_job(index, scheduled_at: now - 120) + driver.job_claim(id: inserted.id, attempted_by: "worker", now: now - 120) + end + eligible = ->(job, _now) { job.id == jobs.last.id } + + expect(driver.job_rescue_stuck(horizon: now - 60, max: 1, now: now, + rescue_if: eligible, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(1) + expect(jobs.map { |job| client.job_get(job.id).state }).to eq(%w[running running retryable]) + expect(driver.job_rescue_stuck(horizon: now - 60, now: now, + rescue_if: eligible, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(0) + end + + it "rescues a stuck job for retry" do + inserted = insert_job(1, scheduled_at: now - 120) + running = driver.job_get_available(attempted_by: "worker", max: 1, now: now - 120, queue: "default").first + retry_policy = Object.new + retry_policy.define_singleton_method(:next_retry) { |_job, _error, now:| now + 60 } + + expect(driver.job_rescue_stuck(horizon: now - 60, now: now, retry_policy: retry_policy)).to eq(1) + rescued = driver.job_get_by_id(running.id) + + expect(rescued).to have_attributes( + errors: contain_exactly(have_attributes(error: "Stuck job rescued by River")), + metadata: have_attributes(to_h: include("river:rescue_count" => 1)), + state: River::JOB_STATE_RETRYABLE + ) + expect(inserted.id).to eq(running.id) + end + + [:raises, nil, "tomorrow", Time.at(0)].each do |retry_result| + it "rescues the whole batch when the retry policy returns #{retry_result.inspect}" do + jobs = 2.times.map do |index| + inserted = insert_job(index, scheduled_at: now - 120) + driver.job_claim(id: inserted.id, attempted_by: "worker", now: now - 120) + end + policy = Object.new + policy.define_singleton_method(:next_retry) do |job, _error, now:| + next now + 60 if job.id == jobs.last.id + raise "broken policy" if retry_result == :raises + + retry_result + end + + expect(driver.job_rescue_stuck(horizon: now - 60, now: now, retry_policy: policy)).to eq(2) + expect(client.job_get(jobs.first.id)).to have_attributes( + state: "retryable", scheduled_at: be > now, + errors: contain_exactly(have_attributes(error: "Stuck job rescued by River")) + ) + expect(client.job_get(jobs.last.id)).to have_attributes(state: "retryable", scheduled_at: be_within(0.001).of(now + 60)) + end + end + + it "rescues the whole batch when rescue counters contain nonnumeric JSON values" do + counts = [nil, true, false, "5", [], {}, 5, 2.5] + jobs = counts.each_with_index.map do |count, index| + inserted = insert_job(index, metadata: {"river:rescue_count" => count}, scheduled_at: now - 120) + driver.job_claim(id: inserted.id, attempted_by: "worker", now: now - 120) + end + + expect(driver.job_rescue_stuck(horizon: now - 60, now: now, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(jobs.length) + expect(jobs.map { |job| client.job_get(job.id).metadata.fetch("river:rescue_count") }).to eq([1, 1, 1, 1, 1, 1, 6, 3]) + expect(jobs.map { |job| client.job_get(job.id).state }.uniq).to eq([River::JOB_STATE_RETRYABLE]) + end + + it "applies the rescue limit after filtering out healthy running jobs" do + healthy = insert_job(1, scheduled_at: now - 1) + driver.job_get_available(attempted_by: "worker", max: 1, now: now, queue: "default") + stuck = insert_job(2, scheduled_at: now - 120) + driver.job_get_available(attempted_by: "worker", max: 1, now: now - 120, queue: "default") + + expect(driver.job_rescue_stuck(horizon: now - 60, max: 1, now: now, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(1) + expect(client.job_get(stuck.id).state).to eq(River::JOB_STATE_RETRYABLE) + expect(client.job_get(healthy.id).state).to eq(River::JOB_STATE_RUNNING) + end + + it "discards a stuck job that exhausted its attempts" do + inserted = insert_job(1, max_attempts: 1, scheduled_at: now - 120) + running = driver.job_get_available(attempted_by: "worker", max: 1, now: now - 120, queue: "default").first + + driver.job_rescue_stuck(horizon: now - 60, now: now, retry_policy: River::DefaultClientRetryPolicy.new) + + expect(driver.job_get_by_id(inserted.id)).to have_attributes( + attempt: running.attempt, + finalized_at: be_within(0.001).of(now), + state: River::JOB_STATE_DISCARDED + ) + end + + it "cancels a stuck job with a pending cancellation request" do + inserted = insert_job(1, scheduled_at: now - 120) + running = driver.job_get_available(attempted_by: "worker", max: 1, now: now - 120, queue: "default").first + driver.job_cancel(running.id, now: now - 100) + + driver.job_rescue_stuck(horizon: now - 60, now: now, retry_policy: River::DefaultClientRetryPolicy.new) + + expect(driver.job_get_by_id(inserted.id)).to have_attributes( + finalized_at: be_within(0.001).of(now), + state: River::JOB_STATE_CANCELLED + ) + end + + it "ignores running jobs newer than the rescue horizon" do + inserted = insert_job(1, scheduled_at: now - 10) + driver.job_get_available(attempted_by: "worker", max: 1, now: now - 10, queue: "default") + + expect(driver.job_rescue_stuck( + horizon: now - 60, + now: now, + retry_policy: River::DefaultClientRetryPolicy.new + )).to eq(0) + expect(driver.job_get_by_id(inserted.id)).to have_attributes(state: River::JOB_STATE_RUNNING) + end + + it "rescues only attempts strictly before the horizon at millisecond precision" do + horizon = now - 60 + Rational(123, 1_000) + jobs = [-1, 0, 1].map do |milliseconds| + row = insert_job(1, scheduled_at: now - 120) + driver.job_claim(id: row.id, attempted_by: "worker", now: horizon + Rational(milliseconds, 1_000)) + end + + expect(driver.job_rescue_stuck(horizon: horizon, now: now, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(1) + expect(driver.job_get_by_id(jobs.first.id).state).to eq("retryable") + jobs.drop(1).each do |job| + expect(driver.job_get_by_id(job.id)).to have_attributes(state: "running", attempted_at: job.attempted_at, errors: job.errors, metadata: job.metadata) + end + end + + it "deletes only finalized jobs older than their state retention" do + old_cancelled = insert_job(1) + old_completed = insert_job(2) + recent_cancelled = insert_job(3) + client.job_update(old_cancelled.id, River::JobUpdateParams.new(finalized_at: now - 120, state: River::JOB_STATE_CANCELLED)) + client.job_update(old_completed.id, River::JobUpdateParams.new(finalized_at: now - 120, state: River::JOB_STATE_COMPLETED)) + client.job_update(recent_cancelled.id, River::JobUpdateParams.new(finalized_at: now - 10, state: River::JOB_STATE_CANCELLED)) + + deleted = driver.job_delete_finalized( + now: now, + retention: {River::JOB_STATE_CANCELLED => 60, River::JOB_STATE_COMPLETED => nil} + ) + + expect(deleted).to eq(1) + expect(driver.job_get_by_id(old_cancelled.id)).to be_nil + expect(driver.job_get_by_id(old_completed.id)).not_to be_nil + expect(driver.job_get_by_id(recent_cancelled.id)).not_to be_nil + end + + it "honors the finalized cleanup limit" do + jobs = 3.times.map do |index| + insert_job(index).tap do |job| + client.job_update(job.id, River::JobUpdateParams.new(finalized_at: now - 120, state: River::JOB_STATE_COMPLETED)) + end + end + + expect(driver.job_delete_finalized(max: 2, now: now, retention: {River::JOB_STATE_COMPLETED => 60})).to eq(2) + expect(jobs.count { |job| driver.job_get_by_id(job.id) }).to eq(1) + end + + it "does nothing when all finalized retention policies are disabled" do + expect(driver.job_delete_finalized(now: now, retention: {River::JOB_STATE_COMPLETED => nil})).to eq(0) + end + + it "returns no jobs when bulk deletion matches nothing" do + expect(driver.job_delete_many(River::JobListParams.new(ids: [-1]))).to eq([]) + end + + it "rejects update fields outside JobUpdateParams" do + params = Object.new + params.define_singleton_method(:each) { [[:unknown, true]].each } + + expect { driver.job_update(-1, params) }.to raise_error(ArgumentError, /unknown update field: unknown/) + end + + it "replaces nested metadata values and preserves null values" do + job = insert_job(1, metadata: {"nested" => {"old" => true}, "nullable" => 2}) + updates = {"nested" => {"new" => true}, "nullable" => nil} + + expected = job.metadata.to_h.merge(updates) + + expect(driver.job_metadata_merge(job.id, updates).metadata.to_h).to eq(expected) + expect(driver.job_metadata_merge(job.id, {}).metadata.to_h).to eq(expected) + end + + it "returns nil when merging metadata into a missing job" do + expect(driver.job_metadata_merge(-1, {"missing" => true})).to be_nil + end + + it "supports the unfiltered internal job list" do + inserted = [insert_job(1), insert_job(2)] + + expect(driver.job_list(:all).map(&:id)).to eq(inserted.map(&:id)) + end + + it "filters and sorts jobs independently" do + first = insert_job(1, metadata: {"tenant" => "a"}, priority: 2, queue: "one", scheduled_at: now - 30, tags: %w[red shared]) + second = insert_job(2, metadata: {"tenant" => "b"}, priority: 3, queue: "two", scheduled_at: now - 20, tags: %w[blue shared]) + third = insert_job(3, metadata: {"tenant" => "a"}, priority: 2, queue: "one", scheduled_at: now - 10, tags: %w[red]) + + expect(driver.job_list(River::JobListParams.new(tags_all: %w[red shared])).map(&:id)).to eq([first.id]) + expect(driver.job_list(River::JobListParams.new(tags_any: %w[missing blue])).map(&:id)).to eq([second.id]) + expect(driver.job_list(River::JobListParams.new(metadata: {tenant: "a"}, priorities: [2])).map(&:id)).to eq([first.id, third.id]) + expect(driver.job_list(River::JobListParams.new(queues: ["one"], sort_by: :scheduled_at, sort_order: :desc)).map(&:id)) + .to eq([third.id, first.id]) + end +end + +RSpec.shared_examples "driver queue and leadership state" do + let(:now) { Time.utc(2026, 1, 2, 3, 4, 5) } + + describe "queue events" do + it "publishes every affected queue beyond the default list limit" do + client = River::Client.new(driver) + names = Array.new(105) { |index| "queue_#{index}" } + names.each { |name| driver.queue_upsert(name, metadata: {"name" => name}, now: now) } + subscription = client.subscribe(River::EVENT_QUEUE_PAUSED, River::EVENT_QUEUE_RESUMED, buffer_size: names.length) + + [[:queue_pause, River::EVENT_QUEUE_PAUSED, be_a(Time)], [:queue_resume, River::EVENT_QUEUE_RESUMED, be_nil]].each do |operation, kind, paused_at| + expect(client.public_send(operation, "*")).to be true + events = names.map { subscription.pop(true) } + + expect(events.map { |event| event.queue.name }).to match_array(names) + expect(events).to all(have_attributes(kind: kind, queue: have_attributes(paused_at: paused_at))) + expect(events.map { |event| event.queue.metadata }).to match_array(names.map { |name| {"name" => name} }) + expect { subscription.pop(true) }.to raise_error(ThreadError) + end + end + + %i[queue_pause queue_resume].each do |operation| + ["work", "*"].each do |name| + it "publishes the #{operation} snapshot for #{name} even if the queue changes before publication" do + client = River::Client.new(driver) + driver.queue_upsert("work") + driver.queue_pause("work") if operation == :queue_resume + opposite = (operation == :queue_pause) ? :queue_resume : :queue_pause + kind = (operation == :queue_pause) ? River::EVENT_QUEUE_PAUSED : River::EVENT_QUEUE_RESUMED + subscription = client.subscribe(kind) + driver.define_singleton_method(operation) do |*arguments, **options| + queues = super(*arguments, **options) + public_send(opposite, "work") + queue_upsert("late") + queues + end + + client.public_send(operation, name) + + paused_at = (operation == :queue_pause) ? be_a(Time) : be_nil + expect(subscription.pop(true)).to have_attributes(kind: kind, queue: have_attributes(name: "work", paused_at: paused_at)) + expect { subscription.pop(true) }.to raise_error(ThreadError) + end + end + end + end + + describe "queue update metadata" do + it "accepts empty objects and nested null values" do + driver.queue_upsert("work", metadata: {"original" => true}) + + expect(driver.queue_update("work", metadata: {}).metadata).to eq({}) + expect(driver.queue_update("work", metadata: {"nested" => {"value" => nil}}).metadata) + .to eq("nested" => {"value" => nil}) + end + + [nil, [], "null", "{}", 1, false].each do |metadata| + it "rejects #{metadata.inspect} without changing the persisted queue" do + original = driver.queue_upsert("work", metadata: {"original" => true}, now: now) + + expect { driver.queue_update("work", metadata: metadata, now: now + 10) } + .to raise_error(ArgumentError, "metadata must be a Hash") + expect(driver.queue_get("work")).to eq(original) + + client = River::Client.new(driver) + expect { client.queue_update("work", metadata: metadata) } + .to raise_error(ArgumentError, "metadata must be a Hash") + expect(driver.queue_get("work")).to eq(original) + end + end + end + + describe "queue upsert metadata" do + [nil, [], "null", "{}", 1, false].each do |metadata| + it "rejects #{metadata.inspect} without inserting or updating a queue" do + original = driver.queue_upsert("work", metadata: {"original" => true}, now: now) + + expect { driver.queue_upsert("new", metadata: metadata) } + .to raise_error(ArgumentError, "metadata must be a Hash") + expect(driver.queue_get("new")).to be_nil + expect { driver.queue_upsert("work", metadata: metadata, now: now + 10) } + .to raise_error(ArgumentError, "metadata must be a Hash") + expect(driver.queue_get("work")).to eq(original) + end + end + end + + it "rolls back queue and leadership changes when notification delivery fails" do + driver.queue_upsert("priority") + driver.queue_upsert("paused") + driver.queue_pause("paused", now: now) + driver.leader_acquire("leader") + driver.define_singleton_method(:runtime_notify) { |*| raise "notification failed" } + expect { driver.queue_pause("priority") }.to raise_error("notification failed") + expect(driver.queue_get("priority").paused_at).to be_nil + expect { driver.queue_resume("paused") }.to raise_error("notification failed") + expect(driver.queue_get("paused").paused_at).to be_within(0.001).of(now) + expect { driver.queue_update("priority", metadata: {"changed" => true}) }.to raise_error("notification failed") + expect(driver.queue_get("priority").metadata).to eq({}) + expect { driver.leader_release("leader") }.to raise_error("notification failed") + expect(driver.leader_renew("leader")).to be true + end + + it "does not create a missing queue when asked to pause or resume it" do + driver.queue_pause("missing") + driver.queue_resume("missing") + expect(driver.queue_get("missing")).to be_nil + end + + it "gets, lists, and updates queues without changing unrelated fields" do + driver.queue_upsert("zeta", now: now) + driver.queue_upsert("alpha", now: now) + + expect(driver.queue_get("missing")).to be_nil + expect(driver.queue_list(max: 1)).to contain_exactly(have_attributes(name: "alpha")) + expect(driver.queue_update("alpha", metadata: {"team" => "ruby"}, now: now + 10)).to have_attributes( + created_at: be_within(0.001).of(now), metadata: {"team" => "ruby"}, name: "alpha", + paused_at: nil, updated_at: be_within(0.001).of(now + 10) + ) + expect(driver.queue_update("missing", metadata: {})).to be_nil + end + + it "pauses and resumes only the named queue" do + driver.queue_upsert("one", now: now) + driver.queue_upsert("two", now: now) + driver.queue_pause("one", now: now + 10) + + expect(driver.queue_get("two")).to have_attributes(paused_at: nil) + driver.queue_resume("one", now: now + 20) + driver.queue_resume("one", now: now + 30) + + expect(driver.queue_get("one")).to have_attributes(paused_at: nil, updated_at: be_within(0.001).of(now + 20)) + end + + it "does not release another client's leadership" do + driver.leader_acquire("one", now: now) + driver.leader_release("two") + + expect(driver.leader_acquire("two", now: now)).to be false + expect(driver.leader_renew("one", now: now)).to be true + end + + it "upserts a queue without replacing its metadata" do + original = driver.queue_upsert("work", metadata: {"team" => "ruby"}, now: now) + refreshed = driver.queue_upsert("work", metadata: {"team" => "other"}, now: now + 10) + + expect(original.metadata).to eq("team" => "ruby") + expect(refreshed).to have_attributes( + created_at: be_within(0.001).of(now), + metadata: {"team" => "ruby"}, + updated_at: be_within(0.001).of(now + 10) + ) + end + + it "keeps the original pause timestamp across repeated pauses" do + driver.queue_upsert("work", now: now) + driver.queue_pause("work", now: now + 10) + driver.queue_pause("work", now: now + 20) + + expect(driver.queue_get("work")).to have_attributes( + paused_at: be_within(0.001).of(now + 10), + updated_at: be_within(0.001).of(now + 10) + ) + end + + it "pauses and resumes every queue with the wildcard" do + driver.queue_upsert("one", now: now) + driver.queue_upsert("two", now: now) + + driver.queue_pause("*", now: now + 10) + + expect(driver.queue_list).to all(have_attributes(paused_at: be_a(Time))) + driver.queue_resume("*", now: now + 20) + + expect(driver.queue_list).to all(have_attributes(paused_at: nil, updated_at: be_within(0.001).of(now + 20))) + end + + it "elects only one live leader" do + expect(driver.leader_acquire("one", now: now, ttl: 30)).to be true + expect(driver.leader_acquire("two", now: now, ttl: 30)).to be false + end + + it "allows a new leader after expiration" do + driver.leader_acquire("one", now: now, ttl: 10) + + expect(driver.leader_acquire("two", now: now + 11, ttl: 30)).to be true + end + + it "renews only the current unexpired leader" do + driver.leader_acquire("one", now: now, ttl: 10) + + expect(driver.leader_renew("one", now: now + 5, ttl: 30)).to be true + expect(driver.leader_renew("two", now: now + 5, ttl: 30)).to be false + expect(driver.leader_renew("one", now: now + 40, ttl: 30)).to be false + end + + it "releases leadership" do + driver.leader_acquire("one", now: now) + driver.leader_release("one") + + expect(driver.leader_acquire("two", now: now)).to be true + end +end diff --git a/ruby/spec/driver_shared_examples.rb b/ruby/spec/driver_shared_examples.rb new file mode 100644 index 000000000..b9c06129a --- /dev/null +++ b/ruby/spec/driver_shared_examples.rb @@ -0,0 +1,586 @@ +# frozen_string_literal: true + +require_relative "driver_runtime_shared_examples" + +class SimpleArgs + attr_accessor :job_num + + def initialize(job_num:) + self.job_num = job_num + end + + def kind = "simple" + + def to_json = JSON.dump({job_num: job_num}) +end + +# Lets us test job-specific insertion opts by making `#insert_opts` an accessor. +# Real args that make use of this functionality will probably want to make +# `#insert_opts` a non-accessor method instead. +class SimpleArgsWithInsertOpts < SimpleArgs + attr_accessor :insert_opts +end + +shared_examples "driver shared examples" do + it_behaves_like "driver job state machine" + it_behaves_like "driver queue and leadership state" + + describe "cross-kind uniqueness" do + [:insert, :insert_many].each do |operation| + it "preserves the original kind and arguments on a duplicate #{operation}" do + options = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_args: true, exclude_kind: true)) + original = client.insert(River::JobArgsHash.new("original", {"id" => 1}), insert_opts: options).job + args = River::JobArgsHash.new("different", {"id" => 1}) + result = if operation == :insert + client.insert(args, insert_opts: options) + else + client.insert_many([River::InsertManyParams.new(args, insert_opts: options)]).first + end + + expect(result.unique_skipped_as_duplicate?).to be true + expect(result.job).to have_attributes(id: original.id, kind: "original", args: original.args) + expect(client.job_get(original.id)).to have_attributes( + args: original.args, kind: original.kind, metadata: original.metadata, state: original.state + ) + end + end + end + + describe "insertion attempt limits" do + it "accepts a single attempt and explicit overrides of argument-level limits" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new(max_attempts: 0) + + expect(client.insert(args, max_attempts: 1).job.max_attempts).to eq(1) + expect(client.insert_many([River::InsertManyParams.new(args, max_attempts: 1)]).first.job.max_attempts).to eq(1) + end + + [0, -1].each do |max_attempts| + it "rejects #{max_attempts} attempts in single inserts without writing any jobs" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new(max_attempts: max_attempts) + + expect { client.insert(args) }.to raise_error(ArgumentError, "max_attempts must be greater than zero") + expect { client.insert(SimpleArgs.new(job_num: 1), insert_opts: args.insert_opts) } + .to raise_error(ArgumentError, "max_attempts must be greater than zero") + expect { client.insert(SimpleArgs.new(job_num: 1), max_attempts: max_attempts) } + .to raise_error(ArgumentError, "max_attempts must be greater than zero") + expect(client.job_list.jobs).to be_empty + end + + it "rejects #{max_attempts} attempts in batch inserts without writing any jobs" do + args = SimpleArgsWithInsertOpts.new(job_num: 2) + args.insert_opts = River::InsertOpts.new(max_attempts: max_attempts) + + expect { client.insert_many([SimpleArgs.new(job_num: 1), args]) } + .to raise_error(ArgumentError, "max_attempts must be greater than zero") + expect do + client.insert_many([ + SimpleArgs.new(job_num: 1), + River::InsertManyParams.new(SimpleArgs.new(job_num: 2), max_attempts: max_attempts) + ]) + end.to raise_error(ArgumentError, "max_attempts must be greater than zero") + expect(client.job_list.jobs).to be_empty + end + end + end + + it "filters kinds before claiming jobs without consuming unknown attempts" do + now = Time.utc(2026, 1, 2) + unknown, known = ["unknown", "known'kind"].map do |kind| + client.insert(River::JobArgsHash.new(kind, {}), insert_opts: River::InsertOpts.new(scheduled_at: now - 1)).job + end + expect(driver.job_get_available(queue: "default", max: 10, attempted_by: "test", kinds: [], now: now)).to eq([]) + expect(driver.job_get_available(queue: "default", max: 1, attempted_by: "test", kinds: ["known'kind"], now: now)) + .to contain_exactly(have_attributes(id: known.id, attempt: 1, state: "running")) + expect(driver.job_get_by_id(unknown.id)).to have_attributes(attempt: 0, state: "available") + expect(driver.job_get_available(queue: "default", max: 10, attempted_by: "test", now: now)) + .to contain_exactly(have_attributes(id: unknown.id)) + end + + %i[insert_begin insert_end insert_many].each do |hook| + it "rolls back #{hook} writes and the enqueue without aborting the caller's transaction" do + plugin = Object.new + plain_client = client + failure = RuntimeError.new("hook failed") + plugin.define_singleton_method(hook) do |*arguments| + arguments.last.call if hook == :insert_many + plain_client.insert(SimpleArgs.new(job_num: 99)) + raise failure + end + hooked_client = River::Client.new(driver, config: River::Config.new(plugins: [plugin])) + + driver.transaction do + kept = plain_client.insert(SimpleArgs.new(job_num: 1)).job + expect { hooked_client.insert(SimpleArgs.new(job_num: 2)) }.to raise_error { |error| expect(error).to equal(failure) } + expect(plain_client.job_list.jobs.map(&:id)).to eq([kept.id]) + expect(plain_client.insert(SimpleArgs.new(job_num: 3)).job.args).to eq("job_num" => 3) + end + expect(plain_client.job_list.jobs.map { |row| row.args.fetch("job_num") }).to eq([1, 3]) + end + end + + it "merges metadata shallowly, preserving nulls and literal keys on both databases" do + job = client.insert(SimpleArgs.new(job_num: 1), insert_opts: River::InsertOpts.new( + metadata: {"keep" => 1, "nested" => {"old" => true}, "nullable" => 2} + )).job + updates = {'a"b' => "literal", "a.b" => [1, true], "a\\b" => false, "nested" => {"new" => true}, "nullable" => nil} + + driver.job_metadata_merge(job.id, updates) + + expect(client.job_get(job.id).metadata.to_h).to eq(job.metadata.to_h.merge(updates)) + end + + it "rolls back cancellation if publishing its notification fails" do + job = client.insert(SimpleArgs.new(job_num: 1)).job + driver.define_singleton_method(:runtime_notify) { |*_args| raise "notification failed" } + + expect { client.job_cancel(job.id) }.to raise_error("notification failed") + expect(client.job_get(job.id)).to have_attributes(state: "available", metadata: job.metadata) + end + + it "does not clean up a finalized job retried after cleanup selected it" do + job = client.insert(SimpleArgs.new(job_num: 1)).job + client.job_update(job.id, River::JobUpdateParams.new(finalized_at: Time.now.utc - 120, state: River::JOB_STATE_COMPLETED)) + driver.define_singleton_method(:runtime_query_rows) do |sql| + rows = super(sql) + job_retry(job.id) if sql.start_with?("SELECT id FROM river_job WHERE") + + rows + end + + expect(driver.job_delete_finalized(retention: {River::JOB_STATE_COMPLETED => 60})).to eq(0) + expect(client.job_get(job.id).state).to eq(River::JOB_STATE_AVAILABLE) + end + + it "keeps static driver constants shareable across Ractor boundaries" do + values = %i[SQLITE_CONFLICT_WHERE SQLITE_JOB_COLUMNS UNIQUE_INSERT_METADATA_KEY] + .map { |name| driver.class.const_get(name, false) } + + expect(values).to all(satisfy { |value| Ractor.shareable?(value) }) + end + + it "implements the worker runtime SQL primitives" do + queue = driver.queue_upsert("runtime-primitives", metadata: {"source" => "spec"}) + + expect(queue).to have_attributes(metadata: {"source" => "spec"}, name: "runtime-primitives") + expect(driver.queue_list(max: 10).map(&:name)).to include("runtime-primitives") + expect(driver.job_list(River::JobListParams.new(limit: 1))).to be_an(Array) + expect(driver.send(:runtime_job_list_without_params)).to be_an(Array) + expect(driver.send(:runtime_postgres?)).to satisfy { |value| value == true || value == false } + expect(driver.send(:runtime_quote, "value")).to be_a(String) + expect(driver.send(:runtime_unique_violation_class)).to be <= StandardError + expect(driver.send(:runtime_value, {"id" => 1}, :id)).to eq(1) + end + + describe "unique insertion" do + it "rejects repeated active unique keys atomically in a returning batch" do + kept = client.insert(SimpleArgs.new(job_num: 99)).job + options = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_args: true)) + batch = [2, 1, 1].map { |number| River::InsertManyParams.new(SimpleArgs.new(job_num: number), insert_opts: options) } + + expect { client.insert_many(batch) }.to raise_error(StandardError) + expect(client.job_list.jobs.map(&:id)).to eq([kept.id]) + end + + it "allows internal driver inserts outside their indexed states and preserves duplicate flags" do + unique = River::UniqueOpts.new(by_args: true, by_state: [:available, :pending, :running, :scheduled]) + batch = %w[retryable available retryable].map do |state| + params = client.send(:make_insert_params, SimpleArgs.new(job_num: 1), River::InsertOpts.new(unique_opts: unique)) + params.state = state + params + end + results = driver.job_insert_many(batch) + jobs = results.map(&:first) + expect(jobs.map(&:id).uniq.length).to eq(3) + expect(results.map(&:last)).to eq([false, false, false]) + + duplicate = client.insert(SimpleArgs.new(job_num: 1), insert_opts: River::InsertOpts.new(unique_opts: unique)) + expect(duplicate).to have_attributes(unique_skipped_as_duplicate?: true, job: have_attributes(id: jobs[1].id)) + if driver.migration_backend == :sqlite + nonces = jobs.map { |job| job.metadata.fetch("river:unique_nonce") } + expect(nonces).to all(match(/\A[0-9a-f]{16}\z/)) + expect(nonces.uniq.length).to eq(3) + end + end + + it "inserts a unique job once" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new( + unique_opts: River::UniqueOpts.new( + by_queue: true + ) + ) + + insert_res = client.insert(args) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(false) + ) + original_job = insert_res.job + + insert_res = client.insert(args) + + expect(insert_res).to have_attributes( + job: have_attributes(id: original_job.id), + unique_skipped_as_duplicate?: be(true) + ) + end + + it "decodes uniqueness states consistently when inserting, fetching, and listing" do + [nil, River::UniqueOpts.new(by_args: true)].each_with_index do |unique, number| + inserted = client.insert(SimpleArgs.new(job_num: number), insert_opts: River::InsertOpts.new(unique_opts: unique)).job + expected = unique ? %w[available completed pending retryable running scheduled] : nil + + expect(inserted.unique_states).to eq(expected) + expect(client.job_get(inserted.id).unique_states).to eq(expected) + expect(client.job_list.jobs.find { |job| job.id == inserted.id }.unique_states).to eq(expected) + end + end + + it "inserts a unique job with custom states" do + client = River::Client.new(driver) + + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new( + unique_opts: River::UniqueOpts.new( + by_queue: true, + by_state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] + ) + ) + + insert_res = client.insert(args) + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicate?: be(false) + ) + original_job = insert_res.job + + insert_res = client.insert(args) + + expect(insert_res).to have_attributes( + job: have_attributes(id: original_job.id), + unique_skipped_as_duplicate?: be(true) + ) + end + end + + describe "#job_get_by_id" do + let(:job_args) { SimpleArgs.new(job_num: 1) } + + it "gets a job by ID" do + insert_res = client.insert(job_args) + expect(driver.job_get_by_id(insert_res.job.id)).to_not be nil + end + + it "returns nil on not found" do + expect(driver.job_get_by_id(-1)).to be nil + end + end + + describe "#job_insert" do + it "inserts a job" do + insert_res = client.insert(SimpleArgs.new(job_num: 1)) + + expect(insert_res).to have_attributes( + job: have_attributes( + args: {"job_num" => 1}, + attempt: 0, + created_at: be_within(2).of(Time.now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now.getutc), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ), + unique_skipped_as_duplicate?: (be false) + ) + + # Make sure it made it to the database. Assert only minimally since we're + # certain it's the same as what we checked above. + job = driver.job_get_by_id(insert_res.job.id) + + expect(job).to have_attributes( + kind: "simple" + ) + end + + it "schedules a job" do + target_time = Time.now.getutc + 1 * 3600 + + insert_res = client.insert( + SimpleArgs.new(job_num: 1), + insert_opts: River::InsertOpts.new(scheduled_at: target_time) + ) + + expect(insert_res).to have_attributes( + job: have_attributes( + scheduled_at: be_within(2).of(target_time), + state: River::JOB_STATE_SCHEDULED + ), + unique_skipped_as_duplicate?: (be false) + ) + end + + it "inserts with job insert opts" do + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new( + max_attempts: 23, + priority: 2, + queue: "job_custom_queue", + tags: ["job_custom"] + ) + + insert_res = client.insert(args) + + expect(insert_res).to have_attributes( + job: have_attributes( + max_attempts: 23, + priority: 2, + queue: "job_custom_queue", + tags: ["job_custom"] + ), + unique_skipped_as_duplicate?: (be false) + ) + end + + it "inserts with insert opts" do + # We set job insert opts in this spec too so that we can verify that the + # options passed at insertion time take precedence. + args = SimpleArgsWithInsertOpts.new(job_num: 1) + args.insert_opts = River::InsertOpts.new( + max_attempts: 23, + priority: 2, + queue: "job_custom_queue", + tags: ["job_custom"] + ) + + insert_res = client.insert(args, insert_opts: River::InsertOpts.new( + max_attempts: 17, + priority: 3, + queue: "my_queue", + tags: ["custom"] + )) + + expect(insert_res).to have_attributes( + job: have_attributes( + max_attempts: 17, + priority: 3, + queue: "my_queue", + tags: ["custom"] + ), + unique_skipped_as_duplicate?: (be false) + ) + end + + it "inserts with job args hash" do + insert_res = client.insert(River::JobArgsHash.new("hash_kind", { + job_num: 1 + })) + expect(insert_res).to have_attributes( + job: have_attributes( + args: {"job_num" => 1}, + kind: "hash_kind" + ), + unique_skipped_as_duplicate?: (be false) + ) + end + + it "inserts in a transaction" do + insert_res = nil + + driver.transaction do + insert_res = client.insert(SimpleArgs.new(job_num: 1)) + + job = driver.job_get_by_id(insert_res.job.id) + + expect(job).to_not be_nil + expect(insert_res.unique_skipped_as_duplicate?).to be false + + raise driver.rollback_exception + end + + # Not present because the job was rolled back. + job = driver.job_get_by_id(insert_res.job.id) + + expect(job).to be_nil + end + + it "inserts a unique job" do + insert_params = River::Driver::JobInsertParams.new( + encoded_args: JSON.dump({"job_num" => 1}), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: Time.now.getutc, + state: River::JOB_STATE_AVAILABLE, + tags: nil, + unique_key: "unique_key", + unique_states: "00000001" + ) + + job_row, unique_skipped_as_duplicated = driver.job_insert(insert_params) + + expect(job_row).to have_attributes( + args: {"job_num" => 1}, + attempt: 0, + created_at: be_within(2).of(Time.now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now.getutc), + state: River::JOB_STATE_AVAILABLE, + tags: [], + unique_key: "unique_key", + unique_states: [::River::JOB_STATE_AVAILABLE] + ) + expect(unique_skipped_as_duplicated).to be false + + # second insertion should be skipped + job_row, unique_skipped_as_duplicated = driver.job_insert(insert_params) + + expect(job_row).to have_attributes( + args: {"job_num" => 1}, + attempt: 0, + created_at: be_within(2).of(Time.now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now.getutc), + state: River::JOB_STATE_AVAILABLE, + tags: [], + unique_key: "unique_key", + unique_states: [::River::JOB_STATE_AVAILABLE] + ) + expect(unique_skipped_as_duplicated).to be true + end + end + + describe "#job_insert_many" do + it "inserts multiple jobs" do + inserted = client.insert_many([ + SimpleArgs.new(job_num: 1), + SimpleArgs.new(job_num: 2) + ]) + + expect(inserted.length).to eq(2) + expect(inserted[0]).to have_attributes( + job: have_attributes(args: {"job_num" => 1}), + unique_skipped_as_duplicate?: false + ) + expect(inserted[1]).to have_attributes( + job: have_attributes(args: {"job_num" => 2}), + unique_skipped_as_duplicate?: false + ) + + jobs = driver.job_list + + expect(jobs.count).to be 2 + + expect(jobs[0]).to have_attributes( + args: {"job_num" => 1}, + attempt: 0, + created_at: be_within(2).of(Time.now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now.getutc), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + + expect(jobs[1]).to have_attributes( + args: {"job_num" => 2}, + attempt: 0, + created_at: be_within(2).of(Time.now.getutc), + kind: "simple", + max_attempts: River::MAX_ATTEMPTS_DEFAULT, + priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, + scheduled_at: be_within(2).of(Time.now.getutc), + state: River::JOB_STATE_AVAILABLE, + tags: [] + ) + end + + it "inserts multiple jobs in a transaction" do + jobs = nil + + driver.transaction do + inserted = client.insert_many([ + SimpleArgs.new(job_num: 1), + SimpleArgs.new(job_num: 2) + ]) + + expect(inserted.length).to eq(2) + expect(inserted[0]).to have_attributes( + job: have_attributes(args: {"job_num" => 1}), + unique_skipped_as_duplicate?: false + ) + expect(inserted[1]).to have_attributes( + job: have_attributes(args: {"job_num" => 2}), + unique_skipped_as_duplicate?: false + ) + + jobs = driver.job_list + + expect(jobs.count).to be 2 + + raise driver.rollback_exception + end + + # Not present because the jobs were rolled back. + expect(driver.job_get_by_id(jobs[0].id)).to be nil + expect(driver.job_get_by_id(jobs[1].id)).to be nil + end + end + + describe "#job_list" do + let(:job_args) { SimpleArgs.new(job_num: 1) } + + it "gets a job by ID" do + insert_res1 = client.insert(job_args) + insert_res2 = client.insert(job_args) + + jobs = driver.job_list + + expect(jobs.count).to be 2 + + expect(jobs[0].id).to be insert_res1.job.id + expect(jobs[1].id).to be insert_res2.job.id + end + + it "returns nil on not found" do + expect(driver.job_get_by_id(-1)).to be nil + end + end + + describe "#transaction" do + it "runs block in a transaction" do + insert_res = nil + + driver.transaction do + insert_res = client.insert(SimpleArgs.new(job_num: 1)) + + job = driver.job_get_by_id(insert_res.job.id) + + expect(job).to_not be_nil + + raise driver.rollback_exception + end + + # Not present because the job was rolled back. + job = driver.job_get_by_id(insert_res.job.id) + + expect(job).to be_nil + end + end +end diff --git a/ruby/spec/errors_spec.rb b/ruby/spec/errors_spec.rb new file mode 100644 index 000000000..8bb5a3b5a --- /dev/null +++ b/ruby/spec/errors_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ".job_cancel" do + it "builds an error with the default message" do + error = River.job_cancel + + expect(error).to be_a(River::JobCancelError) + expect(error).to have_attributes( + cause: be_nil, + message: "job cancelled" + ) + end + + it "builds an error from a message" do + error = River.job_cancel("account closed") + + expect(error).to be_a(River::JobCancelError) + expect(error).to have_attributes( + cause: be_nil, + message: "account closed" + ) + end + + it "wraps an exception" do + cause = RuntimeError.new("account closed") + error = River.job_cancel(cause) + + expect(error).to be_a(River::JobCancelError) + expect(error).to have_attributes( + cause: equal(cause), + message: "account closed" + ) + end +end + +RSpec.describe ".job_snooze" do + it "builds a snooze error" do + error = River.job_snooze(30) + + expect(error).to be_a(River::JobSnoozeError) + expect(error.duration).to eq(30.0) + end +end + +RSpec.describe River::JobCancelError do + it "has a default message" do + expect(described_class.new.message).to eq("job cancelled") + end + + it "retains a custom message and cause" do + cause = RuntimeError.new("original") + error = described_class.new("cancelled externally", cause: cause) + + expect(error).to have_attributes( + cause: equal(cause), + message: "cancelled externally" + ) + end +end + +RSpec.describe River::JobSnoozeError do + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |seconds| + it "rejects non-finite snoozes of #{seconds}" do + expect { described_class.new(seconds) }.to raise_error(ArgumentError, "duration must be finite") + end + end + + it "coerces and exposes its duration" do + error = described_class.new("1.5") + + expect(error).to have_attributes( + duration: 1.5, + message: "job snoozed for 1.5 seconds" + ) + end + + it "allows an immediate retry" do + expect(described_class.new(0).duration).to eq(0.0) + end + + it "rejects a negative duration" do + expect { described_class.new(-1) } + .to raise_error(ArgumentError, "duration must be zero or greater") + end + + it "rejects a nonnumeric duration" do + expect { described_class.new("later") }.to raise_error(ArgumentError) + end +end + +RSpec.describe River::UnknownJobKindError do + it "exposes the unknown kind" do + error = described_class.new("missing") + + expect(error).to have_attributes( + kind: "missing", + message: "unknown job kind: missing" + ) + end +end diff --git a/ruby/spec/event_spec.rb b/ruby/spec/event_spec.rb new file mode 100644 index 000000000..c0ad91280 --- /dev/null +++ b/ruby/spec/event_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe River::Subscription do + let(:matching) { River::Event.new(River::EVENT_JOB_COMPLETED, nil, nil, nil) } + let(:other) { River::Event.new(River::EVENT_JOB_FAILED, nil, nil, nil) } + + it "publishes subscribed event kinds" do + subscription = described_class.new(["job_completed"]) + subscription.publish(matching) + + expect(subscription.pop(true)).to equal(matching) + end + + it "ignores event kinds that were not subscribed" do + subscription = described_class.new([River::EVENT_JOB_COMPLETED]) + subscription.publish(other) + + expect { subscription.pop(true) }.to raise_error(ThreadError) + end + + it "drops events when its bounded buffer is full" do + subscription = described_class.new([River::EVENT_JOB_COMPLETED], buffer_size: 1) + subscription.publish(matching) + subscription.publish(River::Event.new(River::EVENT_JOB_COMPLETED, :second, nil, nil)) + + expect(subscription.pop(true)).to equal(matching) + expect { subscription.pop(true) }.to raise_error(ThreadError) + end + + it "provides an enumerator and terminates iteration when closed" do + subscription = described_class.new([River::EVENT_JOB_COMPLETED]) + subscription.publish(matching) + subscription.close + + expect(subscription.each).to be_an(Enumerator) + expect(subscription.each.to_a).to eq([matching]) + end + + it "detaches exactly once when closed more than once" do + detached = [] + subscription = described_class.new( + [River::EVENT_JOB_COMPLETED], + on_close: ->(value) { detached << value } + ) + + expect(subscription.close).to be_nil + expect(subscription.close).to be_nil + expect(detached).to eq([subscription]) + end + + it "does not publish after close" do + subscription = described_class.new([River::EVENT_JOB_COMPLETED]) + subscription.close + subscription.publish(matching) + + expect(subscription.each.to_a).to be_empty + end + + it "closes promptly even when its buffer is full" do + subscription = described_class.new([River::EVENT_JOB_COMPLETED], buffer_size: 1) + subscription.publish(matching) + + expect { Timeout.timeout(0.5) { subscription.close } }.not_to raise_error + expect(subscription.each.to_a).to eq([matching]) + end + + it "wakes all readers and keeps returning nil after closure" do + subscription = described_class.new([River::EVENT_JOB_COMPLETED]) + readers = Array.new(3) { Thread.new { subscription.pop } } + subscription.close + + Timeout.timeout(1) { expect(readers.map(&:value)).to eq([nil, nil, nil]) } + + expect(subscription.pop).to be_nil + expect(subscription.each.to_a).to be_empty + expect { subscription.pop(true) }.to raise_error(ThreadError) + end +end diff --git a/ruby/spec/insert_notification_shared_examples.rb b/ruby/spec/insert_notification_shared_examples.rb new file mode 100644 index 000000000..1d69efd5c --- /dev/null +++ b/ruby/spec/insert_notification_shared_examples.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require "pg" +require_relative "support/conformance_fixtures" + +shared_context "PostgreSQL notification listener" do |topic| + around do |example| + @insert_listener = PG.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test") + row = @driver.send(:runtime_query_rows, "SELECT current_schema() AS name").first + @insert_channel = "#{row[:name] || row["name"]}.#{topic}" + @insert_listener.exec("LISTEN #{PG::Connection.quote_ident(@insert_channel)}") + example.run + ensure + @insert_listener&.close + end + + # The marker is committed after the operation under test. Receiving it proves + # we've drained all earlier notifications, without a sleep/absence timeout. + def insert_notifications + marker = SecureRandom.hex(12) + @insert_listener.exec_params("SELECT pg_notify($1, $2)", [@insert_channel, marker]) + payloads = [] + loop do + payload = nil + notification = @insert_listener.wait_for_notify(5) { |_channel, _pid, value| payload = value } + raise "notification marker did not arrive" unless notification + break if payload == marker + + payloads << JSON.parse(payload) + end + payloads + end +end + +shared_examples "PostgreSQL insert notifications" do + include_context "PostgreSQL notification listener", "river_insert" + + it "notifies each available queue once and delivers only after the caller commits" do + client = River::Client.new(@driver) + @driver.transaction do + client.insert_many(%w[one one two pending scheduled].map do |queue| + state = %w[pending scheduled].include?(queue) ? queue : :available + River::InsertManyParams.new(River::JobArgsHash.new(:notify, {}), insert_opts: River::InsertOpts.new(queue: queue, state: state)) + end) + expect(insert_notifications).to be_empty + end + expect(insert_notifications).to contain_exactly({"queue" => "one"}, {"queue" => "two"}) + end + + it "does not publish rolled-back insertions or notifications from failed middleware" do + client = River::Client.new(@driver) + @driver.transaction do + client.insert(River::JobArgsHash.new(:notify, {})) + raise @driver.rollback_exception + end + expect(insert_notifications).to be_empty + + plugin = Object.new + plugin.define_singleton_method(:insert_many) do |_params, operation| + operation.call + raise "middleware failed" + end + client = River::Client.new(@driver, config: River::Config.new(plugins: [plugin])) + expect { client.insert(River::JobArgsHash.new(:notify, {})) }.to raise_error("middleware failed") + expect(insert_notifications).to be_empty + expect(client.job_list.jobs).to be_empty + end +end + +shared_examples "PostgreSQL cancellation notifications" do + include_context "PostgreSQL notification listener", "river_control" + + it "publishes Go-compatible cancellation only when the transaction commits" do + client = River::Client.new(@driver) + row = client.insert(River::JobArgsHash.new(:notify, {})).job + @driver.job_claim(id: row.id, attempted_by: "test") + @driver.transaction do + client.job_cancel(row.id) + expect(insert_notifications).to be_empty + end + expect(insert_notifications).to eq([{"action" => "cancel", "job_id" => row.id, "queue" => "default"}]) + end + + it "does not notify rolled-back, missing, or already-finalized cancellations" do + client = River::Client.new(@driver) + row = client.insert(River::JobArgsHash.new(:notify, {})).job + @driver.transaction do + client.job_cancel(row.id) + raise @driver.rollback_exception + end + expect(client.job_get(row.id).state).to eq("available") + expect(insert_notifications).to be_empty + client.job_cancel(row.id) + expect(insert_notifications.length).to eq(1) + client.job_cancel(row.id) + expect { client.job_cancel(-1) }.to raise_error(River::NotFoundError) + expect(insert_notifications).to be_empty + end +end + +shared_examples "SQLite cancellation notifications" do + def cancellation_notifications + @driver.send(:runtime_query_rows, "SELECT payload FROM river_notification WHERE topic = 'river_control' ORDER BY id") + .map { |row| JSON.parse(@driver.send(:runtime_value, row, :payload)) } + end + + it "makes cancellation and its outbox notification visible together on commit" do + client = River::Client.new(@driver) + row = client.insert(River::JobArgsHash.new(:notify, {})).job + @driver.job_claim(id: row.id, attempted_by: "test") + @driver.transaction do + client.job_cancel(row.id) + expect(cancellation_notifications.length).to eq(1) + observer = Thread.new { cancellation_notifications } + expect(Timeout.timeout(5) { observer.value }).to be_empty + end + expect(cancellation_notifications).to eq([{"action" => "cancel", "job_id" => row.id, "queue" => "default"}]) + end + + it "rolls back both cancellation and the outbox row" do + client = River::Client.new(@driver) + row = client.insert(River::JobArgsHash.new(:notify, {})).job + @driver.transaction do + client.job_cancel(row.id) + raise @driver.rollback_exception + end + expect(client.job_get(row.id)).to have_attributes(state: "available", metadata: satisfy { |metadata| !metadata.key?("cancel_attempted_at") }) + expect(cancellation_notifications).to be_empty + end + + it "cleans expired outbox rows in bounded batches without reusing notification IDs" do + now = Time.utc(2026, 1, 2) + times = [now - 2, now - 1, now, now + 1] + times.each do |time| + @driver.send(:runtime_execute, "INSERT INTO river_notification (topic, payload, created_at) VALUES ('river_control', '{}', #{@driver.send(:runtime_time, time)})") + end + expect(@driver.notification_delete_before(horizon: now, max: 1)).to eq(1) + expect(@driver.notification_delete_before(horizon: now, max: 1)).to eq(1) + expect(@driver.notification_delete_before(horizon: now, max: 1)).to eq(0) + last_id = @driver.send(:runtime_query_rows, "SELECT max(id) AS id FROM river_notification").first + expect(@driver.notification_delete_before(horizon: now + 2)).to eq(2) + @driver.send(:runtime_execute, "INSERT INTO river_notification (topic, payload) VALUES ('river_control', '{}')") + next_id = @driver.send(:runtime_query_rows, "SELECT max(id) AS id FROM river_notification").first + expect(@driver.send(:runtime_value, next_id, :id)).to be > @driver.send(:runtime_value, last_id, :id) + end +end + +shared_examples "PostgreSQL queue control notifications" do + include_context "PostgreSQL notification listener", "river_control" + + it "publishes Go's pause, resume, and metadata payloads only after commit" do + @driver.queue_upsert("priority") + @driver.transaction do + @driver.queue_pause("priority") + @driver.queue_resume("priority") + @driver.queue_update("priority", metadata: {"owner" => "candidate"}) + expect(insert_notifications).to be_empty + end + fixtures = RiverConformanceFixtures.load("protocol_values").fetch("notifications") + expected = %w[pause resume metadata_changed].map do |name| + fixtures.find { |fixture| fixture.fetch("name") == name }.fetch("payload") + end + expect(insert_notifications).to eq(expected) + end + + it "rolls back queue updates and notifications together" do + @driver.queue_upsert("priority") + @driver.transaction do + @driver.queue_pause("priority") + @driver.queue_update("priority", metadata: {"owner" => "rolled back"}) + raise @driver.rollback_exception + end + expect(insert_notifications).to be_empty + expect(@driver.queue_get("priority")).to have_attributes(paused_at: nil, metadata: {}) + end + + it "notifies wildcard controls and ignores missing queues" do + @driver.queue_upsert("one") + @driver.queue_upsert("two") + @driver.queue_pause("*") + @driver.queue_resume("*") + @driver.queue_pause("missing") + @driver.queue_resume("missing") + @driver.queue_update("missing", metadata: {}) + expect(insert_notifications).to eq([ + {"action" => "pause", "queue" => "*"}, {"action" => "resume", "queue" => "*"} + ]) + end +end + +shared_examples "PostgreSQL leadership notifications" do + include_context "PostgreSQL notification listener", "river_leadership" + + it "broadcasts Go's resignation payload only when the leader's deletion commits" do + @driver.leader_acquire("client-1") + @driver.leader_release("other-client") + expect(insert_notifications).to be_empty + @driver.transaction do + @driver.leader_release("client-1") + expect(insert_notifications).to be_empty + raise @driver.rollback_exception + end + expect(@driver.leader_renew("client-1")).to be true + expect(insert_notifications).to be_empty + @driver.transaction do + @driver.leader_release("client-1") + expect(insert_notifications).to be_empty + end + fixture = RiverConformanceFixtures.load("protocol_values").fetch("notifications").find { |entry| entry.fetch("name") == "resigned" } + expect(insert_notifications).to eq([fixture.fetch("payload")]) + @driver.leader_release("client-1") + expect(insert_notifications).to be_empty + end +end diff --git a/ruby/spec/insertion_feature_spec.rb b/ruby/spec/insertion_feature_spec.rb new file mode 100644 index 000000000..c54cffc06 --- /dev/null +++ b/ruby/spec/insertion_feature_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" + +class InsertionFeatureArgs + attr_accessor :insert_opts + + def initialize(kind: "insertion_feature", payload: {"value" => 1}, insert_opts: nil) + @insert_opts = insert_opts + @kind = kind + @payload = payload + end + + attr_reader :kind + + def to_json = JSON.dump(@payload) +end + +RSpec.describe "River insertion features" do + around(:each) { |example| available_test_transaction(&example) } + + let(:driver) { River::Driver::Sequel.new(available_test_database) } + + def build_client(**config) + River::Client.new(driver, config: River::Config.new(**config)) + end + + it "merges argument metadata with call-site metadata taking precedence" do + args = InsertionFeatureArgs.new(insert_opts: River::InsertOpts.new(metadata: {"one" => 1, "shared" => "args"})) + result = build_client.insert( + args, + insert_opts: River::InsertOpts.new(metadata: {"shared" => "call", "two" => 2}) + ) + + expect(result.job.metadata.except("river:unique_nonce")).to eq( + "one" => 1, + "shared" => "call", + "two" => 2 + ) + end + + it "supports an explicit initial pending state" do + result = build_client.insert( + InsertionFeatureArgs.new, + insert_opts: River::InsertOpts.new(state: River::JOB_STATE_PENDING) + ) + + expect(result.job).to have_attributes(state: River::JOB_STATE_PENDING) + end + + it "honors an explicit available state for a future scheduled time" do + scheduled_at = Time.now.utc + 60 + result = build_client.insert( + InsertionFeatureArgs.new, + insert_opts: River::InsertOpts.new(scheduled_at: scheduled_at, state: River::JOB_STATE_AVAILABLE) + ) + + expect(result.job).to have_attributes( + scheduled_at: be_within(0.001).of(scheduled_at), + state: River::JOB_STATE_AVAILABLE + ) + end + + it "accepts nil argument-level insertion options" do + result = build_client.insert(InsertionFeatureArgs.new(insert_opts: nil)) + + expect(result.job).to have_attributes(max_attempts: River::MAX_ATTEMPTS_DEFAULT, queue: River::QUEUE_DEFAULT) + end + + it "runs plugin insert callbacks in forward and reverse order" do + calls = [] + first = Object.new + first.define_singleton_method(:insert_begin) { |params| calls << [:first_begin, params.kind] } + first.define_singleton_method(:insert_end) { |result| calls << [:first_end, result.job.kind] } + second = Object.new + second.define_singleton_method(:insert_begin) { |params| calls << [:second_begin, params.kind] } + second.define_singleton_method(:insert_end) { |result| calls << [:second_end, result.job.kind] } + + build_client(plugins: [first, second]).insert(InsertionFeatureArgs.new) + + expect(calls).to eq([ + [:first_begin, "insertion_feature"], + [:second_begin, "insertion_feature"], + [:second_end, "insertion_feature"], + [:first_end, "insertion_feature"] + ]) + end + + it "runs plugin insert callbacks for every job in a bulk insertion" do + calls = [] + plugin = Object.new + plugin.define_singleton_method(:insert_begin) { |params| calls << [:begin, params.args.to_json] } + plugin.define_singleton_method(:insert_end) { |result| calls << [:end, result.job.args] } + client = build_client(plugins: [plugin]) + + results = client.insert_many([ + InsertionFeatureArgs.new(payload: {"value" => 1}), + InsertionFeatureArgs.new(payload: {"value" => 2}) + ]) + + expect(results.length).to eq(2) + expect(calls.map(&:first)).to eq([:begin, :begin, :end, :end]) + end + + it "runs insertion middleware plugins outside-in around callbacks and insertion" do + calls = [] + callback = Object.new + callback.define_singleton_method(:insert_begin) { |_params| calls << :insert_begin } + callback.define_singleton_method(:insert_end) { |_result| calls << :insert_end } + first = Object.new + first.define_singleton_method(:insert_many) do |params, operation| + calls << [:first_before, params.length] + results = operation.call + calls << :first_after + results + end + + second = Object.new + second.define_singleton_method(:insert_many) do |_params, operation| + calls << :second_before + results = operation.call + calls << :second_after + results + end + + result = build_client(plugins: [first, callback, second]).insert(InsertionFeatureArgs.new) + + expect(result.job.kind).to eq("insertion_feature") + expect(calls).to eq([ + [:first_before, 1], :second_before, :insert_begin, :insert_end, + :second_after, :first_after + ]) + end + + it "allows plugins that implement no insertion behavior" do + client = build_client(plugins: [Object.new]) + + result = client.insert_many([InsertionFeatureArgs.new]) + + expect(result.length).to eq(1) + end + + it "passes a bulk insertion to plugin middleware as one operation" do + observed = [] + plugin = Object.new + plugin.define_singleton_method(:insert_many) do |params, operation| + observed << params.map(&:kind) + operation.call + end + + client = build_client(plugins: [plugin]) + + results = client.insert_many([ + InsertionFeatureArgs.new(kind: "first"), + InsertionFeatureArgs.new(kind: "second") + ]) + + expect(results.map { |result| result.job.kind }).to eq(%w[first second]) + expect(observed).to eq([%w[first second]]) + end + + it "rejects an empty bulk insertion like Go" do + expect { build_client.insert_many([]) }.to raise_error(ArgumentError, "no jobs to insert") + expect(driver.job_list).to be_empty + end + + it "does not enable uniqueness for empty options or explicit zero values" do + [River::UniqueOpts.new, River::UniqueOpts.new(by_args: false, by_queue: false, exclude_kind: false, by_period: 0, by_state: [])].each do |unique| + opts = River::InsertOpts.new(unique_opts: unique) + first, second = 2.times.map { build_client.insert(InsertionFeatureArgs.new, insert_opts: opts).job } + expect(first.id).not_to eq(second.id) + expect([first.unique_key, second.unique_key]).to eq([nil, nil]) + end + end + + it "uses default unique states and ignores a zero period when other dimensions are enabled" do + opts = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_args: true, by_state: [], by_period: 0)) + first, second = 2.times.map { build_client.insert(InsertionFeatureArgs.new, insert_opts: opts).job } + expect(first.id).to eq(second.id) + expect(first.unique_states).to eq(%w[available completed pending retryable running scheduled]) + end + + it "rejects excluding kind without another unique dimension before inserting any jobs" do + client = build_client + [ + River::UniqueOpts.new(exclude_kind: true), + River::UniqueOpts.new(exclude_kind: true, by_args: false, by_queue: false, by_period: 0, by_state: []), + River::UniqueOpts.new(exclude_kind: true, by_state: %i[available pending running scheduled]) + ].each do |unique| + expect(unique).not_to be_empty + opts = River::InsertOpts.new(unique_opts: unique) + args = InsertionFeatureArgs.new(insert_opts: opts) + message = "exclude_kind requires by_args, by_queue, or by_period" + + expect { client.insert(InsertionFeatureArgs.new, insert_opts: opts) }.to raise_error(ArgumentError, message) + expect { client.insert(args) }.to raise_error(ArgumentError, message) + expect { client.insert_many([InsertionFeatureArgs.new, args]) }.to raise_error(ArgumentError, message) + expect { client.insert_many([InsertionFeatureArgs.new, River::InsertManyParams.new(InsertionFeatureArgs.new, insert_opts: opts)]) } + .to raise_error(ArgumentError, message) + expect(driver.job_list).to be_empty + end + end + + it "makes past and exactly-due schedules available without mutating the caller's time" do + now = Time.utc(2026, 1, 2, 3, 4, 5) + client = build_client + client.instance_variable_set(:@time_now_utc, -> { now }) + [now, now - 60].each do |time| + scheduled_at = time.getlocal("+05:30").freeze + result = client.insert(InsertionFeatureArgs.new, insert_opts: River::InsertOpts.new(scheduled_at: scheduled_at)) + expect(result.job.state).to eq(River::JOB_STATE_AVAILABLE) + expect(result.job.scheduled_at).to eq(time) + expect(scheduled_at.utc_offset).to eq(19_800) + end + end + + it "treats a completed job as a duplicate with default unique states" do + client = build_client + opts = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_queue: true)) + original = client.insert(InsertionFeatureArgs.new, insert_opts: opts).job + running = driver.job_get_available(attempted_by: "worker", max: 1, queue: original.queue).first + driver.job_set_state_if_running(id: running.id, finalized_at: Time.now.utc, state: River::JOB_STATE_COMPLETED) + + duplicate = client.insert(InsertionFeatureArgs.new, insert_opts: opts) + + expect(duplicate.unique_skipped_as_duplicate?).to be true + expect(duplicate.job.id).to eq(original.id) + end + + it "allows a new job when its custom unique states exclude completed jobs" do + client = build_client + opts = River::InsertOpts.new(unique_opts: River::UniqueOpts.new( + by_queue: true, + by_state: [ + River::JOB_STATE_AVAILABLE, + River::JOB_STATE_PENDING, + River::JOB_STATE_RUNNING, + River::JOB_STATE_SCHEDULED + ] + )) + original = client.insert(InsertionFeatureArgs.new, insert_opts: opts).job + running = driver.job_get_available(attempted_by: "worker", max: 1, queue: original.queue).first + driver.job_set_state_if_running(id: running.id, finalized_at: Time.now.utc, state: River::JOB_STATE_COMPLETED) + + replacement = client.insert(InsertionFeatureArgs.new, insert_opts: opts) + + expect(replacement.unique_skipped_as_duplicate?).to be false + expect(replacement.job.id).not_to eq(original.id) + end + + [{by_args: true}, {by_args: [:value]}, {by_queue: true}, {by_period: 60}].each do |dimensions| + it "enforces uniqueness across different kinds with #{dimensions.inspect} when kind is excluded" do + client = build_client + client.instance_variable_set(:@time_now_utc, -> { Time.utc(2026, 1, 2) }) + opts = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(**dimensions, exclude_kind: true)) + original = client.insert(InsertionFeatureArgs.new(kind: "first"), insert_opts: opts) + duplicate = client.insert(InsertionFeatureArgs.new(kind: "second"), insert_opts: opts) + + expect(original.unique_skipped_as_duplicate?).to be false + expect(duplicate.unique_skipped_as_duplicate?).to be true + expect(duplicate.job.id).to eq(original.job.id) + + with_kind = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(**dimensions)) + distinct = %w[first second].map { |kind| client.insert(InsertionFeatureArgs.new(kind: kind), insert_opts: with_kind).job } + expect(([original.job] + distinct).map(&:unique_key).uniq.length).to eq(3) + end + end + + it "allows unique jobs in adjacent time periods" do + client = build_client + opts = ->(scheduled_at) do + River::InsertOpts.new( + scheduled_at: scheduled_at, + unique_opts: River::UniqueOpts.new(by_period: 60) + ) + end + first = client.insert(InsertionFeatureArgs.new, insert_opts: opts.call(Time.utc(2026, 1, 1, 0, 0, 59))) + second = client.insert(InsertionFeatureArgs.new, insert_opts: opts.call(Time.utc(2026, 1, 1, 0, 1, 0))) + + expect(first.unique_skipped_as_duplicate?).to be false + expect(second.unique_skipped_as_duplicate?).to be false + expect(second.job.id).not_to eq(first.job.id) + end +end diff --git a/ruby/spec/job_persisted_logging/plugin_spec.rb b/ruby/spec/job_persisted_logging/plugin_spec.rb new file mode 100644 index 000000000..b591e5a3d --- /dev/null +++ b/ruby/spec/job_persisted_logging/plugin_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "spec_helper" +require "stringio" + +RSpec.describe River::JobPersistedLogging::Plugin do + let(:diagnostics) { StringIO.new } + let(:client) { River::Client.new(Object.new, config: River::Config.new(logger: Logger.new(diagnostics))) } + let(:row) do + River::JobRow.new(id: 123, args: {}, attempt: 1, created_at: Time.now.utc, + kind: "logging", max_attempts: 3, metadata: {}, priority: 1, + queue: "default", scheduled_at: Time.now.utc, state: River::JOB_STATE_RUNNING) + end + let(:job) { River::Job.new(client, row) } + + def capture(text, job: self.job, **options) + described_class.new(**options).work(job, -> { job.logger << text }) + job.metadata.fetch("river:log") + end + + it "exposes a standard INFO logger without replacing the client's logger" do + result = described_class.new.work(job, -> do + expect(job.logger).to be_a(Logger) + job.logger.debug { raise "debug should not be evaluated" } + job.logger.info "hello" + job.output = 42 + :result + end) + + expect(result).to eq(:result) + expect(job.metadata.fetch("river:log")).to match([ + {"attempt" => 1, "log" => a_string_including("INFO", "hello")} + ]) + expect(job.metadata.fetch("output")).to eq(42) + expect(row.metadata).to eq({}) + expect(diagnostics.string).to eq("") + end + + it "requires the plugin and removes the logger after work" do + expect { job.logger }.to raise_error(River::Error, /JobPersistedLogging::Plugin/) + capture("hello") + expect { job.logger }.to raise_error(River::Error, /JobPersistedLogging::Plugin/) + end + + it "supports a fresh custom logger per attempt and restores a previous logger" do + writers = [] + plugin = described_class.new do |writer| + writers << writer + Logger.new(writer, formatter: ->(_severity, _time, _progname, message) { JSON.generate(message: message) + "\n" }) + end + outer = Object.new + job.__with_logger(outer) do + 2.times { plugin.work(job, -> { job.logger.info "hello" }) } + expect(job.logger).to equal(outer) + end + + expect(writers.uniq.length).to eq(2) + expect(job.metadata.fetch("river:log").map { |entry| entry.fetch("log") }).to eq(["{\"message\":\"hello\"}\n"] * 2) + end + + it "persists logs without replacing the original exception" do + [RuntimeError.new("broken"), River.job_cancel("cancel"), River.job_snooze(10), + River::ClientRuntime::Interrupted.new, Timeout::Error.new, Exception.new("fatal")].each do |error| + attempt = River::Job.new(client, row) + expect do + described_class.new.work(attempt, -> do + attempt.logger << "before failure\n" + raise error + end) + end.to raise_error { |raised| expect(raised).to equal(error) } + + expect(attempt.metadata.fetch("river:log")).to eq([{"attempt" => 1, "log" => "before failure\n"}]) + expect { attempt.logger }.to raise_error(River::Error, /JobPersistedLogging::Plugin/) + end + end + + it "does not create or alter log metadata when nothing was logged" do + described_class.new.work(job, -> {}) + expect(job.metadata_updates).to eq({}) + + row.metadata["river:log"] = [{"attempt" => 0, "log" => "old"}] + described_class.new.work(job, -> { job.logger.debug "filtered" }) + expect(job.metadata_updates).to eq({}) + expect(row.metadata.fetch("river:log")).to eq([{"attempt" => 0, "log" => "old"}]) + end + + it "keeps the first bytes up to the per-attempt limit" do + expect(capture("123456789", max_size_bytes: 4)).to eq([{"attempt" => 1, "log" => "1234"}]) + expect(diagnostics.string).to include("truncated to 4 bytes", "job 123") + end + + it "drops incomplete UTF-8, invalid byte sequences, and NULs" do + expect(capture("aé", max_size_bytes: 2).last.fetch("log")).to eq("a") + expect(capture("é\0\xFFz".b).last.fetch("log")).to eq("éz") + expect(capture("é", max_size_bytes: 2).last.fetch("log")).to eq("é") + end + + it "appends to Go-format history without mutating existing entries or unrelated metadata" do + previous = {"attempt" => 1, "log" => "old"}.freeze + row.metadata = {"river:log" => [previous].freeze, "other" => "keep"}.freeze + row.attempt = 2 + + expect(capture("new")).to eq([previous, {"attempt" => 2, "log" => "new"}]) + expect(row.metadata.fetch("river:log")).to eq([previous]) + expect(job.metadata.fetch("other")).to eq("keep") + end + + it "drops the oldest entries based on serialized JSON bytes, including escaping" do + history = (1..3).map { |attempt| {"attempt" => attempt, "log" => "é\"\n"} } + row.metadata["river:log"] = history + row.attempt = 4 + newest = {"attempt" => 4, "log" => "é\"\n"} + expected = [history.last, newest] + + expect(capture(newest.fetch("log"), max_total_bytes: JSON.generate(expected).bytesize)).to eq(expected) + expect(diagnostics.string).to include("dropped 2 oldest entries") + expect(history.length).to eq(3) + end + + it "retains the newest entry even if that entry alone exceeds the history cap" do + row.metadata["river:log"] = [{"attempt" => 0, "log" => "old"}] + expect(capture("latest", max_total_bytes: 1)).to eq([{"attempt" => 1, "log" => "latest"}]) + end + + it "validates limits and caps the total history setting at 64 MiB" do + %i[max_size_bytes max_total_bytes].product([nil, 0, -1, 1.5, Float::INFINITY, "2"]).each do |option, value| + expect { described_class.new(**{option => value}) }.to raise_error(ArgumentError, /positive integer/) + end + expect(described_class.new.instance_variable_get(:@max_size_bytes)).to eq(2 * 1024 * 1024) + expect(described_class.new.instance_variable_get(:@max_total_bytes)).to eq(8 * 1024 * 1024) + expect(described_class.new(max_total_bytes: 100 * 1024 * 1024).instance_variable_get(:@max_total_bytes)).to eq(64 * 1024 * 1024) + end + + it "reports malformed history without losing worker errors or overwriting metadata" do + nested = 150.times.reduce(nil) { |value, _| [value] } + [nil, {}, "bad", [{"log" => Float::NAN}], [nested]].each do |history| + row.metadata = {"river:log" => history} + attempt = River::Job.new(client, row) + error = RuntimeError.new("worker failed") + expect do + described_class.new.work(attempt, -> do + attempt.logger << "hello" + raise error + end) + end.to raise_error { |raised| expect(raised).to equal(error) } + + expect(attempt.metadata_updates).to eq({}) + end + expect(diagnostics.string).to include("could not be persisted", "job 123") + end + + it "does not capture writes made after an attempt finishes or its logger closes" do + logger = nil + described_class.new.work(job, -> do + logger = job.logger + logger << "hello" + logger.close + logger << "closed" + end) + logger << "late" + expect(job.metadata.fetch("river:log")).to eq([{"attempt" => 1, "log" => "hello"}]) + end + + it "bounds capture memory and supports concurrent writes" do + writer = described_class.const_get(:Buffer, false).new(100) + threads = 4.times.map do + Thread.new { 100.times { expect(writer.write("abc")).to eq(3) } } + end + threads.each(&:value) + + expect(writer.instance_variable_get(:@data).bytesize).to eq(100) + expect(writer.finish).to eq([("abc" * 34).byteslice(0, 100), true]) + expect(writer.write("ignored")).to eq(7) + expect(writer.finish.first.bytesize).to eq(100) + end + + it "keeps simultaneous attempts isolated when sharing a plugin" do + plugin = described_class.new + attempts = Array.new(4) { River::Job.new(client, row) } + threads = attempts.each_with_index.map do |attempt, index| + Thread.new { plugin.work(attempt, -> { 10.times { attempt.logger << "job #{index}\n" } }) } + end + threads.each(&:value) + + attempts.each_with_index do |attempt, index| + expect(attempt.metadata.fetch("river:log").last.fetch("log")).to eq("job #{index}\n" * 10) + end + end +end diff --git a/ruby/spec/job_row_decoder_spec.rb b/ruby/spec/job_row_decoder_spec.rb new file mode 100644 index 000000000..3a6062949 --- /dev/null +++ b/ruby/spec/job_row_decoder_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe River::Driver::JobRowDecoder do + it "decodes canonical errors without changing their values" do + value = {"at" => "2026-09-30T12:34:56.123456Z", "attempt" => 2, "error" => "failed", "trace" => "stack"} + expect(described_class.attempt_error(value).to_h.transform_keys(&:to_s)).to eq(value) + end + + it "tolerates historical error values without losing structured messages" do + [nil, "legacy", 42, ["trace"], {"error" => {"message" => "failed"}, "trace" => ["frame"]}].each do |value| + error = described_class.attempt_error(value) + expect(error.at).to eq(Time.utc(1)) + expect(error.attempt).to eq(0) + expected = value.is_a?(Hash) ? value["error"] : value + expect(error.error).to eq(described_class.error_string(expected)) + end + expect(described_class.attempt_error({"trace" => ["frame"]}).trace).to eq('["frame"]') + end + + it "tolerates unreadable timestamps while retaining Ruby's existing timestamp formats" do + [nil, 123, "invalid", "2026-99-30T12:34:56Z"].each do |value| + expect(described_class.error_time(value)).to eq(Time.utc(1)) + end + expect(described_class.error_time("2026-09-30T14:34:56+02:00")).to eq(Time.utc(2026, 9, 30, 12, 34, 56)) + expect(described_class.error_time("2026-09-30 12:34:56+00")).to eq(Time.utc(2026, 9, 30, 12, 34, 56)) + end + + it "accepts JSON null for nullable collections" do + expect(described_class.new.json(:tags, "null", type: Array, strings: true)).to be_nil + end + + it "reports non-string JSON input through the partial row error" do + decoder = described_class.new + expect(decoder.json(:metadata, 42, type: Hash, default: {})).to eq({}) + row = Struct.new(:id, :__decode_error).new(123) + expect { decoder.finish(row) }.to raise_error(River::JobRowDecodeError, /metadata/) + end + + it "accepts integral attempts and numeric strings without truncating fractions" do + {1 => 1, " 2 " => 2, "3.0" => 3, "4e0" => 4, 5.0 => 5, 1.5 => 0, + "1.5" => 0, "invalid" => 0, nil => 0, true => 0, Float::INFINITY => 0, + 1e20 => 0}.each do |value, expected| + expect(described_class.error_integer(value)).to eq(expected) + end + end +end diff --git a/ruby/spec/job_spec.rb b/ruby/spec/job_spec.rb new file mode 100644 index 000000000..76b738599 --- /dev/null +++ b/ruby/spec/job_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe River::JobArgsHash do + it "generates a job args based on a hash" do + args = River::JobArgsHash.new("my_hash_kind", {job_num: 123}) + expect(args).to have_attributes( + kind: "my_hash_kind", + to_json: JSON.generate({job_num: 123}) + ) + end + + it "round-trips JSON-compatible arguments" do + args = described_class.new(:example, values: [nil, true, false, 1, 1.5, "a\"b", "é", {nested: []}]) + + expect(JSON.parse(args.to_json)).to eq("values" => [nil, true, false, 1, 1.5, "a\"b", "é", {"nested" => []}]) + end + + it "rejects nonfinite numbers when encoding arguments" do + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |value| + expect { described_class.new(:example, value: value).to_json }.to raise_error(JSON::GeneratorError) + end + end + + it "rejects arguments beyond the default JSON nesting limit" do + nested = 150.times.reduce(nil) { |value, _| [value] } + + expect { described_class.new(:example, nested: nested).to_json }.to raise_error(JSON::NestingError) + end + + it "does not depend on or modify application-wide JSON.dump options" do + options = JSON.dump_default_options.dup + JSON.dump_default_options[:max_nesting] = 1 + JSON.dump_default_options[:allow_nan] = true + + expect(described_class.new(:example, values: [1]).to_json).to eq('{"values":[1]}') + expect { described_class.new(:example, value: Float::NAN).to_json }.to raise_error(JSON::GeneratorError) + expect(JSON.dump_default_options).to include(max_nesting: 1, allow_nan: true) + ensure + JSON.dump_default_options.replace(options) + end + + it "errors on a nil kind" do + expect do + River::JobArgsHash.new(nil, {job_num: 123}) + end.to raise_error(ArgumentError, "kind should be non-nil") + end + + it "errors on a nil hash" do + expect do + River::JobArgsHash.new("my_hash_kind", nil) + end.to raise_error(ArgumentError, "hash should be non-nil") + end +end + +describe River::AttemptError do + it "initializes with parameters" do + now = Time.now + + attempt_error = River::AttemptError.new( + at: now, + attempt: 1, + error: "job failure", + trace: "error trace" + ) + + expect(attempt_error).to have_attributes( + at: now, + attempt: 1, + error: "job failure", + trace: "error trace" + ) + end + + it "serializes local and frozen timestamps without changing their timezones" do + time = Time.new(2026, 1, 2, 3, 4, 5, "+05:30") + error = described_class.new(at: time, attempt: 1, error: "failed", trace: "") + + expect(error.to_h[:at]).to eq("2026-01-01T21:34:05.000000Z") + expect(time.utc_offset).to eq(19_800) + time.freeze + expect(error.to_h[:at]).to eq("2026-01-01T21:34:05.000000Z") + end +end diff --git a/ruby/spec/migration_cli_shared_examples.rb b/ruby/spec/migration_cli_shared_examples.rb new file mode 100644 index 000000000..427a4f641 --- /dev/null +++ b/ruby/spec/migration_cli_shared_examples.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "stringio" +require "tmpdir" +require_relative "../lib/migration_cli" + +RSpec.shared_examples "migration command" do |adapter| + it "supports help and reports usage errors" do + out = StringIO.new + err = StringIO.new + + expect(River::MigrationCLI.run(["--help"], err: err, out: out)).to eq(0) + expect(out.string).to include("Usage: river ", "migrate-up", "migrate-down", "migrate-status") + expect(River::MigrationCLI.run(["unknown"], err: err, out: out)).to eq(1) + expect(River::MigrationCLI.run(["--unknown"], err: err, out: out)).to eq(1) + end + + it "migrates SQLite through the #{adapter} command and guards destructive operations", database: :sqlite do + Dir.mktmpdir("river-cli-test-") do |directory| + url = "sqlite://#{File.join(directory, "river.sqlite3")}" + # ActiveRecord accepts sqlite3 URLs; Sequel accepts sqlite URLs. + url = url.sub("sqlite:", "sqlite3:") if adapter == "activerecord" + + options = ["--database-url", url] + out = StringIO.new + err = StringIO.new + run = ->(*args) { + out.truncate(0) + out.rewind + River::MigrationCLI.run(args + options, err: err, out: out) + } + + expect(run.call("migrate-status")).to eq(0) + expect(out.string).to include("pending 001") + expect(run.call("migrate-up", "--dry-run")).to eq(0) + expect(out.string).to include("planned 008") + expect(run.call("migrate-up", "--steps", "2", "--target", "4")).to eq(0) + expect(run.call("migrate-up", "--target", "1")).to eq(0) + expect(out.string).not_to include("applied") + expect(run.call("migrate-status")).to eq(0) + expect(out.string).to include("pending 003") + expect(run.call("migrate-up")).to eq(0) + expect(run.call("migrate-status")).to eq(0) + expect(out.string).to include("applied 008") + expect(run.call("migrate-down")).to eq(1) + expect(err.string).to include("--yes") + expect(run.call("migrate-down", "--dry-run")).to eq(0) + expect(run.call("migrate-down", "--yes", "--target", "0")).to eq(0) + expect(run.call("migrate-status")).to eq(0) + expect(out.string).to include("pending 001") + expect(run.call("migrate-up", "--schema", "invalid")).to eq(1) + end + end +end diff --git a/ruby/spec/migration_cli_spec.rb b/ruby/spec/migration_cli_spec.rb new file mode 100644 index 000000000..cd4586fc3 --- /dev/null +++ b/ruby/spec/migration_cli_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "migration_cli_shared_examples" + +RSpec.describe River::MigrationCLI, database: :sqlite do + %w[activerecord sequel].each do |adapter| + context "with only #{adapter} available" do + around { |example| with_available_drivers([adapter]) { example.run } } + + it_behaves_like "migration command", adapter + end + end + + %w[activerecord sequel].each do |adapter| + it "auto-detects #{adapter} and runs real SQLite migrations" do + with_available_drivers([adapter]) do + Dir.mktmpdir("river-cli-detection-") do |directory| + err = StringIO.new + out = StringIO.new + scheme = (adapter == "sequel") ? "sqlite" : "sqlite3" + url = "#{scheme}://#{File.join(directory, "river.sqlite3")}" + + expect(described_class.run(["migrate-up", "--database-url", url], err: err, out: out)).to eq(0), err.string + expect(out.string).to include("applied 001") + + out.truncate(0) + out.rewind + expect(described_class.run(["migrate-status", "--database-url", url], err: err, out: out)).to eq(0), err.string + expect(out.string).to include("applied 001") + expect(out.string.lines).to all(start_with("applied ")) + end + end + end + end + + it "prefers Sequel when both SQL adapters are available" do + err = StringIO.new + out = StringIO.new + expect(described_class.run(["migrate-status", "--database-url", "sqlite::memory:"], err: err, out: out)).to eq(0), err.string + expect(out.string).to include("pending 001") + end + + it "explains which gems to install when neither SQL adapter is available" do + with_available_drivers([]) do + err = StringIO.new + expect(described_class.run(["migrate-status", "--database-url", "sqlite::memory:"], err: err)).to eq(1) + expect(err.string).to include("install riverqueue-activerecord or riverqueue-sequel in your bundle") + + out = StringIO.new + expect(described_class.run(["--help"], err: err, out: out)).to eq(0) + expect(out.string).not_to include("--driver") + end + end + + it "rejects the removed driver option" do + err = StringIO.new + expect(described_class.run(["migrate-status", "--driver", "sequel"], err: err)).to eq(1) + expect(err.string).to include("invalid option: --driver") + end + + it "validates database configuration and redacts unexpected connection errors" do + previous_url = ENV.delete("DATABASE_URL") + err = StringIO.new + expect(described_class.run(["migrate-status"], err: err)).to eq(1) + expect(err.string).to include("provide --database-url or DATABASE_URL") + Dir.mktmpdir("river-cli-errors-") do |directory| + ENV["DATABASE_URL"] = "sqlite://#{File.join(directory, "missing", "river.sqlite3")}" + expect(described_class.run(["migrate-status"], err: err)).to eq(1) + expect(err.string).to include("Sequel::DatabaseConnectionError") + expect(err.string).not_to include(directory) + end + ensure + ENV["DATABASE_URL"] = previous_url + end + + it "loads the optional Pro migration entry point only when requested" do + # Only the loading/dispatch contract is doubled here. Private Pro migration + # behavior is exercised by that package's own real-database suite. + Dir.mktmpdir("river-cli-pro-") do |directory| + integration = File.join(directory, "riverqueue-pro.rb") + File.write(integration, <<~RUBY) + module River + module Pro + class Migrator + def initialize(driver, schema:) + end + def status + [River::Migrator::Status.new(1, "pro_test", false)] + end + end + end + end + RUBY + $LOAD_PATH.unshift(directory) + out = StringIO.new + err = StringIO.new + url = "sqlite://#{File.join(directory, "river.sqlite3")}" + expect(described_class.run(["migrate-status", "--database-url", url, "--line", "pro"], err: err, out: out)).to eq(0) + expect(out.string).to include("pending 001 pro_test") + ensure + $LOAD_PATH.delete(directory) + $LOADED_FEATURES.delete(integration) + River.send(:remove_const, :Pro) + end + end + + # Change only gem discovery; adapter loading and database operations remain real. + def with_available_drivers(drivers) + original = Gem::Specification.method(:find_all_by_name) + Gem::Specification.define_singleton_method(:find_all_by_name) do |name, *requirements| + if %w[riverqueue-activerecord riverqueue-sequel].include?(name) && !drivers.include?(name.delete_prefix("riverqueue-")) + [] + else + original.call(name, *requirements) + end + end + yield + ensure + Gem::Specification.define_singleton_method(:find_all_by_name, original) + end +end diff --git a/ruby/spec/migrator_shared_examples.rb b/ruby/spec/migrator_shared_examples.rb new file mode 100644 index 000000000..5e2e1c018 --- /dev/null +++ b/ruby/spec/migrator_shared_examples.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +RSpec.shared_examples "canonical migrations" do |adapter| + let(:migrator) { River::Migrator.new(@driver) } + + it "tracks additional migration lines separately and enforces their main prerequisite" do + other = River::Migrator.new(@driver, line: "test", migrations_path: File.join(__dir__, "support/migrations")) + + expect { other.migrate }.to raise_error(River::Error, /main to version 7/) + migrator.migrate(target: 4) + + expect { other.status }.to raise_error(River::Error, /main to version 7/) + migrator.migrate(target: 5) + + expect { other.status }.to raise_error(River::Error, /main to version 7/) + migrator.migrate + + expect(other.migrate.map(&:version)).to eq([1]) + expect(other.status).to contain_exactly(have_attributes(applied: true, version: 1)) + expect(other.migrate(direction: :down).map(&:version)).to eq([1]) + expect(other.status).to contain_exactly(have_attributes(applied: false, version: 1)) + expect(other.migrate(direction: :down)).to be_empty + expect(other.migrate.map(&:version)).to eq([1]) + expect(migrator.status).to all(have_attributes(applied: true)) + end + + it "validates migration options before applying any DDL" do + expect { migrator.migrate(direction: :sideways) }.to raise_error(ArgumentError) + [0, -1, "one"].each { |value| expect { migrator.migrate(steps: value) }.to raise_error(ArgumentError) } + [-1, 9, "one"].each { |value| expect { migrator.migrate(target: value) }.to raise_error(ArgumentError) } + + expect { migrator.migrate(direction: :down, target: 1) }.to raise_error(ArgumentError, /opposite direction/) + migrator.migrate + + expect(migrator.migrate(target: 1)).to eq([]) + end + + it "bootstraps, upgrades legacy history, and is idempotent" do + expect(migrator.status).to all(have_attributes(applied: false)) + expect(migrator.migrate(target: 4).map(&:version)).to eq([1, 2, 3, 4]) + expect(migrator.status.select(&:applied).map(&:version)).to eq([1, 2, 3, 4]) + expect(River::Migrator.new(@driver).migrate.map(&:version)).to eq([5, 6, 7, 8]) + expect(migrator.status).to all(have_attributes(applied: true)) + expect(migrator.migrate).to eq([]) + end + + it "reverses migrations across the line-column boundary and can bootstrap again" do + migrator.migrate + + expect(migrator.migrate(direction: :down).map(&:version)).to eq([8]) + expect(migrator.migrate(direction: :down, target: 4).map(&:version)).to eq([7, 6, 5]) + expect(migrator.status.select(&:applied).map(&:version)).to eq([1, 2, 3, 4]) + expect(migrator.migrate(direction: :down, target: 0).map(&:version)).to eq([4, 3, 2, 1]) + expect(migrator.status).to all(have_attributes(applied: false)) + expect(migrator.migrate.length).to eq(8) + end + + it "plans without writes and honors step limits" do + expect(migrator.migrate(dry_run: true).length).to eq(8) + expect(migrator.status).to all(have_attributes(applied: false)) + expect(migrator.migrate(steps: 2).map(&:version)).to eq([1, 2]) + expect(migrator.migrate(direction: :down, dry_run: true).map(&:version)).to eq([2]) + expect(migrator.status.count(&:applied)).to eq(2) + expect(migrator.migrate(direction: :down, steps: 2).map(&:version)).to eq([2, 1]) + end + + it "preserves populated jobs through the SQLite JSONB migration and its reversal" do + migrator.migrate(target: 6) + @driver.migration_connection do |connection| + sql = "INSERT INTO river_job (args, kind, max_attempts, metadata) VALUES ('{\"value\":42}', 'migration_test', 25, '{\"keep\":true}')" + (@driver.migration_backend == :postgresql) ? connection.exec(sql) : connection.execute_batch(sql) + end + + migrator.migrate + + expect(@driver.job_list).to contain_exactly(have_attributes(args: {"value" => 42}, kind: "migration_test", metadata: {"keep" => true})) + migrator.migrate(direction: :down, target: 6) + migrator.migrate + + expect(@driver.job_list).to contain_exactly(have_attributes(args: {"value" => 42}, kind: "migration_test", metadata: {"keep" => true})) + end + + it "rolls back failed DDL and history together while retaining earlier commits" do + original = migrator.migrations + broken = original.map do |migration| + (migration.version == 2) ? River::Migrator::Migration.new(2, "broken", "CREATE TABLE migration_failure (value integer); SELECT * FROM no_such_migration_table;", "") : migration + end + + migrator.instance_variable_set(:@migrations, broken) + + expect { migrator.migrate }.to raise_error(StandardError) + expect(migrator.status.select(&:applied).map(&:version)).to eq([1]) + migrator.instance_variable_set(:@migrations, original) + + expect(migrator.migrate.length).to eq(7) + end + + it "refuses to run inside an application transaction" do + @driver.transaction do + expect { migrator.migrate }.to raise_error(River::Error, /application transaction/) + end + end + + it "refuses unknown future or incomplete database histories" do + migrator.migrate + @driver.migration_connection do |connection| + sql = "INSERT INTO river_migration (line, version) VALUES ('main', 9)" + (@driver.migration_backend == :postgresql) ? connection.exec(sql) : connection.execute_batch(sql) + end + + expect { migrator.migrate }.to raise_error(River::Error, /history/) + @driver.migration_connection do |connection| + sql = "DELETE FROM river_migration WHERE version IN (3, 9)" + (@driver.migration_backend == :postgresql) ? connection.exec(sql) : connection.execute_batch(sql) + end + + expect { migrator.status }.to raise_error(River::Error, /history/) + end + + it "does not erase other migration lines when downgrading main" do + migrator.migrate + @driver.migration_connection do |connection| + sql = "INSERT INTO river_migration (line, version) VALUES ('pro', 1)" + (@driver.migration_backend == :postgresql) ? connection.exec(sql) : connection.execute_batch(sql) + end + + expect { migrator.migrate(direction: :down, target: 4) }.to raise_error(River::Error, /non-main/) + expect(migrator.status.select(&:applied).map(&:version)).to eq((1..8).to_a) + end + + it "leaves pending migrations untouched when an up target is already applied" do + migrator.migrate(target: 5) + [4, 5].each do |target| + expect(migrator.migrate(target: target)).to eq([]) + expect(migrator.migrate(target: target, steps: 1, dry_run: true)).to eq([]) + expect(migrator.status.select(&:applied).map(&:version)).to eq((1..5).to_a) + end + expect(migrator.migrate(target: 8, steps: 1).map(&:version)).to eq([6]) + end + + it "preserves jobs and unique indexes when upgrading and reversing migration 008" do + migrator.migrate(target: 7) + client = River::Client.new(@driver) + args = River::JobArgsHash.new(:migration_test, {"value" => 42}) + options = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_args: true), metadata: {"keep" => true}, tags: ["tag"]) + original = client.insert(args, insert_opts: options).job + + [[:up, 8], [:down, 7], [:up, 8]].each do |direction, target| + migrator.migrate(direction: direction, target: target) + attributes = original.instance_variables.to_h { |name| [name.to_s.delete_prefix("@").to_sym, original.instance_variable_get(name)] } + expect(client.job_get(original.id)).to have_attributes(attributes) + duplicate = client.insert(args, insert_opts: options) + expect(duplicate).to have_attributes(unique_skipped_as_duplicate?: true, job: have_attributes(id: original.id)) + end + + client.job_delete(original.id) + expect(client.insert(args).job.id).to be > original.id + end + + if adapter == :sqlite + it "guards both directions of the SQLite rebuild against installed Pro schema" do + migrator.migrate(target: 7) + [[:up, 8, 7], [:down, 7, 8]].each do |direction, target, current| + @driver.migration_connection { |connection| connection.execute_batch("CREATE TABLE river_workflow (id integer PRIMARY KEY)") } + expect { migrator.migrate(direction: direction, target: target) }.to raise_error(/cannot run while River Pro schema is installed/) + expect(migrator.status.select(&:applied).map(&:version)).to eq((1..current).to_a) + @driver.migration_connection { |connection| connection.execute_batch("DROP TABLE river_workflow") } + migrator.migrate if direction == :up + end + end + end +end diff --git a/ruby/spec/migrator_spec.rb b/ruby/spec/migrator_spec.rb new file mode 100644 index 000000000..95b73d511 --- /dev/null +++ b/ruby/spec/migrator_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" +require_relative "support/client_test_database" +require_relative "migrator_shared_examples" +require "digest" + +RSpec.describe River::Migrator do + it "ships exact files recorded by the upstream checksum manifest" do + root = File.expand_path("../migration", __dir__) + manifest = JSON.parse(File.read(File.join(root, "manifest.json"))) + + expect(Dir.glob(File.join(root, "**/*.sql")).size).to eq(manifest.fetch("files").size) + manifest.fetch("files").each do |path, digest| + expect(Digest::SHA256.file(File.join(root, path)).hexdigest).to eq(digest) + end + end + + it "rejects unsupported backends, invalid lines, empty bundles, and SQLite schemas" do + driver = Object.new + driver.define_singleton_method(:migration_backend) { :unknown } + + expect { described_class.new(driver) }.to raise_error(ArgumentError, /backend/) + driver.define_singleton_method(:migration_backend) { :sqlite } + + expect { described_class.new(driver, line: "../main") }.to raise_error(ArgumentError, /line/) + expect { described_class.new(driver, line: "missing") }.to raise_error(ArgumentError, /contiguous/) + expect { described_class.new(driver, schema: "other") }.to raise_error(ArgumentError, /SQLite/) + end + + [:postgres, :sqlite].each do |adapter| + context "with #{adapter}", database: adapter do + around do |example| + skip "PostgreSQL unavailable" if adapter == :postgres && !DB + + ClientTestDatabase.with_sequel(adapter, migrate: false) do |driver| + @driver = driver + example.run + end + end + + it_behaves_like "canonical migrations", adapter + + if adapter == :sqlite + it "supports SQLite connections configured to return hashes" do + @driver.migration_connection { |connection| connection.results_as_hash = true } + expect(described_class.new(@driver).migrate.length).to eq(8) + end + end + + it "detects concurrent history changes before executing SQL" do + migrator = described_class.new(@driver) + calls = 0 + migrator.define_singleton_method(:existing_versions) { ((calls += 1) == 1) ? [] : [1] } + + expect { migrator.migrate }.to raise_error(River::Error, /changed concurrently/) + end + + if adapter == :postgres + it "rejects an empty search path" do + @driver.migration_connection do |connection| + connection.exec("SET search_path TO missing_river_schema") + expect { described_class.new(@driver).status }.to raise_error(ArgumentError, /identifier/) + end + end + + it "supports explicit schemas and rejects unsafe identifiers" do + schema = @driver.send(:runtime_query_rows, "SELECT current_schema() AS name").first.fetch(:name) + + expect(described_class.new(@driver, schema: schema).migrate.length).to eq(8) + expect { described_class.new(@driver, schema: "bad'name").status }.to raise_error(ArgumentError, /identifier/) + end + + it "refuses concurrent Ruby migrators using the same schema" do + locked = Queue.new + release = Queue.new + holder = Thread.new do + @driver.migration_connection do |connection| + schema = connection.exec("SELECT current_schema() AS name").first.fetch("name") + connection.exec("SELECT pg_advisory_lock(hashtext(current_database()), hashtext('river_migrate:#{schema}'))") + locked << true + release.pop + connection.exec("SELECT pg_advisory_unlock(hashtext(current_database()), hashtext('river_migrate:#{schema}'))") + end + end + + Timeout.timeout(5) { locked.pop } + + expect { described_class.new(@driver).migrate }.to raise_error(River::Error, /schema lock/) + ensure + release << true + holder&.join + end + end + end + end +end diff --git a/ruby/spec/params_spec.rb b/ruby/spec/params_spec.rb new file mode 100644 index 000000000..47ed3d5c2 --- /dev/null +++ b/ruby/spec/params_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe River::JobListParams do + it "uses stable pagination defaults" do + params = described_class.new + + expect(params).to have_attributes(after_id: nil, filters?: be(false), limit: 100, sort_by: :id, sort_order: :asc) + end + + it "accepts every supported filter" do + params = described_class.new( + after_id: 10, + ids: [11], + kinds: ["email"], + metadata: {tenant: "one"}, + priorities: [2], + queues: ["default"], + states: [River::JOB_STATE_AVAILABLE], + tags_all: ["one"], + tags_any: ["two"] + ) + + expect(params).to have_attributes(after_id: 10, filters?: be(true), ids: [11], kinds: ["email"], priorities: [2]) + end + + it "does not consider empty collections to be filters" do + params = described_class.new(ids: [], kinds: [], metadata: {}, queues: [], states: [], tags_all: [], tags_any: []) + + expect(params.filters?).to be false + end + + it "normalizes symbol filters without modifying the supplied arrays" do + kinds = [:email, "report"].freeze + queues = [:default].freeze + states = [:available].freeze + params = described_class.new(kinds: kinds, queues: queues, states: states) + + expect(params).to have_attributes(kinds: %w[email report], queues: ["default"], states: ["available"]) + expect(kinds).to eq([:email, "report"]) + expect(queues).to eq([:default]) + expect(states).to eq([:available]) + end + + it "coerces limit and sorting values" do + params = described_class.new(limit: "25", sort_by: "scheduled_at", sort_order: "desc") + + expect(params).to have_attributes(limit: 25, sort_by: :scheduled_at, sort_order: :desc) + end + + [0, 10_001].each do |limit| + it "rejects limit=#{limit}" do + expect { described_class.new(limit: limit) }.to raise_error(ArgumentError, /limit must be between/) + end + end + + it "rejects an unsupported sort field" do + expect { described_class.new(sort_by: :priority) }.to raise_error(ArgumentError, "invalid sort field") + end + + it "rejects an unsupported sort direction" do + expect { described_class.new(sort_order: :sideways) }.to raise_error(ArgumentError, "invalid sort order") + end + + it "accepts a cursor as a filter and coerces the ID shortcut" do + cursor = River::JobListCursor.new(id: 10, sort_by: :id, sort_order: :asc, value: 10) + expect(described_class.new(after: cursor)).to have_attributes(after: cursor, filters?: be(true)) + expect(described_class.new(after_id: "10").after_id).to eq(10) + end + + it "rejects ambiguous or incompatible pagination" do + cursor = River::JobListCursor.new(id: 10, sort_by: :scheduled_at, sort_order: :asc, value: Time.now.utc) + expect { described_class.new(after: cursor, after_id: 10) }.to raise_error(ArgumentError, /either after or after_id/) + expect { described_class.new(after_id: 10, sort_by: :scheduled_at) }.to raise_error(ArgumentError, /after_id requires/) + expect { described_class.new(after: 10) }.to raise_error(ArgumentError, /JobListCursor/) + expect { described_class.new(after: cursor) }.to raise_error(ArgumentError, /same ordering/) + expect { described_class.new(after: cursor, sort_by: :scheduled_at, sort_order: :desc) }.to raise_error(ArgumentError, /same ordering/) + expect(described_class.new(after: cursor, sort_by: :scheduled_at).after).to eq(cursor) + end +end + +RSpec.describe River::JobUpdateParams do + it "normalizes symbolic states without changing unset or explicitly nil states" do + expect(described_class.new(state: :available).each.to_h).to eq(state: "available") + expect(described_class.new(state: nil).each.to_h).to eq(state: nil) + expect(described_class.new.each.to_h).to eq({}) + end + + it "enumerates only explicitly supplied fields" do + params = described_class.new(attempt: 2, finalized_at: nil, metadata: {"updated" => true}) + + expect(params.each.to_h).to eq(attempt: 2, finalized_at: nil, metadata: {"updated" => true}) + end + + it "distinguishes an explicit nil from an unset field" do + params = described_class.new(attempted_at: nil) + + expect(params.each.to_a).to eq([[:attempted_at, nil]]) + end + + it "returns an enumerator without a block" do + expect(described_class.new.each).to be_an(Enumerator) + end + + it "is empty when no updates are supplied" do + expect(described_class.new.each.to_a).to be_empty + end +end diff --git a/ruby/spec/periodic_cron_spec.rb b/ruby/spec/periodic_cron_spec.rb new file mode 100644 index 000000000..3a0e34f81 --- /dev/null +++ b/ruby/spec/periodic_cron_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open3" + +RSpec.describe River::PeriodicCron do + it "does not load Fugit when requiring River or using intervals" do + output, status = Open3.capture2(RbConfig.ruby, "-Ilib", "-e", <<~RUBY) + require "riverqueue" + River::PeriodicInterval.new(60).next(Time.now) + abort "Fugit was loaded" if defined?(Fugit) + RUBY + expect(status.success?).to be(true), output + end + + it "returns the next UTC occurrence, excluding an exact boundary" do + schedule = described_class.new("*/15 * * * *") + now = Time.utc(2026, 1, 1, 9) + + expect(schedule.next(now)).to eq(Time.utc(2026, 1, 1, 9, 15)) + expect(schedule.next(now + 0.5)).to have_attributes(utc?: true, year: 2026) + expect(now).to eq(Time.utc(2026, 1, 1, 9)) + end + + it "supports aliases and optional seconds" do + now = Time.utc(2026, 1, 1) + + expect(described_class.new("@daily").next(now)).to eq(Time.utc(2026, 1, 2)) + expect(described_class.new("*/10 * * * * *").next(now)).to eq(now + 10) + end + + it "uses UTC by default even when the supplied time has a different offset" do + schedule = described_class.new("0 9 * * *") + + expect(schedule.next(Time.new(2026, 1, 1, 9, 0, 0, "+08:00"))).to eq(Time.utc(2026, 1, 1, 9)) + end + + it "calculates weekdays in the requested timezone across daylight-saving changes" do + schedule = described_class.new("0 9 * * 1-5", timezone: "America/New_York") + + expect(schedule.next(Time.utc(2026, 3, 6, 14))).to eq(Time.utc(2026, 3, 9, 13)) + expect(schedule.next(Time.utc(2026, 10, 30, 13))).to eq(Time.utc(2026, 11, 2, 14)) + end + + it "skips a nonexistent spring-forward local time" do + schedule = described_class.new("30 2 * * *", timezone: "America/New_York") + + expect(schedule.next(Time.utc(2026, 3, 7, 7, 30))).to eq(Time.utc(2026, 3, 9, 6, 30)) + end + + it "matches either restricted day-of-month or day-of-week" do + schedule = described_class.new("0 0 13 * FRI") + + expect(schedule.next(Time.utc(2026, 1, 1))).to eq(Time.utc(2026, 1, 2)) + expect(schedule.next(Time.utc(2026, 1, 12))).to eq(Time.utc(2026, 1, 13)) + end + + it "finds leap days" do + expect(described_class.new("0 0 29 2 *").next(Time.utc(2026, 1, 1))).to eq(Time.utc(2028, 2, 29)) + end + + it "honors a timezone prefix over the keyword timezone" do + schedule = described_class.new("CRON_TZ=America/New_York 0 9 * * *", timezone: "UTC") + expect(schedule.next(Time.utc(2026, 1, 1))).to eq(Time.utc(2026, 1, 1, 14)) + end + + it "returns both occurrences of a repeated wall time even from a local reference" do + schedule = described_class.new("30 1 * * *", timezone: "America/New_York") + first = Time.new(2026, 11, 1, 1, 30, 0, "-04:00") + expect(schedule.next(first)).to eq(Time.utc(2026, 11, 1, 6, 30)) + end + + {"0" => 1, "-1h" => 1, "+1.5s" => 1, ".5h" => 1800, + "1ns1us1µs1μs" => 1, "9223372036854775807ns" => 9_223_372_036, + "-9223372036854775808ns" => 1}.each do |duration, seconds| + it "matches Go's interval normalization for #{duration}" do + now = Time.utc(2026, 1, 1) + Rational(9, 10) + expect(described_class.new("@every #{duration}").next(now)).to eq(Time.at(now.to_i + seconds).utc) + end + end + + %w[1d -+1s ++1s 1e3s 1 0.0 1s? 9223372036854775808ns -9223372036854775809ns].each do |duration| + it "rejects invalid Go duration #{duration}" do + expect { described_class.new("@every #{duration}") }.to raise_error(ArgumentError) + end + end + + it "works through the existing periodic job and bundle interfaces" do + job = River::PeriodicJob.new(constructor: -> {}, run_on_start: true, schedule: described_class.new("0 9 * * *")) + jobs = River::PeriodicJobBundle.new([job], wake: -> {}) + now = Time.now.utc + 1 + + expect(jobs.due(now)).to eq([job]) + expect(jobs.due(now)).to be_empty + expect(jobs.due(job.next_at(now))).to eq([job]) + end + + [nil, 123, "", "not cron", "60 * * * *", "0 9 * * * America/New_York"].each do |expression| + it "rejects invalid or timezone-suffixed expression #{expression.inspect}" do + expect { described_class.new(expression) }.to raise_error(ArgumentError) + end + end + + [nil, "", "UTC extra", "Not/A_Zone"].each do |timezone| + it "rejects invalid timezone #{timezone.inspect}" do + expect { described_class.new("0 9 * * *", timezone: timezone) }.to raise_error(ArgumentError) + end + end +end diff --git a/ruby/spec/periodic_job_spec.rb b/ruby/spec/periodic_job_spec.rb new file mode 100644 index 000000000..98d82312e --- /dev/null +++ b/ruby/spec/periodic_job_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe River::PeriodicJob do + it "uses an object schedule implementing next" do + now = Time.utc(2026, 1, 1) + job = described_class.new(constructor: -> {}, schedule: River::PeriodicInterval.new(60)) + + expect(job.next_at(now)).to eq(now + 60) + end + + it "uses a callable schedule" do + now = Time.utc(2026, 1, 1) + job = described_class.new(constructor: -> {}, schedule: ->(time) { time + 30 }) + + expect(job.next_at(now)).to eq(now + 30) + end + + it "retains registration attributes" do + constructor = -> { :args } + job = described_class.new(id: "cleanup", constructor: constructor, run_on_start: true, schedule: ->(time) { time }) + + expect(job).to have_attributes(id: "cleanup", constructor: constructor, run_on_start: true) + end + + it "normalizes symbolic IDs to strings" do + job = described_class.new(id: :cleanup, constructor: -> {}, schedule: ->(time) { time }) + + expect(job.id).to eq("cleanup") + end + + it "accepts a factory block without running it during registration" do + calls = 0 + job = described_class.new(schedule: River::PeriodicInterval.new(60)) do + calls += 1 + :args + end + + expect(calls).to eq(0) + expect(job.constructor.call).to eq(:args) + expect(calls).to eq(1) + end + + it "requires exactly one callable factory" do + schedule = River::PeriodicInterval.new(60) + expect { described_class.new(schedule: schedule) }.to raise_error(ArgumentError, /factory/) + expect { described_class.new(schedule: schedule, constructor: Object.new) }.to raise_error(ArgumentError, /callable/) + expect { described_class.new(schedule: schedule, constructor: -> {}) {} }.to raise_error(ArgumentError, /not both/) + end + + it "rejects invalid schedules and non-advancing or non-Time results" do + expect { described_class.new(schedule: Object.new) {} }.to raise_error(ArgumentError, /schedule/) + now = Time.utc(2026, 1, 1) + [nil, 123, now, now - 1].each do |result| + job = described_class.new(schedule: ->(_) { result }) {} + expect { job.next_at(now) }.to raise_error(ArgumentError, /future Time/) + end + end +end + +RSpec.describe River::PeriodicInterval do + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |seconds| + it "rejects non-finite interval #{seconds}" do + expect { described_class.new(seconds) }.to raise_error(ArgumentError, "period must be finite") + end + end + + it "coerces seconds and advances a time" do + now = Time.utc(2026, 1, 1) + + expect(described_class.new("1.5").next(now)).to eq(now + 1.5) + end + + [0, -1].each do |seconds| + it "rejects interval #{seconds}" do + expect { described_class.new(seconds) }.to raise_error(ArgumentError, "period must be greater than zero") + end + end + + it "rejects a nonnumeric interval" do + expect { described_class.new("daily") }.to raise_error(ArgumentError) + end +end + +RSpec.describe River::PeriodicJobBundle do + let(:wake) { proc {} } + let(:schedule) { ->(time) { time + 60 } } + + def periodic(id: nil, run_on_start: false, schedule: ->(time) { time + 60 }) + River::PeriodicJob.new(id: id, constructor: -> {}, run_on_start: run_on_start, schedule: schedule) + end + + it "assigns increasing handles and wakes once per atomic addition" do + wake_count = 0 + jobs = described_class.new([], wake: -> { wake_count += 1 }) + + expect(jobs.add(periodic(id: "one"))).to eq(1) + expect(jobs.add_many([periodic(id: "two"), periodic])).to eq([2, 3]) + expect(wake_count).to eq(2) + end + + it "registers a batch atomically when an ID conflicts or a schedule fails" do + jobs = described_class.new([periodic(id: "existing")], wake: wake) + expect { jobs.add_many([periodic(id: "new"), periodic(id: "existing")]) }.to raise_error(ArgumentError, /already registered/) + expect(jobs.remove_by_id("new")).to be false + expect { jobs.add_many([periodic(id: "new"), periodic(id: "new")]) }.to raise_error(ArgumentError, /already registered/) + expect(jobs.remove_by_id("new")).to be false + failing = periodic(schedule: ->(_) { raise "bad schedule" }) + expect { jobs.add_many([periodic(id: "new"), failing]) }.to raise_error("bad schedule") + expect(jobs.remove_by_id("new")).to be false + expect(jobs.add(periodic)).to eq(2) + expect(jobs.add_many([])).to eq([]) + end + + it "invokes schedule and wake callbacks outside the registry lock" do + jobs = nil + jobs = described_class.new([], wake: -> { jobs.remove_by_id("missing") }) + reentrant = periodic(schedule: ->(time) { + jobs.remove_by_id("missing") + time + 60 + }) + expect { jobs.add(reentrant) }.not_to raise_error + expect(jobs.due(Time.now.utc + 120)).to eq([reentrant]) + end + + it "reports schedule errors without losing healthy due jobs" do + broken = periodic(id: "broken", run_on_start: true, schedule: ->(_) { raise "bad schedule" }) + good = periodic(run_on_start: true) + jobs = described_class.new([broken, good], wake: wake) + errors = [] + expect(jobs.due(Time.now.utc + 1) { |job, error| errors << [job, error.message] }).to eq([good]) + expect(errors).to eq([[broken, "bad schedule"]]) + expect { jobs.due(Time.now.utc + 1) }.to raise_error("bad schedule") + end + + it "does not return a job removed while its schedule is evaluated" do + jobs = described_class.new([], wake: wake) + job = periodic(id: "removed", run_on_start: true, schedule: ->(time) { + jobs.remove_by_id("removed") + time + 60 + }) + jobs.add(job) + expect(jobs.due(Time.now.utc + 1)).to be_empty + end + + it "does not return a job already claimed by a concurrent scheduler" do + jobs = described_class.new([], wake: wake) + nested = nil + job = periodic(run_on_start: true, schedule: ->(time) { + unless nested + nested = [] + nested = jobs.due(time) + end + time + 60 + }) + jobs.add(job) + expect(jobs.due(Time.now.utc + 1)).to be_empty + expect(nested).to eq([job]) + end + + it "rejects duplicate non-nil IDs" do + jobs = described_class.new([periodic(id: "same")], wake: wake) + + expect { jobs.add(periodic(id: "same")) } + .to raise_error(ArgumentError, "periodic job ID is already registered: same") + end + + it "allows multiple anonymous registrations" do + jobs = described_class.new([], wake: wake) + + expect { jobs.add_many([periodic, periodic]) }.not_to raise_error + end + + it "treats string and symbol IDs as the same registration" do + jobs = described_class.new([periodic(id: :cleanup)], wake: wake) + + expect { jobs.add(periodic(id: "cleanup")) } + .to raise_error(ArgumentError, "periodic job ID is already registered: cleanup") + expect(jobs.remove_by_id(:cleanup)).to be true + expect(jobs.remove_by_id("cleanup")).to be false + expect(jobs.remove_by_id(nil)).to be false + end + + it "makes run-on-start jobs immediately due" do + job = periodic(id: "startup", run_on_start: true) + jobs = described_class.new([job], wake: wake) + + expect(jobs.due(Time.now.utc + 1)).to eq([job]) + end + + it "does not return future jobs" do + jobs = described_class.new([periodic], wake: wake) + + expect(jobs.due(Time.now.utc)).to be_empty + end + + it "reschedules a due job from the supplied time" do + calls = [] + schedule = ->(time) { + calls << time + time + 60 + } + job = periodic(run_on_start: true, schedule: schedule) + jobs = described_class.new([job], wake: wake) + due_at = Time.now.utc + 1 + + expect(jobs.due(due_at)).to eq([job]) + expect(jobs.due(due_at + 30)).to be_empty + expect(calls.last).to eq(due_at) + end + + it "removes a registration by handle" do + jobs = described_class.new([], wake: wake) + job = periodic(run_on_start: true) + handle = jobs.add(job) + + expect(jobs.remove(handle)).to equal(job) + expect(jobs.remove(handle)).to be_nil + expect(jobs.due(Time.now.utc + 1)).to be_empty + end + + it "removes a registration by ID" do + jobs = described_class.new([periodic(id: "remove", run_on_start: true)], wake: wake) + + expect(jobs.remove_by_id("remove")).to be true + expect(jobs.remove_by_id("remove")).to be false + end + + it "clears all registrations" do + jobs = described_class.new([periodic(run_on_start: true), periodic(run_on_start: true)], wake: wake) + expect(jobs.clear).to equal(jobs) + + expect(jobs.due(Time.now.utc + 1)).to be_empty + end +end diff --git a/ruby/spec/postgres_capabilities_spec.rb b/ruby/spec/postgres_capabilities_spec.rb new file mode 100644 index 000000000..7a579b721 --- /dev/null +++ b/ruby/spec/postgres_capabilities_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "spec_helper" +require "timeout" + +RSpec.describe River::Driver::PostgresCapabilities do + [ + ["PostgreSQL 15.12", 150_012, false, true, :xmax, "(xmax != 0)"], + ["PostgreSQL 18.0", 180_000, false, true, :returning_old, "(OLD.id IS NOT NULL)"], + ["PostgreSQL 19beta1", 190_000, false, true, :returning_old, "(OLD.id IS NOT NULL)"], + ["PostgreSQL 15.12-YB-2025.2.1.0-b1", 150_012, false, false, :metadata_nonce, "false"], + ["PostgreSQL 15.12-YB-2025.2.3.0-b1", 150_012, true, true, :metadata_nonce, "false"], + ["YugabyteDB", 180_000, false, false, :metadata_nonce, "false"] + ].each do |product, version, notify, expected_notify, mode, sql| + it "detects #{product} with notifications #{notify}" do + result = described_class.new(product: product, version_num: version, yb_listen_notify_enabled: notify) + expect(result).to have_attributes(supports_listen_notify: expected_notify, unique_insert_mode: mode, unique_insert_sql: sql) + expect(Ractor.shareable?(result)).to be true + end + end +end + +RSpec.describe River::Driver::PostgresCapabilities::Cache do + let(:cache) { described_class.new } + let(:detected) { River::Driver::PostgresCapabilities.new(product: "PostgreSQL 18", version_num: 180_000, yb_listen_notify_enabled: false) } + + it "caches successful detection separately for each connection pool" do + first, second = Object.new, Object.new + expect(cache.fetch(first) { detected }).to equal(detected) + expect(cache.fetch(first) { raise "already detected" }).to equal(detected) + expect { cache.fetch(second) { raise "different pool" } }.to raise_error("different pool") + end + + it "does not cache failed detection" do + expect { cache.fetch(:pool) { raise "query failed" } }.to raise_error("query failed") + expect(cache.fetch(:pool) { detected }).to equal(detected) + end + + it "does not hold its lock across I/O and keeps the first successful detection" do + entered, release = Queue.new, Queue.new + thread = Thread.new do + cache.fetch(:pool) do + entered << true + release.pop + River::Driver::PostgresCapabilities.new(product: "YugabyteDB", version_num: 150_012, yb_listen_notify_enabled: false) + end + end + Timeout.timeout(5) { entered.pop } + expect(Timeout.timeout(5) { cache.fetch(:pool) { detected } }).to equal(detected) + release << true + expect(Timeout.timeout(5) { thread.value }).to equal(detected) + ensure + release << true + thread&.join + end +end diff --git a/ruby/spec/public_api_spec.rb b/ruby/spec/public_api_spec.rb new file mode 100644 index 000000000..a14c5da4f --- /dev/null +++ b/ruby/spec/public_api_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" + +RSpec.describe "Ruby client keyword APIs" do + around { |example| available_test_transaction(&example) } + + let(:driver) { River::Driver::Sequel.new(available_test_database) } + let(:client) { River::Client.new(driver) } + let(:args) { River::JobArgsHash.new(:example, value: 1) } + + it "preserves the insertion result class name from earlier releases" do + expect(River::InsertResult).to equal(River::JobInsertResult) + + result = client.insert(args) + expect(result).to be_an_instance_of(River::InsertResult) + expect(client.insert_many([args])).to all(be_an_instance_of(River::InsertResult)) + + legacy_result = River::InsertResult.new(result.job, unique_skipped_as_duplicated: true) + expect(legacy_result.job).to equal(result.job) + expect(legacy_result).to be_unique_skipped_as_duplicate + expect(legacy_result.unique_skipped_as_duplicated).to be true + end + + it "accepts insertion keywords with the same defaults and uniqueness behavior" do + unique = River::UniqueOpts.new(by_args: true) + first = client.insert(args, queue: :critical, priority: 2, unique_opts: unique) + duplicate = client.insert(args, queue: :critical, priority: 2, unique_opts: unique) + + expect(first.job).to have_attributes(queue: "critical", priority: 2) + expect(first).not_to be_unique_skipped_as_duplicate + expect(duplicate).to be_unique_skipped_as_duplicate + expect(first.unique_skipped_as_duplicated).to be false + expect(duplicate.unique_skipped_as_duplicated).to be true + expect(duplicate.job.id).to eq(first.job.id) + end + + it "merges keyword options with argument-level defaults without mutating them" do + defaults = River::InsertOpts.new(queue: :default_queue, priority: 3, metadata: {"default" => true}).freeze + args.define_singleton_method(:insert_opts) { defaults } + + result = client.insert(args, queue: :critical, metadata: {"call" => true}) + + expect(result.job).to have_attributes(queue: "critical", priority: 3) + expect(result.job.metadata).to include("default" => true, "call" => true) + expect(defaults).to have_attributes(queue: :default_queue, metadata: {"default" => true}) + end + + it "accepts per-job keyword options in a mixed batch" do + results = client.insert_many([ + args, + River::InsertManyParams.new(args, queue: :critical, priority: 2) + ]) + + expect(results.map { |result| result.job.queue }).to eq(%w[default critical]) + expect(results.last.job.priority).to eq(2) + end + + it "lists, paginates, and deletes with keyword filters" do + first, second = client.insert_many([args, args]).map(&:job) + page = client.job_list(kinds: [:example], limit: 1) + next_page = client.job_list(kinds: [:example], after: page.last_cursor) + + expect(page.jobs.map(&:id)).to eq([first.id]) + expect(next_page.jobs.map(&:id)).to eq([second.id]) + expect(client.job_delete_many(ids: [first.id]).jobs.map(&:id)).to eq([first.id]) + expect(client.job_list.jobs.map(&:id)).to eq([second.id]) + expect { client.job_delete_many }.to raise_error(ArgumentError, /no filters/) + end + + it "distinguishes omitted update fields from explicit nil" do + row = client.insert(args).job + at = Time.utc(2026, 1, 1) + client.job_update(row.id, attempted_at: at, max_attempts: 7) + + expect(client.job_update(row.id)).to have_attributes(attempted_at: at, max_attempts: 7) + expect(client.job_update(row.id, attempted_at: nil)).to have_attributes(attempted_at: nil, max_attempts: 7) + end + + it "rejects mixed option forms instead of silently choosing precedence" do + options = River::InsertOpts.new(queue: :default) + filters = River::JobListParams.new(kinds: [:example]) + updates = River::JobUpdateParams.new(max_attempts: 7) + + expect { client.insert(args, insert_opts: options, queue: :critical) }.to raise_error(ArgumentError, /not both/) + expect { River::InsertManyParams.new(args, insert_opts: options, queue: :critical) }.to raise_error(ArgumentError, /not both/) + expect { client.job_list(filters, limit: 1) }.to raise_error(ArgumentError, /not both/) + expect { client.job_delete_many(filters, limit: 1) }.to raise_error(ArgumentError, /not both/) + expect { client.job_update(1, updates, max_attempts: 8) }.to raise_error(ArgumentError, /not both/) + expect(driver.job_list).to be_empty + end + + it "rejects unknown keywords before making changes" do + expect { client.insert(args, quue: :critical) }.to raise_error(ArgumentError, /unknown keyword/) + expect { River::InsertManyParams.new(args, quue: :critical) }.to raise_error(ArgumentError, /unknown keyword/) + expect { client.job_list(knd: :example) }.to raise_error(ArgumentError, /unknown keyword/) + expect { client.job_delete_many(knd: :example) }.to raise_error(ArgumentError, /unknown keyword/) + expect { client.job_update(1, priority: 2) }.to raise_error(ArgumentError, /unknown keyword/) + expect(driver.job_list).to be_empty + end +end diff --git a/ruby/spec/ractor_spec.rb b/ruby/spec/ractor_spec.rb new file mode 100644 index 000000000..d7053364b --- /dev/null +++ b/ruby/spec/ractor_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open3" + +RSpec.describe "Ractor compatibility" do + it "keeps core constants shareable across Ractor boundaries" do + values = [ + River::JOB_STATE_AVAILABLE, + River::JOB_STATE_CANCELLED, + River::JOB_STATE_COMPLETED, + River::JOB_STATE_DISCARDED, + River::JOB_STATE_PENDING, + River::JOB_STATE_RETRYABLE, + River::JOB_STATE_RUNNING, + River::JOB_STATE_SCHEDULED, + River::QUEUE_DEFAULT, + River::RESUMABLE_CURSOR_METADATA_KEY, + River::RESUMABLE_STEP_METADATA_KEY, + River::Client.const_get(:DEFAULT_UNIQUE_STATES, false), + River::Client.const_get(:REQUIRED_UNIQUE_STATES, false), + River::Client.const_get(:EMPTY_INSERT_OPTS, false), + River::Client.const_get(:TAG_RE, false), + River::UniqueBitmask.const_get(:JOB_STATE_BIT_POSITIONS, false), + River::Job.const_get(:RESUMABLE_CURSOR_UNSET, false), + River::JobUpdateParams::UNSET, + River::QUEUE_NAME_REGEX, + River::EVENT_JOB_COMPLETED + ] + + expect(values).to all(satisfy { |value| Ractor.shareable?(value) }) + end + + # A fresh process avoids test instrumentation and ensures a main-Ractor call + # hasn't already warmed up a lazily initialized dependency. Bound the whole + # subprocess so a broken producer or Ractor cannot hang the test suite. + def in_ractor(body) + script = <<~RUBY + require_relative "spec/support/ractor_test_driver" + begin + ractors = Array.new(2) do + Ractor.new do + #{body} + end + end + ractors.each do |ractor| + result = ractor.respond_to?(:value) ? ractor.value : ractor.take + raise "unexpected result: \#{result.inspect}" unless result == :ok + end + rescue Exception => error + warn error.full_message + Process.exit!(1) + end + + # Ruby 4.0.2 can deadlock tearing down Ractors' Timeout helper threads. + # Both results are verified before bypassing VM shutdown in this process. + Process.exit!(0) + RUBY + Open3.popen2e(RbConfig.ruby, "-Ilib", "-e", script) do |input, output, process| + input.close + begin + result = Timeout.timeout(15) { output.read } + expect(process.value.success?).to be(true), result + ensure + Process.kill("KILL", process.pid) if process.alive? + process.join + end + end + end + + it "reports Ractor exceptions instead of treating subprocess exit as success" do + expect { in_ractor('raise "ractor failure sentinel"') } + .to raise_error(RSpec::Expectations::ExpectationNotMetError, /ractor failure sentinel/) + end + + it "requires successful results before exiting the subprocess" do + expect { in_ractor(":unexpected") } + .to raise_error(RSpec::Expectations::ExpectationNotMetError, /unexpected result: :unexpected/) + end + + it "inserts and computes unique keys on first use in a non-main Ractor" do + in_ractor <<~RUBY + client = River::Client.new(RactorTestDriver.new) + args = River::JobArgsHash.new(:ractor_test, value: 1) + row = client.insert(args, insert_opts: River::InsertOpts.new( + unique_opts: River::UniqueOpts.new(by_args: true, by_queue: true) + )).job + raise "args" unless row.args == {"value" => 1} + expected = Digest::SHA256.digest('&kind=ractor_test&args={"value":1}&queue=default') + raise "unique key" unless row.unique_key == expected + raise "unique states" unless row.unique_states == "11110101" + raise "insert many" unless client.insert_many([args, args]).length == 2 + :ok + RUBY + end + + it "executes resumable work, retries, and events with Ractor-local state" do + in_ractor <<~'RUBY' + worker = Object.new + def worker.work(job) + job.resumable_step(:download) { job.update_metadata("downloaded" => true) } + job.resumable_step_cursor(:rows, default: {"id" => 0}) do |cursor| + if cursor.fetch("id") == 0 + job.resumable_checkpoint(cursor: {id: 1}) + job.resumable_set_cursor(id: 2) + raise "retry me" + end + raise "cursor" unless cursor == {"id" => 2} + job.output = "done" + end + end + config = River::Config.new(workers: River::Workers.new.add(:ractor_test, worker), job_timeout: nil) + client = River::Client.new(RactorTestDriver.new, config: config) + events = client.subscribe(:job_failed, :job_completed) + row = client.insert(River::JobArgsHash.new(:ractor_test, {})).job + result = River::Testing.perform_job(client, row.id) + raise "retry: #{result.error.inspect}" unless result.outcome == :retried && result.error.message == "retry me" + raise "failed event" unless events.pop(true).kind == :job_failed + result = River::Testing.perform_job(client, row.id, allow_scheduled: true) + raise "completion: #{result.error.inspect}" unless result.outcome == :completed + raise "output" unless result.job.metadata["output"] == "done" + raise "completed event" unless events.pop(true).kind == :job_completed + events.close + :ok + RUBY + end + + it "starts producer/maintenance threads and stops within a non-main Ractor" do + in_ractor <<~'RUBY' + config = River::Config.new( + workers: River::Workers.new.add(RactorTestWorker), queues: {default: 1}, + fetch_cooldown: 0.001, fetch_poll_interval: 0.01, job_timeout: nil, + periodic_jobs: [River::PeriodicJob.new( + schedule: River::PeriodicInterval.new(3600), run_on_start: true, + constructor: -> { River::JobArgsHash.new(:ractor_test, value: 21) } + )] + ) + client = River::Client.new(RactorTestDriver.new, config: config) + events = client.subscribe(:job_completed, :job_failed) + begin + client.start + event = events.pop + raise "work failed: #{event.job.errors.inspect}" unless event.kind == :job_completed + raise "output" unless event.job.metadata["output"] == 42 + ensure + client.stop + events.close + end + raise "did not stop" unless client.stopped? + :ok + RUBY + end + + it "persists attempt logs with Ractor-local loggers and buffers" do + in_ractor <<~'RUBY' + worker = Object.new + def worker.work(job) + job.logger.info "hello from a Ractor" + end + config = River::Config.new( + workers: River::Workers.new.add(:ractor_test, worker), job_timeout: nil, + plugins: [River::JobPersistedLogging::Plugin.new] + ) + client = River::Client.new(RactorTestDriver.new, config: config) + row = client.insert(River::JobArgsHash.new(:ractor_test, {})).job + result = River::Testing.perform_job(client, row.id) + raise "work failed: #{result.error.inspect}" unless result.outcome == :completed + log = result.job.metadata.fetch("river:log").fetch(0) + raise "attempt" unless log.fetch("attempt") == 1 + raise "log" unless log.fetch("log").include?("hello from a Ractor") + :ok + RUBY + end + + it "enforces job timeouts inside a non-main Ractor on Ruby 4+" do + skip "timeout gem's Ractor support requires Ruby 4+" if RUBY_VERSION.to_i < 4 + + in_ractor <<~'RUBY' + worker = Object.new + def worker.work(_job) = sleep(60) + config = River::Config.new(workers: River::Workers.new.add(:ractor_test, worker), job_timeout: 0.01) + client = River::Client.new(RactorTestDriver.new, config: config) + row = client.insert(River::JobArgsHash.new(:ractor_test, {})).job + result = River::Testing.perform_job(client, row.id) + raise "timeout: #{result.error.inspect}" unless result.error.is_a?(Timeout::Error) && result.outcome == :retried + :ok + RUBY + end + + it "rejects the signal-handling runner outside the main Ractor" do + in_ractor <<~RUBY + client = River::Client.new(RactorTestDriver.new, config: River::Config.new(queues: {default: 1})) + begin + River::WorkerRunner.new(client).run + raise "runner should reject a non-main Ractor" + rescue ArgumentError => error + raise unless error.message.include?("main Ractor") + end + :ok + RUBY + end +end diff --git a/ruby/spec/resumable_spec.rb b/ruby/spec/resumable_spec.rb new file mode 100644 index 000000000..0cd982cfe --- /dev/null +++ b/ruby/spec/resumable_spec.rb @@ -0,0 +1,338 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe River::ResumableState do + it "starts at the beginning without persisted progress" do + state = described_class.new({}) + + expect(state).to have_attributes(cursors_dirty: false, resume_matched: true, resume_step: nil) + expect(state.cursors).to eq({}) + end + + it "loads persisted step and cursor progress defensively" do + metadata = { + River::RESUMABLE_STEP_METADATA_KEY => "items", + River::RESUMABLE_CURSOR_METADATA_KEY => {"items" => 4} + } + state = described_class.new(metadata) + state.cursors["items"] = 5 + + expect(state).to have_attributes(cursors_dirty: false, resume_matched: false, resume_step: "items") + expect(metadata.fetch(River::RESUMABLE_CURSOR_METADATA_KEY)).to eq("items" => 4) + end + + it "rejects duplicate step names" do + state = described_class.new({}) + + expect(state.register("same")).to be_truthy + expect { state.register("same") }.to raise_error(River::Error, 'duplicate resumable step name "same"') + end +end + +RSpec.describe "resumable job execution" do + def build_row(metadata: {}, state: River::JOB_STATE_RUNNING) + River::JobRow.new( + id: 123, + args: {}, + attempt: 1, + created_at: Time.now.utc, + kind: "resumable", + max_attempts: 3, + metadata: metadata, + priority: 1, + queue: "default", + scheduled_at: Time.now.utc, + state: state + ) + end + + def build_job(row: build_row, driver: Object.new) + River::Job.new(Struct.new(:driver).new(driver), row) + end + + it "runs named steps in order and returns their values" do + job = build_job + calls = [] + + expect(job.resumable_step("first") { + calls << "first" + 1 + }).to eq(1) + expect(job.resumable_step("second") { + calls << "second" + 2 + }).to eq(2) + expect { job.__finish_resumable_work! }.not_to raise_error + expect(calls).to eq(%w[first second]) + end + + it "supplies a default cursor and normalizes saved cursors through JSON" do + job = build_job + received = nil + + expect do + job.resumable_step_cursor :items, default: {start: 1} do |cursor| + received = cursor + job.resumable_set_cursor(symbol_key: 2) + raise "retry" + end + end.to raise_error("retry") + + job.__capture_resumable_metadata! + + expect(received).to eq(start: 1) + expect(job.metadata_updates).to eq( + River::RESUMABLE_CURSOR_METADATA_KEY => {"items" => {"symbol_key" => 2}} + ) + end + + %i[resumable_set_cursor resumable_checkpoint].each do |method| + it "rejects unsupported JSON cursors before changing progress in #{method}" do + nested = 150.times.reduce(nil) { |value, _| [value] } + [[Float::NAN, JSON::GeneratorError], [Float::INFINITY, JSON::GeneratorError], + [-Float::INFINITY, JSON::GeneratorError], [nested, JSON::NestingError]].each do |cursor, error| + job = build_job + + expect do + job.resumable_step_cursor :items do + if method == :resumable_checkpoint + job.resumable_checkpoint(cursor: cursor) + else + job.resumable_set_cursor(cursor) + end + end + end.to raise_error(error) + + job.__capture_resumable_metadata! + expect(job.metadata_updates).to eq({}) + expect(job.row.metadata).to eq({}) + end + end + end + + it "skips completed steps when resuming" do + job = build_job(row: build_row(metadata: {River::RESUMABLE_STEP_METADATA_KEY => "download"})) + calls = [] + + job.resumable_step(:prepare) { calls << "prepare" } + job.resumable_step(:download) { calls << "download" } + job.resumable_step(:process) { calls << "process" } + job.__finish_resumable_work! + + expect(calls).to eq(["process"]) + end + + it "resumes a cursor step from its persisted cursor" do + metadata = { + River::RESUMABLE_STEP_METADATA_KEY => "items", + River::RESUMABLE_CURSOR_METADATA_KEY => {"items" => 7} + } + job = build_job(row: build_row(metadata: metadata)) + received = nil + + job.resumable_step("prepare") { raise "must be skipped" } + job.resumable_step_cursor(:items, default: 0) { |cursor| received = cursor } + job.__finish_resumable_work! + + expect(received).to eq(7) + end + + it "removes a completed persisted cursor on the next failed attempt" do + metadata = { + River::RESUMABLE_STEP_METADATA_KEY => "items", + River::RESUMABLE_CURSOR_METADATA_KEY => {"items" => 7} + } + job = build_job(row: build_row(metadata: metadata)) + job.resumable_step_cursor("items") { |_cursor| } + job.__capture_resumable_metadata! + + expect(job.metadata_updates).to include( + River::RESUMABLE_STEP_METADATA_KEY => "items", + River::RESUMABLE_CURSOR_METADATA_KEY => nil + ) + end + + it "captures a completed non-cursor step without cursor metadata" do + job = build_job + job.resumable_step(:done) {} + + job.__capture_resumable_metadata! + + expect(job.metadata_updates).to eq(River::RESUMABLE_STEP_METADATA_KEY => "done") + end + + it "raises a step error immediately and restores step context" do + job = build_job + + expect { job.resumable_step("fails") { raise "step failed" } }.to raise_error(RuntimeError, "step failed") + expect { job.resumable_set_cursor(1) }.to raise_error(River::Error, /inside a resumable step/) + end + + it "does not run later steps after a step error" do + job = build_job + later_ran = false + expect do + job.resumable_step("fails") { raise "step failed" } + job.resumable_step("later") { later_ran = true } + end.to raise_error("step failed") + + expect(later_ran).to be false + end + + it "reports a persisted resume step missing from the worker" do + job = build_job(row: build_row(metadata: {River::RESUMABLE_STEP_METADATA_KEY => "removed"})) + job.resumable_step("current") {} + + expect { job.__finish_resumable_work! } + .to raise_error(River::Error, 'resumable step "removed" not found in worker') + end + + it "reports duplicate step names immediately" do + job = build_job + job.resumable_step("same") {} + expect { job.resumable_step(:same) {} } + .to raise_error(River::Error, 'duplicate resumable step name "same"') + end + + it "rejects an empty step name" do + expect { build_job.resumable_step("") {} } + .to raise_error(ArgumentError, "resumable step name must be non-empty") + end + + it "rejects nested steps without recording an unreachable resume point" do + [:resumable_step, :resumable_step_cursor].each do |method| + job = build_job + expect do + job.resumable_step :outer do + job.public_send(method, :inner) { raise "must not run" } + end + end.to raise_error(River::Error, /cannot be nested/) + job.__capture_resumable_metadata! + expect(job.metadata_updates).to eq({}) + expect { job.resumable_set_cursor(1) }.to raise_error(River::Error, /inside a resumable step/) + end + end + + it "rejects setting a cursor outside a step" do + expect { build_job.resumable_set_cursor(1) } + .to raise_error(River::Error, "resumable cursor can only be set inside a resumable step") + end + + it "rejects persisting outside a step" do + expect { build_job.resumable_checkpoint } + .to raise_error(River::Error, "resumable step can only be persisted inside a resumable step") + end + + it "requires a running job for an immediate checkpoint" do + job = build_job(row: build_row(state: River::JOB_STATE_AVAILABLE)) + captured = nil + job.resumable_step("inside") do + captured = begin + job.resumable_checkpoint + rescue => error + error + end + end + + expect(captured).to be_a(River::Error).and have_attributes(message: "job must be running") + end + + it "persists the current step and optional cursor immediately" do + row = build_row + received = nil + driver = Object.new + driver.define_singleton_method(:job_metadata_merge) do |id, updates| + received = [id, updates] + row.dup.tap { |updated| updated.metadata = row.metadata.merge(updates) } + end + + job = build_job(driver: driver, row: row) + + job.resumable_step_cursor("items") { job.resumable_checkpoint(cursor: {last_id: 42}) } + + expect(received).to eq([ + 123, + { + River::RESUMABLE_STEP_METADATA_KEY => "items", + River::RESUMABLE_CURSOR_METADATA_KEY => {"items" => {"last_id" => 42}} + } + ]) + expect(job.metadata).to include(River::RESUMABLE_STEP_METADATA_KEY => "items") + end + + it "raises when the job disappears during an immediate checkpoint" do + driver = Object.new + driver.define_singleton_method(:job_metadata_merge) { |_id, _updates| nil } + job = build_job(driver: driver) + captured = nil + job.resumable_step("inside") do + captured = begin + job.resumable_checkpoint + rescue => error + error + end + end + + expect(captured).to be_a(River::NotFoundError).and have_attributes(message: "job not found: 123") + end + + it "does not advance progress when the checkpoint write fails" do + driver = Object.new + driver.define_singleton_method(:job_metadata_merge) { |_id, _updates| raise "database unavailable" } + job = build_job(driver: driver) + job.resumable_step(:prepare) {} + + expect do + job.resumable_step_cursor(:items) { job.resumable_checkpoint(cursor: 42) } + end.to raise_error("database unavailable") + job.__capture_resumable_metadata! + + expect(job.metadata_updates).to eq(River::RESUMABLE_STEP_METADATA_KEY => "prepare") + end + + it "clears a previous persisted cursor when checkpointing the next step" do + row = build_row(metadata: { + River::RESUMABLE_STEP_METADATA_KEY => "items", + River::RESUMABLE_CURSOR_METADATA_KEY => {"items" => 42} + }) + driver = Object.new + driver.define_singleton_method(:job_metadata_merge) do |_id, updates| + row.dup.tap { |updated| updated.metadata = row.metadata.merge(updates) } + end + job = build_job(driver: driver, row: row) + job.resumable_step_cursor(:items) { |_cursor| } + + expect do + job.resumable_step(:finish) do + job.resumable_checkpoint + raise "later failure" + end + end.to raise_error("later failure") + job.__capture_resumable_metadata! + + expect(job.metadata).to include(River::RESUMABLE_STEP_METADATA_KEY => "finish", River::RESUMABLE_CURSOR_METADATA_KEY => nil) + expect(job.metadata_updates).to be_empty + end + + it "persists cursor changes made after an explicit checkpoint" do + row = build_row + driver = Object.new + driver.define_singleton_method(:job_metadata_merge) do |_id, updates| + row.dup.tap { |updated| updated.metadata = row.metadata.merge(updates) } + end + job = build_job(driver: driver, row: row) + + expect do + job.resumable_step_cursor(:items) do + job.resumable_checkpoint(cursor: 42) + job.resumable_set_cursor 43 + raise "later failure" + end + end.to raise_error("later failure") + job.__capture_resumable_metadata! + + expect(job.metadata_updates).to eq(River::RESUMABLE_CURSOR_METADATA_KEY => {"items" => 43}) + expect(job.metadata[River::RESUMABLE_STEP_METADATA_KEY]).to eq("items") + end +end diff --git a/ruby/spec/row_decoding_shared_examples.rb b/ruby/spec/row_decoding_shared_examples.rb new file mode 100644 index 000000000..dd29e89d8 --- /dev/null +++ b/ruby/spec/row_decoding_shared_examples.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +RSpec.shared_examples "historical attempt error decoding" do + it "isolates non-object metadata across reads, unique inserts, claims, and finalization" do + rows = [[], "invalid metadata", 7, true].map.with_index do |metadata, index| + args = River::JobArgsHash.new(:decode, {index: index}) + options = {max_attempts: 1, unique_opts: River::UniqueOpts.new(by_args: true)} + row = client.insert(args, **options).job + encoded = driver.send(:runtime_json, JSON.generate(metadata)) + driver.send(:runtime_execute, "UPDATE river_job SET metadata = #{encoded} WHERE id = #{row.id}") + expect { client.job_get(row.id) }.to raise_error(River::JobRowDecodeError, /metadata/) + expect { client.job_list(ids: [row.id]) }.to raise_error(River::JobRowDecodeError, /metadata/) + expect { client.insert(args, **options) }.to raise_error(River::JobRowDecodeError, /metadata/) + row + end + healthy = client.insert(River::JobArgsHash.new(:decode, {})).job + + claimed = driver.job_get_available(queue: "default", max: 10, attempted_by: "test") + expect(claimed.map(&:id)).to match_array([*rows.map(&:id), healthy.id]) + expect(claimed.reject(&:__decode_error).map(&:id)).to eq([healthy.id]) + claimed.select(&:__decode_error).each do |row| + client.__finish_claimed_job(row, row.__decode_error) + failed = driver.send(:runtime_read_job, row.id) + expect(failed).to have_attributes(state: "discarded", errors: contain_exactly(have_attributes(error: include("metadata")))) + end + end + + it "reads, fetches, and returns duplicate jobs with unusually shaped error entries" do + args = River::JobArgsHash.new(:decode, {}) + options = {unique_opts: River::UniqueOpts.new(by_args: true)} + row = client.insert(args, **options).job + errors = [{"at" => "unreadable timestamp", "attempt" => "2", "error" => {"message" => "failed"}, "trace" => ["frame"]}, "legacy", nil, '{"error":"literal JSON text"}'] + encoded = if driver.send(:runtime_postgres?) + "ARRAY[#{errors.map { |error| driver.send(:runtime_json, JSON.generate(error)) }.join(",")}]::jsonb[]" + else + driver.send(:runtime_json, errors) + end + driver.send(:runtime_execute, "UPDATE river_job SET errors = #{encoded} WHERE id = #{row.id}") + + read = client.job_get(row.id) + expect(read.errors.map(&:error)).to eq(['{"message":"failed"}', "legacy", "", '{"error":"literal JSON text"}']) + expect(read.errors.first).to have_attributes(at: Time.utc(1), attempt: 2, trace: '["frame"]') + expect(client.job_list.jobs.first.errors.map(&:to_h)).to eq(read.errors.map(&:to_h)) + expect(client.insert(args, **options).job.errors.map(&:to_h)).to eq(read.errors.map(&:to_h)) + expect(driver.job_get_available(queue: "default", max: 10, attempted_by: "test").first.errors.map(&:to_h)).to eq(read.errors.map(&:to_h)) + end +end + +RSpec.shared_examples "SQLite corrupt job runtime" do + it "fails only undecodable attempts before hooks and works healthy jobs in the same batch" do + worked, hooks, handled, retry_hooks = Queue.new, Queue.new, Queue.new, Queue.new + worker = Object.new + worker.define_singleton_method(:work) { |job| worked << job.id } + worker.define_singleton_method(:next_retry) { |*| + retry_hooks << true + Time.now.utc + 60 + } + plugin = Object.new + plugin.define_singleton_method(:work_begin) { |job| hooks << job.id } + policy = Object.new + policy.define_singleton_method(:next_retry) { |*, now:| now + 60 } + client = River::Client.new(@driver, config: River::Config.new( + queues: {default: 20}, workers: River::Workers.new.add(:decode, worker), plugins: [plugin], + fetch_cooldown: 0.001, fetch_poll_interval: 0.005, leader_election_disabled: true, + retry_policy: policy, error_handler: ->(error, job) { handled << [job.id, error] } + )) + args = River::JobArgsHash.new(:decode, {}) + first = client.insert(args).job + corrupt_values = %w[args metadata attempted_by errors tags].product(["'{'", "X'1BFF'"]) + bad = corrupt_values.map.with_index do |(field, sql), index| + row = client.insert(args, max_attempts: index.even? ? 1 : 2).job + @driver.send(:runtime_execute, "UPDATE river_job SET #{field} = #{sql} WHERE id = #{row.id}") + row + end + last = client.insert(args).job + events = client.subscribe(:job_completed, :job_failed) + client.start + received = Timeout.timeout(5) { (bad.length + 2).times.map { events.pop } } + client.stop + + expect(received.map { |event| event.job.id }).to match_array([first.id, *bad.map(&:id), last.id]) + expect([worked.pop(true), worked.pop(true)]).to match_array([first.id, last.id]) + expect(worked).to be_empty + expect([hooks.pop(true), hooks.pop(true)]).to match_array([first.id, last.id]) + expect(hooks).to be_empty + expect(retry_hooks).to be_empty + failures = received.select { |event| event.kind == :job_failed } + expect(failures.length).to eq(bad.length) + expect(failures.map { |event| event.job.state }).to match_array(["discarded", "retryable"] * 5) + expect(failures.map { |event| event.job.errors.last.error }).to all(include("couldn't be decoded")) + expect(bad.length.times.map { handled.pop(true).last }).to all(be_a(River::JobRowDecodeError)) + expect(handled).to be_empty + ensure + client&.stop_and_cancel + events&.close + end +end + +RSpec.shared_examples "SQLite corrupt job isolation" do + def decoding_insert(**options) + client.insert(River::JobArgsHash.new(:decode, {}), **options).job + end + + def corrupt(row, field, sql) + driver.send(:runtime_execute, "UPDATE river_job SET #{field} = #{sql} WHERE id = #{row.id}") + end + + it "claims healthy jobs on both sides of malformed rows without repairing bad values" do + first = decoding_insert + bad = [[:args, "'{'"], [:metadata, "'{'"], [:tags, "'{'"], [:tags, "jsonb('[1]')"], + [:attempted_by, "'{'"], [:attempted_by, "jsonb('{}')"], [:errors, "'{'"], [:errors, "jsonb('{}')"], + [:args, "X'FF'"], *%i[args metadata attempted_by errors tags].map { |field| [field, "X'1BFF'"] }, + [:attempted_by, driver.send(:runtime_json, Array.new(100, "worker") + [{"unexpected" => true}])]].map do |field, sql| + decoding_insert.tap { |row| corrupt(row, field, sql) } + end + last = decoding_insert + bad.each { |row| expect { client.job_get(row.id) }.to raise_error(River::JobRowDecodeError) } + + rows = driver.job_get_available(queue: "default", max: 100, attempted_by: "test") + expect(rows.map(&:id)).to match_array([first.id, *bad.map(&:id), last.id]) + expect(rows).to all(have_attributes(state: "running", attempt: 1)) + expect(rows.reject(&:__decode_error).map(&:id)).to eq([first.id, last.id]) + expect(rows.select(&:__decode_error).map(&:id)).to eq(bad.map(&:id)) + expect(driver.send(:runtime_query_rows, "SELECT attempted_by FROM river_job WHERE id = #{bad[4].id}").first.values).to eq(["{"]) + end + + ["jsonb('{}')", "jsonb('\"old\"')", "'{'", "X'1BFF'", "NULL"].each do |old_errors| + it "preserves #{old_errors} when appending a failure and rescuing a stuck job" do + now = Time.now.utc + row = decoding_insert(scheduled_at: now - 120) + corrupt(row, :errors, old_errors) + corrupt(row, :metadata, "'{'") + driver.job_claim(id: row.id, attempted_by: "test", now: now - 120) + + expect(driver.job_rescue_stuck(horizon: now - 60, now: now, retry_policy: River::DefaultClientRetryPolicy.new)).to eq(1) + partial = driver.send(:runtime_read_job, row.id) + expect(partial).to have_attributes(state: "retryable", __decode_error: be_a(River::JobRowDecodeError)) + expect(partial.errors.last.error).to eq("Stuck job rescued by River") + expect(partial.errors.length).to eq((old_errors == "NULL") ? 1 : 2) + expect(partial.errors.first.error).to eq("invalid JSONB: 1BFF") if old_errors == "X'1BFF'" + expect(driver.send(:runtime_query_rows, "SELECT metadata FROM river_job WHERE id = #{row.id}").first.values).to eq(["{"]) + end + end + + it "schedules malformed jobs and preserves malformed metadata on uniqueness conflicts" do + now = Time.now.utc + args = River::JobArgsHash.new(:unique_decode, {}) + unique = River::UniqueOpts.new(by_args: true, by_state: %w[available pending running scheduled]) + collision = client.insert(args, unique_opts: unique, scheduled_at: now - 60).job + client.job_update(collision.id, state: :retryable) + original = client.insert(args, unique_opts: unique).job + [original, collision].each { |row| corrupt(row, :metadata, "'{'") } + bad = decoding_insert(state: :scheduled, scheduled_at: now - 60) + %i[args metadata tags attempted_by errors].each { |field| corrupt(bad, field, "'{'") } + healthy = decoding_insert(state: :scheduled, scheduled_at: now - 60) + + expect(driver.job_schedule(now: now)).to eq(3) + expect(driver.send(:runtime_read_job, collision.id).state).to eq("discarded") + expect(driver.send(:runtime_read_job, bad.id).state).to eq("available") + expect(client.job_get(healthy.id).state).to eq("available") + expect(driver.send(:runtime_query_rows, "SELECT metadata FROM river_job WHERE id = #{collision.id}").first.values).to eq(["{"]) + end + + it "finds other jobs' cancellation requests when a running job's metadata is corrupt" do + first, second = 2.times.map { decoding_insert } + driver.job_get_available(queue: "default", max: 2, attempted_by: "test") + client.job_cancel(second.id) + ["'{'", "X'1BFF'"].each do |sql| + corrupt(first, :metadata, sql) + expect(driver.job_get_cancelled_ids([first.id, second.id])).to eq([second.id]) + end + end +end diff --git a/ruby/spec/row_decoding_spec.rb b/ruby/spec/row_decoding_spec.rb new file mode 100644 index 000000000..cfa9730ef --- /dev/null +++ b/ruby/spec/row_decoding_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" +require_relative "row_decoding_shared_examples" + +RSpec.describe "SQLite row decoding", database: :sqlite do + around { |example| sqlite_test_transaction(&example) } + let(:driver) { River::Driver::Sequel.new(SQLITE_DB) } + let(:client) { River::Client.new(driver) } + + it_behaves_like "historical attempt error decoding" + it_behaves_like "SQLite corrupt job isolation" +end diff --git a/ruby/spec/runtime_draining_shared_examples.rb b/ruby/spec/runtime_draining_shared_examples.rb new file mode 100644 index 000000000..879b6b742 --- /dev/null +++ b/ruby/spec/runtime_draining_shared_examples.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "stringio" +require "timeout" + +RSpec.shared_examples "cancellation while draining" do + it "allows a worker to request nonblocking stop and finish normally" do + worker = Object.new + worker.define_singleton_method(:work) { |job| job.client.stop(wait: false) } + client = River::Client.new(@driver, config: River::Config.new( + fetch_cooldown: 0.001, fetch_poll_interval: 0.01, job_timeout: nil, + leader_election_disabled: true, logger: Logger.new(StringIO.new), + queues: {drain: 1}, workers: River::Workers.new.add("drain", worker) + )) + subscription = client.subscribe(:job_completed) + job = client.insert(River::JobArgsHash.new("drain", {}), queue: :drain).job + client.start + + expect(Timeout.timeout(3) { subscription.pop }).to have_attributes(job: have_attributes(id: job.id, state: "completed")) + expect(Timeout.timeout(3) { client.stop }).to equal(client) + expect(client).to be_stopped + ensure + client&.stop_and_cancel + subscription&.close + end + + %i[queue_remove stop stop_and_cancel].each do |operation| + it "rejects a blocking #{operation} from its own worker without stopping the queue" do + errors = Queue.new + worker = Object.new + worker.define_singleton_method(:work) do |job| + if operation == :queue_remove + job.client.queue_remove(:drain) + else + job.client.public_send(operation) + end + rescue ThreadError => error + errors << error + end + client = River::Client.new(@driver, config: River::Config.new( + fetch_cooldown: 0.001, fetch_poll_interval: 0.01, job_timeout: nil, + leader_election_disabled: true, logger: Logger.new(StringIO.new), + queues: {drain: 1}, workers: River::Workers.new.add("drain", worker) + )) + subscription = client.subscribe(:job_completed) + client.start + + 2.times do + job = client.insert(River::JobArgsHash.new("drain", {}), queue: :drain).job + error = Timeout.timeout(3) { errors.pop } + + expect(error.message).to include((operation == :queue_remove) ? "own worker" : "wait: false") + expect(Timeout.timeout(3) { subscription.pop }).to have_attributes(job: have_attributes(id: job.id, state: "completed")) + expect(client).to be_started + end + ensure + client&.stop_and_cancel + subscription&.close + end + end + + %i[queue_remove stop stop_without_wait].product([false, true]).each do |operation, poll_failure| + it "observes remote cancellation after #{operation} begins draining with polling failure=#{poll_failure}" do + draining = Queue.new + entered = Queue.new + release = Queue.new + worker = Object.new + worker.define_singleton_method(:work) do |job| + entered << job.id + release.pop + end + client = River::Client.new(@driver, config: River::Config.new( + fetch_cooldown: 0.001, fetch_poll_interval: 0.01, job_timeout: nil, + leader_election_disabled: true, logger: Logger.new(StringIO.new), + queues: {drain: 1}, workers: River::Workers.new.add("drain", worker) + )) + runtime = client.instance_variable_get(:@runtime) + signalled = false + failures = 0 + @driver.define_singleton_method(:job_get_cancelled_ids) do |ids| + if poll_failure && signalled && failures.zero? + failures += 1 + raise "temporary cancellation query failure" + end + super(ids) + end + runtime.define_singleton_method(:queue_stopping?) do |queue| + stopping = super(queue) + if stopping && !signalled + signalled = true + draining << true + end + stopping + end + subscription = client.subscribe(:job_cancelled, :job_completed) + job = client.insert(River::JobArgsHash.new("drain", {}), queue: :drain).job + client.start + expect(Timeout.timeout(5) { entered.pop }).to eq(job.id) + waiting = client.insert(River::JobArgsHash.new("drain", {}), queue: :drain).job + + stopper = case operation + when :queue_remove then Thread.new { client.queue_remove(:drain) } + when :stop then Thread.new { client.stop } + else + expect(client.stop(wait: false)).to equal(client) + nil + end + Timeout.timeout(5) { draining.pop } + + # Persist through another client so only database polling can deliver it. + River::Client.new(@driver).job_cancel(job.id) + event = Timeout.timeout(3) { subscription.pop } + + expect(event).to have_attributes(kind: :job_cancelled, job: have_attributes(id: job.id, state: "cancelled")) + expect(Timeout.timeout(3) { stopper.value }).to equal(client) if stopper + expect(client.job_get(waiting.id)).to have_attributes(attempt: 0, state: "available") + expect(entered).to be_empty + expect(failures).to eq(poll_failure ? 1 : 0) + expect(client).to be_started if operation == :queue_remove + ensure + release&.close + client&.stop_and_cancel + stopper&.join + subscription&.close + end + end +end diff --git a/ruby/spec/runtime_feature_spec.rb b/ruby/spec/runtime_feature_spec.rb new file mode 100644 index 000000000..80f23c6e7 --- /dev/null +++ b/ruby/spec/runtime_feature_spec.rb @@ -0,0 +1,706 @@ +# frozen_string_literal: true + +require "spec_helper" +require "riverqueue-sequel" +require "stringio" +require_relative "support/river_sqlite_schema_fixture" + +RUNTIME_FEATURE_DB = if RiverTestDatabase.enabled?(:sqlite) + Sequel.sqlite.tap do |database| + database.synchronize { |connection| RiverSQLiteSchemaFixture.load(connection) } + end +end + +class RuntimeFeatureArgs < River::JobArgsHash + def initialize(value = 1, kind: "runtime_feature") + super(kind, {"value" => value}) + end +end + +RSpec.describe "River worker execution features", database: :sqlite do + before do + @clients = [] + RUNTIME_FEATURE_DB[:river_notification].delete + RUNTIME_FEATURE_DB[:river_queue].delete + RUNTIME_FEATURE_DB[:river_leader].delete + RUNTIME_FEATURE_DB[:river_job].delete + end + + after do + @clients.reverse_each(&:stop_and_cancel) + end + + def wait_until(timeout: 3) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + result = yield + return result if result + raise "timed out waiting for condition" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + + sleep(0.005) + end + end + + def build_client(worker = nil, workers: nil, queues: {"runtime" => 2}, **overrides) + workers ||= River::Workers.new.tap { |registry| registry.add("runtime_feature", worker) if worker } + config = River::Config.new( + id: "runtime-feature-client", + fetch_cooldown: 0.001, + fetch_poll_interval: 0.005, + queues: queues, + workers: workers, + **overrides + ) + River::Client.new(River::Driver::Sequel.new(RUNTIME_FEATURE_DB), config: config).tap { |client| @clients << client } + end + + def insert(client, value = 1, **options) + client.insert( + RuntimeFeatureArgs.new(value), + insert_opts: River::InsertOpts.new(queue: "runtime", **options) + ).job + end + + def event_from(subscription) + wait_until do + subscription.pop(true) + rescue ThreadError + nil + end + end + + it "starts and stops cleanly without configured queues" do + client = build_client(queues: {}) + + expect(client.start).to equal(client) + expect(client).to be_started + expect(client.stop).to equal(client) + expect(client).to be_stopped + end + + it "does not stop an already running client when start is called twice" do + client = build_client(Class.new { def work(_job) = nil }) + client.start + + expect { client.start }.to raise_error(River::ClientAlreadyStartedError) + expect(client).to be_started + job = insert(client) + wait_until { client.job_get(job.id).state == River::JOB_STATE_COMPLETED } + end + + it "makes stop idempotent before and after a run" do + client = build_client(queues: {}) + + expect(client.stop).to equal(client) + expect(client.start.stop.stop).to equal(client) + end + + it "restores stopped state when startup fails" do + driver = Object.new + driver.define_singleton_method(:queue_upsert) { |_name| raise "cannot create queue" } + driver.define_singleton_method(:leader_release) { |_id| } + config = River::Config.new(queues: {runtime: 1}) + client = River::Client.new(driver, config: config) + + expect { client.start }.to raise_error(RuntimeError, "cannot create queue") + expect(client).to be_stopped + expect(client).not_to be_started + end + + it "waits for active work during a graceful stop" do + entered = Queue.new + release = Queue.new + worker = Object.new + worker.define_singleton_method(:work) do |_job| + entered << true + release.pop + end + + client = build_client(worker) + job = insert(client) + client.start + entered.pop + + stopper = Thread.new { client.stop } + sleep(0.02) + + expect(stopper).to be_alive + release << true + stopper.join + + expect(client.job_get(job.id)).to have_attributes(state: River::JOB_STATE_COMPLETED) + ensure + release << true if release && release.empty? + + stopper&.join + end + + it "instantiates a worker class separately for each job" do + instances = Queue.new + worker_class = Class.new do + define_method(:initialize) { instances << object_id } + def work(_job) + end + end + + client = build_client(worker_class) + jobs = [insert(client, 1), insert(client, 2)] + client.start + wait_until { jobs.all? { |job| client.job_get(job.id).state == River::JOB_STATE_COMPLETED } } + + expect([instances.pop, instances.pop].uniq.length).to eq(2) + end + + it "never exceeds a queue's configured worker concurrency" do + lock = Mutex.new + current = 0 + maximum = 0 + worker = Object.new + worker.define_singleton_method(:work) do |_job| + lock.synchronize do + current += 1 + maximum = [maximum, current].max + end + + sleep(0.03) + ensure + lock.synchronize { current -= 1 } + end + + client = build_client(worker, queues: {"runtime" => 2}) + jobs = 5.times.map { |index| insert(client, index) } + client.start + wait_until { jobs.all? { |job| client.job_get(job.id).state == River::JOB_STATE_COMPLETED } } + + expect(maximum).to eq(2) + end + + it "discards an unknown job kind with an explanatory attempt error" do + client = build_client(workers: River::Workers.new) + job = client.insert( + RuntimeFeatureArgs.new(kind: "missing"), + insert_opts: River::InsertOpts.new(max_attempts: 1, queue: "runtime") + ).job + client.start + + discarded = wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_DISCARDED && row } + + expect(discarded.errors.last.error).to eq("unknown job kind: missing") + end + + it "treats JobCancelError raised by a worker as final cancellation" do + worker = Class.new { def work(_job) = raise(River.job_cancel("worker cancelled")) } + client = build_client(worker) + subscription = client.subscribe(River::EVENT_JOB_CANCELLED) + job = insert(client) + client.start + + cancelled = wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_CANCELLED && row } + + expect(cancelled).to have_attributes(errors: contain_exactly(have_attributes(error: "worker cancelled")), finalized_at: be_a(Time)) + expect(event_from(subscription)).to have_attributes(job: have_attributes(id: job.id), kind: River::EVENT_JOB_CANCELLED) + end + + it "schedules long snoozes and publishes a snoozed event" do + worker = Class.new { def work(_job) = raise(River.job_snooze(10)) } + client = build_client(worker) + subscription = client.subscribe(River::EVENT_JOB_SNOOZED) + job = insert(client) + client.start + + snoozed = wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_SCHEDULED && row } + + expect(snoozed).to have_attributes(attempt: 0, metadata: include("snoozes" => 1)) + expect(snoozed.scheduled_at).to be > Time.now.utc + 8 + expect(event_from(subscription).kind).to eq(River::EVENT_JOB_SNOOZED) + end + + it "allows a worker-specific nil timeout to disable the client timeout" do + worker = Class.new do + def timeout(_job) = nil + def work(_job) = sleep(0.03) + end + + client = build_client(worker, job_timeout: 0.005) + job = insert(client) + client.start + + expect(wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_COMPLETED && row }).to be_a(River::JobRow) + end + + it "uses the client timeout when a worker returns zero" do + worker = Class.new do + def timeout(_job) = 0 + def work(_job) = sleep(1) + end + + client = build_client(worker, job_timeout: 0.005) + job = insert(client, max_attempts: 1) + client.start + + discarded = wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_DISCARDED && row } + + expect(discarded.errors.last.error).to match(/execution expired/) + end + + [:worker_retry, :worker_next_retry, :policy_next_retry, :invalid_retry_time].each do |failure| + it "persists the original work error when #{failure} fails" do + log = StringIO.new + worker = Object.new + worker.define_singleton_method(:work) { |_job| raise "work failed" } + policy = River::DefaultClientRetryPolicy.new + case failure + when :worker_retry + worker.define_singleton_method(:retry?) { |_job, _error| raise "retry hook failed" } + when :worker_next_retry + worker.define_singleton_method(:next_retry) { |_job, _error| raise "retry hook failed" } + when :policy_next_retry + policy.define_singleton_method(:next_retry) { |_job, _error, now:| raise "retry hook failed" } + when :invalid_retry_time + worker.define_singleton_method(:next_retry) { |_job, _error| "tomorrow" } + end + client = build_client(worker, logger: Logger.new(log), retry_policy: policy) + job = insert(client) + subscription = client.subscribe(River::EVENT_JOB_FAILED) + started_at = Time.now.utc + + result = client.__perform_job(job.id) + + expect(result[0]).to have_attributes(state: "available", errors: contain_exactly(have_attributes(error: "work failed"))) + expect(result[0].scheduled_at).to be_between(started_at + 0.8, Time.now.utc + 1.2) + expect(result[2]).to eq(:retried) + expect(subscription.pop(true)).to have_attributes(kind: River::EVENT_JOB_FAILED) + expect(log.string).to include("River", "retry") + end + end + + [:retry, :snooze, :interrupt].each do |transition| + it "reports cancellation when it wins a race with #{transition}" do + worker = Object.new + worker.define_singleton_method(:work) do |job| + job.client.job_cancel(job.id) + case transition + when :retry then raise "retry" + when :snooze then raise River.job_snooze(60) + when :interrupt then raise River::ClientRuntime::Interrupted + end + end + client = build_client(worker) + job = insert(client) + subscription = client.subscribe(River::EVENT_JOB_CANCELLED, River::EVENT_JOB_FAILED, River::EVENT_JOB_SNOOZED, River::EVENT_JOB_INTERRUPTED) + + result = client.__perform_job(job.id) + + expect(result[0].state).to eq("cancelled") + expect(result[2]).to eq(:cancelled) + expect(subscription.pop(true)).to have_attributes(kind: River::EVENT_JOB_CANCELLED, job: have_attributes(state: "cancelled")) + end + end + + it "uses a worker-specific future retry time" do + retry_at = Time.now.utc + 60 + worker = Object.new + worker.define_singleton_method(:work) { |_job| raise "retry later" } + worker.define_singleton_method(:next_retry) { |_job, _error| retry_at } + client = build_client(worker) + job = insert(client) + client.start + + retryable = wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_RETRYABLE && row } + + expect(retryable.scheduled_at).to be_within(0.001).of(retry_at) + expect(retryable.errors.last.error).to eq("retry later") + end + + it "uses the executing worker instance to calculate its retry time" do + worker = Class.new do + attr_reader :retry_at + + def work(_job) + @retry_at = Time.now.utc + 60 + raise "retry later" + end + + def next_retry(_job, _error) + raise "wrong instance" unless @retry_at + + @retry_at + end + end + + client = build_client(worker) + job = insert(client) + client.start + + retryable = wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_RETRYABLE && row } + + expect(retryable.scheduled_at).to be > Time.now.utc + 50 + expect(retryable.errors.last.error).to eq("retry later") + end + + it "falls back to default backoff when custom retry time is in the past" do + worker = Object.new + worker.define_singleton_method(:next_retry) { |_job, _error| Time.at(0) } + client = build_client(worker, queues: {}) + inserted = client.insert(RuntimeFeatureArgs.new, insert_opts: River::InsertOpts.new(max_attempts: 2)).job + running = client.driver.job_get_available(attempted_by: client.id, max: 1, queue: "default").first + before = Time.now.utc + + client.__finish_claimed_job(running, RuntimeError.new("retry")) + updated = client.job_get(inserted.id) + + expect(updated).to have_attributes(errors: contain_exactly(have_attributes(error: "retry")), state: River::JOB_STATE_AVAILABLE) + expect(updated.scheduled_at).to be_between(before + 0.8, Time.now.utc + 1.2) + end + + it "supports an error-handler object that cancels a job" do + handled = nil + handler = Object.new + handler.define_singleton_method(:handle_error) do |error, job| + handled = [error, job] + true + end + + client = build_client(Object.new, error_handler: handler, queues: {}) + inserted = client.insert(RuntimeFeatureArgs.new).job + running = client.driver.job_get_available(attempted_by: client.id, max: 1, queue: "default").first + error = RuntimeError.new("cancel") + + client.__finish_claimed_job(running, error) + + expect(client.job_get(inserted.id)).to have_attributes(state: River::JOB_STATE_CANCELLED) + expect(handled).to have_attributes( + first: equal(error), + last: be_a(River::Job) + ) + end + + it "logs an error-handler failure and continues normal retry handling" do + output = StringIO.new + logger = Logger.new(output) + handler = ->(_error, _job) { raise "handler failed" } + client = build_client(Object.new, error_handler: handler, logger: logger, queues: {}) + inserted = client.insert(RuntimeFeatureArgs.new, insert_opts: River::InsertOpts.new(max_attempts: 2)).job + running = client.driver.job_get_available(attempted_by: client.id, max: 1, queue: "default").first + + client.__finish_claimed_job(running, RuntimeError.new("work failed")) + + expect(client.job_get(inserted.id)).to have_attributes(state: River::JOB_STATE_AVAILABLE) + expect(output.string).to include("River error handler failed", "handler failed") + end + + it "runs plugin work middleware outside-in around worker execution" do + calls = [] + worker = Object.new + worker.define_singleton_method(:work) { |_job| calls << :work } + first = Object.new + first.define_singleton_method(:work) do |_job, operation| + calls << :first_before + operation.call + calls << :first_after + end + + second = Object.new + second.define_singleton_method(:work) do |_job, operation| + calls << :second_before + operation.call + calls << :second_after + end + + client = build_client(worker, plugins: [first, second]) + job = insert(client) + client.start + wait_until { client.job_get(job.id).state == River::JOB_STATE_COMPLETED } + + expect(calls).to eq([:first_before, :second_before, :work, :second_after, :first_after]) + end + + it "runs plugin work callbacks in registration order" do + calls = [] + plugin_one = Object.new + plugin_one.define_singleton_method(:work_begin) { |_job| calls << :one_begin } + plugin_one.define_singleton_method(:work_end) { |_job, _error| calls << :one_end } + plugin_two = Object.new + plugin_two.define_singleton_method(:work_begin) { |_job| calls << :two_begin } + plugin_two.define_singleton_method(:work_end) { |_job, _error| calls << :two_end } + client = build_client(Class.new { + def work(_job) + end + }, plugins: [plugin_one, plugin_two]) + job = insert(client) + client.start + wait_until { client.job_get(job.id).state == River::JOB_STATE_COMPLETED } + + expect(calls).to eq([:one_begin, :two_begin, :one_end, :two_end]) + end + + it "allows a finalize plugin to delete ephemeral jobs" do + plugin = Object.new + plugin.define_singleton_method(:job_finalize) { |_job, _state| :delete } + client = build_client(Class.new { + def work(_job) + end + }, plugins: [plugin]) + job = insert(client) + client.start + + wait_until { client.driver.job_get_by_id(job.id).nil? } + + expect { client.job_get(job.id) }.to raise_error(River::NotFoundError) + end + + it "completes jobs after non-deleting finalization hooks" do + calls = [] + plugin = Object.new + plugin.define_singleton_method(:job_finalize) { |_job, state| calls << state } + client = build_client(Class.new { def work(_job) = nil }, plugins: [plugin]) + row = insert(client) + client.start + wait_until { client.job_get(row.id).state == "completed" } + expect(calls).to eq(["completed"]) + end + + it "checks cancellation before invoking finalization hooks" do + calls = [] + plugin = Object.new + plugin.define_singleton_method(:job_finalize) { |_job, state| calls << state } + worker = Class.new + client = build_client(worker, plugins: [plugin]) + row = insert(client) + worker.define_method(:work) { |_job| client.job_cancel(row.id) } + client.start + wait_until { client.job_get(row.id).state == "cancelled" } + expect(calls).to be_empty + end + + it "completes an externally claimed job through the extension boundary" do + client = build_client(queues: {}) + inserted = client.insert(RuntimeFeatureArgs.new).job + running = client.driver.job_get_available(attempted_by: client.id, max: 1, queue: "default").first + subscription = client.subscribe(River::EVENT_JOB_COMPLETED) + + client.__finish_claimed_job(running) + + expect(client.job_get(inserted.id)).to have_attributes(state: River::JOB_STATE_COMPLETED) + expect(subscription.pop(true).kind).to eq(River::EVENT_JOB_COMPLETED) + end +end + +RSpec.describe "River dynamic queues and maintenance features", database: :sqlite do + before do + @clients = [] + RUNTIME_FEATURE_DB[:river_notification].delete + RUNTIME_FEATURE_DB[:river_queue].delete + RUNTIME_FEATURE_DB[:river_leader].delete + RUNTIME_FEATURE_DB[:river_job].delete + end + + after do + @clients.reverse_each(&:stop_and_cancel) + end + + def wait_until(timeout: 3) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + result = yield + return result if result + raise "timed out waiting for condition" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + + sleep(0.005) + end + end + + def build_client(worker, queues: {}, **overrides) + config = River::Config.new( + id: "runtime-maintenance-client", + fetch_cooldown: 0.001, + fetch_poll_interval: 0.005, + queues: queues, + workers: River::Workers.new.add("runtime_feature", worker), + **overrides + ) + River::Client.new(River::Driver::Sequel.new(RUNTIME_FEATURE_DB), config: config).tap { |client| @clients << client } + end + + it "processes work from a queue added before startup" do + client = build_client(Class.new { + def work(_job) + end + }) + + expect(client.queue_add("dynamic", 1)).to equal(client) + job = client.insert(RuntimeFeatureArgs.new, insert_opts: River::InsertOpts.new(queue: "dynamic")).job + + client.start + + expect(wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_COMPLETED && row }).to be_a(River::JobRow) + end + + it "validates dynamic queue addition and removal" do + client = build_client(Object.new) + client.queue_add("dynamic", River::QueueConfig.new(max_workers: 1)) + + expect { client.queue_add("dynamic", 1) }.to raise_error(ArgumentError, "queue is already configured: dynamic") + expect { client.queue_add("not valid", 1) }.to raise_error(ArgumentError, /invalid queue name/) + expect(client.queue_remove("dynamic")).to equal(client) + expect { client.queue_remove("dynamic") }.to raise_error(River::NotFoundError, "queue is not configured: dynamic") + end + + it "does not work jobs while their queue is paused" do + client = build_client(Class.new { + def work(_job) + end + }, queues: {"runtime" => 1}) + client.driver.queue_upsert("runtime") + client.queue_pause("runtime") + job = client.insert(RuntimeFeatureArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + sleep(0.03) + + expect(client.job_get(job.id)).to have_attributes(state: River::JOB_STATE_AVAILABLE) + client.queue_resume("runtime") + + expect(wait_until { (row = client.job_get(job.id)).state == River::JOB_STATE_COMPLETED && row }).to be_a(River::JobRow) + end + + it "runs custom maintenance services while holding leadership" do + calls = Queue.new + service = Object.new + service.define_singleton_method(:run) { |client, driver, now| calls << [client, driver, now] } + client = build_client(Object.new, maintenance_services: [service]) + + client.start + invocation = wait_until { + begin + calls.pop(true) + rescue + nil + end + } + + expect(invocation[0]).to equal(client) + expect(invocation[1]).to equal(client.driver) + expect(invocation[2]).to be_a(Time) + end + + [:configured, :added_before_start, :added_after_start].each do |registration| + it "produces #{registration} periodic jobs without consuming any queues" do + periodic = River::PeriodicJob.new( + id: "producer-only", run_on_start: true, schedule: River::PeriodicInterval.new(60) + ) { RuntimeFeatureArgs.new } + client = build_client(Object.new, periodic_jobs: (registration == :configured) ? [periodic] : []) + client.periodic_jobs.add(periodic) if registration == :added_before_start + client.start + client.periodic_jobs.add(periodic) if registration == :added_after_start + + inserted = wait_until { client.job_list.jobs.first } + + expect(inserted).to have_attributes(state: "available", attempt: 0, metadata: include("river:periodic_job_id" => "producer-only")) + expect(client.queue_list.queues).to be_empty + client.stop + expect(client).to be_stopped + end + end + + it "respects client and worker timeouts during stuck-job rescue" do + worker = Class.new do + def timeout(job) + job.args.fetch("timeout") + end + end + output = StringIO.new + client = build_client(worker, job_timeout: 10_800, logger: Logger.new(output), queues: {"unused" => 1}) + now = Time.now.utc + jobs = [0, nil, 10_800, 60, "invalid"].map do |timeout| + row = client.insert(River::JobArgsHash.new("runtime_feature", {"timeout" => timeout}), scheduled_at: now - 7_200).job + client.driver.job_claim(id: row.id, attempted_by: "worker", now: now - 7_200) + end + cancelled = client.insert(River::JobArgsHash.new("runtime_feature", {"timeout" => nil}), scheduled_at: now - 7_200).job + client.driver.job_claim(id: cancelled.id, attempted_by: "worker", now: now - 7_200) + client.job_cancel(cancelled.id) + corrupt = client.insert(RuntimeFeatureArgs.new, scheduled_at: now - 7_200).job + client.driver.send(:runtime_execute, "UPDATE river_job SET args = '{' WHERE id = #{corrupt.id}") + client.driver.job_claim(id: corrupt.id, attempted_by: "worker", now: now - 7_200) + cleaned = Queue.new + client.driver.define_singleton_method(:job_delete_finalized) do |**options| + super(**options).tap { cleaned << true } + end + + client.start + Timeout.timeout(3) { cleaned.pop } + + expect(jobs.map { |job| client.job_get(job.id).state }).to eq(%w[running running running retryable retryable]) + expect(client.job_get(cancelled.id).state).to eq("cancelled") + expect(client.driver.send(:runtime_read_job, corrupt.id).state).to eq("retryable") + expect(output.string).to include("River rescue timeout check failed") + end + + it "continues rescue and cleanup after a retry policy fails" do + output = StringIO.new + policy = Object.new + policy.define_singleton_method(:next_retry) { |*args, **options| raise "broken rescue policy" } + client = build_client(Object.new, logger: Logger.new(output), queues: {"runtime" => 1}, retry_policy: policy) + now = Time.now.utc + stuck = client.insert(RuntimeFeatureArgs.new, scheduled_at: now - 7_200).job + client.driver.job_claim(id: stuck.id, attempted_by: "old-worker", now: now - 7_200) + expired = client.insert(RuntimeFeatureArgs.new).job + client.job_update(expired.id, state: "completed", finalized_at: now - 172_800) + + client.start + wait_until { client.driver.job_get_by_id(expired.id).nil? } + + expect(client.job_get(stuck.id)).to have_attributes(state: "retryable", scheduled_at: be > now) + expect(output.string).to include("River rescue retry scheduling failed", "broken rescue policy") + end + + it "inserts a periodic job whose constructor returns bare arguments" do + periodic = River::PeriodicJob.new( + id: "bare", + run_on_start: true, + schedule: River::PeriodicInterval.new(60) + ) { RuntimeFeatureArgs.new } + client = build_client( + Class.new { + def work(_job) + end + }, + periodic_jobs: [periodic], + queues: {"default" => 1} + ) + client.start + + completed = wait_until do + client.job_list(River::JobListParams.new(kinds: ["runtime_feature"], states: [River::JOB_STATE_COMPLETED])).jobs.first + end + + expect(completed).to be_a(River::JobRow) + end + + it "skips a periodic job whose constructor returns nil" do + maintenance_ran = Queue.new + service = Object.new + service.define_singleton_method(:run) { |_client, _driver, _now| maintenance_ran << true } + periodic = River::PeriodicJob.new( + constructor: -> {}, + run_on_start: true, + schedule: River::PeriodicInterval.new(60) + ) + client = build_client( + Object.new, + maintenance_services: [service], + periodic_jobs: [periodic], + queues: {"default" => 1} + ) + + client.start + wait_until { + begin + maintenance_ran.pop(true) + rescue + nil + end + } + + expect(client.job_list.jobs).to be_empty + end +end diff --git a/ruby/spec/runtime_finishing_shared_examples.rb b/ruby/spec/runtime_finishing_shared_examples.rb new file mode 100644 index 000000000..a586aa1e1 --- /dev/null +++ b/ruby/spec/runtime_finishing_shared_examples.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "stringio" + +RSpec.shared_examples "externally claimed job finalization" do + [ + [River::ClientRuntime::Interrupted.new, :job_interrupted, "available", 0], + [River::JobCancelError.new, :job_cancelled, "cancelled", 1], + [River::JobSnoozeError.new(60), :job_snoozed, "scheduled", 0] + ].each do |error, event_kind, state, attempt| + it "honors #{error.class} reported for an externally claimed job" do + worker = Class.new { def initialize = raise("must not initialize for control signals") } + client = River::Client.new(@driver, config: River::Config.new(workers: River::Workers.new.add(:external, worker))) + inserted = client.insert(River::JobArgsHash.new(:external, {})).job + claimed = @driver.job_claim(id: inserted.id, attempted_by: "external") + events = client.subscribe(event_kind) + before = Time.now.utc + + client.__finish_claimed_job(claimed, error) + + updated = client.job_get(inserted.id) + expect(updated).to have_attributes(attempt: attempt, state: state) + if event_kind == :job_cancelled + expect(updated.errors).to contain_exactly(have_attributes(error: error.message)) + expect(updated.finalized_at).to be_a(Time) + else + expect(Array(updated.errors)).to be_empty + expect(updated.finalized_at).to be_nil + delay = (event_kind == :job_snoozed) ? 60 : 0 + expect(updated.scheduled_at).to be_between(before + delay - 0.001, Time.now.utc + delay + 0.001) + end + expect(updated.metadata).to include("snoozes" => 1) if event_kind == :job_snoozed + expect(events.pop(true).job).to have_attributes(id: inserted.id, state: state) + ensure + events&.close + end + end + + [false, true].product([false, true]).each do |worker_class, retry_allowed| + it "honors retry hooks for an external job with worker_class=#{worker_class} and retry_allowed=#{retry_allowed}" do + calls = [] + retry_at = Time.now.utc + 60 + worker = Class.new do + define_method(:next_retry) do |_job, _error| + raise "retry hooks must use the same instance" unless @checked_retry + + retry_at + end + + define_method(:retry?) do |job, error| + @checked_retry = true + calls << [job.id, error] + retry_allowed + end + end + workers = River::Workers.new.add(:external, worker_class ? worker : worker.new, aliases: [:external_alias]) + client = River::Client.new(@driver, config: River::Config.new(workers: workers)) + inserted = client.insert(River::JobArgsHash.new(:external_alias, {})).job + claimed = @driver.job_claim(id: inserted.id, attempted_by: "external") + error = RuntimeError.new("external work failed") + events = client.subscribe(:job_failed) + + client.__finish_claimed_job(claimed, error) + + expect(calls).to eq([[inserted.id, error]]) + expect(client.job_get(inserted.id)).to have_attributes( + attempt: 1, errors: contain_exactly(have_attributes(error: "external work failed")), + finalized_at: retry_allowed ? nil : be_a(Time), + state: retry_allowed ? "retryable" : "discarded" + ) + event = events.pop(true) + expect(event.job.id).to eq(inserted.id) + expect(event.job.scheduled_at).to be_within(0.001).of(retry_at) if retry_allowed + ensure + events&.close + end + end + + [:cancelled, :completed, :failed].each do |outcome| + it "measures external #{outcome} timing from the claim timestamp" do + client = River::Client.new(@driver) + scheduled_at = Time.now.utc - 600 + attempted_at = scheduled_at + 60 + inserted = client.insert(River::JobArgsHash.new(:external, {}), scheduled_at: scheduled_at).job + claimed = @driver.job_claim(id: inserted.id, attempted_by: "external", now: attempted_at) + events = client.subscribe(:job_completed, :job_failed, :job_cancelled) + client.job_cancel(inserted.id) if outcome == :cancelled + + client.__finish_claimed_job(claimed, (outcome == :failed) ? RuntimeError.new("external work failed") : nil) + + event = events.pop(true) + expect(event.kind).to eq(:"job_#{outcome}") + expect(event.stats).to have_attributes(queue_wait_duration: be_within(0.001).of(60), run_duration: 0.0) + unless outcome == :completed + expect(client.job_get(inserted.id).errors.last.at).to be_within(0.001).of(attempted_at) + end + ensure + events&.close + end + end + + [:initialize, :next_retry, :retry?].each do |hook| + it "records the external work error even if #{hook} raises" do + output = StringIO.new + worker = Class.new + worker.define_method(hook) { |*| raise "worker callback failed" } + client = River::Client.new(@driver, config: River::Config.new( + logger: Logger.new(output), workers: River::Workers.new.add(:external, worker) + )) + inserted = client.insert(River::JobArgsHash.new(:external, {})).job + claimed = @driver.job_claim(id: inserted.id, attempted_by: "external") + + client.__finish_claimed_job(claimed, RuntimeError.new("external work failed")) + + expect(client.job_get(inserted.id)).to have_attributes( + state: "available", errors: contain_exactly(have_attributes(error: "external work failed")) + ) + expect(output.string).to include("worker callback failed") + end + end + + it "skips worker hooks when reporting an externally claimed job's decode error" do + worker = Class.new { def initialize = raise("must not initialize for decode failures") } + policy = Object.new + retry_at = Time.now.utc + 60 + policy.define_singleton_method(:next_retry) { |*, now:| retry_at } + client = River::Client.new(@driver, config: River::Config.new( + retry_policy: policy, workers: River::Workers.new.add(:external, worker) + )) + inserted = client.insert(River::JobArgsHash.new(:external, {})).job + claimed = @driver.job_claim(id: inserted.id, attempted_by: "external") + error = River::JobRowDecodeError.new(claimed, "invalid arguments") + + client.__finish_claimed_job(claimed, error) + + expect(client.job_get(inserted.id)).to have_attributes( + errors: contain_exactly(have_attributes(error: error.message)), + scheduled_at: be_within(0.001).of(retry_at), state: "retryable" + ) + end +end diff --git a/ruby/spec/runtime_spec.rb b/ruby/spec/runtime_spec.rb new file mode 100644 index 000000000..8e438347f --- /dev/null +++ b/ruby/spec/runtime_spec.rb @@ -0,0 +1,543 @@ +# frozen_string_literal: true + +require "spec_helper" +require "riverqueue-sequel" +require_relative "support/river_sqlite_schema_fixture" + +RUNTIME_DB = if !RiverTestDatabase.enabled?(:sqlite) + nil +elsif ENV["RUNTIME_DATABASE_URL"] + Sequel.connect(ENV.fetch("RUNTIME_DATABASE_URL")) +else + Sequel.sqlite.tap do |database| + database.synchronize { |connection| RiverSQLiteSchemaFixture.load(connection) } + end +end + +class RuntimeArgs < River::JobArgsHash + def initialize(value = 1) + super("runtime", {"value" => value}) + end +end + +RSpec.describe "River worker runtime", database: :sqlite do + def wait_until(timeout: 3) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + result = yield + return result if result + raise "timed out waiting for condition" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + + sleep(0.01) + end + end + + def build_client(worker, queue: "runtime", **config_overrides) + workers = River::Workers.new.add("runtime", worker) + config = River::Config.new( + fetch_cooldown: 0.001, + fetch_poll_interval: 0.01, + queues: {queue => River::QueueConfig.new(max_workers: 2)}, + workers: workers, + **config_overrides + ) + River::Client.new(River::Driver::Sequel.new(RUNTIME_DB), config: config) + end + + before do + RUNTIME_DB[:river_notification].delete + RUNTIME_DB[:river_queue].delete + RUNTIME_DB[:river_leader].delete + RUNTIME_DB[:river_job].delete + end + + it "claims, works, completes, emits events, and persists worker output" do + worker = Class.new do + def work(job) + job.update_metadata("worked" => true) + job.output = {"doubled" => job.args.fetch("value") * 2} + end + end + + client = build_client(worker) + subscription = client.subscribe(River::EVENT_JOB_COMPLETED) + inserted = client.insert(RuntimeArgs.new(3), insert_opts: River::InsertOpts.new(queue: "runtime")).job + + expect(client.start).to equal(client) + completed = wait_until { (row = client.job_get(inserted.id)).state == River::JOB_STATE_COMPLETED && row } + event = wait_until do + subscription.pop(true) + rescue ThreadError + nil + end + + expect(completed).to have_attributes( + attempt: 1, + attempted_by: [client.id], + metadata: have_attributes(to_h: include("output" => {"doubled" => 6}, "worked" => true)) + ) + expect(event).to have_attributes( + job: have_attributes(id: completed.id), + kind: River::EVENT_JOB_COMPLETED, + stats: have_attributes(run_duration: be >= 0) + ) + expect(client.started?).to be true + expect(client.stop).to equal(client) + expect(client.stopped?).to be true + expect(subscription.close).to be_nil + expect(client.instance_variable_get(:@runtime).instance_variable_get(:@subscriptions)).to be_empty + ensure + client&.stop_and_cancel + end + + it "records failures and discards jobs at max attempts" do + worker = Class.new do + def work(_job) = raise("nope") + end + + client = build_client(worker) + subscription = client.subscribe(River::EVENT_JOB_FAILED) + inserted = client.insert( + RuntimeArgs.new, + insert_opts: River::InsertOpts.new(max_attempts: 1, queue: "runtime") + ).job + client.start + + discarded = wait_until { (row = client.job_get(inserted.id)).state == River::JOB_STATE_DISCARDED && row } + + expect(discarded.finalized_at).to be_a(Time) + expect(discarded.errors.last).to have_attributes(attempt: 1, error: "nope") + expect(subscription.pop(true).kind).to eq(River::EVENT_JOB_FAILED) + ensure + client&.stop_and_cancel + end + + it "snoozes without consuming an attempt and then works the job" do + worker = Class.new do + def initialize = @first = true + + def work(_job) + if @first + @first = false + raise River.job_snooze(0.01) + end + end + end.new + client = build_client(worker) + inserted = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + + completed = wait_until { (row = client.job_get(inserted.id)).state == River::JOB_STATE_COMPLETED && row } + + expect(completed).to have_attributes(attempt: 1) + expect(completed.metadata.fetch("snoozes")).to eq(1) + ensure + client&.stop_and_cancel + end + + it "cancels running work remotely" do + worker = Class.new do + def work(_job) = sleep(10) + end + + client = build_client(worker) + inserted = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + wait_until { client.job_get(inserted.id).state == River::JOB_STATE_RUNNING } + + expect(client.job_cancel(inserted.id).metadata).to include("cancel_attempted_at") + cancelled = wait_until { (row = client.job_get(inserted.id)).state == River::JOB_STATE_CANCELLED && row } + + expect(cancelled.finalized_at).to be_a(Time) + ensure + client&.stop_and_cancel + end + + it "interrupts running work on hard stop and makes the job available again" do + worker = Class.new do + def work(_job) = sleep(10) + end + + client = build_client(worker) + inserted = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + wait_until { client.job_get(inserted.id).state == River::JOB_STATE_RUNNING } + + client.stop_and_cancel + + expect(client.job_get(inserted.id)).to have_attributes(attempt: 0, state: River::JOB_STATE_AVAILABLE) + ensure + client&.stop_and_cancel + end + + it "supports job and queue administration" do + client = build_client(Class.new { + def work(_job) + end + }) + first = client.insert(RuntimeArgs.new(1), insert_opts: River::InsertOpts.new(queue: "runtime")).job + second = client.insert(RuntimeArgs.new(2), insert_opts: River::InsertOpts.new(queue: "runtime")).job + + expect(client.job_get(first.id).id).to eq(first.id) + expect(client.job_list(River::JobListParams.new(ids: [second.id])).jobs.map(&:id)).to eq([second.id]) + expect(client.job_update(first.id, River::JobUpdateParams.new(max_attempts: 30))).to have_attributes(max_attempts: 30) + expect(client.job_cancel(first.id)).to have_attributes(state: River::JOB_STATE_CANCELLED) + expect(client.job_retry(first.id)).to have_attributes(state: River::JOB_STATE_AVAILABLE) + expect(client.job_delete_many(River::JobListParams.new(ids: [second.id])).jobs.map(&:id)).to eq([second.id]) + expect { client.job_get(second.id) }.to raise_error(River::NotFoundError) + expect { client.job_delete_many(River::JobListParams.new) }.to raise_error(ArgumentError) + + client.driver.queue_upsert("runtime") + subscription = client.subscribe(River::EVENT_QUEUE_PAUSED, River::EVENT_QUEUE_RESUMED) + + expect(client.queue_pause("runtime")).to be true + expect(client.queue_get("runtime").paused_at).to be_a(Time) + expect(subscription.pop(true).kind).to eq(River::EVENT_QUEUE_PAUSED) + expect(client.queue_update("runtime", metadata: {"team" => "ruby"}).metadata).to eq("team" => "ruby") + expect(client.queue_list.queues.map(&:name)).to include("runtime") + expect(client.queue_resume("runtime")).to be true + expect(client.queue_get("runtime").paused_at).to be_nil + expect(subscription.pop(true).kind).to eq(River::EVENT_QUEUE_RESUMED) + end + + it "adds and removes queue producers dynamically" do + client = build_client(Class.new { + def work(_job) + end + }) + client.queue_add("dynamic", River::QueueConfig.new(max_workers: 1)) + client.start + inserted = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "dynamic")).job + wait_until { client.job_get(inserted.id).state == River::JOB_STATE_COMPLETED } + + expect(client.queue_remove("dynamic")).to equal(client) + waiting = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "dynamic")).job + sleep(0.03) + + expect(client.job_get(waiting.id).state).to eq(River::JOB_STATE_AVAILABLE) + ensure + client&.stop_and_cancel + end + + it "requests stop without waiting, then drains through a later stop call" do + entered = Queue.new + release = Queue.new + worker = Object.new + worker.define_singleton_method(:work) do |_job| + entered.push(true) + release.pop + end + + client = build_client(worker, queues: {"runtime" => 1}) + running = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + Timeout.timeout(3) { entered.pop } + + expect(client.stop(wait: false)).to equal(client) + expect(client.stop(wait: false)).to equal(client) + expect(client).to have_attributes(started?: true, stopped?: false) + expect(client.job_get(running.id)).to have_attributes(attempt: 1, state: River::JOB_STATE_RUNNING) + expect { client.start }.to raise_error(River::ClientAlreadyStartedError) + + waiting = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + release.push(true) + Timeout.timeout(3) { client.stop } + + expect(client).to have_attributes(started?: false, stopped?: true) + expect(client.job_get(running.id)).to have_attributes(attempt: 1, state: River::JOB_STATE_COMPLETED) + expect(client.job_get(waiting.id).state).to eq(River::JOB_STATE_AVAILABLE) + + client.start + Timeout.timeout(3) { entered.pop } + release.push(true) + wait_until { client.job_get(waiting.id).state == River::JOB_STATE_COMPLETED } + client.stop + + expect(client.stop(wait: false)).to equal(client) + ensure + release&.push(true) + client&.stop_and_cancel + end + + it "can escalate a nonblocking stop to cancellation" do + entered = Queue.new + worker = Object.new + worker.define_singleton_method(:work) do |_job| + entered.push(true) + sleep(30) + end + + client = build_client(worker) + row = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + Timeout.timeout(3) { entered.pop } + client.stop(wait: false) + Timeout.timeout(3) { client.stop_and_cancel } + + expect(client.job_get(row.id)).to have_attributes(attempt: 0, state: River::JOB_STATE_AVAILABLE) + expect(client).to be_stopped + ensure + client&.stop_and_cancel + end + + it "validates runtime configuration and worker registration" do + expect { River::QueueConfig.new(max_workers: 0) }.to raise_error(ArgumentError) + expect { River::Config.new(fetch_cooldown: 0) }.to raise_error(ArgumentError) + expect { River::Config.new(job_timeout: 0) }.to raise_error(ArgumentError) + expect { River::JobListParams.new(limit: 0) }.to raise_error(ArgumentError) + expect { River::PeriodicInterval.new(0) }.to raise_error(ArgumentError) + + workers = River::Workers.new.add("runtime", Object.new) + + expect { workers.add("runtime", Object.new) }.to raise_error(ArgumentError) + expect(workers).to include("runtime") + + client = build_client(Object.new) + + expect { client.queue_add("invalid queue", 1) }.to raise_error(ArgumentError) + end + + it "schedules, rescues, cleans, and elects a maintenance leader" do + client = build_client(Class.new { + def work(_job) + end + }) + scheduled = client.insert( + RuntimeArgs.new, + insert_opts: River::InsertOpts.new(queue: "runtime", scheduled_at: Time.now.utc - 10, state: :scheduled) + ).job + + expect(client.driver.job_schedule).to eq(1) + expect(client.job_get(scheduled.id).state).to eq(River::JOB_STATE_AVAILABLE) + + stuck = client.driver.job_get_available(attempted_by: "stuck", max: 1, queue: "runtime").first + client.job_update(stuck.id, River::JobUpdateParams.new(attempted_at: Time.now.utc - 7_200)) + + expect(client.driver.job_rescue_stuck( + horizon: Time.now.utc - 3_600, + retry_policy: River::DefaultClientRetryPolicy.new(random: Random.new(1)) + )).to eq(1) + expect(client.job_get(stuck.id).state).to satisfy { |state| [River::JOB_STATE_AVAILABLE, River::JOB_STATE_RETRYABLE].include?(state) } + + old = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.driver.job_cancel(old.id, now: Time.now.utc - 10) + + expect(client.driver.job_delete_finalized( + now: Time.now.utc, + retention: {River::JOB_STATE_CANCELLED => 0} + )).to eq(1) + expect(client.driver.job_get_by_id(old.id)).to be_nil + + now = Time.now.utc + + expect(client.driver.leader_acquire("leader-a", now: now)).to be true + expect(client.driver.leader_acquire("leader-b", now: now)).to be false + expect(client.driver.leader_renew("leader-a", now: now)).to be true + client.driver.leader_release("leader-a") + + expect(client.driver.leader_acquire("leader-b", now: now)).to be true + end + + it "filters jobs by metadata, tags, priorities, queues, and cursor" do + client = build_client(Class.new { + def work(_job) + end + }) + first = client.insert( + RuntimeArgs.new(1), + insert_opts: River::InsertOpts.new( + metadata: {"tenant" => "one"}, priority: 2, queue: "runtime", tags: %w[alpha shared] + ) + ).job + second = client.insert( + RuntimeArgs.new(2), + insert_opts: River::InsertOpts.new( + metadata: {"tenant" => "two"}, priority: 3, queue: "other", tags: %w[beta shared] + ) + ).job + + expect(client.job_list(River::JobListParams.new(metadata: {tenant: "one"})).jobs.map(&:id)).to eq([first.id]) + expect(client.job_list(River::JobListParams.new(tags_all: %w[alpha shared])).jobs.map(&:id)).to eq([first.id]) + expect(client.job_list(River::JobListParams.new(tags_any: %w[missing beta])).jobs.map(&:id)).to eq([second.id]) + expect(client.job_list(River::JobListParams.new(priorities: [3], queues: ["other"])).jobs.map(&:id)).to eq([second.id]) + expect(client.job_list(River::JobListParams.new(after_id: second.id, sort_order: :desc)).jobs.map(&:id)).to eq([first.id]) + end + + it "runs plugin middleware and callbacks around retries" do + calls = [] + worker = Object.new + worker.define_singleton_method(:work) do |job| + calls << :work + unless job.metadata["retried"] + job.update_metadata("retried" => true) + raise "retry" + end + end + + worker.define_singleton_method(:next_retry) { |_job, _error| Time.now.utc } + plugin = Object.new + plugin.define_singleton_method(:insert_begin) { |_params| calls << :insert_begin } + plugin.define_singleton_method(:insert_end) { |_result| calls << :insert_end } + plugin.define_singleton_method(:work_begin) { |_job| calls << :work_begin } + plugin.define_singleton_method(:work_end) { |_job, error| calls << (error ? :work_error : :work_end) } + plugin.define_singleton_method(:work) do |_job, operation| + calls << :middleware + operation.call + end + + client = build_client(worker, plugins: [plugin]) + inserted = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + + completed = wait_until { (row = client.job_get(inserted.id)).state == River::JOB_STATE_COMPLETED && row } + + expect(completed.attempt).to eq(2) + expect(calls).to eq([ + :insert_begin, :insert_end, + :middleware, :work_begin, :work, :work_error, + :middleware, :work_begin, :work, :work_end + ]) + ensure + client&.stop_and_cancel + end + + it "resumes checkpointed steps and cursors after a failed attempt" do + calls = [] + worker = Object.new + failed_once = false + worker.define_singleton_method(:work) do |job| + job.resumable_step("first") { calls << "first" } + job.resumable_step_cursor("items", default: 0) do |cursor| + calls << "items:#{cursor}" + ((cursor + 1)..2).each do |item| + calls << "item:#{item}" + job.resumable_set_cursor(item) + unless failed_once + failed_once = true + raise "retry resumable work" + end + end + end + + job.resumable_step("last") { calls << "last" } + end + + worker.define_singleton_method(:next_retry) { |_job, _error| Time.now.utc } + client = build_client(worker) + inserted = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + + completed = wait_until { (row = client.job_get(inserted.id)).state == River::JOB_STATE_COMPLETED && row } + + expect(completed.attempt).to eq(2) + expect(completed.metadata.to_h).to include( + River::RESUMABLE_STEP_METADATA_KEY => "first", + River::RESUMABLE_CURSOR_METADATA_KEY => {"items" => 1} + ) + expect(calls).to eq(["first", "items:0", "item:1", "items:1", "item:2", "last"]) + ensure + client&.stop_and_cancel + end + + it "persists an explicit resumable checkpoint immediately" do + observed = nil + worker = Class.new do + define_method(:work) do |job| + job.resumable_step_cursor("page", default: {}) do + job.resumable_checkpoint(cursor: {"last_id" => 42}) + observed = job.client.job_get(job.id).metadata + end + end + end.new + client = build_client(worker) + inserted = client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + client.start + wait_until { client.job_get(inserted.id).state == River::JOB_STATE_COMPLETED } + + expect(observed.to_h).to include( + River::RESUMABLE_STEP_METADATA_KEY => "page", + River::RESUMABLE_CURSOR_METADATA_KEY => {"page" => {"last_id" => 42}} + ) + ensure + client&.stop_and_cancel + end + + it "honors error-handler cancellation and worker timeouts" do + cancel_client = build_client( + Class.new { def work(_job) = raise("cancel me") }, + error_handler: ->(_error, _job) { :cancel } + ) + cancelled = cancel_client.insert(RuntimeArgs.new, insert_opts: River::InsertOpts.new(queue: "runtime")).job + cancel_client.start + wait_until { cancel_client.job_get(cancelled.id).state == River::JOB_STATE_CANCELLED } + cancel_client.stop + + timeout_client = build_client(Class.new { def work(_job) = sleep(1) }, job_timeout: 0.01) + timed_out = timeout_client.insert( + RuntimeArgs.new, + insert_opts: River::InsertOpts.new(max_attempts: 1, queue: "runtime") + ).job + timeout_client.start + discarded = wait_until { (row = timeout_client.job_get(timed_out.id)).state == River::JOB_STATE_DISCARDED && row } + + expect(discarded.errors.last.error).to match(/execution expired/) + ensure + cancel_client&.stop_and_cancel + timeout_client&.stop_and_cancel + end + + it "runs periodic jobs and supports restart" do + periodic = River::PeriodicJob.new( + id: "runtime-periodic", + constructor: -> { [RuntimeArgs.new, River::InsertOpts.new(queue: "runtime")] }, + run_on_start: true, + schedule: River::PeriodicInterval.new(60) + ) + client = build_client(Class.new { + def work(_job) + end + }, periodic_jobs: [periodic]) + client.start + wait_until do + client.job_list(River::JobListParams.new(kinds: ["runtime"], states: [River::JOB_STATE_COMPLETED])).jobs.any? + end + + expect { client.start }.to raise_error(River::ClientAlreadyStartedError) + client.stop + + expect(client.start.stop).to equal(client) + ensure + client&.stop_and_cancel + end + + it "manages periodic registrations and subscriptions" do + wake_count = 0 + jobs = River::PeriodicJobBundle.new([], wake: -> { wake_count += 1 }) + future = River::PeriodicJob.new( + id: "future", constructor: -> {}, schedule: ->(time) { time + 60 } + ) + handle = jobs.add(future) + + expect(jobs.add_many([])).to eq([]) + expect { jobs.add(future) }.to raise_error(ArgumentError) + expect(jobs.due(Time.now.utc - 60)).to eq([]) + expect(jobs.remove(handle)).not_to be_nil + jobs.add(future) + + expect(jobs.remove_by_id("future")).to be true + expect(jobs.remove_by_id("missing")).to be false + jobs.clear + + expect(wake_count).to eq(2) + + event = River::Event.new(River::EVENT_JOB_COMPLETED, nil, nil, nil) + subscription = River::Subscription.new([River::EVENT_JOB_COMPLETED], buffer_size: 1) + subscription.publish(event) + subscription.publish(event) # full buffers drop rather than blocking workers + + expect(subscription.each.first).to equal(event) + subscription.close + + expect(subscription.each.to_a).to eq([]) + end +end diff --git a/ruby/spec/spec_helper.rb b/ruby/spec/spec_helper.rb new file mode 100644 index 000000000..6101182a6 --- /dev/null +++ b/ruby/spec/spec_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative "support/test_database" +require "debug" + +ENV["RIVERQUEUE_ROOT_TEST_SUITE"] = "1" + +# Only show coverage information if running the entire suite. +if RSpec.configuration.files_to_run.length > 1 + require "simplecov" + SimpleCov.start do + enable_coverage :branch + command_name RiverTestDatabase::BACKEND + minimum_coverage branch: 100, line: 100 if RiverTestDatabase::BACKEND == "all" + + # Drivers have their own spec suite where they're covered 100.0%, but + # they're not fully covered from this top level test suite. + add_filter("driver/riverqueue-sequel/") + add_filter("driver/riverqueue-activerecord/") + add_filter("/spec/") + end +end + +require "riverqueue" diff --git a/ruby/spec/support/client_test_database.rb b/ruby/spec/support/client_test_database.rb new file mode 100644 index 000000000..49cb9bc44 --- /dev/null +++ b/ruby/spec/support/client_test_database.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "securerandom" +require "tmpdir" +require_relative "river_test_schema" + +# Client tests need committed data and independent connections, unlike the +# rollback-wrapped driver contracts. Never run their workers against public +# tables: migrate an empty disposable schema with the bundled canonical SQL. +module ClientTestDatabase + def self.with_active_record(adapter, migrate: true, pg_catalog_last: false) + original = ActiveRecord::Base.connection_db_config.configuration_hash + if adapter == :postgres + ActiveRecord::Base.establish_connection(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test") + schema = "river_client_test_#{SecureRandom.hex(8)}" + config = ActiveRecord::Base.connection_db_config.configuration_hash + search_path = pg_catalog_last ? "#{schema},pg_catalog" : "#{schema},public" + ActiveRecord::Base.establish_connection(config.merge(pool: 20, schema_search_path: search_path)) + # Create and drop through the test pool to avoid opening separate admin + # connections for every example. PostgreSQL permits a not-yet-created + # schema in search_path. + ActiveRecord::Base.connection.execute("CREATE SCHEMA #{schema}") + schema_created = true + driver = River::Driver::ActiveRecord.new + RiverTestSchema.load(driver) if migrate + + yield driver + else + Dir.mktmpdir("river-client-test-") do |directory| + ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: File.join(directory, "river.sqlite3"), pool: 20, timeout: 5_000) + begin + driver = River::Driver::ActiveRecord.new + RiverTestSchema.load(driver) if migrate + + yield driver + ensure + ActiveRecord::Base.connection_pool.disconnect! + end + end + end + ensure + begin + ActiveRecord::Base.connection.execute("DROP SCHEMA #{schema} CASCADE") if schema_created + ensure + ActiveRecord::Base.establish_connection(original) + end + end + + def self.with_sequel(adapter, migrate: true, pg_catalog_last: false) + if adapter == :postgres + schema = "river_client_test_#{SecureRandom.hex(8)}" + search_path = pg_catalog_last ? "#{schema},pg_catalog" : "#{schema},public" + database = Sequel.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test", max_connections: 20, search_path: search_path) + database.run("CREATE SCHEMA #{schema}") + schema_created = true + driver = River::Driver::Sequel.new(database) + RiverTestSchema.load(driver) if migrate + + yield driver + else + Dir.mktmpdir("river-client-test-") do |directory| + database = Sequel.sqlite(File.join(directory, "river.sqlite3"), max_connections: 4, timeout: 5_000) + begin + driver = River::Driver::Sequel.new(database) + RiverTestSchema.load(driver) if migrate + + yield driver + ensure + database.disconnect + end + end + end + ensure + begin + database.run("DROP SCHEMA #{schema} CASCADE") if schema_created + ensure + database&.disconnect + end + end +end diff --git a/ruby/spec/support/conformance_fixtures.rb b/ruby/spec/support/conformance_fixtures.rb new file mode 100644 index 000000000..ddcc99ed0 --- /dev/null +++ b/ruby/spec/support/conformance_fixtures.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "json" + +module RiverConformanceFixtures + def self.read(name) + File.read(File.expand_path("../../../conformance/testdata/#{name}.json", __dir__)) + rescue Errno::ENOENT + raise "Missing Go-generated #{name} fixtures; run `make generate/fixtures` from the repository root" + end + + def self.load(name) + JSON.parse(read(name)) + end +end diff --git a/ruby/spec/support/connection_class_test_database.rb b/ruby/spec/support/connection_class_test_database.rb new file mode 100644 index 000000000..5e6a5c16f --- /dev/null +++ b/ruby/spec/support/connection_class_test_database.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "tmpdir" +require "securerandom" + +# Creates a second isolated database/schema without changing Base's connection. +module ConnectionClassTestDatabase + def self.with_class(connection_class, backend) + Dir.mktmpdir("river-connection-class-") do |directory| + if backend == :postgres + schema = "river_connection_test_#{SecureRandom.hex(8)}" + ActiveRecord::Base.connection.execute("CREATE SCHEMA #{schema}") + config = ActiveRecord::Base.connection_db_config.configuration_hash.merge(pool: 20, schema_search_path: schema) + else + config = {adapter: "sqlite3", database: File.join(directory, "river.sqlite3"), pool: 20, timeout: 5_000} + end + + connection_class.establish_connection(config) + yield River::Driver::ActiveRecord.new(connection_class: connection_class) + ensure + connection_class.remove_connection + ActiveRecord::Base.connection.execute("DROP SCHEMA #{schema} CASCADE") if schema + end + end +end diff --git a/ruby/spec/support/migrations/postgresql/test/001_test.down.sql b/ruby/spec/support/migrations/postgresql/test/001_test.down.sql new file mode 100644 index 000000000..8f748bb43 --- /dev/null +++ b/ruby/spec/support/migrations/postgresql/test/001_test.down.sql @@ -0,0 +1 @@ +DROP TABLE /* TEMPLATE: schema */river_migration_test; diff --git a/ruby/spec/support/migrations/postgresql/test/001_test.up.sql b/ruby/spec/support/migrations/postgresql/test/001_test.up.sql new file mode 100644 index 000000000..d60f87b02 --- /dev/null +++ b/ruby/spec/support/migrations/postgresql/test/001_test.up.sql @@ -0,0 +1 @@ +CREATE TABLE /* TEMPLATE: schema */river_migration_test (id integer PRIMARY KEY); diff --git a/ruby/spec/support/migrations/sqlite/test/001_test.down.sql b/ruby/spec/support/migrations/sqlite/test/001_test.down.sql new file mode 100644 index 000000000..bfc26b8ca --- /dev/null +++ b/ruby/spec/support/migrations/sqlite/test/001_test.down.sql @@ -0,0 +1 @@ +DROP TABLE river_migration_test; diff --git a/ruby/spec/support/migrations/sqlite/test/001_test.up.sql b/ruby/spec/support/migrations/sqlite/test/001_test.up.sql new file mode 100644 index 000000000..d985fb052 --- /dev/null +++ b/ruby/spec/support/migrations/sqlite/test/001_test.up.sql @@ -0,0 +1 @@ +CREATE TABLE river_migration_test (id integer PRIMARY KEY); diff --git a/ruby/spec/support/ractor_test_driver.rb b/ruby/spec/support/ractor_test_driver.rb new file mode 100644 index 000000000..7682c93a0 --- /dev/null +++ b/ruby/spec/support/ractor_test_driver.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "riverqueue/testing" + +# A worker definition loaded by the main Ractor and instantiated by each runtime. +class RactorTestWorker + def self.kind = "ractor_test" + + def work(job) = job.output = job.args.fetch("value") * 2 +end + +# Deliberately small, Ractor-local stand-in for database I/O. This lets the real +# client/runtime run without ORM globals, native drivers, or RSpec mocks. +class RactorTestDriver + def initialize + @rows = {} + @mutex = Mutex.new + end + + def job_insert(params) + @mutex.synchronize do + row = River::JobRow.new( + id: @rows.length + 1, args: JSON.parse(params.encoded_args), attempt: 0, + created_at: Time.now.utc, kind: params.kind, max_attempts: params.max_attempts, + metadata: params.metadata.dup, priority: params.priority, queue: params.queue, + scheduled_at: params.scheduled_at, state: params.state, tags: params.tags, + unique_key: params.unique_key, unique_states: params.unique_states + ) + @rows[row.id] = row + [row, false] + end + end + + def job_insert_many(params) = params.map { |param| job_insert(param) } + + def job_get_by_id(id) = @mutex.synchronize { @rows.fetch(id) } + + def job_claim(id:, attempted_by:, allow_scheduled: false) + @mutex.synchronize do + row = @rows.fetch(id) + row.state = River::JOB_STATE_RUNNING + row.attempt += 1 + row.attempted_by = [attempted_by] + row + end + end + + def job_set_state_if_running(id:, now: nil, error: nil, metadata: nil, **attributes) + @mutex.synchronize do + row = @rows.fetch(id) + attributes.each { |name, value| row.public_send(:"#{name}=", value) } + row.errors = Array(row.errors) + [error] if error + row.metadata.merge!(metadata) if metadata + row + end + end + + def job_complete(**attributes) + job_set_state_if_running(**attributes, state: River::JOB_STATE_COMPLETED) + end + + def job_metadata_merge(id, metadata) + @mutex.synchronize do + @rows.fetch(id).tap { |row| row.metadata.merge!(metadata) } + end + end + + def job_get_available(queue:, max:, attempted_by:) + ids = @mutex.synchronize do + @rows.values.select { |row| row.queue == queue && row.state == River::JOB_STATE_AVAILABLE }.first(max).map(&:id) + end + ids.map { |id| job_claim(id: id, attempted_by: attempted_by) } + end + + def job_get_cancelled_ids(_ids) = [] + + def queue_get(_name) = nil + + def queue_upsert(_name) = nil + + def leader_acquire(_id, now:) = true + + def leader_renew(_id, now:) = true + + def leader_release(_id) = nil + + def job_schedule(now:) = nil + + def job_rescue_stuck(horizon:, now:, retry_policy:) = nil + + def job_delete_finalized(now:, retention:) = nil +end diff --git a/ruby/spec/support/river_sqlite_schema_fixture.rb b/ruby/spec/support/river_sqlite_schema_fixture.rb new file mode 100644 index 000000000..ef2c1acf4 --- /dev/null +++ b/ruby/spec/support/river_sqlite_schema_fixture.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# A snapshot of River's complete SQLite schema after main migration 008. +# Production databases must use River's migrations; schema creation belongs in +# test support here so Ruby and Go never develop competing migration histories. +module RiverSQLiteSchemaFixture + SCHEMA = <<~SQL + CREATE TABLE river_migration ( + line text NOT NULL, + version integer NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT line_length CHECK (length(line) > 0 AND length(line) < 128), + CONSTRAINT version_gte_1 CHECK (version >= 1), + PRIMARY KEY (line, version) + ); + + INSERT INTO river_migration (line, version) VALUES ('main', 8); + + CREATE TABLE river_job ( + id integer PRIMARY KEY AUTOINCREMENT, + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, + finalized_at timestamp, + kind text NOT NULL, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + max_attempts integer NOT NULL DEFAULT 25, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) + ); + + CREATE INDEX river_job_kind ON river_job (kind); + CREATE INDEX river_job_state_and_finalized_at_index + ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; + CREATE INDEX river_job_prioritized_fetching_index + ON river_job (state, queue, priority, scheduled_at, id); + CREATE UNIQUE INDEX river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; + + CREATE TABLE river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL DEFAULT 'default' CHECK (name = 'default'), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) + ); + + CREATE TABLE river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE river_notification ( + id integer PRIMARY KEY AUTOINCREMENT, + created_at timestamp NOT NULL DEFAULT (datetime('now', 'subsec')), + payload text NOT NULL, + topic text NOT NULL, + CONSTRAINT topic_length CHECK (length(topic) > 0 AND length(topic) < 128) + ); + + CREATE INDEX river_notification_created_at_idx + ON river_notification (created_at); + CREATE INDEX river_notification_topic_id_idx + ON river_notification (topic, id); + SQL + + def self.load(database) + database.execute_batch(SCHEMA) + end +end diff --git a/ruby/spec/support/river_test_schema.rb b/ruby/spec/support/river_test_schema.rb new file mode 100644 index 000000000..0ade9ce41 --- /dev/null +++ b/ruby/spec/support/river_test_schema.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Only for empty, disposable test databases. Migration tests use the real +# migrator; other integration tests don't need its per-version history queries +# and advisory locks. Replay the same SQL, without maintaining a schema copy. +module RiverTestSchema + def self.load(driver) + migrations = River::Migrator.new(driver).migrations + sql = migrations.map do |migration| + # PostgreSQL must commit new enum values before later migrations use them. + "BEGIN;\n#{migration.sql_up}\n;COMMIT;" + end.join("\n") + versions = migrations.map { |migration| "('main', #{migration.version})" }.join(", ") + sql << "\nBEGIN; INSERT INTO /* TEMPLATE: schema */river_migration (line, version) VALUES #{versions}; COMMIT;" + + driver.migration_connection do |connection| + if driver.migration_backend == :postgresql + schema = connection.quote_ident(connection.exec("SELECT current_schema()").getvalue(0, 0)) + execute = ->(statement) { connection.exec(statement) } + sql = sql.gsub("/* TEMPLATE: schema */", "#{schema}.") + else + execute = ->(statement) { connection.execute_batch(statement) } + sql = sql.gsub("/* TEMPLATE: schema */", "") + end + + loaded = false + begin + execute.call(sql) + loaded = true + ensure + execute.call("ROLLBACK") unless loaded + end + end + end +end diff --git a/ruby/spec/support/runner_test_client.rb b/ruby/spec/support/runner_test_client.rb new file mode 100644 index 000000000..830a4c1c7 --- /dev/null +++ b/ruby/spec/support/runner_test_client.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Lifecycle double for deterministic deadline/error tests. Database-backed +# subprocess tests exercise real claims and signals in the driver suites. +class RunnerTestClient < River::Client + attr_reader :interruptions, :stop_thread + + def initialize(healthy: true, signals: ["TERM"], stall: false, unresponsive: false) + @config = River::Config.new(queues: {"test" => 1}) + @healthy = healthy + @interruptions = 0 + @release = Queue.new + @signals = signals + @stall = stall + @unresponsive = unresponsive + end + + def __interrupt_workers + @interruptions += 1 + release unless @unresponsive + end + + def __runtime_healthy? = @healthy + + def job_list(_params) = [] + + def release = @release.push(true) + + def start + @signals.each { |signal| Process.kill(signal, Process.pid) } + self + end + + def started? = false + + def stop(wait: true) + return self unless wait + + @stop_thread = Thread.current + @release.pop if @stall + self + end +end diff --git a/ruby/spec/support/test_database.rb b/ruby/spec/support/test_database.rb new file mode 100644 index 000000000..b873d7417 --- /dev/null +++ b/ruby/spec/support/test_database.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Like the Go suite, default to both backends and allow CI to test them separately. +module RiverTestDatabase + BACKEND = ENV.fetch("TEST_DATABASE", "all") + raise ArgumentError, "TEST_DATABASE must be all, postgres, or sqlite" unless %w[all postgres sqlite].include?(BACKEND) + + def self.enabled?(backend) + BACKEND == "all" || backend.to_s == BACKEND + end +end + +RSpec.configure do |config| + config.filter_run_excluding database: :postgres unless RiverTestDatabase.enabled?(:postgres) + config.filter_run_excluding database: :sqlite unless RiverTestDatabase.enabled?(:sqlite) +end diff --git a/ruby/spec/support/yugabyte_test_database.rb b/ruby/spec/support/yugabyte_test_database.rb new file mode 100644 index 000000000..acecb5d51 --- /dev/null +++ b/ruby/spec/support/yugabyte_test_database.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Like Go's DBPoolWithYugabyteVersion, this exercises product/setting detection +# on PostgreSQL, not Yugabyte's storage or transaction semantics. The caller must +# own an isolated schema and put it ahead of pg_catalog in every connection's +# search_path. An unavailable pg_notify deliberately raises instead of no-oping. +module YugabyteTestDatabase + def self.simulate(driver, notifications: nil, product: "PostgreSQL 15.12-YB-2025.2.3.0-b1") + setting = if notifications.nil? + "NULL::text" + else + notifications ? "'on'::text" : "'off'::text" + end + driver.send(:runtime_execute, <<~SQL) + CREATE FUNCTION version() RETURNS text LANGUAGE sql AS $$ + SELECT #{driver.send(:runtime_quote, product)}::text + $$; + CREATE FUNCTION current_setting(setting_name text, missing_ok boolean) RETURNS text LANGUAGE sql AS $$ + SELECT CASE WHEN setting_name = 'yb_enable_listen_notify' THEN #{setting} + ELSE pg_catalog.current_setting(setting_name, missing_ok) END + $$; + SQL + unless notifications + driver.send(:runtime_execute, <<~SQL) + CREATE FUNCTION pg_notify(text, text) RETURNS void LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'LISTEN/NOTIFY is unavailable'; END + $$; + SQL + end + end +end diff --git a/ruby/spec/test_schema_spec.rb b/ruby/spec/test_schema_spec.rb new file mode 100644 index 000000000..7ddf1d31a --- /dev/null +++ b/ruby/spec/test_schema_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" +require_relative "support/client_test_database" + +RSpec.describe RiverTestSchema do + [:postgres, :sqlite].each do |adapter| + context "with #{adapter}", database: adapter do + around do |example| + skip "PostgreSQL unavailable" if adapter == :postgres && !DB + + ClientTestDatabase.with_sequel(adapter, migrate: false) do |driver| + @driver = driver + example.run + end + end + + it "loads the canonical schema and complete migration history" do + described_class.load(@driver) + migrator = River::Migrator.new(@driver) + expect(migrator.status.map(&:version)).to eq(migrator.migrations.map(&:version)) + expect(migrator.status).to all(have_attributes(applied: true)) + expect(migrator.migrate).to be_empty + + client = River::Client.new(@driver) + args = River::JobArgsHash.new(:fixture, {}) + options = {state: :pending, unique_opts: River::UniqueOpts.new(by_args: true)} + first = client.insert(args, **options) + duplicate = client.insert(args, **options) + expect(first.job.state).to eq("pending") + expect(duplicate).to have_attributes(unique_skipped_as_duplicate?: true, job: have_attributes(id: first.job.id)) + + expect(migrator.migrate(direction: :down, target: 0)).to eq(migrator.migrations.reverse) + expect(migrator.status).to all(have_attributes(applied: false)) + end + + it "rolls back a failed setup so the connection remains usable for cleanup" do + @driver.send(:runtime_execute, "CREATE TABLE river_job (id integer)") + expect { described_class.load(@driver) }.to raise_error(/river_job.*already exists/) + expect { @driver.send(:runtime_execute, "DROP TABLE river_job") }.not_to raise_error + end + end + end +end diff --git a/ruby/spec/testing_spec.rb b/ruby/spec/testing_spec.rb new file mode 100644 index 000000000..6d0b1e903 --- /dev/null +++ b/ruby/spec/testing_spec.rb @@ -0,0 +1,436 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open3" +require "riverqueue-sequel" +require "riverqueue/testing" +require "riverqueue/testing/minitest" +require "riverqueue/testing/rspec" +require_relative "support/river_sqlite_schema_fixture" + +RSpec.describe River::Testing, database: :sqlite do + include River::Testing::Assertions + include River::Testing::RSpec + + let(:database) do + Sequel.sqlite.tap { |db| db.synchronize { |connection| RiverSQLiteSchemaFixture.load(connection) } } + end + + let(:worker) { Object.new.tap { |object| object.define_singleton_method(:work) { |_job| } } } + let(:plugins) { [] } + let(:client) do + River::Client.new(River::Driver::Sequel.new(database), config: River::Config.new( + plugins: plugins, + workers: River::Workers.new.add("testing", worker) + )) + end + + after { database.disconnect } + + def insert(value = 1, **options) + client.insert(River::JobArgsHash.new("testing", {"value" => value}), insert_opts: River::InsertOpts.new(scheduled_at: Time.now.utc - 1, state: "available", **options)).job + end + + it "propagates resumable step failures before later worker code or success hooks run" do + calls = [] + failure = RuntimeError.new("step failed") + plugin = Object.new + plugin.define_singleton_method(:work_end) { |_job, error| calls << error } + plugins << plugin + worker.define_singleton_method(:work) do |job| + job.resumable_step(:download) { raise failure } + calls << :must_not_run + end + + result = described_class.perform_job(client, insert.id) + + expect(result.error).to equal(failure) + expect(calls).to eq([failure]) + end + + [Float::NAN, Float::INFINITY, -Float::INFINITY, -1].each do |timeout| + it "reports an invalid worker timeout of #{timeout} as a normal attempt error" do + worker.define_singleton_method(:timeout) { |_job| timeout } + worker.define_singleton_method(:work) { |_job| raise "must not execute" } + + result = described_class.perform_job(client, insert.id) + + expect(result.outcome).to eq(:retried) + expect(result.error).to be_a(ArgumentError).and have_attributes(message: /timeout must be finite/) + end + end + + { + cancelled: River.job_cancel("stop"), + snoozed: River.job_snooze(60), + interrupted: River::ClientRuntime::Interrupted.new + }.each do |outcome, exception| + it "does not swallow #{outcome} control flow inside a resumable step" do + calls = [] + worker.define_singleton_method(:work) do |job| + job.resumable_step_cursor :items, default: 0 do + job.resumable_set_cursor 1 + raise exception + end + calls << :must_not_run + end + + result = described_class.perform_job(client, insert.id) + + expect(result.outcome).to eq(outcome) + expect(result.error).to equal(exception) + expect(result.job.metadata[River::RESUMABLE_CURSOR_METADATA_KEY]).to eq("items" => 1) + expect(calls).to be_empty + end + end + + [false, true].each do |with_cursor| + it "does not re-persist a rolled-back #{with_cursor ? "cursor" : "step"} checkpoint" do + received = [] + worker.define_singleton_method(:work) do |job| + operation = ->(cursor = nil) do + received << cursor + job.client.driver.transaction do + job.client.insert(River::JobArgsHash.new("child", {})) + with_cursor ? job.resumable_checkpoint(cursor: 42) : job.resumable_checkpoint + raise "rollback checkpoint" if job.attempt == 1 + end + end + if with_cursor + job.resumable_step_cursor(:import, default: 0, &operation) + else + job.resumable_step(:import, &operation) + end + end + row = insert + + first = described_class.perform_job(client, row.id) + + expect(first.error.message).to eq("rollback checkpoint") + expect(first.job.metadata).not_to have_key(River::RESUMABLE_STEP_METADATA_KEY) + expect(first.job.metadata).not_to have_key(River::RESUMABLE_CURSOR_METADATA_KEY) + expect(client.job_list(River::JobListParams.new(kinds: ["child"])).jobs).to be_empty + client.job_retry row.id + expect(described_class.perform_job(client, row.id).outcome).to eq(:completed) + expect(received).to eq(with_cursor ? [0, 0] : [nil, nil]) + expect(client.job_list(River::JobListParams.new(kinds: ["child"])).jobs.length).to eq(1) + end + end + + it "loads neither framework nor testing helpers by default" do + script = <<~RUBY + require "riverqueue" + abort "testing loaded by default" if defined?(River::Testing) + require "riverqueue/testing" + abort "RSpec loaded" if defined?(RSpec) + abort "Minitest loaded" if defined?(Minitest) + RUBY + output, status = Open3.capture2e(RbConfig.ruby, "-Ilib", "-e", script) + + expect(status.success?).to be(true), output + end + + it "normalizes only literal identifier filters without mutating caller values" do + attributes = {kind: :testing, queue: "default", state: :available, args: :unchanged}.freeze + + expect(described_class.normalize_attributes(attributes)) + .to eq(kind: "testing", queue: "default", state: "available", args: :unchanged) + expect(attributes[:kind]).to eq(:testing) + end + + it "provides execution outcome assertions with the original error in failures" do + completed = described_class.perform_job(client, insert.id) + + expect(assert_job_completed(completed)).to equal(completed) + expect { assert_job_cancelled(completed) }.to raise_error(River::Testing::AssertionError) + expect { assert_job_discarded(completed) }.to raise_error(River::Testing::AssertionError) + worker.define_singleton_method(:work) { |_job| raise River.job_cancel("not needed") } + cancelled = described_class.perform_job(client, insert.id) + + expect(assert_job_cancelled(cancelled)).to equal(cancelled) + expect { assert_job_completed(cancelled) }.to raise_error(River::Testing::AssertionError, /not needed/) + worker.define_singleton_method(:work) { |_job| raise "broken" } + discarded = described_class.perform_job(client, insert(1, max_attempts: 1).id) + + expect(assert_job_discarded(discarded)).to equal(discarded) + end + + it "returns the new matching row and ignores existing jobs and other attributes" do + insert + row = assert_job_inserted(client, args: {"value" => 2}, kind: :testing, queue: :orders, state: :available) do + insert(1) + insert(2, queue: "orders") + end + + expect(row).to have_attributes(args: {"value" => 2}, kind: "testing", queue: "orders") + assert_no_jobs_inserted(client) {} + end + + it "compares nested args exactly, not as a subset" do + assert_no_jobs_inserted(client, args: {}) { insert } + end + + it "does not count a uniqueness conflict as insertion" do + options = River::UniqueOpts.new(by_args: true) + insert(1, unique_opts: options) + + assert_no_jobs_inserted(client) { insert(1, unique_opts: options) } + end + + it "paginates snapshots beyond 100 rows" do + 101.times { insert } + expect(assert_jobs_inserted(client, count: 2) { 2.times { insert } }.length).to eq(2) + end + + it "reports assertion mismatches and validates inputs before invoking the block" do + expect { assert_job_inserted(client) {} }.to raise_error(River::Testing::AssertionError, /got 0/) + expect { assert_jobs_inserted(client, count: -1) {} }.to raise_error(ArgumentError) + expect { assert_jobs_inserted(client, count: "1") {} }.to raise_error(ArgumentError) + expect { assert_job_inserted(client, typo: 1) { raise "should not run" } }.to raise_error(ArgumentError, /typo/) + expect { assert_job_inserted(client) }.to raise_error(ArgumentError, /block/) + expect { assert_no_jobs_inserted(client) { raise "application error" } }.to raise_error("application error") + end + + it "supports RSpec block matches, counts, zero counts, and negation" do + expect { insert }.to insert_job(client, kind: "testing") + expect { 2.times { insert } }.to insert_jobs(client, count: 2) + expect {}.to insert_jobs(client, count: 0) + expect {}.not_to insert_job(client) + expect {}.not_to insert_jobs(client, count: 2) + matcher = insert_job(client) + + expect(matcher.supports_value_expectations?).to be false + expect(matcher.description).to include("River jobs") + expect { expect {}.to insert_job(client) }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /got 0/) + expect { expect { insert }.not_to insert_jobs(client, count: 2) }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /got 1/) + expect { insert_jobs(client, count: -1) }.to raise_error(ArgumentError) + expect { insert_jobs(client, count: nil) }.to raise_error(ArgumentError) + end + + it "matches nested RSpec expectations and scheduled times without weakening literal equality" do + scheduled_at = Time.now.utc + 60 + expect { insert(42, metadata: {"nested" => {"items" => [1, 2]}}, scheduled_at: scheduled_at) }.to insert_job( + client, + args: a_hash_including("value" => be > 40), + metadata: a_hash_including("nested" => {"items" => [be_a(Integer), 2]}), + scheduled_at: be_within(0.01).of(scheduled_at) + ) + expect { insert(42) }.not_to insert_job(client, args: {}) + expect { insert(42) }.not_to insert_job(client, args: a_hash_including("value" => be < 10)) + end + + it "counts only matching new rows and clones attribute matchers between candidates" do + insert(42) + expect { [1, 42, 43].each { |value| insert(value) } }.to insert_jobs( + client, count: 2, args: a_hash_including("value" => be > 40) + ) + options = River::UniqueOpts.new(by_args: true) + insert(99, unique_opts: options) + expect { insert(99, unique_opts: options) }.not_to insert_job(client, args: a_hash_including("value" => 99)) + end + + it "supports flexible count chains and preserves zero-match negation" do + expect { 3.times { insert } }.to insert_jobs(client).at_least(2) + expect { insert }.to insert_jobs(client).at_most(2) + expect {}.to insert_jobs(client).at_most(0) + expect { 2.times { insert } }.to insert_job(client).exactly(2) + expect {}.not_to insert_jobs(client).at_most(3) + expect { expect { insert }.to insert_jobs(client).at_least(2) }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /at least 2.*got 1/) + expect { expect { 2.times { insert } }.to insert_jobs(client).at_most(1) }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /at most 1.*got 2/) + expect { expect { insert }.not_to insert_jobs(client).at_least(2) }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /got 1/) + end + + it "composes insertion matchers while invoking the application block once" do + calls = 0 + expect { + calls += 1 + insert(1, queue: "one") + insert(2, queue: "two") + }.to insert_job(client, queue: "one").and insert_job(client, queue: "two") + expect(calls).to eq(1) + end + + it "matches existing persisted jobs in any state without counting only new inserts" do + expect(client).not_to have_job(kind: "testing") + first = insert(1) + insert(2) + client.job_cancel(first.id) + expect(client).to have_job(kind: :testing, queue: :default).exactly(2) + expect(client).to have_job(id: first.id, args: a_hash_including("value" => 1), state: "cancelled") + expect(client).to have_job(state: :available).and have_job(state: :cancelled) + expect(client).not_to have_job(queue: "absent") + expect(have_job.supports_value_expectations?).to be true + expect(have_job.supports_block_expectations?).to be false + expect { expect(client).to have_job(kind: "absent") }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /at least 1.*got 0/) + expect { expect(client).not_to have_job.exactly(3) }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /got 2/) + end + + it "paginates existing-job matches" do + 100.times { insert } + last = insert(101) + expect(client).to have_job(id: last.id, args: {"value" => 101}) + expect(client).to have_job.exactly(101) + end + + it "validates count chains and attributes before any application work" do + [-1, nil, "1", 1.5].each do |count| + %i[at_least at_most exactly].each do |method| + expect { have_job.public_send(method, count) }.to raise_error(ArgumentError, /nonnegative integer/) + end + end + expect { have_job(typo: 1) }.to raise_error(ArgumentError, /typo/) + expect { expect { raise "should not run" }.to insert_job(client, typo: 1) }.to raise_error(ArgumentError, /typo/) + expect { expect { raise "application error" }.to insert_job(client) }.to raise_error("application error") + end + + it "describes nested expectations and resets matching results when reused" do + matcher = have_job(args: a_hash_including("value" => be > 10)) + expect(matcher.matches?(client)).to be false + expect(matcher.failure_message).to include("a hash including", "got 0") + insert(42) + expect(matcher.matches?(client)).to be true + expect(matcher.does_not_match?(client)).to be false + expect(matcher.failure_message_when_negated).to include("a hash including", "got 1") + end + + it "uses native Minitest assertions without autorun" do + test = Class.new do + include Minitest::Assertions + include River::Testing::Minitest + + attr_accessor :assertions + end.new + test.assertions = 0 + row = test.assert_job_inserted(client) { insert } + + expect(row.kind).to eq("testing") + expect { test.assert_no_jobs_inserted(client) { insert } }.to raise_error(Minitest::Assertion) + expect(test.assertions).to eq(2) + end + + it "executes one job on the caller's thread with output, metadata, plugins, and events" do + threads = [] + callbacks = [] + plugins << Object.new.tap do |plugin| + plugin.define_singleton_method(:work_begin) { |_job| callbacks << :begin } + plugin.define_singleton_method(:work_end) { |_job, error| callbacks << error } + plugin.define_singleton_method(:work) do |_job, operation| + callbacks << :before + operation.call + callbacks << :after + end + end + worker.define_singleton_method(:work) do |job| + threads << Thread.current + job.output = {"ok" => true} + job.update_metadata("worked" => true) + end + + subscription = client.subscribe(River::EVENT_JOB_COMPLETED) + row = insert + other = insert + result = described_class.perform_job(client, row.id) + + expect(result).to have_attributes(id: row.id, error: nil, outcome: :completed) + expect(result.job).to have_attributes(attempt: 1, attempted_by: [client.id], state: "completed") + expect(result.job.metadata).to include("worked" => true, "output" => {"ok" => true}) + expect(threads).to eq([Thread.current]) + expect(callbacks).to eq([:before, :begin, nil, :after]) + expect(client.job_get(other.id).state).to eq("available") + expect(subscription.pop(true).kind).to eq(River::EVENT_JOB_COMPLETED) + expect(client.started?).to be false + end + + it "enforces the worker timeout through the real pipeline" do + worker.define_singleton_method(:timeout) { |_job| 0.001 } + worker.define_singleton_method(:work) { |_job| sleep(1) } + result = described_class.perform_job(client, insert.id) + + expect(result).to have_attributes(error: be_a(Timeout::Error), outcome: :retried) + expect(result.job.attempt).to eq(1) + end + + it "returns the original exception for retries and discards" do + error = RuntimeError.new("boom") + worker.define_singleton_method(:work) { |_job| raise error } + result = described_class.perform_job(client, insert.id) + + expect(result).to have_attributes(error: equal(error), outcome: :retried) + expect(result.job.errors.last.error).to eq("boom") + expect(described_class.perform_job(client, insert(1, max_attempts: 1).id)).to have_attributes(error: equal(error), outcome: :discarded) + end + + it "handles cancellation, snoozing, interruption, and deletion" do + worker.define_singleton_method(:work) { |_job| raise River.job_cancel("cancel") } + + expect(described_class.perform_job(client, insert.id)).to have_attributes(error: be_a(River::JobCancelError), outcome: :cancelled) + worker.define_singleton_method(:work) { |_job| raise River.job_snooze(60) } + result = described_class.perform_job(client, insert.id) + + expect(result).to have_attributes(error: be_a(River::JobSnoozeError), outcome: :snoozed) + expect(result.job).to have_attributes(attempt: 0, state: "scheduled") + worker.define_singleton_method(:work) { |_job| raise River::ClientRuntime::Interrupted } + + expect(described_class.perform_job(client, insert.id).outcome).to eq(:interrupted) + worker.define_singleton_method(:work) { |_job| } + deleting = River::Client.new(client.driver, config: River::Config.new( + plugins: [Object.new.tap { |p| p.define_singleton_method(:job_finalize) { |_job, _state| :delete } }], + workers: client.config.workers + )) + + expect(described_class.perform_job(deleting, insert.id)).to have_attributes(error: nil, job: nil, outcome: :deleted) + end + + it "requires explicit early execution and preserves scheduled_at" do + row = insert(1, scheduled_at: Time.now.utc + 3_600) + + expect { described_class.perform_job(client, row.id) }.to raise_error(ArgumentError, /future/) + result = described_class.perform_job(client, row.id, allow_scheduled: true) + + expect(result.job).to have_attributes(scheduled_at: row.scheduled_at, state: "completed") + expect { described_class.perform_job(client, row.id, allow_scheduled: true) }.to raise_error(ArgumentError) + expect { described_class.perform_job(client, -1) }.to raise_error(ArgumentError) + end + + it "refuses active clients and nested execution, and can run after stop" do + row = insert + client.start + + expect { described_class.perform_job(client, row.id) }.to raise_error(River::ClientAlreadyStartedError) + client.stop + operation = ->(_job) do + expect { client.start }.to raise_error(River::ClientAlreadyStartedError) + expect { described_class.perform_job(client, row.id) }.to raise_error(River::ClientAlreadyStartedError) + end + worker.define_singleton_method(:work) { |job| operation.call(job) } + + expect(described_class.perform_job(client, row.id).outcome).to eq(:completed) + end + + it "drains due jobs and their children without running other queues or future jobs" do + seen = [] + operation = ->(job) do + seen << job.args.fetch("value") + insert(3) if job.args.fetch("value") == 2 + end + worker.define_singleton_method(:work) { |job| operation.call(job) } + insert(2, priority: 2) + insert(1, priority: 1) + insert(4, queue: "other") + insert(5, scheduled_at: Time.now.utc + 3_600) + + expect(described_class.drain(client, max_jobs: 3, queue: :default).map(&:outcome)).to eq([:completed] * 3) + expect(seen).to eq([1, 2, 3]) + expect(described_class.drain(client, queue: "empty")).to eq([]) + end + + it "bounds attempts, including self-enqueuing workers, and validates the limit" do + operation = -> { insert } + worker.define_singleton_method(:work) { |_job| operation.call } + insert + + expect { described_class.drain(client, max_jobs: 2, queue: "default") }.to raise_error(River::Testing::DrainLimitError) + expect { described_class.drain(client, max_jobs: 0, queue: "default") }.to raise_error(ArgumentError) + expect { described_class.drain(client, max_jobs: nil, queue: "default") }.to raise_error(ArgumentError) + end +end diff --git a/ruby/spec/unique_args_spec.rb b/ruby/spec/unique_args_spec.rb new file mode 100644 index 000000000..536b7736a --- /dev/null +++ b/ruby/spec/unique_args_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe River::UniqueArgs do + it "retains raw values and ignores outer whitespace" do + expect(described_class.encode(" { \"z\": -0, \"a\": { \"nested\": [1e0, 2] } } ", true)) + .to eq('{"a":{ "nested": [1e0, 2] },"z":-0}') + end + + it "preserves token offsets with JSON whitespace and multibyte characters" do + json = "\t{\r\n \"é🌊\" \t:\n [ \"雪\", {\"escaped\": \"\\u003c\"} ],\r \"number\": -0\n} \r\n" + expect(described_class.encode(json, true)).to eq('{"number":-0,"é🌊":[ "雪", {"escaped": "\u003c"} ]}') + end + + it "preserves long strings containing escaped quotes and structural characters" do + raw_value = JSON.generate("\"\\{},[]:\n雪" * 10_000) + expect(described_class.encode("{\"value\":#{raw_value},\"a\":1}", true)).to eq("{\"a\":1,\"value\":#{raw_value}}") + end + + it "stops at an unterminated string instead of searching past escaped quotes" do + json = '{"' + '\"' * 8_000 + ":1}" + # Recovering tokens after the opening quote both invents a member and makes + # the scan quadratic. Check that it stops without relying on timing limits. + expect(described_class.members(json)).to eq({}) + expect { described_class.encode(json, true) }.to raise_error(JSON::ParserError) + end + + it "sorts all arguments for an empty selection" do + expect(described_class.encode('{"z":0,"a":1}', [])).to eq('{"a":1,"z":0}') + end + + it "distinguishes literal field names from nested paths" do + expect(described_class.encode('{"user.id":1,"user":{"id":2}}', ["user.id", [:user, :id]])) + .to eq('{"user":{"id":2},"user.id":1}') + end + + it "omits missing fields but retains null and false" do + expect(described_class.encode('{"null":null,"false":false,"scalar":1}', [:null, "false", [:scalar, :missing]])) + .to eq('{"false":false,"null":null}') + end + + it "keeps a selected whole object when a child is also selected" do + expect(described_class.encode('{"account":{ "z":-0, "id":1 }}', [[:account, :id], :account])) + .to eq('{"account":{ "z":-0, "id":1 }}') + end + + it "rejects empty paths and invalid JSON" do + expect { described_class.encode("{}", [[]]) }.to raise_error(ArgumentError, /paths must not be empty/) + expect { described_class.encode("{", true) }.to raise_error(JSON::ParserError) + end + + it "truncates periods against Go's year-one epoch without rounding across a boundary" do + client = River::Client.new(Object.new) + boundary = Time.utc(2026, 1, 2, 3, 4, 5) + expect(client.send(:truncate_time, boundary - Rational(1, 1_000_000_000), 1)).to eq(boundary - 1) + time = Time.at(0).utc + # Unix epoch is not divisible by seven seconds measured from Go's epoch. + expect(client.send(:truncate_time, time, 7)).to eq(Time.at(-4)) + end +end diff --git a/ruby/spec/unique_bitmask_spec.rb b/ruby/spec/unique_bitmask_spec.rb new file mode 100644 index 000000000..faaa56d66 --- /dev/null +++ b/ruby/spec/unique_bitmask_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../driver/riverqueue-sequel/spec/spec_helper" + +RSpec.describe River::UniqueBitmask do + describe ".from_states" do + it "converts an array of states to a bitmask string" do + expect(described_class.from_states(River::Client.const_get(:DEFAULT_UNIQUE_STATES))).to eq("11110101") + expect(described_class.from_states([River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED])).to eq("11010001") + expect(described_class.from_states([River::JOB_STATE_AVAILABLE])).to eq("00000001") + end + end + + describe ".to_states" do + it "converts a bitmask string to an array of states" do + expect(described_class.to_states(0b11110101)).to eq(River::Client.const_get(:DEFAULT_UNIQUE_STATES)) + expect(described_class.to_states(0b11010001)).to eq([River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED]) + expect(described_class.to_states(0b00000001)).to eq([River::JOB_STATE_AVAILABLE]) + end + end +end diff --git a/ruby/spec/upstream_runtime_spec.rb b/ruby/spec/upstream_runtime_spec.rb new file mode 100644 index 000000000..0e1909f47 --- /dev/null +++ b/ruby/spec/upstream_runtime_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require "spec_helper" +require "riverqueue-sequel" +require "riverqueue/testing" +require_relative "support/river_sqlite_schema_fixture" +require_relative "row_decoding_shared_examples" +require_relative "runtime_draining_shared_examples" +require_relative "runtime_finishing_shared_examples" + +RSpec.describe "upstream runtime parity", database: :sqlite do + it_behaves_like "SQLite corrupt job runtime" + it_behaves_like "cancellation while draining" + it_behaves_like "externally claimed job finalization" + + before do + @database = Sequel.sqlite + @database.synchronize { |connection| RiverSQLiteSchemaFixture.load(connection) } + @driver = River::Driver::Sequel.new(@database) + end + + after do + @client&.stop_and_cancel + @database.disconnect + end + + def build_client(**options) + @client = River::Client.new(@driver, config: River::Config.new( + queues: {default: 2}, fetch_cooldown: 0.001, fetch_poll_interval: 0.005, **options + )) + end + + def receive(queue) + Timeout.timeout(5) { queue.pop } + end + + it "finalizes attempts whose workers produce invalid JSON metadata" do + worker = Object.new + worker.define_singleton_method(:work) do |job| + job.update_metadata("kept" => true) + job.output = Float::NAN + end + client = build_client(workers: River::Workers.new.add(:known, worker)) + row = client.insert(River::JobArgsHash.new(:known, {}), max_attempts: 1).job + + result = River::Testing.perform_job(client, row.id) + expect(result).to have_attributes(outcome: :discarded, error: be_a(JSON::GeneratorError)) + expect(result.job).to have_attributes(state: "discarded", errors: contain_exactly(have_attributes(error: include("NaN")))) + expect(result.job.metadata).to include("kept" => true) + expect(result.job.metadata).not_to have_key("output") + end + + it "reports a decode failure through synchronous execution without invoking the worker" do + worker = Object.new + worker.define_singleton_method(:work) { |_| raise "must not work a partial row" } + client = build_client(workers: River::Workers.new.add(:known, worker)) + row = client.insert(River::JobArgsHash.new(:known, {}), max_attempts: 1).job + @database[:river_job].where(id: row.id).update(tags: "{") + + result = River::Testing.perform_job(client, row.id) + expect(result).to have_attributes(outcome: :discarded, error: be_a(River::JobRowDecodeError)) + expect(result.job).to have_attributes(state: "discarded", errors: contain_exactly(have_attributes(error: include("tags:")))) + end + + it "does not emit completion events for rows still running, pending, or in an unknown state" do + client = build_client + events = client.subscribe(:job_completed) + row = client.insert(River::JobArgsHash.new(:known, {})).job + @driver.define_singleton_method(:job_complete) { |**| row } + + %w[running pending future_state].each do |state| + row.state = state + client.__finish_claimed_job(row) + end + expect { events.pop(true) }.to raise_error(ThreadError) + ensure + events&.close + end + + it "allows eligible clients to manage periodic jobs" do + expect(build_client.periodic_jobs).to be_a(River::PeriodicJobBundle) + end + + it "fetches only registered kinds and aliases captured at startup" do + workers = River::Workers.new + client = build_client(workers: workers, fetch_only_known_kinds: true) + worker = Class.new { def work(_job) = nil } + workers.add(:known, worker, aliases: [:old_kind]) + unknown, *known = [:unknown, :known, :old_kind].map { |kind| client.insert(River::JobArgsHash.new(kind, {})).job } + events = client.subscribe(:job_completed) + + client.start + expect(2.times.map { receive(events).job.id }).to match_array(known.map(&:id)) + client.stop + expect(client.job_get(unknown.id)).to have_attributes(attempt: 0, state: "available") + end + + it "does not turn an empty kind registry into an unfiltered fetch" do + client = build_client(fetch_only_known_kinds: true) + row = client.insert(River::JobArgsHash.new(:unknown, {})).job + fetches = Queue.new + @driver.define_singleton_method(:job_get_available) do |**options| + result = super(**options) + fetches << options + result + end + + client.start + expect(receive(fetches).fetch(:kinds)).to eq([]) + client.stop + expect(client.job_get(row.id)).to have_attributes(attempt: 0, state: "available") + end + + it "runs configured and dynamically added queues without leadership or maintenance" do + worker = Class.new { def work(_job) = nil } + client = build_client(workers: River::Workers.new.add(:known, worker), leader_election_disabled: true) + maintenance_calls = Queue.new + [:leader_acquire, :leader_renew, :leader_release, :job_schedule, :job_rescue_stuck, :job_delete_finalized, :notification_delete_before].each do |method| + @driver.define_singleton_method(method) { |*args, **options| maintenance_calls << method } + end + expect { client.periodic_jobs }.to raise_error(ArgumentError, /leader_election_disabled/) + events = client.subscribe(:job_completed) + client.insert(River::JobArgsHash.new(:known, {})) + + client.start + client.queue_add(:dynamic, 1) + client.insert(River::JobArgsHash.new(:known, {}), insert_opts: River::InsertOpts.new(queue: :dynamic)) + expect(2.times.map { receive(events).job.queue }).to match_array(%w[default dynamic]) + client.stop + expect(maintenance_calls).to be_empty + expect(@database[:river_leader].count).to eq(0) + end + + it "does not lose a cancellation committed while a fetch is returning" do + claimed = Queue.new + release = Queue.new + worker = Object.new + worker.define_singleton_method(:work) { |_job| Queue.new.pop } + client = build_client(workers: River::Workers.new.add(:known, worker), leader_election_disabled: true) + row = client.insert(River::JobArgsHash.new(:known, {})).job + events = client.subscribe(:job_cancelled) + @driver.define_singleton_method(:job_get_available) do |**options| + jobs = super(**options) + unless jobs.empty? + claimed << true + release.pop + end + jobs + end + + client.start + receive(claimed) + client.job_cancel(row.id) + release << true + expect(receive(events).job).to have_attributes(id: row.id, state: "cancelled") + ensure + release << true if release + end + + it "gives the error handler the row for each separately reported job" do + handled = [] + client = build_client(error_handler: ->(_error, job) { handled << [job.id, job.args] }) + rows = [1, 2].map do |value| + inserted = client.insert(River::JobArgsHash.new(:known, {value: value}), insert_opts: River::InsertOpts.new(max_attempts: 1)).job + @driver.job_claim(id: inserted.id, attempted_by: "test") + end + rows.each { |row| client.__finish_claimed_job(row, RuntimeError.new("failed")) } + expect(handled).to eq(rows.map { |row| [row.id, row.args] }) + end + + it "keeps other running jobs' outcomes independent of a remote cancellation" do + started = Queue.new + release = Queue.new + worker = Object.new + worker.define_singleton_method(:work) do |job| + started << job.id + release.pop + raise "peer failed" if job.args.fetch("role") == "failure" + end + worker.define_singleton_method(:next_retry) { |_job, _error| Time.now.utc + 60 } + client = build_client(queues: {default: 3}, workers: River::Workers.new.add(:known, worker), leader_election_disabled: true) + cancelled, failed, completed = %w[cancel failure success].map do |role| + client.insert(River::JobArgsHash.new(:known, {role: role})).job + end + events = client.subscribe(:job_cancelled, :job_failed, :job_completed) + + client.start + expect(3.times.map { receive(started) }).to match_array([cancelled.id, failed.id, completed.id]) + canceller = River::Client.new(River::Driver::Sequel.new(@database)) + canceller.job_cancel(cancelled.id) + expect(receive(events)).to have_attributes(kind: :job_cancelled, job: have_attributes(id: cancelled.id, state: "cancelled")) + + 2.times { release << true } + expect(2.times.map { receive(events).kind }).to match_array([:job_failed, :job_completed]) + client.stop + expect(client.job_get(failed.id)).to have_attributes(state: "retryable", errors: contain_exactly(have_attributes(error: "peer failed"))) + expect(client.job_get(completed.id)).to have_attributes(state: "completed", errors: []) + ensure + events&.close + end + + it "rejects a SQLite batch that would update the same unique row twice" do + client = build_client + options = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_args: true)) + batch = [1, 2, 2].map do |value| + River::InsertManyParams.new(River::JobArgsHash.new(:known, {value: value}), insert_opts: options) + end + expect { client.insert_many(batch) }.to raise_error(ArgumentError, "unique key appears more than once in batch") + expect(client.job_list.jobs).to be_empty + expect(@database[:river_notification].count).to eq(0) + end + + it "allows unindexed SQLite unique keys and gives every inserted row a separate nonce" do + client = build_client + params = [[nil, nil], ["", nil], ["key", nil], ["key", "00000000"], ["key", "00010000"]].map do |key, states| + param = client.send(:make_insert_params, River::JobArgsHash.new(:known, {}), River::InsertOpts.new) + param.unique_key = key + param.unique_states = states + param + end + results = @driver.job_insert_many(params) + expect(results.map(&:last)).to eq([false] * params.length) + nonces = results.map { |row, _duplicate| row.metadata.fetch("river:unique_nonce") } + expect(nonces).to all(match(/\A[0-9a-f]{16}\z/)) + expect(nonces.uniq.length).to eq(params.length) + end +end diff --git a/ruby/spec/worker_process_shared_examples.rb b/ruby/spec/worker_process_shared_examples.rb new file mode 100644 index 000000000..eb254e492 --- /dev/null +++ b/ruby/spec/worker_process_shared_examples.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "open3" +require "timeout" +require "tmpdir" + +RSpec.shared_examples "dedicated worker process" do + def process_boot + if @driver.respond_to?(:connection_class) + options = @driver.connection_class.connection_db_config.configuration_hash + <<~RUBY + require "riverqueue-activerecord" + ActiveRecord::Base.establish_connection(#{options.inspect}) + driver = River::Driver::ActiveRecord.new + RUBY + else + options = @driver.instance_variable_get(:@db).opts.slice(:adapter, :database, :host, :port, :user, :password, :search_path, :max_connections, :timeout) + <<~RUBY + require "riverqueue-sequel" + driver = River::Driver::Sequel.new(Sequel.connect(#{options.inspect})) + RUBY + end + end + + def with_worker_process(stop_timeout: 30) + Dir.mktmpdir("river-worker-process-") do |directory| + path = File.join(directory, "river.rb") + File.write(path, process_boot + <<~RUBY) + class ProcessTestWorker + def self.kind = "process_test" + def timeout(_job) = nil + def work(job) + puts "attempt entered" + $stdout.flush + sleep(60) if job.args["block"] + job.output = {"worked" => true} + end + end + River::Client.new(driver, config: River::Config.new( + fetch_cooldown: 0.001, + fetch_poll_interval: 0.01, + queues: {"process_test" => 1}, + workers: River::Workers.new.add(ProcessTestWorker) + )) + RUBY + command = File.expand_path("../exe/river", __dir__) + Open3.popen2e(RbConfig.ruby, command, "worker", "--config", path, "--stop-timeout", stop_timeout.to_s) do |input, output, process| + input.close + begin + yield output, process + ensure + Process.kill("KILL", process.pid) if process.alive? + process.join + end + end + end + end + + def await_output(output, text) + seen = +"" + Timeout.timeout(10) do + loop do + line = output.gets + raise "Worker exited before #{text.inspect}: #{seen}" unless line + seen << line + return seen if line.include?(text) + end + end + end + + it "boots, executes a real job, handles TSTP, and exits cleanly after TERM" do + client = River::Client.new(@driver) + row = client.insert(River::JobArgsHash.new("process_test", {}), insert_opts: River::InsertOpts.new(queue: "process_test")).job + with_worker_process do |output, process| + await_output(output, "ready pid=") + Timeout.timeout(10) do + sleep(0.01) until client.job_get(row.id).state == River::JOB_STATE_COMPLETED + end + + Process.kill("TSTP", process.pid) + await_output(output, "stop requested") + expect(process.alive?).to be true + Process.kill("TERM", process.pid) + expect(Timeout.timeout(10) { process.value.exitstatus }).to eq(0) + expect(client.job_get(row.id)).to have_attributes( + attempt: 1, + metadata: include("output" => {"worked" => true}), + state: River::JOB_STATE_COMPLETED + ) + end + end + + it "interrupts an active attempt at the deadline and makes it available again" do + client = River::Client.new(@driver) + row = client.insert(River::JobArgsHash.new("process_test", {"block" => true}), insert_opts: River::InsertOpts.new(queue: "process_test")).job + with_worker_process(stop_timeout: 0) do |output, process| + await_output(output, "attempt entered") + Process.kill("INT", process.pid) + expect(Timeout.timeout(10) { process.value.exitstatus }).to eq(1) + expect(output.read).to include("interrupting active attempts", "stopped") + expect(client.job_get(row.id)).to have_attributes(attempt: 0, state: River::JOB_STATE_AVAILABLE) + end + end +end diff --git a/ruby/spec/worker_runner_spec.rb b/ruby/spec/worker_runner_spec.rb new file mode 100644 index 000000000..f8a2d7f47 --- /dev/null +++ b/ruby/spec/worker_runner_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "spec_helper" +require "stringio" + +require_relative "support/runner_test_client" + +RSpec.describe River::WorkerRunner do + let(:out) { StringIO.new } + + it "validates deadlines, client state, queues, and the main thread" do + [-1, Float::INFINITY, Float::NAN].each do |invalid| + expect { described_class.new(RunnerTestClient.new, stop_timeout: invalid) }.to raise_error(ArgumentError) + expect { described_class.new(RunnerTestClient.new, finalization_timeout: invalid) }.to raise_error(ArgumentError) + end + + client = RunnerTestClient.new + client.define_singleton_method(:started?) { true } + expect { described_class.new(client, out: out).run }.to raise_error(ArgumentError, /stopped client/) + expect { described_class.new(River::Client.new(Object.new), out: out).run }.to raise_error(ArgumentError, /queue/) + + error = Thread.new do + described_class.new(RunnerTestClient.new, out: out).run + rescue ArgumentError => e + e + end.value + expect(error.message).to include("main thread") + end + + %w[INT TERM].each do |signal| + it "drains after #{signal} and restores the previous signal handler" do + handler = proc {} + previous = Signal.trap(signal, handler) + expect(described_class.new(RunnerTestClient.new(signals: [signal]), out: out).run).to eq(0) + expect(Signal.trap(signal, handler)).to equal(handler) + expect(out.string).to include("starting", "ready pid=", "draining", "stopped") + ensure + Signal.trap(signal, previous) + end + end + + it "handles TSTP followed by TERM through nonblocking stop" do + client = RunnerTestClient.new(signals: %w[TSTP TERM]) + expect(described_class.new(client, out: out).run).to eq(0) + expect(out.string).to include("stop requested", "stopped") + end + + it "interrupts attempts when the grace period expires" do + client = RunnerTestClient.new(stall: true) + expect(described_class.new(client, out: out, stop_timeout: 0).run).to eq(1) + expect(client.interruptions).to eq(1) + expect(out.string).to include("interrupting active attempts", "stopped") + end + + it "escalates a second stop signal before the deadline" do + client = RunnerTestClient.new(signals: %w[TERM INT], stall: true) + expect(described_class.new(client, out: out, stop_timeout: 60).run).to eq(1) + expect(client.interruptions).to eq(1) + end + + it "stops unsuccessfully when a runtime thread exits, without a signal" do + client = RunnerTestClient.new(healthy: false, signals: []) + expect(described_class.new(client, out: out).run).to eq(1) + expect(out.string).to include("runtime thread exited unexpectedly") + end + + it "continues waiting while healthy and no stop has been requested" do + client = RunnerTestClient.new(signals: []) + polls = 0 + client.define_singleton_method(:__runtime_healthy?) do + polls += 1 + Process.kill("TERM", Process.pid) if polls == 2 + true + end + expect(described_class.new(client, out: out).run).to eq(0) + expect(polls).to be >= 3 + end + + it "forces process exit if interrupted work cannot finalize" do + client = RunnerTestClient.new(stall: true, unresponsive: true) + exits = [] + forced_exit = Class.new(StandardError) + original_exit = Process.method(:exit!) + Process.define_singleton_method(:exit!) do |status| + exits << status + raise forced_exit + end + expect { described_class.new(client, finalization_timeout: 0, out: out, stop_timeout: 0).run }.to raise_error(forced_exit) + expect(exits).to eq([1]) + expect(out.string).to include("forcing process exit") + ensure + Process.define_singleton_method(:exit!, original_exit) + client.release + client.stop_thread&.join + end + + it "restores signal handlers on boot failure" do + client = RunnerTestClient.new + client.define_singleton_method(:job_list) { |_params| raise "database unavailable" } + handler = proc {} + previous = Signal.trap("TERM", handler) + expect { described_class.new(client, out: out).run }.to raise_error("database unavailable") + expect(Signal.trap("TERM", handler)).to equal(handler) + ensure + Signal.trap("TERM", previous) + end +end diff --git a/ruby/spec/worker_spec.rb b/ruby/spec/worker_spec.rb new file mode 100644 index 000000000..f89bb12c9 --- /dev/null +++ b/ruby/spec/worker_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe River::Workers do + it "registers and fetches a worker under an explicit kind" do + worker = Object.new + registry = described_class.new.add("email", worker) + + expect(registry.fetch("email")).to equal(worker) + expect(registry).to include("email") + end + + it "infers kind from a worker instance" do + worker = Class.new { def kind = :instance_kind }.new + registry = described_class.new.add(worker) + + expect(registry.fetch("instance_kind")).to equal(worker) + end + + it "infers kind from an instance's worker class" do + worker = Class.new { def self.kind = :class_kind }.new + registry = described_class.new.add(worker) + + expect(registry.fetch("class_kind")).to equal(worker) + end + + it "infers kind from a worker class" do + worker_class = Class.new { def self.kind = :class_kind } + registry = described_class.new.add(worker_class) + + expect(registry.fetch("class_kind")).to equal(worker_class) + end + + it "registers aliases as strings" do + worker = Object.new + registry = described_class.new.add(:primary, worker, aliases: [:old_name, "legacy"]) + + expect(registry.kinds).to contain_exactly("primary", "old_name", "legacy") + expect(registry.fetch("old_name")).to equal(worker) + expect(registry.fetch(:primary)).to equal(worker) + expect(registry.fetch(:old_name)).to equal(worker) + expect(registry).to include(:primary, :old_name, :legacy) + expect(registry).not_to include(:missing) + end + + it "rejects a duplicate primary kind" do + registry = described_class.new.add("email", Object.new) + + expect { registry.add("email", Object.new) } + .to raise_error(ArgumentError, 'worker for kind "email" is already registered') + end + + it "rejects a duplicate alias without partially registering the worker" do + registry = described_class.new.add("existing", Object.new) + + expect { registry.add("fresh", Object.new, aliases: ["existing"]) } + .to raise_error(ArgumentError, 'worker for kind "existing" is already registered') + expect(registry).not_to include("fresh") + end + + it "returns nil for an unknown kind" do + expect(described_class.new[:missing]).to be_nil + end + + it "supports bracket lookup for registered kinds" do + worker = Object.new + registry = described_class.new.add(:known, worker) + + expect(registry[:known]).to equal(worker) + expect(registry["known"]).to equal(worker) + end + + it "fetches with Hash-like missing-key, default, and block behavior" do + worker = Object.new + registry = described_class.new.add(:known, worker) + + expect { registry.fetch(:missing) }.to raise_error(KeyError) + expect(registry.fetch(:missing, nil)).to be_nil + expect(registry.fetch(:missing, false)).to be false + expect(registry.fetch(:missing) { |kind| "fallback for #{kind}" }).to eq("fallback for missing") + expect(registry.fetch(:known) { raise "must not evaluate fallback" }).to equal(worker) + end + + it "returns a frozen snapshot of registered kinds" do + registry = described_class.new.add("one", Object.new) + kinds = registry.kinds + registry.add("two", Object.new) + + expect(kinds).to eq(["one"]) + expect(kinds).to be_frozen + end +end + +RSpec.describe River::Job do + let(:row) do + River::JobRow.new( + id: 123, + args: {"value" => 1}, + attempt: 1, + created_at: Time.now.utc, + kind: "example", + max_attempts: 3, + metadata: {"original" => true}, + priority: 1, + queue: "default", + scheduled_at: Time.now.utc, + state: River::JOB_STATE_RUNNING + ) + end + + let(:job) { described_class.new(Object.new, row) } + + it "exposes arguments and delegates persisted attributes" do + expect(job).to have_attributes( + id: 123, + args: {"value" => 1}, + kind: "example" + ) + expect(job).to respond_to(:scheduled_at) + end + + it "merges metadata updates without mutating the persisted row" do + expect(job.update_metadata(updated: 2)).to equal(job) + + expect(job.metadata).to eq("original" => true, "updated" => 2) + expect(row.metadata).to eq("original" => true) + end + + it "stores worker output in metadata" do + job.output = {"answer" => 42} + + expect(job.metadata).to include("output" => {"answer" => 42}) + end + + it "returns a defensive copy of pending metadata updates" do + job.update_metadata("one" => 1) + copy = job.metadata_updates + copy["two"] = 2 + + expect(job.metadata_updates).to eq("one" => 1) + end + + it "snapshots JSON metadata and prevents nested mutations from corrupting pending updates" do + value = {"nested" => [1]} + job.update_metadata(value: value) + value["nested"] << Float::NAN + job.metadata["value"]["nested"] << Float::NAN + job.metadata_updates["value"]["nested"] << Float::NAN + + expect(job.metadata_updates).to eq("value" => {"nested" => [1]}) + end + + it "rejects invalid JSON without replacing previously valid metadata" do + job.update_metadata(value: 1) + expect { job.update_metadata(value: Float::INFINITY) }.to raise_error(JSON::GeneratorError) + expect(job.metadata_updates).to eq("value" => 1) + end + + it "raises normally for an unknown delegated method" do + expect { job.not_a_job_attribute }.to raise_error(NoMethodError) + expect(job).not_to respond_to(:not_a_job_attribute) + end +end + +RSpec.describe River::DefaultClientRetryPolicy do + def random_returning(value) + Object.new.tap { |random| random.define_singleton_method(:rand) { value } } + end + + it "uses quartic backoff based on the next error count" do + policy = described_class.new(random: random_returning(0.5)) + job = Struct.new(:errors).new([Object.new, Object.new]) + now = Time.utc(2026, 1, 1) + + expect(policy.next_retry(job, now: now)).to eq(now + 81) + end + + it "applies up to ten percent negative jitter" do + policy = described_class.new(random: random_returning(0.0)) + now = Time.utc(2026, 1, 1) + + expect(policy.next_retry(Struct.new(:errors).new([]), now: now)).to be_within(0.000001).of(now + 0.9) + end + + it "applies up to ten percent positive jitter" do + policy = described_class.new(random: random_returning(1.0)) + now = Time.utc(2026, 1, 1) + + expect(policy.next_retry(Struct.new(:errors).new(nil), now: now)).to be_within(0.000001).of(now + 1.1) + end + + it "caps large error counts at Go's exact maximum duration without jitter" do + random = Object.new + random.define_singleton_method(:rand) { raise "capped retries must not apply jitter" } + policy = described_class.new(random: random) + now = Time.utc(2026, 1, 1) + [309, 1_000].each do |count| + job = Struct.new(:errors).new(Array.new(count)) + expect(policy.next_retry(job, now: now).to_r - now.to_r).to eq(Rational((1 << 63) - 1, 1_000_000_000)) + end + end + + it "caps again if positive jitter takes the delay past the maximum" do + policy = described_class.new(random: random_returning(1.0)) + now = Time.utc(2026, 1, 1) + job = Struct.new(:errors).new(Array.new(308)) + expect(policy.next_retry(job, now: now).to_r - now.to_r).to eq(Rational((1 << 63) - 1, 1_000_000_000)) + end +end diff --git a/ruby/spec/yugabyte_shared_examples.rb b/ruby/spec/yugabyte_shared_examples.rb new file mode 100644 index 000000000..8292a6ea9 --- /dev/null +++ b/ruby/spec/yugabyte_shared_examples.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "timeout" + +RSpec.shared_examples "Yugabyte driver compatibility" do |notifications| + let(:client) { River::Client.new(@driver) } + let(:unique_options) { River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_args: true)) } + + def args(value) + River::JobArgsHash.new(:yugabyte_test, {value: value}) + end + + it "detects capabilities lazily and caches only successful detection" do + queries = 0 + @driver.define_singleton_method(:runtime_query_rows) do |sql| + if sql.include?("current_setting('server_version_num')") + queries += 1 + raise "temporary detection failure" if queries == 1 + end + super(sql) + end + expect { @driver.init_driver }.to raise_error("temporary detection failure") + @driver.init_driver + capabilities = @driver.postgres_capabilities + expect(capabilities).to have_attributes(unique_insert_mode: :metadata_nonce, unique_insert_sql: "false", supports_listen_notify: !!notifications) + expect(@driver.postgres_capabilities).to equal(capabilities) + row = client.insert(args(0)).job + client.job_cancel(row.id) + expect(queries).to eq(2) + end + + it "detects conflicts in a mixed batch without changing application metadata" do + original = client.insert(args(1), insert_opts: unique_options).job + metadata = {"keep" => {"nested" => [1, nil]}, "river:unique_nonce" => "application value"}.freeze + options = River::InsertOpts.new(metadata: metadata, unique_opts: River::UniqueOpts.new(by_args: true)) + results = client.insert_many([ + River::InsertManyParams.new(args(1), insert_opts: options), + River::InsertManyParams.new(args(2), insert_opts: options), + args(3) + ]) + + expect(results.map(&:unique_skipped_as_duplicate?)).to eq([true, false, false]) + expect(results.first.job).to have_attributes(id: original.id, metadata: original.metadata) + expect(results[1].job.metadata).to include("keep" => metadata.fetch("keep"), "river:unique_nonce" => match(/\A[0-9a-f]{16}\z/)) + expect(results[2].job.metadata["river:unique_nonce"]).to eq(results[1].job.metadata["river:unique_nonce"]) + expect(metadata["river:unique_nonce"]).to eq("application value") + end + + it "recognizes duplicates written without a valid nonce, including by Go" do + [{}, {"river:unique_nonce" => nil}, {"river:unique_nonce" => 123}, {"river:unique_nonce" => {"nested" => true}}].each_with_index do |metadata, index| + original = client.insert(args(index), insert_opts: unique_options).job + client.job_update(original.id, River::JobUpdateParams.new(metadata: metadata)) + duplicate = client.insert(args(index), insert_opts: unique_options) + expect(duplicate).to have_attributes(unique_skipped_as_duplicate?: true, job: have_attributes(id: original.id, metadata: metadata)) + end + end + + it "rolls back a batch that upserts the same indexed row twice" do + repeated = River::InsertManyParams.new(args(1), insert_opts: unique_options) + expect { client.insert_many([args(0), repeated, repeated]) }.to raise_error(StandardError) + expect(client.job_list.jobs).to be_empty + end + + it "keeps inserts and cancellation in the application's transaction" do + kept = client.insert(args(1)).job + @driver.transaction do + client.insert(args(2)) + client.job_cancel(kept.id) + raise @driver.rollback_exception + end + expect(client.job_list.jobs).to contain_exactly(have_attributes(id: kept.id, state: "available", metadata: kept.metadata)) + end + + it "elects, renews, and replaces leaders without requiring notifications" do + now = Time.utc(2026, 1, 2) + expect(@driver.leader_acquire("first", now: now)).to be true + expect(@driver.leader_acquire("second", now: now)).to be false + expect(@driver.leader_renew("first", now: now + 1)).to be true + @driver.leader_release("first") + expect(@driver.leader_acquire("second", now: now + 2)).to be true + @driver.leader_release("second") + end + + it "polls committed remote cancellations while notifications are unavailable" do + entered = Queue.new + worker = Object.new + worker.define_singleton_method(:work) do |_job| + entered << true + Queue.new.pop + end + consumer = River::Client.new(@driver, config: River::Config.new( + workers: River::Workers.new.add(:yugabyte_test, worker), queues: {default: 1}, + fetch_cooldown: 0.001, fetch_poll_interval: 0.01 + )) + events = consumer.subscribe(:job_cancelled) + publisher = River::Client.new(new_driver.call) + row = publisher.insert(args(1)).job + consumer.start + Timeout.timeout(10) { entered.pop } + publisher.driver.transaction do + publisher.job_cancel(row.id) + observer = Thread.new { consumer.job_get(row.id) } + expect(Timeout.timeout(10) { observer.value }.metadata).not_to have_key("cancel_attempted_at") + raise publisher.driver.rollback_exception + end + publisher.driver.transaction { publisher.job_cancel(row.id) } + expect(Timeout.timeout(10) { events.pop }.job).to have_attributes(id: row.id, state: "cancelled") + ensure + consumer&.stop_and_cancel + end +end