active_orchestrator 0.1.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/ADAPTERS.md +60 -0
- data/CHANGELOG.md +7 -0
- data/GUIDE.md +80 -0
- data/LICENSE +21 -0
- data/README.md +444 -0
- data/lib/active_workflow/configuration.rb +23 -0
- data/lib/active_workflow/context.rb +82 -0
- data/lib/active_workflow/dsl/options.rb +34 -0
- data/lib/active_workflow/dsl/signals.rb +24 -0
- data/lib/active_workflow/dsl/steps.rb +131 -0
- data/lib/active_workflow/errors.rb +18 -0
- data/lib/active_workflow/execution.rb +65 -0
- data/lib/active_workflow/jobs/runner_job.rb +20 -0
- data/lib/active_workflow/railtie.rb +17 -0
- data/lib/active_workflow/serializers/json.rb +20 -0
- data/lib/active_workflow/stores/active_record.rb +695 -0
- data/lib/active_workflow/stores/base.rb +58 -0
- data/lib/active_workflow/task.rb +32 -0
- data/lib/active_workflow/version.rb +5 -0
- data/lib/active_workflow/workflow.rb +136 -0
- data/lib/active_workflow.rb +94 -0
- data/lib/generators/active_workflow/install/install_generator.rb +43 -0
- data/lib/generators/active_workflow/install/templates/initializer.rb +8 -0
- data/lib/generators/active_workflow/install/templates/migrations/create_active_workflow_tables.rb +69 -0
- data/lib/generators/active_workflow/install/templates/sample_workflow.rb +29 -0
- data/lib/generators/active_workflow/workflow/templates/workflow.rb +18 -0
- data/lib/generators/active_workflow/workflow/workflow_generator.rb +17 -0
- metadata +173 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/hash_with_indifferent_access"
|
4
|
+
|
5
|
+
module ActiveWorkflow
|
6
|
+
# TODO Revisit impl.
|
7
|
+
class Context
|
8
|
+
include Enumerable
|
9
|
+
|
10
|
+
def initialize(payload = {})
|
11
|
+
@payload = normalize(payload || {})
|
12
|
+
end
|
13
|
+
|
14
|
+
def [](key)
|
15
|
+
@payload[key.to_sym]
|
16
|
+
end
|
17
|
+
|
18
|
+
def []=(key, value)
|
19
|
+
@payload[key.to_sym] = normalize(value)
|
20
|
+
end
|
21
|
+
|
22
|
+
def fetch(key, *args)
|
23
|
+
@payload.fetch(key.to_sym, *args)
|
24
|
+
end
|
25
|
+
|
26
|
+
def merge!(other_hash)
|
27
|
+
other_hash.each_pair { |k, v| self[k] = v }
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_h
|
32
|
+
deep_dup(@payload)
|
33
|
+
end
|
34
|
+
|
35
|
+
def each(&block)
|
36
|
+
@payload.each(&block)
|
37
|
+
end
|
38
|
+
|
39
|
+
def dup
|
40
|
+
self.class.new(to_h)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def normalize(value)
|
46
|
+
case value
|
47
|
+
when Context
|
48
|
+
value.to_h
|
49
|
+
when ActiveSupport::HashWithIndifferentAccess
|
50
|
+
normalize_hash(value.to_h)
|
51
|
+
when Hash
|
52
|
+
normalize_hash(value)
|
53
|
+
when Array
|
54
|
+
value.each_with_index do |item, index|
|
55
|
+
value[index] = normalize(item)
|
56
|
+
end
|
57
|
+
value
|
58
|
+
else
|
59
|
+
value
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def normalize_hash(hash)
|
64
|
+
hash.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |(key, val), memo|
|
65
|
+
memo[key] = normalize(val)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def deep_dup(value)
|
70
|
+
case value
|
71
|
+
when ActiveSupport::HashWithIndifferentAccess
|
72
|
+
ActiveSupport::HashWithIndifferentAccess.new(value.transform_values { |v| deep_dup(v) })
|
73
|
+
when Hash
|
74
|
+
value.each_with_object({}) { |(k, v), memo| memo[k] = deep_dup(v) }
|
75
|
+
when Array
|
76
|
+
value.map { |v| deep_dup(v) }
|
77
|
+
else
|
78
|
+
value
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWorkflow
|
4
|
+
module DSL
|
5
|
+
# Configuration helpers shared by workflows.
|
6
|
+
module Options
|
7
|
+
def self.extended(base)
|
8
|
+
base.class_attribute :_aw_defaults, instance_writer: false, default: {}
|
9
|
+
base.class_attribute :_aw_timeout, instance_writer: false, default: nil
|
10
|
+
base.class_attribute :_aw_idempotency_block, instance_writer: false, default: nil
|
11
|
+
end
|
12
|
+
|
13
|
+
def defaults(opts = nil)
|
14
|
+
if opts
|
15
|
+
self._aw_defaults = (_aw_defaults.deep_dup || {}).deep_merge(opts.deep_symbolize_keys)
|
16
|
+
else
|
17
|
+
_aw_defaults.deep_dup || {}
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def timeout(value = nil)
|
22
|
+
value ? self._aw_timeout = value : _aw_timeout
|
23
|
+
end
|
24
|
+
|
25
|
+
def idempotency_key(&block)
|
26
|
+
block ? self._aw_idempotency_block = block : _aw_idempotency_block
|
27
|
+
end
|
28
|
+
|
29
|
+
def resolve_defaults(step_options)
|
30
|
+
defaults.deep_merge(step_options.deep_symbolize_keys)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWorkflow
|
4
|
+
module DSL
|
5
|
+
module Signals
|
6
|
+
def self.extended(base)
|
7
|
+
base.class_attribute :_aw_signal_handlers, instance_writer: false, default: {} # name => method
|
8
|
+
end
|
9
|
+
|
10
|
+
def inherited(subclass)
|
11
|
+
super
|
12
|
+
subclass._aw_signal_handlers = _aw_signal_handlers.dup
|
13
|
+
end
|
14
|
+
|
15
|
+
def signal(name, to:)
|
16
|
+
self._aw_signal_handlers = _aw_signal_handlers.merge(name.to_sym => to.to_sym)
|
17
|
+
end
|
18
|
+
|
19
|
+
def signal_handler_for(name)
|
20
|
+
_aw_signal_handlers[name.to_sym]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWorkflow
|
4
|
+
module DSL
|
5
|
+
module Steps
|
6
|
+
StepDefinition = Struct.new(:name, :style, :callable, :options, keyword_init: true) do
|
7
|
+
def initialize(*)
|
8
|
+
super
|
9
|
+
self.options = self[:options]
|
10
|
+
end
|
11
|
+
|
12
|
+
def options
|
13
|
+
self[:options] ||= {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def options=(value)
|
17
|
+
self[:options] = value.is_a?(Hash) ? normalize(value) : {}
|
18
|
+
end
|
19
|
+
|
20
|
+
def dup
|
21
|
+
self.class.new(name:, style:, callable:, options: deep_dup(options))
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_h
|
25
|
+
{ name:, style:, callable:, options: deep_dup(options) }
|
26
|
+
end
|
27
|
+
|
28
|
+
def async?
|
29
|
+
options[:async] || false
|
30
|
+
end
|
31
|
+
|
32
|
+
def dedupe?
|
33
|
+
options[:dedupe] || false
|
34
|
+
end
|
35
|
+
|
36
|
+
def fire_and_forget?
|
37
|
+
options[:fire_and_forget] || false
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def normalize(value)
|
43
|
+
case value
|
44
|
+
when Hash
|
45
|
+
value.each_with_object({}) do |(k, v), memo|
|
46
|
+
memo[k.to_sym] = normalize(v)
|
47
|
+
end
|
48
|
+
when Array
|
49
|
+
value.map { |item| normalize(item) }
|
50
|
+
else
|
51
|
+
value
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def deep_dup(hash)
|
56
|
+
case hash
|
57
|
+
when Hash
|
58
|
+
hash.each_with_object({}) do |(k, v), memo|
|
59
|
+
memo[k] = deep_dup(v)
|
60
|
+
end
|
61
|
+
when Array
|
62
|
+
hash.map { |item| deep_dup(item) }
|
63
|
+
else
|
64
|
+
hash
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.extended(base)
|
70
|
+
base.class_attribute :_aw_steps, instance_writer: false, default: []
|
71
|
+
end
|
72
|
+
|
73
|
+
def inherited(subclass)
|
74
|
+
super
|
75
|
+
subclass._aw_steps = _aw_steps.map(&:dup)
|
76
|
+
end
|
77
|
+
|
78
|
+
def steps
|
79
|
+
_aw_steps
|
80
|
+
end
|
81
|
+
|
82
|
+
def step_definition(name)
|
83
|
+
steps.find { |definition| definition.name == name.to_sym } ||
|
84
|
+
raise(ActiveWorkflow::Errors::InvalidStep, "Unknown step: #{name}")
|
85
|
+
end
|
86
|
+
|
87
|
+
def step(name, **options)
|
88
|
+
register_step(:method, name, nil, options)
|
89
|
+
end
|
90
|
+
|
91
|
+
def task(name, handler = nil, **options, &block)
|
92
|
+
if handler && block
|
93
|
+
raise ActiveWorkflow::Errors::InvalidStep, "Provide a Task class or a block, not both"
|
94
|
+
end
|
95
|
+
|
96
|
+
callable = handler || block
|
97
|
+
style = if block
|
98
|
+
:block
|
99
|
+
elsif handler.is_a?(Class) && handler <= ActiveWorkflow::Task
|
100
|
+
:task
|
101
|
+
elsif handler.respond_to?(:call)
|
102
|
+
:callable
|
103
|
+
else
|
104
|
+
raise ActiveWorkflow::Errors::InvalidStep, "Task handler must be a Task subclass, callable, or block"
|
105
|
+
end
|
106
|
+
|
107
|
+
step_options = if handler.is_a?(Class) && handler <= ActiveWorkflow::Task
|
108
|
+
handler.async_options.deep_merge(options)
|
109
|
+
else
|
110
|
+
options
|
111
|
+
end
|
112
|
+
|
113
|
+
register_step(style, name, callable, step_options)
|
114
|
+
end
|
115
|
+
|
116
|
+
def wait_for_signal(name, **options)
|
117
|
+
register_step(:wait, name, nil, options)
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def register_step(style, name, callable, options)
|
123
|
+
name = name.to_sym
|
124
|
+
step_options = resolve_defaults((options || {}).deep_symbolize_keys)
|
125
|
+
definition = StepDefinition.new(name:, style:, callable:, options: step_options)
|
126
|
+
remaining = _aw_steps.reject { |step| step.name == name }
|
127
|
+
self._aw_steps = remaining + [definition]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWorkflow
|
4
|
+
module Errors
|
5
|
+
class Error < StandardError; end
|
6
|
+
|
7
|
+
class Configuration < Error; end
|
8
|
+
class InvalidWorkflow < Error; end
|
9
|
+
class InvalidStep < Error; end
|
10
|
+
class InvalidTransition < Error; end
|
11
|
+
class StepTimeout < Error; end
|
12
|
+
class StepNotWaiting < Error; end
|
13
|
+
class SignalNotFound < Error; end
|
14
|
+
class IdempotencyConflict < Error; end
|
15
|
+
class StoreError < Error; end
|
16
|
+
class AsyncCompletionConflict < Error; end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWorkflow
|
4
|
+
# Lightweight value object representing a workflow execution.
|
5
|
+
class Execution
|
6
|
+
attr_reader :id, :workflow_class, :state, :ctx, :cursor_step, :created_at, :updated_at
|
7
|
+
|
8
|
+
def initialize(id:, workflow_class:, state:, ctx:, cursor_step:, created_at:, updated_at:, store: ActiveWorkflow.store)
|
9
|
+
@id = id
|
10
|
+
@workflow_class = workflow_class
|
11
|
+
@state = state
|
12
|
+
@ctx = ActiveWorkflow::Context.new(ctx)
|
13
|
+
@cursor_step = cursor_step&.to_sym
|
14
|
+
@created_at = created_at
|
15
|
+
@updated_at = updated_at
|
16
|
+
@store = store
|
17
|
+
end
|
18
|
+
|
19
|
+
def completed?
|
20
|
+
state == "completed"
|
21
|
+
end
|
22
|
+
|
23
|
+
def failed?
|
24
|
+
state == "failed"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Blocks until execution reaches a terminal state or timeout expires.
|
28
|
+
# Returns the final execution snapshot.
|
29
|
+
#
|
30
|
+
# @param timeout [Numeric, nil] seconds to wait, nil for indefinite
|
31
|
+
# @param interval [Numeric] polling interval
|
32
|
+
def await(timeout: nil, interval: 0.5)
|
33
|
+
clock = ActiveWorkflow.configuration.clock
|
34
|
+
deadline = timeout && clock.call + timeout
|
35
|
+
loop do
|
36
|
+
break if terminal?
|
37
|
+
raise Timeout::Error, "Execution #{id} did not finish within #{timeout}s" if deadline && clock.call > deadline
|
38
|
+
|
39
|
+
sleep(interval)
|
40
|
+
reload!
|
41
|
+
end
|
42
|
+
self
|
43
|
+
end
|
44
|
+
|
45
|
+
def terminal?
|
46
|
+
%w[completed failed cancelled timed_out].include?(state)
|
47
|
+
end
|
48
|
+
|
49
|
+
def reload!
|
50
|
+
fresh = @store.load_execution(id)
|
51
|
+
update_from(fresh) if fresh
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def update_from(other)
|
58
|
+
@state = other.state
|
59
|
+
@ctx = other.ctx
|
60
|
+
@cursor_step = other.cursor_step
|
61
|
+
@created_at = other.created_at
|
62
|
+
@updated_at = other.updated_at
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWorkflow
|
4
|
+
module Jobs
|
5
|
+
# Executes workflow transitions within Active Job.
|
6
|
+
class RunnerJob < ActiveJob::Base
|
7
|
+
queue_as :active_workflow
|
8
|
+
|
9
|
+
# We want retries for transient errors only; user-defined retries handled within workflow.
|
10
|
+
retry_on ActiveRecord::Deadlocked, wait: 1.second, attempts: 5 if defined?(ActiveRecord::Deadlocked)
|
11
|
+
|
12
|
+
def perform(execution_id)
|
13
|
+
ActiveWorkflow.configuration.store!.process_execution(execution_id)
|
14
|
+
rescue ActiveWorkflow::Errors::InvalidWorkflow => e
|
15
|
+
ActiveWorkflow.configuration.logger.error("ActiveWorkflow invalid workflow: #{e.message}")
|
16
|
+
raise
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/railtie"
|
4
|
+
|
5
|
+
module ActiveWorkflow
|
6
|
+
class Railtie < Rails::Railtie
|
7
|
+
initializer "active_workflow.configure" do |_app|
|
8
|
+
ActiveWorkflow.configure do |config|
|
9
|
+
config.logger ||= Rails.logger
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
rake_tasks do
|
14
|
+
load "active_workflow/tasks.rake" if File.exist?(File.join(__dir__, "../../tasks.rake"))
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module ActiveWorkflow
|
6
|
+
module Serializers
|
7
|
+
# Simple JSON wrapper allowing custom serialization strategies.
|
8
|
+
class Json
|
9
|
+
def dump(object)
|
10
|
+
JSON.generate(object)
|
11
|
+
end
|
12
|
+
|
13
|
+
def load(payload)
|
14
|
+
return {} if payload.nil? || payload == ""
|
15
|
+
|
16
|
+
JSON.parse(payload)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|