pgbus 0.18.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +5 -0
- data/app/controllers/pgbus/frontends_controller.rb +10 -8
- data/app/helpers/pgbus/application_helper.rb +4 -3
- data/app/models/pgbus/job_stat.rb +4 -0
- data/app/models/pgbus/processed_event.rb +3 -1
- data/app/models/pgbus/stream_queue.rb +34 -3
- data/app/models/pgbus/stream_stat.rb +3 -0
- data/lib/pgbus/active_job/current_attributes.rb +3 -1
- data/lib/pgbus/batch.rb +17 -9
- data/lib/pgbus/cli.rb +3 -0
- data/lib/pgbus/client/read_after.rb +10 -1
- data/lib/pgbus/client.rb +54 -9
- data/lib/pgbus/concurrency.rb +3 -0
- data/lib/pgbus/configuration.rb +84 -9
- data/lib/pgbus/current_attributes.rb +3 -1
- data/lib/pgbus/dedup_cache.rb +15 -7
- data/lib/pgbus/engine.rb +11 -0
- data/lib/pgbus/event_bus/handler.rb +16 -4
- data/lib/pgbus/execution_pools/async_pool.rb +23 -2
- data/lib/pgbus/execution_pools/thread_pool.rb +22 -0
- data/lib/pgbus/execution_pools.rb +26 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +17 -9
- data/lib/pgbus/integrations/appsignal/subscriber.rb +30 -18
- data/lib/pgbus/integrations/appsignal.rb +26 -16
- data/lib/pgbus/mcp/rack_app.rb +1 -1
- data/lib/pgbus/metrics/subscriber.rb +35 -21
- data/lib/pgbus/process/consumer.rb +1 -1
- data/lib/pgbus/process/consumer_priority.rb +1 -1
- data/lib/pgbus/process/lifecycle.rb +5 -5
- data/lib/pgbus/process/notify_listener.rb +32 -18
- data/lib/pgbus/process/wake_pipe.rb +4 -1
- data/lib/pgbus/process/worker.rb +4 -2
- data/lib/pgbus/retry_backoff.rb +3 -0
- data/lib/pgbus/shareable.rb +70 -0
- data/lib/pgbus/streams/broadcastable_override.rb +3 -0
- data/lib/pgbus/streams/presence.rb +6 -7
- data/lib/pgbus/streams.rb +38 -27
- data/lib/pgbus/testing.rb +19 -3
- data/lib/pgbus/uniqueness.rb +3 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/visibility_heartbeat.rb +51 -34
- data/lib/pgbus/web/authentication.rb +19 -2
- data/lib/pgbus/web/data_source/list_counts.rb +8 -6
- data/lib/pgbus/web/health_server.rb +4 -1
- data/lib/pgbus/web/process_activity.rb +1 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +4 -1
- data/lib/pgbus/web/streamer/hub_client.rb +4 -1
- data/lib/pgbus/web/streamer/listener.rb +10 -1
- data/lib/pgbus/web/streamer/master_hub.rb +11 -5
- data/lib/pgbus/web/streamer/master_hub_boot.rb +3 -1
- data/lib/pgbus/web/streamer/outbound_pump.rb +4 -1
- data/lib/pgbus/web/streamer/peer_close_watcher.rb +4 -1
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +13 -1
- data/lib/pgbus.rb +92 -30
- data/sig/pgbus/active_job/adapter.rbs +57 -0
- data/sig/pgbus/client.rbs +78 -0
- data/sig/pgbus/configuration.rbs +316 -0
- data/sig/pgbus/streams/stream.rbs +25 -0
- data/sig/pgbus/streams.rbs +22 -0
- data/sig/pgbus.rbs +73 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a11cc47f884c4d3a845e019d2b526ca865be0d8f94851dda955d6163f4c822da
|
|
4
|
+
data.tar.gz: 6e560c1140a12ae2dc192f59f01898f291f4ea3c2265dd85c08d4ab115257f82
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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) |
|
|
@@ -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
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
|
|
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) }`:
|
data/lib/pgbus/concurrency.rb
CHANGED
|
@@ -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
|
#
|