solidflow 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,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ module Testing
5
+ module_function
6
+
7
+ # Runs an execution synchronously until it reaches a terminal state or the
8
+ # provided iteration limit. Useful for unit-style workflow specs.
9
+ def drain_execution(execution_id, max_iterations: 100)
10
+ runner = SolidFlow::Runner.new
11
+ max_iterations.times do
12
+ runner.run(execution_id)
13
+
14
+ status = SolidFlow.store.with_execution(execution_id, lock: false) do |execution|
15
+ execution[:state]
16
+ end
17
+
18
+ break unless status == "running"
19
+ end
20
+ end
21
+
22
+ def start_and_drain(workflow_class, **input)
23
+ execution = workflow_class.start(**input)
24
+ drain_execution(execution.id)
25
+ execution
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ module Wait
5
+ class Instruction
6
+ attr_reader :type, :options
7
+
8
+ def initialize(type, options)
9
+ @type = type
10
+ @options = options
11
+ end
12
+
13
+ def to_h
14
+ { type:, options: options }
15
+ end
16
+ end
17
+
18
+ class TimerInstruction < Instruction
19
+ def initialize(options)
20
+ super(:timer, options)
21
+ end
22
+ end
23
+
24
+ class SignalInstruction < Instruction
25
+ def initialize(options)
26
+ super(:signal, options)
27
+ end
28
+ end
29
+
30
+ class Context
31
+ attr_reader :instructions
32
+
33
+ def initialize
34
+ @instructions = []
35
+ end
36
+
37
+ def for(seconds: nil, timestamp: nil, at: nil, metadata: {})
38
+ metadata_payload =
39
+ case metadata
40
+ when Hash
41
+ metadata.deep_dup
42
+ else
43
+ metadata
44
+ end
45
+ options = { metadata: metadata_payload }
46
+ if seconds
47
+ raise ArgumentError, "seconds must be positive" unless seconds.to_f.positive?
48
+
49
+ options[:delay_seconds] = seconds.to_f
50
+ elsif at || timestamp
51
+ target = at || timestamp
52
+ options[:run_at] = target
53
+ else
54
+ raise ArgumentError, "wait.for requires :seconds or :timestamp"
55
+ end
56
+
57
+ instruction = TimerInstruction.new(options)
58
+ @instructions << instruction
59
+ instruction
60
+ end
61
+
62
+ def for_signal(name, metadata: {})
63
+ metadata_payload =
64
+ case metadata
65
+ when Hash
66
+ metadata.deep_dup
67
+ else
68
+ metadata
69
+ end
70
+ instruction = SignalInstruction.new(signal: name.to_sym, metadata: metadata_payload)
71
+ @instructions << instruction
72
+ instruction
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/class/subclasses"
4
+ require "active_support/core_ext/object/blank"
5
+ require "set"
6
+
7
+ module SolidFlow
8
+ # Base class that developers subclass to implement durable workflows.
9
+ # Provides a DSL for defining steps, signals, queries, and compensations.
10
+ class Workflow
11
+ StepDefinition = Struct.new(
12
+ :name,
13
+ :task,
14
+ :block,
15
+ :retry_policy,
16
+ :timeouts,
17
+ :idempotency_key,
18
+ :options,
19
+ keyword_init: true
20
+ ) do
21
+ def task?
22
+ task.present?
23
+ end
24
+
25
+ def block?
26
+ !block.nil?
27
+ end
28
+
29
+ def dup
30
+ self.class.new(
31
+ name:,
32
+ task:,
33
+ block:,
34
+ retry_policy: retry_policy.deep_dup,
35
+ timeouts: timeouts.deep_dup,
36
+ idempotency_key:,
37
+ options: options.deep_dup
38
+ )
39
+ end
40
+ end
41
+
42
+ class << self
43
+ def inherited(subclass)
44
+ super
45
+
46
+ SolidFlow.configuration.workflow_registry.register(subclass.workflow_name, subclass)
47
+ subclass.instance_variable_set(:@workflow_steps, workflow_steps.map(&:dup))
48
+ subclass.instance_variable_set(:@workflow_signals, workflow_signals.deep_dup)
49
+ subclass.instance_variable_set(:@workflow_queries, workflow_queries.deep_dup)
50
+ subclass.instance_variable_set(:@signal_handlers, signal_handlers.deep_dup)
51
+ subclass.instance_variable_set(:@query_handlers, query_handlers.deep_dup)
52
+ subclass.instance_variable_set(:@workflow_defaults, workflow_defaults.deep_dup)
53
+ subclass.instance_variable_set(:@workflow_compensations, workflow_compensations.deep_dup)
54
+ end
55
+
56
+ def workflow_name(value = nil)
57
+ if value
58
+ normalized = value.to_s
59
+ @workflow_name = normalized
60
+ SolidFlow.configuration.workflow_registry.register(normalized, self)
61
+ @workflow_name
62
+ else
63
+ @workflow_name ||= name || "anonymous_workflow"
64
+ end
65
+ end
66
+
67
+ def workflow_steps
68
+ @workflow_steps ||= []
69
+ end
70
+
71
+ def workflow_signals
72
+ @workflow_signals ||= {}
73
+ end
74
+
75
+ def workflow_queries
76
+ @workflow_queries ||= {}
77
+ end
78
+
79
+ def signal_handlers
80
+ @signal_handlers ||= {}
81
+ end
82
+
83
+ def query_handlers
84
+ @query_handlers ||= {}
85
+ end
86
+
87
+ def workflow_defaults
88
+ @workflow_defaults ||= {
89
+ retry: {},
90
+ timeouts: {}
91
+ }
92
+ end
93
+
94
+ def workflow_compensations
95
+ @workflow_compensations ||= {}
96
+ end
97
+
98
+ def defaults(options = {})
99
+ workflow_defaults.deep_merge!(options.deep_symbolize_keys)
100
+ end
101
+
102
+ def step(name, task: nil, **options, &block)
103
+ raise ArgumentError, "step #{name} already defined" if workflow_steps.any? { |s| s.name == name.to_sym }
104
+
105
+ retry_policy = workflow_defaults[:retry].merge(options.delete(:retry) || {})
106
+ timeouts = workflow_defaults[:timeouts].merge(options.delete(:timeouts) || {})
107
+ idempotency = options.delete(:idempotency_key)
108
+
109
+ workflow_steps << StepDefinition.new(
110
+ name: name.to_sym,
111
+ task: task&.to_sym,
112
+ block: block,
113
+ retry_policy: retry_policy,
114
+ timeouts: timeouts,
115
+ idempotency_key: idempotency,
116
+ options: options
117
+ )
118
+ end
119
+
120
+ def signal(name, buffer: true)
121
+ workflow_signals[name.to_sym] = { buffer: }
122
+ end
123
+
124
+ def on_signal(name, method_name = nil, &block)
125
+ handler =
126
+ if method_name
127
+ lambda { |payload| send(method_name, payload) }
128
+ else
129
+ block
130
+ end
131
+
132
+ raise ArgumentError, "on_signal requires a block or method name" unless handler
133
+
134
+ signal_handlers[name.to_sym] = handler
135
+ end
136
+
137
+ def query(name)
138
+ workflow_queries[name.to_sym] = {}
139
+ end
140
+
141
+ def on_query(name, method_name = nil, &block)
142
+ handler =
143
+ if method_name
144
+ lambda { send(method_name) }
145
+ else
146
+ block
147
+ end
148
+
149
+ raise ArgumentError, "on_query requires a block or method name" unless handler
150
+
151
+ query_handlers[name.to_sym] = handler
152
+ end
153
+
154
+ def compensate(step_name, with:)
155
+ workflow_compensations[step_name.to_sym] = with.to_sym
156
+ end
157
+
158
+ def steps
159
+ workflow_steps.dup
160
+ end
161
+
162
+ def signals
163
+ workflow_signals.dup
164
+ end
165
+
166
+ def queries
167
+ workflow_queries.dup
168
+ end
169
+
170
+ def compensations
171
+ workflow_compensations.dup
172
+ end
173
+
174
+ def graph_signature
175
+ Determinism.graph_signature(self)
176
+ end
177
+
178
+ def start(**input)
179
+ SolidFlow.instrument("solidflow.execution.start", workflow: workflow_name, input:)
180
+ signature = graph_signature
181
+ SolidFlow.store.start_execution(
182
+ workflow_class: self,
183
+ input: input,
184
+ graph_signature: signature
185
+ )
186
+ end
187
+
188
+ def signal(execution_id, signal_name, payload = {})
189
+ raise Errors::UnknownSignal, signal_name unless signal_handlers.key?(signal_name.to_sym) || workflow_signals.key?(signal_name.to_sym)
190
+
191
+ normalized_payload = payload.is_a?(Hash) ? payload.deep_symbolize_keys : payload
192
+
193
+ SolidFlow.store.signal_execution(
194
+ execution_id:,
195
+ workflow_class: self,
196
+ signal_name: signal_name.to_sym,
197
+ payload: normalized_payload
198
+ )
199
+ end
200
+
201
+ def query(execution_id, query_name)
202
+ handler = query_handlers.fetch(query_name.to_sym) do
203
+ raise Errors::UnknownQuery, query_name
204
+ end
205
+
206
+ SolidFlow.store.query_execution(
207
+ execution_id:,
208
+ workflow_class: self
209
+ ) do |state|
210
+ workflow = new(ctx: state.ctx, execution: state.execution_state, history: state.history)
211
+ workflow.instance_exec(&handler)
212
+ end
213
+ end
214
+ end
215
+
216
+ attr_reader :ctx, :execution, :history
217
+
218
+ def initialize(ctx:, execution:, history: [])
219
+ @ctx = ctx.with_indifferent_access
220
+ @execution = execution
221
+ @history = history
222
+ @wait_context = nil
223
+ end
224
+
225
+ def wait
226
+ @wait_context ||= Wait::Context.new
227
+ end
228
+
229
+ def reset_wait_context!
230
+ @wait_context = Wait::Context.new
231
+ end
232
+
233
+ def consume_wait_instructions
234
+ instructions = @wait_context&.instructions || []
235
+ @wait_context = nil
236
+ instructions
237
+ end
238
+
239
+ def apply_signal(name, payload)
240
+ handler = self.class.signal_handlers[name.to_sym]
241
+ raise Errors::UnknownSignal, name unless handler
242
+
243
+ instance_exec(payload.with_indifferent_access, &handler)
244
+ end
245
+
246
+ def signal_defined?(name)
247
+ self.class.signal_handlers.key?(name.to_sym) || self.class.workflow_signals.key?(name.to_sym)
248
+ end
249
+
250
+ def query_defined?(name)
251
+ self.class.query_handlers.key?(name.to_sym)
252
+ end
253
+
254
+ def cancel!(reason = "cancelled")
255
+ @cancelled = true
256
+ raise Errors::Cancelled, reason
257
+ end
258
+
259
+ def cancelled?
260
+ !!@cancelled
261
+ end
262
+ end
263
+ end
data/lib/solid_flow.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "active_support"
5
+ require "active_support/core_ext/module"
6
+ require "active_support/core_ext/hash/indifferent_access"
7
+ require "active_support/core_ext/object/deep_dup"
8
+ require "active_support/notifications"
9
+ require "active_support/time"
10
+ require "zeitwerk"
11
+ require "securerandom"
12
+ require "thread"
13
+ require "digest"
14
+
15
+ loader = Zeitwerk::Loader.for_gem
16
+ loader.ignore("#{__dir__}/../app")
17
+ loader.setup
18
+
19
+ require "solid_flow/engine" if defined?(Rails::Engine)
20
+
21
+ module SolidFlow
22
+ class << self
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield(configuration)
29
+ end
30
+
31
+ def logger
32
+ configuration.logger
33
+ end
34
+
35
+ def store
36
+ configuration.store
37
+ end
38
+
39
+ def task_registry
40
+ configuration.task_registry
41
+ end
42
+
43
+ def instrument(event, payload = {})
44
+ ActiveSupport::Notifications.instrument(event, payload)
45
+ end
46
+ end
47
+
48
+ # Minimal dependency injection container for runtime components.
49
+ class Configuration
50
+ attr_accessor :logger,
51
+ :event_serializer,
52
+ :store,
53
+ :task_registry,
54
+ :workflow_registry,
55
+ :default_execution_queue,
56
+ :default_task_queue,
57
+ :default_timer_queue,
58
+ :time_provider,
59
+ :id_generator
60
+
61
+ def initialize
62
+ @logger = Logger.new($stdout, level: Logger::INFO)
63
+ @event_serializer = Serializers::Oj.new
64
+ @workflow_registry = Registries::WorkflowRegistry.new
65
+ @task_registry = Registries::TaskRegistry.new
66
+ @default_execution_queue = "solidflow"
67
+ @default_task_queue = "solidflow_tasks"
68
+ @default_timer_queue = "solidflow_timers"
69
+ @time_provider = -> { Time.current }
70
+ @id_generator = -> { SecureRandom.uuid }
71
+ @store = Stores::ActiveRecord.new(event_serializer: @event_serializer,
72
+ time_provider: @time_provider,
73
+ logger: @logger)
74
+ end
75
+ end
76
+ end
data/lib/solidflow.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "solid_flow"
metadata ADDED
@@ -0,0 +1,213 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solidflow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Laerti Papa
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-19 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
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '8.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.1'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: activejob
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '8.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '7.1'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '8.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: activerecord
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '7.1'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '8.0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '7.1'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '8.0'
73
+ - !ruby/object:Gem::Dependency
74
+ name: zeitwerk
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '2.6'
80
+ type: :runtime
81
+ prerelease: false
82
+ version_requirements: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '2.6'
87
+ - !ruby/object:Gem::Dependency
88
+ name: thor
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '1.3'
94
+ type: :runtime
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '1.3'
101
+ - !ruby/object:Gem::Dependency
102
+ name: oj
103
+ requirement: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '3.16'
108
+ type: :runtime
109
+ prerelease: false
110
+ version_requirements: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '3.16'
115
+ - !ruby/object:Gem::Dependency
116
+ name: rspec
117
+ requirement: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '3.13'
122
+ type: :development
123
+ prerelease: false
124
+ version_requirements: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - "~>"
127
+ - !ruby/object:Gem::Version
128
+ version: '3.13'
129
+ - !ruby/object:Gem::Dependency
130
+ name: rubocop
131
+ requirement: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - "~>"
134
+ - !ruby/object:Gem::Version
135
+ version: '1.65'
136
+ type: :development
137
+ prerelease: false
138
+ version_requirements: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - "~>"
141
+ - !ruby/object:Gem::Version
142
+ version: '1.65'
143
+ description: |
144
+ SolidFlow provides deterministic workflow orchestration for Ruby on Rails using ActiveJob
145
+ and ActiveRecord. It features event-sourced history, replay, timers, signals, tasks, and SAGA
146
+ compensations designed for production-grade systems.
147
+ email:
148
+ - laertis.pappas@gmail.com
149
+ executables:
150
+ - solidflow
151
+ extensions: []
152
+ extra_rdoc_files: []
153
+ files:
154
+ - README.md
155
+ - Rakefile
156
+ - app/jobs/solidflow/jobs/run_execution_job.rb
157
+ - app/jobs/solidflow/jobs/run_task_job.rb
158
+ - app/jobs/solidflow/jobs/timer_sweep_job.rb
159
+ - app/models/solidflow/application_record.rb
160
+ - app/models/solidflow/event.rb
161
+ - app/models/solidflow/execution.rb
162
+ - app/models/solidflow/signal_message.rb
163
+ - app/models/solidflow/timer.rb
164
+ - bin/solidflow
165
+ - db/migrate/001_create_solidflow_core_tables.rb
166
+ - lib/solid_flow.rb
167
+ - lib/solid_flow/cli.rb
168
+ - lib/solid_flow/determinism.rb
169
+ - lib/solid_flow/engine.rb
170
+ - lib/solid_flow/errors.rb
171
+ - lib/solid_flow/idempotency.rb
172
+ - lib/solid_flow/instrumentation.rb
173
+ - lib/solid_flow/registries/task_registry.rb
174
+ - lib/solid_flow/registries/workflow_registry.rb
175
+ - lib/solid_flow/replay.rb
176
+ - lib/solid_flow/runner.rb
177
+ - lib/solid_flow/serializers/oj.rb
178
+ - lib/solid_flow/signals.rb
179
+ - lib/solid_flow/stores/active_record.rb
180
+ - lib/solid_flow/stores/base.rb
181
+ - lib/solid_flow/task.rb
182
+ - lib/solid_flow/testing.rb
183
+ - lib/solid_flow/version.rb
184
+ - lib/solid_flow/wait.rb
185
+ - lib/solid_flow/workflow.rb
186
+ - lib/solidflow.rb
187
+ homepage: https://github.com/laertispappas/solidflow
188
+ licenses:
189
+ - MIT
190
+ metadata:
191
+ homepage_uri: https://github.com/laertispappas/solidflow
192
+ source_code_uri: https://github.com/laertispappas/solidflow
193
+ changelog_uri: https://github.com/laertispappas/solidflow/CHANGELOG.md
194
+ post_install_message:
195
+ rdoc_options: []
196
+ require_paths:
197
+ - lib
198
+ required_ruby_version: !ruby/object:Gem::Requirement
199
+ requirements:
200
+ - - ">="
201
+ - !ruby/object:Gem::Version
202
+ version: '3.1'
203
+ required_rubygems_version: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - ">="
206
+ - !ruby/object:Gem::Version
207
+ version: '0'
208
+ requirements: []
209
+ rubygems_version: 3.5.23
210
+ signing_key:
211
+ specification_version: 4
212
+ summary: Durable workflows for Ruby & Rails
213
+ test_files: []