cmdx 1.18.0 → 1.20.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +73 -9
  4. data/README.md +1 -1
  5. data/lib/cmdx/attribute.rb +88 -20
  6. data/lib/cmdx/attribute_registry.rb +79 -8
  7. data/lib/cmdx/attribute_value.rb +8 -3
  8. data/lib/cmdx/callback_registry.rb +60 -26
  9. data/lib/cmdx/chain.rb +47 -4
  10. data/lib/cmdx/coercion_registry.rb +42 -20
  11. data/lib/cmdx/coercions/array.rb +8 -3
  12. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  13. data/lib/cmdx/coercions/boolean.rb +6 -2
  14. data/lib/cmdx/coercions/complex.rb +1 -1
  15. data/lib/cmdx/coercions/date.rb +2 -7
  16. data/lib/cmdx/coercions/date_time.rb +2 -7
  17. data/lib/cmdx/coercions/float.rb +1 -1
  18. data/lib/cmdx/coercions/hash.rb +1 -1
  19. data/lib/cmdx/coercions/integer.rb +4 -5
  20. data/lib/cmdx/coercions/rational.rb +1 -1
  21. data/lib/cmdx/coercions/string.rb +1 -1
  22. data/lib/cmdx/coercions/symbol.rb +1 -1
  23. data/lib/cmdx/coercions/time.rb +1 -7
  24. data/lib/cmdx/configuration.rb +26 -0
  25. data/lib/cmdx/context.rb +9 -6
  26. data/lib/cmdx/deprecator.rb +27 -14
  27. data/lib/cmdx/errors.rb +3 -4
  28. data/lib/cmdx/exception.rb +7 -0
  29. data/lib/cmdx/executor.rb +77 -54
  30. data/lib/cmdx/identifier.rb +4 -6
  31. data/lib/cmdx/locale.rb +32 -9
  32. data/lib/cmdx/middleware_registry.rb +43 -23
  33. data/lib/cmdx/middlewares/correlate.rb +4 -2
  34. data/lib/cmdx/middlewares/timeout.rb +11 -10
  35. data/lib/cmdx/parallelizer.rb +100 -0
  36. data/lib/cmdx/pipeline.rb +42 -23
  37. data/lib/cmdx/railtie.rb +1 -1
  38. data/lib/cmdx/result.rb +27 -11
  39. data/lib/cmdx/retry.rb +166 -0
  40. data/lib/cmdx/settings.rb +222 -0
  41. data/lib/cmdx/task.rb +53 -61
  42. data/lib/cmdx/utils/format.rb +17 -1
  43. data/lib/cmdx/utils/normalize.rb +52 -0
  44. data/lib/cmdx/utils/wrap.rb +38 -0
  45. data/lib/cmdx/validator_registry.rb +45 -20
  46. data/lib/cmdx/validators/absence.rb +1 -1
  47. data/lib/cmdx/validators/exclusion.rb +2 -2
  48. data/lib/cmdx/validators/format.rb +1 -1
  49. data/lib/cmdx/validators/inclusion.rb +2 -2
  50. data/lib/cmdx/validators/length.rb +1 -1
  51. data/lib/cmdx/validators/numeric.rb +1 -1
  52. data/lib/cmdx/validators/presence.rb +1 -1
  53. data/lib/cmdx/version.rb +1 -1
  54. data/lib/cmdx.rb +12 -0
  55. data/lib/generators/cmdx/templates/install.rb +11 -0
  56. data/mkdocs.yml +5 -1
  57. metadata +6 -15
data/lib/cmdx/executor.rb CHANGED
@@ -10,6 +10,14 @@ module CMDx
10
10
 
11
11
  extend Forwardable
12
12
 
13
+ # @rbs STATE_CALLBACKS: Hash[String, Symbol]
14
+ STATE_CALLBACKS = Result::STATES.to_h { |s| [s, :"on_#{s}"] }.freeze
15
+ private_constant :STATE_CALLBACKS
16
+
17
+ # @rbs STATUS_CALLBACKS: Hash[String, Symbol]
18
+ STATUS_CALLBACKS = Result::STATUSES.to_h { |s| [s, :"on_#{s}"] }.freeze
19
+ private_constant :STATUS_CALLBACKS
20
+
13
21
  # Returns the task being executed.
14
22
  #
15
23
  # @return [Task] The task instance
@@ -63,23 +71,24 @@ module CMDx
63
71
  #
64
72
  # @rbs () -> Result
65
73
  def execute
66
- task.class.settings[:middlewares].call!(task) do
74
+ task.class.settings.middlewares.call!(task) do
67
75
  pre_execution! unless @pre_execution
68
76
  execution!
69
- verify_returns!
77
+ verify_context_returns!
70
78
  rescue UndefinedMethodError => e
71
- raise(e) # No need to clear the Chain since exception is not being re-raised
79
+ raise_exception(e)
72
80
  rescue Fault => e
73
81
  result.throw!(e.result, halt: false, cause: e)
74
82
  rescue StandardError => e
75
83
  retry if retry_execution?(e)
76
- result.fail!("[#{e.class}] #{e.message}", halt: false, cause: e)
77
- task.class.settings[:exception_handler]&.call(task, e)
84
+ result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception)
85
+ task.class.settings.exception_handler&.call(task, e)
78
86
  ensure
79
87
  result.executed!
80
88
  post_execution!
81
89
  end
82
90
 
91
+ verify_middleware_yield!
83
92
  finalize_execution!
84
93
  end
85
94
 
@@ -95,24 +104,31 @@ module CMDx
95
104
  #
96
105
  # @rbs () -> Result
97
106
  def execute!
98
- task.class.settings[:middlewares].call!(task) do
107
+ task.class.settings.middlewares.call!(task) do
99
108
  pre_execution! unless @pre_execution
100
109
  execution!
101
- verify_returns!
110
+ verify_context_returns!
102
111
  rescue UndefinedMethodError => e
103
112
  raise_exception(e)
104
113
  rescue Fault => e
105
114
  result.throw!(e.result, halt: false, cause: e)
106
- halt_execution?(e) ? raise_exception(e) : post_execution!
115
+
116
+ if halt_execution?(e)
117
+ raise_exception(e)
118
+ else
119
+ result.executed!
120
+ post_execution!
121
+ end
107
122
  rescue StandardError => e
108
123
  retry if retry_execution?(e)
109
- result.fail!("[#{e.class}] #{e.message}", halt: false, cause: e)
124
+ result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception)
110
125
  raise_exception(e)
111
126
  else
112
127
  result.executed!
113
128
  post_execution!
114
129
  end
115
130
 
131
+ verify_middleware_yield!
116
132
  finalize_execution!
117
133
  end
118
134
 
@@ -126,10 +142,12 @@ module CMDx
126
142
  #
127
143
  # @rbs (Exception exception) -> bool
128
144
  def halt_execution?(exception)
129
- statuses = task.class.settings[:breakpoints] || task.class.settings[:task_breakpoints]
130
- statuses = Array(statuses).map(&:to_s).uniq
145
+ @halt_statuses ||= Utils::Normalize.statuses(
146
+ task.class.settings.breakpoints ||
147
+ task.class.settings.task_breakpoints
148
+ ).freeze
131
149
 
132
- statuses.include?(exception.result.status)
150
+ @halt_statuses.include?(exception.result.status)
133
151
  end
134
152
 
135
153
  # Determines if execution should be retried based on retry configuration.
@@ -140,36 +158,22 @@ module CMDx
140
158
  #
141
159
  # @rbs (Exception exception) -> bool
142
160
  def retry_execution?(exception)
143
- available_retries = Integer(task.class.settings[:retries] || 0)
144
- return false unless available_retries.positive?
145
-
146
- current_retry = result.retries
147
- remaining_retries = available_retries - current_retry
148
- return false unless remaining_retries.positive?
161
+ @retry ||= Retry.new(task)
149
162
 
150
- exceptions = Array(task.class.settings[:retry_on] || StandardError)
151
- return false unless exceptions.any? { |e| exception.class <= e }
163
+ return false unless @retry.available? && @retry.remaining?
164
+ return false unless @retry.exception?(exception)
152
165
 
153
166
  result.retries += 1
154
167
 
155
168
  task.logger.warn do
156
- reason = "[#{exception.class}] #{exception.message}"
157
- task.to_h.merge!(reason:, remaining_retries:)
169
+ reason = Utils::Normalize.exception(exception)
170
+ task.to_h.merge!(reason:, remaining_retries: @retry.remaining)
158
171
  end
159
172
 
160
- jitter = task.class.settings[:retry_jitter]
161
- jitter =
162
- if jitter.is_a?(Symbol)
163
- task.send(jitter, current_retry)
164
- elsif jitter.is_a?(Proc)
165
- task.instance_exec(current_retry, &jitter)
166
- elsif jitter.respond_to?(:call)
167
- jitter.call(task, current_retry)
168
- else
169
- jitter.to_f * current_retry
170
- end
173
+ task.errors.clear
171
174
 
172
- sleep(jitter) if jitter.positive?
175
+ wait = @retry.wait
176
+ sleep(wait) if wait.positive?
173
177
 
174
178
  true
175
179
  end
@@ -198,7 +202,7 @@ module CMDx
198
202
  #
199
203
  # @rbs (Symbol type) -> void
200
204
  def invoke_callbacks(type)
201
- task.class.settings[:callbacks].invoke(type, task)
205
+ task.class.settings.callbacks.invoke(type, task)
202
206
  end
203
207
 
204
208
  private
@@ -211,11 +215,12 @@ module CMDx
211
215
 
212
216
  invoke_callbacks(:before_validation)
213
217
 
214
- task.class.settings[:attributes].define_and_verify(task)
218
+ task.class.settings.attributes.define_and_verify(task)
215
219
  return if task.errors.empty?
216
220
 
217
221
  result.fail!(
218
222
  Locale.t("cmdx.faults.invalid"),
223
+ source: :validation,
219
224
  errors: {
220
225
  full_message: task.errors.to_s,
221
226
  messages: task.errors.to_h
@@ -236,10 +241,10 @@ module CMDx
236
241
  # Verifies that all declared returns are present in the context after execution.
237
242
  #
238
243
  # @rbs () -> void
239
- def verify_returns!
244
+ def verify_context_returns!
240
245
  return unless result.success?
241
246
 
242
- returns = Array(task.class.settings[:returns])
247
+ returns = Utils::Wrap.array(task.class.settings.returns)
243
248
  missing = returns.reject { |name| task.context.key?(name) }
244
249
  return if missing.empty?
245
250
 
@@ -247,6 +252,7 @@ module CMDx
247
252
 
248
253
  result.fail!(
249
254
  Locale.t("cmdx.faults.invalid"),
255
+ source: :context,
250
256
  errors: {
251
257
  full_message: task.errors.to_s,
252
258
  messages: task.errors.to_h
@@ -258,20 +264,37 @@ module CMDx
258
264
  #
259
265
  # @rbs () -> void
260
266
  def post_execution!
261
- invoke_callbacks(:"on_#{result.state}")
267
+ return if task.class.settings.callbacks.empty?
268
+
269
+ invoke_callbacks(STATE_CALLBACKS[result.state])
262
270
  invoke_callbacks(:on_executed) if result.executed?
263
271
 
264
- invoke_callbacks(:"on_#{result.status}")
272
+ invoke_callbacks(STATUS_CALLBACKS[result.status])
265
273
  invoke_callbacks(:on_good) if result.good?
266
274
  invoke_callbacks(:on_bad) if result.bad?
267
275
  end
268
276
 
277
+ # Detects if middleware swallowed the block without yielding.
278
+ # When this happens the result is still in the initialized state.
279
+ #
280
+ # @rbs () -> void
281
+ def verify_middleware_yield!
282
+ return unless result.initialized?
283
+
284
+ result.fail!(
285
+ Locale.t("cmdx.faults.invalid"),
286
+ halt: false,
287
+ source: :middleware
288
+ )
289
+ result.executed!
290
+ end
291
+
269
292
  # Finalizes execution by freezing the task, logging results, and rolling back work.
270
293
  #
271
- # @rbs () -> Result
294
+ # @rbs () -> void
272
295
  def finalize_execution!
273
296
  log_execution!
274
- log_backtrace! if task.class.settings[:backtrace]
297
+ log_backtrace! if task.class.settings.backtrace
275
298
 
276
299
  rollback_execution!
277
300
  freeze_execution!
@@ -291,12 +314,12 @@ module CMDx
291
314
  def log_backtrace!
292
315
  return unless result.failed?
293
316
 
294
- exception = result.caused_failure.cause
295
- return if exception.is_a?(Fault)
317
+ exception = result.caused_failure&.cause
318
+ return if exception.nil? || exception.is_a?(Fault)
296
319
 
297
320
  task.logger.error do
298
- "[#{exception.class}] #{exception.message}\n" <<
299
- if (cleaner = task.class.settings[:backtrace_cleaner])
321
+ Utils::Normalize.exception(exception) << "\n" <<
322
+ if (cleaner = task.class.settings.backtrace_cleaner)
300
323
  cleaner.call(exception.backtrace).join("\n\t")
301
324
  else
302
325
  exception.full_message(highlight: false)
@@ -309,25 +332,26 @@ module CMDx
309
332
  # @rbs () -> void
310
333
  def freeze_execution!
311
334
  # Stubbing on frozen objects is not allowed in most test environments.
312
- skip_freezing = ENV.fetch("SKIP_CMDX_FREEZING", false)
313
- return if Coercions::Boolean.call(skip_freezing)
335
+ return unless CMDx.configuration.freeze_results
314
336
 
315
337
  task.freeze
316
338
  result.freeze
317
339
 
318
- # Freezing the context and chain can only be done
319
- # once the outer-most task has completed.
340
+ # Freezing the context and chain can only be done once the outer-most
341
+ # task has completed.
320
342
  return unless result.index.zero?
321
343
 
322
344
  task.context.freeze
323
345
  task.chain.freeze
324
346
  end
325
347
 
326
- # Clears the chain if the task is the outermost (top-level) task.
348
+ # Clears the chain if the task is the outermost (top-level) task
349
+ # and the current thread's chain is the same instance this task belongs to.
327
350
  #
328
351
  # @rbs () -> void
329
352
  def clear_chain!
330
353
  return unless result.index.zero?
354
+ return unless Chain.current.equal?(task.chain)
331
355
 
332
356
  Chain.clear
333
357
  end
@@ -339,9 +363,8 @@ module CMDx
339
363
  return if result.rolled_back?
340
364
  return unless task.respond_to?(:rollback)
341
365
 
342
- statuses = task.class.settings[:rollback_on]
343
- statuses = Array(statuses).map(&:to_s).uniq
344
- return unless statuses.include?(result.status)
366
+ @rollback_statuses ||= Utils::Normalize.statuses(task.class.settings.rollback_on).freeze
367
+ return unless @rollback_statuses.include?(result.status)
345
368
 
346
369
  result.rolled_back = true
347
370
  task.rollback
@@ -20,12 +20,10 @@ module CMDx
20
20
  # # => "01890b2c-1234-5678-9abc-def123456789"
21
21
  #
22
22
  # @rbs () -> String
23
- def generate
24
- if SecureRandom.respond_to?(:uuid_v7)
25
- SecureRandom.uuid_v7
26
- else
27
- SecureRandom.uuid
28
- end
23
+ if SecureRandom.respond_to?(:uuid_v7)
24
+ def generate = SecureRandom.uuid_v7
25
+ else
26
+ def generate = SecureRandom.uuid
29
27
  end
30
28
 
31
29
  end
data/lib/cmdx/locale.rb CHANGED
@@ -2,18 +2,15 @@
2
2
 
3
3
  module CMDx
4
4
  # Provides internationalization and localization support for CMDx.
5
- # Handles translation lookups with fallback to default English messages
6
- # when I18n gem is not available.
5
+ # Handles translation lookups with fallback to the configured locale's
6
+ # default messages when I18n gem is not available.
7
7
  module Locale
8
8
 
9
9
  extend self
10
10
 
11
- # @rbs EN: Hash[String, untyped]
12
- EN = YAML.load_file(CMDx.gem_path.join("lib/locales/en.yml")).freeze
13
- private_constant :EN
14
-
15
11
  # Translates a key to the current locale with optional interpolation.
16
- # Falls back to English translations if I18n gem is unavailable.
12
+ # Falls back to the configured locale's YAML file translations if
13
+ # I18n gem is unavailable.
17
14
  #
18
15
  # @param key [String, Symbol] The translation key (supports dot notation)
19
16
  # @param options [Hash] Translation options
@@ -38,12 +35,12 @@ module CMDx
38
35
  #
39
36
  # @rbs ((String | Symbol) key, **untyped options) -> String
40
37
  def translate(key, **options)
41
- options[:default] ||= EN.dig("en", *key.to_s.split("."))
38
+ options[:default] ||= translation_default(key)
42
39
  return ::I18n.t(key, **options) if defined?(::I18n)
43
40
 
44
41
  case message = options.delete(:default)
45
- when NilClass then "Translation missing: #{key}"
46
42
  when String then message % options
43
+ when NilClass then "Translation missing: #{key}"
47
44
  else message
48
45
  end
49
46
  end
@@ -51,5 +48,31 @@ module CMDx
51
48
  # @see #translate
52
49
  alias t translate
53
50
 
51
+ private
52
+
53
+ # Resolves and caches the default translation for a key by digging
54
+ # into the configured locale's YAML translations.
55
+ #
56
+ # @param key [String, Symbol] The translation key
57
+ #
58
+ # @return [String, nil] The resolved translation or nil
59
+ #
60
+ # @rbs ((String | Symbol) key) -> String?
61
+ def translation_default(key)
62
+ tkey = "#{CMDx.configuration.default_locale}.#{key}"
63
+
64
+ @translation_defaults ||= {}
65
+ return @translation_defaults[tkey] if @translation_defaults.key?(tkey)
66
+
67
+ @default_translations ||= begin
68
+ path = CMDx.gem_path.join("lib/locales/#{CMDx.configuration.default_locale}.yml")
69
+ raise ArgumentError, "locale file not found: #{path}" unless path.exist?
70
+
71
+ YAML.load_file(path).freeze
72
+ end
73
+
74
+ @translation_defaults[tkey] = @default_translations.dig(*tkey.split("."))
75
+ end
76
+
54
77
  end
55
78
  end
@@ -7,43 +7,48 @@ module CMDx
7
7
  # that can be inserted, removed, and executed in sequence. Each middleware
8
8
  # can be configured with specific options and is executed in the order
9
9
  # they were registered.
10
+ #
11
+ # Supports copy-on-write semantics: a duped registry shares the parent's
12
+ # data until a write operation triggers materialization.
10
13
  class MiddlewareRegistry
11
14
 
12
- # Returns the ordered collection of middleware entries.
13
- #
14
- # @return [Array<Array>] Array of middleware-options pairs
15
- #
16
- # @example
17
- # registry.registry # => [[LoggingMiddleware, {level: :debug}], [AuthMiddleware, {}]]
18
- #
19
- # @rbs @registry: Array[Array[untyped]]
20
- attr_reader :registry
21
- alias to_a registry
22
-
23
15
  # Initialize a new middleware registry.
24
16
  #
25
- # @param registry [Array] Initial array of middleware entries
17
+ # @param registry [Array, nil] Initial array of middleware entries
26
18
  #
27
19
  # @example
28
20
  # registry = MiddlewareRegistry.new
29
21
  # registry = MiddlewareRegistry.new([[MyMiddleware, {option: 'value'}]])
30
22
  #
31
- # @rbs (?Array[Array[untyped]] registry) -> void
32
- def initialize(registry = [])
33
- @registry = registry
23
+ # @rbs (?Array[Array[untyped]]? registry) -> void
24
+ def initialize(registry = nil)
25
+ @registry = registry || []
26
+ end
27
+
28
+ # Sets up copy-on-write state when duplicated via dup.
29
+ #
30
+ # @param source [MiddlewareRegistry] The registry being duplicated
31
+ #
32
+ # @rbs (MiddlewareRegistry source) -> void
33
+ def initialize_dup(source)
34
+ @parent = source
35
+ @registry = nil
36
+ super
34
37
  end
35
38
 
36
- # Create a duplicate of the registry with duplicated middleware entries.
39
+ # Returns the ordered collection of middleware entries.
40
+ # Delegates to the parent registry when not yet materialized.
37
41
  #
38
- # @return [MiddlewareRegistry] A new registry instance with duplicated entries
42
+ # @return [Array<Array>] Array of middleware-options pairs
39
43
  #
40
44
  # @example
41
- # new_registry = registry.dup
45
+ # registry.registry # => [[LoggingMiddleware, {level: :debug}], [AuthMiddleware, {}]]
42
46
  #
43
- # @rbs () -> MiddlewareRegistry
44
- def dup
45
- self.class.new(registry.map(&:dup))
47
+ # @rbs () -> Array[Array[untyped]]
48
+ def registry
49
+ @registry || @parent.registry
46
50
  end
51
+ alias to_a registry
47
52
 
48
53
  # Register a middleware component in the registry.
49
54
  #
@@ -61,7 +66,9 @@ module CMDx
61
66
  #
62
67
  # @rbs (untyped middleware, ?at: Integer, **untyped options) -> self
63
68
  def register(middleware, at: -1, **options)
64
- registry.insert(at, [middleware, options])
69
+ materialize!
70
+
71
+ @registry.insert(at, [middleware, options])
65
72
  self
66
73
  end
67
74
 
@@ -76,7 +83,9 @@ module CMDx
76
83
  #
77
84
  # @rbs (untyped middleware) -> self
78
85
  def deregister(middleware)
79
- registry.reject! { |mw, _opts| mw == middleware }
86
+ materialize!
87
+
88
+ @registry.reject! { |mw, _opts| mw == middleware }
80
89
  self
81
90
  end
82
91
 
@@ -105,6 +114,17 @@ module CMDx
105
114
 
106
115
  private
107
116
 
117
+ # Copies the parent's registry data into this instance,
118
+ # severing the copy-on-write link.
119
+ #
120
+ # @rbs () -> void
121
+ def materialize!
122
+ return if @registry
123
+
124
+ @registry = @parent.registry.map(&:dup)
125
+ @parent = nil
126
+ end
127
+
108
128
  # Recursively execute middleware in the chain.
109
129
  #
110
130
  # @param index [Integer] Current middleware index in the chain
@@ -129,8 +129,10 @@ module CMDx
129
129
  # @return [Hash] The thread or fiber storage
130
130
  #
131
131
  # @rbs () -> Hash
132
- def thread_or_fiber
133
- Fiber.respond_to?(:storage) ? Fiber.storage : Thread.current
132
+ if Fiber.respond_to?(:storage)
133
+ def thread_or_fiber = Fiber.storage
134
+ else
135
+ def thread_or_fiber = Thread.current
134
136
  end
135
137
 
136
138
  end
@@ -1,14 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
-
5
- # Error raised when task execution exceeds the configured timeout limit.
6
- #
7
- # This error occurs when a task takes longer to execute than the specified
8
- # time limit. Timeout errors are raised by Ruby's Timeout module and are
9
- # caught by the middleware to properly fail the task with timeout information.
10
- TimeoutError = Class.new(Interrupt)
11
-
12
4
  module Middlewares
13
5
  # Middleware for enforcing execution time limits on tasks.
14
6
  #
@@ -66,12 +58,21 @@ module CMDx
66
58
  else callable.respond_to?(:call) ? callable.call(task) : DEFAULT_LIMIT
67
59
  end
68
60
 
61
+ limit = Float(limit)
62
+ return yield unless limit.positive?
63
+
69
64
  ::Timeout.timeout(limit, TimeoutError, "execution exceeded #{limit} seconds", &)
70
65
  rescue TimeoutError => e
71
- task.result.tap { |r| r.fail!("[#{e.class}] #{e.message}", cause: e, limit:) }
66
+ task.result.tap do |r|
67
+ r.fail!(
68
+ Utils::Normalize.exception(e),
69
+ cause: e,
70
+ source: :timeout,
71
+ limit:
72
+ )
73
+ end
72
74
  end
73
75
 
74
76
  end
75
77
  end
76
-
77
78
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Bounded thread pool that processes items concurrently.
5
+ #
6
+ # Distributes work across a fixed number of threads using a queue,
7
+ # collecting results in submission order.
8
+ class Parallelizer
9
+
10
+ # Returns the items to process.
11
+ #
12
+ # @return [Array] the items to process
13
+ #
14
+ # @example
15
+ # parallelizer.items # => [1, 2, 3]
16
+ #
17
+ # @rbs @items: Array[untyped]
18
+ attr_reader :items
19
+
20
+ # Returns the number of threads in the pool.
21
+ #
22
+ # @return [Integer] the thread pool size
23
+ #
24
+ # @example
25
+ # parallelizer.pool_size # => 4
26
+ #
27
+ # @rbs @pool_size: Integer
28
+ attr_reader :pool_size
29
+
30
+ # Creates a new Parallelizer instance.
31
+ #
32
+ # @param items [Array] the items to process concurrently
33
+ # @param pool_size [Integer] number of threads (defaults to item count)
34
+ #
35
+ # @return [Parallelizer] a new parallelizer instance
36
+ #
37
+ # @example
38
+ # Parallelizer.new([1, 2, 3], pool_size: 2)
39
+ #
40
+ # @rbs (Array[untyped] items, ?pool_size: Integer) -> void
41
+ def initialize(items, pool_size: nil)
42
+ @items = items
43
+ @pool_size = Integer(pool_size || items.size)
44
+ end
45
+
46
+ # Processes items concurrently and returns results in submission order.
47
+ #
48
+ # @param items [Array] the items to process concurrently
49
+ # @param pool_size [Integer] number of threads (defaults to item count)
50
+ #
51
+ # @yield [item] block called for each item in a worker thread
52
+ # @yieldparam item [Object] an item from the items array
53
+ # @yieldreturn [Object] the result for this item
54
+ #
55
+ # @return [Array] results in the same order as input items
56
+ #
57
+ # @example
58
+ # Parallelizer.call([1, 2, 3], pool_size: 2) { |n| n * 10 }
59
+ # # => [10, 20, 30]
60
+ #
61
+ # @rbs [T, R] (Array[T] items, ?pool_size: Integer) { (T) -> R } -> Array[R]
62
+ def self.call(items, pool_size: nil, &block)
63
+ new(items, pool_size:).call(&block)
64
+ end
65
+
66
+ # Distributes items across the thread pool and returns results
67
+ # in submission order.
68
+ #
69
+ # @yield [item] block called for each item in a worker thread
70
+ # @yieldparam item [Object] an item from the items array
71
+ # @yieldreturn [Object] the result for this item
72
+ #
73
+ # @return [Array] results in the same order as input items
74
+ #
75
+ # @example
76
+ # Parallelizer.new(%w[a b c]).call { |s| s.upcase }
77
+ # # => ["A", "B", "C"]
78
+ #
79
+ # @rbs [T, R] () { (T) -> R } -> Array[R]
80
+ def call(&block)
81
+ results = Array.new(items.size)
82
+ queue = Queue.new
83
+
84
+ items.each_with_index { |item, i| queue << [item, i] }
85
+ pool_size.times { queue << nil }
86
+
87
+ Array.new(pool_size) do
88
+ Thread.new do
89
+ while (entry = queue.pop)
90
+ item, index = entry
91
+ results[index] = block.call(item) # rubocop:disable Performance/RedundantBlockCall
92
+ end
93
+ end
94
+ end.each(&:join)
95
+
96
+ results
97
+ end
98
+
99
+ end
100
+ end