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,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry"
|
|
4
|
+
require "active_durable"
|
|
5
|
+
|
|
6
|
+
module ActiveDurable
|
|
7
|
+
# Traces executions, steps, compensations and undos as OpenTelemetry spans.
|
|
8
|
+
#
|
|
9
|
+
# # config/initializers/active_durable.rb
|
|
10
|
+
# require "active_durable/open_telemetry"
|
|
11
|
+
# ActiveDurable::OpenTelemetry.install!
|
|
12
|
+
#
|
|
13
|
+
# Spans nest: a worker run ("active_durable.execution checkout") contains its steps, and flow.parallel
|
|
14
|
+
# branches stay under it even though they run in other threads. Failed steps record the exception.
|
|
15
|
+
module OpenTelemetry
|
|
16
|
+
EVENTS = %w[execution step compensation undo].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def install!(tracer_provider: ::OpenTelemetry.tracer_provider)
|
|
20
|
+
uninstall!
|
|
21
|
+
subscriber = Subscriber.new(tracer_provider.tracer("active_durable", ActiveDurable::VERSION))
|
|
22
|
+
@subscriptions = EVENTS.map { |event| ActiveSupport::Notifications.subscribe("#{event}.active_durable", subscriber) }
|
|
23
|
+
unless ActiveDurable.branch_wrappers.include?(ContextPropagation)
|
|
24
|
+
ActiveDurable.branch_wrappers << ContextPropagation
|
|
25
|
+
end
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def uninstall!
|
|
30
|
+
Array(@subscriptions).each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
|
|
31
|
+
@subscriptions = nil
|
|
32
|
+
ActiveDurable.branch_wrappers.delete(ContextPropagation)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Starts a span when an event starts and ends it when the event finishes, so spans nest naturally.
|
|
37
|
+
class Subscriber
|
|
38
|
+
def initialize(tracer)
|
|
39
|
+
@tracer = tracer
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start(name, _id, payload)
|
|
43
|
+
span = @tracer.start_span(span_name(name, payload), attributes: attributes(payload), kind: :internal)
|
|
44
|
+
token = ::OpenTelemetry::Context.attach(::OpenTelemetry::Trace.context_with_span(span))
|
|
45
|
+
stack.push([span, token])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def finish(_name, _id, payload)
|
|
49
|
+
span, token = stack.pop
|
|
50
|
+
return unless span
|
|
51
|
+
|
|
52
|
+
error = payload[:exception_object]
|
|
53
|
+
if error && !error.is_a?(ActiveDurable::ControlFlow)
|
|
54
|
+
span.record_exception(error)
|
|
55
|
+
span.status = ::OpenTelemetry::Trace::Status.error(error.message)
|
|
56
|
+
end
|
|
57
|
+
span.finish
|
|
58
|
+
::OpenTelemetry::Context.detach(token)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def stack
|
|
64
|
+
Thread.current[:active_durable_otel_spans] ||= []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def span_name(event, payload)
|
|
68
|
+
kind = event.delete_suffix(".active_durable")
|
|
69
|
+
target = payload[:step] || payload[:recipe]
|
|
70
|
+
target ? "active_durable.#{kind} #{target}" : "active_durable.#{kind}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attributes(payload)
|
|
74
|
+
{
|
|
75
|
+
"active_durable.execution_id" => payload[:execution_id],
|
|
76
|
+
"active_durable.recipe" => payload[:recipe],
|
|
77
|
+
"active_durable.step" => payload[:step],
|
|
78
|
+
"active_durable.step_kind" => payload[:kind]
|
|
79
|
+
}.compact.transform_values(&:to_s)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Carries the current span into flow.parallel branch threads.
|
|
84
|
+
module ContextPropagation
|
|
85
|
+
def self.capture
|
|
86
|
+
::OpenTelemetry::Context.current
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.wrap(context, &)
|
|
90
|
+
::OpenTelemetry::Context.with_current(context, &)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# What a person can do with an execution: from the console, a script or the dashboard.
|
|
5
|
+
#
|
|
6
|
+
# ActiveDurable.retry("checkout-7") # blocked: try again where it stopped
|
|
7
|
+
# ActiveDurable.compensate("checkout-7", reason: "") # undo everything (only before the pivot)
|
|
8
|
+
# ActiveDurable.rerun("checkout-7", from: :ship) # a new execution that reuses steps before :ship
|
|
9
|
+
#
|
|
10
|
+
# Every operation takes the execution only when no worker holds it, and rotates the lease token so a
|
|
11
|
+
# stale worker can never write again.
|
|
12
|
+
module Operations
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# A blocked execution tries again from where it stopped: failed steps (or failed undos, if it was
|
|
16
|
+
# compensating) get a fresh set of attempts. Deploy the fix first when the cause was a bug.
|
|
17
|
+
def retry(execution_id)
|
|
18
|
+
execution = with_idle_execution(execution_id, allowed: %w[blocked]) do |record|
|
|
19
|
+
failed = record.steps.where(status: "failed")
|
|
20
|
+
failed = record.compensating ? failed.where(kind: "undo") : failed.where.not(kind: "undo")
|
|
21
|
+
failed.update_all(status: "retrying", attempts: 0, wake_at: nil, updated_at: ActiveDurable.now)
|
|
22
|
+
reopen!(record, error: nil)
|
|
23
|
+
end
|
|
24
|
+
ActiveDurable.instrument("retried", execution_id: execution.id)
|
|
25
|
+
execution
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Undoes every completed step, last one first. Not possible once the point of no return was passed.
|
|
29
|
+
def compensate(execution_id, reason: "compensated by an operator")
|
|
30
|
+
execution = with_idle_execution(execution_id, allowed: %w[blocked pending running sleeping waiting]) do |record|
|
|
31
|
+
raise Error, "#{record.id} is already compensating; use ActiveDurable.retry to resume it" if record.compensating
|
|
32
|
+
if record.steps.exists?(kind: "pivot", status: "completed")
|
|
33
|
+
raise Error, "#{record.id} already passed its point of no return; it can only move forward"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
error = { "class" => "ActiveDurable::ManualCompensation", "message" => reason,
|
|
37
|
+
"at" => ActiveDurable.now.utc.iso8601(6) }
|
|
38
|
+
reopen!(record, compensating: true, error: error)
|
|
39
|
+
end
|
|
40
|
+
ActiveDurable.instrument("compensation_requested", execution_id: execution.id, reason: reason)
|
|
41
|
+
execution
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Starts a new execution with the same input that reuses every completed step before `from`.
|
|
45
|
+
# Steps from `from` on run again with new tickets, so they have effects again: that is the point.
|
|
46
|
+
# A blocked original is marked superseded, so it can never compensate the steps they now share.
|
|
47
|
+
def rerun(execution_id, from:)
|
|
48
|
+
from = from.to_s
|
|
49
|
+
execution = nil
|
|
50
|
+
Record.transaction do
|
|
51
|
+
original = Execution.lock.find(execution_id)
|
|
52
|
+
check_rerunnable!(original)
|
|
53
|
+
start = original.steps.where.not(kind: "undo").find_by(name: from)
|
|
54
|
+
raise Error, "#{original.id} has no step :#{from} in its notebook" unless start
|
|
55
|
+
|
|
56
|
+
execution = Execution.create!(id: rerun_id(original), recipe: original.recipe,
|
|
57
|
+
recipe_version: original.recipe_version, input: original.input,
|
|
58
|
+
status: "pending", forked_from: original.id)
|
|
59
|
+
copy_steps(original, execution, before: start.position)
|
|
60
|
+
original.update_columns(status: "superseded", updated_at: ActiveDurable.now) if original.status == "blocked"
|
|
61
|
+
end
|
|
62
|
+
ActiveDurable.instrument("rerun", execution_id: execution.id, forked_from: execution.forked_from, from: from)
|
|
63
|
+
execution
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def with_idle_execution(execution_id, allowed:)
|
|
67
|
+
execution = Record.transaction do
|
|
68
|
+
record = Execution.lock.find(execution_id)
|
|
69
|
+
unless allowed.include?(record.status)
|
|
70
|
+
raise Error, "#{record.id} is #{record.status}; this works on #{allowed.join(", ")} executions"
|
|
71
|
+
end
|
|
72
|
+
if record.locked_until && record.locked_until > ActiveDurable.now
|
|
73
|
+
raise Error, "#{record.id} is running right now; try again in a moment"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
yield record
|
|
77
|
+
record
|
|
78
|
+
end
|
|
79
|
+
ActiveDurable.enqueue(execution.id)
|
|
80
|
+
execution
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reopen!(record, **attributes)
|
|
84
|
+
record.update_columns(status: "pending", wake_at: nil, locked_until: nil, lease_token: SecureRandom.uuid,
|
|
85
|
+
updated_at: ActiveDurable.now, **attributes)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_rerunnable!(original)
|
|
89
|
+
return if original.status == "completed"
|
|
90
|
+
return if original.status == "blocked" && !original.compensating
|
|
91
|
+
|
|
92
|
+
raise Error, "#{original.id} is #{original.status}#{" and compensating" if original.compensating}; " \
|
|
93
|
+
"only completed executions, or blocked ones that are not compensating, can be rerun"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def rerun_id(original)
|
|
97
|
+
root = original.id.sub(/~rerun-\d+\z/, "")
|
|
98
|
+
count = Execution.where("id LIKE ?", "#{Execution.sanitize_sql_like(root)}~rerun-%").count
|
|
99
|
+
"#{root}~rerun-#{count + 1}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def copy_steps(original, execution, before:)
|
|
103
|
+
now = ActiveDurable.now
|
|
104
|
+
rows = original.steps.where.not(kind: "undo").where(status: "completed").where(position: ...before).map do |step|
|
|
105
|
+
{ execution_id: execution.id, name: step.name, kind: step.kind, position: step.position,
|
|
106
|
+
status: "completed", attempts: step.attempts, result: step.result, created_at: now, updated_at: now }
|
|
107
|
+
end
|
|
108
|
+
Step.insert_all!(rows) if rows.any?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# Collects the branches of a flow.parallel block. Nothing runs while the block is read:
|
|
5
|
+
# the branches run afterwards, each in its own thread.
|
|
6
|
+
#
|
|
7
|
+
# results = flow.parallel(:reserve) do |branches|
|
|
8
|
+
# warehouses.each do |warehouse|
|
|
9
|
+
# branches.step(warehouse.code, undo: ->(r) { warehouse.release(r["id"]) }) do |ticket|
|
|
10
|
+
# warehouse.reserve(ticket)
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
# results # => { "MEX" => {...}, "GDL" => {...} }
|
|
15
|
+
class ParallelGroup
|
|
16
|
+
Branch = Struct.new(:name, :full_name, :kind, :undo, :options, :block)
|
|
17
|
+
OPTIONS = %i[retry undo_on_failure].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :branches
|
|
20
|
+
|
|
21
|
+
def initialize(parallel_name)
|
|
22
|
+
@parallel_name = parallel_name
|
|
23
|
+
@branches = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def step(name, undo: nil, **options, &block)
|
|
27
|
+
add(name, "step", undo, options, block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def transaction(name, undo: nil, **options, &block)
|
|
31
|
+
add(name, "transaction", undo, options, block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def add(name, kind, undo, options, block)
|
|
37
|
+
name = name.to_s
|
|
38
|
+
raise InvalidRecipe, "branch :#{name} of flow.parallel :#{@parallel_name} needs a block" unless block
|
|
39
|
+
raise InvalidRecipe, "branch names in flow.parallel :#{@parallel_name} cannot be blank" if name.empty?
|
|
40
|
+
if name.include?("/") || name.end_with?(":undo")
|
|
41
|
+
raise InvalidRecipe, "branch name #{name.inspect} cannot contain '/' or end in ':undo'"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unknown = options.keys - OPTIONS
|
|
45
|
+
raise InvalidRecipe, "unknown option(s) for a parallel branch: #{unknown.join(", ")}" if unknown.any?
|
|
46
|
+
if @branches.any? { |branch| branch.name == name }
|
|
47
|
+
raise DuplicateStepName, "flow.parallel :#{@parallel_name} declares the branch :#{name} twice"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@branches << Branch.new(name, "#{@parallel_name}/#{name}", kind, undo, options, block)
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# Base class for the gem's tables. It shares ActiveRecord::Base's connection on purpose:
|
|
5
|
+
# flow.transaction is only atomic when the notebook lives in the same database as your data.
|
|
6
|
+
class Record < ActiveRecord::Base
|
|
7
|
+
self.abstract_class = true
|
|
8
|
+
|
|
9
|
+
# A JSON type object (not the :json symbol): resolving a symbol needs a database connection,
|
|
10
|
+
# and these classes may load before the app has configured one.
|
|
11
|
+
JSON_TYPE = ActiveRecord::Type::Json.new
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# A named recipe: the block that describes the steps of a saga.
|
|
5
|
+
class Recipe
|
|
6
|
+
attr_reader :name, :version, :block
|
|
7
|
+
|
|
8
|
+
def initialize(name, version, block)
|
|
9
|
+
@name = name
|
|
10
|
+
@version = version
|
|
11
|
+
@block = block
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(flow, input)
|
|
15
|
+
block.call(flow, **input.to_h.transform_keys(&:to_sym))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def inspect
|
|
19
|
+
"#<ActiveDurable::Recipe #{name} v#{version}>"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Keeps every defined recipe by name and version.
|
|
24
|
+
#
|
|
25
|
+
# Recipes are looked up by name when a worker picks up an execution, possibly in a fresh process.
|
|
26
|
+
# In a Rails app, put each recipe in app/sagas/<name>_saga.rb and assign it to a constant
|
|
27
|
+
# (CheckoutSaga = Durable.define(:checkout) { ... }). The registry autoloads that constant on a miss.
|
|
28
|
+
class Registry
|
|
29
|
+
def initialize
|
|
30
|
+
@recipes = {}
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def define(name, version: 1, &block)
|
|
35
|
+
raise ArgumentError, "Durable.define :#{name} needs a block" unless block
|
|
36
|
+
|
|
37
|
+
name = name.to_s
|
|
38
|
+
raise ArgumentError, "recipe names cannot be blank" if name.empty?
|
|
39
|
+
|
|
40
|
+
recipe = Recipe.new(name, Integer(version), block)
|
|
41
|
+
@mutex.synchronize { (@recipes[name] ||= {})[recipe.version] = recipe }
|
|
42
|
+
recipe
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def latest(name)
|
|
46
|
+
versions = versions_for(name)
|
|
47
|
+
versions.empty? ? raise_unknown(name) : versions.max_by(&:version)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fetch(name, version)
|
|
51
|
+
versions_for(name).find { |recipe| recipe.version == version.to_i } ||
|
|
52
|
+
raise(UnknownRecipe, "recipe :#{name} has no version #{version}. Keep old versions defined " \
|
|
53
|
+
"until no execution uses them (rake active_durable:versions).")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def versions_for(name)
|
|
57
|
+
name = name.to_s
|
|
58
|
+
# Always touch the constant, even if the name is known: after a code reload in development the constant
|
|
59
|
+
# is gone, and referencing it loads the edited file, which defines the recipe again.
|
|
60
|
+
autoload_constant(name)
|
|
61
|
+
(@recipes[name] || {}).values.sort_by(&:version)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def names
|
|
65
|
+
@recipes.keys.sort
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def clear!
|
|
69
|
+
@mutex.synchronize { @recipes.clear }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def raise_unknown(name)
|
|
75
|
+
raise UnknownRecipe, "no recipe named :#{name}. Define it with Durable.define(:#{name}) " \
|
|
76
|
+
"in app/sagas/#{name}_saga.rb and assign it to #{constant_name(name)}."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def autoload_constant(name)
|
|
80
|
+
constant_name(name).safe_constantize
|
|
81
|
+
rescue StandardError, LoadError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def constant_name(name)
|
|
86
|
+
"#{name.to_s.camelize}Saga"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# How many times a step is tried and how long to wait between tries.
|
|
5
|
+
#
|
|
6
|
+
# flow.step :charge, retry: 5 { ... }
|
|
7
|
+
# flow.step :charge, retry: { attempts: 5, backoff: [1, 10, 60] } { ... }
|
|
8
|
+
# flow.step :charge, retry: false { ... } # a single attempt
|
|
9
|
+
class RetryPolicy
|
|
10
|
+
attr_reader :attempts
|
|
11
|
+
|
|
12
|
+
def self.build(option, default_attempts:)
|
|
13
|
+
case option
|
|
14
|
+
when nil then new(attempts: default_attempts)
|
|
15
|
+
when false then new(attempts: 1)
|
|
16
|
+
when Integer then new(attempts: option)
|
|
17
|
+
when Hash then new(attempts: option.fetch(:attempts, default_attempts), backoff: option[:backoff])
|
|
18
|
+
when RetryPolicy then option
|
|
19
|
+
else raise ArgumentError, "retry: expects false, an Integer or a Hash, got #{option.inspect}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(attempts:, backoff: nil)
|
|
24
|
+
@attempts = Integer(attempts)
|
|
25
|
+
raise ArgumentError, "retry attempts must be at least 1" if @attempts < 1
|
|
26
|
+
|
|
27
|
+
@backoff = backoff
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Seconds to wait after the given failed attempt (1-based).
|
|
31
|
+
def delay(attempt)
|
|
32
|
+
backoff = @backoff || ActiveDurable.config.backoff
|
|
33
|
+
seconds =
|
|
34
|
+
case backoff
|
|
35
|
+
when Proc then backoff.call(attempt)
|
|
36
|
+
when Array then backoff[attempt - 1] || backoff.last
|
|
37
|
+
else backoff
|
|
38
|
+
end
|
|
39
|
+
seconds.to_f
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# Runs one execution. Duplicates are harmless: if another worker holds the lease, this one returns.
|
|
5
|
+
class RunJob < ActiveJob::Base
|
|
6
|
+
queue_as { ActiveDurable.config.queue_name }
|
|
7
|
+
|
|
8
|
+
def perform(execution_id)
|
|
9
|
+
Runner.run(execution_id)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# Runs one execution as far as it can go: claims the lease, replays the recipe against the notebook,
|
|
5
|
+
# and ends by completing, suspending (sleep, wait, retry), compensating or blocking.
|
|
6
|
+
class Runner
|
|
7
|
+
SUSPEND = :active_durable_suspend
|
|
8
|
+
|
|
9
|
+
# Returns :completed, :compensated, :blocked, :sleeping, :waiting, :busy or :lease_lost.
|
|
10
|
+
def self.run(execution_id)
|
|
11
|
+
lease = Lease.claim(execution_id)
|
|
12
|
+
return :busy unless lease
|
|
13
|
+
|
|
14
|
+
new(Execution.find(execution_id), lease).run
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :execution, :lease, :notebook
|
|
18
|
+
|
|
19
|
+
def initialize(execution, lease)
|
|
20
|
+
@execution = execution
|
|
21
|
+
@lease = lease
|
|
22
|
+
@notebook = Notebook.new(execution, lease)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
ActiveDurable.instrument("execution", execution_id: execution.id, recipe: execution.recipe) do
|
|
27
|
+
catch(SUSPEND) { run_recipe }
|
|
28
|
+
end
|
|
29
|
+
rescue LeaseLost => e
|
|
30
|
+
ActiveDurable.config.logger.warn("[ActiveDurable] #{e.message}")
|
|
31
|
+
:lease_lost
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Releases the execution until wake_at (or until a signal arrives) and stops this run.
|
|
35
|
+
def suspend!(wake_at, status)
|
|
36
|
+
lease.release!(status: status, wake_at: wake_at)
|
|
37
|
+
ActiveDurable.enqueue(execution.id, wait_until: wake_at) if wake_at
|
|
38
|
+
# A signal may have been committed while we held the lease; its own job found us busy.
|
|
39
|
+
if status == "waiting" && SignalRecord.pending.exists?(execution_id: execution.id)
|
|
40
|
+
ActiveDurable.enqueue(execution.id)
|
|
41
|
+
end
|
|
42
|
+
throw SUSPEND, status.to_sym
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def run_recipe
|
|
48
|
+
recipe = ActiveDurable.registry.fetch(execution.recipe, execution.recipe_version)
|
|
49
|
+
@flow = Flow.new(self, compensating: execution.compensating)
|
|
50
|
+
output = recipe.call(@flow, execution.input || {})
|
|
51
|
+
@flow.finish!
|
|
52
|
+
@flow.compensating? ? compensate! : complete!(output)
|
|
53
|
+
rescue StopForward
|
|
54
|
+
compensate!
|
|
55
|
+
rescue RecipeChanged, InvalidRecipe, NotSerializable, UnknownRecipe => e
|
|
56
|
+
block!(e)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
fail!(e)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fail!(error)
|
|
62
|
+
return block!(error) if @flow.nil? || @flow.pivoted?
|
|
63
|
+
|
|
64
|
+
start_compensation!(error) unless @flow.compensating?
|
|
65
|
+
compensate!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def complete!(output)
|
|
69
|
+
output = Serializer.normalize(output, "the recipe's return value")
|
|
70
|
+
lease.release!(status: "completed", output: output, wake_at: nil)
|
|
71
|
+
ActiveDurable.instrument("completed", execution_id: execution.id, recipe: execution.recipe)
|
|
72
|
+
:completed
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def block!(error)
|
|
76
|
+
step = error.respond_to?(:step_name) ? error.step_name : nil
|
|
77
|
+
dumped = ActiveDurable.dump_error(error, step: step)
|
|
78
|
+
ActiveDurable.config.logger.error("[ActiveDurable] #{execution.id} blocked: #{error.class}: #{error.message}")
|
|
79
|
+
lease.release!(status: "blocked", error: dumped, wake_at: nil)
|
|
80
|
+
ActiveDurable.instrument("blocked", execution_id: execution.id, recipe: execution.recipe, error: dumped)
|
|
81
|
+
:blocked
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def start_compensation!(error)
|
|
85
|
+
step = error.respond_to?(:step_name) ? error.step_name : nil
|
|
86
|
+
lease.renew!(compensating: true, error: ActiveDurable.dump_error(error, step: step))
|
|
87
|
+
@flow.compensating!
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Runs the undos of every completed step, last one first. Each undo is its own notebook entry,
|
|
91
|
+
# so a crash in the middle resumes where it stopped.
|
|
92
|
+
def compensate!
|
|
93
|
+
if @flow.pivoted?
|
|
94
|
+
return block!(InvalidRecipe.new("#{execution.id} cannot be compensated: it already passed its point " \
|
|
95
|
+
"of no return (flow.pivot)"))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
ActiveDurable.instrument("compensation", execution_id: execution.id) do
|
|
99
|
+
@flow.undo_stack.reverse_each { |entry| undo!(entry) }
|
|
100
|
+
end
|
|
101
|
+
lease.release!(status: "compensated", wake_at: nil)
|
|
102
|
+
ActiveDurable.instrument("compensated", execution_id: execution.id, recipe: execution.recipe)
|
|
103
|
+
:compensated
|
|
104
|
+
rescue UndoFailed => e
|
|
105
|
+
block!(e)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def undo!(entry)
|
|
109
|
+
name = "#{entry.name}:undo"
|
|
110
|
+
record = notebook[name]
|
|
111
|
+
return if record&.completed?
|
|
112
|
+
|
|
113
|
+
suspend!(record.wake_at, "sleeping") if record&.retrying? && record.wake_at && record.wake_at > ActiveDurable.now
|
|
114
|
+
|
|
115
|
+
ticket = "#{execution.id}:#{name}"
|
|
116
|
+
ActiveDurable.crash_point(:before_undo, entry.name)
|
|
117
|
+
ActiveDurable.instrument("undo", execution_id: execution.id, step: entry.name) do
|
|
118
|
+
if entry.kind == "transaction"
|
|
119
|
+
notebook.transaction { call_undo(entry, ticket, name) }
|
|
120
|
+
else
|
|
121
|
+
call_undo(entry, ticket, name)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
ActiveDurable.crash_point(:after_undo_record, entry.name)
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
retry_undo!(entry, name, record, e)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def call_undo(entry, ticket, name)
|
|
130
|
+
callable = entry.undo
|
|
131
|
+
# (result, undo ticket, ticket of the step being undone): the undo takes as many as it declares.
|
|
132
|
+
args = [entry.result.deep_dup, ticket, "#{execution.id}:#{entry.name}"]
|
|
133
|
+
parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
134
|
+
if parameters.any? { |type, _| type == :rest }
|
|
135
|
+
callable.call(*args)
|
|
136
|
+
else
|
|
137
|
+
callable.call(*args.first(parameters.count { |type, _| %i[req opt].include?(type) }))
|
|
138
|
+
end
|
|
139
|
+
ActiveDurable.crash_point(:after_undo_call, entry.name)
|
|
140
|
+
notebook.complete!(name, kind: "undo", position: nil, result: nil)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def retry_undo!(entry, name, record, error)
|
|
144
|
+
attempts = (record&.attempts || 0) + 1
|
|
145
|
+
dumped = ActiveDurable.dump_error(error, step: name)
|
|
146
|
+
max = ActiveDurable.config.undo_attempts
|
|
147
|
+
if attempts >= max
|
|
148
|
+
notebook.fail!(name, kind: "undo", position: nil, attempts: attempts, error: dumped)
|
|
149
|
+
raise UndoFailed, "undo of :#{entry.name} failed #{attempts} times (#{error.class}: #{error.message})"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
wake_at = ActiveDurable.now + RetryPolicy.new(attempts: max).delay(attempts)
|
|
153
|
+
notebook.retry!(name, kind: "undo", position: nil, attempts: attempts, wake_at: wake_at, error: dumped)
|
|
154
|
+
suspend!(wake_at, "sleeping")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# Step results, inputs and signal payloads live in the notebook as JSON. This module turns a value
|
|
5
|
+
# into exactly what a later replay will read back (string keys, no symbols), so the first run and a
|
|
6
|
+
# replay behave the same. Anything that is not plain JSON is rejected with a message that says where.
|
|
7
|
+
module Serializer
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def normalize(value, path = "value")
|
|
11
|
+
case value
|
|
12
|
+
when nil, true, false, String, Integer then value
|
|
13
|
+
when Float then finite_float(value, path)
|
|
14
|
+
when Symbol then value.to_s
|
|
15
|
+
when Array then value.each_with_index.map { |item, index| normalize(item, "#{path}[#{index}]") }
|
|
16
|
+
when Hash then normalize_hash(value, path)
|
|
17
|
+
else
|
|
18
|
+
raise NotSerializable,
|
|
19
|
+
"#{path} is a #{value.class}. Steps, inputs and signals must use plain JSON values " \
|
|
20
|
+
"(nil, true/false, numbers, strings, arrays and hashes). Keep only what you need, " \
|
|
21
|
+
"for example { \"id\" => charge.id }."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalize_hash(hash, path)
|
|
26
|
+
hash.each_with_object({}) do |(key, item), out|
|
|
27
|
+
key = key.to_s if key.is_a?(Symbol)
|
|
28
|
+
raise NotSerializable, "#{path} has a #{key.class} key; use strings or symbols" unless key.is_a?(String)
|
|
29
|
+
|
|
30
|
+
out[key] = normalize(item, "#{path}[#{key.inspect}]")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def finite_float(value, path)
|
|
35
|
+
raise NotSerializable, "#{path} is #{value}, which JSON cannot store" unless value.finite?
|
|
36
|
+
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# A message for a saga that is (or will be) waiting in flow.wait_for.
|
|
5
|
+
class SignalRecord < Record
|
|
6
|
+
self.table_name = "durable_signals"
|
|
7
|
+
|
|
8
|
+
attribute :payload, JSON_TYPE
|
|
9
|
+
|
|
10
|
+
belongs_to :execution, class_name: "ActiveDurable::Execution", inverse_of: :signals, optional: true
|
|
11
|
+
|
|
12
|
+
scope :pending, -> { where(consumed_at: nil) }
|
|
13
|
+
|
|
14
|
+
after_create_commit { ActiveDurable.enqueue(execution_id) }
|
|
15
|
+
|
|
16
|
+
def self.next_for(execution_id, name)
|
|
17
|
+
pending.where(execution_id: execution_id, name: name.to_s).order(:id).first
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# One notebook entry: a step (or an undo) and what it returned.
|
|
5
|
+
class Step < Record
|
|
6
|
+
self.table_name = "durable_steps"
|
|
7
|
+
|
|
8
|
+
attribute :result, JSON_TYPE
|
|
9
|
+
attribute :error, JSON_TYPE
|
|
10
|
+
|
|
11
|
+
# optional: the foreign key already guarantees it, and this skips a SELECT on every notebook write.
|
|
12
|
+
belongs_to :execution, class_name: "ActiveDurable::Execution", inverse_of: :steps, optional: true
|
|
13
|
+
|
|
14
|
+
def completed?
|
|
15
|
+
status == "completed"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def failed?
|
|
19
|
+
status == "failed"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def retrying?
|
|
23
|
+
status == "retrying"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def waiting?
|
|
27
|
+
status == "waiting"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def undo?
|
|
31
|
+
kind == "undo"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# Schedule it every minute (Solid Queue recurring tasks, cron, sidekiq-cron...).
|
|
5
|
+
class SweepJob < ActiveJob::Base
|
|
6
|
+
queue_as { ActiveDurable.config.queue_name }
|
|
7
|
+
|
|
8
|
+
def perform
|
|
9
|
+
Sweeper.call
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|