durable_flow 0.1.0 → 0.2.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 +4 -4
- data/README.md +92 -17
- data/app/controllers/durable_flow/workflow_runs_controller.rb +136 -0
- data/app/views/durable_flow/workflow_runs/definition.html.erb +145 -0
- data/app/views/durable_flow/workflow_runs/index.html.erb +4 -0
- data/app/views/durable_flow/workflow_runs/show.html.erb +4 -1
- data/app/views/layouts/durable_flow/application.html.erb +88 -1
- data/config/routes.rb +5 -1
- data/lib/durable_flow/child_workflow_builder.rb +26 -0
- data/lib/durable_flow/definition_analyzer.rb +342 -0
- data/lib/durable_flow/definition_graph.rb +99 -0
- data/lib/durable_flow/errors.rb +18 -0
- data/lib/durable_flow/models/workflow_step.rb +26 -0
- data/lib/durable_flow/step_proxy.rb +52 -3
- data/lib/durable_flow/test_helper.rb +40 -0
- data/lib/durable_flow/version.rb +1 -1
- data/lib/durable_flow/workflow.rb +328 -25
- data/lib/durable_flow.rb +6 -0
- metadata +19 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableFlow
|
|
4
|
+
class ChildWorkflowBuilder
|
|
5
|
+
Request = Data.define(:workflow_key, :workflow_class, :workflow_args, :workflow_kwargs) do
|
|
6
|
+
def perform_later
|
|
7
|
+
workflow_class.perform_later(*workflow_args, **workflow_kwargs)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :requests
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@requests = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def workflow(workflow_class, *args, key:, **kwargs)
|
|
18
|
+
requests << Request.new(
|
|
19
|
+
workflow_key: key.to_s,
|
|
20
|
+
workflow_class: workflow_class,
|
|
21
|
+
workflow_args: args,
|
|
22
|
+
workflow_kwargs: kwargs
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module DurableFlow
|
|
6
|
+
class DefinitionAnalyzer
|
|
7
|
+
Path = Data.define(:node_id, :condition)
|
|
8
|
+
|
|
9
|
+
STEP_PROXY_CALLS = %i[
|
|
10
|
+
run
|
|
11
|
+
sleep
|
|
12
|
+
sleep_until
|
|
13
|
+
wait_for_event
|
|
14
|
+
invoke
|
|
15
|
+
invoke_each
|
|
16
|
+
call
|
|
17
|
+
call_each
|
|
18
|
+
child_workflow
|
|
19
|
+
child_workflows
|
|
20
|
+
each_child_workflow
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
WORKFLOW_CALLS = %i[invoke call child_workflow].freeze
|
|
24
|
+
FANOUT_CALLS = %i[invoke_each call_each child_workflows each_child_workflow].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def call(workflow_class)
|
|
28
|
+
new(workflow_class).call
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(workflow_class)
|
|
33
|
+
@workflow_class = workflow_class
|
|
34
|
+
@source_file, @perform_line = workflow_class.instance_method(:perform).source_location
|
|
35
|
+
@graph = DefinitionGraph.new(workflow_class: workflow_class.name, source_file: source_file)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
result = Prism.parse_file(source_file)
|
|
40
|
+
result.errors.each do |error|
|
|
41
|
+
graph.warnings << "Parse warning at #{source_file}:#{error.location.start_line}: #{error.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
perform_node = find_perform_node(result.value)
|
|
45
|
+
unless perform_node
|
|
46
|
+
graph.warnings << "Could not locate #{workflow_class.name}#perform at #{source_file}:#{perform_line}"
|
|
47
|
+
return graph
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
body = perform_node.body&.body || []
|
|
51
|
+
analyze_statements(body, [ Path.new(nil, nil) ])
|
|
52
|
+
graph
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
attr_reader :workflow_class, :source_file, :perform_line, :graph
|
|
57
|
+
|
|
58
|
+
def analyze_statements(statements, incoming)
|
|
59
|
+
statements.reduce(incoming) do |paths, statement|
|
|
60
|
+
analyze_statement(statement, paths)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def analyze_statement(statement, incoming)
|
|
65
|
+
case statement
|
|
66
|
+
when Prism::IfNode
|
|
67
|
+
analyze_if(statement, incoming)
|
|
68
|
+
when Prism::ReturnNode
|
|
69
|
+
call_node = durable_call_in(statement)
|
|
70
|
+
call_node ? add_call_node(call_node, incoming).then { [] } : []
|
|
71
|
+
else
|
|
72
|
+
if loop_like?(statement) && durable_call_nested_in?(statement)
|
|
73
|
+
warn_about_hidden_durable_calls(statement)
|
|
74
|
+
return incoming
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if (call_node = durable_call_in(statement))
|
|
78
|
+
add_call_node(call_node, incoming)
|
|
79
|
+
else
|
|
80
|
+
warn_about_hidden_durable_calls(statement)
|
|
81
|
+
incoming
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def analyze_if(if_node, incoming)
|
|
87
|
+
condition = expression_source(if_node.predicate)
|
|
88
|
+
true_paths = incoming.map { |path| path.with(condition: combine_conditions(path.condition, condition)) }
|
|
89
|
+
then_exits = if if_node.statements&.body&.any?
|
|
90
|
+
analyze_statements(if_node.statements.body, true_paths)
|
|
91
|
+
else
|
|
92
|
+
true_paths
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
false_condition = negate_condition(condition)
|
|
96
|
+
false_paths = incoming.map { |path| path.with(condition: combine_conditions(path.condition, false_condition)) }
|
|
97
|
+
else_exits = case if_node.subsequent
|
|
98
|
+
when Prism::ElseNode
|
|
99
|
+
if if_node.subsequent.statements&.body&.any?
|
|
100
|
+
analyze_statements(if_node.subsequent.statements.body, false_paths)
|
|
101
|
+
else
|
|
102
|
+
false_paths
|
|
103
|
+
end
|
|
104
|
+
when Prism::IfNode
|
|
105
|
+
analyze_if(if_node.subsequent, false_paths)
|
|
106
|
+
else
|
|
107
|
+
false_paths
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
then_exits + else_exits
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def add_call_node(call_node, incoming)
|
|
114
|
+
definition = call_definition(call_node)
|
|
115
|
+
return incoming unless definition
|
|
116
|
+
|
|
117
|
+
node = graph.add_node(**definition)
|
|
118
|
+
incoming.each do |path|
|
|
119
|
+
graph.add_edge(from: path.node_id, to: node.id, condition: path.condition)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
[ Path.new(node.id, nil) ]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def call_definition(call_node)
|
|
126
|
+
arguments = call_arguments(call_node)
|
|
127
|
+
keywords = keyword_arguments(arguments)
|
|
128
|
+
positional = positional_arguments(arguments)
|
|
129
|
+
primitive_name = primitive_name(call_node)
|
|
130
|
+
|
|
131
|
+
case primitive_name
|
|
132
|
+
when :step
|
|
133
|
+
durable_step_definition(call_node, positional, keywords)
|
|
134
|
+
when :run
|
|
135
|
+
durable_step_definition(call_node, positional, keywords)
|
|
136
|
+
when :sleep, :sleep_until
|
|
137
|
+
named_definition(call_node, positional, keywords, type: "sleep")
|
|
138
|
+
when :wait_for_event
|
|
139
|
+
named_definition(call_node, positional, keywords, type: "wait_event").tap do |definition|
|
|
140
|
+
definition[:metadata]["event"] = expression_source(keywords["event"]) if keywords["event"]
|
|
141
|
+
definition[:metadata]["match"] = expression_source(keywords["match"]) if keywords["match"]
|
|
142
|
+
end
|
|
143
|
+
when *WORKFLOW_CALLS
|
|
144
|
+
workflow_call_definition(call_node, positional, keywords)
|
|
145
|
+
when *FANOUT_CALLS
|
|
146
|
+
fanout_definition(call_node, positional, keywords)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def durable_step_definition(call_node, positional, keywords)
|
|
151
|
+
named_definition(call_node, positional, keywords, type: "step")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def workflow_call_definition(call_node, positional, keywords)
|
|
155
|
+
target = if primitive_name(call_node) == :call
|
|
156
|
+
expression_source(positional.first)
|
|
157
|
+
else
|
|
158
|
+
expression_source(positional[1])
|
|
159
|
+
end
|
|
160
|
+
name_node = keywords["as"] || positional.first
|
|
161
|
+
|
|
162
|
+
named_definition(call_node, [ name_node ].compact, keywords, type: "workflow_call").tap do |definition|
|
|
163
|
+
definition[:target_workflow_class] = target
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def fanout_definition(call_node, positional, keywords)
|
|
168
|
+
target = nil
|
|
169
|
+
fanout_source = nil
|
|
170
|
+
key_source = expression_source(keywords["key"])
|
|
171
|
+
|
|
172
|
+
if primitive_name(call_node) == :call_each
|
|
173
|
+
target = expression_source(positional.first)
|
|
174
|
+
fanout_source = expression_source(keywords["from"] || positional[1])
|
|
175
|
+
else
|
|
176
|
+
fanout_source = expression_source(positional[1])
|
|
177
|
+
workflow_request = workflow_request_call_in(call_node.block)
|
|
178
|
+
target = expression_source(positional.first) if primitive_name(call_node) == :each_child_workflow
|
|
179
|
+
target ||= expression_source(workflow_request&.arguments&.arguments&.first)
|
|
180
|
+
request_keywords = keyword_arguments(call_arguments(workflow_request))
|
|
181
|
+
key_source ||= expression_source(request_keywords["key"])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
name_node = keywords["as"] || positional.first
|
|
185
|
+
named_definition(call_node, [ name_node ].compact, keywords, type: "fanout").tap do |definition|
|
|
186
|
+
definition[:target_workflow_class] = target
|
|
187
|
+
definition[:metadata]["fanout_source"] = fanout_source if fanout_source.present?
|
|
188
|
+
definition[:metadata]["key"] = key_source if key_source.present?
|
|
189
|
+
if target.blank?
|
|
190
|
+
graph.warnings << "Could not resolve fan-out target workflow at #{source_file}:#{call_node.location.start_line}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def named_definition(call_node, positional, keywords, type:)
|
|
196
|
+
name, dynamic_name = durable_name(positional.first)
|
|
197
|
+
metadata = {
|
|
198
|
+
"expression" => expression_source(call_node),
|
|
199
|
+
"dynamic_name" => dynamic_name,
|
|
200
|
+
"timeout" => expression_source(keywords["timeout"]),
|
|
201
|
+
"start" => expression_source(keywords["start"]),
|
|
202
|
+
"isolated" => expression_source(keywords["isolated"])
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if dynamic_name
|
|
206
|
+
graph.warnings << "Could not statically resolve durable step name #{expression_source(positional.first).inspect} at #{source_file}:#{call_node.location.start_line}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
{
|
|
210
|
+
type: type,
|
|
211
|
+
name: name,
|
|
212
|
+
target_workflow_class: nil,
|
|
213
|
+
source_line: call_node.location.start_line,
|
|
214
|
+
metadata: metadata
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def find_perform_node(node)
|
|
219
|
+
return if node.nil?
|
|
220
|
+
return node if node.is_a?(Prism::DefNode) && node.name == :perform && node.location.start_line == perform_line
|
|
221
|
+
|
|
222
|
+
node.child_nodes.compact.each do |child|
|
|
223
|
+
found = find_perform_node(child)
|
|
224
|
+
return found if found
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def durable_call_in(node)
|
|
231
|
+
return if node.nil? || node.is_a?(Prism::IfNode)
|
|
232
|
+
return node if durable_call?(node)
|
|
233
|
+
|
|
234
|
+
node.child_nodes.compact.each do |child|
|
|
235
|
+
found = durable_call_in(child)
|
|
236
|
+
return found if found
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def warn_about_hidden_durable_calls(statement)
|
|
243
|
+
return unless loop_like?(statement) && durable_call_nested_in?(statement)
|
|
244
|
+
|
|
245
|
+
graph.warnings << "Durable step inside dynamic loop at #{source_file}:#{statement.location.start_line}; use step.call_each for graphable fan-out"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def durable_call_nested_in?(node)
|
|
249
|
+
node.child_nodes.compact.any? do |child|
|
|
250
|
+
durable_call?(child) || durable_call_nested_in?(child)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def loop_like?(node)
|
|
255
|
+
node.is_a?(Prism::ForNode) ||
|
|
256
|
+
node.is_a?(Prism::WhileNode) ||
|
|
257
|
+
node.is_a?(Prism::UntilNode) ||
|
|
258
|
+
(node.is_a?(Prism::CallNode) && node.block && %i[each map flat_map].include?(node.name))
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def durable_call?(node)
|
|
262
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
263
|
+
return true if node.name == :step && node.receiver.nil?
|
|
264
|
+
|
|
265
|
+
step_receiver?(node.receiver) && STEP_PROXY_CALLS.include?(node.name)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def step_receiver?(receiver)
|
|
269
|
+
receiver.is_a?(Prism::CallNode) && receiver.name == :step && receiver.receiver.nil?
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def primitive_name(call_node)
|
|
273
|
+
call_node.name == :step && call_node.receiver.nil? ? :step : call_node.name
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def workflow_request_call_in(node)
|
|
277
|
+
return if node.nil?
|
|
278
|
+
|
|
279
|
+
if node.is_a?(Prism::CallNode) && node.name == :workflow
|
|
280
|
+
return node
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
node.child_nodes.compact.each do |child|
|
|
284
|
+
found = workflow_request_call_in(child)
|
|
285
|
+
return found if found
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def call_arguments(call_node)
|
|
292
|
+
call_node&.arguments&.arguments || []
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def positional_arguments(arguments)
|
|
296
|
+
arguments.reject { |argument| argument.is_a?(Prism::KeywordHashNode) }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def keyword_arguments(arguments)
|
|
300
|
+
keyword_hash = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
|
|
301
|
+
return {} unless keyword_hash
|
|
302
|
+
|
|
303
|
+
keyword_hash.elements.each_with_object({}) do |element, keywords|
|
|
304
|
+
next unless element.respond_to?(:key) && element.respond_to?(:value)
|
|
305
|
+
|
|
306
|
+
keywords[symbol_value(element.key)] = element.value
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def durable_name(node)
|
|
311
|
+
case node
|
|
312
|
+
when Prism::SymbolNode, Prism::StringNode
|
|
313
|
+
[ node.unescaped.to_s, false ]
|
|
314
|
+
when nil
|
|
315
|
+
[ "unknown", true ]
|
|
316
|
+
else
|
|
317
|
+
[ expression_source(node), true ]
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def symbol_value(node)
|
|
322
|
+
return unless node.respond_to?(:unescaped)
|
|
323
|
+
|
|
324
|
+
node.unescaped.to_s
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def expression_source(node)
|
|
328
|
+
node&.location&.slice
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def combine_conditions(left, right)
|
|
332
|
+
return right if left.blank?
|
|
333
|
+
return left if right.blank?
|
|
334
|
+
|
|
335
|
+
"(#{left}) && (#{right})"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def negate_condition(condition)
|
|
339
|
+
"!(#{condition})"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableFlow
|
|
4
|
+
DefinitionNode = Data.define(
|
|
5
|
+
:id,
|
|
6
|
+
:type,
|
|
7
|
+
:name,
|
|
8
|
+
:workflow_class,
|
|
9
|
+
:target_workflow_class,
|
|
10
|
+
:source_file,
|
|
11
|
+
:source_line,
|
|
12
|
+
:metadata
|
|
13
|
+
) do
|
|
14
|
+
def to_h
|
|
15
|
+
{
|
|
16
|
+
id: id,
|
|
17
|
+
type: type,
|
|
18
|
+
name: name,
|
|
19
|
+
workflow_class: workflow_class,
|
|
20
|
+
target_workflow_class: target_workflow_class,
|
|
21
|
+
source_file: source_file,
|
|
22
|
+
source_line: source_line,
|
|
23
|
+
metadata: metadata || {}
|
|
24
|
+
}.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
alias as_json to_h
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
DefinitionEdge = Data.define(:from, :to, :type, :condition, :metadata) do
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
from: from,
|
|
34
|
+
to: to,
|
|
35
|
+
type: type,
|
|
36
|
+
condition: condition,
|
|
37
|
+
metadata: metadata || {}
|
|
38
|
+
}.compact
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
alias as_json to_h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class DefinitionGraph
|
|
45
|
+
attr_reader :workflow_class, :source_file, :nodes, :edges, :warnings
|
|
46
|
+
|
|
47
|
+
def initialize(workflow_class:, source_file:)
|
|
48
|
+
@workflow_class = workflow_class.to_s
|
|
49
|
+
@source_file = source_file
|
|
50
|
+
@nodes = []
|
|
51
|
+
@edges = []
|
|
52
|
+
@warnings = []
|
|
53
|
+
@node_ids = Hash.new(0)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_node(type:, name:, target_workflow_class:, source_line:, metadata: {})
|
|
57
|
+
node_name = name.to_s.presence || "unknown"
|
|
58
|
+
@node_ids[node_name] += 1
|
|
59
|
+
node_id = @node_ids[node_name] == 1 ? node_name : "#{node_name}##{@node_ids[node_name]}"
|
|
60
|
+
|
|
61
|
+
warnings << "Duplicate durable step name #{node_name.inspect} at #{source_file}:#{source_line}" if @node_ids[node_name] > 1
|
|
62
|
+
|
|
63
|
+
DefinitionNode.new(
|
|
64
|
+
id: node_id,
|
|
65
|
+
type: type.to_s,
|
|
66
|
+
name: node_name,
|
|
67
|
+
workflow_class: workflow_class,
|
|
68
|
+
target_workflow_class: target_workflow_class,
|
|
69
|
+
source_file: source_file,
|
|
70
|
+
source_line: source_line,
|
|
71
|
+
metadata: metadata.compact
|
|
72
|
+
).tap { |node| nodes << node }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def add_edge(from:, to:, type: "sequence", condition: nil, metadata: {})
|
|
76
|
+
return if from.blank? || to.blank?
|
|
77
|
+
|
|
78
|
+
edges << DefinitionEdge.new(
|
|
79
|
+
from: from,
|
|
80
|
+
to: to,
|
|
81
|
+
type: type.to_s,
|
|
82
|
+
condition: condition.presence,
|
|
83
|
+
metadata: metadata.compact
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_h
|
|
88
|
+
{
|
|
89
|
+
workflow_class: workflow_class,
|
|
90
|
+
source_file: source_file,
|
|
91
|
+
nodes: nodes.map(&:to_h),
|
|
92
|
+
edges: edges.map(&:to_h),
|
|
93
|
+
warnings: warnings
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
alias as_json to_h
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/durable_flow/errors.rb
CHANGED
|
@@ -5,6 +5,24 @@ module DurableFlow
|
|
|
5
5
|
|
|
6
6
|
class MissingStepResultError < Error; end
|
|
7
7
|
|
|
8
|
+
class ChildWorkflowFailedError < Error
|
|
9
|
+
attr_reader :run_id, :workflow_class, :error_class, :error_message
|
|
10
|
+
|
|
11
|
+
def initialize(run_id:, workflow_class:, error_class: nil, error_message: nil)
|
|
12
|
+
@run_id = run_id
|
|
13
|
+
@workflow_class = workflow_class
|
|
14
|
+
@error_class = error_class
|
|
15
|
+
@error_message = error_message
|
|
16
|
+
|
|
17
|
+
details = [ workflow_class, run_id ].compact.join(" ")
|
|
18
|
+
message = "Child workflow #{details} failed"
|
|
19
|
+
message = "#{message}: #{error_class}" if error_class.present?
|
|
20
|
+
message = "#{message} - #{error_message}" if error_message.present?
|
|
21
|
+
|
|
22
|
+
super(message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
8
26
|
class WaitTimeoutError < Error
|
|
9
27
|
attr_reader :event_name, :step_name
|
|
10
28
|
|
|
@@ -25,6 +25,23 @@ module DurableFlow
|
|
|
25
25
|
)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def fail!(error)
|
|
29
|
+
update!(
|
|
30
|
+
status: "failed",
|
|
31
|
+
metadata: metadata_hash.merge("last_error" => error_payload(error)),
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def retry!(error, retry_at: nil)
|
|
36
|
+
metadata = metadata_hash.merge("last_error" => error_payload(error))
|
|
37
|
+
metadata["retry_at"] = retry_at.utc.iso8601(9) if retry_at
|
|
38
|
+
|
|
39
|
+
update!(
|
|
40
|
+
status: "retrying",
|
|
41
|
+
metadata: metadata,
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
28
45
|
def metadata_hash
|
|
29
46
|
metadata.presence || {}
|
|
30
47
|
end
|
|
@@ -44,5 +61,14 @@ module DurableFlow
|
|
|
44
61
|
updated_at: updated_at,
|
|
45
62
|
}
|
|
46
63
|
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
def error_payload(error)
|
|
67
|
+
{
|
|
68
|
+
"class" => error.class.name,
|
|
69
|
+
"message" => error.message,
|
|
70
|
+
"backtrace" => Array(error.backtrace).first(10),
|
|
71
|
+
}
|
|
72
|
+
end
|
|
47
73
|
end
|
|
48
74
|
end
|
|
@@ -6,17 +6,66 @@ module DurableFlow
|
|
|
6
6
|
@workflow = workflow
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
def run(name, start: nil, isolated: false, &block)
|
|
10
|
+
@workflow.step(name, start: start, isolated: isolated, &block)
|
|
11
|
+
end
|
|
12
|
+
|
|
9
13
|
def sleep(name, duration = nil, **options)
|
|
10
14
|
@workflow.sleep_step(name, duration, until_time: options[:until] || options[:until_time])
|
|
11
15
|
end
|
|
12
16
|
|
|
13
|
-
def
|
|
14
|
-
|
|
17
|
+
def sleep_until(name, time)
|
|
18
|
+
sleep(name, until: time)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def wait_for_event(name, event: nil, timeout: nil, match: {}, allow_past_events: false)
|
|
22
|
+
@workflow.wait_for_event_step(
|
|
23
|
+
name,
|
|
24
|
+
event_name: event || name,
|
|
25
|
+
timeout: timeout,
|
|
26
|
+
match: match,
|
|
27
|
+
allow_past_events: allow_past_events,
|
|
28
|
+
)
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
def wait_for_workflow(name, workflow_or_run_id, timeout: nil)
|
|
18
32
|
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(
|
|
33
|
+
wait_for_event(
|
|
34
|
+
name,
|
|
35
|
+
event: DurableFlow::WORKFLOW_COMPLETED_EVENT,
|
|
36
|
+
timeout: timeout,
|
|
37
|
+
match: { run_id: run_id },
|
|
38
|
+
allow_past_events: true,
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def child_workflow(name, workflow_class = nil, *args, timeout: nil, on_failure: :raise, **kwargs, &block)
|
|
43
|
+
@workflow.child_workflow(name, workflow_class, *args, timeout: timeout, on_failure: on_failure, **kwargs, &block)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def invoke(name, workflow_class = nil, *args, timeout: nil, on_failure: :raise, **kwargs, &block)
|
|
47
|
+
@workflow.invoke_workflow(name, workflow_class, *args, timeout: timeout, on_failure: on_failure, **kwargs, &block)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def child_workflows(name, collection = nil, key: nil, timeout: nil, concurrency: nil, on_failure: :raise, &block)
|
|
51
|
+
@workflow.child_workflows(name, collection, key: key, timeout: timeout, concurrency: concurrency, on_failure: on_failure, &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def invoke_each(name, collection, timeout: nil, concurrency: nil, on_failure: :raise, &block)
|
|
55
|
+
@workflow.invoke_workflows(name, collection, timeout: timeout, concurrency: concurrency, on_failure: on_failure, &block)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def each_child_workflow(name, collection, key:, timeout: nil, on_failure: :raise, &block)
|
|
59
|
+
@workflow.each_child_workflow(name, collection, key: key, timeout: timeout, on_failure: on_failure, &block)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def workflow(workflow_class, *args, key:, **kwargs)
|
|
63
|
+
ChildWorkflowBuilder::Request.new(
|
|
64
|
+
workflow_key: key.to_s,
|
|
65
|
+
workflow_class: workflow_class,
|
|
66
|
+
workflow_args: args,
|
|
67
|
+
workflow_kwargs: kwargs
|
|
68
|
+
)
|
|
20
69
|
end
|
|
21
70
|
end
|
|
22
71
|
end
|
|
@@ -51,6 +51,26 @@ module DurableFlow
|
|
|
51
51
|
perform_enqueued_jobs(**options, &block)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
def perform_durable_flow_until_idle(at: Time.current, limit: 100, **options)
|
|
55
|
+
raise "Include ActiveJob::TestHelper to perform DurableFlow jobs" unless respond_to?(:perform_enqueued_jobs)
|
|
56
|
+
|
|
57
|
+
performed = 0
|
|
58
|
+
|
|
59
|
+
limit.times do
|
|
60
|
+
break unless durable_flow_performable_job_enqueued?(at: at)
|
|
61
|
+
|
|
62
|
+
before = respond_to?(:performed_jobs) ? performed_jobs.size : 0
|
|
63
|
+
perform_durable_flow_jobs(**options.merge(at: at))
|
|
64
|
+
performed += performed_jobs.size - before if respond_to?(:performed_jobs)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if durable_flow_performable_job_enqueued?(at: at)
|
|
68
|
+
raise "DurableFlow jobs did not become idle after #{limit} drain attempts"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
performed
|
|
72
|
+
end
|
|
73
|
+
|
|
54
74
|
def notify_workflow_event(name, **payload)
|
|
55
75
|
payload.empty? ? DurableFlow.notify(name) : DurableFlow.notify(name, payload)
|
|
56
76
|
end
|
|
@@ -93,6 +113,17 @@ module DurableFlow
|
|
|
93
113
|
wait
|
|
94
114
|
end
|
|
95
115
|
|
|
116
|
+
def assert_workflow_waiting_for_workflow(workflow_or_run, run_id, step: nil)
|
|
117
|
+
wait = assert_workflow_waiting_for(
|
|
118
|
+
workflow_or_run,
|
|
119
|
+
DurableFlow::WORKFLOW_COMPLETED_EVENT,
|
|
120
|
+
match: { run_id: run_id.to_s },
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
assert_equal step.to_s, wait.workflow_step.name if step
|
|
124
|
+
wait
|
|
125
|
+
end
|
|
126
|
+
|
|
96
127
|
def assert_step_succeeded(workflow_or_run, name)
|
|
97
128
|
step = durable_flow_step(workflow_or_run, name)
|
|
98
129
|
|
|
@@ -210,6 +241,15 @@ module DurableFlow
|
|
|
210
241
|
Time.iso8601(value.to_s)
|
|
211
242
|
end
|
|
212
243
|
|
|
244
|
+
def durable_flow_performable_job_enqueued?(at:)
|
|
245
|
+
return false unless respond_to?(:enqueued_jobs)
|
|
246
|
+
|
|
247
|
+
enqueued_jobs.any? do |payload|
|
|
248
|
+
scheduled_at = payload[:at] || payload["at"]
|
|
249
|
+
scheduled_at.blank? || scheduled_at.to_f <= at.to_f
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
213
253
|
def workflow_class_name(workflow_class)
|
|
214
254
|
workflow_class.respond_to?(:name) ? workflow_class.name : workflow_class.to_s
|
|
215
255
|
end
|
data/lib/durable_flow/version.rb
CHANGED