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.
@@ -10,6 +10,46 @@ module DurableFlow
10
10
 
11
11
  attr_reader :workflow_run
12
12
 
13
+ class << self
14
+ def retry_on(*exceptions, **options, &block)
15
+ return super(*exceptions, **options) unless block
16
+
17
+ super(*exceptions, **options) do |job, error|
18
+ begin
19
+ block.call(job, error)
20
+ ensure
21
+ job.send(:fail_workflow_after_unhandled_error!, error) if job.is_a?(DurableFlow::Workflow)
22
+ end
23
+ end
24
+ end
25
+
26
+ def discard_on(*exceptions, **options, &block)
27
+ super(*exceptions, **options) do |job, error|
28
+ begin
29
+ block.call(job, error) if block
30
+ ensure
31
+ job.send(:fail_workflow_after_unhandled_error!, error) if job.is_a?(DurableFlow::Workflow)
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def perform_now
38
+ super
39
+ rescue Exception => error
40
+ fail_workflow_after_unhandled_error!(error)
41
+ raise
42
+ end
43
+
44
+ def retry_job(options = {})
45
+ if options[:error]
46
+ persist_retrying_workflow!(options[:error])
47
+ failed_workflow_step&.retry!(options[:error], retry_at: retry_at_from(options))
48
+ end
49
+
50
+ super
51
+ end
52
+
13
53
  def checkpoint!
14
54
  refresh_execution_lock!
15
55
  interrupt!(reason: :stopping) if queue_adapter.respond_to?(:stopping?) && queue_adapter.stopping?
@@ -25,7 +65,7 @@ module DurableFlow
25
65
  durable_step(name) do
26
66
  step_record = current_workflow_step
27
67
  metadata = step_record.metadata_hash
28
- wake_at = parse_time(metadata["wake_at"]) || time_from(duration, explicit_time: until_time)
68
+ wake_at = durable_flow_parse_time(metadata["wake_at"]) || durable_flow_time_from(duration, explicit_time: until_time)
29
69
 
30
70
  raise ArgumentError, "Provide a duration or until: time for sleep step #{name.inspect}" unless wake_at
31
71
 
@@ -39,14 +79,15 @@ module DurableFlow
39
79
  end
40
80
  end
41
81
 
42
- def wait_for_event_step(name, event_name:, timeout:, match:)
82
+ def wait_for_event_step(name, event_name:, timeout:, match:, allow_past_events: false, &block)
43
83
  durable_step(name) do
44
84
  step_record = current_workflow_step
45
85
  wait = find_or_initialize_wait(step_record, event_name: event_name, timeout: timeout, match: match)
46
86
 
47
- if (event = matched_event_for(wait))
87
+ if (event = matched_event_for(wait, allow_past_events: allow_past_events))
48
88
  wait.update!(status: "matched", workflow_event: event)
49
- event.payload_value
89
+ payload = event.payload_value
90
+ block ? block.call(payload) : payload
50
91
  elsif wait.timeout_at && Time.current >= wait.timeout_at
51
92
  wait.update!(status: "timed_out")
52
93
  step_record.update!(status: "failed", metadata: step_record.metadata_hash.merge("timeout_at" => wait.timeout_at.utc.iso8601(9)))
@@ -73,12 +114,83 @@ module DurableFlow
73
114
  StepProxy.new(self).wait_for_workflow(name, workflow_or_run_id, timeout: timeout)
74
115
  end
75
116
 
117
+ def child_workflow(name, workflow_class = nil, *args, timeout: nil, on_failure: :raise, **kwargs, &block)
118
+ validate_child_workflow_failure_policy!(on_failure)
119
+
120
+ base_name = name.to_s
121
+ child = start_child_workflow_step("#{base_name}_start", workflow_class, *args, **kwargs, &block)
122
+
123
+ wait_for_child_workflow("#{base_name}_wait", child, timeout: timeout, on_failure: on_failure)
124
+ end
125
+
126
+ def invoke_workflow(name, workflow_class = nil, *args, timeout: nil, on_failure: :raise, **kwargs, &block)
127
+ child_workflow(name, workflow_class, *args, timeout: timeout, on_failure: on_failure, **kwargs, &block)
128
+ end
129
+
130
+ def invoke_workflows(name, collection, timeout: nil, concurrency: nil, on_failure: :raise, &block)
131
+ validate_child_workflow_failure_policy!(on_failure)
132
+ raise ArgumentError, "Provide a block that returns workflow requests" unless block
133
+
134
+ requests = collection.map do |item|
135
+ request = block.call(item)
136
+ unless request.respond_to?(:workflow_key)
137
+ raise ArgumentError, "invoke_each blocks must return a workflow request with a stable workflow_key"
138
+ end
139
+
140
+ request
141
+ end
142
+
143
+ child_workflows(name, requests, timeout: timeout, concurrency: concurrency, on_failure: on_failure)
144
+ end
145
+
146
+ def child_workflows(name, collection = nil, key: nil, timeout: nil, concurrency: nil, on_failure: :raise, &block)
147
+ validate_child_workflow_failure_policy!(on_failure)
148
+
149
+ if collection.nil?
150
+ raise ArgumentError, "Provide a child workflow collection or builder block" unless block
151
+
152
+ builder = ChildWorkflowBuilder.new
153
+ block.call(builder)
154
+ collection = builder.requests
155
+ block = nil
156
+ key ||= :workflow_key
157
+ end
158
+
159
+ batches = child_workflow_batches(collection, concurrency)
160
+
161
+ batches.flat_map do |batch|
162
+ children = batch.map do |item|
163
+ item_key = child_workflow_item_key(item, key)
164
+ child = start_child_workflow_step("#{name}_#{item_key}_start") do
165
+ block ? block.call(item) : start_child_workflow_request(item)
166
+ end
167
+
168
+ child.merge("key" => item_key)
169
+ end
170
+
171
+ children.map do |child|
172
+ completion = wait_for_child_workflow("#{name}_#{child.fetch("key")}_wait", child, timeout: timeout, on_failure: on_failure)
173
+ completion.to_h.with_indifferent_access.merge(
174
+ "key" => child.fetch("key"),
175
+ "run_id" => child.fetch("run_id"),
176
+ "workflow_class" => child["workflow_class"] || completion_value(completion, :workflow_class),
177
+ ).compact
178
+ end
179
+ end
180
+ end
181
+
182
+ def each_child_workflow(name, collection, key:, timeout: nil, on_failure: :raise, &block)
183
+ raise ArgumentError, "Provide a block that starts each child workflow" unless block
184
+
185
+ child_workflows(name, collection, key: key, timeout: timeout, on_failure: on_failure, &block)
186
+ end
187
+
76
188
  def log
77
189
  @workflow_logger ||= WorkflowLogger.new(self)
78
190
  end
79
191
 
80
192
  private
81
- attr_reader :current_workflow_step
193
+ attr_reader :current_workflow_step, :failed_workflow_step
82
194
 
83
195
  def continue(&block)
84
196
  ensure_workflow_run!
@@ -94,20 +206,18 @@ module DurableFlow
94
206
  end
95
207
 
96
208
  mark_workflow_running!
97
- block.call
98
- complete_workflow!
209
+ result = block.call
210
+ complete_workflow!(result)
99
211
  rescue Pause => pause
100
212
  persist_interrupted_workflow!(pause.status)
101
213
  rescue ActiveJob::Continuation::Interrupt => interrupt
102
214
  resume_job(interrupt)
103
215
  rescue ActiveJob::Continuation::Error => error
104
- fail_workflow!(error)
105
216
  raise
106
217
  rescue StandardError => error
107
218
  if resume_errors_after_advancing? && continuation.advanced?
108
219
  resume_job(exception: error)
109
220
  else
110
- fail_workflow!(error)
111
221
  raise
112
222
  end
113
223
  ensure
@@ -150,6 +260,12 @@ module DurableFlow
150
260
  value = block.arity == 0 ? block.call : block.call(continuation_step)
151
261
  step_record.complete!(value)
152
262
  loaded = true
263
+ rescue Pause, ActiveJob::Continuation::Interrupt
264
+ raise
265
+ rescue StandardError => error
266
+ @failed_workflow_step = step_record
267
+ step_record.fail!(error)
268
+ raise
153
269
  ensure
154
270
  @current_workflow_step = nil
155
271
  end
@@ -219,20 +335,23 @@ module DurableFlow
219
335
  event_name: event_name.to_s,
220
336
  status: "pending",
221
337
  match: Serializer.dump(match || {}),
222
- timeout_at: (time_from(timeout) if timeout),
338
+ timeout_at: (durable_flow_time_from(timeout) if timeout),
223
339
  )).tap do |wait|
224
340
  updates = {}
225
341
  updates[:event_name] = event_name.to_s if wait.event_name != event_name.to_s
226
342
  updates[:match] = Serializer.dump(match || {}) if wait.match.blank?
227
- updates[:timeout_at] = time_from(timeout) if timeout && wait.timeout_at.blank?
343
+ updates[:timeout_at] = durable_flow_time_from(timeout) if timeout && wait.timeout_at.blank?
228
344
  wait.update!(updates) if updates.any?
229
345
  end
230
346
  end
231
347
 
232
- def matched_event_for(wait)
348
+ def matched_event_for(wait, allow_past_events: false)
233
349
  return wait.workflow_event if wait.workflow_event
234
350
 
235
- WorkflowEvent.named(wait.event_name).where("created_at >= ?", wait.created_at).order(:created_at).detect do |event|
351
+ scope = WorkflowEvent.named(wait.event_name)
352
+ scope = scope.where("created_at >= ?", wait.created_at) unless allow_past_events
353
+
354
+ scope.order(:created_at).detect do |event|
236
355
  wait.matches_event?(event)
237
356
  end
238
357
  end
@@ -307,7 +426,20 @@ module DurableFlow
307
426
  )
308
427
  end
309
428
 
310
- def complete_workflow!
429
+ def persist_retrying_workflow!(error)
430
+ ensure_workflow_run!
431
+
432
+ workflow_run.update!(
433
+ status: "retrying",
434
+ interrupted_at: Time.current,
435
+ serialized_job: serialize,
436
+ queue_name: queue_name,
437
+ priority: priority,
438
+ last_error: error_payload(error),
439
+ )
440
+ end
441
+
442
+ def complete_workflow!(result = nil)
311
443
  workflow_run.update!(
312
444
  status: "completed",
313
445
  completed_at: Time.current,
@@ -315,11 +447,23 @@ module DurableFlow
315
447
  last_error: nil,
316
448
  )
317
449
 
318
- DurableFlow.notify(DurableFlow::WORKFLOW_COMPLETED_EVENT, {
450
+ payload = {
319
451
  run_id: workflow_run.run_id,
320
452
  job_id: job_id,
321
453
  workflow_class: self.class.name,
322
- })
454
+ result: result,
455
+ }
456
+
457
+ DurableFlow.notify(DurableFlow::WORKFLOW_COMPLETED_EVENT, payload)
458
+ DurableFlow.notify(DurableFlow::WORKFLOW_FINISHED_EVENT, payload.merge(status: "completed"))
459
+ end
460
+
461
+ def fail_workflow_after_unhandled_error!(error)
462
+ return unless workflow_run
463
+ return if workflow_run.terminal?
464
+
465
+ failed_workflow_step&.fail!(error)
466
+ fail_workflow!(error)
323
467
  end
324
468
 
325
469
  def fail_workflow!(error)
@@ -327,23 +471,182 @@ module DurableFlow
327
471
  status: "failed",
328
472
  failed_at: Time.current,
329
473
  serialized_job: serialize,
330
- last_error: {
331
- "class" => error.class.name,
332
- "message" => error.message,
333
- "backtrace" => Array(error.backtrace).first(10),
334
- },
474
+ last_error: error_payload(error),
335
475
  )
336
476
 
337
- DurableFlow.notify(DurableFlow::WORKFLOW_FAILED_EVENT, {
477
+ payload = {
338
478
  run_id: workflow_run.run_id,
339
479
  job_id: job_id,
340
480
  workflow_class: self.class.name,
341
481
  error_class: error.class.name,
342
482
  error_message: error.message,
343
- })
483
+ }
484
+
485
+ DurableFlow.notify(DurableFlow::WORKFLOW_FAILED_EVENT, payload)
486
+ DurableFlow.notify(DurableFlow::WORKFLOW_FINISHED_EVENT, payload.merge(status: "failed"))
487
+ end
488
+
489
+ def error_payload(error)
490
+ {
491
+ "class" => error.class.name,
492
+ "message" => error.message,
493
+ "backtrace" => Array(error.backtrace).first(10),
494
+ }
495
+ end
496
+
497
+ def retry_at_from(options)
498
+ return options[:wait_until].to_time if options[:wait_until].respond_to?(:to_time)
499
+ return unless options[:wait]
500
+
501
+ Time.current + options[:wait].to_i
502
+ end
503
+
504
+ def start_child_workflow_step(name, workflow_class = nil, *args, **kwargs, &block)
505
+ durable_step(name) do
506
+ child = if block
507
+ block.arity.zero? ? block.call : block.call(workflow_class)
508
+ else
509
+ raise ArgumentError, "Provide a workflow class or block for child workflow #{name.inspect}" unless workflow_class
510
+
511
+ workflow_class.perform_later(*args, **kwargs)
512
+ end
513
+
514
+ child_workflow_start_payload(child, workflow_class)
515
+ end
516
+ end
517
+
518
+ def wait_for_child_workflow(name, child, timeout: nil, on_failure: :raise)
519
+ validate_child_workflow_failure_policy!(on_failure)
520
+
521
+ child = child.to_h.stringify_keys
522
+ wait_for_event_step(
523
+ name,
524
+ event_name: DurableFlow::WORKFLOW_FINISHED_EVENT,
525
+ timeout: timeout,
526
+ match: { run_id: child.fetch("run_id") },
527
+ allow_past_events: true,
528
+ ) do |completion|
529
+ if completion_value(completion, :status) == "failed" && on_failure == :raise
530
+ raise_child_workflow_failed!(completion)
531
+ end
532
+
533
+ completion
534
+ end
535
+ end
536
+
537
+ def validate_child_workflow_failure_policy!(value)
538
+ return if %i[raise return].include?(value)
539
+
540
+ raise ArgumentError, "Child workflow on_failure must be :raise or :return"
541
+ end
542
+
543
+ def child_workflow_start_payload(child, workflow_class = nil)
544
+ if child.respond_to?(:to_h)
545
+ payload = child.to_h.with_indifferent_access
546
+ if payload[:run_id].present?
547
+ return {
548
+ "run_id" => payload.fetch(:run_id),
549
+ "workflow_class" => payload[:workflow_class],
550
+ }.compact
551
+ end
552
+ end
553
+
554
+ {
555
+ "run_id" => extract_child_workflow_run_id(child),
556
+ "workflow_class" => child_workflow_class_name(child, workflow_class),
557
+ }.compact
558
+ end
559
+
560
+ def extract_child_workflow_run_id(child)
561
+ run_id = child.respond_to?(:job_id) ? child.job_id : child.to_s
562
+
563
+ raise ArgumentError, "Child workflow start must return a job, run id, or object responding to job_id" if run_id.blank?
564
+
565
+ run_id
566
+ end
567
+
568
+ def child_workflow_item_key(item, key)
569
+ key ||= :workflow_key
570
+
571
+ value = if key.respond_to?(:call)
572
+ key.call(item)
573
+ elsif item.respond_to?(:fetch)
574
+ item.fetch(key) { item.fetch(key.to_s) }
575
+ else
576
+ item.public_send(key)
577
+ end
578
+
579
+ value.to_s
580
+ end
581
+
582
+ def child_workflow_batches(collection, concurrency)
583
+ items = collection.to_a
584
+ return [ items ] unless concurrency
585
+
586
+ size = concurrency.to_i
587
+ raise ArgumentError, "Child workflow concurrency must be at least 1" if size < 1
588
+
589
+ items.each_slice(size).to_a
590
+ end
591
+
592
+ def start_child_workflow_request(request)
593
+ if request.respond_to?(:workflow_class)
594
+ workflow_class = request.workflow_class
595
+ args = request.respond_to?(:workflow_args) ? Array(request.workflow_args) : []
596
+ kwargs = child_workflow_request_kwargs(request)
597
+
598
+ return child_workflow_start_payload(workflow_class.perform_later(*args, **kwargs), workflow_class)
599
+ end
600
+
601
+ unless request.respond_to?(:perform_later)
602
+ raise ArgumentError, "Child workflow request must respond to perform_later or workflow_class"
603
+ end
604
+
605
+ request.perform_later
606
+ end
607
+
608
+ def child_workflow_request_kwargs(request)
609
+ kwargs = if request.respond_to?(:workflow_kwargs)
610
+ request.workflow_kwargs
611
+ elsif request.respond_to?(:workflow_arguments)
612
+ request.workflow_arguments
613
+ else
614
+ {}
615
+ end
616
+
617
+ kwargs ||= {}
618
+ kwargs.respond_to?(:symbolize_keys) ? kwargs.symbolize_keys : kwargs.to_h
619
+ end
620
+
621
+ def child_workflow_class_name(child, workflow_class = nil)
622
+ return workflow_class.name if workflow_class.respond_to?(:name)
623
+ return child.class.name if child.respond_to?(:job_id)
624
+
625
+ nil
626
+ end
627
+
628
+ def raise_child_workflow_failed!(completion)
629
+ raise ChildWorkflowFailedError.new(
630
+ run_id: completion_value(completion, :run_id),
631
+ workflow_class: completion_value(completion, :workflow_class),
632
+ error_class: completion_value(completion, :error_class),
633
+ error_message: completion_value(completion, :error_message),
634
+ )
635
+ end
636
+
637
+ def completion_value(payload, key)
638
+ return unless payload.respond_to?(:[])
639
+
640
+ if payload.respond_to?(:key?) && payload.key?(key)
641
+ payload[key]
642
+ elsif payload.respond_to?(:key?) && payload.key?(key.to_s)
643
+ payload[key.to_s]
644
+ else
645
+ payload[key]
646
+ end
344
647
  end
345
648
 
346
- def time_from(value, explicit_time: nil)
649
+ def durable_flow_time_from(value, explicit_time: nil)
347
650
  return explicit_time.to_time if explicit_time.respond_to?(:to_time)
348
651
  return nil if value.nil?
349
652
  return value.to_time if value.respond_to?(:to_time)
@@ -351,7 +654,7 @@ module DurableFlow
351
654
  Time.current + value
352
655
  end
353
656
 
354
- def parse_time(value)
657
+ def durable_flow_parse_time(value)
355
658
  return if value.blank?
356
659
  return value if value.is_a?(Time)
357
660
 
data/lib/durable_flow.rb CHANGED
@@ -6,6 +6,8 @@ require "active_job/continuation"
6
6
  require "active_record"
7
7
  require "active_support/core_ext/numeric/time"
8
8
  require "active_support/core_ext/object/blank"
9
+ require "active_support/core_ext/hash/indifferent_access"
10
+ require "active_support/core_ext/hash/keys"
9
11
  require "active_support/core_ext/module/attribute_accessors"
10
12
  require "securerandom"
11
13
 
@@ -14,8 +16,11 @@ require "durable_flow/errors"
14
16
  require "durable_flow/serializer"
15
17
  require "durable_flow/schema"
16
18
  require "durable_flow/live"
19
+ require "durable_flow/definition_graph"
20
+ require "durable_flow/definition_analyzer"
17
21
  require "durable_flow/workflow_logger"
18
22
  require "durable_flow/workflow_timeline"
23
+ require "durable_flow/child_workflow_builder"
19
24
  require "durable_flow/models/application_record"
20
25
  require "durable_flow/models/workflow_run"
21
26
  require "durable_flow/models/workflow_step"
@@ -39,6 +44,7 @@ module DurableFlow
39
44
 
40
45
  WORKFLOW_COMPLETED_EVENT = "durable_flow.workflow.completed"
41
46
  WORKFLOW_FAILED_EVENT = "durable_flow.workflow.failed"
47
+ WORKFLOW_FINISHED_EVENT = "durable_flow.workflow.finished"
42
48
  IGNORED_EVENT_NAMESPACES = %w[
43
49
  action_controller
44
50
  action_mailbox
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: durable_flow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DurableFlow contributors
@@ -89,6 +89,20 @@ dependencies:
89
89
  - - "<"
90
90
  - !ruby/object:Gem::Version
91
91
  version: '9.0'
92
+ - !ruby/object:Gem::Dependency
93
+ name: prism
94
+ requirement: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 1.3.0
99
+ type: :runtime
100
+ prerelease: false
101
+ version_requirements: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: 1.3.0
92
106
  - !ruby/object:Gem::Dependency
93
107
  name: railties
94
108
  requirement: !ruby/object:Gem::Requirement
@@ -162,11 +176,15 @@ files:
162
176
  - MIT-LICENSE
163
177
  - README.md
164
178
  - app/controllers/durable_flow/workflow_runs_controller.rb
179
+ - app/views/durable_flow/workflow_runs/definition.html.erb
165
180
  - app/views/durable_flow/workflow_runs/index.html.erb
166
181
  - app/views/durable_flow/workflow_runs/show.html.erb
167
182
  - app/views/layouts/durable_flow/application.html.erb
168
183
  - config/routes.rb
169
184
  - lib/durable_flow.rb
185
+ - lib/durable_flow/child_workflow_builder.rb
186
+ - lib/durable_flow/definition_analyzer.rb
187
+ - lib/durable_flow/definition_graph.rb
170
188
  - lib/durable_flow/dispatcher.rb
171
189
  - lib/durable_flow/engine.rb
172
190
  - lib/durable_flow/errors.rb