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,150 @@
1
+ class AddPgbusWorkflows < ActiveRecord::Migration<%= migration_version %>
2
+ # if_not_exists throughout: pgbus:update queues this migration when any of
3
+ # the three core tables is missing, so a half-present schema must not fail
4
+ # it. The waiting-step columns and pgbus_workflow_signals (issue #541) are
5
+ # here too, so a fresh install needs no pgbus:add_workflow_signals.
6
+ # up/down rather than change: Rails 7.1 cannot reverse an add_foreign_key
7
+ # that names its primary_key, and dropping the tables drops the keys.
8
+ def up
9
+ # One row per workflow run (Pgbus::WorkflowEntry).
10
+ create_table :pgbus_workflows, if_not_exists: true do |t|
11
+ t.string :workflow_id, null: false
12
+ t.string :workflow_class, null: false
13
+ t.string :subject_type
14
+ t.string :subject_id
15
+ t.string :subject_gid
16
+ t.jsonb :arguments, null: false, default: {}
17
+ t.string :status, null: false, default: "ready"
18
+ t.string :current_step
19
+ t.jsonb :step_job_options, null: false, default: {}
20
+ t.string :error_class
21
+ t.text :error_message
22
+ t.text :error_backtrace
23
+ t.datetime :paused_at
24
+ t.datetime :finished_at
25
+ t.datetime :cancelled_at
26
+ t.string :idempotency_key
27
+ t.string :definition_digest, null: false
28
+ t.string :rerun_of_workflow_id
29
+ t.datetime :created_at, null: false
30
+ t.datetime :updated_at, null: false
31
+ end
32
+
33
+ add_index :pgbus_workflows, :workflow_id,
34
+ unique: true, name: "idx_pgbus_workflows_workflow_id", if_not_exists: true
35
+ add_index :pgbus_workflows, [:workflow_class, :subject_type, :subject_id],
36
+ unique: true, where: "status IN ('ready','performing','waiting','paused')",
37
+ name: "idx_pgbus_workflows_one_ongoing", if_not_exists: true
38
+ add_index :pgbus_workflows, [:workflow_class, :idempotency_key],
39
+ unique: true, where: "idempotency_key IS NOT NULL",
40
+ name: "idx_pgbus_workflows_idempotency", if_not_exists: true
41
+ add_index :pgbus_workflows, [:status, :updated_at],
42
+ name: "idx_pgbus_workflows_status", if_not_exists: true
43
+ add_index :pgbus_workflows, [:workflow_class, :status, :created_at],
44
+ name: "idx_pgbus_workflows_class_status", if_not_exists: true
45
+ add_index :pgbus_workflows, [:subject_type, :subject_id],
46
+ name: "idx_pgbus_workflows_subject", if_not_exists: true
47
+
48
+ # One row per step attempt (Pgbus::WorkflowStep).
49
+ create_table :pgbus_workflow_steps, if_not_exists: true do |t|
50
+ t.string :workflow_id, null: false
51
+ t.string :workflow_class, null: false
52
+ t.string :step_name, null: false
53
+ t.integer :attempt, null: false, default: 1
54
+ t.string :status, null: false, default: "scheduled"
55
+ t.string :job_id
56
+ t.bigint :msg_id
57
+ t.datetime :scheduled_at, null: false
58
+ t.datetime :enqueued_at
59
+ t.datetime :started_at
60
+ t.datetime :finished_at
61
+ t.datetime :stall_at
62
+ t.string :outcome
63
+ t.jsonb :output
64
+ t.string :error_class
65
+ t.text :error_message
66
+ t.text :error_backtrace
67
+ t.jsonb :error_causes
68
+ t.string :worker
69
+ t.string :app_revision
70
+ # Waiting steps (fan_out, wait_for:): also added by pgbus:add_workflow_signals.
71
+ t.string :batch_id
72
+ t.string :wait_for
73
+ t.bigint :signal_id
74
+ t.datetime :waiting_since
75
+ t.datetime :timeout_at
76
+ t.datetime :created_at, null: false
77
+ end
78
+
79
+ add_index :pgbus_workflow_steps, [:workflow_id, :step_name, :attempt],
80
+ unique: true, name: "idx_pgbus_workflow_steps_attempt", if_not_exists: true
81
+ add_index :pgbus_workflow_steps, [:status, :scheduled_at],
82
+ name: "idx_pgbus_workflow_steps_scheduled", if_not_exists: true
83
+ add_index :pgbus_workflow_steps, [:status, :stall_at],
84
+ name: "idx_pgbus_workflow_steps_stall", if_not_exists: true
85
+ add_index :pgbus_workflow_steps, [:workflow_class, :step_name, :finished_at],
86
+ name: "idx_pgbus_workflow_steps_insights", if_not_exists: true
87
+ # A steps table that predates waiting steps lacks these columns (the
88
+ # queued pgbus:add_workflow_signals adds them and their indexes).
89
+ if column_exists?(:pgbus_workflow_steps, :batch_id)
90
+ add_index :pgbus_workflow_steps, :batch_id,
91
+ name: "idx_pgbus_workflow_steps_batch", if_not_exists: true
92
+ end
93
+ if column_exists?(:pgbus_workflow_steps, :timeout_at)
94
+ add_index :pgbus_workflow_steps, [:status, :timeout_at],
95
+ name: "idx_pgbus_workflow_steps_timeout", if_not_exists: true
96
+ end
97
+ add_foreign_key :pgbus_workflow_steps, :pgbus_workflows,
98
+ column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade, if_not_exists: true
99
+
100
+ # Append-only audit trail: one row per transition or operator action
101
+ # (Pgbus::WorkflowEvent).
102
+ create_table :pgbus_workflow_events, if_not_exists: true do |t|
103
+ t.string :workflow_id, null: false
104
+ t.bigint :step_id
105
+ t.string :step_name
106
+ t.integer :attempt
107
+ t.string :event, null: false
108
+ t.string :actor, null: false
109
+ t.text :reason
110
+ t.jsonb :details, null: false, default: {}
111
+ t.datetime :created_at, null: false
112
+ end
113
+
114
+ add_index :pgbus_workflow_events, [:workflow_id, :id],
115
+ name: "idx_pgbus_workflow_events_workflow", if_not_exists: true
116
+ add_foreign_key :pgbus_workflow_events, :pgbus_workflows,
117
+ column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade, if_not_exists: true
118
+
119
+ # One row per signal delivered to a run (Pgbus::WorkflowSignal):
120
+ # pending → claimed → consumed.
121
+ create_table :pgbus_workflow_signals, if_not_exists: true do |t|
122
+ t.string :workflow_id, null: false
123
+ t.string :name, null: false
124
+ t.jsonb :payload, null: false, default: {}
125
+ t.string :idempotency_key
126
+ t.string :status, null: false, default: "pending"
127
+ t.bigint :claimed_by_step_id
128
+ t.datetime :created_at, null: false
129
+ t.datetime :claimed_at
130
+ t.datetime :consumed_at
131
+ end
132
+
133
+ add_index :pgbus_workflow_signals, [:workflow_id, :idempotency_key],
134
+ unique: true, where: "idempotency_key IS NOT NULL",
135
+ name: "idx_pgbus_workflow_signals_idempotency", if_not_exists: true
136
+ add_index :pgbus_workflow_signals, [:workflow_id, :name, :status],
137
+ name: "idx_pgbus_workflow_signals_pending", if_not_exists: true
138
+ add_index :pgbus_workflow_signals, [:workflow_id, :status, :claimed_by_step_id],
139
+ name: "idx_pgbus_workflow_signals_claimed", if_not_exists: true
140
+ add_foreign_key :pgbus_workflow_signals, :pgbus_workflows,
141
+ column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade, if_not_exists: true
142
+ end
143
+
144
+ def down
145
+ drop_table :pgbus_workflow_signals, if_exists: true
146
+ drop_table :pgbus_workflow_events, if_exists: true
147
+ drop_table :pgbus_workflow_steps, if_exists: true
148
+ drop_table :pgbus_workflows, if_exists: true
149
+ end
150
+ end
@@ -211,6 +211,129 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
211
211
  add_index :pgbus_deploy_task_runs, :run_at,
212
212
  name: "idx_pgbus_deploy_task_runs_cleanup"
213
213
 
214
+ # Workflows (Pgbus::Workflow): one row per run, one per step attempt, and
215
+ # the append-only audit trail. At most one ongoing run per (class, subject).
216
+ create_table :pgbus_workflows do |t|
217
+ t.string :workflow_id, null: false
218
+ t.string :workflow_class, null: false
219
+ t.string :subject_type
220
+ t.string :subject_id
221
+ t.string :subject_gid
222
+ t.jsonb :arguments, null: false, default: {}
223
+ t.string :status, null: false, default: "ready"
224
+ t.string :current_step
225
+ t.jsonb :step_job_options, null: false, default: {}
226
+ t.string :error_class
227
+ t.text :error_message
228
+ t.text :error_backtrace
229
+ t.datetime :paused_at
230
+ t.datetime :finished_at
231
+ t.datetime :cancelled_at
232
+ t.string :idempotency_key
233
+ t.string :definition_digest, null: false
234
+ t.string :rerun_of_workflow_id
235
+ t.datetime :created_at, null: false
236
+ t.datetime :updated_at, null: false
237
+ end
238
+
239
+ add_index :pgbus_workflows, :workflow_id,
240
+ unique: true, name: "idx_pgbus_workflows_workflow_id"
241
+ add_index :pgbus_workflows, [:workflow_class, :subject_type, :subject_id],
242
+ unique: true, where: "status IN ('ready','performing','waiting','paused')",
243
+ name: "idx_pgbus_workflows_one_ongoing"
244
+ add_index :pgbus_workflows, [:workflow_class, :idempotency_key],
245
+ unique: true, where: "idempotency_key IS NOT NULL",
246
+ name: "idx_pgbus_workflows_idempotency"
247
+ add_index :pgbus_workflows, [:status, :updated_at],
248
+ name: "idx_pgbus_workflows_status"
249
+ add_index :pgbus_workflows, [:workflow_class, :status, :created_at],
250
+ name: "idx_pgbus_workflows_class_status"
251
+ add_index :pgbus_workflows, [:subject_type, :subject_id],
252
+ name: "idx_pgbus_workflows_subject"
253
+
254
+ create_table :pgbus_workflow_steps do |t|
255
+ t.string :workflow_id, null: false
256
+ t.string :workflow_class, null: false
257
+ t.string :step_name, null: false
258
+ t.integer :attempt, null: false, default: 1
259
+ t.string :status, null: false, default: "scheduled"
260
+ t.string :job_id
261
+ t.bigint :msg_id
262
+ t.datetime :scheduled_at, null: false
263
+ t.datetime :enqueued_at
264
+ t.datetime :started_at
265
+ t.datetime :finished_at
266
+ t.datetime :stall_at
267
+ t.string :outcome
268
+ t.jsonb :output
269
+ t.string :error_class
270
+ t.text :error_message
271
+ t.text :error_backtrace
272
+ t.jsonb :error_causes
273
+ t.string :worker
274
+ t.string :app_revision
275
+ t.string :batch_id
276
+ t.string :wait_for
277
+ t.bigint :signal_id
278
+ t.datetime :waiting_since
279
+ t.datetime :timeout_at
280
+ t.datetime :created_at, null: false
281
+ end
282
+
283
+ add_index :pgbus_workflow_steps, [:workflow_id, :step_name, :attempt],
284
+ unique: true, name: "idx_pgbus_workflow_steps_attempt"
285
+ add_index :pgbus_workflow_steps, [:status, :scheduled_at],
286
+ name: "idx_pgbus_workflow_steps_scheduled"
287
+ add_index :pgbus_workflow_steps, [:status, :stall_at],
288
+ name: "idx_pgbus_workflow_steps_stall"
289
+ add_index :pgbus_workflow_steps, [:workflow_class, :step_name, :finished_at],
290
+ name: "idx_pgbus_workflow_steps_insights"
291
+ add_index :pgbus_workflow_steps, :batch_id,
292
+ name: "idx_pgbus_workflow_steps_batch"
293
+ add_index :pgbus_workflow_steps, [:status, :timeout_at],
294
+ name: "idx_pgbus_workflow_steps_timeout"
295
+ add_foreign_key :pgbus_workflow_steps, :pgbus_workflows,
296
+ column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade
297
+
298
+ create_table :pgbus_workflow_events do |t|
299
+ t.string :workflow_id, null: false
300
+ t.bigint :step_id
301
+ t.string :step_name
302
+ t.integer :attempt
303
+ t.string :event, null: false
304
+ t.string :actor, null: false
305
+ t.text :reason
306
+ t.jsonb :details, null: false, default: {}
307
+ t.datetime :created_at, null: false
308
+ end
309
+
310
+ add_index :pgbus_workflow_events, [:workflow_id, :id],
311
+ name: "idx_pgbus_workflow_events_workflow"
312
+ add_foreign_key :pgbus_workflow_events, :pgbus_workflows,
313
+ column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade
314
+
315
+ create_table :pgbus_workflow_signals do |t|
316
+ t.string :workflow_id, null: false
317
+ t.string :name, null: false
318
+ t.jsonb :payload, null: false, default: {}
319
+ t.string :idempotency_key
320
+ t.string :status, null: false, default: "pending"
321
+ t.bigint :claimed_by_step_id
322
+ t.datetime :created_at, null: false
323
+ t.datetime :claimed_at
324
+ t.datetime :consumed_at
325
+ end
326
+
327
+ add_index :pgbus_workflow_signals, [:workflow_id, :idempotency_key],
328
+ unique: true, where: "idempotency_key IS NOT NULL",
329
+ name: "idx_pgbus_workflow_signals_idempotency"
330
+ add_index :pgbus_workflow_signals, [:workflow_id, :name, :status],
331
+ name: "idx_pgbus_workflow_signals_pending"
332
+ add_index :pgbus_workflow_signals, [:workflow_id, :status, :claimed_by_step_id],
333
+ name: "idx_pgbus_workflow_signals_claimed"
334
+ add_foreign_key :pgbus_workflow_signals, :pgbus_workflows,
335
+ column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade
336
+
214
337
  # Stream queue registry — records which PGMQ queues back streams (vs jobs).
215
338
  # Stream and job queues share the pgbus_ namespace, so maintenance
216
339
  # (per-stream retention, orphan sweep) and wildcard workers rely on this
@@ -245,6 +368,10 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
245
368
  execute "SELECT pgmq.drop_queue('pgbus_default')"
246
369
 
247
370
  drop_table :pgbus_stream_queues
371
+ drop_table :pgbus_workflow_signals
372
+ drop_table :pgbus_workflow_events
373
+ drop_table :pgbus_workflow_steps
374
+ drop_table :pgbus_workflows
248
375
  drop_table :pgbus_deploy_task_runs
249
376
  drop_table :pgbus_deploy_tasks
250
377
  drop_table :pgbus_recurring_executions
@@ -80,6 +80,7 @@ module Pgbus
80
80
  # reset: once the send returns, msg_id is set and the rescue routes
81
81
  # on that instead.
82
82
  sending = false
83
+ retry_lock = hand_lock_to_retry(active_job, payload_hash, queue)
83
84
 
84
85
  if key && concurrency
85
86
  # The check and the park commit together, under the semaphore row
@@ -115,6 +116,9 @@ module Pgbus
115
116
  # Bind before backfill so a live message is never left with an unbound
116
117
  # uniqueness row if execution-row bookkeeping raises.
117
118
  bind_acquired_uniqueness_lock(queue, msg_id) if msg_id
119
+ bind_retry_lock(retry_lock, queue, msg_id) if msg_id
120
+ # Dropped by its concurrency limit: no retry will run, the attempt does.
121
+ return_retry_lock(retry_lock) unless msg_id || blocked
118
122
  Batch.backfill_execution(payload_hash, msg_id, physical_queue(queue, priority)) if msg_id
119
123
  # A retry re-enqueue that is now live (sent, or parked as a blocked
120
124
  # execution) must stop the original attempt from signalling completion.
@@ -135,6 +139,7 @@ module Pgbus
135
139
  # database error from the slot upsert or the park is not ambiguous.
136
140
  if msg_id.nil? && !(sending && ambiguous_delivery?(e))
137
141
  rollback_acquired_uniqueness_lock
142
+ return_retry_lock(retry_lock)
138
143
  uncount_batch_job(payload_hash, active_job)
139
144
  else
140
145
  # Drop the thread-local so a later discard on this thread cannot
@@ -247,9 +252,10 @@ module Pgbus
247
252
  # the executor still holds the key (it releases only on success/DLQ). So
248
253
  # a re-enqueue with executions > 0 would otherwise hit its own held key,
249
254
  # be rejected as a duplicate (JobNotUnique), and dead-letter the original
250
- # while losing the retry. Let the retry through; the existing key row
251
- # correctly stays held until the job finally succeeds or dead-letters.
252
- # See issue #333.
255
+ # while losing the retry. Let the retry through (issue #333); the key
256
+ # row is handed to it (hand_lock_to_retry, then bind_retry_lock once
257
+ # sent), so the attempt's release leaves it held until the job finally
258
+ # succeeds or dead-letters (issue #564).
253
259
  return false if active_job.executions.to_i.positive?
254
260
 
255
261
  result = Uniqueness.acquire_enqueue_lock(uniqueness_key, active_job, queue_name: queue)
@@ -285,6 +291,78 @@ module Pgbus
285
291
  Pgbus.logger.warn { "[Pgbus] Uniqueness bind failed: #{e.message}" }
286
292
  end
287
293
 
294
+ # The job's :until_executed key and the delivery of the attempt running
295
+ # it, when this enqueue is that attempt's retry: the retry is let
296
+ # through the key the attempt holds and never acquires one, so the key
297
+ # row has to follow it. nil for a first attempt (it acquired its own
298
+ # row), a retry enqueued outside the executor (no attempt to take it
299
+ # from), or a job run inline inside another job, whose delivery it sees.
300
+ def retry_uniqueness_lock(active_job, payload_hash)
301
+ key = Uniqueness.extract_key(payload_hash)
302
+ return unless key && Uniqueness.extract_strategy(payload_hash) == :until_executed
303
+ return unless active_job.executions.to_i.positive?
304
+
305
+ delivery = Delivery.current
306
+ [key, delivery] if delivery && delivery[:job_id] == active_job.job_id
307
+ end
308
+
309
+ # Take the attempt's row for its retry before the retry exists — sent or
310
+ # parked — the way a first enqueue acquires before its send (see
311
+ # UniquenessKey.unbind!). Returns [key, delivery, stamp] for
312
+ # bind_retry_lock / return_retry_lock, or nil when there is nothing to
313
+ # hand over. A failure is logged and the retry goes out without the row,
314
+ # which then stays with the attempt.
315
+ def hand_lock_to_retry(active_job, payload_hash, queue)
316
+ key, delivery = retry_uniqueness_lock(active_job, payload_hash)
317
+ return unless key
318
+
319
+ stamp = UniquenessKey.unbind!(key, from_queue_name: Uniqueness.lock_queue_name(delivery[:queue_name]),
320
+ from_msg_id: delivery[:msg_id], from_enqueued_at: delivery[:enqueued_at],
321
+ queue_name: Uniqueness.lock_queue_name(queue))
322
+ return [key, delivery, stamp] if stamp
323
+
324
+ warn_retry_lock_not_held(key)
325
+ nil
326
+ rescue StandardError => e
327
+ Pgbus.logger.warn { "[Pgbus] Uniqueness handoff to retry failed for #{key}: #{e.message}" }
328
+ nil
329
+ end
330
+
331
+ # Point the handed-over row at the sent retry. The retry is live, so a
332
+ # failure is logged, not raised: the row stays unbound and stamped
333
+ # before the send, and the retry's own release takes it.
334
+ def bind_retry_lock(retry_lock, queue, msg_id)
335
+ return unless retry_lock
336
+
337
+ key, _delivery, stamp = retry_lock
338
+ UniquenessKey.bind!(key, queue_name: Uniqueness.lock_queue_name(queue), msg_id: msg_id, acquired_at: stamp)
339
+ rescue StandardError => e
340
+ Pgbus.logger.warn { "[Pgbus] Uniqueness bind to retry failed for #{key}: #{e.message}" }
341
+ end
342
+
343
+ # No retry came of it (dropped by its concurrency limit, or the send
344
+ # failed before any message existed): bind the row back to the attempt,
345
+ # which runs on and releases it when it finishes.
346
+ def return_retry_lock(retry_lock)
347
+ return unless retry_lock
348
+
349
+ key, delivery, stamp = retry_lock
350
+ UniquenessKey.bind!(key, queue_name: Uniqueness.lock_queue_name(delivery[:queue_name]),
351
+ msg_id: delivery[:msg_id], acquired_at: stamp)
352
+ rescue StandardError => e
353
+ Pgbus.logger.warn { "[Pgbus] Uniqueness return to the attempt failed for #{key}: #{e.message}" }
354
+ end
355
+
356
+ # The row is gone (released by an operator, reaped) or a successor's:
357
+ # the retry runs without the key, and a fresh enqueue is not deduped
358
+ # against it.
359
+ def warn_retry_lock_not_held(key)
360
+ Pgbus.logger.warn do
361
+ "[Pgbus] Uniqueness key #{key} is no longer held by the attempt that enqueued its retry; " \
362
+ "the retry runs without it"
363
+ end
364
+ end
365
+
288
366
  # Reverses inject_batch_metadata for a job discarded at enqueue time
289
367
  # (uniqueness duplicate or concurrency :discard conflict): the message is
290
368
  # never sent, so it can never signal completion. Only applies to a job
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module ActiveJob
5
+ # The PGMQ delivery of the job running in this fiber:
6
+ #
7
+ # Pgbus::ActiveJob::Delivery.current
8
+ # # => { msg_id: 42, read_ct: 2, queue_name: "default",
9
+ # # enqueued_at: "2026-10-18 10:30:00+00", job_id: "8f0c…" }
10
+ #
11
+ # Set by Executor#execute around perform_now and cleared after, also when
12
+ # the job raises; nil outside the executor (perform_now in a test, a
13
+ # top-level inline-adapter job). A job run inline from inside an executing
14
+ # job sees the enclosing job's delivery; StepJob only acts on it when the
15
+ # step row's msg_id matches, and the adapter only when job_id is its own,
16
+ # so that is harmless. Fiber-local (Thread.current[]), so concurrent jobs under
17
+ # execution_mode: :async each see their own. Pgbus::Workflow::StepJob reads
18
+ # read_ct to tell a redelivery after a worker died from a first delivery;
19
+ # the adapter reads the message identity and enqueued_at to move the
20
+ # job's uniqueness lock onto a retry the job enqueues (issue #564).
21
+ #
22
+ # The values sit in one fiber-local each and the Hash is built only
23
+ # when read: setting them allocates nothing, which keeps the executor's
24
+ # per-job allocation budget (spec/pgbus/active_job/executor_allocation_budget_spec.rb).
25
+ module Delivery
26
+ MSG_ID_KEY = :pgbus_delivery_msg_id
27
+ READ_CT_KEY = :pgbus_delivery_read_ct
28
+ QUEUE_NAME_KEY = :pgbus_delivery_queue_name
29
+ ENQUEUED_AT_KEY = :pgbus_delivery_enqueued_at
30
+ JOB_ID_KEY = :pgbus_delivery_job_id
31
+
32
+ module_function
33
+
34
+ def current
35
+ msg_id = Thread.current[MSG_ID_KEY]
36
+ return nil if msg_id.nil?
37
+
38
+ { msg_id: msg_id, read_ct: Thread.current[READ_CT_KEY], queue_name: Thread.current[QUEUE_NAME_KEY],
39
+ enqueued_at: Thread.current[ENQUEUED_AT_KEY], job_id: Thread.current[JOB_ID_KEY] }
40
+ end
41
+
42
+ def with(msg_id:, read_ct:, queue_name:, enqueued_at: nil, job_id: nil)
43
+ previous_msg_id = Thread.current[MSG_ID_KEY]
44
+ previous_read_ct = Thread.current[READ_CT_KEY]
45
+ previous_queue_name = Thread.current[QUEUE_NAME_KEY]
46
+ previous_enqueued_at = Thread.current[ENQUEUED_AT_KEY]
47
+ previous_job_id = Thread.current[JOB_ID_KEY]
48
+ Thread.current[MSG_ID_KEY] = msg_id
49
+ Thread.current[READ_CT_KEY] = read_ct
50
+ Thread.current[QUEUE_NAME_KEY] = queue_name
51
+ Thread.current[ENQUEUED_AT_KEY] = enqueued_at
52
+ Thread.current[JOB_ID_KEY] = job_id
53
+ yield
54
+ ensure
55
+ Thread.current[MSG_ID_KEY] = previous_msg_id
56
+ Thread.current[READ_CT_KEY] = previous_read_ct
57
+ Thread.current[QUEUE_NAME_KEY] = previous_queue_name
58
+ Thread.current[ENQUEUED_AT_KEY] = previous_enqueued_at
59
+ Thread.current[JOB_ID_KEY] = previous_job_id
60
+ end
61
+ end
62
+ end
63
+ end
@@ -33,7 +33,7 @@ module Pgbus
33
33
  FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
34
34
  signal_concurrency(payload)
35
35
  signal_batch_discarded(payload)
36
- Uniqueness.release_lock(Uniqueness.extract_key(payload))
36
+ release_uniqueness_lock(Uniqueness.extract_key(payload), queue_name, message)
37
37
  record_stat(payload, queue_name, "dead_lettered", execution_start, message: message)
38
38
  instrument(
39
39
  "pgbus.job_dead_lettered",
@@ -99,7 +99,12 @@ module Pgbus
99
99
  # `batch` (and `batch.enqueue` for open batches) work inside a job.
100
100
  assign_batch_id(job, payload)
101
101
  Pgbus.logger.debug { "[Pgbus::Executor] running #{log_tag(message, queue_name)} job_class=#{job_class}" }
102
- with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload) { execute_job(job) }
102
+ with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload) do
103
+ Delivery.with(msg_id: msg_id, read_ct: read_count, queue_name: queue_name,
104
+ enqueued_at: message.enqueued_at, job_id: payload["job_id"]) do
105
+ execute_job(job)
106
+ end
107
+ end
103
108
  # retry_on re-enqueues from inside perform_now and returns normally:
104
109
  # this attempt is done (archive it) but the job is not — the retry
105
110
  # message carries the batch tag and signals on its own outcome.
@@ -119,7 +124,7 @@ module Pgbus
119
124
  end
120
125
  Pgbus.logger.debug { "[Pgbus::Executor] archived #{log_tag(message, queue_name)} job_class=#{job_class}" }
121
126
  job_succeeded = true
122
- release_uniqueness_lock(uniqueness_key)
127
+ release_uniqueness_lock(uniqueness_key, queue_name, message)
123
128
  # Only FailedEventRecorder.record! writes this table, and only after a
124
129
  # failed attempt, so a first delivery has no row: skip the per-job
125
130
  # DELETE round trip (issue #484). A stale row from a dropped-and-
@@ -144,7 +149,7 @@ module Pgbus
144
149
  # A failed :while_executing attempt is no longer executing — release
145
150
  # so the retry (same message, after VT) can acquire. :until_executed
146
151
  # keeps its lock until success/DLQ by design (#126, #333).
147
- release_uniqueness_lock(uniqueness_key) if uniqueness_strategy == :while_executing
152
+ release_uniqueness_lock(uniqueness_key, queue_name, message) if uniqueness_strategy == :while_executing
148
153
  instrument(
149
154
  "pgbus.job_failed",
150
155
  queue: queue_name,
@@ -190,10 +195,15 @@ module Pgbus
190
195
  job.batch_id = batch_id
191
196
  end
192
197
 
193
- def release_uniqueness_lock(key)
198
+ # Releases only the row this message holds, never the key as such: a
199
+ # row the job moved onto its retry (issue #564), or a successor's, stays.
200
+ # The queue is spelled as the row was bound (Uniqueness.lock_queue_name),
201
+ # as for release_duplicate_execution_lock.
202
+ def release_uniqueness_lock(key, queue_name, message)
194
203
  return unless key
195
204
 
196
- Uniqueness.release_lock(key)
205
+ UniquenessKey.release_held_by!(key, queue_name: Uniqueness.lock_queue_name(queue_name, config: config),
206
+ msg_id: message.msg_id.to_i, enqueued_at: message.enqueued_at)
197
207
  rescue StandardError => e
198
208
  Pgbus.logger.warn { "[Pgbus] Uniqueness release failed: #{e.message}" }
199
209
  end
@@ -423,7 +433,8 @@ module Pgbus
423
433
 
424
434
  # Same queue the execution lock was acquired under, so the match
425
435
  # cannot land on another queue's message of the same id.
426
- UniquenessKey.release_if_bound!(uniqueness_key, queue_name: queue_name, msg_id: msg_id)
436
+ UniquenessKey.release_if_bound!(uniqueness_key, queue_name: Uniqueness.lock_queue_name(queue_name, config: config),
437
+ msg_id: msg_id)
427
438
  rescue StandardError => e
428
439
  Pgbus.logger.warn { "[Pgbus] Duplicate-execution lock release failed: #{e.message}" }
429
440
  end
@@ -440,7 +451,8 @@ module Pgbus
440
451
  end
441
452
  headers = DeadLetterHeader.build(
442
453
  existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "worker",
443
- source_queue: source_queue || config.queue_name(queue_name), attempts: message.read_ct.to_i,
454
+ source_queue: source_queue || config.queue_name(queue_name), source_msg_id: message.msg_id.to_i,
455
+ source_enqueued_at: message.enqueued_at, attempts: message.read_ct.to_i,
444
456
  max_retries: config.max_retries, error: last_error
445
457
  )
446
458
  if source_queue
@@ -16,13 +16,21 @@ module Pgbus
16
16
  # once. Handing it a pre-serialized String stored a JSON *string*
17
17
  # (double-encoded), which every document reader of the column —
18
18
  # `payload->>'job_id'`, the job-class lookup, `scheduled_at` — misread.
19
+ #
20
+ # created_at is left to the column's CURRENT_TIMESTAMP default: the
21
+ # job's uniqueness lock is stamped by the database too, and discarding
22
+ # a parked job releases only a lock acquired no later than the park
23
+ # (UniquenessKey.release_if_unbound!), so both sides need one clock.
19
24
  def insert(concurrency_key:, queue_name:, payload:, duration:, priority: 0)
20
- Pgbus::BlockedExecution.create!(
21
- concurrency_key: concurrency_key,
22
- queue_name: queue_name,
23
- payload: payload,
24
- priority: priority,
25
- expires_at: Time.current + duration
25
+ Pgbus::BlockedExecution.insert!(
26
+ {
27
+ concurrency_key: concurrency_key,
28
+ queue_name: queue_name,
29
+ payload: payload,
30
+ priority: priority,
31
+ expires_at: Time.current + duration
32
+ },
33
+ record_timestamps: false
26
34
  )
27
35
  end
28
36