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.
- checksums.yaml +4 -4
- data/.cursor/commands/pr.md +36 -0
- data/.cursor/rules/axn-framework-patterns.mdc +43 -0
- data/.cursor/rules/general-coding-standards.mdc +27 -0
- data/.cursor/rules/spec/testing-patterns.mdc +40 -0
- data/CHANGELOG.md +57 -0
- data/Rakefile +114 -4
- data/docs/.vitepress/config.mjs +19 -10
- data/docs/advanced/conventions.md +3 -3
- data/docs/advanced/mountable.md +476 -0
- data/docs/advanced/profiling.md +351 -0
- data/docs/advanced/rough.md +27 -8
- data/docs/index.md +5 -3
- data/docs/intro/about.md +1 -1
- data/docs/intro/overview.md +6 -6
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +103 -18
- data/docs/recipes/rubocop-integration.md +38 -284
- data/docs/recipes/testing.md +14 -14
- data/docs/recipes/validating-user-input.md +1 -1
- data/docs/reference/async.md +429 -0
- data/docs/reference/axn-result.md +107 -0
- data/docs/reference/class.md +225 -64
- data/docs/reference/configuration.md +366 -34
- data/docs/reference/form-object.md +252 -0
- data/docs/reference/instance.md +14 -29
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/strategies/index.md +21 -21
- data/docs/strategies/transaction.md +1 -1
- data/docs/usage/setup.md +16 -2
- data/docs/usage/steps.md +7 -7
- data/docs/usage/using.md +23 -12
- data/docs/usage/writing.md +191 -12
- data/lib/axn/async/adapters/active_job.rb +74 -0
- data/lib/axn/async/adapters/disabled.rb +41 -0
- data/lib/axn/async/adapters/sidekiq.rb +67 -0
- data/lib/axn/async/adapters.rb +26 -0
- 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 +178 -0
- data/lib/axn/configuration.rb +113 -0
- data/lib/{action → axn}/context.rb +22 -4
- data/lib/axn/core/automatic_logging.rb +89 -0
- data/lib/axn/core/context/facade.rb +69 -0
- data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
- data/lib/{action → axn}/core/context/internal.rb +5 -5
- data/lib/{action → axn}/core/contract.rb +111 -73
- data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
- data/lib/{action → axn}/core/contract_validation.rb +27 -12
- data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/field_resolvers/extract.rb +32 -0
- data/lib/axn/core/field_resolvers/model.rb +63 -0
- data/lib/axn/core/field_resolvers.rb +24 -0
- data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
- data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
- data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
- data/lib/axn/core/flow/handlers/invoker.rb +47 -0
- data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
- data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
- data/lib/axn/core/flow/handlers.rb +20 -0
- data/lib/{action → axn}/core/flow/messages.rb +8 -8
- data/lib/{action → axn}/core/flow.rb +4 -4
- data/lib/{action → axn}/core/hooks.rb +17 -5
- data/lib/axn/core/logging.rb +48 -0
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
- data/lib/{action → axn}/core/timing.rb +1 -1
- data/lib/axn/core/tracing.rb +90 -0
- data/lib/axn/core/use_strategy.rb +29 -0
- data/lib/{action → axn}/core/validation/fields.rb +26 -2
- data/lib/{action → axn}/core/validation/subfields.rb +14 -12
- data/lib/axn/core/validation/validators/model_validator.rb +36 -0
- data/lib/axn/core/validation/validators/type_validator.rb +80 -0
- data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
- data/lib/{action → axn}/core.rb +55 -55
- data/lib/{action → axn}/exceptions.rb +12 -2
- 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 +122 -34
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +30 -0
- data/lib/axn/internal/registry.rb +87 -0
- data/lib/axn/mountable/descriptor.rb +76 -0
- data/lib/axn/mountable/helpers/class_builder.rb +193 -0
- data/lib/axn/mountable/helpers/mounter.rb +33 -0
- data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
- data/lib/axn/mountable/helpers/validator.rb +112 -0
- data/lib/axn/mountable/inherit_profiles.rb +72 -0
- data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
- data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
- data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
- data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
- data/lib/axn/mountable/mounting_strategies.rb +32 -0
- data/lib/axn/mountable.rb +119 -0
- data/lib/axn/rails/engine.rb +51 -0
- data/lib/axn/rails/generators/axn_generator.rb +86 -0
- data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
- data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
- data/lib/{action → axn}/result.rb +32 -13
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +26 -0
- data/lib/axn/strategies.rb +20 -0
- data/lib/axn/testing/spec_helpers.rb +6 -8
- 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/util/memoization.rb +20 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +26 -16
- data/lib/rubocop/cop/axn/README.md +23 -23
- data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
- metadata +106 -64
- data/.rspec +0 -3
- data/.rubocop.yml +0 -76
- data/.tool-versions +0 -1
- data/docs/reference/action-result.md +0 -37
- data/lib/action/attachable/base.rb +0 -43
- data/lib/action/attachable/steps.rb +0 -63
- data/lib/action/attachable/subactions.rb +0 -70
- data/lib/action/attachable.rb +0 -17
- data/lib/action/configuration.rb +0 -55
- data/lib/action/core/automatic_logging.rb +0 -93
- data/lib/action/core/context/facade.rb +0 -48
- data/lib/action/core/flow/handlers/invoker.rb +0 -73
- data/lib/action/core/flow/handlers.rb +0 -20
- data/lib/action/core/logging.rb +0 -37
- data/lib/action/core/tracing.rb +0 -17
- data/lib/action/core/use_strategy.rb +0 -30
- data/lib/action/core/validation/validators/model_validator.rb +0 -34
- data/lib/action/core/validation/validators/type_validator.rb +0 -30
- data/lib/action/enqueueable/via_sidekiq.rb +0 -76
- data/lib/action/enqueueable.rb +0 -13
- data/lib/action/strategies/transaction.rb +0 -19
- data/lib/action/strategies.rb +0 -48
- data/lib/axn/util.rb +0 -24
- data/package.json +0 -10
- data/yarn.lock +0 -1166
data/docs/usage/writing.md
CHANGED
|
@@ -8,7 +8,7 @@ The core boilerplate is pretty minimal:
|
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
10
|
class Foo
|
|
11
|
-
include
|
|
11
|
+
include Axn
|
|
12
12
|
|
|
13
13
|
def call
|
|
14
14
|
# ... do some stuff here?
|
|
@@ -22,14 +22,15 @@ The first step is to determine what arguments you expect to be passed into `call
|
|
|
22
22
|
|
|
23
23
|
If you want to expose any results to the caller, declare that via the `exposes` keyword.
|
|
24
24
|
|
|
25
|
-
Both of these optionally accept `type:`, `allow_nil:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
|
|
25
|
+
Both of these optionally accept `type:`, `optional:`, `allow_nil:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
class Foo
|
|
30
|
-
include
|
|
30
|
+
include Axn
|
|
31
31
|
|
|
32
32
|
expects :name, type: String # [!code focus:2]
|
|
33
|
+
expects :email, type: String, optional: true # [!code focus:2]
|
|
33
34
|
exposes :meaning_of_life
|
|
34
35
|
|
|
35
36
|
def call
|
|
@@ -42,13 +43,15 @@ end
|
|
|
42
43
|
|
|
43
44
|
Once the interface is defined, you're primarily focused on defining the `call` method.
|
|
44
45
|
|
|
45
|
-
To abort execution with a specific error message, call `fail!`.
|
|
46
|
+
To abort execution with a specific error message, call `fail!`. You can also provide exposures as keyword arguments.
|
|
47
|
+
|
|
48
|
+
To complete execution early with a success result, call `done!` with an optional success message and exposures as keyword arguments.
|
|
46
49
|
|
|
47
50
|
If you declare that your action `exposes` anything, you need to actually `expose` it.
|
|
48
51
|
|
|
49
52
|
```ruby
|
|
50
53
|
class Foo
|
|
51
|
-
include
|
|
54
|
+
include Axn
|
|
52
55
|
|
|
53
56
|
expects :name, type: String
|
|
54
57
|
exposes :meaning_of_life
|
|
@@ -64,6 +67,84 @@ end
|
|
|
64
67
|
|
|
65
68
|
See [the reference doc](/reference/instance) for a few more handy helper methods (e.g. `#log`).
|
|
66
69
|
|
|
70
|
+
### Convenient failure with context
|
|
71
|
+
|
|
72
|
+
Both `fail!` and `done!` can accept keyword arguments to expose data before halting execution:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class UserValidator
|
|
76
|
+
include Axn
|
|
77
|
+
|
|
78
|
+
expects :email
|
|
79
|
+
exposes :error_code, :field
|
|
80
|
+
|
|
81
|
+
def call
|
|
82
|
+
if email.blank?
|
|
83
|
+
fail!("Email is required", error_code: 422, field: "email")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ... validation logic
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Early completion with `done!`
|
|
92
|
+
|
|
93
|
+
The `done!` method allows you to complete an action early with a success result, bypassing the rest of the execution:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class UserLookup
|
|
97
|
+
include Axn
|
|
98
|
+
|
|
99
|
+
expects :user_id
|
|
100
|
+
exposes :user, :cached
|
|
101
|
+
|
|
102
|
+
def call
|
|
103
|
+
# Check cache first
|
|
104
|
+
cached_user = Rails.cache.read("user:#{user_id}")
|
|
105
|
+
if cached_user
|
|
106
|
+
done!("User found in cache", user: cached_user, cached: true) # Early completion with exposures
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# This won't execute if done! was called above
|
|
110
|
+
user = User.find(user_id)
|
|
111
|
+
expose user: user, cached: false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Important behavior notes
|
|
117
|
+
|
|
118
|
+
**Hook execution:**
|
|
119
|
+
- `done!` **skips** any `after` hooks (or `call` method if called from a `before` hook)
|
|
120
|
+
- `around` hooks **will complete** normally, allowing transactions and tracing to finish properly
|
|
121
|
+
- If you want code that executes on both normal AND early success, use an `on_success` callback instead of an `after` hook
|
|
122
|
+
|
|
123
|
+
**Transaction handling:**
|
|
124
|
+
- `done!` is implemented internally via an exception, so it **will roll back** manually applied `ActiveRecord::Base.transaction` blocks
|
|
125
|
+
- Use the [`use :transaction` strategy](/strategies/transaction) instead - transactions applied via this strategy will **NOT** be rolled back by `done!`
|
|
126
|
+
- This ensures database consistency while allowing early completion
|
|
127
|
+
|
|
128
|
+
**Validation:**
|
|
129
|
+
- Outbound validation (required `exposes`) still runs even with early completion
|
|
130
|
+
- If required fields are not provided, the action will fail despite the early completion
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class BadExample
|
|
134
|
+
include Axn
|
|
135
|
+
|
|
136
|
+
expects :user_id
|
|
137
|
+
exposes :user # Required field
|
|
138
|
+
|
|
139
|
+
def call
|
|
140
|
+
done!("Early completion") # This will FAIL - user not exposed
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
BadExample.call(user_id: 123).ok? # => false
|
|
145
|
+
BadExample.call(user_id: 123).exception # => Axn::OutboundValidationError
|
|
146
|
+
```
|
|
147
|
+
|
|
67
148
|
## Customizing messages
|
|
68
149
|
|
|
69
150
|
The default `error` and `success` message strings ("Something went wrong" / "Action completed successfully", respectively) _are_ technically safe to show users, but you'll often want to set them to something more useful.
|
|
@@ -74,7 +155,7 @@ For instance, configuring the action like this:
|
|
|
74
155
|
|
|
75
156
|
```ruby
|
|
76
157
|
class Foo
|
|
77
|
-
include
|
|
158
|
+
include Axn
|
|
78
159
|
|
|
79
160
|
expects :name, type: String
|
|
80
161
|
exposes :meaning_of_life
|
|
@@ -106,7 +187,7 @@ You can also use conditional error messages with the `prefix:` keyword and combi
|
|
|
106
187
|
|
|
107
188
|
```ruby
|
|
108
189
|
class ValidationAction
|
|
109
|
-
include
|
|
190
|
+
include Axn
|
|
110
191
|
|
|
111
192
|
expects :input
|
|
112
193
|
|
|
@@ -123,11 +204,14 @@ class ValidationAction
|
|
|
123
204
|
end
|
|
124
205
|
|
|
125
206
|
class ApiAction
|
|
126
|
-
include
|
|
207
|
+
include Axn
|
|
127
208
|
|
|
128
209
|
expects :data
|
|
129
210
|
|
|
130
|
-
#
|
|
211
|
+
# Simply inherit child's error (prefix and handler are optional)
|
|
212
|
+
error from: ValidationAction
|
|
213
|
+
|
|
214
|
+
# Or combine prefix with from for consistent error formatting
|
|
131
215
|
error from: ValidationAction, prefix: "API Error: " do |e|
|
|
132
216
|
"Request validation failed: #{e.message}"
|
|
133
217
|
end
|
|
@@ -135,6 +219,12 @@ class ApiAction
|
|
|
135
219
|
# Or use prefix only (falls back to exception message)
|
|
136
220
|
error from: ValidationAction, prefix: "API Error: "
|
|
137
221
|
|
|
222
|
+
# Match multiple child actions
|
|
223
|
+
error from: [ValidationAction, AnotherAction]
|
|
224
|
+
|
|
225
|
+
# Match any child action
|
|
226
|
+
error from: true
|
|
227
|
+
|
|
138
228
|
def call
|
|
139
229
|
ValidationAction.call!(input: data)
|
|
140
230
|
end
|
|
@@ -142,9 +232,11 @@ end
|
|
|
142
232
|
```
|
|
143
233
|
|
|
144
234
|
This configuration provides:
|
|
235
|
+
- Simple error message inheritance without requiring prefix or handler
|
|
145
236
|
- Consistent error message formatting with prefixes
|
|
146
237
|
- Automatic fallback to exception messages when no custom message is provided
|
|
147
238
|
- Proper error message inheritance from nested actions
|
|
239
|
+
- Support for matching multiple child actions or any child action
|
|
148
240
|
|
|
149
241
|
::: warning Message Ordering
|
|
150
242
|
**Important**: When using conditional messages, always define your static fallback messages **first** in your class, before any conditional messages. This ensures proper fallback behavior.
|
|
@@ -152,7 +244,7 @@ This configuration provides:
|
|
|
152
244
|
**Correct order:**
|
|
153
245
|
```ruby
|
|
154
246
|
class Foo
|
|
155
|
-
include
|
|
247
|
+
include Axn
|
|
156
248
|
|
|
157
249
|
# Static fallback messages first
|
|
158
250
|
success "Default success message"
|
|
@@ -175,13 +267,44 @@ In addition to `#call`, there are a few additional pieces to be aware of:
|
|
|
175
267
|
|
|
176
268
|
`before`, `after`, and `around` hooks are supported. They can receive a block directly, or the symbol name of a local method.
|
|
177
269
|
|
|
178
|
-
Note execution is halted whenever `fail!` is called or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `result.ok?` be false even though `call` completed successfully).
|
|
270
|
+
Note execution is halted whenever `fail!` is called, `done!` is called, or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `result.ok?` be false even though `call` completed successfully). The `done!` method specifically skips `after` hooks and any remaining `call` method execution, but allows `around` hooks to complete normally.
|
|
271
|
+
|
|
272
|
+
#### Around hooks
|
|
273
|
+
|
|
274
|
+
Around hooks wrap the entire action execution, including before and after hooks. They receive a block that represents the next step in the chain:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
class Foo
|
|
278
|
+
include Axn
|
|
279
|
+
|
|
280
|
+
around :with_timing
|
|
281
|
+
around do |chain|
|
|
282
|
+
log("outer around start")
|
|
283
|
+
chain.call
|
|
284
|
+
log("outer around end")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def call
|
|
288
|
+
log("in call")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private
|
|
292
|
+
|
|
293
|
+
def with_timing(chain)
|
|
294
|
+
start = Time.current
|
|
295
|
+
chain.call
|
|
296
|
+
log("Took #{Time.current - start}s")
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
#### Before/After example
|
|
179
302
|
|
|
180
303
|
For instance, given this configuration:
|
|
181
304
|
|
|
182
305
|
```ruby
|
|
183
306
|
class Foo
|
|
184
|
-
include
|
|
307
|
+
include Axn
|
|
185
308
|
|
|
186
309
|
before { log("before hook") } # [!code focus:2]
|
|
187
310
|
after :log_after
|
|
@@ -220,6 +343,62 @@ This follows the natural pattern of setup (general → specific) and teardown (s
|
|
|
220
343
|
A number of custom callback are available for you as well, if you want to take specific actions when a given Axn succeeds or fails. See the [Class Interface docs](/reference/class#callbacks) for details.
|
|
221
344
|
|
|
222
345
|
## Strategies
|
|
346
|
+
|
|
223
347
|
A number of [Strategies](/strategies/index), which are <abbr title="Don't Repeat Yourself">DRY</abbr>ed bits of commonly-used configuration, are available for your use as well.
|
|
224
348
|
|
|
349
|
+
::: info Optional Peer Libraries
|
|
350
|
+
Axn provides enhanced functionality when certain peer libraries are available:
|
|
351
|
+
|
|
352
|
+
- **Rails**: Automatic engine loading, autoloading for `app/actions`, and generators
|
|
353
|
+
- **Faraday**: Enables the [Client Strategy](/strategies/client) for HTTP API integrations
|
|
354
|
+
- **memo_wise**: Extends built-in `memo` helper to support methods with arguments (see [Memoization recipe](/recipes/memoization))
|
|
355
|
+
|
|
356
|
+
These are all optional—Axn works great without them, but they unlock additional features when present.
|
|
357
|
+
:::
|
|
358
|
+
|
|
359
|
+
## Advanced: Default call behavior
|
|
360
|
+
|
|
361
|
+
::: tip For Experienced Users
|
|
362
|
+
This section covers an advanced shortcut. If you're new to Axn, start by explicitly defining your `call` method.
|
|
363
|
+
:::
|
|
364
|
+
|
|
365
|
+
If you don't define a `call` method, Axn provides a default implementation that automatically exposes all declared exposures by calling methods with matching names. This allows you to simplify actions that only need to compute and expose values:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
class CertificatesByDestination
|
|
369
|
+
include Axn
|
|
370
|
+
exposes :certs_by_destination, type: Hash
|
|
371
|
+
|
|
372
|
+
private
|
|
373
|
+
|
|
374
|
+
def certs_by_destination
|
|
375
|
+
# Your logic here - automatically exposed
|
|
376
|
+
{ "dest1" => "cert1", "dest2" => "cert2" }
|
|
377
|
+
end
|
|
378
|
+
end
|
|
225
379
|
```
|
|
380
|
+
|
|
381
|
+
This is equivalent to:
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
class CertificatesByDestination
|
|
385
|
+
include Axn
|
|
386
|
+
exposes :certs_by_destination, type: Hash
|
|
387
|
+
|
|
388
|
+
def call
|
|
389
|
+
expose certs_by_destination: certs_by_destination
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
def certs_by_destination
|
|
395
|
+
{ "dest1" => "cert1", "dest2" => "cert2" }
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**Important notes:**
|
|
401
|
+
- The default `call` requires a method matching each declared exposure (unless a `default` is provided)
|
|
402
|
+
- If a method is missing and no default is provided, the action will fail with a helpful error message
|
|
403
|
+
- You can still override `call` to implement custom logic when needed
|
|
404
|
+
- If a method returns `nil` for an exposed-only field with no default, it's treated as missing (user-defined methods that legitimately return `nil` should use `expose` explicitly or provide a default)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Async
|
|
5
|
+
class Adapters
|
|
6
|
+
module ActiveJob
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
def self._running_in_background?
|
|
10
|
+
defined?(ActiveJob) && ActiveJob::Base.current_job.present?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
raise LoadError, "ActiveJob is not available. Please add 'activejob' to your Gemfile." unless defined?(::ActiveJob::Base)
|
|
15
|
+
|
|
16
|
+
# Validate that kwargs are not provided for ActiveJob
|
|
17
|
+
if _async_config&.any?
|
|
18
|
+
raise ArgumentError, "ActiveJob adapter requires a configuration block. Use `async :active_job do ... end` instead of passing keyword arguments."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class_methods do
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Implements adapter-specific enqueueing logic for ActiveJob.
|
|
26
|
+
# Note: Adapters must implement _enqueue_async_job and must NOT override call_async.
|
|
27
|
+
def _enqueue_async_job(kwargs)
|
|
28
|
+
job = active_job_proxy_class
|
|
29
|
+
|
|
30
|
+
# Extract and normalize _async options (removes _async from kwargs)
|
|
31
|
+
normalized_options = _extract_and_normalize_async_options(kwargs)
|
|
32
|
+
|
|
33
|
+
# Process normalized async options if present
|
|
34
|
+
if normalized_options
|
|
35
|
+
if normalized_options["wait_until"]
|
|
36
|
+
job = job.set(wait_until: normalized_options["wait_until"])
|
|
37
|
+
elsif normalized_options["wait"]
|
|
38
|
+
job = job.set(wait: normalized_options["wait"])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
job.perform_later(kwargs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def active_job_proxy_class
|
|
46
|
+
@active_job_proxy_class ||= create_active_job_proxy_class
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_active_job_proxy_class
|
|
50
|
+
# Store reference to the original action class
|
|
51
|
+
action_class = self
|
|
52
|
+
|
|
53
|
+
# Create the ActiveJob proxy class
|
|
54
|
+
Class.new(::ActiveJob::Base).tap do |proxy|
|
|
55
|
+
# Give the job class a meaningful name for logging and debugging
|
|
56
|
+
job_name = "#{name}::ActiveJobProxy"
|
|
57
|
+
const_set("ActiveJobProxy", proxy)
|
|
58
|
+
proxy.define_singleton_method(:name) { job_name }
|
|
59
|
+
|
|
60
|
+
# Apply the async configuration block if it exists
|
|
61
|
+
proxy.class_eval(&_async_config_block) if _async_config_block
|
|
62
|
+
|
|
63
|
+
# Define the perform method
|
|
64
|
+
proxy.define_method(:perform) do |job_context = {}|
|
|
65
|
+
# Call the original action class with the job context
|
|
66
|
+
action_class.call!(**job_context)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Async
|
|
5
|
+
class Adapters
|
|
6
|
+
module Disabled
|
|
7
|
+
def self._running_in_background?
|
|
8
|
+
false
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.included(base)
|
|
12
|
+
base.class_eval do
|
|
13
|
+
# Validate that kwargs are not provided for Disabled adapter
|
|
14
|
+
raise ArgumentError, "Disabled adapter does not accept configuration options." if _async_config&.any?
|
|
15
|
+
raise ArgumentError, "Disabled adapter does not accept configuration block." if _async_config_block
|
|
16
|
+
|
|
17
|
+
# Exception to the adapter pattern: Disabled adapter overrides call_async directly
|
|
18
|
+
# to raise immediately without emitting notifications or logging.
|
|
19
|
+
# Other adapters must NOT override call_async and should only implement _enqueue_async_job.
|
|
20
|
+
def self.call_async(**kwargs)
|
|
21
|
+
# Remove _async parameter to avoid confusion in error message
|
|
22
|
+
kwargs.delete(:_async)
|
|
23
|
+
|
|
24
|
+
# Don't emit notification or log - just raise immediately
|
|
25
|
+
raise NotImplementedError,
|
|
26
|
+
"Async execution is explicitly disabled for #{name}. " \
|
|
27
|
+
"Use `async :sidekiq` or `async :active_job` to enable background processing."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self._enqueue_async_job(kwargs)
|
|
31
|
+
# This should never be called since call_async raises, but define it for completeness
|
|
32
|
+
raise NotImplementedError,
|
|
33
|
+
"Async execution is explicitly disabled for #{name}. " \
|
|
34
|
+
"Use `async :sidekiq` or `async :active_job` to enable background processing."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Async
|
|
5
|
+
class Adapters
|
|
6
|
+
module Sidekiq
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
def self._running_in_background?
|
|
10
|
+
defined?(::Sidekiq) && ::Sidekiq.server?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
raise LoadError, "Sidekiq is not available. Please add 'sidekiq' to your Gemfile." unless defined?(::Sidekiq)
|
|
15
|
+
|
|
16
|
+
# Use Sidekiq::Job if available (Sidekiq 7+), otherwise error
|
|
17
|
+
raise LoadError, "Sidekiq::Job is not available. Please check your Sidekiq version." unless defined?(::Sidekiq::Job)
|
|
18
|
+
|
|
19
|
+
include ::Sidekiq::Job
|
|
20
|
+
|
|
21
|
+
# Sidekiq's processor calls .new on the worker class from outside the class hierarchy
|
|
22
|
+
# (see Sidekiq::Processor#dispatch which does `klass.new`).
|
|
23
|
+
# Since Axn::Core makes :new private, we need to restore it for Sidekiq workers.
|
|
24
|
+
public_class_method :new
|
|
25
|
+
|
|
26
|
+
# Apply configuration block if present
|
|
27
|
+
class_eval(&_async_config_block) if _async_config_block
|
|
28
|
+
|
|
29
|
+
# Apply kwargs configuration if present
|
|
30
|
+
sidekiq_options(**_async_config) if _async_config&.any?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class_methods do
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Implements adapter-specific enqueueing logic for Sidekiq.
|
|
37
|
+
# Note: Adapters must implement _enqueue_async_job and must NOT override call_async.
|
|
38
|
+
def _enqueue_async_job(kwargs)
|
|
39
|
+
# Extract and normalize _async options (removes _async from kwargs)
|
|
40
|
+
normalized_options = _extract_and_normalize_async_options(kwargs)
|
|
41
|
+
|
|
42
|
+
# Convert kwargs to string keys and handle GlobalID conversion
|
|
43
|
+
job_kwargs = Axn::Util::GlobalIdSerialization.serialize(kwargs)
|
|
44
|
+
|
|
45
|
+
# Process normalized async options if present
|
|
46
|
+
if normalized_options
|
|
47
|
+
if normalized_options["wait_until"]
|
|
48
|
+
return perform_at(normalized_options["wait_until"], job_kwargs)
|
|
49
|
+
elsif normalized_options["wait"]
|
|
50
|
+
return perform_in(normalized_options["wait"], job_kwargs)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
perform_async(job_kwargs)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def perform(*args)
|
|
59
|
+
context = Axn::Util::GlobalIdSerialization.deserialize(args.first)
|
|
60
|
+
|
|
61
|
+
# Always use bang version so sidekiq can retry if we failed
|
|
62
|
+
self.class.call!(**context)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "axn/internal/registry"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module Axn
|
|
7
|
+
module Async
|
|
8
|
+
class AdapterNotFound < Axn::Internal::Registry::NotFound; end
|
|
9
|
+
class DuplicateAdapterError < Axn::Internal::Registry::DuplicateError; end
|
|
10
|
+
|
|
11
|
+
class Adapters < Axn::Internal::Registry
|
|
12
|
+
class << self
|
|
13
|
+
def registry_directory = __dir__
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def item_type = "Adapter"
|
|
18
|
+
def not_found_error_class = AdapterNotFound
|
|
19
|
+
def duplicate_error_class = DuplicateAdapterError
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Trigger registry loading to ensure adapters are available
|
|
24
|
+
Adapters.all
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Async
|
|
5
|
+
module BatchEnqueue
|
|
6
|
+
# Stores the configuration for a single enqueues_each declaration
|
|
7
|
+
class Config
|
|
8
|
+
attr_reader :field, :from, :via, :filter_block
|
|
9
|
+
|
|
10
|
+
def initialize(field:, from:, via:, filter_block:)
|
|
11
|
+
@field = field
|
|
12
|
+
@from = from
|
|
13
|
+
@via = via
|
|
14
|
+
@filter_block = filter_block
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Resolves the source collection for iteration
|
|
18
|
+
# Can be a lambda, a symbol (method name on target), or inferred from model: true
|
|
19
|
+
def resolve_source(target:)
|
|
20
|
+
return from.call if from.is_a?(Proc)
|
|
21
|
+
return target.send(from) if from.is_a?(Symbol)
|
|
22
|
+
|
|
23
|
+
# Infer from field's model config if 'from' is nil
|
|
24
|
+
field_config = target.internal_field_configs.find { |c| c.field == field }
|
|
25
|
+
model_opts = field_config&.validations&.dig(:model)
|
|
26
|
+
model_class = model_opts[:klass] if model_opts.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
unless model_class
|
|
29
|
+
raise ArgumentError,
|
|
30
|
+
"enqueues_each :#{field} requires `from:` option or a `model:` declaration " \
|
|
31
|
+
"on `expects :#{field}` to infer the source collection."
|
|
32
|
+
end
|
|
33
|
+
model_class.all
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "axn/async/batch_enqueue/config"
|
|
4
|
+
|
|
5
|
+
module Axn
|
|
6
|
+
module Async
|
|
7
|
+
# BatchEnqueue provides declarative batch enqueueing for Axn actions.
|
|
8
|
+
#
|
|
9
|
+
# Fields with `model:` declarations are automatically inferred for iteration.
|
|
10
|
+
# Use `enqueues_each` to override defaults, add filtering, or iterate non-model fields.
|
|
11
|
+
# All Axn classes have `enqueue_all` defined, which validates configuration and
|
|
12
|
+
# executes iteration asynchronously via EnqueueAllOrchestrator.
|
|
13
|
+
#
|
|
14
|
+
# @example Auto-inference from model: (no enqueues_each needed)
|
|
15
|
+
# class SyncCompany
|
|
16
|
+
# include Axn
|
|
17
|
+
# async :sidekiq
|
|
18
|
+
#
|
|
19
|
+
# expects :company, model: Company # Auto-inferred: Company.all
|
|
20
|
+
#
|
|
21
|
+
# def call
|
|
22
|
+
# # sync logic
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# SyncCompany.enqueue_all # Automatically iterates Company.all
|
|
27
|
+
#
|
|
28
|
+
# @example With explicit source override
|
|
29
|
+
# enqueues_each :company, from: -> { Company.active }
|
|
30
|
+
#
|
|
31
|
+
# @example With extraction (passes company_id instead of company object)
|
|
32
|
+
# enqueues_each :company_id, from: -> { Company.active }, via: :id
|
|
33
|
+
#
|
|
34
|
+
# @example With filter block
|
|
35
|
+
# enqueues_each :company do |company|
|
|
36
|
+
# company.active? && !company.in_exit?
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# @example Override on enqueue_all call
|
|
40
|
+
# # Override with enumerable (replaces source)
|
|
41
|
+
# SyncCompany.enqueue_all(company: Company.active.limit(10))
|
|
42
|
+
#
|
|
43
|
+
# # Override with scalar (makes it static, no iteration)
|
|
44
|
+
# SyncCompany.enqueue_all(company: Company.find(123))
|
|
45
|
+
#
|
|
46
|
+
# @example Multi-field cross-product
|
|
47
|
+
# enqueues_each :user, from: -> { User.active }
|
|
48
|
+
# enqueues_each :company, from: -> { Company.active }
|
|
49
|
+
# # Produces user_count × company_count jobs
|
|
50
|
+
module BatchEnqueue
|
|
51
|
+
extend ActiveSupport::Concern
|
|
52
|
+
|
|
53
|
+
included do
|
|
54
|
+
class_attribute :_batch_enqueue_configs, default: []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# DSL methods for batch enqueueing
|
|
58
|
+
module DSL
|
|
59
|
+
# Batch enqueue jobs for this action.
|
|
60
|
+
#
|
|
61
|
+
# Validates async is configured, validates static args, then executes
|
|
62
|
+
# iteration asynchronously via EnqueueAllOrchestrator.
|
|
63
|
+
#
|
|
64
|
+
# Fields with `model:` declarations are automatically inferred for iteration.
|
|
65
|
+
# You can override iteration by passing enumerables (to replace source) or
|
|
66
|
+
# scalars (to make fields static) as kwargs.
|
|
67
|
+
#
|
|
68
|
+
# @param static_args [Hash] Arguments to pass to every enqueued job.
|
|
69
|
+
# - Scalar values: Treated as static args (passed to all jobs)
|
|
70
|
+
# - Enumerable values: Treated as iteration sources (overrides configured sources)
|
|
71
|
+
# - Exception: Arrays/Sets are static when field expects enumerable type
|
|
72
|
+
# @return [String] Job ID from the async adapter
|
|
73
|
+
# @raise [NotImplementedError] If async is not configured
|
|
74
|
+
# @raise [MissingEnqueuesEachError] If expects exist but no iteration config found
|
|
75
|
+
# @raise [ArgumentError] If required static fields are missing
|
|
76
|
+
def enqueue_all(**static_args)
|
|
77
|
+
EnqueueAllOrchestrator.enqueue_for(self, **static_args)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Declare a field to iterate over for batch enqueueing.
|
|
81
|
+
#
|
|
82
|
+
# Note: Fields with `model:` declarations are automatically inferred, so
|
|
83
|
+
# `enqueues_each` is only needed to override defaults, add filtering, or
|
|
84
|
+
# iterate non-model fields.
|
|
85
|
+
#
|
|
86
|
+
# @param field [Symbol] The field name from expects to iterate over
|
|
87
|
+
# @param from [Proc, Symbol, nil] The source collection.
|
|
88
|
+
# - Proc/lambda: Called to get the collection
|
|
89
|
+
# - Symbol: Method name on the action class
|
|
90
|
+
# - nil: Inferred from field's `model:` declaration (Model.all)
|
|
91
|
+
# @param via [Symbol, nil] Optional attribute to extract from each item (e.g., :id)
|
|
92
|
+
# @param block [Proc, nil] Optional filter block - return truthy to enqueue, falsy to skip
|
|
93
|
+
def enqueues_each(field, from: nil, via: nil, &filter_block)
|
|
94
|
+
self._batch_enqueue_configs += [Config.new(field:, from:, via:, filter_block:)]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|