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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +131 -0
- data/LICENSE.txt +21 -0
- data/README.md +630 -0
- data/Rakefile +12 -0
- data/app/controllers/active_durable/application_controller.rb +25 -0
- data/app/controllers/active_durable/executions_controller.rb +59 -0
- data/app/helpers/active_durable/dashboard_helper.rb +163 -0
- data/app/views/active_durable/executions/index.html.erb +90 -0
- data/app/views/active_durable/executions/show.html.erb +197 -0
- data/app/views/layouts/active_durable/application.html.erb +422 -0
- data/config/routes.rb +13 -0
- data/lib/active_durable/configuration.rb +59 -0
- data/lib/active_durable/engine.rb +24 -0
- data/lib/active_durable/errors.rb +61 -0
- data/lib/active_durable/execution.rb +43 -0
- data/lib/active_durable/flow.rb +277 -0
- data/lib/active_durable/flow_parallel.rb +200 -0
- data/lib/active_durable/lease.rb +50 -0
- data/lib/active_durable/notebook.rb +100 -0
- data/lib/active_durable/open_telemetry.rb +94 -0
- data/lib/active_durable/operations.rb +111 -0
- data/lib/active_durable/parallel.rb +54 -0
- data/lib/active_durable/record.rb +13 -0
- data/lib/active_durable/registry.rb +89 -0
- data/lib/active_durable/retry_policy.rb +42 -0
- data/lib/active_durable/run_job.rb +12 -0
- data/lib/active_durable/runner.rb +157 -0
- data/lib/active_durable/serializer.rb +40 -0
- data/lib/active_durable/signal_record.rb +20 -0
- data/lib/active_durable/step.rb +34 -0
- data/lib/active_durable/sweep_job.rb +12 -0
- data/lib/active_durable/sweeper.rb +22 -0
- data/lib/active_durable/testing.rb +118 -0
- data/lib/active_durable/version.rb +5 -0
- data/lib/active_durable.rb +145 -0
- data/lib/generators/active_durable/install/install_generator.rb +27 -0
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +60 -0
- data/lib/tasks/active_durable.rake +24 -0
- data/sig/active_durable.rbs +4 -0
- 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,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
|
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: []
|