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,186 @@
|
|
|
1
|
+
# Formatting Context for Error Tracking Systems
|
|
2
|
+
|
|
3
|
+
The `context` hash passed to the global `on_exception` handler may contain complex objects (like ActiveRecord models, `ActionController::Parameters`, or `Axn::FormObject` instances) that aren't easily serialized by error tracking systems. You can format these values to make them more readable.
|
|
4
|
+
|
|
5
|
+
## Basic Example
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
Axn.configure do |c|
|
|
9
|
+
c.on_exception = proc do |e, action:, context:|
|
|
10
|
+
formatted_context = format_hash_values(context)
|
|
11
|
+
|
|
12
|
+
Honeybadger.notify(e, context: { axn_context: formatted_context })
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def format_hash_values(hash)
|
|
17
|
+
hash.transform_values do |v|
|
|
18
|
+
if v.respond_to?(:to_global_id)
|
|
19
|
+
v.to_global_id.to_s
|
|
20
|
+
elsif v.is_a?(ActionController::Parameters)
|
|
21
|
+
v.to_unsafe_h
|
|
22
|
+
elsif v.is_a?(Axn::FormObject)
|
|
23
|
+
v.to_h
|
|
24
|
+
else
|
|
25
|
+
v
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What This Converts
|
|
32
|
+
|
|
33
|
+
- **ActiveRecord objects** → Their global ID string (via `to_global_id`)
|
|
34
|
+
- **`ActionController::Parameters`** → A plain hash
|
|
35
|
+
- **`Axn::FormObject` instances** → Their hash representation
|
|
36
|
+
- **Other values** → Remain unchanged
|
|
37
|
+
|
|
38
|
+
This ensures that your error tracking system receives serializable, readable context data instead of complex objects that may not serialize properly.
|
|
39
|
+
|
|
40
|
+
## Recursive Formatting
|
|
41
|
+
|
|
42
|
+
If your context contains nested hashes with complex objects, you may want to recursively format the entire structure:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
def format_hash_values(hash)
|
|
46
|
+
hash.transform_values do |v|
|
|
47
|
+
case v
|
|
48
|
+
when Hash
|
|
49
|
+
format_hash_values(v)
|
|
50
|
+
when Array
|
|
51
|
+
v.map { |item| item.is_a?(Hash) ? format_hash_values(item) : format_value(item) }
|
|
52
|
+
else
|
|
53
|
+
format_value(v)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def format_value(v)
|
|
59
|
+
if v.respond_to?(:to_global_id)
|
|
60
|
+
v.to_global_id.to_s
|
|
61
|
+
elsif v.is_a?(ActionController::Parameters)
|
|
62
|
+
v.to_unsafe_h
|
|
63
|
+
elsif v.is_a?(Axn::FormObject)
|
|
64
|
+
v.to_h
|
|
65
|
+
else
|
|
66
|
+
v
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Advanced Example: Production Implementation
|
|
72
|
+
|
|
73
|
+
Here's a comprehensive example that includes additional context, a retry command generator, and proper handling of ActiveRecord models:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
Axn.configure do |c|
|
|
77
|
+
def format_hash_values(hash)
|
|
78
|
+
hash.transform_values do |v|
|
|
79
|
+
if v.respond_to?(:to_global_id)
|
|
80
|
+
v.to_global_id.to_s
|
|
81
|
+
elsif v.is_a?(ActionController::Parameters)
|
|
82
|
+
v.to_unsafe_h
|
|
83
|
+
elsif v.is_a?(Axn::FormObject)
|
|
84
|
+
v.to_h
|
|
85
|
+
else
|
|
86
|
+
v
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Format values for retry commands - produces copy-pasteable Ruby code
|
|
92
|
+
def format_value_for_retry_command(value)
|
|
93
|
+
# Handle ActiveRecord model instances
|
|
94
|
+
if value.respond_to?(:to_global_id) && value.respond_to?(:id) && !value.is_a?(Class)
|
|
95
|
+
begin
|
|
96
|
+
model_class = value.class.name
|
|
97
|
+
id = value.id
|
|
98
|
+
return "#{model_class}.find(#{id.inspect})"
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# If accessing id fails, fall through to default behavior
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Handle GlobalID strings (useful for serialized values)
|
|
105
|
+
if value.is_a?(String) && value.start_with?("gid://")
|
|
106
|
+
begin
|
|
107
|
+
gid = GlobalID.parse(value)
|
|
108
|
+
if gid
|
|
109
|
+
model_class = gid.model_class.name
|
|
110
|
+
id = gid.model_id
|
|
111
|
+
return "#{model_class}.find(#{id.inspect})"
|
|
112
|
+
end
|
|
113
|
+
rescue StandardError
|
|
114
|
+
# If parsing fails, fall through to default behavior
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Default: use inspect for other types
|
|
119
|
+
value.inspect
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def retry_command(action:, context:)
|
|
123
|
+
action_name = action.class.name
|
|
124
|
+
return nil if action_name.nil?
|
|
125
|
+
|
|
126
|
+
expected_fields = action.internal_field_configs.map(&:field)
|
|
127
|
+
|
|
128
|
+
return "#{action_name}.call()" if expected_fields.empty?
|
|
129
|
+
|
|
130
|
+
args = expected_fields.map do |field|
|
|
131
|
+
value = context[field]
|
|
132
|
+
"#{field}: #{format_value_for_retry_command(value)}"
|
|
133
|
+
end.join(", ")
|
|
134
|
+
|
|
135
|
+
"#{action_name}.call(#{args})"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
c.on_exception = proc do |e, action:, context:|
|
|
139
|
+
axn_name = action.class.name || "AnonymousClass"
|
|
140
|
+
message = "[#{axn_name}] Raised #{e.class.name}: #{e.message}"
|
|
141
|
+
|
|
142
|
+
hb_context = {
|
|
143
|
+
axn: axn_name,
|
|
144
|
+
axn_context: format_hash_values(context),
|
|
145
|
+
current_attributes: format_hash_values(Current.attributes),
|
|
146
|
+
retry_command: retry_command(action:, context:),
|
|
147
|
+
exception: e,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fingerprint = [axn_name, e.class.name, e.message].join(" - ")
|
|
151
|
+
Honeybadger.notify(message, context: hb_context, backtrace: e.backtrace, fingerprint:)
|
|
152
|
+
rescue StandardError => rep
|
|
153
|
+
Rails.logger.warn "!! Axn failed to report action failure to honeybadger!\nOriginal exception: #{e}\nReporting exception: #{rep}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
This example includes:
|
|
159
|
+
|
|
160
|
+
- **Formatted context**: Uses `format_hash_values` to serialize complex objects for readable error tracking
|
|
161
|
+
- **Smart retry commands**: Generates copy-pasteable Ruby code, converting ActiveRecord models to `Model.find(id)` calls instead of raw inspect output
|
|
162
|
+
- **GlobalID support**: Handles both live model instances and serialized GlobalID strings
|
|
163
|
+
- **Additional context**: Includes `Current.attributes` (if using a Current pattern) for request-level context
|
|
164
|
+
- **Error fingerprinting**: Creates a fingerprint from action name, exception class, and message to group similar errors
|
|
165
|
+
- **Error handling**: Wraps the Honeybadger notification in a rescue block to prevent reporting failures from masking the original exception
|
|
166
|
+
|
|
167
|
+
### Example Output
|
|
168
|
+
|
|
169
|
+
For an action like:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class UpdateUser
|
|
173
|
+
include Axn
|
|
174
|
+
expects :user, model: User
|
|
175
|
+
expects :name, type: String
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The retry command would generate:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
UpdateUser.call(user: User.find(123), name: "Alice")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
This can be copied directly from your error tracking system and pasted into a Rails console to reproduce the error.
|
|
186
|
+
|
data/docs/recipes/memoization.md
CHANGED
|
@@ -1,46 +1,131 @@
|
|
|
1
|
-
|
|
1
|
+
# Memoization
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Axn has built-in memoization support via the `memo` helper. This caches the result of method calls, ensuring they're only computed once per action execution.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Basic Usage
|
|
6
6
|
|
|
7
|
+
The `memo` helper works out of the box for methods without arguments:
|
|
7
8
|
|
|
8
9
|
```ruby
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
class GenerateReport
|
|
11
|
+
include Axn
|
|
12
|
+
|
|
13
|
+
expects :company, model: Company
|
|
14
|
+
exposes :report
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
expose report: {
|
|
18
|
+
total_revenue: total_revenue,
|
|
19
|
+
top_products: top_products.map(&:name),
|
|
20
|
+
# top_products is only queried once, even though it's called twice
|
|
21
|
+
product_count: top_products.count
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
memo def top_products
|
|
28
|
+
company.products.order(sales_count: :desc).limit(10)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
memo def total_revenue
|
|
32
|
+
company.orders.sum(:total)
|
|
11
33
|
end
|
|
34
|
+
end
|
|
12
35
|
```
|
|
13
36
|
|
|
37
|
+
## How It Works
|
|
38
|
+
|
|
39
|
+
- `memo` wraps the method and caches its return value on first call
|
|
40
|
+
- Subsequent calls return the cached value without re-executing the method
|
|
41
|
+
- Memoization is scoped to the action instance, so each `call` starts fresh
|
|
42
|
+
|
|
43
|
+
## Methods With Arguments
|
|
44
|
+
|
|
45
|
+
For methods that accept arguments, Axn supports the `memo_wise` gem:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Gemfile
|
|
49
|
+
gem "memo_wise"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
With `memo_wise` available, you can automatically memoize methods with arguments:
|
|
53
|
+
|
|
14
54
|
```ruby
|
|
15
|
-
|
|
16
|
-
|
|
55
|
+
class CalculatePricing
|
|
56
|
+
include Axn
|
|
57
|
+
|
|
58
|
+
expects :product
|
|
59
|
+
exposes :pricing
|
|
17
60
|
|
|
18
|
-
|
|
19
|
-
|
|
61
|
+
def call
|
|
62
|
+
expose pricing: {
|
|
63
|
+
retail: price_for(:retail),
|
|
64
|
+
wholesale: price_for(:wholesale),
|
|
65
|
+
# Each unique argument is cached separately
|
|
66
|
+
bulk: price_for(:bulk)
|
|
67
|
+
}
|
|
20
68
|
end
|
|
21
69
|
|
|
22
|
-
|
|
23
|
-
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
memo def price_for(tier)
|
|
73
|
+
# Complex pricing calculation...
|
|
74
|
+
PricingEngine.calculate(product, tier:)
|
|
24
75
|
end
|
|
25
76
|
end
|
|
26
77
|
```
|
|
27
78
|
|
|
28
|
-
|
|
79
|
+
If you try to use `memo` on a method with arguments without `memo_wise` installed, you'll get a helpful error:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
ArgumentError: Memoization of methods with arguments requires the 'memo_wise' gem.
|
|
83
|
+
Please add 'memo_wise' to your Gemfile or use a method without arguments.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## When to Use Memoization
|
|
87
|
+
|
|
88
|
+
Memoization is particularly useful for:
|
|
89
|
+
|
|
90
|
+
- **Database queries** called multiple times within an action
|
|
91
|
+
- **API calls** or external service lookups
|
|
92
|
+
- **Complex computations** that are expensive to repeat
|
|
29
93
|
|
|
30
94
|
```ruby
|
|
31
|
-
class
|
|
95
|
+
class SyncUserData
|
|
32
96
|
include Axn
|
|
33
97
|
|
|
34
|
-
|
|
98
|
+
expects :user, model: User
|
|
35
99
|
|
|
36
100
|
def call
|
|
37
|
-
|
|
101
|
+
update_profile if needs_profile_update?
|
|
102
|
+
update_preferences if needs_preferences_update?
|
|
103
|
+
notify_if_changed
|
|
38
104
|
end
|
|
39
105
|
|
|
40
106
|
private
|
|
41
107
|
|
|
42
|
-
|
|
108
|
+
# Called multiple times - only fetches once
|
|
109
|
+
memo def external_data
|
|
110
|
+
ExternalApi.fetch_user_data(user.external_id)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def needs_profile_update?
|
|
114
|
+
external_data[:profile_version] > user.profile_version
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def needs_preferences_update?
|
|
118
|
+
external_data[:preferences_hash] != user.preferences_hash
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def notify_if_changed
|
|
122
|
+
# ...
|
|
123
|
+
end
|
|
43
124
|
end
|
|
44
125
|
```
|
|
45
126
|
|
|
46
|
-
|
|
127
|
+
## Notes
|
|
128
|
+
|
|
129
|
+
- Memoization persists only for the duration of a single action execution
|
|
130
|
+
- When `memo_wise` is available, Axn automatically uses it (no configuration needed)
|
|
131
|
+
- See the [memo_wise documentation](https://github.com/panorama-ed/memo_wise) for advanced features like cache resetting
|
data/docs/reference/async.md
CHANGED
|
@@ -158,3 +158,272 @@ end
|
|
|
158
158
|
# The job will be retried up to 3 times before giving up
|
|
159
159
|
FailingAction.call_async(data: "test")
|
|
160
160
|
```
|
|
161
|
+
|
|
162
|
+
## Batch Enqueueing with `enqueues_each`
|
|
163
|
+
|
|
164
|
+
The `enqueues_each` method provides a declarative way to set up batch enqueueing. It automatically iterates over collections and enqueues each item as a separate background job.
|
|
165
|
+
|
|
166
|
+
### Basic Usage
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
class SyncForCompany
|
|
170
|
+
include Axn
|
|
171
|
+
async :sidekiq
|
|
172
|
+
|
|
173
|
+
expects :company, model: Company
|
|
174
|
+
|
|
175
|
+
def call
|
|
176
|
+
puts "Syncing data for company: #{company.name}"
|
|
177
|
+
# Sync individual company data
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# No enqueues_each needed! Source is auto-inferred from model: Company
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Usage
|
|
184
|
+
SyncForCompany.enqueue_all # Automatically iterates Company.all and enqueues each company
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**How it works:**
|
|
188
|
+
1. `enqueue_all` validates configuration upfront (async configured, static args present)
|
|
189
|
+
2. If all arguments are serializable, enqueues an `EnqueueAllOrchestrator` job in the background
|
|
190
|
+
3. If any argument is unserializable (e.g., an AR relation override), executes in the foreground instead
|
|
191
|
+
4. During execution, iterates over the source collection and enqueues individual jobs
|
|
192
|
+
5. Model-based iterations (using `find_each`) are processed first for memory efficiency
|
|
193
|
+
|
|
194
|
+
### Auto-Inference from `model:` Declarations
|
|
195
|
+
|
|
196
|
+
If a field has a `model:` declaration and the model class responds to `find_each`, you **don't need to explicitly declare `enqueues_each`**. The source collection is automatically inferred:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class SyncForCompany
|
|
200
|
+
include Axn
|
|
201
|
+
async :sidekiq
|
|
202
|
+
|
|
203
|
+
expects :company, model: Company # Auto-inferred: Company.all
|
|
204
|
+
|
|
205
|
+
def call
|
|
206
|
+
# ... sync logic
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# No enqueues_each needed - automatically iterates Company.all
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
SyncForCompany.enqueue_all # Works without explicit enqueues_each!
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Explicit Configuration with `enqueues_each`
|
|
216
|
+
|
|
217
|
+
Use `enqueues_each` when you need to:
|
|
218
|
+
- Override the default source (e.g., `Company.active` instead of `Company.all`)
|
|
219
|
+
- Add filtering logic
|
|
220
|
+
- Extract specific attributes
|
|
221
|
+
- Iterate over fields without `model:` declarations
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class SyncForCompany
|
|
225
|
+
include Axn
|
|
226
|
+
async :sidekiq
|
|
227
|
+
|
|
228
|
+
expects :company, model: Company
|
|
229
|
+
|
|
230
|
+
def call
|
|
231
|
+
# ... sync logic
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Override default source
|
|
235
|
+
enqueues_each :company, from: -> { Company.active }
|
|
236
|
+
|
|
237
|
+
# With extraction (passes company_id instead of company object)
|
|
238
|
+
enqueues_each :company_id, from: -> { Company.active }, via: :id
|
|
239
|
+
|
|
240
|
+
# With filter block
|
|
241
|
+
enqueues_each :company do |company|
|
|
242
|
+
company.active? && !company.in_exit?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Method name as source
|
|
246
|
+
enqueues_each :company, from: :active_companies
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Overriding on `enqueue_all` Call
|
|
251
|
+
|
|
252
|
+
You can override iteration sources or make fields static when calling `enqueue_all`:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
class SyncForCompany
|
|
256
|
+
include Axn
|
|
257
|
+
async :sidekiq
|
|
258
|
+
|
|
259
|
+
expects :company, model: Company
|
|
260
|
+
expects :user, model: User
|
|
261
|
+
|
|
262
|
+
def call
|
|
263
|
+
# ... sync logic
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Default: iterates Company.all
|
|
267
|
+
enqueues_each :company
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Override with a subset (enumerable kwarg replaces source)
|
|
271
|
+
SyncForCompany.enqueue_all(company: Company.active.limit(10))
|
|
272
|
+
|
|
273
|
+
# Override with a single value (scalar kwarg makes it static, no iteration)
|
|
274
|
+
SyncForCompany.enqueue_all(company: Company.find(123))
|
|
275
|
+
|
|
276
|
+
# Mix static and iterated fields
|
|
277
|
+
SyncForCompany.enqueue_all(
|
|
278
|
+
company: Company.active, # Iterates over active companies
|
|
279
|
+
user: User.find(1) # Static: same user for all jobs
|
|
280
|
+
)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Dynamic Iteration via Kwargs
|
|
284
|
+
|
|
285
|
+
You can iterate over fields without any `enqueues_each` declaration by passing enumerables directly:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
class ProcessFormats
|
|
289
|
+
include Axn
|
|
290
|
+
async :sidekiq
|
|
291
|
+
|
|
292
|
+
expects :format
|
|
293
|
+
expects :mode
|
|
294
|
+
|
|
295
|
+
def call
|
|
296
|
+
# ... process logic
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Pass enumerables to create cross-product iteration
|
|
301
|
+
ProcessFormats.enqueue_all(
|
|
302
|
+
format: [:csv, :json, :xml], # Iterates: 3 jobs
|
|
303
|
+
mode: :full # Static: same mode for all
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Multiple enumerables create cross-product
|
|
307
|
+
ProcessFormats.enqueue_all(
|
|
308
|
+
format: [:csv, :json], # 2 formats
|
|
309
|
+
mode: [:full, :incremental] # 2 modes
|
|
310
|
+
)
|
|
311
|
+
# Result: 2 × 2 = 4 jobs total
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**Note:** Arrays and Sets are treated as static values (not iterated) when the field expects an enumerable type:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
expects :tags, type: Array
|
|
318
|
+
|
|
319
|
+
# This passes the entire array as a static value
|
|
320
|
+
ProcessTags.enqueue_all(tags: ["ruby", "rails", "testing"])
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Multi-Field Cross-Product Iteration
|
|
324
|
+
|
|
325
|
+
Multiple `enqueues_each` declarations create a cross-product of all combinations:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
class SyncForUserAndCompany
|
|
329
|
+
include Axn
|
|
330
|
+
async :sidekiq
|
|
331
|
+
|
|
332
|
+
expects :user, model: User
|
|
333
|
+
expects :company, model: Company
|
|
334
|
+
|
|
335
|
+
def call
|
|
336
|
+
# ... sync logic for user + company combination
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
enqueues_each :user, from: -> { User.active }
|
|
340
|
+
enqueues_each :company, from: -> { Company.active }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Creates user_count × company_count jobs
|
|
344
|
+
# Each combination of (user, company) gets its own job
|
|
345
|
+
SyncForUserAndCompany.enqueue_all
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Static Fields
|
|
349
|
+
|
|
350
|
+
Fields declared with `expects` but not covered by `enqueues_each` (or auto-inference) become static fields that must be passed to `enqueue_all`:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
class SyncWithMode
|
|
354
|
+
include Axn
|
|
355
|
+
async :sidekiq
|
|
356
|
+
|
|
357
|
+
expects :company, model: Company # Auto-inferred, will iterate
|
|
358
|
+
expects :sync_mode # Static, must be provided
|
|
359
|
+
|
|
360
|
+
def call
|
|
361
|
+
# Uses both company (iterated) and sync_mode (static)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# sync_mode must be provided - it's passed to every enqueued job
|
|
366
|
+
SyncWithMode.enqueue_all(sync_mode: :full)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Memory Efficiency
|
|
370
|
+
|
|
371
|
+
For optimal memory usage, model-based configs (using `find_each`) are automatically processed first in nested iterations. This ensures ActiveRecord-style batch processing happens before loading potentially large enumerables into memory.
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
# Model-based iteration uses find_each (memory efficient)
|
|
375
|
+
expects :company, model: Company # Processed first
|
|
376
|
+
|
|
377
|
+
# Array-based iteration uses each (loads all into memory)
|
|
378
|
+
enqueues_each :format, from: -> { [:csv, :json, :xml] } # Processed second
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Iteration Method Selection
|
|
382
|
+
|
|
383
|
+
- **`find_each`**: Used when the source responds to `find_each` (ActiveRecord collections) - processes in batches for memory efficiency
|
|
384
|
+
- **`each`**: Used for plain arrays and other enumerables - loads all items into memory
|
|
385
|
+
|
|
386
|
+
### Background vs Foreground Execution
|
|
387
|
+
|
|
388
|
+
By default, `enqueue_all` enqueues an `EnqueueAllOrchestrator` job to perform the iteration and individual job enqueueing in the background. This makes it safe to call directly from clock processes (e.g., Heroku scheduler) without risking memory bloat from loading large collections.
|
|
389
|
+
|
|
390
|
+
However, if you pass an **enumerable override** (like an ActiveRecord relation or array), `enqueue_all` automatically falls back to foreground execution—iterating and enqueueing immediately in the current process. This is because the iteration source (a lambda wrapping the enumerable) cannot be serialized for background execution.
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
class SyncForCompany
|
|
394
|
+
include Axn
|
|
395
|
+
async :sidekiq
|
|
396
|
+
|
|
397
|
+
expects :company, model: Company
|
|
398
|
+
|
|
399
|
+
def call
|
|
400
|
+
# ... sync logic
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
enqueues_each :company, from: -> { Company.active }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Default: enqueues orchestrator job in background (safe for clock processes)
|
|
407
|
+
SyncForCompany.enqueue_all
|
|
408
|
+
|
|
409
|
+
# Override with a relation: executes in foreground (relation can't be serialized)
|
|
410
|
+
SyncForCompany.enqueue_all(company: Company.where(plan: "enterprise"))
|
|
411
|
+
|
|
412
|
+
# Override with a single value: runs in background (GlobalID-serializable scalar)
|
|
413
|
+
SyncForCompany.enqueue_all(company: Company.find(123))
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
**Why this matters:**
|
|
417
|
+
- Configure your default iteration source via `enqueues_each` for scheduled/recurring jobs
|
|
418
|
+
- By default, `enqueue_all` runs safely in the background without loading your entire dataset into memory
|
|
419
|
+
- For one-off manual calls with a filtered subset, pass an enumerable override—foreground execution handles it automatically
|
|
420
|
+
- Scalar overrides (single objects) are serialized via GlobalID and still run in the background
|
|
421
|
+
|
|
422
|
+
This design lets you use the same action class for both scheduled batch processing and ad-hoc targeted runs.
|
|
423
|
+
|
|
424
|
+
### Edge Cases and Limitations
|
|
425
|
+
|
|
426
|
+
1. **Fields expecting enumerable types**: If a field expects `Array` or `Set`, arrays/sets passed to `enqueue_all` are treated as static values (not iterated)
|
|
427
|
+
2. **Strings and Hashes**: Always treated as static values, even though they respond to `:each`
|
|
428
|
+
3. **No model or source**: If a field has no `model:` declaration and no `enqueues_each` with `from:`, you must pass it as a kwarg to `enqueue_all` or it will raise an error
|
|
429
|
+
4. **Required static fields**: Fields without defaults that aren't covered by iteration must be provided to `enqueue_all`
|