active_saga 0.1.3 → 0.1.5
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/ADAPTERS.md +1 -1
- data/README.md +21 -3
- data/lib/active_saga/configuration.rb +38 -1
- data/lib/active_saga/dsl/options.rb +8 -1
- data/lib/active_saga/jobs/runner_job.rb +3 -1
- data/lib/active_saga/stores/active_record.rb +56 -10
- data/lib/active_saga/stores/base.rb +14 -1
- data/lib/active_saga/task.rb +1 -1
- data/lib/active_saga/version.rb +1 -1
- data/lib/active_saga.rb +42 -0
- data/lib/generators/active_saga/install/templates/initializer.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f65ca97601199df9c8aa9e181356b7e37d373978fbd356c56ea1d49ed56dea9
|
|
4
|
+
data.tar.gz: a666fb0d16559a8ae29bcf0ea0675205133f923d25da533d5ae35413302416b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 258505e4e70af04dd2942d1d183f2bee1b64c80239f110b3156d216383c2a1eb9f39f36dc6412c897d7d95dfffc8ef1c040bbce6208487a50573e9eca13eafad
|
|
7
|
+
data.tar.gz: 569dcf7156607d0676576d1e5f89797fa0f2733336834a93ed2053e243b46411c0c60b2fdebdca122d6739f8b748ec88452c87de9db2119247999692138f84de
|
data/ADAPTERS.md
CHANGED
|
@@ -15,7 +15,7 @@ All adapters inherit from `ActiveSaga::Stores::Base` and must implement:
|
|
|
15
15
|
- `extend_timeout!(execution_id, step_name, by:)`
|
|
16
16
|
- `heartbeat!(execution_id, step_name, at:)`
|
|
17
17
|
- `signal!(execution_id, name, payload:)`
|
|
18
|
-
- `enqueue_runner(
|
|
18
|
+
- `enqueue_runner(execution, step_name: nil, run_at: nil)` – adapters may simply enqueue the internal runner job.
|
|
19
19
|
- `cancel_execution!(execution_id, reason: nil)` – run compensations and cancel remaining steps.
|
|
20
20
|
|
|
21
21
|
The methods must be **atomic**: never run business code outside a transaction or lock that protects
|
data/README.md
CHANGED
|
@@ -80,10 +80,11 @@ The installer creates:
|
|
|
80
80
|
```ruby
|
|
81
81
|
# config/initializers/active_saga.rb
|
|
82
82
|
ActiveSaga.configure do |c|
|
|
83
|
-
c.store =
|
|
83
|
+
c.store = :active_record
|
|
84
84
|
c.logger = Rails.logger
|
|
85
85
|
c.serializer = :json
|
|
86
86
|
c.clock = -> { Time.now.utc }
|
|
87
|
+
# c.queue = :critical
|
|
87
88
|
end
|
|
88
89
|
```
|
|
89
90
|
|
|
@@ -92,10 +93,11 @@ end
|
|
|
92
93
|
class CheckoutFlow < ActiveSaga::Workflow
|
|
93
94
|
idempotency_key { "checkout:#{ctx[:order_id]}" }
|
|
94
95
|
defaults retry: { max: 5, backoff: :exponential, jitter: true, first_delay: 1.second }
|
|
96
|
+
queue :critical
|
|
95
97
|
timeout 30.minutes
|
|
96
98
|
|
|
97
99
|
step :charge_card, compensate: :refund_payment, retry: { max: 6, first_delay: 2.seconds }
|
|
98
|
-
task :reserve_stock, ReserveStockTask, dedupe: true
|
|
100
|
+
task :reserve_stock, ReserveStockTask, dedupe: true, queue: :fulfillment
|
|
99
101
|
task :send_receipt, fire_and_forget: true do |ctx|
|
|
100
102
|
Mailers::Receipt.deliver_later(ctx[:order_id])
|
|
101
103
|
end
|
|
@@ -327,7 +329,7 @@ step :slow_call, retry: { max: 10, backoff: :fixed, delay: 5.seconds }
|
|
|
327
329
|
|
|
328
330
|
```ruby
|
|
329
331
|
ActiveSaga.configure do |c|
|
|
330
|
-
c.store =
|
|
332
|
+
c.store = :active_record
|
|
331
333
|
c.logger = Rails.logger
|
|
332
334
|
c.serializer = :json # or a custom object responding to dump/load
|
|
333
335
|
c.clock = -> { Time.now.utc }
|
|
@@ -359,6 +361,7 @@ You can implement other stores (e.g., Redis) by following the `ActiveSaga::Store
|
|
|
359
361
|
- A single internal job (e.g., `ActiveSaga::Jobs::RunnerJob`) receives execution/step IDs and performs the next transition safely.
|
|
360
362
|
- **No dependency** on specific backends (Sidekiq, Solid Queue, etc.)—any Active Job adapter works.
|
|
361
363
|
- Timers/backoffs are scheduled via `set(wait_until: ...)`.
|
|
364
|
+
- Set a default queue with `queue :critical` in your workflow, and override individual steps via `queue: :fulfillment`.
|
|
362
365
|
|
|
363
366
|
---
|
|
364
367
|
|
|
@@ -383,6 +386,21 @@ Each event carries identifiers (execution_id, workflow, step), timings, attempts
|
|
|
383
386
|
|
|
384
387
|
---
|
|
385
388
|
|
|
389
|
+
## Debugging Helpers
|
|
390
|
+
|
|
391
|
+
Quick helpers make it easier to inspect running or finished executions from the console:
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
ActiveSaga.executions(limit: 20).map { |exec| [exec.id, exec.state] }
|
|
395
|
+
ActiveSaga.steps_for_execution(execution_id)
|
|
396
|
+
ActiveSaga.events_for_execution(execution_id)
|
|
397
|
+
ActiveSaga.failure_summary(execution_id)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
These APIs surface the stored step/event data without querying the tables directly.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
386
404
|
## Generators
|
|
387
405
|
|
|
388
406
|
```bash
|
|
@@ -5,12 +5,14 @@ require "active_support/logger"
|
|
|
5
5
|
module ActiveSaga
|
|
6
6
|
# Holds gem-level configuration.
|
|
7
7
|
class Configuration
|
|
8
|
-
attr_accessor :
|
|
8
|
+
attr_accessor :serializer, :logger, :clock, :queue
|
|
9
|
+
attr_reader :store
|
|
9
10
|
|
|
10
11
|
def initialize
|
|
11
12
|
@serializer = ActiveSaga::Serializers::Json.new
|
|
12
13
|
@logger = ActiveSupport::Logger.new($stdout, level: :info)
|
|
13
14
|
@clock = -> { Time.now.utc }
|
|
15
|
+
@queue = :active_saga
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
# Ensures store is set before usage.
|
|
@@ -19,5 +21,40 @@ module ActiveSaga
|
|
|
19
21
|
|
|
20
22
|
store
|
|
21
23
|
end
|
|
24
|
+
|
|
25
|
+
def store=(value)
|
|
26
|
+
@store = normalize_store(value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def normalize_store(value)
|
|
32
|
+
return nil if value.nil?
|
|
33
|
+
|
|
34
|
+
case value
|
|
35
|
+
when Symbol, String
|
|
36
|
+
resolve_store_class(value).new
|
|
37
|
+
when Class
|
|
38
|
+
ensure_store_class!(value)
|
|
39
|
+
value.new
|
|
40
|
+
else
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolve_store_class(name)
|
|
46
|
+
case name.to_sym
|
|
47
|
+
when :active_record
|
|
48
|
+
ActiveSaga::Stores::ActiveRecord
|
|
49
|
+
else
|
|
50
|
+
raise ActiveSaga::Errors::Configuration, "Unknown store adapter: #{name}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ensure_store_class!(klass)
|
|
55
|
+
return if klass <= ActiveSaga::Stores::Base
|
|
56
|
+
|
|
57
|
+
raise ActiveSaga::Errors::Configuration, "Store class must inherit from ActiveSaga::Stores::Base"
|
|
58
|
+
end
|
|
22
59
|
end
|
|
23
60
|
end
|
|
@@ -8,6 +8,7 @@ module ActiveSaga
|
|
|
8
8
|
base.class_attribute :_as_defaults, instance_writer: false, default: {}
|
|
9
9
|
base.class_attribute :_as_timeout, instance_writer: false, default: nil
|
|
10
10
|
base.class_attribute :_as_idempotency_block, instance_writer: false, default: nil
|
|
11
|
+
base.class_attribute :_as_queue, instance_writer: false, default: nil
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def defaults(opts = nil)
|
|
@@ -26,8 +27,14 @@ module ActiveSaga
|
|
|
26
27
|
block ? self._as_idempotency_block = block : _as_idempotency_block
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
def queue(value = nil)
|
|
31
|
+
value ? self._as_queue = value : _as_queue
|
|
32
|
+
end
|
|
33
|
+
|
|
29
34
|
def resolve_defaults(step_options)
|
|
30
|
-
defaults
|
|
35
|
+
base_defaults = defaults
|
|
36
|
+
base_defaults[:queue] ||= _as_queue if _as_queue
|
|
37
|
+
base_defaults.deep_merge(step_options.deep_symbolize_keys)
|
|
31
38
|
end
|
|
32
39
|
end
|
|
33
40
|
end
|
|
@@ -4,7 +4,9 @@ module ActiveSaga
|
|
|
4
4
|
module Jobs
|
|
5
5
|
# Executes workflow transitions within Active Job.
|
|
6
6
|
class RunnerJob < ActiveJob::Base
|
|
7
|
-
queue_as
|
|
7
|
+
queue_as do
|
|
8
|
+
ActiveSaga.configuration.queue || :active_saga
|
|
9
|
+
end
|
|
8
10
|
|
|
9
11
|
# We want retries for transient errors only; user-defined retries handled within workflow.
|
|
10
12
|
retry_on ActiveRecord::Deadlocked, wait: 1.second, attempts: 5 if defined?(ActiveRecord::Deadlocked)
|
|
@@ -99,7 +99,7 @@ module ActiveSaga
|
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
-
enqueue_runner(record.
|
|
102
|
+
enqueue_runner(record, step_name: steps.first && steps.first[:name]) unless steps.empty?
|
|
103
103
|
build_execution(record)
|
|
104
104
|
end
|
|
105
105
|
|
|
@@ -127,14 +127,43 @@ module ActiveSaga
|
|
|
127
127
|
build_execution(record) if record
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
def executions(limit: 100, offset: 0, workflow_class: nil, state: nil, order: :desc)
|
|
131
|
+
scope = Models::Execution.order(created_at: order == :asc ? :asc : :desc)
|
|
132
|
+
if workflow_class
|
|
133
|
+
name = workflow_class.is_a?(Class) ? workflow_class.name : workflow_class.to_s
|
|
134
|
+
scope = scope.where(workflow_class: name)
|
|
135
|
+
end
|
|
136
|
+
scope = scope.where(state:) if state
|
|
137
|
+
scope = scope.offset(offset) if offset&.positive?
|
|
138
|
+
scope = scope.limit(limit) if limit
|
|
139
|
+
scope.map { |record| build_execution(record) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def steps_for(execution_id)
|
|
143
|
+
Models::Step.where(execution_id: execution_id).order(:position).map do |step|
|
|
144
|
+
step.attributes.deep_symbolize_keys
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def events_for(execution_id)
|
|
149
|
+
Models::Event.where(execution_id: execution_id).order(:created_at).map do |event|
|
|
150
|
+
event.attributes.deep_symbolize_keys
|
|
135
151
|
end
|
|
136
152
|
end
|
|
137
153
|
|
|
154
|
+
def enqueue_runner(execution, step_name: nil, run_at: nil)
|
|
155
|
+
return if execution.nil?
|
|
156
|
+
|
|
157
|
+
options = {}
|
|
158
|
+
options[:wait_until] = run_at if run_at
|
|
159
|
+
queue = queue_for_execution(execution, step_name)
|
|
160
|
+
options[:queue] = queue if queue
|
|
161
|
+
|
|
162
|
+
job = ActiveSaga::Jobs::RunnerJob
|
|
163
|
+
job = job.set(options) if options.any?
|
|
164
|
+
job.perform_later(execution.id)
|
|
165
|
+
end
|
|
166
|
+
|
|
138
167
|
def with_execution_lock(execution_id)
|
|
139
168
|
::ActiveRecord::Base.transaction do
|
|
140
169
|
record = Models::Execution.lock.find_by(id: execution_id)
|
|
@@ -194,7 +223,7 @@ module ActiveSaga
|
|
|
194
223
|
|
|
195
224
|
persist_context(execution, ctx)
|
|
196
225
|
advance_cursor(execution, step)
|
|
197
|
-
enqueue_runner(execution.
|
|
226
|
+
enqueue_runner(execution, step_name: execution.cursor_step)
|
|
198
227
|
end
|
|
199
228
|
end
|
|
200
229
|
|
|
@@ -252,7 +281,7 @@ module ActiveSaga
|
|
|
252
281
|
apply_signal_handler(execution, name, payload)
|
|
253
282
|
|
|
254
283
|
if execution.steps.where(name: name.to_s, state: "waiting").exists?
|
|
255
|
-
enqueue_runner(execution
|
|
284
|
+
enqueue_runner(execution, step_name: name)
|
|
256
285
|
end
|
|
257
286
|
end
|
|
258
287
|
end
|
|
@@ -374,7 +403,7 @@ module ActiveSaga
|
|
|
374
403
|
attempts: current_attempts + 1,
|
|
375
404
|
wait: delay)
|
|
376
405
|
|
|
377
|
-
enqueue_runner(execution.
|
|
406
|
+
enqueue_runner(execution, step_name: step.name, run_at: scheduled_at)
|
|
378
407
|
else
|
|
379
408
|
transition_to_failed!(execution, step)
|
|
380
409
|
end
|
|
@@ -392,6 +421,23 @@ module ActiveSaga
|
|
|
392
421
|
|
|
393
422
|
private
|
|
394
423
|
|
|
424
|
+
def queue_for_execution(execution, step_name)
|
|
425
|
+
name = step_name || execution.cursor_step
|
|
426
|
+
queue = nil
|
|
427
|
+
if name
|
|
428
|
+
definition = execution.workflow_class.constantize.step_definition(name.to_sym)
|
|
429
|
+
queue = definition.options[:queue] if definition.options.key?(:queue)
|
|
430
|
+
end
|
|
431
|
+
queue = queue.to_s if queue.is_a?(Symbol)
|
|
432
|
+
queue ||= ActiveSaga.configuration.queue
|
|
433
|
+
queue = queue.to_s if queue.is_a?(Symbol)
|
|
434
|
+
queue
|
|
435
|
+
rescue ActiveSaga::Errors::InvalidStep
|
|
436
|
+
queue = ActiveSaga.configuration.queue
|
|
437
|
+
queue = queue.to_s if queue.is_a?(Symbol)
|
|
438
|
+
queue
|
|
439
|
+
end
|
|
440
|
+
|
|
395
441
|
def run_compensations!(execution, upto:)
|
|
396
442
|
return unless upto
|
|
397
443
|
|
|
@@ -627,7 +673,7 @@ module ActiveSaga
|
|
|
627
673
|
ActiveSupport::Notifications.instrument("active_saga.step.waiting",
|
|
628
674
|
execution_id: execution.id, workflow: execution.workflow_class, step: definition.name)
|
|
629
675
|
|
|
630
|
-
store.enqueue_runner(execution.
|
|
676
|
+
store.enqueue_runner(execution, step_name: definition.name, run_at: timeout_at) if timeout_at
|
|
631
677
|
end
|
|
632
678
|
|
|
633
679
|
def mark_skipped(step, definition)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
module ActiveSaga
|
|
@@ -46,7 +47,7 @@ module ActiveSaga
|
|
|
46
47
|
raise NotImplementedError
|
|
47
48
|
end
|
|
48
49
|
|
|
49
|
-
def enqueue_runner(
|
|
50
|
+
def enqueue_runner(_execution, step_name: nil, run_at: nil)
|
|
50
51
|
raise NotImplementedError
|
|
51
52
|
end
|
|
52
53
|
|
|
@@ -57,6 +58,18 @@ module ActiveSaga
|
|
|
57
58
|
def cancel_execution!(_execution_id, reason: nil)
|
|
58
59
|
raise NotImplementedError
|
|
59
60
|
end
|
|
61
|
+
|
|
62
|
+
def executions(**_options)
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def steps_for(_execution_id)
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def events_for(_execution_id)
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
end
|
|
60
73
|
end
|
|
61
74
|
end
|
|
62
75
|
end
|
data/lib/active_saga/task.rb
CHANGED
data/lib/active_saga/version.rb
CHANGED
data/lib/active_saga.rb
CHANGED
|
@@ -88,6 +88,48 @@ module ActiveSaga
|
|
|
88
88
|
store.cancel_execution!(execution_id, reason: reason)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
def execution(execution_id)
|
|
92
|
+
store.load_execution(execution_id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def executions(**options)
|
|
96
|
+
store.executions(**options)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def steps_for_execution(execution_id)
|
|
100
|
+
store.steps_for(execution_id)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def events_for_execution(execution_id)
|
|
104
|
+
store.events_for(execution_id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def failure_summary(execution_id)
|
|
108
|
+
execution = store.load_execution(execution_id)
|
|
109
|
+
return { execution_id:, state: nil, failures: [] } unless execution
|
|
110
|
+
|
|
111
|
+
steps = store.steps_for(execution_id)
|
|
112
|
+
failures = steps.select { |step| %w[failed timed_out].include?(step[:state]) }
|
|
113
|
+
.map do |step|
|
|
114
|
+
{
|
|
115
|
+
name: step[:name],
|
|
116
|
+
state: step[:state],
|
|
117
|
+
attempts: step[:attempts],
|
|
118
|
+
last_error_class: step[:last_error_class],
|
|
119
|
+
last_error_message: step[:last_error_message],
|
|
120
|
+
last_error_details: step[:last_error_details],
|
|
121
|
+
last_error_at: step[:last_error_at]
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
execution_id: execution.id,
|
|
127
|
+
workflow: execution.workflow_class,
|
|
128
|
+
state: execution.state,
|
|
129
|
+
failures: failures
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
91
133
|
private
|
|
92
134
|
|
|
93
135
|
def with_instrumentation(action, execution_id, step_name)
|