durable_flow 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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class WorkflowLog < ApplicationRecord
5
+ include Live::Broadcastable
6
+
7
+ self.table_name = "durable_flow_workflow_logs"
8
+
9
+ belongs_to :workflow_run, class_name: "DurableFlow::WorkflowRun"
10
+ belongs_to :workflow_step, class_name: "DurableFlow::WorkflowStep", optional: true
11
+
12
+ LEVELS = WorkflowLogger::LEVELS
13
+
14
+ validates :level, inclusion: { in: LEVELS }
15
+ validates :message, presence: true
16
+
17
+ scope :ordered, -> { order(:created_at, :id) }
18
+
19
+ def data_value
20
+ Serializer.load(data)
21
+ end
22
+
23
+ def live_snapshot
24
+ {
25
+ id: id,
26
+ workflow_run_id: workflow_run_id,
27
+ workflow_step_id: workflow_step_id,
28
+ level: level,
29
+ message: message,
30
+ data: data,
31
+ created_at: created_at,
32
+ updated_at: updated_at,
33
+ }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class WorkflowRun < ApplicationRecord
5
+ include Live::Broadcastable
6
+
7
+ self.table_name = "durable_flow_workflow_runs"
8
+
9
+ TERMINAL_STATUSES = %w[completed failed].freeze
10
+
11
+ has_many :workflow_steps, class_name: "DurableFlow::WorkflowStep", dependent: :delete_all
12
+ has_many :workflow_waits, class_name: "DurableFlow::WorkflowWait", dependent: :delete_all
13
+ has_many :workflow_logs, class_name: "DurableFlow::WorkflowLog", dependent: :delete_all
14
+
15
+ scope :active, -> { where.not(status: TERMINAL_STATUSES) }
16
+
17
+ def acquire_execution_lock!(owner:, ttl:)
18
+ now = Time.current
19
+ lock_expires_at = now + ttl
20
+
21
+ updated = self.class
22
+ .where(id: id)
23
+ .active
24
+ .where("execution_locked_by IS NULL OR execution_lock_expires_at <= ?", now)
25
+ .update_all(
26
+ execution_locked_by: owner,
27
+ execution_locked_at: now,
28
+ execution_lock_expires_at: lock_expires_at,
29
+ updated_at: now,
30
+ )
31
+
32
+ reload if updated == 1
33
+ updated == 1
34
+ end
35
+
36
+ def release_execution_lock!(owner:)
37
+ self.class
38
+ .where(id: id, execution_locked_by: owner)
39
+ .update_all(
40
+ execution_locked_by: nil,
41
+ execution_locked_at: nil,
42
+ execution_lock_expires_at: nil,
43
+ updated_at: Time.current,
44
+ )
45
+ end
46
+
47
+ def refresh_execution_lock!(owner:, ttl:)
48
+ now = Time.current
49
+
50
+ updated = self.class
51
+ .where(id: id, execution_locked_by: owner)
52
+ .update_all(
53
+ execution_lock_expires_at: now + ttl,
54
+ updated_at: now,
55
+ )
56
+
57
+ reload if updated == 1
58
+ updated == 1
59
+ end
60
+
61
+ def execution_locked?
62
+ execution_locked_by.present? && execution_lock_expires_at.present? && execution_lock_expires_at > Time.current
63
+ end
64
+
65
+ def completed?
66
+ status == "completed"
67
+ end
68
+
69
+ def failed?
70
+ status == "failed"
71
+ end
72
+
73
+ def terminal?
74
+ completed? || failed?
75
+ end
76
+
77
+ def timeline
78
+ WorkflowTimeline.new(self)
79
+ end
80
+
81
+ def live_snapshot
82
+ {
83
+ id: id,
84
+ run_id: run_id,
85
+ job_id: job_id,
86
+ workflow_class: workflow_class,
87
+ status: status,
88
+ queue_name: queue_name,
89
+ priority: priority,
90
+ started_at: started_at,
91
+ interrupted_at: interrupted_at,
92
+ completed_at: completed_at,
93
+ failed_at: failed_at,
94
+ execution_locked: execution_locked?,
95
+ execution_lock_expires_at: execution_lock_expires_at,
96
+ created_at: created_at,
97
+ updated_at: updated_at,
98
+ }
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class WorkflowStep < ApplicationRecord
5
+ include Live::Broadcastable
6
+
7
+ self.table_name = "durable_flow_workflow_steps"
8
+
9
+ belongs_to :workflow_run, class_name: "DurableFlow::WorkflowRun"
10
+ has_many :workflow_logs, class_name: "DurableFlow::WorkflowLog", dependent: :nullify
11
+
12
+ def succeeded?
13
+ status == "succeeded"
14
+ end
15
+
16
+ def result_value
17
+ Serializer.load(result)
18
+ end
19
+
20
+ def complete!(value)
21
+ update!(
22
+ status: "succeeded",
23
+ result: Serializer.dump(value),
24
+ completed_at: Time.current,
25
+ )
26
+ end
27
+
28
+ def metadata_hash
29
+ metadata.presence || {}
30
+ end
31
+
32
+ def live_snapshot
33
+ {
34
+ id: id,
35
+ workflow_run_id: workflow_run_id,
36
+ name: name,
37
+ status: status,
38
+ attempts: attempts,
39
+ result: result,
40
+ metadata: metadata,
41
+ started_at: started_at,
42
+ completed_at: completed_at,
43
+ created_at: created_at,
44
+ updated_at: updated_at,
45
+ }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class WorkflowWait < ApplicationRecord
5
+ include Live::Broadcastable
6
+
7
+ self.table_name = "durable_flow_workflow_waits"
8
+
9
+ belongs_to :workflow_run, class_name: "DurableFlow::WorkflowRun"
10
+ belongs_to :workflow_step, class_name: "DurableFlow::WorkflowStep"
11
+ belongs_to :workflow_event, class_name: "DurableFlow::WorkflowEvent", optional: true
12
+
13
+ scope :pending, -> { where(status: "pending") }
14
+
15
+ def match_value
16
+ Serializer.load(self.match)
17
+ end
18
+
19
+ def matches_event?(event)
20
+ event.name == event_name && event.matches_payload?(match_value)
21
+ end
22
+
23
+ def live_snapshot
24
+ {
25
+ id: id,
26
+ workflow_run_id: workflow_run_id,
27
+ workflow_step_id: workflow_step_id,
28
+ workflow_event_id: workflow_event_id,
29
+ event_name: event_name,
30
+ status: status,
31
+ match: self.match,
32
+ timeout_at: timeout_at,
33
+ created_at: created_at,
34
+ updated_at: updated_at,
35
+ }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "durable_flow.subscribe_to_events", after: :load_config_initializers do
6
+ ActiveSupport.on_load(:active_record) do
7
+ DurableFlow.subscribe_to_rails_events!
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ module Schema
5
+ module_function
6
+
7
+ def define(connection = ActiveRecord::Base.connection)
8
+ create_workflow_runs(connection)
9
+ create_workflow_steps(connection)
10
+ create_workflow_events(connection)
11
+ create_workflow_waits(connection)
12
+ create_workflow_logs(connection)
13
+ end
14
+
15
+ def create_workflow_runs(connection)
16
+ if connection.data_source_exists?(:durable_flow_workflow_runs)
17
+ ensure_workflow_run_lock_columns(connection)
18
+ return
19
+ end
20
+
21
+ connection.create_table :durable_flow_workflow_runs do |t|
22
+ t.string :run_id, null: false
23
+ t.string :job_id, null: false
24
+ t.string :workflow_class, null: false
25
+ t.string :status, null: false, default: "enqueued"
26
+ t.string :queue_name
27
+ t.integer :priority
28
+ t.json :arguments
29
+ t.json :serialized_job
30
+ t.json :last_error
31
+ t.datetime :started_at
32
+ t.datetime :interrupted_at
33
+ t.datetime :completed_at
34
+ t.datetime :failed_at
35
+ t.string :execution_locked_by
36
+ t.datetime :execution_locked_at
37
+ t.datetime :execution_lock_expires_at
38
+ t.timestamps
39
+ end
40
+
41
+ connection.add_index :durable_flow_workflow_runs, :run_id, unique: true
42
+ connection.add_index :durable_flow_workflow_runs, [ :workflow_class, :status ], name: "idx_durable_flow_runs_on_class_status"
43
+ connection.add_index :durable_flow_workflow_runs, :execution_lock_expires_at, name: "idx_durable_flow_runs_on_lock_expiry"
44
+ end
45
+
46
+ def ensure_workflow_run_lock_columns(connection)
47
+ unless connection.column_exists?(:durable_flow_workflow_runs, :execution_locked_by)
48
+ connection.add_column :durable_flow_workflow_runs, :execution_locked_by, :string
49
+ end
50
+
51
+ unless connection.column_exists?(:durable_flow_workflow_runs, :execution_locked_at)
52
+ connection.add_column :durable_flow_workflow_runs, :execution_locked_at, :datetime
53
+ end
54
+
55
+ unless connection.column_exists?(:durable_flow_workflow_runs, :execution_lock_expires_at)
56
+ connection.add_column :durable_flow_workflow_runs, :execution_lock_expires_at, :datetime
57
+ end
58
+
59
+ unless connection.index_exists?(:durable_flow_workflow_runs, :execution_lock_expires_at, name: "idx_durable_flow_runs_on_lock_expiry")
60
+ connection.add_index :durable_flow_workflow_runs, :execution_lock_expires_at, name: "idx_durable_flow_runs_on_lock_expiry"
61
+ end
62
+ end
63
+
64
+ def create_workflow_steps(connection)
65
+ return if connection.data_source_exists?(:durable_flow_workflow_steps)
66
+
67
+ connection.create_table :durable_flow_workflow_steps do |t|
68
+ t.references :workflow_run, null: false, index: false
69
+ t.string :name, null: false
70
+ t.string :status, null: false, default: "pending"
71
+ t.integer :attempts, null: false, default: 0
72
+ t.json :result
73
+ t.json :metadata
74
+ t.datetime :started_at
75
+ t.datetime :completed_at
76
+ t.timestamps
77
+ end
78
+
79
+ connection.add_index :durable_flow_workflow_steps,
80
+ [ :workflow_run_id, :name ],
81
+ unique: true,
82
+ name: "idx_durable_flow_steps_on_run_and_name"
83
+ end
84
+
85
+ def create_workflow_events(connection)
86
+ return if connection.data_source_exists?(:durable_flow_workflow_events)
87
+
88
+ connection.create_table :durable_flow_workflow_events do |t|
89
+ t.string :name, null: false
90
+ t.json :payload
91
+ t.json :tags
92
+ t.json :context
93
+ t.json :source_location
94
+ t.datetime :occurred_at, null: false
95
+ t.timestamps
96
+ end
97
+
98
+ connection.add_index :durable_flow_workflow_events, [ :name, :occurred_at ], name: "idx_durable_flow_events_on_name_time"
99
+ end
100
+
101
+ def create_workflow_waits(connection)
102
+ return if connection.data_source_exists?(:durable_flow_workflow_waits)
103
+
104
+ connection.create_table :durable_flow_workflow_waits do |t|
105
+ t.references :workflow_run, null: false, index: false
106
+ t.references :workflow_step, null: false, index: false
107
+ t.references :workflow_event, index: false
108
+ t.string :event_name, null: false
109
+ t.string :status, null: false, default: "pending"
110
+ t.json :match
111
+ t.datetime :timeout_at
112
+ t.timestamps
113
+ end
114
+
115
+ connection.add_index :durable_flow_workflow_waits,
116
+ [ :workflow_run_id, :workflow_step_id ],
117
+ unique: true,
118
+ name: "idx_durable_flow_waits_on_run_and_step"
119
+ connection.add_index :durable_flow_workflow_waits,
120
+ [ :event_name, :status ],
121
+ name: "idx_durable_flow_waits_on_event_status"
122
+ end
123
+
124
+ def create_workflow_logs(connection)
125
+ return if connection.data_source_exists?(:durable_flow_workflow_logs)
126
+
127
+ connection.create_table :durable_flow_workflow_logs do |t|
128
+ t.references :workflow_run, null: false, index: false
129
+ t.references :workflow_step, index: false
130
+ t.string :level, null: false
131
+ t.string :message, null: false
132
+ t.json :data
133
+ t.timestamps
134
+ end
135
+
136
+ connection.add_index :durable_flow_workflow_logs,
137
+ [ :workflow_run_id, :created_at ],
138
+ name: "idx_durable_flow_logs_on_run_time"
139
+ connection.add_index :durable_flow_workflow_logs,
140
+ [ :workflow_step_id, :created_at ],
141
+ name: "idx_durable_flow_logs_on_step_time"
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ module Serializer
5
+ module_function
6
+
7
+ def dump(value)
8
+ ActiveJob::Arguments.serialize([ value ]).first
9
+ end
10
+
11
+ def load(value)
12
+ ActiveJob::Arguments.deserialize([ value ]).first
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class StepProxy
5
+ def initialize(workflow)
6
+ @workflow = workflow
7
+ end
8
+
9
+ def sleep(name, duration = nil, **options)
10
+ @workflow.sleep_step(name, duration, until_time: options[:until] || options[:until_time])
11
+ end
12
+
13
+ def wait_for_event(name, event: nil, timeout: nil, match: {})
14
+ @workflow.wait_for_event_step(name, event_name: event || name, timeout: timeout, match: match)
15
+ end
16
+
17
+ def wait_for_workflow(name, workflow_or_run_id, timeout: nil)
18
+ run_id = workflow_or_run_id.respond_to?(:job_id) ? workflow_or_run_id.job_id : workflow_or_run_id.to_s
19
+ wait_for_event(name, event: DurableFlow::WORKFLOW_COMPLETED_EVENT, timeout: timeout, match: { run_id: run_id })
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/test_helper"
4
+ require "active_support/concern"
5
+ require "active_support/testing/time_helpers"
6
+ require "durable_flow"
7
+ require "time"
8
+
9
+ module DurableFlow
10
+ module TestHelper
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ include ActiveJob::TestHelper
15
+ include ActiveSupport::Testing::TimeHelpers
16
+ end
17
+
18
+ def clear_durable_flow!(clear_jobs: true, reset_live: true)
19
+ DurableFlow.reset_live_broadcasters! if reset_live
20
+ send(:clear_enqueued_jobs) if clear_jobs && respond_to?(:clear_enqueued_jobs, true)
21
+ send(:clear_performed_jobs) if clear_jobs && respond_to?(:clear_performed_jobs, true)
22
+
23
+ WorkflowWait.delete_all
24
+ WorkflowEvent.delete_all
25
+ WorkflowLog.delete_all
26
+ WorkflowStep.delete_all
27
+ WorkflowRun.delete_all
28
+ end
29
+
30
+ def durable_flow_run(run_id)
31
+ WorkflowRun.find_by!(run_id: run_id.to_s)
32
+ end
33
+
34
+ def durable_flow_run_for(workflow_class)
35
+ WorkflowRun.where(workflow_class: workflow_class_name(workflow_class)).order(:created_at, :id).last ||
36
+ flunk("Expected a DurableFlow run for #{workflow_class_name(workflow_class)}")
37
+ end
38
+
39
+ def durable_flow_timeline_for(workflow_or_run)
40
+ durable_flow_resolve_run(workflow_or_run).timeline
41
+ end
42
+
43
+ def durable_flow_step(workflow_or_run, name)
44
+ run = durable_flow_resolve_run(workflow_or_run)
45
+ run.workflow_steps.find_by!(name: name.to_s)
46
+ end
47
+
48
+ def perform_durable_flow_jobs(**options, &block)
49
+ raise "Include ActiveJob::TestHelper to perform DurableFlow jobs" unless respond_to?(:perform_enqueued_jobs)
50
+
51
+ perform_enqueued_jobs(**options, &block)
52
+ end
53
+
54
+ def notify_workflow_event(name, **payload)
55
+ payload.empty? ? DurableFlow.notify(name) : DurableFlow.notify(name, payload)
56
+ end
57
+
58
+ def resume_workflows_for(name, **payload)
59
+ notify_workflow_event(name, **payload)
60
+ perform_durable_flow_jobs(at: Time.current)
61
+ end
62
+
63
+ def assert_workflow_completed(workflow_or_run)
64
+ assert_workflow_status(workflow_or_run, "completed")
65
+ end
66
+
67
+ def assert_workflow_failed(workflow_or_run, error: nil)
68
+ run = assert_workflow_status(workflow_or_run, "failed")
69
+
70
+ if error
71
+ expected_class = error.respond_to?(:name) ? error.name : error.to_s
72
+ assert_equal expected_class, run.last_error&.fetch("class", nil)
73
+ end
74
+
75
+ run
76
+ end
77
+
78
+ def assert_workflow_sleeping(workflow_or_run, step: nil)
79
+ run = assert_workflow_status(workflow_or_run, "sleeping")
80
+ sleep_step = step ? durable_flow_step(run, step) : run.workflow_steps.find_by!(status: "sleeping")
81
+
82
+ assert_equal "sleeping", sleep_step.status
83
+ assert sleep_step.metadata_hash["wake_at"].present?, "Expected sleep step #{sleep_step.name.inspect} to store wake_at metadata"
84
+ sleep_step
85
+ end
86
+
87
+ def assert_workflow_waiting_for(workflow_or_run, event_name, match: nil)
88
+ run = assert_workflow_status(workflow_or_run, "waiting")
89
+ wait = run.workflow_waits.find_by!(event_name: event_name.to_s)
90
+
91
+ assert_equal "pending", wait.status
92
+ assert_equal match, wait.match_value if match
93
+ wait
94
+ end
95
+
96
+ def assert_step_succeeded(workflow_or_run, name)
97
+ step = durable_flow_step(workflow_or_run, name)
98
+
99
+ assert_equal "succeeded", step.status
100
+ step
101
+ end
102
+
103
+ def assert_step_result(workflow_or_run, name, expected)
104
+ step = assert_step_succeeded(workflow_or_run, name)
105
+
106
+ assert_equal expected, step.result_value
107
+ step
108
+ end
109
+
110
+ def assert_step_attempts(workflow_or_run, name, expected)
111
+ step = durable_flow_step(workflow_or_run, name)
112
+
113
+ assert_equal expected, step.attempts
114
+ step
115
+ end
116
+
117
+ def assert_workflow_log(workflow_or_run, level: nil, message: nil, data: nil)
118
+ run = durable_flow_resolve_run(workflow_or_run)
119
+ assert_log_in(run.workflow_logs.ordered, level: level, message: message, data: data)
120
+ end
121
+
122
+ def assert_step_log(workflow_or_run, step_name, level: nil, message: nil, data: nil)
123
+ step = durable_flow_step(workflow_or_run, step_name)
124
+ assert_log_in(step.workflow_logs.ordered, level: level, message: message, data: data)
125
+ end
126
+
127
+ def travel_to_next_workflow_wake(workflow_or_run = nil)
128
+ wake_at = next_workflow_wake_at(workflow_or_run)
129
+
130
+ assert wake_at, "Expected a sleeping DurableFlow step with wake_at metadata"
131
+ travel_to wake_at
132
+ wake_at
133
+ end
134
+
135
+ def next_workflow_wake_at(workflow_or_run = nil)
136
+ scope = WorkflowStep.where(status: "sleeping")
137
+ scope = scope.where(workflow_run: durable_flow_resolve_run(workflow_or_run)) if workflow_or_run
138
+
139
+ scope.filter_map { |step| parse_workflow_wake_at(step.metadata_hash["wake_at"]) }.min
140
+ end
141
+
142
+ def capture_durable_flow_changes
143
+ changes = []
144
+ subscriber = DurableFlow.on_change { |change| changes << change }
145
+
146
+ yield changes
147
+ changes
148
+ ensure
149
+ DurableFlow.unsubscribe_from_changes(subscriber) if subscriber
150
+ end
151
+
152
+ def assert_durable_flow_change(changes, type, **payload)
153
+ change = changes.find do |candidate|
154
+ candidate.type == type && payload.all? { |key, value| durable_flow_change_value(candidate, key) == value }
155
+ end
156
+
157
+ assert change, "Expected DurableFlow change #{type.inspect} with #{payload.inspect}, got #{changes.map(&:payload).inspect}"
158
+ change
159
+ end
160
+
161
+ private
162
+ def assert_workflow_status(workflow_or_run, status)
163
+ run = durable_flow_resolve_run(workflow_or_run).reload
164
+
165
+ assert_equal status, run.status
166
+ run
167
+ end
168
+
169
+ def assert_log_in(scope, level:, message:, data:)
170
+ logs = scope.to_a
171
+ log = logs.find do |candidate|
172
+ (level.nil? || candidate.level == level.to_s) &&
173
+ (message.nil? || durable_flow_message_matches?(candidate.message, message)) &&
174
+ (data.nil? || candidate.data_value == data)
175
+ end
176
+
177
+ assert log, "Expected workflow log with #{ { level: level, message: message, data: data }.compact.inspect }, got #{logs.map { |entry| [ entry.level, entry.message, entry.data_value ] }.inspect}"
178
+ log
179
+ end
180
+
181
+ def durable_flow_resolve_run(workflow_or_run)
182
+ case workflow_or_run
183
+ when WorkflowRun
184
+ workflow_or_run
185
+ when String
186
+ durable_flow_run(workflow_or_run)
187
+ else
188
+ durable_flow_run_for(workflow_or_run)
189
+ end
190
+ end
191
+
192
+ def durable_flow_message_matches?(actual, expected)
193
+ expected.is_a?(Regexp) ? expected.match?(actual) : actual == expected.to_s
194
+ end
195
+
196
+ def durable_flow_change_value(change, key)
197
+ if change.snapshot.key?(key)
198
+ change.snapshot[key]
199
+ elsif change.payload.key?(key)
200
+ change.payload[key]
201
+ elsif change.respond_to?(key)
202
+ change.public_send(key)
203
+ end
204
+ end
205
+
206
+ def parse_workflow_wake_at(value)
207
+ return if value.blank?
208
+ return value if value.is_a?(Time)
209
+
210
+ Time.iso8601(value.to_s)
211
+ end
212
+
213
+ def workflow_class_name(workflow_class)
214
+ workflow_class.respond_to?(:name) ? workflow_class.name : workflow_class.to_s
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ VERSION = "0.1.0"
5
+ end