cmdx 1.19.0 → 1.21.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +82 -16
  4. data/README.md +1 -1
  5. data/lib/cmdx/attribute.rb +82 -19
  6. data/lib/cmdx/attribute_registry.rb +79 -8
  7. data/lib/cmdx/attribute_value.rb +2 -2
  8. data/lib/cmdx/callback_registry.rb +60 -26
  9. data/lib/cmdx/chain.rb +34 -5
  10. data/lib/cmdx/coercion_registry.rb +42 -20
  11. data/lib/cmdx/coercions/array.rb +2 -2
  12. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  13. data/lib/cmdx/coercions/boolean.rb +2 -2
  14. data/lib/cmdx/coercions/complex.rb +1 -1
  15. data/lib/cmdx/coercions/date.rb +1 -1
  16. data/lib/cmdx/coercions/date_time.rb +1 -1
  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 +1 -1
  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 -1
  24. data/lib/cmdx/configuration.rb +38 -0
  25. data/lib/cmdx/context.rb +11 -8
  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 +80 -53
  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/runtime.rb +18 -3
  35. data/lib/cmdx/middlewares/timeout.rb +11 -10
  36. data/lib/cmdx/parallelizer.rb +100 -0
  37. data/lib/cmdx/pipeline.rb +42 -23
  38. data/lib/cmdx/railtie.rb +1 -1
  39. data/lib/cmdx/result.rb +91 -19
  40. data/lib/cmdx/retry.rb +166 -0
  41. data/lib/cmdx/settings.rb +226 -0
  42. data/lib/cmdx/task.rb +62 -65
  43. data/lib/cmdx/utils/format.rb +17 -1
  44. data/lib/cmdx/utils/normalize.rb +52 -0
  45. data/lib/cmdx/utils/wrap.rb +38 -0
  46. data/lib/cmdx/validator_registry.rb +44 -19
  47. data/lib/cmdx/validators/absence.rb +1 -1
  48. data/lib/cmdx/validators/exclusion.rb +2 -2
  49. data/lib/cmdx/validators/format.rb +1 -1
  50. data/lib/cmdx/validators/inclusion.rb +2 -2
  51. data/lib/cmdx/validators/length.rb +1 -1
  52. data/lib/cmdx/validators/numeric.rb +1 -1
  53. data/lib/cmdx/validators/presence.rb +1 -1
  54. data/lib/cmdx/version.rb +1 -1
  55. data/lib/cmdx/workflow.rb +17 -0
  56. data/lib/cmdx.rb +12 -0
  57. data/lib/generators/cmdx/templates/install.rb +20 -5
  58. data/lib/locales/af.yml +2 -0
  59. data/lib/locales/ar.yml +2 -0
  60. data/lib/locales/az.yml +2 -0
  61. data/lib/locales/be.yml +2 -0
  62. data/lib/locales/bg.yml +2 -0
  63. data/lib/locales/bn.yml +2 -0
  64. data/lib/locales/bs.yml +2 -0
  65. data/lib/locales/ca.yml +2 -0
  66. data/lib/locales/cnr.yml +2 -0
  67. data/lib/locales/cs.yml +2 -0
  68. data/lib/locales/cy.yml +2 -0
  69. data/lib/locales/da.yml +2 -0
  70. data/lib/locales/de.yml +2 -0
  71. data/lib/locales/dz.yml +2 -0
  72. data/lib/locales/el.yml +2 -0
  73. data/lib/locales/en.yml +2 -0
  74. data/lib/locales/eo.yml +2 -0
  75. data/lib/locales/es.yml +2 -0
  76. data/lib/locales/et.yml +2 -0
  77. data/lib/locales/eu.yml +2 -0
  78. data/lib/locales/fa.yml +2 -0
  79. data/lib/locales/fi.yml +2 -0
  80. data/lib/locales/fr.yml +2 -0
  81. data/lib/locales/fy.yml +2 -0
  82. data/lib/locales/gd.yml +2 -0
  83. data/lib/locales/gl.yml +2 -0
  84. data/lib/locales/he.yml +2 -0
  85. data/lib/locales/hi.yml +2 -0
  86. data/lib/locales/hr.yml +2 -0
  87. data/lib/locales/hu.yml +2 -0
  88. data/lib/locales/hy.yml +2 -0
  89. data/lib/locales/id.yml +2 -0
  90. data/lib/locales/is.yml +2 -0
  91. data/lib/locales/it.yml +2 -0
  92. data/lib/locales/ja.yml +2 -0
  93. data/lib/locales/ka.yml +2 -0
  94. data/lib/locales/kk.yml +2 -0
  95. data/lib/locales/km.yml +2 -0
  96. data/lib/locales/kn.yml +2 -0
  97. data/lib/locales/ko.yml +2 -0
  98. data/lib/locales/lb.yml +2 -0
  99. data/lib/locales/lo.yml +2 -0
  100. data/lib/locales/lt.yml +2 -0
  101. data/lib/locales/lv.yml +2 -0
  102. data/lib/locales/mg.yml +2 -0
  103. data/lib/locales/mk.yml +2 -0
  104. data/lib/locales/ml.yml +2 -0
  105. data/lib/locales/mn.yml +2 -0
  106. data/lib/locales/mr-IN.yml +2 -0
  107. data/lib/locales/ms.yml +2 -0
  108. data/lib/locales/nb.yml +2 -0
  109. data/lib/locales/ne.yml +2 -0
  110. data/lib/locales/nl.yml +2 -0
  111. data/lib/locales/nn.yml +2 -0
  112. data/lib/locales/oc.yml +2 -0
  113. data/lib/locales/or.yml +2 -0
  114. data/lib/locales/pa.yml +2 -0
  115. data/lib/locales/pl.yml +2 -0
  116. data/lib/locales/pt.yml +2 -0
  117. data/lib/locales/rm.yml +2 -0
  118. data/lib/locales/ro.yml +2 -0
  119. data/lib/locales/ru.yml +2 -0
  120. data/lib/locales/sc.yml +2 -0
  121. data/lib/locales/sk.yml +2 -0
  122. data/lib/locales/sl.yml +2 -0
  123. data/lib/locales/sq.yml +2 -0
  124. data/lib/locales/sr.yml +2 -0
  125. data/lib/locales/st.yml +2 -0
  126. data/lib/locales/sv.yml +2 -0
  127. data/lib/locales/sw.yml +2 -0
  128. data/lib/locales/ta.yml +2 -0
  129. data/lib/locales/te.yml +2 -0
  130. data/lib/locales/th.yml +2 -0
  131. data/lib/locales/tl.yml +2 -0
  132. data/lib/locales/tr.yml +2 -0
  133. data/lib/locales/tt.yml +2 -0
  134. data/lib/locales/ug.yml +2 -0
  135. data/lib/locales/uk.yml +2 -0
  136. data/lib/locales/ur.yml +2 -0
  137. data/lib/locales/uz.yml +2 -0
  138. data/lib/locales/vi.yml +2 -0
  139. data/lib/locales/wo.yml +2 -0
  140. data/lib/locales/zh-CN.yml +2 -0
  141. data/lib/locales/zh-HK.yml +2 -0
  142. data/lib/locales/zh-TW.yml +2 -0
  143. data/lib/locales/zh-YUE.yml +2 -0
  144. data/mkdocs.yml +5 -1
  145. 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,30 +104,38 @@ 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
 
119
135
  protected
120
136
 
121
137
  # Determines if execution should halt based on breakpoint configuration.
138
+ # Returns false when the result was created with +strict: false+.
122
139
  #
123
140
  # @param exception [Exception] The exception that occurred
124
141
  #
@@ -126,10 +143,14 @@ module CMDx
126
143
  #
127
144
  # @rbs (Exception exception) -> bool
128
145
  def halt_execution?(exception)
129
- statuses = task.class.settings[:breakpoints] || task.class.settings[:task_breakpoints]
130
- statuses = Array(statuses).map(&:to_s).uniq
146
+ return false unless exception.result.strict?
131
147
 
132
- statuses.include?(exception.result.status)
148
+ @halt_statuses ||= Utils::Normalize.statuses(
149
+ task.class.settings.breakpoints ||
150
+ task.class.settings.task_breakpoints
151
+ ).freeze
152
+
153
+ @halt_statuses.include?(exception.result.status)
133
154
  end
134
155
 
135
156
  # Determines if execution should be retried based on retry configuration.
@@ -140,36 +161,22 @@ module CMDx
140
161
  #
141
162
  # @rbs (Exception exception) -> bool
142
163
  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?
164
+ @retry ||= Retry.new(task)
149
165
 
150
- exceptions = Array(task.class.settings[:retry_on] || StandardError)
151
- return false unless exceptions.any? { |e| exception.class <= e }
166
+ return false unless @retry.available? && @retry.remaining?
167
+ return false unless @retry.exception?(exception)
152
168
 
153
169
  result.retries += 1
154
170
 
155
171
  task.logger.warn do
156
- reason = "[#{exception.class}] #{exception.message}"
157
- task.to_h.merge!(reason:, remaining_retries:)
172
+ reason = Utils::Normalize.exception(exception)
173
+ task.to_h.merge!(reason:, remaining_retries: @retry.remaining)
158
174
  end
159
175
 
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
176
+ task.errors.clear
171
177
 
172
- sleep(jitter) if Float(jitter).positive?
178
+ wait = @retry.wait
179
+ sleep(wait) if wait.positive?
173
180
 
174
181
  true
175
182
  end
@@ -198,7 +205,7 @@ module CMDx
198
205
  #
199
206
  # @rbs (Symbol type) -> void
200
207
  def invoke_callbacks(type)
201
- task.class.settings[:callbacks].invoke(type, task)
208
+ task.class.settings.callbacks.invoke(type, task)
202
209
  end
203
210
 
204
211
  private
@@ -211,11 +218,12 @@ module CMDx
211
218
 
212
219
  invoke_callbacks(:before_validation)
213
220
 
214
- task.class.settings[:attributes].define_and_verify(task)
221
+ task.class.settings.attributes.define_and_verify(task)
215
222
  return if task.errors.empty?
216
223
 
217
224
  result.fail!(
218
225
  Locale.t("cmdx.faults.invalid"),
226
+ source: :validation,
219
227
  errors: {
220
228
  full_message: task.errors.to_s,
221
229
  messages: task.errors.to_h
@@ -224,22 +232,23 @@ module CMDx
224
232
  end
225
233
 
226
234
  # Executes the main task logic.
235
+ # Wraps task.work in catch(:cmdx_halt) so that success! can halt early.
227
236
  #
228
237
  # @rbs () -> void
229
238
  def execution!
230
239
  invoke_callbacks(:before_execution)
231
240
 
232
241
  result.executing!
233
- task.work
242
+ catch(:cmdx_halt) { task.work }
234
243
  end
235
244
 
236
245
  # Verifies that all declared returns are present in the context after execution.
237
246
  #
238
247
  # @rbs () -> void
239
- def verify_returns!
248
+ def verify_context_returns!
240
249
  return unless result.success?
241
250
 
242
- returns = Array(task.class.settings[:returns])
251
+ returns = Utils::Wrap.array(task.class.settings.returns)
243
252
  missing = returns.reject { |name| task.context.key?(name) }
244
253
  return if missing.empty?
245
254
 
@@ -247,6 +256,7 @@ module CMDx
247
256
 
248
257
  result.fail!(
249
258
  Locale.t("cmdx.faults.invalid"),
259
+ source: :context,
250
260
  errors: {
251
261
  full_message: task.errors.to_s,
252
262
  messages: task.errors.to_h
@@ -258,20 +268,37 @@ module CMDx
258
268
  #
259
269
  # @rbs () -> void
260
270
  def post_execution!
261
- invoke_callbacks(:"on_#{result.state}")
271
+ return if task.class.settings.callbacks.empty?
272
+
273
+ invoke_callbacks(STATE_CALLBACKS[result.state])
262
274
  invoke_callbacks(:on_executed) if result.executed?
263
275
 
264
- invoke_callbacks(:"on_#{result.status}")
276
+ invoke_callbacks(STATUS_CALLBACKS[result.status])
265
277
  invoke_callbacks(:on_good) if result.good?
266
278
  invoke_callbacks(:on_bad) if result.bad?
267
279
  end
268
280
 
281
+ # Detects if middleware swallowed the block without yielding.
282
+ # When this happens the result is still in the initialized state.
283
+ #
284
+ # @rbs () -> void
285
+ def verify_middleware_yield!
286
+ return unless result.initialized?
287
+
288
+ result.fail!(
289
+ Locale.t("cmdx.faults.invalid"),
290
+ halt: false,
291
+ source: :middleware
292
+ )
293
+ result.executed!
294
+ end
295
+
269
296
  # Finalizes execution by freezing the task, logging results, and rolling back work.
270
297
  #
271
- # @rbs () -> Result
298
+ # @rbs () -> void
272
299
  def finalize_execution!
273
300
  log_execution!
274
- log_backtrace! if task.class.settings[:backtrace]
301
+ log_backtrace! if task.class.settings.backtrace
275
302
 
276
303
  rollback_execution!
277
304
  freeze_execution!
@@ -295,8 +322,8 @@ module CMDx
295
322
  return if exception.nil? || exception.is_a?(Fault)
296
323
 
297
324
  task.logger.error do
298
- "[#{exception.class}] #{exception.message}\n" <<
299
- if (cleaner = task.class.settings[:backtrace_cleaner])
325
+ Utils::Normalize.exception(exception) << "\n" <<
326
+ if (cleaner = task.class.settings.backtrace_cleaner)
300
327
  cleaner.call(exception.backtrace).join("\n\t")
301
328
  else
302
329
  exception.full_message(highlight: false)
@@ -309,25 +336,26 @@ module CMDx
309
336
  # @rbs () -> void
310
337
  def freeze_execution!
311
338
  # 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)
339
+ return unless CMDx.configuration.freeze_results
314
340
 
315
341
  task.freeze
316
342
  result.freeze
317
343
 
318
- # Freezing the context and chain can only be done
319
- # once the outer-most task has completed.
344
+ # Freezing the context and chain can only be done once the outer-most
345
+ # task has completed.
320
346
  return unless result.index.zero?
321
347
 
322
348
  task.context.freeze
323
349
  task.chain.freeze
324
350
  end
325
351
 
326
- # Clears the chain if the task is the outermost (top-level) task.
352
+ # Clears the chain if the task is the outermost (top-level) task
353
+ # and the current thread's chain is the same instance this task belongs to.
327
354
  #
328
355
  # @rbs () -> void
329
356
  def clear_chain!
330
357
  return unless result.index.zero?
358
+ return unless Chain.current.equal?(task.chain)
331
359
 
332
360
  Chain.clear
333
361
  end
@@ -339,9 +367,8 @@ module CMDx
339
367
  return if result.rolled_back?
340
368
  return unless task.respond_to?(:rollback)
341
369
 
342
- statuses = task.class.settings[:rollback_on]
343
- statuses = Array(statuses).map(&:to_s).uniq
344
- return unless statuses.include?(result.status)
370
+ @rollback_statuses ||= Utils::Normalize.statuses(task.class.settings.rollback_on).freeze
371
+ return unless @rollback_statuses.include?(result.status)
345
372
 
346
373
  result.rolled_back = true
347
374
  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
@@ -11,7 +11,8 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
- # Middleware entry point that measures task execution runtime.
14
+ # Middleware entry point that measures task execution runtime and
15
+ # task execution start and end times.
15
16
  #
16
17
  # Evaluates the condition from options and measures execution time
17
18
  # if enabled. Uses monotonic clock for precise timing measurements
@@ -35,11 +36,16 @@ module CMDx
35
36
  #
36
37
  # @rbs (Task task, **untyped options) { () -> untyped } -> untyped
37
38
  def call(task, **options)
39
+ unow = utc_time
40
+ mnow = monotonic_time
38
41
  return yield unless Utils::Condition.evaluate(task, options)
39
42
 
40
- now = monotonic_time
41
43
  result = yield
42
- task.result.metadata[:runtime] = monotonic_time - now
44
+ task.result.metadata.merge!(
45
+ runtime: monotonic_time - mnow,
46
+ ended_at: utc_time,
47
+ started_at: unow
48
+ )
43
49
  result
44
50
  end
45
51
 
@@ -57,6 +63,15 @@ module CMDx
57
63
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
58
64
  end
59
65
 
66
+ # Gets the current UTC time in ISO 8601 format.
67
+ #
68
+ # @return [String] Current UTC time in ISO 8601 format
69
+ #
70
+ # @rbs () -> String
71
+ def utc_time
72
+ Time.now.utc.iso8601
73
+ end
74
+
60
75
  end
61
76
  end
62
77
  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