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.
@@ -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
@@ -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 wait_for_event(name, event: nil, timeout: nil, match: {})
14
- @workflow.wait_for_event_step(name, event_name: event || name, timeout: timeout, match: match)
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(name, event: DurableFlow::WORKFLOW_COMPLETED_EVENT, timeout: timeout, match: { run_id: run_id })
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DurableFlow
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end