job-iteration 1.13.1 → 1.14.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: e8e5e3fa1240a67b88f2b83290f7b01fcadb28b4a7be257f646d199cff9c2e97
4
- data.tar.gz: 6a2130de073c8c5eb29c13934f7e4bb73af31cf15fc33af74b5738b420a9b38b
3
+ metadata.gz: a6cd8bb708ce42d3333f9631f344bb33b506cd2e3d0fc8a5d73882f2f52a811a
4
+ data.tar.gz: 30b9ece97b5e981a61fbef2110e60fdffcbd95dfdeec14def3652f9384f7e4d5
5
5
  SHA512:
6
- metadata.gz: b6316aa38f0e6f7df7874a478253b6e285db61c6077089c43ef1edfce408ec1835cdd7d50d4ccf257e096677cc4fcdb23e67cc609efc48998ae91a0667a8974a
7
- data.tar.gz: ac9fdbf6afeb440010323c70aa1fcdf60288460788893b0a0dbc536684caeca3291a0e90c80142439c7aac4efbf4853039b482ae37078d2b2c69ab2d565680be
6
+ metadata.gz: cc9bd924dfbce3e582356a9b29edec62ca597c6b50cad39afb29970e9d1b1ce799678797b7441b6aa6732d25e8b5e8fd896f88b7dd118cae552f5acc50a31143
7
+ data.tar.gz: 532e91769910a4172d8f7773bfd59b3fa54372fed4b9fd22d6309afa0861a626a878dedb50972127658a76e90b651cf1837a7d87fc61deebc81655ee418698d9
data/CHANGELOG.md CHANGED
@@ -16,6 +16,22 @@ nil
16
16
 
17
17
  nil
18
18
 
19
+ ## v1.14.0 (May 14, 2026)
20
+
21
+ ### Breaking Changes
22
+
23
+ - [704](https://github.com/Shopify/job-iteration/pull/704) - Drop support for Rails 7.0. The minimum supported Rails version is now 7.1.
24
+
25
+ ### Features
26
+
27
+ - [702](https://github.com/Shopify/job-iteration/pull/702) - Add support for parallel iteration with `enumerator_builder.parallel`. This enqueues multiple jobs, allowing you to split up the work across multiple instances.
28
+ - [705](https://github.com/Shopify/job-iteration/pull/705) - Add support for parallel array iteration with `enumerator_builder.parallel_array`.
29
+ - [706](https://github.com/Shopify/job-iteration/pull/706) - Add support for parallel Active Record relation iteration with `enumerator_builder.parallel_active_record_on_records` and `enumerator_builder.parallel_active_record_on_batches`.
30
+
31
+ ### Bug fixes
32
+
33
+ - [709](https://github.com/Shopify/job-iteration/pull/709) - Fix an issue with parallel iteration when `instances` changed after child jobs were enqueued.
34
+
19
35
  ## v1.13.1 (Apr 28, 2026)
20
36
 
21
37
  ### Bug fixes
data/README.md CHANGED
@@ -167,7 +167,7 @@ Job-iteration currently supports the following queue adapters (in order of imple
167
167
  It supports the following platforms:
168
168
 
169
169
  - Ruby 3.1 and later
170
- - Rails 7.0 and later
170
+ - Rails 7.1 and later
171
171
 
172
172
  Support for older platforms that have reached end of life may occasionally be dropped if maintaining backwards compatibility is cumbersome.
173
173
 
@@ -26,5 +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_dependency("activejob", ">= 7.0")
29
+ spec.add_dependency("activejob", ">= 7.1")
30
30
  end
@@ -66,10 +66,6 @@ module JobIteration
66
66
  pkey = @column_mgr.primary_key
67
67
  pkey_values = primary_key_values
68
68
 
69
- # If the primary key is only composed of a single column, simplify the
70
- # query. This keeps us compatible with Rails prior to 7.1 where composite
71
- # primary keys were introduced along with the syntax that allows you to
72
- # query for multi-column values.
73
69
  if pkey.size <= 1
74
70
  pkey = pkey.first
75
71
  pkey_values = pkey_values.map(&:first)
@@ -18,7 +18,7 @@ module JobIteration
18
18
  end
19
19
  end
20
20
 
21
- def initialize(relation, columns = nil, position = nil)
21
+ def initialize(relation, columns, position, instance, instances)
22
22
  @columns = if columns
23
23
  Array(columns)
24
24
  else
@@ -35,6 +35,17 @@ module JobIteration
35
35
  end
36
36
 
37
37
  @base_relation = relation.reorder(@columns.join(","))
38
+
39
+ if instances.present?
40
+ pk = relation.primary_key
41
+ unless pk.is_a?(String) && relation.klass.column_for_attribute(pk).type == :integer
42
+ raise ArgumentError, "Parallel iteration requires a single integer primary key. " \
43
+ "For more complex cases, use the enumerator_builder.parallel primitive directly."
44
+ end
45
+
46
+ @base_relation = @base_relation.where("#{relation.table_name}.#{pk} % ? = ?", instances, instance)
47
+ end
48
+
38
49
  @reached_end = false
39
50
  end
40
51
 
@@ -56,7 +67,7 @@ module JobIteration
56
67
  self.position = @columns.map do |column|
57
68
  method = column.to_s.split(".").last
58
69
 
59
- if ActiveRecord.version >= Gem::Version.new("7.1.0.alpha") && method == "id"
70
+ if method == "id"
60
71
  record.id_value
61
72
  else
62
73
  record.send(method.to_sym)
@@ -7,7 +7,7 @@ module JobIteration
7
7
  class ActiveRecordEnumerator
8
8
  SQL_DATETIME_WITH_NSEC = "%Y-%m-%d %H:%M:%S.%N"
9
9
 
10
- def initialize(relation, columns: nil, batch_size: 100, timezone: nil, cursor: nil)
10
+ def initialize(relation, columns: nil, batch_size: 100, timezone: nil, cursor: nil, instance: nil, instances: nil)
11
11
  @relation = relation
12
12
  @batch_size = batch_size
13
13
  @timezone = timezone
@@ -17,6 +17,8 @@ module JobIteration
17
17
  Array(relation.primary_key).map { |pk| "#{relation.table_name}.#{pk}" }
18
18
  end
19
19
  @cursor = cursor
20
+ @instance = instance
21
+ @instances = instances
20
22
  end
21
23
 
22
24
  def records
@@ -39,7 +41,8 @@ module JobIteration
39
41
  end
40
42
 
41
43
  def size
42
- @relation.count(:all)
44
+ full_size = @relation.count(:all)
45
+ @instances.present? ? full_size / @instances : full_size
43
46
  end
44
47
 
45
48
  private
@@ -61,7 +64,7 @@ module JobIteration
61
64
  end
62
65
 
63
66
  def finder_cursor
64
- JobIteration::ActiveRecordCursor.new(@relation, @columns, @cursor)
67
+ JobIteration::ActiveRecordCursor.new(@relation, @columns, @cursor, @instance, @instances)
65
68
  end
66
69
 
67
70
  def column_value(record, attribute)
@@ -5,6 +5,7 @@ require_relative "active_record_enumerator"
5
5
  require_relative "csv_enumerator"
6
6
  require_relative "throttle_enumerator"
7
7
  require_relative "nested_enumerator"
8
+ require_relative "parallel_enumerator"
8
9
  require "forwardable"
9
10
 
10
11
  module JobIteration
@@ -65,6 +66,23 @@ module JobIteration
65
66
  wrap(self, enumerable.each_with_index.drop(drop).to_enum { enumerable.size - drop })
66
67
  end
67
68
 
69
+ # Builds an Enumerator that iterates over a given array, across +instances+ parallel jobs.
70
+ #
71
+ # Child job i iterates over the slice of the array starting at
72
+ # index (array.size / instances * i).floor and ending at index (array.size / instances * (i + 1)).floor - 1.
73
+ def build_parallel_array_enumerator(array, instances:, cursor:)
74
+ unless array.is_a?(Array)
75
+ raise ArgumentError, "array must be an Array"
76
+ end
77
+
78
+ build_parallel_enumerator(instances: instances, cursor: cursor) do |instance, instances, inner_cursor|
79
+ slice_start = (array.size.to_f / instances * instance).floor
80
+ next_slice_start = (array.size.to_f / instances * (instance + 1)).floor
81
+ slice = array[slice_start...next_slice_start]
82
+ build_array_enumerator(slice, cursor: inner_cursor)
83
+ end
84
+ end
85
+
68
86
  # Builds Enumerator from Active Record Relation. Each Enumerator tick moves the cursor one row forward.
69
87
  #
70
88
  # +columns:+ argument is used to build the actual query for iteration. +columns+: defaults to primary key:
@@ -103,6 +121,21 @@ module JobIteration
103
121
  wrap(self, enum)
104
122
  end
105
123
 
124
+ # Builds an Enumerator that iterates over a given Active Record Relation, across +instances+ parallel jobs.
125
+ #
126
+ # Child job i iterates over the records where the id is equal to (instance % instances).
127
+ def build_parallel_active_record_enumerator_on_records(scope, instances:, cursor:, **args)
128
+ build_parallel_enumerator(instances: instances, cursor: cursor) do |instance, instances, inner_cursor|
129
+ build_active_record_enumerator(
130
+ scope,
131
+ cursor: inner_cursor,
132
+ instances: instances,
133
+ instance: instance,
134
+ **args,
135
+ ).records
136
+ end
137
+ end
138
+
106
139
  # Builds Enumerator from Active Record Relation and enumerates on batches of records.
107
140
  # Each Enumerator tick moves the cursor +batch_size+ rows forward.
108
141
  #
@@ -118,6 +151,21 @@ module JobIteration
118
151
  wrap(self, enum)
119
152
  end
120
153
 
154
+ # Builds an Enumerator that iterates over a given Active Record Relation, across +instances+ parallel jobs, and enumerates on batches.
155
+ #
156
+ # Child job i iterates over the batches of records where the id is equal to (instance % instances).
157
+ def build_parallel_active_record_enumerator_on_batches(scope, instances:, cursor:, **args)
158
+ build_parallel_enumerator(instances: instances, cursor: cursor) do |instance, instances, inner_cursor|
159
+ build_active_record_enumerator(
160
+ scope,
161
+ cursor: inner_cursor,
162
+ instances: instances,
163
+ instance: instance,
164
+ **args,
165
+ ).batches
166
+ end
167
+ end
168
+
121
169
  # Builds Enumerator from Active Record Relation and enumerates on batches, yielding Active Record Relations.
122
170
  # See documentation for #build_active_record_enumerator_on_batches.
123
171
  def build_active_record_enumerator_on_batch_relations(scope, wrap: true, cursor:, **args)
@@ -185,27 +233,48 @@ module JobIteration
185
233
  wrap(self, enum)
186
234
  end
187
235
 
236
+ def build_parallel_enumerator(instances:, cursor:, &block)
237
+ unless instances.is_a?(Integer) && instances.positive?
238
+ raise ArgumentError, "instances must be a positive Integer"
239
+ end
240
+
241
+ return ParallelEnumerator::EnqueueJobs.new(instances) if cursor.nil?
242
+
243
+ enum = ParallelEnumerator.new(block, cursor: cursor).to_enum
244
+ wrap(self, enum)
245
+ end
246
+
188
247
  alias_method :once, :build_once_enumerator
189
248
  alias_method :times, :build_times_enumerator
190
249
  alias_method :array, :build_array_enumerator
250
+ alias_method :parallel_array, :build_parallel_array_enumerator
191
251
  alias_method :active_record_on_records, :build_active_record_enumerator_on_records
252
+ alias_method :parallel_active_record_on_records, :build_parallel_active_record_enumerator_on_records
192
253
  alias_method :active_record_on_batches, :build_active_record_enumerator_on_batches
254
+ alias_method :parallel_active_record_on_batches, :build_parallel_active_record_enumerator_on_batches
193
255
  alias_method :active_record_on_batch_relations, :build_active_record_enumerator_on_batch_relations
194
256
  alias_method :throttle, :build_throttle_enumerator
195
257
  alias_method :csv, :build_csv_enumerator
196
258
  alias_method :csv_on_batches, :build_csv_enumerator_on_batches
197
259
  alias_method :nested, :build_nested_enumerator
260
+ alias_method :parallel, :build_parallel_enumerator
198
261
 
199
262
  private
200
263
 
201
- def build_active_record_enumerator(scope, cursor:, **args)
264
+ def build_active_record_enumerator(scope, cursor:, instance: nil, instances: nil, **args)
202
265
  unless scope.is_a?(ActiveRecord::Relation)
203
266
  raise ArgumentError, "scope must be an ActiveRecord::Relation"
204
267
  end
205
268
 
269
+ if (instance.nil? && instances.present?) || (instance.present? && instances.nil?)
270
+ raise ArgumentError, "instance and instances must both be provided or both be nil"
271
+ end
272
+
206
273
  JobIteration::ActiveRecordEnumerator.new(
207
274
  scope,
208
275
  cursor: cursor,
276
+ instance: instance,
277
+ instances: instances,
209
278
  **args,
210
279
  )
211
280
  end
@@ -141,6 +141,14 @@ module JobIteration
141
141
  return
142
142
  end
143
143
 
144
+ if enumerator.is_a?(ParallelEnumerator::EnqueueJobs)
145
+ tags = instrumentation_tags.merge(instances: enumerator.instances)
146
+ ActiveSupport::Notifications.instrument("enqueue_parallel_jobs.iteration", tags) do
147
+ enumerator.enqueue_jobs(self)
148
+ end
149
+ return
150
+ end
151
+
144
152
  assert_enumerator!(enumerator)
145
153
 
146
154
  if executions == 1 && times_interrupted == 0
@@ -19,6 +19,12 @@ module JobIteration
19
19
  end
20
20
  end
21
21
 
22
+ def enqueue_parallel_jobs(event)
23
+ info do
24
+ "[JobIteration::Iteration] Enqueued #{event.payload[:instances]} parallel jobs."
25
+ end
26
+ end
27
+
22
28
  def interrupted(event)
23
29
  info do
24
30
  "[JobIteration::Iteration] Interrupting and re-enqueueing the job " \
@@ -0,0 +1,52 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module JobIteration
5
+ # ParallelEnumerator allows you to parallelize iterations.
6
+ class ParallelEnumerator
7
+ class EnqueueError < StandardError; end
8
+
9
+ class EnqueueJobs
10
+ def initialize(instances)
11
+ @instances = instances
12
+ end
13
+
14
+ attr_reader :instances
15
+
16
+ def enqueue_jobs(job)
17
+ child_jobs = instances.times.map do |index|
18
+ job.class.new(*job.arguments).tap do |child_job|
19
+ child_job.cursor_position = { "instance" => index, "instances" => instances, "inner_cursor" => nil }
20
+
21
+ # Carry forward potential overrides from the parent job
22
+ child_job.queue_name = job.queue_name
23
+ child_job.priority = job.priority if job.priority
24
+ end
25
+ end
26
+
27
+ ActiveJob.perform_all_later(child_jobs)
28
+
29
+ unless child_jobs.all?(&:successfully_enqueued?)
30
+ failed_count = instances - child_jobs.count(&:successfully_enqueued?)
31
+ raise EnqueueError, "Failed to enqueue #{failed_count} out of #{instances} child jobs"
32
+ end
33
+ end
34
+ end
35
+
36
+ def initialize(block, cursor:)
37
+ @instance = cursor["instance"]
38
+ @instances = cursor["instances"]
39
+ inner_cursor = cursor["inner_cursor"]
40
+ @inner_enum = block.call(@instance, @instances, inner_cursor)
41
+ end
42
+
43
+ def to_enum
44
+ Enumerator.new(-> { @inner_enum.size }) do |yielder|
45
+ @inner_enum.each do |object_from_enumerator, cursor_from_enumerator|
46
+ parallel_cursor = { "instance" => @instance, "instances" => @instances, "inner_cursor" => cursor_from_enumerator }
47
+ yielder.yield(object_from_enumerator, parallel_cursor)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -5,8 +5,7 @@ return unless defined?(Rails::Railtie)
5
5
  module JobIteration
6
6
  class Railtie < Rails::Railtie
7
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)
8
+ app.deprecators[:job_iteration] = JobIteration::Deprecation
10
9
  end
11
10
  end
12
11
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JobIteration
4
- VERSION = "1.13.1"
4
+ VERSION = "1.14.0"
5
5
  end
@@ -105,15 +105,11 @@ module Tapioca
105
105
  ).returns(T::Array[RBI::TypedParam])
106
106
  end
107
107
  def perform_later_parameters(parameters, returned_job_class)
108
- if ::Gem::Requirement.new(">= 7.0").satisfied_by?(::ActiveJob.gem_version)
109
- parameters.reject! { |typed_param| RBI::BlockParam === typed_param.param }
110
- parameters + [create_block_param(
111
- "block",
112
- type: "T.nilable(T.proc.params(job: #{returned_job_class}).void)",
113
- )]
114
- else
115
- parameters
116
- end
108
+ parameters.reject! { |typed_param| RBI::BlockParam === typed_param.param }
109
+ parameters + [create_block_param(
110
+ "block",
111
+ type: "T.nilable(T.proc.params(job: #{returned_job_class}).void)",
112
+ )]
117
113
  end
118
114
 
119
115
  class << self
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.13.1
4
+ version: 1.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.0'
18
+ version: '7.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '7.0'
25
+ version: '7.1'
26
26
  description: Makes your background jobs interruptible and resumable.
27
27
  email:
28
28
  - ops-accounts+shipit@shopify.com
@@ -52,6 +52,7 @@ files:
52
52
  - lib/job-iteration/iteration.rb
53
53
  - lib/job-iteration/log_subscriber.rb
54
54
  - lib/job-iteration/nested_enumerator.rb
55
+ - lib/job-iteration/parallel_enumerator.rb
55
56
  - lib/job-iteration/railtie.rb
56
57
  - lib/job-iteration/test_helper.rb
57
58
  - lib/job-iteration/throttle_enumerator.rb
@@ -77,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
78
  - !ruby/object:Gem::Version
78
79
  version: '0'
79
80
  requirements: []
80
- rubygems_version: 4.0.10
81
+ rubygems_version: 4.0.11
81
82
  specification_version: 4
82
83
  summary: Makes your background jobs interruptible and resumable.
83
84
  test_files: []