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.
@@ -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