pgbus 0.9.10 → 0.10.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -7
  3. data/README.md +34 -7
  4. data/Rakefile +22 -1
  5. data/app/models/pgbus/stream_queue.rb +87 -0
  6. data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
  7. data/lib/generators/pgbus/install_generator.rb +3 -3
  8. data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
  9. data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
  10. data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
  11. data/lib/generators/pgbus/update_generator.rb +6 -66
  12. data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
  13. data/lib/pgbus/client/read_after.rb +7 -6
  14. data/lib/pgbus/client/resizable_pool.rb +160 -0
  15. data/lib/pgbus/client.rb +261 -4
  16. data/lib/pgbus/configuration.rb +244 -11
  17. data/lib/pgbus/doctor.rb +73 -2
  18. data/lib/pgbus/engine.rb +30 -3
  19. data/lib/pgbus/generators/migration_detector.rb +7 -0
  20. data/lib/pgbus/process/dispatcher.rb +89 -19
  21. data/lib/pgbus/process/notify_listener.rb +18 -2
  22. data/lib/pgbus/process/worker.rb +19 -3
  23. data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
  24. data/lib/pgbus/streams/pool_trigger.rb +124 -0
  25. data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
  26. data/lib/pgbus/streams.rb +2 -2
  27. data/lib/pgbus/version.rb +1 -1
  28. data/lib/pgbus/web/streamer/connection.rb +22 -6
  29. data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
  30. data/lib/pgbus/web/streamer/instance.rb +75 -5
  31. data/lib/pgbus/web/streamer/io_writer.rb +11 -5
  32. data/lib/pgbus/web/streamer/listener.rb +61 -1
  33. data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
  34. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
  35. metadata +9 -4
  36. data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
  37. data/lib/pgbus/config_loader.rb +0 -73
  38. data/lib/pgbus/generators/config_converter.rb +0 -345
@@ -1,50 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
- require "pgbus/generators/config_converter"
5
4
  require "pgbus/generators/migration_detector"
6
5
  require "pgbus/generators/database_target_detector"
7
6
 
8
7
  module Pgbus
9
8
  module Generators
10
- # Upgrade command with two independent jobs:
11
- #
12
- # 1. Config conversion: if config/pgbus.yml exists, convert it to
13
- # config/initializers/pgbus.rb using the modern Ruby DSL. Skip
14
- # silently if the initializer already exists or the YAML is
15
- # absent — safe to re-run.
16
- #
17
- # 2. Migration detection: inspect the live database and add any
18
- # missing pgbus migrations to db/migrate (or db/pgbus_migrate
19
- # if a separate database is configured). Invokes each matching
20
- # sub-generator in-process via Thor's invoke, so this mirrors
21
- # what the user would get running each generator by hand.
9
+ # Upgrade command: inspect the live database and add any missing pgbus
10
+ # migrations to db/migrate (or db/pgbus_migrate if a separate database is
11
+ # configured). Invokes each matching sub-generator in-process via Thor's
12
+ # invoke, so this mirrors what the user would get running each generator
13
+ # by hand.
22
14
  #
23
15
  # Usage:
24
16
  #
25
17
  # bin/rails generate pgbus:update
26
18
  # bin/rails generate pgbus:update --dry-run
27
- # bin/rails generate pgbus:update --skip-config
28
19
  # bin/rails generate pgbus:update --skip-migrations
29
20
  # bin/rails generate pgbus:update --database=pgbus
30
21
  # bin/rails generate pgbus:update --quiet
31
22
  class UpdateGenerator < Rails::Generators::Base
32
- desc "Upgrade pgbus: convert YAML config + add any missing migrations"
33
-
34
- class_option :source,
35
- type: :string,
36
- default: "config/pgbus.yml",
37
- desc: "Path to an existing YAML config to convert (default: config/pgbus.yml)"
38
-
39
- class_option :destination,
40
- type: :string,
41
- default: "config/initializers/pgbus.rb",
42
- desc: "Path to the generated initializer (default: config/initializers/pgbus.rb)"
43
-
44
- class_option :skip_config,
45
- type: :boolean,
46
- default: false,
47
- desc: "Skip the YAML → Ruby initializer conversion step"
23
+ desc "Upgrade pgbus: add any missing migrations"
48
24
 
49
25
  class_option :skip_migrations,
50
26
  type: :boolean,
@@ -67,30 +43,6 @@ module Pgbus
67
43
  default: false,
68
44
  desc: "Suppress verbose per-step output"
69
45
 
70
- def convert_yaml_if_present
71
- return if options[:skip_config]
72
-
73
- source_path = File.expand_path(options[:source], destination_root)
74
- destination_path = File.expand_path(options[:destination], destination_root)
75
-
76
- unless File.exist?(source_path)
77
- log "YAML config not found at #{options[:source]}; skipping config conversion."
78
- return
79
- end
80
-
81
- if File.exist?(destination_path)
82
- log "Initializer already exists at #{options[:destination]}; skipping config conversion."
83
- return
84
- end
85
-
86
- ruby_source = load_and_convert(source_path)
87
- if options[:dry_run]
88
- log_change "[dry-run] would create #{options[:destination]}"
89
- else
90
- create_file destination_path, ruby_source
91
- end
92
- end
93
-
94
46
  def detect_and_install_missing_migrations
95
47
  return if options[:skip_migrations]
96
48
 
@@ -172,18 +124,6 @@ module Pgbus
172
124
 
173
125
  private
174
126
 
175
- # Wrap converter and YAML errors as Thor::Error so the generator
176
- # surfaces them with the standard "in red, no backtrace, exit 1"
177
- # behavior. Catches:
178
- # - ConfigConverter::Error (validation, missing file race)
179
- # - Psych::Exception (malformed YAML, disallowed types)
180
- # - Errno::ENOENT / Errno::EACCES (file disappeared / not readable)
181
- def load_and_convert(source_path)
182
- ConfigConverter.from_yaml(source_path)
183
- rescue ConfigConverter::Error, Psych::Exception, Errno::ENOENT, Errno::EACCES => e
184
- raise Thor::Error, "Failed to convert #{options[:source]}: #{e.message}"
185
- end
186
-
187
127
  def active_record_available?
188
128
  defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.respond_to?(:connection)
189
129
  end
@@ -22,7 +22,14 @@ module Pgbus
22
22
  full_name = config.queue_name(stream_name)
23
23
 
24
24
  with_stale_connection_retry do
25
- ensure_queue(stream_name)
25
+ # Create the BARE queue directly. ensure_queue would fan out through
26
+ # the priority strategy to _p0.._pN under priority_levels>1, leaving
27
+ # the bare queue — the one the streamer NOTIFYs on and read_after
28
+ # peeks — uncreated, and the enable_notify_if_needed below would then
29
+ # raise on the missing bare table. Streams never use priority
30
+ # sub-queues (issue #310).
31
+ ensure_pgmq_schema
32
+ ensure_single_queue(full_name)
26
33
 
27
34
  # PGMQ's default NOTIFY throttle is 250ms — meant to coalesce
28
35
  # high-frequency worker queue inserts. Streams are latency-
@@ -38,6 +45,8 @@ module Pgbus
38
45
  # requires a roundtrip and a brief ACCESS SHARE lock on the archive
39
46
  # table. Broadcast-per-after_commit loops can hit this 1000x/sec on
40
47
  # the same stream, so memoize per-process after the first success.
48
+ # The StreamQueue registration shares this memo — both are one-time
49
+ # per stream per process and must both survive a first-broadcast.
41
50
  return if @stream_indexes_created[stream_name]
42
51
 
43
52
  sanitized = QueueNameValidator.sanitize!(full_name)
@@ -52,6 +61,11 @@ module Pgbus
52
61
  end
53
62
  end
54
63
 
64
+ # Record the physical queue name so maintenance (stream-archive prune,
65
+ # orphan sweep, compact_archives) and wildcard workers can tell this
66
+ # queue apart from a job queue. No-ops on unmigrated installs.
67
+ Pgbus::StreamQueue.record!(full_name)
68
+
55
69
  @stream_indexes_created[stream_name] = true
56
70
  end
57
71
  end
@@ -18,7 +18,7 @@ module Pgbus
18
18
  sql = build_read_after_sql(sanitized)
19
19
 
20
20
  rows = synchronized do
21
- with_raw_connection do |conn|
21
+ with_streams_connection do |conn|
22
22
  conn.exec_params(sql, [after_id.to_i, limit.to_i]).to_a
23
23
  end
24
24
  end
@@ -34,7 +34,7 @@ module Pgbus
34
34
  sanitized = sanitized_queue(stream_name)
35
35
  sql = "SELECT COALESCE(MAX(msg_id), 0) AS max FROM pgmq.q_#{sanitized}"
36
36
  synchronized do
37
- with_raw_connection do |conn|
37
+ with_streams_connection do |conn|
38
38
  conn.exec(sql).first.fetch("max").to_i
39
39
  end
40
40
  end
@@ -53,7 +53,7 @@ module Pgbus
53
53
  ) AS least
54
54
  SQL
55
55
  synchronized do
56
- with_raw_connection do |conn|
56
+ with_streams_connection do |conn|
57
57
  value = conn.exec(sql).first.fetch("least")
58
58
  value&.to_i
59
59
  end
@@ -84,9 +84,10 @@ module Pgbus
84
84
  # See issues #101 and #104. The comparison is case-insensitive because
85
85
  # Postgres downcases unquoted identifiers in its error output, while
86
86
  # `sanitized` can contain uppercase characters for GlobalID-keyed streams
87
- # (e.g. `pgbus_stream_Z2lkOi8vY29zbW9zL1VzZXIvMQ` from
88
- # `pgbus_stream_from Current.user`). A case-sensitive substring match
89
- # would miss the downcased relation name and let the exception escape.
87
+ # (e.g. `pgbus_Z2lkOi8vY29zbW9zL1VzZXIvMQ` from
88
+ # `pgbus_stream_from Current.user` stream queues share the job prefix,
89
+ # see Configuration#queue_name). A case-sensitive substring match would
90
+ # miss the downcased relation name and let the exception escape.
90
91
  #
91
92
  # The regex uses `\b` word boundaries so `pgmq.q_<needle>` doesn't
92
93
  # accidentally match longer related identifiers like
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ class Client
7
+ # Owns the current streams PGMQ::Client behind a Concurrent::AtomicReference
8
+ # and resizes it by BUILDING A NEW pool, atomically swapping the reference,
9
+ # then bounded-draining + shutting down the old one (issue #323 spike).
10
+ #
11
+ # connection_pool 2.x cannot resize in place (@size is frozen at
12
+ # construction; only shutdown/reload/available exist), so growing REQUIRES a
13
+ # new PGMQ::Client + a reference swap.
14
+ #
15
+ # DRAIN CORRECTNESS — the load-bearing detail. We do NOT trust
16
+ # connection_pool's `available == size` as "drained": pgmq-ruby's
17
+ # #with_connection retries a lost connection (checkout → checkin → checkout
18
+ # again), so between the two checkouts the connection sits idle in the queue
19
+ # and `available` momentarily equals `size` while a produce/read is still
20
+ # mid-flight. Closing the pool in that window makes the retry's checkout raise
21
+ # PoolShuttingDownError → a genuinely LOST durable broadcast (the
22
+ # PgBouncer/failover mid-INSERT case). Instead each reader op is bracketed by
23
+ # an in-flight AtomicFixnum on the CAPTURED pool, and drain waits until THAT
24
+ # hits zero. This is immune to the internal retry and independent of the
25
+ # connection_pool version.
26
+ #
27
+ # DURABLE BROADCASTS are loss-safe by construction: #produce captures the
28
+ # Pool once, runs its whole retrying checkout→INSERT→COMMIT→checkin on that
29
+ # pool, then decrements. Both old and new pools connect to the same Postgres,
30
+ # so any INSERT commits; and close waits for the captured pool's inflight to
31
+ # reach zero, so a pool is never shut while a produce is in flight on it.
32
+ class ResizablePool
33
+ # The current pool: a PGMQ::Client plus its own in-flight checkout counter.
34
+ Pool = Data.define(:pgmq, :inflight)
35
+
36
+ SwapStats = Data.define(:swap_count, :last_drain_seconds, :last_conns_closed,
37
+ :last_from_size, :last_to_size, :last_drained)
38
+
39
+ DRAIN_POLL = 0.01 # 10ms
40
+
41
+ # `shared:` is accepted for symmetry with the Client's two connection paths
42
+ # but is not stored — the shared-AR alias guard lives in #close_current via
43
+ # the `job_pool:` identity check, and #swap is simply never called on the
44
+ # shared path (Client#resize_streams_pool short-circuits there).
45
+ def initialize(pgmq, shared:, drain_timeout:, logger:, clock: ::Process) # rubocop:disable Lint/UnusedMethodArgument
46
+ @ref = Concurrent::AtomicReference.new(new_pool(pgmq))
47
+ @drain_timeout = drain_timeout
48
+ @logger = logger
49
+ @clock = clock
50
+ @swap_mutex = Mutex.new # serializes swap AND close_current; reads never take it
51
+ @swap_count = 0
52
+ @last = nil
53
+ end
54
+
55
+ # Hot path: one volatile load, no lock.
56
+ def current = @ref.get
57
+
58
+ # Bracketed reader ops — inflight decrements only after the WHOLE retrying
59
+ # call returns (so drain can't close under an in-flight produce/read).
60
+ def produce(*, **)
61
+ pool = @ref.get
62
+ pool.inflight.increment
63
+ begin
64
+ pool.pgmq.produce(*, **)
65
+ ensure
66
+ pool.inflight.decrement
67
+ end
68
+ end
69
+
70
+ def with_connection(&)
71
+ pool = @ref.get
72
+ pool.inflight.increment
73
+ begin
74
+ pool.pgmq.with_connection(&)
75
+ ensure
76
+ pool.inflight.decrement
77
+ end
78
+ end
79
+
80
+ def stats = @ref.get.pgmq.stats
81
+
82
+ # Swap in a freshly-built PGMQ::Client, then drain + close the old Pool.
83
+ def swap(new_pgmq, from_size:, to_size:)
84
+ @swap_mutex.synchronize do
85
+ old = @ref.get
86
+ @ref.set(new_pool(new_pgmq)) # new checkouts now resolve to the new pool
87
+ drained, secs, closed = drain_and_close(old)
88
+ @swap_count += 1
89
+ @last = SwapStats.new(swap_count: @swap_count, last_drain_seconds: secs,
90
+ last_conns_closed: closed, last_from_size: from_size,
91
+ last_to_size: to_size, last_drained: drained)
92
+ end
93
+ end
94
+
95
+ # Teardown of the current pool (from Client#close), race-free vs swap.
96
+ # Skips the close when the current pool aliases the job pool (shared-AR path).
97
+ def close_current(job_pool:)
98
+ @swap_mutex.synchronize do
99
+ pool = @ref.get
100
+ pool.pgmq.close unless pool.pgmq.equal?(job_pool)
101
+ end
102
+ end
103
+
104
+ def stats_snapshot
105
+ @last || SwapStats.new(swap_count: 0, last_drain_seconds: 0.0, last_conns_closed: 0,
106
+ last_from_size: nil, last_to_size: nil, last_drained: nil)
107
+ end
108
+
109
+ private
110
+
111
+ def new_pool(pgmq)
112
+ Pool.new(pgmq: pgmq, inflight: Concurrent::AtomicFixnum.new(0))
113
+ end
114
+
115
+ # Bounded drain on the OLD pool's inflight counter, then shutdown. Mirrors
116
+ # OutboundPump#stop: fixed budget, log-and-abandon, NEVER Thread#kill /
117
+ # Timeout.timeout. Returns [drained?, wall_secs, conns_closed].
118
+ def drain_and_close(old)
119
+ t0 = mono
120
+ drained = wait_until_drained(old.inflight)
121
+ # Count connections about to be torn down BEFORE close (after close it's ~0).
122
+ conns = conns_open(old.pgmq)
123
+ unless drained
124
+ @logger.warn do
125
+ "[Pgbus::ResizablePool] streams pool did not fully drain in " \
126
+ "#{@drain_timeout}s (inflight=#{old.inflight.value}); shutting down anyway — " \
127
+ "in-flight connections self-close on checkin"
128
+ end
129
+ end
130
+ old.pgmq.close
131
+ [drained, mono - t0, conns]
132
+ rescue StandardError => e
133
+ @logger.error { "[Pgbus::ResizablePool] old pool teardown failed: #{e.class}: #{e.message}" }
134
+ [false, mono - t0, 0]
135
+ end
136
+
137
+ def wait_until_drained(inflight)
138
+ deadline = mono + @drain_timeout
139
+ loop do
140
+ return true if inflight.value <= 0
141
+ return false if mono >= deadline
142
+
143
+ sleep(DRAIN_POLL) # NOT Timeout.timeout — Pgbus/NoRubyTimeout cop
144
+ end
145
+ end
146
+
147
+ # size - available = connections currently open (created & not idle-in-queue).
148
+ def conns_open(pgmq)
149
+ stats = pgmq.stats
150
+ [stats[:size] - stats[:available], 0].max
151
+ rescue StandardError
152
+ 0
153
+ end
154
+
155
+ # Qualify ::Process — Pgbus::Process (the worker/supervisor namespace)
156
+ # shadows the top-level constant here.
157
+ def mono = @clock.clock_gettime(::Process::CLOCK_MONOTONIC)
158
+ end
159
+ end
160
+ end