pgbus 0.19.4 → 0.20.1

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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +97 -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 +148 -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/blocked_execution.rb +12 -0
  16. data/app/models/pgbus/uniqueness_key.rb +88 -3
  17. data/app/models/pgbus/workflow_entry.rb +172 -0
  18. data/app/models/pgbus/workflow_event.rb +43 -0
  19. data/app/models/pgbus/workflow_signal.rb +77 -0
  20. data/app/models/pgbus/workflow_step.rb +149 -0
  21. data/app/views/layouts/pgbus/application.html.erb +2 -0
  22. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  23. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  24. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  25. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  26. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  27. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  28. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  29. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  30. data/app/views/pgbus/workflows/_timeline.html.erb +49 -0
  31. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  32. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  33. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  34. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  35. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  36. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  37. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  38. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  39. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  40. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  41. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  42. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  43. data/app/views/pgbus/workflows/index.html.erb +15 -0
  44. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  45. data/app/views/pgbus/workflows/show.html.erb +93 -0
  46. data/config/i18n-tasks.yml +6 -0
  47. data/config/locales/da.yml +445 -0
  48. data/config/locales/de.yml +445 -0
  49. data/config/locales/en.yml +445 -0
  50. data/config/locales/es.yml +445 -0
  51. data/config/locales/fi.yml +445 -0
  52. data/config/locales/fr.yml +445 -0
  53. data/config/locales/it.yml +445 -0
  54. data/config/locales/ja.yml +445 -0
  55. data/config/locales/nb.yml +445 -0
  56. data/config/locales/nl.yml +445 -0
  57. data/config/locales/pt.yml +445 -0
  58. data/config/locales/sv.yml +445 -0
  59. data/config/routes.rb +26 -0
  60. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  61. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  62. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  63. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  64. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  65. data/lib/pgbus/active_job/adapter.rb +81 -3
  66. data/lib/pgbus/active_job/delivery.rb +63 -0
  67. data/lib/pgbus/active_job/executor.rb +20 -8
  68. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +64 -4
  71. data/lib/pgbus/dead_letter_header.rb +9 -1
  72. data/lib/pgbus/generators/migration_detector.rb +40 -0
  73. data/lib/pgbus/instrumentation.rb +33 -0
  74. data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
  75. data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
  76. data/lib/pgbus/mcp/rack_app.rb +7 -2
  77. data/lib/pgbus/mcp/runner.rb +2 -1
  78. data/lib/pgbus/mcp/serialization.rb +25 -0
  79. data/lib/pgbus/mcp/server.rb +53 -10
  80. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  81. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  82. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  83. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  84. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  85. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  86. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  87. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  88. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  90. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  91. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  92. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  93. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  94. data/lib/pgbus/mcp/write_tool.rb +134 -0
  95. data/lib/pgbus/mcp.rb +15 -0
  96. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  97. data/lib/pgbus/metrics/subscriber.rb +14 -1
  98. data/lib/pgbus/metrics/workflow_metrics.rb +75 -0
  99. data/lib/pgbus/process/consumer.rb +2 -1
  100. data/lib/pgbus/process/dispatcher.rb +39 -3
  101. data/lib/pgbus/process/worker.rb +10 -1
  102. data/lib/pgbus/recurring/schedule.rb +1 -1
  103. data/lib/pgbus/streams/presence.rb +65 -36
  104. data/lib/pgbus/uniqueness.rb +23 -6
  105. data/lib/pgbus/version.rb +1 -1
  106. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  107. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  108. data/lib/pgbus/web/data_source/workflow_list.rb +291 -0
  109. data/lib/pgbus/web/data_source.rb +126 -15
  110. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  111. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  112. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  113. data/lib/pgbus/web/workflow_payload.rb +36 -0
  114. data/lib/pgbus/web/workflow_query.rb +82 -0
  115. data/lib/pgbus/web/workflow_state.rb +89 -0
  116. data/lib/pgbus/web/workflow_timeline.rb +130 -0
  117. data/lib/pgbus/web/workflow_window.rb +51 -0
  118. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  119. data/lib/pgbus/workflow/dsl.rb +105 -0
  120. data/lib/pgbus/workflow/error_capture.rb +63 -0
  121. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  122. data/lib/pgbus/workflow/fan_out.rb +157 -0
  123. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  124. data/lib/pgbus/workflow/flow_control.rb +94 -0
  125. data/lib/pgbus/workflow/instance.rb +183 -0
  126. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  127. data/lib/pgbus/workflow/payload.rb +53 -0
  128. data/lib/pgbus/workflow/registry.rb +53 -0
  129. data/lib/pgbus/workflow/removed_step.rb +14 -0
  130. data/lib/pgbus/workflow/runner.rb +218 -0
  131. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  132. data/lib/pgbus/workflow/signals.rb +132 -0
  133. data/lib/pgbus/workflow/states.rb +119 -0
  134. data/lib/pgbus/workflow/step_context.rb +58 -0
  135. data/lib/pgbus/workflow/step_definition.rb +133 -0
  136. data/lib/pgbus/workflow/step_execution.rb +242 -0
  137. data/lib/pgbus/workflow/step_job.rb +28 -0
  138. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  139. data/lib/pgbus/workflow/steps.rb +103 -0
  140. data/lib/pgbus/workflow/sweep.rb +156 -0
  141. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  142. data/lib/pgbus/workflow/wait_timeout.rb +94 -0
  143. data/lib/pgbus/workflow/waiting.rb +82 -0
  144. data/lib/pgbus/workflow.rb +183 -0
  145. data/sig/pgbus/configuration.rbs +23 -0
  146. 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, :late 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"), wait_timed_out: t("pgbus.workflows.events.wait_timed_out"),
162
+ statuses: Workflow::States::STEP.index_with { |status| t("pgbus.workflows.step_statuses.#{status}") } }
163
+ end
164
+ end
165
+ end
@@ -62,5 +62,17 @@ module Pgbus
62
62
  .limit(limit)
63
63
  .pluck(:concurrency_key)
64
64
  end
65
+
66
+ # The uniqueness keys, out of +lock_keys+, that a parked job carries. Its
67
+ # :until_executed row stays unbound until it runs and it sits in no queue,
68
+ # so the uniqueness reaper asks here before calling the row an orphan. A
69
+ # row parked before the double-encoding fix is read through its string,
70
+ # since the reaper can run before the promote sweep repairs it.
71
+ def self.parked_uniqueness_keys(lock_keys)
72
+ key = Uniqueness::METADATA_KEY
73
+ key_path = "CASE WHEN jsonb_typeof(payload) = 'string' " \
74
+ "THEN (payload #>> '{}')::jsonb ->> '#{key}' ELSE payload ->> '#{key}' END"
75
+ where("#{key_path} IN (?)", lock_keys).distinct.pluck(Arel.sql(key_path)).to_set
76
+ end
65
77
  end
66
78
  end
@@ -40,10 +40,11 @@ module Pgbus
40
40
  # Does not touch created_at — the reaper's age floor is from acquire time.
41
41
  # Restricted to this enqueue's unbound row (msg_id=0, matching created_at
42
42
  # when acquire! stamped one) so a completed job's bind cannot retarget a
43
- # successor that re-acquired the key.
44
- def self.bind!(lock_key, queue_name:, msg_id:)
43
+ # successor that re-acquired the key. A retry passes the stamp unbind!
44
+ # returned as +acquired_at+.
45
+ def self.bind!(lock_key, queue_name:, msg_id:, acquired_at: nil)
45
46
  stamps = Thread.current[:pgbus_uniqueness_created_at]
46
- created_at = stamps&.delete(lock_key)
47
+ created_at = acquired_at || stamps&.delete(lock_key)
47
48
  sql = "UPDATE #{table_name} SET queue_name = $2, msg_id = $3 " \
48
49
  "WHERE lock_key = $1 AND msg_id = 0"
49
50
  binds = [lock_key, queue_name, msg_id]
@@ -86,6 +87,90 @@ module Pgbus
86
87
  )
87
88
  end
88
89
 
90
+ # The row a message holds: bound to that message (queue + msg_id, see
91
+ # release_if_bound!), or still unbound — a parked job's row, or one whose
92
+ # bind failed — and acquired no later than the message was sent ($4). A
93
+ # lock is always taken before its message exists, so an unbound row
94
+ # acquired after the send is a successor's. created_at is a timestamp
95
+ # without time zone written in the session's zone, and the cast compares
96
+ # it in that same zone; PGMQ's enqueued_at string carries its offset.
97
+ # Unbound means msg_id <= 0 only: "pending" (the placeholder queue) is a
98
+ # name a real queue can have too, and a lock bound to one of its messages
99
+ # is bound.
100
+ HELD_BY_CONDITION = "lock_key = $1 AND ((queue_name = $2 AND msg_id = $3 AND msg_id > 0) OR " \
101
+ "(msg_id <= 0 AND created_at <= $4::timestamptz))"
102
+ private_constant :HELD_BY_CONDITION
103
+
104
+ # Release the lock a finishing message holds (success or DLQ). Unlike the
105
+ # key-only release!, it leaves a row that has moved on: handed to the
106
+ # message's retry (unbind!), or taken by a successor after this
107
+ # message's own row went. +enqueued_at+ is when that message was sent;
108
+ # without it only the row bound to the message goes (an unbound row could
109
+ # be a retry's it handed over), and the reaper takes an orphan.
110
+ def self.release_held_by!(lock_key, queue_name:, msg_id:, enqueued_at:)
111
+ Thread.current[:pgbus_uniqueness_created_at]&.delete(lock_key)
112
+ connection.exec_delete(
113
+ "DELETE FROM #{table_name} WHERE #{HELD_BY_CONDITION}",
114
+ "UniquenessKey Release Held By", held_by_binds(lock_key, queue_name, msg_id, sent_at_literal(enqueued_at))
115
+ )
116
+ end
117
+
118
+ # Hand the running attempt's lock to its retry, which goes out through the
119
+ # key the attempt still holds (issue #333) and never acquires one. Called
120
+ # before the retry exists, as a first enqueue acquires before its send:
121
+ # the row is left unbound on the retry's queue and stamped now, after the
122
+ # attempt was sent, so the attempt's release no longer counts it as its
123
+ # own while the retry's (sent later) does, even if the retry finishes
124
+ # before bind! points the row at it. Returns the new stamp for that
125
+ # bind!, or nil when the attempt no longer holds the key. Without the
126
+ # attempt's enqueued_at only a row bound to it is taken.
127
+ def self.unbind!(lock_key, from_queue_name:, from_msg_id:, from_enqueued_at:, queue_name:)
128
+ binds = held_by_binds(lock_key, from_queue_name, from_msg_id, sent_at_literal(from_enqueued_at))
129
+ result = connection.exec_query(
130
+ "UPDATE #{table_name} SET queue_name = $5, msg_id = 0, created_at = CURRENT_TIMESTAMP " \
131
+ "WHERE #{HELD_BY_CONDITION} RETURNING created_at",
132
+ "UniquenessKey Unbind", binds + [queue_name.to_s]
133
+ )
134
+ result.rows.first&.first
135
+ end
136
+
137
+ # release_held_by! for many messages at once — the dashboard's bulk
138
+ # discards and queue purges — as [lock_key, queue_name, msg_id,
139
+ # enqueued_at] tuples, one statement per thousand.
140
+ def self.release_all_held_by!(holders)
141
+ holders.each_slice(1000).sum do |slice|
142
+ values = slice.map do |lock_key, queue_name, msg_id, enqueued_at|
143
+ "(#{connection.quote(lock_key.to_s)}, #{connection.quote(queue_name.to_s)}, #{msg_id.to_i}, " \
144
+ "#{connection.quote(sent_at_literal(enqueued_at))}::timestamptz)"
145
+ end
146
+ connection.exec_delete(
147
+ "DELETE FROM #{table_name} AS u USING (VALUES #{values.join(", ")}) AS m(lock_key, queue_name, msg_id, sent_at) " \
148
+ "WHERE u.lock_key = m.lock_key AND ((u.queue_name = m.queue_name AND u.msg_id = m.msg_id AND u.msg_id > 0) OR " \
149
+ "(u.msg_id <= 0 AND u.created_at <= m.sent_at))",
150
+ "UniquenessKey Release All Held By"
151
+ )
152
+ end
153
+ end
154
+
155
+ def self.held_by_binds(lock_key, queue_name, msg_id, sent_at)
156
+ [lock_key, queue_name.to_s, msg_id.to_i, sent_at]
157
+ end
158
+ private_class_method :held_by_binds
159
+
160
+ # A message's enqueued_at as a timestamptz literal that carries its own
161
+ # offset. PGMQ hands over a String that already does; a Time (the
162
+ # dashboard reads it through ActiveRecord) or epoch seconds would
163
+ # otherwise be bound as zone-less digits and read in the session's zone.
164
+ # An unknown send time is -infinity: no unbound row counts as the
165
+ # message's own.
166
+ def self.sent_at_literal(enqueued_at)
167
+ sent_at = enqueued_at.is_a?(Numeric) ? Time.at(enqueued_at) : enqueued_at
168
+ return "-infinity" if sent_at.nil?
169
+
170
+ sent_at.respond_to?(:iso8601) ? sent_at.iso8601(6) : sent_at.to_s
171
+ end
172
+ private_class_method :sent_at_literal
173
+
89
174
  # Release a lock only while it is still UNBOUND (msg_id = 0) AND was
90
175
  # acquired no later than +acquired_before+. Used when discarding a parked
91
176
  # job: a parked job never got a msg_id, so its lock was never bound, and
@@ -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