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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +104 -9
- data/Rakefile +6 -1
- data/app/controllers/pgbus/dashboard_controller.rb +2 -0
- data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
- data/app/controllers/pgbus/workflows_controller.rb +197 -0
- data/app/frontend/pgbus/modules/charts.js +130 -4
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/style.css +1 -1
- data/app/helpers/pgbus/button_helper.rb +4 -2
- data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
- data/app/helpers/pgbus/workflows_helper.rb +165 -0
- data/app/models/pgbus/workflow_entry.rb +172 -0
- data/app/models/pgbus/workflow_event.rb +43 -0
- data/app/models/pgbus/workflow_signal.rb +77 -0
- data/app/models/pgbus/workflow_step.rb +142 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
- data/app/views/pgbus/workflows/_actions.html.erb +59 -0
- data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
- data/app/views/pgbus/workflows/_filters.html.erb +29 -0
- data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
- data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
- data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
- data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
- data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
- data/app/views/pgbus/workflows/attention.html.erb +88 -0
- data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
- data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
- data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
- data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
- data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
- data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
- data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
- data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
- data/app/views/pgbus/workflows/index.html.erb +15 -0
- data/app/views/pgbus/workflows/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/show.html.erb +93 -0
- data/config/i18n-tasks.yml +6 -0
- data/config/locales/da.yml +442 -0
- data/config/locales/de.yml +442 -0
- data/config/locales/en.yml +442 -0
- data/config/locales/es.yml +442 -0
- data/config/locales/fi.yml +442 -0
- data/config/locales/fr.yml +442 -0
- data/config/locales/it.yml +442 -0
- data/config/locales/ja.yml +442 -0
- data/config/locales/nb.yml +442 -0
- data/config/locales/nl.yml +442 -0
- data/config/locales/pt.yml +442 -0
- data/config/locales/sv.yml +442 -0
- data/config/routes.rb +26 -0
- data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
- data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
- data/lib/pgbus/active_job/adapter.rb +67 -26
- data/lib/pgbus/active_job/batch_id.rb +23 -1
- data/lib/pgbus/active_job/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- data/lib/pgbus/batch/builder.rb +1 -0
- data/lib/pgbus/batch.rb +35 -3
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +21 -3
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +32 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
- data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
- data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
- data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
- data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
- data/lib/pgbus/mcp/rack_app.rb +7 -2
- data/lib/pgbus/mcp/runner.rb +2 -1
- data/lib/pgbus/mcp/serialization.rb +25 -0
- data/lib/pgbus/mcp/server.rb +53 -10
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
- data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
- data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
- data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
- data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
- data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
- data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
- data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
- data/lib/pgbus/mcp/write_tool.rb +134 -0
- data/lib/pgbus/mcp.rb +15 -0
- data/lib/pgbus/metrics/backends/statsd.rb +6 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
- data/lib/pgbus/process/dispatcher.rb +26 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
- data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
- data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
- data/lib/pgbus/web/data_source.rb +3 -0
- data/lib/pgbus/web/workflow_action_plan.rb +62 -0
- data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
- data/lib/pgbus/web/workflow_insights_report.rb +171 -0
- data/lib/pgbus/web/workflow_payload.rb +36 -0
- data/lib/pgbus/web/workflow_query.rb +82 -0
- data/lib/pgbus/web/workflow_state.rb +76 -0
- data/lib/pgbus/web/workflow_timeline.rb +113 -0
- data/lib/pgbus/web/workflow_window.rb +51 -0
- data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
- data/lib/pgbus/workflow/dsl.rb +105 -0
- data/lib/pgbus/workflow/error_capture.rb +63 -0
- data/lib/pgbus/workflow/exception_policy.rb +170 -0
- data/lib/pgbus/workflow/fan_out.rb +157 -0
- data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
- data/lib/pgbus/workflow/flow_control.rb +94 -0
- data/lib/pgbus/workflow/instance.rb +183 -0
- data/lib/pgbus/workflow/outside_verbs.rb +138 -0
- data/lib/pgbus/workflow/payload.rb +53 -0
- data/lib/pgbus/workflow/registry.rb +53 -0
- data/lib/pgbus/workflow/removed_step.rb +14 -0
- data/lib/pgbus/workflow/runner.rb +218 -0
- data/lib/pgbus/workflow/signal_matcher.rb +53 -0
- data/lib/pgbus/workflow/signals.rb +132 -0
- data/lib/pgbus/workflow/states.rb +119 -0
- data/lib/pgbus/workflow/step_context.rb +58 -0
- data/lib/pgbus/workflow/step_definition.rb +131 -0
- data/lib/pgbus/workflow/step_execution.rb +242 -0
- data/lib/pgbus/workflow/step_job.rb +28 -0
- data/lib/pgbus/workflow/step_outcome.rb +151 -0
- data/lib/pgbus/workflow/steps.rb +103 -0
- data/lib/pgbus/workflow/sweep.rb +156 -0
- data/lib/pgbus/workflow/test_helpers.rb +152 -0
- data/lib/pgbus/workflow/wait_timeout.rb +73 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +22 -0
- 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
|