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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWorkflow
4
+ module Stores
5
+ # Base interface for persistence adapters.
6
+ class Base
7
+ attr_reader :logger, :serializer, :clock
8
+
9
+ def initialize(logger: ActiveWorkflow.configuration.logger,
10
+ serializer: ActiveWorkflow.configuration.serializer,
11
+ clock: ActiveWorkflow.configuration.clock)
12
+ @logger = logger
13
+ @serializer = serializer
14
+ @clock = clock
15
+ end
16
+
17
+ def start_execution(**)
18
+ raise NotImplementedError
19
+ end
20
+
21
+ def load_execution(_id)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def complete_step!(*_args)
26
+ raise NotImplementedError
27
+ end
28
+
29
+ def fail_step!(*_args)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ def extend_timeout!(*_args)
34
+ raise NotImplementedError
35
+ end
36
+
37
+ def heartbeat!(*_args)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ def signal!(*_args)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def process_execution(_execution_id)
46
+ raise NotImplementedError
47
+ end
48
+
49
+ def enqueue_runner(_execution_id, _step_name = nil)
50
+ raise NotImplementedError
51
+ end
52
+
53
+ def with_execution_lock(_execution_id)
54
+ raise NotImplementedError
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWorkflow
4
+ # Base class for reusable workflow tasks.
5
+ class Task
6
+ class << self
7
+ attr_reader :_aw_async_options
8
+
9
+ def async!(**options)
10
+ @_aw_async_options = options.deep_symbolize_keys
11
+ end
12
+
13
+ def async_options
14
+ _aw_async_options || {}
15
+ end
16
+ end
17
+
18
+ def initialize(context)
19
+ @context = context
20
+ end
21
+
22
+ attr_reader :context
23
+
24
+ def call(_ctx)
25
+ raise NotImplementedError, "Override #call in #{self.class.name}"
26
+ end
27
+
28
+ def compensate(_ctx, result: nil)
29
+ nil
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWorkflow
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWorkflow
4
+ # Base class for defining workflows.
5
+ class Workflow
6
+ extend DSL::Options
7
+ extend DSL::Steps
8
+ extend DSL::Signals
9
+
10
+ attr_reader :context, :execution_id
11
+
12
+ class << self
13
+ def start(**attrs)
14
+ options = attrs.dup
15
+ explicit_idempotency_key = options.delete(:idempotency_key)
16
+ metadata = options.delete(:metadata) { {} }
17
+
18
+ context = ActiveWorkflow::Context.new(options)
19
+ workflow = new(context: context)
20
+ workflow.before_start
21
+
22
+ idempotency_key = explicit_idempotency_key || workflow.compute_idempotency_key
23
+ store = ActiveWorkflow.configuration.store!
24
+
25
+ execution = store.start_execution(
26
+ workflow_class: name,
27
+ context: context.to_h,
28
+ steps: steps.map.with_index { |step, idx| build_step_payload(step, idx) },
29
+ idempotency_key:,
30
+ timeout: timeout,
31
+ metadata: (metadata || {}).deep_symbolize_keys
32
+ )
33
+
34
+ ActiveSupport::Notifications.instrument("active_workflow.execution.started",
35
+ execution_id: execution.id, workflow: name)
36
+
37
+ execution
38
+ end
39
+
40
+ def build_step_payload(step, idx)
41
+ {
42
+ name: step.name.to_s,
43
+ style: step.style.to_s,
44
+ callable: callable_identifier(step),
45
+ options: step.options,
46
+ position: idx
47
+ }
48
+ end
49
+
50
+ def callable_identifier(step)
51
+ case step.style
52
+ when :method
53
+ step.name.to_s
54
+ when :task
55
+ step.callable.is_a?(Class) ? step.callable.name : step.callable.class.name
56
+ when :block
57
+ "block"
58
+ when :wait
59
+ "wait"
60
+ else
61
+ step.callable.respond_to?(:name) ? step.callable.name : step.callable.to_s
62
+ end
63
+ end
64
+ end
65
+
66
+ def initialize(context:, execution_id: nil)
67
+ @context = context
68
+ @execution_id = execution_id
69
+ end
70
+
71
+ def ctx
72
+ context
73
+ end
74
+
75
+ def before_start; end
76
+
77
+ def compute_idempotency_key
78
+ block = self.class.idempotency_key
79
+ return unless block
80
+
81
+ instance_exec(&block)
82
+ end
83
+
84
+ def call_step_callable(step_definition)
85
+ positional, keyword = resolve_arguments(step_definition)
86
+
87
+ case step_definition.style
88
+ when :method
89
+ send(step_definition.name, *positional, **keyword)
90
+ when :task
91
+ run_task_step(step_definition, positional, keyword)
92
+ when :block
93
+ step_definition.callable.call(context, *positional, **keyword)
94
+ when :wait
95
+ :waiting
96
+ else
97
+ raise ActiveWorkflow::Errors::InvalidStep, "Unknown style #{step_definition.style}"
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def resolve_arguments(step_definition)
104
+ args_option = step_definition.options[:args]
105
+ return [[], {}] unless args_option
106
+
107
+ value = case args_option
108
+ when Proc
109
+ instance_exec(context, &args_option)
110
+ else
111
+ args_option
112
+ end
113
+
114
+ case value
115
+ when Array
116
+ [value, {}]
117
+ when Hash
118
+ [[], value.symbolize_keys]
119
+ when nil
120
+ [[], {}]
121
+ else
122
+ [[value], {}]
123
+ end
124
+ end
125
+
126
+ def run_task_step(step_definition, positional, keyword)
127
+ callable = step_definition.callable
128
+ case callable
129
+ when Class
130
+ callable.new(context).call(context, *positional, **keyword)
131
+ else
132
+ callable.call(context, *positional, **keyword)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/module"
5
+ require "active_support/core_ext/object"
6
+ require "active_support/core_ext/array"
7
+ require "active_support/core_ext/numeric/time"
8
+ require "active_support/core_ext/hash"
9
+ require "active_support/inflector"
10
+ require "active_support/notifications"
11
+ require "active_job"
12
+
13
+ require_relative "active_workflow/version"
14
+ require_relative "active_workflow/errors"
15
+ require_relative "active_workflow/configuration"
16
+ require_relative "active_workflow/context"
17
+ require_relative "active_workflow/execution"
18
+ require_relative "active_workflow/dsl/options"
19
+ require_relative "active_workflow/dsl/steps"
20
+ require_relative "active_workflow/dsl/signals"
21
+ require_relative "active_workflow/workflow"
22
+ require_relative "active_workflow/task"
23
+ require_relative "active_workflow/jobs/runner_job"
24
+ require_relative "active_workflow/stores/base"
25
+ require_relative "active_workflow/stores/active_record"
26
+ require_relative "active_workflow/serializers/json"
27
+ require_relative "active_workflow/railtie" if defined?(Rails::Railtie)
28
+
29
+ module ActiveWorkflow
30
+ class << self
31
+ # @return [ActiveWorkflow::Configuration]
32
+ def configuration
33
+ @configuration ||= Configuration.new
34
+ end
35
+
36
+ # Yields global configuration block and memoizes the configuration instance.
37
+ #
38
+ # @yieldparam [ActiveWorkflow::Configuration] config
39
+ def configure
40
+ yield(configuration)
41
+ end
42
+
43
+ # Resets configuration (mainly for tests)
44
+ def reset_configuration!
45
+ @configuration = Configuration.new
46
+ end
47
+
48
+ # Delegates the store accessor for convenience.
49
+ #
50
+ # @return [ActiveWorkflow::Stores::Base]
51
+ def store
52
+ configuration.store!
53
+ end
54
+
55
+ # Entry point for async completions. Delegates to store and runner.
56
+ def complete_step!(execution_id, step_name, payload: nil, idempotency_key: nil)
57
+ with_instrumentation("complete", execution_id, step_name) do
58
+ store.complete_step!(execution_id, step_name.to_s, payload: payload, idempotency_key: idempotency_key)
59
+ end
60
+ end
61
+
62
+ def fail_step!(execution_id, step_name, error_class:, message:, details: {}, idempotency_key: nil)
63
+ with_instrumentation("fail", execution_id, step_name) do
64
+ store.fail_step!(execution_id, step_name.to_s,
65
+ error_class: error_class,
66
+ message: message,
67
+ details: details,
68
+ idempotency_key: idempotency_key)
69
+ end
70
+ end
71
+
72
+ def extend_timeout!(execution_id, step_name, by:)
73
+ store.extend_timeout!(execution_id, step_name.to_s, by: by)
74
+ end
75
+
76
+ def heartbeat!(execution_id, step_name, at: configuration.clock.call)
77
+ store.heartbeat!(execution_id, step_name.to_s, at: at)
78
+ end
79
+
80
+ def signal!(execution_id, name, payload: nil)
81
+ with_instrumentation("signal", execution_id, name) do
82
+ store.signal!(execution_id, name.to_s, payload: payload)
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def with_instrumentation(action, execution_id, step_name)
89
+ ActiveSupport::Notifications.instrument("active_workflow.step.#{action}",
90
+ execution_id: execution_id,
91
+ step: step_name.to_s) { yield }
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module ActiveWorkflow
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :skip_initializer, type: :boolean, default: false, desc: "Skip initializer file"
14
+ class_option :skip_sample, type: :boolean, default: false, desc: "Skip sample workflow"
15
+
16
+ def copy_initializer
17
+ return if options[:skip_initializer]
18
+
19
+ template "initializer.rb", "config/initializers/active_workflow.rb"
20
+ end
21
+
22
+ def copy_migration
23
+ migration_template "migrations/create_active_workflow_tables.rb", "db/migrate/create_active_workflow_tables.rb"
24
+ end
25
+
26
+ def copy_sample_workflow
27
+ return if options[:skip_sample]
28
+
29
+ template "sample_workflow.rb", "app/workflows/sample_workflow.rb"
30
+ end
31
+
32
+ private
33
+
34
+ def self.next_migration_number(dirname)
35
+ if ActiveRecord::Base.timestamped_migrations
36
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
37
+ else
38
+ format("%03d", (current_migration_number(dirname) + 1))
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveWorkflow.configure do |config|
4
+ config.logger = Rails.logger
5
+ config.clock = -> { Time.now.utc }
6
+ config.serializer = ActiveWorkflow::Serializers::Json.new
7
+ config.store = ActiveWorkflow::Stores::ActiveRecord.new
8
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateActiveWorkflowTables < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :aw_executions do |t|
6
+ t.string :workflow_class, null: false
7
+ t.string :state, null: false
8
+ t.text :ctx, null: false
9
+ t.string :cursor_step
10
+ t.string :idempotency_key
11
+ t.jsonb :metadata, null: false, default: {}
12
+ t.datetime :timeout_at
13
+ t.string :last_error_class
14
+ t.text :last_error_message
15
+ t.datetime :last_error_at
16
+ t.datetime :last_enqueued_at
17
+ t.datetime :completed_at
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :aw_executions, :workflow_class
23
+ add_index :aw_executions, :state
24
+ add_index :aw_executions, :idempotency_key, unique: true
25
+
26
+ create_table :aw_steps do |t|
27
+ t.references :execution, null: false, foreign_key: { to_table: :aw_executions }
28
+ t.string :name, null: false
29
+ t.string :style, null: false
30
+ t.jsonb :options, null: false, default: {}
31
+ t.string :state, null: false
32
+ t.integer :position, null: false
33
+ t.integer :attempts, null: false, default: 0
34
+ t.datetime :scheduled_at
35
+ t.datetime :waiting_since
36
+ t.datetime :timeout_at
37
+ t.datetime :started_at
38
+ t.datetime :completed_at
39
+ t.datetime :last_error_at
40
+ t.string :last_error_class
41
+ t.text :last_error_message
42
+ t.jsonb :last_error_details, null: false, default: {}
43
+ t.text :last_error_backtrace
44
+ t.jsonb :init_result
45
+ t.jsonb :completion_payload
46
+ t.string :completion_idempotency_key
47
+ t.string :correlation_id
48
+ t.datetime :last_heartbeat_at
49
+
50
+ t.timestamps
51
+ end
52
+
53
+ add_index :aw_steps, [:execution_id, :position]
54
+ add_index :aw_steps, [:execution_id, :name]
55
+ add_index :aw_steps, [:execution_id, :completion_idempotency_key], unique: true, where: "completion_idempotency_key IS NOT NULL"
56
+
57
+ create_table :aw_events do |t|
58
+ t.references :execution, null: false, foreign_key: { to_table: :aw_executions }
59
+ t.string :name, null: false
60
+ t.jsonb :payload, null: false, default: {}
61
+ t.datetime :consumed_at
62
+
63
+ t.timestamps
64
+ end
65
+
66
+ add_index :aw_events, [:execution_id, :name]
67
+ add_index :aw_events, [:execution_id, :name], where: "consumed_at IS NULL", name: "index_aw_events_unconsumed"
68
+ end
69
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SampleWorkflow < ActiveWorkflow::Workflow
4
+ idempotency_key { "sample:#{ctx[:reference]}" }
5
+ defaults retry: { max: 3, strategy: :exponential, first_delay: 2.seconds, jitter: true }
6
+
7
+ step :prepare_data
8
+ task :perform_remote_export, RemoteExportTask, async: true, timeout: 15.minutes, store_result_as: :export_job
9
+ wait_for_signal :approval, as: :approval_payload
10
+ task :finalize do |ctx|
11
+ Rails.logger.info("Finalizing workflow for #{ctx[:reference]} with #{ctx[:approval_payload]}")
12
+ end
13
+
14
+ def prepare_data
15
+ ctx[:prepared_at] = Time.now.utc
16
+ end
17
+ end
18
+
19
+ class RemoteExportTask < ActiveWorkflow::Task
20
+ async! timeout: 15.minutes
21
+
22
+ def call(ctx)
23
+ ExternalExporter.request!(ctx[:reference]).slice(:job_id, :correlation_id)
24
+ end
25
+
26
+ def compensate(ctx, result: nil)
27
+ ExternalExporter.cancel!(result[:job_id]) if result
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < ActiveWorkflow::Workflow
4
+ # idempotency_key { "#{name.underscore}:#{ctx[:resource_id]}" }
5
+ # defaults retry: { max: 3, strategy: :exponential, first_delay: 2.seconds, jitter: true }
6
+
7
+ # Example step styles:
8
+ # step :do_something, timeout: 5.minutes
9
+ # task :perform_task, SomeTaskClass, dedupe: true
10
+ # task :notify do |ctx|
11
+ # Notifications.deliver(ctx[:user_id])
12
+ # end
13
+ # wait_for_signal :approval, as: :approval_payload
14
+
15
+ def do_something
16
+ # ctx[:result] = Service.call!(ctx[:input])
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module ActiveWorkflow
6
+ module Generators
7
+ class WorkflowGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Generates a workflow skeleton under app/workflows"
11
+
12
+ def create_workflow
13
+ template "workflow.rb", File.join("app/workflows", class_path, "#{file_name}.rb")
14
+ end
15
+ end
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,173 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_orchestrator
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ActiveWorkflow Contributors
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-10-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activejob
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.12'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.12'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '6.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '6.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0.14'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0.14'
97
+ - !ruby/object:Gem::Dependency
98
+ name: sqlite3
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '1.6'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '1.6'
111
+ description: |
112
+ ActiveWorkflow provides durable, idempotent workflow orchestration built on Rails Active Job.
113
+ It supports sync and async steps, retries with backoff, signals, compensations, and pluggable persistence stores.
114
+ email:
115
+ - laertis.pappas@gmail.com
116
+ executables: []
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - ADAPTERS.md
121
+ - CHANGELOG.md
122
+ - GUIDE.md
123
+ - LICENSE
124
+ - README.md
125
+ - lib/active_workflow.rb
126
+ - lib/active_workflow/configuration.rb
127
+ - lib/active_workflow/context.rb
128
+ - lib/active_workflow/dsl/options.rb
129
+ - lib/active_workflow/dsl/signals.rb
130
+ - lib/active_workflow/dsl/steps.rb
131
+ - lib/active_workflow/errors.rb
132
+ - lib/active_workflow/execution.rb
133
+ - lib/active_workflow/jobs/runner_job.rb
134
+ - lib/active_workflow/railtie.rb
135
+ - lib/active_workflow/serializers/json.rb
136
+ - lib/active_workflow/stores/active_record.rb
137
+ - lib/active_workflow/stores/base.rb
138
+ - lib/active_workflow/task.rb
139
+ - lib/active_workflow/version.rb
140
+ - lib/active_workflow/workflow.rb
141
+ - lib/generators/active_workflow/install/install_generator.rb
142
+ - lib/generators/active_workflow/install/templates/initializer.rb
143
+ - lib/generators/active_workflow/install/templates/migrations/create_active_workflow_tables.rb
144
+ - lib/generators/active_workflow/install/templates/sample_workflow.rb
145
+ - lib/generators/active_workflow/workflow/templates/workflow.rb
146
+ - lib/generators/active_workflow/workflow/workflow_generator.rb
147
+ homepage: https://github.com/laertispappas/active_workflow
148
+ licenses:
149
+ - MIT
150
+ metadata:
151
+ homepage_uri: https://github.com/laertispappas/active_workflow
152
+ source_code_uri: https://github.com/laertispappas/active_workflow
153
+ changelog_uri: https://github.com/laertispappas/active_workflow/blob/main/CHANGELOG.md
154
+ post_install_message:
155
+ rdoc_options: []
156
+ require_paths:
157
+ - lib
158
+ required_ruby_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '3.2'
163
+ required_rubygems_version: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '0'
168
+ requirements: []
169
+ rubygems_version: 3.5.23
170
+ signing_key:
171
+ specification_version: 4
172
+ summary: Durable workflow orchestration for Ruby on Rails'
173
+ test_files: []