pgbus 0.19.4 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +96 -7
  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/delivery.rb +49 -0
  64. data/lib/pgbus/active_job/executor.rb +3 -1
  65. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  66. data/lib/pgbus/configuration.rb +21 -3
  67. data/lib/pgbus/generators/migration_detector.rb +40 -0
  68. data/lib/pgbus/instrumentation.rb +32 -0
  69. data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
  70. data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
  71. data/lib/pgbus/mcp/rack_app.rb +7 -2
  72. data/lib/pgbus/mcp/runner.rb +2 -1
  73. data/lib/pgbus/mcp/serialization.rb +25 -0
  74. data/lib/pgbus/mcp/server.rb +53 -10
  75. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  76. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  77. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  78. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  79. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  80. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  81. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  82. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  83. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  84. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  85. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  86. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  87. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  88. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  89. data/lib/pgbus/mcp/write_tool.rb +134 -0
  90. data/lib/pgbus/mcp.rb +15 -0
  91. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  92. data/lib/pgbus/metrics/subscriber.rb +14 -1
  93. data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
  94. data/lib/pgbus/process/dispatcher.rb +26 -0
  95. data/lib/pgbus/version.rb +1 -1
  96. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  97. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  98. data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
  99. data/lib/pgbus/web/data_source.rb +3 -0
  100. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  101. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  102. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  103. data/lib/pgbus/web/workflow_payload.rb +36 -0
  104. data/lib/pgbus/web/workflow_query.rb +82 -0
  105. data/lib/pgbus/web/workflow_state.rb +76 -0
  106. data/lib/pgbus/web/workflow_timeline.rb +113 -0
  107. data/lib/pgbus/web/workflow_window.rb +51 -0
  108. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  109. data/lib/pgbus/workflow/dsl.rb +105 -0
  110. data/lib/pgbus/workflow/error_capture.rb +63 -0
  111. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  112. data/lib/pgbus/workflow/fan_out.rb +157 -0
  113. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  114. data/lib/pgbus/workflow/flow_control.rb +94 -0
  115. data/lib/pgbus/workflow/instance.rb +183 -0
  116. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  117. data/lib/pgbus/workflow/payload.rb +53 -0
  118. data/lib/pgbus/workflow/registry.rb +53 -0
  119. data/lib/pgbus/workflow/removed_step.rb +14 -0
  120. data/lib/pgbus/workflow/runner.rb +218 -0
  121. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  122. data/lib/pgbus/workflow/signals.rb +132 -0
  123. data/lib/pgbus/workflow/states.rb +119 -0
  124. data/lib/pgbus/workflow/step_context.rb +58 -0
  125. data/lib/pgbus/workflow/step_definition.rb +131 -0
  126. data/lib/pgbus/workflow/step_execution.rb +242 -0
  127. data/lib/pgbus/workflow/step_job.rb +28 -0
  128. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  129. data/lib/pgbus/workflow/steps.rb +103 -0
  130. data/lib/pgbus/workflow/sweep.rb +156 -0
  131. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  132. data/lib/pgbus/workflow/wait_timeout.rb +73 -0
  133. data/lib/pgbus/workflow/waiting.rb +82 -0
  134. data/lib/pgbus/workflow.rb +183 -0
  135. data/sig/pgbus/configuration.rbs +22 -0
  136. metadata +92 -1
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Pgbus
7
+ # The Workflows pages' words (issue #542): status badges, the "what happens
8
+ # next" sentence, the subject link, the Resume label, the audit-trail
9
+ # sentence and the attention reasons. Moments go through pgbus_time; the
10
+ # _html keys let the <time>, <code> and links through, translate escapes
11
+ # every other argument.
12
+ module WorkflowsHelper
13
+ BADGE_CSS = {
14
+ blue: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
15
+ gray: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
16
+ yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
17
+ green: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
18
+ red: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
19
+ }.freeze
20
+
21
+ # The outside verbs the show page has a button for (Send signal is its
22
+ # own form; pause and reschedule stay in the console for now).
23
+ DASHBOARD_ACTIONS = %i[resume resume_at run_now skip rerun cancel].freeze
24
+
25
+ # A link inside a sentence: inline (the button helper's inline-flex would
26
+ # break the sentence) and underlined; WCAG 2.5.8 exempts inline targets.
27
+ INLINE_LINK_CSS = "font-medium underline text-indigo-700 hover:text-indigo-900 dark:text-indigo-300 dark:hover:text-indigo-200"
28
+
29
+ # Text inputs, selects and textareas on the Workflows pages.
30
+ INPUT_CSS = "mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 " \
31
+ "px-2 py-1.5 text-sm text-gray-900 dark:text-white focus:border-indigo-500 focus:ring-indigo-500"
32
+
33
+ STEP_TONES = { "scheduled" => :gray, "waiting" => :yellow, "in_progress" => :blue, "completed" => :green,
34
+ "failed" => :red, "cancelled" => :gray, "skipped" => :gray }.freeze
35
+
36
+ SIGNAL_TONES = { "pending" => :yellow, "claimed" => :blue, "consumed" => :green }.freeze
37
+
38
+ # The verbs that need the run's class (its step list): resume!, resume_at!
39
+ # and rerun! raise StepNotDefined without it, and skip! would find no next
40
+ # step and finish the run.
41
+ CLASS_ACTIONS = %i[resume resume_at rerun skip].freeze
42
+
43
+ # The show page's buttons: available_actions it has a button for, without
44
+ # CLASS_ACTIONS when the run's class is not loaded here.
45
+ def pgbus_workflow_dashboard_actions(workflow)
46
+ actions = Array(workflow[:available_actions]) & DASHBOARD_ACTIONS
47
+ workflow[:class_loaded] == false ? actions - CLASS_ACTIONS : actions
48
+ end
49
+
50
+ def pgbus_workflow_state(row)
51
+ Web::WorkflowState.present(row, threshold: Pgbus.configuration.workflow_stall_threshold)
52
+ end
53
+
54
+ def pgbus_workflow_badge(result)
55
+ pgbus_workflow_tone_badge(t("pgbus.workflows.statuses.#{result.badge}"), result.badge_tone)
56
+ end
57
+
58
+ def pgbus_workflow_step_badge(status)
59
+ pgbus_workflow_tone_badge(t("pgbus.workflows.step_statuses.#{status}"), STEP_TONES.fetch(status.to_s, :gray))
60
+ end
61
+
62
+ def pgbus_workflow_signal_badge(status)
63
+ pgbus_workflow_tone_badge(t("pgbus.workflows.signal_statuses.#{status}"), SIGNAL_TONES.fetch(status.to_s, :gray))
64
+ end
65
+
66
+ def pgbus_workflow_tone_badge(label, tone)
67
+ tag.span(label, class: "#{ApplicationHelper::BATCH_BADGE_BASE} #{BADGE_CSS.fetch(tone)}")
68
+ end
69
+
70
+ # "Waiting for batch OrderWorkflow#notify_vendors (7 of 12 jobs done)."
71
+ def pgbus_workflow_next(row, result = pgbus_workflow_state(row))
72
+ args = result.next_args.to_h do |key, value|
73
+ case key
74
+ when :step, :signal then [key, tag.code(value, class: "font-mono")]
75
+ when :batch then [key, pgbus_workflow_batch_link(value, row[:batch_id])]
76
+ when :ago then [key, pgbus_time(value)]
77
+ when :time then [key, pgbus_time(value, clock: true)]
78
+ else [key, value]
79
+ end
80
+ end
81
+ translate("pgbus.workflows.next.#{result.next_key}_html", **args)
82
+ end
83
+
84
+ def pgbus_workflow_batch_link(name, batch_id)
85
+ label = tag.code(name, class: "font-mono")
86
+ batch_id ? link_to(label, pgbus.batch_path(batch_id), class: INLINE_LINK_CSS, data: { turbo_frame: "_top" }) : label
87
+ end
88
+
89
+ # "Order 1042", linked through config.workflow_subject_url when it gives
90
+ # an http(s) URL or a same-site path; plain text otherwise, and "—"
91
+ # for a subject-less run.
92
+ def pgbus_workflow_subject(row)
93
+ return Web::TimeFormat::NONE unless row[:subject_type]
94
+
95
+ label = "#{row[:subject_type]} #{row[:subject_id]}"
96
+ url = pgbus_workflow_subject_url(row[:subject_type], row[:subject_id])
97
+ url ? pgbus_link_to(label, url, data: { turbo_frame: "_top" }) : label
98
+ end
99
+
100
+ def pgbus_workflow_subject_url(type, id)
101
+ builder = Pgbus.configuration.workflow_subject_url
102
+ return nil unless builder
103
+
104
+ pgbus_workflow_safe_url(builder.call(type, id))
105
+ rescue StandardError => e
106
+ Pgbus.logger.error { "[Pgbus::Web] workflow_subject_url raised #{e.class}: #{e.message}" }
107
+ nil
108
+ end
109
+
110
+ # An absolute http(s) URL with a host, or a path on this site ("/…",
111
+ # never "//host" or "/\host"). Anything else (javascript:, data:, a
112
+ # relative path) is not linked.
113
+ def pgbus_workflow_safe_url(value)
114
+ url = value.to_s.strip
115
+ return nil if url.empty?
116
+ return url if url.start_with?("/") && !url.start_with?("//", "/\\")
117
+
118
+ uri = URI.parse(url)
119
+ %w[http https].include?(uri.scheme&.downcase) && uri.host.present? ? url : nil
120
+ rescue URI::InvalidURIError
121
+ nil
122
+ end
123
+
124
+ # The Resume button's label: what resume! will do (WorkflowActionPlan).
125
+ def pgbus_workflow_resume_label(workflow)
126
+ action = workflow[:resume_action]
127
+ return t("pgbus.workflows.actions.resume") unless action
128
+
129
+ t("pgbus.workflows.resume.#{action}", step: workflow[:resume_step])
130
+ end
131
+
132
+ # "Resumed by dashboard:alice: SMTP fixed", "Step failed by system (capture #2)".
133
+ def pgbus_workflow_event_sentence(event)
134
+ what = t("pgbus.workflows.events.#{event[:event]}", default: event[:event].to_s.humanize)
135
+ sentence = t("pgbus.workflows.audit.by", event: what, actor: event[:actor])
136
+ if event[:step_name]
137
+ sentence = t("pgbus.workflows.audit.with_step", sentence: sentence, step: event[:step_name], attempt: event[:attempt])
138
+ end
139
+ event[:reason].present? ? t("pgbus.workflows.audit.with_reason", sentence: sentence, reason: event[:reason]) : sentence
140
+ end
141
+
142
+ def pgbus_workflow_value(value)
143
+ value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
144
+ end
145
+
146
+ # The bulk confirm: "Resume all 3 runs paused at capture on Timeout::Error?"
147
+ def pgbus_workflow_bulk_confirm(action, group)
148
+ key = group[:error_class] ? "#{action}_error" : action.to_s
149
+ t("pgbus.workflows.attention.confirm.#{key}", count: group[:count], step: group[:step_name], error: group[:error_class])
150
+ end
151
+
152
+ # The index URL with the current query, merged with +extra+ (nil drops a key).
153
+ def pgbus_workflows_path_with(query, **extra)
154
+ pgbus.workflows_path(query.to_param(**extra))
155
+ end
156
+
157
+ # Series names and words the timeline chart prints (charts.js renderTimeline).
158
+ def pgbus_workflow_chart_i18n
159
+ series = %w[waited queued waiting paused ran_ok ran_failed ran_open ran_cancelled]
160
+ { series: series.index_with { |name| t("pgbus.workflows.timeline.series.#{name}") },
161
+ paused_row: t("pgbus.workflows.timeline.paused_row"),
162
+ statuses: Workflow::States::STEP.index_with { |status| t("pgbus.workflows.step_statuses.#{status}") } }
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ # One workflow run (pgbus_workflows). Pgbus::Workflow is the DSL and the
7
+ # handle; this is its row. Every status change goes through
8
+ # Pgbus::Workflow::Runner, as a guarded UPDATE under the row lock.
9
+ class WorkflowEntry < BusRecord
10
+ self.table_name = "pgbus_workflows"
11
+
12
+ scope :ongoing, -> { where(status: Workflow::States::ONGOING) }
13
+ scope :terminal, -> { where(status: Workflow::States::TERMINAL) }
14
+ scope :stale, ->(before:) { terminal.where(updated_at: ...before) }
15
+
16
+ # The run's row, locked FOR UPDATE for the rest of the transaction.
17
+ def self.lock_for(workflow_id)
18
+ lock.find_by(workflow_id: workflow_id)
19
+ end
20
+
21
+ # Single-winner status change: 1 when this call moved the row, 0 when
22
+ # its status was not in +from+.
23
+ def self.transition!(workflow_id, from:, to:, **attrs) # rubocop:disable Naming/MethodParameterName
24
+ where(workflow_id: workflow_id, status: from).update_all(attrs.merge(status: to, updated_at: Time.current))
25
+ end
26
+
27
+ # Inserts a run. With an idempotency_key a conflict on
28
+ # (workflow_class, idempotency_key) inserts nothing and returns false; a
29
+ # conflict on the one-ongoing-per-subject index raises
30
+ # ActiveRecord::RecordNotUnique. Raw SQL rather than
31
+ # insert_all(unique_by:), which resolves the index through the schema
32
+ # cache (issue #401).
33
+ def self.insert_run!(attrs) # rubocop:disable Naming/PredicateMethod
34
+ columns = attrs.keys
35
+ values = attrs.values.map { |value| value.is_a?(Hash) ? JSON.generate(value) : value }
36
+ placeholders = (1..columns.size).map { |i| "$#{i}" }.join(", ")
37
+ conflict = (" ON CONFLICT (workflow_class, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING" if attrs[:idempotency_key])
38
+ result = connection.exec_query(
39
+ "INSERT INTO #{table_name} (#{columns.join(", ")}) VALUES (#{placeholders})#{conflict} RETURNING id",
40
+ "WorkflowEntry Insert", values
41
+ )
42
+ result.rows.any?
43
+ end
44
+
45
+ # { [workflow_class, status] => count } over every run.
46
+ def self.status_counts
47
+ group(:workflow_class, :status).count
48
+ end
49
+
50
+ # Why ongoing runs need a person, as { [workflow_class, reason] => count }
51
+ # (Workflow::States::STUCK_REASONS). One query: each paused or ready run
52
+ # (and, with +waiting_threshold+, each waiting run) with its latest step row.
53
+ # stalled paused on a step that failed with StepStalled
54
+ # paused_error paused on a step that failed with any other error
55
+ # paused_manual paused otherwise (the step called pause!, or an outside pause!)
56
+ # overdue ready, its scheduled step due more than +threshold+ ago
57
+ # waiting_overdue waiting longer than +waiting_threshold+ on a step
58
+ # without a wait_timeout: (only with +waiting_threshold+,
59
+ # which needs the waiting-step columns)
60
+ # With +workflow_class+, that class's runs only (the workflow insights,
61
+ # issue #549).
62
+ def self.stuck_reason_counts(threshold:, now: Time.current, waiting_threshold: nil, workflow_class: nil)
63
+ binds = stuck_binds(threshold, now, waiting_threshold)
64
+ class_bind = "$#{binds.size + 1}" if workflow_class
65
+ sql = <<~SQL
66
+ SELECT stuck.workflow_class, stuck.reason, COUNT(*) AS runs
67
+ FROM (#{stuck_runs_sql(waiting: !waiting_threshold.nil?, class_bind: class_bind)}) stuck
68
+ WHERE stuck.reason IS NOT NULL
69
+ GROUP BY stuck.workflow_class, stuck.reason
70
+ SQL
71
+ connection.exec_query(sql, "WorkflowEntry Stuck", binds + [workflow_class].compact).rows.to_h do |klass, reason, count|
72
+ [[klass, reason], count.to_i]
73
+ end
74
+ end
75
+
76
+ # The dashboard's needs-attention view (issue #542): the stuck runs of
77
+ # stuck_reason_counts grouped by (class, step, reason, error class), the
78
+ # biggest group first, at most +limit+ groups. first_seen_at/last_seen_at
79
+ # bound when the group's runs got stuck (paused_at, the overdue step's
80
+ # scheduled_at, or the waiting step's waiting_since); error_message is
81
+ # the newest one's. With +workflow_class+, that class's groups only.
82
+ def self.attention_groups(threshold:, now: Time.current, waiting_threshold: nil, limit: 200, workflow_class: nil)
83
+ binds = stuck_binds(threshold, now, waiting_threshold)
84
+ class_bind = "$#{binds.size + 1}" if workflow_class
85
+ sql = <<~SQL
86
+ SELECT stuck.workflow_class, stuck.step_name, stuck.reason, stuck.error_class, COUNT(*) AS runs,
87
+ MIN(stuck.since) AS first_seen_at, MAX(stuck.since) AS last_seen_at,
88
+ (ARRAY_AGG(stuck.error_message ORDER BY stuck.since DESC NULLS LAST, stuck.id DESC))[1] AS error_message
89
+ FROM (#{stuck_runs_sql(waiting: !waiting_threshold.nil?, class_bind: class_bind)}) stuck
90
+ WHERE stuck.reason IS NOT NULL
91
+ GROUP BY stuck.workflow_class, stuck.step_name, stuck.reason, stuck.error_class
92
+ ORDER BY runs DESC, last_seen_at DESC NULLS LAST, stuck.workflow_class
93
+ LIMIT #{Integer(limit)}
94
+ SQL
95
+ result = connection.exec_query(sql, "WorkflowEntry Attention", binds + [workflow_class].compact)
96
+ result.cast_values.map do |klass, step_name, reason, error_class, count, first_seen, last_seen, message|
97
+ { workflow_class: klass, step_name: step_name, reason: reason, error_class: error_class, count: count.to_i,
98
+ first_seen_at: first_seen, last_seen_at: last_seen, error_message: message }
99
+ end
100
+ end
101
+
102
+ # The workflow_ids of one attention group (+group+: workflow_class,
103
+ # step_name, reason, error_class; a nil error_class matches runs without
104
+ # one), oldest stuck first, at most +limit+: what a bulk action walks.
105
+ def self.attention_ids(group, threshold:, now: Time.current, waiting_threshold: nil, limit: 500)
106
+ binds = stuck_binds(threshold, now, waiting_threshold)
107
+ first = binds.size + 1
108
+ sql = <<~SQL
109
+ SELECT stuck.workflow_id
110
+ FROM (#{stuck_runs_sql(waiting: !waiting_threshold.nil?)}) stuck
111
+ WHERE stuck.workflow_class = $#{first} AND stuck.step_name = $#{first + 1} AND stuck.reason = $#{first + 2}
112
+ AND stuck.error_class IS NOT DISTINCT FROM $#{first + 3}
113
+ ORDER BY stuck.since NULLS LAST, stuck.id
114
+ LIMIT #{Integer(limit)}
115
+ SQL
116
+ values = binds + group.values_at(:workflow_class, :step_name, :reason, :error_class)
117
+ connection.exec_query(sql, "WorkflowEntry Attention Ids", values).rows.map(&:first)
118
+ end
119
+
120
+ # Each paused or ready run (and, when +waiting+, each waiting run) with
121
+ # its latest step row and why it is stuck (NULL when it is not). Binds
122
+ # $1 (the overdue cutoff) and, when +waiting+, $2 (the waiting cutoff);
123
+ # +class_bind+ names the placeholder that narrows it to one class.
124
+ def self.stuck_runs_sql(waiting:, class_bind: nil)
125
+ <<~SQL
126
+ SELECT w.id, w.workflow_id, w.workflow_class, COALESCE(s.step_name, w.current_step) AS step_name,
127
+ s.error_class, s.error_message, stuck_reason.reason,
128
+ CASE
129
+ WHEN stuck_reason.reason IN ('stalled', 'paused_error', 'paused_manual') THEN w.paused_at
130
+ WHEN stuck_reason.reason = 'overdue' THEN s.scheduled_at
131
+ #{"WHEN stuck_reason.reason = 'waiting_overdue' THEN s.waiting_since" if waiting}
132
+ END AS since
133
+ FROM #{table_name} w
134
+ LEFT JOIN LATERAL (
135
+ SELECT step_name, status, error_class, error_message, scheduled_at#{", timeout_at, waiting_since" if waiting}
136
+ FROM #{WorkflowStep.table_name}
137
+ WHERE workflow_id = w.workflow_id ORDER BY id DESC LIMIT 1
138
+ ) s ON TRUE
139
+ CROSS JOIN LATERAL (
140
+ SELECT CASE
141
+ WHEN w.status = 'paused' AND s.status = 'failed' AND s.error_class = 'Pgbus::Workflow::StepStalled' THEN 'stalled'
142
+ WHEN w.status = 'paused' AND s.status = 'failed' THEN 'paused_error'
143
+ WHEN w.status = 'paused' THEN 'paused_manual'
144
+ WHEN w.status = 'ready' AND s.status = 'scheduled' AND s.scheduled_at < $1 THEN 'overdue'
145
+ #{"WHEN w.status = 'waiting' AND s.status = 'waiting' AND s.timeout_at IS NULL AND s.waiting_since < $2 THEN 'waiting_overdue'" if waiting}
146
+ END AS reason
147
+ ) stuck_reason
148
+ WHERE w.status IN ('paused', 'ready'#{", 'waiting'" if waiting})#{" AND w.workflow_class = #{class_bind}" if class_bind}
149
+ SQL
150
+ end
151
+ private_class_method :stuck_runs_sql
152
+
153
+ def self.stuck_binds(threshold, now, waiting_threshold)
154
+ waiting_threshold.nil? ? [now - threshold] : [now - threshold, now - waiting_threshold]
155
+ end
156
+ private_class_method :stuck_binds
157
+
158
+ # The Pgbus::Workflow subclass that started this run, or nil when it no
159
+ # longer exists.
160
+ def workflow_klass
161
+ klass = workflow_class.safe_constantize
162
+ klass if klass.is_a?(Class) && klass < Workflow
163
+ end
164
+
165
+ # True when the run started on a different step list than its class
166
+ # declares now (a step added, removed or reordered since).
167
+ def definition_outdated?
168
+ klass = workflow_klass
169
+ klass ? klass.definition_digest != definition_digest : false
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # The audit trail (pgbus_workflow_events): one row per transition and per
5
+ # operator action, with who did it (actor) and why (reason). Append-only:
6
+ # written by Pgbus::Workflow::Runner inside the transaction of the
7
+ # transition it records, read-only once inserted, removed with its run.
8
+ #
9
+ # Actors: "app" (an outside verb called without actor:), "system" (the
10
+ # StepJob, the sweep, crash detection), or what the caller passes
11
+ # ("dashboard:<name>", "mcp", "alice", ...).
12
+ class WorkflowEvent < BusRecord
13
+ self.table_name = "pgbus_workflow_events"
14
+
15
+ REASON_LIMIT = 1_000
16
+ DETAIL_LIMIT = 1_000
17
+
18
+ EVENTS = %w[
19
+ started step_scheduled step_started step_completed step_failed step_skipped step_reattempted
20
+ paused resumed cancelled finished run_now rescheduled rerun stall_detected late_completion_ignored
21
+ signal_received signal_claimed step_waiting wait_timed_out fan_out_opened fan_out_finished
22
+ ].freeze
23
+
24
+ def self.record!(workflow_id:, event:, actor:, reason: nil, step: nil, details: {})
25
+ create!(
26
+ workflow_id: workflow_id, event: event.to_s, actor: actor.to_s,
27
+ reason: reason && Workflow::ErrorCapture.truncate(reason.to_s, REASON_LIMIT),
28
+ step_id: step&.id, step_name: step&.step_name, attempt: step&.attempt,
29
+ details: cap(details), created_at: Time.current
30
+ )
31
+ end
32
+
33
+ # String values in details are capped, so one large error message cannot
34
+ # bloat the trail.
35
+ def self.cap(details)
36
+ details.to_h do |key, value|
37
+ [key.to_s, value.is_a?(String) ? Workflow::ErrorCapture.truncate(value, DETAIL_LIMIT) : value]
38
+ end
39
+ end
40
+
41
+ def readonly? = persisted? || super
42
+ end
43
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ # One signal delivered to a workflow run (pgbus_workflow_signals, issue
7
+ # #541): pending (buffered, no step has taken it), claimed (a wait_for:
8
+ # step took it and runs with it as received_signal) and consumed (that
9
+ # step ended). Written only by Pgbus::Workflow::Signals under the run's row
10
+ # lock.
11
+ #
12
+ # +payload+ reads back the signal! payload with indifferent access
13
+ # (through ActiveJob::Arguments, as workflow arguments do).
14
+ class WorkflowSignal < BusRecord
15
+ self.table_name = "pgbus_workflow_signals"
16
+
17
+ STATUSES = %w[pending claimed consumed].freeze
18
+
19
+ # Inserts a signal. A second signal with the same (workflow_id,
20
+ # idempotency_key) inserts nothing and returns nil. Raw SQL rather than
21
+ # insert_all(unique_by:), which resolves the index through the schema
22
+ # cache (issue #401), as WorkflowEntry.insert_run! does.
23
+ def self.insert_signal!(workflow_id:, name:, payload:, idempotency_key:, now: Time.current)
24
+ sql = <<~SQL
25
+ INSERT INTO #{table_name} (workflow_id, name, payload, idempotency_key, status, created_at)
26
+ VALUES ($1, $2, $3, $4, 'pending', $5)
27
+ ON CONFLICT (workflow_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
28
+ RETURNING id
29
+ SQL
30
+ result = connection.exec_query(sql, "WorkflowSignal Insert",
31
+ [workflow_id, name, JSON.generate(payload), idempotency_key, now])
32
+ id = result.rows.first&.first
33
+ id && find(id)
34
+ end
35
+
36
+ # The pending signals of a run (only +name+'s when given); iterate them
37
+ # oldest first with find_each, which batches by id.
38
+ def self.pending_for(workflow_id, name: nil)
39
+ scope = where(workflow_id: workflow_id, status: "pending")
40
+ name ? scope.where(name: name.to_s) : scope
41
+ end
42
+
43
+ # The signal an earlier attempt of +step_name+ claimed and did not
44
+ # consume (a reattempt, or a resume! after a failure, runs with it).
45
+ def self.held_by_step(workflow_id, step_name)
46
+ attempts = WorkflowStep.where(workflow_id: workflow_id, step_name: step_name.to_s).select(:id)
47
+ where(workflow_id: workflow_id, status: "claimed", claimed_by_step_id: attempts).order(:id).first
48
+ end
49
+
50
+ # pending → claimed, by +step_id+. Returns the number of rows moved.
51
+ def self.claim!(id, step_id:, now: Time.current)
52
+ where(id: id, status: "pending").update_all(status: "claimed", claimed_by_step_id: step_id, claimed_at: now)
53
+ end
54
+
55
+ # claimed → consumed. Returns the number of rows moved.
56
+ def self.consume!(id, now: Time.current)
57
+ where(id: id, status: "claimed").update_all(status: "consumed", consumed_at: now)
58
+ end
59
+
60
+ attr_writer :duplicate_delivery
61
+
62
+ # True on what signal! returned for a redelivery: a signal with the same
63
+ # idempotency key was already stored, and nothing else happened.
64
+ def duplicate_delivery? = @duplicate_delivery == true
65
+
66
+ def payload
67
+ return @payload if defined?(@payload)
68
+
69
+ @payload = Workflow::Payload.load(super) || {}.with_indifferent_access
70
+ end
71
+
72
+ def reload(...)
73
+ remove_instance_variable(:@payload) if defined?(@payload)
74
+ super
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Pgbus
6
+ # One step attempt (pgbus_workflow_steps). The unique (workflow_id,
7
+ # step_name, attempt) index makes a duplicate attempt impossible, and the
8
+ # guarded claim (scheduled → in_progress) makes a duplicate StepJob a no-op.
9
+ #
10
+ # The after-commit callback below is the ONLY place a
11
+ # Pgbus::Workflow::StepJob is enqueued: start!, an advance, a reattempt,
12
+ # resume!, run_now!, and the sweep all write a row (a new scheduled row, or
13
+ # a bumped enqueued_at) and let it fire once the write is visible, even
14
+ # inside an application transaction.
15
+ class WorkflowStep < BusRecord
16
+ self.table_name = "pgbus_workflow_steps"
17
+
18
+ after_save_commit :enqueue_step_job, if: :needs_enqueue?
19
+
20
+ # Inserts the next attempt of +step_name+: one more than the highest
21
+ # attempt so far (1 for a step that never ran).
22
+ def self.insert_next!(workflow_id:, workflow_class:, step_name:, scheduled_at:)
23
+ attempt = where(workflow_id: workflow_id, step_name: step_name).maximum(:attempt).to_i + 1
24
+ create!(workflow_id: workflow_id, workflow_class: workflow_class, step_name: step_name, attempt: attempt,
25
+ status: "scheduled", scheduled_at: scheduled_at, created_at: Time.current)
26
+ end
27
+
28
+ # scheduled → in_progress, only once the row is due (a second of slack
29
+ # for clock skew between the sender and this worker). A message that
30
+ # arrives early (left behind by reschedule! to a later time) claims
31
+ # nothing; the later send does. Returns the number of rows claimed.
32
+ def self.claim!(id, now:, stall_at:, msg_id:, worker:, app_revision:)
33
+ where(id: id, status: "scheduled").where(scheduled_at: ..(now + 1))
34
+ .update_all(status: "in_progress", started_at: now, stall_at: stall_at,
35
+ msg_id: msg_id, worker: worker, app_revision: app_revision)
36
+ end
37
+
38
+ # Records an attempt's outcome, only while the row is still in_progress.
39
+ # 0 means the attempt was declared stalled (or the run cancelled) while
40
+ # it was still running: its outcome is not applied.
41
+ def self.finish!(id, **attrs)
42
+ where(id: id, status: "in_progress").update_all(attrs)
43
+ end
44
+
45
+ # in_progress → waiting: the step parks on a signal or its fan-out batch
46
+ # (Workflow::Waiting). Returns the number of rows moved.
47
+ def self.park!(id, **attrs)
48
+ where(id: id, status: "in_progress").update_all(attrs.merge(status: "waiting"))
49
+ end
50
+
51
+ # waiting → in_progress, so the outcome of a finished wait (the batch
52
+ # finished, the wait timed out, an operator skipped it) is applied like
53
+ # any attempt's. Returns the number of rows moved.
54
+ def self.unpark!(id)
55
+ where(id: id, status: "waiting").update_all(status: "in_progress")
56
+ end
57
+
58
+ # waiting rows of waiting runs past their timeout_at (the sweep applies
59
+ # their on_timeout).
60
+ def self.wait_expired(now: Time.current)
61
+ where(status: "waiting").where(timeout_at: ...now)
62
+ .where(workflow_id: WorkflowEntry.where(status: "waiting").select(:workflow_id))
63
+ end
64
+
65
+ # The scheduled, waiting and in_progress rows of a run, cancelled (an
66
+ # outside cancel!, a resume_at!).
67
+ def self.cancel_open!(workflow_id, now: Time.current)
68
+ where(workflow_id: workflow_id, status: Workflow::States::OPEN_STEP)
69
+ .update_all(status: "cancelled", outcome: "cancelled", finished_at: now)
70
+ end
71
+
72
+ # The newest row of a run: the step it is on.
73
+ def self.latest_for(workflow_id)
74
+ where(workflow_id: workflow_id).order(id: :desc).first
75
+ end
76
+
77
+ # How many of +step_name+'s latest finished attempts in a row were
78
+ # reattempted (what max_reattempts counts). Any other outcome, such as a
79
+ # pause an operator resumed, ends the run of reattempts.
80
+ def self.consecutive_reattempts(workflow_id, step_name)
81
+ outcomes = where(workflow_id: workflow_id, step_name: step_name).where.not(outcome: nil)
82
+ .order(attempt: :desc).pluck(:outcome)
83
+ outcomes.take_while { |outcome| outcome == "reattempted" }.size
84
+ end
85
+
86
+ def self.completed_output(workflow_id, step_name)
87
+ where(workflow_id: workflow_id, step_name: step_name.to_s, status: "completed").order(attempt: :desc).pick(:output)
88
+ end
89
+
90
+ # { step_name => output } of each step's latest completed attempt.
91
+ def self.completed_outputs(workflow_id)
92
+ where(workflow_id: workflow_id, status: "completed").order(:attempt).pluck(:step_name, :output).to_h
93
+ end
94
+
95
+ # in_progress rows past their stall_at.
96
+ def self.stalled(now: Time.current)
97
+ where(status: "in_progress").where(stall_at: ...now)
98
+ end
99
+
100
+ # scheduled rows of ready runs, due more than +threshold+ ago and not
101
+ # sent within it: the sweep sends them again.
102
+ def self.overdue(threshold:, now: Time.current)
103
+ cutoff = now - threshold
104
+ where(status: "scheduled").where(scheduled_at: ...cutoff)
105
+ .where("enqueued_at IS NULL OR enqueued_at < ?", cutoff)
106
+ .where(workflow_id: WorkflowEntry.where(status: "ready").select(:workflow_id))
107
+ end
108
+
109
+ # Fires on a new scheduled row, on a change to scheduled, and on a
110
+ # bumped enqueued_at; never on any other save.
111
+ def needs_enqueue?
112
+ status == "scheduled" && (previously_new_record? || saved_change_to_status? || saved_change_to_enqueued_at?)
113
+ end
114
+
115
+ private
116
+
117
+ # Runs after commit, so a failure cannot roll back the row: it is
118
+ # reported, and the sweep sends the step again once it is
119
+ # workflow_stall_threshold overdue.
120
+ def enqueue_step_job
121
+ job = Workflow::StepJob.set(**step_job_set_options).perform_later(id)
122
+ update_columns(enqueued_at: Time.current, job_id: job.job_id) if job
123
+ rescue StandardError => e
124
+ Pgbus.logger.error do
125
+ "[Pgbus] Workflow StepJob for step #{id} (#{step_name}) could not be enqueued: #{e.class}: #{e.message}"
126
+ end
127
+ ErrorReporter.report(e, { action: "workflow_step_enqueue", workflow_class: workflow_class,
128
+ workflow_id: workflow_id, step_name: step_name, attempt: attempt })
129
+ end
130
+
131
+ # queue/priority from the class and the step (step wins), sent at
132
+ # scheduled_at (which already holds any job_options wait/wait_until, see
133
+ # Workflow::Runner.schedule!).
134
+ def step_job_set_options
135
+ klass = workflow_class.safe_constantize
136
+ options = klass.is_a?(Class) && klass < Workflow ? klass.step_job_options_for(step_name) : {}
137
+ set = { queue: options[:queue] || Pgbus.configuration.workflow_queue, priority: options[:priority] }
138
+ set[:wait_until] = scheduled_at if scheduled_at > Time.current
139
+ set.compact
140
+ end
141
+ end
142
+ end
@@ -130,6 +130,7 @@
130
130
  <%= pgbus_nav_link t("pgbus.layout.nav.processes"), pgbus.processes_path %>
131
131
  <%= pgbus_nav_link t("pgbus.layout.nav.events"), pgbus.events_path %>
132
132
  <%= pgbus_nav_link t("pgbus.layout.nav.batches"), pgbus.batches_path %>
133
+ <%= pgbus_nav_link t("pgbus.layout.nav.workflows"), pgbus.workflows_path %>
133
134
  <%= pgbus_nav_link t("pgbus.layout.nav.dlq"), pgbus.dead_letter_index_path %>
134
135
  <%= pgbus_nav_link t("pgbus.layout.nav.outbox"), pgbus.outbox_index_path %>
135
136
  <%= pgbus_nav_link t("pgbus.layout.nav.locks"), pgbus.locks_path %>
@@ -191,6 +192,7 @@
191
192
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.processes"), pgbus.processes_path %>
192
193
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.events"), pgbus.events_path %>
193
194
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.batches"), pgbus.batches_path %>
195
+ <%= pgbus_mobile_nav_link t("pgbus.layout.nav.workflows"), pgbus.workflows_path %>
194
196
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.dlq"), pgbus.dead_letter_index_path %>
195
197
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.outbox"), pgbus.outbox_index_path %>
196
198
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.locks"), pgbus.locks_path %>
@@ -77,6 +77,23 @@
77
77
  <p class="text-xs text-gray-600 dark:text-gray-300"><%= throughput[:available] ? t("pgbus.dashboard.stats_cards.throughput_detail", minutes: throughput[:window_minutes]) : t("pgbus.dashboard.stats_cards.throughput_unavailable") %></p>
78
78
  <% end %>
79
79
 
80
+ <%# Two links in one card (the list and the attention view), so the card
81
+ is a div; its title and number link to the list. %>
82
+ <% workflows = @workflow_counts %>
83
+ <div data-testid="workflows-card" class="<%= card %>">
84
+ <%= link_to pgbus.workflows_path, data: { turbo_frame: "_top" }, class: "block" do %>
85
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.dashboard.stats_cards.workflows") %></p>
86
+ <p class="<%= number %>"><%= number_with_delimiter(workflows[:ongoing]) %></p>
87
+ <% end %>
88
+ <p class="text-xs text-gray-600 dark:text-gray-300">
89
+ <%= t("pgbus.dashboard.stats_cards.workflows_detail", paused: workflows[:paused]) %>
90
+ <% if workflows[:attention].positive? %>
91
+ · <%= pgbus_link_to t("pgbus.dashboard.stats_cards.workflows_attention", count: workflows[:attention]),
92
+ pgbus.attention_workflows_path, size: :sm, tone: :yellow, data: { turbo_frame: "_top" } %>
93
+ <% end %>
94
+ </p>
95
+ </div>
96
+
80
97
  <%= link_to pgbus.locks_path, data: { turbo_frame: "_top" }, class: card do %>
81
98
  <p class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.dashboard.stats_cards.parked_jobs") %></p>
82
99
  <p class="mt-1 text-3xl font-semibold <%= @stats[:parked_total].to_i > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-900 dark:text-white' %>">