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.
- checksums.yaml +4 -4
- data/.cursor/commands/pr.md +36 -0
- data/CHANGELOG.md +15 -1
- data/Rakefile +102 -2
- data/docs/.vitepress/config.mjs +12 -8
- data/docs/advanced/conventions.md +1 -1
- data/docs/advanced/mountable.md +4 -90
- data/docs/advanced/profiling.md +26 -30
- data/docs/advanced/rough.md +27 -8
- data/docs/intro/overview.md +1 -1
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +102 -17
- data/docs/reference/async.md +269 -0
- data/docs/reference/class.md +113 -50
- data/docs/reference/configuration.md +226 -75
- data/docs/reference/form-object.md +252 -0
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/usage/setup.md +2 -2
- data/docs/usage/writing.md +99 -1
- data/lib/axn/async/adapters/active_job.rb +19 -10
- data/lib/axn/async/adapters/disabled.rb +15 -0
- data/lib/axn/async/adapters/sidekiq.rb +25 -32
- data/lib/axn/async/batch_enqueue/config.rb +38 -0
- data/lib/axn/async/batch_enqueue.rb +99 -0
- data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
- data/lib/axn/async.rb +121 -4
- data/lib/axn/configuration.rb +53 -13
- data/lib/axn/context.rb +1 -0
- data/lib/axn/core/automatic_logging.rb +47 -51
- data/lib/axn/core/context/facade_inspector.rb +1 -1
- data/lib/axn/core/contract.rb +73 -30
- data/lib/axn/core/contract_for_subfields.rb +1 -1
- data/lib/axn/core/contract_validation.rb +14 -9
- data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/flow/exception_execution.rb +5 -0
- data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
- data/lib/axn/core/flow/handlers/invoker.rb +4 -30
- data/lib/axn/core/flow/handlers/matcher.rb +4 -14
- data/lib/axn/core/flow/messages.rb +1 -1
- data/lib/axn/core/hooks.rb +1 -0
- data/lib/axn/core/logging.rb +16 -5
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/axn/core/tracing.rb +77 -4
- data/lib/axn/core/validation/validators/type_validator.rb +1 -1
- data/lib/axn/core.rb +31 -46
- data/lib/axn/extras/strategies/client.rb +150 -0
- data/lib/axn/extras/strategies/vernier.rb +121 -0
- data/lib/axn/extras.rb +4 -0
- data/lib/axn/factory.rb +22 -2
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +5 -1
- data/lib/axn/mountable/helpers/class_builder.rb +41 -10
- data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
- data/lib/axn/mountable/inherit_profiles.rb +2 -2
- data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
- data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
- data/lib/axn/mountable.rb +41 -7
- data/lib/axn/rails/generators/axn_generator.rb +19 -1
- data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
- data/lib/axn/result.rb +2 -2
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +7 -0
- data/lib/axn/util/callable.rb +120 -0
- data/lib/axn/util/contract_error_handling.rb +32 -0
- data/lib/axn/util/execution_context.rb +34 -0
- data/lib/axn/util/global_id_serialization.rb +52 -0
- data/lib/axn/util/logging.rb +87 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +9 -0
- metadata +22 -4
- data/lib/axn/core/profiling.rb +0 -124
- 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
|
-
#
|
|
41
|
-
|
|
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
|
data/lib/axn/configuration.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Axn
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
class Configuration
|
|
9
|
-
attr_accessor :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|