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,62 @@
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
+
57
+ def cancel_execution!(_execution_id, reason: nil)
58
+ raise NotImplementedError
59
+ end
60
+ end
61
+ end
62
+ 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,99 @@
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/backoff"
19
+ require_relative "active_workflow/dsl/options"
20
+ require_relative "active_workflow/dsl/steps"
21
+ require_relative "active_workflow/dsl/signals"
22
+ require_relative "active_workflow/workflow"
23
+ require_relative "active_workflow/task"
24
+ require_relative "active_workflow/jobs/runner_job"
25
+ require_relative "active_workflow/stores/base"
26
+ require_relative "active_workflow/stores/active_record"
27
+ require_relative "active_workflow/serializers/json"
28
+ require_relative "active_workflow/railtie" if defined?(Rails::Railtie)
29
+
30
+ module ActiveWorkflow
31
+ class << self
32
+ # @return [ActiveWorkflow::Configuration]
33
+ def configuration
34
+ @configuration ||= Configuration.new
35
+ end
36
+
37
+ # Yields global configuration block and memoizes the configuration instance.
38
+ #
39
+ # @yieldparam [ActiveWorkflow::Configuration] config
40
+ def configure
41
+ yield(configuration)
42
+ end
43
+
44
+ # Resets configuration (mainly for tests)
45
+ def reset_configuration!
46
+ @configuration = Configuration.new
47
+ end
48
+
49
+ # Delegates the store accessor for convenience.
50
+ #
51
+ # @return [ActiveWorkflow::Stores::Base]
52
+ def store
53
+ configuration.store!
54
+ end
55
+
56
+ # Entry point for async completions. Delegates to store and runner.
57
+ def complete_step!(execution_id, step_name, payload: nil, idempotency_key: nil)
58
+ with_instrumentation("complete", execution_id, step_name) do
59
+ store.complete_step!(execution_id, step_name.to_s, payload: payload, idempotency_key: idempotency_key)
60
+ end
61
+ end
62
+
63
+ def fail_step!(execution_id, step_name, error_class:, message:, details: {}, idempotency_key: nil)
64
+ with_instrumentation("fail", execution_id, step_name) do
65
+ store.fail_step!(execution_id, step_name.to_s,
66
+ error_class: error_class,
67
+ message: message,
68
+ details: details,
69
+ idempotency_key: idempotency_key)
70
+ end
71
+ end
72
+
73
+ def extend_timeout!(execution_id, step_name, by:)
74
+ store.extend_timeout!(execution_id, step_name.to_s, by: by)
75
+ end
76
+
77
+ def heartbeat!(execution_id, step_name, at: configuration.clock.call)
78
+ store.heartbeat!(execution_id, step_name.to_s, at: at)
79
+ end
80
+
81
+ def signal!(execution_id, name, payload: nil)
82
+ with_instrumentation("signal", execution_id, name) do
83
+ store.signal!(execution_id, name.to_s, payload: payload)
84
+ end
85
+ end
86
+
87
+ def cancel!(execution_id, reason: nil)
88
+ store.cancel_execution!(execution_id, reason: reason)
89
+ end
90
+
91
+ private
92
+
93
+ def with_instrumentation(action, execution_id, step_name)
94
+ ActiveSupport::Notifications.instrument("active_workflow.step.#{action}",
95
+ execution_id: execution_id,
96
+ step: step_name.to_s) { yield }
97
+ end
98
+ end
99
+ 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,70 @@
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
+ t.datetime :cancelled_at
19
+
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :aw_executions, :workflow_class
24
+ add_index :aw_executions, :state
25
+ add_index :aw_executions, :idempotency_key, unique: true
26
+
27
+ create_table :aw_steps do |t|
28
+ t.references :execution, null: false, foreign_key: { to_table: :aw_executions }
29
+ t.string :name, null: false
30
+ t.string :style, null: false
31
+ t.jsonb :options, null: false, default: {}
32
+ t.string :state, null: false
33
+ t.integer :position, null: false
34
+ t.integer :attempts, null: false, default: 0
35
+ t.datetime :scheduled_at
36
+ t.datetime :waiting_since
37
+ t.datetime :timeout_at
38
+ t.datetime :started_at
39
+ t.datetime :completed_at
40
+ t.datetime :last_error_at
41
+ t.string :last_error_class
42
+ t.text :last_error_message
43
+ t.jsonb :last_error_details, null: false, default: {}
44
+ t.text :last_error_backtrace
45
+ t.jsonb :init_result
46
+ t.jsonb :completion_payload
47
+ t.string :completion_idempotency_key
48
+ t.string :correlation_id
49
+ t.datetime :last_heartbeat_at
50
+
51
+ t.timestamps
52
+ end
53
+
54
+ add_index :aw_steps, [:execution_id, :position]
55
+ add_index :aw_steps, [:execution_id, :name]
56
+ add_index :aw_steps, [:execution_id, :completion_idempotency_key], unique: true, where: "completion_idempotency_key IS NOT NULL"
57
+
58
+ create_table :aw_events do |t|
59
+ t.references :execution, null: false, foreign_key: { to_table: :aw_executions }
60
+ t.string :name, null: false
61
+ t.jsonb :payload, null: false, default: {}
62
+ t.datetime :consumed_at
63
+
64
+ t.timestamps
65
+ end
66
+
67
+ add_index :aw_events, [:execution_id, :name]
68
+ add_index :aw_events, [:execution_id, :name], where: "consumed_at IS NULL", name: "index_aw_events_unconsumed"
69
+ end
70
+ 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,160 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_saga
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Laerti Papa
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-10-14 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: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
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: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: |
98
+ ActiveWorkflow provides durable, idempotent workflow orchestration built on Rails Active Job.
99
+ It supports sync and async steps, retries with backoff, signals, compensations, and pluggable persistence stores.
100
+ email:
101
+ - laertis.pappas@gmail.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - ADAPTERS.md
107
+ - CHANGELOG.md
108
+ - GUIDE.md
109
+ - LICENSE
110
+ - README.md
111
+ - lib/active_workflow.rb
112
+ - lib/active_workflow/backoff.rb
113
+ - lib/active_workflow/configuration.rb
114
+ - lib/active_workflow/context.rb
115
+ - lib/active_workflow/dsl/options.rb
116
+ - lib/active_workflow/dsl/signals.rb
117
+ - lib/active_workflow/dsl/steps.rb
118
+ - lib/active_workflow/errors.rb
119
+ - lib/active_workflow/execution.rb
120
+ - lib/active_workflow/jobs/runner_job.rb
121
+ - lib/active_workflow/railtie.rb
122
+ - lib/active_workflow/serializers/json.rb
123
+ - lib/active_workflow/stores/active_record.rb
124
+ - lib/active_workflow/stores/base.rb
125
+ - lib/active_workflow/task.rb
126
+ - lib/active_workflow/version.rb
127
+ - lib/active_workflow/workflow.rb
128
+ - lib/generators/active_workflow/install/install_generator.rb
129
+ - lib/generators/active_workflow/install/templates/initializer.rb
130
+ - lib/generators/active_workflow/install/templates/migrations/create_active_workflow_tables.rb
131
+ - lib/generators/active_workflow/install/templates/sample_workflow.rb
132
+ - lib/generators/active_workflow/workflow/templates/workflow.rb
133
+ - lib/generators/active_workflow/workflow/workflow_generator.rb
134
+ homepage: https://github.com/laertispappas/active_saga
135
+ licenses:
136
+ - MIT
137
+ metadata:
138
+ homepage_uri: https://github.com/laertispappas/active_saga
139
+ source_code_uri: https://github.com/laertispappas/active_saga
140
+ changelog_uri: https://github.com/laertispappas/active_saga/blob/main/CHANGELOG.md
141
+ post_install_message:
142
+ rdoc_options: []
143
+ require_paths:
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: '3.2'
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubygems_version: 3.5.23
157
+ signing_key:
158
+ specification_version: 4
159
+ summary: Durable workflow orchestration for Ruby on Rails'
160
+ test_files: []