pgbus 0.19.3 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +104 -9
  4. data/Rakefile +6 -1
  5. data/app/controllers/pgbus/dashboard_controller.rb +2 -0
  6. data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
  7. data/app/controllers/pgbus/workflows_controller.rb +197 -0
  8. data/app/frontend/pgbus/modules/charts.js +130 -4
  9. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  10. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  11. data/app/frontend/pgbus/style.css +1 -1
  12. data/app/helpers/pgbus/button_helper.rb +4 -2
  13. data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
  14. data/app/helpers/pgbus/workflows_helper.rb +165 -0
  15. data/app/models/pgbus/workflow_entry.rb +172 -0
  16. data/app/models/pgbus/workflow_event.rb +43 -0
  17. data/app/models/pgbus/workflow_signal.rb +77 -0
  18. data/app/models/pgbus/workflow_step.rb +142 -0
  19. data/app/views/layouts/pgbus/application.html.erb +2 -0
  20. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  21. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  22. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  23. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  24. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  25. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  26. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  27. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  28. data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
  29. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  30. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  31. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  32. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  33. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  34. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  35. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  36. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  37. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  38. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  39. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  40. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  41. data/app/views/pgbus/workflows/index.html.erb +15 -0
  42. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  43. data/app/views/pgbus/workflows/show.html.erb +93 -0
  44. data/config/i18n-tasks.yml +6 -0
  45. data/config/locales/da.yml +442 -0
  46. data/config/locales/de.yml +442 -0
  47. data/config/locales/en.yml +442 -0
  48. data/config/locales/es.yml +442 -0
  49. data/config/locales/fi.yml +442 -0
  50. data/config/locales/fr.yml +442 -0
  51. data/config/locales/it.yml +442 -0
  52. data/config/locales/ja.yml +442 -0
  53. data/config/locales/nb.yml +442 -0
  54. data/config/locales/nl.yml +442 -0
  55. data/config/locales/pt.yml +442 -0
  56. data/config/locales/sv.yml +442 -0
  57. data/config/routes.rb +26 -0
  58. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  59. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  60. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  61. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  62. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  63. data/lib/pgbus/active_job/adapter.rb +67 -26
  64. data/lib/pgbus/active_job/batch_id.rb +23 -1
  65. data/lib/pgbus/active_job/delivery.rb +49 -0
  66. data/lib/pgbus/active_job/executor.rb +3 -1
  67. data/lib/pgbus/batch/builder.rb +1 -0
  68. data/lib/pgbus/batch.rb +35 -3
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +21 -3
  71. data/lib/pgbus/generators/migration_detector.rb +40 -0
  72. data/lib/pgbus/instrumentation.rb +32 -0
  73. data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
  74. data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
  75. data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
  76. data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
  77. data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
  78. data/lib/pgbus/mcp/rack_app.rb +7 -2
  79. data/lib/pgbus/mcp/runner.rb +2 -1
  80. data/lib/pgbus/mcp/serialization.rb +25 -0
  81. data/lib/pgbus/mcp/server.rb +53 -10
  82. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  83. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  84. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  85. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  86. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  87. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  88. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  90. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  91. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  92. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  93. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  94. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  95. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  96. data/lib/pgbus/mcp/write_tool.rb +134 -0
  97. data/lib/pgbus/mcp.rb +15 -0
  98. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  99. data/lib/pgbus/metrics/subscriber.rb +14 -1
  100. data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
  101. data/lib/pgbus/process/dispatcher.rb +26 -0
  102. data/lib/pgbus/version.rb +1 -1
  103. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  104. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  105. data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
  106. data/lib/pgbus/web/data_source.rb +3 -0
  107. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  108. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  109. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  110. data/lib/pgbus/web/workflow_payload.rb +36 -0
  111. data/lib/pgbus/web/workflow_query.rb +82 -0
  112. data/lib/pgbus/web/workflow_state.rb +76 -0
  113. data/lib/pgbus/web/workflow_timeline.rb +113 -0
  114. data/lib/pgbus/web/workflow_window.rb +51 -0
  115. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  116. data/lib/pgbus/workflow/dsl.rb +105 -0
  117. data/lib/pgbus/workflow/error_capture.rb +63 -0
  118. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  119. data/lib/pgbus/workflow/fan_out.rb +157 -0
  120. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  121. data/lib/pgbus/workflow/flow_control.rb +94 -0
  122. data/lib/pgbus/workflow/instance.rb +183 -0
  123. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  124. data/lib/pgbus/workflow/payload.rb +53 -0
  125. data/lib/pgbus/workflow/registry.rb +53 -0
  126. data/lib/pgbus/workflow/removed_step.rb +14 -0
  127. data/lib/pgbus/workflow/runner.rb +218 -0
  128. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  129. data/lib/pgbus/workflow/signals.rb +132 -0
  130. data/lib/pgbus/workflow/states.rb +119 -0
  131. data/lib/pgbus/workflow/step_context.rb +58 -0
  132. data/lib/pgbus/workflow/step_definition.rb +131 -0
  133. data/lib/pgbus/workflow/step_execution.rb +242 -0
  134. data/lib/pgbus/workflow/step_job.rb +28 -0
  135. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  136. data/lib/pgbus/workflow/steps.rb +103 -0
  137. data/lib/pgbus/workflow/sweep.rb +156 -0
  138. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  139. data/lib/pgbus/workflow/wait_timeout.rb +73 -0
  140. data/lib/pgbus/workflow/waiting.rb +82 -0
  141. data/lib/pgbus/workflow.rb +183 -0
  142. data/sig/pgbus/configuration.rbs +22 -0
  143. metadata +95 -1
@@ -0,0 +1,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
@@ -11,14 +11,16 @@ module Pgbus
11
11
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
12
12
  payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
13
13
  payload_hash = FairShare.inject_metadata(active_job, payload_hash)
14
- payload_hash = inject_batch_metadata(payload_hash, active_job: active_job)
14
+ payload_hash = inject_batch_metadata(payload_hash, active_job)
15
15
 
16
16
  if uniqueness_rejected?(active_job, payload_hash, queue: queue)
17
- uncount_batch_job(payload_hash)
17
+ uncount_batch_job(payload_hash, active_job)
18
18
  return active_job
19
19
  end
20
20
 
21
21
  enqueue_with_concurrency(active_job, queue, payload_hash)
22
+ ensure
23
+ forget_enqueue_batch_id(active_job)
22
24
  end
23
25
 
24
26
  def enqueue_at(active_job, timestamp)
@@ -27,15 +29,17 @@ module Pgbus
27
29
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
28
30
  payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
29
31
  payload_hash = FairShare.inject_metadata(active_job, payload_hash)
30
- payload_hash = inject_batch_metadata(payload_hash, active_job: active_job)
32
+ payload_hash = inject_batch_metadata(payload_hash, active_job)
31
33
  delay = [(timestamp - Time.current.to_f).ceil, 0].max
32
34
 
33
35
  if uniqueness_rejected?(active_job, payload_hash, queue: queue)
34
- uncount_batch_job(payload_hash)
36
+ uncount_batch_job(payload_hash, active_job)
35
37
  return active_job
36
38
  end
37
39
 
38
40
  enqueue_with_concurrency(active_job, queue, payload_hash, delay: delay)
41
+ ensure
42
+ forget_enqueue_batch_id(active_job)
39
43
  end
40
44
 
41
45
  def enqueue_all(active_jobs)
@@ -114,7 +118,7 @@ module Pgbus
114
118
  Batch.backfill_execution(payload_hash, msg_id, physical_queue(queue, priority)) if msg_id
115
119
  # A retry re-enqueue that is now live (sent, or parked as a blocked
116
120
  # execution) must stop the original attempt from signalling completion.
117
- Batch.note_retry_reenqueued(payload_hash["job_id"]) if (msg_id || blocked) && retry_retagged?(payload_hash)
121
+ Batch.note_retry_reenqueued(payload_hash["job_id"]) if (msg_id || blocked) && retry_retagged?(payload_hash, active_job)
118
122
  uniqueness_key = Thread.current[:pgbus_acquired_uniqueness_key]
119
123
  UniquenessKey.clear_bind_stamp!(uniqueness_key) if uniqueness_key
120
124
  Thread.current[:pgbus_acquired_uniqueness_key] = nil
@@ -131,7 +135,7 @@ module Pgbus
131
135
  # database error from the slot upsert or the park is not ambiguous.
132
136
  if msg_id.nil? && !(sending && ambiguous_delivery?(e))
133
137
  rollback_acquired_uniqueness_lock
134
- uncount_batch_job(payload_hash)
138
+ uncount_batch_job(payload_hash, active_job)
135
139
  else
136
140
  # Drop the thread-local so a later discard on this thread cannot
137
141
  # release that job's uniqueness lock, but do not DELETE the
@@ -210,7 +214,7 @@ module Pgbus
210
214
  # acquired earlier in this enqueue (no executor will release it), and
211
215
  # uncount it from its batch so completion is not waited on forever.
212
216
  rollback_acquired_uniqueness_lock
213
- uncount_batch_job(payload_hash)
217
+ uncount_batch_job(payload_hash, active_job)
214
218
  when :raise
215
219
  raise ConcurrencyLimitExceeded, "Concurrency limit reached for key: #{key}"
216
220
  end
@@ -283,25 +287,39 @@ module Pgbus
283
287
 
284
288
  # Reverses inject_batch_metadata for a job discarded at enqueue time
285
289
  # (uniqueness duplicate or concurrency :discard conflict): the message is
286
- # never sent, so it can never signal completion. Only applies while the
287
- # tagging batch's block is still active on this thread.
288
- def uncount_batch_job(payload_hash)
290
+ # never sent, so it can never signal completion. Only applies to a job
291
+ # counted in as a member (see #member_batch_id).
292
+ def uncount_batch_job(payload_hash, active_job)
289
293
  batch_id = payload_hash[Batch::METADATA_KEY]
290
294
  return unless batch_id
291
295
 
292
- if batch_id == Thread.current[:pgbus_batch_id]
296
+ if batch_id == member_batch_id(active_job)
293
297
  Batch.untrack_enqueue(payload_hash)
294
- elsif retry_retagged?(payload_hash)
298
+ elsif retry_retagged?(payload_hash, active_job)
295
299
  # The retry never became live; the original attempt's row and its
296
300
  # normal completion signal stand.
297
301
  Batch.forget_retry_reenqueued(payload_hash["job_id"])
298
302
  end
299
303
  end
300
304
 
301
- # Tagged for a batch while no Batch#enqueue block is active on this
302
- # thread — only a retry_on re-enqueue gets there (issue #424).
303
- def retry_retagged?(payload_hash)
304
- payload_hash[Batch::METADATA_KEY] && Thread.current[:pgbus_batch_id].nil?
305
+ # Tagged for a batch the job was neither added to nor enqueued inside
306
+ # the block of — only a retry_on re-enqueue gets there (issue #424).
307
+ def retry_retagged?(payload_hash, active_job)
308
+ payload_hash[Batch::METADATA_KEY] && member_batch_id(active_job).nil?
309
+ end
310
+
311
+ # The batch a job joins as a new member: the one it was added to, kept
312
+ # on the instance so it survives an enqueue deferred to after commit
313
+ # (issue #553), else the Batch#enqueue block active on this thread. The
314
+ # instance wins: a deferred enqueue can run while another batch's
315
+ # block is open on the committing thread.
316
+ def member_batch_id(active_job)
317
+ (active_job.enqueue_batch_id if active_job.respond_to?(:enqueue_batch_id)) ||
318
+ Thread.current[:pgbus_batch_id]
319
+ end
320
+
321
+ def forget_enqueue_batch_id(active_job)
322
+ active_job.enqueue_batch_id = nil if active_job.respond_to?(:enqueue_batch_id=)
305
323
  end
306
324
 
307
325
  # A retry_on re-enqueue of a job that is already a batch member: same
@@ -316,18 +334,17 @@ module Pgbus
316
334
  active_job.batch_id
317
335
  end
318
336
 
319
- # Tag the payload with the active batch and count it in (guarded
320
- # increment + execution row, before the send — Batch.track_enqueue). Pass
321
- # track: false to only tag, when the caller counts a bulk once.
322
- def inject_batch_metadata(payload_hash, active_job: nil, track: true)
323
- batch_id = Thread.current[:pgbus_batch_id]
337
+ # Tag the payload with the job's batch and count it in (guarded
338
+ # increment + execution row, before the send — Batch.track_enqueue).
339
+ def inject_batch_metadata(payload_hash, active_job)
340
+ batch_id = member_batch_id(active_job)
324
341
  if batch_id
325
342
  tagged = payload_hash.merge(Batch::METADATA_KEY => batch_id)
326
- Batch.track_enqueue(tagged) if track
343
+ Batch.track_enqueue(tagged)
327
344
  return tagged
328
345
  end
329
346
 
330
- retry_batch_id = active_job && retry_batch_id_for(active_job)
347
+ retry_batch_id = retry_batch_id_for(active_job)
331
348
  return payload_hash unless retry_batch_id
332
349
 
333
350
  tagged = payload_hash.merge(Batch::METADATA_KEY => retry_batch_id)
@@ -335,14 +352,20 @@ module Pgbus
335
352
  tagged
336
353
  end
337
354
 
355
+ # Only tags: the bulk is counted once per batch below. A retry re-enqueue
356
+ # never takes the bulk path (retry_on enqueues one job at a time).
357
+ def tag_bulk_member(payload_hash, active_job)
358
+ batch_id = member_batch_id(active_job)
359
+ batch_id ? payload_hash.merge(Batch::METADATA_KEY => batch_id) : payload_hash
360
+ end
361
+
338
362
  def enqueue_immediate(queue, jobs, priority: nil)
339
363
  return if jobs.empty?
340
364
 
341
365
  payloads = jobs.map do |j|
342
- inject_batch_metadata(FairShare.inject_metadata(j, Serializer.serialize_job_hash(j)), track: false)
366
+ tag_bulk_member(FairShare.inject_metadata(j, Serializer.serialize_job_hash(j)), j)
343
367
  end
344
- # One guarded increment for the whole bulk, not one per job.
345
- Batch.track_enqueue(payloads) if Thread.current[:pgbus_batch_id]
368
+ track_bulk(payloads)
346
369
  physical = physical_queue(queue, priority)
347
370
  msg_ids = nil
348
371
  msg_ids = Pgbus.client.send_batch(queue, payloads, priority: priority)
@@ -361,6 +384,24 @@ module Pgbus
361
384
  rescue Pgbus::SchemaNotReady => e
362
385
  Pgbus.logger.error { "[Pgbus] #{e.message}" }
363
386
  raise
387
+ ensure
388
+ jobs.each { |j| forget_enqueue_batch_id(j) }
389
+ end
390
+
391
+ # One guarded increment per batch for the whole bulk, not one per job.
392
+ # When a later batch refuses (finished), the batches already counted are
393
+ # uncounted before the error goes up: nothing has been sent yet.
394
+ def track_bulk(payloads)
395
+ tracked = []
396
+ payloads.select { |p| p[Batch::METADATA_KEY] }
397
+ .group_by { |p| p[Batch::METADATA_KEY] }
398
+ .each_value do |members|
399
+ Batch.track_enqueue(members)
400
+ tracked.concat(members)
401
+ end
402
+ rescue StandardError
403
+ tracked.each { |payload| Batch.untrack_enqueue(payload) }
404
+ raise
364
405
  end
365
406
 
366
407
  def scheduled_in_future?(job)
@@ -16,11 +16,33 @@ module Pgbus
16
16
  # Both round-trip through +serialize+/+deserialize+, and both are omitted
17
17
  # from the serialized hash when unset — an unbatched job's payload is
18
18
  # byte-for-byte what it was before this mixin existed.
19
+ #
20
+ # A third, transient id, +enqueue_batch_id+, is the batch this instance
21
+ # was *added* to. +b.add+ / +batch.add+ set it, and +enqueue+ copies the
22
+ # block's tag onto it, before Active Job decides whether to defer the
23
+ # enqueue to after commit (+enqueue_after_transaction_commit+). The
24
+ # deferred enqueue keeps the instance, so the adapter still finds the
25
+ # batch once the block's thread tag is gone (issue #553). It is never
26
+ # serialized and the adapter clears it once the enqueue is done.
19
27
  module BatchId
20
28
  extend ActiveSupport::Concern
21
29
 
22
30
  included do
23
- attr_accessor :batch_id, :callback_batch_id
31
+ attr_accessor :batch_id, :callback_batch_id, :enqueue_batch_id
32
+ end
33
+
34
+ # Mark +jobs+ as added to +batch_id+ (instances without this mixin are
35
+ # left alone; they join through the block's thread tag only).
36
+ def self.capture(jobs, batch_id)
37
+ jobs.each { |job| job.enqueue_batch_id = batch_id if job.respond_to?(:enqueue_batch_id=) }
38
+ end
39
+
40
+ # Plain assignment, not ||=: an id left by an add whose enqueue never
41
+ # ran (rolled back, or aborted by a before_enqueue) must not follow the
42
+ # instance into this one. b.add / batch.add never come through here.
43
+ def enqueue(...)
44
+ self.enqueue_batch_id = Thread.current[:pgbus_batch_id]
45
+ super
24
46
  end
25
47
 
26
48
  def serialize
@@ -0,0 +1,49 @@
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 # => { msg_id: 42, read_ct: 2, queue_name: "default" }
8
+ #
9
+ # Set by Executor#execute around perform_now and cleared after, also when
10
+ # the job raises; nil outside the executor (perform_now in a test, a
11
+ # top-level inline-adapter job). A job run inline from inside an executing
12
+ # job sees the enclosing job's delivery; StepJob only acts on it when the
13
+ # step row's msg_id matches, so that is harmless. Fiber-local (Thread.current[]), so concurrent jobs under
14
+ # execution_mode: :async each see their own. Pgbus::Workflow::StepJob reads
15
+ # read_ct to tell a redelivery after a worker died from a first delivery.
16
+ #
17
+ # The three values sit in three fiber-locals and the Hash is built only
18
+ # when read: setting them allocates nothing, which keeps the executor's
19
+ # per-job allocation budget (spec/pgbus/active_job/executor_allocation_budget_spec.rb).
20
+ module Delivery
21
+ MSG_ID_KEY = :pgbus_delivery_msg_id
22
+ READ_CT_KEY = :pgbus_delivery_read_ct
23
+ QUEUE_NAME_KEY = :pgbus_delivery_queue_name
24
+
25
+ module_function
26
+
27
+ def current
28
+ msg_id = Thread.current[MSG_ID_KEY]
29
+ return nil if msg_id.nil?
30
+
31
+ { msg_id: msg_id, read_ct: Thread.current[READ_CT_KEY], queue_name: Thread.current[QUEUE_NAME_KEY] }
32
+ end
33
+
34
+ def with(msg_id:, read_ct:, queue_name:)
35
+ previous_msg_id = Thread.current[MSG_ID_KEY]
36
+ previous_read_ct = Thread.current[READ_CT_KEY]
37
+ previous_queue_name = Thread.current[QUEUE_NAME_KEY]
38
+ Thread.current[MSG_ID_KEY] = msg_id
39
+ Thread.current[READ_CT_KEY] = read_ct
40
+ Thread.current[QUEUE_NAME_KEY] = queue_name
41
+ yield
42
+ ensure
43
+ Thread.current[MSG_ID_KEY] = previous_msg_id
44
+ Thread.current[READ_CT_KEY] = previous_read_ct
45
+ Thread.current[QUEUE_NAME_KEY] = previous_queue_name
46
+ end
47
+ end
48
+ end
49
+ end
@@ -99,7 +99,9 @@ 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) { execute_job(job) }
104
+ end
103
105
  # retry_on re-enqueues from inside perform_now and returns normally:
104
106
  # this attempt is done (archive it) but the job is not — the retry
105
107
  # message carries the batch tag and signals on its own outcome.
@@ -59,6 +59,7 @@ module Pgbus
59
59
  raise ArgumentError, UNTAGGED_MESSAGE unless Thread.current[:pgbus_batch_id] == @batch.batch_id
60
60
 
61
61
  jobs = self.class.job_instances(jobs)
62
+ Pgbus::ActiveJob::BatchId.capture(jobs, @batch.batch_id)
62
63
  ::ActiveJob.perform_all_later(jobs) if jobs.any?
63
64
  yield if block_given?
64
65
  jobs
data/lib/pgbus/batch.rb CHANGED
@@ -45,6 +45,19 @@ module Pgbus
45
45
  # Thread.current): b.add raises anywhere else, and a plain perform_later
46
46
  # from a thread or fiber the block spawns does not join the batch.
47
47
  #
48
+ # Inside a database transaction the batch stays pending until the
49
+ # transaction commits: a job with enqueue_after_transaction_commit keeps
50
+ # the batch on its instance (b.add, batch.add and perform_later put it
51
+ # there) and counts itself in at commit, before the batch starts. On a
52
+ # rollback no job is sent and, when pgbus shares the application's
53
+ # database, the batch row is gone with the rest (a separate pgbus
54
+ # database commits the row on its own connection). A bare
55
+ # ActiveJob.perform_all_later in the block does not keep it: use b.add.
56
+ # Add jobs that defer: one sent at once, on a connection other than the
57
+ # caller's, can run before the batch commits and is then accounted for
58
+ # only by the stalled-execution sweep. A job instance is one member (one
59
+ # job_id): enqueue it once.
60
+ #
48
61
  # Pgbus::Batch.start(description: "Import") do |b|
49
62
  # b.on_complete ImportDoneJob
50
63
  # b.add(*rows.map { |row| ImportRowJob.new(row.id) })
@@ -124,7 +137,7 @@ module Pgbus
124
137
  end
125
138
  @started = true
126
139
  count_jobs(&)
127
- start_processing
140
+ self.class.send(:after_transactions_commit) { start_processing }
128
141
  self
129
142
  end
130
143
 
@@ -139,6 +152,7 @@ module Pgbus
139
152
  return jobs if jobs.empty? && block.nil?
140
153
 
141
154
  enqueue do
155
+ Pgbus::ActiveJob::BatchId.capture(jobs, batch_id)
142
156
  ::ActiveJob.perform_all_later(jobs) if jobs.any?
143
157
  yield if block
144
158
  end
@@ -473,6 +487,22 @@ module Pgbus
473
487
  finish_if_needed(try_finish!(batch_id))
474
488
  end
475
489
 
490
+ # Run the end-of-block start (or an open batch's re-check) once every
491
+ # open transaction has committed, and at once when none is open
492
+ # (issue #553). Jobs with enqueue_after_transaction_commit register
493
+ # their enqueue as an after_commit callback during the block; Active
494
+ # Record runs a transaction's callbacks in registration order and
495
+ # appends a savepoint's to its parent's on release, so every one of
496
+ # them has counted itself into the still-pending row before this runs.
497
+ # On a rollback none of them runs, and the row (created in the same
498
+ # transaction) is gone. Active Record 7.1 has no
499
+ # after_all_transactions_commit, and Active Job 7.1 never defers.
500
+ def after_transactions_commit(&)
501
+ return yield unless ::ActiveRecord.respond_to?(:after_all_transactions_commit)
502
+
503
+ ::ActiveRecord.after_all_transactions_commit(&)
504
+ end
505
+
476
506
  def finish_if_needed(result)
477
507
  return result unless result&.fetch(:just_finished, false) && result[:record]
478
508
 
@@ -629,8 +659,10 @@ module Pgbus
629
659
  raise AlreadyFinished, "Can't add jobs into an already finished batch" if finished?
630
660
 
631
661
  count_jobs(&)
632
- reload
633
- self.class.send(:finish_if_needed, BatchEntry.check_finished!(batch_id))
662
+ self.class.send(:after_transactions_commit) do
663
+ reload
664
+ self.class.send(:finish_if_needed, BatchEntry.check_finished!(batch_id))
665
+ end
634
666
  self
635
667
  end
636
668