active_saga 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,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/logger"
4
+
5
+ module ActiveWorkflow
6
+ # Holds gem-level configuration.
7
+ class Configuration
8
+ attr_accessor :store, :serializer, :logger, :clock
9
+
10
+ def initialize
11
+ @serializer = ActiveWorkflow::Serializers::Json.new
12
+ @logger = ActiveSupport::Logger.new($stdout, level: :info)
13
+ @clock = -> { Time.now.utc }
14
+ end
15
+
16
+ # Ensures store is set before usage.
17
+ def store!
18
+ raise ActiveWorkflow::Errors::Configuration, "ActiveWorkflow.store is not configured" unless store
19
+
20
+ store
21
+ end
22
+ end
23
+ end
@@ -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,76 @@
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, :cancelled_at
7
+
8
+ def initialize(id:, workflow_class:, state:, ctx:, cursor_step:, created_at:, updated_at:, cancelled_at: nil, 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
+ @cancelled_at = cancelled_at
17
+ @store = store
18
+ end
19
+
20
+ def completed?
21
+ state == "completed"
22
+ end
23
+
24
+ def failed?
25
+ state == "failed"
26
+ end
27
+
28
+ def cancelled?
29
+ state == "cancelled"
30
+ end
31
+
32
+ # Blocks until execution reaches a terminal state or timeout expires.
33
+ # Returns the final execution snapshot.
34
+ #
35
+ # @param timeout [Numeric, nil] seconds to wait, nil for indefinite
36
+ # @param interval [Numeric] polling interval
37
+ def await(timeout: nil, interval: 0.5)
38
+ clock = ActiveWorkflow.configuration.clock
39
+ deadline = timeout && clock.call + timeout
40
+ loop do
41
+ break if terminal?
42
+ raise Timeout::Error, "Execution #{id} did not finish within #{timeout}s" if deadline && clock.call > deadline
43
+
44
+ sleep(interval)
45
+ reload!
46
+ end
47
+ self
48
+ end
49
+
50
+ def terminal?
51
+ %w[completed failed cancelled timed_out].include?(state)
52
+ end
53
+
54
+ def reload!
55
+ fresh = @store.load_execution(id)
56
+ update_from(fresh) if fresh
57
+ self
58
+ end
59
+
60
+ def cancel!(reason: nil)
61
+ ActiveWorkflow.cancel!(id, reason: reason)
62
+ reload!
63
+ end
64
+
65
+ private
66
+
67
+ def update_from(other)
68
+ @state = other.state
69
+ @ctx = other.ctx
70
+ @cursor_step = other.cursor_step
71
+ @created_at = other.created_at
72
+ @updated_at = other.updated_at
73
+ @cancelled_at = other.cancelled_at
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,29 @@
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
+ execution = ActiveWorkflow.configuration.store.load_execution(execution_id)
14
+ if execution
15
+ workflow = execution.workflow_class || "UnknownWorkflow"
16
+ step = execution.cursor_step || "pending"
17
+ ActiveWorkflow.configuration.logger.info(
18
+ "ActiveWorkflow::RunnerJob executing #{workflow}##{step} (execution=#{execution_id}) (state=#{execution.state})"
19
+ )
20
+ end
21
+
22
+ ActiveWorkflow.configuration.store!.process_execution(execution_id)
23
+ rescue ActiveWorkflow::Errors::InvalidWorkflow => e
24
+ ActiveWorkflow.configuration.logger.error("ActiveWorkflow invalid workflow: #{e.message}")
25
+ raise
26
+ end
27
+ end
28
+ end
29
+ 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