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.
- checksums.yaml +7 -0
- data/README.md +70 -0
- data/Rakefile +10 -0
- data/app/jobs/solidflow/jobs/run_execution_job.rb +21 -0
- data/app/jobs/solidflow/jobs/run_task_job.rb +107 -0
- data/app/jobs/solidflow/jobs/timer_sweep_job.rb +32 -0
- data/app/models/solidflow/application_record.rb +7 -0
- data/app/models/solidflow/event.rb +23 -0
- data/app/models/solidflow/execution.rb +36 -0
- data/app/models/solidflow/signal_message.rb +16 -0
- data/app/models/solidflow/timer.rb +19 -0
- data/bin/solidflow +7 -0
- data/db/migrate/001_create_solidflow_core_tables.rb +71 -0
- data/lib/solid_flow/cli.rb +68 -0
- data/lib/solid_flow/determinism.rb +39 -0
- data/lib/solid_flow/engine.rb +23 -0
- data/lib/solid_flow/errors.rb +52 -0
- data/lib/solid_flow/idempotency.rb +34 -0
- data/lib/solid_flow/instrumentation.rb +16 -0
- data/lib/solid_flow/registries/task_registry.rb +32 -0
- data/lib/solid_flow/registries/workflow_registry.rb +33 -0
- data/lib/solid_flow/replay.rb +258 -0
- data/lib/solid_flow/runner.rb +468 -0
- data/lib/solid_flow/serializers/oj.rb +24 -0
- data/lib/solid_flow/signals.rb +47 -0
- data/lib/solid_flow/stores/active_record.rb +461 -0
- data/lib/solid_flow/stores/base.rb +87 -0
- data/lib/solid_flow/task.rb +152 -0
- data/lib/solid_flow/testing.rb +28 -0
- data/lib/solid_flow/version.rb +5 -0
- data/lib/solid_flow/wait.rb +76 -0
- data/lib/solid_flow/workflow.rb +263 -0
- data/lib/solid_flow.rb +76 -0
- data/lib/solidflow.rb +3 -0
- metadata +213 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4880f6ff9e531227b618a8fe2ff91bbb09688d60d6c9544b4a90e36b5922e00e
|
|
4
|
+
data.tar.gz: f63bf94ff8865affdb2ba9f7ca1909eb84c83ca46d1fe0d3d7c6c30ca03eecc8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d1a230750a5489a0c2818e419545ead56b863d0a5a591ee7e2c3bc90751e641b9a82ad86aef6ed05ebdb6a6883b6ea771dcc044af2ecd8bb515318f7ce27f667
|
|
7
|
+
data.tar.gz: 61804d906296b93dfe1be46b663bd2ae4e09dc90f4df0c429d3c999afe2854105e7e3d6540dddf146bfc30fa22e2c0d75a81c78acd3a103ff9f9fcb6263037cd
|
data/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SolidFlow
|
|
2
|
+
|
|
3
|
+
**SolidFlow** — Durable workflows for Ruby and Rails. Deterministic workflows, event‑sourced history, timers, signals, tasks, and SAGA compensations. Backed by ActiveJob and ActiveRecord. Production‑ready, scalable, and extensible.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "solidflow", path: "solidflow"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run `bundle install` and install the migrations:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bin/rails solidflow:install:migrations
|
|
17
|
+
bin/rails db:migrate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
Define a workflow and a task:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class ReserveInventoryTask < SolidFlow::Task
|
|
26
|
+
def perform(order_id:)
|
|
27
|
+
Inventory.reserve(order_id: order_id)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class OrderFulfillmentWorkflow < SolidFlow::Workflow
|
|
32
|
+
signal :payment_captured
|
|
33
|
+
|
|
34
|
+
step :reserve_inventory, task: :reserve_inventory_task
|
|
35
|
+
|
|
36
|
+
step :await_payment do
|
|
37
|
+
wait.for(seconds: 30)
|
|
38
|
+
wait.for_signal(:payment_captured) unless ctx[:payment_received]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
on_signal :payment_captured do |payload|
|
|
42
|
+
ctx[:payment_received] = true
|
|
43
|
+
ctx[:txn_id] = payload.fetch("txn_id")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Kick off an execution:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
execution = OrderFulfillmentWorkflow.start(order_id: "ORD-1")
|
|
52
|
+
OrderFulfillmentWorkflow.signal(execution.id, :payment_captured, txn_id: "txn-123")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## CLI
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bin/solidflow start OrderFulfillmentWorkflow --args '{"order_id":"ORD-1"}'
|
|
59
|
+
bin/solidflow signal <execution_id> payment_captured --payload '{"txn_id":"txn-123"}'
|
|
60
|
+
bin/solidflow query <execution_id> status
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Testing
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
execution = SolidFlow::Testing.start_and_drain(OrderFulfillmentWorkflow, order_id: "ORD-1")
|
|
67
|
+
expect(execution.state).to eq("completed")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
See `solid_flow_instructions.md` for the full architecture and feature specification.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
module Jobs
|
|
5
|
+
class RunExecutionJob < ActiveJob::Base
|
|
6
|
+
queue_as do
|
|
7
|
+
SolidFlow.configuration.default_execution_queue
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
retry_exceptions = [ActiveRecord::Deadlocked]
|
|
11
|
+
retry_exceptions << ActiveRecord::LockWaitTimeout if defined?(ActiveRecord::LockWaitTimeout)
|
|
12
|
+
retry_on(*retry_exceptions, attempts: 5, wait: :exponentially_longer)
|
|
13
|
+
|
|
14
|
+
def perform(execution_id)
|
|
15
|
+
SolidFlow::Runner.new.run(execution_id)
|
|
16
|
+
rescue Errors::ExecutionNotFound => e
|
|
17
|
+
SolidFlow.logger.warn("SolidFlow execution not found: #{execution_id} (#{e.class}: #{e.message})")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
module Jobs
|
|
5
|
+
class RunTaskJob < ActiveJob::Base
|
|
6
|
+
queue_as do
|
|
7
|
+
SolidFlow.configuration.default_task_queue
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def perform(execution_id, step_name, task_name, arguments, headers)
|
|
11
|
+
headers = headers.deep_symbolize_keys
|
|
12
|
+
arguments = arguments.deep_symbolize_keys if arguments.respond_to?(:deep_symbolize_keys)
|
|
13
|
+
|
|
14
|
+
task_class = SolidFlow.task_registry.fetch(task_name)
|
|
15
|
+
workflow_class = SolidFlow.configuration.workflow_registry.fetch(headers.fetch(:workflow_name))
|
|
16
|
+
attempt = headers[:attempt] || 1
|
|
17
|
+
idempotency_key = headers[:idempotency_key]
|
|
18
|
+
compensation = headers[:compensation]
|
|
19
|
+
|
|
20
|
+
result = task_class.execute(arguments: arguments || {}, headers:)
|
|
21
|
+
|
|
22
|
+
SolidFlow.store.with_execution(execution_id) do |_|
|
|
23
|
+
if compensation
|
|
24
|
+
SolidFlow.store.record_compensation_result(
|
|
25
|
+
execution_id:,
|
|
26
|
+
step: step_name.to_sym,
|
|
27
|
+
compensation_task: headers[:compensation_task],
|
|
28
|
+
result:
|
|
29
|
+
)
|
|
30
|
+
else
|
|
31
|
+
SolidFlow.store.record_task_result(
|
|
32
|
+
execution_id:,
|
|
33
|
+
workflow_class:,
|
|
34
|
+
step: step_name.to_sym,
|
|
35
|
+
result:,
|
|
36
|
+
attempt:,
|
|
37
|
+
idempotency_key: idempotency_key
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
SolidFlow.instrument(
|
|
43
|
+
"solidflow.task.completed",
|
|
44
|
+
execution_id:,
|
|
45
|
+
workflow: workflow_class.workflow_name,
|
|
46
|
+
step: step_name,
|
|
47
|
+
task: task_name,
|
|
48
|
+
attempt:,
|
|
49
|
+
result:,
|
|
50
|
+
compensation: compensation
|
|
51
|
+
)
|
|
52
|
+
rescue Errors::TaskFailure => failure
|
|
53
|
+
retryable = retryable?(workflow_class, step_name, attempt, headers)
|
|
54
|
+
|
|
55
|
+
SolidFlow.store.with_execution(execution_id) do |_|
|
|
56
|
+
if headers[:compensation]
|
|
57
|
+
SolidFlow.store.record_compensation_failure(
|
|
58
|
+
execution_id:,
|
|
59
|
+
step: step_name.to_sym,
|
|
60
|
+
compensation_task: headers[:compensation_task],
|
|
61
|
+
error: {
|
|
62
|
+
message: failure.message,
|
|
63
|
+
class: failure.details&.fetch(:class, failure.class.name),
|
|
64
|
+
backtrace: Array(failure.details&.fetch(:backtrace, failure.backtrace))
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
else
|
|
68
|
+
SolidFlow.store.record_task_failure(
|
|
69
|
+
execution_id:,
|
|
70
|
+
workflow_class:,
|
|
71
|
+
step: step_name.to_sym,
|
|
72
|
+
attempt:,
|
|
73
|
+
error: {
|
|
74
|
+
message: failure.message,
|
|
75
|
+
class: failure.details&.fetch(:class, failure.class.name),
|
|
76
|
+
backtrace: Array(failure.details&.fetch(:backtrace, failure.backtrace))
|
|
77
|
+
},
|
|
78
|
+
retryable:
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
SolidFlow.instrument(
|
|
84
|
+
"solidflow.task.failed",
|
|
85
|
+
execution_id:,
|
|
86
|
+
workflow: workflow_class.workflow_name,
|
|
87
|
+
step: step_name,
|
|
88
|
+
task: task_name,
|
|
89
|
+
attempt:,
|
|
90
|
+
error: failure,
|
|
91
|
+
compensation: headers[:compensation]
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def retryable?(workflow_class, step_name, attempt, headers)
|
|
98
|
+
return false if headers[:compensation]
|
|
99
|
+
|
|
100
|
+
step_definition = workflow_class.steps.find { |step| step.name.to_sym == step_name.to_sym }
|
|
101
|
+
policy = step_definition&.retry_policy || {}
|
|
102
|
+
max_attempts = policy[:max_attempts] || 1
|
|
103
|
+
attempt < max_attempts
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
module Jobs
|
|
5
|
+
class TimerSweepJob < ActiveJob::Base
|
|
6
|
+
queue_as do
|
|
7
|
+
SolidFlow.configuration.default_timer_queue
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def perform(batch_size: 100)
|
|
11
|
+
now = SolidFlow.configuration.time_provider.call
|
|
12
|
+
|
|
13
|
+
SolidFlow::Timer.transaction do
|
|
14
|
+
SolidFlow::Timer
|
|
15
|
+
.scheduled
|
|
16
|
+
.where("run_at <= ?", now)
|
|
17
|
+
.limit(batch_size)
|
|
18
|
+
.lock("FOR UPDATE SKIP LOCKED")
|
|
19
|
+
.each do |timer|
|
|
20
|
+
SolidFlow.store.mark_timer_fired(timer_id: timer.id)
|
|
21
|
+
SolidFlow.instrument(
|
|
22
|
+
"solidflow.timer.fired",
|
|
23
|
+
execution_id: timer.execution_id,
|
|
24
|
+
timer_id: timer.id,
|
|
25
|
+
step: timer.step
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
class Event < ApplicationRecord
|
|
5
|
+
self.table_name = "solidflow_events"
|
|
6
|
+
|
|
7
|
+
belongs_to :execution,
|
|
8
|
+
class_name: "SolidFlow::Execution",
|
|
9
|
+
inverse_of: :events
|
|
10
|
+
|
|
11
|
+
scope :ordered, -> { order(:sequence) }
|
|
12
|
+
|
|
13
|
+
def to_replay_event
|
|
14
|
+
Replay::Event.new(
|
|
15
|
+
id: id,
|
|
16
|
+
type: event_type,
|
|
17
|
+
sequence: sequence,
|
|
18
|
+
payload: payload,
|
|
19
|
+
recorded_at: recorded_at
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
class Execution < ApplicationRecord
|
|
5
|
+
self.table_name = "solidflow_executions"
|
|
6
|
+
|
|
7
|
+
has_many :events,
|
|
8
|
+
class_name: "SolidFlow::Event",
|
|
9
|
+
foreign_key: :execution_id,
|
|
10
|
+
inverse_of: :execution,
|
|
11
|
+
dependent: :destroy
|
|
12
|
+
|
|
13
|
+
has_many :timers,
|
|
14
|
+
class_name: "SolidFlow::Timer",
|
|
15
|
+
foreign_key: :execution_id,
|
|
16
|
+
inverse_of: :execution,
|
|
17
|
+
dependent: :destroy
|
|
18
|
+
|
|
19
|
+
has_many :signal_messages,
|
|
20
|
+
class_name: "SolidFlow::SignalMessage",
|
|
21
|
+
foreign_key: :execution_id,
|
|
22
|
+
inverse_of: :execution,
|
|
23
|
+
dependent: :destroy
|
|
24
|
+
|
|
25
|
+
enum state: {
|
|
26
|
+
running: "running",
|
|
27
|
+
completed: "completed",
|
|
28
|
+
failed: "failed",
|
|
29
|
+
cancelled: "cancelled"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def ctx_hash
|
|
33
|
+
(self[:ctx] || {}).with_indifferent_access
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
class SignalMessage < ApplicationRecord
|
|
5
|
+
self.table_name = "solidflow_signal_messages"
|
|
6
|
+
|
|
7
|
+
belongs_to :execution,
|
|
8
|
+
class_name: "SolidFlow::Execution",
|
|
9
|
+
inverse_of: :signal_messages
|
|
10
|
+
|
|
11
|
+
enum status: {
|
|
12
|
+
pending: "pending",
|
|
13
|
+
consumed: "consumed"
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
class Timer < ApplicationRecord
|
|
5
|
+
self.table_name = "solidflow_timers"
|
|
6
|
+
|
|
7
|
+
belongs_to :execution,
|
|
8
|
+
class_name: "SolidFlow::Execution",
|
|
9
|
+
inverse_of: :timers
|
|
10
|
+
|
|
11
|
+
enum status: {
|
|
12
|
+
scheduled: "scheduled",
|
|
13
|
+
fired: "fired",
|
|
14
|
+
cancelled: "cancelled"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
scope :due, ->(now = Time.current) { scheduled.where("run_at <= ?", now) }
|
|
18
|
+
end
|
|
19
|
+
end
|
data/bin/solidflow
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
class CreateSolidflowCoreTables < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
enable_extension "pgcrypto" unless extension_enabled?("pgcrypto")
|
|
4
|
+
|
|
5
|
+
create_table :solidflow_executions, id: :uuid do |t|
|
|
6
|
+
t.string :workflow, null: false
|
|
7
|
+
t.string :state, null: false, default: "running"
|
|
8
|
+
t.jsonb :ctx, null: false, default: {}
|
|
9
|
+
t.string :cursor_step
|
|
10
|
+
t.integer :cursor_index, null: false, default: 0
|
|
11
|
+
t.string :graph_signature
|
|
12
|
+
t.jsonb :metadata, null: false, default: {}
|
|
13
|
+
t.jsonb :last_error
|
|
14
|
+
t.string :shard_key
|
|
15
|
+
t.string :account_id
|
|
16
|
+
t.datetime :started_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :solidflow_executions, :workflow
|
|
21
|
+
add_index :solidflow_executions, %i[state workflow]
|
|
22
|
+
add_index :solidflow_executions, :shard_key
|
|
23
|
+
add_index :solidflow_executions, :account_id
|
|
24
|
+
|
|
25
|
+
create_table :solidflow_events, id: :uuid do |t|
|
|
26
|
+
t.uuid :execution_id, null: false
|
|
27
|
+
t.integer :sequence, null: false
|
|
28
|
+
t.string :event_type, null: false
|
|
29
|
+
t.jsonb :payload, null: false, default: {}
|
|
30
|
+
t.string :idempotency_key
|
|
31
|
+
t.datetime :recorded_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
32
|
+
t.timestamps
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
add_index :solidflow_events, %i[execution_id sequence], unique: true
|
|
36
|
+
add_index :solidflow_events, :event_type
|
|
37
|
+
add_index :solidflow_events, %i[execution_id idempotency_key], unique: true, where: "idempotency_key IS NOT NULL"
|
|
38
|
+
add_foreign_key :solidflow_events, :solidflow_executions, column: :execution_id
|
|
39
|
+
|
|
40
|
+
create_table :solidflow_timers, id: :uuid do |t|
|
|
41
|
+
t.uuid :execution_id, null: false
|
|
42
|
+
t.string :step, null: false
|
|
43
|
+
t.datetime :run_at, null: false
|
|
44
|
+
t.string :status, null: false, default: "scheduled"
|
|
45
|
+
t.jsonb :instruction, null: false, default: {}
|
|
46
|
+
t.jsonb :metadata, null: false, default: {}
|
|
47
|
+
t.datetime :fired_at
|
|
48
|
+
t.timestamps
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
add_index :solidflow_timers, :execution_id
|
|
52
|
+
add_index :solidflow_timers, %i[status run_at]
|
|
53
|
+
add_foreign_key :solidflow_timers, :solidflow_executions, column: :execution_id
|
|
54
|
+
|
|
55
|
+
create_table :solidflow_signal_messages, id: :uuid do |t|
|
|
56
|
+
t.uuid :execution_id, null: false
|
|
57
|
+
t.string :signal_name, null: false
|
|
58
|
+
t.jsonb :payload, null: false, default: {}
|
|
59
|
+
t.jsonb :metadata, null: false, default: {}
|
|
60
|
+
t.string :status, null: false, default: "pending"
|
|
61
|
+
t.datetime :received_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
62
|
+
t.datetime :consumed_at
|
|
63
|
+
t.timestamps
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
add_index :solidflow_signal_messages, :execution_id
|
|
67
|
+
add_index :solidflow_signal_messages, %i[execution_id status]
|
|
68
|
+
add_index :solidflow_signal_messages, %i[execution_id signal_name status], name: "index_solidflow_signals_lookup"
|
|
69
|
+
add_foreign_key :solidflow_signal_messages, :solidflow_executions, column: :execution_id
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module SolidFlow
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
desc "start WORKFLOW", "Start a workflow execution"
|
|
9
|
+
option :args, type: :string, default: "{}", desc: "JSON payload with workflow input arguments"
|
|
10
|
+
def start(workflow_name)
|
|
11
|
+
workflow_class = SolidFlow.configuration.workflow_registry.fetch(workflow_name)
|
|
12
|
+
arguments = parse_json(options[:args])
|
|
13
|
+
|
|
14
|
+
execution = workflow_class.start(**symbolize_keys(arguments))
|
|
15
|
+
say("Started execution #{execution.id}", :green)
|
|
16
|
+
rescue Errors::ConfigurationError, JSON::ParserError => e
|
|
17
|
+
say("Failed to start workflow: #{e.message}", :red)
|
|
18
|
+
exit(1)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "signal EXECUTION_ID SIGNAL", "Send a signal to a workflow execution"
|
|
22
|
+
option :workflow, type: :string, desc: "Workflow name (optional; inferred if omitted)"
|
|
23
|
+
option :payload, type: :string, default: "{}", desc: "JSON payload for the signal"
|
|
24
|
+
def signal(execution_id, signal_name)
|
|
25
|
+
workflow_class = resolve_workflow(options[:workflow], execution_id)
|
|
26
|
+
payload = parse_json(options[:payload])
|
|
27
|
+
|
|
28
|
+
workflow_class.signal(execution_id, signal_name.to_sym, payload)
|
|
29
|
+
say("Signal #{signal_name} enqueued for execution #{execution_id}", :green)
|
|
30
|
+
rescue Errors::ConfigurationError, JSON::ParserError => e
|
|
31
|
+
say("Failed to send signal: #{e.message}", :red)
|
|
32
|
+
exit(1)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc "query EXECUTION_ID QUERY", "Execute a read-only query against a workflow"
|
|
36
|
+
option :workflow, type: :string, desc: "Workflow name (optional; inferred if omitted)"
|
|
37
|
+
def query(execution_id, query_name)
|
|
38
|
+
workflow_class = resolve_workflow(options[:workflow], execution_id)
|
|
39
|
+
result = workflow_class.query(execution_id, query_name.to_sym)
|
|
40
|
+
say(JSON.pretty_generate(result))
|
|
41
|
+
rescue Errors::ConfigurationError => e
|
|
42
|
+
say("Failed to run query: #{e.message}", :red)
|
|
43
|
+
exit(1)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
no_commands do
|
|
47
|
+
def parse_json(string)
|
|
48
|
+
return {} if string.nil? || string.strip.empty?
|
|
49
|
+
|
|
50
|
+
JSON.parse(string)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def symbolize_keys(hash)
|
|
54
|
+
return hash unless hash.respond_to?(:transform_keys)
|
|
55
|
+
|
|
56
|
+
hash.transform_keys(&:to_sym)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_workflow(name, execution_id)
|
|
60
|
+
return SolidFlow.configuration.workflow_registry.fetch(name) if name
|
|
61
|
+
|
|
62
|
+
SolidFlow.store.with_execution(execution_id, lock: false) do |execution|
|
|
63
|
+
return SolidFlow.configuration.workflow_registry.fetch(execution[:workflow])
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidFlow
|
|
6
|
+
module Determinism
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def graph_signature(workflow_class)
|
|
10
|
+
payload = {
|
|
11
|
+
workflow: workflow_class.name,
|
|
12
|
+
steps: workflow_class.steps.map do |step|
|
|
13
|
+
{
|
|
14
|
+
name: step.name,
|
|
15
|
+
task: step.task,
|
|
16
|
+
block: step.block? ? "block" : nil,
|
|
17
|
+
retry: step.retry_policy,
|
|
18
|
+
timeouts: step.timeouts,
|
|
19
|
+
options: step.options
|
|
20
|
+
}
|
|
21
|
+
end,
|
|
22
|
+
signals: workflow_class.signals.keys.map(&:to_s).sort,
|
|
23
|
+
queries: workflow_class.queries.keys.map(&:to_s).sort,
|
|
24
|
+
compensations: workflow_class.compensations.transform_keys(&:to_s).transform_values(&:to_s).sort.to_h
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def assert_graph!(workflow_class, persisted_signature)
|
|
31
|
+
signature = graph_signature(workflow_class)
|
|
32
|
+
return signature unless persisted_signature
|
|
33
|
+
|
|
34
|
+
return signature if signature == persisted_signature
|
|
35
|
+
|
|
36
|
+
raise Errors::NonDeterministicWorkflowError.new(expected: persisted_signature, actual: signature)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module SolidFlow
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace SolidFlow
|
|
8
|
+
engine_name "solidflow"
|
|
9
|
+
|
|
10
|
+
initializer "solidflow.active_job" do
|
|
11
|
+
ActiveSupport.on_load(:active_job) do
|
|
12
|
+
queue_adapter # touch to ensure ActiveJob loaded
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "solidflow.append_migrations" do |app|
|
|
17
|
+
unless app.root.to_s.match?(root.to_s)
|
|
18
|
+
config_file = root.join("db/migrate")
|
|
19
|
+
app.config.paths["db/migrate"].concat([config_file.to_s])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
module Errors
|
|
5
|
+
# Base error for all SolidFlow failures.
|
|
6
|
+
class SolidFlowError < StandardError; end
|
|
7
|
+
|
|
8
|
+
class ConfigurationError < SolidFlowError; end
|
|
9
|
+
class DeterminismError < SolidFlowError; end
|
|
10
|
+
class ExecutionNotFound < SolidFlowError; end
|
|
11
|
+
class Cancelled < SolidFlowError; end
|
|
12
|
+
class TimeoutError < SolidFlowError; end
|
|
13
|
+
class TaskFailure < SolidFlowError
|
|
14
|
+
attr_reader :details
|
|
15
|
+
|
|
16
|
+
def initialize(message = nil, details: nil)
|
|
17
|
+
@details = details
|
|
18
|
+
super(message || "Task execution failed")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class NonDeterministicWorkflowError < DeterminismError
|
|
23
|
+
attr_reader :expected, :actual
|
|
24
|
+
|
|
25
|
+
def initialize(expected:, actual:)
|
|
26
|
+
@expected = expected
|
|
27
|
+
@actual = actual
|
|
28
|
+
super("Workflow definition diverged from persisted graph signature (expected: #{expected}, actual: #{actual})")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class UnknownSignal < SolidFlowError
|
|
33
|
+
def initialize(signal)
|
|
34
|
+
super("Unknown signal `#{signal}`")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class UnknownQuery < SolidFlowError
|
|
39
|
+
def initialize(query)
|
|
40
|
+
super("Unknown query `#{query}`")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class InvalidStep < SolidFlowError
|
|
45
|
+
def initialize(step)
|
|
46
|
+
super("Step `#{step}` is not defined on this workflow")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class WaitInstructionError < SolidFlowError; end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module SolidFlow
|
|
6
|
+
module Idempotency
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def evaluate(key, workflow:, step:)
|
|
10
|
+
case key
|
|
11
|
+
when Proc
|
|
12
|
+
workflow.instance_exec(&key).to_s
|
|
13
|
+
when Symbol
|
|
14
|
+
workflow.public_send(key).to_s
|
|
15
|
+
when String
|
|
16
|
+
key
|
|
17
|
+
when Array
|
|
18
|
+
key.compact.map(&:to_s).join(":")
|
|
19
|
+
when nil
|
|
20
|
+
default(workflow.execution.id, step.name)
|
|
21
|
+
else
|
|
22
|
+
key.to_s
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def default(execution_id, step_name, attempt = 0)
|
|
27
|
+
Digest::SHA256.hexdigest([execution_id, step_name, attempt].join(":"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def digest(*parts)
|
|
31
|
+
Digest::SHA256.hexdigest(parts.flatten.compact.map(&:to_s).join("|"))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidFlow
|
|
4
|
+
module Instrumentation
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def subscribe(logger: SolidFlow.logger)
|
|
8
|
+
ActiveSupport::Notifications.subscribe(/solidflow\./) do |event_name, start, finish, _id, payload|
|
|
9
|
+
next unless logger
|
|
10
|
+
|
|
11
|
+
duration = (finish - start) * 1000.0
|
|
12
|
+
logger.info("[#{event_name}] (#{format('%.1fms', duration)}) #{payload.compact}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|