axn 0.1.0.pre.alpha.3 → 0.1.0.pre.alpha.4

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/CHANGELOG.md +15 -1
  4. data/Rakefile +102 -2
  5. data/docs/.vitepress/config.mjs +12 -8
  6. data/docs/advanced/conventions.md +1 -1
  7. data/docs/advanced/mountable.md +4 -90
  8. data/docs/advanced/profiling.md +26 -30
  9. data/docs/advanced/rough.md +27 -8
  10. data/docs/intro/overview.md +1 -1
  11. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  12. data/docs/recipes/memoization.md +102 -17
  13. data/docs/reference/async.md +269 -0
  14. data/docs/reference/class.md +113 -50
  15. data/docs/reference/configuration.md +226 -75
  16. data/docs/reference/form-object.md +252 -0
  17. data/docs/strategies/client.md +212 -0
  18. data/docs/strategies/form.md +235 -0
  19. data/docs/usage/setup.md +2 -2
  20. data/docs/usage/writing.md +99 -1
  21. data/lib/axn/async/adapters/active_job.rb +19 -10
  22. data/lib/axn/async/adapters/disabled.rb +15 -0
  23. data/lib/axn/async/adapters/sidekiq.rb +25 -32
  24. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  25. data/lib/axn/async/batch_enqueue.rb +99 -0
  26. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  27. data/lib/axn/async.rb +121 -4
  28. data/lib/axn/configuration.rb +53 -13
  29. data/lib/axn/context.rb +1 -0
  30. data/lib/axn/core/automatic_logging.rb +47 -51
  31. data/lib/axn/core/context/facade_inspector.rb +1 -1
  32. data/lib/axn/core/contract.rb +73 -30
  33. data/lib/axn/core/contract_for_subfields.rb +1 -1
  34. data/lib/axn/core/contract_validation.rb +14 -9
  35. data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
  36. data/lib/axn/core/default_call.rb +63 -0
  37. data/lib/axn/core/flow/exception_execution.rb +5 -0
  38. data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
  39. data/lib/axn/core/flow/handlers/invoker.rb +4 -30
  40. data/lib/axn/core/flow/handlers/matcher.rb +4 -14
  41. data/lib/axn/core/flow/messages.rb +1 -1
  42. data/lib/axn/core/hooks.rb +1 -0
  43. data/lib/axn/core/logging.rb +16 -5
  44. data/lib/axn/core/memoization.rb +53 -0
  45. data/lib/axn/core/tracing.rb +77 -4
  46. data/lib/axn/core/validation/validators/type_validator.rb +1 -1
  47. data/lib/axn/core.rb +31 -46
  48. data/lib/axn/extras/strategies/client.rb +150 -0
  49. data/lib/axn/extras/strategies/vernier.rb +121 -0
  50. data/lib/axn/extras.rb +4 -0
  51. data/lib/axn/factory.rb +22 -2
  52. data/lib/axn/form_object.rb +90 -0
  53. data/lib/axn/internal/logging.rb +5 -1
  54. data/lib/axn/mountable/helpers/class_builder.rb +41 -10
  55. data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
  56. data/lib/axn/mountable/inherit_profiles.rb +2 -2
  57. data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
  58. data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
  59. data/lib/axn/mountable.rb +41 -7
  60. data/lib/axn/rails/generators/axn_generator.rb +19 -1
  61. data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
  62. data/lib/axn/result.rb +2 -2
  63. data/lib/axn/strategies/form.rb +98 -0
  64. data/lib/axn/strategies/transaction.rb +7 -0
  65. data/lib/axn/util/callable.rb +120 -0
  66. data/lib/axn/util/contract_error_handling.rb +32 -0
  67. data/lib/axn/util/execution_context.rb +34 -0
  68. data/lib/axn/util/global_id_serialization.rb +52 -0
  69. data/lib/axn/util/logging.rb +87 -0
  70. data/lib/axn/version.rb +1 -1
  71. data/lib/axn.rb +9 -0
  72. metadata +22 -4
  73. data/lib/axn/core/profiling.rb +0 -124
  74. data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +0 -55
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Async
5
+ # Custom error for missing enqueues_each configuration
6
+ class MissingEnqueuesEachError < StandardError; end
7
+
8
+ # Shared trigger action for executing batch enqueueing in the background.
9
+ # Called by enqueue_all to iterate over configured fields asynchronously.
10
+ #
11
+ # Configure the async adapter via Axn.config.set_enqueue_all_async,
12
+ # or it defaults to Axn.config.set_default_async.
13
+ #
14
+ # @example Configure a specific queue for all enqueue_all jobs
15
+ # Axn.configure do |c|
16
+ # c.set_enqueue_all_async(:sidekiq, queue: :batch)
17
+ # end
18
+ class EnqueueAllOrchestrator
19
+ include Axn
20
+
21
+ # Disable automatic before/after logging - we log the count manually
22
+ log_calls false
23
+
24
+ expects :target_class_name
25
+ expects :static_args, default: {}, allow_blank: true
26
+
27
+ def call
28
+ target = target_class_name.constantize
29
+
30
+ # Deserialize static_args (convert GlobalID strings back to objects)
31
+ deserialized_static_args = Axn::Util::GlobalIdSerialization.deserialize(static_args)
32
+
33
+ count = self.class.execute_iteration(
34
+ target,
35
+ **deserialized_static_args,
36
+ on_progress: method(:set_logging_context),
37
+ )
38
+
39
+ message_parts = ["Batch enqueued #{count} jobs for #{target.name}"]
40
+ message_parts << "with explicit args: #{static_args.inspect}" if static_args.any?
41
+
42
+ Axn::Util::Logging.log_at_level(
43
+ self.class,
44
+ level: :info,
45
+ message_parts:,
46
+ error_context: "logging batch enqueue completion",
47
+ )
48
+ end
49
+
50
+ class << self
51
+ # Entry point for enqueue_all - validates upfront, then executes async
52
+ #
53
+ # @param target [Class] The action class to batch enqueue
54
+ # @param static_args [Hash] Static arguments passed to each job
55
+ # @return [String] Job ID from the async adapter
56
+ def enqueue_for(target, **static_args)
57
+ validate_async_configured!(target)
58
+
59
+ # Handle no-expects case: just call_async directly
60
+ return target.call_async(**static_args) if target.internal_field_configs.empty?
61
+
62
+ # Get configs and resolved static args
63
+ # Kwargs are split: scalars → resolved_static, enumerables → configs
64
+ configs, resolved_static = resolve_configs(target, static_args:)
65
+
66
+ # Validate static args upfront (raises ArgumentError if missing)
67
+ validate_static_args!(target, configs, resolved_static) if configs.any?
68
+
69
+ # Check if any configs came from kwargs (these have lambdas that can't be serialized)
70
+ # If so, we must execute iteration synchronously
71
+ has_kwarg_iteration = configs.any? { |c| c.from.is_a?(Proc) && static_args.key?(c.field) }
72
+
73
+ if has_kwarg_iteration
74
+ # Execute iteration synchronously - kwargs with iterables can't be serialized
75
+ kwarg_fields = configs.select { |c| c.from.is_a?(Proc) && static_args.key?(c.field) }.map(&:field)
76
+ info "[enqueue_all] Running in foreground: kwargs #{kwarg_fields.join(', ')} cannot be serialized for background execution"
77
+ execute_iteration_without_logging(target, **static_args)
78
+ else
79
+ # Serialize static_args for Sidekiq (convert GlobalID objects, stringify keys)
80
+ serialized_static_args = Axn::Util::GlobalIdSerialization.serialize(resolved_static)
81
+
82
+ # Execute iteration in background via EnqueueAllOrchestrator
83
+ call_async(target_class_name: target.name, static_args: serialized_static_args)
84
+ end
85
+ end
86
+
87
+ # Execute the actual iteration (called from #call in background)
88
+ # Returns the count of jobs enqueued
89
+ #
90
+ # @param target [Class] The action class to enqueue jobs for
91
+ # @param on_progress [Proc, nil] Callback to track iteration progress for logging context
92
+ # @param static_args [Hash] Static arguments to pass to each job
93
+ def execute_iteration(target, on_progress: nil, **static_args)
94
+ configs, resolved_static = resolve_configs(target, static_args:)
95
+ count = { value: 0 }
96
+ iterate(target:, configs:, index: 0, accumulated: {}, static_args: resolved_static, count:, on_progress:)
97
+ count[:value]
98
+ end
99
+
100
+ # Execute iteration with per-job async logging suppressed (for foreground execution)
101
+ def execute_iteration_without_logging(target, **static_args)
102
+ original_log_level = target.log_calls_level
103
+ target.log_calls_level = nil
104
+ execute_iteration(target, **static_args)
105
+ ensure
106
+ target.log_calls_level = original_log_level
107
+ end
108
+
109
+ private
110
+
111
+ # Builds iteration sources and resolves static args from kwargs
112
+ #
113
+ # Returns [configs, resolved_static_args] where:
114
+ # - configs: Array of Config objects for fields to iterate
115
+ # - resolved_static_args: Hash of field => value for static (non-iterated) fields
116
+ #
117
+ # Kwargs handling:
118
+ # - Scalar values → static args (override any inferred/explicit config)
119
+ # - Enumerable values → iteration source (replaces inferred/explicit config source)
120
+ # Exception: if field expects enumerable type (Array, etc), treat as scalar
121
+ def resolve_configs(target, static_args: {})
122
+ explicit_configs = target._batch_enqueue_configs
123
+ explicit_fields = explicit_configs.map(&:field)
124
+
125
+ resolved_static = {}
126
+ kwarg_configs = []
127
+
128
+ # Process kwargs: separate into static vs iterable
129
+ static_args.each do |field, value|
130
+ field_config = target.internal_field_configs.find { |c| c.field == field }
131
+
132
+ if should_iterate?(value, field_config)
133
+ # Enumerable kwarg → create a config to iterate over it
134
+ kwarg_configs << BatchEnqueue::Config.new(
135
+ field:,
136
+ from: -> { value },
137
+ via: nil,
138
+ filter_block: nil,
139
+ )
140
+ else
141
+ # Scalar kwarg → static arg
142
+ resolved_static[field] = value
143
+ end
144
+ end
145
+
146
+ # Fields covered by scalar kwargs shouldn't be iterated
147
+ scalar_fields = resolved_static.keys
148
+ iterable_kwarg_fields = kwarg_configs.map(&:field)
149
+
150
+ # Filter explicit configs: remove fields that are given as scalars
151
+ # but keep fields that are given as iterables (kwarg will override the source)
152
+ filtered_explicit = explicit_configs.reject { |c| scalar_fields.include?(c.field) }
153
+
154
+ # For explicit configs with matching kwarg iterables, the kwarg takes precedence
155
+ filtered_explicit = filtered_explicit.reject { |c| iterable_kwarg_fields.include?(c.field) }
156
+
157
+ # Infer configs for model: fields not already covered
158
+ exclude_from_inference = explicit_fields + scalar_fields + iterable_kwarg_fields
159
+ inferred = infer_configs_from_models(target, exclude: exclude_from_inference)
160
+
161
+ # Merge: inferred first (as defaults), then explicit (as overrides), then kwarg configs (as final overrides)
162
+ merged = inferred + filtered_explicit + kwarg_configs
163
+
164
+ # Sort for memory efficiency: model-based configs (using find_each) should be processed first
165
+ # to minimize memory usage in nested iterations
166
+ merged.sort_by! { |config| model_based_config?(config, target) ? 0 : 1 }
167
+
168
+ return [merged, resolved_static] if merged.any?
169
+
170
+ # No configs at all - error only if there are required fields not covered by static args
171
+ uncovered_fields = target.internal_field_configs.map(&:field) - resolved_static.keys
172
+ uncovered_required = uncovered_fields.reject do |field|
173
+ field_config = target.internal_field_configs.find { |c| c.field == field }
174
+ field_config&.default.present? || field_config&.validations&.dig(:allow_blank)
175
+ end
176
+
177
+ return [[], resolved_static] if uncovered_required.empty?
178
+
179
+ raise MissingEnqueuesEachError,
180
+ "#{target.name} has required fields (#{uncovered_required.join(', ')}) " \
181
+ "not covered by enqueues_each, model: declarations, or static args. " \
182
+ "Add `enqueues_each :field_name, from: -> { ... }` for fields to iterate, " \
183
+ "use `expects :field, model: SomeModel` where SomeModel responds to find_each, " \
184
+ "or pass the field as a static argument to enqueue_all."
185
+ end
186
+
187
+ # Infer configs from fields with model: declarations whose model responds to find_each
188
+ def infer_configs_from_models(target, exclude: [])
189
+ target.internal_field_configs.filter_map do |field_config|
190
+ next if exclude.include?(field_config.field)
191
+
192
+ model_config = field_config.validations&.dig(:model)
193
+ next unless model_config
194
+
195
+ model_class = model_config[:klass]
196
+ next unless model_class.respond_to?(:find_each)
197
+
198
+ # Create an inferred config (equivalent to `enqueues_each :field`)
199
+ BatchEnqueue::Config.new(field: field_config.field, from: nil, via: nil, filter_block: nil)
200
+ end
201
+ end
202
+
203
+ # Checks if a config is model-based (will use find_each for memory-efficient iteration)
204
+ def model_based_config?(config, target)
205
+ # Configs with nil 'from' are inferred from model declarations
206
+ return true if config.from.nil?
207
+
208
+ # For explicit configs, check if the field has a model declaration that supports find_each
209
+ field_config = target.internal_field_configs.find { |c| c.field == config.field }
210
+ model_config = field_config&.validations&.dig(:model)
211
+ return true if model_config && model_config[:klass].respond_to?(:find_each)
212
+
213
+ false
214
+ end
215
+
216
+ def validate_async_configured!(target)
217
+ # Set up default async configuration if none is set (same pattern as call_async)
218
+ if target._async_adapter.nil? && Axn.config._default_async_adapter.present?
219
+ target.async(
220
+ Axn.config._default_async_adapter,
221
+ **Axn.config._default_async_config,
222
+ &Axn.config._default_async_config_block
223
+ )
224
+ end
225
+
226
+ return if target._async_adapter.present? && target._async_adapter != false
227
+
228
+ raise NotImplementedError,
229
+ "#{target.name} does not have async configured. " \
230
+ "Add `async :sidekiq` or `async :active_job` to enable enqueue_all."
231
+ end
232
+
233
+ def validate_static_args!(target, configs, static_args)
234
+ enqueue_each_fields = configs.map(&:field)
235
+ all_expected_fields = target.internal_field_configs.map(&:field)
236
+ static_fields = all_expected_fields - enqueue_each_fields
237
+
238
+ # Check for required static fields (those without defaults and not optional)
239
+ required_static = static_fields.reject do |field|
240
+ field_config = target.internal_field_configs.find { |c| c.field == field }
241
+ next true if field_config&.default.present?
242
+ next true if field_config&.validations&.dig(:allow_blank)
243
+
244
+ false
245
+ end
246
+
247
+ missing = required_static - static_args.keys
248
+ return unless missing.any?
249
+
250
+ raise ArgumentError,
251
+ "Missing required static field(s): #{missing.join(', ')}. " \
252
+ "These fields are not covered by enqueues_each and must be provided."
253
+ end
254
+
255
+ def iterate(target:, configs:, index:, accumulated:, static_args:, count:, on_progress:)
256
+ # Base case: all fields accumulated, enqueue the job
257
+ if index >= configs.length
258
+ on_progress&.call(stage: :enqueueing, enqueue_args: accumulated.merge(static_args))
259
+ target.call_async(**accumulated, **static_args)
260
+ count[:value] += 1
261
+ return
262
+ end
263
+
264
+ config = configs[index]
265
+
266
+ # Track which field's source we're resolving
267
+ on_progress&.call(stage: :resolving_source, field: config.field)
268
+ source = config.resolve_source(target:)
269
+
270
+ # Use find_each if available (ActiveRecord), otherwise each
271
+ iterator = source.respond_to?(:find_each) ? :find_each : :each
272
+
273
+ source.public_send(iterator) do |item|
274
+ # Track current item being processed
275
+ item_id = item.try(:id) || item.to_s.truncate(100)
276
+ on_progress&.call(stage: :iterating, field: config.field, current_item_id: item_id)
277
+
278
+ # Apply filter block if present - swallow errors, skip item
279
+ if config.filter_block
280
+ filter_result = begin
281
+ config.filter_block.call(item)
282
+ rescue StandardError => e
283
+ Axn::Internal::Logging.piping_error(
284
+ "filter block for :#{config.field}",
285
+ exception: e,
286
+ )
287
+ false
288
+ end
289
+ next unless filter_result
290
+ end
291
+
292
+ # Apply via extraction if present - swallow errors, skip item
293
+ value = if config.via
294
+ begin
295
+ item.public_send(config.via)
296
+ rescue StandardError => e
297
+ Axn::Internal::Logging.piping_error(
298
+ "via extraction (:#{config.via}) for :#{config.field}",
299
+ exception: e,
300
+ )
301
+ next
302
+ end
303
+ else
304
+ item
305
+ end
306
+
307
+ # Recurse to next field
308
+ iterate(
309
+ target:,
310
+ configs:,
311
+ index: index + 1,
312
+ accumulated: accumulated.merge(config.field => value),
313
+ static_args:,
314
+ count:,
315
+ on_progress:,
316
+ )
317
+ end
318
+ end
319
+
320
+ # Determines if a kwarg value should be iterated over or used as static
321
+ #
322
+ # @param value [Object] The value passed in kwargs
323
+ # @param field_config [Object, nil] The field's config from internal_field_configs
324
+ # @return [Boolean] true if we should iterate over the value
325
+ def should_iterate?(value, field_config)
326
+ return false unless value.respond_to?(:each)
327
+ return false if value.is_a?(String) || value.is_a?(Hash)
328
+ return false if field_expects_enumerable?(field_config)
329
+
330
+ true
331
+ end
332
+
333
+ # Checks if a field expects an enumerable type (Array, Set, etc)
334
+ #
335
+ # @param field_config [Object, nil] The field's config from internal_field_configs
336
+ # @return [Boolean] true if the field expects an enumerable
337
+ def field_expects_enumerable?(field_config)
338
+ return false unless field_config
339
+
340
+ type_config = field_config.validations&.dig(:type)
341
+ return false unless type_config
342
+
343
+ # type: Array becomes { klass: Array } after syntactic sugar
344
+ expected_type = type_config[:klass]
345
+ return false unless expected_type
346
+
347
+ # Handle array of types (e.g., type: [Array, Hash])
348
+ Array(expected_type).any? do |type|
349
+ next false unless type.is_a?(Class)
350
+
351
+ ENUMERABLE_TYPES.any? { |enum_type| type <= enum_type }
352
+ end
353
+ rescue TypeError
354
+ # expected_type might be a Symbol or something that doesn't support <=
355
+ false
356
+ end
357
+
358
+ # Types that are considered enumerable for the purposes of iteration detection
359
+ ENUMERABLE_TYPES = [Array, Set].freeze
360
+ end
361
+ end
362
+ end
363
+ end
data/lib/axn/async.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "axn/async/adapters"
4
+ require "axn/async/batch_enqueue"
4
5
 
5
6
  module Axn
6
7
  module Async
@@ -8,6 +9,10 @@ module Axn
8
9
 
9
10
  included do
10
11
  class_attribute :_async_adapter, :_async_config, :_async_config_block, default: nil
12
+
13
+ # Include batch enqueue functionality
14
+ include BatchEnqueue
15
+ extend BatchEnqueue::DSL
11
16
  end
12
17
 
13
18
  class_methods do
@@ -29,16 +34,26 @@ module Axn
29
34
  end
30
35
  end
31
36
 
32
- def call_async(**)
37
+ def call_async(**kwargs)
33
38
  # Set up default async configuration if none is set
34
39
  if _async_adapter.nil?
35
40
  async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
36
41
  # Call ourselves again now that the adapter is included
37
- return call_async(**)
42
+ return call_async(**kwargs)
38
43
  end
39
44
 
40
- # This will be overridden by the included adapter module
41
- raise NotImplementedError, "No async adapter configured. Use e.g. `async :sidekiq` or `async :active_job` to enable background processing."
45
+ # Skip notification and logging for disabled adapter (it will raise immediately)
46
+ return _enqueue_async_job(kwargs) if _async_adapter == false
47
+
48
+ # Emit notification for async call
49
+ _emit_call_async_notification(kwargs)
50
+
51
+ # Log async invocation if logging is enabled
52
+ adapter_name = _async_adapter_name_for_logging
53
+ _log_async_invocation(kwargs, adapter_name:) if adapter_name && log_calls_level
54
+
55
+ # Delegate to adapter-specific enqueueing logic
56
+ _enqueue_async_job(kwargs)
42
57
  end
43
58
 
44
59
  # Ensure default async is applied when the class is first instantiated
@@ -50,12 +65,114 @@ module Axn
50
65
 
51
66
  private
52
67
 
68
+ def _emit_call_async_notification(kwargs)
69
+ resource = name || "AnonymousClass"
70
+ # Use dup to ensure kwargs modifications don't affect the notification payload
71
+ payload = { resource:, action_class: self, kwargs: kwargs.dup, adapter: _async_adapter_name }
72
+
73
+ ActiveSupport::Notifications.instrument("axn.call_async", payload)
74
+ rescue StandardError => e
75
+ # Don't raise in notification emission to avoid interfering with async enqueueing
76
+ Axn::Internal::Logging.piping_error("emitting notification for axn.call_async", action_class: self, exception: e)
77
+ end
78
+
79
+ def _log_async_invocation(kwargs, adapter_name:)
80
+ Axn::Util::Logging.log_at_level(
81
+ self,
82
+ level: log_calls_level,
83
+ message_parts: ["Enqueueing async execution via #{adapter_name}"],
84
+ join_string: " with: ",
85
+ before: _async_log_separator,
86
+ prefix: "[#{name.presence || 'Anonymous Class'}]",
87
+ error_context: "logging async invocation",
88
+ context_direction: :inbound,
89
+ context_data: kwargs,
90
+ )
91
+ end
92
+
93
+ def _async_log_separator
94
+ return if Axn.config.env.production?
95
+ return if Axn::Util::ExecutionContext.background?
96
+ return if Axn::Util::ExecutionContext.console?
97
+
98
+ "\n------\n"
99
+ end
100
+
101
+ # Hook method that must be implemented by async adapter modules.
102
+ #
103
+ # Adapters MUST:
104
+ # - Implement this method with adapter-specific enqueueing logic
105
+ # - NOT override `call_async` (the base implementation handles notifications, logging, and delegates here)
106
+ #
107
+ # The only exception is the Disabled adapter, which overrides `call_async` to raise immediately
108
+ # without emitting notifications.
109
+ #
110
+ # @param kwargs [Hash] The keyword arguments to pass to the action when it executes
111
+ # @return The result of enqueueing (typically a job ID or similar, adapter-specific)
112
+ def _enqueue_async_job(kwargs)
113
+ # This will be overridden by the included adapter module
114
+ raise NotImplementedError, "No async adapter configured. Use e.g. `async :sidekiq` or `async :active_job` to enable background processing."
115
+ end
116
+
117
+ def _async_adapter_name
118
+ if _async_adapter.nil?
119
+ "none"
120
+ elsif _async_adapter == false
121
+ "disabled"
122
+ else
123
+ _async_adapter.to_s
124
+ end
125
+ end
126
+
127
+ def _async_adapter_name_for_logging
128
+ return nil if _async_adapter.nil? || _async_adapter == false
129
+
130
+ _async_adapter_name
131
+ end
132
+
53
133
  def _ensure_default_async_configured
54
134
  return if _async_adapter.present?
55
135
  return unless Axn.config._default_async_adapter.present?
56
136
 
57
137
  async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
58
138
  end
139
+
140
+ # Extracts and normalizes _async options from kwargs.
141
+ # Returns normalized options hash (with string keys and converted durations) and removes _async from kwargs.
142
+ #
143
+ # @param kwargs [Hash] The keyword arguments (modified in place)
144
+ # @return [Hash, nil] Normalized async options hash, or nil if no _async options present
145
+ def _extract_and_normalize_async_options(kwargs)
146
+ async_options = kwargs.delete(:_async) if kwargs[:_async].is_a?(Hash)
147
+ _normalize_async_options(async_options) if async_options
148
+ end
149
+
150
+ # Normalizes _async options hash:
151
+ # - Converts symbol keys to string keys
152
+ # - Converts ActiveSupport::Duration values to integer seconds (for wait)
153
+ # - Preserves Time objects (for wait_until)
154
+ #
155
+ # @param async_hash [Hash, nil] The async options hash
156
+ # @return [Hash, nil] Normalized hash with string keys, or nil if input is not a hash
157
+ def _normalize_async_options(async_hash)
158
+ return nil unless async_hash.is_a?(Hash)
159
+
160
+ normalized = {}
161
+ async_hash.each do |key, value|
162
+ string_key = key.to_s
163
+
164
+ normalized[string_key] = case string_key
165
+ when "wait"
166
+ # Convert ActiveSupport::Duration to integer seconds
167
+ value.respond_to?(:to_i) ? value.to_i : value
168
+ else
169
+ # Preserve wait_until and other keys/values as-is
170
+ value
171
+ end
172
+ end
173
+
174
+ normalized
175
+ end
59
176
  end
60
177
  end
61
178
  end
@@ -6,7 +6,7 @@ module Axn
6
6
  end
7
7
 
8
8
  class Configuration
9
- attr_accessor :wrap_with_trace, :emit_metrics
9
+ attr_accessor :emit_metrics, :raise_piping_errors_in_dev
10
10
  attr_writer :logger, :env, :on_exception, :additional_includes, :log_level, :rails
11
11
 
12
12
  def log_level = @log_level ||= :info
@@ -23,32 +23,54 @@ module Axn
23
23
  @default_async_adapter = adapter unless adapter.nil?
24
24
  @default_async_config = config.any? ? config : {}
25
25
  @default_async_config_block = block_given? ? block : nil
26
+
27
+ _apply_async_to_enqueue_all_orchestrator
28
+ end
29
+
30
+ # Async configuration for EnqueueAllOrchestrator (used by enqueue_all_async)
31
+ # Defaults to the default async config if not explicitly set
32
+ def _enqueue_all_async_adapter = @enqueue_all_async_adapter || _default_async_adapter
33
+ def _enqueue_all_async_config = @enqueue_all_async_config || _default_async_config
34
+ def _enqueue_all_async_config_block = @enqueue_all_async_config_block || _default_async_config_block
35
+
36
+ def set_enqueue_all_async(adapter, **config, &block)
37
+ @enqueue_all_async_adapter = adapter
38
+ @enqueue_all_async_config = config.any? ? config : {}
39
+ @enqueue_all_async_config_block = block_given? ? block : nil
40
+
41
+ _apply_async_to_enqueue_all_orchestrator
26
42
  end
27
43
 
28
44
  def rails = @rails ||= RailsConfiguration.new
29
45
 
30
46
  def on_exception(e, action:, context: {})
31
- msg = "Handled exception (#{e.class.name}): #{e.message}"
47
+ if action.respond_to?(:result) && action.result.respond_to?(:error)
48
+ resolved_error = action.result.error
49
+ # Compare with the default fallback message instead of calling default_error
50
+ # to avoid triggering error message resolution multiple times
51
+ detail = resolved_error == Axn::Core::Flow::Handlers::Resolvers::MessageResolver::DEFAULT_ERROR ? e.message : resolved_error
52
+ else
53
+ detail = e.message
54
+ end
55
+
56
+ msg = "Handled exception (#{e.class.name}): #{detail}"
32
57
  msg = ("#" * 10) + " #{msg} " + ("#" * 10) unless Axn.config.env.production?
33
58
  action.log(msg)
34
59
 
35
60
  return unless @on_exception
36
61
 
37
- # Only pass the kwargs that the given block expects
38
- kwargs = @on_exception.parameters.select { |type, _name| %i[key keyreq].include?(type) }.map(&:last)
39
- kwarg_hash = {}
40
- kwarg_hash[:action] = action if kwargs.include?(:action)
41
- kwarg_hash[:context] = context if kwargs.include?(:context)
42
- if kwarg_hash.any?
43
- @on_exception.call(e, **kwarg_hash)
44
- else
45
- @on_exception.call(e)
46
- end
62
+ # Only pass the args and kwargs that the given block expects
63
+ Axn::Util::Callable.call_with_desired_shape(@on_exception, args: [e], kwargs: { action:, context: })
47
64
  end
48
65
 
49
66
  def logger
50
67
  @logger ||= begin
51
- Rails.logger
68
+ # Use sidekiq logger if in background
69
+ if Axn::Util::ExecutionContext.background? && defined?(Sidekiq)
70
+ Sidekiq.logger
71
+ else
72
+ Rails.logger
73
+ end
52
74
  rescue NameError
53
75
  Logger.new($stdout).tap do |l|
54
76
  l.level = Logger::INFO
@@ -60,6 +82,24 @@ module Axn
60
82
  @env ||= ENV["RACK_ENV"].presence || ENV["RAILS_ENV"].presence || "development"
61
83
  ActiveSupport::StringInquirer.new(@env)
62
84
  end
85
+
86
+ private
87
+
88
+ # Apply async config to EnqueueAllOrchestrator if it's already loaded.
89
+ # Called from set_default_async and set_enqueue_all_async to ensure the
90
+ # orchestrator has Sidekiq::Job included before any worker tries to process jobs.
91
+ def _apply_async_to_enqueue_all_orchestrator
92
+ return unless defined?(Axn::Async::EnqueueAllOrchestrator)
93
+
94
+ adapter = _enqueue_all_async_adapter
95
+ return if adapter.nil? || adapter == false
96
+
97
+ Axn::Async::EnqueueAllOrchestrator.async(
98
+ adapter,
99
+ **_enqueue_all_async_config,
100
+ &_enqueue_all_async_config_block
101
+ )
102
+ end
63
103
  end
64
104
 
65
105
  class << self
data/lib/axn/context.rb CHANGED
@@ -40,6 +40,7 @@ module Axn
40
40
  def __record_early_completion(message)
41
41
  @early_completion_message = message unless message == Axn::Internal::EarlyCompletion.new.message
42
42
  @early_completion = true
43
+ @finalized = true
43
44
  end
44
45
 
45
46
  def __early_completion_message = @early_completion_message.presence