├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Makefile ├── Steepfile ├── docs ├── README.md └── development.md ├── driver ├── riverqueue-activerecord │ ├── Gemfile │ ├── Gemfile.lock │ ├── README.md │ ├── docs │ │ ├── README.md │ │ └── development.md │ ├── lib │ │ ├── driver.rb │ │ └── riverqueue-activerecord.rb │ ├── riverqueue-activerecord.gemspec │ └── spec │ │ ├── driver_spec.rb │ │ └── spec_helper.rb └── riverqueue-sequel │ ├── Gemfile │ ├── Gemfile.lock │ ├── docs │ ├── README.md │ └── development.md │ ├── lib │ ├── driver.rb │ └── riverqueue-sequel.rb │ ├── riverqueue-sequel.gemspec │ └── spec │ ├── driver_spec.rb │ └── spec_helper.rb ├── lib ├── client.rb ├── driver.rb ├── insert_opts.rb ├── job.rb ├── riverqueue.rb └── unique_bitmask.rb ├── riverqueue.gemspec ├── scripts └── update_gemspec_version.rb ├── sig ├── client.rbs ├── driver.rbs ├── insert_opts.rbs ├── job.rbs ├── riverqueue.rbs └── unique_bitmask.rbs └── spec ├── client_spec.rb ├── driver_shared_examples.rb ├── job_spec.rb ├── spec_helper.rb └── unique_bitmask_spec.rb /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | # Database to connect to that can create other databases with `CREATE DATABASE`. 5 | ADMIN_DATABASE_URL: postgres://postgres:postgres@localhost:5432 6 | 7 | # Just a common place for steps to put binaries they need and which is added 8 | # to GITHUB_PATH/PATH. 9 | BIN_PATH: /home/runner/bin 10 | 11 | # The version of Ruby that non-spec tasks like the build check or lint run 12 | # against. The setup-ruby step must have a version specified, which is why 13 | # this is necessary. 14 | # 15 | # If updating this value, you probably also want to add a new version to the 16 | # spec version matrix below. 17 | RUBY_VERSION: "3.4" 18 | 19 | # A suitable URL for a test database. 20 | TEST_DATABASE_NAME: river_test 21 | TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/river_test?sslmode=disable 22 | 23 | on: 24 | - push 25 | 26 | jobs: 27 | gem_build: 28 | runs-on: ubuntu-latest 29 | timeout-minutes: 3 30 | 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Install Ruby + `bundle install` 36 | uses: ruby/setup-ruby@v1 37 | with: 38 | ruby-version: ${{ env.RUBY_VERSION }} 39 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 40 | 41 | - name: Build gem (riverqueue-ruby) 42 | run: gem build riverqueue.gemspec 43 | working-directory: . 44 | 45 | - name: Build gem (riverqueue-activerecord) 46 | run: gem build riverqueue-activerecord.gemspec 47 | working-directory: ./driver/riverqueue-activerecord 48 | 49 | - name: Build gem (riverqueue-sequel) 50 | run: gem build riverqueue-sequel.gemspec 51 | working-directory: ./driver/riverqueue-sequel 52 | 53 | lint: 54 | runs-on: ubuntu-latest 55 | timeout-minutes: 3 56 | 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v4 60 | 61 | - name: Install Ruby + `bundle install` 62 | uses: ruby/setup-ruby@v1 63 | with: 64 | ruby-version: ${{ env.RUBY_VERSION }} 65 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 66 | 67 | - name: Standard Ruby (riverqueue-ruby) 68 | run: bundle exec standardrb 69 | working-directory: . 70 | 71 | - name: bundle install (riverqueue-activerecord) 72 | run: bundle install 73 | working-directory: ./driver/riverqueue-activerecord 74 | 75 | - name: Standard Ruby (riverqueue-activerecord) 76 | run: bundle exec standardrb 77 | working-directory: ./driver/riverqueue-activerecord 78 | 79 | - name: bundle install (riverqueue-sequel) 80 | run: bundle install 81 | working-directory: ./driver/riverqueue-sequel 82 | 83 | - name: Standard Ruby (riverqueue-sequel) 84 | run: bundle exec standardrb 85 | working-directory: ./driver/riverqueue-sequel 86 | 87 | tool_versions_check: 88 | runs-on: ubuntu-latest 89 | 90 | steps: 91 | - name: Checkout 92 | uses: actions/checkout@v4 93 | 94 | - name: Check RUBY_VERSION matches .tool-versions Ruby version 95 | run: | 96 | cat <<- "EOF" | ruby 97 | ruby_env = ENV["RUBY_VERSION"] || abort("need RUBY_VERSION") 98 | ruby_tool_versions = File.read('.tool-versions').split('\n')[0].split[1] 99 | 100 | if ruby_env != ruby_tool_versions 101 | abort("CI version $RUBY_VERSION ${ruby_env } should match .tool-versions Ruby ${ruby_tool_versions }") 102 | end 103 | EOF 104 | 105 | # run: | 106 | # [[ "$RUBY_VERSION" == "$(cat .tool-versions | grep ruby | cut -w -f 2)" ]] || echo "CI version \$RUBY_VERSION should match .tool-versions Ruby `cat .tool-versions | grep ruby | cut -w -f 2`" && (exit 1) 107 | 108 | type_check: 109 | runs-on: ubuntu-latest 110 | timeout-minutes: 3 111 | 112 | steps: 113 | - name: Checkout 114 | uses: actions/checkout@v4 115 | 116 | - name: Install Ruby + `bundle install` 117 | uses: ruby/setup-ruby@v1 118 | with: 119 | ruby-version: ${{ env.RUBY_VERSION }} 120 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 121 | 122 | - name: Steep (riverqueue-ruby) 123 | run: bundle exec steep check 124 | working-directory: . 125 | 126 | spec: 127 | runs-on: ubuntu-latest 128 | timeout-minutes: 3 129 | strategy: 130 | matrix: 131 | # If adding a value, you probably also want to update the default 132 | # RUBY_VERSION for non-spec jobs above. 133 | ruby_version: 134 | - "3.2" 135 | - "3.3" 136 | - "3.4" 137 | 138 | services: 139 | postgres: 140 | image: postgres 141 | env: 142 | POSTGRES_PASSWORD: postgres 143 | options: >- 144 | --health-cmd pg_isready 145 | --health-interval 2s 146 | --health-timeout 5s 147 | --health-retries 5 148 | ports: 149 | - 5432:5432 150 | 151 | steps: 152 | - name: Checkout 153 | uses: actions/checkout@v4 154 | 155 | - name: Install Ruby + `bundle install` 156 | uses: ruby/setup-ruby@v1 157 | with: 158 | ruby-version: ${{ matrix.ruby_version }} 159 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 160 | 161 | # Needed for River's CLI. There is a version of Go on Actions' base image, 162 | # but it's old and can't read modern `go.mod` annotations correctly. 163 | - name: Install Go 164 | uses: actions/setup-go@v5 165 | with: 166 | go-version: "stable" 167 | check-latest: true 168 | 169 | - name: Create database 170 | run: psql --echo-errors --quiet -c '\timing off' -c "CREATE DATABASE ${TEST_DATABASE_NAME};" ${ADMIN_DATABASE_URL} 171 | 172 | - name: Install River CLI 173 | run: go install github.com/riverqueue/river/cmd/river@latest 174 | 175 | - name: river migrate-up 176 | run: river migrate-up --database-url "$TEST_DATABASE_URL" 177 | 178 | - name: Rspec (riverqueue-ruby) 179 | run: bundle exec rspec 180 | working-directory: . 181 | 182 | - name: bundle install (riverqueue-activerecord) 183 | run: bundle install 184 | working-directory: ./driver/riverqueue-activerecord 185 | 186 | - name: Rspec (riverqueue-activerecord) 187 | run: bundle exec rspec 188 | working-directory: ./driver/riverqueue-activerecord 189 | 190 | - name: bundle install (riverqueue-sequel) 191 | run: bundle install 192 | working-directory: ./driver/riverqueue-sequel 193 | 194 | - name: Rspec (riverqueue-sequel) 195 | run: bundle exec rspec 196 | working-directory: ./driver/riverqueue-sequel 197 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.4 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.9.0] - 2025-04-11 11 | 12 | ### Changed 13 | 14 | - `by_period` uniqueness is now based off a job's `scheduled_at` instead of the current time if it has a value. [PR #39](https://github.com/riverqueue/riverqueue-ruby/pull/39). 15 | 16 | ## Fixed 17 | 18 | - Correct some mistakes in the readme that referenced `SimpleArgs` instead of `SortArgs`. [PR #44](https://github.com/riverqueue/riverqueue-ruby/pull/44). 19 | 20 | ## [0.8.0] - 2024-12-19 21 | 22 | ⚠️ Version 0.8.0 contains breaking changes to transition to River's new unique jobs implementation and to enable broader, more flexible application of unique jobs. Detailed notes on the implementation are contained in [the original River PR](https://github.com/riverqueue/river/pull/590), and the notes below include short summaries of the ways this impacts this client specifically. 23 | 24 | Users should upgrade backends to River v0.12.0 before upgrading this library in order to ensure a seamless transition of all in-flight jobs. Afterward, the latest River version may be used. 25 | 26 | ### Breaking 27 | 28 | - **Breaking change:** The return type of `Client#insert_many` has been changed. Rather than returning just the number of rows inserted, it returns an array of all the `InsertResult` values for each inserted row. Unique conflicts which are skipped as duplicates are indicated in the same fashion as single inserts (the `unique_skipped_as_duplicated` attribute), and in such cases the conflicting row will be returned instead. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). 29 | - **Breaking change:** Unique jobs no longer allow total customization of their states when using the `by_state` option. The pending, scheduled, available, and running states are required whenever customizing this list. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). 30 | 31 | ### Added 32 | 33 | - The `UniqueOpts` class gains an `exclude_kind` option for cases where uniqueness needs to be guaranteed across multiple job types. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). 34 | - Unique jobs utilizing `by_args` can now also opt to have a subset of the job's arguments considered for uniqueness. For example, you could choose to consider only the `customer_id` field while ignoring the other fields: 35 | 36 | ```ruby 37 | UniqueOpts.new(by_args: ["customer_id"]) 38 | ``` 39 | 40 | Any fields considered in uniqueness are also sorted alphabetically in order to guarantee a consistent result across implementations, even if the encoded JSON isn't sorted consistently. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). 41 | 42 | ### Changed 43 | 44 | - Unique jobs have been improved to allow bulk insertion of unique jobs via `Client#insert_many`. 45 | 46 | This updated implementation is significantly faster due to the removal of advisory locks in favor of an index-backed uniqueness system, while allowing some flexibility in which job states are considered. However, not all states may be removed from consideration when using the `by_state` option; pending, scheduled, available, and running states are required whenever customizing this list. [PR #32](https://github.com/riverqueue/riverqueue-ruby/pull/32). 47 | 48 | - Update REXML dependency. [PR #28](https://github.com/riverqueue/riverqueue-ruby/pull/36). 49 | 50 | ## [0.7.0] - 2024-08-30 51 | 52 | ### Changed 53 | 54 | - Now compatible with "fast path" unique job insertion that uses a unique index instead of advisory lock and fetch [as introduced in River #451](https://github.com/riverqueue/river/pull/451). [PR #28](https://github.com/riverqueue/riverqueue-ruby/pull/28). 55 | 56 | ## [0.6.1] - 2024-08-21 57 | 58 | ### Fixed 59 | 60 | - Fix source files not being correctly included in built Ruby gems. [PR #26](https://github.com/riverqueue/riverqueue-ruby/pull/26). 61 | 62 | ## [0.6.0] - 2024-07-06 63 | 64 | ### Changed 65 | 66 | - Advisory lock prefixes are now checked to make sure they fit inside of four bytes. [PR #24](https://github.com/riverqueue/riverqueue-ruby/pull/24). 67 | 68 | ## [0.5.0] - 2024-07-05 69 | 70 | ### Changed 71 | 72 | - Tag format is now checked on insert. Tags should be no more than 255 characters and match the regex `/\A[\w][\w\-]+[\w]\z/`. [PR #22](https://github.com/riverqueue/riverqueue-ruby/pull/22). 73 | - Returned jobs now have a `metadata` property. [PR #21](https://github.com/riverqueue/riverqueue-ruby/pull/22). 74 | 75 | ## [0.4.0] - 2024-04-28 76 | 77 | ### Changed 78 | 79 | - Implement the FNV (Fowler–Noll–Vo) hashing algorithm in the project and drop dependency on the `fnv-hash` gem. [PR #14](https://github.com/riverqueue/riverqueue-ruby/pull/14). 80 | 81 | ## [0.3.0] - 2024-04-27 82 | 83 | ### Added 84 | 85 | - Implement unique job insertion. [PR #10](https://github.com/riverqueue/riverqueue-ruby/pull/10). 86 | 87 | ## [0.2.0] - 2024-04-27 88 | 89 | ### Added 90 | 91 | - Implement `#insert_many` for batch job insertion. [PR #5](https://github.com/riverqueue/riverqueue-ruby/pull/5). 92 | 93 | ## [0.1.0] - 2024-04-25 94 | 95 | ### Added 96 | 97 | - Initial implementation that supports inserting jobs using either ActiveRecord or Sequel. [PR #1](https://github.com/riverqueue/riverqueue-ruby/pull/1). 98 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem "standard" 7 | gem "steep" 8 | end 9 | 10 | group :test do 11 | gem "debug" 12 | gem "rspec-core" 13 | gem "rspec-expectations" 14 | gem "riverqueue-sequel", path: "driver/riverqueue-sequel" 15 | gem "simplecov", require: false 16 | end 17 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | riverqueue (0.9.0) 5 | 6 | PATH 7 | remote: driver/riverqueue-sequel 8 | specs: 9 | riverqueue-sequel (0.9.0) 10 | pg (> 0, < 1000) 11 | sequel (> 0, < 1000) 12 | 13 | GEM 14 | remote: https://rubygems.org/ 15 | specs: 16 | activesupport (8.0.2) 17 | base64 18 | benchmark (>= 0.3) 19 | bigdecimal 20 | concurrent-ruby (~> 1.0, >= 1.3.1) 21 | connection_pool (>= 2.2.5) 22 | drb 23 | i18n (>= 1.6, < 2) 24 | logger (>= 1.4.2) 25 | minitest (>= 5.1) 26 | securerandom (>= 0.3) 27 | tzinfo (~> 2.0, >= 2.0.5) 28 | uri (>= 0.13.1) 29 | ast (2.4.3) 30 | base64 (0.2.0) 31 | benchmark (0.4.0) 32 | bigdecimal (3.1.9) 33 | concurrent-ruby (1.3.5) 34 | connection_pool (2.5.0) 35 | csv (3.3.3) 36 | date (3.4.1) 37 | debug (1.10.0) 38 | irb (~> 1.10) 39 | reline (>= 0.3.8) 40 | diff-lcs (1.6.1) 41 | docile (1.4.1) 42 | drb (2.2.1) 43 | ffi (1.17.1-arm64-darwin) 44 | ffi (1.17.1-x86_64-linux-gnu) 45 | fileutils (1.7.3) 46 | i18n (1.14.7) 47 | concurrent-ruby (~> 1.0) 48 | io-console (0.8.0) 49 | irb (1.15.2) 50 | pp (>= 0.6.0) 51 | rdoc (>= 4.0.0) 52 | reline (>= 0.4.2) 53 | json (2.10.2) 54 | language_server-protocol (3.17.0.4) 55 | lint_roller (1.1.0) 56 | listen (3.9.0) 57 | rb-fsevent (~> 0.10, >= 0.10.3) 58 | rb-inotify (~> 0.9, >= 0.9.10) 59 | logger (1.7.0) 60 | minitest (5.25.5) 61 | mutex_m (0.3.0) 62 | parallel (1.26.3) 63 | parser (3.3.7.4) 64 | ast (~> 2.4.1) 65 | racc 66 | pg (1.5.9) 67 | pp (0.6.2) 68 | prettyprint 69 | prettyprint (0.2.0) 70 | prism (1.4.0) 71 | psych (5.2.3) 72 | date 73 | stringio 74 | racc (1.8.1) 75 | rainbow (3.1.1) 76 | rb-fsevent (0.11.2) 77 | rb-inotify (0.11.1) 78 | ffi (~> 1.0) 79 | rbs (3.9.2) 80 | logger 81 | rdoc (6.13.1) 82 | psych (>= 4.0.0) 83 | regexp_parser (2.10.0) 84 | reline (0.6.1) 85 | io-console (~> 0.5) 86 | rspec-core (3.13.3) 87 | rspec-support (~> 3.13.0) 88 | rspec-expectations (3.13.3) 89 | diff-lcs (>= 1.2.0, < 2.0) 90 | rspec-support (~> 3.13.0) 91 | rspec-support (3.13.2) 92 | rubocop (1.75.2) 93 | json (~> 2.3) 94 | language_server-protocol (~> 3.17.0.2) 95 | lint_roller (~> 1.1.0) 96 | parallel (~> 1.10) 97 | parser (>= 3.3.0.2) 98 | rainbow (>= 2.2.2, < 4.0) 99 | regexp_parser (>= 2.9.3, < 3.0) 100 | rubocop-ast (>= 1.44.0, < 2.0) 101 | ruby-progressbar (~> 1.7) 102 | unicode-display_width (>= 2.4.0, < 4.0) 103 | rubocop-ast (1.44.0) 104 | parser (>= 3.3.7.2) 105 | prism (~> 1.4) 106 | rubocop-performance (1.25.0) 107 | lint_roller (~> 1.1) 108 | rubocop (>= 1.75.0, < 2.0) 109 | rubocop-ast (>= 1.38.0, < 2.0) 110 | ruby-progressbar (1.13.0) 111 | securerandom (0.4.1) 112 | sequel (5.91.0) 113 | bigdecimal 114 | simplecov (0.22.0) 115 | docile (~> 1.1) 116 | simplecov-html (~> 0.11) 117 | simplecov_json_formatter (~> 0.1) 118 | simplecov-html (0.13.1) 119 | simplecov_json_formatter (0.1.4) 120 | standard (1.49.0) 121 | language_server-protocol (~> 3.17.0.2) 122 | lint_roller (~> 1.0) 123 | rubocop (~> 1.75.2) 124 | standard-custom (~> 1.0.0) 125 | standard-performance (~> 1.8) 126 | standard-custom (1.0.2) 127 | lint_roller (~> 1.0) 128 | rubocop (~> 1.50) 129 | standard-performance (1.8.0) 130 | lint_roller (~> 1.1) 131 | rubocop-performance (~> 1.25.0) 132 | steep (1.10.0) 133 | activesupport (>= 5.1) 134 | concurrent-ruby (>= 1.1.10) 135 | csv (>= 3.0.9) 136 | fileutils (>= 1.1.0) 137 | json (>= 2.1.0) 138 | language_server-protocol (>= 3.17.0.4, < 4.0) 139 | listen (~> 3.0) 140 | logger (>= 1.3.0) 141 | mutex_m (>= 0.3.0) 142 | parser (>= 3.1) 143 | rainbow (>= 2.2.2, < 4.0) 144 | rbs (~> 3.9) 145 | securerandom (>= 0.1) 146 | strscan (>= 1.0.0) 147 | terminal-table (>= 2, < 5) 148 | uri (>= 0.12.0) 149 | stringio (3.1.6) 150 | strscan (3.1.2) 151 | terminal-table (4.0.0) 152 | unicode-display_width (>= 1.1.1, < 4) 153 | tzinfo (2.0.6) 154 | concurrent-ruby (~> 1.0) 155 | unicode-display_width (3.1.4) 156 | unicode-emoji (~> 4.0, >= 4.0.4) 157 | unicode-emoji (4.0.4) 158 | uri (1.0.3) 159 | 160 | PLATFORMS 161 | arm64-darwin-22 162 | arm64-darwin-23 163 | arm64-darwin-24 164 | x86_64-linux 165 | 166 | DEPENDENCIES 167 | debug 168 | riverqueue! 169 | riverqueue-sequel! 170 | rspec-core 171 | rspec-expectations 172 | simplecov 173 | standard 174 | steep 175 | 176 | BUNDLED WITH 177 | 2.4.20 178 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | 375 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | # Looks at comments using ## on targets and uses them to produce a help output. 4 | .PHONY: help 5 | help: ALIGN=14 6 | help: ## Print this message 7 | @awk -F ': .*## ' -- "/^[^':]+: .*## /"' { printf "'$$(tput bold)'%-$(ALIGN)s'$$(tput sgr0)' %s\n", $$1, $$2 }' $(MAKEFILE_LIST) 8 | 9 | .PHONY: install 10 | install: ## Run `bundle install` on gem and all subgems 11 | bundle install 12 | cd driver/riverqueue-activerecord && bundle install 13 | cd driver/riverqueue-sequel && bundle install 14 | 15 | .PHONY: lint 16 | lint: standardrb ## Run linter (standardrb) on gem and all subgems 17 | 18 | .PHONY: rspec 19 | rspec: spec 20 | 21 | .PHONY: spec 22 | spec: 23 | bundle exec rspec 24 | cd driver/riverqueue-activerecord && bundle exec rspec 25 | cd driver/riverqueue-sequel && bundle exec rspec 26 | 27 | .PHONY: standardrb 28 | standardrb: 29 | bundle exec standardrb --fix 30 | cd driver/riverqueue-activerecord && bundle exec standardrb --fix 31 | cd driver/riverqueue-sequel && bundle exec standardrb --fix 32 | 33 | .PHONY: steep 34 | steep: 35 | bundle exec steep check 36 | 37 | .PHONY: test 38 | test: spec ## Run test suite (rspec) on gem and all subgems 39 | 40 | .PHONY: type-check 41 | type-check: steep ## Run type check with Steep 42 | 43 | .PHONY: update 44 | update: ## Run `bundle update` on gem and all subgems 45 | bundle update 46 | cd driver/riverqueue-activerecord && bundle update 47 | cd driver/riverqueue-sequel && bundle update 48 | -------------------------------------------------------------------------------- /Steepfile: -------------------------------------------------------------------------------- 1 | D = Steep::Diagnostic 2 | 3 | target :lib do 4 | check "lib" 5 | 6 | library "digest" 7 | library "json" 8 | library "time" 9 | 10 | signature "sig" 11 | 12 | configure_code_diagnostics(D::Ruby.strict) 13 | end 14 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # 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) 2 | 3 | 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. 4 | 5 | ## Basic usage 6 | 7 | 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)): 8 | 9 | ```ruby 10 | gem "riverqueue" 11 | gem "riverqueue-sequel" 12 | ``` 13 | 14 | Initialize a client with: 15 | 16 | ```ruby 17 | require "riverqueue" 18 | require "riverqueue-activerecord" 19 | 20 | DB = Sequel.connect("postgres://...") 21 | client = River::Client.new(River::Driver::ActiveRecord.new) 22 | ``` 23 | 24 | Define a job and insert it: 25 | 26 | ```ruby 27 | class SortArgs 28 | attr_accessor :strings 29 | 30 | def initialize(strings:) 31 | self.strings = strings 32 | end 33 | 34 | def kind = "sort" 35 | 36 | def to_json = JSON.dump({strings: strings}) 37 | end 38 | 39 | insert_res = client.insert(SortArgs.new(strings: ["whale", "tiger", "bear"])) 40 | insert_res.job # inserted job row 41 | ``` 42 | 43 | Job args should: 44 | 45 | - Respond to `#kind` with a unique string that identifies them in the database, and which a Go worker will recognize. 46 | - Response to `#to_json` with a JSON serialization that'll be parseable as an object in Go. 47 | 48 | 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. 49 | 50 | ## Insertion options 51 | 52 | Inserts take an `insert_opts` parameter to customize features of the inserted job: 53 | 54 | ```ruby 55 | insert_res = client.insert( 56 | SortArgs.new(strings: ["whale", "tiger", "bear"]), 57 | insert_opts: River::InsertOpts.new( 58 | max_attempts: 17, 59 | priority: 3, 60 | queue: "my_queue", 61 | tags: ["custom"] 62 | ) 63 | ) 64 | ``` 65 | 66 | ## Inserting unique jobs 67 | 68 | [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. 69 | 70 | ```ruby 71 | insert_res = client.insert(args, insert_opts: River::InsertOpts.new( 72 | unique_opts: River::UniqueOpts.new( 73 | by_args: true, 74 | by_period: 15 * 60, 75 | by_queue: true, 76 | by_state: [River::JOB_STATE_AVAILABLE] 77 | ) 78 | ) 79 | 80 | # contains either a newly inserted job, or an existing one if insertion was skipped 81 | insert_res.job 82 | 83 | # true if insertion was skipped 84 | insert_res.unique_skipped_as_duplicated 85 | ``` 86 | 87 | ## Inserting jobs in bulk 88 | 89 | Use `#insert_many` to bulk insert jobs as a single operation for improved efficiency: 90 | 91 | ```ruby 92 | num_inserted = client.insert_many([ 93 | SortArgs.new(strings: ["whale", "tiger", "bear"]), 94 | SortArgs.new(strings: ["lion", "dolphin", "eagle"]) 95 | ]) 96 | ``` 97 | 98 | Or with `InsertManyParams`, which may include insertion options: 99 | 100 | ```ruby 101 | num_inserted = client.insert_many([ 102 | River::InsertManyParams.new(SortArgs.new(strings: ["whale", "tiger", "bear"]), insert_opts: River::InsertOpts.new(max_attempts: 5)), 103 | River::InsertManyParams.new(SortArgs.new(strings: ["lion", "dolphin", "eagle"]), insert_opts: River::InsertOpts.new(queue: "high_priority")) 104 | ]) 105 | ``` 106 | 107 | ## Inserting in a transaction 108 | 109 | 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. 110 | 111 | ```ruby 112 | ActiveRecord::Base.transaction do 113 | client.insert(SortArgs.new(strings: ["whale", "tiger", "bear"])) 114 | end 115 | ``` 116 | 117 | ```ruby 118 | DB.transaction do 119 | client.insert(SortArgs.new(strings: ["whale", "tiger", "bear"])) 120 | end 121 | ``` 122 | 123 | ## Inserting with a Ruby hash 124 | 125 | `JobArgsHash` can be used to insert with a kind and JSON hash so that it's not necessary to define a class: 126 | 127 | ```ruby 128 | insert_res = client.insert(River::JobArgsHash.new("hash_kind", { 129 | job_num: 1 130 | })) 131 | ``` 132 | 133 | ## RBS and type checking 134 | 135 | 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). 136 | 137 | ## Drivers 138 | 139 | ### ActiveRecord 140 | 141 | Use River with [ActiveRecord](https://guides.rubyonrails.org/active_record_basics.html) by putting the `riverqueue-activerecord` driver in your `Gemfile`: 142 | 143 | ```ruby 144 | gem "riverqueue" 145 | gem "riverqueue-activerecord" 146 | ``` 147 | 148 | Then initialize driver and client: 149 | 150 | ```ruby 151 | ActiveRecord::Base.establish_connection("postgres://...") 152 | client = River::Client.new(River::Driver::ActiveRecord.new) 153 | ``` 154 | 155 | ### Sequel 156 | 157 | Use River with [Sequel](https://github.com/jeremyevans/sequel) by putting the `riverqueue-sequel` driver in your `Gemfile`: 158 | 159 | ```ruby 160 | gem "riverqueue" 161 | gem "riverqueue-sequel" 162 | ``` 163 | 164 | Then initialize driver and client: 165 | 166 | ```ruby 167 | DB = Sequel.connect("postgres://...") 168 | client = River::Client.new(River::Driver::Sequel.new(DB)) 169 | ``` 170 | 171 | ## Development 172 | 173 | See [development](./development.md). 174 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # riverqueue-ruby development 2 | 3 | ## Install dependencies 4 | 5 | ```shell 6 | $ bundle install 7 | $ pushd driver/riverqueue-activerecord && bundle install && popd 8 | $ pushd driver/riverqueue-sequel && bundle install && popd 9 | ``` 10 | ## Run tests 11 | 12 | Create a test database and migrate with River's CLI: 13 | 14 | ```shell 15 | $ go install github.com/riverqueue/river/cmd/river 16 | $ createdb river_test 17 | $ river migrate-up --database-url "postgres://localhost/river_test" 18 | ``` 19 | 20 | Run all specs: 21 | 22 | ```shell 23 | $ bundle exec rspec spec 24 | ``` 25 | 26 | ## Run lint 27 | 28 | ```shell 29 | $ bundle exec standardrb --fix 30 | ``` 31 | 32 | ## Run type check (Steep) 33 | 34 | ```shell 35 | $ bundle exec steep check 36 | ``` 37 | 38 | ## Code coverage 39 | 40 | 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: 41 | 42 | ```shell 43 | $ bundle exec rspec spec 44 | $ open coverage/index.html 45 | ``` 46 | 47 | ## Publish gems 48 | 49 | 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: 50 | 51 | ```shell 52 | git checkout master && git pull --rebase 53 | export VERSION=v0.x.0 54 | 55 | ruby scripts/update_gemspec_version.rb riverqueue.gemspec 56 | ruby scripts/update_gemspec_version.rb driver/riverqueue-activerecord/riverqueue-activerecord.gemspec 57 | ruby scripts/update_gemspec_version.rb driver/riverqueue-sequel/riverqueue-sequel.gemspec 58 | 59 | gem build riverqueue.gemspec 60 | pushd driver/riverqueue-activerecord && gem build riverqueue-activerecord.gemspec && popd 61 | pushd driver/riverqueue-sequel && gem build riverqueue-sequel.gemspec && popd 62 | 63 | bundle install 64 | pushd driver/riverqueue-activerecord && bundle install && popd 65 | pushd driver/riverqueue-sequel && bundle install && popd 66 | 67 | gco -b $USER-$VERSION 68 | ``` 69 | 70 | 2. Update `CHANGELOG.md` to include the new version and open a pull request with those changes and the ones to the gemspecs and `Gemfile.lock`s above. 71 | 72 | 3. Build and push each gem, then tag the release and push that: 73 | 74 | ```shell 75 | git pull origin master 76 | 77 | gem push riverqueue-${"${VERSION}"/v/}.gem 78 | pushd driver/riverqueue-activerecord && gem push riverqueue-activerecord-${"${VERSION}"/v/}.gem && popd 79 | pushd driver/riverqueue-sequel && gem push riverqueue-sequel-${"${VERSION}"/v/}.gem && popd 80 | 81 | git tag $VERSION 82 | git push --tags 83 | ``` 84 | 85 | 4. Cut a new GitHub release by visiting [new release](https://github.com/riverqueue/riverqueue-ruby/releases/new), selecting the new tag, and copying in the version's `CHANGELOG.md` content as the release body. 86 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem "riverqueue", path: "../.." 7 | gem "standard" 8 | end 9 | 10 | group :test do 11 | gem "debug" 12 | gem "rspec-core" 13 | gem "rspec-expectations" 14 | gem "simplecov", require: false 15 | end 16 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | riverqueue (0.9.0) 5 | 6 | PATH 7 | remote: . 8 | specs: 9 | riverqueue-activerecord (0.9.0) 10 | activerecord (> 0, < 1000) 11 | activesupport (> 0, < 1000) 12 | pg (> 0, < 1000) 13 | 14 | GEM 15 | remote: https://rubygems.org/ 16 | specs: 17 | activemodel (8.0.1) 18 | activesupport (= 8.0.1) 19 | activerecord (8.0.1) 20 | activemodel (= 8.0.1) 21 | activesupport (= 8.0.1) 22 | timeout (>= 0.4.0) 23 | activesupport (8.0.1) 24 | base64 25 | benchmark (>= 0.3) 26 | bigdecimal 27 | concurrent-ruby (~> 1.0, >= 1.3.1) 28 | connection_pool (>= 2.2.5) 29 | drb 30 | i18n (>= 1.6, < 2) 31 | logger (>= 1.4.2) 32 | minitest (>= 5.1) 33 | securerandom (>= 0.3) 34 | tzinfo (~> 2.0, >= 2.0.5) 35 | uri (>= 0.13.1) 36 | ast (2.4.2) 37 | base64 (0.2.0) 38 | benchmark (0.4.0) 39 | bigdecimal (3.1.9) 40 | concurrent-ruby (1.3.4) 41 | connection_pool (2.4.1) 42 | date (3.4.1) 43 | debug (1.10.0) 44 | irb (~> 1.10) 45 | reline (>= 0.3.8) 46 | diff-lcs (1.5.1) 47 | docile (1.4.1) 48 | drb (2.2.1) 49 | i18n (1.14.6) 50 | concurrent-ruby (~> 1.0) 51 | io-console (0.8.0) 52 | irb (1.14.3) 53 | rdoc (>= 4.0.0) 54 | reline (>= 0.4.2) 55 | json (2.9.1) 56 | language_server-protocol (3.17.0.3) 57 | lint_roller (1.1.0) 58 | logger (1.6.4) 59 | minitest (5.25.4) 60 | parallel (1.26.3) 61 | parser (3.3.6.0) 62 | ast (~> 2.4.1) 63 | racc 64 | pg (1.5.9) 65 | psych (5.2.2) 66 | date 67 | stringio 68 | racc (1.8.1) 69 | rainbow (3.1.1) 70 | rdoc (6.10.0) 71 | psych (>= 4.0.0) 72 | regexp_parser (2.10.0) 73 | reline (0.6.0) 74 | io-console (~> 0.5) 75 | rspec-core (3.13.2) 76 | rspec-support (~> 3.13.0) 77 | rspec-expectations (3.13.3) 78 | diff-lcs (>= 1.2.0, < 2.0) 79 | rspec-support (~> 3.13.0) 80 | rspec-support (3.13.2) 81 | rubocop (1.69.2) 82 | json (~> 2.3) 83 | language_server-protocol (>= 3.17.0) 84 | parallel (~> 1.10) 85 | parser (>= 3.3.0.2) 86 | rainbow (>= 2.2.2, < 4.0) 87 | regexp_parser (>= 2.9.3, < 3.0) 88 | rubocop-ast (>= 1.36.2, < 2.0) 89 | ruby-progressbar (~> 1.7) 90 | unicode-display_width (>= 2.4.0, < 4.0) 91 | rubocop-ast (1.37.0) 92 | parser (>= 3.3.1.0) 93 | rubocop-performance (1.23.0) 94 | rubocop (>= 1.48.1, < 2.0) 95 | rubocop-ast (>= 1.31.1, < 2.0) 96 | ruby-progressbar (1.13.0) 97 | securerandom (0.4.1) 98 | simplecov (0.22.0) 99 | docile (~> 1.1) 100 | simplecov-html (~> 0.11) 101 | simplecov_json_formatter (~> 0.1) 102 | simplecov-html (0.13.1) 103 | simplecov_json_formatter (0.1.4) 104 | standard (1.43.0) 105 | language_server-protocol (~> 3.17.0.2) 106 | lint_roller (~> 1.0) 107 | rubocop (~> 1.69.1) 108 | standard-custom (~> 1.0.0) 109 | standard-performance (~> 1.6) 110 | standard-custom (1.0.2) 111 | lint_roller (~> 1.0) 112 | rubocop (~> 1.50) 113 | standard-performance (1.6.0) 114 | lint_roller (~> 1.1) 115 | rubocop-performance (~> 1.23.0) 116 | stringio (3.1.2) 117 | timeout (0.4.3) 118 | tzinfo (2.0.6) 119 | concurrent-ruby (~> 1.0) 120 | unicode-display_width (3.1.2) 121 | unicode-emoji (~> 4.0, >= 4.0.4) 122 | unicode-emoji (4.0.4) 123 | uri (1.0.2) 124 | 125 | PLATFORMS 126 | arm64-darwin-22 127 | arm64-darwin-23 128 | arm64-darwin-24 129 | x86_64-linux 130 | 131 | DEPENDENCIES 132 | debug 133 | riverqueue! 134 | riverqueue-activerecord! 135 | rspec-core 136 | rspec-expectations 137 | simplecov 138 | standard 139 | 140 | BUNDLED WITH 141 | 2.4.20 142 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/README.md: -------------------------------------------------------------------------------- 1 | # River Ruby bindings ActiveRecord driver 2 | 3 | A future home for River's Ruby bindings. For now, the [Gem is registered](https://rubygems.org/gems/riverqueue), but nothing else is done. 4 | 5 | ``` sh 6 | $ gem build riverqueue-activerecord.gemspec 7 | $ gem push riverqueue-activerecord-0.0.1.gem 8 | ``` 9 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/docs/README.md: -------------------------------------------------------------------------------- 1 | # riverqueue-sequel [![Build Status](https://github.com/riverqueue/riverqueue-ruby-sequel/workflows/CI/badge.svg)](https://github.com/riverqueue/riverqueue-ruby-sequel/actions) 2 | 3 | [ActiveRecord](https://github.com/jeremyevans/sequel) driver for [River](https://github.com/riverqueue/river)'s [`riverqueue` gem for Ruby](https://rubygems.org/gems/riverqueue). 4 | 5 | `Gemfile` should contain the core gem and a driver like this one: 6 | 7 | ``` yaml 8 | gem "riverqueue" 9 | gem "riverqueue-sequel" 10 | ``` 11 | 12 | Initialize a client with: 13 | 14 | ```ruby 15 | DB = ActiveRecord.connect("postgres://...") 16 | client = River::Client.new(River::Driver::ActiveRecord.new(DB)) 17 | ``` 18 | 19 | See also [`rubyqueue`](https://github.com/riverqueue/riverqueue-ruby). 20 | 21 | ## Development 22 | 23 | See [development](./development.md). 24 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/docs/development.md: -------------------------------------------------------------------------------- 1 | # riverqueue-ruby development 2 | 3 | ## Install dependencies 4 | 5 | ```shell 6 | $ bundle install 7 | ``` 8 | ## Run tests 9 | 10 | Create a test database and migrate with River's CLI: 11 | 12 | ```shell 13 | $ go install github.com/riverqueue/river/cmd/river 14 | $ createdb river_test 15 | $ river migrate-up --database-url "postgres://localhost/river_test" 16 | ``` 17 | 18 | Run all specs: 19 | 20 | ```shell 21 | $ bundle exec rspec spec 22 | ``` 23 | 24 | ## Run lint 25 | 26 | ```shell 27 | $ standardrb --fix 28 | ``` 29 | 30 | ## Code coverage 31 | 32 | 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: 33 | 34 | ```shell 35 | $ bundle exec rspec spec 36 | $ open coverage/index.html 37 | ``` 38 | 39 | ## Publish a new gem 40 | 41 | ```shell 42 | git checkout master && git pull --rebase 43 | VERSION=v0.0.x 44 | gem build riverqueue-sequel.gemspec 45 | gem push riverqueue-sequel-$VERSION.gem 46 | git tag $VERSION 47 | git push --tags 48 | ``` 49 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/lib/driver.rb: -------------------------------------------------------------------------------- 1 | module River::Driver 2 | # Provides a ActiveRecord driver for River. 3 | # 4 | # Used in conjunction with a River client like: 5 | # 6 | # DB = ActiveRecord.connect("postgres://...") 7 | # client = River::Client.new(River::Driver::ActiveRecord.new(DB)) 8 | # 9 | class ActiveRecord 10 | def initialize 11 | # It's Ruby, so we can only define a model after ActiveRecord's established a 12 | # connection because it's all dynamic. 13 | if !River::Driver::ActiveRecord.const_defined?(:RiverJob) 14 | River::Driver::ActiveRecord.const_set(:RiverJob, Class.new(::ActiveRecord::Base) do 15 | self.table_name = "river_job" 16 | 17 | # Unfortunately, Rails errors if you have a column called `errors` and 18 | # provides no way to remap names (beyond ignoring a column, which we 19 | # really don't want). This patch is in place so we can hydrate this 20 | # model at all without ActiveRecord self-immolating. 21 | def self.dangerous_attribute_method?(method_name) 22 | return false if method_name == "errors" 23 | super 24 | end 25 | 26 | # See comment above, but since we force allowed `errors` as an 27 | # attribute name, ActiveRecord would otherwise fail to save a row as 28 | # it checked for its own `errors` hash and finding no values. 29 | def errors = {} 30 | end) 31 | end 32 | end 33 | 34 | def job_get_by_id(id) 35 | data_set = RiverJob.where(id: id) 36 | data_set.first ? to_job_row_from_model(data_set.first) : nil 37 | end 38 | 39 | def job_insert(insert_params) 40 | job_insert_many([insert_params]).first 41 | end 42 | 43 | def job_insert_many(insert_params_many) 44 | res = RiverJob.upsert_all( 45 | insert_params_many.map { |param| insert_params_to_hash(param) }, 46 | on_duplicate: Arel.sql("kind = EXCLUDED.kind"), 47 | returning: Arel.sql("*, (xmax != 0) AS unique_skipped_as_duplicate"), 48 | 49 | # It'd be nice to specify this as `(kind, unique_key) WHERE unique_key 50 | # IS NOT NULL` like we do elsewhere, but in its pure ingenuity, fucking 51 | # ActiveRecord tries to look up a unique index instead of letting 52 | # Postgres handle that, and of course it doesn't support a `WHERE` 53 | # clause. The workaround is to target the index name instead of columns. 54 | unique_by: "river_job_unique_idx" 55 | ) 56 | to_insert_results(res) 57 | end 58 | 59 | def job_list 60 | data_set = RiverJob.order(:id) 61 | data_set.all.map { |job| to_job_row_from_model(job) } 62 | end 63 | 64 | def rollback_exception 65 | ::ActiveRecord::Rollback 66 | end 67 | 68 | def transaction(&) 69 | ::ActiveRecord::Base.transaction(requires_new: true, &) 70 | end 71 | 72 | private def insert_params_to_hash(insert_params) 73 | { 74 | args: insert_params.encoded_args, 75 | kind: insert_params.kind, 76 | max_attempts: insert_params.max_attempts, 77 | priority: insert_params.priority, 78 | queue: insert_params.queue, 79 | state: insert_params.state, 80 | scheduled_at: insert_params.scheduled_at, 81 | tags: insert_params.tags || [], 82 | unique_key: insert_params.unique_key, 83 | unique_states: insert_params.unique_states 84 | } 85 | end 86 | 87 | private def to_job_row_from_model(river_job) 88 | # needs to be accessed through values because `errors` is shadowed by both 89 | # ActiveRecord and the patch above 90 | errors = river_job.attributes["errors"] 91 | 92 | River::JobRow.new( 93 | id: river_job.id, 94 | args: JSON.parse(river_job.args), 95 | attempt: river_job.attempt, 96 | attempted_at: river_job.attempted_at&.getutc, 97 | attempted_by: river_job.attempted_by, 98 | created_at: river_job.created_at.getutc, 99 | errors: errors&.map { |e| 100 | deserialized_error = JSON.parse(e, symbolize_names: true) 101 | 102 | River::AttemptError.new( 103 | at: Time.parse(deserialized_error[:at]), 104 | attempt: deserialized_error[:attempt], 105 | error: deserialized_error[:error], 106 | trace: deserialized_error[:trace] 107 | ) 108 | }, 109 | finalized_at: river_job.finalized_at&.getutc, 110 | kind: river_job.kind, 111 | max_attempts: river_job.max_attempts, 112 | metadata: river_job.metadata, 113 | priority: river_job.priority, 114 | queue: river_job.queue, 115 | scheduled_at: river_job.scheduled_at.getutc, 116 | state: river_job.state, 117 | tags: river_job.tags, 118 | unique_key: river_job.unique_key, 119 | unique_states: river_job.unique_states 120 | ) 121 | end 122 | 123 | private def to_insert_results(res) 124 | res.rows.map do |row| 125 | to_job_row_from_raw(row, res.columns, res.column_types) 126 | end 127 | end 128 | 129 | # This is really awful, but some of ActiveRecord's methods (e.g. `.create`) 130 | # return a model, and others (e.g. `.upsert`) return raw values, and 131 | # therefore this second version from unmarshaling a job row exists. I 132 | # searched long and hard for a way to have the former type of method return 133 | # raw or the latter type of method return a model, but was unable to find 134 | # anything. 135 | private def to_job_row_from_raw(row, columns, column_types) 136 | river_job = {} 137 | 138 | row.each_with_index do |val, i| 139 | river_job[columns[i]] = column_types[i].deserialize(val) 140 | end 141 | 142 | errors = river_job["errors"]&.map do |e| 143 | deserialized_error = JSON.parse(e) 144 | 145 | River::AttemptError.new( 146 | at: Time.parse(deserialized_error["at"]), 147 | attempt: deserialized_error["attempt"], 148 | error: deserialized_error["error"], 149 | trace: deserialized_error["trace"] 150 | ) 151 | end 152 | 153 | [ 154 | River::JobRow.new( 155 | id: river_job["id"], 156 | args: JSON.parse(river_job["args"]), 157 | attempt: river_job["attempt"], 158 | attempted_at: river_job["attempted_at"]&.getutc, 159 | attempted_by: river_job["attempted_by"], 160 | created_at: river_job["created_at"].getutc, 161 | errors: errors, 162 | finalized_at: river_job["finalized_at"]&.getutc, 163 | kind: river_job["kind"], 164 | max_attempts: river_job["max_attempts"], 165 | metadata: river_job["metadata"], 166 | priority: river_job["priority"], 167 | queue: river_job["queue"], 168 | scheduled_at: river_job["scheduled_at"].getutc, 169 | state: river_job["state"], 170 | tags: river_job["tags"], 171 | unique_key: river_job["unique_key"], 172 | unique_states: ::River::UniqueBitmask.to_states(river_job["unique_states"]&.to_i(2)) 173 | ), 174 | river_job["unique_skipped_as_duplicate"] 175 | ] 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/lib/riverqueue-activerecord.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | 3 | require_relative "driver" 4 | 5 | module River 6 | end 7 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/riverqueue-activerecord.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "riverqueue-activerecord" 3 | s.version = "0.9.0" 4 | s.summary = "ActiveRecord driver for the River Ruby gem." 5 | s.description = "ActiveRecord driver for the River Ruby gem. Use in conjunction with the riverqueue gem to insert jobs that are worked in Go." 6 | s.authors = ["Blake Gentry", "Brandur Leach"] 7 | s.email = "brandur@brandur.org" 8 | s.files = Dir.glob("lib/**/*") 9 | s.homepage = "https://riverqueue.com" 10 | s.license = "LGPL-3.0-or-later" 11 | 12 | # The stupid version bounds are used to silence Ruby's extremely obnoxious warnings. 13 | s.add_dependency "activerecord", "> 0", "< 1000" 14 | s.add_dependency "activesupport", "> 0", "< 1000" # required for ActiveRecord to load properly 15 | s.add_dependency "pg", "> 0", "< 1000" 16 | end 17 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/spec/driver_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require_relative "../../../spec/driver_shared_examples" 3 | 4 | RSpec.describe River::Driver::ActiveRecord do 5 | around(:each) { |ex| test_transaction(&ex) } 6 | 7 | let!(:driver) { River::Driver::ActiveRecord.new } 8 | let(:client) { River::Client.new(driver) } 9 | 10 | before do 11 | if ENV["RIVER_DEBUG"] == "1" || ENV["RIVER_DEBUG"] == "true" 12 | ActiveRecord::Base.logger = Logger.new($stdout) 13 | end 14 | end 15 | 16 | it_behaves_like "driver shared examples" 17 | 18 | describe "#to_job_row_from_model" do 19 | it "converts a database record to `River::JobRow` with minimal properties" do 20 | river_job = River::Driver::ActiveRecord::RiverJob.create( 21 | id: 1, 22 | args: %({"job_num":1}), 23 | kind: "simple", 24 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 25 | priority: River::PRIORITY_DEFAULT, 26 | queue: River::QUEUE_DEFAULT, 27 | state: River::JOB_STATE_AVAILABLE 28 | ) 29 | 30 | job_row = driver.send(:to_job_row_from_model, river_job) 31 | 32 | expect(job_row).to be_an_instance_of(River::JobRow) 33 | expect(job_row).to have_attributes( 34 | id: 1, 35 | args: {"job_num" => 1}, 36 | attempt: 0, 37 | attempted_at: nil, 38 | attempted_by: nil, 39 | created_at: be_within(2).of(Time.now.getutc), 40 | finalized_at: nil, 41 | kind: "simple", 42 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 43 | priority: River::PRIORITY_DEFAULT, 44 | queue: River::QUEUE_DEFAULT, 45 | scheduled_at: be_within(2).of(Time.now.getutc), 46 | state: River::JOB_STATE_AVAILABLE, 47 | tags: [] 48 | ) 49 | end 50 | 51 | it "converts a database record to `River::JobRow` with all properties" do 52 | now = Time.now 53 | river_job = River::Driver::ActiveRecord::RiverJob.create( 54 | id: 1, 55 | attempt: 1, 56 | attempted_at: now, 57 | attempted_by: ["client1"], 58 | created_at: now, 59 | args: %({"job_num":1}), 60 | finalized_at: now, 61 | kind: "simple", 62 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 63 | priority: River::PRIORITY_DEFAULT, 64 | queue: River::QUEUE_DEFAULT, 65 | scheduled_at: now, 66 | state: River::JOB_STATE_COMPLETED, 67 | tags: ["tag1"], 68 | unique_key: Digest::SHA256.digest("unique_key_str") 69 | ) 70 | 71 | job_row = driver.send(:to_job_row_from_model, river_job) 72 | 73 | expect(job_row).to be_an_instance_of(River::JobRow) 74 | expect(job_row).to have_attributes( 75 | id: 1, 76 | args: {"job_num" => 1}, 77 | attempt: 1, 78 | attempted_at: now.getutc, 79 | attempted_by: ["client1"], 80 | created_at: now.getutc, 81 | finalized_at: now.getutc, 82 | kind: "simple", 83 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 84 | priority: River::PRIORITY_DEFAULT, 85 | queue: River::QUEUE_DEFAULT, 86 | scheduled_at: now.getutc, 87 | state: River::JOB_STATE_COMPLETED, 88 | tags: ["tag1"], 89 | unique_key: Digest::SHA256.digest("unique_key_str") 90 | ) 91 | end 92 | 93 | it "with errors" do 94 | now = Time.now.utc 95 | river_job = River::Driver::ActiveRecord::RiverJob.create( 96 | args: %({"job_num":1}), 97 | errors: [JSON.dump( 98 | { 99 | at: now, 100 | attempt: 1, 101 | error: "job failure", 102 | trace: "error trace" 103 | } 104 | )], 105 | kind: "simple", 106 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 107 | state: River::JOB_STATE_AVAILABLE 108 | ) 109 | 110 | job_row = driver.send(:to_job_row_from_model, river_job) 111 | 112 | expect(job_row.errors.count).to be(1) 113 | expect(job_row.errors[0]).to be_an_instance_of(River::AttemptError) 114 | expect(job_row.errors[0]).to have_attributes( 115 | at: now.floor(0), 116 | attempt: 1, 117 | error: "job failure", 118 | trace: "error trace" 119 | ) 120 | end 121 | end 122 | 123 | describe "#to_job_row_from_raw" do 124 | it "converts a database record to `River::JobRow` with minimal properties" do 125 | res = River::Driver::ActiveRecord::RiverJob.insert({ 126 | id: 1, 127 | args: %({"job_num":1}), 128 | kind: "simple", 129 | max_attempts: River::MAX_ATTEMPTS_DEFAULT 130 | }, returning: Arel.sql("*, false AS unique_skipped_as_duplicate")) 131 | 132 | job_row, skipped_as_duplicate = driver.send(:to_job_row_from_raw, res.rows[0], res.columns, res.column_types) 133 | 134 | expect(job_row).to be_an_instance_of(River::JobRow) 135 | expect(job_row).to have_attributes( 136 | id: 1, 137 | args: {"job_num" => 1}, 138 | attempt: 0, 139 | attempted_at: nil, 140 | attempted_by: nil, 141 | created_at: be_within(2).of(Time.now.getutc), 142 | finalized_at: nil, 143 | kind: "simple", 144 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 145 | priority: River::PRIORITY_DEFAULT, 146 | queue: River::QUEUE_DEFAULT, 147 | scheduled_at: be_within(2).of(Time.now.getutc), 148 | state: River::JOB_STATE_AVAILABLE, 149 | tags: [] 150 | ) 151 | expect(skipped_as_duplicate).to be(false) 152 | end 153 | 154 | it "converts a database record to `River::JobRow` with all properties" do 155 | now = Time.now 156 | res = River::Driver::ActiveRecord::RiverJob.insert({ 157 | id: 1, 158 | attempt: 1, 159 | attempted_at: now, 160 | attempted_by: ["client1"], 161 | created_at: now, 162 | args: %({"job_num":1}), 163 | finalized_at: now, 164 | kind: "simple", 165 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 166 | priority: River::PRIORITY_DEFAULT, 167 | queue: River::QUEUE_DEFAULT, 168 | scheduled_at: now, 169 | state: River::JOB_STATE_COMPLETED, 170 | tags: ["tag1"], 171 | unique_key: Digest::SHA256.digest("unique_key_str") 172 | }, returning: Arel.sql("*, true AS unique_skipped_as_duplicate")) 173 | 174 | job_row, skipped_as_duplicate = driver.send(:to_job_row_from_raw, res.rows[0], res.columns, res.column_types) 175 | 176 | expect(job_row).to be_an_instance_of(River::JobRow) 177 | expect(job_row).to have_attributes( 178 | id: 1, 179 | args: {"job_num" => 1}, 180 | attempt: 1, 181 | attempted_at: be_within(2).of(now.getutc), 182 | attempted_by: ["client1"], 183 | created_at: be_within(2).of(now.getutc), 184 | finalized_at: be_within(2).of(now.getutc), 185 | kind: "simple", 186 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 187 | priority: River::PRIORITY_DEFAULT, 188 | queue: River::QUEUE_DEFAULT, 189 | scheduled_at: be_within(2).of(now.getutc), 190 | state: River::JOB_STATE_COMPLETED, 191 | tags: ["tag1"], 192 | unique_key: Digest::SHA256.digest("unique_key_str") 193 | ) 194 | expect(skipped_as_duplicate).to be(true) 195 | end 196 | 197 | it "with errors" do 198 | now = Time.now.utc 199 | res = River::Driver::ActiveRecord::RiverJob.insert({ 200 | args: %({"job_num":1}), 201 | errors: [JSON.dump( 202 | { 203 | at: now, 204 | attempt: 1, 205 | error: "job failure", 206 | trace: "error trace" 207 | } 208 | )], 209 | kind: "simple", 210 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 211 | state: River::JOB_STATE_AVAILABLE 212 | }, returning: Arel.sql("*, false AS unique_skipped_as_duplicate")) 213 | 214 | job_row, skipped_as_duplicate = driver.send(:to_job_row_from_raw, res.rows[0], res.columns, res.column_types) 215 | 216 | expect(job_row.errors.count).to be(1) 217 | expect(job_row.errors[0]).to be_an_instance_of(River::AttemptError) 218 | expect(job_row.errors[0]).to have_attributes( 219 | at: now.floor(0), 220 | attempt: 1, 221 | error: "job failure", 222 | trace: "error trace" 223 | ) 224 | expect(skipped_as_duplicate).to be(false) 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /driver/riverqueue-activerecord/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "debug" 3 | 4 | ActiveRecord::Base.establish_connection(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test") 5 | 6 | def test_transaction 7 | ActiveRecord::Base.transaction do 8 | yield 9 | raise ActiveRecord::Rollback 10 | end 11 | end 12 | 13 | require "simplecov" 14 | SimpleCov.start do 15 | enable_coverage :branch 16 | minimum_coverage line: 100, branch: 100 17 | end 18 | 19 | require "riverqueue" 20 | require "riverqueue-activerecord" 21 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem "riverqueue", path: "../.." 7 | gem "standard" 8 | end 9 | 10 | group :test do 11 | gem "rspec-core" 12 | gem "rspec-expectations" 13 | gem "simplecov", require: false 14 | end 15 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | riverqueue (0.9.0) 5 | 6 | PATH 7 | remote: . 8 | specs: 9 | riverqueue-sequel (0.9.0) 10 | pg (> 0, < 1000) 11 | sequel (> 0, < 1000) 12 | 13 | GEM 14 | remote: https://rubygems.org/ 15 | specs: 16 | ast (2.4.2) 17 | bigdecimal (3.1.9) 18 | diff-lcs (1.5.1) 19 | docile (1.4.1) 20 | json (2.9.1) 21 | language_server-protocol (3.17.0.3) 22 | lint_roller (1.1.0) 23 | parallel (1.26.3) 24 | parser (3.3.6.0) 25 | ast (~> 2.4.1) 26 | racc 27 | pg (1.5.9) 28 | racc (1.8.1) 29 | rainbow (3.1.1) 30 | regexp_parser (2.10.0) 31 | rspec-core (3.13.2) 32 | rspec-support (~> 3.13.0) 33 | rspec-expectations (3.13.3) 34 | diff-lcs (>= 1.2.0, < 2.0) 35 | rspec-support (~> 3.13.0) 36 | rspec-support (3.13.2) 37 | rubocop (1.69.2) 38 | json (~> 2.3) 39 | language_server-protocol (>= 3.17.0) 40 | parallel (~> 1.10) 41 | parser (>= 3.3.0.2) 42 | rainbow (>= 2.2.2, < 4.0) 43 | regexp_parser (>= 2.9.3, < 3.0) 44 | rubocop-ast (>= 1.36.2, < 2.0) 45 | ruby-progressbar (~> 1.7) 46 | unicode-display_width (>= 2.4.0, < 4.0) 47 | rubocop-ast (1.37.0) 48 | parser (>= 3.3.1.0) 49 | rubocop-performance (1.23.0) 50 | rubocop (>= 1.48.1, < 2.0) 51 | rubocop-ast (>= 1.31.1, < 2.0) 52 | ruby-progressbar (1.13.0) 53 | sequel (5.87.0) 54 | bigdecimal 55 | simplecov (0.22.0) 56 | docile (~> 1.1) 57 | simplecov-html (~> 0.11) 58 | simplecov_json_formatter (~> 0.1) 59 | simplecov-html (0.13.1) 60 | simplecov_json_formatter (0.1.4) 61 | standard (1.43.0) 62 | language_server-protocol (~> 3.17.0.2) 63 | lint_roller (~> 1.0) 64 | rubocop (~> 1.69.1) 65 | standard-custom (~> 1.0.0) 66 | standard-performance (~> 1.6) 67 | standard-custom (1.0.2) 68 | lint_roller (~> 1.0) 69 | rubocop (~> 1.50) 70 | standard-performance (1.6.0) 71 | lint_roller (~> 1.1) 72 | rubocop-performance (~> 1.23.0) 73 | unicode-display_width (3.1.2) 74 | unicode-emoji (~> 4.0, >= 4.0.4) 75 | unicode-emoji (4.0.4) 76 | 77 | PLATFORMS 78 | arm64-darwin-22 79 | arm64-darwin-23 80 | arm64-darwin-24 81 | x86_64-linux 82 | 83 | DEPENDENCIES 84 | riverqueue! 85 | riverqueue-sequel! 86 | rspec-core 87 | rspec-expectations 88 | simplecov 89 | standard 90 | 91 | BUNDLED WITH 92 | 2.4.20 93 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/docs/README.md: -------------------------------------------------------------------------------- 1 | # riverqueue-sequel [![Build Status](https://github.com/riverqueue/riverqueue-ruby-sequel/workflows/CI/badge.svg)](https://github.com/riverqueue/riverqueue-ruby-sequel/actions) 2 | 3 | [Sequel](https://github.com/jeremyevans/sequel) driver for [River](https://github.com/riverqueue/river)'s [`riverqueue` gem for Ruby](https://rubygems.org/gems/riverqueue). 4 | 5 | `Gemfile` should contain the core gem and a driver like this one: 6 | 7 | ``` yaml 8 | gem "riverqueue" 9 | gem "riverqueue-sequel" 10 | ``` 11 | 12 | Initialize a client with: 13 | 14 | ```ruby 15 | DB = Sequel.connect("postgres://...") 16 | client = River::Client.new(River::Driver::Sequel.new(DB)) 17 | ``` 18 | 19 | See also [`rubyqueue`](https://github.com/riverqueue/riverqueue-ruby). 20 | 21 | ## Development 22 | 23 | See [development](./development.md). 24 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/docs/development.md: -------------------------------------------------------------------------------- 1 | # riverqueue-ruby development 2 | 3 | ## Install dependencies 4 | 5 | ```shell 6 | $ bundle install 7 | ``` 8 | ## Run tests 9 | 10 | Create a test database and migrate with River's CLI: 11 | 12 | ```shell 13 | $ go install github.com/riverqueue/river/cmd/river 14 | $ createdb river_test 15 | $ river migrate-up --database-url "postgres://localhost/river_test" 16 | ``` 17 | 18 | Run all specs: 19 | 20 | ```shell 21 | $ bundle exec rspec spec 22 | ``` 23 | 24 | ## Run lint 25 | 26 | ```shell 27 | $ standardrb --fix 28 | ``` 29 | 30 | ## Code coverage 31 | 32 | 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: 33 | 34 | ```shell 35 | $ bundle exec rspec spec 36 | $ open coverage/index.html 37 | ``` 38 | 39 | ## Publish a new gem 40 | 41 | ```shell 42 | git checkout master && git pull --rebase 43 | VERSION=v0.0.x 44 | gem build riverqueue-sequel.gemspec 45 | gem push riverqueue-sequel-$VERSION.gem 46 | git tag $VERSION 47 | git push --tags 48 | ``` 49 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/lib/driver.rb: -------------------------------------------------------------------------------- 1 | module River::Driver 2 | # Provides a Sequel driver for River. 3 | # 4 | # Used in conjunction with a River client like: 5 | # 6 | # DB = Sequel.connect("postgres://...") 7 | # client = River::Client.new(River::Driver::Sequel.new(DB)) 8 | # 9 | class Sequel 10 | def initialize(db) 11 | @db = db 12 | @db.extension(:pg_array) 13 | @db.extension(:pg_json) 14 | end 15 | 16 | def job_get_by_id(id) 17 | data_set = @db[:river_job].where(id: id) 18 | data_set.first ? to_job_row(data_set.first) : nil 19 | end 20 | 21 | def job_insert(insert_params) 22 | job_insert_many([insert_params]).first 23 | end 24 | 25 | def job_insert_many(insert_params_array) 26 | @db[:river_job] 27 | .insert_conflict( 28 | target: [:unique_key], 29 | conflict_where: ::Sequel.lit( 30 | "unique_key IS NOT NULL AND unique_states IS NOT NULL AND river_job_state_in_bitmask(unique_states, state)" 31 | ), 32 | update: {kind: ::Sequel[:excluded][:kind]} 33 | ) 34 | .returning(::Sequel.lit("*, (xmax != 0) AS unique_skipped_as_duplicate")) 35 | .multi_insert(insert_params_array.map { |p| insert_params_to_hash(p) }) 36 | .map { |row| to_insert_result(row) } 37 | end 38 | 39 | def job_list 40 | data_set = @db[:river_job].order_by(:id) 41 | data_set.all.map { |job| to_job_row(job) } 42 | end 43 | 44 | def rollback_exception 45 | ::Sequel::Rollback 46 | end 47 | 48 | def transaction(&) 49 | @db.transaction(savepoint: true, &) 50 | end 51 | 52 | private def insert_params_to_hash(insert_params) 53 | { 54 | args: insert_params.encoded_args, 55 | kind: insert_params.kind, 56 | max_attempts: insert_params.max_attempts, 57 | priority: insert_params.priority, 58 | queue: insert_params.queue, 59 | state: insert_params.state, 60 | scheduled_at: insert_params.scheduled_at, 61 | tags: ::Sequel.pg_array(insert_params.tags || [], :text), 62 | unique_key: insert_params.unique_key ? ::Sequel.blob(insert_params.unique_key) : nil, 63 | unique_states: insert_params.unique_states 64 | } 65 | end 66 | 67 | private def to_insert_result(result) 68 | [to_job_row(result), result[:unique_skipped_as_duplicate]] 69 | end 70 | 71 | private def to_job_row(river_job) 72 | River::JobRow.new( 73 | id: river_job[:id], 74 | args: river_job[:args].to_h, 75 | attempt: river_job[:attempt], 76 | attempted_at: river_job[:attempted_at]&.getutc, 77 | attempted_by: river_job[:attempted_by], 78 | created_at: river_job[:created_at].getutc, 79 | errors: river_job[:errors]&.map { |deserialized_error| 80 | River::AttemptError.new( 81 | at: Time.parse(deserialized_error["at"]), 82 | attempt: deserialized_error["attempt"], 83 | error: deserialized_error["error"], 84 | trace: deserialized_error["trace"] 85 | ) 86 | }, 87 | finalized_at: river_job[:finalized_at]&.getutc, 88 | kind: river_job[:kind], 89 | max_attempts: river_job[:max_attempts], 90 | metadata: river_job[:metadata], 91 | priority: river_job[:priority], 92 | queue: river_job[:queue], 93 | scheduled_at: river_job[:scheduled_at].getutc, 94 | state: river_job[:state], 95 | tags: river_job[:tags].to_a, 96 | unique_key: river_job[:unique_key]&.to_s, 97 | unique_states: ::River::UniqueBitmask.to_states(river_job[:unique_states]&.to_i(2)) 98 | ) 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/lib/riverqueue-sequel.rb: -------------------------------------------------------------------------------- 1 | require "sequel" 2 | 3 | require_relative "driver" 4 | 5 | module River 6 | end 7 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/riverqueue-sequel.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "riverqueue-sequel" 3 | s.version = "0.9.0" 4 | s.summary = "Sequel driver for the River Ruby gem." 5 | s.description = "Sequel driver for the River Ruby gem. Use in conjunction with the riverqueue gem to insert jobs that are worked in Go." 6 | s.authors = ["Blake Gentry", "Brandur Leach"] 7 | s.email = "brandur@brandur.org" 8 | s.files = Dir.glob("lib/**/*") 9 | s.homepage = "https://riverqueue.com" 10 | s.license = "LGPL-3.0-or-later" 11 | 12 | # The stupid version bounds are used to silence Ruby's extremely obnoxious warnings. 13 | s.add_dependency "pg", "> 0", "< 1000" 14 | s.add_dependency "sequel", "> 0", "< 1000" 15 | end 16 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/spec/driver_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require_relative "../../../spec/driver_shared_examples" 3 | 4 | RSpec.describe River::Driver::Sequel do 5 | around(:each) { |ex| test_transaction(&ex) } 6 | 7 | let!(:driver) { River::Driver::Sequel.new(DB) } 8 | let(:client) { River::Client.new(driver) } 9 | 10 | it_behaves_like "driver shared examples" 11 | 12 | describe "#to_job_row" do 13 | it "converts a database record to `River::JobRow` with minimal properties" do 14 | river_job = DB[:river_job].returning.insert_select({ 15 | id: 1, 16 | args: %({"job_num":1}), 17 | kind: "simple", 18 | max_attempts: River::MAX_ATTEMPTS_DEFAULT 19 | }) 20 | 21 | job_row = driver.send(:to_job_row, river_job) 22 | 23 | expect(job_row).to be_an_instance_of(River::JobRow) 24 | expect(job_row).to have_attributes( 25 | id: 1, 26 | args: {"job_num" => 1}, 27 | attempt: 0, 28 | attempted_at: nil, 29 | attempted_by: nil, 30 | created_at: be_within(2).of(Time.now.getutc), 31 | finalized_at: nil, 32 | kind: "simple", 33 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 34 | priority: River::PRIORITY_DEFAULT, 35 | queue: River::QUEUE_DEFAULT, 36 | scheduled_at: be_within(2).of(Time.now.getutc), 37 | state: River::JOB_STATE_AVAILABLE, 38 | tags: [] 39 | ) 40 | end 41 | 42 | it "converts a database record to `River::JobRow` with all properties" do 43 | now = Time.now 44 | river_job = DB[:river_job].returning.insert_select({ 45 | id: 1, 46 | attempt: 1, 47 | attempted_at: now, 48 | attempted_by: ::Sequel.pg_array(["client1"]), 49 | created_at: now, 50 | args: %({"job_num":1}), 51 | finalized_at: now, 52 | kind: "simple", 53 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 54 | priority: River::PRIORITY_DEFAULT, 55 | queue: River::QUEUE_DEFAULT, 56 | scheduled_at: now, 57 | state: River::JOB_STATE_COMPLETED, 58 | tags: ::Sequel.pg_array(["tag1"]), 59 | unique_key: ::Sequel.blob(Digest::SHA256.digest("unique_key_str")) 60 | }) 61 | 62 | job_row = driver.send(:to_job_row, river_job) 63 | 64 | expect(job_row).to be_an_instance_of(River::JobRow) 65 | expect(job_row).to have_attributes( 66 | id: 1, 67 | args: {"job_num" => 1}, 68 | attempt: 1, 69 | attempted_at: be_within(2).of(now.getutc), 70 | attempted_by: ["client1"], 71 | created_at: be_within(2).of(now.getutc), 72 | finalized_at: be_within(2).of(now.getutc), 73 | kind: "simple", 74 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 75 | priority: River::PRIORITY_DEFAULT, 76 | queue: River::QUEUE_DEFAULT, 77 | scheduled_at: be_within(2).of(now.getutc), 78 | state: River::JOB_STATE_COMPLETED, 79 | tags: ["tag1"], 80 | unique_key: Digest::SHA256.digest("unique_key_str") 81 | ) 82 | end 83 | 84 | it "with errors" do 85 | now = Time.now.utc 86 | river_job = DB[:river_job].returning.insert_select({ 87 | args: %({"job_num":1}), 88 | errors: ::Sequel.pg_array([ 89 | ::Sequel.pg_json_wrap({ 90 | at: now, 91 | attempt: 1, 92 | error: "job failure", 93 | trace: "error trace" 94 | }) 95 | ]), 96 | kind: "simple", 97 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 98 | state: River::JOB_STATE_AVAILABLE 99 | }) 100 | 101 | job_row = driver.send(:to_job_row, river_job) 102 | 103 | expect(job_row.errors.count).to be(1) 104 | expect(job_row.errors[0]).to be_an_instance_of(River::AttemptError) 105 | expect(job_row.errors[0]).to have_attributes( 106 | at: now.floor(0), 107 | attempt: 1, 108 | error: "job failure", 109 | trace: "error trace" 110 | ) 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /driver/riverqueue-sequel/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "sequel" 2 | 3 | DB = Sequel.connect(ENV["TEST_DATABASE_URL"] || "postgres://localhost/river_test") 4 | 5 | def test_transaction 6 | DB.transaction do 7 | yield 8 | raise Sequel::Rollback 9 | end 10 | end 11 | 12 | require "simplecov" 13 | SimpleCov.start do 14 | enable_coverage :branch 15 | minimum_coverage line: 100, branch: 100 16 | end 17 | 18 | require "riverqueue" 19 | require "riverqueue-sequel" 20 | -------------------------------------------------------------------------------- /lib/client.rb: -------------------------------------------------------------------------------- 1 | require "digest" 2 | require "time" 3 | 4 | module River 5 | # Default number of maximum attempts for a job. 6 | MAX_ATTEMPTS_DEFAULT = 25 7 | 8 | # Default priority for a job. 9 | PRIORITY_DEFAULT = 1 10 | 11 | # Default queue for a job. 12 | QUEUE_DEFAULT = "default" 13 | 14 | # Provides a client for River that inserts jobs. Unlike the Go version of the 15 | # River client, this one can insert jobs only. Jobs can only be worked from Go 16 | # code, so job arg kinds and JSON encoding details must be shared between Ruby 17 | # and Go code. 18 | # 19 | # Used in conjunction with a River driver like: 20 | # 21 | # DB = Sequel.connect(...) 22 | # client = River::Client.new(River::Driver::Sequel.new(DB)) 23 | # 24 | # River drivers are found in separate gems like `riverqueue-sequel` to help 25 | # minimize transient dependencies. 26 | class Client 27 | def initialize(driver) 28 | @driver = driver 29 | @time_now_utc = -> { Time.now.utc } # for test time stubbing 30 | end 31 | 32 | # Inserts a new job for work given a job args implementation and insertion 33 | # options (which may be omitted). 34 | # 35 | # With job args only: 36 | # 37 | # insert_res = client.insert(SimpleArgs.new(job_num: 1)) 38 | # insert_res.job # inserted job row 39 | # 40 | # With insert opts: 41 | # 42 | # insert_res = client.insert(SimpleArgs.new(job_num: 1), insert_opts: InsertOpts.new(queue: "high_priority")) 43 | # insert_res.job # inserted job row 44 | # 45 | # Job arg implementations are expected to respond to: 46 | # 47 | # * `#kind`: A string that uniquely identifies the job in the database. 48 | # * `#to_json`: Encodes the args to JSON for persistence in the database. 49 | # Must match encoding an args struct on the Go side to be workable. 50 | # 51 | # They may also respond to `#insert_opts` which is expected to return an 52 | # `InsertOpts` that contains options that will apply to all jobs of this 53 | # kind. Insertion options provided as an argument to `#insert` override 54 | # those returned by job args. 55 | # 56 | # For example: 57 | # 58 | # class SimpleArgs 59 | # attr_accessor :job_num 60 | # 61 | # def initialize(job_num:) 62 | # self.job_num = job_num 63 | # end 64 | # 65 | # def kind = "simple" 66 | # 67 | # def to_json = JSON.dump({job_num: job_num}) 68 | # end 69 | # 70 | # See also JobArgsHash for an easy way to insert a job from a hash. 71 | # 72 | # Returns an instance of InsertResult. 73 | def insert(args, insert_opts: EMPTY_INSERT_OPTS) 74 | insert_params = make_insert_params(args, insert_opts) 75 | insert_and_check_unique_job(insert_params) 76 | end 77 | 78 | # Inserts many new jobs as part of a single batch operation for improved 79 | # efficiency. 80 | # 81 | # Takes an array of job args or InsertManyParams which encapsulate job args 82 | # and a paired InsertOpts. 83 | # 84 | # With job args: 85 | # 86 | # num_inserted = client.insert_many([ 87 | # SimpleArgs.new(job_num: 1), 88 | # SimpleArgs.new(job_num: 2) 89 | # ]) 90 | # 91 | # With InsertManyParams: 92 | # 93 | # num_inserted = client.insert_many([ 94 | # River::InsertManyParams.new(SimpleArgs.new(job_num: 1), insert_opts: InsertOpts.new(max_attempts: 5)), 95 | # River::InsertManyParams.new(SimpleArgs.new(job_num: 2), insert_opts: InsertOpts.new(queue: "high_priority")) 96 | # ]) 97 | # 98 | # Job arg implementations are expected to respond to: 99 | # 100 | # * `#kind`: A string that uniquely identifies the job in the database. 101 | # * `#to_json`: Encodes the args to JSON for persistence in the database. 102 | # Must match encoding an args struct on the Go side to be workable. 103 | # 104 | # For example: 105 | # 106 | # class SimpleArgs 107 | # attr_accessor :job_num 108 | # 109 | # def initialize(job_num:) 110 | # self.job_num = job_num 111 | # end 112 | # 113 | # def kind = "simple" 114 | # 115 | # def to_json = JSON.dump({job_num: job_num}) 116 | # end 117 | # 118 | # See also JobArgsHash for an easy way to insert a job from a hash. 119 | # 120 | # Returns the number of jobs inserted. 121 | def insert_many(args) 122 | all_params = args.map do |arg| 123 | if arg.is_a?(InsertManyParams) 124 | make_insert_params(arg.args, arg.insert_opts || EMPTY_INSERT_OPTS) 125 | else # jobArgs 126 | make_insert_params(arg, EMPTY_INSERT_OPTS) 127 | end 128 | end 129 | 130 | @driver.job_insert_many(all_params) 131 | .map do |job, unique_skipped_as_duplicate| 132 | InsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate) 133 | end 134 | end 135 | 136 | # Default states that are used during a unique insert. Can be overridden by 137 | # setting UniqueOpts#by_state. 138 | DEFAULT_UNIQUE_STATES = [ 139 | JOB_STATE_AVAILABLE, 140 | JOB_STATE_COMPLETED, 141 | JOB_STATE_PENDING, 142 | JOB_STATE_RETRYABLE, 143 | JOB_STATE_RUNNING, 144 | JOB_STATE_SCHEDULED 145 | ].freeze 146 | private_constant :DEFAULT_UNIQUE_STATES 147 | 148 | REQUIRED_UNIQUE_STATES = [ 149 | JOB_STATE_AVAILABLE, 150 | JOB_STATE_PENDING, 151 | JOB_STATE_RUNNING, 152 | JOB_STATE_SCHEDULED 153 | ].freeze 154 | private_constant :REQUIRED_UNIQUE_STATES 155 | 156 | EMPTY_INSERT_OPTS = InsertOpts.new.freeze 157 | private_constant :EMPTY_INSERT_OPTS 158 | 159 | private def insert_and_check_unique_job(insert_params) 160 | job, unique_skipped_as_duplicate = @driver.job_insert(insert_params) 161 | InsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate) 162 | end 163 | 164 | private def make_insert_params(args, insert_opts) 165 | raise "args should respond to `#kind`" if !args.respond_to?(:kind) 166 | 167 | # ~all objects in Ruby respond to `#to_json`, so check non-nil instead. 168 | args_json = args.to_json 169 | raise "args should return non-nil from `#to_json`" if !args_json 170 | 171 | args_insert_opts = if args.respond_to?(:insert_opts) 172 | args_with_insert_opts = args #: _JobArgsWithInsertOpts # rubocop:disable Layout/LeadingCommentSpace 173 | args_with_insert_opts.insert_opts || EMPTY_INSERT_OPTS 174 | else 175 | EMPTY_INSERT_OPTS 176 | end 177 | 178 | scheduled_at = insert_opts.scheduled_at || args_insert_opts.scheduled_at 179 | 180 | insert_params = Driver::JobInsertParams.new( 181 | encoded_args: args_json, 182 | kind: args.kind, 183 | max_attempts: insert_opts.max_attempts || args_insert_opts.max_attempts || MAX_ATTEMPTS_DEFAULT, 184 | priority: insert_opts.priority || args_insert_opts.priority || PRIORITY_DEFAULT, 185 | queue: insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT, 186 | scheduled_at: scheduled_at&.utc || Time.now, 187 | state: scheduled_at ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE, 188 | tags: validate_tags(insert_opts.tags || args_insert_opts.tags || []) 189 | ) 190 | 191 | unique_opts = insert_opts.unique_opts || args_insert_opts.unique_opts 192 | if unique_opts 193 | unique_key, unique_states = make_unique_key_and_bitmask(insert_params, unique_opts) 194 | insert_params.unique_key = unique_key 195 | insert_params.unique_states = unique_states 196 | end 197 | insert_params 198 | end 199 | 200 | private def make_unique_key_and_bitmask(insert_params, unique_opts) 201 | unique_key = "" 202 | 203 | # It's extremely important here that this unique key format and algorithm 204 | # match the one in the main River library _exactly_. Don't change them 205 | # unless they're updated everywhere. 206 | unless unique_opts.exclude_kind 207 | unique_key += "&kind=#{insert_params.kind}" 208 | end 209 | 210 | if unique_opts.by_args 211 | parsed_args = JSON.parse(insert_params.encoded_args) 212 | filtered_args = if unique_opts.by_args.is_a?(Array) 213 | parsed_args.slice(*unique_opts.by_args) 214 | else 215 | parsed_args 216 | end 217 | 218 | encoded_args = JSON.generate(filtered_args.sort.to_h) 219 | unique_key += "&args=#{encoded_args}" 220 | end 221 | 222 | if unique_opts.by_period 223 | lower_period_bound = truncate_time(insert_params.scheduled_at || @time_now_utc.call, unique_opts.by_period).utc 224 | 225 | unique_key += "&period=#{lower_period_bound.strftime("%FT%TZ")}" 226 | end 227 | 228 | if unique_opts.by_queue 229 | unique_key += "&queue=#{insert_params.queue}" 230 | end 231 | 232 | unique_key_hash = Digest::SHA256.digest(unique_key) 233 | unique_states = validate_unique_states(unique_opts.by_state || DEFAULT_UNIQUE_STATES) 234 | 235 | [unique_key_hash, UniqueBitmask.from_states(unique_states)] 236 | end 237 | 238 | # Truncates the given time down to the interval. For example: 239 | # 240 | # Thu Jan 15 21:26:36 UTC 2024 @ 15 minutes -> 241 | # Thu Jan 15 21:15:00 UTC 2024 242 | private def truncate_time(time, interval_seconds) 243 | Time.at((time.to_f / interval_seconds).floor * interval_seconds) 244 | end 245 | 246 | # Moves an integer that may occupy the entire uint64 space to one that's 247 | # bounded within int64. Allows overflow. 248 | private def uint64_to_int64(int) 249 | [int].pack("Q").unpack1("q") #: Integer # rubocop:disable Layout/LeadingCommentSpace 250 | end 251 | 252 | TAG_RE = /\A[\w][\w\-]+[\w]\z/ 253 | private_constant :TAG_RE 254 | 255 | private def validate_tags(tags) 256 | tags.each do |tag| 257 | raise ArgumentError, "tags should be 255 characters or less" if tag.length > 255 258 | raise ArgumentError, "tag should match regex #{TAG_RE.inspect}" unless TAG_RE.match(tag) 259 | end 260 | end 261 | 262 | private def validate_unique_states(states) 263 | REQUIRED_UNIQUE_STATES.each do |required_state| 264 | raise ArgumentError, "by_state should include required state #{required_state}" unless states.include?(required_state) 265 | end 266 | states 267 | end 268 | end 269 | 270 | # A single job to insert that's part of an #insert_many batch insert. Unlike 271 | # sending raw job args, supports an InsertOpts to pair with the job. 272 | class InsertManyParams 273 | # Job args to insert. 274 | attr_reader :args 275 | 276 | # Insertion options to use with the insert. 277 | attr_reader :insert_opts 278 | 279 | def initialize(args, insert_opts: nil) 280 | @args = args 281 | @insert_opts = insert_opts 282 | end 283 | end 284 | 285 | # Result of a single insertion. 286 | class InsertResult 287 | # Inserted job row, or an existing job row if insert was skipped due to a 288 | # previously existing unique job. 289 | attr_reader :job 290 | 291 | # True if for a unique job, the insertion was skipped due to an equivalent 292 | # job matching unique property already being present. 293 | attr_reader :unique_skipped_as_duplicated 294 | 295 | def initialize(job, unique_skipped_as_duplicated:) 296 | @job = job 297 | @unique_skipped_as_duplicated = unique_skipped_as_duplicated 298 | end 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /lib/driver.rb: -------------------------------------------------------------------------------- 1 | module River 2 | # Contains an interface used by the top-level River module to interface with 3 | # its driver implementations. All types and methods in this module should be 4 | # considered to be for internal use only and subject to change. API stability 5 | # is not guaranteed. 6 | module Driver 7 | # Insert parameters for a job. This is sent to underlying drivers and is meant 8 | # for internal use only. Its interface is subject to change. 9 | class JobInsertParams 10 | attr_accessor :encoded_args 11 | attr_accessor :kind 12 | attr_accessor :max_attempts 13 | attr_accessor :priority 14 | attr_accessor :queue 15 | attr_accessor :scheduled_at 16 | attr_accessor :state 17 | attr_accessor :tags 18 | attr_accessor :unique_key 19 | attr_accessor :unique_states 20 | 21 | def initialize( 22 | encoded_args:, 23 | kind:, 24 | max_attempts:, 25 | priority:, 26 | queue:, 27 | scheduled_at:, 28 | state:, 29 | tags:, 30 | unique_key: nil, 31 | unique_states: nil 32 | ) 33 | self.encoded_args = encoded_args 34 | self.kind = kind 35 | self.max_attempts = max_attempts 36 | self.priority = priority 37 | self.queue = queue 38 | self.scheduled_at = scheduled_at 39 | self.state = state 40 | self.tags = tags 41 | self.unique_key = unique_key 42 | self.unique_states = unique_states 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/insert_opts.rb: -------------------------------------------------------------------------------- 1 | module River 2 | # Options for job insertion, and which can be provided by implementing 3 | # #insert_opts on job args, or specified as a parameter on #insert or 4 | # #insert_many. 5 | class InsertOpts 6 | # The maximum number of total attempts (including both the original run and 7 | # all retries) before a job is abandoned and set as discarded. 8 | attr_accessor :max_attempts 9 | 10 | # The priority of the job, with 1 being the highest priority and 4 being the 11 | # lowest. When fetching available jobs to work, the highest priority jobs 12 | # will always be fetched before any lower priority jobs are fetched. Note 13 | # that if your workers are swamped with more high-priority jobs then they 14 | # can handle, lower priority jobs may not be fetched. 15 | # 16 | # Defaults to PRIORITY_DEFAULT. 17 | attr_accessor :priority 18 | 19 | # The name of the job queue in which to insert the job. 20 | # 21 | # Defaults to QUEUE_DEFAULT. 22 | attr_accessor :queue 23 | 24 | # A time in future at which to schedule the job (i.e. in cases where it 25 | # shouldn't be run immediately). The job is guaranteed not to run before 26 | # this time, but may run slightly after depending on the number of other 27 | # scheduled jobs and how busy the queue is. 28 | # 29 | # Use of this option generally only makes sense when passing options into 30 | # Insert rather than when a job args is returning `#insert_opts`, however, 31 | # it will work in both cases. 32 | attr_accessor :scheduled_at 33 | 34 | # An arbitrary list of keywords to add to the job. They have no functional 35 | # behavior and are meant entirely as a user-specified construct to help 36 | # group and categorize jobs. 37 | # 38 | # If tags are specified from both a job args override and from options on 39 | # Insert, the latter takes precedence. Tags are not merged. 40 | attr_accessor :tags 41 | 42 | # Options relating to job uniqueness. No unique options means that the job 43 | # is never treated as unique. 44 | attr_accessor :unique_opts 45 | 46 | def initialize( 47 | max_attempts: nil, 48 | priority: nil, 49 | queue: nil, 50 | scheduled_at: nil, 51 | tags: nil, 52 | unique_opts: nil 53 | ) 54 | self.max_attempts = max_attempts 55 | self.priority = priority 56 | self.queue = queue 57 | self.scheduled_at = scheduled_at 58 | self.tags = tags 59 | self.unique_opts = unique_opts 60 | end 61 | end 62 | 63 | # Parameters for uniqueness for a job. 64 | # 65 | # If all properties are nil, no uniqueness at is enforced. As each property is 66 | # initialized, it's added as a dimension on the uniqueness matrix, and with 67 | # any property on, the job's kind always counts toward uniqueness. 68 | # 69 | # So for example, if only #by_queue is on, then for the given job kind, only a 70 | # single instance is allowed in any given queue, regardless of other 71 | # properties on the job. If both #by_args and #by_queue are on, then for the 72 | # given job kind, a single instance is allowed for each combination of args 73 | # and queues. If either args or queue is changed on a new job, it's allowed to 74 | # be inserted as a new job. 75 | class UniqueOpts 76 | # Indicates that uniqueness should be enforced for any specific instance of 77 | # encoded args for a job. 78 | # 79 | # Default is false, meaning that as long as any other unique property is 80 | # enabled, uniqueness will be enforced for a kind regardless of input args. 81 | attr_accessor :by_args 82 | 83 | # Defines uniqueness within a given period. On an insert time is rounded 84 | # down to the nearest multiple of the given period, and a job is only 85 | # inserted if there isn't an existing job that will run between then and the 86 | # next multiple of the period. 87 | # 88 | # The period should be specified in seconds. So a job that's unique every 15 89 | # minute period would have a value of 900. 90 | # 91 | # Default is no unique period, meaning that as long as any other unique 92 | # property is enabled, uniqueness will be enforced across all jobs of the 93 | # kind in the database, regardless of when they were scheduled. 94 | attr_accessor :by_period 95 | 96 | # Indicates that uniqueness should be enforced within each queue. 97 | # 98 | # Default is false, meaning that as long as any other unique property is 99 | # enabled, uniqueness will be enforced for a kind across all queues. 100 | attr_accessor :by_queue 101 | 102 | # Indicates that uniqueness should be enforced across any of the states in 103 | # the given set. For example, if the given states were `(scheduled, 104 | # running)` then a new job could be inserted even if one of the same kind 105 | # was already being worked by the queue (new jobs are inserted as 106 | # `available`). 107 | # 108 | # Unlike other unique options, ByState gets a default when it's not set for 109 | # user convenience. The default is equivalent to: 110 | # 111 | # 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] 112 | # 113 | # With this setting, any jobs of the same kind that have been completed or 114 | # discarded, but not yet cleaned out by the system, won't count towards the 115 | # uniqueness of a new insert. 116 | # 117 | # The pending, scheduled, available, and running states are required when 118 | # customizing this list. 119 | attr_accessor :by_state 120 | 121 | # Indicates that the job kind should not be considered for uniqueness. This 122 | # is useful when you want to enforce uniqueness based on other properties 123 | # across multiple worker types. 124 | attr_accessor :exclude_kind 125 | 126 | def initialize( 127 | by_args: nil, 128 | by_period: nil, 129 | by_queue: nil, 130 | by_state: nil, 131 | exclude_kind: nil 132 | ) 133 | self.by_args = by_args 134 | self.by_period = by_period 135 | self.by_queue = by_queue 136 | self.by_state = by_state 137 | self.exclude_kind = exclude_kind 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/job.rb: -------------------------------------------------------------------------------- 1 | module River 2 | JOB_STATE_AVAILABLE = "available" 3 | JOB_STATE_CANCELLED = "cancelled" 4 | JOB_STATE_COMPLETED = "completed" 5 | JOB_STATE_DISCARDED = "discarded" 6 | JOB_STATE_PENDING = "pending" 7 | JOB_STATE_RETRYABLE = "retryable" 8 | JOB_STATE_RUNNING = "running" 9 | JOB_STATE_SCHEDULED = "scheduled" 10 | 11 | # Provides a way of creating a job args from a simple Ruby hash for a quick 12 | # way to insert a job without having to define a class. The first argument is 13 | # a "kind" string for identifying the job in the database and the second is a 14 | # hash that will be encoded to JSON. 15 | # 16 | # For example: 17 | # 18 | # insert_res = client.insert(River::JobArgsHash.new("job_kind", { 19 | # job_num: 1 20 | # })) 21 | class JobArgsHash 22 | def initialize(kind, hash) 23 | raise "kind should be non-nil" if !kind 24 | raise "hash should be non-nil" if !hash 25 | 26 | @kind = kind 27 | @hash = hash 28 | end 29 | 30 | attr_reader :kind 31 | 32 | def to_json 33 | JSON.dump(@hash) 34 | end 35 | end 36 | 37 | # JobRow contains the properties of a job that are persisted to the database. 38 | class JobRow 39 | # ID of the job. Generated as part of a Postgres sequence and generally 40 | # ascending in nature, but there may be gaps in it as transactions roll 41 | # back. 42 | attr_accessor :id 43 | 44 | # The job's args as a hash decoded from JSON. 45 | attr_accessor :args 46 | 47 | # The attempt number of the job. Jobs are inserted at 0, the number is 48 | # incremented to 1 the first time work its worked, and may increment further 49 | # if it's either snoozed or errors. 50 | attr_accessor :attempt 51 | 52 | # The time that the job was last worked. Starts out as `nil` on a new 53 | # insert. 54 | attr_accessor :attempted_at 55 | 56 | # The set of worker IDs that have worked this job. A worker ID differs 57 | # between different programs, but is shared by all executors within any 58 | # given one. (i.e. Different Go processes have different IDs, but IDs are 59 | # shared within any given process.) A process generates a new ID based on 60 | # host and current time when it starts up. 61 | attr_accessor :attempted_by 62 | 63 | # When the job record was created. 64 | attr_accessor :created_at 65 | 66 | # A set of errors that occurred when the job was worked, one for each 67 | # attempt. Ordered from earliest error to the latest error. 68 | attr_accessor :errors 69 | 70 | # The time at which the job was "finalized", meaning it was either completed 71 | # successfully or errored for the last time such that it'll no longer be 72 | # retried. 73 | attr_accessor :finalized_at 74 | 75 | # Kind uniquely identifies the type of job and instructs which worker 76 | # should work it. It is set at insertion time via `#kind` on job args. 77 | attr_accessor :kind 78 | 79 | # The maximum number of attempts that the job will be tried before it errors 80 | # for the last time and will no longer be worked. 81 | attr_accessor :max_attempts 82 | 83 | # Arbitrary metadata associated with the job. 84 | attr_accessor :metadata 85 | 86 | # The priority of the job, with 1 being the highest priority and 4 being the 87 | # lowest. When fetching available jobs to work, the highest priority jobs 88 | # will always be fetched before any lower priority jobs are fetched. Note 89 | # that if your workers are swamped with more high-priority jobs then they 90 | # can handle, lower priority jobs may not be fetched. 91 | attr_accessor :priority 92 | 93 | # The name of the queue where the job will be worked. Queues can be 94 | # configured independently and be used to isolate jobs. 95 | attr_accessor :queue 96 | 97 | # When the job is scheduled to become available to be worked. Jobs default 98 | # to running immediately, but may be scheduled for the future when they're 99 | # inserted. They may also be scheduled for later because they were snoozed 100 | # or because they errored and have additional retry attempts remaining. 101 | attr_accessor :scheduled_at 102 | 103 | # The state of job like `available` or `completed`. Jobs are `available` 104 | # when they're first inserted. 105 | attr_accessor :state 106 | 107 | # Tags are an arbitrary list of keywords to add to the job. They have no 108 | # functional behavior and are meant entirely as a user-specified construct 109 | # to help group and categorize jobs. 110 | attr_accessor :tags 111 | 112 | # A unique key for the job within its kind that's used for unique job 113 | # insertions. It's generated by hashing an inserted job's unique opts 114 | # configuration. 115 | attr_accessor :unique_key 116 | 117 | # A list of states that the job must be in to be considered for uniqueness. 118 | attr_accessor :unique_states 119 | 120 | def initialize( 121 | id:, 122 | args:, 123 | attempt:, 124 | created_at:, 125 | kind:, 126 | max_attempts:, 127 | metadata:, 128 | priority:, 129 | queue:, 130 | scheduled_at:, 131 | state:, 132 | 133 | # nullable/optional 134 | attempted_at: nil, 135 | attempted_by: nil, 136 | errors: nil, 137 | finalized_at: nil, 138 | tags: nil, 139 | unique_key: nil, 140 | unique_states: nil 141 | ) 142 | self.id = id 143 | self.args = args 144 | self.attempt = attempt 145 | self.attempted_at = attempted_at 146 | self.attempted_by = attempted_by 147 | self.created_at = created_at 148 | self.errors = errors 149 | self.finalized_at = finalized_at 150 | self.kind = kind 151 | self.max_attempts = max_attempts 152 | self.metadata = metadata 153 | self.priority = priority 154 | self.queue = queue 155 | self.scheduled_at = scheduled_at 156 | self.state = state 157 | self.tags = tags 158 | self.unique_key = unique_key 159 | self.unique_states = unique_states 160 | end 161 | end 162 | 163 | # A failed job work attempt containing information about the error or panic 164 | # that occurred. 165 | class AttemptError 166 | # The time at which the error occurred. 167 | attr_accessor :at 168 | 169 | # The attempt number on which the error occurred (maps to #attempt on a job 170 | # row). 171 | attr_accessor :attempt 172 | 173 | # Contains the stringified error of an error returned from a job or a panic 174 | # value in case of a panic. 175 | attr_accessor :error 176 | 177 | # Contains a stack trace from a job that panicked. The trace is produced by 178 | # invoking `debug.Trace()` in Go. 179 | attr_accessor :trace 180 | 181 | def initialize( 182 | at:, 183 | attempt:, 184 | error:, 185 | trace: 186 | ) 187 | self.at = at 188 | self.attempt = attempt 189 | self.error = error 190 | self.trace = trace 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /lib/riverqueue.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | require_relative "insert_opts" 4 | require_relative "job" 5 | 6 | require_relative "client" 7 | require_relative "driver" 8 | require_relative "unique_bitmask" 9 | 10 | module River 11 | end 12 | -------------------------------------------------------------------------------- /lib/unique_bitmask.rb: -------------------------------------------------------------------------------- 1 | module River 2 | class UniqueBitmask 3 | JOB_STATE_BIT_POSITIONS = { 4 | ::River::JOB_STATE_AVAILABLE => 7, 5 | ::River::JOB_STATE_CANCELLED => 6, 6 | ::River::JOB_STATE_COMPLETED => 5, 7 | ::River::JOB_STATE_DISCARDED => 4, 8 | ::River::JOB_STATE_PENDING => 3, 9 | ::River::JOB_STATE_RETRYABLE => 2, 10 | ::River::JOB_STATE_RUNNING => 1, 11 | ::River::JOB_STATE_SCHEDULED => 0 12 | }.freeze 13 | private_constant :JOB_STATE_BIT_POSITIONS 14 | 15 | def self.from_states(states) 16 | val = 0 17 | 18 | states.each do |state| 19 | bit_index = JOB_STATE_BIT_POSITIONS[state] 20 | 21 | bit_position = 7 - (bit_index % 8) 22 | val |= 1 << bit_position 23 | end 24 | 25 | format("%08b", val) 26 | end 27 | 28 | def self.to_states(mask) 29 | states = [] #: Array[jobStateAll] # rubocop:disable Layout/LeadingCommentSpace 30 | 31 | JOB_STATE_BIT_POSITIONS.each do |state, bit_index| 32 | bit_position = 7 - (bit_index % 8) 33 | if (mask & (1 << bit_position)) != 0 34 | states << state 35 | end 36 | end 37 | 38 | states.sort 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /riverqueue.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "riverqueue" 3 | s.version = "0.9.0" 4 | s.summary = "River is a fast job queue for Go." 5 | 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." 6 | s.authors = ["Blake Gentry", "Brandur Leach"] 7 | s.email = "brandur@brandur.org" 8 | s.files = Dir.glob("lib/**/*") 9 | s.homepage = "https://riverqueue.com" 10 | s.license = "LGPL-3.0-or-later" 11 | s.require_path = %(lib) 12 | s.metadata = { 13 | "bug_tracker_uri" => "https://github.com/riverqueue/riverqueue-ruby/issues", 14 | "changelog_uri" => "https://github.com/riverqueue/riverqueue-ruby/blob/master/CHANGELOG.md", 15 | "rubygems_mfa_required" => "true", 16 | "source_code_uri" => "https://github.com/riverqueue/riverqueue-ruby" 17 | } 18 | end 19 | -------------------------------------------------------------------------------- /scripts/update_gemspec_version.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Updates the version in a gemspec file since doing it from the shell is a total 3 | # pain. 4 | # 5 | 6 | file = ARGV[0] || abort("failure: need one argument, which is a gemspec filename") 7 | version = ENV["VERSION"] || abort("failure: need VERSION") 8 | 9 | file_data = File.read(file) 10 | 11 | version = version[1..] # strip `v` from the beginning of the string 12 | 13 | updated_file_data = file_data.gsub(%r{^(\W+)s\.version = "[\d\.]+"$}, %(\\1s.version = "#{version}")) 14 | 15 | abort("failure: nothing changed in file") if file_data == updated_file_data 16 | 17 | File.write(file, updated_file_data) 18 | -------------------------------------------------------------------------------- /sig/client.rbs: -------------------------------------------------------------------------------- 1 | module River 2 | MAX_ATTEMPTS_DEFAULT: Integer 3 | PRIORITY_DEFAULT: Integer 4 | QUEUE_DEFAULT: String 5 | 6 | class Client 7 | @driver: _Driver 8 | @time_now_utc: ^() -> Time 9 | 10 | def initialize: (_Driver driver) -> void 11 | def insert: (jobArgs, ?insert_opts: InsertOpts) -> InsertResult 12 | def insert_many: (Array[jobArgs | InsertManyParams]) -> Array[InsertResult] 13 | 14 | DEFAULT_UNIQUE_STATES: Array[jobStateAll] 15 | EMPTY_INSERT_OPTS: InsertOpts 16 | REQUIRED_UNIQUE_STATES: Array[jobStateAll] 17 | 18 | private def insert_and_check_unique_job: (Driver::JobInsertParams) -> InsertResult 19 | private def make_insert_params: (jobArgs, InsertOpts) -> Driver::JobInsertParams 20 | private def make_unique_key_and_bitmask: (Driver::JobInsertParams, UniqueOpts) -> [String, String] 21 | private def truncate_time: (Time, Integer) -> Time 22 | private def uint64_to_int64: (Integer) -> Integer 23 | 24 | TAG_RE: Regexp 25 | 26 | private def validate_tags: (Array[String]) -> Array[String] 27 | private def validate_unique_states: (Array[jobStateAll]) -> Array[jobStateAll] 28 | end 29 | 30 | class InsertManyParams 31 | @args: jobArgs 32 | @insert_opts: InsertOpts? 33 | 34 | attr_reader args: jobArgs 35 | attr_reader insert_opts: InsertOpts? 36 | 37 | def initialize: (jobArgs job, ?insert_opts: InsertOpts?) -> void 38 | def is_a?: (Class) -> bool 39 | end 40 | 41 | class InsertResult 42 | @job: JobRow 43 | @unique_skipped_as_duplicated: bool 44 | 45 | attr_reader job: JobRow 46 | attr_reader unique_skipped_as_duplicated: bool 47 | 48 | def initialize: (JobRow job, unique_skipped_as_duplicated: bool) -> void 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /sig/driver.rbs: -------------------------------------------------------------------------------- 1 | module River 2 | interface _Driver 3 | def advisory_lock: (Integer) -> void 4 | def job_get_by_kind_and_unique_properties: (Driver::JobGetByKindAndUniquePropertiesParam) -> JobRow? 5 | def job_insert: (Driver::JobInsertParams) -> [JobRow, bool] 6 | def job_insert_many: (Array[Driver::JobInsertParams]) -> Array[[JobRow, bool]] 7 | def transaction: [T] () { () -> T } -> T 8 | 9 | # this set of methods is used only in tests 10 | def advisory_lock_try: (Integer) -> bool 11 | def job_get_by_id: (Integer) -> JobRow? 12 | def job_list: -> Array[JobRow] 13 | def rollback_exception: -> Exception 14 | end 15 | 16 | module Driver 17 | class JobGetByKindAndUniquePropertiesParam 18 | attr_accessor created_at: [Time, Time]? 19 | attr_accessor encoded_args: String? 20 | attr_accessor kind: String 21 | attr_accessor queue: String? 22 | attr_accessor state: Array[jobStateAll]? 23 | 24 | def initialize: (kind: String, ?created_at: [Time, Time]?, ?encoded_args: String?, ?queue: String?, ?state: Array[jobStateAll]?) -> void 25 | end 26 | 27 | class JobInsertParams 28 | attr_accessor encoded_args: String 29 | attr_accessor kind: String 30 | attr_accessor max_attempts: Integer 31 | attr_accessor priority: Integer 32 | attr_accessor queue: String 33 | attr_accessor scheduled_at: Time? 34 | attr_accessor state: jobStateAll 35 | attr_accessor tags: Array[String]? 36 | attr_accessor unique_key: String? 37 | attr_accessor unique_states: String? 38 | 39 | 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 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /sig/insert_opts.rbs: -------------------------------------------------------------------------------- 1 | module River 2 | class InsertOpts 3 | attr_accessor max_attempts: Integer? 4 | attr_accessor priority: Integer? 5 | attr_accessor queue: String? 6 | attr_accessor scheduled_at: Time? 7 | attr_accessor tags: Array[String]? 8 | attr_accessor unique_opts: UniqueOpts? 9 | 10 | def initialize: (?max_attempts: Integer?, ?priority: Integer?, ?queue: String?, ?scheduled_at: Time?, ?tags: Array[String]?, ?unique_opts: UniqueOpts?) -> void 11 | end 12 | 13 | class UniqueOpts 14 | attr_accessor by_args: bool? | Array[String]? 15 | attr_accessor by_period: Integer? 16 | attr_accessor by_queue: bool? 17 | attr_accessor by_state: Array[jobStateAll]? 18 | attr_accessor exclude_kind: bool? 19 | 20 | def initialize: (?by_args: bool? | Array[String]?, ?by_period: Integer?, ?by_queue: bool?, ?by_state: Array[jobStateAll]?, ?exclude_kind: bool?) -> void 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /sig/job.rbs: -------------------------------------------------------------------------------- 1 | module River 2 | JOB_STATE_AVAILABLE: "available" 3 | JOB_STATE_CANCELLED: "cancelled" 4 | JOB_STATE_COMPLETED: "completed" 5 | JOB_STATE_DISCARDED: "discarded" 6 | JOB_STATE_PENDING: "pending" 7 | JOB_STATE_RETRYABLE: "retryable" 8 | JOB_STATE_RUNNING: "running" 9 | JOB_STATE_SCHEDULED: "scheduled" 10 | 11 | type jobStateAll = "available" | "cancelled" | "completed" | "discarded" | "pending" | "retryable" | "running" | "scheduled" 12 | 13 | interface _JobArgs 14 | def is_a?: (Class) -> bool 15 | def kind: () -> String 16 | def respond_to?: (Symbol) -> bool 17 | def to_json: () -> String 18 | end 19 | 20 | interface _JobArgsWithInsertOpts 21 | include _JobArgs 22 | 23 | def insert_opts: () -> InsertOpts? 24 | end 25 | 26 | type jobArgs = _JobArgs | _JobArgsWithInsertOpts 27 | 28 | class JobArgsHash 29 | @kind: String 30 | @hash: Hash[String | Symbol, untyped] 31 | 32 | attr_reader kind: String 33 | 34 | def initialize: (String kind, Hash[String | Symbol, untyped] hash) -> void 35 | def to_json: () -> String 36 | end 37 | 38 | class JobRow 39 | attr_accessor id: Integer 40 | attr_accessor args: Hash[String, untyped] 41 | attr_accessor attempt: Integer 42 | attr_accessor attempted_at: Time? 43 | attr_accessor attempted_by: String? 44 | attr_accessor created_at: Time 45 | attr_accessor errors: Array[AttemptError]? 46 | attr_accessor finalized_at: Time? 47 | attr_accessor kind: String 48 | attr_accessor max_attempts: Integer 49 | attr_accessor metadata: Hash[String, untyped] 50 | attr_accessor priority: Integer 51 | attr_accessor queue: String 52 | attr_accessor scheduled_at: Time 53 | attr_accessor state: jobStateAll 54 | attr_accessor tags: Array[String]? 55 | attr_accessor unique_key: String? 56 | attr_accessor unique_states: Array[jobStateAll]? 57 | 58 | def initialize: (id: Integer, args: Hash[String, untyped], attempt: Integer, ?attempted_at: Time?, ?attempted_by: String?, created_at: Time, ?errors: Array[AttemptError]?, ?finalized_at: Time?, kind: String, max_attempts: Integer, metadata: Hash[String, untyped], priority: Integer, queue: String, scheduled_at: Time, state: jobStateAll, ?tags: Array[String]?, ?unique_key: String?, ?unique_states: Array[jobStateAll]?) -> void 59 | end 60 | 61 | class AttemptError 62 | attr_accessor at: Time 63 | attr_accessor attempt: Integer 64 | attr_accessor error: String 65 | attr_accessor trace: String 66 | 67 | def initialize: (at: Time, attempt: Integer, error: String, trace: String) -> void 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /sig/riverqueue.rbs: -------------------------------------------------------------------------------- 1 | module River 2 | end 3 | -------------------------------------------------------------------------------- /sig/unique_bitmask.rbs: -------------------------------------------------------------------------------- 1 | module River 2 | class UniqueBitmask 3 | JOB_STATE_BIT_POSITIONS: Hash[jobStateAll, Integer] 4 | 5 | def self.from_states: (Array[jobStateAll]) -> String 6 | 7 | def self.to_states: (Integer) -> Array[jobStateAll] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require_relative "../driver/riverqueue-sequel/spec/spec_helper" 3 | 4 | class SimpleArgs 5 | attr_accessor :job_num 6 | 7 | def initialize(job_num:) 8 | self.job_num = job_num 9 | end 10 | 11 | def kind = "simple" 12 | 13 | def to_json = JSON.dump({job_num: job_num}) 14 | end 15 | 16 | # Lets us test job-specific insertion opts by making `#insert_opts` an accessor. 17 | # Real args that make use of this functionality will probably want to make 18 | # `#insert_opts` a non-accessor method instead. 19 | class SimpleArgsWithInsertOpts < SimpleArgs 20 | attr_accessor :insert_opts 21 | end 22 | 23 | class ComplexArgs 24 | attr_accessor :customer_id 25 | attr_accessor :order_id 26 | attr_accessor :trace_id 27 | attr_accessor :email 28 | 29 | def initialize(customer_id:, order_id:, trace_id:, email:) 30 | self.customer_id = customer_id 31 | self.order_id = order_id 32 | self.trace_id = trace_id 33 | self.email = email 34 | end 35 | 36 | def kind = "complex" 37 | 38 | # intentionally not sorted alphabetically so we can ensure that the JSON 39 | # used in the unique key is sorted. 40 | def to_json = JSON.dump({order_id: order_id, customer_id: customer_id, trace_id: trace_id, email: email}) 41 | end 42 | 43 | # I originally had this top-level client test set up so that it was using a mock 44 | # driver, but it just turned out to be too horribly unsustainable. Adding 45 | # anything new required careful mock engineering, and even once done, we weren't 46 | # getting good guarantees that the right things were happening because it wasn't 47 | # end to end. We now use the real Sequel driver, with the only question being 48 | # whether we should maybe move all these tests into the common driver shared 49 | # examples so that all drivers get the full barrage. 50 | RSpec.describe River::Client do 51 | around(:each) { |ex| test_transaction(&ex) } 52 | 53 | let!(:driver) { River::Driver::Sequel.new(DB) } 54 | let(:client) { River::Client.new(driver) } 55 | 56 | describe "#insert" do 57 | it "inserts a job with defaults" do 58 | insert_res = client.insert(SimpleArgs.new(job_num: 1)) 59 | expect(insert_res.job).to have_attributes( 60 | args: {"job_num" => 1}, 61 | attempt: 0, 62 | created_at: be_within(2).of(Time.now), 63 | kind: "simple", 64 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 65 | priority: River::PRIORITY_DEFAULT, 66 | queue: River::QUEUE_DEFAULT, 67 | scheduled_at: be_within(2).of(Time.now), 68 | state: River::JOB_STATE_AVAILABLE, 69 | tags: [] 70 | ) 71 | end 72 | 73 | it "schedules a job" do 74 | target_time = Time.now + 1 * 3600 75 | 76 | insert_res = client.insert( 77 | SimpleArgs.new(job_num: 1), 78 | insert_opts: River::InsertOpts.new(scheduled_at: target_time) 79 | ) 80 | expect(insert_res.job).to have_attributes( 81 | scheduled_at: be_within(2).of(target_time), 82 | state: River::JOB_STATE_SCHEDULED 83 | ) 84 | 85 | # Expect all inserted timestamps to go to UTC. 86 | expect(insert_res.job.scheduled_at.utc?).to be true 87 | end 88 | 89 | it "inserts with job insert opts" do 90 | job_args = SimpleArgsWithInsertOpts.new(job_num: 1) 91 | job_args.insert_opts = River::InsertOpts.new( 92 | max_attempts: 23, 93 | priority: 2, 94 | queue: "job_custom_queue", 95 | tags: ["job_custom"] 96 | ) 97 | 98 | insert_res = client.insert(job_args) 99 | expect(insert_res.job).to have_attributes( 100 | max_attempts: 23, 101 | priority: 2, 102 | queue: "job_custom_queue", 103 | tags: ["job_custom"] 104 | ) 105 | end 106 | 107 | it "inserts with insert opts" do 108 | # We set job insert opts in this spec too so that we can verify that the 109 | # options passed at insertion time take precedence. 110 | job_args = SimpleArgsWithInsertOpts.new(job_num: 1) 111 | job_args.insert_opts = River::InsertOpts.new( 112 | max_attempts: 23, 113 | priority: 2, 114 | queue: "job_custom_queue", 115 | tags: ["job_custom"] 116 | ) 117 | 118 | insert_res = client.insert(job_args, insert_opts: River::InsertOpts.new( 119 | max_attempts: 17, 120 | priority: 3, 121 | queue: "my_queue", 122 | tags: ["custom"] 123 | )) 124 | expect(insert_res.job).to have_attributes( 125 | max_attempts: 17, 126 | priority: 3, 127 | queue: "my_queue", 128 | tags: ["custom"] 129 | ) 130 | end 131 | 132 | it "inserts with job args hash" do 133 | insert_res = client.insert(River::JobArgsHash.new("hash_kind", { 134 | job_num: 1 135 | })) 136 | expect(insert_res.job).to have_attributes( 137 | args: {"job_num" => 1}, 138 | kind: "hash_kind" 139 | ) 140 | end 141 | 142 | it "errors if args don't respond to #kind" do 143 | args_klass = Class.new do 144 | def to_json = {} 145 | end 146 | 147 | expect do 148 | client.insert(args_klass.new) 149 | end.to raise_error(RuntimeError, "args should respond to `#kind`") 150 | end 151 | 152 | it "errors if args return nil from #to_json" do 153 | args_klass = Class.new do 154 | def kind = "args_kind" 155 | 156 | def to_json = nil 157 | end 158 | 159 | expect do 160 | client.insert(args_klass.new) 161 | end.to raise_error(RuntimeError, "args should return non-nil from `#to_json`") 162 | end 163 | 164 | it "raises error if tags are too long" do 165 | expect do 166 | client.insert(SimpleArgs.new(job_num: 1), insert_opts: River::InsertOpts.new( 167 | tags: ["a" * 256] 168 | )) 169 | end.to raise_error(ArgumentError, "tags should be 255 characters or less") 170 | end 171 | 172 | it "raises error if tags are misformatted" do 173 | expect do 174 | client.insert(SimpleArgs.new(job_num: 1), insert_opts: River::InsertOpts.new( 175 | tags: ["no,commas,allowed"] 176 | )) 177 | end.to raise_error(ArgumentError, 'tag should match regex /\A[\w][\w\-]+[\w]\z/') 178 | end 179 | 180 | def check_bigint_bounds(int) 181 | raise "lock key shouldn't be larger than Postgres bigint max (9223372036854775807); was: #{int}" if int > 9223372036854775807 182 | raise "lock key shouldn't be smaller than Postgres bigint min (-9223372036854775808); was: #{int}" if int < -9223372036854775808 183 | int 184 | end 185 | 186 | # These unique insertion specs are pretty mock heavy, but each of the 187 | # individual drivers has their own unique insert tests that make sure to do 188 | # a full round trip. 189 | describe "unique opts" do 190 | let(:now) { Time.now.utc } 191 | before { client.instance_variable_set(:@time_now_utc, -> { now }) } 192 | 193 | it "inserts a new unique job with minimal options" do 194 | job_args = SimpleArgsWithInsertOpts.new(job_num: 1) 195 | job_args.insert_opts = River::InsertOpts.new( 196 | unique_opts: River::UniqueOpts.new( 197 | by_queue: true 198 | ) 199 | ) 200 | 201 | insert_res = client.insert(job_args) 202 | expect(insert_res.job).to_not be_nil 203 | expect(insert_res.unique_skipped_as_duplicated).to be false 204 | 205 | unique_key_str = "&kind=#{insert_res.job.kind}" \ 206 | "&queue=#{River::QUEUE_DEFAULT}" 207 | expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(unique_key_str)) 208 | 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]) 209 | 210 | insert_res = client.insert(job_args) 211 | expect(insert_res.job).to_not be_nil 212 | expect(insert_res.unique_skipped_as_duplicated).to be true 213 | end 214 | 215 | it "inserts a new unique job with custom states" do 216 | job_args = SimpleArgsWithInsertOpts.new(job_num: 1) 217 | job_args.insert_opts = River::InsertOpts.new( 218 | unique_opts: River::UniqueOpts.new( 219 | by_queue: true, 220 | by_state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] 221 | ) 222 | ) 223 | 224 | insert_res = client.insert(job_args) 225 | expect(insert_res.job).to_not be_nil 226 | expect(insert_res.unique_skipped_as_duplicated).to be false 227 | 228 | lock_str = "&kind=#{job_args.kind}" \ 229 | "&queue=#{River::QUEUE_DEFAULT}" 230 | 231 | expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(lock_str)) 232 | expect(insert_res.job.unique_states).to eq([River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED]) 233 | 234 | insert_res = client.insert(job_args) 235 | expect(insert_res.job).to_not be_nil 236 | expect(insert_res.unique_skipped_as_duplicated).to be true 237 | end 238 | 239 | it "inserts a new unique job with all options" do 240 | job_args = ComplexArgs.new(customer_id: 1, order_id: 2, trace_id: 3, email: "john@example.com") 241 | insert_opts = River::InsertOpts.new( 242 | unique_opts: River::UniqueOpts.new( 243 | by_args: true, 244 | by_period: 15 * 60, 245 | by_queue: true, 246 | by_state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_CANCELLED, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED], 247 | exclude_kind: true 248 | ) 249 | ) 250 | 251 | insert_res = client.insert(job_args, insert_opts: insert_opts) 252 | expect(insert_res.job).to_not be_nil 253 | expect(insert_res.unique_skipped_as_duplicated).to be false 254 | 255 | sorted_json = {customer_id: 1, email: "john@example.com", order_id: 2, trace_id: 3} 256 | unique_key_str = "&args=#{JSON.dump(sorted_json)}" \ 257 | "&period=#{client.send(:truncate_time, now, 15 * 60).utc.strftime("%FT%TZ")}" \ 258 | "&queue=#{River::QUEUE_DEFAULT}" 259 | expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(unique_key_str)) 260 | 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]) 261 | 262 | insert_res = client.insert(job_args, insert_opts: insert_opts) 263 | expect(insert_res.job).to_not be_nil 264 | expect(insert_res.unique_skipped_as_duplicated).to be true 265 | end 266 | 267 | it "inserts a new unique job with custom by_args" do 268 | job_args = ComplexArgs.new(customer_id: 1, order_id: 2, trace_id: 3, email: "john@example.com") 269 | insert_opts = River::InsertOpts.new( 270 | unique_opts: River::UniqueOpts.new(by_args: ["customer_id", "order_id"]) 271 | ) 272 | 273 | insert_res = client.insert(job_args, insert_opts: insert_opts) 274 | expect(insert_res.job).to_not be_nil 275 | expect(insert_res.unique_skipped_as_duplicated).to be false 276 | original_job_id = insert_res.job.id 277 | 278 | unique_key_str = "&kind=complex&args=#{JSON.dump({customer_id: 1, order_id: 2})}" 279 | expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(unique_key_str)) 280 | 281 | insert_res = client.insert(job_args, insert_opts: insert_opts) 282 | expect(insert_res.job).to_not be_nil 283 | expect(insert_res.job.id).to eq(original_job_id) 284 | expect(insert_res.unique_skipped_as_duplicated).to be true 285 | 286 | # Change just the customer ID and the job should be unique again. 287 | job_args.customer_id = 2 288 | insert_res = client.insert(job_args, insert_opts: insert_opts) 289 | expect(insert_res.job).to_not be_nil 290 | expect(insert_res.job.id).to_not eq(original_job_id) 291 | expect(insert_res.unique_skipped_as_duplicated).to be false 292 | end 293 | 294 | it "inserts a new unique job with period determined from `scheduled_at`" do 295 | job_args = ComplexArgs.new(customer_id: 1, order_id: 2, trace_id: 3, email: "john@example.com") 296 | insert_opts = River::InsertOpts.new( 297 | scheduled_at: now + 3600, 298 | unique_opts: River::UniqueOpts.new( 299 | by_period: 15 * 60 300 | ) 301 | ) 302 | 303 | insert_res = client.insert(job_args, insert_opts: insert_opts) 304 | expect(insert_res.job).to_not be_nil 305 | expect(insert_res.unique_skipped_as_duplicated).to be false 306 | 307 | unique_key_str = "&kind=#{insert_res.job.kind}" \ 308 | "&period=#{client.send(:truncate_time, now + 3600, 15 * 60).utc.strftime("%FT%TZ")}" 309 | expect(insert_res.job.unique_key).to eq(Digest::SHA256.digest(unique_key_str)) 310 | end 311 | 312 | it "skips unique check if unique opts empty" do 313 | job_args = SimpleArgsWithInsertOpts.new(job_num: 1) 314 | job_args.insert_opts = River::InsertOpts.new( 315 | unique_opts: River::UniqueOpts.new 316 | ) 317 | 318 | insert_res = client.insert(job_args) 319 | expect(insert_res.job).to_not be_nil 320 | expect(insert_res.unique_skipped_as_duplicated).to be false 321 | end 322 | 323 | it "errors if any of the required unique states are removed from a custom by_states list" do 324 | default_states = [River::JOB_STATE_AVAILABLE, River::JOB_STATE_COMPLETED, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_RETRYABLE, River::JOB_STATE_SCHEDULED] 325 | required_states = [River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] 326 | required_states.each do |state| 327 | job_args = SimpleArgsWithInsertOpts.new(job_num: 1) 328 | job_args.insert_opts = River::InsertOpts.new( 329 | unique_opts: River::UniqueOpts.new( 330 | by_state: default_states - [state] 331 | ) 332 | ) 333 | 334 | expect do 335 | client.insert(job_args) 336 | end.to raise_error(ArgumentError, "by_state should include required state #{state}") 337 | end 338 | end 339 | end 340 | end 341 | 342 | describe "#insert_many" do 343 | it "inserts jobs from jobArgs with defaults" do 344 | results = client.insert_many([ 345 | SimpleArgs.new(job_num: 1), 346 | SimpleArgs.new(job_num: 2) 347 | ]) 348 | expect(results.length).to eq(2) 349 | expect(results[0].job).to have_attributes(args: {"job_num" => 1}) 350 | expect(results[1].job).to have_attributes(args: {"job_num" => 2}) 351 | 352 | jobs = driver.job_list 353 | expect(jobs.count).to be 2 354 | 355 | expect(jobs[0]).to have_attributes( 356 | args: {"job_num" => 1}, 357 | attempt: 0, 358 | created_at: be_within(2).of(Time.now), 359 | kind: "simple", 360 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 361 | priority: River::PRIORITY_DEFAULT, 362 | queue: River::QUEUE_DEFAULT, 363 | scheduled_at: be_within(2).of(Time.now), 364 | state: River::JOB_STATE_AVAILABLE, 365 | tags: [] 366 | ) 367 | 368 | expect(jobs[1]).to have_attributes( 369 | args: {"job_num" => 2}, 370 | attempt: 0, 371 | created_at: be_within(2).of(Time.now), 372 | kind: "simple", 373 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 374 | priority: River::PRIORITY_DEFAULT, 375 | queue: River::QUEUE_DEFAULT, 376 | scheduled_at: be_within(2).of(Time.now), 377 | state: River::JOB_STATE_AVAILABLE, 378 | tags: [] 379 | ) 380 | end 381 | 382 | it "inserts jobs from InsertManyParams with defaults" do 383 | results = client.insert_many([ 384 | River::InsertManyParams.new(SimpleArgs.new(job_num: 1)), 385 | River::InsertManyParams.new(SimpleArgs.new(job_num: 2)) 386 | ]) 387 | expect(results.length).to eq(2) 388 | expect(results[0].job).to have_attributes(args: {"job_num" => 1}) 389 | expect(results[1].job).to have_attributes(args: {"job_num" => 2}) 390 | 391 | jobs = driver.job_list 392 | expect(jobs.count).to be 2 393 | 394 | expect(jobs[0]).to have_attributes( 395 | args: {"job_num" => 1}, 396 | attempt: 0, 397 | created_at: be_within(2).of(Time.now), 398 | kind: "simple", 399 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 400 | priority: River::PRIORITY_DEFAULT, 401 | queue: River::QUEUE_DEFAULT, 402 | scheduled_at: be_within(2).of(Time.now), 403 | state: River::JOB_STATE_AVAILABLE, 404 | tags: [] 405 | ) 406 | 407 | expect(jobs[1]).to have_attributes( 408 | args: {"job_num" => 2}, 409 | attempt: 0, 410 | created_at: be_within(2).of(Time.now), 411 | kind: "simple", 412 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 413 | priority: River::PRIORITY_DEFAULT, 414 | queue: River::QUEUE_DEFAULT, 415 | scheduled_at: be_within(2).of(Time.now), 416 | state: River::JOB_STATE_AVAILABLE, 417 | tags: [] 418 | ) 419 | end 420 | 421 | it "inserts jobs with insert opts" do 422 | # First, insert a job which will cause a duplicate conflict with the bulk 423 | # insert so the bulk insert's row gets skipped. 424 | dupe_job_args = SimpleArgsWithInsertOpts.new(job_num: 0) 425 | dupe_job_args.insert_opts = River::InsertOpts.new( 426 | queue: "job_to_duplicate", 427 | unique_opts: River::UniqueOpts.new( 428 | by_queue: true 429 | ) 430 | ) 431 | 432 | insert_res = client.insert(dupe_job_args) 433 | expect(insert_res.job).to_not be_nil 434 | 435 | # We set job insert opts in this spec too so that we can verify that the 436 | # options passed at insertion time take precedence. 437 | args1 = SimpleArgsWithInsertOpts.new(job_num: 1) 438 | args1.insert_opts = River::InsertOpts.new( 439 | max_attempts: 23, 440 | priority: 2, 441 | queue: "job_custom_queue_1", 442 | tags: ["job_custom_1"] 443 | ) 444 | args2 = SimpleArgsWithInsertOpts.new(job_num: 2) 445 | args2.insert_opts = River::InsertOpts.new( 446 | max_attempts: 24, 447 | priority: 3, 448 | queue: "job_custom_queue_2", 449 | tags: ["job_custom_2"] 450 | ) 451 | args3 = SimpleArgsWithInsertOpts.new(job_num: 3) 452 | args3.insert_opts = River::InsertOpts.new( 453 | queue: "to_duplicate", # duplicate by queue, will be skipped 454 | tags: ["job_custom_3"], 455 | unique_opts: River::UniqueOpts.new( 456 | by_queue: true 457 | ) 458 | ) 459 | 460 | results = client.insert_many([ 461 | River::InsertManyParams.new(args1, insert_opts: River::InsertOpts.new( 462 | max_attempts: 17, 463 | priority: 3, 464 | queue: "my_queue_1", 465 | tags: ["custom_1"] 466 | )), 467 | River::InsertManyParams.new(args2, insert_opts: River::InsertOpts.new( 468 | max_attempts: 18, 469 | priority: 4, 470 | queue: "my_queue_2", 471 | tags: ["custom_2"] 472 | )), 473 | River::InsertManyParams.new(args3, insert_opts: River::InsertOpts.new( 474 | queue: "job_to_duplicate", # duplicate by queue, will be skipped 475 | tags: ["custom_3"], 476 | unique_opts: River::UniqueOpts.new( 477 | by_queue: true 478 | ) 479 | )) 480 | ]) 481 | expect(results.length).to eq(3) # all rows returned, including skipped duplicates 482 | expect(results[0].job).to have_attributes(tags: ["custom_1"]) 483 | expect(results[1].job).to have_attributes(tags: ["custom_2"]) 484 | expect(results[2].unique_skipped_as_duplicated).to be true 485 | expect(results[2].job).to have_attributes( 486 | id: insert_res.job.id, 487 | tags: [] 488 | ) 489 | 490 | jobs = driver.job_list 491 | expect(jobs.count).to be 3 492 | 493 | expect(jobs[0]).to have_attributes(queue: "job_to_duplicate") 494 | 495 | expect(jobs[1]).to have_attributes( 496 | max_attempts: 17, 497 | priority: 3, 498 | queue: "my_queue_1", 499 | tags: ["custom_1"] 500 | ) 501 | 502 | expect(jobs[2]).to have_attributes( 503 | max_attempts: 18, 504 | priority: 4, 505 | queue: "my_queue_2", 506 | tags: ["custom_2"] 507 | ) 508 | end 509 | end 510 | 511 | describe River::Client.const_get(:DEFAULT_UNIQUE_STATES) do 512 | it "should be sorted" do 513 | expect(River::Client.const_get(:DEFAULT_UNIQUE_STATES)).to eq(River::Client.const_get(:DEFAULT_UNIQUE_STATES).sort) 514 | end 515 | end 516 | 517 | describe "#truncate_time" do 518 | it "truncates times to nearest interval" do 519 | expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 1 * 60).utc).to eq(Time.parse("Thu Jan 15 21:26:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing 520 | expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 5 * 60).utc).to eq(Time.parse("Thu Jan 15 21:25:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing 521 | expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 15 * 60).utc).to eq(Time.parse("Thu Jan 15 21:15:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing 522 | expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 1 * 60 * 60).utc).to eq(Time.parse("Thu Jan 15 21:00:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing 523 | expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 5 * 60 * 60).utc).to eq(Time.parse("Thu Jan 15 17:00:00 UTC 2024")) # rubocop:disable Layout/ExtraSpacing 524 | expect(client.send(:truncate_time, Time.parse("Thu Jan 15 21:26:36 UTC 2024").utc, 24 * 60 * 60).utc).to eq(Time.parse("Thu Jan 15 00:00:00 UTC 2024")) 525 | end 526 | end 527 | 528 | describe "#uint64_to_int64" do 529 | it "converts between integer types" do 530 | expect(client.send(:uint64_to_int64, 123456)).to eq(123456) 531 | expect(client.send(:uint64_to_int64, 13977996710702069744)).to eq(-4468747363007481872) 532 | end 533 | end 534 | end 535 | 536 | RSpec.describe River::InsertManyParams do 537 | it "initializes" do 538 | job_args = SimpleArgs.new(job_num: 1) 539 | 540 | params = River::InsertManyParams.new(job_args) 541 | expect(params.args).to eq(job_args) 542 | expect(params.insert_opts).to be_nil 543 | end 544 | 545 | it "initializes with insert opts" do 546 | job_args = SimpleArgs.new(job_num: 1) 547 | insert_opts = River::InsertOpts.new(queue: "other") 548 | 549 | params = River::InsertManyParams.new(job_args, insert_opts: insert_opts) 550 | expect(params.args).to eq(job_args) 551 | expect(params.insert_opts).to eq(insert_opts) 552 | end 553 | end 554 | -------------------------------------------------------------------------------- /spec/driver_shared_examples.rb: -------------------------------------------------------------------------------- 1 | class SimpleArgs 2 | attr_accessor :job_num 3 | 4 | def initialize(job_num:) 5 | self.job_num = job_num 6 | end 7 | 8 | def kind = "simple" 9 | 10 | def to_json = JSON.dump({job_num: job_num}) 11 | end 12 | 13 | # Lets us test job-specific insertion opts by making `#insert_opts` an accessor. 14 | # Real args that make use of this functionality will probably want to make 15 | # `#insert_opts` a non-accessor method instead. 16 | class SimpleArgsWithInsertOpts < SimpleArgs 17 | attr_accessor :insert_opts 18 | end 19 | 20 | shared_examples "driver shared examples" do 21 | describe "unique insertion" do 22 | it "inserts a unique job once" do 23 | args = SimpleArgsWithInsertOpts.new(job_num: 1) 24 | args.insert_opts = River::InsertOpts.new( 25 | unique_opts: River::UniqueOpts.new( 26 | by_queue: true 27 | ) 28 | ) 29 | 30 | insert_res = client.insert(args) 31 | expect(insert_res.job).to_not be_nil 32 | expect(insert_res.unique_skipped_as_duplicated).to be false 33 | original_job = insert_res.job 34 | 35 | insert_res = client.insert(args) 36 | expect(insert_res.job.id).to eq(original_job.id) 37 | expect(insert_res.unique_skipped_as_duplicated).to be true 38 | end 39 | 40 | it "inserts a unique job with custom states" do 41 | client = River::Client.new(driver) 42 | 43 | args = SimpleArgsWithInsertOpts.new(job_num: 1) 44 | args.insert_opts = River::InsertOpts.new( 45 | unique_opts: River::UniqueOpts.new( 46 | by_queue: true, 47 | by_state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED] 48 | ) 49 | ) 50 | 51 | insert_res = client.insert(args) 52 | expect(insert_res.job).to_not be_nil 53 | expect(insert_res.unique_skipped_as_duplicated).to be false 54 | original_job = insert_res.job 55 | 56 | insert_res = client.insert(args) 57 | expect(insert_res.job.id).to eq(original_job.id) 58 | expect(insert_res.unique_skipped_as_duplicated).to be true 59 | end 60 | end 61 | 62 | describe "#job_get_by_id" do 63 | let(:job_args) { SimpleArgs.new(job_num: 1) } 64 | 65 | it "gets a job by ID" do 66 | insert_res = client.insert(job_args) 67 | expect(driver.job_get_by_id(insert_res.job.id)).to_not be nil 68 | end 69 | 70 | it "returns nil on not found" do 71 | expect(driver.job_get_by_id(-1)).to be nil 72 | end 73 | end 74 | 75 | describe "#job_insert" do 76 | it "inserts a job" do 77 | insert_res = client.insert(SimpleArgs.new(job_num: 1)) 78 | expect(insert_res.job).to have_attributes( 79 | args: {"job_num" => 1}, 80 | attempt: 0, 81 | created_at: be_within(2).of(Time.now.getutc), 82 | kind: "simple", 83 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 84 | queue: River::QUEUE_DEFAULT, 85 | priority: River::PRIORITY_DEFAULT, 86 | scheduled_at: be_within(2).of(Time.now.getutc), 87 | state: River::JOB_STATE_AVAILABLE, 88 | tags: [] 89 | ) 90 | expect(insert_res.unique_skipped_as_duplicated).to be false 91 | 92 | # Make sure it made it to the database. Assert only minimally since we're 93 | # certain it's the same as what we checked above. 94 | job = driver.job_get_by_id(insert_res.job.id) 95 | expect(job).to have_attributes( 96 | kind: "simple" 97 | ) 98 | end 99 | 100 | it "schedules a job" do 101 | target_time = Time.now.getutc + 1 * 3600 102 | 103 | insert_res = client.insert( 104 | SimpleArgs.new(job_num: 1), 105 | insert_opts: River::InsertOpts.new(scheduled_at: target_time) 106 | ) 107 | expect(insert_res.job).to have_attributes( 108 | scheduled_at: be_within(2).of(target_time), 109 | state: River::JOB_STATE_SCHEDULED 110 | ) 111 | expect(insert_res.unique_skipped_as_duplicated).to be false 112 | end 113 | 114 | it "inserts with job insert opts" do 115 | args = SimpleArgsWithInsertOpts.new(job_num: 1) 116 | args.insert_opts = River::InsertOpts.new( 117 | max_attempts: 23, 118 | priority: 2, 119 | queue: "job_custom_queue", 120 | tags: ["job_custom"] 121 | ) 122 | 123 | insert_res = client.insert(args) 124 | expect(insert_res.job).to have_attributes( 125 | max_attempts: 23, 126 | priority: 2, 127 | queue: "job_custom_queue", 128 | tags: ["job_custom"] 129 | ) 130 | expect(insert_res.unique_skipped_as_duplicated).to be false 131 | end 132 | 133 | it "inserts with insert opts" do 134 | # We set job insert opts in this spec too so that we can verify that the 135 | # options passed at insertion time take precedence. 136 | args = SimpleArgsWithInsertOpts.new(job_num: 1) 137 | args.insert_opts = River::InsertOpts.new( 138 | max_attempts: 23, 139 | priority: 2, 140 | queue: "job_custom_queue", 141 | tags: ["job_custom"] 142 | ) 143 | 144 | insert_res = client.insert(args, insert_opts: River::InsertOpts.new( 145 | max_attempts: 17, 146 | priority: 3, 147 | queue: "my_queue", 148 | tags: ["custom"] 149 | )) 150 | expect(insert_res.job).to have_attributes( 151 | max_attempts: 17, 152 | priority: 3, 153 | queue: "my_queue", 154 | tags: ["custom"] 155 | ) 156 | expect(insert_res.unique_skipped_as_duplicated).to be false 157 | end 158 | 159 | it "inserts with job args hash" do 160 | insert_res = client.insert(River::JobArgsHash.new("hash_kind", { 161 | job_num: 1 162 | })) 163 | expect(insert_res.job).to have_attributes( 164 | args: {"job_num" => 1}, 165 | kind: "hash_kind" 166 | ) 167 | expect(insert_res.unique_skipped_as_duplicated).to be false 168 | end 169 | 170 | it "inserts in a transaction" do 171 | insert_res = nil 172 | 173 | driver.transaction do 174 | insert_res = client.insert(SimpleArgs.new(job_num: 1)) 175 | 176 | job = driver.job_get_by_id(insert_res.job.id) 177 | expect(job).to_not be_nil 178 | expect(insert_res.unique_skipped_as_duplicated).to be false 179 | 180 | raise driver.rollback_exception 181 | end 182 | 183 | # Not present because the job was rolled back. 184 | job = driver.job_get_by_id(insert_res.job.id) 185 | expect(job).to be_nil 186 | end 187 | 188 | it "inserts a unique job" do 189 | insert_params = River::Driver::JobInsertParams.new( 190 | encoded_args: JSON.dump({"job_num" => 1}), 191 | kind: "simple", 192 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 193 | queue: River::QUEUE_DEFAULT, 194 | priority: River::PRIORITY_DEFAULT, 195 | scheduled_at: Time.now.getutc, 196 | state: River::JOB_STATE_AVAILABLE, 197 | unique_key: "unique_key", 198 | unique_states: "00000001", 199 | tags: nil 200 | ) 201 | 202 | job_row, unique_skipped_as_duplicated = driver.job_insert(insert_params) 203 | expect(job_row).to have_attributes( 204 | attempt: 0, 205 | args: {"job_num" => 1}, 206 | created_at: be_within(2).of(Time.now.getutc), 207 | kind: "simple", 208 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 209 | queue: River::QUEUE_DEFAULT, 210 | priority: River::PRIORITY_DEFAULT, 211 | scheduled_at: be_within(2).of(Time.now.getutc), 212 | state: River::JOB_STATE_AVAILABLE, 213 | tags: [], 214 | unique_key: "unique_key", 215 | unique_states: [::River::JOB_STATE_AVAILABLE] 216 | ) 217 | expect(unique_skipped_as_duplicated).to be false 218 | 219 | # second insertion should be skipped 220 | job_row, unique_skipped_as_duplicated = driver.job_insert(insert_params) 221 | expect(job_row).to have_attributes( 222 | attempt: 0, 223 | args: {"job_num" => 1}, 224 | created_at: be_within(2).of(Time.now.getutc), 225 | kind: "simple", 226 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 227 | queue: River::QUEUE_DEFAULT, 228 | priority: River::PRIORITY_DEFAULT, 229 | scheduled_at: be_within(2).of(Time.now.getutc), 230 | state: River::JOB_STATE_AVAILABLE, 231 | tags: [], 232 | unique_key: "unique_key", 233 | unique_states: [::River::JOB_STATE_AVAILABLE] 234 | ) 235 | expect(unique_skipped_as_duplicated).to be true 236 | end 237 | end 238 | 239 | describe "#job_insert_many" do 240 | it "inserts multiple jobs" do 241 | inserted = client.insert_many([ 242 | SimpleArgs.new(job_num: 1), 243 | SimpleArgs.new(job_num: 2) 244 | ]) 245 | expect(inserted.length).to eq(2) 246 | expect(inserted[0].job).to have_attributes(args: {"job_num" => 1}) 247 | expect(inserted[0].unique_skipped_as_duplicated).to eq false 248 | expect(inserted[1].job).to have_attributes(args: {"job_num" => 2}) 249 | expect(inserted[1].unique_skipped_as_duplicated).to eq false 250 | 251 | jobs = driver.job_list 252 | expect(jobs.count).to be 2 253 | 254 | expect(jobs[0]).to have_attributes( 255 | attempt: 0, 256 | args: {"job_num" => 1}, 257 | created_at: be_within(2).of(Time.now.getutc), 258 | kind: "simple", 259 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 260 | queue: River::QUEUE_DEFAULT, 261 | priority: River::PRIORITY_DEFAULT, 262 | scheduled_at: be_within(2).of(Time.now.getutc), 263 | state: River::JOB_STATE_AVAILABLE, 264 | tags: [] 265 | ) 266 | 267 | expect(jobs[1]).to have_attributes( 268 | attempt: 0, 269 | args: {"job_num" => 2}, 270 | created_at: be_within(2).of(Time.now.getutc), 271 | kind: "simple", 272 | max_attempts: River::MAX_ATTEMPTS_DEFAULT, 273 | queue: River::QUEUE_DEFAULT, 274 | priority: River::PRIORITY_DEFAULT, 275 | scheduled_at: be_within(2).of(Time.now.getutc), 276 | state: River::JOB_STATE_AVAILABLE, 277 | tags: [] 278 | ) 279 | end 280 | 281 | it "inserts multiple jobs in a transaction" do 282 | jobs = nil 283 | 284 | driver.transaction do 285 | inserted = client.insert_many([ 286 | SimpleArgs.new(job_num: 1), 287 | SimpleArgs.new(job_num: 2) 288 | ]) 289 | expect(inserted.length).to eq(2) 290 | expect(inserted[0].unique_skipped_as_duplicated).to eq false 291 | expect(inserted[0].job).to have_attributes(args: {"job_num" => 1}) 292 | expect(inserted[1].unique_skipped_as_duplicated).to eq false 293 | expect(inserted[1].job).to have_attributes(args: {"job_num" => 2}) 294 | 295 | jobs = driver.job_list 296 | expect(jobs.count).to be 2 297 | 298 | raise driver.rollback_exception 299 | end 300 | 301 | # Not present because the jobs were rolled back. 302 | expect(driver.job_get_by_id(jobs[0].id)).to be nil 303 | expect(driver.job_get_by_id(jobs[1].id)).to be nil 304 | end 305 | end 306 | 307 | describe "#job_list" do 308 | let(:job_args) { SimpleArgs.new(job_num: 1) } 309 | 310 | it "gets a job by ID" do 311 | insert_res1 = client.insert(job_args) 312 | insert_res2 = client.insert(job_args) 313 | 314 | jobs = driver.job_list 315 | expect(jobs.count).to be 2 316 | 317 | expect(jobs[0].id).to be insert_res1.job.id 318 | expect(jobs[1].id).to be insert_res2.job.id 319 | end 320 | 321 | it "returns nil on not found" do 322 | expect(driver.job_get_by_id(-1)).to be nil 323 | end 324 | end 325 | 326 | describe "#transaction" do 327 | it "runs block in a transaction" do 328 | insert_res = nil 329 | 330 | driver.transaction do 331 | insert_res = client.insert(SimpleArgs.new(job_num: 1)) 332 | 333 | job = driver.job_get_by_id(insert_res.job.id) 334 | expect(job).to_not be_nil 335 | 336 | raise driver.rollback_exception 337 | end 338 | 339 | # Not present because the job was rolled back. 340 | job = driver.job_get_by_id(insert_res.job.id) 341 | expect(job).to be_nil 342 | end 343 | end 344 | end 345 | -------------------------------------------------------------------------------- /spec/job_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe River::JobArgsHash do 4 | it "generates a job args based on a hash" do 5 | args = River::JobArgsHash.new("my_hash_kind", {job_num: 123}) 6 | expect(args.kind).to eq("my_hash_kind") 7 | expect(args.to_json).to eq(JSON.dump({job_num: 123})) 8 | end 9 | 10 | it "errors on a nil kind" do 11 | expect do 12 | River::JobArgsHash.new(nil, {job_num: 123}) 13 | end.to raise_error(RuntimeError, "kind should be non-nil") 14 | end 15 | 16 | it "errors on a nil hash" do 17 | expect do 18 | River::JobArgsHash.new("my_hash_kind", nil) 19 | end.to raise_error(RuntimeError, "hash should be non-nil") 20 | end 21 | end 22 | 23 | describe River::AttemptError do 24 | it "initializes with parameters" do 25 | now = Time.now 26 | 27 | attempt_error = River::AttemptError.new( 28 | at: now, 29 | attempt: 1, 30 | error: "job failure", 31 | trace: "error trace" 32 | ) 33 | expect(attempt_error).to have_attributes( 34 | at: now, 35 | attempt: 1, 36 | error: "job failure", 37 | trace: "error trace" 38 | ) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "debug" 2 | 3 | # Only show coverage information if running the entire suite. 4 | if RSpec.configuration.files_to_run.length > 1 5 | require "simplecov" 6 | SimpleCov.start do 7 | enable_coverage :branch 8 | minimum_coverage line: 100, branch: 100 9 | 10 | # Drivers have their own spec suite where they're covered 100.0%, but 11 | # they're not fully covered from this top level test suite. 12 | add_filter("driver/riverqueue-sequel/") 13 | end 14 | end 15 | 16 | require "riverqueue" 17 | -------------------------------------------------------------------------------- /spec/unique_bitmask_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require_relative "../driver/riverqueue-sequel/spec/spec_helper" 3 | 4 | RSpec.describe River::UniqueBitmask do 5 | describe ".from_states" do 6 | it "converts an array of states to a bitmask string" do 7 | expect(described_class.from_states(River::Client.const_get(:DEFAULT_UNIQUE_STATES))).to eq("11110101") 8 | expect(described_class.from_states([River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED])).to eq("11010001") 9 | expect(described_class.from_states([River::JOB_STATE_AVAILABLE])).to eq("00000001") 10 | end 11 | end 12 | 13 | describe ".to_states" do 14 | it "converts a bitmask string to an array of states" do 15 | expect(described_class.to_states(0b11110101)).to eq(River::Client.const_get(:DEFAULT_UNIQUE_STATES)) 16 | expect(described_class.to_states(0b11010001)).to eq([River::JOB_STATE_AVAILABLE, River::JOB_STATE_PENDING, River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED]) 17 | expect(described_class.to_states(0b00000001)).to eq([River::JOB_STATE_AVAILABLE]) 18 | end 19 | end 20 | end 21 | --------------------------------------------------------------------------------