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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -7
- data/README.md +34 -7
- data/Rakefile +22 -1
- data/app/models/pgbus/stream_queue.rb +87 -0
- data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
- data/lib/generators/pgbus/install_generator.rb +3 -3
- data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
- data/lib/generators/pgbus/update_generator.rb +6 -66
- data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
- data/lib/pgbus/client/read_after.rb +7 -6
- data/lib/pgbus/client/resizable_pool.rb +160 -0
- data/lib/pgbus/client.rb +261 -4
- data/lib/pgbus/configuration.rb +244 -11
- data/lib/pgbus/doctor.rb +73 -2
- data/lib/pgbus/engine.rb +30 -3
- data/lib/pgbus/generators/migration_detector.rb +7 -0
- data/lib/pgbus/process/dispatcher.rb +89 -19
- data/lib/pgbus/process/notify_listener.rb +18 -2
- data/lib/pgbus/process/worker.rb +19 -3
- data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
- data/lib/pgbus/streams/pool_trigger.rb +124 -0
- data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
- data/lib/pgbus/streams.rb +2 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/streamer/connection.rb +22 -6
- data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
- data/lib/pgbus/web/streamer/instance.rb +75 -5
- data/lib/pgbus/web/streamer/io_writer.rb +11 -5
- data/lib/pgbus/web/streamer/listener.rb +61 -1
- data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
- metadata +9 -4
- data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
- data/lib/pgbus/config_loader.rb +0 -73
- 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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. `
|
|
88
|
-
# `pgbus_stream_from Current.user`
|
|
89
|
-
#
|
|
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
|