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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f22bd87e089cbb7d4ccd08fe763e561743824bb335131d37a41e2b6b78f8da45
4
- data.tar.gz: 8ba648a0deac1b45cd719fef2c23501d5f5e2ba9f098938ae75f302b6b72b2c8
3
+ metadata.gz: 6f65ca97601199df9c8aa9e181356b7e37d373978fbd356c56ea1d49ed56dea9
4
+ data.tar.gz: a666fb0d16559a8ae29bcf0ea0675205133f923d25da533d5ae35413302416b0
5
5
  SHA512:
6
- metadata.gz: 72361a30caa2c5d3bf49474f10e0cfbf97a926e6280b57429c786e6fd27a801a1541488db02140a274dc425b7941e726145d5e2891123318ed3893b37d81dd96
7
- data.tar.gz: 88ffd609d73f55490aea49d7d60871697b9e49fad0a8347cb01cc5fb3138cab8bd8635791b569b1bbfa62ccd3809c93c5d19226272e2c0098a0b54950a1c1566
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(execution_id, run_at: nil)` – adapters may simply enqueue the internal runner job.
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 = ActiveSaga::Stores::ActiveRecord.new
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 = ActiveSaga::Stores::ActiveRecord.new
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 :store, :serializer, :logger, :clock
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.deep_merge(step_options.deep_symbolize_keys)
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 :active_saga
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.id) unless steps.empty?
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 enqueue_runner(execution_id, run_at: nil)
131
- if run_at
132
- ActiveSaga::Jobs::RunnerJob.set(wait_until: run_at).perform_later(execution_id)
133
- else
134
- ActiveSaga::Jobs::RunnerJob.perform_later(execution_id)
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.id)
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.id)
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.id, run_at: scheduled_at)
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.id, run_at: timeout_at) if timeout_at
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(_execution_id, _step_name = nil)
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
@@ -13,7 +13,7 @@ module ActiveSaga
13
13
  def async_options
14
14
  _as_async_options || {}
15
15
  end
16
- end
16
+ end
17
17
 
18
18
  def initialize(context)
19
19
  @context = context
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveSaga
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.5"
5
5
  end
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)
@@ -4,5 +4,5 @@ ActiveSaga.configure do |config|
4
4
  config.logger = Rails.logger
5
5
  config.clock = -> { Time.now.utc }
6
6
  config.serializer = ActiveSaga::Serializers::Json.new
7
- config.store = ActiveSaga::Stores::ActiveRecord.new
7
+ config.store = :active_record
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_saga
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laerti Papa