diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5c14dba..ae093cf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,7 +23,43 @@ env: on: - push +permissions: + contents: read + jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 3 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + + - name: Read upstream River revision + id: river_revision + run: | + ruby -rjson -e ' + revision = JSON.parse(File.read("migration/manifest.json")).fetch("revision") + abort "Invalid River revision" unless /\A[0-9a-f]{40}\z/.match?(revision) + File.open(ENV.fetch("GITHUB_OUTPUT"), "a") { |file| file.puts("revision=#{revision}") } + ' + + - name: Checkout upstream River + uses: actions/checkout@v4 + with: + path: upstream/river + persist-credentials: false + ref: ${{ steps.river_revision.outputs.revision }} + repository: riverqueue/river + + - name: Verify migrations + run: make verify RIVER_PATH="$GITHUB_WORKSPACE/upstream/river" + gem_build: runs-on: ubuntu-latest timeout-minutes: 3 @@ -50,6 +86,10 @@ jobs: run: gem build riverqueue-sequel.gemspec working-directory: ./driver/riverqueue-sequel + - name: Build gem (riverqueue-rails) + run: gem build riverqueue-rails.gemspec + working-directory: ./rails/riverqueue-rails + lint: runs-on: ubuntu-latest timeout-minutes: 3 @@ -68,6 +108,10 @@ jobs: run: bundle exec standardrb working-directory: . + - name: Frozen string literal comments + run: bundle exec rubocop --config .rubocop-frozen-string-literal.yaml --only Style/FrozenStringLiteralComment + working-directory: . + - name: bundle install (riverqueue-activerecord) run: bundle install working-directory: ./driver/riverqueue-activerecord @@ -195,3 +239,44 @@ jobs: - name: Rspec (riverqueue-sequel) run: bundle exec rspec working-directory: ./driver/riverqueue-sequel + + rails: + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + matrix: + include: + - rails: "~> 7.2.0" + ruby: "3.2" + - rails: "~> 8.0.0" + ruby: "3.3" + - rails: "~> 8.1.0" + ruby: "4.0" + env: + BUNDLE_FROZEN: "false" + RAILS_VERSION: ${{ matrix.rails }} + RIVER_REQUIRE_DATABASES: "1" + services: + postgres: + image: postgres:17 + 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@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - run: bundle install + working-directory: ./rails/riverqueue-rails + - run: bundle exec rspec + working-directory: ./rails/riverqueue-rails + - run: bundle exec standardrb + working-directory: ./rails/riverqueue-rails diff --git a/.rubocop-frozen-string-literal.yaml b/.rubocop-frozen-string-literal.yaml new file mode 100644 index 0000000..9b58f7f --- /dev/null +++ b/.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/CHANGELOG.md b/CHANGELOG.md index 0283d24..6e48000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a full Ruby client for River with Go-compatible job insertion and execution on PostgreSQL and SQLite through ActiveRecord or Sequel. Includes workers, retries, cancellation, periodic and resumable jobs, 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 diff --git a/Gemfile b/Gemfile index ffa97ff..494f5a9 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source "https://rubygems.org" gemspec @@ -9,9 +11,12 @@ 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" diff --git a/Gemfile.lock b/Gemfile.lock index e6bb42c..fc754a6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,16 +2,35 @@ PATH remote: . specs: riverqueue (0.11.0) + logger (> 0, < 1000) + optparse (> 0, < 1000) + securerandom (> 0, < 1000) + timeout (> 0, < 1000) + +PATH + remote: driver/riverqueue-activerecord + specs: + riverqueue-activerecord (0.11.0) + activerecord (> 0, < 1000) + activesupport (> 0, < 1000) + riverqueue (= 0.11.0) PATH remote: driver/riverqueue-sequel specs: riverqueue-sequel (0.11.0) + riverqueue (= 0.11.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 @@ -39,9 +58,14 @@ GEM 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) @@ -62,6 +86,7 @@ GEM 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) @@ -75,6 +100,7 @@ GEM psych (5.3.1) date stringio + raabro (1.5.0) racc (1.8.1) rainbow (3.1.1) rb-fsevent (0.11.2) @@ -159,6 +185,7 @@ GEM 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) @@ -173,8 +200,11 @@ PLATFORMS DEPENDENCIES debug + fugit (~> 1.13) + minitest pg riverqueue! + riverqueue-activerecord! riverqueue-sequel! rspec-core rspec-expectations @@ -184,4 +214,4 @@ DEPENDENCIES steep BUNDLED WITH - 2.6.7 + 4.0.9 diff --git a/Makefile b/Makefile index b6a5d5b..309c560 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ .DEFAULT_GOAL := help +RIVER_PATH ?= ../river +RIVERQUEUE_PRO_PATH ?= ../riverqueue-ruby-pro + # Looks at comments using ## on targets and uses them to produce a help output. .PHONY: help help: ALIGN=14 @@ -11,9 +14,11 @@ 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 + @if [ -f "$(RIVERQUEUE_PRO_PATH)/riverqueue-pro.gemspec" ]; then $(MAKE) -C "$(RIVERQUEUE_PRO_PATH)" install; fi .PHONY: lint -lint: standardrb ## Run linter (standardrb) on gem and all subgems +lint: standardrb frozen-string-literals ## Run linters on gem and all subgems .PHONY: rspec rspec: spec @@ -23,12 +28,21 @@ spec: bundle exec rspec cd driver/riverqueue-activerecord && bundle exec rspec cd driver/riverqueue-sequel && bundle exec rspec + cd rails/riverqueue-rails && bundle exec rspec + @if [ -d driver/riverqueue-redis ]; then cd driver/riverqueue-redis && bundle exec rspec; fi + @if [ -f "$(RIVERQUEUE_PRO_PATH)/riverqueue-pro.gemspec" ]; then $(MAKE) -C "$(RIVERQUEUE_PRO_PATH)" test; fi .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 + @if [ -f "$(RIVERQUEUE_PRO_PATH)/riverqueue-pro.gemspec" ]; then $(MAKE) -C "$(RIVERQUEUE_PRO_PATH)" standardrb; fi + +.PHONY: frozen-string-literals +frozen-string-literals: + bundle exec rubocop --config .rubocop-frozen-string-literal.yaml --only Style/FrozenStringLiteralComment .PHONY: steep steep: @@ -45,3 +59,9 @@ 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 + @if [ -f "$(RIVERQUEUE_PRO_PATH)/riverqueue-pro.gemspec" ]; then $(MAKE) -C "$(RIVERQUEUE_PRO_PATH)" update; fi + +.PHONY: verify +verify: ## Verify bundled migrations against RIVER_PATH (default ../river) + ruby scripts/sync_migrations.rb --check "$(RIVER_PATH)" diff --git a/Steepfile b/Steepfile index ac910d1..646ec8d 100644 --- a/Steepfile +++ b/Steepfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + D = Steep::Diagnostic target :lib do @@ -5,7 +7,12 @@ target :lib do library "digest" library "json" + library "logger" + library "optparse" + library "securerandom" + library "socket" library "time" + library "timeout" signature "sig" diff --git a/docs/README.md b/docs/README.md index 9734d78..deb9884 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,172 +1,629 @@ # River client for Ruby [![Build Status](https://github.com/riverqueue/riverqueue-ruby/workflows/CI/badge.svg)](https://github.com/riverqueue/riverqueue-ruby/actions) [![Gem Version](https://badge.fury.io/rb/riverqueue.svg)](https://badge.fury.io/rb/riverqueue) -An insert-only Ruby client for [River](https://github.com/riverqueue/river) packaged in the [`riverqueue` gem](https://rubygems.org/gems/riverqueue). Allows jobs to be inserted in Ruby and run by a Go worker, but doesn't support working jobs in Ruby. +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. -## Basic usage +## Installation + +Moving an existing application? See [Migrating from Sidekiq](migrating_from_sidekiq.md). -Your project's `Gemfile` should contain the `riverqueue` gem and a driver like [`riverqueue-sequel`](https://github.com/riverqueue/riverqueue-ruby/driver/riverqueue-sequel) (see [drivers](#drivers)): +Add one River driver and only the database adapter your application uses. The driver brings in `riverqueue`: ```ruby -gem "riverqueue" +# Sequel gem "riverqueue-sequel" -``` +gem "pg" # or: gem "sqlite3" -Initialize a client with: +# Active Record +gem "riverqueue-activerecord" +gem "pg" # or: gem "sqlite3" +``` -```ruby -require "riverqueue" -require "riverqueue-activerecord" +Apply River's canonical migrations before using the client. See [Schema and migrations](#schema-and-migrations). -client = River::Client.new(River::Driver::ActiveRecord.new) -``` +## Basic usage -Define a job and insert it: +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_accessor :strings + attr_reader :strings def initialize(strings:) - self.strings = 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]), + insert_opts: River::InsertOpts.new(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. - def to_json = JSON.dump({ strings: strings }) +## 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) -insert_res = client.insert(SortArgs.new(strings: ["whale", "tiger", "bear"])) -insert_res.job # inserted job row +`River::InsertOpts` controls the queue, priority, maximum attempts, schedule, tags, metadata, and uniqueness of a job. Priority `1` is highest. + +```ruby +result = client.insert(args, insert_opts: River::InsertOpts.new( + max_attempts: 10, + metadata: {trace_id: trace_id}, + priority: 1, + queue: :critical, + tags: %w[billing customer-42] +)) ``` -Job args should: +For simple jobs, `River::JobArgsHash.new(:kind, hash)` avoids defining an argument class. + +### [Transactional enqueueing](https://riverqueue.com/docs/transactional-enqueueing) -- Respond to `#kind` with a unique string that identifies them in the database, and which a Go worker will recognize. -- Response to `#to_json` with a JSON serialization that'll be parseable as an object in Go. +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 +``` -They may also respond to `#insert_opts` with an instance of `InsertOpts` to define insertion options that'll be used for all jobs of the kind. +The equivalent works inside `ActiveRecord::Base.transaction` with the Active Record driver. -## Insertion options +### [Bulk insertion](https://riverqueue.com/docs/inserting-many-jobs) -Inserts take an `insert_opts` parameter to customize features of the inserted job: +`#insert_many` inserts a batch atomically and returns one `River::JobInsertResult` per input. Use `River::InsertManyParams` when jobs need different options: ```ruby -insert_res = client.insert( - SortArgs.new(strings: ["whale", "tiger", "bear"]), - insert_opts: River::InsertOpts.new( - max_attempts: 17, - priority: 3, - queue: "my_queue", - tags: ["custom"] +results = client.insert_many([ + SortArgs.new(strings: %w[c b a]), + River::InsertManyParams.new( + SortArgs.new(strings: %w[z y x]), + insert_opts: River::InsertOpts.new(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, insert_opts: River::InsertOpts.new( + scheduled_at: Time.now.utc + 3600 +)) ``` -## Inserting unique jobs +### [Unique jobs](https://riverqueue.com/docs/unique-jobs) -[Unique jobs](https://riverqueue.com/docs/unique-jobs) are supported through `InsertOpts#unique_opts`, and can be made unique by args, period, queue, and state. If a job matching unique properties is found on insert, the insert is skipped and the existing job returned. +`River::UniqueOpts` can make a kind unique by all or selected arguments, time period, queue, and state. A conflict returns the existing job with `unique_skipped_as_duplicated == true`. ```ruby -insert_res = client.insert(args, insert_opts: River::InsertOpts.new( +result = client.insert(args, insert_opts: River::InsertOpts.new( unique_opts: River::UniqueOpts.new( - by_args: true, + by_args: [:account_id], by_period: 15 * 60, - by_queue: true, - by_state: [River::JOB_STATE_AVAILABLE] + 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. + +### [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 rescues stale running jobs after an hour, retrying or discarding them according to their attempt count. `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. + +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. +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. + +### [Error handling and timeouts](https://riverqueue.com/docs/error-handling) -# contains either a newly inserted job, or an existing one if insertion was skipped -insert_res.job +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. -# true if insertion was skipped -insert_res.unique_skipped_as_duplicated +```ruby +config = River::Config.new( + error_handler: ->(error, _job) { :cancel if error.is_a?(PermanentError) }, + job_timeout: 30 +) ``` -## Inserting jobs in bulk +### [Cancelling jobs](https://riverqueue.com/docs/cancelling-jobs) -Use `#insert_many` to bulk insert jobs as a single operation for improved efficiency: +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 -num_inserted = client.insert_many([ - SortArgs.new(strings: ["whale", "tiger", "bear"]), - SortArgs.new(strings: ["lion", "dolphin", "eagle"]) -]) +client.job_cancel job_id + +def work(job) + raise River.job_cancel("account closed") if account_closed?(job) +end ``` -Or with `InsertManyParams`, which may include insertion options: +`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 -num_inserted = client.insert_many([ - River::InsertManyParams.new(SortArgs.new(strings: ["whale", "tiger", "bear"]), insert_opts: River::InsertOpts.new(max_attempts: 5)), - River::InsertManyParams.new(SortArgs.new(strings: ["lion", "dolphin", "eagle"]), insert_opts: River::InsertOpts.new(queue: "high_priority")) -]) +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)`. + +### [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. + +### 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 constructor that returns job arguments, `[arguments, insert_options]`, or `nil` to skip that run. Core periodic schedules live in the client process; River Pro adds durable schedules. + +```ruby +cleanup = River::PeriodicJob.new( + id: :cleanup, + constructor: -> { [CleanupArgs.new, River::InsertOpts.new(queue: :maintenance)] }, + run_on_start: true, + schedule: River::PeriodicInterval.new(3600) +) + +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`, matching River's +Go API. + +Schedules can be callbacks (`schedule: ->(now) { now + 300 }`) or objects +implementing `next(time)`. They compute the next occurrence after the supplied +time; they do not execute the job themselves. + +For calendar schedules, add `gem "fugit", "~> 1.13"` to your Gemfile: + +```ruby +cleanup = River::PeriodicJob.new( + id: :weekday_cleanup, + constructor: -> { CleanupArgs.new }, + schedule: River::PeriodicCron.new("0 9 * * 1-5", timezone: "America/New_York") +) ``` -## Inserting in a transaction +`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; +provide it through `timezone:`, not inside the expression. Five-field cron, +optional seconds, and aliases such as `@daily` use Fugit's syntax. Results are +UTC `Time` objects. Local calendar times follow Fugit's daylight-saving rules; +nonexistent spring-forward times are skipped. Test ambiguous fall-back times +for your schedules. Cron does not change core scheduling durability or replay +missed occurrences after downtime. + +For a one-time date, insert a job with +`InsertOpts.new(scheduled_at: Time.utc(2026, 9, 20, 9))` instead of registering +a periodic job. This stores the scheduled job immediately in the database. -No extra code is needed to insert jobs from inside a transaction. Just make sure that one is open from your ORM of choice, call the normal `#insert` or `#insert_many` methods, and insertions will take part in it. +### [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 -ActiveRecord::Base.transaction do - client.insert(SortArgs.new(strings: ["whale", "tiger", "bear"])) +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. + +`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 -DB.transaction do - client.insert(SortArgs.new(strings: ["whale", "tiger", "bear"])) +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 ``` -## Inserting with a Ruby hash +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) -`JobArgsHash` can be used to insert with a kind and JSON hash so that it's not necessary to define a class: +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. ```ruby -insert_res = client.insert(River::JobArgsHash.new("hash_kind", { - job_num: 1 -})) +def work(job) + job.update_metadata provider_request_id: request_id + job.output = {imported: 42} +end ``` -## RBS and type checking +### Plugins -The gem [bundles RBS files](https://github.com/riverqueue/riverqueue-ruby/tree/master/sig) containing type annotations for its API to support type checking in Ruby through a tool like [Sorbet](https://sorbet.org/) or [Steep](https://github.com/soutaro/steep). +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`: -## Drivers +- 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. -### ActiveRecord +A plugin may implement any combination of these methods: -Use River with [ActiveRecord](https://guides.rubyonrails.org/active_record_basics.html) by putting the `riverqueue-activerecord` driver in your `Gemfile`: +| 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. ```ruby -gem "riverqueue" -gem "riverqueue-activerecord" +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]) ``` -Then initialize driver and client: +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 -ActiveRecord::Base.establish_connection("postgres://...") -client = River::Client.new(River::Driver::ActiveRecord.new) +subscription = client.subscribe( + :job_completed, + :job_failed, + buffer_size: 1_000 +) + +subscription.each { |event| consume(event) } +subscription.close ``` -### Sequel +Events include completed, failed, cancelled, snoozed, and interrupted jobs, plus paused and resumed queues. -Use River with [Sequel](https://github.com/jeremyevans/sequel) by putting the `riverqueue-sequel` driver in your `Gemfile`: +### 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 -gem "riverqueue" -gem "riverqueue-sequel" +list_options = { + limit: 100, + queues: [:bulk], + sort_by: :scheduled_at, + states: [:discarded], + tags_any: ["billing"] +} +page = client.job_list(River::JobListParams.new(**list_options)) + +next_page = client.job_list(River::JobListParams.new(**list_options, after: page.last_cursor)) +client.job_update job_id, River::JobUpdateParams.new(max_attempts: 50) +client.job_delete_many River::JobListParams.new(states: [:cancelled]) +``` + +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. + +### [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. + +### [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. + +### [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. + +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 +``` + +## Schema and migrations + +`River::Migrator` and the bundled `river` command run exact copies of Go's +canonical migrations for PostgreSQL and SQLite. No Go installation is needed: + +```sh +bundle exec river migrate-up --database-url postgres://localhost/my_app +``` + +The command auto-detects `riverqueue-sequel` or `riverqueue-activerecord` from +the available gems, preferring Sequel when both are in your bundle. +For the API, status, dry runs, downgrades, and schema options, see +[migrations](migrations.md). Test schema snapshots under `spec/support` remain +test-only and must not be used to provision production databases. + +For River Pro, apply both the canonical `main` and `pro` migration lines. Ruby and Go clients use the same tables, columns, indexes, generated columns, and triggers. + +## 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. + +## RBS and type checking + +The gem bundles [RBS files](https://github.com/riverqueue/riverqueue-ruby/tree/master/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) ``` -Then initialize driver and client: +### 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. + +### Redis (experimental) + +Add `riverqueue-redis` to use Redis 7+ without either SQL adapter: + +```ruby +require "riverqueue-redis" + +pool = RedisClient.config(url: ENV.fetch("REDIS_URL")).new_pool(size: 10) +client = River::Client.new( + River::Driver::Redis.new(pool, prefix: "my_app", schema: "shared"), + config: River::Config.new( + queues: {ruby: 10}, + workers: River::Workers.new.add(SortWorker) + ) +).start +``` + +Ruby and Go's experimental `riverredisv9` driver can share the same namespace, +using identical job records, indexes, and ID allocation. Stop the client before +closing the pool. No migrations are needed. Redis-only transactions cannot be +atomic with application SQL writes; Rails integration and Pro's SQL-backed +features are not supported. See the [Redis driver guide](../driver/riverqueue-redis/README.md) +for durability requirements, compatibility, and other experimental limitations. + +## 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 + +The Ruby client does not currently provide dedicated OpenTelemetry/metrics +integrations, job-persisted logging, 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/docs/development.md b/docs/development.md index 697bc56..efd76bd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,23 +6,99 @@ $ bundle install $ pushd driver/riverqueue-activerecord && bundle install && popd $ pushd driver/riverqueue-sequel && bundle install && popd +$ pushd rails/riverqueue-rails && bundle install && popd ``` + +Pro lives in the separate private `riverqueue-ruby-pro` repository. If you have +access, check it out alongside this repository and run +`make -C ../riverqueue-ruby-pro install`. Root `make install` also installs its +dependencies when that sibling checkout is present. + +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 and migrate with River's CLI: +Create a test database and migrate with the bundled Ruby command: ```shell -$ go install github.com/riverqueue/river/cmd/river $ createdb river_test -$ river migrate-up --database-url "postgres://localhost/river_test" +$ bundle exec river migrate-up --database-url "postgres://localhost/river_test" ``` -Run all specs: +Run the core, SQL driver packages, and Rails integration, plus Redis and Pro +when their local checkouts are present: ```shell -$ bundle exec rspec spec +$ RIVER_REQUIRE_DATABASES=1 make test +``` + +Real database tests run by default. `RIVER_REQUIRE_DATABASES=1` requires both +PostgreSQL and SQLite to be available instead of permitting local skips. CI +also requires them. Set `TEST_DATABASE_URL` to override the PostgreSQL test +database URL; it must already contain River's migrated tables in `public`. + +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. + +`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 `River::Migrator` with the bundled canonical SQL, not +the shared public job tables. PostgreSQL tests need permission to create and +drop schemas. Shared migration contracts also cover upgrades, downgrades, +legacy history, rollback, and populated data. See [migrations](migrations.md) +for synchronizing the SQL with upstream Go. + +`bundle exec rspec spec` from the repository root runs only the core suite; +use `make test` for the SQL adapter matrix, Rails, and optional Redis and Pro suites. +The optional suites run only when `driver/riverqueue-redis` or the sibling +`../riverqueue-ruby-pro` gem exists. Override `RIVERQUEUE_PRO_PATH` to use another +Pro checkout. Missing packages are skipped; failures in present packages still +fail the test run. Pro also provides its own `make test` and `make lint` targets. + +Redis also has driver-local targets for running its suite independently and +verifying Go interoperability. When working on the local experimental driver, +install Redis 7+ (`redis-server` on PATH) and run: + +```shell +$ make -C driver/riverqueue-redis install +$ make -C driver/riverqueue-redis test +$ make -C driver/riverqueue-redis verify RIVER_PATH=/path/to/river ``` +The Redis suite starts a disposable server on a private Unix socket with +persistence disabled; it never flushes a shared Redis database. It runs the +shared runtime and client contracts plus key/index, conflict, and atomicity tests. +The driver-local `verify` target also compares the bundled Lua scripts +and builds a Go helper that verifies real Go/Ruby interoperability. This requires +a Go checkout containing the experimental `riverredisv9` driver and its Go +toolchain. Redis and Pro are held back from the public release and are not built, +linted, or tested by public CI. Redis remains local to this checkout; the Pro +implementation and its test suite live in their separate private repository. + +## Verify migrations + +Check bundled PostgreSQL and SQLite migrations against the local Go checkout: + +```shell +$ make verify +$ make verify RIVER_PATH=/path/to/river +``` + +`RIVER_PATH` defaults to `../river`. Verification checks the exact SQL files, +license, and manifest, including the upstream commit. It needs only Ruby and Git, +not Go, database access, or installed gems. CI checks out `riverqueue/river` at +the revision in `migration/manifest.json` and runs the same target. This verifies +the recorded source, not whether newer migrations have been published upstream. +See [updating the bundled SQL](migrations.md#updating-the-bundled-sql) to update +the files and recorded revision together. + ## Run lint ```shell @@ -37,7 +113,9 @@ $ bundle exec steep check ## 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: +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 @@ -46,6 +124,9 @@ $ 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 @@ -54,15 +135,21 @@ $ open coverage/index.html 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-redis/riverqueue-redis.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-redis && gem build riverqueue-redis.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-redis && bundle install && popd pushd driver/riverqueue-sequel && bundle install && popd + pushd rails/riverqueue-rails && bundle install && popd gco -b $USER-$VERSION ``` @@ -76,7 +163,9 @@ $ open coverage/index.html gem push riverqueue-${"${VERSION}"/v/}.gem pushd driver/riverqueue-activerecord && gem push riverqueue-activerecord-${"${VERSION}"/v/}.gem && popd + pushd driver/riverqueue-redis && gem push riverqueue-redis-${"${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 $VERSION git push --tags diff --git a/docs/migrating_from_sidekiq.md b/docs/migrating_from_sidekiq.md new file mode 100644 index 0000000..016ea7b --- /dev/null +++ b/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), + insert_opts: River::InsertOpts.new(unique_opts: unique) +) +duplicate = result.unique_skipped_as_duplicated +``` + +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/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..bb20a8d --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,112 @@ +# 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; 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: 7) +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. + +## 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. +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 the upstream commit and each file's SHA-256. +Synchronize or verify the public migrations against a local Go checkout: + +```sh +ruby scripts/sync_migrations.rb ../river +make verify +make verify RIVER_PATH=/path/to/river +``` + +`make verify` defaults to `../river` and checks SQL contents, filenames, the +license, and the manifest's checksums and source revision. CI fetches the public +River repository at that recorded revision and runs the same check; it does not +require a sibling checkout or access to the private Pro repository. + +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/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..e6afa96 --- /dev/null +++ b/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/docs/workers.md b/docs/workers.md new file mode 100644 index 0000000..49f8b59 --- /dev/null +++ b/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/driver/riverqueue-activerecord/Gemfile b/driver/riverqueue-activerecord/Gemfile index b3a8848..fbff1c6 100644 --- a/driver/riverqueue-activerecord/Gemfile +++ b/driver/riverqueue-activerecord/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source "https://rubygems.org" gemspec diff --git a/driver/riverqueue-activerecord/Gemfile.lock b/driver/riverqueue-activerecord/Gemfile.lock index 2c73969..060fcda 100644 --- a/driver/riverqueue-activerecord/Gemfile.lock +++ b/driver/riverqueue-activerecord/Gemfile.lock @@ -2,6 +2,10 @@ PATH remote: ../.. specs: riverqueue (0.11.0) + logger (> 0, < 1000) + optparse (> 0, < 1000) + securerandom (> 0, < 1000) + timeout (> 0, < 1000) PATH remote: . @@ -9,6 +13,7 @@ PATH riverqueue-activerecord (0.11.0) activerecord (> 0, < 1000) activesupport (> 0, < 1000) + riverqueue (= 0.11.0) GEM remote: https://rubygems.org/ @@ -60,6 +65,7 @@ GEM 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) @@ -157,4 +163,4 @@ DEPENDENCIES standard BUNDLED WITH - 2.4.20 + 4.0.9 diff --git a/driver/riverqueue-activerecord/docs/README.md b/driver/riverqueue-activerecord/docs/README.md index 345c8f2..5b1915e 100644 --- a/driver/riverqueue-activerecord/docs/README.md +++ b/driver/riverqueue-activerecord/docs/README.md @@ -2,16 +2,14 @@ [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 the core gem and this driver to `Gemfile`: +Add this driver and only the database adapter used by the application to +`Gemfile`. The driver pulls in the core gem: ```ruby -gem "riverqueue" gem "riverqueue-activerecord" +gem "pg" # or: gem "sqlite3" ``` -Database adapters are optional dependencies. Add only the adapter used by your -application. - For PostgreSQL, add `pg` to `Gemfile`: ```ruby diff --git a/driver/riverqueue-activerecord/lib/driver.rb b/driver/riverqueue-activerecord/lib/driver.rb index f2bfbc1..afcc37e 100644 --- a/driver/riverqueue-activerecord/lib/driver.rb +++ b/driver/riverqueue-activerecord/lib/driver.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "securerandom" module River::Driver @@ -10,7 +12,94 @@ module River::Driver # client = River::Client.new(River::Driver::ActiveRecord.new) # class ActiveRecord - SQLITE_CONFLICT_WHERE = <<~SQL.chomp + 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 + @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? + + @is_sqlite ? sqlite_job_insert_many(insert_params_many) : postgres_job_insert_many(insert_params_many) + 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 @@ -25,14 +114,13 @@ class ActiveRecord ELSE 0 END >= 1 SQL - private_constant :SQLITE_CONFLICT_WHERE # 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. - SQLITE_JOB_COLUMNS = <<~SQL.chomp + SQLITE_JOB_COLUMNS = <<~SQL.chomp.freeze id, json(args) AS args, attempt, @@ -52,73 +140,42 @@ class ActiveRecord unique_key, unique_states SQL - private_constant :SQLITE_JOB_COLUMNS SQLITE_UNIQUE_NONCE_KEY = "river:unique_nonce" - private_constant :SQLITE_UNIQUE_NONCE_KEY - - def initialize - @is_sqlite = ::ActiveRecord::Base.connection.adapter_name.downcase.include?("sqlite") - - # It's Ruby, so we can only define a model after ActiveRecord's established a - # connection because it's all dynamic. - if !River::Driver::ActiveRecord.const_defined?(:RiverJob) - River::Driver::ActiveRecord.const_set(:RiverJob, Class.new(::ActiveRecord::Base) do - self.table_name = "river_job" - - # Unfortunately, Rails errors if you have a column called `errors` and - # provides no way to remap names (beyond ignoring a column, which we - # really don't want). This patch is in place so we can hydrate this - # model at all without ActiveRecord self-immolating. - def self.dangerous_attribute_method?(method_name) - return false if method_name == "errors" - super - end - - # See comment above, but since we force allowed `errors` as an - # attribute name, ActiveRecord would otherwise fail to save a row as - # it checked for its own `errors` hash and finding no values. - def errors = {} - end) - 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 - data_set = RiverJob.where(id: id) - data_set.first ? to_job_row_from_model(data_set.first) : nil - end - end + private_constant :SQLITE_CONFLICT_WHERE, :SQLITE_JOB_COLUMNS, :SQLITE_UNIQUE_NONCE_KEY - def job_insert(insert_params) - job_insert_many([insert_params]).first + private def format_time(time) + time.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N") end - def job_insert_many(insert_params_many) - @is_sqlite ? sqlite_job_insert_many(insert_params_many) : postgres_job_insert_many(insert_params_many) - end + private def parse_sqlite_time(value) + return nil unless value - def job_list - if @is_sqlite - sqlite_job_rows("ORDER BY id").map { |row| sqlite_to_job_row_from_raw(row) } - else - RiverJob.order(:id).all.map { |job| to_job_row_from_model(job) } - end - end + value = value.to_s + value += " UTC" unless value.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/) - def rollback_exception - ::ActiveRecord::Rollback + Time.parse(value).utc end - def transaction(&) - ::ActiveRecord::Base.transaction(requires_new: true, &) + private def postgres_insert_params_to_hash(insert_params) + { + args: JSON.parse(insert_params.encoded_args), + kind: insert_params.kind, + max_attempts: insert_params.max_attempts, + metadata: insert_params.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) - res = RiverJob.upsert_all( + res = @job_model.upsert_all( insert_params_many.map { |param| postgres_insert_params_to_hash(param) }, on_duplicate: Arel.sql("kind = EXCLUDED.kind"), returning: Arel.sql("*, (xmax != 0) AS unique_skipped_as_duplicate"), @@ -133,99 +190,12 @@ def transaction(&) postgres_to_insert_results(res) 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) - return [] if insert_params_many.empty? - - ::ActiveRecord::Base.transaction(requires_new: true) do - nonce = SecureRandom.hex(8) - jobs = insert_params_many.map { |param| sqlite_insert_params_to_hash(param, nonce) } - - 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 = EXCLUDED.kind - RETURNING #{SQLITE_JOB_COLUMNS} - SQL - - rows = ::ActiveRecord::Base.connection.raw_connection.execute(sql, [JSON.dump(jobs)]) - sqlite_notify_insert(insert_params_many) - - rows.map do |row| - metadata = JSON.parse(row["metadata"]) - [sqlite_to_job_row_from_raw(row), metadata[SQLITE_UNIQUE_NONCE_KEY] != nonce] - end + private def postgres_to_insert_results(res) + res.rows.map do |row| + postgres_to_job_row_from_raw(row, res.columns, res.column_types) end end - private def postgres_insert_params_to_hash(insert_params) - { - args: JSON.parse(insert_params.encoded_args), - kind: insert_params.kind, - max_attempts: insert_params.max_attempts, - priority: insert_params.priority, - queue: insert_params.queue, - state: insert_params.state, - scheduled_at: insert_params.scheduled_at, - tags: insert_params.tags || [], - unique_key: insert_params.unique_key, - unique_states: insert_params.unique_states - } - 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: {SQLITE_UNIQUE_NONCE_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) - } - 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 - private def postgres_to_job_row_from_model(river_job) # needs to be accessed through values because `errors` is shadowed by both # ActiveRecord and the patch above @@ -239,7 +209,7 @@ def transaction(&) attempted_by: river_job.attempted_by, created_at: river_job.created_at.getutc, errors: errors&.map { |e| - deserialized_error = JSON.parse(e, symbolize_names: true) + deserialized_error = (e.is_a?(String) ? JSON.parse(e) : e).transform_keys(&:to_sym) River::AttemptError.new( at: Time.parse(deserialized_error[:at]), @@ -262,49 +232,6 @@ def transaction(&) ) 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) - errors = row["errors"] ? JSON.parse(row["errors"]) : [] - - River::JobRow.new( - id: row["id"], - args: JSON.parse(row["args"]), - attempt: row["attempt"], - attempted_at: parse_sqlite_time(row["attempted_at"]), - attempted_by: row["attempted_by"] ? JSON.parse(row["attempted_by"]) : nil, - created_at: parse_sqlite_time(row["created_at"]), - errors: errors.map { |e| - River::AttemptError.new( - at: Time.parse(e["at"]), - attempt: e["attempt"], - error: e["error"], - trace: e["trace"] - ) - }, - finalized_at: parse_sqlite_time(row["finalized_at"]), - kind: row["kind"], - max_attempts: row["max_attempts"], - metadata: JSON.parse(row["metadata"]), - priority: row["priority"], - queue: row["queue"], - scheduled_at: parse_sqlite_time(row["scheduled_at"]), - state: row["state"], - tags: JSON.parse(row["tags"]), - unique_key: row["unique_key"]&.to_s, - unique_states: row["unique_states"] ? ::River::UniqueBitmask.to_states(row["unique_states"]) : nil - ) - end - - private def postgres_to_insert_results(res) - res.rows.map do |row| - postgres_to_job_row_from_raw(row, res.columns, res.column_types) - end - end - # This is really awful, but some of ActiveRecord's methods (e.g. `.create`) # return a model, and others (e.g. `.upsert`) return raw values, and # therefore this second version from unmarshaling a job row exists. I @@ -354,21 +281,121 @@ def transaction(&) ] end - private def format_time(time) - time.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N") + private def runtime_execute(sql) + @connection_class.connection.execute(sql) end - private def parse_sqlite_time(value) - return nil unless value + private def runtime_job_list_without_params + job_list(:all) + end - value = value.to_s - value += " UTC" unless value.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/) - Time.parse(value).utc + 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: {SQLITE_UNIQUE_NONCE_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) + @connection_class.transaction(requires_new: true) do + nonce = SecureRandom.hex(8) + jobs = insert_params_many.map { |param| sqlite_insert_params_to_hash(param, nonce) } + + 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 = EXCLUDED.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), metadata[SQLITE_UNIQUE_NONCE_KEY] != nonce] + end + end end private def sqlite_job_rows(suffix, binds = []) sql = "SELECT #{SQLITE_JOB_COLUMNS} FROM river_job #{suffix}" - ::ActiveRecord::Base.connection.raw_connection.execute(sql, binds) + @connection_class.connection.raw_connection.execute(sql, binds) end private def sqlite_notify_insert(insert_params_many) @@ -379,10 +406,10 @@ def transaction(&) return if queues.empty? notifications = queues.map do |queue| - {payload: JSON.dump({queue: queue}), topic: "insert"} + {payload: JSON.generate({queue: queue}), topic: "insert"} end - ::ActiveRecord::Base.connection.raw_connection.execute(<<~SQL, [JSON.dump(notifications)]) + @connection_class.connection.raw_connection.execute(<<~SQL, [JSON.generate(notifications)]) INSERT INTO river_notification (payload, topic) SELECT json_extract(value, '$.payload'), @@ -390,5 +417,46 @@ def transaction(&) 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) + errors = row["errors"] ? JSON.parse(row["errors"]) : [] + + River::JobRow.new( + id: row["id"], + args: JSON.parse(row["args"]), + attempt: row["attempt"], + attempted_at: parse_sqlite_time(row["attempted_at"]), + attempted_by: row["attempted_by"] ? JSON.parse(row["attempted_by"]) : nil, + created_at: parse_sqlite_time(row["created_at"]), + errors: errors.map { |e| + River::AttemptError.new( + at: Time.parse(e["at"]), + attempt: e["attempt"], + error: e["error"], + trace: e["trace"] + ) + }, + finalized_at: parse_sqlite_time(row["finalized_at"]), + kind: row["kind"], + max_attempts: row["max_attempts"], + metadata: JSON.parse(row["metadata"]), + priority: row["priority"], + queue: row["queue"], + scheduled_at: parse_sqlite_time(row["scheduled_at"]), + state: row["state"], + tags: JSON.parse(row["tags"]), + unique_key: row["unique_key"]&.to_s, + unique_states: row["unique_states"] ? ::River::UniqueBitmask.to_states(row["unique_states"]) : nil + ) + 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/driver/riverqueue-activerecord/lib/riverqueue-activerecord.rb b/driver/riverqueue-activerecord/lib/riverqueue-activerecord.rb index 82bf467..ec10611 100644 --- a/driver/riverqueue-activerecord/lib/riverqueue-activerecord.rb +++ b/driver/riverqueue-activerecord/lib/riverqueue-activerecord.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "active_record" +require "riverqueue" require_relative "driver" diff --git a/driver/riverqueue-activerecord/riverqueue-activerecord.gemspec b/driver/riverqueue-activerecord/riverqueue-activerecord.gemspec index 48736d3..603fdad 100644 --- a/driver/riverqueue-activerecord/riverqueue-activerecord.gemspec +++ b/driver/riverqueue-activerecord/riverqueue-activerecord.gemspec @@ -1,15 +1,18 @@ +# frozen_string_literal: true + Gem::Specification.new do |s| s.name = "riverqueue-activerecord" s.version = "0.11.0" s.summary = "ActiveRecord PostgreSQL and SQLite driver for the River Ruby gem." - s.description = "ActiveRecord PostgreSQL and SQLite driver for the River Ruby gem. Use in conjunction with the riverqueue gem to insert jobs that are worked in Go." + 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 = "LGPL-3.0-or-later" - + 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", "= 0.11.0" end diff --git a/driver/riverqueue-activerecord/spec/client_spec.rb b/driver/riverqueue-activerecord/spec/client_spec.rb new file mode 100644 index 0000000..147d207 --- /dev/null +++ b/driver/riverqueue-activerecord/spec/client_spec.rb @@ -0,0 +1,31 @@ +# 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" + +RSpec.describe "ActiveRecord client integration" do + [:postgres, :sqlite].each do |adapter| + context "with #{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 "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/driver/riverqueue-activerecord/spec/connection_class_spec.rb b/driver/riverqueue-activerecord/spec/connection_class_spec.rb new file mode 100644 index 0000000..3c62f04 --- /dev/null +++ b/driver/riverqueue-activerecord/spec/connection_class_spec.rb @@ -0,0 +1,92 @@ +# 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}" 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 + + 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/driver/riverqueue-activerecord/spec/driver_spec.rb b/driver/riverqueue-activerecord/spec/driver_spec.rb index c7c5e36..4252c98 100644 --- a/driver/riverqueue-activerecord/spec/driver_spec.rb +++ b/driver/riverqueue-activerecord/spec/driver_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" require_relative "../../../spec/driver_shared_examples" @@ -129,6 +131,7 @@ ) job, = driver.job_insert(params) + expect(job.scheduled_at).to be_within(2).of(Time.now.utc) end @@ -136,6 +139,7 @@ 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 @@ -150,7 +154,7 @@ ]) ) else - River::Driver::ActiveRecord::RiverJob.create( + driver.instance_variable_get(:@job_model).create( id: 1, args: {"job_num" => 1}, kind: "simple", @@ -161,7 +165,7 @@ ) end - river_job = River::Driver::ActiveRecord::RiverJob.first + 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) @@ -198,13 +202,13 @@ Digest::SHA256.digest("unique_key_str")] ) else - River::Driver::ActiveRecord::RiverJob.create( + driver.instance_variable_get(:@job_model).create( id: 1, + args: {"job_num" => 1}, attempt: 1, attempted_at: now, attempted_by: ["client1"], created_at: now, - args: {"job_num" => 1}, finalized_at: now, kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, @@ -217,7 +221,7 @@ ) end - river_job = River::Driver::ActiveRecord::RiverJob.first + 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) @@ -253,7 +257,7 @@ ]) ) else - River::Driver::ActiveRecord::RiverJob.create( + driver.instance_variable_get(:@job_model).create( args: {"job_num" => 1}, errors: [JSON.dump({at: now, attempt: 1, error: "job failure", trace: "error trace"})], kind: "simple", @@ -262,7 +266,7 @@ ) end - river_job = River::Driver::ActiveRecord::RiverJob.first + 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) @@ -281,7 +285,7 @@ describe "#postgres_to_job_row_from_raw" do it "converts a database record to `River::JobRow` with minimal properties" do - res = River::Driver::ActiveRecord::RiverJob.insert({ + res = driver.instance_variable_get(:@job_model).insert({ id: 1, args: {"job_num" => 1}, kind: "simple", @@ -312,13 +316,13 @@ it "converts a database record to `River::JobRow` with all properties" do now = Time.now - res = River::Driver::ActiveRecord::RiverJob.insert({ + 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, - args: {"job_num" => 1}, finalized_at: now, kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, @@ -355,7 +359,7 @@ it "with errors" do now = Time.now.utc - res = River::Driver::ActiveRecord::RiverJob.insert({ + res = driver.instance_variable_get(:@job_model).insert({ args: {"job_num" => 1}, errors: [JSON.dump( { diff --git a/driver/riverqueue-activerecord/spec/migrator_spec.rb b/driver/riverqueue-activerecord/spec/migrator_spec.rb new file mode 100644 index 0000000..794fca9 --- /dev/null +++ b/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}" 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" + end + end +end diff --git a/driver/riverqueue-activerecord/spec/spec_helper.rb b/driver/riverqueue-activerecord/spec/spec_helper.rb index 946c0f7..7b611e0 100644 --- a/driver/riverqueue-activerecord/spec/spec_helper.rb +++ b/driver/riverqueue-activerecord/spec/spec_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record" require "debug" require_relative "../../../spec/support/river_sqlite_schema_fixture" @@ -7,12 +9,22 @@ ActiveRecord::Base.connection.execute("SELECT 1") true rescue => e + raise if ENV["CI"] == "true" || ENV["RIVER_REQUIRE_DATABASES"] == "1" + warn "PostgreSQL not available, skipping PostgreSQL tests: #{e.message}" false end def test_transaction ActiveRecord::Base.transaction do + # Tests assume an empty jobs table. Delete inside the transaction so a + # developer's existing test data is restored by the rollback below. + %w[river_job river_notification river_queue river_leader].each do |table| + if ActiveRecord::Base.connection.data_source_exists?(table) + ActiveRecord::Base.connection.execute("DELETE FROM #{table}") + end + end + yield raise ActiveRecord::Rollback end @@ -27,10 +39,13 @@ def switch_to_postgres! ActiveRecord::Base.establish_connection(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test") end -require "simplecov" -SimpleCov.start do - enable_coverage :branch - minimum_coverage line: 100, branch: 100 +unless ENV["RIVERQUEUE_ROOT_TEST_SUITE"] + require "simplecov" + SimpleCov.start do + add_filter "/spec/" + enable_coverage :branch + minimum_coverage branch: 100, line: 100 + end end require "riverqueue" diff --git a/driver/riverqueue-sequel/Gemfile b/driver/riverqueue-sequel/Gemfile index ab4e105..606177f 100644 --- a/driver/riverqueue-sequel/Gemfile +++ b/driver/riverqueue-sequel/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source "https://rubygems.org" gemspec diff --git a/driver/riverqueue-sequel/Gemfile.lock b/driver/riverqueue-sequel/Gemfile.lock index 26e5981..5e4a8a7 100644 --- a/driver/riverqueue-sequel/Gemfile.lock +++ b/driver/riverqueue-sequel/Gemfile.lock @@ -2,11 +2,16 @@ PATH remote: ../.. specs: riverqueue (0.11.0) + logger (> 0, < 1000) + optparse (> 0, < 1000) + securerandom (> 0, < 1000) + timeout (> 0, < 1000) PATH remote: . specs: riverqueue-sequel (0.11.0) + riverqueue (= 0.11.0) sequel (> 0, < 1000) GEM @@ -19,6 +24,8 @@ GEM 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) @@ -54,6 +61,7 @@ GEM 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) @@ -76,6 +84,7 @@ GEM 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) @@ -98,4 +107,4 @@ DEPENDENCIES standard BUNDLED WITH - 2.4.20 + 4.0.9 diff --git a/driver/riverqueue-sequel/docs/README.md b/driver/riverqueue-sequel/docs/README.md index 3f7824d..ef04c52 100644 --- a/driver/riverqueue-sequel/docs/README.md +++ b/driver/riverqueue-sequel/docs/README.md @@ -2,16 +2,14 @@ [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 the core gem and this driver to `Gemfile`: +Add this driver and only the database adapter used by the application to +`Gemfile`. The driver pulls in the core gem: ```ruby -gem "riverqueue" gem "riverqueue-sequel" +gem "pg" # or: gem "sqlite3" ``` -Database adapters are optional dependencies. Add only the adapter used by your -application. - For PostgreSQL, add `pg` to `Gemfile`: ```ruby diff --git a/driver/riverqueue-sequel/lib/driver.rb b/driver/riverqueue-sequel/lib/driver.rb index 3513929..666e1ac 100644 --- a/driver/riverqueue-sequel/lib/driver.rb +++ b/driver/riverqueue-sequel/lib/driver.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "securerandom" module River::Driver @@ -14,7 +16,69 @@ module River::Driver # client = River::Client.new(River::Driver::Sequel.new(DB)) # class Sequel - SQLITE_CONFLICT_WHERE = <<~SQL.chomp + include River::Driver::Runtime + + # Creates a driver backed by a connected Sequel::Database for PostgreSQL or + # SQLite. + def initialize(db) + @db = db + @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? + + @is_sqlite ? sqlite_job_insert_many(insert_params_array) : postgres_job_insert_many(insert_params_array) + 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(&) + @db.transaction(savepoint: true, &) + end + + SQLITE_CONFLICT_WHERE = <<~SQL.chomp.freeze unique_key IS NOT NULL AND unique_states IS NOT NULL AND CASE state @@ -29,14 +93,13 @@ class Sequel ELSE 0 END >= 1 SQL - private_constant :SQLITE_CONFLICT_WHERE # 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. - SQLITE_JOB_COLUMNS = <<~SQL.chomp + SQLITE_JOB_COLUMNS = <<~SQL.chomp.freeze id, json(args) AS args, attempt, @@ -56,62 +119,47 @@ class Sequel unique_key, unique_states SQL - private_constant :SQLITE_JOB_COLUMNS SQLITE_UNIQUE_NONCE_KEY = "river:unique_nonce" - private_constant :SQLITE_UNIQUE_NONCE_KEY - def initialize(db) - @db = db - @is_sqlite = (db.database_type == :sqlite) + private_constant :SQLITE_CONFLICT_WHERE, :SQLITE_JOB_COLUMNS, :SQLITE_UNIQUE_NONCE_KEY - 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 - data_set = @db[:river_job].where(id: id) - data_set.first ? to_job_row(data_set.first) : nil - end - end - - def job_insert(insert_params) - job_insert_many([insert_params]).first + private def format_time(time) + time.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N") end - def job_insert_many(insert_params_array) - @is_sqlite ? sqlite_job_insert_many(insert_params_array) : postgres_job_insert_many(insert_params_array) - end + private def parse_sqlite_time(value) + return nil unless value - def job_list - if @is_sqlite - sqlite_job_rows("ORDER BY id").map { |row| sqlite_to_job_row_from_raw(row) } - else - @db[:river_job].order_by(:id).all.map { |job| to_job_row(job) } - end - end + value = value.to_s + value += " UTC" unless value.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/) - def rollback_exception - ::Sequel::Rollback + Time.parse(value).utc end - def transaction(&) - @db.transaction(savepoint: true, &) + private def postgres_insert_params_to_hash(insert_params) + { + args: insert_params.encoded_args, + kind: insert_params.kind, + max_attempts: insert_params.max_attempts, + metadata: ::Sequel.pg_jsonb(insert_params.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) @db[:river_job] .insert_conflict( - target: [:unique_key], 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[:excluded][:kind]} ) .returning(::Sequel.lit("*, (xmax != 0) AS unique_skipped_as_duplicate")) @@ -119,12 +167,92 @@ def transaction(&) .map { |row| [to_job_row(row), row[:unique_skipped_as_duplicate]] } end + private def postgres_to_job_row(river_job) + River::JobRow.new( + id: river_job[:id], + args: river_job[:args].to_h, + attempt: river_job[:attempt], + attempted_at: river_job[:attempted_at]&.getutc, + attempted_by: river_job[:attempted_by]&.to_a, + created_at: river_job[:created_at].getutc, + errors: river_job[:errors]&.map { |deserialized_error| + River::AttemptError.new( + at: Time.parse(deserialized_error["at"]), + attempt: deserialized_error["attempt"], + error: deserialized_error["error"], + trace: deserialized_error["trace"] + ) + }, + finalized_at: river_job[:finalized_at]&.getutc, + kind: river_job[:kind], + max_attempts: river_job[:max_attempts], + metadata: river_job[:metadata].to_h, + priority: river_job[:priority], + queue: river_job[:queue], + scheduled_at: river_job[:scheduled_at].getutc, + state: river_job[:state], + tags: river_job[:tags].to_a, + unique_key: river_job[:unique_key]&.to_s, + unique_states: ::River::UniqueBitmask.to_states(river_job[:unique_states]&.to_i(2)) + ) + 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: {SQLITE_UNIQUE_NONCE_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) - return [] if insert_params_array.empty? - @db.transaction(savepoint: true) do nonce = SecureRandom.hex(8) jobs = insert_params_array.map { |param| sqlite_insert_params_to_hash(param, nonce) } @@ -167,7 +295,7 @@ def transaction(&) RETURNING #{SQLITE_JOB_COLUMNS} SQL - rows = @db.fetch(sql, JSON.dump(jobs)).all + rows = @db.fetch(sql, JSON.generate(jobs)).all sqlite_notify_insert(insert_params_array) rows.map do |row| @@ -177,74 +305,20 @@ def transaction(&) end end - private def postgres_insert_params_to_hash(insert_params) - { - args: insert_params.encoded_args, - kind: insert_params.kind, - max_attempts: insert_params.max_attempts, - priority: insert_params.priority, - queue: insert_params.queue, - state: insert_params.state, - scheduled_at: insert_params.scheduled_at, - 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 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: {SQLITE_UNIQUE_NONCE_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) - } + private def sqlite_job_rows(suffix, *binds) + @db.fetch("SELECT #{SQLITE_JOB_COLUMNS} FROM river_job #{suffix}", *binds).all 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 + 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? - private def postgres_to_job_row(river_job) - River::JobRow.new( - id: river_job[:id], - args: river_job[:args].to_h, - attempt: river_job[:attempt], - attempted_at: river_job[:attempted_at]&.getutc, - attempted_by: river_job[:attempted_by], - created_at: river_job[:created_at].getutc, - errors: river_job[:errors]&.map { |deserialized_error| - River::AttemptError.new( - at: Time.parse(deserialized_error["at"]), - attempt: deserialized_error["attempt"], - error: deserialized_error["error"], - trace: deserialized_error["trace"] - ) - }, - finalized_at: river_job[:finalized_at]&.getutc, - kind: river_job[:kind], - max_attempts: river_job[:max_attempts], - metadata: river_job[:metadata], - priority: river_job[:priority], - queue: river_job[:queue], - scheduled_at: river_job[:scheduled_at].getutc, - state: river_job[:state], - tags: river_job[:tags].to_a, - unique_key: river_job[:unique_key]&.to_s, - unique_states: ::River::UniqueBitmask.to_states(river_job[:unique_states]&.to_i(2)) - ) + @db[:river_notification].multi_insert(queues.map do |queue| + {payload: JSON.generate({queue: queue}), topic: "insert"} + end) end private def sqlite_to_job_row_from_raw(river_job) @@ -279,32 +353,13 @@ def transaction(&) ) end - private def format_time(time) - time.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N") - end - - private def parse_sqlite_time(value) - return nil unless value - - value = value.to_s - value += " UTC" unless value.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/) - Time.parse(value).utc - 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.dump({queue: queue}), topic: "insert"} - 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/driver/riverqueue-sequel/lib/riverqueue-sequel.rb b/driver/riverqueue-sequel/lib/riverqueue-sequel.rb index 9c7ba09..4e9c324 100644 --- a/driver/riverqueue-sequel/lib/riverqueue-sequel.rb +++ b/driver/riverqueue-sequel/lib/riverqueue-sequel.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "sequel" +require "riverqueue" require_relative "driver" diff --git a/driver/riverqueue-sequel/riverqueue-sequel.gemspec b/driver/riverqueue-sequel/riverqueue-sequel.gemspec index c3f18a7..8891195 100644 --- a/driver/riverqueue-sequel/riverqueue-sequel.gemspec +++ b/driver/riverqueue-sequel/riverqueue-sequel.gemspec @@ -1,14 +1,17 @@ +# frozen_string_literal: true + Gem::Specification.new do |s| s.name = "riverqueue-sequel" s.version = "0.11.0" s.summary = "Sequel PostgreSQL and SQLite driver for the River Ruby gem." - s.description = "Sequel PostgreSQL and SQLite driver for the River Ruby gem. Use in conjunction with the riverqueue gem to insert jobs that are worked in Go." + 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 = "LGPL-3.0-or-later" - + 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", "= 0.11.0" end diff --git a/driver/riverqueue-sequel/spec/client_spec.rb b/driver/riverqueue-sequel/spec/client_spec.rb new file mode 100644 index 0000000..93bc5a0 --- /dev/null +++ b/driver/riverqueue-sequel/spec/client_spec.rb @@ -0,0 +1,31 @@ +# 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" + +RSpec.describe "Sequel client integration" do + [:postgres, :sqlite].each do |adapter| + context "with #{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 "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/driver/riverqueue-sequel/spec/driver_spec.rb b/driver/riverqueue-sequel/spec/driver_spec.rb index e1f0c8d..fe5328c 100644 --- a/driver/riverqueue-sequel/spec/driver_spec.rb +++ b/driver/riverqueue-sequel/spec/driver_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" require_relative "../../../spec/driver_shared_examples" @@ -60,11 +62,11 @@ 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, - args: %({"job_num":1}), finalized_at: now, kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, @@ -186,6 +188,7 @@ ]) rows = SQLITE_DB[:river_notification].order(:id).select(:payload, :topic).all + expect(rows).to contain_exactly( {payload: JSON.dump({queue: River::QUEUE_DEFAULT}), topic: "insert"} ) @@ -208,6 +211,7 @@ ) job, = driver.job_insert(params) + expect(job.scheduled_at).to be_within(2).of(Time.now.utc) end @@ -252,11 +256,11 @@ 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, - args: Sequel.function(:jsonb, %({"job_num":1})), finalized_at: now_str, kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, diff --git a/driver/riverqueue-sequel/spec/migrator_spec.rb b/driver/riverqueue-sequel/spec/migrator_spec.rb new file mode 100644 index 0000000..aad825f --- /dev/null +++ b/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}" 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" + end + end +end diff --git a/driver/riverqueue-sequel/spec/spec_helper.rb b/driver/riverqueue-sequel/spec/spec_helper.rb index fce54d1..446aa05 100644 --- a/driver/riverqueue-sequel/spec/spec_helper.rb +++ b/driver/riverqueue-sequel/spec/spec_helper.rb @@ -1,9 +1,13 @@ +# frozen_string_literal: true + require "sequel" require_relative "../../../spec/support/river_sqlite_schema_fixture" DB = begin Sequel.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test") rescue => e + raise if ENV["CI"] == "true" || ENV["RIVER_REQUIRE_DATABASES"] == "1" + warn "PostgreSQL not available, skipping PostgreSQL tests: #{e.message}" nil end @@ -14,12 +18,20 @@ 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 def test_transaction DB.transaction do + # Tests assume an empty jobs table. Delete inside the transaction so a + # developer's existing test data is restored by the rollback below. + [:river_job, :river_notification, :river_queue, :river_leader].each do |table| + DB[table].delete if DB.table_exists?(table) + end + yield raise Sequel::Rollback end @@ -27,15 +39,33 @@ def test_transaction def sqlite_test_transaction SQLITE_DB.transaction do + [:river_job, :river_notification, :river_queue, :river_leader].each { |table| SQLITE_DB[table].delete } yield raise Sequel::Rollback end end -require "simplecov" -SimpleCov.start do - enable_coverage :branch - minimum_coverage line: 100, branch: 100 +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 + minimum_coverage branch: 100, line: 100 + end end require "riverqueue" diff --git a/exe/river b/exe/river new file mode 100755 index 0000000..e61e046 --- /dev/null +++ b/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/lib/cli.rb b/lib/cli.rb new file mode 100644 index 0000000..778f238 --- /dev/null +++ b/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/lib/client.rb b/lib/client.rb index 88de340..4fd02f1 100644 --- a/lib/client.rb +++ b/lib/client.rb @@ -1,4 +1,6 @@ -require "digest" +# frozen_string_literal: true + +require "digest/sha2" require "time" module River @@ -11,10 +13,7 @@ module River # Default queue for a job. QUEUE_DEFAULT = "default" - # Provides a client for River that inserts jobs. Unlike the Go version of the - # River client, this one can insert jobs only. Jobs can only be worked from Go - # code, so job arg kinds and JSON encoding details must be shared between Ruby - # and Go code. + # Provides a River client for inserting and working jobs. # # Used in conjunction with a River driver like: # @@ -24,11 +23,38 @@ module River # River drivers are found in separate gems like `riverqueue-sequel` to help # minimize transient dependencies. class Client - def initialize(driver) + # 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. + 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). # @@ -39,12 +65,12 @@ def initialize(driver) # # With insert opts: # - # insert_res = client.insert(SimpleArgs.new(job_num: 1), insert_opts: InsertOpts.new(queue: "high_priority")) + # insert_res = client.insert(SimpleArgs.new(job_num: 1), insert_opts: InsertOpts.new(queue: :high_priority)) # insert_res.job # inserted job row # # Job arg implementations are expected to respond to: # - # * `#kind`: A string that uniquely identifies the job in the database. + # * `#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. # @@ -64,15 +90,15 @@ def initialize(driver) # # def kind = "simple" # - # def to_json = JSON.dump({job_num: job_num}) + # 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 InsertResult. + # Returns an instance of JobInsertResult. def insert(args, insert_opts: EMPTY_INSERT_OPTS) insert_params = make_insert_params(args, insert_opts) - insert_and_check_unique_job(insert_params) + 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 @@ -83,21 +109,21 @@ def insert(args, insert_opts: EMPTY_INSERT_OPTS) # # With job args: # - # num_inserted = client.insert_many([ + # insert_results = client.insert_many([ # SimpleArgs.new(job_num: 1), # SimpleArgs.new(job_num: 2) # ]) # # With InsertManyParams: # - # num_inserted = client.insert_many([ + # insert_results = client.insert_many([ # River::InsertManyParams.new(SimpleArgs.new(job_num: 1), insert_opts: InsertOpts.new(max_attempts: 5)), - # River::InsertManyParams.new(SimpleArgs.new(job_num: 2), insert_opts: InsertOpts.new(queue: "high_priority")) + # River::InsertManyParams.new(SimpleArgs.new(job_num: 2), insert_opts: InsertOpts.new(queue: :high_priority)) # ]) # # Job arg implementations are expected to respond to: # - # * `#kind`: A string that uniquely identifies the job in the database. + # * `#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. # @@ -112,12 +138,12 @@ def insert(args, insert_opts: EMPTY_INSERT_OPTS) # # def kind = "simple" # - # def to_json = JSON.dump({job_num: job_num}) + # 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 the number of jobs inserted. + # Returns one JobInsertResult for each argument, in input order. def insert_many(args) all_params = args.map do |arg| if arg.is_a?(InsertManyParams) @@ -127,10 +153,163 @@ def insert_many(args) end end - @driver.job_insert_many(all_params) - .map do |job, unique_skipped_as_duplicate| - InsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate) + 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. + def job_delete_many(params) + 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. + def job_list(params = JobListParams.new) + 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. + def job_retry(id) + @driver.job_retry(id) || raise(NotFoundError, "job not found: #{id}") + end + + # Applies the supplied JobUpdateParams to a job and returns its updated + # JobRow. Raises NotFoundError if the job does not exist. + def job_update(id, params) + @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 = @runtime.periodic_jobs + + # 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 + @driver.queue_pause(name) + @runtime.wake + queues = (name == "*") ? @driver.queue_list : [@driver.queue_get(name)].compact + 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. + 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 + @driver.queue_resume(name) + @runtime.wake + queues = (name == "*") ? @driver.queue_list : [@driver.queue_get(name)].compact + 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. + def stop(wait: true) + @runtime.stop(wait: wait) + self + end + + # Stops fetching new jobs, interrupts active work, and returns self. + 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 @@ -143,7 +322,8 @@ def insert_many(args) JOB_STATE_RUNNING, JOB_STATE_SCHEDULED ].freeze - private_constant :DEFAULT_UNIQUE_STATES + + EMPTY_INSERT_OPTS = InsertOpts.new.freeze REQUIRED_UNIQUE_STATES = [ JOB_STATE_AVAILABLE, @@ -151,14 +331,14 @@ def insert_many(args) JOB_STATE_RUNNING, JOB_STATE_SCHEDULED ].freeze - private_constant :REQUIRED_UNIQUE_STATES - EMPTY_INSERT_OPTS = InsertOpts.new.freeze - private_constant :EMPTY_INSERT_OPTS + TAG_RE = /\A\w[\w-]+\w\z/ + + private_constant :DEFAULT_UNIQUE_STATES, :EMPTY_INSERT_OPTS, :REQUIRED_UNIQUE_STATES, :TAG_RE private def insert_and_check_unique_job(insert_params) job, unique_skipped_as_duplicate = @driver.job_insert(insert_params) - InsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate) + JobInsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate) end private def make_insert_params(args, insert_opts) @@ -176,15 +356,18 @@ def insert_many(args) end scheduled_at = insert_opts.scheduled_at || args_insert_opts.scheduled_at + state = (insert_opts.state || args_insert_opts.state || (scheduled_at ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE)).to_s #: jobStateAll # rubocop:disable Layout/LeadingCommentSpace insert_params = Driver::JobInsertParams.new( + args: args, encoded_args: args_json, - kind: args.kind, + kind: args.kind.to_s, max_attempts: insert_opts.max_attempts || args_insert_opts.max_attempts || MAX_ATTEMPTS_DEFAULT, + metadata: (args_insert_opts.metadata || {}).merge(insert_opts.metadata || {}), priority: insert_opts.priority || args_insert_opts.priority || PRIORITY_DEFAULT, - queue: insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT, + queue: (insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT).to_s, scheduled_at: scheduled_at&.utc || Time.now, - state: scheduled_at ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE, + state: state, tags: validate_tags(insert_opts.tags || args_insert_opts.tags || []) ) @@ -194,6 +377,7 @@ def insert_many(args) insert_params.unique_key = unique_key insert_params.unique_states = unique_states end + insert_params end @@ -210,7 +394,7 @@ def insert_many(args) if unique_opts.by_args parsed_args = JSON.parse(insert_params.encoded_args) filtered_args = if unique_opts.by_args.is_a?(Array) - parsed_args.slice(*unique_opts.by_args) + parsed_args.slice(*unique_opts.by_args.map(&:to_s)) else parsed_args end @@ -235,6 +419,41 @@ def insert_many(args) [unique_key_hash, UniqueBitmask.from_states(unique_states)] end + private def run_insert_plugins(all_params, &insert_operation) + if config.plugins.empty? + 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 + + 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 + + @runtime.wake + 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 + + operation.call + end + # Truncates the given time down to the interval. For example: # # Thu Jan 15 21:26:36 UTC 2024 @ 15 minutes -> @@ -249,9 +468,6 @@ def insert_many(args) [int].pack("Q").unpack1("q") #: Integer # rubocop:disable Layout/LeadingCommentSpace end - TAG_RE = /\A\w[\w-]+\w\z/ - private_constant :TAG_RE - private def validate_tags(tags) tags.each do |tag| raise ArgumentError, "tags should be 255 characters or less" if tag.length > 255 @@ -260,9 +476,11 @@ def insert_many(args) 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 @@ -276,6 +494,7 @@ class InsertManyParams # Insertion options to use with the insert. attr_reader :insert_opts + # Pairs job arguments with per-job insertion options for Client#insert_many. def initialize(args, insert_opts: nil) @args = args @insert_opts = insert_opts @@ -283,7 +502,7 @@ def initialize(args, insert_opts: nil) end # Result of a single insertion. - class InsertResult + class JobInsertResult # Inserted job row, or an existing job row if insert was skipped due to a # previously existing unique job. attr_reader :job @@ -292,6 +511,8 @@ class InsertResult # job matching unique property already being present. attr_reader :unique_skipped_as_duplicated + # Creates an insertion result. Applications normally receive instances from + # Client#insert or Client#insert_many. def initialize(job, unique_skipped_as_duplicated:) @job = job @unique_skipped_as_duplicated = unique_skipped_as_duplicated diff --git a/lib/client_runtime.rb b/lib/client_runtime.rb new file mode 100644 index 0000000..206fcc3 --- /dev/null +++ b/lib/client_runtime.rb @@ -0,0 +1,570 @@ +# frozen_string_literal: true + +require "timeout" + +module River + class ClientRuntime + class Interrupted < StandardError; end + + attr_reader :periodic_jobs + + def initialize(client, driver, config) + @client = client + @condition = ConditionVariable.new + @config = config + @driver = driver + @mutex = Mutex.new + @periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs, wake: method(:wake)) + @producer_threads = {} + @queue_configs = config.queues.dup + @removed_queues = {} + @running = {} + @started = false + @stopped = true + @subscriptions = [] + @threads = [] + end + + def finish_claimed(row, error = nil) + started_at = Time.now.utc + job = Job.new(@client, row) + if error + finish_failed(row, job, error, started_at) + else + now = Time.now.utc + completed = @driver.job_set_state_if_running(id: row.id, finalized_at: now, now: now, state: JOB_STATE_COMPLETED) + publish(EVENT_JOB_COMPLETED, completed, started_at) if completed + 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) + [@driver.job_get_by_id(id), 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 + start_producer(name, queue_config) + 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) + + @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 + @mutex.synchronize do + raise ClientAlreadyStartedError, "client is already started" if @started || @performing + + @started = true + @stop_requested = false + @stopped = false + end + + begin + @queue_configs.each { |name, queue_config| start_producer(name, queue_config) } + start_maintenance unless @queue_configs.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 + + @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) + @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) + started_at = Time.now.utc + job = Job.new(@client, row) + begin + worker = resolve_worker(row.kind) + begin_work(row.id) + begin + Thread.handle_interrupt(Interrupted => :immediate, JobCancelError => :immediate) do + invoke_worker(worker, job) + end + ensure + 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) + @driver.job_delete_if_running(row.id) + return [:deleted, nil] + end + end + + completed_at = Time.now.utc + completed = if finalize_hooks + @driver.job_set_state_if_running(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at, state: JOB_STATE_COMPLETED) + else + result = @driver.job_complete(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at) + case result + when :cancelled then raise JobCancelError + else result + end + end + publish(EVENT_JOB_COMPLETED, completed, started_at) if completed + + [:completed, nil] + rescue JobSnoozeError => error + job.__capture_resumable_metadata! + [finish_snoozed(row, job, error, started_at), error] + rescue JobCancelError => error + job.__capture_resumable_metadata! + finish_failed(row, job, error, started_at, cancelled: true) + [:cancelled, error] + rescue Interrupted => error + job.__capture_resumable_metadata! + 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, started_at) if interrupted + + [(interrupted&.state == JOB_STATE_CANCELLED) ? :cancelled : :interrupted, error] + rescue => error + job.__capture_resumable_metadata! + [finish_failed(row, job, error, started_at, worker: worker), error] + ensure + @mutex.synchronize do + @running.delete(row.id) + @condition.broadcast + end + end + end + + private def finish_failed(row, job, error, started_at, cancelled: false, worker: nil) + cancelled ||= error_handler_cancel?(error, job) + now = Time.now.utc + attempt_error = AttemptError.new( + at: 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, started_at) 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_snoozed(row, job, error, started_at) + scheduled_at = Time.now.utc + error.duration + state = (error.duration <= 5) ? JOB_STATE_AVAILABLE : JOB_STATE_SCHEDULED + metadata = job.metadata_updates.merge("snoozes" => row.metadata.fetch("snoozes", 0).to_i + 1) + 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, started_at) 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 { |service| service.run(@client, @driver, now) } + next_schedule = now + 5 + end + + if now >= next_rescue + @driver.job_rescue_stuck(horizon: now - 3_600, now: now, 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 + }) + 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.fetch(row.kind) + 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 + + private def perform_work(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?) + + result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job) + job.__finish_resumable_work! + result + end + + private def producer_loop(queue, queue_config) + cooldown = queue_config.resolved_fetch_cooldown(@config) + last_fetch = 0.0 + poll_interval = queue_config.resolved_fetch_poll_interval(@config) + loop do + break if queue_stopping?(queue) + + check_remote_cancellations(queue) + queue_row = @driver.queue_get(queue) + capacity = queue_config.max_workers - running_count(queue) + if capacity.positive? && !queue_row&.paused_at + sleep_for = cooldown - (monotonic_now - last_fetch) + wait([sleep_for, 0].max) if sleep_for.positive? + break if queue_stopping?(queue) + + jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue) + last_fetch = monotonic_now + jobs.each { |job| launch(job) } + next unless jobs.empty? + end + + wait(poll_interval) + end + rescue => error + @config.logger.error("River producer for #{queue.inspect} stopped: #{error.full_message}") + wait(poll_interval || @config.fetch_poll_interval) + retry unless queue_stopping?(queue) + end + + private def publish(kind, job, started_at) + kind = EVENT_JOB_CANCELLED if job.state == JOB_STATE_CANCELLED + completed_at = Time.now.utc + stats = JobStatistics.new(0, started_at - job.scheduled_at, completed_at - started_at) + 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 resolve_worker(kind) + worker = @config.workers.fetch(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) + @periodic_jobs.due(now).each do |periodic_job| + value = periodic_job.constructor.call + next unless value + + args, opts = value.is_a?(Array) ? value : [value, nil] + @client.insert(args, insert_opts: opts || InsertOpts.new) + 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 + @mutex.synchronize do + 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) + thread = Thread.new { producer_loop(name, queue_config) } + @mutex.synchronize do + @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 + end +end diff --git a/lib/config.rb b/lib/config.rb new file mode 100644 index 0000000..44000fe --- /dev/null +++ b/lib/config.rb @@ -0,0 +1,143 @@ +# 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_poll_interval, :id, :job_timeout, :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. + def initialize( + queues: {}, + workers: Workers.new, + id: nil, + fetch_cooldown: FETCH_COOLDOWN_DEFAULT, + fetch_poll_interval: FETCH_POLL_INTERVAL_DEFAULT, + job_timeout: JOB_TIMEOUT_DEFAULT, + 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_poll_interval = Float(fetch_poll_interval) + @id = id || "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(6)}" + @job_timeout = job_timeout.nil? ? nil : Float(job_timeout) + @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_poll_interval: fetch_poll_interval, + job_timeout: job_timeout, + 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) + + 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/lib/driver.rb b/lib/driver.rb index 92903f6..58d1ee2 100644 --- a/lib/driver.rb +++ b/lib/driver.rb @@ -1,3 +1,5 @@ +# 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 @@ -7,9 +9,11 @@ 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 @@ -22,17 +26,16 @@ def initialize( encoded_args:, kind:, max_attempts:, - priority:, - queue:, - scheduled_at:, - state:, - tags:, + priority:, queue:, scheduled_at:, state:, tags:, metadata: {}, unique_key: nil, - unique_states: 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 @@ -44,3 +47,5 @@ def initialize( end end end + +require_relative "driver/runtime" diff --git a/lib/driver/runtime.rb b/lib/driver/runtime.rb new file mode 100644 index 0000000..e14b340 --- /dev/null +++ b/lib/driver/runtime.rb @@ -0,0 +1,524 @@ +# 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 job_cancel(id, now: Time.now.utc) + original_id = Integer(id) + 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 + job_get_by_id(updated_id || original_id) + 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 + + # Drivers may combine the cancellation probe and completion atomically. + # :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) + return :cancelled if job_get_cancelled_ids([id]).include?(id) + + job_set_state_if_running(id: id, finalized_at: finalized_at, metadata: metadata, now: now, state: "completed") + 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 + + def job_delete_if_running(id) + runtime_returning_ids("DELETE FROM river_job WHERE id = #{Integer(id)} AND state = 'running' RETURNING id").any? + end + + def job_delete_many(params) + transaction do + jobs = job_list(params).reject { |job| job.state == River::JOB_STATE_RUNNING } + 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:, now: Time.now.utc) + runtime_claim_jobs("state = 'available' AND queue = #{runtime_quote(queue)} AND scheduled_at <= #{runtime_time(now)}", 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 + + clauses = [] #: Array[String] + 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} + SQL + 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) + transaction do + # Select only stuck jobs before applying the limit, and hold their locks + # until rescue finishes so a newer attempt cannot be rescued by mistake. + lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : "" + ids = runtime_returning_ids(<<~SQL) + SELECT id FROM river_job + WHERE state = 'running' AND attempted_at < #{runtime_time(horizon)} + ORDER BY id LIMIT #{Integer(max)} #{lock} + SQL + jobs = ids.map { |id| job_get_by_id(id) } + jobs.each do |job| + cancelled = job.metadata.key?("cancel_attempted_at") + 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: "") + job_set_state_if_running( + id: job.id, + error: error, + finalized_at: final ? now : nil, + metadata: {"river:rescue_count" => job.metadata.fetch("river:rescue_count", 0).to_i + 1}, + now: now, + scheduled_at: final ? nil : retry_policy.next_retry(job, error, now: now), + state: state + ) + end + + jobs.length + end + end + + def job_retry(id, now: Time.now.utc) + 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, + finalized_at = NULL, + scheduled_at = #{runtime_time(now)} + WHERE id = #{Integer(id)} + AND state != 'running' + AND (state != 'available' OR scheduled_at > #{runtime_time(now)}) + RETURNING id + SQL + job_get_by_id(updated_id || id) + 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 + retrying = [River::JOB_STATE_AVAILABLE, River::JOB_STATE_RETRYABLE, River::JOB_STATE_SCHEDULED].include?(state) + cancel_path = retrying ? runtime_cancel_attempted : "false" + + 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 ? job_get_by_id(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) + runtime_execute("DELETE FROM river_leader WHERE leader_id = #{runtime_quote(id)}") + 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 + + 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) + filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}" + runtime_execute(<<~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} + SQL + end + + def queue_resume(name, now: Time.now.utc) + filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}" + runtime_execute(<<~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} + SQL + end + + def queue_update(name, metadata:, now: Time.now.utc) + id = 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 + id ? queue_get(name) : nil + end + + def queue_upsert(name, metadata: {}, now: Time.now.utc) + 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 + "jsonb(json_insert(json(coalesce(errors, jsonb('[]'))), '$[#]', json(#{runtime_quote(JSON.generate(value))})))" + end + end + + private def runtime_cancel_attempted + runtime_postgres? ? "metadata ? 'cancel_attempted_at'" : "(metadata -> 'cancel_attempted_at') 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 + "jsonb(json_insert(json(coalesce(attempted_by, jsonb('[]'))), '$[#]', #{runtime_quote(attempted_by)}))" + end + + lock_clause = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : "" + ids = runtime_returning_ids(<<~SQL) + UPDATE river_job + SET attempt = attempt + 1, + 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| job_get_by_id(id) } + end + end + + private def runtime_in_clause(column, values) + "#{column} IN (#{values.map { |value| runtime_quote(value) }.join(",")})" + 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_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) + 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. + 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 + 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_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, :max_attempts + Integer(value).to_s + when :attempted_at, :finalized_at + value ? runtime_time(value) : "NULL" + when :attempted_by + runtime_postgres? ? "ARRAY[#{Array(value).map { |item| runtime_quote(item) }.join(",")}]::text[]" : runtime_json(Array(value)) + 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 :metadata + runtime_json(value) + when :state + runtime_state(value) + end + end + end +end diff --git a/lib/errors.rb b/lib/errors.rb new file mode 100644 index 0000000..3c21a31 --- /dev/null +++ b/lib/errors.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module River + class Error < StandardError; end + + class NotFoundError < Error; end + + class JobRunningError < Error; 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/lib/event.rb b/lib/event.rb new file mode 100644 index 0000000..a3d9056 --- /dev/null +++ b/lib/event.rb @@ -0,0 +1,78 @@ +# 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. + 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/lib/insert_opts.rb b/lib/insert_opts.rb index 236038c..b4ad7e9 100644 --- a/lib/insert_opts.rb +++ b/lib/insert_opts.rb @@ -1,3 +1,5 @@ +# 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 @@ -7,6 +9,10 @@ class InsertOpts # all retries) before a job is abandoned and set as discarded. 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 @@ -16,7 +22,7 @@ class InsertOpts # Defaults to PRIORITY_DEFAULT. attr_accessor :priority - # The name of the job queue in which to insert the job. + # The name of the job queue in which to insert the job, as a symbol or string. # # Defaults to QUEUE_DEFAULT. attr_accessor :queue @@ -31,6 +37,10 @@ class InsertOpts # 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 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. @@ -43,18 +53,23 @@ class InsertOpts # 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 @@ -74,7 +89,8 @@ def initialize( # be inserted as a new job. class UniqueOpts # Indicates that uniqueness should be enforced for any specific instance of - # encoded args for a job. + # encoded args for a job. An array of symbol or string keys selects only + # those top-level arguments. # # 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. @@ -108,14 +124,14 @@ class UniqueOpts # Unlike other unique options, ByState gets a default when it's not set for # user convenience. The default is equivalent to: # - # by_state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_COMPLETED, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_RETRYABLE, River::JOB_STATE_SCHEDULED] + # by_state: %i[available completed pending running retryable scheduled] # - # With this setting, any jobs of the same kind that have been completed or + # 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. + # 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 @@ -123,6 +139,8 @@ class UniqueOpts # across multiple worker types. 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, diff --git a/lib/job.rb b/lib/job.rb index ca56780..5df4cb8 100644 --- a/lib/job.rb +++ b/lib/job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module River JOB_STATE_AVAILABLE = "available" JOB_STATE_CANCELLED = "cancelled" @@ -10,37 +12,36 @@ module River # 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" string for identifying the job in the database and the second is a + # 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", { + # 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 "kind should be non-nil" if !kind raise "hash should be non-nil" if !hash - @kind = kind @hash = hash + @kind = kind.to_s end - attr_reader :kind - + # Encodes the argument hash for insertion. def to_json - JSON.dump(@hash) + # 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 - # 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 - # The job's args as a hash decoded from JSON. attr_accessor :args @@ -72,6 +73,11 @@ class JobRow # 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 @@ -189,5 +195,10 @@ def initialize( self.error = error self.trace = trace end + + # Returns the database-compatible representation of this attempt error. + def to_h + {at: at.utc.iso8601(6), attempt: attempt, error: error, trace: trace} + end end end diff --git a/lib/migration_cli.rb b/lib/migration_cli.rb new file mode 100644 index 0000000..cbf4d99 --- /dev/null +++ b/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/lib/migrator.rb b/lib/migrator.rb new file mode 100644 index 0000000..bf9c8c8 --- /dev/null +++ b/lib/migrator.rb @@ -0,0 +1,188 @@ +# 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. + # 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 == :up && destination < current) || (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/lib/params.rb b/lib/params.rb new file mode 100644 index 0000000..27a95ab --- /dev/null +++ b/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/lib/periodic_cron.rb b/lib/periodic_cron.rb new file mode 100644 index 0000000..87fbeef --- /dev/null +++ b/lib/periodic_cron.rb @@ -0,0 +1,24 @@ +# 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 such as America/New_York for local calendar schedules. Specify the + # timezone here, not inside +expression+. 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) + + @cron = Object.const_get(:Fugit).const_get(:Cron).do_parse("#{expression} #{timezone}") + end + + # Returns a UTC Time strictly after +time+. Calendar and daylight-saving + # rules are provided by Fugit; this helper does not enqueue or persist jobs. + def next(time) + @cron.next_time(time).to_t.getutc + end + end +end diff --git a/lib/periodic_job.rb b/lib/periodic_job.rb new file mode 100644 index 0000000..495805e --- /dev/null +++ b/lib/periodic_job.rb @@ -0,0 +1,107 @@ +# 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 +constructor+ is called whenever the job is due. +id+ accepts + # a symbol or string; nil leaves the registration anonymous. + def initialize(schedule:, constructor:, id: nil, run_on_start: false) + @constructor = constructor + @id = id&.to_s + @run_on_start = run_on_start + @schedule = schedule + end + + # Returns the next scheduled Time after +now+. + def next_at(now) + schedule.respond_to?(:next) ? schedule.next(now) : schedule.call(now) + 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:) + @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) + @mutex.synchronize do + raise ArgumentError, "periodic job ID is already registered: #{job.id}" if job.id && @jobs.values.any? { |entry| entry[:job].id == job.id } + + @next_handle += 1 + @jobs[@next_handle] = {job: job, next_at: job.run_on_start ? Time.now.utc : job.next_at(Time.now.utc)} + @wake.call + @next_handle + end + end + + # Adds periodic jobs and returns their handles in input order. + def add_many(jobs) + jobs.map { |job| add(job) } + end + + # Removes all periodic jobs from the bundle. + def clear + @mutex.synchronize { @jobs.clear } + end + + def due(now) + @mutex.synchronize do + @jobs.values.filter_map do |entry| + next if entry[:next_at] > now + + entry[:next_at] = entry[:job].next_at(now) + entry[:job] + end + end + end + + # Removes the job associated with +handle+, returning its internal entry or + # nil when no such handle exists. + def remove(handle) + @mutex.synchronize { @jobs.delete(handle) } + 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/lib/resumable.rb b/lib/resumable.rb new file mode 100644 index 0000000..71ba61c --- /dev/null +++ b/lib/resumable.rb @@ -0,0 +1,146 @@ +# 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. 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 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 + + previous_step_name = @resumable_state.step_name + @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 = previous_step_name + end + end + end +end diff --git a/lib/riverqueue.rb b/lib/riverqueue.rb index 429e0d5..b3d70e6 100644 --- a/lib/riverqueue.rb +++ b/lib/riverqueue.rb @@ -1,11 +1,25 @@ +# frozen_string_literal: true + require "json" +require "securerandom" +require_relative "errors" require_relative "insert_opts" require_relative "job" +require_relative "worker" +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 "migrator" module River end diff --git a/lib/riverqueue/testing.rb b/lib/riverqueue/testing.rb new file mode 100644 index 0000000..56a81ea --- /dev/null +++ b/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/lib/riverqueue/testing/minitest.rb b/lib/riverqueue/testing/minitest.rb new file mode 100644 index 0000000..8ef4083 --- /dev/null +++ b/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/lib/riverqueue/testing/rspec.rb b/lib/riverqueue/testing/rspec.rb new file mode 100644 index 0000000..aa585b0 --- /dev/null +++ b/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/lib/unique_bitmask.rb b/lib/unique_bitmask.rb index 389885c..5a7b323 100644 --- a/lib/unique_bitmask.rb +++ b/lib/unique_bitmask.rb @@ -1,17 +1,7 @@ +# frozen_string_literal: true + module River class UniqueBitmask - 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 - def self.from_states(states) val = 0 @@ -37,5 +27,17 @@ def self.to_states(mask) 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/lib/worker.rb b/lib/worker.rb new file mode 100644 index 0000000..7cfdfd0 --- /dev/null +++ b/lib/worker.rb @@ -0,0 +1,118 @@ +# 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 fetch(kind) + @workers[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 + @metadata_updates = {} + @row = row + initialize_resumable_state + end + + # Returns the job arguments decoded from their persisted JSON. + def args = row.args + + # Returns persisted metadata merged with changes made during this attempt. + def metadata = row.metadata.merge(@metadata_updates) + + # Returns a copy of metadata changes waiting to be persisted when work + # finishes. + def metadata_updates + @metadata_updates.dup + 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. Keys are converted to strings. Returns self. + def update_metadata(values) + @metadata_updates.merge!(values.transform_keys(&:to_s)) + self + end + end + + # Default retry schedule used when a worker does not provide its own policy. + class DefaultClientRetryPolicy + # 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) + now + seconds + (seconds * (@random.rand * 0.2 - 0.1)) + end + end +end diff --git a/lib/worker_runner.rb b/lib/worker_runner.rb new file mode 100644 index 0000000..f676008 --- /dev/null +++ b/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/migration/LICENSE b/migration/LICENSE new file mode 100644 index 0000000..2f8ed18 --- /dev/null +++ b/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/migration/README.md b/migration/README.md new file mode 100644 index 0000000..b0ccbee --- /dev/null +++ b/migration/README.md @@ -0,0 +1,10 @@ +# 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. + +manifest.json records the source commit and SHA-256 of every SQL file. +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/migration/manifest.json b/migration/manifest.json new file mode 100644 index 0000000..45aff55 --- /dev/null +++ b/migration/manifest.json @@ -0,0 +1,34 @@ +{ + "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", + "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": "ea4cc3b27dd0b98951f04ea08441ad906cff1f4d34f2425d9934d8d8dd1eae00", + "sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql": "3adbfaf2319c588ed4baf52b5a63d2c8db7e3622f5d200db96bb077af9e4af15", + "sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql": "213cd4c251016c3b18b54aff3d95009db029986a8fdb3bbf633e5297804779e1" + }, + "repository": "river", + "revision": "48c0036dcb12b1e2bb65c355593388bb7ff9926e" +} diff --git a/migration/postgresql/main/001_create_river_migration.down.sql b/migration/postgresql/main/001_create_river_migration.down.sql new file mode 100644 index 0000000..8bfe820 --- /dev/null +++ b/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/migration/postgresql/main/001_create_river_migration.up.sql b/migration/postgresql/main/001_create_river_migration.up.sql new file mode 100644 index 0000000..27006d5 --- /dev/null +++ b/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/migration/postgresql/main/002_initial_schema.down.sql b/migration/postgresql/main/002_initial_schema.down.sql new file mode 100644 index 0000000..d334d8a --- /dev/null +++ b/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/migration/postgresql/main/002_initial_schema.up.sql b/migration/postgresql/main/002_initial_schema.up.sql new file mode 100644 index 0000000..7fbca71 --- /dev/null +++ b/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/migration/postgresql/main/003_river_job_tags_non_null.down.sql b/migration/postgresql/main/003_river_job_tags_non_null.down.sql new file mode 100644 index 0000000..acef65c --- /dev/null +++ b/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/migration/postgresql/main/003_river_job_tags_non_null.up.sql b/migration/postgresql/main/003_river_job_tags_non_null.up.sql new file mode 100644 index 0000000..0a472dd --- /dev/null +++ b/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/migration/postgresql/main/004_pending_and_more.down.sql b/migration/postgresql/main/004_pending_and_more.down.sql new file mode 100644 index 0000000..1b7ec7e --- /dev/null +++ b/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/migration/postgresql/main/004_pending_and_more.up.sql b/migration/postgresql/main/004_pending_and_more.up.sql new file mode 100644 index 0000000..9f5e47b --- /dev/null +++ b/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/migration/postgresql/main/005_migration_unique_client.down.sql b/migration/postgresql/main/005_migration_unique_client.down.sql new file mode 100644 index 0000000..b8e041d --- /dev/null +++ b/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/migration/postgresql/main/005_migration_unique_client.up.sql b/migration/postgresql/main/005_migration_unique_client.up.sql new file mode 100644 index 0000000..e0f1711 --- /dev/null +++ b/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/migration/postgresql/main/006_bulk_unique.down.sql b/migration/postgresql/main/006_bulk_unique.down.sql new file mode 100644 index 0000000..26cd843 --- /dev/null +++ b/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/migration/postgresql/main/006_bulk_unique.up.sql b/migration/postgresql/main/006_bulk_unique.up.sql new file mode 100644 index 0000000..ef96a19 --- /dev/null +++ b/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/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql b/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql new file mode 100644 index 0000000..bed717f --- /dev/null +++ b/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/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql b/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql new file mode 100644 index 0000000..39e3249 --- /dev/null +++ b/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/migration/sqlite/main/001_create_river_migration.down.sql b/migration/sqlite/main/001_create_river_migration.down.sql new file mode 100644 index 0000000..8bfe820 --- /dev/null +++ b/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/migration/sqlite/main/001_create_river_migration.up.sql b/migration/sqlite/main/001_create_river_migration.up.sql new file mode 100644 index 0000000..bdaf093 --- /dev/null +++ b/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/migration/sqlite/main/002_initial_schema.down.sql b/migration/sqlite/main/002_initial_schema.down.sql new file mode 100644 index 0000000..cbdd56d --- /dev/null +++ b/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/migration/sqlite/main/002_initial_schema.up.sql b/migration/sqlite/main/002_initial_schema.up.sql new file mode 100644 index 0000000..043facf --- /dev/null +++ b/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/migration/sqlite/main/003_river_job_tags_non_null.down.sql b/migration/sqlite/main/003_river_job_tags_non_null.down.sql new file mode 100644 index 0000000..8d314cf --- /dev/null +++ b/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/migration/sqlite/main/003_river_job_tags_non_null.up.sql b/migration/sqlite/main/003_river_job_tags_non_null.up.sql new file mode 100644 index 0000000..d4e1e24 --- /dev/null +++ b/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/migration/sqlite/main/004_pending_and_more.down.sql b/migration/sqlite/main/004_pending_and_more.down.sql new file mode 100644 index 0000000..c645544 --- /dev/null +++ b/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/migration/sqlite/main/004_pending_and_more.up.sql b/migration/sqlite/main/004_pending_and_more.up.sql new file mode 100644 index 0000000..254e1f7 --- /dev/null +++ b/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/migration/sqlite/main/005_migration_unique_client.down.sql b/migration/sqlite/main/005_migration_unique_client.down.sql new file mode 100644 index 0000000..d94787d --- /dev/null +++ b/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/migration/sqlite/main/005_migration_unique_client.up.sql b/migration/sqlite/main/005_migration_unique_client.up.sql new file mode 100644 index 0000000..dc32733 --- /dev/null +++ b/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/migration/sqlite/main/006_bulk_unique.down.sql b/migration/sqlite/main/006_bulk_unique.down.sql new file mode 100644 index 0000000..a8d273f --- /dev/null +++ b/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/migration/sqlite/main/006_bulk_unique.up.sql b/migration/sqlite/main/006_bulk_unique.up.sql new file mode 100644 index 0000000..83e4991 --- /dev/null +++ b/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 makes this autoincrementing automatically + 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; \ No newline at end of file diff --git a/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql b/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql new file mode 100644 index 0000000..24944ad --- /dev/null +++ b/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 makes this autoincrementing automatically + 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/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql b/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql new file mode 100644 index 0000000..511aede --- /dev/null +++ b/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 makes this autoincrementing automatically + 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/rails/riverqueue-rails/Gemfile b/rails/riverqueue-rails/Gemfile new file mode 100644 index 0000000..88e1d78 --- /dev/null +++ b/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/rails/riverqueue-rails/Gemfile.lock b/rails/riverqueue-rails/Gemfile.lock new file mode 100644 index 0000000..6f3ffb3 --- /dev/null +++ b/rails/riverqueue-rails/Gemfile.lock @@ -0,0 +1,379 @@ +PATH + remote: ../../driver/riverqueue-activerecord + specs: + riverqueue-activerecord (0.11.0) + activerecord (> 0, < 1000) + activesupport (> 0, < 1000) + riverqueue (= 0.11.0) + +PATH + remote: ../.. + specs: + riverqueue (0.11.0) + logger (> 0, < 1000) + optparse (> 0, < 1000) + securerandom (> 0, < 1000) + timeout (> 0, < 1000) + +PATH + remote: . + specs: + riverqueue-rails (0.11.0) + activejob (>= 7.2, < 8.2) + railties (>= 7.2, < 8.2) + riverqueue-activerecord (= 0.11.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) + actionpack (8.1.3.1) + actionview (8.1.3.1) + activejob (8.1.3.1) + activemodel (8.1.3.1) + activerecord (8.1.3.1) + activesupport (8.1.3.1) + 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) + 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.11.0) + riverqueue-activerecord (0.11.0) + riverqueue-rails (0.11.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) + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + +BUNDLED WITH + 4.0.9 diff --git a/rails/riverqueue-rails/README.md b/rails/riverqueue-rails/README.md new file mode 100644 index 0000000..5f87850 --- /dev/null +++ b/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/rails/riverqueue-rails/lib/active_job/queue_adapters/river_adapter.rb b/rails/riverqueue-rails/lib/active_job/queue_adapters/river_adapter.rb new file mode 100644 index 0000000..ff3f777 --- /dev/null +++ b/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}), + insert_opts: River::InsertOpts.new(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/rails/riverqueue-rails/lib/generators/river/install_generator.rb b/rails/riverqueue-rails/lib/generators/river/install_generator.rb new file mode 100644 index 0000000..2726a4f --- /dev/null +++ b/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/rails/riverqueue-rails/lib/generators/river/templates/jobs b/rails/riverqueue-rails/lib/generators/river/templates/jobs new file mode 100644 index 0000000..b7ef8d9 --- /dev/null +++ b/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/rails/riverqueue-rails/lib/generators/river/templates/river.rb b/rails/riverqueue-rails/lib/generators/river/templates/river.rb new file mode 100644 index 0000000..e805ac7 --- /dev/null +++ b/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/rails/riverqueue-rails/lib/river/rails/configuration.rb b/rails/riverqueue-rails/lib/river/rails/configuration.rb new file mode 100644 index 0000000..4d578c0 --- /dev/null +++ b/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/rails/riverqueue-rails/lib/river/rails/railtie.rb b/rails/riverqueue-rails/lib/river/rails/railtie.rb new file mode 100644 index 0000000..b62a46c --- /dev/null +++ b/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/rails/riverqueue-rails/lib/river/rails/runner.rb b/rails/riverqueue-rails/lib/river/rails/runner.rb new file mode 100644 index 0000000..5124035 --- /dev/null +++ b/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/rails/riverqueue-rails/lib/river/rails/worker.rb b/rails/riverqueue-rails/lib/river/rails/worker.rb new file mode 100644 index 0000000..42f2926 --- /dev/null +++ b/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/rails/riverqueue-rails/lib/riverqueue-rails.rb b/rails/riverqueue-rails/lib/riverqueue-rails.rb new file mode 100644 index 0000000..6f07563 --- /dev/null +++ b/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/rails/riverqueue-rails/riverqueue-rails.gemspec b/rails/riverqueue-rails/riverqueue-rails.gemspec new file mode 100644 index 0000000..1b0c7d0 --- /dev/null +++ b/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.11.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", "= 0.11.0" +end diff --git a/rails/riverqueue-rails/spec/configuration_spec.rb b/rails/riverqueue-rails/spec/configuration_spec.rb new file mode 100644 index 0000000..12e48d1 --- /dev/null +++ b/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/rails/riverqueue-rails/spec/connection_class_spec.rb b/rails/riverqueue-rails/spec/connection_class_spec.rb new file mode 100644 index 0000000..3415909 --- /dev/null +++ b/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" 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/rails/riverqueue-rails/spec/dummy/app/jobs/reloadable_job.rb b/rails/riverqueue-rails/spec/dummy/app/jobs/reloadable_job.rb new file mode 100644 index 0000000..57dbdeb --- /dev/null +++ b/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/rails/riverqueue-rails/spec/dummy/config/database.yml b/rails/riverqueue-rails/spec/dummy/config/database.yml new file mode 100644 index 0000000..63c21dd --- /dev/null +++ b/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/rails/riverqueue-rails/spec/integration_spec.rb b/rails/riverqueue-rails/spec/integration_spec.rb new file mode 100644 index 0000000..79d8c9d --- /dev/null +++ b/rails/riverqueue-rails/spec/integration_spec.rb @@ -0,0 +1,333 @@ +# 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 = [:sqlite] +begin + require "pg" + PG.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test").close + adapters << :postgres +rescue PG::Error => error + raise if ENV["RIVER_REQUIRE_DATABASES"] == "1" + + warn "Skipping Rails PostgreSQL tests: #{error.message}" +end + +adapters.each do |backend| + RSpec.describe "Rails integration with #{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 "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/rails/riverqueue-rails/spec/runner_spec.rb b/rails/riverqueue-rails/spec/runner_spec.rb new file mode 100644 index 0000000..5d4a850 --- /dev/null +++ b/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/rails/riverqueue-rails/spec/spec_helper.rb b/rails/riverqueue-rails/spec/spec_helper.rb new file mode 100644 index 0000000..5790796 --- /dev/null +++ b/rails/riverqueue-rails/spec/spec_helper.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +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/riverqueue.gemspec b/riverqueue.gemspec index 84db7b6..b537962 100644 --- a/riverqueue.gemspec +++ b/riverqueue.gemspec @@ -1,13 +1,18 @@ +# frozen_string_literal: true + Gem::Specification.new do |s| s.name = "riverqueue" s.version = "0.11.0" - s.summary = "River is a fast job queue for Go." - s.description = "River is a fast job queue for Go. Use this gem in conjunction with gems riverqueue-activerecord or riverqueue-sequel to insert jobs in Ruby which will be worked from Go." + 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("lib/**/*") + s.files = Dir.glob("{exe,lib,migration,sig}/**/*") + ["CHANGELOG.md", "LICENSE", "docs/README.md", "docs/migrations.md", "docs/testing.md", "docs/workers.md"] + s.bindir = "exe" + s.executables = ["river"] s.homepage = "https://riverqueue.com" - s.license = "LGPL-3.0-or-later" + s.license = "MPL-2.0" + s.required_ruby_version = ">= 3.2" s.require_path = %(lib) s.metadata = { "bug_tracker_uri" => "https://github.com/riverqueue/riverqueue-ruby/issues", @@ -15,4 +20,10 @@ Gem::Specification.new do |s| "rubygems_mfa_required" => "true", "source_code_uri" => "https://github.com/riverqueue/riverqueue-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/scripts/sync_migrations.rb b/scripts/sync_migrations.rb new file mode 100644 index 0000000..459f763 --- /dev/null +++ b/scripts/sync_migrations.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "json" +require "open3" + +# Copies upstream SQL verbatim. --check verifies both file names and bytes. +check = ARGV.delete("--check") + +source = File.expand_path(ARGV.fetch(0)) +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 + +revision, status = Open3.capture2("git", "-C", source, "rev-parse", "HEAD") +abort "Cannot resolve upstream revision" unless status.success? + +manifest = {"files" => files, "repository" => "river", "revision" => revision.strip} +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 at #{revision.strip}" diff --git a/scripts/update_gemspec_version.rb b/scripts/update_gemspec_version.rb index 689a135..a567ea1 100644 --- a/scripts/update_gemspec_version.rb +++ b/scripts/update_gemspec_version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # # Updates the version in a gemspec file since doing it from the shell is a total # pain. diff --git a/sig/client.rbs b/sig/client.rbs index 8fb37ae..327f301 100644 --- a/sig/client.rbs +++ b/sig/client.rbs @@ -4,27 +4,57 @@ module River QUEUE_DEFAULT: String class Client + @config: Config @driver: _Driver + @runtime: ClientRuntime @time_now_utc: ^() -> Time - def initialize: (_Driver driver) -> void - def insert: (jobArgs, ?insert_opts: InsertOpts) -> InsertResult - def insert_many: (Array[jobArgs | InsertManyParams]) -> Array[InsertResult] + 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) -> JobInsertResult + def insert_many: (Array[jobArgs | InsertManyParams]) -> Array[JobInsertResult] + def job_cancel: (Integer) -> JobRow + def job_delete: (Integer) -> JobRow + def job_delete_many: (JobListParams) -> untyped + def job_get: (Integer) -> JobRow + def job_list: (?JobListParams) -> untyped + def job_retry: (Integer) -> JobRow + def job_update: (Integer, JobUpdateParams) -> 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 REQUIRED_UNIQUE_STATES: Array[jobStateAll] + TAG_RE: Regexp - private def insert_and_check_unique_job: (Driver::JobInsertParams) -> InsertResult + 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) -> Time private def uint64_to_int64: (Integer) -> Integer - - TAG_RE: Regexp - private def validate_tags: (Array[String]) -> Array[String] - private def validate_unique_states: (Array[jobStateAll]) -> Array[jobStateAll] + private def validate_unique_states: (Array[jobStateInput]) -> Array[jobStateAll] end class InsertManyParams @@ -38,7 +68,7 @@ module River def is_a?: (Class) -> bool end - class InsertResult + class JobInsertResult @job: JobRow @unique_skipped_as_duplicated: bool diff --git a/sig/driver.rbs b/sig/driver.rbs index ddf49db..cd8a199 100644 --- a/sig/driver.rbs +++ b/sig/driver.rbs @@ -1,15 +1,41 @@ module River interface _Driver + 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 + def job_delete_many: (JobListParams) -> Array[JobRow] + def job_get_available: (queue: String, max: Integer, attempted_by: 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 queue_get: (String) -> Queue? + def queue_list: (?max: Integer) -> Array[Queue] + def queue_pause: (String, ?now: Time) -> untyped + def queue_resume: (String, ?now: Time) -> untyped + 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: -> Array[JobRow] + def job_list: (?JobListParams | :all) -> Array[JobRow] def rollback_exception: -> Exception end @@ -25,9 +51,11 @@ module River 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? @@ -36,7 +64,66 @@ module River attr_accessor unique_key: String? attr_accessor unique_states: String? - def initialize: (encoded_args: String, kind: String, max_attempts: Integer, priority: Integer, queue: String, scheduled_at: Time?, state: jobStateAll, tags: Array[String]?, ?unique_key: String?, ?unique_states: String?) -> void + 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 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, ?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) -> 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 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_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_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_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_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/sig/insert_opts.rbs b/sig/insert_opts.rbs index 45b5fa6..0af4227 100644 --- a/sig/insert_opts.rbs +++ b/sig/insert_opts.rbs @@ -1,22 +1,24 @@ module River class InsertOpts attr_accessor max_attempts: Integer? + attr_accessor metadata: Hash[String | Symbol, untyped]? attr_accessor priority: Integer? - attr_accessor queue: String? + attr_accessor queue: (String | Symbol)? attr_accessor scheduled_at: Time? + attr_accessor state: jobStateInput? attr_accessor tags: Array[String]? attr_accessor unique_opts: UniqueOpts? - def initialize: (?max_attempts: Integer?, ?priority: Integer?, ?queue: String?, ?scheduled_at: Time?, ?tags: Array[String]?, ?unique_opts: UniqueOpts?) -> void + def initialize: (?max_attempts: Integer?, ?metadata: Hash[String | Symbol, untyped]?, ?priority: Integer?, ?queue: (String | Symbol)?, ?scheduled_at: Time?, ?state: jobStateInput?, ?tags: Array[String]?, ?unique_opts: UniqueOpts?) -> void end class UniqueOpts - attr_accessor by_args: bool? | Array[String]? + attr_accessor by_args: bool? | Array[String | Symbol]? attr_accessor by_period: Integer? attr_accessor by_queue: bool? - attr_accessor by_state: Array[jobStateAll]? + attr_accessor by_state: Array[jobStateInput]? attr_accessor exclude_kind: bool? - def initialize: (?by_args: bool? | Array[String]?, ?by_period: Integer?, ?by_queue: bool?, ?by_state: Array[jobStateAll]?, ?exclude_kind: bool?) -> void + def initialize: (?by_args: bool? | Array[String | Symbol]?, ?by_period: Integer?, ?by_queue: bool?, ?by_state: Array[jobStateInput]?, ?exclude_kind: bool?) -> void end end diff --git a/sig/job.rbs b/sig/job.rbs index 16a0288..782fdba 100644 --- a/sig/job.rbs +++ b/sig/job.rbs @@ -9,10 +9,11 @@ module River JOB_STATE_SCHEDULED: "scheduled" type jobStateAll = "available" | "cancelled" | "completed" | "discarded" | "pending" | "retryable" | "running" | "scheduled" + type jobStateInput = jobStateAll | :available | :cancelled | :completed | :discarded | :pending | :retryable | :running | :scheduled interface _JobArgs def is_a?: (Class) -> bool - def kind: () -> String + def kind: () -> (String | Symbol) def respond_to?: (Symbol) -> bool def to_json: () -> String end @@ -26,17 +27,16 @@ module River type jobArgs = _JobArgs | _JobArgsWithInsertOpts class JobArgsHash - @kind: String @hash: Hash[String | Symbol, untyped] + @kind: String attr_reader kind: String - def initialize: (String kind, Hash[String | Symbol, untyped] hash) -> void + def initialize: (String | Symbol kind, Hash[String | Symbol, untyped] hash) -> void def to_json: () -> String end class JobRow - attr_accessor id: Integer attr_accessor args: Hash[String, untyped] attr_accessor attempt: Integer attr_accessor attempted_at: Time? @@ -44,6 +44,7 @@ module River 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] @@ -65,5 +66,6 @@ module River 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/sig/migrator.rbs b/sig/migrator.rbs new file mode 100644 index 0000000..e283453 --- /dev/null +++ b/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/sig/runtime.rbs b/sig/runtime.rbs new file mode 100644 index 0000000..a88680e --- /dev/null +++ b/sig/runtime.rbs @@ -0,0 +1,365 @@ +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 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 fetch: (String | Symbol) -> untyped + def include?: (String | Symbol) -> bool + def kinds: () -> Array[String] + end + + class Job + RESUMABLE_CURSOR_UNSET: Object + @client: Client + @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 args: () -> 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 + @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_poll_interval: Float + @id: String + @job_timeout: Float? + @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_poll_interval: Float + attr_reader id: String + attr_reader job_timeout: Float? + 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_poll_interval: untyped, ?job_timeout: untyped, + ?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) -> void + def next_at: (Time) -> Time + end + + class PeriodicCron + @cron: untyped + def initialize: (String, ?timezone: String) -> void + def next: (Time) -> Time + end + + class PeriodicInterval + @seconds: Float + def initialize: (Numeric) -> void + def next: (Time) -> Time + end + + class PeriodicJobBundle + @jobs: Hash[Integer, untyped] + @mutex: Mutex + @next_handle: Integer + @wake: untyped + def initialize: (Array[PeriodicJob], wake: untyped) -> void + def add: (PeriodicJob) -> Integer + def add_many: (Array[PeriodicJob]) -> Array[Integer] + def clear: () -> untyped + def due: (Time) -> Array[PeriodicJob] + def remove: (Integer) -> untyped + def remove_by_id: (untyped) -> bool + end + + class ClientRuntime + class Interrupted < StandardError + 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, Time, ?cancelled: bool, ?worker: untyped) -> untyped + def finish_snoozed: (JobRow, Job, JobSnoozeError, Time) -> 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 retry_allowed?: (untyped, Job, Exception) -> untyped + def perform_work: (untyped, Job) -> untyped + def producer_loop: (String, QueueConfig) -> untyped + def publish: (Symbol, JobRow, Time) -> untyped + def queue_stopping?: (String) -> bool + def remove_subscription: (Subscription) -> Subscription? + 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 + end +end diff --git a/sig/testing.rbs b/sig/testing.rbs new file mode 100644 index 0000000..deb67e5 --- /dev/null +++ b/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/sig/unique_bitmask.rbs b/sig/unique_bitmask.rbs index af5f95b..94caa19 100644 --- a/sig/unique_bitmask.rbs +++ b/sig/unique_bitmask.rbs @@ -1,9 +1,8 @@ module River class UniqueBitmask - JOB_STATE_BIT_POSITIONS: Hash[jobStateAll, Integer] - 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/sig/worker_runner.rbs b/sig/worker_runner.rbs new file mode 100644 index 0000000..e78dac2 --- /dev/null +++ b/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/spec/cli_spec.rb b/spec/cli_spec.rb new file mode 100644 index 0000000..27a122e --- /dev/null +++ b/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/spec/client_admin_spec.rb b/spec/client_admin_spec.rb new file mode 100644 index 0000000..17d0802 --- /dev/null +++ b/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/spec/client_driver_shared_examples.rb b/spec/client_driver_shared_examples.rb new file mode 100644 index 0000000..5c601bd --- /dev/null +++ b/spec/client_driver_shared_examples.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +require "timeout" +require "riverqueue/testing" + +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 + [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_duplicated).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(:client) do + River::Client.new(@driver, config: River::Config.new( + fetch_cooldown: 0.001, + fetch_poll_interval: 0.01, + queues: {"driver_e2e" => 2}, + 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 + + 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/spec/client_runtime_branch_spec.rb b/spec/client_runtime_branch_spec.rb new file mode 100644 index 0000000..cfe2532 --- /dev/null +++ b/spec/client_runtime_branch_spec.rb @@ -0,0 +1,389 @@ +# frozen_string_literal: true + +require "spec_helper" +require "stringio" + +RSpec.describe River::ClientRuntime do + 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 + + 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) + runtime(driver: driver, worker: worker).send(:finish_failed, value, + River::Job.new(Object.new, value), RuntimeError.new("failed"), Time.now.utc, 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_set_state_if_running) { |**| nil } + + expect(runtime(driver: driver).finish_claimed(row)).to be_nil + 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 "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 "handles a temporarily failing producer and retries until stopped" do + driver = Object.new + driver.define_singleton_method(:queue_get) { |_queue| raise "temporary producer failure" } + value = runtime(driver: driver) + checks = 0 + value.define_singleton_method(:queue_stopping?) do |_queue| + checks += 1 + checks >= 3 + end + + value.define_singleton_method(:wait) { |_duration| } + + expect { value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1)) }.not_to raise_error + expect(checks).to eq(3) + end + + it "does not retry a failed producer after its queue stops" do + driver = Object.new + driver.define_singleton_method(:queue_get) { |_queue| raise "terminal producer failure" } + value = runtime(driver: driver) + checks = 0 + value.define_singleton_method(:queue_stopping?) do |_queue| + checks += 1 + checks >= 2 + end + + value.define_singleton_method(:wait) { |_duration| } + + expect { value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1)) }.not_to raise_error + expect(checks).to eq(2) + 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 } + driver.define_singleton_method(:job_get_available) { |**| [] } + value = runtime(driver: driver) + checks = 0 + value.define_singleton_method(:queue_stopping?) do |_queue| + checks += 1 + checks >= 2 + end + + value.define_singleton_method(:wait) { |_duration| } + + value.send(:producer_loop, "branch", River::QueueConfig.new(max_workers: 1)) + + expect(checks).to eq(2) + 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 "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 "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 "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 +end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 2c7a3f6..75c84d5 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" require_relative "../driver/riverqueue-sequel/spec/spec_helper" @@ -48,14 +50,31 @@ def to_json = JSON.dump({order_id: order_id, customer_id: customer_id, trace_id: # 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| test_transaction(&ex) } + around(:each) { |ex| available_test_transaction(&ex) } - let!(:driver) { River::Driver::Sequel.new(DB) } + 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, @@ -77,6 +96,7 @@ def to_json = JSON.dump({order_id: order_id, customer_id: customer_id, trace_id: 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 @@ -96,6 +116,7 @@ def to_json = JSON.dump({order_id: order_id, customer_id: customer_id, trace_id: ) insert_res = client.insert(job_args) + expect(insert_res.job).to have_attributes( max_attempts: 23, priority: 2, @@ -121,6 +142,7 @@ def to_json = JSON.dump({order_id: order_id, customer_id: customer_id, trace_id: queue: "my_queue", tags: ["custom"] )) + expect(insert_res.job).to have_attributes( max_attempts: 17, priority: 3, @@ -180,6 +202,7 @@ def to_json = nil 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 @@ -199,17 +222,26 @@ def check_bigint_bounds(int) ) insert_res = client.insert(job_args) - expect(insert_res.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be false + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: be(false) + ) unique_key_str = "&kind=#{insert_res.job.kind}" \ "&queue=#{River::QUEUE_DEFAULT}" - expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(unique_key_str)) - expect(insert_res.job.unique_states).to eq([River::JOB_STATE_AVAILABLE, River::JOB_STATE_COMPLETED, River::JOB_STATE_PENDING, River::JOB_STATE_RETRYABLE, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED]) + + 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.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be true + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: be(true) + ) end it "inserts a new unique job with custom states" do @@ -222,22 +254,30 @@ def check_bigint_bounds(int) ) insert_res = client.insert(job_args) - expect(insert_res.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be false + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: be(false) + ) lock_str = "&kind=#{job_args.kind}" \ "&queue=#{River::QUEUE_DEFAULT}" - expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(lock_str)) - expect(insert_res.job.unique_states).to eq([River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED]) + 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.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be true + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: be(true) + ) end it "inserts a new unique job with all options" do - job_args = ComplexArgs.new(customer_id: 1, order_id: 2, trace_id: 3, email: "john@example.com") + 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, @@ -249,50 +289,67 @@ def check_bigint_bounds(int) ) insert_res = client.insert(job_args, insert_opts: insert_opts) - expect(insert_res.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be false + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: 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.unique_key).to eq(Digest::SHA256.digest(unique_key_str)) - expect(insert_res.job.unique_states).to eq([River::JOB_STATE_AVAILABLE, River::JOB_STATE_CANCELLED, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED]) + + 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.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be true + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: be(true) + ) end it "inserts a new unique job with custom by_args" do - job_args = ComplexArgs.new(customer_id: 1, order_id: 2, trace_id: 3, email: "john@example.com") + 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.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be false + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: 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.job.id).to eq(original_job_id) - expect(insert_res.unique_skipped_as_duplicated).to be true + expect(insert_res).to have_attributes( + job: have_attributes(id: original_job_id), + unique_skipped_as_duplicated: 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_duplicated).to be false end it "inserts a new unique job with period determined from `scheduled_at`" do - job_args = ComplexArgs.new(customer_id: 1, order_id: 2, trace_id: 3, email: "john@example.com") + 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( @@ -301,11 +358,15 @@ def check_bigint_bounds(int) ) insert_res = client.insert(job_args, insert_opts: insert_opts) - expect(insert_res.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be false + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: 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 @@ -316,8 +377,11 @@ def check_bigint_bounds(int) ) insert_res = client.insert(job_args) - expect(insert_res.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be false + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: be(false) + ) end it "errors if any of the required unique states are removed from a custom by_states list" do @@ -345,11 +409,13 @@ def check_bigint_bounds(int) 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( @@ -384,11 +450,13 @@ def check_bigint_bounds(int) 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( @@ -430,6 +498,7 @@ def check_bigint_bounds(int) ) 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 @@ -478,16 +547,20 @@ def check_bigint_bounds(int) ) )) ]) + 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].unique_skipped_as_duplicated).to be true - expect(results[2].job).to have_attributes( - id: insert_res.job.id, - tags: [] + expect(results[2]).to have_attributes( + job: have_attributes( + id: insert_res.job.id, + tags: [] + ), + unique_skipped_as_duplicated: (be true) ) jobs = driver.job_list + expect(jobs.count).to be 3 expect(jobs[0]).to have_attributes(queue: "job_to_duplicate") @@ -538,8 +611,11 @@ def check_bigint_bounds(int) job_args = SimpleArgs.new(job_num: 1) params = River::InsertManyParams.new(job_args) - expect(params.args).to eq(job_args) - expect(params.insert_opts).to be_nil + + expect(params).to have_attributes( + args: job_args, + insert_opts: be_nil + ) end it "initializes with insert opts" do @@ -547,7 +623,10 @@ def check_bigint_bounds(int) insert_opts = River::InsertOpts.new(queue: "other") params = River::InsertManyParams.new(job_args, insert_opts: insert_opts) - expect(params.args).to eq(job_args) - expect(params.insert_opts).to eq(insert_opts) + + expect(params).to have_attributes( + args: job_args, + insert_opts: insert_opts + ) end end diff --git a/spec/config_spec.rb b/spec/config_spec.rb new file mode 100644 index 0000000..2c96249 --- /dev/null +++ b/spec/config_spec.rb @@ -0,0 +1,193 @@ +# 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_poll_interval: River::FETCH_POLL_INTERVAL_DEFAULT, + job_timeout: River::JOB_TIMEOUT_DEFAULT, + 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], + 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.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 + + ["", "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/spec/driver_runtime_feature_spec.rb b/spec/driver_runtime_feature_spec.rb new file mode 100644 index 0000000..a8907c7 --- /dev/null +++ b/spec/driver_runtime_feature_spec.rb @@ -0,0 +1,119 @@ +# 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 "River shared driver runtime edge cases" 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 "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/spec/driver_runtime_shared_examples.rb b/spec/driver_runtime_shared_examples.rb new file mode 100644 index 0000000..5b4dac2 --- /dev/null +++ b/spec/driver_runtime_shared_examples.rb @@ -0,0 +1,610 @@ +# 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 "driver job state machine" do + let(:client) { River::Client.new(driver) } + let(:now) { Time.utc(2026, 1, 2, 3, 4, 5) } + + 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 "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 "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 "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 + + 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 + + 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, 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) + insert_job(1, unique_opts: unique) + retryable = insert_job(2, scheduled_at: now - 1, state: River::JOB_STATE_RETRYABLE, 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 "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 + + 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) } + + 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/spec/driver_shared_examples.rb b/spec/driver_shared_examples.rb index df0b54f..d33ed72 100644 --- a/spec/driver_shared_examples.rb +++ b/spec/driver_shared_examples.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require_relative "driver_runtime_shared_examples" + class SimpleArgs attr_accessor :job_num @@ -18,6 +22,54 @@ class SimpleArgsWithInsertOpts < SimpleArgs end shared_examples "driver shared examples" do + it_behaves_like "driver job state machine" + it_behaves_like "driver queue and leadership state" + + 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 "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 SQLITE_UNIQUE_NONCE_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 "inserts a unique job once" do args = SimpleArgsWithInsertOpts.new(job_num: 1) @@ -28,13 +80,19 @@ class SimpleArgsWithInsertOpts < SimpleArgs ) insert_res = client.insert(args) - expect(insert_res.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be false + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: be(false) + ) original_job = insert_res.job insert_res = client.insert(args) - expect(insert_res.job.id).to eq(original_job.id) - expect(insert_res.unique_skipped_as_duplicated).to be true + + expect(insert_res).to have_attributes( + job: have_attributes(id: original_job.id), + unique_skipped_as_duplicated: be(true) + ) end it "inserts a unique job with custom states" do @@ -49,13 +107,19 @@ class SimpleArgsWithInsertOpts < SimpleArgs ) insert_res = client.insert(args) - expect(insert_res.job).to_not be_nil - expect(insert_res.unique_skipped_as_duplicated).to be false + + expect(insert_res).to have_attributes( + job: be_a(River::JobRow), + unique_skipped_as_duplicated: be(false) + ) original_job = insert_res.job insert_res = client.insert(args) - expect(insert_res.job.id).to eq(original_job.id) - expect(insert_res.unique_skipped_as_duplicated).to be true + + expect(insert_res).to have_attributes( + job: have_attributes(id: original_job.id), + unique_skipped_as_duplicated: be(true) + ) end end @@ -75,23 +139,27 @@ class SimpleArgsWithInsertOpts < SimpleArgs describe "#job_insert" do it "inserts a job" do insert_res = client.insert(SimpleArgs.new(job_num: 1)) - expect(insert_res.job).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, - queue: River::QUEUE_DEFAULT, - priority: River::PRIORITY_DEFAULT, - scheduled_at: be_within(2).of(Time.now.getutc), - state: River::JOB_STATE_AVAILABLE, - tags: [] + + 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_duplicated: (be false) ) - expect(insert_res.unique_skipped_as_duplicated).to 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" ) @@ -104,11 +172,14 @@ class SimpleArgsWithInsertOpts < SimpleArgs 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(insert_res).to have_attributes( + job: have_attributes( + scheduled_at: be_within(2).of(target_time), + state: River::JOB_STATE_SCHEDULED + ), + unique_skipped_as_duplicated: (be false) ) - expect(insert_res.unique_skipped_as_duplicated).to be false end it "inserts with job insert opts" do @@ -121,13 +192,16 @@ class SimpleArgsWithInsertOpts < SimpleArgs ) insert_res = client.insert(args) - expect(insert_res.job).to have_attributes( - max_attempts: 23, - priority: 2, - queue: "job_custom_queue", - tags: ["job_custom"] + + expect(insert_res).to have_attributes( + job: have_attributes( + max_attempts: 23, + priority: 2, + queue: "job_custom_queue", + tags: ["job_custom"] + ), + unique_skipped_as_duplicated: (be false) ) - expect(insert_res.unique_skipped_as_duplicated).to be false end it "inserts with insert opts" do @@ -147,24 +221,29 @@ class SimpleArgsWithInsertOpts < SimpleArgs queue: "my_queue", tags: ["custom"] )) - expect(insert_res.job).to have_attributes( - 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_duplicated: (be false) ) - expect(insert_res.unique_skipped_as_duplicated).to 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.job).to have_attributes( - args: {"job_num" => 1}, - kind: "hash_kind" + expect(insert_res).to have_attributes( + job: have_attributes( + args: {"job_num" => 1}, + kind: "hash_kind" + ), + unique_skipped_as_duplicated: (be false) ) - expect(insert_res.unique_skipped_as_duplicated).to be false end it "inserts in a transaction" do @@ -174,6 +253,7 @@ class SimpleArgsWithInsertOpts < SimpleArgs 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_duplicated).to be false @@ -182,6 +262,7 @@ class SimpleArgsWithInsertOpts < SimpleArgs # Not present because the job was rolled back. job = driver.job_get_by_id(insert_res.job.id) + expect(job).to be_nil end @@ -190,24 +271,25 @@ class SimpleArgsWithInsertOpts < SimpleArgs encoded_args: JSON.dump({"job_num" => 1}), kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, - queue: River::QUEUE_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", - tags: nil + unique_states: "00000001" ) job_row, unique_skipped_as_duplicated = driver.job_insert(insert_params) + expect(job_row).to have_attributes( - attempt: 0, args: {"job_num" => 1}, + attempt: 0, created_at: be_within(2).of(Time.now.getutc), kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, - queue: River::QUEUE_DEFAULT, priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, scheduled_at: be_within(2).of(Time.now.getutc), state: River::JOB_STATE_AVAILABLE, tags: [], @@ -218,14 +300,15 @@ class SimpleArgsWithInsertOpts < SimpleArgs # second insertion should be skipped job_row, unique_skipped_as_duplicated = driver.job_insert(insert_params) + expect(job_row).to have_attributes( - attempt: 0, args: {"job_num" => 1}, + attempt: 0, created_at: be_within(2).of(Time.now.getutc), kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, - queue: River::QUEUE_DEFAULT, priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, scheduled_at: be_within(2).of(Time.now.getutc), state: River::JOB_STATE_AVAILABLE, tags: [], @@ -242,36 +325,42 @@ class SimpleArgsWithInsertOpts < SimpleArgs SimpleArgs.new(job_num: 1), SimpleArgs.new(job_num: 2) ]) + expect(inserted.length).to eq(2) - expect(inserted[0].job).to have_attributes(args: {"job_num" => 1}) - expect(inserted[0].unique_skipped_as_duplicated).to eq false - expect(inserted[1].job).to have_attributes(args: {"job_num" => 2}) - expect(inserted[1].unique_skipped_as_duplicated).to eq false + expect(inserted[0]).to have_attributes( + job: have_attributes(args: {"job_num" => 1}), + unique_skipped_as_duplicated: false + ) + expect(inserted[1]).to have_attributes( + job: have_attributes(args: {"job_num" => 2}), + unique_skipped_as_duplicated: false + ) jobs = driver.job_list + expect(jobs.count).to be 2 expect(jobs[0]).to have_attributes( - attempt: 0, args: {"job_num" => 1}, + attempt: 0, created_at: be_within(2).of(Time.now.getutc), kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, - queue: River::QUEUE_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( - attempt: 0, args: {"job_num" => 2}, + attempt: 0, created_at: be_within(2).of(Time.now.getutc), kind: "simple", max_attempts: River::MAX_ATTEMPTS_DEFAULT, - queue: River::QUEUE_DEFAULT, priority: River::PRIORITY_DEFAULT, + queue: River::QUEUE_DEFAULT, scheduled_at: be_within(2).of(Time.now.getutc), state: River::JOB_STATE_AVAILABLE, tags: [] @@ -286,13 +375,19 @@ class SimpleArgsWithInsertOpts < SimpleArgs SimpleArgs.new(job_num: 1), SimpleArgs.new(job_num: 2) ]) + expect(inserted.length).to eq(2) - expect(inserted[0].unique_skipped_as_duplicated).to eq false - expect(inserted[0].job).to have_attributes(args: {"job_num" => 1}) - expect(inserted[1].unique_skipped_as_duplicated).to eq false - expect(inserted[1].job).to have_attributes(args: {"job_num" => 2}) + expect(inserted[0]).to have_attributes( + job: have_attributes(args: {"job_num" => 1}), + unique_skipped_as_duplicated: false + ) + expect(inserted[1]).to have_attributes( + job: have_attributes(args: {"job_num" => 2}), + unique_skipped_as_duplicated: false + ) jobs = driver.job_list + expect(jobs.count).to be 2 raise driver.rollback_exception @@ -312,6 +407,7 @@ class SimpleArgsWithInsertOpts < SimpleArgs 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 @@ -331,6 +427,7 @@ class SimpleArgsWithInsertOpts < SimpleArgs 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 @@ -338,6 +435,7 @@ class SimpleArgsWithInsertOpts < SimpleArgs # 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 diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb new file mode 100644 index 0000000..8bb5a3b --- /dev/null +++ b/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/spec/event_spec.rb b/spec/event_spec.rb new file mode 100644 index 0000000..c0ad912 --- /dev/null +++ b/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/spec/insertion_feature_spec.rb b/spec/insertion_feature_spec.rb new file mode 100644 index 0000000..2444b2e --- /dev/null +++ b/spec/insertion_feature_spec.rb @@ -0,0 +1,228 @@ +# 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 "handles an empty bulk insertion" do + expect(build_client.insert_many([])).to eq([]) + expect(driver.job_list).to be_empty + 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_duplicated).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_duplicated).to be false + expect(replacement.job.id).not_to eq(original.id) + end + + it "can enforce uniqueness across different kinds when kind is excluded" do + client = build_client + opts = River::InsertOpts.new(unique_opts: River::UniqueOpts.new(by_queue: true, 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_duplicated).to be false + expect(duplicate.unique_skipped_as_duplicated).to be true + expect(duplicate.job.id).to eq(original.job.id) + 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_duplicated).to be false + expect(second.unique_skipped_as_duplicated).to be false + expect(second.job.id).not_to eq(first.job.id) + end +end diff --git a/spec/job_spec.rb b/spec/job_spec.rb index d34b40a..ac32cef 100644 --- a/spec/job_spec.rb +++ b/spec/job_spec.rb @@ -1,10 +1,44 @@ +# 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.kind).to eq("my_hash_kind") - expect(args.to_json).to eq(JSON.dump({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 @@ -30,6 +64,7 @@ error: "job failure", trace: "error trace" ) + expect(attempt_error).to have_attributes( at: now, attempt: 1, diff --git a/spec/migration_cli_shared_examples.rb b/spec/migration_cli_shared_examples.rb new file mode 100644 index 0000000..6d9741d --- /dev/null +++ b/spec/migration_cli_shared_examples.rb @@ -0,0 +1,50 @@ +# 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" 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 007") + expect(run.call("migrate-up", "--steps", "2", "--target", "4")).to eq(0) + expect(run.call("migrate-up")).to eq(0) + expect(run.call("migrate-status")).to eq(0) + expect(out.string).to include("applied 007") + 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/spec/migration_cli_spec.rb b/spec/migration_cli_spec.rb new file mode 100644 index 0000000..c4356bc --- /dev/null +++ b/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 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/spec/migrator_shared_examples.rb b/spec/migrator_shared_examples.rb new file mode 100644 index 0000000..aa54b11 --- /dev/null +++ b/spec/migrator_shared_examples.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +RSpec.shared_examples "canonical migrations" do + 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(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, 8, "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 raise_error(ArgumentError, /opposite direction/) + 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]) + 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([7]) + expect(migrator.migrate(direction: :down, target: 4).map(&:version)).to eq([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(7) + end + + it "plans without writes and honors step limits" do + expect(migrator.migrate(dry_run: true).length).to eq(7) + 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) + 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(6) + 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', 8)" + (@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, 8)" + (@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..7).to_a) + end +end diff --git a/spec/migrator_spec.rb b/spec/migrator_spec.rb new file mode 100644 index 0000000..c4e4ac2 --- /dev/null +++ b/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}" 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" + + 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(7) + 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(7) + 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/spec/params_spec.rb b/spec/params_spec.rb new file mode 100644 index 0000000..47ed3d5 --- /dev/null +++ b/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/spec/periodic_cron_spec.rb b/spec/periodic_cron_spec.rb new file mode 100644 index 0000000..bb2629e --- /dev/null +++ b/spec/periodic_cron_spec.rb @@ -0,0 +1,83 @@ +# 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 "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/spec/periodic_job_spec.rb b/spec/periodic_job_spec.rb new file mode 100644 index 0000000..c2aeb81 --- /dev/null +++ b/spec/periodic_job_spec.rb @@ -0,0 +1,148 @@ +# 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 +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 for every 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(3) + 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) + handle = jobs.add(periodic(run_on_start: true)) + + expect(jobs.remove(handle)).to be_a(Hash) + 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) + jobs.clear + + expect(jobs.due(Time.now.utc + 1)).to be_empty + end +end diff --git a/spec/ractor_spec.rb b/spec/ractor_spec.rb new file mode 100644 index 0000000..1094546 --- /dev/null +++ b/spec/ractor_spec.rb @@ -0,0 +1,162 @@ +# 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" + 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 + 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 "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 "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/spec/resumable_spec.rb b/spec/resumable_spec.rb new file mode 100644 index 0000000..a8752c1 --- /dev/null +++ b/spec/resumable_spec.rb @@ -0,0 +1,324 @@ +# 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 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/spec/runtime_feature_spec.rb b/spec/runtime_feature_spec.rb new file mode 100644 index 0000000..4914ea7 --- /dev/null +++ b/spec/runtime_feature_spec.rb @@ -0,0 +1,635 @@ +# frozen_string_literal: true + +require "spec_helper" +require "riverqueue-sequel" +require "stringio" +require_relative "support/river_sqlite_schema_fixture" + +RUNTIME_FEATURE_DB = Sequel.sqlite.tap do |database| + database.synchronize { |connection| RiverSQLiteSchemaFixture.load(connection) } +end + +class RuntimeFeatureArgs < River::JobArgsHash + def initialize(value = 1, kind: "runtime_feature") + super(kind, {"value" => value}) + end +end + +RSpec.describe "River worker execution features" 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" 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], queues: {"runtime" => 1}) + + 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 + + it "inserts a periodic job whose constructor returns bare arguments" do + periodic = River::PeriodicJob.new( + id: "bare", + constructor: -> { RuntimeFeatureArgs.new }, + run_on_start: true, + schedule: River::PeriodicInterval.new(60) + ) + 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/spec/runtime_spec.rb b/spec/runtime_spec.rb new file mode 100644 index 0000000..a375c20 --- /dev/null +++ b/spec/runtime_spec.rb @@ -0,0 +1,541 @@ +# frozen_string_literal: true + +require "spec_helper" +require "riverqueue-sequel" +require_relative "support/river_sqlite_schema_fixture" + +RUNTIME_DB = if 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" 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) + ).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/spec/spec_helper.rb b/spec/spec_helper.rb index feb82b6..2abc53d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,15 +1,21 @@ +# frozen_string_literal: true + 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 - minimum_coverage line: 100, branch: 100 + minimum_coverage branch: 100, line: 100 # 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 diff --git a/spec/support/client_test_database.rb b/spec/support/client_test_database.rb new file mode 100644 index 0000000..8f91b8f --- /dev/null +++ b/spec/support/client_test_database.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "securerandom" +require "tmpdir" + +# 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) + 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 + ActiveRecord::Base.establish_connection(config.merge(pool: 20, schema_search_path: "#{schema},public")) + # 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 + River::Migrator.new(driver).migrate 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 + River::Migrator.new(driver).migrate 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) + if adapter == :postgres + admin = Sequel.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test") + schema = "river_client_test_#{SecureRandom.hex(8)}" + admin.run("CREATE SCHEMA #{schema}") + database = Sequel.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test", max_connections: 20, search_path: "#{schema},public") + driver = River::Driver::Sequel.new(database) + River::Migrator.new(driver).migrate 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) + River::Migrator.new(driver).migrate if migrate + + yield driver + ensure + database.disconnect + end + end + end + ensure + database&.disconnect + admin&.run("DROP SCHEMA #{schema} CASCADE") if schema + + admin&.disconnect + end +end diff --git a/spec/support/connection_class_test_database.rb b/spec/support/connection_class_test_database.rb new file mode 100644 index 0000000..5e6a5c1 --- /dev/null +++ b/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/spec/support/migrations/postgresql/test/001_test.down.sql b/spec/support/migrations/postgresql/test/001_test.down.sql new file mode 100644 index 0000000..8f748bb --- /dev/null +++ b/spec/support/migrations/postgresql/test/001_test.down.sql @@ -0,0 +1 @@ +DROP TABLE /* TEMPLATE: schema */river_migration_test; diff --git a/spec/support/migrations/postgresql/test/001_test.up.sql b/spec/support/migrations/postgresql/test/001_test.up.sql new file mode 100644 index 0000000..d60f87b --- /dev/null +++ b/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/spec/support/migrations/sqlite/test/001_test.down.sql b/spec/support/migrations/sqlite/test/001_test.down.sql new file mode 100644 index 0000000..bfc26b8 --- /dev/null +++ b/spec/support/migrations/sqlite/test/001_test.down.sql @@ -0,0 +1 @@ +DROP TABLE river_migration_test; diff --git a/spec/support/migrations/sqlite/test/001_test.up.sql b/spec/support/migrations/sqlite/test/001_test.up.sql new file mode 100644 index 0000000..d985fb0 --- /dev/null +++ b/spec/support/migrations/sqlite/test/001_test.up.sql @@ -0,0 +1 @@ +CREATE TABLE river_migration_test (id integer PRIMARY KEY); diff --git a/spec/support/ractor_test_driver.rb b/spec/support/ractor_test_driver.rb new file mode 100644 index 0000000..7682c93 --- /dev/null +++ b/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/spec/support/river_sqlite_schema_fixture.rb b/spec/support/river_sqlite_schema_fixture.rb index 74321f1..2aea3ea 100644 --- a/spec/support/river_sqlite_schema_fixture.rb +++ b/spec/support/river_sqlite_schema_fixture.rb @@ -1,8 +1,21 @@ -# A snapshot of the river_job and river_notification schema after River's -# SQLite main migration 007. These are the only tables exercised by this -# insert-only client. Production databases must use River's migrations. +# frozen_string_literal: true + +# A snapshot of River's complete SQLite schema after main migration 007. +# 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', 7); + CREATE TABLE river_job ( id integer PRIMARY KEY, args blob NOT NULL DEFAULT (jsonb('{}')), @@ -52,6 +65,23 @@ module RiverSQLiteSchemaFixture 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')), diff --git a/spec/support/runner_test_client.rb b/spec/support/runner_test_client.rb new file mode 100644 index 0000000..830a4c1 --- /dev/null +++ b/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/spec/testing_spec.rb b/spec/testing_spec.rb new file mode 100644 index 0000000..88d2d59 --- /dev/null +++ b/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 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/spec/unique_bitmask_spec.rb b/spec/unique_bitmask_spec.rb index 4357b5d..faaa56d 100644 --- a/spec/unique_bitmask_spec.rb +++ b/spec/unique_bitmask_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" require_relative "../driver/riverqueue-sequel/spec/spec_helper" diff --git a/spec/worker_process_shared_examples.rb b/spec/worker_process_shared_examples.rb new file mode 100644 index 0000000..eb254e4 --- /dev/null +++ b/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/spec/worker_runner_spec.rb b/spec/worker_runner_spec.rb new file mode 100644 index 0000000..f8a2d7f --- /dev/null +++ b/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/spec/worker_spec.rb b/spec/worker_spec.rb new file mode 100644 index 0000000..9f31234 --- /dev/null +++ b/spec/worker_spec.rb @@ -0,0 +1,157 @@ +# 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.fetch("missing")).to be_nil + 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 "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 +end