active_durable 0.5.0

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +131 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +630 -0
  5. data/Rakefile +12 -0
  6. data/app/controllers/active_durable/application_controller.rb +25 -0
  7. data/app/controllers/active_durable/executions_controller.rb +59 -0
  8. data/app/helpers/active_durable/dashboard_helper.rb +163 -0
  9. data/app/views/active_durable/executions/index.html.erb +90 -0
  10. data/app/views/active_durable/executions/show.html.erb +197 -0
  11. data/app/views/layouts/active_durable/application.html.erb +422 -0
  12. data/config/routes.rb +13 -0
  13. data/lib/active_durable/configuration.rb +59 -0
  14. data/lib/active_durable/engine.rb +24 -0
  15. data/lib/active_durable/errors.rb +61 -0
  16. data/lib/active_durable/execution.rb +43 -0
  17. data/lib/active_durable/flow.rb +277 -0
  18. data/lib/active_durable/flow_parallel.rb +200 -0
  19. data/lib/active_durable/lease.rb +50 -0
  20. data/lib/active_durable/notebook.rb +100 -0
  21. data/lib/active_durable/open_telemetry.rb +94 -0
  22. data/lib/active_durable/operations.rb +111 -0
  23. data/lib/active_durable/parallel.rb +54 -0
  24. data/lib/active_durable/record.rb +13 -0
  25. data/lib/active_durable/registry.rb +89 -0
  26. data/lib/active_durable/retry_policy.rb +42 -0
  27. data/lib/active_durable/run_job.rb +12 -0
  28. data/lib/active_durable/runner.rb +157 -0
  29. data/lib/active_durable/serializer.rb +40 -0
  30. data/lib/active_durable/signal_record.rb +20 -0
  31. data/lib/active_durable/step.rb +34 -0
  32. data/lib/active_durable/sweep_job.rb +12 -0
  33. data/lib/active_durable/sweeper.rb +22 -0
  34. data/lib/active_durable/testing.rb +118 -0
  35. data/lib/active_durable/version.rb +5 -0
  36. data/lib/active_durable.rb +145 -0
  37. data/lib/generators/active_durable/install/install_generator.rb +27 -0
  38. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +60 -0
  39. data/lib/tasks/active_durable.rake +24 -0
  40. data/sig/active_durable.rbs +4 -0
  41. metadata +134 -0
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # The safety net. Finds executions that should be running but have no job: the process died between
5
+ # the commit and the enqueue, a timer job was lost, or a worker crashed and its lease expired.
6
+ # Enqueuing twice is harmless: only one worker can claim the lease.
7
+ module Sweeper
8
+ module_function
9
+
10
+ def call(now: ActiveDurable.now)
11
+ quiet = Execution.active
12
+ .where("locked_until IS NULL OR locked_until < ?", now)
13
+ .where("updated_at < ?", now - ActiveDurable.config.sweep_grace.to_f)
14
+ due = quiet.where(status: %w[pending running])
15
+ .or(quiet.where(wake_at: ..now))
16
+ .or(quiet.where(status: "waiting", id: SignalRecord.pending.select(:execution_id)))
17
+ ids = due.pluck(:id)
18
+ ids.each { |id| ActiveDurable.enqueue(id) }
19
+ ids
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_durable"
4
+
5
+ module ActiveDurable
6
+ # Helpers for your test suite. require "active_durable/testing" in spec_helper.rb / test_helper.rb.
7
+ module Testing
8
+ # Raised at a crash point. It inherits from Exception so nothing in your code or ours rescues it,
9
+ # exactly like a process that dies.
10
+ class SimulatedCrash < Exception; end # rubocop:disable Lint/InheritException
11
+
12
+ module_function
13
+
14
+ # Forget simulated time and crash hooks. Call it before each test.
15
+ def reset!
16
+ ActiveDurable.time_offset = 0.0
17
+ ActiveDurable.crash_hook = nil
18
+ end
19
+
20
+ # Moves ActiveDurable's clock forward without sleeping.
21
+ def travel(seconds)
22
+ ActiveDurable.time_offset = ActiveDurable.time_offset + seconds.to_f
23
+ end
24
+
25
+ # Runs an execution synchronously until it finishes, fast-forwarding sleeps, retries and expired
26
+ # leases. Signals given as { name => payload } are delivered when the saga waits for them.
27
+ # Returns the execution; it stays "waiting" if it waits for a signal you did not provide.
28
+ def drain(execution_id, signals: {}, max_runs: 500)
29
+ signals = signals.transform_keys(&:to_s)
30
+ max_runs.times do
31
+ execution = Execution.find(execution_id)
32
+ return execution if execution.terminal?
33
+ return execution unless ready_for_next_run?(execution, signals)
34
+
35
+ Runner.run(execution_id)
36
+ end
37
+ raise Error, "execution #{execution_id} did not settle after #{max_runs} runs"
38
+ end
39
+
40
+ # Runs the saga once without failures to find every crash point, then once more per crash point,
41
+ # crashing there and resuming with a new worker. Yields each finished execution and the point.
42
+ #
43
+ # ActiveDurable::Testing.crash_everywhere(:checkout, order_id: order.id) do |execution, point|
44
+ # expect(FakeStripe.charges_per_ticket.values).to all(eq 1)
45
+ # end
46
+ def crash_everywhere(recipe, signals: {}, **input)
47
+ points = []
48
+ discovery = with_crash_hook(->(kind, name) { points << [kind, name] }) do
49
+ drain(start_quietly(recipe, input), signals: signals)
50
+ end
51
+ yield discovery, nil if block_given?
52
+
53
+ points.each_with_index.map do |point, index|
54
+ execution = crash_at(recipe, input, index + 1, signals)
55
+ yield execution, point if block_given?
56
+ execution
57
+ end.unshift(discovery)
58
+ end
59
+
60
+ def crash_at(recipe, input, hit_number, signals)
61
+ id = start_quietly(recipe, input)
62
+ hits = 0
63
+ crash = lambda do |kind, name|
64
+ hits += 1
65
+ raise SimulatedCrash, "simulated crash at #{kind} :#{name}" if hits == hit_number
66
+ end
67
+ begin
68
+ with_crash_hook(crash) { drain(id, signals: signals) }
69
+ rescue SimulatedCrash
70
+ nil
71
+ end
72
+ drain(id, signals: signals)
73
+ end
74
+
75
+ def start_quietly(recipe, input)
76
+ previous = ActiveDurable.enqueue_disabled
77
+ ActiveDurable.enqueue_disabled = true
78
+ ActiveDurable.start(recipe, **input).id
79
+ ensure
80
+ ActiveDurable.enqueue_disabled = previous
81
+ end
82
+
83
+ def with_crash_hook(hook)
84
+ previous = ActiveDurable.crash_hook
85
+ ActiveDurable.crash_hook = hook
86
+ yield
87
+ ensure
88
+ ActiveDurable.crash_hook = previous
89
+ end
90
+
91
+ # Decides what has to happen before the next run. Returns false when nothing can move the execution.
92
+ def ready_for_next_run?(execution, signals)
93
+ now = ActiveDurable.now
94
+ if execution.locked_until && execution.locked_until > now
95
+ travel(execution.locked_until - now + 0.001) # a crashed worker still holds the lease
96
+ elsif execution.status == "waiting"
97
+ return waiting_can_move?(execution, signals, now)
98
+ elsif execution.wake_at && execution.wake_at > now
99
+ travel(execution.wake_at - now + 0.001)
100
+ end
101
+ true
102
+ end
103
+
104
+ def waiting_can_move?(execution, signals, now)
105
+ return true if SignalRecord.pending.exists?(execution_id: execution.id)
106
+
107
+ waiting = execution.steps.find_by(status: "waiting")
108
+ if waiting && signals.key?(waiting.name)
109
+ ActiveDurable.signal(execution.id, waiting.name, signals[waiting.name])
110
+ elsif execution.wake_at
111
+ travel(execution.wake_at - now + 0.001) if execution.wake_at > now
112
+ else
113
+ return false
114
+ end
115
+ true
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ VERSION = "0.5.0"
5
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger" # concurrent-ruby 1.3.5+ no longer loads it, and older Active Support expects it
4
+ require "securerandom"
5
+ require "set"
6
+ require "time"
7
+ require "active_support"
8
+ require "active_support/core_ext"
9
+ require "active_record"
10
+ require "active_job"
11
+
12
+ require_relative "active_durable/version"
13
+ require_relative "active_durable/errors"
14
+ require_relative "active_durable/configuration"
15
+ require_relative "active_durable/serializer"
16
+ require_relative "active_durable/retry_policy"
17
+ require_relative "active_durable/registry"
18
+ require_relative "active_durable/lease"
19
+ require_relative "active_durable/notebook"
20
+ require_relative "active_durable/flow"
21
+ require_relative "active_durable/parallel"
22
+ require_relative "active_durable/flow_parallel"
23
+ require_relative "active_durable/runner"
24
+ require_relative "active_durable/sweeper"
25
+ require_relative "active_durable/operations"
26
+
27
+ # Durable sagas for Rails. See README.md.
28
+ module ActiveDurable
29
+ # Loaded on first use: defining them at require time would load ActiveRecord::Base and
30
+ # ActiveJob::Base before a Rails app has applied its configuration.
31
+ autoload :Record, File.expand_path("active_durable/record", __dir__)
32
+ autoload :Execution, File.expand_path("active_durable/execution", __dir__)
33
+ autoload :Step, File.expand_path("active_durable/step", __dir__)
34
+ autoload :SignalRecord, File.expand_path("active_durable/signal_record", __dir__)
35
+ autoload :RunJob, File.expand_path("active_durable/run_job", __dir__)
36
+ autoload :SweepJob, File.expand_path("active_durable/sweep_job", __dir__)
37
+
38
+ class << self
39
+ attr_writer :time_offset
40
+ attr_accessor :crash_hook, :enqueue_disabled
41
+
42
+ def config
43
+ @config ||= Configuration.new
44
+ end
45
+
46
+ def configure
47
+ yield config
48
+ end
49
+
50
+ def registry
51
+ @registry ||= Registry.new
52
+ end
53
+
54
+ # Defines a recipe. Assign the result to a constant (CheckoutSaga = Durable.define(:checkout) { ... })
55
+ # so Rails can autoload it in any process.
56
+ #
57
+ # To change a recipe while executions are in flight, keep the old block and add a new version:
58
+ # new executions use the highest version, and every execution keeps running the version it started with.
59
+ def define(name, version: 1, &)
60
+ registry.define(name, version: version, &)
61
+ end
62
+
63
+ # { ["checkout", 1] => 12, ["checkout", 2] => 340 }: executions that are not finished yet (active or
64
+ # blocked) per recipe version. A version can be deleted once it no longer appears here.
65
+ def versions_in_use
66
+ Execution.where(status: Execution::ACTIVE + %w[blocked]).group(:recipe, :recipe_version).count
67
+ .transform_keys { |(recipe, version)| [recipe, version.to_i] }
68
+ end
69
+
70
+ # Starts a saga. Call it inside the transaction that creates your record: the saga row is committed
71
+ # with it, and the job is enqueued only after the commit. Pass id: to make the call idempotent.
72
+ def start(name, id: nil, **input)
73
+ recipe = registry.latest(name)
74
+ attributes = { recipe: recipe.name, recipe_version: recipe.version, status: "pending",
75
+ input: Serializer.normalize(input, "input") }
76
+ return Execution.create!(attributes.merge(id: "#{recipe.name}-#{SecureRandom.uuid}")) if id.nil?
77
+
78
+ execution = Execution.create_or_find_by!(id: id.to_s) { |record| record.assign_attributes(attributes) }
79
+ return execution if execution.recipe == recipe.name
80
+
81
+ raise ArgumentError, "execution #{id} already exists for recipe :#{execution.recipe}"
82
+ end
83
+
84
+ # Delivers a signal to a saga waiting (now or later) in flow.wait_for(name).
85
+ def signal(execution_id, name, payload = nil)
86
+ execution = Execution.find(execution_id)
87
+ raise Error, "execution #{execution_id} already finished (#{execution.status})" if execution.terminal?
88
+
89
+ SignalRecord.create!(execution_id: execution.id, name: name.to_s,
90
+ payload: Serializer.normalize(payload, "signal payload"))
91
+ end
92
+
93
+ def find(execution_id)
94
+ Execution.find(execution_id)
95
+ end
96
+
97
+ # See ActiveDurable::Operations.
98
+ def retry(execution_id)
99
+ Operations.retry(execution_id)
100
+ end
101
+
102
+ def compensate(execution_id, reason: "compensated by an operator")
103
+ Operations.compensate(execution_id, reason: reason)
104
+ end
105
+
106
+ def rerun(execution_id, from:)
107
+ Operations.rerun(execution_id, from: from)
108
+ end
109
+
110
+ def now
111
+ config.clock.call + time_offset
112
+ end
113
+
114
+ def time_offset
115
+ @time_offset ||= 0.0
116
+ end
117
+
118
+ # Objects with #capture (called in the worker thread) and #wrap(captured) { } (called around each
119
+ # flow.parallel branch thread). Used to carry context such as OpenTelemetry spans into the branches.
120
+ def branch_wrappers
121
+ @branch_wrappers ||= []
122
+ end
123
+
124
+ def enqueue(execution_id, wait_until: nil)
125
+ return if enqueue_disabled
126
+
127
+ job = wait_until ? RunJob.set(wait_until: wait_until) : RunJob
128
+ job.perform_later(execution_id)
129
+ end
130
+
131
+ def instrument(event, payload = {}, &)
132
+ ActiveSupport::Notifications.instrument("#{event}.active_durable", payload, &)
133
+ end
134
+
135
+ # Hook used by ActiveDurable::Testing to simulate crashes at precise points.
136
+ def crash_point(kind, name)
137
+ crash_hook&.call(kind, name)
138
+ end
139
+ end
140
+ end
141
+
142
+ # The short name used in recipes. Skipped if your app already defines a Durable constant.
143
+ Durable = ActiveDurable unless defined?(Durable)
144
+
145
+ require_relative "active_durable/engine" if defined?(Rails::Engine)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module ActiveDurable
7
+ module Generators
8
+ # rails generate active_durable:install
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+ desc "Creates the migration for ActiveDurable's tables (sagas, notebook and signals)."
14
+
15
+ def create_migration_file
16
+ migration_template "create_active_durable_tables.rb.tt", "db/migrate/create_active_durable_tables.rb",
17
+ migration_version: migration_version
18
+ end
19
+
20
+ private
21
+
22
+ def migration_version
23
+ "[#{ActiveRecord::Migration.current_version}]"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateActiveDurableTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ # One row per saga run. It is also the buzón: create it in the same transaction as your record.
6
+ create_table :durable_executions, id: false do |t|
7
+ t.string :id, null: false, primary_key: true
8
+ t.string :recipe, null: false
9
+ t.integer :recipe_version, null: false, default: 1
10
+ t.string :status, null: false
11
+ t.boolean :compensating, null: false, default: false
12
+ t.column :input, json_type
13
+ t.column :output, json_type
14
+ t.column :error, json_type
15
+ t.datetime :wake_at, precision: 6
16
+ t.datetime :locked_until, precision: 6
17
+ t.string :lease_token
18
+ t.string :forked_from
19
+ t.timestamps precision: 6
20
+ end
21
+ add_index :durable_executions, %i[status wake_at]
22
+ add_index :durable_executions, %i[recipe status]
23
+ add_index :durable_executions, :forked_from
24
+
25
+ # The notebook: one row per step (and per undo) of an execution.
26
+ create_table :durable_steps do |t|
27
+ t.string :execution_id, null: false
28
+ t.string :name, null: false
29
+ t.string :kind, null: false
30
+ t.integer :position
31
+ t.string :status, null: false
32
+ t.integer :attempts, null: false, default: 0
33
+ t.column :result, json_type
34
+ t.column :error, json_type
35
+ t.datetime :wake_at, precision: 6
36
+ t.timestamps precision: 6
37
+ end
38
+ add_index :durable_steps, %i[execution_id name], unique: true
39
+ add_index :durable_steps, %i[execution_id position]
40
+ add_foreign_key :durable_steps, :durable_executions, column: :execution_id, on_delete: :cascade
41
+
42
+ # Signals for flow.wait_for. They can arrive before the saga starts waiting.
43
+ create_table :durable_signals do |t|
44
+ t.string :execution_id, null: false
45
+ t.string :name, null: false
46
+ t.column :payload, json_type
47
+ t.datetime :consumed_at, precision: 6
48
+ t.datetime :created_at, null: false, precision: 6
49
+ end
50
+ add_index :durable_signals, %i[execution_id name consumed_at]
51
+ add_foreign_key :durable_signals, :durable_executions, column: :execution_id, on_delete: :cascade
52
+ end
53
+
54
+ private
55
+
56
+ # jsonb on PostgreSQL, json elsewhere. No database defaults on these columns: MySQL rejects them.
57
+ def json_type
58
+ connection.adapter_name.downcase.include?("postg") ? :jsonb : :json
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :active_durable do
4
+ desc "List recipe versions that unfinished executions still use, so you know when an old version can go"
5
+ task versions: :environment do
6
+ in_use = ActiveDurable.versions_in_use
7
+ if in_use.empty?
8
+ puts "No unfinished executions."
9
+ else
10
+ in_use.sort.each do |(recipe, version), count|
11
+ defined = ActiveDurable.registry.versions_for(recipe).any? { |r| r.version == version }
12
+ puts format("%-30<recipe>s v%-4<version>d %6<count>d unfinished%<warning>s",
13
+ recipe: recipe, version: version, count: count,
14
+ warning: defined ? "" : " <- NOT DEFINED: these executions will block")
15
+ end
16
+ end
17
+ end
18
+
19
+ desc "Enqueue executions that should be running but have no job (run it every minute)"
20
+ task sweep: :environment do
21
+ ids = ActiveDurable::Sweeper.call
22
+ puts "ActiveDurable: enqueued #{ids.size} execution(s)"
23
+ end
24
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveDurable
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_durable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - William Romero
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '6.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '6.1'
55
+ description: |
56
+ ActiveDurable runs multi-step business flows (checkouts, onboarding, payouts) as durable sagas
57
+ stored in your own database. Completed steps are checkpointed and never repeated after a crash,
58
+ failures before the point of no return are compensated in reverse order, and failures after it
59
+ are retried. No Redis and no extra servers: just Active Record and Active Job.
60
+ email:
61
+ - williamromerovela@gmail.com
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - CHANGELOG.md
67
+ - LICENSE.txt
68
+ - README.md
69
+ - Rakefile
70
+ - app/controllers/active_durable/application_controller.rb
71
+ - app/controllers/active_durable/executions_controller.rb
72
+ - app/helpers/active_durable/dashboard_helper.rb
73
+ - app/views/active_durable/executions/index.html.erb
74
+ - app/views/active_durable/executions/show.html.erb
75
+ - app/views/layouts/active_durable/application.html.erb
76
+ - config/routes.rb
77
+ - lib/active_durable.rb
78
+ - lib/active_durable/configuration.rb
79
+ - lib/active_durable/engine.rb
80
+ - lib/active_durable/errors.rb
81
+ - lib/active_durable/execution.rb
82
+ - lib/active_durable/flow.rb
83
+ - lib/active_durable/flow_parallel.rb
84
+ - lib/active_durable/lease.rb
85
+ - lib/active_durable/notebook.rb
86
+ - lib/active_durable/open_telemetry.rb
87
+ - lib/active_durable/operations.rb
88
+ - lib/active_durable/parallel.rb
89
+ - lib/active_durable/record.rb
90
+ - lib/active_durable/registry.rb
91
+ - lib/active_durable/retry_policy.rb
92
+ - lib/active_durable/run_job.rb
93
+ - lib/active_durable/runner.rb
94
+ - lib/active_durable/serializer.rb
95
+ - lib/active_durable/signal_record.rb
96
+ - lib/active_durable/step.rb
97
+ - lib/active_durable/sweep_job.rb
98
+ - lib/active_durable/sweeper.rb
99
+ - lib/active_durable/testing.rb
100
+ - lib/active_durable/version.rb
101
+ - lib/generators/active_durable/install/install_generator.rb
102
+ - lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt
103
+ - lib/tasks/active_durable.rake
104
+ - sig/active_durable.rbs
105
+ homepage: https://github.com/williamromero/active_durable
106
+ licenses:
107
+ - MIT
108
+ metadata:
109
+ homepage_uri: https://github.com/williamromero/active_durable
110
+ source_code_uri: https://github.com/williamromero/active_durable
111
+ changelog_uri: https://github.com/williamromero/active_durable/blob/main/CHANGELOG.md
112
+ bug_tracker_uri: https://github.com/williamromero/active_durable/issues
113
+ rubygems_mfa_required: 'true'
114
+ post_install_message:
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: 3.1.0
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 3.5.7
130
+ signing_key:
131
+ specification_version: 4
132
+ summary: 'Durable sagas for Rails: finish the work or undo it in order, even if the
133
+ server dies.'
134
+ test_files: []