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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +19 -0
- data/README.md +552 -0
- data/app/controllers/durable_flow/workflow_runs_controller.rb +71 -0
- data/app/views/durable_flow/workflow_runs/index.html.erb +74 -0
- data/app/views/durable_flow/workflow_runs/show.html.erb +180 -0
- data/app/views/layouts/durable_flow/application.html.erb +464 -0
- data/config/routes.rb +6 -0
- data/lib/durable_flow/dispatcher.rb +26 -0
- data/lib/durable_flow/engine.rb +7 -0
- data/lib/durable_flow/errors.rb +38 -0
- data/lib/durable_flow/event_subscriber.rb +31 -0
- data/lib/durable_flow/live.rb +67 -0
- data/lib/durable_flow/models/application_record.rb +7 -0
- data/lib/durable_flow/models/workflow_event.rb +55 -0
- data/lib/durable_flow/models/workflow_log.rb +36 -0
- data/lib/durable_flow/models/workflow_run.rb +101 -0
- data/lib/durable_flow/models/workflow_step.rb +48 -0
- data/lib/durable_flow/models/workflow_wait.rb +38 -0
- data/lib/durable_flow/railtie.rb +11 -0
- data/lib/durable_flow/schema.rb +144 -0
- data/lib/durable_flow/serializer.rb +15 -0
- data/lib/durable_flow/step_proxy.rb +22 -0
- data/lib/durable_flow/test_helper.rb +217 -0
- data/lib/durable_flow/version.rb +5 -0
- data/lib/durable_flow/workflow.rb +361 -0
- data/lib/durable_flow/workflow_logger.rb +42 -0
- data/lib/durable_flow/workflow_timeline.rb +188 -0
- data/lib/durable_flow.rb +146 -0
- data/lib/generators/durable_flow/install_generator.rb +22 -0
- data/lib/generators/durable_flow/templates/create_durable_flow_tables.rb +93 -0
- metadata +216 -0
|
@@ -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
|