job-iteration 1.4.1 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b6129ebd214f775a5a2bad7a91061499a29035eff823fc3cba03fbb32799b0f
4
- data.tar.gz: 37f0309527e1e73f9ab755bc7ec8956f0a7eb8373b362f02fa664404b465c148
3
+ metadata.gz: cdace86b05a5a1d98777e2310d85d75679073ca63b5e52b0e83bd000891198cf
4
+ data.tar.gz: 4ed5b475931e24bf56d2b8b126bd9354064c438d0ea16f67c10276fb92ccd97b
5
5
  SHA512:
6
- metadata.gz: 63cab7dba1d5827758bf510168884f56faf1e18fbc50c54ffe187ef714b51c22f0c54b83cfa328f2f517d5c346444ce0f14b34ff5e4ff9a7837cd9f91bf29bd0
7
- data.tar.gz: baceac4783910a2cb58e1e500d0b1c8ac20b483c1fe2ce3b57b2c798503451e1ecbe98dede9e0089031d18f83f00dbabee93438a75050b6864b37d67d58b938e
6
+ metadata.gz: cf9d7a54a8881146a4a0a1750e99dddf8d32c9db3339411fc61c8477a89f5766e9bb99f493257e4484d56e18ef443cb6a2a0300958342774d2e7ebd2b3e23c32
7
+ data.tar.gz: 966c39a5e89ae26343e07000111ba582724b85b4bf9b7a17f790a10b679e297dca94105d3fb42830f987498fb8982037c80cf3e8130b8bede845f9c7532ade41
@@ -5,8 +5,8 @@ on: [push, pull_request]
5
5
  jobs:
6
6
  build:
7
7
  runs-on: ubuntu-latest
8
- name: Ruby ${{ matrix.ruby }} | Gemfile ${{ matrix.gemfile }}
9
- continue-on-error: ${{ matrix.gemfile == 'rails_edge' }}
8
+ name: Ruby ${{ matrix.ruby }} | Rails ${{ matrix.rails }} | Gemfile ${{ matrix.gemfile }}
9
+ continue-on-error: ${{ matrix.rails == 'edge' }}
10
10
  services:
11
11
  redis:
12
12
  image: redis
@@ -14,34 +14,53 @@ jobs:
14
14
  - 6379:6379
15
15
  strategy:
16
16
  matrix:
17
- ruby: ["2.6", "2.7", "3.0", "3.1", "3.2"]
18
- gemfile: [rails_5_2, rails_6_0, rails_6_1, rails_7_0, rails_edge]
17
+ ruby: ["2.6", "2.7", "3.0", "3.1", "3.2", "3.3"]
18
+ rails: ["5.2", "6.0", "6.1", "7.0", "7.1", "edge"]
19
+ gemfile: [rails_gems]
19
20
  exclude:
20
21
  - ruby: "2.6"
21
- gemfile: rails_7_0
22
+ rails: "7.0"
22
23
  - ruby: "2.6"
23
- gemfile: rails_edge
24
+ rails: "7.1"
25
+ - ruby: "2.6"
26
+ rails: "edge"
27
+ - ruby: "2.7"
28
+ rails: "7.1"
29
+ - ruby: "2.7"
30
+ rails: "edge"
31
+ - ruby: "3.0"
32
+ rails: "5.2"
33
+ - ruby: "3.0"
34
+ rails: "7.1"
24
35
  - ruby: "3.0"
25
- gemfile: rails_5_2
36
+ rails: "edge"
26
37
  - ruby: "3.1"
27
- gemfile: rails_5_2
28
- - ruby: "3.2"
29
- gemfile: rails_5_2
38
+ rails: "5.2"
30
39
  - ruby: "3.1"
31
- gemfile: rails_6_0
40
+ rails: "6.0"
41
+ - ruby: "3.2"
42
+ rails: "5.2"
32
43
  - ruby: "3.2"
33
- gemfile: rails_6_0
44
+ rails: "6.0"
34
45
  - ruby: "3.2"
35
- gemfile: rails_6_1
46
+ rails: "6.1"
47
+ - ruby: "3.3"
48
+ rails: "5.2"
49
+ - ruby: "3.3"
50
+ rails: "6.0"
51
+ - ruby: "3.3"
52
+ rails: "6.1"
36
53
 
37
54
  include:
38
55
  - ruby: head
39
- gemfile: rails_edge
56
+ rails: "edge"
57
+ gemfile: rails_gems
40
58
  env:
41
59
  BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
60
+ RAILS_VERSION: ${{ matrix.rails }}
42
61
  steps:
43
62
  - name: Check out code
44
- uses: actions/checkout@v3
63
+ uses: actions/checkout@v4
45
64
  - name: Set up Ruby ${{ matrix.ruby }}
46
65
  uses: ruby/setup-ruby@v1
47
66
  with:
@@ -53,20 +72,16 @@ jobs:
53
72
  mysql -uroot -h localhost -proot -e "CREATE DATABASE job_iteration_test;"
54
73
  - name: Ruby tests
55
74
  run: bundle exec rake test
56
- env:
57
- REDIS_HOST: localhost
58
- REDIS_PORT: ${{ job.services.redis.ports[6379] }}
59
75
 
60
76
  lint:
61
77
  runs-on: ubuntu-latest
62
78
  name: Lint
63
79
  steps:
64
80
  - name: Check out code
65
- uses: actions/checkout@v3
81
+ uses: actions/checkout@v4
66
82
  - name: Set up Ruby
67
83
  uses: ruby/setup-ruby@v1
68
84
  with:
69
- ruby-version: "3.2"
70
85
  bundler-cache: true
71
86
  - name: Rubocop
72
87
  run: bundle exec rubocop
data/.gitignore CHANGED
@@ -6,6 +6,6 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  /tmp/
9
- .ruby-version
10
9
  .rubocop-http---shopify-github-io-ruby-style-guide-rubocop-yml
11
10
  gemfiles/*.lock
11
+ dump.rdb
data/.rubocop.yml CHANGED
@@ -1,20 +1,16 @@
1
1
  inherit_gem:
2
2
  rubocop-shopify: rubocop.yml
3
3
 
4
+ inherit_mode:
5
+ merge:
6
+ - Include
7
+
4
8
  AllCops:
5
- TargetRubyVersion: 2.6
6
- Exclude:
7
- - "vendor/bundle/**/*"
9
+ Include:
10
+ - '**/*.gemfile'
8
11
  Lint/SuppressedException:
9
12
  Exclude:
10
13
  - lib/job-iteration.rb
11
- Style/GlobalVars:
12
- Exclude:
13
- - lib/job-iteration/integrations/resque.rb
14
14
  Naming/FileName:
15
15
  Exclude:
16
16
  - lib/job-iteration.rb
17
- Style/MethodCallWithArgsParentheses:
18
- Exclude:
19
- - "gemfiles/*"
20
- - Gemfile
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.0
data/CHANGELOG.md CHANGED
@@ -1,4 +1,25 @@
1
1
  ### Main (unreleased)
2
+ Nil
3
+
4
+ ## v1.5.0 (May 29, 2024)
5
+ ### Changes
6
+
7
+ - [437](https://github.com/Shopify/job-iteration/pull/437) - Use minimum between per-class `job_iteration_max_job_runtime` and `JobIteration.max_job_runtime`, instead of enforcing only setting decreasing values.
8
+ Because it is possible to change the global or parent values after setting the value on a class, it is not possible to truly enforce the decreasing value constraint. Instead, we now use the minimum between the global value and per-class value. This is considered a non-breaking change, as it should not break any **existing** code, it only removes the constraint on new classes.
9
+ - [443](https://github.com/Shopify/job-iteration/pull/443) - Use Sidekiq `:quit` callback to detect graceful shutdown. This makes job-iteration compatible with Sidekiq run in embedded mode.
10
+ - [445](https://github.com/Shopify/job-iteration/pull/445) - Add the `around_iterate` callback, which runs around each call of `each_iteration`. This adds extensibility to build some generic handlers, such as metrics collection and logging.
11
+ - [450](https://github.com/Shopify/job-iteration/pull/450) - Infer which interruption adapter to use from the queue adapter of the job. This deprecates setting `JobIteration.interruption_adapter = <callable>`, in favor of `JobIteration.register_interruption_adapter(<queue adapter name>, <callable>)`. `JobIteration.interruption_adapter` will be removed in a future release.
12
+
13
+ ### Bug fixes
14
+
15
+ - [437](https://github.com/Shopify/job-iteration/pull/437) - Defer reading `JobIteration.max_job_runtime` until runtime, instead of closing around the value at the time of job definition.
16
+ - [431](https://github.com/Shopify/job-iteration/pull/431) - Use `#id_value` instead of `send(:id)`
17
+ when generating position for cursor based on `:id` column (Rails 7.1 and above, where composite
18
+ primary models are now supported). This ensures we grab the value of the id column, rather than a
19
+ potentially composite primary key value.
20
+ - [456](https://github.com/Shopify/job-iteration/pull/431) - Use Arel to generate SQL that's type compatible for the
21
+ cursor pagination conditionals in ActiveRecord cursor. Previously, the cursor would coerce numeric ids to a string value
22
+ (e.g.: `... AND id > '1'`)
2
23
 
3
24
  ## v1.4.1 (Sep 5, 2023)
4
25
 
data/Gemfile CHANGED
@@ -11,6 +11,17 @@ gemspec
11
11
  gem "sidekiq"
12
12
  gem "resque"
13
13
 
14
+ if defined?(@rails_gems_requirements) && @rails_gems_requirements
15
+ # We avoid the `gem "..."` syntax here so Dependabot doesn't try to update these gems.
16
+ [
17
+ "activejob",
18
+ "activerecord",
19
+ ].each { |name| gem name, @rails_gems_requirements }
20
+ else
21
+ # gem "activejob" # Set in gemspec
22
+ gem "activerecord"
23
+ end
24
+
14
25
  gem "mysql2", github: "brianmario/mysql2"
15
26
  gem "globalid"
16
27
  gem "i18n"
@@ -22,6 +33,7 @@ gem "mocha"
22
33
  gem "rubocop-shopify", require: false
23
34
  gem "yard"
24
35
  gem "rake"
36
+ gem "csv" # required for Ruby 3.4+
25
37
 
26
38
  # for unit testing optional sorbet support
27
39
  gem "sorbet-runtime"
data/Gemfile.lock CHANGED
@@ -1,13 +1,13 @@
1
1
  GIT
2
2
  remote: https://github.com/brianmario/mysql2
3
- revision: 79f78f940685396f1b5f30ec502544bb7e3ba9cf
3
+ revision: 43ea8af635f5e23f054294ef7759320d47f30e5f
4
4
  specs:
5
- mysql2 (0.5.5)
5
+ mysql2 (0.5.6)
6
6
 
7
7
  PATH
8
8
  remote: .
9
9
  specs:
10
- job-iteration (1.4.1)
10
+ job-iteration (1.5.0)
11
11
  activejob (>= 5.2)
12
12
 
13
13
  GEM
@@ -28,61 +28,58 @@ GEM
28
28
  tzinfo (~> 2.0)
29
29
  ast (2.4.2)
30
30
  coderay (1.1.3)
31
- concurrent-ruby (1.2.2)
31
+ concurrent-ruby (1.2.3)
32
32
  connection_pool (2.4.1)
33
+ csv (3.3.0)
33
34
  globalid (1.1.0)
34
35
  activesupport (>= 5.0)
35
- i18n (1.14.1)
36
+ i18n (1.14.4)
36
37
  concurrent-ruby (~> 1.0)
37
- json (2.6.3)
38
+ json (2.7.1)
38
39
  language_server-protocol (3.17.0.3)
39
40
  method_source (1.0.0)
40
41
  minitest (5.19.0)
41
- mocha (2.1.0)
42
+ mocha (2.2.0)
42
43
  ruby2_keywords (>= 0.0.5)
43
44
  mono_logger (1.1.2)
44
45
  multi_json (1.15.0)
45
- mustermann (3.0.0)
46
- ruby2_keywords (~> 0.0.1)
47
- parallel (1.23.0)
48
- parser (3.2.2.3)
46
+ parallel (1.24.0)
47
+ parser (3.3.0.5)
49
48
  ast (~> 2.4.1)
50
49
  racc
51
50
  pry (0.14.2)
52
51
  coderay (~> 1.1)
53
52
  method_source (~> 1.0)
54
- racc (1.7.1)
55
- rack (2.2.8)
56
- rack-protection (3.1.0)
57
- rack (~> 2.2, >= 2.2.4)
53
+ racc (1.7.3)
54
+ rack (3.0.9.1)
58
55
  rainbow (3.1.1)
59
- rake (13.0.6)
60
- redis (5.0.7)
61
- redis-client (>= 0.9.0)
62
- redis-client (0.16.0)
56
+ rake (13.2.1)
57
+ redis (5.2.0)
58
+ redis-client (>= 0.22.0)
59
+ redis-client (0.22.1)
63
60
  connection_pool
64
61
  redis-namespace (1.11.0)
65
62
  redis (>= 4)
66
- regexp_parser (2.8.1)
63
+ regexp_parser (2.9.0)
67
64
  resque (2.6.0)
68
65
  mono_logger (~> 1.0)
69
66
  multi_json (~> 1.0)
70
67
  redis-namespace (~> 1.6)
71
68
  sinatra (>= 0.9.2)
72
- rexml (3.2.5)
73
- rubocop (1.54.2)
69
+ rexml (3.2.6)
70
+ rubocop (1.62.1)
74
71
  json (~> 2.3)
75
72
  language_server-protocol (>= 3.17.0)
76
73
  parallel (~> 1.10)
77
- parser (>= 3.2.2.3)
74
+ parser (>= 3.3.0.2)
78
75
  rainbow (>= 2.2.2, < 4.0)
79
76
  regexp_parser (>= 1.8, < 3.0)
80
77
  rexml (>= 3.2.5, < 4.0)
81
- rubocop-ast (>= 1.28.0, < 2.0)
78
+ rubocop-ast (>= 1.31.1, < 2.0)
82
79
  ruby-progressbar (~> 1.7)
83
80
  unicode-display_width (>= 2.4.0, < 3.0)
84
- rubocop-ast (1.29.0)
85
- parser (>= 3.2.1.0)
81
+ rubocop-ast (1.31.2)
82
+ parser (>= 3.3.0.4)
86
83
  rubocop-shopify (2.14.0)
87
84
  rubocop (~> 1.51)
88
85
  ruby-progressbar (1.13.0)
@@ -92,23 +89,20 @@ GEM
92
89
  connection_pool (>= 2.3.0)
93
90
  rack (>= 2.2.4)
94
91
  redis-client (>= 0.14.0)
95
- sinatra (3.1.0)
96
- mustermann (~> 3.0)
97
- rack (~> 2.2, >= 2.2.4)
98
- rack-protection (= 3.1.0)
99
- tilt (~> 2.0)
92
+ sinatra (1.0)
93
+ rack (>= 1.0)
100
94
  sorbet-runtime (0.5.10978)
101
- tilt (2.2.0)
102
95
  tzinfo (2.0.6)
103
96
  concurrent-ruby (~> 1.0)
104
- unicode-display_width (2.4.2)
105
- yard (0.9.34)
97
+ unicode-display_width (2.5.0)
98
+ yard (0.9.36)
106
99
 
107
100
  PLATFORMS
108
101
  ruby
109
102
 
110
103
  DEPENDENCIES
111
104
  activerecord
105
+ csv
112
106
  globalid
113
107
  i18n
114
108
  job-iteration!
@@ -124,4 +118,4 @@ DEPENDENCIES
124
118
  yard
125
119
 
126
120
  BUNDLED WITH
127
- 2.3.5
121
+ 2.5.7
data/README.md CHANGED
@@ -185,7 +185,7 @@ There a few configuration assumptions that are required for Iteration to work wi
185
185
 
186
186
  **What happens when my job is interrupted?** A checkpoint will be persisted to Redis after the current `each_iteration`, and the job will be re-enqueued. Once it's popped off the queue, the worker will work off from the next iteration.
187
187
 
188
- **What happens with retries?** An interruption of a job does not count as a retry. The iteration of job that caused the job to fail will be retried and progress will continue from there on.
188
+ **What happens with retries?** An interruption of a job does not count as a retry. If an exception occurs, the job will retry or be discarded as normal using Active Job configuration for the job. If the job retries, it processes the iteration that originally failed and progress will continue from there on if successful.
189
189
 
190
190
  **What happens if my iteration takes a long time?** We recommend that a single `each_iteration` should take no longer than 30 seconds. In the future, this may raise an exception.
191
191
 
data/dev.yml CHANGED
@@ -3,17 +3,16 @@
3
3
  name: job-iteration
4
4
 
5
5
  up:
6
- - homebrew:
7
- - mysql-client:
8
- or: [mysql@5.7]
9
- - ruby:
10
- version: 2.7.6
11
- - isogun
6
+ - packages:
7
+ - mysql_client
8
+ - ruby
12
9
  - bundler
10
+ - mysql
11
+ - redis
13
12
  - custom:
14
13
  name: Create Job Iteration database
15
- meet: mysql -uroot -h job-iteration.railgun -e "CREATE DATABASE job_iteration_test"
16
- met?: mysql -uroot -h job-iteration.railgun job_iteration_test -e "SELECT 1" &> /dev/null
14
+ meet: mysql -uroot -h $MYSQL_HOST -P $MYSQL_PORT -e "CREATE DATABASE job_iteration_test"
15
+ met?: mysql -uroot -h $MYSQL_HOST -P $MYSQL_PORT job_iteration_test -e "SELECT 1" &> /dev/null
17
16
 
18
17
  commands:
19
18
  test:
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ rails_version = ENV.fetch("RAILS_VERSION")
4
+ @rails_gems_requirements = case rails_version
5
+ when "edge" then { github: "rails/rails", branch: "main" }
6
+ when /\A\d+\.\d+\z/ then "~> #{rails_version}.0"
7
+ else raise "Unsupported RAILS_VERSION: #{rails_version}"
8
+ end
9
+
10
+ eval_gemfile "../Gemfile"
11
+
12
+ # https://github.com/rails/rails/pull/44083
13
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.1") &&
14
+ rails_version != "edge" && Gem::Version.new(rails_version) < Gem::Version.new("7")
15
+ gem "net-imap", require: false
16
+ gem "net-pop", require: false
17
+ gem "net-smtp", require: false
18
+ end
@@ -1,19 +1,25 @@
1
- Iteration leverages the [Enumerator](https://ruby-doc.org/3.2.1/Enumerator.html) pattern from the Ruby standard library,
1
+ # Custom Enumerator
2
+
3
+ `Iteration` leverages the [Enumerator](https://ruby-doc.org/3.2.1/Enumerator.html) pattern from the Ruby standard library,
2
4
  which allows us to use almost any resource as a collection to iterate.
3
5
 
4
6
  Before writing an enumerator, it is important to understand [how Iteration works](iteration-how-it-works.md) and how
5
7
  your enumerator will be used by it. An enumerator must `yield` two things in the following order as positional
6
8
  arguments:
7
9
  - An object to be processed in a job `each_iteration` method
8
- - A cursor position, which Iteration will persist if `each_iteration` returns succesfully and the job is forced to shut
10
+ - A cursor position, which `Iteration` will persist if `each_iteration` returns successfully and the job is forced to shut
9
11
  down. It can be any data type your job backend can serialize and deserialize correctly.
10
12
 
11
- A job that includes Iteration is first started with `nil` as the cursor. When resuming an interrupted job, Iteration
13
+ A job that includes `Iteration` is first started with `nil` as the cursor. When resuming an interrupted job, `Iteration`
12
14
  will deserialize the persisted cursor and pass it to the job's `build_enumerator` method, which your enumerator uses to
13
15
  find objects that come _after_ the last successfully processed object. The [array enumerator](https://github.com/Shopify/job-iteration/blob/v1.3.6/lib/job-iteration/enumerator_builder.rb#L50-L67)
14
16
  is a simple example which uses the array index as the cursor position.
15
17
 
16
- For a more complex example, consider this Enumerator that wraps a third party API (Stripe) for paginated iteration and
18
+ In addition to the remainder of this guide, we recommend you read the implementation of the other enumerators that come with the library (`CsvEnumerator`, `ActiveRecordEnumerator`) to gain a better understanding of building enumerators.
19
+
20
+ ## Enumerator with cursor
21
+
22
+ For a more complex example, consider this `Enumerator` that wraps a third party API (Stripe) for paginated iteration and
17
23
  stores a string as the cursor position:
18
24
 
19
25
  ```ruby
@@ -58,6 +64,8 @@ class StripeListEnumerator
58
64
  end
59
65
  ```
60
66
 
67
+ ### Usage
68
+
61
69
  Here we leverage the Stripe cursor pagination where the cursor is an ID of a specific item in the collection. The job
62
70
  which uses such an `Enumerator` would then look like so:
63
71
 
@@ -90,12 +98,14 @@ end
90
98
  and you initiate the job with
91
99
 
92
100
  ```ruby
93
- LoadRefundsForChargeJob.perform_later(_charge_id = "chrg_345")
101
+ LoadRefundsForChargeJob.perform_later(charge_id = "chrg_345")
94
102
  ```
95
103
 
96
- Sometimes you can ignore the cursor. Consider the following custom Enumerator that takes items from a Redis list, which
104
+ ## Cursorless enumerator
105
+
106
+ Sometimes you can ignore the cursor. Consider the following custom `Enumerator` that takes items from a Redis list, which
97
107
  is essentially a queue. Even if this job doesn't need to persist a cursor in order to resume, it can still use
98
- Iteration's signal handling to finish `each_iteration` and gracefully terminate.
108
+ `Iteration`'s signal handling to finish `each_iteration` and gracefully terminate.
99
109
 
100
110
  ```ruby
101
111
  class RedisPopListJob < ActiveJob::Base
@@ -115,7 +125,9 @@ class RedisPopListJob < ActiveJob::Base
115
125
  end
116
126
  ```
117
127
 
118
- We recommend that you read the implementation of the other enumerators that come with the library (`CsvEnumerator`, `ActiveRecordEnumerator`) to gain a better understanding of building Enumerator objects.
128
+ ## Caveats
129
+
130
+ ### Post-`yield` code
119
131
 
120
132
  Code that is written after the `yield` in a custom enumerator is not guaranteed to execute. In the case that a job is
121
133
  forced to exit ie `job_should_exit?` is true, then the job is re-enqueued during the yield and the rest of the code in
@@ -21,6 +21,14 @@ SELECT `products`.* FROM `products` ORDER BY products.id LIMIT 100
21
21
  SELECT `products`.* FROM `products` WHERE (products.id > 2) ORDER BY products.id LIMIT 100
22
22
  ```
23
23
 
24
+ ## Exceptions inside `each_iteration`
25
+
26
+ Unrescued exceptions inside the `each_iteration` block are handled the same way as exceptions occuring in `perform` for a regular Active Job subclass, meaning you need to configure it to retry using [`retry_on`](https://api.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-retry_on) or manually call [`retry_job`](https://api.rubyonrails.org/classes/ActiveJob/Exceptions.html#method-i-retry_job). The job will re-enqueue itself with the last successful cursor, the iteration that failed will be retried with the same parameters and the cursor will only move if that iteration succeeds. This behaviour may be enough for intermittent errors, such as network connection failures, but if your execution is deterministic and you have an error, subsequent iterations will never run.
27
+
28
+ In other words, if you are trying to process 100 records but the job consistently fails on the 61st, only the first 60 will be processed and the job will try to process the 61st record until retries are exhausted.
29
+
30
+ If no retries are configured or retries are exhausted, Active Job 'bubbles up' the exception to the job backend. Retries by the backend (e.g. Sidekiq) are not supported, meaning that jobs retried by the job backend instead of Active Job will restart from the beginning.
31
+
24
32
  ## Signals
25
33
 
26
34
  It's critical to know [UNIX signals](https://www.tutorialspoint.com/unix/unix-signals-traps.htm) in order to understand how interruption works. There are two main signals that Sidekiq and Resque use: `SIGTERM` and `SIGKILL`. `SIGTERM` is the graceful termination signal which means that the process should exit _soon_, not immediately. For Iteration, it means that we have time to wait for the last iteration to finish and to push job back to the queue with the last cursor position.
data/isogun.yml CHANGED
@@ -1,4 +1,3 @@
1
- # https://development.shopify.io/tools/dev/railgun/Railgun-Config
2
1
  name: job-iteration
3
2
 
4
3
  vm:
@@ -26,6 +26,5 @@ Gem::Specification.new do |spec|
26
26
  spec.metadata["changelog_uri"] = "https://github.com/Shopify/job-iteration/blob/main/CHANGELOG.md"
27
27
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
28
28
 
29
- spec.add_development_dependency("activerecord")
30
29
  spec.add_dependency("activejob", ">= 5.2")
31
30
  end
@@ -18,12 +18,8 @@ module JobIteration
18
18
  end
19
19
  end
20
20
 
21
- def initialize(relation, columns = nil, position = nil)
22
- @columns = if columns
23
- Array(columns)
24
- else
25
- Array(relation.primary_key).map { |pk| "#{relation.table_name}.#{pk}" }
26
- end
21
+ def initialize(relation, columns, position = nil)
22
+ @columns = columns
27
23
  self.position = Array.wrap(position)
28
24
  raise ArgumentError, "Must specify at least one column" if columns.empty?
29
25
  if relation.joins_values.present? && !@columns.all? { |column| column.to_s.include?(".") }
@@ -34,7 +30,7 @@ module JobIteration
34
30
  raise ConditionNotSupportedError
35
31
  end
36
32
 
37
- @base_relation = relation.reorder(@columns.join(","))
33
+ @base_relation = relation.reorder(*@columns)
38
34
  @reached_end = false
39
35
  end
40
36
 
@@ -54,8 +50,11 @@ module JobIteration
54
50
 
55
51
  def update_from_record(record)
56
52
  self.position = @columns.map do |column|
57
- method = column.to_s.split(".").last
58
- record.send(method.to_sym)
53
+ if ActiveRecord.version >= Gem::Version.new("7.1.0.alpha") && column.name == "id"
54
+ record.id_value
55
+ else
56
+ record.send(column.name)
57
+ end
59
58
  end
60
59
  end
61
60
 
@@ -84,14 +83,14 @@ module JobIteration
84
83
  i = @position.size - 1
85
84
  column = @columns[i]
86
85
  conditions = if @columns.size == @position.size
87
- "#{column} > ?"
86
+ column.gt(@position[i])
88
87
  else
89
- "#{column} >= ?"
88
+ column.gteq(@position[i])
90
89
  end
91
90
  while i > 0
92
91
  i -= 1
93
92
  column = @columns[i]
94
- conditions = "#{column} > ? OR (#{column} = ? AND (#{conditions}))"
93
+ conditions = column.gt(@position[i]).or(column.eq(@position[i]).and(conditions))
95
94
  end
96
95
  ret = @position.reduce([conditions]) { |params, value| params << value << value }
97
96
  ret.pop
@@ -11,9 +11,9 @@ module JobIteration
11
11
  @relation = relation
12
12
  @batch_size = batch_size
13
13
  @columns = if columns
14
- Array(columns)
14
+ Array(columns).map { |col| relation.arel_table[col.to_sym] }
15
15
  else
16
- Array(relation.primary_key).map { |pk| "#{relation.table_name}.#{pk}" }
16
+ Array(relation.primary_key).map { |pk| relation.arel_table[pk.to_sym] }
17
17
  end
18
18
  @cursor = cursor
19
19
  end
@@ -45,7 +45,7 @@ module JobIteration
45
45
 
46
46
  def cursor_value(record)
47
47
  positions = @columns.map do |column|
48
- attribute_name = column.to_s.split(".").last
48
+ attribute_name = column.name.to_sym
49
49
  column_value(record, attribute_name)
50
50
  end
51
51
  return positions.first if positions.size == 1
@@ -58,8 +58,8 @@ module JobIteration
58
58
  end
59
59
 
60
60
  def column_value(record, attribute)
61
- value = record.read_attribute(attribute.to_sym)
62
- case record.class.columns_hash.fetch(attribute).type
61
+ value = record.read_attribute(attribute)
62
+ case record.class.columns_hash.fetch(attribute.to_s).type
63
63
  when :datetime
64
64
  value.strftime(SQL_DATETIME_WITH_NSEC)
65
65
  else
@@ -20,7 +20,7 @@ module JobIteration
20
20
  # csv = CSV.open('tmp/files', { converters: :integer, headers: true })
21
21
  # JobIteration::CsvEnumerator.new(csv).rows(cursor: cursor)
22
22
  def initialize(csv)
23
- unless csv.instance_of?(CSV)
23
+ unless defined?(CSV) && csv.instance_of?(CSV)
24
24
  raise ArgumentError, "CsvEnumerator.new takes CSV object"
25
25
  end
26
26
 
@@ -55,9 +55,6 @@ module JobIteration
55
55
  unless enumerable.is_a?(Array)
56
56
  raise ArgumentError, "enumerable must be an Array"
57
57
  end
58
- if enumerable.any? { |i| defined?(ActiveRecord) && i.is_a?(ActiveRecord::Base) }
59
- raise ArgumentError, "array cannot contain ActiveRecord objects"
60
- end
61
58
 
62
59
  drop =
63
60
  if cursor.nil?
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This adapter never interrupts.
4
+ module JobIteration
5
+ module InterruptionAdapters
6
+ module NullAdapter
7
+ class << self
8
+ def call
9
+ false
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "resque"
5
+ rescue LoadError
6
+ # Resque is not available, no need to load the adapter
7
+ return
8
+ end
9
+
10
+ module JobIteration
11
+ module InterruptionAdapters
12
+ module ResqueAdapter
13
+ # @private
14
+ module IterationExtension
15
+ def initialize(*)
16
+ $resque_worker = self # rubocop:disable Style/GlobalVars
17
+ super
18
+ end
19
+ end
20
+
21
+ # @private
22
+ module ::Resque
23
+ class Worker
24
+ # The patch is required in order to call shutdown? on a Resque::Worker instance
25
+ prepend(IterationExtension)
26
+ end
27
+ end
28
+
29
+ class << self
30
+ def call
31
+ $resque_worker.try!(:shutdown?) # rubocop:disable Style/GlobalVars
32
+ end
33
+ end
34
+ end
35
+
36
+ register(:resque, ResqueAdapter)
37
+ end
38
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "sidekiq"
5
+ rescue LoadError
6
+ # Sidekiq is not available, no need to load the adapter
7
+ return
8
+ end
9
+
10
+ module JobIteration
11
+ module InterruptionAdapters
12
+ module SidekiqAdapter
13
+ class << self
14
+ attr_accessor :stopping
15
+
16
+ def call
17
+ stopping
18
+ end
19
+ end
20
+
21
+ ::Sidekiq.configure_server do |config|
22
+ config.on(:quiet) do
23
+ SidekiqAdapter.stopping = true
24
+ end
25
+ end
26
+ end
27
+
28
+ register(:sidekiq, SidekiqAdapter)
29
+ end
30
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "interruption_adapters/null_adapter"
4
+
5
+ module JobIteration
6
+ module InterruptionAdapters
7
+ BUNDLED_ADAPTERS = [:resque, :sidekiq].freeze # @api private
8
+
9
+ class << self
10
+ # Returns adapter for specified name.
11
+ #
12
+ # JobIteration::InterruptionAdapters.lookup(:sidekiq)
13
+ # # => JobIteration::InterruptionAdapters::SidekiqAdapter
14
+ def lookup(name)
15
+ registry.fetch(name.to_sym) do
16
+ Deprecation.warn(<<~DEPRECATION_MESSAGE, caller_locations(1))
17
+ No interruption adapter is registered for #{name.inspect}; falling back to `NullAdapter`, which never interrupts.
18
+ Use `JobIteration::InterruptionAdapters.register(#{name.to_sym.inspect}, <adapter>) to register one.
19
+ This will raise starting in version #{Deprecation.deprecation_horizon} of #{Deprecation.gem_name}!"
20
+ DEPRECATION_MESSAGE
21
+
22
+ NullAdapter
23
+ end
24
+ end
25
+
26
+ # Registers adapter for specified name.
27
+ #
28
+ # JobIteration::InterruptionAdapters.register(:sidekiq, MyCustomSidekiqAdapter)
29
+ def register(name, adapter)
30
+ raise ArgumentError, "adapter must be callable" unless adapter.respond_to?(:call)
31
+
32
+ registry[name.to_sym] = adapter
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :registry
38
+ end
39
+
40
+ @registry = {}
41
+
42
+ # Built-in Rails adapters. It doesn't make sense to interrupt for these.
43
+ register(:async, NullAdapter)
44
+ register(:inline, NullAdapter)
45
+ register(:test, NullAdapter)
46
+
47
+ # External adapters
48
+ BUNDLED_ADAPTERS.each do |name|
49
+ require_relative "interruption_adapters/#{name}_adapter"
50
+ end
51
+ end
52
+ end
@@ -42,35 +42,15 @@ module JobIteration
42
42
 
43
43
  included do |_base|
44
44
  define_callbacks :start
45
+ define_callbacks :iterate
45
46
  define_callbacks :shutdown
46
47
  define_callbacks :complete
47
48
 
48
49
  class_attribute(
49
50
  :job_iteration_max_job_runtime,
50
- instance_writer: false,
51
+ instance_accessor: false,
51
52
  instance_predicate: false,
52
- default: JobIteration.max_job_runtime,
53
53
  )
54
-
55
- singleton_class.prepend(PrependedClassMethods)
56
- end
57
-
58
- module PrependedClassMethods
59
- def job_iteration_max_job_runtime=(new)
60
- existing = job_iteration_max_job_runtime
61
-
62
- if existing && (!new || new > existing)
63
- existing_label = existing.inspect
64
- new_label = new ? new.inspect : "#{new.inspect} (no limit)"
65
- raise(
66
- ArgumentError,
67
- "job_iteration_max_job_runtime may only decrease; " \
68
- "#{self} tried to increase it from #{existing_label} to #{new_label}",
69
- )
70
- end
71
-
72
- super
73
- end
74
54
  end
75
55
 
76
56
  module ClassMethods
@@ -91,6 +71,10 @@ module JobIteration
91
71
  set_callback(:complete, :after, *filters, &blk)
92
72
  end
93
73
 
74
+ def around_iterate(&blk)
75
+ set_callback(:iterate, :around, &blk)
76
+ end
77
+
94
78
  private
95
79
 
96
80
  def ban_perform_definition
@@ -136,6 +120,10 @@ module JobIteration
136
120
 
137
121
  private
138
122
 
123
+ def interruption_adapter # @private
124
+ JobIteration.interruption_adapter || JobIteration::InterruptionAdapters.lookup(self.class.queue_adapter_name)
125
+ end
126
+
139
127
  def enumerator_builder
140
128
  JobIteration.enumerator_builder.new(self)
141
129
  end
@@ -194,7 +182,9 @@ module JobIteration
194
182
  tags = instrumentation_tags.merge(cursor_position: cursor_from_enumerator)
195
183
  ActiveSupport::Notifications.instrument("each_iteration.iteration", tags) do
196
184
  found_record = true
197
- each_iteration(object_from_enumerator, *arguments)
185
+ run_callbacks(:iterate) do
186
+ each_iteration(object_from_enumerator, *arguments)
187
+ end
198
188
  self.cursor_position = cursor_from_enumerator
199
189
  end
200
190
 
@@ -291,11 +281,20 @@ module JobIteration
291
281
  end
292
282
 
293
283
  def job_should_exit?
294
- if job_iteration_max_job_runtime && start_time && (Time.now.utc - start_time) > job_iteration_max_job_runtime
295
- return true
296
- end
284
+ max_job_runtime = job_iteration_max_job_runtime
285
+ return true if max_job_runtime && start_time && (Time.now.utc - start_time) > max_job_runtime
286
+
287
+ interruption_adapter.call || (defined?(super) && super)
288
+ end
289
+
290
+ def job_iteration_max_job_runtime
291
+ global_max = JobIteration.max_job_runtime
292
+ class_max = self.class.job_iteration_max_job_runtime
293
+
294
+ return global_max unless class_max
295
+ return class_max unless global_max
297
296
 
298
- JobIteration.interruption_adapter.call || (defined?(super) && super)
297
+ [global_max, class_max].min
299
298
  end
300
299
 
301
300
  def handle_completed(completed)
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(Rails::Railtie)
4
+
5
+ module JobIteration
6
+ class Railtie < Rails::Railtie
7
+ initializer "job_iteration.register_deprecator" do |app|
8
+ # app.deprecators was added in Rails 7.1
9
+ app.deprecators[:job_iteration] = JobIteration::Deprecation if app.respond_to?(:deprecators)
10
+ end
11
+ end
12
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JobIteration
4
- VERSION = "1.4.1"
4
+ VERSION = "1.5.0"
5
5
  end
data/lib/job-iteration.rb CHANGED
@@ -3,13 +3,13 @@
3
3
  require "active_job"
4
4
  require_relative "./job-iteration/version"
5
5
  require_relative "./job-iteration/enumerator_builder"
6
+ require_relative "./job-iteration/interruption_adapters"
6
7
  require_relative "./job-iteration/iteration"
7
8
  require_relative "./job-iteration/log_subscriber"
9
+ require_relative "./job-iteration/railtie"
8
10
 
9
11
  module JobIteration
10
- IntegrationLoadError = Class.new(StandardError)
11
-
12
- INTEGRATIONS = [:resque, :sidekiq]
12
+ Deprecation = ActiveSupport::Deprecation.new("2.0", "JobIteration")
13
13
 
14
14
  extend self
15
15
 
@@ -29,14 +29,22 @@ module JobIteration
29
29
  # This setting will make it to always interrupt a job after it's been iterating for 5 minutes.
30
30
  # Defaults to nil which means that jobs will not be interrupted except on termination signal.
31
31
  #
32
- # This setting can be further reduced (but not increased) by using the inheritable per-class
33
- # job_iteration_max_job_runtime setting.
32
+ # This setting can be overriden by using the inheritable per-class job_iteration_max_job_runtime setting. At runtime,
33
+ # the lower of the two will be used.
34
34
  # @example
35
35
  #
36
36
  # class MyJob < ActiveJob::Base
37
37
  # include JobIteration::Iteration
38
38
  # self.job_iteration_max_job_runtime = 1.minute
39
39
  # # ...
40
+ #
41
+ # Note that if a sub-class overrides its parent's setting, only the global and sub-class setting will be considered,
42
+ # not the parent's.
43
+ # @example
44
+ #
45
+ # class ChildJob < MyJob
46
+ # self.job_iteration_max_job_runtime = 3.minutes # MyJob's 1.minute will be discarded.
47
+ # # ...
40
48
  attr_accessor :max_job_runtime
41
49
 
42
50
  # Configures a delay duration to wait before resuming an interrupted job.
@@ -49,10 +57,21 @@ module JobIteration
49
57
  # where the throttle backoff value will take precedence over this setting.
50
58
  attr_accessor :default_retry_backoff
51
59
 
52
- # Used internally for hooking into job processing frameworks like Sidekiq and Resque.
53
- attr_accessor :interruption_adapter
60
+ attr_reader :interruption_adapter
61
+
62
+ # Overrides interruption checks based on queue adapter.
63
+ # @deprecated - Use JobIteration::InterruptionAdapters.register(:foo, callable) instead.
64
+ def interruption_adapter=(adapter)
65
+ Deprecation.warn("Setting JobIteration.interruption_adapter is deprecated. "\
66
+ "Use JobIteration::InterruptionAdapters.register(:foo, callable) instead "\
67
+ "to register the callable (a proc, method, or other object responding to #call) "\
68
+ "as the interruption adapter for queue adapter :foo.")
69
+ @interruption_adapter = adapter
70
+ end
54
71
 
55
- self.interruption_adapter = -> { false }
72
+ def register_interruption_adapter(adapter_name, adapter)
73
+ InterruptionAdapters.register(adapter_name, adapter)
74
+ end
56
75
 
57
76
  # Set if you want to use your own enumerator builder instead of default EnumeratorBuilder.
58
77
  # @example
@@ -65,29 +84,4 @@ module JobIteration
65
84
  attr_accessor :enumerator_builder
66
85
 
67
86
  self.enumerator_builder = JobIteration::EnumeratorBuilder
68
-
69
- def load_integrations
70
- loaded = nil
71
- INTEGRATIONS.each do |integration|
72
- load_integration(integration)
73
- if loaded
74
- raise IntegrationLoadError,
75
- "#{loaded} integration has already been loaded, but #{integration} is also available. " \
76
- "Iteration will only work with one integration."
77
- end
78
- loaded = integration
79
- rescue LoadError
80
- end
81
- end
82
-
83
- def load_integration(integration)
84
- unless INTEGRATIONS.include?(integration)
85
- raise IntegrationLoadError,
86
- "#{integration} integration is not supported. Available integrations: #{INTEGRATIONS.join(", ")}"
87
- end
88
-
89
- require_relative "./job-iteration/integrations/#{integration}"
90
- end
91
87
  end
92
-
93
- JobIteration.load_integrations unless ENV["ITERATION_DISABLE_AUTOCONFIGURE"]
metadata CHANGED
@@ -1,29 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: job-iteration
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-09-05 00:00:00.000000000 Z
11
+ date: 2024-05-29 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: activerecord
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '0'
20
- type: :development
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: '0'
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: activejob
29
15
  requirement: !ruby/object:Gem::Requirement
@@ -50,6 +36,7 @@ files:
50
36
  - ".github/workflows/cla.yml"
51
37
  - ".gitignore"
52
38
  - ".rubocop.yml"
39
+ - ".ruby-version"
53
40
  - ".yardopts"
54
41
  - CHANGELOG.md
55
42
  - CODE_OF_CONDUCT.md
@@ -61,11 +48,7 @@ files:
61
48
  - bin/setup
62
49
  - bin/test
63
50
  - dev.yml
64
- - gemfiles/rails_5_2.gemfile
65
- - gemfiles/rails_6_0.gemfile
66
- - gemfiles/rails_6_1.gemfile
67
- - gemfiles/rails_7_0.gemfile
68
- - gemfiles/rails_edge.gemfile
51
+ - gemfiles/rails_gems.gemfile
69
52
  - guides/argument-semantics.md
70
53
  - guides/best-practices.md
71
54
  - guides/custom-enumerator.md
@@ -79,11 +62,14 @@ files:
79
62
  - lib/job-iteration/active_record_enumerator.rb
80
63
  - lib/job-iteration/csv_enumerator.rb
81
64
  - lib/job-iteration/enumerator_builder.rb
82
- - lib/job-iteration/integrations/resque.rb
83
- - lib/job-iteration/integrations/sidekiq.rb
65
+ - lib/job-iteration/interruption_adapters.rb
66
+ - lib/job-iteration/interruption_adapters/null_adapter.rb
67
+ - lib/job-iteration/interruption_adapters/resque_adapter.rb
68
+ - lib/job-iteration/interruption_adapters/sidekiq_adapter.rb
84
69
  - lib/job-iteration/iteration.rb
85
70
  - lib/job-iteration/log_subscriber.rb
86
71
  - lib/job-iteration/nested_enumerator.rb
72
+ - lib/job-iteration/railtie.rb
87
73
  - lib/job-iteration/test_helper.rb
88
74
  - lib/job-iteration/throttle_enumerator.rb
89
75
  - lib/job-iteration/version.rb
@@ -108,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
94
  - !ruby/object:Gem::Version
109
95
  version: '0'
110
96
  requirements: []
111
- rubygems_version: 3.4.19
97
+ rubygems_version: 3.5.10
112
98
  signing_key:
113
99
  specification_version: 4
114
100
  summary: Makes your background jobs interruptible and resumable.
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- eval_gemfile "../Gemfile"
4
-
5
- gem "activejob", "~> 5.2.0"
6
- gem "activerecord", "~> 5.2.0"
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- eval_gemfile "../Gemfile"
4
-
5
- gem "activejob", "~> 6.0.0"
6
- gem "activerecord", "~> 6.0.0"
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- eval_gemfile "../Gemfile"
4
-
5
- if Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new("3.1")
6
- gem "net-imap", require: false
7
- gem "net-pop", require: false
8
- gem "net-smtp", require: false
9
- end
10
-
11
- gem "activejob", "~> 6.1.0"
12
- gem "activerecord", "~> 6.1.0"
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- eval_gemfile "../Gemfile"
4
-
5
- gem "activejob", "~> 7.0.0"
6
- gem "activerecord", "~> 7.0.0"
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- eval_gemfile "../Gemfile"
4
-
5
- gem "activejob", github: "rails/rails", branch: "main"
6
- gem "activerecord", github: "rails/rails", branch: "main"
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "resque"
4
-
5
- module JobIteration
6
- module Integrations
7
- module ResqueIterationExtension # @private
8
- def initialize(*) # @private
9
- $resque_worker = self
10
- super
11
- end
12
- end
13
-
14
- # @private
15
- module ::Resque
16
- class Worker
17
- # The patch is required in order to call shutdown? on a Resque::Worker instance
18
- prepend(ResqueIterationExtension)
19
- end
20
- end
21
-
22
- JobIteration.interruption_adapter = -> { $resque_worker.try!(:shutdown?) }
23
- end
24
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "sidekiq"
4
-
5
- module JobIteration
6
- module Integrations # @private
7
- JobIteration.interruption_adapter = -> do
8
- if defined?(Sidekiq::CLI) && Sidekiq::CLI.instance
9
- Sidekiq::CLI.instance.launcher.stopping?
10
- else
11
- false
12
- end
13
- end
14
- end
15
- end