pgbus 0.19.0 → 0.19.2

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -147
  3. data/README.md +65 -27
  4. data/app/controllers/pgbus/deploy_tasks_controller.rb +38 -0
  5. data/app/helpers/pgbus/deploy_tasks_helper.rb +77 -0
  6. data/app/helpers/pgbus/processes_helper.rb +1 -1
  7. data/app/models/pgbus/deploy_task.rb +104 -0
  8. data/app/models/pgbus/deploy_task_run.rb +45 -0
  9. data/app/views/layouts/pgbus/application.html.erb +2 -0
  10. data/app/views/pgbus/deploy_tasks/_rollover_banner.html.erb +25 -0
  11. data/app/views/pgbus/deploy_tasks/_tasks_table.html.erb +71 -0
  12. data/app/views/pgbus/deploy_tasks/index.html.erb +6 -0
  13. data/app/views/pgbus/deploy_tasks/show.html.erb +134 -0
  14. data/config/i18n-tasks.yml +2 -0
  15. data/config/locales/da.yml +97 -0
  16. data/config/locales/de.yml +97 -0
  17. data/config/locales/en.yml +98 -1
  18. data/config/locales/es.yml +97 -0
  19. data/config/locales/fi.yml +97 -0
  20. data/config/locales/fr.yml +97 -0
  21. data/config/locales/it.yml +97 -0
  22. data/config/locales/ja.yml +97 -0
  23. data/config/locales/nb.yml +97 -0
  24. data/config/locales/nl.yml +97 -0
  25. data/config/locales/pt.yml +97 -0
  26. data/config/locales/sv.yml +97 -0
  27. data/config/routes.rb +7 -0
  28. data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +4 -1
  29. data/lib/generators/pgbus/add_deploy_tasks_generator.rb +51 -0
  30. data/lib/generators/pgbus/templates/add_deploy_tasks_tables.rb.erb +43 -0
  31. data/lib/generators/pgbus/templates/deploy_tasks.yml.erb +42 -0
  32. data/lib/generators/pgbus/templates/migration.rb.erb +43 -0
  33. data/lib/pgbus/active_job/batch_id.rb +10 -4
  34. data/lib/pgbus/batch/builder.rb +92 -0
  35. data/lib/pgbus/batch.rb +97 -22
  36. data/lib/pgbus/configuration/deploy_settings.rb +46 -0
  37. data/lib/pgbus/configuration.rb +3 -0
  38. data/lib/pgbus/deploy/revision.rb +59 -0
  39. data/lib/pgbus/deploy/rollover.rb +65 -0
  40. data/lib/pgbus/deploy/runner.rb +259 -0
  41. data/lib/pgbus/deploy/task.rb +105 -0
  42. data/lib/pgbus/engine.rb +17 -0
  43. data/lib/pgbus/generators/migration_detector.rb +12 -1
  44. data/lib/pgbus/instrumentation.rb +14 -0
  45. data/lib/pgbus/integrations/appsignal/subscriber.rb +15 -0
  46. data/lib/pgbus/mcp/server.rb +2 -1
  47. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +86 -0
  48. data/lib/pgbus/mcp.rb +1 -0
  49. data/lib/pgbus/metrics/subscriber.rb +12 -0
  50. data/lib/pgbus/outbox/poller.rb +1 -0
  51. data/lib/pgbus/process/consumer.rb +57 -3
  52. data/lib/pgbus/process/dispatcher.rb +18 -1
  53. data/lib/pgbus/process/heartbeat.rb +7 -2
  54. data/lib/pgbus/process/supervisor.rb +35 -3
  55. data/lib/pgbus/process/worker.rb +1 -0
  56. data/lib/pgbus/recurring/scheduler.rb +54 -20
  57. data/lib/pgbus/version.rb +1 -1
  58. data/lib/pgbus/web/data_source/deploy_task_list.rb +114 -0
  59. data/lib/pgbus/web/data_source/list_counts.rb +2 -1
  60. data/lib/pgbus/web/data_source.rb +1 -0
  61. data/lib/pgbus/web/deploy_task_state.rb +51 -0
  62. data/lib/pgbus.rb +11 -0
  63. data/sig/pgbus/configuration.rbs +10 -0
  64. data/sig/pgbus.rbs +9 -0
  65. metadata +21 -1
@@ -236,6 +236,102 @@ pt:
236
236
  payload: Carga útil
237
237
  retry: Tentar novamente
238
238
  title: 'Mensagem DLQ #%{id}'
239
+ deploy_tasks:
240
+ banner:
241
+ heading: Transição
242
+ lagging_html:
243
+ one: 'Aguardando a transição: 1 processo ainda não executa <code>%{revision}</code>.'
244
+ other: 'Aguardando a transição: %{count} processos ainda não executam <code>%{revision}</code>.'
245
+ lagging_more: "…e mais %{count}"
246
+ lagging_process: "%{kind}@%{hostname} pid %{pid} em %{revision}"
247
+ live_count: "%{count} em %{revision}"
248
+ live_heading: 'Processos ativos:'
249
+ no_revision_hint_html: Defina <code>config.app_revision</code>, ou uma de <code>PGBUS_REVISION</code>, <code>DASH_VERSION</code> ou <code>KAMAL_VERSION</code>, ou inclua um arquivo <code>REVISION</code>. Executar agora continua funcionando.
250
+ no_revision_html: 'Revisão da aplicação não configurada: as tarefas de deploy aguardam até que esteja.'
251
+ no_revision_label: sem revisão
252
+ no_scheduler_html: 'Nenhum agendador em execução: as tarefas de deploy só são disparadas pelo agendador. Este veredito usa a revisão do próprio painel.'
253
+ no_workers_html: 'Aguardando a transição: ainda nenhum worker em <code>%{revision}</code>.'
254
+ ready_html: 'Transição concluída: cada processo ativo executa <code>%{revision}</code>. Tarefas armadas executam no próximo tick do agendador.'
255
+ unavailable_html: Não foi possível ler o estado da transição.
256
+ default_queue: default
257
+ disable: Desativar
258
+ enable: Ativar
259
+ enqueue:
260
+ enqueued: Tarefa de deploy enfileirada
261
+ failed: 'A tarefa de deploy não foi enfileirada: veja o log para o motivo'
262
+ every_deploy: Em todo deploy
263
+ index:
264
+ task_count:
265
+ one: "%{count} tarefa declarada"
266
+ other: "%{count} tarefas declaradas"
267
+ title: Tarefas de deploy
268
+ not_found: Tarefa de deploy não encontrada
269
+ reasons:
270
+ disabled_armed_html: 'Desativada com uma execução de transição pendente: ative-a para deixá-la executar.'
271
+ disabled_html: 'Desativada: o agendador a ignora.'
272
+ due_html: 'Pronta: executa no próximo tick do agendador.'
273
+ manual_html: "· executada %{ago} (manual)"
274
+ never_html: Nunca executada após uma transição.
275
+ ran_html: Executada %{ago} em <code>%{revision}</code> (transição).
276
+ waiting_lagging_html:
277
+ one: 'Aguardando a transição: 1 processo ainda não executa <code>%{revision}</code>.'
278
+ other: 'Aguardando a transição: %{count} processos ainda não executam <code>%{revision}</code>.'
279
+ waiting_no_revision_html: 'Aguardando: a revisão da aplicação não está configurada.'
280
+ waiting_no_scheduler_html: 'Aguardando: nenhum agendador em execução.'
281
+ waiting_no_workers_html: Aguardando um worker em <code>%{revision}</code>.
282
+ waiting_unknown_html: 'Aguardando: não foi possível ler o estado da transição.'
283
+ run_now: Executar agora
284
+ show:
285
+ armed_html: "%{armed_for}, armada %{ago}"
286
+ back: Voltar
287
+ configuration: Configuração
288
+ labels:
289
+ arguments: Argumentos
290
+ armed: Execução pendente
291
+ command: Comando
292
+ description: Descrição
293
+ job_class: Classe do job
294
+ priority: Prioridade
295
+ queue: Fila
296
+ source: Origem
297
+ last_run: Última execução
298
+ no_runs: Nenhuma execução registrada ainda
299
+ recent_runs: Execuções recentes
300
+ run_headers:
301
+ enqueued_at: Enfileirada em
302
+ queue: Fila
303
+ revision: Revisão
304
+ triggered_by: Disparada por
305
+ version: Versão
306
+ source_config: Arquivo de configuração
307
+ source_dynamic: Dinâmica
308
+ state: Estado
309
+ version: Versão
310
+ states:
311
+ disabled: Desativada
312
+ due: Pronta
313
+ never: Nunca executada
314
+ ran: Executada
315
+ waiting: Aguardando
316
+ tasks_table:
317
+ empty_hint_html: Declare tarefas pontuais em <code>config/deploy_tasks.yml</code>; <code>rails generate pgbus:add_deploy_tasks</code> cria um arquivo inicial.
318
+ empty_title: Nenhuma tarefa de deploy declarada
319
+ headers:
320
+ actions: Ações
321
+ last_run: Última execução
322
+ queue: Fila
323
+ state: Estado
324
+ task: Tarefa
325
+ version: Versão
326
+ never: Nunca
327
+ toggle:
328
+ disabled: Tarefa de deploy desativada
329
+ enabled: Tarefa de deploy ativada
330
+ failed: Falha ao alternar a tarefa de deploy
331
+ triggered_by:
332
+ manual: Manual
333
+ rollover: Transição
334
+ version: v%{version}
239
335
  dialogs:
240
336
  cancel: Cancelar
241
337
  confirm: Confirmar
@@ -617,6 +713,7 @@ pt:
617
713
  nav:
618
714
  batches: Lotes
619
715
  dashboard: Painel
716
+ deploy_tasks: Deploy
620
717
  dlq: DLQ
621
718
  events: Eventos
622
719
  insights: Insights
@@ -236,6 +236,102 @@ sv:
236
236
  payload: Innehåll
237
237
  retry: Försök igen
238
238
  title: 'DLQ-meddelande #%{id}'
239
+ deploy_tasks:
240
+ banner:
241
+ heading: Utrullning
242
+ lagging_html:
243
+ one: 'Väntar på utrullning: 1 process kör ännu inte <code>%{revision}</code>.'
244
+ other: 'Väntar på utrullning: %{count} processer kör ännu inte <code>%{revision}</code>.'
245
+ lagging_more: "…och %{count} till"
246
+ lagging_process: "%{kind}@%{hostname} pid %{pid} på %{revision}"
247
+ live_count: "%{count} på %{revision}"
248
+ live_heading: 'Aktiva processer:'
249
+ no_revision_hint_html: Sätt <code>config.app_revision</code>, eller någon av <code>PGBUS_REVISION</code>, <code>DASH_VERSION</code> eller <code>KAMAL_VERSION</code>, eller leverera en <code>REVISION</code>-fil. Kör nu fungerar fortfarande.
250
+ no_revision_html: 'Appens revision är inte konfigurerad: deploy-uppgifter väntar tills den är det.'
251
+ no_revision_label: ingen revision
252
+ no_scheduler_html: 'Ingen schemaläggare körs: deploy-uppgifter startas bara av schemaläggaren. Bedömningen använder instrumentpanelens egen revision.'
253
+ no_workers_html: 'Väntar på utrullning: ingen worker på <code>%{revision}</code> ännu.'
254
+ ready_html: 'Utrullad: varje aktiv process kör <code>%{revision}</code>. Väntande uppgifter körs vid schemaläggarens nästa tick.'
255
+ unavailable_html: Utrullningsstatusen kunde inte läsas.
256
+ default_queue: default
257
+ disable: Inaktivera
258
+ enable: Aktivera
259
+ enqueue:
260
+ enqueued: Deploy-uppgiften köad
261
+ failed: 'Deploy-uppgiften köades inte: se loggen för orsaken'
262
+ every_deploy: Vid varje deploy
263
+ index:
264
+ task_count:
265
+ one: "%{count} uppgift deklarerad"
266
+ other: "%{count} uppgifter deklarerade"
267
+ title: Deploy-uppgifter
268
+ not_found: Deploy-uppgiften hittades inte
269
+ reasons:
270
+ disabled_armed_html: 'Inaktiverad med en väntande utrullningskörning: aktivera den för att låta den köras.'
271
+ disabled_html: 'Inaktiverad: schemaläggaren hoppar över den.'
272
+ due_html: 'Redo: körs vid schemaläggarens nästa tick.'
273
+ manual_html: "· kördes %{ago} (manuellt)"
274
+ never_html: Har aldrig körts efter en utrullning.
275
+ ran_html: Kördes %{ago} på <code>%{revision}</code> (utrullning).
276
+ waiting_lagging_html:
277
+ one: 'Väntar på utrullning: 1 process kör ännu inte <code>%{revision}</code>.'
278
+ other: 'Väntar på utrullning: %{count} processer kör ännu inte <code>%{revision}</code>.'
279
+ waiting_no_revision_html: 'Väntar: appens revision är inte konfigurerad.'
280
+ waiting_no_scheduler_html: 'Väntar: ingen schemaläggare körs.'
281
+ waiting_no_workers_html: Väntar på en worker på <code>%{revision}</code>.
282
+ waiting_unknown_html: 'Väntar: utrullningsstatusen kunde inte läsas.'
283
+ run_now: Kör nu
284
+ show:
285
+ armed_html: "%{armed_for}, förberedd %{ago}"
286
+ back: Tillbaka
287
+ configuration: Konfiguration
288
+ labels:
289
+ arguments: Argument
290
+ armed: Väntande körning
291
+ command: Kommando
292
+ description: Beskrivning
293
+ job_class: Jobbklass
294
+ priority: Prioritet
295
+ queue: Kö
296
+ source: Källa
297
+ last_run: Senaste körning
298
+ no_runs: Inga körningar registrerade ännu
299
+ recent_runs: Senaste körningar
300
+ run_headers:
301
+ enqueued_at: Köad
302
+ queue: Kö
303
+ revision: Revision
304
+ triggered_by: Startad av
305
+ version: Version
306
+ source_config: Konfigurationsfil
307
+ source_dynamic: Dynamisk
308
+ state: Tillstånd
309
+ version: Version
310
+ states:
311
+ disabled: Inaktiverad
312
+ due: Redo
313
+ never: Aldrig körd
314
+ ran: Körd
315
+ waiting: Väntar
316
+ tasks_table:
317
+ empty_hint_html: Deklarera engångsuppgifter i <code>config/deploy_tasks.yml</code>; <code>rails generate pgbus:add_deploy_tasks</code> skapar en startfil.
318
+ empty_title: Inga deploy-uppgifter deklarerade
319
+ headers:
320
+ actions: Åtgärder
321
+ last_run: Senaste körning
322
+ queue: Kö
323
+ state: Tillstånd
324
+ task: Uppgift
325
+ version: Version
326
+ never: Aldrig
327
+ toggle:
328
+ disabled: Deploy-uppgiften inaktiverad
329
+ enabled: Deploy-uppgiften aktiverad
330
+ failed: Det gick inte att växla deploy-uppgiften
331
+ triggered_by:
332
+ manual: Manuellt
333
+ rollover: Utrullning
334
+ version: v%{version}
239
335
  dialogs:
240
336
  cancel: Avbryt
241
337
  confirm: Bekräfta
@@ -617,6 +713,7 @@ sv:
617
713
  nav:
618
714
  batches: Grupper
619
715
  dashboard: Instrumentpanel
716
+ deploy_tasks: Deploy
620
717
  dlq: DLQ
621
718
  events: Händelser
622
719
  insights: Insikter
data/config/routes.rb CHANGED
@@ -45,6 +45,13 @@ Pgbus::Engine.routes.draw do
45
45
  end
46
46
  end
47
47
 
48
+ resources :deploy_tasks, only: %i[index show] do
49
+ member do
50
+ post :toggle
51
+ post :enqueue
52
+ end
53
+ end
54
+
48
55
  resources :batches, only: %i[index show]
49
56
  resources :processes, only: [:index]
50
57
 
@@ -33,7 +33,10 @@ module Pgbus
33
33
  say " 2. Restart pgbus: bin/pgbus start"
34
34
  say ""
35
35
  say "You can now pass a configured ActiveJob instance to a batch:"
36
- say " Pgbus::Batch.new(on_finish: ReportJob.new.set(queue: :critical, wait: 5.minutes))"
36
+ say " Pgbus.batch do |b|"
37
+ say " b.on_complete ReportJob.new.set(queue: :critical, wait: 5.minutes)"
38
+ say " b.add(*rows.map { |row| ImportRowJob.new(row.id) })"
39
+ say " end"
37
40
  say ""
38
41
  end
39
42
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require_relative "migration_path"
6
+
7
+ module Pgbus
8
+ module Generators
9
+ class AddDeployTasksGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+ include MigrationPath
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Add deploy tasks tables (one-time jobs run after a rollover) to an existing Pgbus installation"
16
+
17
+ class_option :database,
18
+ type: :string,
19
+ default: nil,
20
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
21
+
22
+ def create_migration_file
23
+ migration_template "add_deploy_tasks_tables.rb.erb",
24
+ File.join(pgbus_migrate_path, "add_pgbus_deploy_tasks_tables.rb")
25
+ end
26
+
27
+ def create_deploy_tasks_config
28
+ template "deploy_tasks.yml.erb", "config/deploy_tasks.yml"
29
+ end
30
+
31
+ def display_post_install
32
+ say ""
33
+ say "Pgbus deploy tasks installed!", :green
34
+ say ""
35
+ say "Next steps:"
36
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
37
+ say " 2. Edit config/deploy_tasks.yml to declare your one-time tasks"
38
+ say " 3. Make sure every pgbus process knows the app revision (config.app_revision,"
39
+ say " PGBUS_REVISION, DASH_VERSION, KAMAL_VERSION or a REVISION file)"
40
+ say " 4. Restart pgbus: bin/pgbus start"
41
+ say ""
42
+ end
43
+
44
+ private
45
+
46
+ def migration_version
47
+ "[#{ActiveRecord::Migration.current_version}]"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,43 @@
1
+ class AddPgbusDeployTasksTables < ActiveRecord::Migration<%= migration_version %>
2
+ # if_not_exists throughout: pgbus:update queues this migration when either
3
+ # table is missing, so a half-present schema must not fail it.
4
+ def change
5
+ create_table :pgbus_deploy_tasks, if_not_exists: true do |t|
6
+ t.string :key, null: false
7
+ t.string :class_name
8
+ t.string :command, limit: 2048
9
+ t.text :arguments
10
+ t.string :queue_name
11
+ t.integer :priority, default: 0
12
+ t.string :version, null: false, default: "1"
13
+ t.boolean :every_deploy, null: false, default: false
14
+ t.boolean :static, null: false, default: true
15
+ t.boolean :enabled, null: false, default: true
16
+ t.text :description
17
+ t.datetime :armed_at
18
+ t.string :armed_for
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :pgbus_deploy_tasks, :key,
23
+ unique: true, name: "idx_pgbus_deploy_tasks_key", if_not_exists: true
24
+ add_index :pgbus_deploy_tasks, :armed_at,
25
+ where: "armed_at IS NOT NULL", name: "idx_pgbus_deploy_tasks_armed", if_not_exists: true
26
+
27
+ create_table :pgbus_deploy_task_runs, if_not_exists: true do |t|
28
+ t.string :task_key, null: false
29
+ t.string :version, null: false
30
+ t.string :revision
31
+ t.string :triggered_by, null: false
32
+ t.string :queue_name
33
+ t.bigint :msg_id
34
+ t.datetime :run_at, null: false
35
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
36
+ end
37
+
38
+ add_index :pgbus_deploy_task_runs, [:task_key, :run_at],
39
+ name: "idx_pgbus_deploy_task_runs_task", if_not_exists: true
40
+ add_index :pgbus_deploy_task_runs, :run_at,
41
+ name: "idx_pgbus_deploy_task_runs_cleanup", if_not_exists: true
42
+ end
43
+ end
@@ -0,0 +1,42 @@
1
+ # Pgbus Deploy Tasks Configuration
2
+ #
3
+ # One-time jobs that run once the whole fleet has rolled over to a new app
4
+ # revision: a backfill, a data fix, a cache warm. Same shape as
5
+ # config/recurring.yml, without schedule:. Supports environment-scoped or
6
+ # flat configuration.
7
+ #
8
+ # The scheduler enqueues a task only when every live pgbus process reports
9
+ # the scheduler's own app revision and at least one worker on it is alive,
10
+ # so a task never runs on old code during the deploy overlap window. The
11
+ # revision comes from config.app_revision, then PGBUS_REVISION, DASH_VERSION,
12
+ # KAMAL_VERSION, then the REVISION file at Rails.root. Without one, deploy
13
+ # tasks wait (the dashboard says so) and only "Run now" enqueues them.
14
+ #
15
+ # Format:
16
+ # task_key:
17
+ # class: JobClassName # ActiveJob class to run (or use command:)
18
+ # version: 1 # Runs once per version; bump it to run again after the next deploy
19
+ # every_deploy: false # true: run after every rollover instead (version is ignored)
20
+ # queue: default # Optional: queue name (default: pgbus default queue)
21
+ # args: [arg1, arg2] # Optional: job arguments
22
+ # priority: 0 # Optional: numeric priority
23
+ # description: "text" # Optional: human-readable description
24
+ #
25
+ # Command-based tasks (no job class needed):
26
+ # warm_fragment_caches:
27
+ # command: "FragmentCacheWarmer.run"
28
+ # every_deploy: true
29
+ #
30
+ # Examples:
31
+ #
32
+ # production:
33
+ # backfill_order_totals:
34
+ # class: BackfillOrderTotalsJob
35
+ # version: 2
36
+ # queue: maintenance
37
+ # args: [{ batch_size: 500 }]
38
+ # description: Recompute totals after the currency migration
39
+ #
40
+ # warm_fragment_caches:
41
+ # command: "FragmentCacheWarmer.run"
42
+ # every_deploy: true
@@ -170,6 +170,47 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
170
170
  add_index :pgbus_recurring_executions, :run_at,
171
171
  name: "idx_pgbus_recurring_executions_cleanup"
172
172
 
173
+ # Deploy tasks (synced from config/deploy_tasks.yml): one-time jobs the
174
+ # scheduler enqueues once the fleet has rolled over to a new revision.
175
+ # armed_at/armed_for is the compare-and-set claim; runs are the history.
176
+ create_table :pgbus_deploy_tasks do |t|
177
+ t.string :key, null: false
178
+ t.string :class_name
179
+ t.string :command, limit: 2048
180
+ t.text :arguments
181
+ t.string :queue_name
182
+ t.integer :priority, default: 0
183
+ t.string :version, null: false, default: "1"
184
+ t.boolean :every_deploy, null: false, default: false
185
+ t.boolean :static, null: false, default: true
186
+ t.boolean :enabled, null: false, default: true
187
+ t.text :description
188
+ t.datetime :armed_at
189
+ t.string :armed_for
190
+ t.timestamps
191
+ end
192
+
193
+ add_index :pgbus_deploy_tasks, :key,
194
+ unique: true, name: "idx_pgbus_deploy_tasks_key"
195
+ add_index :pgbus_deploy_tasks, :armed_at,
196
+ where: "armed_at IS NOT NULL", name: "idx_pgbus_deploy_tasks_armed"
197
+
198
+ create_table :pgbus_deploy_task_runs do |t|
199
+ t.string :task_key, null: false
200
+ t.string :version, null: false
201
+ t.string :revision
202
+ t.string :triggered_by, null: false
203
+ t.string :queue_name
204
+ t.bigint :msg_id
205
+ t.datetime :run_at, null: false
206
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
207
+ end
208
+
209
+ add_index :pgbus_deploy_task_runs, [:task_key, :run_at],
210
+ name: "idx_pgbus_deploy_task_runs_task"
211
+ add_index :pgbus_deploy_task_runs, :run_at,
212
+ name: "idx_pgbus_deploy_task_runs_cleanup"
213
+
173
214
  # Stream queue registry — records which PGMQ queues back streams (vs jobs).
174
215
  # Stream and job queues share the pgbus_ namespace, so maintenance
175
216
  # (per-stream retention, orphan sweep) and wildcard workers rely on this
@@ -204,6 +245,8 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
204
245
  execute "SELECT pgmq.drop_queue('pgbus_default')"
205
246
 
206
247
  drop_table :pgbus_stream_queues
248
+ drop_table :pgbus_deploy_task_runs
249
+ drop_table :pgbus_deploy_tasks
207
250
  drop_table :pgbus_recurring_executions
208
251
  drop_table :pgbus_recurring_tasks
209
252
  drop_table :pgbus_batch_executions
@@ -8,9 +8,9 @@ module Pgbus
8
8
  #
9
9
  # * +batch_id+ — the batch this job is a *member* of. Assigned by the
10
10
  # executor from the payload's +pgbus_batch_id+ before +perform+, so a
11
- # running job can call +batch.enqueue+ to add siblings.
11
+ # running job can call +batch.add+ to add siblings.
12
12
  # * +callback_batch_id+ — the batch this job *reports on*. Set on
13
- # +on_finish+/+on_success+/+on_failure+ jobs at fire time. A callback is
13
+ # +on_complete+/+on_success+/+on_failure+ jobs at fire time. A callback is
14
14
  # never a member of the batch it reports on, so its +batch_id+ is nil.
15
15
  #
16
16
  # Both round-trip through +serialize+/+deserialize+, and both are omitted
@@ -36,8 +36,14 @@ module Pgbus
36
36
  self.callback_batch_id = job_data["callback_batch_id"]
37
37
  end
38
38
 
39
- # The batch this job reports on, or is a member of. nil when neither.
40
- def batch
39
+ # Without a block: the batch this job reports on, or is a member of; nil
40
+ # when neither. With a block: open a new, independent batch through
41
+ # Pgbus.batch (this job's own membership is untouched).
42
+ #
43
+ # batch(description: "Import") { |b| b.add(*rows.map { |r| RowJob.new(r.id) }) }
44
+ def batch(**options, &block)
45
+ return Pgbus.batch(**options, &block) if block
46
+ raise ArgumentError, "batch(...) with options needs a block: batch(description: ...) { |b| ... }" if options.any?
41
47
  return @batch if defined?(@batch)
42
48
 
43
49
  id = callback_batch_id || batch_id
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Batch
5
+ # What Pgbus.batch (and +batch { |b| ... }+ inside a job) yields: callback
6
+ # setters and +add+ on a batch that is open for the length of the block.
7
+ #
8
+ # Pgbus.batch(description: "Import #{source.id}") do |b|
9
+ # b.on_complete ImportDoneJob.new.set(queue: :critical)
10
+ # b.on_failure ImportFailedJob
11
+ # b.add(*source.rows.map { |row| ImportRowJob.new(row.id) })
12
+ # end
13
+ #
14
+ # The batch row exists (and a uniqueness key is held) before the block
15
+ # runs, and it stays pending until the block returns, so the order of the
16
+ # calls does not matter and a job that completes during the block cannot
17
+ # finish the batch early. Once the block has returned the builder is
18
+ # closed: add more jobs through +batch.add+ on the returned batch.
19
+ class Builder
20
+ CLOSED_MESSAGE = "the batch builder only works inside its Pgbus.batch block; " \
21
+ "add more jobs later with batch.add on the batch Pgbus.batch returned"
22
+ UNTAGGED_MESSAGE = "b.add ran on a thread or fiber outside its Pgbus.batch block (or inside another " \
23
+ "batch's block), where its jobs would not join the batch; call b.add from the block itself"
24
+
25
+ # Flatten +jobs+ and check that each one is an ActiveJob instance.
26
+ def self.job_instances(jobs)
27
+ jobs = jobs.flatten
28
+ invalid = jobs.grep_v(::ActiveJob::Base)
29
+ return jobs if invalid.empty?
30
+
31
+ raise ArgumentError,
32
+ "a batch takes job instances (MyJob.new(args), optionally .set(wait:, queue:)), got #{invalid.first.inspect}"
33
+ end
34
+
35
+ def initialize(batch)
36
+ @batch = batch
37
+ @closed = false
38
+ end
39
+
40
+ def on_complete(job = nil, &) = set_callback(:on_complete, job, &)
41
+
42
+ def on_success(job = nil, &) = set_callback(:on_success, job, &)
43
+
44
+ def on_failure(job = nil, &) = set_callback(:on_failure, job, &)
45
+
46
+ # Deprecated alias of #on_complete until 1.0.
47
+ def on_finish(job = nil, &)
48
+ Batch.warn_on_finish_deprecated
49
+ set_callback(:on_complete, job, &)
50
+ end
51
+
52
+ # Enqueue job instances into the batch with one bulk send (and anything
53
+ # the block enqueues). Returns the jobs. Membership is the batch tag the
54
+ # block holds (Thread.current, so fiber-local), so an add from another
55
+ # thread or fiber, or from inside a nested batch's block, raises rather
56
+ # than send jobs that would not be counted.
57
+ def add(*jobs)
58
+ raise ArgumentError, CLOSED_MESSAGE if @closed
59
+ raise ArgumentError, UNTAGGED_MESSAGE unless Thread.current[:pgbus_batch_id] == @batch.batch_id
60
+
61
+ jobs = self.class.job_instances(jobs)
62
+ ::ActiveJob.perform_all_later(jobs) if jobs.any?
63
+ yield if block_given?
64
+ jobs
65
+ end
66
+
67
+ # Called by Batch.start when the block returns or raises.
68
+ def close
69
+ @closed = true
70
+ end
71
+
72
+ private
73
+
74
+ def set_callback(slot, job, &block)
75
+ raise ArgumentError, CLOSED_MESSAGE if @closed
76
+
77
+ unless block.nil? && callback_job?(job)
78
+ raise ArgumentError,
79
+ "#{slot} takes an ActiveJob class or a configured instance (MyJob.new.set(queue: :critical)), " \
80
+ "not #{block ? "a block" : job.inspect}: callbacks are stored on the batch row"
81
+ end
82
+
83
+ @batch.send(:write_callback, slot, job)
84
+ self
85
+ end
86
+
87
+ def callback_job?(job)
88
+ (job.is_a?(Class) && job < ::ActiveJob::Base) || job.is_a?(::ActiveJob::Base)
89
+ end
90
+ end
91
+ end
92
+ end