job-iteration 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6cd8bb708ce42d3333f9631f344bb33b506cd2e3d0fc8a5d73882f2f52a811a
4
- data.tar.gz: 30b9ece97b5e981a61fbef2110e60fdffcbd95dfdeec14def3652f9384f7e4d5
3
+ metadata.gz: dbb861f47602c89b8163b0f36f662c8b1f10c34528c0d06b0ce74918973e89db
4
+ data.tar.gz: b00adea778b0a722dc727fdd4a9954c60442df995ee273ec82ff9d85b9fa222f
5
5
  SHA512:
6
- metadata.gz: cc9bd924dfbce3e582356a9b29edec62ca597c6b50cad39afb29970e9d1b1ce799678797b7441b6aa6732d25e8b5e8fd896f88b7dd118cae552f5acc50a31143
7
- data.tar.gz: 532e91769910a4172d8f7773bfd59b3fa54372fed4b9fd22d6309afa0861a626a878dedb50972127658a76e90b651cf1837a7d87fc61deebc81655ee418698d9
6
+ metadata.gz: 1d085184d274fcc7315277c0e11e45093674e96a152d6bf49eff6644be23974df17f6d778e0abf0d1289ac2203af8ea59a9c7a7d9aeedd46de26b6ed16649d86
7
+ data.tar.gz: 50fea852189c3f13f4b0c84f465f532e4b27804d5d71238f044fa0f6beec67c8372c83561b0690e1605eb09c47a3f07cfad24a417a3dd612a4b57bfda840696d
data/CHANGELOG.md CHANGED
@@ -16,6 +16,12 @@ nil
16
16
 
17
17
  nil
18
18
 
19
+ ## v1.15.0 (Jun 4, 2026)
20
+
21
+ ### Features
22
+
23
+ - [715](https://github.com/Shopify/job-iteration/pull/715) - Add support for keyword arguments in `build_enumerator` and `each_iteration` while preserving positional params Hash compatibility for existing jobs. This compatibility path is transitional; migrate jobs away from positional params Hash signatures paired with `perform_later(keyword: value)`.
24
+
19
25
  ## v1.14.0 (May 14, 2026)
20
26
 
21
27
  ### Breaking Changes
data/README.md CHANGED
@@ -101,6 +101,21 @@ class BatchesAsRelationJob < ApplicationJob
101
101
  end
102
102
  ```
103
103
 
104
+ ```ruby
105
+ class ParallelIterationJob < ApplicationJob
106
+ include JobIteration::Iteration
107
+
108
+ def build_enumerator(cursor:)
109
+ enumerator_builder.parallel_active_record_on_records(User.all, instances: 5, cursor: cursor)
110
+ end
111
+
112
+ # Runs in 5 separate jobs, with each job processing a subset of the users
113
+ def each_iteration(user)
114
+ user.notify_about_something
115
+ end
116
+ end
117
+ ```
118
+
104
119
  ```ruby
105
120
  class ArrayJob < ApplicationJob
106
121
  include JobIteration::Iteration
@@ -178,20 +193,21 @@ Support for older platforms that have reached end of life may occasionally be dr
178
193
  * [Best practices](guides/best-practices.md)
179
194
  * [Writing custom enumerator](guides/custom-enumerator.md)
180
195
  * [Throttling](guides/throttling.md)
196
+ * [Parallel iteration](guides/parallel-iteration.md)
181
197
 
182
198
  For more detailed documentation, see [rubydoc](https://www.rubydoc.info/gems/job-iteration).
183
199
 
184
200
  ## Requirements
185
201
 
186
- ActiveJob is the primary requirement for Iteration. While there's nothing that prevents it, Iteration is not yet compatible with [vanilla](https://github.com/mperham/sidekiq/wiki/Active-Job) Sidekiq API.
202
+ Active Job is the primary requirement for Iteration. For iteration without Active Job in Sidekiq, see [Sidekiq Iteration](https://github.com/sidekiq/sidekiq/wiki/Iteration).
187
203
 
188
204
  ### API
189
205
 
190
- Iteration job must respond to `build_enumerator` and `each_iteration` methods. `build_enumerator` must return [Enumerator](http://ruby-doc.org/core-2.5.1/Enumerator.html) object that respects the `cursor` value.
206
+ Iteration job must respond to `build_enumerator` and `each_iteration` methods. `build_enumerator` must return an [Enumerator](http://ruby-doc.org/core-2.5.1/Enumerator.html) object that respects the `cursor` value.
191
207
 
192
208
  ### Sidekiq adapter
193
209
 
194
- Unless you are running on Heroku, we recommend you to tune Sidekiq's [timeout](https://github.com/mperham/sidekiq/wiki/Deployment#overview) option from the default 8 seconds to 25-30 seconds, to allow the last `each_iteration` to complete and gracefully shutdown.
210
+ Running iterating jobs on Sidekiq should work with the default configuration. The most important setting is Sidekiq's [timeout](https://github.com/mperham/sidekiq/wiki/Deployment#overview) option, which defaults to 25 seconds. That allows the last `each_iteration` to complete and gracefully shutdown.
195
211
 
196
212
  ### Resque adapter
197
213
 
@@ -203,11 +219,11 @@ There a few configuration assumptions that are required for Iteration to work wi
203
219
 
204
220
  **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.
205
221
 
206
- **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.
222
+ **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 re-processes the iteration that originally failed and progress will continue from there on if successful.
207
223
 
208
224
  **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.
209
225
 
210
- **Why is it important that `each_iteration` takes less than 30 seconds?** When the job worker is scheduled for restart or shutdown, it gets a notice to finish remaining unit of work. To guarantee that no progress is lost we need to make sure that `each_iteration` completes within a reasonable amount of time.
226
+ **Why is it important that `each_iteration` runs quickly?** When the job worker is scheduled for restart or shutdown, it gets a notice to finish remaining unit of work. To guarantee that no progress is lost we need to make sure that `each_iteration` completes within a reasonable amount of time. The exact timeout depends on your queue adapter configuration.
211
227
 
212
228
  **Why do I use have to use this ugly helper in `build_enumerator`? Why can't you automatically infer it?** This is how the first version of the API worked. We checked the type of object returned by `build_enumerable`, and whether it was ActiveRecord Relation or an Array, we used the matching adapter. This caused opaque type branching in Iteration internals and it didn’t allow developers to craft their own Enumerators and control the cursor value. We made a decision to _always_ return Enumerator instance from `build_enumerator`. Now we provide explicit helpers to convert ActiveRecord Relation or an Array to Enumerator, and for more complex iteration flows developers can build their own `Enumerator` objects.
213
229
 
@@ -107,8 +107,8 @@ module JobIteration
107
107
  self.total_time = Float(job_data["total_time"] || 0.0)
108
108
  end
109
109
 
110
- def perform(*params) # @private
111
- interruptible_perform(*params)
110
+ def perform(...) # @private
111
+ interruptible_perform(...)
112
112
 
113
113
  nil
114
114
  end
@@ -128,12 +128,12 @@ module JobIteration
128
128
  JobIteration.enumerator_builder.new(self)
129
129
  end
130
130
 
131
- def interruptible_perform(*arguments)
131
+ def interruptible_perform(*args, **kwargs)
132
132
  self.start_time = Time.now.utc
133
133
 
134
134
  enumerator = nil
135
135
  ActiveSupport::Notifications.instrument("build_enumerator.iteration", instrumentation_tags) do
136
- enumerator = build_enumerator(*arguments, cursor: cursor_position)
136
+ enumerator = call_job_iteration_build_enumerator(args, kwargs)
137
137
  end
138
138
 
139
139
  unless enumerator
@@ -161,7 +161,7 @@ module JobIteration
161
161
  end
162
162
 
163
163
  completed = catch(:abort) do
164
- iterate_with_enumerator(enumerator, arguments)
164
+ iterate_with_enumerator(enumerator, args, kwargs)
165
165
  end
166
166
 
167
167
  run_callbacks(:shutdown)
@@ -178,8 +178,7 @@ module JobIteration
178
178
  end
179
179
  end
180
180
 
181
- def iterate_with_enumerator(enumerator, arguments)
182
- arguments = arguments.dup.freeze
181
+ def iterate_with_enumerator(enumerator, args, kwargs)
183
182
  found_record = false
184
183
  @needs_reenqueue = false
185
184
 
@@ -191,7 +190,7 @@ module JobIteration
191
190
  ActiveSupport::Notifications.instrument("each_iteration.iteration", tags) do
192
191
  found_record = true
193
192
  run_callbacks(:iterate) do
194
- each_iteration(object_from_enumerator, *arguments)
193
+ call_job_iteration_each_iteration(object_from_enumerator, args, kwargs)
195
194
  end
196
195
  self.cursor_position = cursor_from_enumerator
197
196
  end
@@ -215,6 +214,64 @@ module JobIteration
215
214
  adjust_total_time
216
215
  end
217
216
 
217
+ def call_job_iteration_build_enumerator(args, kwargs)
218
+ positional_args, keyword_args = normalize_job_iteration_arguments(args, kwargs, :build_enumerator)
219
+
220
+ if keyword_args&.key?(:cursor)
221
+ raise ArgumentError, "The keyword argument `cursor` is reserved for the job iteration framework. " \
222
+ "Please remove `cursor` from the arguments passed to the job or rename it"
223
+ end
224
+
225
+ # `keyword_args || {}` because splatting `nil` raises on Ruby < 3.4; it only
226
+ # became a no-op in 3.4 (https://bugs.ruby-lang.org/issues/20064).
227
+ build_enumerator(*positional_args, **(keyword_args || {}), cursor: cursor_position)
228
+ end
229
+
230
+ def call_job_iteration_each_iteration(object_from_enumerator, args, kwargs)
231
+ positional_args, keyword_args = normalize_job_iteration_arguments(args, kwargs, :each_iteration)
232
+ # `keyword_args || {}` because splatting `nil` raises on Ruby < 3.4; it only
233
+ # became a no-op in 3.4 (https://bugs.ruby-lang.org/issues/20064).
234
+ each_iteration(object_from_enumerator, *positional_args, **(keyword_args || {}))
235
+ end
236
+
237
+ # Normalize Active Job kwargs for job-iteration dispatch. If the target method
238
+ # accepts job keyword parameters other than build_enumerator's reserved cursor:,
239
+ # keep kwargs separate so they can be splatted. Otherwise, append kwargs as a
240
+ # positional params Hash for transitional compatibility with existing jobs
241
+ # enqueued with keyword syntax.
242
+ #: (Array[top], Hash[Symbol, top], Symbol) -> [Array[top], Hash[Symbol, top]?]
243
+ def normalize_job_iteration_arguments(args, kwargs, method_name)
244
+ if kwargs.empty?
245
+ [args.dup.freeze, nil]
246
+ elsif job_has_keyword_parameters?(method_name)
247
+ [args.dup.freeze, kwargs.dup.freeze]
248
+ else
249
+ params_hash_args = args + [kwargs]
250
+ [params_hash_args.dup.freeze, nil]
251
+ end
252
+ end
253
+
254
+ #: (Symbol) -> bool
255
+ def job_has_keyword_parameters?(method_name)
256
+ @job_has_keyword_parameters ||= {}
257
+ return @job_has_keyword_parameters[method_name] if @job_has_keyword_parameters.key?(method_name)
258
+
259
+ @job_has_keyword_parameters[method_name] = method_parameters(method_name).any? do |type, name|
260
+ job_keyword_argument?(method_name, type, name)
261
+ end
262
+ end
263
+
264
+ def job_keyword_argument?(method_name, type, name)
265
+ # Match keyword parameters: double-splat (**kwargs), required (key:) or optional (key: default).
266
+ return false unless type == :keyrest || type == :keyreq || type == :key
267
+
268
+ # Ignore the `cursor:` argument, which is part of the `job-iteration`
269
+ # framework, and not a argument in the job's serialized argument list.
270
+ return false if method_name == :build_enumerator && name == :cursor
271
+
272
+ true
273
+ end
274
+
218
275
  def reenqueue_iteration_job
219
276
  ActiveSupport::Notifications.instrument(
220
277
  "interrupted.iteration",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JobIteration
4
- VERSION = "1.14.0"
4
+ VERSION = "1.15.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: job-iteration
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.14.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
@@ -78,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
78
  - !ruby/object:Gem::Version
79
79
  version: '0'
80
80
  requirements: []
81
- rubygems_version: 4.0.11
81
+ rubygems_version: 4.0.12
82
82
  specification_version: 4
83
83
  summary: Makes your background jobs interruptible and resumable.
84
84
  test_files: []