pgbus 0.8.3 → 0.9.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.
@@ -1,6 +1,35 @@
1
1
  ---
2
2
  nb:
3
3
  pgbus:
4
+ batches:
5
+ index:
6
+ description: Jobbgrupper med fremdriftssporing og tilbakekall
7
+ empty: Ingen grupper funnet
8
+ headers:
9
+ batch_id: Gruppe-ID
10
+ created: Opprettet
11
+ description: Beskrivelse
12
+ jobs: Jobber
13
+ progress: Fremdrift
14
+ status: Status
15
+ title: Grupper
16
+ show:
17
+ back: Tilbake til grupper
18
+ batch_id: Gruppe-ID
19
+ completed: Fullført
20
+ created_at: Opprettet den
21
+ details: Detaljer
22
+ discarded: Forkastet
23
+ finished_at: Avsluttet den
24
+ not_found: Gruppe ikke funnet
25
+ on_discard: Ved forkastning
26
+ on_finish: Ved avslutning
27
+ on_success: Ved suksess
28
+ progress: Fremdrift
29
+ properties: Egenskaper
30
+ remaining: Gjenstående
31
+ title: Gruppe
32
+ total_jobs: Totalt antall jobber
4
33
  dashboard:
5
34
  processes_table:
6
35
  empty: Ingen prosesser kjører
@@ -189,6 +218,10 @@ nb:
189
218
  not_found: Hendelse ikke funnet
190
219
  title: Hendelse %{event_id}
191
220
  helpers:
221
+ batch_status:
222
+ finished: Fullført
223
+ pending: Avventer
224
+ processing: Behandler
192
225
  bulk_select_all: Velg alle
193
226
  bulk_select_row: Velg %{id}
194
227
  bulk_selected: valgt
@@ -346,6 +379,7 @@ nb:
346
379
  layout:
347
380
  brand: Pgbus
348
381
  nav:
382
+ batches: Grupper
349
383
  dashboard: Dashbord
350
384
  dlq: DLQ
351
385
  events: Hendelser
@@ -1,6 +1,35 @@
1
1
  ---
2
2
  nl:
3
3
  pgbus:
4
+ batches:
5
+ index:
6
+ description: Taakgroepen met voortgangsregistratie en callbacks
7
+ empty: Geen groepen gevonden
8
+ headers:
9
+ batch_id: Groep-ID
10
+ created: Aangemaakt
11
+ description: Beschrijving
12
+ jobs: Taken
13
+ progress: Voortgang
14
+ status: Status
15
+ title: Groepen
16
+ show:
17
+ back: Terug naar groepen
18
+ batch_id: Groep-ID
19
+ completed: Voltooid
20
+ created_at: Aangemaakt op
21
+ details: Details
22
+ discarded: Verworpen
23
+ finished_at: Voltooid op
24
+ not_found: Groep niet gevonden
25
+ on_discard: Bij verwerping
26
+ on_finish: Bij voltooiing
27
+ on_success: Bij succes
28
+ progress: Voortgang
29
+ properties: Eigenschappen
30
+ remaining: Resterend
31
+ title: Groep
32
+ total_jobs: Totaal taken
4
33
  dashboard:
5
34
  processes_table:
6
35
  empty: Geen processen actief
@@ -189,6 +218,10 @@ nl:
189
218
  not_found: Gebeurtenis niet gevonden
190
219
  title: Gebeurtenis %{event_id}
191
220
  helpers:
221
+ batch_status:
222
+ finished: Voltooid
223
+ pending: In afwachting
224
+ processing: Bezig
192
225
  bulk_select_all: Alles selecteren
193
226
  bulk_select_row: Selecteer %{id}
194
227
  bulk_selected: geselecteerd
@@ -346,6 +379,7 @@ nl:
346
379
  layout:
347
380
  brand: Pgbus
348
381
  nav:
382
+ batches: Groepen
349
383
  dashboard: Dashboard
350
384
  dlq: DLQ
351
385
  events: Evenementen
@@ -1,6 +1,35 @@
1
1
  ---
2
2
  pt:
3
3
  pgbus:
4
+ batches:
5
+ index:
6
+ description: Lotes de trabalhos com acompanhamento de progresso e callbacks
7
+ empty: Nenhum lote encontrado
8
+ headers:
9
+ batch_id: ID do lote
10
+ created: Criado
11
+ description: Descrição
12
+ jobs: Trabalhos
13
+ progress: Progresso
14
+ status: Status
15
+ title: Lotes
16
+ show:
17
+ back: Voltar para lotes
18
+ batch_id: ID do lote
19
+ completed: Concluídos
20
+ created_at: Criado em
21
+ details: Detalhes
22
+ discarded: Descartados
23
+ finished_at: Finalizado em
24
+ not_found: Lote não encontrado
25
+ on_discard: Ao descartar
26
+ on_finish: Ao finalizar
27
+ on_success: Ao ter sucesso
28
+ progress: Progresso
29
+ properties: Propriedades
30
+ remaining: Restantes
31
+ title: Lote
32
+ total_jobs: Total de trabalhos
4
33
  dashboard:
5
34
  processes_table:
6
35
  empty: Nenhum processo em execução
@@ -189,6 +218,10 @@ pt:
189
218
  not_found: Evento não encontrado
190
219
  title: Evento %{event_id}
191
220
  helpers:
221
+ batch_status:
222
+ finished: Finalizado
223
+ pending: Pendente
224
+ processing: Processando
192
225
  bulk_select_all: Selecionar todos
193
226
  bulk_select_row: Selecionar %{id}
194
227
  bulk_selected: selecionado
@@ -346,6 +379,7 @@ pt:
346
379
  layout:
347
380
  brand: Pgbus
348
381
  nav:
382
+ batches: Lotes
349
383
  dashboard: Painel
350
384
  dlq: DLQ
351
385
  events: Eventos
@@ -1,6 +1,35 @@
1
1
  ---
2
2
  sv:
3
3
  pgbus:
4
+ batches:
5
+ index:
6
+ description: Jobbgrupper med framstegsspårning och callbacks
7
+ empty: Inga grupper hittades
8
+ headers:
9
+ batch_id: Grupp-ID
10
+ created: Skapad
11
+ description: Beskrivning
12
+ jobs: Jobb
13
+ progress: Framsteg
14
+ status: Status
15
+ title: Grupper
16
+ show:
17
+ back: Tillbaka till grupper
18
+ batch_id: Grupp-ID
19
+ completed: Slutförda
20
+ created_at: Skapad den
21
+ details: Detaljer
22
+ discarded: Kasserade
23
+ finished_at: Avslutad den
24
+ not_found: Grupp hittades inte
25
+ on_discard: Vid kassering
26
+ on_finish: Vid avslut
27
+ on_success: Vid framgång
28
+ progress: Framsteg
29
+ properties: Egenskaper
30
+ remaining: Återstående
31
+ title: Grupp
32
+ total_jobs: Totalt antal jobb
4
33
  dashboard:
5
34
  processes_table:
6
35
  empty: Inga processer körs
@@ -189,6 +218,10 @@ sv:
189
218
  not_found: Händelse hittades inte
190
219
  title: Händelse %{event_id}
191
220
  helpers:
221
+ batch_status:
222
+ finished: Slutförd
223
+ pending: Väntande
224
+ processing: Bearbetas
192
225
  bulk_select_all: Markera alla
193
226
  bulk_select_row: Markera %{id}
194
227
  bulk_selected: valda
@@ -346,6 +379,7 @@ sv:
346
379
  layout:
347
380
  brand: Pgbus
348
381
  nav:
382
+ batches: Grupper
349
383
  dashboard: Instrumentpanel
350
384
  dlq: DLQ
351
385
  events: Händelser
data/config/routes.rb CHANGED
@@ -45,6 +45,7 @@ Pgbus::Engine.routes.draw do
45
45
  end
46
46
  end
47
47
 
48
+ resources :batches, only: %i[index show]
48
49
  resources :processes, only: [:index]
49
50
 
50
51
  resources :events, only: %i[index show] do
data/lib/pgbus/client.rb CHANGED
@@ -351,6 +351,87 @@ module Pgbus
351
351
  total
352
352
  end
353
353
 
354
+ # --- Grouped reads (PGMQ v1.11.0+) ---
355
+
356
+ def read_grouped(queue_name, qty:, vt: nil)
357
+ full_name = config.queue_name(queue_name)
358
+ Instrumentation.instrument("pgbus.client.read_grouped", queue: full_name, qty: qty) do
359
+ with_stale_connection_retry do
360
+ synchronized { @pgmq.read_grouped(full_name, vt: vt || config.visibility_timeout, qty: qty) }
361
+ end
362
+ end
363
+ end
364
+
365
+ def read_grouped_rr(queue_name, qty:, vt: nil)
366
+ full_name = config.queue_name(queue_name)
367
+ Instrumentation.instrument("pgbus.client.read_grouped_rr", queue: full_name, qty: qty) do
368
+ with_stale_connection_retry do
369
+ synchronized { @pgmq.read_grouped_rr(full_name, vt: vt || config.visibility_timeout, qty: qty) }
370
+ end
371
+ end
372
+ end
373
+
374
+ def read_grouped_head(queue_name, qty:, vt: nil)
375
+ full_name = config.queue_name(queue_name)
376
+ with_stale_connection_retry do
377
+ synchronized { @pgmq.read_grouped_head(full_name, vt: vt || config.visibility_timeout, qty: qty) }
378
+ end
379
+ end
380
+
381
+ # --- FIFO index management (PGMQ v1.11.0+) ---
382
+
383
+ def create_fifo_index(queue_name)
384
+ full_name = config.queue_name(queue_name)
385
+ with_stale_connection_retry do
386
+ synchronized { @pgmq.create_fifo_index(full_name) }
387
+ end
388
+ end
389
+
390
+ def create_fifo_indexes_all
391
+ with_stale_connection_retry do
392
+ synchronized { @pgmq.create_fifo_indexes_all }
393
+ end
394
+ end
395
+
396
+ # --- LISTEN/NOTIFY management (PGMQ v1.11.0+) ---
397
+
398
+ def wait_for_notify(queue_name, timeout: nil, &block)
399
+ full_name = config.queue_name(queue_name)
400
+ with_stale_connection_retry do
401
+ synchronized { @pgmq.wait_for_notify(full_name, timeout: timeout, &block) }
402
+ end
403
+ end
404
+
405
+ def update_notify_insert(queue_name, throttle_interval_ms:)
406
+ full_name = config.queue_name(queue_name)
407
+ with_stale_connection_retry do
408
+ synchronized { @pgmq.update_notify_insert(full_name, throttle_interval_ms: throttle_interval_ms) }
409
+ end
410
+ end
411
+
412
+ def list_notify_insert_throttles
413
+ with_stale_connection_retry do
414
+ synchronized { @pgmq.list_notify_insert_throttles }
415
+ end
416
+ end
417
+
418
+ # --- Archive partitioning (requires pg_partman extension) ---
419
+
420
+ def convert_archive_partitioned(queue_name, partition_interval: "10000", retention_interval: "100000",
421
+ leading_partition: 10)
422
+ full_name = config.queue_name(queue_name)
423
+ with_stale_connection_retry do
424
+ synchronized do
425
+ @pgmq.convert_archive_partitioned(
426
+ full_name,
427
+ partition_interval: partition_interval,
428
+ retention_interval: retention_interval,
429
+ leading_partition: leading_partition
430
+ )
431
+ end
432
+ end
433
+ end
434
+
354
435
  # Topic routing
355
436
  def bind_topic(pattern, queue_name)
356
437
  full_name = config.queue_name(queue_name)
@@ -503,6 +584,7 @@ module Pgbus
503
584
  @pgmq.create(full_name)
504
585
  tune_autovacuum(full_name)
505
586
  enable_notify_if_needed(full_name, NOTIFY_THROTTLE_MS)
587
+ create_fifo_index_if_needed(full_name)
506
588
  end
507
589
  true
508
590
  end
@@ -515,6 +597,12 @@ module Pgbus
515
597
  @pgmq.enable_notify_insert(full_name, throttle_interval_ms: throttle_ms)
516
598
  end
517
599
 
600
+ def create_fifo_index_if_needed(full_name)
601
+ return unless config.group_mode
602
+
603
+ @pgmq.create_fifo_index(full_name)
604
+ end
605
+
518
606
  # Check whether the NOTIFY trigger already exists on this queue with the
519
607
  # expected throttle interval. When it does, we can skip the destructive
520
608
  # DROP TRIGGER + CREATE TRIGGER cycle that causes deadlocks when multiple
@@ -544,11 +632,21 @@ module Pgbus
544
632
  false
545
633
  end
546
634
 
635
+ # Apply PGMQ-tuned autovacuum + storage parameters to a queue's tables.
636
+ #
637
+ # Delegates to pgmq-ruby's tune_autovacuum (v0.7+), which sets the same
638
+ # queue/archive parameters pgbus used to apply by hand — vacuum scale
639
+ # factor 0.01/0.05, cost_delay 2/5, analyze scale factor 0.05, and
640
+ # fillfactor 70 on the queue table — plus a vacuum_threshold floor of 50.
641
+ # It quotes/lowercases the table name and runs both ALTER TABLEs in one
642
+ # pooled checkout. Tuning is best-effort: a failure here never blocks a
643
+ # queue from being usable, so we log and move on.
644
+ #
645
+ # Pgbus::AutovacuumTuning is still the source for the migration generators
646
+ # (sql_for_all_queues, sql_for_high_churn_tables) which tune pgbus-owned
647
+ # metadata tables the gem doesn't know about.
547
648
  def tune_autovacuum(queue_name)
548
- with_raw_connection do |conn|
549
- conn.exec(AutovacuumTuning.sql_for_queue(queue_name))
550
- conn.exec(TableMaintenance.fillfactor_sql_for_queue(queue_name))
551
- end
649
+ @pgmq.tune_autovacuum(queue_name)
552
650
  rescue StandardError => e
553
651
  Pgbus.logger.debug { "[Pgbus::Client] Autovacuum tuning failed for #{queue_name}: #{e.message}" }
554
652
  end
@@ -44,6 +44,12 @@ module Pgbus
44
44
  # Priority queues
45
45
  attr_accessor :priority_levels, :default_priority
46
46
 
47
+ # Grouped reads (PGMQ v1.11.0+ FIFO grouping).
48
+ # nil = disabled (default read_batch behavior).
49
+ # :fifo = use read_grouped (drains oldest group first, throughput-optimized).
50
+ # :round_robin = use read_grouped_rr (fair round-robin across groups).
51
+ attr_reader :group_mode
52
+
47
53
  # Archive compaction. Only the user-facing retention window is configurable;
48
54
  # the loop interval and batch size are tuned via constants on
49
55
  # Pgbus::Process::Dispatcher.
@@ -106,7 +112,8 @@ module Pgbus
106
112
  :streams_write_deadline_ms, :streams_falcon_streaming_body,
107
113
  :streams_stats_enabled, :streams_test_mode,
108
114
  :streams_orphan_sweep_interval, :streams_orphan_threshold,
109
- :streams_durable_patterns
115
+ :streams_durable_patterns,
116
+ :streams_host, :streams_port, :streams_database_url
110
117
  attr_reader :streams_default_broadcast_mode # rubocop:disable Style/AccessorGrouping
111
118
 
112
119
  # AppSignal integration (auto-loaded when ::Appsignal is defined and this is true).
@@ -145,6 +152,7 @@ module Pgbus
145
152
 
146
153
  @priority_levels = nil
147
154
  @default_priority = 1
155
+ @group_mode = nil
148
156
 
149
157
  @archive_retention = 7 * 24 * 3600 # 7 days
150
158
 
@@ -193,6 +201,22 @@ module Pgbus
193
201
  @streams_enabled = true
194
202
  @streams_path = nil
195
203
  @streams_queue_prefix = "pgbus_stream"
204
+ # Streamer-only connection overrides. The Streamer's Listener owns a
205
+ # dedicated long-lived `wait_for_notify` PG connection that can't go
206
+ # through a PgBouncer in transaction mode (LISTEN/NOTIFY don't survive
207
+ # transaction-pool COMMIT boundaries — see PlanetScale's docs). Setting
208
+ # any of these overrides only the Streamer's connection options; the
209
+ # worker, dispatcher, and client publish paths keep using the regular
210
+ # `database_url` / `connection_params` (typically pooled).
211
+ #
212
+ # streams_host — override host only
213
+ # streams_port — override port only (most common case:
214
+ # pooler is on 6432, direct is 5432)
215
+ # streams_database_url — full URL override; takes precedence over
216
+ # the host/port surgicals when set
217
+ @streams_host = nil
218
+ @streams_port = nil
219
+ @streams_database_url = nil
196
220
  @streams_signed_name_secret = nil
197
221
  @streams_default_retention = 5 * 60 # 5 minutes
198
222
  @streams_retention = {}
@@ -283,6 +307,24 @@ module Pgbus
283
307
  @streams_default_broadcast_mode = mode
284
308
  end
285
309
 
310
+ VALID_GROUP_MODES = [nil, :fifo, :round_robin].freeze
311
+
312
+ def group_mode=(mode)
313
+ coerced = case mode
314
+ when nil then nil
315
+ when Symbol then mode
316
+ when String then mode.to_sym
317
+ else
318
+ raise ArgumentError,
319
+ "Invalid group_mode type: #{mode.class}. Must be nil, String, or Symbol"
320
+ end
321
+ unless VALID_GROUP_MODES.include?(coerced)
322
+ raise ArgumentError, "Invalid group_mode: #{coerced.inspect}. Must be nil, :fifo, or :round_robin"
323
+ end
324
+
325
+ @group_mode = coerced
326
+ end
327
+
286
328
  VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
287
329
 
288
330
  def pgmq_schema_mode=(mode)
@@ -608,6 +650,43 @@ module Pgbus
608
650
  end
609
651
  end
610
652
 
653
+ # Connection options the Streamer's dedicated LISTEN/NOTIFY PG connection
654
+ # should use. Defaults to `connection_options` (same as workers and the
655
+ # publish path). If any of `streams_database_url`, `streams_host`, or
656
+ # `streams_port` is set, the Streamer's connection is reconfigured —
657
+ # everything else keeps using the base options.
658
+ #
659
+ # The typical use is "workers go through PgBouncer, streamer goes direct":
660
+ #
661
+ # c.connects_to = { database: { writing: :pgbus } } # pooler
662
+ # c.streams_port = 5432 # direct
663
+ #
664
+ # Precedence: streams_database_url > streams_host/port override > base.
665
+ def streams_connection_options
666
+ return streams_database_url if streams_database_url
667
+
668
+ base = connection_options
669
+ return base unless streams_host || streams_port
670
+
671
+ case base
672
+ when Hash
673
+ result = base.dup
674
+ result[:host] = streams_host if streams_host
675
+ result[:port] = streams_port if streams_port
676
+ result
677
+ when String
678
+ # libpq's conninfo parser takes later key=value pairs as overrides
679
+ # for earlier ones, so we just append. Handles both URI form
680
+ # (postgres://...) and key=value form.
681
+ parts = [base]
682
+ parts << "host=#{streams_host}" if streams_host
683
+ parts << "port=#{streams_port}" if streams_port
684
+ parts.join(" ")
685
+ else
686
+ base
687
+ end
688
+ end
689
+
611
690
  private
612
691
 
613
692
  # Coerce a duration setting value to a positive Numeric.
@@ -69,6 +69,7 @@ module Pgbus
69
69
  single_active = worker_config[:single_active_consumer] || worker_config["single_active_consumer"] || false
70
70
  priority = worker_config[:consumer_priority] || worker_config["consumer_priority"] || 0
71
71
  exec_mode = config.execution_mode_for(worker_config)
72
+ grp_mode = worker_config[:group_mode] || worker_config["group_mode"] || config.group_mode
72
73
 
73
74
  pid = fork do
74
75
  restore_signals
@@ -78,7 +79,7 @@ module Pgbus
78
79
  worker = Worker.new(
79
80
  queues: queues, threads: threads, config: config,
80
81
  single_active_consumer: single_active, consumer_priority: priority,
81
- execution_mode: exec_mode
82
+ execution_mode: exec_mode, group_mode: grp_mode
82
83
  )
83
84
  worker.run
84
85
  end
@@ -11,12 +11,24 @@ module Pgbus
11
11
 
12
12
  def initialize(queues:, threads: 5, config: Pgbus.configuration,
13
13
  single_active_consumer: false, consumer_priority: 0,
14
- execution_mode: :threads)
14
+ execution_mode: :threads, group_mode: nil)
15
15
  @queues = Array(queues)
16
16
  @wildcard = @queues.include?("*")
17
17
  @threads = threads
18
18
  @config = config
19
19
  @execution_mode = ExecutionPools.normalize_mode(execution_mode)
20
+ @group_mode = case group_mode
21
+ when nil then nil
22
+ when Symbol then group_mode
23
+ when String then group_mode.to_sym
24
+ else
25
+ raise ArgumentError,
26
+ "Invalid group_mode type: #{group_mode.class}. Must be nil, String, or Symbol"
27
+ end
28
+ unless Pgbus::Configuration::VALID_GROUP_MODES.include?(@group_mode)
29
+ raise ArgumentError,
30
+ "Invalid group_mode: #{@group_mode.inspect}. Must be nil, :fifo, or :round_robin"
31
+ end
20
32
  @single_active_consumer = single_active_consumer
21
33
  @consumer_priority = consumer_priority
22
34
  @lifecycle = Lifecycle.new
@@ -141,6 +153,8 @@ module Pgbus
141
153
 
142
154
  if priority_enabled?
143
155
  fetch_prioritized(active_queues, qty)
156
+ elsif @group_mode
157
+ fetch_grouped(active_queues, qty)
144
158
  elsif active_queues.size == 1
145
159
  queue = active_queues.first
146
160
  messages = Pgbus.client.read_batch(queue, qty: qty) || []
@@ -210,6 +224,29 @@ module Pgbus
210
224
  end
211
225
  end
212
226
 
227
+ # Use grouped reads for fair or throughput-optimized multi-tenant processing.
228
+ # Each queue is read independently with the configured group strategy.
229
+ def fetch_grouped(active_queues, qty)
230
+ remaining = qty
231
+ results = []
232
+
233
+ active_queues.each do |queue|
234
+ break if remaining <= 0
235
+
236
+ messages = case @group_mode
237
+ when :round_robin
238
+ Pgbus.client.read_grouped_rr(queue, qty: remaining) || []
239
+ else # :fifo
240
+ Pgbus.client.read_grouped(queue, qty: remaining) || []
241
+ end
242
+
243
+ messages.each { |m| results << [queue, m] }
244
+ remaining -= messages.size
245
+ end
246
+
247
+ results
248
+ end
249
+
213
250
  def priority_enabled?
214
251
  config.priority_levels && config.priority_levels > 1
215
252
  end
@@ -58,7 +58,7 @@ module Pgbus
58
58
  raise ArgumentError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
59
59
 
60
60
  allowed = Pgbus.configuration.allowed_global_id_models
61
- if allowed&.empty?
61
+ if allowed && allowed.empty?
62
62
  raise ArgumentError,
63
63
  "GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
64
64
  "Set to nil to allow all models, or add permitted classes."
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.8.3"
4
+ VERSION = "0.9.0"
5
5
  end