pgbus 0.18.1 → 0.19.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +5 -0
  4. data/app/controllers/pgbus/application_controller.rb +2 -2
  5. data/app/controllers/pgbus/frontends_controller.rb +10 -8
  6. data/app/helpers/pgbus/application_helper.rb +4 -3
  7. data/app/models/pgbus/job_stat.rb +4 -0
  8. data/app/models/pgbus/processed_event.rb +3 -1
  9. data/app/models/pgbus/stream_queue.rb +34 -3
  10. data/app/models/pgbus/stream_stat.rb +3 -0
  11. data/lib/pgbus/active_job/current_attributes.rb +3 -1
  12. data/lib/pgbus/batch.rb +17 -9
  13. data/lib/pgbus/cli.rb +3 -0
  14. data/lib/pgbus/client/read_after.rb +10 -1
  15. data/lib/pgbus/client.rb +54 -9
  16. data/lib/pgbus/concurrency.rb +3 -0
  17. data/lib/pgbus/configuration.rb +84 -9
  18. data/lib/pgbus/current_attributes.rb +3 -1
  19. data/lib/pgbus/dedup_cache.rb +15 -7
  20. data/lib/pgbus/engine.rb +11 -0
  21. data/lib/pgbus/event_bus/handler.rb +16 -4
  22. data/lib/pgbus/execution_pools/async_pool.rb +23 -2
  23. data/lib/pgbus/execution_pools/thread_pool.rb +22 -0
  24. data/lib/pgbus/execution_pools.rb +26 -0
  25. data/lib/pgbus/integrations/appsignal/probe.rb +17 -9
  26. data/lib/pgbus/integrations/appsignal/subscriber.rb +30 -18
  27. data/lib/pgbus/integrations/appsignal.rb +26 -16
  28. data/lib/pgbus/mcp/rack_app.rb +1 -1
  29. data/lib/pgbus/metrics/subscriber.rb +35 -21
  30. data/lib/pgbus/process/consumer.rb +1 -1
  31. data/lib/pgbus/process/consumer_priority.rb +1 -1
  32. data/lib/pgbus/process/lifecycle.rb +5 -5
  33. data/lib/pgbus/process/notify_listener.rb +32 -18
  34. data/lib/pgbus/process/wake_pipe.rb +4 -1
  35. data/lib/pgbus/process/worker.rb +4 -2
  36. data/lib/pgbus/retry_backoff.rb +3 -0
  37. data/lib/pgbus/shareable.rb +70 -0
  38. data/lib/pgbus/streams/broadcastable_override.rb +3 -0
  39. data/lib/pgbus/streams/presence.rb +6 -7
  40. data/lib/pgbus/streams.rb +38 -27
  41. data/lib/pgbus/testing.rb +19 -3
  42. data/lib/pgbus/uniqueness.rb +3 -0
  43. data/lib/pgbus/version.rb +1 -1
  44. data/lib/pgbus/visibility_heartbeat.rb +51 -34
  45. data/lib/pgbus/web/authentication.rb +19 -2
  46. data/lib/pgbus/web/data_source/list_counts.rb +8 -6
  47. data/lib/pgbus/web/health_server.rb +4 -1
  48. data/lib/pgbus/web/process_activity.rb +1 -1
  49. data/lib/pgbus/web/streamer/heartbeat.rb +4 -1
  50. data/lib/pgbus/web/streamer/hub_client.rb +4 -1
  51. data/lib/pgbus/web/streamer/listener.rb +10 -1
  52. data/lib/pgbus/web/streamer/master_hub.rb +11 -5
  53. data/lib/pgbus/web/streamer/master_hub_boot.rb +3 -1
  54. data/lib/pgbus/web/streamer/outbound_pump.rb +4 -1
  55. data/lib/pgbus/web/streamer/peer_close_watcher.rb +4 -1
  56. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +13 -1
  57. data/lib/pgbus.rb +92 -30
  58. data/sig/pgbus/active_job/adapter.rbs +57 -0
  59. data/sig/pgbus/client.rbs +78 -0
  60. data/sig/pgbus/configuration.rbs +316 -0
  61. data/sig/pgbus/streams/stream.rbs +25 -0
  62. data/sig/pgbus/streams.rbs +22 -0
  63. data/sig/pgbus.rbs +73 -0
  64. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e64af488e23381da4f7685e20a194addef033c1fb175fa64b1de7b169dc67996
4
- data.tar.gz: b98a831339953b46a44e0a37468277d05b8718c3ac3fa7d7ee82d8857f78928f
3
+ metadata.gz: a11cc47f884c4d3a845e019d2b526ca865be0d8f94851dda955d6163f4c822da
4
+ data.tar.gz: 6e560c1140a12ae2dc192f59f01898f291f4ea3c2265dd85c08d4ab115257f82
5
5
  SHA512:
6
- metadata.gz: f1a4d7d62a2c9a7a2767896246e20ec36ba3b5626ef0d45bb4b2cdc7efb50b14b99e57a848167db58a8d49e35ae5b7b8a367959a66044aa350b8eda7716afa05
7
- data.tar.gz: f39f4dc967ea079dab0b1d7536bbc6effb9bd044f310a40e0fc1e4a988c03f1b104da4ab12f5c2eed515d67e805f6abc2e2d0944afdf8d77c4025436080b3a83
6
+ metadata.gz: aa6702c6b5a46bb4a939726e3776b01ea7bbbc2c002e7f9a248b1d0c963c904d6dd034fcadcbfd90ec6f2209bccc32f05fde57e7e12727477e9166c1b5d15778
7
+ data.tar.gz: 9f4730ff62b0050eef7246ce9919f22f6c4400d8ee2f982eb3cc7ea25eb90e8eed83b70e6adb006bba8e79ea7b8e78850c83d8af3be495fe708605bde3b456ac
data/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
1
+ ## [0.19.0]
2
+
3
+ ### Added
4
+
5
+ - **RBS signatures for the public surface** in `sig/`, shipped with the gem: the `Pgbus` facade (`configure`, `stream`, `publish`, …), `Streams::Stream` (`broadcast`, `broadcast_render`, …), the ActiveJob adapter and the job-class options (`limits_concurrency`, `ensures_uniqueness`, `pgbus_retry_backoff`, `pgbus_visibility_heartbeat`), with `Configuration` and `Client` generated from reflection. CI runs `rbs -I sig validate`, and `spec/pgbus/rbs_spec.rb` fails when a public method has no signature or a signature outlives its method. Refs #527.
6
+ - **`Pgbus.finalize!`: the configuration is finalized at boot.** It runs on its own, with no app code calling it: in a Rails app the engine calls it from `after_initialize`, after the app's own initializer files and `after_initialize` blocks; without Rails, each `Pgbus.configure` finalizes at its end; and `pgbus start` finalizes again once its flags are applied, before the supervisor forks. Call it yourself only when you configure pgbus through setters alone, without Rails. It validates the configuration (unless `eager_validation` is off) and replaces every plain-data setting (Strings, Symbols, numbers, booleans, Regexps, classes, and Arrays/Hashes/Sets of those) with a deep-frozen, Ractor-shareable copy, so every such reader is `Ractor.shareable?` after boot. Settings that hold callables or objects (the logger, `error_reporters`, `web_auth`, `web_data_source`, `fair_share`, `event_fair_share`, `metrics_backend`, `streams_presence_member`, `streams_presence_on_change`, `streams_authorize`) stay as they are, on the main Ractor, and so does any other value that cannot be made shareable (a lambda that closes over app state, a filter list holding a proc, a collection that contains itself): finalizing never raises for one. `Configuration#finalize!`/`#finalized?` are the per-object halves. Refs #527.
7
+
8
+ ### Changed
9
+
10
+ - **The configuration is validated at boot, also without `Pgbus.configure`.** `Pgbus.finalize!` runs `validate!` from the engine's `after_initialize` (unless `eager_validation` is off), so an invalid value set through direct setters (`Pgbus.configuration.visibility_timeout = 0`) now raises `Pgbus::ConfigurationError` at boot instead of surfacing later in a worker. `pgbus start` validates again after applying its flags. Refs #527.
11
+ - **After boot, the configuration's collections are frozen.** Setters keep working (they assign a new value, and a later `Pgbus.configure` finalizes again), but changing a collection setting in place once the app has booted, e.g. `Pgbus.configuration.streams_durable_patterns << "x"` in an `after_initialize` block that runs after pgbus's, now raises `FrozenError`: assign a new value instead (`c.streams_durable_patterns = [...]`), or configure in an initializer. Without Rails the configuration is finalized at the end of each `Pgbus.configure`, so the same holds from then on, also between two `configure` calls. `config.error_reporters << reporter` is unaffected. The app's own arrays and hashes are copied, never frozen. Refs #527.
12
+ - **The streamer's dispatch values are Ractor-shareable.** `Listener::WakeMessage`, `StreamEventDispatcher::StreamEnvelope` and `Client::ReadAfter::Envelope`, the values handed from the LISTEN thread (or the master hub) to the dispatcher and from the dispatcher to `Connection#enqueue`, freeze their Strings where they are built, so a future Ractor dispatcher could pass them by reference. Nothing else changes (`StreamEnvelope#exclude` is the String it was already compared as). `rake bench:one[streams_frame_bench]` measures the frame path. Refs #527.
13
+
14
+ ### Fixed
15
+
16
+ - **Smaller check-then-act races at the process level.** Each event handler class's `dedup_cache` is built when the class is defined instead of by a lazy `||=` (two first deliveries could each build one); the dashboard's "no authentication" warning is claimed under a lock, so concurrent first requests log it once; the batch schema probes and their warn-once flag are read and written under a lock. Refs #527.
17
+ - **The metrics and AppSignal subscribers install once, even when two threads install at once.** `Metrics::Subscriber.install!`, `Integrations::Appsignal.install!` and its `Subscriber`/`Probe` checked `@installed` and then subscribed without a lock, so concurrent installs subscribed every event twice (double-counted metrics), and `reset!` then only detached the last set. The check and the install now happen under one lock per module. Refs #527.
18
+ - **`Pgbus::Streams.filters` and `.coalescer` are one per process.** Both were lazy `@x ||= X.new` memos: two threads making the first call at once could each build one, and an audience filter registered on the registry that lost was gone. They are now built when `pgbus/streams` loads (the test resets replace them with fresh ones). `StreamQueue.all_names` hands out a frozen set that `record!` replaces whole instead of mutating in place, so the dispatcher iterating the set it got can no longer see it change under it (or raise mid-iteration) while a broadcast thread registers a stream, and a cold load of that set publishes its snapshot only when no stream was recorded and no `reset_cache!` ran while it loaded (the load itself runs outside the lock), so neither is lost. `Presence.seats_supported?` reads and writes its schema answer under its lock only. Refs #527.
19
+ - **`DedupCache#mark!` is atomic.** It checked whether the key was present and whether the cache was full, then inserted, without a lock: two threads marking at once could both pass the checks, recording the key twice in the eviction order or growing the cache past `max_size`. The check and the insert (and an expired key's eviction) now happen under one lock; `seen?` still reads without it. Refs #527.
20
+ - **`Client#wait_for_notify` no longer stalls the client while it waits.** On the shared ActiveRecord connection path it held the client's PGMQ mutex, which serializes every PGMQ call, across the whole blocking wait (indefinitely with `timeout: nil`); on the dedicated path it held a pool connection. It now LISTENs on a dedicated connection of its own (`worker_notify_connection_options`), opened for the wait and closed after. On the shared path that needs `worker_notify_database_url` (or a base `database_url`), and it raises `Pgbus::ConfigurationError` without one instead of borrowing the shared connection. A dedicated connection found dead before use is replaced and the wait starts again, as the pooled wait's stale-connection retry did. Refs #527.
21
+ - **An immediate shutdown (QUIT) no longer leaves ActiveRecord connections checked out by killed jobs.** `Worker#immediate_shutdown` and `Consumer#immediate_shutdown` killed the execution pool outright: a job that held a connection died with it checked out, and the pool stayed that many connections short until ActiveRecord's reaper ran. They now stop taking work, give the running jobs `config.immediate_shutdown_grace` seconds (new, default 2) to finish, kill only what is still running after that, and then reclaim every connection whose thread or fiber is gone. Thread and async pools both wait for the jobs they kill to unwind first (the thread pool also for a job that started while it was killing, all of them within one join budget), and the connections are reclaimed also when the wait itself raises. `immediate_shutdown_grace` must be a finite number >= 0. Refs #527.
22
+ - **`Pgbus.client`, `Pgbus.configuration` and `Pgbus.stream` hand every thread the same object.** All three were unlocked `||=` memos, so threads making the first call at once could each build their own: a second `Client` (whose connection pool was orphaned and never closed), a second `Configuration`, or a second stream cache. The configuration and the stream cache are now built when the gem loads (and `reset!` replaces them with fresh ones instead of `nil`), and the client is built on first use under a lock; `Pgbus.stopping` is read and written under the same lock. Because the configuration now exists before Rails sets `Rails.logger`, an unset `config.logger` is resolved on every read (Rails.logger when there is one, else `$stdout`) instead of once when the configuration is built; a `log_format` set before Rails.logger exists (`config/application.rb`) is applied to it once the engine initializes. Refs #527.
23
+ - **The visibility heartbeat's lock can no longer split in two.** `Pgbus::VisibilityHeartbeat` built its Mutex lazily (`@mutex ||= Mutex.new`), so the first jobs of a process could each build and lock a Mutex of their own and register, extend and drop entries without excluding each other. A ticker stopped while a new job started another also exits now, instead of ticking beside the new one. The lock is now built when the module loads, and every read of the entry table and the ticker state happens under it (the ticker loop used to read `@running` unlocked). Refs #527.
24
+ - **`Process::NotifyListener#start` and `#stop` no longer interleave.** A `stop` that ran while `start` was still creating the listener thread found no thread to join, and `start` then started one nobody joined (a restart could run a second LISTEN loop beside it on the same connection). Both now run whole under one lock that the listener thread never takes. Refs #527.
25
+ - **A `Pgbus::Client` subclass can ensure the PGMQ schema.** The install lock is `Client`'s class state, which a subclass does not inherit, so a subclass raised `NoMethodError` on the schema check; it now takes `Client`'s lock. Refs #527.
26
+ - **`Pgbus::Testing.fake!`/`inline!`/`disabled!` without a block are process-wide again from any fiber.** The mode was stored with `Thread.main[]=`, which writes the main thread's *current fiber*, and read back with `Thread.main[]`, which reads whichever fiber the main thread is running at that moment. A mode set inside an Async task (a Falcon test, `Sync { }`) vanished when the task ended, and a sibling task never saw it. The default mode is now a module-level value behind a lock built at load, so every thread and fiber sees it; the block form still scopes the mode to the fiber that set it (`Thread.current[]`), so a `fake! do … end` inside one task does not leak to its siblings. When such a block ends, `streams_test_mode` follows the mode in effect again; under a process-wide `fake!`/`inline!` it used to be switched off, letting later SSE requests reach the real streamer. Refs #527.
27
+
1
28
  ## [Unreleased]
2
29
 
3
30
  ### Added
@@ -255,6 +282,8 @@
255
282
 
256
283
  ### Fixed
257
284
 
285
+ - **Dashboard views keep every engine helper when `base_controller_class` is a host controller (issue #528).** `Pgbus::ApplicationController` named only `ApplicationHelper` and `ButtonHelper`. Rails runs `helper :all` only when a controller's superclass is `ActionController::Base`, so a custom base never loaded the rest. The home page renders `processes/_status` from the dashboard controller, which does not own `ProcessesHelper`, and raised `undefined method 'pgbus_process_status'` as soon as a process row rendered. The controller now calls `helper :all` itself, the same call Rails makes for the default base, so every helper under `app/helpers/pgbus/` is available on every dashboard view. A host base controller's own helpers stay in the chain. Refs #528.
286
+
258
287
  - **`ensures_uniqueness` / `limits_concurrency` declared on a base class now reach every subclass — previously the declaration was silently inert for them (issue #357).** Both macros stored their config in a class-level ivar (`@pgbus_uniqueness` / `@pgbus_concurrency`) and every lookup read `active_job.class.pgbus_uniqueness` without walking ancestors — class-level ivars are not inherited in Ruby, so `class RecurringBase < ApplicationJob; ensures_uniqueness strategy: :until_executed; end` gave subclasses NO uniqueness key, no scheduler overlap protection, and no #333 guard, with no warning that the natural reading of the code was false. The readers now fall back to the nearest ancestor's declaration (a class's own declaration still beats an inherited one). Coupled fix, required to make inheritance safe rather than harmful: no default key proc is stored anymore — the old `key || ->(*) { name }` captured the DECLARING class, so an inheritance fix alone would have collapsed every subclass into ONE shared key (the base class's name), and with `on_conflict: :discard` sibling jobs would silently discard each other's enqueues — strictly worse than inert. The class-name default is now resolved from the ENQUEUED job's class at all three call sites (`Uniqueness.resolve_key`, `Concurrency.resolve_key`, and the recurring scheduler's `resolve_uniqueness_key`), and `config[:key]` is `nil` when no explicit `key:` was given. Consumer note: concrete-class declarations resolve to exactly the same keys as before, but a base-class declaration that was silently inert now ACTIVATES per-subclass uniqueness/concurrency on upgrade — audit any base-class declarations (the macro finally does what it says). In the scheduler path the no-key default is additionally qualified with the recurring task's `args:` (`SyncJob:["site_a"]`) so two tasks pointing at the same job class with different arguments don't share one lock — the scheduler's analogue of the #333 raise-at-enqueue guard, which a scheduler loop can't use — and a failure to resolve ANY uniqueness key — default or user-supplied key proc — now fails closed (skips the tick, logs, and reaches `error_reporters`) instead of degrading to nil and enqueuing WITHOUT a lock, which silently disabled the very protection the proc configured. Both macros also now reject a non-nil, non-callable `key:` (e.g. `key: false`) at definition time with the documented ArgumentError — previously `false` slipped the truthiness guard and surfaced as a `NoMethodError` at enqueue (uniqueness) or silently acted as the class-name default (concurrency). Measured (benchmark-ips, enqueue-path budget per docs/performance.md): the shared `Support.call_key_proc` dispatch is ~17% faster than the old inline dispatch (136ns vs 160ns/op), the class-name default is 3.7× faster than the old stored-proc call (23ns vs 86ns/op), and the inherited-config ancestor walk adds ~15ns/op. Refs #357, #333.
259
288
  - **The Ruby-Timeout read fallback no longer leaves a wedged `CONNECTION_OK` connection in the pgmq pool to re-hang the next read (issue #354).** On the one path where libpq can't bound a hung socket (dedicated connection on non-Linux hosts or libpq < 12), the last-resort `Timeout.timeout` interrupts the read via `Thread#raise` — and libpq may leave the pooled `PG::Connection` reporting `CONNECTION_OK` while it will in fact re-hang on reuse, invisible to pgmq-ruby's checkout health check (it isn't `CONNECTION_BAD`). This was a documented KNOWN LIMITATION waiting on a public pool-reload upstream (cf. mensfeld/pgmq-ruby#94); pgmq-ruby 0.7.1 shipped it, so the fallback now raises an internal `ReadTimeoutError` subclass and reloads the job pool before re-raising — the poisoned connection (already checked back in by `connection_pool`'s ensure) is dropped and the pool rebuilds lazily. A clean server-side `statement_timeout` cancel still never triggers a reload (the connection is healthy), and a reload failure is logged without masking the timeout. Requires pgmq-ruby >= 0.7.1 (dependency bumped to `~> 0.7.1`). Refs #354.
260
289
  - **`connection_guc_mode = :session` no longer kills every SSE stream and NOTIFY wakeup with `PG::Error: invalid connection option "variables"` (issue #352).** In `:session` mode, `forward_connection_variables` deliberately leaves the database.yml `variables:` hash on the connection options for the *caller* to strip and apply via post-connect `SET` (a transaction-mode pooler rejects the libpq `options` startup param — the reason `:session` mode exists). `Pgbus::Client#wrap_session_gucs` honors that contract on both pgmq pools, but the two dedicated raw-connect paths — the streamer's `build_raw_pg_connection` and the worker `NotifyListener` — passed the hash straight to `PG.connect`, which rejects the non-libpq `:variables` key. Result: the first stream request per process raised, `StreamApp` rescued it into a 500 (so **every** `/pgbus/streams/…` request failed from then on), and NOTIFY-gated wakeups died with workers silently falling back to polling. Both paths now connect through the new `Pgbus::DedicatedConnection.connect`, which strips `:variables` and applies each GUC via post-connect `SET` — dedicated connections *keep* the operator's GUCs (`statement_timeout`, `timezone`, …), matching the pooled paths, and the mechanism works on both a direct port and a session-mode pooler. A guard spec confines raw `PG.connect` call sites to `Client` and `DedicatedConnection`, so the next dedicated-connection path can't silently reintroduce the bypass. Refs #352.
data/README.md CHANGED
@@ -139,6 +139,8 @@ The capsule string DSL is the shortest form for the common case. Use `c.capsule`
139
139
 
140
140
  Configuration is validated eagerly: `Pgbus.configure` runs `Configuration#validate!` right after your block yields, so an invalid value (`visibility_timeout = 0`, for example) raises `Pgbus::ConfigurationError` at boot instead of failing later inside a worker. Set `c.eager_validation = false` for the rare setup that intentionally holds a transiently-invalid config across sequential `configure` calls.
141
141
 
142
+ Configuration is finalized at boot: once the app's initializers have run, the engine calls `Pgbus.finalize!`, which freezes every plain-data setting (strings, symbols, numbers, regexps, and arrays and hashes of those) into a Ractor-shareable value. Callables and the logger are left as they are. Setters keep working, and a later `Pgbus.configure` finalizes again, but changing a collection in place after boot (`Pgbus.configuration.streams_durable_patterns << "x"`) raises `FrozenError`: assign a new value instead. `c.error_reporters << reporter` keeps working. Without Rails, each `Pgbus.configure` finalizes at its end; `pgbus start` finalizes again once its flags are applied. Call `Pgbus.finalize!` yourself only when you configure pgbus through setters alone. A value that cannot be made shareable (a lambda that closes over app state, a logger, a filter list holding a proc) never makes finalizing raise: it is kept as it is, for the main Ractor. More in [docs/concurrency.md](docs/concurrency.md).
143
+
142
144
  > **Upgrading from an older pgbus?** Run `rails generate pgbus:update`. It inspects your live database and adds any missing pgbus migrations to `db/migrate` (or `db/pgbus_migrate` if you use `connects_to`). The generator detects your separate-database config automatically from `Pgbus.configuration.connects_to` or by scanning the initializer / `config/application.rb`, so you don't have to re-specify `--database=pgbus` every time.
143
145
  >
144
146
  > Useful flags: `--dry-run` (print the plan without creating files), `--skip-migrations`, `--quiet`. Running it on a database with no pgbus tables at all will redirect you to `pgbus:install` instead of stacking individual add_* migrations.
@@ -691,6 +693,8 @@ end
691
693
 
692
694
  **Not recommended for:** CPU-bound jobs (image processing, heavy computation). These block the single reactor thread and should use thread mode.
693
695
 
696
+ Which state is fiber-local (broadcast options, the batch a job joins, the uniqueness key an enqueue holds) and what that means when work hops between fibers is in [docs/concurrency.md](docs/concurrency.md), with the threads each process runs and pgbus's Ractor readiness.
697
+
694
698
  ## Routing and ordering
695
699
 
696
700
  How messages flow between producers and the workers that handle them: priority sub-queues, consumer priority for active/standby workers, and single-active-consumer for strict ordering.
@@ -2369,6 +2373,7 @@ Curated headline options for the README. The full operator reference (with types
2369
2373
  | `zombie_detection` | `true` | Detect and reclaim work from crashed workers |
2370
2374
  | `read_timeout` | `30` | Seconds before a single PGMQ read is bounded (libpq `statement_timeout` + `tcp_user_timeout` on a dedicated connection; nil disables) |
2371
2375
  | `drain_timeout` | `30` | Seconds to wait for in-flight jobs during graceful shutdown before abandoning them |
2376
+ | `immediate_shutdown_grace` | `2` | On QUIT, seconds a worker or consumer gives running jobs to finish before killing them; killed jobs' ActiveRecord connections are reclaimed |
2372
2377
  | `shutdown_timeout` | `drain_timeout + 5` | Seconds the supervisor waits for children after TERM before SIGKILL; an orchestrator's stop grace period must exceed it |
2373
2378
  | `stall_threshold` | `300` | Seconds without progress before a worker is considered stalled |
2374
2379
  | `priority_levels` | `nil` | Number of priority sub-queues (nil = disabled, 2-10) |
@@ -16,8 +16,8 @@ module Pgbus
16
16
 
17
17
  layout "pgbus/application"
18
18
 
19
- helper Pgbus::ApplicationHelper unless self < Pgbus::ApplicationHelper
20
- helper Pgbus::ButtonHelper unless self < Pgbus::ButtonHelper
19
+ # Rails calls `helper :all` only when the superclass is ActionController::Base.
20
+ helper :all
21
21
 
22
22
  # Make `pgbus` route proxy available in views (e.g. pgbus.root_path).
23
23
  # With isolate_namespace, the non-prefixed helpers (root_path) work inside
@@ -5,27 +5,29 @@ module Pgbus
5
5
  protect_from_forgery with: :exception
6
6
  skip_after_action :verify_same_origin_request, raise: false
7
7
 
8
- FRONTEND_ROOT = Engine.root.join("app", "frontend", "pgbus")
8
+ FRONTEND_ROOT = Engine.root.join("app", "frontend", "pgbus").freeze
9
9
 
10
10
  STATIC_ASSETS = {
11
11
  css: {
12
- style: FRONTEND_ROOT.join("style.css")
13
- }
12
+ style: FRONTEND_ROOT.join("style.css").freeze
13
+ }.freeze
14
14
  }.freeze
15
15
 
16
16
  # Modules are served minified (rake frontend:js), each with its .min.js.map
17
17
  # beside it. ApexCharts ships minified already; charts.js imports it on
18
18
  # demand, so only Insights downloads it.
19
19
  MODULE_OVERRIDES = {
20
- application: FRONTEND_ROOT.join("application.min.js"),
21
- turbo: FRONTEND_ROOT.join("vendor", "turbo.min.js"),
22
- apexcharts: FRONTEND_ROOT.join("vendor", "apexcharts.js")
20
+ application: FRONTEND_ROOT.join("application.min.js").freeze,
21
+ turbo: FRONTEND_ROOT.join("vendor", "turbo.min.js").freeze,
22
+ apexcharts: FRONTEND_ROOT.join("vendor", "apexcharts.js").freeze
23
23
  }.freeze
24
24
 
25
+ # An idempotent memo: concurrent first requests glob the same files and
26
+ # build equal Hashes; whichever is stored, every reader gets a frozen one.
25
27
  def self.js_modules
26
- @js_modules ||= FRONTEND_ROOT.join("modules").glob("*.min.js").sort
28
+ @js_modules ||= FRONTEND_ROOT.join("modules").glob("*.min.js").sort # rubocop:disable ThreadSafety/ClassInstanceVariable
27
29
  .to_h { |f| [f.basename(".min.js").to_s.to_sym, f] }
28
- .merge(MODULE_OVERRIDES)
30
+ .merge(MODULE_OVERRIDES).freeze
29
31
  end
30
32
 
31
33
  CURRENT_VERSION = Pgbus::VERSION.tr(".", "-").freeze
@@ -94,10 +94,11 @@ module Pgbus
94
94
  end
95
95
 
96
96
  BATCH_BADGE_BASE = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
97
+ # Interpolated strings are not frozen by the magic comment: freeze each.
97
98
  BATCH_BADGE_CSS = {
98
- "finished" => "#{BATCH_BADGE_BASE} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
99
- "processing" => "#{BATCH_BADGE_BASE} bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
100
- "pending" => "#{BATCH_BADGE_BASE} bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
99
+ "finished" => "#{BATCH_BADGE_BASE} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400".freeze,
100
+ "processing" => "#{BATCH_BADGE_BASE} bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400".freeze,
101
+ "pending" => "#{BATCH_BADGE_BASE} bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400".freeze
101
102
  }.freeze
102
103
 
103
104
  def pgbus_batch_status_badge(status)
@@ -37,6 +37,9 @@ module Pgbus
37
37
  # false on the first hiccup would permanently disable job stats
38
38
  # for the process lifetime, which is a worse failure mode than
39
39
  # a few retries. See issue #98 / PR #91 (StreamStat fix).
40
+ # An idempotent schema memo: concurrent first callers probe the same
41
+ # schema and store the same Boolean, so the unlocked read/write is safe.
42
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
40
43
  def self.table_exists?
41
44
  return @table_exists if defined?(@table_exists)
42
45
 
@@ -59,6 +62,7 @@ module Pgbus
59
62
  Pgbus.logger.debug { "[Pgbus] Failed to check job stat latency columns: #{e.message}" }
60
63
  false
61
64
  end
65
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
62
66
 
63
67
  # Throughput: jobs per minute bucketed by minute for the last N minutes
64
68
  def self.throughput(minutes: 60)
@@ -21,7 +21,9 @@ module Pgbus
21
21
  # failure path leaves the message for VT redelivery — and the next
22
22
  # delivery probes again.
23
23
  def completion_column?
24
- detected = @completion_column
24
+ # Double-checked: the unlocked read only skips the lock once the
25
+ # answer is set; it is written under @completion_column_mutex.
26
+ detected = @completion_column # rubocop:disable ThreadSafety/ClassInstanceVariable
25
27
  return detected unless detected.nil?
26
28
 
27
29
  @completion_column_mutex.synchronize do
@@ -30,6 +30,15 @@ module Pgbus
30
30
  # unsynchronized check-and-set could WARN more than once per process.
31
31
  @record_failure_mutex = Mutex.new
32
32
 
33
+ # Guards the @all_names memo and its generation (issue #527). The memo is a frozen Set,
34
+ # replaced whole (record! publishes a copy with the new name), so a
35
+ # caller iterating the set it got can never see it change underneath:
36
+ # record! runs on broadcast threads while the dispatcher iterates it.
37
+ @names_lock = Mutex.new
38
+ # Bumped by every record! and reset_cache! (under @names_lock): a cold
39
+ # load publishes its snapshot only if no such change happened while it ran.
40
+ @names_generation = 0
41
+
33
42
  class << self
34
43
  # Inserts the physical queue name. Idempotent and cheap to call on
35
44
  # every broadcast; the caller (`ensure_stream_queue`) also memoizes
@@ -61,7 +70,10 @@ module Pgbus
61
70
  # already-registered stream until the next reset_cache!. Leaving it
62
71
  # nil lets the next all_names call do a real load, which already
63
72
  # includes this row since the insert above has committed.
64
- @all_names&.add(queue_name)
73
+ @names_lock.synchronize do
74
+ @names_generation += 1
75
+ @all_names = (@all_names | [queue_name]).freeze if @all_names
76
+ end
65
77
  true
66
78
  rescue ActiveRecord::ActiveRecordError => e
67
79
  Pgbus.logger.debug { "[Pgbus] Failed to record stream queue #{queue_name}: #{e.message}" }
@@ -76,8 +88,21 @@ module Pgbus
76
88
  # not one per queue. Callers that need freshness (a long-lived process
77
89
  # picking up newly-created streams) call `reset_cache!` at the top of
78
90
  # their loop.
91
+ # A cold load runs outside the lock (record! takes a connection before
92
+ # the lock, so a load holding the lock while it waits for a connection
93
+ # could deadlock an exhausted pool) and is published only when no
94
+ # record! or reset_cache! ran meanwhile: its snapshot could predate
95
+ # them, and publishing it would lose their change. Otherwise the caller
96
+ # gets the snapshot and the memo stays empty, so the next call reloads.
79
97
  def all_names
80
- @all_names ||= load_names
98
+ cached, generation = @names_lock.synchronize { [@all_names, @names_generation] }
99
+ return cached if cached
100
+
101
+ loaded = load_names.freeze
102
+ @names_lock.synchronize do
103
+ @all_names ||= loaded if @names_generation == generation
104
+ @all_names || loaded
105
+ end
81
106
  end
82
107
 
83
108
  # Registry ∪ fingerprint-matched names. Use this for classification
@@ -128,11 +153,17 @@ module Pgbus
128
153
  # wildcard resolution so freshly-created streams are picked up without
129
154
  # a process restart.
130
155
  def reset_cache!
131
- @all_names = nil
156
+ @names_lock.synchronize do
157
+ @names_generation += 1
158
+ @all_names = nil
159
+ end
132
160
  end
133
161
 
134
162
  # Memoized like StreamStat: a successful probe sticks; a transient
135
163
  # error returns false without caching, so the next call retries.
164
+ # An idempotent memo: concurrent first callers probe the same schema
165
+ # and store the same Boolean, so the unlocked read/write is safe.
166
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
136
167
  def table_exists?
137
168
  return @table_exists if defined?(@table_exists) && @table_exists
138
169
 
@@ -49,6 +49,9 @@ module Pgbus
49
49
  # false on the first hiccup would permanently disable stream
50
50
  # stats for the process lifetime, which is a worse failure mode
51
51
  # than a few retries.
52
+ # An idempotent schema memo: concurrent first callers probe the same
53
+ # schema and store the same Boolean, so the unlocked read/write is safe.
54
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
52
55
  def self.table_exists?
53
56
  return @table_exists if defined?(@table_exists)
54
57
 
@@ -28,7 +28,9 @@ module Pgbus
28
28
  included do
29
29
  attr_accessor :pgbus_current_attributes
30
30
 
31
- class_attribute :pgbus_persist_current_attributes, instance_writer: false, default: nil
31
+ # A per-class setting the job's own class body writes at load; jobs
32
+ # only read it while they serialize.
33
+ class_attribute :pgbus_persist_current_attributes, instance_writer: false, default: nil # rubocop:disable ThreadSafety/ClassAndModuleAttributes
32
34
  end
33
35
 
34
36
  def serialize
data/lib/pgbus/batch.rb CHANGED
@@ -209,22 +209,28 @@ module Pgbus
209
209
  0
210
210
  end
211
211
 
212
+ # Schema-probe memos (a true answer is kept for the life of the process)
213
+ # and the warn-once flag, read and written under @schema_lock (issue
214
+ # #527). The probes run outside it: a duplicate probe on a race is
215
+ # harmless, a lock held across a query is not.
216
+ @schema_lock = Mutex.new
217
+
212
218
  def self.executions_migrated?
213
- return true if @executions_migrated
219
+ return true if @schema_lock.synchronize { @executions_migrated }
214
220
 
215
221
  result = begin
216
222
  BatchExecution.table_exists?
217
223
  rescue StandardError
218
224
  false
219
225
  end
220
- @executions_migrated = true if result
226
+ @schema_lock.synchronize { @executions_migrated = true } if result
221
227
  result
222
228
  end
223
229
 
224
230
  def self.warn_callback_jobs_unmigrated
225
- return if @warned_callback_jobs_unmigrated
231
+ first = @schema_lock.synchronize { @warned_callback_jobs_unmigrated ? false : (@warned_callback_jobs_unmigrated = true) }
232
+ return unless first
226
233
 
227
- @warned_callback_jobs_unmigrated = true
228
234
  Pgbus.logger.warn do
229
235
  "[Pgbus] Batch callback configured as an ActiveJob instance, but pgbus_batches has no " \
230
236
  "on_*_job columns yet — .set options (queue, wait, priority) are ignored until " \
@@ -233,23 +239,25 @@ module Pgbus
233
239
  end
234
240
 
235
241
  def self.reset_executions_migrated_cache!
236
- @executions_migrated = nil
237
- @callback_jobs_migrated = nil
238
- @warned_callback_jobs_unmigrated = nil
242
+ @schema_lock.synchronize do
243
+ @executions_migrated = nil
244
+ @callback_jobs_migrated = nil
245
+ @warned_callback_jobs_unmigrated = nil
246
+ end
239
247
  end
240
248
 
241
249
  # True once the on_finish_job / on_success_job / on_failure_job jsonb
242
250
  # columns exist (issue #415). Until then, configured callback instances
243
251
  # have nowhere to live and only bare classes are stored.
244
252
  def self.callback_jobs_migrated?
245
- return true if @callback_jobs_migrated
253
+ return true if @schema_lock.synchronize { @callback_jobs_migrated }
246
254
 
247
255
  result = begin
248
256
  BatchEntry.column_names.include?("on_finish_job")
249
257
  rescue StandardError
250
258
  false
251
259
  end
252
- @callback_jobs_migrated = true if result
260
+ @schema_lock.synchronize { @callback_jobs_migrated = true } if result
253
261
  result
254
262
  end
255
263
 
data/lib/pgbus/cli.rb CHANGED
@@ -37,6 +37,9 @@ module Pgbus
37
37
 
38
38
  def start_supervisor(args = [])
39
39
  apply_start_options(args)
40
+ # The flags go through setters, after boot finalized the configuration:
41
+ # finalize their values too before the supervisor forks.
42
+ Pgbus.finalize!
40
43
  Pgbus.logger.info { "[Pgbus] Starting Pgbus #{Pgbus::VERSION}..." }
41
44
 
42
45
  supervisor = Process::Supervisor.new
@@ -9,7 +9,16 @@ module Pgbus
9
9
  # The cursor is the highest msg_id the client has already seen. Replay returns
10
10
  # everything strictly greater, ordered by msg_id ASC, capped by `limit`.
11
11
  module ReadAfter
12
- Envelope = Data.define(:msg_id, :enqueued_at, :payload, :source)
12
+ # Ractor-shareable like the streamer's other dispatch values (issue
13
+ # #527): the Strings are frozen in place, they are the read's own.
14
+ Envelope = Data.define(:msg_id, :enqueued_at, :payload, :source) do
15
+ def initialize(msg_id:, enqueued_at:, payload:, source:)
16
+ enqueued_at&.freeze
17
+ payload&.freeze
18
+ source&.freeze
19
+ super
20
+ end
21
+ end
13
22
 
14
23
  DEFAULT_LIMIT = 500
15
24
 
data/lib/pgbus/client.rb CHANGED
@@ -20,9 +20,6 @@ module Pgbus
20
20
 
21
21
  attr_reader :pgmq, :config, :connection_health
22
22
 
23
- PGMQ_REQUIRE_MUTEX = Mutex.new
24
- private_constant :PGMQ_REQUIRE_MUTEX
25
-
26
23
  # Fixed advisory-lock key serializing pgmq schema installation across
27
24
  # processes (issue #397). "pgmqinst" in ASCII hex — arbitrary but stable;
28
25
  # it only has to be identical in every process that can install.
@@ -55,6 +52,11 @@ module Pgbus
55
52
  # (issue #397, forensics in getzazu/app#3413).
56
53
  @pgmq_install_mutex = Mutex.new
57
54
 
55
+ # Serializes the one-time `require "pgmq"` (see load_pgmq_gem!). Class
56
+ # state built at load, not a constant: a constant holding a Mutex is
57
+ # unreadable from a non-main Ractor (issue #527).
58
+ @pgmq_require_mutex = Mutex.new
59
+
58
60
  class << self
59
61
  attr_reader :pgmq_install_mutex
60
62
  end
@@ -82,7 +84,10 @@ module Pgbus
82
84
  # instead of the global Kernel#require, which — if stubbed before pgmq is
83
85
  # genuinely loaded — permanently prevents the real gem from ever loading.
84
86
  def self.load_pgmq_gem!
85
- PGMQ_REQUIRE_MUTEX.synchronize do
87
+ # The lock is Client's class state, which a subclass does not inherit.
88
+ return Client.load_pgmq_gem! unless equal?(Client)
89
+
90
+ @pgmq_require_mutex.synchronize do
86
91
  Object.const_set(:PGMQ, Module.new) unless defined?(::PGMQ)
87
92
  require "pgmq"
88
93
  end
@@ -920,10 +925,33 @@ module Pgbus
920
925
 
921
926
  # --- LISTEN/NOTIFY management (PGMQ v1.11.0+) ---
922
927
 
923
- def wait_for_notify(queue_name, timeout: nil, &block)
924
- full_name = config.queue_name(queue_name)
925
- with_stale_connection_retry do
926
- synchronized { @pgmq.wait_for_notify(full_name, timeout: timeout, &block) }
928
+ # Blocks until PGMQ's insert trigger notifies +queue_name+ or +timeout+
929
+ # seconds pass (nil waits indefinitely); returns the channel, or nil on a
930
+ # timeout, and yields (channel, pid, payload) like
931
+ # PG::Connection#wait_for_notify. The wait gets a dedicated connection of
932
+ # its own (worker_notify_connection_options), opened for it and closed
933
+ # after: never a pool connection, and never under @pgmq_mutex, which on
934
+ # the shared-AR path serializes every PGMQ call, so holding it across a
935
+ # blocking wait stalled the whole client (issue #527). The shared-AR path
936
+ # therefore needs worker_notify_database_url (or a base database_url).
937
+ # A connection found dead before it was used (STALE_CONNECTION_PATTERNS)
938
+ # is replaced and the wait starts again, as with_stale_connection_retry
939
+ # did for the pooled wait; a connection lost mid-wait is not retried.
940
+ def wait_for_notify(queue_name, timeout: nil, &)
941
+ channel = "pgmq.q_#{config.queue_name(queue_name)}.INSERT"
942
+ attempts = 0
943
+ begin
944
+ listen_on_dedicated_connection(channel, timeout, &)
945
+ rescue StandardError => e
946
+ attempts += 1
947
+ raise unless attempts <= STALE_RETRY_ATTEMPTS && stale_pg_error?(e)
948
+
949
+ sleep STALE_RETRY_DELAYS[[attempts - 1, STALE_RETRY_DELAYS.size - 1].min]
950
+ Pgbus.logger.warn do
951
+ "[Pgbus::Client] Retrying wait_for_notify after a stale connection " \
952
+ "(attempt #{attempts}/#{STALE_RETRY_ATTEMPTS}): #{e.message}"
953
+ end
954
+ retry
927
955
  end
928
956
  end
929
957
 
@@ -1199,7 +1227,8 @@ module Pgbus
1199
1227
  def ensure_pgmq_schema
1200
1228
  return if @schema_ensured
1201
1229
 
1202
- self.class.pgmq_install_mutex.synchronize do
1230
+ # Client's lock, also for a subclass: it serializes every client's install.
1231
+ Client.pgmq_install_mutex.synchronize do
1203
1232
  return if @schema_ensured
1204
1233
 
1205
1234
  # Cache only a durable result: true only when this call owned the
@@ -2069,6 +2098,22 @@ module Pgbus
2069
2098
  end
2070
2099
  end
2071
2100
 
2101
+ def listen_on_dedicated_connection(channel, timeout, &)
2102
+ conn = DedicatedConnection.connect(config.worker_notify_connection_options)
2103
+ begin
2104
+ conn.exec("LISTEN #{conn.quote_ident(channel)}")
2105
+ conn.wait_for_notify(timeout, &)
2106
+ ensure
2107
+ DedicatedConnection.close_quietly(conn)
2108
+ end
2109
+ end
2110
+
2111
+ # A raw PG::Error (the dedicated connection is not wrapped by pgmq-ruby)
2112
+ # whose message says the connection was dead before it was used.
2113
+ def stale_pg_error?(error)
2114
+ defined?(::PG::Error) && error.is_a?(::PG::Error) && stale_connection_error?(error)
2115
+ end
2116
+
2072
2117
  def stale_connection_error?(error)
2073
2118
  # Substring match against any stale-connection pattern. Deliberately a
2074
2119
  # single Regexp rather than `STALE_CONNECTION_PATTERNS.any? { |p| msg.include?(p) }`:
@@ -12,6 +12,9 @@ module Pgbus
12
12
  # parked job whose class no longer resolves.
13
13
  DEFAULT_DURATION = 15 * 60
14
14
 
15
+ # Class-body macro: it runs while the job class loads (at boot, or under the
16
+ # autoloader's lock), stores a frozen Hash, and the reader only reads it.
17
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
15
18
  class_methods do
16
19
  # Limit concurrent execution of jobs with the same key.
17
20
  #