axn 0.1.0.pre.alpha.2.8.1 → 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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/.cursor/rules/axn-framework-patterns.mdc +43 -0
  4. data/.cursor/rules/general-coding-standards.mdc +27 -0
  5. data/.cursor/rules/spec/testing-patterns.mdc +40 -0
  6. data/CHANGELOG.md +57 -0
  7. data/Rakefile +114 -4
  8. data/docs/.vitepress/config.mjs +19 -10
  9. data/docs/advanced/conventions.md +3 -3
  10. data/docs/advanced/mountable.md +476 -0
  11. data/docs/advanced/profiling.md +351 -0
  12. data/docs/advanced/rough.md +27 -8
  13. data/docs/index.md +5 -3
  14. data/docs/intro/about.md +1 -1
  15. data/docs/intro/overview.md +6 -6
  16. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  17. data/docs/recipes/memoization.md +103 -18
  18. data/docs/recipes/rubocop-integration.md +38 -284
  19. data/docs/recipes/testing.md +14 -14
  20. data/docs/recipes/validating-user-input.md +1 -1
  21. data/docs/reference/async.md +429 -0
  22. data/docs/reference/axn-result.md +107 -0
  23. data/docs/reference/class.md +225 -64
  24. data/docs/reference/configuration.md +366 -34
  25. data/docs/reference/form-object.md +252 -0
  26. data/docs/reference/instance.md +14 -29
  27. data/docs/strategies/client.md +212 -0
  28. data/docs/strategies/form.md +235 -0
  29. data/docs/strategies/index.md +21 -21
  30. data/docs/strategies/transaction.md +1 -1
  31. data/docs/usage/setup.md +16 -2
  32. data/docs/usage/steps.md +7 -7
  33. data/docs/usage/using.md +23 -12
  34. data/docs/usage/writing.md +191 -12
  35. data/lib/axn/async/adapters/active_job.rb +74 -0
  36. data/lib/axn/async/adapters/disabled.rb +41 -0
  37. data/lib/axn/async/adapters/sidekiq.rb +67 -0
  38. data/lib/axn/async/adapters.rb +26 -0
  39. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  40. data/lib/axn/async/batch_enqueue.rb +99 -0
  41. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  42. data/lib/axn/async.rb +178 -0
  43. data/lib/axn/configuration.rb +113 -0
  44. data/lib/{action → axn}/context.rb +22 -4
  45. data/lib/axn/core/automatic_logging.rb +89 -0
  46. data/lib/axn/core/context/facade.rb +69 -0
  47. data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
  48. data/lib/{action → axn}/core/context/internal.rb +5 -5
  49. data/lib/{action → axn}/core/contract.rb +111 -73
  50. data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
  51. data/lib/{action → axn}/core/contract_validation.rb +27 -12
  52. data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
  53. data/lib/axn/core/default_call.rb +63 -0
  54. data/lib/axn/core/field_resolvers/extract.rb +32 -0
  55. data/lib/axn/core/field_resolvers/model.rb +63 -0
  56. data/lib/axn/core/field_resolvers.rb +24 -0
  57. data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
  58. data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
  59. data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
  60. data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
  61. data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
  62. data/lib/axn/core/flow/handlers/invoker.rb +47 -0
  63. data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
  64. data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
  65. data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
  66. data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
  67. data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
  68. data/lib/axn/core/flow/handlers.rb +20 -0
  69. data/lib/{action → axn}/core/flow/messages.rb +8 -8
  70. data/lib/{action → axn}/core/flow.rb +4 -4
  71. data/lib/{action → axn}/core/hooks.rb +17 -5
  72. data/lib/axn/core/logging.rb +48 -0
  73. data/lib/axn/core/memoization.rb +53 -0
  74. data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
  75. data/lib/{action → axn}/core/timing.rb +1 -1
  76. data/lib/axn/core/tracing.rb +90 -0
  77. data/lib/axn/core/use_strategy.rb +29 -0
  78. data/lib/{action → axn}/core/validation/fields.rb +26 -2
  79. data/lib/{action → axn}/core/validation/subfields.rb +14 -12
  80. data/lib/axn/core/validation/validators/model_validator.rb +36 -0
  81. data/lib/axn/core/validation/validators/type_validator.rb +80 -0
  82. data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
  83. data/lib/{action → axn}/core.rb +55 -55
  84. data/lib/{action → axn}/exceptions.rb +12 -2
  85. data/lib/axn/extras/strategies/client.rb +150 -0
  86. data/lib/axn/extras/strategies/vernier.rb +121 -0
  87. data/lib/axn/extras.rb +4 -0
  88. data/lib/axn/factory.rb +122 -34
  89. data/lib/axn/form_object.rb +90 -0
  90. data/lib/axn/internal/logging.rb +30 -0
  91. data/lib/axn/internal/registry.rb +87 -0
  92. data/lib/axn/mountable/descriptor.rb +76 -0
  93. data/lib/axn/mountable/helpers/class_builder.rb +193 -0
  94. data/lib/axn/mountable/helpers/mounter.rb +33 -0
  95. data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
  96. data/lib/axn/mountable/helpers/validator.rb +112 -0
  97. data/lib/axn/mountable/inherit_profiles.rb +72 -0
  98. data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
  99. data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
  100. data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
  101. data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
  102. data/lib/axn/mountable/mounting_strategies.rb +32 -0
  103. data/lib/axn/mountable.rb +119 -0
  104. data/lib/axn/rails/engine.rb +51 -0
  105. data/lib/axn/rails/generators/axn_generator.rb +86 -0
  106. data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
  107. data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
  108. data/lib/{action → axn}/result.rb +32 -13
  109. data/lib/axn/strategies/form.rb +98 -0
  110. data/lib/axn/strategies/transaction.rb +26 -0
  111. data/lib/axn/strategies.rb +20 -0
  112. data/lib/axn/testing/spec_helpers.rb +6 -8
  113. data/lib/axn/util/callable.rb +120 -0
  114. data/lib/axn/util/contract_error_handling.rb +32 -0
  115. data/lib/axn/util/execution_context.rb +34 -0
  116. data/lib/axn/util/global_id_serialization.rb +52 -0
  117. data/lib/axn/util/logging.rb +87 -0
  118. data/lib/axn/util/memoization.rb +20 -0
  119. data/lib/axn/version.rb +1 -1
  120. data/lib/axn.rb +26 -16
  121. data/lib/rubocop/cop/axn/README.md +23 -23
  122. data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
  123. metadata +106 -64
  124. data/.rspec +0 -3
  125. data/.rubocop.yml +0 -76
  126. data/.tool-versions +0 -1
  127. data/docs/reference/action-result.md +0 -37
  128. data/lib/action/attachable/base.rb +0 -43
  129. data/lib/action/attachable/steps.rb +0 -63
  130. data/lib/action/attachable/subactions.rb +0 -70
  131. data/lib/action/attachable.rb +0 -17
  132. data/lib/action/configuration.rb +0 -55
  133. data/lib/action/core/automatic_logging.rb +0 -93
  134. data/lib/action/core/context/facade.rb +0 -48
  135. data/lib/action/core/flow/handlers/invoker.rb +0 -73
  136. data/lib/action/core/flow/handlers.rb +0 -20
  137. data/lib/action/core/logging.rb +0 -37
  138. data/lib/action/core/tracing.rb +0 -17
  139. data/lib/action/core/use_strategy.rb +0 -30
  140. data/lib/action/core/validation/validators/model_validator.rb +0 -34
  141. data/lib/action/core/validation/validators/type_validator.rb +0 -30
  142. data/lib/action/enqueueable/via_sidekiq.rb +0 -76
  143. data/lib/action/enqueueable.rb +0 -13
  144. data/lib/action/strategies/transaction.rb +0 -19
  145. data/lib/action/strategies.rb +0 -48
  146. data/lib/axn/util.rb +0 -24
  147. data/package.json +0 -10
  148. data/yarn.lock +0 -1166
@@ -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 ADDED
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/async/adapters"
4
+ require "axn/async/batch_enqueue"
5
+
6
+ module Axn
7
+ module Async
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
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
16
+ end
17
+
18
+ class_methods do
19
+ def async(adapter = nil, **config, &block)
20
+ self._async_adapter = adapter
21
+ self._async_config = config
22
+ self._async_config_block = block
23
+
24
+ case adapter
25
+ when false
26
+ include Adapters.find(:disabled)
27
+ when nil
28
+ # Use default configuration
29
+ async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
30
+ else
31
+ # Look up adapter in registry
32
+ adapter_module = Adapters.find(adapter)
33
+ include adapter_module
34
+ end
35
+ end
36
+
37
+ def call_async(**kwargs)
38
+ # Set up default async configuration if none is set
39
+ if _async_adapter.nil?
40
+ async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
41
+ # Call ourselves again now that the adapter is included
42
+ return call_async(**kwargs)
43
+ end
44
+
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)
57
+ end
58
+
59
+ # Ensure default async is applied when the class is first instantiated
60
+ # This is important for Sidekiq workers which load the class in a separate process
61
+ def new(*args, **kwargs)
62
+ _ensure_default_async_configured
63
+ super
64
+ end
65
+
66
+ private
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
+
133
+ def _ensure_default_async_configured
134
+ return if _async_adapter.present?
135
+ return unless Axn.config._default_async_adapter.present?
136
+
137
+ async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
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
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ class RailsConfiguration
5
+ attr_accessor :app_actions_autoload_namespace
6
+ end
7
+
8
+ class Configuration
9
+ attr_accessor :emit_metrics, :raise_piping_errors_in_dev
10
+ attr_writer :logger, :env, :on_exception, :additional_includes, :log_level, :rails
11
+
12
+ def log_level = @log_level ||= :info
13
+
14
+ def additional_includes = @additional_includes ||= []
15
+
16
+ def _default_async_adapter = @default_async_adapter ||= false
17
+ def _default_async_config = @default_async_config ||= {}
18
+ def _default_async_config_block = @default_async_config_block
19
+
20
+ def set_default_async(adapter = false, **config, &block) # rubocop:disable Style/OptionalBooleanParameter
21
+ raise ArgumentError, "Cannot set default async adapter to nil as it would cause infinite recursion" if adapter.nil?
22
+
23
+ @default_async_adapter = adapter unless adapter.nil?
24
+ @default_async_config = config.any? ? config : {}
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
42
+ end
43
+
44
+ def rails = @rails ||= RailsConfiguration.new
45
+
46
+ def on_exception(e, action:, context: {})
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}"
57
+ msg = ("#" * 10) + " #{msg} " + ("#" * 10) unless Axn.config.env.production?
58
+ action.log(msg)
59
+
60
+ return unless @on_exception
61
+
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: })
64
+ end
65
+
66
+ def logger
67
+ @logger ||= begin
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
74
+ rescue NameError
75
+ Logger.new($stdout).tap do |l|
76
+ l.level = Logger::INFO
77
+ end
78
+ end
79
+ end
80
+
81
+ def env
82
+ @env ||= ENV["RACK_ENV"].presence || ENV["RAILS_ENV"].presence || "development"
83
+ ActiveSupport::StringInquirer.new(@env)
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
103
+ end
104
+
105
+ class << self
106
+ def config = @config ||= Configuration.new
107
+
108
+ def configure
109
+ self.config ||= Configuration.new
110
+ yield(config) if block_given?
111
+ end
112
+ end
113
+ end