pgbus 0.19.3 → 0.20.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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +104 -9
  4. data/Rakefile +6 -1
  5. data/app/controllers/pgbus/dashboard_controller.rb +2 -0
  6. data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
  7. data/app/controllers/pgbus/workflows_controller.rb +197 -0
  8. data/app/frontend/pgbus/modules/charts.js +130 -4
  9. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  10. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  11. data/app/frontend/pgbus/style.css +1 -1
  12. data/app/helpers/pgbus/button_helper.rb +4 -2
  13. data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
  14. data/app/helpers/pgbus/workflows_helper.rb +165 -0
  15. data/app/models/pgbus/workflow_entry.rb +172 -0
  16. data/app/models/pgbus/workflow_event.rb +43 -0
  17. data/app/models/pgbus/workflow_signal.rb +77 -0
  18. data/app/models/pgbus/workflow_step.rb +142 -0
  19. data/app/views/layouts/pgbus/application.html.erb +2 -0
  20. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  21. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  22. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  23. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  24. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  25. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  26. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  27. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  28. data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
  29. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  30. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  31. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  32. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  33. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  34. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  35. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  36. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  37. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  38. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  39. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  40. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  41. data/app/views/pgbus/workflows/index.html.erb +15 -0
  42. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  43. data/app/views/pgbus/workflows/show.html.erb +93 -0
  44. data/config/i18n-tasks.yml +6 -0
  45. data/config/locales/da.yml +442 -0
  46. data/config/locales/de.yml +442 -0
  47. data/config/locales/en.yml +442 -0
  48. data/config/locales/es.yml +442 -0
  49. data/config/locales/fi.yml +442 -0
  50. data/config/locales/fr.yml +442 -0
  51. data/config/locales/it.yml +442 -0
  52. data/config/locales/ja.yml +442 -0
  53. data/config/locales/nb.yml +442 -0
  54. data/config/locales/nl.yml +442 -0
  55. data/config/locales/pt.yml +442 -0
  56. data/config/locales/sv.yml +442 -0
  57. data/config/routes.rb +26 -0
  58. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  59. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  60. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  61. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  62. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  63. data/lib/pgbus/active_job/adapter.rb +67 -26
  64. data/lib/pgbus/active_job/batch_id.rb +23 -1
  65. data/lib/pgbus/active_job/delivery.rb +49 -0
  66. data/lib/pgbus/active_job/executor.rb +3 -1
  67. data/lib/pgbus/batch/builder.rb +1 -0
  68. data/lib/pgbus/batch.rb +35 -3
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +21 -3
  71. data/lib/pgbus/generators/migration_detector.rb +40 -0
  72. data/lib/pgbus/instrumentation.rb +32 -0
  73. data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
  74. data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
  75. data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
  76. data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
  77. data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
  78. data/lib/pgbus/mcp/rack_app.rb +7 -2
  79. data/lib/pgbus/mcp/runner.rb +2 -1
  80. data/lib/pgbus/mcp/serialization.rb +25 -0
  81. data/lib/pgbus/mcp/server.rb +53 -10
  82. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  83. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  84. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  85. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  86. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  87. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  88. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  90. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  91. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  92. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  93. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  94. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  95. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  96. data/lib/pgbus/mcp/write_tool.rb +134 -0
  97. data/lib/pgbus/mcp.rb +15 -0
  98. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  99. data/lib/pgbus/metrics/subscriber.rb +14 -1
  100. data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
  101. data/lib/pgbus/process/dispatcher.rb +26 -0
  102. data/lib/pgbus/version.rb +1 -1
  103. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  104. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  105. data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
  106. data/lib/pgbus/web/data_source.rb +3 -0
  107. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  108. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  109. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  110. data/lib/pgbus/web/workflow_payload.rb +36 -0
  111. data/lib/pgbus/web/workflow_query.rb +82 -0
  112. data/lib/pgbus/web/workflow_state.rb +76 -0
  113. data/lib/pgbus/web/workflow_timeline.rb +113 -0
  114. data/lib/pgbus/web/workflow_window.rb +51 -0
  115. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  116. data/lib/pgbus/workflow/dsl.rb +105 -0
  117. data/lib/pgbus/workflow/error_capture.rb +63 -0
  118. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  119. data/lib/pgbus/workflow/fan_out.rb +157 -0
  120. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  121. data/lib/pgbus/workflow/flow_control.rb +94 -0
  122. data/lib/pgbus/workflow/instance.rb +183 -0
  123. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  124. data/lib/pgbus/workflow/payload.rb +53 -0
  125. data/lib/pgbus/workflow/registry.rb +53 -0
  126. data/lib/pgbus/workflow/removed_step.rb +14 -0
  127. data/lib/pgbus/workflow/runner.rb +218 -0
  128. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  129. data/lib/pgbus/workflow/signals.rb +132 -0
  130. data/lib/pgbus/workflow/states.rb +119 -0
  131. data/lib/pgbus/workflow/step_context.rb +58 -0
  132. data/lib/pgbus/workflow/step_definition.rb +131 -0
  133. data/lib/pgbus/workflow/step_execution.rb +242 -0
  134. data/lib/pgbus/workflow/step_job.rb +28 -0
  135. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  136. data/lib/pgbus/workflow/steps.rb +103 -0
  137. data/lib/pgbus/workflow/sweep.rb +156 -0
  138. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  139. data/lib/pgbus/workflow/wait_timeout.rb +73 -0
  140. data/lib/pgbus/workflow/waiting.rb +82 -0
  141. data/lib/pgbus/workflow.rb +183 -0
  142. data/sig/pgbus/configuration.rbs +22 -0
  143. metadata +95 -1
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # Where a workflow run's time went (issue #542), from its step rows and
6
+ # audit trail. Each attempt splits into
7
+ #
8
+ # waited created_at → scheduled_at the step's wait: (or a reattempt's)
9
+ # queued scheduled_at → started_at due, not yet claimed by a worker
10
+ # ran started_at → finished_at the body (up to now while in progress)
11
+ # waiting waiting_since → … parked on a signal or its fan-out batch
12
+ #
13
+ # and a paused run's pauses (paused → resumed, from the events) are their
14
+ # own segments, so a long gap explains itself. The show page's step table
15
+ # prints durations; the range-bar chart (charts.js renderTimeline) draws
16
+ # chart. Pure: rows in, hashes out.
17
+ module WorkflowTimeline
18
+ OPEN = %w[scheduled waiting in_progress].freeze
19
+ PAUSE_ENDS = %w[resumed cancelled finished].freeze
20
+ PAUSE_EVENTS = (%w[paused] + PAUSE_ENDS).freeze
21
+ RAN_SERIES = { "completed" => "ran_ok", "skipped" => "ran_ok", "failed" => "ran_failed",
22
+ "in_progress" => "ran_open", "waiting" => "ran_open" }.freeze
23
+
24
+ module_function
25
+
26
+ # [{ kind:, from:, to: }] in time order. +step+ is a step hash (the
27
+ # data source's) with Time values.
28
+ def segments(step, now:)
29
+ created, scheduled, started, since = step.values_at(:created_at, :scheduled_at, :started_at, :waiting_since)
30
+ ending = step[:finished_at] || (OPEN.include?(step[:status].to_s) ? now : nil)
31
+ pieces = if since && scheduled && since < scheduled
32
+ # A signal step woken by its signal: the row was scheduled
33
+ # again at the wake and claimed a second time.
34
+ [[:waited, created, since], [:waiting, since, scheduled], [:queued, scheduled, started],
35
+ [:ran, started, ending]]
36
+ elsif since
37
+ [[:waited, created, scheduled], [:queued, scheduled, started], [:ran, started, since],
38
+ [:waiting, since, ending]]
39
+ else
40
+ [[:waited, created, scheduled], [:queued, scheduled, started || ending], [:ran, started, ending]]
41
+ end
42
+ clip(pieces, step[:finished_at])
43
+ end
44
+
45
+ # { waited:, queued:, ran:, waiting: } in seconds; nil for a kind the
46
+ # attempt never reached.
47
+ def durations(step, now:)
48
+ totals = segments(step, now: now).group_by { |segment| segment[:kind] }
49
+ %i[waited queued ran waiting].to_h do |kind|
50
+ pieces = totals[kind]
51
+ [kind, pieces&.sum { |segment| (segment[:to] - segment[:from]).to_f }]
52
+ end
53
+ end
54
+
55
+ # [{ from:, to: }]: each "paused" event up to the event that ended it,
56
+ # oldest first; a pause still open runs to now. +events+ in any order.
57
+ def paused_periods(events, status:, now:)
58
+ periods = []
59
+ opened = nil
60
+ events.sort_by { |event| event[:created_at] }.each do |event|
61
+ if event[:event] == "paused"
62
+ opened ||= event[:created_at]
63
+ elsif opened && PAUSE_ENDS.include?(event[:event])
64
+ periods << { from: opened, to: event[:created_at] }
65
+ opened = nil
66
+ end
67
+ end
68
+ periods << { from: opened, to: now } if opened && status.to_s == "paused"
69
+ periods
70
+ end
71
+
72
+ # The chart payload: one row per attempt (oldest first) and the paused
73
+ # periods. Times are epoch milliseconds; each segment carries its
74
+ # series (its colour: ran is coloured by how the attempt ended) and its
75
+ # duration in words for the tooltip.
76
+ def chart(steps, events, status:, now:)
77
+ rows = steps.sort_by { |step| [step[:created_at], step[:attempt].to_i] }.map do |step|
78
+ { label: "#{step[:step_name]} ##{step[:attempt]}", status: step[:status], outcome: step[:outcome],
79
+ error_class: step[:error_class],
80
+ segments: segments(step, now: now).map { |segment| chart_segment(segment, series_for(segment[:kind], step)) } }
81
+ end
82
+ paused = paused_periods(events, status: status, now: now).map do |period|
83
+ chart_segment(period.merge(kind: :paused), "paused")
84
+ end
85
+ { rows: rows, paused: paused }
86
+ end
87
+
88
+ def series_for(kind, step)
89
+ return kind.to_s unless kind == :ran
90
+
91
+ RAN_SERIES.fetch(step[:status].to_s, "ran_cancelled")
92
+ end
93
+
94
+ def chart_segment(segment, series)
95
+ { kind: segment[:kind].to_s, series: series, from: millis(segment[:from]), to: millis(segment[:to]),
96
+ duration: TimeFormat.duration(segment[:to] - segment[:from]) }
97
+ end
98
+
99
+ def millis(time) = (time.to_f * 1000).round
100
+
101
+ # Drops a piece without both ends or of no length, and ends every piece
102
+ # at the attempt's finished_at (a step cancelled before its scheduled_at).
103
+ def clip(pieces, finished)
104
+ pieces.filter_map do |kind, from, to|
105
+ next unless from && to
106
+
107
+ to = [to, finished].min if finished
108
+ { kind: kind, from: from, to: to } if to > from
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "active_support/core_ext/time"
5
+
6
+ module Pgbus
7
+ module Web
8
+ # The window the workflow insights read (issue #549): 1 h, 24 h (the
9
+ # default), 7 d or 30 d, never longer than config.workflow_retention
10
+ # (finished and cancelled runs older than that are gone, so a longer
11
+ # window would undercount). Each length has its chart bucket, at most 30
12
+ # per window: 5 min, 1 h, 6 h, 1 d; a capped window takes the bucket of
13
+ # the shortest choice that covers it. Anything else raises Invalid (400
14
+ # on the page, an error on the MCP tool).
15
+ class WorkflowWindow
16
+ class Invalid < StandardError; end
17
+
18
+ # name => [seconds, bucket seconds]
19
+ CHOICES = Ractor.make_shareable({ "1h" => [3_600, 300], "24h" => [86_400, 3_600], "7d" => [604_800, 21_600],
20
+ "30d" => [2_592_000, 86_400] })
21
+ DEFAULT = "24h"
22
+
23
+ attr_reader :name, :seconds, :bucket_seconds, :now
24
+
25
+ # +now+ is fixed here, so every read and every link of one page share
26
+ # one window end.
27
+ def self.parse(value, retention: Pgbus.configuration.workflow_retention, now: Time.current)
28
+ name = value.nil? || value == "" ? DEFAULT : value
29
+ raise Invalid, "unknown window #{value.inspect} (one of #{CHOICES.keys.join(", ")})" unless name.is_a?(String) && CHOICES.key?(name)
30
+
31
+ new(name, retention: retention, now: now)
32
+ end
33
+
34
+ def initialize(name, retention:, now: Time.current)
35
+ @name = name
36
+ @now = now
37
+ requested = CHOICES.fetch(name).first
38
+ @seconds = retention ? [requested, retention.to_i].min : requested
39
+ @capped = @seconds < requested
40
+ @bucket_seconds = CHOICES.values.find { |length, _| length >= @seconds }.last
41
+ freeze
42
+ end
43
+
44
+ def capped? = @capped
45
+
46
+ def since(reference = now) = reference - seconds
47
+
48
+ def to_h = { name: name, seconds: seconds, bucket_seconds: bucket_seconds, capped: capped? }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Pgbus
6
+ class Workflow
7
+ # The on_complete callback of a fan_out step's batch (issue #541):
8
+ # perform(step_id) resumes the run once every member finished (FanOut).
9
+ # Sent on the step's StepJob queue (config.workflow_queue unless the
10
+ # class or step says otherwise). A callback is never a member of the
11
+ # batch it reports on, and the StepJob it leads to is sent outside any
12
+ # Pgbus.batch block, so neither joins a batch.
13
+ class BatchFinishedJob < ::ActiveJob::Base
14
+ queue_as { Pgbus.configuration.workflow_queue }
15
+
16
+ def perform(step_id)
17
+ FanOut.finished(step_id)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The class-body DSL. Every declaration builds a new frozen value and
6
+ # stores it whole in a class_attribute, so a subclass extends what its
7
+ # parent declared without changing the parent, and nothing is ever
8
+ # mutated after the class body ran (docs/concurrency.md).
9
+ module DSL
10
+ # step :name, wait:, skip_if:, timeout:, job_options:, on_exception:,
11
+ # before_step:, after_step: do ... end
12
+ # step { ... } # anonymous: step_1, step_2, ...
13
+ # step :name # runs the instance method `name`
14
+ # step def name = ... # the same, declared in one line
15
+ def step(name = nil, before_step: nil, after_step: nil, **options, &block)
16
+ raise ArgumentError, "a step needs a name or a block" if name.nil? && block.nil?
17
+
18
+ reject_unknown_options!(options)
19
+ name ||= _workflow_steps.anonymous_name
20
+ definition = StepDefinition.new(name: name, body: block || name.to_sym, **options)
21
+ self._workflow_steps = _workflow_steps.add(definition, before: before_step, after: after_step)
22
+ definition.name.to_sym
23
+ end
24
+
25
+ # fan_out :name, on_batch_failure: :pause!, **step_options do |b| ... end
26
+ #
27
+ # A step that adds a group of jobs with b.add and waits for them as one
28
+ # batch: the step parks as waiting, and the batch's on_complete resumes
29
+ # the run once every member finished. Any dead-lettered member applies
30
+ # on_batch_failure (:pause!, :cancel! or :skip!) with
31
+ # Pgbus::Workflow::FanOutFailed recorded. Takes every step option but
32
+ # wait_for:, wait_timeout: and on_timeout:. Without a block it runs the
33
+ # instance method of the same name, which gets the builder.
34
+ def fan_out(name = nil, on_batch_failure: :pause!, before_step: nil, after_step: nil, **options, &block)
35
+ raise ArgumentError, "a fan_out needs a name or a block" if name.nil? && block.nil?
36
+
37
+ name ||= _workflow_steps.anonymous_name
38
+ waits = options.keys & %i[wait_for wait_timeout on_timeout]
39
+ if waits.any?
40
+ raise ArgumentError, "fan_out #{name.to_s.to_sym.inspect} cannot take #{waits.first}: it waits for its batch, " \
41
+ "not for a signal"
42
+ end
43
+
44
+ reject_unknown_options!(options, kind: "fan_out", accepted: FanOutDefinition::OPTIONS)
45
+ definition = FanOutDefinition.new(name: name, body: block || name.to_sym, on_batch_failure: on_batch_failure, **options)
46
+ self._workflow_steps = _workflow_steps.add(definition, before: before_step, after: after_step)
47
+ definition.name.to_sym
48
+ end
49
+
50
+ # A step that no longer exists. Declare it where the step used to be: a
51
+ # run still scheduled on it skips it and moves on to the step after.
52
+ def removed_step(*names)
53
+ names.each { |name| self._workflow_steps = _workflow_steps.remove(name) }
54
+ end
55
+
56
+ # Runs before every step under the workflow lock; truthy cancels the
57
+ # workflow. A block or a method name; several are OR-ed.
58
+ def cancel_if(method_name = nil, &block)
59
+ condition = block || method_name
60
+ raise ArgumentError, "cancel_if needs a block or a method name" unless condition.is_a?(Proc) || condition.is_a?(Symbol)
61
+
62
+ self._cancel_conditions = [*_cancel_conditions, condition].freeze
63
+ end
64
+
65
+ # A class-level exception policy (see ExceptionPolicy).
66
+ def on_exception(*matching, action: nil, **, &)
67
+ policy = ExceptionPolicy.new(action, matching: matching.flatten, **, &)
68
+ self._exception_policies = [*_exception_policies, policy].freeze
69
+ end
70
+
71
+ # StepJob options for every step of this class: queue, priority, wait,
72
+ # wait_until. A step's own job_options: win over these.
73
+ def set_step_job_options(**options)
74
+ self._step_job_options = _step_job_options.merge(StepDefinition.validate_job_options!(options)).freeze
75
+ end
76
+
77
+ def workflow_steps = _workflow_steps
78
+
79
+ def cancel_conditions = _cancel_conditions
80
+
81
+ def exception_policies = _exception_policies
82
+
83
+ def step_job_options = _step_job_options
84
+
85
+ # SHA256 of the ordered [name, kind] step list. Stored on every run at
86
+ # start, so a run that began on an older definition can be told apart.
87
+ def definition_digest = Registry.digest(_workflow_steps)
88
+
89
+ # The StepJob options for one step: the class's, then the step's own.
90
+ def step_job_options_for(step_name)
91
+ own = _workflow_steps.fetch(step_name)&.job_options || {}
92
+ _step_job_options.merge(own)
93
+ end
94
+
95
+ private
96
+
97
+ def reject_unknown_options!(options, kind: "step", accepted: StepDefinition::OPTIONS)
98
+ unknown = options.keys - accepted
99
+ return if unknown.empty?
100
+
101
+ raise ArgumentError, "unknown #{kind} option #{unknown.first.inspect} (accepted: #{accepted.join(", ")})"
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # Turns an exception into the step row's error columns: what an operator
6
+ # needs to act on a failed step without opening the error service.
7
+ #
8
+ # error_class the class name
9
+ # error_message capped at MESSAGE_LIMIT characters
10
+ # error_backtrace cleaned through Rails.backtrace_cleaner when Rails is
11
+ # loaded, cut to config.workflow_error_backtrace_lines
12
+ # error_causes [{ "class", "message" }] along Exception#cause, at most
13
+ # MAX_CAUSES deep (a cyclic chain stops where it repeats)
14
+ module ErrorCapture
15
+ MESSAGE_LIMIT = 10_000
16
+ CAUSE_MESSAGE_LIMIT = 1_000
17
+ MAX_CAUSES = 5
18
+
19
+ module_function
20
+
21
+ def capture(error, backtrace_lines: Pgbus.configuration.workflow_error_backtrace_lines)
22
+ {
23
+ error_class: error.class.name,
24
+ error_message: truncate(error.message.to_s, MESSAGE_LIMIT),
25
+ error_backtrace: backtrace(error, backtrace_lines).join("\n"),
26
+ error_causes: causes(error)
27
+ }
28
+ end
29
+
30
+ # The columns cleared when a later attempt succeeds or a run resumes.
31
+ def cleared = { error_class: nil, error_message: nil, error_backtrace: nil }
32
+
33
+ def backtrace(error, lines)
34
+ raw = error.backtrace || []
35
+ cleaned = clean(raw)
36
+ (cleaned.empty? ? raw : cleaned).first(lines)
37
+ end
38
+
39
+ def causes(error)
40
+ seen = {}.compare_by_identity
41
+ seen[error] = true
42
+ chain = []
43
+ cause = error.cause
44
+ while cause && chain.size < MAX_CAUSES && !seen[cause]
45
+ seen[cause] = true
46
+ chain << { "class" => cause.class.name, "message" => truncate(cause.message.to_s, CAUSE_MESSAGE_LIMIT) }
47
+ cause = cause.cause
48
+ end
49
+ chain
50
+ end
51
+
52
+ def truncate(string, limit)
53
+ string.length > limit ? "#{string[0, limit - 1]}…" : string
54
+ end
55
+
56
+ def clean(lines)
57
+ return lines unless defined?(::Rails) && ::Rails.respond_to?(:backtrace_cleaner) && ::Rails.backtrace_cleaner
58
+
59
+ ::Rails.backtrace_cleaner.clean(lines)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # What happens when a step body raises (issue #540). A policy matches an
6
+ # exception (by class, class name, or a list of either; none = every
7
+ # StandardError) and names an action:
8
+ #
9
+ # on_exception Net::OpenTimeout, action: :reattempt!, wait: 15.seconds, max_reattempts: 5
10
+ # on_exception PaymentDeclined, action: :cancel!, report: :never
11
+ # on_exception action: :pause! # blanket
12
+ # on_exception Timeout::Error do |error| # imperative: must call a verb
13
+ # error.message.include?("read") ? reattempt!(wait: 5) : skip!
14
+ # end
15
+ #
16
+ # Resolution: the step's own policies, then the class's specific ones,
17
+ # then the class's blanket ones, then the default pause!. The error is
18
+ # recorded on the step row either way; +report+ decides whether it also
19
+ # goes to Pgbus::ErrorReporter (:always, :never, or :terminal_only: only
20
+ # when the action ends the retrying). max_reattempts counts consecutive
21
+ # reattempted attempts of the same step; once spent, terminal_action
22
+ # (default pause!) applies.
23
+ class ExceptionPolicy
24
+ ACTIONS = %i[pause! reattempt! cancel! skip!].freeze
25
+ TERMINAL_ACTIONS = %i[pause! cancel! skip!].freeze
26
+ REPORTS = %i[always never terminal_only].freeze
27
+
28
+ # +outcome+ is a FlowControl::Outcome; +report+ whether ErrorReporter
29
+ # hears of it; +handler_error+ is what an imperative block raised.
30
+ Decision = Data.define(:outcome, :report, :terminal, :handler_error)
31
+
32
+ attr_reader :action, :matching, :wait, :max_reattempts, :terminal_action, :report, :handler
33
+
34
+ class << self
35
+ # The step-level `on_exception:` option: a policy, an action
36
+ # (`:reattempt!`), a Hash of policy options, or an Array of those.
37
+ def coerce_all(value)
38
+ Array(value.is_a?(Hash) ? [value] : value).map { |entry| coerce(entry) }.freeze
39
+ end
40
+
41
+ def coerce(value)
42
+ case value
43
+ when ExceptionPolicy then value
44
+ when Symbol, String then new(value.to_sym)
45
+ when Hash
46
+ options = value.transform_keys(&:to_sym)
47
+ new(options.delete(:action), **options)
48
+ else
49
+ raise ArgumentError, "on_exception: takes a Pgbus::Workflow::ExceptionPolicy, an action, a Hash or an Array, " \
50
+ "got #{value.inspect}"
51
+ end
52
+ end
53
+
54
+ def resolve(error, step_policies: [], class_policies: [])
55
+ step_policies.find { |policy| policy.matches?(error) } ||
56
+ class_policies.find { |policy| policy.specific? && policy.matches?(error) } ||
57
+ class_policies.find { |policy| !policy.specific? && policy.matches?(error) } ||
58
+ DEFAULT
59
+ end
60
+ end
61
+
62
+ def initialize(action = nil, matching: nil, wait: nil, max_reattempts: nil, terminal_action: nil, report: :always, &handler)
63
+ raise ArgumentError, "on_exception takes action: or a block, not both" if action && handler
64
+ raise ArgumentError, "on_exception needs action: (#{ACTIONS.join(", ")}) or a block" unless action || handler
65
+
66
+ @action = action && normalize_action(action, ACTIONS, "action")
67
+ reattempt_only!(wait: wait, terminal_action: terminal_action)
68
+ @matching = normalize_matching(matching)
69
+ @wait = StepDefinition.seconds(wait, "wait", positive: false)
70
+ @max_reattempts = validate_max_reattempts(max_reattempts)
71
+ @terminal_action = normalize_action(terminal_action.nil? ? :pause! : terminal_action, TERMINAL_ACTIONS, "terminal_action")
72
+ @report = validate_report(report)
73
+ @handler = handler
74
+ freeze
75
+ end
76
+
77
+ def specific? = !matching.nil?
78
+
79
+ def matches?(error)
80
+ return error.is_a?(StandardError) if matching.nil?
81
+
82
+ matching.any? do |matcher|
83
+ matcher.is_a?(String) ? error.class.ancestors.any? { |ancestor| ancestor.name == matcher } : error.is_a?(matcher)
84
+ end
85
+ end
86
+
87
+ # +consecutive_reattempts+ is a callable (it reads the step rows), only
88
+ # called when max_reattempts needs it.
89
+ def decide(error, workflow:, consecutive_reattempts: -> { 0 })
90
+ return decide_with_handler(error, workflow) if handler
91
+ return decide_reattempt(consecutive_reattempts) if action == :reattempt!
92
+
93
+ Decision.new(outcome: FlowControl::Outcome.for(action), report: report != :never, terminal: true, handler_error: nil)
94
+ end
95
+
96
+ private
97
+
98
+ def decide_reattempt(consecutive_reattempts)
99
+ if max_reattempts && consecutive_reattempts.call >= max_reattempts
100
+ return Decision.new(outcome: FlowControl::Outcome.for(terminal_action), report: report != :never,
101
+ terminal: true, handler_error: nil)
102
+ end
103
+
104
+ Decision.new(outcome: FlowControl::Outcome.reattempt(wait: wait), report: report == :always,
105
+ terminal: false, handler_error: nil)
106
+ end
107
+
108
+ # The block runs on the workflow instance and must call a verb; one
109
+ # that returns without calling any pauses the workflow. A block that
110
+ # raises pauses it too, and its own error is handed back for reporting.
111
+ def decide_with_handler(error, workflow)
112
+ outcome = catch(FlowControl::THROW_TAG) do
113
+ workflow.instance_exec(error, &handler)
114
+ nil
115
+ end
116
+ terminal = !outcome&.reattempt?
117
+ Decision.new(outcome: outcome || FlowControl::Outcome.pause,
118
+ report: report == :always || (report == :terminal_only && terminal),
119
+ terminal: terminal, handler_error: nil)
120
+ rescue StandardError => e
121
+ Decision.new(outcome: FlowControl::Outcome.pause, report: true, terminal: true, handler_error: e)
122
+ end
123
+
124
+ def normalize_action(value, allowed, option)
125
+ verb = :"#{value.to_s.delete_suffix("!")}!"
126
+ return verb if allowed.include?(verb)
127
+
128
+ raise ArgumentError, "#{option}: must be one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}"
129
+ end
130
+
131
+ def normalize_matching(matching)
132
+ return nil if matching.nil? || (matching.respond_to?(:empty?) && matching.empty?)
133
+
134
+ Array(matching).map do |matcher|
135
+ next -matcher.to_s if matcher.is_a?(String) || matcher.is_a?(Symbol)
136
+ next matcher if matcher.is_a?(Module)
137
+
138
+ raise ArgumentError, "matching: takes exception classes or class names, got #{matcher.inspect}"
139
+ end.freeze
140
+ end
141
+
142
+ # wait: and terminal_action: shape a reattempt!; anywhere else (another
143
+ # action, an imperative block, which passes its own wait) they would be
144
+ # silently ignored.
145
+ def reattempt_only!(**options)
146
+ given = options.compact.keys
147
+ return if given.empty? || action == :reattempt!
148
+
149
+ raise ArgumentError, "#{given.map { |key| "#{key}:" }.join(" and ")} only applies to action: :reattempt!"
150
+ end
151
+
152
+ def validate_max_reattempts(value)
153
+ return nil if value.nil?
154
+ raise ArgumentError, "max_reattempts: only applies to action: :reattempt!" unless action == :reattempt!
155
+ return value if value.is_a?(Integer) && value.positive?
156
+
157
+ raise ArgumentError, "max_reattempts: must be a positive Integer, got #{value.inspect}"
158
+ end
159
+
160
+ def validate_report(value)
161
+ return value if REPORTS.include?(value)
162
+
163
+ raise ArgumentError, "report: must be one of #{REPORTS.map(&:inspect).join(", ")}, got #{value.inspect}"
164
+ end
165
+ end
166
+
167
+ # What applies when no policy matches: pause!, reported.
168
+ ExceptionPolicy::DEFAULT = ExceptionPolicy.new(:pause!)
169
+ end
170
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # fan_out steps (issue #541): a group of jobs the workflow waits for as
6
+ # one batch.
7
+ #
8
+ # fan_out :notify_vendors do |b|
9
+ # b.add(*subject.vendors.map { |vendor| NotifyVendorJob.new(vendor.id) })
10
+ # end
11
+ #
12
+ # 1. run (inside the StepJob, outside any transaction): the body gets a
13
+ # Builder that records its b.add calls. A flow-control verb or an
14
+ # exception in the body ends the step before any batch exists. When
15
+ # the body returns, Pgbus.batch opens with on_complete
16
+ # BatchFinishedJob.new(step_id) and the recorded adds.
17
+ # 2. park (under the run's lock): the step waits on the batch. A batch
18
+ # that already finished (an empty one finishes at once, before
19
+ # Pgbus.batch returns) is resolved here and then, since its callback
20
+ # may have run before the step parked and found nothing to resume.
21
+ # 3. finished (BatchFinishedJob, under the lock): no dead-lettered member
22
+ # completes the step and advances; any applies on_batch_failure with
23
+ # FanOutFailed recorded. A step no longer waiting (skipped, cancelled,
24
+ # already resolved by the park) is left alone.
25
+ #
26
+ # A reattempt or a resume! of a failed fan_out runs the whole body again
27
+ # and opens a fresh batch: member jobs must be idempotent.
28
+ module FanOut
29
+ # The `b` a fan_out body gets. Records its adds; the batch opens once
30
+ # the body returns. The workflow owns the batch's callbacks.
31
+ class Builder
32
+ CALLBACK_MESSAGE = "a fan_out step owns its batch's callbacks (on_complete resumes the workflow); " \
33
+ "do the follow-up work in the next step"
34
+
35
+ def initialize
36
+ @adds = []
37
+ end
38
+
39
+ # Adds job instances (MyJob.new(args), optionally .set(...)). A block
40
+ # runs inside the batch, so a perform_later in it joins too.
41
+ def add(*jobs, &block)
42
+ jobs = Batch::Builder.job_instances(jobs)
43
+ @adds << [jobs, block]
44
+ jobs
45
+ end
46
+
47
+ def size = @adds.sum { |jobs, _block| jobs.size }
48
+
49
+ %i[on_complete on_success on_failure on_finish].each do |slot|
50
+ define_method(slot) { |*, **| raise ArgumentError, "#{slot}: #{CALLBACK_MESSAGE}" }
51
+ end
52
+
53
+ # Replays the recorded adds on a real batch builder.
54
+ def replay(batch_builder)
55
+ @adds.each { |jobs, block| batch_builder.add(*jobs, &block) }
56
+ end
57
+ end
58
+
59
+ module_function
60
+
61
+ def run(definition, workflow)
62
+ builder = Builder.new
63
+ definition.body.is_a?(Symbol) ? workflow.send(definition.body, builder) : workflow.instance_exec(builder, &definition.body)
64
+ batch = open_batch(workflow, workflow.send(:step_context), builder)
65
+ throw FlowControl::THROW_TAG, FlowControl::Outcome.fan_out(batch_id: batch.batch_id, size: batch.reload.total_jobs)
66
+ end
67
+
68
+ def open_batch(workflow, attempt, builder)
69
+ queue = workflow.class.step_job_options_for(attempt.step_name)[:queue] || Pgbus.configuration.workflow_queue
70
+ callback = BatchFinishedJob.new(attempt.step_id).set(queue: queue)
71
+ Pgbus.batch(description: "#{workflow.workflow_class}##{attempt.step_name} #{workflow.workflow_id}",
72
+ properties: { workflow_id: workflow.workflow_id, step_id: attempt.step_id }) do |b|
73
+ b.on_complete(callback)
74
+ builder.replay(b)
75
+ end
76
+ end
77
+
78
+ # The StepJob's outcome for a fan_out whose batch is open. What the body
79
+ # recorded with output(...) is kept on the row until the step resolves.
80
+ def park(record, step, outcome, notes:, output: nil)
81
+ batch_id = outcome.details.fetch(:batch_id)
82
+ Runner.event!(record, "fan_out_opened", actor: Runner::SYSTEM_ACTOR, step: step, details: outcome.details)
83
+ unless Waiting.park!(record, step, now: Time.current, batch_id: batch_id, output: output)
84
+ StepOutcome.ignore_late_completion(record, step, "waiting", "fan_out", nil)
85
+ return
86
+ end
87
+
88
+ entry = BatchEntry.find_by(batch_id: batch_id)
89
+ resolve(record, step, entry, notes) if entry&.status == "finished"
90
+ end
91
+
92
+ # BatchFinishedJob#perform (and the sweep, for a lost one). Truthy when
93
+ # it resolved the step.
94
+ def finished(step_id)
95
+ step = WorkflowStep.find_by(id: step_id)
96
+ return log("step #{step_id} no longer exists (its run was deleted)") unless step
97
+
98
+ Runner.locked(step.workflow_id) do |record, notes|
99
+ step = record && WorkflowStep.find_by(id: step_id)
100
+ unless step&.status == "waiting" && step.batch_id
101
+ next log("step #{step_id} is not waiting on its batch (#{step&.status || "gone"}): nothing to resume")
102
+ end
103
+
104
+ entry = BatchEntry.find_by(batch_id: step.batch_id)
105
+ next resolve_vanished(record, step, notes) unless entry
106
+ next log("batch #{step.batch_id} of step #{step_id} has not finished") unless entry.status == "finished"
107
+
108
+ resolve(record, step, entry, notes)
109
+ end
110
+ end
111
+
112
+ # The sweep's repair for a lost callback: fan_out steps still waiting on
113
+ # a batch that finished more than +grace+ ago, or whose batch row is
114
+ # gone (batch retention ran before a long-delayed callback). Returns how
115
+ # many it resolved.
116
+ def resolve_lost(now:, grace:, limit: Sweep::BATCH_SIZE)
117
+ finished_batches = BatchEntry.finished.where(finished_at: ...(now - grace)).select(:batch_id)
118
+ waiting = WorkflowStep.where(status: "waiting").where.not(batch_id: nil)
119
+ lost = waiting.where(batch_id: finished_batches)
120
+ .or(waiting.where(waiting_since: ...(now - grace)).where.not(batch_id: BatchEntry.select(:batch_id)))
121
+ lost.order(:id).limit(limit).pluck(:id).count { |step_id| finished(step_id) }
122
+ end
123
+
124
+ # The batch row is gone, so its outcome is unknown: the step pauses
125
+ # (whatever on_batch_failure says) with FanOutFailed, for a person to
126
+ # resume (a fresh batch) or skip.
127
+ def resolve_vanished(record, step, notes)
128
+ Runner.event!(record, "fan_out_finished", actor: Runner::SYSTEM_ACTOR, step: step,
129
+ details: { batch_id: step.batch_id, vanished: true })
130
+ error = FanOutFailed.new("batch #{step.batch_id} no longer exists (deleted before its callback ran); its outcome is unknown")
131
+ Waiting.resolve!(record, step, FlowControl::Outcome.pause, notes: notes, error: error)
132
+ end
133
+
134
+ def resolve(record, step, entry, notes)
135
+ batch = Batch.find(entry.batch_id)
136
+ failed = batch.failed_jobs
137
+ total = batch.total_jobs
138
+ Runner.event!(record, "fan_out_finished", actor: Runner::SYSTEM_ACTOR, step: step,
139
+ details: { batch_id: entry.batch_id, failed_jobs: failed, total_jobs: total })
140
+ return Waiting.resolve!(record, step, FlowControl::Outcome.complete, notes: notes) if failed.zero?
141
+
142
+ error = FanOutFailed.new("#{failed} of #{total} jobs dead-lettered (batch #{entry.batch_id})")
143
+ Waiting.resolve!(record, step, FlowControl::Outcome.for(failure_action(record, step)), notes: notes, error: error)
144
+ end
145
+
146
+ def failure_action(record, step)
147
+ definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
148
+ definition.respond_to?(:on_batch_failure) ? definition.on_batch_failure : :pause!
149
+ end
150
+
151
+ def log(message)
152
+ Pgbus.logger.info { "[Pgbus::Workflow] fan_out: #{message}" }
153
+ nil
154
+ end
155
+ end
156
+ end
157
+ end