pgbus 0.13.8 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 403234bf91b0ae06bd9c1331b6356450c98cc7a062a7ebcfb45f159950d07c2c
4
- data.tar.gz: a314687f0071c9588433dcd452c146178cc1afc52a2e46a675e998004bdbef2f
3
+ metadata.gz: 66fe070813c414410d4b55f2b9c9f1dfcacd70236f97d22f8e037ec7a2d906c4
4
+ data.tar.gz: 285e8851e43a02384734c0dfda2ae240ea4450554b5c8dca478e351b04a57528
5
5
  SHA512:
6
- metadata.gz: 80ce61932ee3f9c34562c353219ae5c19a2a1b312c8418e6ed645e32c66ac2e7f590edd7b9cc861e4f13b4bd784a7f2712d4b45b11f0a0c49b30fa117a664501
7
- data.tar.gz: 91856c566af986f8de528c40e036a00548adf0d7bb0d6e1fc96abb887120613df0b9f8fb22a04292bf4bb794baf06e6a7668b823b05fed730d52b420ff5cfc9b
6
+ metadata.gz: 160efab6bf57a74ab7d190b127b7e0f64426fc17877407a87c0f70a3ba328bea8e0dfa6c44e2477263e746b16a23544843a044e1eb88c285147a6e31e129f404
7
+ data.tar.gz: 7dc944582757565e3d0c8339661e533745f750731da645f2aaf6693673083b31b470f1b92265e880278b7b5720a593cc266eb9d3a11aaf554a527780f7cd481b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ### Fixed
4
4
 
5
+ - **Boot-time BusRecord connections no longer wedge `db:test:purge` — pgbus disconnects its own pools before every database purge/drop (issue #409).** With pgbus on a dedicated database (`config.connects_to`), any boot-time touch of a pgbus model left an idle session on that database for the life of the process. Rails' purge/drop only disconnects the connection it establishes for the target db_config — it knows nothing about gem-owned pools — so the rake process's own idle session blocked its own `DROP DATABASE`: a hang without a `statement_timeout`, a fast `ActiveRecord::QueryCanceled` with one, and either way `db:test:prepare` / `maintain_test_schema!` permanently broken on that machine (every retry re-boots, re-opens the connection, and re-blocks; terminating sessions externally can't help). Three changes: **(1)** `Pgbus::DatabaseTasksGuard` is prepended onto `ActiveRecord::Tasks::DatabaseTasks` at boot, so every route to a purge/drop — `db:test:purge` / `db:purge` / `db:drop` and their per-database variants, `maintain_test_schema!`'s in-process purge, parallel-testing's `TestDatabases` — first runs `Pgbus::BusRecord.disconnect_all_pools!` (all roles; a no-op on primary-database installs, `ActiveRecord::Base`'s pool is never touched). **(2)** The task-aware guard the event-bus registry already used internally is now public as **`Pgbus.database_task?`** — true while the process runs a `db:*` / `assets:*`-family rake task — so apps can skip their own boot-time warm-ups in the contexts where a database may legitimately not exist. **(3)** `pgbus:tune_autovacuum` (enhanced onto `db:schema:load`) no longer holds a permanent connection lease from `BusRecord.connection`: it checks out via `connection_pool.with_connection` and disconnects the pool afterward, leaving no idle session behind inside longer rake chains. Refs #409.
6
+
7
+ - **Concurrent queue creation no longer kills the loser with `PG::UniqueViolation` on `pg_class` (issue #404).** The sibling of the #403 trigger race, one DDL step earlier: `CREATE TABLE IF NOT EXISTS` is not race-safe — two backends creating a not-yet-existing queue both pass the existence check (READ COMMITTED; neither sees the other's uncommitted catalog rows), both insert into `pg_class`, and the loser raises `unique_violation` on `pg_class_relname_nsp_index` instead of the friendly `duplicate_table`. All the dedup guards (`@queues_created`, `Stream#@ensured`, `synchronized`) are process-local, so with lazily-created per-record durable stream queues two processes first-touching the same brand-new stream hit `pgmq.create` simultaneously — and via `send_message`'s `ensure_queue` the same window applies to plain job queues on cold post-deploy herds (production: AppSignal incident #963, an unrescued `PG::UniqueViolation` out of an unrelated `after_commit`). Three DDL steps now rescue the duplicate as success, mirroring the #397/#403 shape: **(1)** `create_queue_table` — `pgmq.create` is a single statement and therefore atomic, so when the loser unblocks the winner has committed the whole queue; the loser re-checks `pgmq.meta` and returns, retrying `pgmq.create` once when the re-check can't confirm (e.g. a leftover physical table without a meta row) with a second failure propagating (the retry's own error — the original duplicate is preserved deeper in its cause chain via Ruby's implicit `$!` chaining); **(2)** `create_fifo_index_if_needed` under group_mode — the duplicate proves the index exists; **(3)** the stream archive `msg_id` index in `ensure_stream_queue` — a raw `CREATE INDEX IF NOT EXISTS` with the same catalog race, where the loser now proceeds to register and memoize the stream instead of dropping the first broadcast. Duplicate detection reuses `DUPLICATE_INSTALL_ERROR_CLASSES`, matched directly (raw `conn.exec` paths) or as the `cause` of pgmq-ruby's `ConnectionError` wrapper. No locking added, no happy-path cost. Refs #404.
8
+
9
+ - **Concurrent lazy stream-queue ensures no longer drop the loser's broadcast with `PG::DuplicateObject` on the NOTIFY trigger (issue #403).** `Client#enable_notify_if_needed` is check-then-act — `notify_trigger_current?` is a plain SELECT and the surrounding `synchronized` is a process-local mutex — so after a deploy, when every process's `@queues_created` memo is cold, two processes handling the first broadcast for the same stream both see "trigger not current" and both run PGMQ's `DROP TRIGGER IF EXISTS` + `CREATE CONSTRAINT TRIGGER` cycle. The loser's CREATE blocks on the winner's table lock and fails with `duplicate_object` once the winner commits — surfaced as `PGMQ::Errors::ConnectionError`, killing that broadcast (in production: a deploy-time thundering herd turning unrelated `after_commit` writes into 500s, AppSignal incident #546). The existing mitigations all miss this window: the 0.12.x `notify_trigger_current?` check made repeat ensures cheap but left the TOCTOU race, the #397 advisory lock serializes schema install only, and the supervisor's pre-fork bootstrap covers job-container children but not web processes. The loser now treats the duplicate as success — the trigger provably exists because a concurrent caller just created it (same shape as the #397 duplicate-object rescue on schema install; matched on the trigger identifier, which survives message localization, plus the `PG::DuplicateObject` cause or the "already exists" text). Before returning, it re-checks `notify_trigger_current?`: racing ensures pass the same throttle so the check normally confirms and returns, but a job-queue ensure (250ms) racing the stream override (0ms) can leave the wrong interval installed — that mismatch retries `enable_notify_insert` once to converge; a second loss propagates. No locking added, no happy-path cost. Refs #403.
10
+
5
11
  - **`StreamQueue.record!` no longer depends on Rails' pool schema-cache index resolution — one bad probe stopped poisoning stream registration for the process lifetime (issue #401).** `record!` used `upsert(unique_by: :queue_name)`, which resolves the unique index through the connection pool's schema cache. That cache stores a negative `data_source_exists?` answer permanently, and `SchemaCache#indexes` returns `[]` (uncached) whenever the cached probe says false — while the `table_exists?` guard at the top of `record!` is a live query. So a single wrong first probe on the pool cache (observed under PgBouncer transaction pooling in production, and from a coalescer flush thread racing foreground test DB work in CI) made the guard pass and the upsert raise `ArgumentError: No unique index found for queue_name` — swallowed at DEBUG — on **every** subsequent `record!` in that process until restart, leaving streams unregistered from that process's perspective (maintenance, orphan sweep, and wildcard classification degrade). The registry write is now a raw `INSERT … ON CONFLICT (queue_name) DO NOTHING` on the model's connection: the unique index is owned by the gem's own migration, so there is nothing for Rails to resolve, no schema-cache traffic leaves the hot first-broadcast path, and a poisoned cache can no longer break registration (pinned by an integration regression spec that deliberately poisons the pool cache, plus a `sql.active_record` assertion that no `SCHEMA` query is issued once the `table_exists?` memo is warm). Failure logging is now class-aware: a transient database error (`ActiveRecord::ActiveRecordError`) still logs at DEBUG per attempt, but a non-database failure — the bug-signal class the old `ArgumentError` belonged to — logs at WARN once per process (DEBUG thereafter) instead of drowning a process-lifetime malfunction in per-broadcast DEBUG spam. Return values and `backfill!`/`all_names` cache semantics are unchanged. Refs #401.
6
12
 
7
13
  - **The schema-install transaction framing no longer commits or destroys a caller's open transaction (#398 review follow-up).** The #397 fix wrapped check+install in `BEGIN`…`COMMIT`/`ROLLBACK` unconditionally. On the Proc-supplied shared-connection path (the Rails lambda), the connection can arrive **mid-transaction** — e.g. `perform_later` inside an application `transaction do` block — where `BEGIN` is a warning-level no-op and the matching `COMMIT`/`ROLLBACK` then commits half of, or destroys, the *caller's* transaction. The framing is now `transaction_status`-aware: an idle connection gets the owned `BEGIN`…`COMMIT` as before; a connection already inside a transaction rides it via `SAVEPOINT pgbus_pgmq_install` / `RELEASE` (`ROLLBACK TO SAVEPOINT` on failure), so the caller's transaction is never touched. On the savepoint path the advisory lock joins the caller's transaction and is held until it ends — over-holding only delays a concurrent installer, never corrupts it — and `@schema_ensured` is NOT cached there: the install is only durable once the caller commits, so a cached true after an outer rollback would skip every future check against a missing schema. The same durability rule now governs `@queues_created`: queue DDL on the shared Proc-supplied connection joins the caller's open transaction, so queue creation there runs uncached (idempotent `CREATE IF NOT EXISTS`) and the next ensure re-checks — a cache write outliving a caller rollback would make later message operations fail against a missing queue. All shared-connection access in these paths — including the transaction-status probe and the schema install itself — holds the per-instance connection mutex, restoring the single-owner invariant the #397 fix had narrowed. Refs #398.
@@ -12,5 +12,20 @@ module Pgbus
12
12
  # and the engine's app/models path isn't registered yet.
13
13
  class BusRecord < ActiveRecord::Base
14
14
  self.abstract_class = true
15
+
16
+ # Disconnects every pool owned by this class — the pools `connects_to`
17
+ # creates when pgbus runs on a dedicated database — across all roles.
18
+ # When pgbus runs in the primary database there is no BusRecord-owned
19
+ # pool and this is a no-op; ActiveRecord::Base's pool is never touched.
20
+ #
21
+ # Called by Pgbus::DatabaseTasksGuard before every database purge/drop so
22
+ # an idle boot-time session on the pgbus database can never block that
23
+ # same process's DROP DATABASE (issue #409). Safe to call at any time:
24
+ # the next checkout after a disconnect reconnects transparently.
25
+ def self.disconnect_all_pools!
26
+ connection_handler.connection_pool_list(:all).each do |pool|
27
+ pool.disconnect! if pool.connection_class == self
28
+ end
29
+ end
15
30
  end
16
31
  end
@@ -46,10 +46,19 @@ module Pgbus
46
46
  ON pgmq.a_#{sanitized} (msg_id)
47
47
  SQL
48
48
 
49
- synchronized do
50
- with_raw_connection do |conn|
51
- conn.exec(sql)
49
+ begin
50
+ synchronized do
51
+ with_raw_connection do |conn|
52
+ conn.exec(sql)
53
+ end
52
54
  end
55
+ rescue StandardError => e
56
+ # CREATE INDEX IF NOT EXISTS shares pgmq.create's catalog race
57
+ # (issue #404): two first-broadcasts both pass the existence
58
+ # check and the loser gets a raw unique_violation on pg_class.
59
+ # The duplicate proves the index exists — carry on to register
60
+ # and memoize.
61
+ raise unless duplicate_relation_error?(e)
53
62
  end
54
63
 
55
64
  # Record the physical queue name so maintenance (stream-archive prune,
data/lib/pgbus/client.rb CHANGED
@@ -64,6 +64,12 @@ module Pgbus
64
64
  # re-running the trigger DDL on every queue.
65
65
  NOTIFY_THROTTLE_MS = 250
66
66
 
67
+ # PGMQ's per-queue NOTIFY trigger name, as created by
68
+ # pgmq.enable_notify_insert — used to recognize the duplicate-trigger
69
+ # race loser (issue #403).
70
+ NOTIFY_TRIGGER_NAME = "trigger_notify_queue_insert_listeners"
71
+ private_constant :NOTIFY_TRIGGER_NAME
72
+
67
73
  # Load the pgmq-ruby gem, defining the PGMQ module before requiring it so
68
74
  # Zeitwerk's eager_load (called inside pgmq.rb) can resolve the constant.
69
75
  # Without the pre-definition, Ruby 4.0 + Zeitwerk 2.7.5 raises NameError
@@ -1177,7 +1183,29 @@ module Pgbus
1177
1183
  end
1178
1184
 
1179
1185
  # Runs inside synchronized — callers own the connection mutex.
1186
+ #
1187
+ # CREATE TABLE IF NOT EXISTS is not race-safe: two backends creating a
1188
+ # not-yet-existing queue both pass the existence check (READ COMMITTED
1189
+ # — neither sees the other's uncommitted catalog rows), both insert
1190
+ # into pg_class, and the loser raises unique_violation on
1191
+ # pg_class_relname_nsp_index instead of the friendly duplicate_table
1192
+ # (issue #404 — the sibling of the #403 trigger race, one DDL step
1193
+ # earlier; `synchronized` is process-local, so nothing serializes
1194
+ # this across processes). pgmq.create is a single statement and
1195
+ # therefore atomic: by the time the loser unblocks, the winner has
1196
+ # committed the WHOLE queue — tables, indexes, and the pgmq.meta row
1197
+ # — so re-check meta and return (the winner also ran autovacuum
1198
+ # tuning). The retry covers the can't-confirm case (e.g. a leftover
1199
+ # physical table without a meta row); its failure propagates as the
1200
+ # retry's own error, with the original duplicate preserved deeper in
1201
+ # the cause chain (raised while $! held it, so Ruby chains it).
1180
1202
  def create_queue_table(name)
1203
+ @pgmq.create(name)
1204
+ tune_autovacuum(name)
1205
+ rescue StandardError => e
1206
+ raise unless duplicate_relation_error?(e)
1207
+ return if queue_registered?(name)
1208
+
1181
1209
  @pgmq.create(name)
1182
1210
  tune_autovacuum(name)
1183
1211
  end
@@ -1203,17 +1231,77 @@ module Pgbus
1203
1231
  end
1204
1232
  end
1205
1233
 
1234
+ # notify_trigger_current? is a plain SELECT and `synchronized` is a
1235
+ # process-local mutex, so this check-then-act races across processes:
1236
+ # after a deploy every process's @queues_created memo is cold, and two
1237
+ # web processes handling the first broadcast for the same lazy stream
1238
+ # queue both see "trigger not current" and both run PGMQ's
1239
+ # DROP + CREATE CONSTRAINT TRIGGER cycle. The loser's CREATE blocks on
1240
+ # the winner's table lock and fails with PG::DuplicateObject once the
1241
+ # winner commits (issue #403). The duplicate proves the trigger exists,
1242
+ # so treat it as success — same shape as the #397 duplicate-object
1243
+ # rescue on schema install. Re-check the throttle first: racing ensures
1244
+ # pass the same value, but a job-queue ensure (250ms) can race the
1245
+ # stream override (0ms), so a mismatch means the winner installed a
1246
+ # different interval — retry once to converge; a second loss propagates.
1206
1247
  def enable_notify_if_needed(full_name, throttle_ms)
1207
1248
  return unless config.listen_notify
1208
1249
  return if notify_trigger_current?(full_name, throttle_ms)
1209
1250
 
1251
+ @pgmq.enable_notify_insert(full_name, throttle_interval_ms: throttle_ms)
1252
+ rescue PGMQ::Errors::ConnectionError => e
1253
+ raise unless duplicate_notify_trigger_error?(e)
1254
+ return if notify_trigger_current?(full_name, throttle_ms)
1255
+
1210
1256
  @pgmq.enable_notify_insert(full_name, throttle_interval_ms: throttle_ms)
1211
1257
  end
1212
1258
 
1259
+ # Matched on the trigger name (an identifier — survives server-side
1260
+ # message localization) plus either the PG::DuplicateObject cause set by
1261
+ # pgmq-ruby's `raise … ConnectionError` inside `rescue PG::Error` (the
1262
+ # defined? guard mirrors the other PG::… checks in this file: a cause
1263
+ # can only be a PG::DuplicateObject when the class is loaded) or the
1264
+ # English "already exists" text when a wrapper dropped the cause.
1265
+ def duplicate_notify_trigger_error?(error)
1266
+ message = error.message.to_s
1267
+ return false unless message.include?(NOTIFY_TRIGGER_NAME)
1268
+
1269
+ (defined?(PG::DuplicateObject) && error.cause.is_a?(PG::DuplicateObject)) ||
1270
+ message.include?("already exists")
1271
+ end
1272
+
1213
1273
  def create_fifo_index_if_needed(full_name)
1214
1274
  return unless config.group_mode
1215
1275
 
1216
1276
  @pgmq.create_fifo_index(full_name)
1277
+ rescue StandardError => e
1278
+ # CREATE INDEX IF NOT EXISTS has the same catalog race as
1279
+ # pgmq.create (issue #404): the loser's duplicate proves a
1280
+ # concurrent ensure created the index.
1281
+ raise unless duplicate_relation_error?(e)
1282
+ end
1283
+
1284
+ # A relation-creation race loser's error: one of the duplicate DDL
1285
+ # classes directly (raw conn.exec paths), or wrapped — pgmq-ruby
1286
+ # raises ConnectionError inside `rescue PG::Error`, so Ruby sets the
1287
+ # duplicate as its cause automatically.
1288
+ def duplicate_relation_error?(error)
1289
+ duplicate_install_error?(error) || duplicate_install_error?(error.cause)
1290
+ end
1291
+
1292
+ # Whether pgmq.meta records the queue — the authoritative "create
1293
+ # committed" signal (pgmq.create writes it atomically with the
1294
+ # tables). The pooled checkout is a sequential sibling of the failed
1295
+ # create's (already returned when the exception unwound), so there is
1296
+ # no nested checkout — same reasoning as notify_trigger_current?.
1297
+ def queue_registered?(full_name)
1298
+ @pgmq.with_connection do |conn|
1299
+ conn.exec_params("SELECT 1 FROM pgmq.meta WHERE queue_name = $1 LIMIT 1", [full_name]).ntuples.positive?
1300
+ end
1301
+ rescue StandardError
1302
+ # Can't confirm (aborted caller transaction, schema not ready) —
1303
+ # fall through to the retry, which surfaces the state honestly.
1304
+ false
1217
1305
  end
1218
1306
 
1219
1307
  # Check whether the NOTIFY trigger already exists on this queue with the
@@ -1238,7 +1326,7 @@ module Pgbus
1238
1326
  JOIN pg_namespace n ON c.relnamespace = n.oid
1239
1327
  WHERE n.nspname = 'pgmq'
1240
1328
  AND c.relname = pgmq.format_table_name($1, 'q')
1241
- AND t.tgname = 'trigger_notify_queue_insert_listeners'
1329
+ AND t.tgname = '#{NOTIFY_TRIGGER_NAME}'
1242
1330
  AND EXISTS (
1243
1331
  SELECT 1 FROM pgmq.notify_insert_throttle
1244
1332
  WHERE queue_name = $1
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Prepended onto ActiveRecord::Tasks::DatabaseTasks' singleton class by the
5
+ # engine (initializer "pgbus.db") so every database purge/drop first
6
+ # disconnects the gem's own BusRecord pools.
7
+ #
8
+ # Why: with pgbus on a dedicated database (config.connects_to), any
9
+ # boot-time touch of a Pgbus model leaves an idle session on that database
10
+ # for the life of the process. Rails' purge/drop only disconnects the
11
+ # connection it establishes for the target db_config — it knows nothing
12
+ # about gem-owned pools — so the process's own idle session blocks its own
13
+ # DROP DATABASE (or kills it via statement_timeout), permanently wedging
14
+ # db:test:prepare (issue #409).
15
+ #
16
+ # Intercepting DatabaseTasks (rather than enhancing the rake tasks) covers
17
+ # every route to a purge/drop: db:test:purge / db:purge / db:drop and their
18
+ # per-database variants, maintain_test_schema!'s in-process purge, and
19
+ # parallel-testing's TestDatabases — they all funnel through these methods.
20
+ #
21
+ # Installed only in processes that booted the app; a bare `db:drop` process
22
+ # that never ran initializers has no BusRecord pool to block on anyway.
23
+ module DatabaseTasksGuard
24
+ def purge(...)
25
+ Pgbus::BusRecord.disconnect_all_pools!
26
+ super
27
+ end
28
+
29
+ def drop(...)
30
+ Pgbus::BusRecord.disconnect_all_pools!
31
+ super
32
+ end
33
+ end
34
+ end
data/lib/pgbus/engine.rb CHANGED
@@ -61,6 +61,12 @@ module Pgbus
61
61
  initializer "pgbus.db" do
62
62
  ActiveSupport.on_load(:active_record) do
63
63
  Pgbus::BusRecord.connects_to(**Pgbus.configuration.connects_to) if Pgbus.configuration.connects_to
64
+
65
+ # Disconnect the gem's own pools before any database purge/drop, so an
66
+ # idle boot-time BusRecord session on a dedicated pgbus database can
67
+ # never block that same process's DROP DATABASE (issue #409). No-op
68
+ # when pgbus runs in the primary database (no BusRecord-owned pool).
69
+ ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(Pgbus::DatabaseTasksGuard)
64
70
  end
65
71
  end
66
72
 
@@ -104,23 +104,11 @@ module Pgbus
104
104
 
105
105
  private
106
106
 
107
- # Rake task-name prefixes during which pgbus must NOT open a PGMQ
108
- # connection: creating/dropping/migrating/loading the schema (a live
109
- # connection blocks DROP DATABASE) and asset precompile (no DB expected).
110
- SCHEMA_TASK_PREFIXES = %w[db: db_test: assets: webpacker: yarn:].freeze
111
- private_constant :SCHEMA_TASK_PREFIXES
112
-
113
107
  # True when running inside a rake schema/asset task, where setup_all!(safe:)
114
- # should skip rather than open a connection. Detected from the invoked rake
115
- # task names; false outside a Rake run.
108
+ # should skip rather than open a connection. Delegates to the shared,
109
+ # public detector (issue #409) so there is a single implementation.
116
110
  def schema_task_context?
117
- return false unless defined?(::Rake) && ::Rake.respond_to?(:application)
118
-
119
- tasks = ::Rake.application.top_level_tasks
120
- tasks.any? { |t| SCHEMA_TASK_PREFIXES.any? { |prefix| t.to_s.start_with?(prefix) } }
121
- rescue StandardError => e
122
- Pgbus.logger.debug { "[Pgbus] schema_task_context? detection failed, assuming non-schema: #{e.class}: #{e.message}" }
123
- false
111
+ Pgbus.database_task?
124
112
  end
125
113
 
126
114
  def matches?(pattern, routing_key)
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.13.8"
4
+ VERSION = "0.14.0"
5
5
  end
data/lib/pgbus.rb CHANGED
@@ -10,6 +10,13 @@ module Pgbus
10
10
  # this to be configurable.
11
11
  DEAD_LETTER_SUFFIX = "_dlq"
12
12
 
13
+ # Rake task-name prefixes during which pgbus must NOT open a database
14
+ # connection: schema management (an idle session on a dedicated pgbus
15
+ # database blocks that same process's DROP DATABASE during db:test:purge —
16
+ # issue #409) and asset precompile (where a database may legitimately not
17
+ # exist). Consulted by Pgbus.database_task?.
18
+ DATABASE_TASK_PREFIXES = %w[db: db_test: assets: webpacker: yarn:].freeze
19
+
13
20
  # Error-hierarchy policy (the 1.0 contract, issue #282):
14
21
  #
15
22
  # * OPERATIONAL errors — a pgbus subsystem failed to do its job at runtime
@@ -149,6 +156,30 @@ module Pgbus
149
156
  @configuration ||= Configuration.new
150
157
  end
151
158
 
159
+ # True when the current process is running a rake task (db:*, assets:*, …
160
+ # — see DATABASE_TASK_PREFIXES) during which pgbus must not touch the
161
+ # database: the database may not exist yet, and an idle connection on a
162
+ # dedicated pgbus database blocks that same process's DROP DATABASE
163
+ # during db:test:purge (issue #409). Use it to guard boot-time database
164
+ # touches in an initializer:
165
+ #
166
+ # Rails.application.config.after_initialize do
167
+ # Pgbus::StreamQueue.table_exists? unless Pgbus.database_task?
168
+ # end
169
+ #
170
+ # False outside a Rake run, and false (never raise into boot) when
171
+ # detection itself fails.
172
+ def database_task?
173
+ return false unless defined?(::Rake) && ::Rake.respond_to?(:application)
174
+
175
+ ::Rake.application.top_level_tasks.any? do |task|
176
+ DATABASE_TASK_PREFIXES.any? { |prefix| task.to_s.start_with?(prefix) }
177
+ end
178
+ rescue StandardError => e
179
+ logger.debug { "[Pgbus] database_task? detection failed, assuming non-schema: #{e.class}: #{e.message}" }
180
+ false
181
+ end
182
+
152
183
  def configure
153
184
  yield configuration
154
185
  # Fail loud at boot on an invalid value rather than leaving it dormant
@@ -5,26 +5,42 @@ namespace :pgbus do
5
5
  task tune_autovacuum: :environment do
6
6
  require "pgbus/autovacuum_tuning"
7
7
 
8
- conn = Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
8
+ apply_tuning = lambda do |conn|
9
+ # Only run if pgmq schema exists (tables may not be created yet during
10
+ # initial setup — the install migration handles tuning itself).
11
+ pgmq_exists = conn.select_value(
12
+ "SELECT 1 FROM information_schema.schemata WHERE schema_name = 'pgmq'"
13
+ )
9
14
 
10
- # Only run if pgmq schema exists (tables may not be created yet during
11
- # initial setup the install migration handles tuning itself).
12
- pgmq_exists = conn.select_value(
13
- "SELECT 1 FROM information_schema.schemata WHERE schema_name = 'pgmq'"
14
- )
15
+ unless pgmq_exists
16
+ puts "[pgbus] PGMQ schema not found skipping autovacuum tuning."
17
+ next
18
+ end
15
19
 
16
- unless pgmq_exists
17
- puts "[pgbus] PGMQ schema not found — skipping autovacuum tuning."
18
- next
19
- end
20
+ puts "[pgbus] Applying autovacuum tuning to PGMQ queue/archive tables..."
21
+ conn.execute(Pgbus::AutovacuumTuning.sql_for_all_queues)
20
22
 
21
- puts "[pgbus] Applying autovacuum tuning to PGMQ queue/archive tables..."
22
- conn.execute(Pgbus::AutovacuumTuning.sql_for_all_queues)
23
+ puts "[pgbus] Applying autovacuum tuning to high-churn pgbus tables..."
24
+ conn.execute(Pgbus::AutovacuumTuning.sql_for_high_churn_tables)
23
25
 
24
- puts "[pgbus] Applying autovacuum tuning to high-churn pgbus tables..."
25
- conn.execute(Pgbus::AutovacuumTuning.sql_for_high_churn_tables)
26
+ puts "[pgbus] Autovacuum tuning complete."
27
+ end
26
28
 
27
- puts "[pgbus] Autovacuum tuning complete."
29
+ if Pgbus.configuration.connects_to
30
+ # Scoped checkout + disconnect: this task is enhanced onto
31
+ # db:schema:load, so a permanent `.connection` lease on the dedicated
32
+ # pgbus database would leave an idle session that blocks a later
33
+ # DROP DATABASE in the same rake process (issue #409).
34
+ begin
35
+ Pgbus::BusRecord.connection_pool.with_connection { |conn| apply_tuning.call(conn) }
36
+ ensure
37
+ # Disconnect even when tuning raises — the session must not outlive
38
+ # the task either way.
39
+ Pgbus::BusRecord.connection_pool.disconnect!
40
+ end
41
+ else
42
+ apply_tuning.call(ActiveRecord::Base.connection)
43
+ end
28
44
  end
29
45
  end
30
46
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.8
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -270,6 +270,7 @@ files:
270
270
  - lib/pgbus/concurrency/semaphore.rb
271
271
  - lib/pgbus/configuration.rb
272
272
  - lib/pgbus/configuration/capsule_dsl.rb
273
+ - lib/pgbus/database_tasks_guard.rb
273
274
  - lib/pgbus/dedicated_connection.rb
274
275
  - lib/pgbus/dedup_cache.rb
275
276
  - lib/pgbus/doctor.rb