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,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