pgbus 0.18.2 → 0.19.1

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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +345 -142
  3. data/README.md +49 -0
  4. data/app/controllers/pgbus/deploy_tasks_controller.rb +38 -0
  5. data/app/controllers/pgbus/frontends_controller.rb +10 -8
  6. data/app/helpers/pgbus/application_helper.rb +4 -3
  7. data/app/helpers/pgbus/deploy_tasks_helper.rb +77 -0
  8. data/app/helpers/pgbus/processes_helper.rb +1 -1
  9. data/app/models/pgbus/deploy_task.rb +104 -0
  10. data/app/models/pgbus/deploy_task_run.rb +45 -0
  11. data/app/models/pgbus/job_stat.rb +4 -0
  12. data/app/models/pgbus/processed_event.rb +3 -1
  13. data/app/models/pgbus/stream_queue.rb +34 -3
  14. data/app/models/pgbus/stream_stat.rb +3 -0
  15. data/app/views/layouts/pgbus/application.html.erb +2 -0
  16. data/app/views/pgbus/deploy_tasks/_rollover_banner.html.erb +25 -0
  17. data/app/views/pgbus/deploy_tasks/_tasks_table.html.erb +71 -0
  18. data/app/views/pgbus/deploy_tasks/index.html.erb +6 -0
  19. data/app/views/pgbus/deploy_tasks/show.html.erb +134 -0
  20. data/config/i18n-tasks.yml +2 -0
  21. data/config/locales/da.yml +97 -0
  22. data/config/locales/de.yml +97 -0
  23. data/config/locales/en.yml +97 -0
  24. data/config/locales/es.yml +97 -0
  25. data/config/locales/fi.yml +97 -0
  26. data/config/locales/fr.yml +97 -0
  27. data/config/locales/it.yml +97 -0
  28. data/config/locales/ja.yml +97 -0
  29. data/config/locales/nb.yml +97 -0
  30. data/config/locales/nl.yml +97 -0
  31. data/config/locales/pt.yml +97 -0
  32. data/config/locales/sv.yml +97 -0
  33. data/config/routes.rb +7 -0
  34. data/lib/generators/pgbus/add_deploy_tasks_generator.rb +51 -0
  35. data/lib/generators/pgbus/templates/add_deploy_tasks_tables.rb.erb +43 -0
  36. data/lib/generators/pgbus/templates/deploy_tasks.yml.erb +42 -0
  37. data/lib/generators/pgbus/templates/migration.rb.erb +43 -0
  38. data/lib/pgbus/active_job/current_attributes.rb +3 -1
  39. data/lib/pgbus/batch.rb +17 -9
  40. data/lib/pgbus/cli.rb +3 -0
  41. data/lib/pgbus/client/read_after.rb +10 -1
  42. data/lib/pgbus/client.rb +54 -9
  43. data/lib/pgbus/concurrency.rb +3 -0
  44. data/lib/pgbus/configuration/deploy_settings.rb +46 -0
  45. data/lib/pgbus/configuration.rb +87 -9
  46. data/lib/pgbus/current_attributes.rb +3 -1
  47. data/lib/pgbus/dedup_cache.rb +15 -7
  48. data/lib/pgbus/deploy/revision.rb +59 -0
  49. data/lib/pgbus/deploy/rollover.rb +65 -0
  50. data/lib/pgbus/deploy/runner.rb +259 -0
  51. data/lib/pgbus/deploy/task.rb +105 -0
  52. data/lib/pgbus/engine.rb +28 -0
  53. data/lib/pgbus/event_bus/handler.rb +16 -4
  54. data/lib/pgbus/execution_pools/async_pool.rb +23 -2
  55. data/lib/pgbus/execution_pools/thread_pool.rb +22 -0
  56. data/lib/pgbus/execution_pools.rb +26 -0
  57. data/lib/pgbus/generators/migration_detector.rb +11 -0
  58. data/lib/pgbus/instrumentation.rb +3 -0
  59. data/lib/pgbus/integrations/appsignal/probe.rb +17 -9
  60. data/lib/pgbus/integrations/appsignal/subscriber.rb +30 -18
  61. data/lib/pgbus/integrations/appsignal.rb +26 -16
  62. data/lib/pgbus/mcp/rack_app.rb +1 -1
  63. data/lib/pgbus/mcp/server.rb +2 -1
  64. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +86 -0
  65. data/lib/pgbus/mcp.rb +1 -0
  66. data/lib/pgbus/metrics/subscriber.rb +35 -21
  67. data/lib/pgbus/outbox/poller.rb +1 -0
  68. data/lib/pgbus/process/consumer.rb +2 -1
  69. data/lib/pgbus/process/consumer_priority.rb +1 -1
  70. data/lib/pgbus/process/dispatcher.rb +18 -1
  71. data/lib/pgbus/process/heartbeat.rb +7 -2
  72. data/lib/pgbus/process/lifecycle.rb +5 -5
  73. data/lib/pgbus/process/notify_listener.rb +32 -18
  74. data/lib/pgbus/process/supervisor.rb +35 -3
  75. data/lib/pgbus/process/wake_pipe.rb +4 -1
  76. data/lib/pgbus/process/worker.rb +5 -2
  77. data/lib/pgbus/recurring/scheduler.rb +54 -20
  78. data/lib/pgbus/retry_backoff.rb +3 -0
  79. data/lib/pgbus/shareable.rb +70 -0
  80. data/lib/pgbus/streams/broadcastable_override.rb +3 -0
  81. data/lib/pgbus/streams/presence.rb +6 -7
  82. data/lib/pgbus/streams.rb +38 -27
  83. data/lib/pgbus/testing.rb +19 -3
  84. data/lib/pgbus/uniqueness.rb +3 -0
  85. data/lib/pgbus/version.rb +1 -1
  86. data/lib/pgbus/visibility_heartbeat.rb +51 -34
  87. data/lib/pgbus/web/authentication.rb +19 -2
  88. data/lib/pgbus/web/data_source/deploy_task_list.rb +114 -0
  89. data/lib/pgbus/web/data_source/list_counts.rb +10 -7
  90. data/lib/pgbus/web/data_source.rb +1 -0
  91. data/lib/pgbus/web/deploy_task_state.rb +51 -0
  92. data/lib/pgbus/web/health_server.rb +4 -1
  93. data/lib/pgbus/web/process_activity.rb +1 -1
  94. data/lib/pgbus/web/streamer/heartbeat.rb +4 -1
  95. data/lib/pgbus/web/streamer/hub_client.rb +4 -1
  96. data/lib/pgbus/web/streamer/listener.rb +10 -1
  97. data/lib/pgbus/web/streamer/master_hub.rb +11 -5
  98. data/lib/pgbus/web/streamer/master_hub_boot.rb +3 -1
  99. data/lib/pgbus/web/streamer/outbound_pump.rb +4 -1
  100. data/lib/pgbus/web/streamer/peer_close_watcher.rb +4 -1
  101. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +13 -1
  102. data/lib/pgbus.rb +92 -30
  103. data/sig/pgbus/active_job/adapter.rbs +57 -0
  104. data/sig/pgbus/client.rbs +78 -0
  105. data/sig/pgbus/configuration.rbs +326 -0
  106. data/sig/pgbus/streams/stream.rbs +25 -0
  107. data/sig/pgbus/streams.rbs +22 -0
  108. data/sig/pgbus.rbs +73 -0
  109. metadata +27 -1
data/CHANGELOG.md CHANGED
@@ -2,87 +2,148 @@
2
2
 
3
3
  ### Added
4
4
 
5
- - **`config.streams_authorize`: the engine's stream endpoint can supply a connection context.** The engine mounts `Pgbus::Web::StreamApp.new` with no `authorize:` hook, so every connection was authorized with no context. Connection-driven presence (`streams_presence_patterns`) then joined nobody, and `visible_to:` predicates that need a context (they receive `nil`, so `user&.admin?` is falsy) hid every broadcast, unless the app mounted its own `StreamApp`. `config.streams_authorize = ->(env, stream_name) { … }` takes the same contract as `StreamApp.new(authorize:)`: a falsy return answers 403, `true` authorizes with no context, any other value (the current user, say) becomes the connection context. It is read per request, so the order of initializers and route loading doesn't matter. An explicit `StreamApp.new(authorize:)` wins; any `StreamApp` without one, the engine's included, uses the configured hook, and nil (the default) keeps today's behaviour. `validate!` rejects a non-callable. Closes #522.
5
+ - **Deploy tasks: one-time work owed by a release, run once the fleet has rolled over.** Declare a backfill, data fix or cache warm in `config/deploy_tasks.yml` (the shape of `recurring.yml` minus `schedule:`, plus `version:` and `every_deploy:`) or `config.deploy_tasks`; the scheduler enqueues it once per `(key, version)` (or once per revision for an `every_deploy:` task) after every live pgbus process reports the new app revision and a worker on it is alive, so it never runs on old code during the overlap window and two schedulers never both fire it. It is not exactly-once: a failure between the enqueue and recording the run can repeat a task after a scheduler restart, and a scheduler killed between claiming and enqueueing loses that arm, so deploy tasks must be idempotent. Every pgbus process stamps the revision (`config.app_revision`, `PGBUS_REVISION`, `DASH_VERSION`, `KAMAL_VERSION` or the `REVISION` file) into its heartbeat. The dashboard gets a "Deploy tasks" page with the rollover status, Run now and Enable/Disable; run history lives in `pgbus_deploy_task_runs` (`deploy_task_run_retention`, default 90 days); `pgbus.deploy_task.enqueue` is instrumented and the MCP server gains `pgbus_deploy_tasks`. Set up with `rails generate pgbus:add_deploy_tasks` (`pgbus:update` detects the missing tables). Refs #536.
6
6
 
7
- - **Streams presence keeps one row per tab, with per-tab state, a change hook and automatic expiry (experimental).** Connection-driven presence (`streams_presence_patterns`) kept one row per member, so a user with two tabs open vanished from the room when either tab closed. On a migrated table it now keeps one row per SSE connection: `pgbus_presence_members` gains `connection_id`, `seat` (the client's per-tab id) and `state jsonb`. `<pgbus-stream-source presence>` (`pgbus_stream_from @room, presence: true`) mints one seat per document and sends it as `?seat=`; the stream app stores it on the connection and the dispatcher joins with `connection_id:` and `seat:`. New API: `Presence#join(member_id:, connection_id:, seat:, metadata:)`, `#leave(connection_id:)`, `#touch(connection_id:)` (one statement per stream, returning the ids it found), `#set_state(connection_id:, state:)` (merges into that row) and `.seats_supported?`; `#members` rows carry `connection_id`, `seat` and `state`. Member-keyed calls keep working on their own row. New settings: `streams_presence_on_change = ->(stream_name, change) { }`, called after each seat join, leave and expiry with `{ type:, connection_id:, seat:, member_id: }` (errors logged and swallowed), and `streams_presence_ttl` (seconds, or a pattern => seconds Hash with `:default`; 60 by default). The heartbeat touches live seats each tick, re-joins a live connection whose row was expired, and at most every ttl / 2 expires silent seats across all streams, including rows of crashed processes, reporting each as a `:leave`. On a seat table `sweep!` only expires member-keyed rows, and pgbus warns at boot when a ttl does not exceed `streams_heartbeat_interval`. **Upgrade:** `rails generate pgbus:add_presence_seats` (queued by `pgbus:update`; fresh `pgbus:add_presence` tables already have the columns). It replaces the `(stream_name, member_id)` unique index with partial unique indexes on `(stream_name, member_id) WHERE connection_id IS NULL` and `(stream_name, connection_id) WHERE connection_id IS NOT NULL`, and is reversible. Until it runs, presence behaves as before; a running process notices the migrated table within 30 seconds (at once on a member-keyed `join` outside a transaction). Closes #515.
7
+ ## [0.19.0] - 2026-10-16
8
8
 
9
- - **The home page and Insights explain themselves.** The home page (`/pgbus`) showed numbers without words ("Failed / DLQ 5 / 3", a Throughput card that was always `0.0` on the web, Queue Health percentages coloured against thresholds it never stated). It now opens with a health verdict strip — the OK / DEGRADED / STALLED verdict and reasons `/readyz` and the MCP compute (`HealthAnalyzer`), with links to the page that explains them. Eight cards each say what their number means and link to the page that explains it: Queues (paused, dead-letter, holding jobs nothing drains), Enqueued (claimable now vs scheduled, running or retrying), Processes (healthy · stalled · stale), Recurring, Retrying (the Jobs page's Retrying count, when the next attempt runs, how many are due now, the newest error), Dead-lettered (the newest message's reason, read as the DLQ page reads it), Throughput (jobs/min over the last 5 minutes from job stats, or "needs job stats") and Parked. The queues table gains a Status column in words (the queue page's summary line: paused and why, nothing drains it, what is waiting). Queue Health states each threshold ("12% on pgmq.q_pgbus_events — vacuum due (warns above 10%, critical above 20%)"; the MVCC card names both its own 30 s / 60 s warnings and the verdict's 300 s) and its table always renders, with an empty state. Recent failures show the job and attempt and link to the failure. New `Web::DataSource#dashboard_signals` and `#queue_status_lines` (in `DataSource::DashboardSignals`) and the pure presenters `Web::HealthVerdict` and `Web::InsightsCharts`; `summary_stats`, `/pgbus/api/stats`, `/pgbus/api/metrics` and the AppSignal probe are unchanged. On Insights each chart has a caption sentence (also the chart's accessible name) and a `<details>` data table rendered on the server (at most 60 rows: long ranges roll minutes up), empty series show an empty state, and the page draws from series it embeds instead of fetching `/pgbus/api/insights` a second time (that endpoint is unchanged). Closes #492.
9
+ ### Added
10
10
 
11
- - **The Processes page says what each process is doing, why its status is what it is, and what happens next.** `/pgbus/processes` showed kind · host · pid · a Healthy/Stalled/Stale badge · "45s ago" · a dump of raw metadata chips; the thresholds behind the badge lived only in Ruby, and nothing said what the process was doing or what would happen to it. Each row now has Process · Status · Activity · Recycle · Heartbeat cells: the Status cell says why ("Loop last ticked 2m ago (limit 1m 30s)", "No heartbeat for 7m 0s (limit 5m 0s)", "Heartbeat 5s ago (limit 5m 0s) · stall detection off") and what happens next ("the supervisor's watchdog restarts it", "no supervisor to restart it", "probably dead; removed at the dispatcher's next sweep (within 5m 0s)", "no live dispatcher to remove it", or what stops advancing when the dispatcher or scheduler stalls); Activity says what it drains or consumes, busy threads (or async slots), buffered claims, jobs/s and failures/s, what a dispatcher, scheduler or outbox poller does, and what a supervisor supervises; Recycle states progress against each configured limit ("1,240 / 10,000 jobs · 310 / 512 MB · up 42m 0s / 1h 0m"); Heartbeat adds uptime. Children are listed under their supervisor ("under supervisor 100"), grouped only when that supervisor's row is on the same host, so a standalone `pgbus worker` is never claimed by a stranger. Clicking a process expands its start time and the metadata nothing else renders. The home page's Processes panel puts the same sentence under each badge. Status derivation moved from `DataSource` into the pure presenter `Web::ProcessState` (with `Web::ProcessActivity` for the activity and recycle words), so the badge and its sentence come from the same moment and limits; `DataSource#processes` keeps its row shape and adds `supervisor_pid` and `started_at`. **New heartbeat keys**, written once per 60 s beat and never per job: every process `started_at`; forked kinds `supervisor_pid`; workers and consumers the pool's `capacity`/`busy`, `buffered`, `memory_mb` (an RSS read through `MemoryUsage`'s 5 s cache — a `ps` subprocess per minute on macOS) and the recycle limits the process resolved (a capsule's own when set); consumers gain a metadata supplier with their handled count. No schema change. Because `capacity`/`busy` are now persisted, `pgbus_worker_pool_capacity` and `pgbus_worker_pool_busy` on `/pgbus/api/metrics` emit for the first time. A process from before this release shows "—" where a fact is missing. Closes #493.
11
+ - **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.
12
+ - **`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.
12
13
 
13
- - **The Events page says what state each event is in, why, and what happens next.** `/pgbus/events` had three tables that explained nothing: a pending list that faked columns inside one cell and showed a raw `vt` and `read_ct`, a processed list of `event_id · handler · processed_at`, and a subscriber list. It now has one pending-events list with real cells (ID · Event · Handler · State · Attempts · Next run / Reason · Actions), state tabs with counts (All / Ready / Scheduled / Handling / Retrying), a pager, auto-refresh and the Jobs page's row expansion. Every row names the handler its queue belongs to (the first subscriber registered on that queue) and says in words why it waits (events ahead, queue paused, no running consumer subscribes to its pattern, no healthy consumer, lease expired), that it is being handled (claimed when, lease expires when), or which attempt failed with which error and when the next one is due (or that the next read dead-letters it). Mark Handled, Discard, Reroute, Edit & Retry and Discard Selected keep working; an orphaned failed row (message gone) is discarded through its failed event. The processed-events audit says whether each claim completed, is being handled (claim refreshed recently) or went silent (the next delivery re-runs the handler), pages on its own `processed_page`, and offers **Replay** for a completed claim whose handler is still registered and whose archived message still carries a routing key its pattern matches. The subscriber list says whether a healthy consumer's topics cover each pattern and links to that handler's pending events. Internally the list is the Jobs list's SQL scoped to the handler queues: `DataSource#job_rows` / `#job_state_counts` take optional `queues:` and `exclude:` (additive; without them the SQL is unchanged), and `DataSource::EventList` + the pure presenter `Web::EventState` add the event words. `EventBus::Handler.claim_ownership_window` is now a class method. Closes #494.
14
+ ### Changed
14
15
 
15
- - **The dead-letter queue says why every message died.** A job that exhausted `max_retries` was moved to `<queue>_dlq` and, in the same breath, `FailedEventRecorder.clear!` deleted the only row that knew what went wrong, so `/pgbus/dlq` could show only the payload and a "Reads" column counting reads *in the DLQ*. The mover (the ActiveJob executor or the event consumer) now reads the last recorded error before the move and writes a `pgbus_dead_letter` block into the DLQ copy's PGMQ headers (`Pgbus::DeadLetterHeader`): reason (`max_retries_exceeded`), source (`worker`/`consumer`), the physical source queue (a priority sub-queue included), attempts, `max_retries`, `dead_lettered_at`, and the last error's class, message (1,000 chars), backtrace head (10 lines) and the attempt it came from. Existing headers (trace ids, `x-pgmq-group`) are kept. `Client#move_to_dead_letter` takes `headers:`. The DLQ page is a real table (ID · Job · Source queue · Died · Attempts · Reason · Actions) whose Reason cell says in words what killed the message; the row expansion shows the full error, the backtrace head and which later attempts recorded nothing; chips filter by DLQ (`?dlq=`) with counts and clicking an error class filters by it (`?error_class=`); `/pgbus/dlq/:id` opens with a "Why it died" card. Retrying from the DLQ strips the block (a live message must not claim to be dead) and carries `pgbus_dlq_retries`, so a second death reads "retried from the DLQ once before". Messages dead-lettered before this release read "Reason not recorded (dead-lettered before pgbus 0.18.0)" and keep working everywhere. `pgbus_dlq`/`pgbus_dlq_detail` (MCP) gain a `dead_letter` summary (error message and backtrace only when payloads are allowed); `pgbus dlq list` gains a REASON column and `pgbus dlq show` prints died, attempts, source, reason and error. `pgbus.job_dead_lettered` carries `error:` (the class). No schema change. The executor's success path is untouched: `executor_bench` main vs branch, same machine back to back, 64.6k vs 66.7k i/s (noise) and 6,448 B / 68 objects per job on both. Event handler errors appear in the block once the consumer records them (#494). Closes #495.
16
+ - **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.
17
+ - **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.
18
+ - **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.
16
19
 
17
- - **docs: a "Sizing capsules" page on the docs site** (Operations, `/docs/sizing-capsules`). It covers when to give a queue threads, fibers, `processes:` or a capsule of its own: how to tell a CPU-bound queue (symptoms, plus the `gvl_wait` baseline from `rake bench:worker_profile`), a decision table, use cases with before/after configs (rendering fan-out, a memory-heavy export, duplicated capsules, a bulk sweep starving latency-sensitive work, and the counter-example of work in an external binary), the memory and connection math for a job host, and a checklist for moving off the workarounds. The README's "Threads vs processes" section and the docs site's Running workers page link to it. Closes #505.
20
+ ### Fixed
18
21
 
19
- - **Capsules take `processes: N` for CPU-bound queues, and their own recycle limits.** A capsule was one forked process with `threads:` threads; threads share one GVL, so a `threads: 8` capsule running CPU-bound jobs (rendering, reports, PDFs) used about one core, and the only ways to more cores were several supervisors per host or sharding the queue. `c.capsule :render, queues: %w[render], threads: 1, processes: 4` now forks four identical workers reading the same queues. Each fork has its own slot — restart backoff, liveness pipe, `pgbus_processes` row, and a wake pipe when the supervisor-scope NotifyHub is active — so a crashed fork restarts alone; readiness counts every fork; the boot banner prints `processes=N`; worker heartbeat metadata gains `capsule` and `process` (`"2/4"`), which the dashboard's Processes page shows; and `pgbus doctor`'s connection budget counts each fork under `worker_notify_scope: :fork`. The default is 1, so every existing config boots unchanged, and the queue-overlap rule is untouched (the forks belong to one capsule). A capsule may also set `max_memory_mb:`, `max_jobs_per_worker:` and `max_worker_lifetime:`, which recycle only that capsule's workers and fall back to the global values when unset; all are validated at boot. README and the docs site gain a "Threads vs processes" section with the memory and connection math. Not in this change: `processes: :auto`, GVL-contention observability, and a string-DSL syntax for `processes`. Closes #503.
22
+ - **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.
23
+ - **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.
24
+ - **`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.
25
+ - **`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.
26
+ - **`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.
27
+ - **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.
28
+ - **`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.
29
+ - **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.
30
+ - **`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.
31
+ - **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.
32
+ - **`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.
20
33
 
21
- - **feat(web): every growing dashboard list has a pager, every button goes through one helper, and mobile card labels follow the locale.** Outbox, Batches, both Locks tables and Recurring tasks now render the shared pager. Before, Batches silently dropped the 101st batch, Locks and concurrency keys stopped at 100, Recurring tasks loaded every row, and Outbox fetched pages but had no links to them. Totals come from a bounded count (`Web::DataSource#list_count`, a `LIMIT 10_001` probe; past the cap the pager says "10,000+"). The two Locks tables page independently on `page` and `keys_page`. Framed lists advance the URL, so the auto-refresh stays on the page you opened. The DataSource readers take `page:`/`per_page:` with defaults that keep today's output. The `pgbus_locks`, `pgbus_concurrency` and `pgbus_recurring` MCP tools accept optional `page`/`per_page` (max 100). Omitting them returns the same rows as before; the response now also carries `page`, `per_page`, `total` and `has_more`. `Pgbus::ButtonHelper` (`pgbus_button_to`, `pgbus_link_to`, `pgbus_button_tag`, `pgbus_button_classes`) replaces about 56 hand-written class strings. Each variant has light and dark colours, a focus ring and a target of at least 24 px (WCAG 2.5.8); row-action links used to be about 16 px tall. Labels and confirms did not change. Every table cell's `data-label` (the label on the card shown below 1024 px) now reuses its column header's translation, so a German operator no longer sees English labels at phone width. Empty states go through `shared/_empty_row`. `spec/pgbus/web/view_conventions_spec.rb` pins all of this, and the contrast and compiled-CSS guards now scan every helper file. Closes #496.
34
+ ## [0.18.2] - 2026-10-15
22
35
 
23
- - **Dashboard quality gates: an axe WCAG 2.1 AA gate in light and dark mode, monthly Lighthouse budgets, one stub data source.** `spec/system/accessibility_spec.rb` runs axe-core 4.13 (from `node_modules`, no gem) on every dashboard page — dashboard, queues + detail, jobs with every state tab and an expanded row, job detail, recurring + detail, processes, events + detail, batches + detail, DLQ + detail, outbox, locks, insights — in both themes, against rich sample data and against the empty states (tags `wcag2a`, `wcag2aa`, `wcag21aa`, and `wcag22aa` for target size, which Lighthouse scores too); any violation fails the system job, and there is no way to accept one. The page list is `lighthouserc.dashboard.json`, shared with Lighthouse, and a route-coverage example fails when an HTML GET route of the engine has no audited URL. Dark mode is entered the way a user enters it (stored preference, fresh page), so the body's colour transition never runs under the audit. Lighthouse runs monthly and on demand (`.github/workflows/lighthouse.yml`, `lhci collect → upload → assert`, score table in the job summary, 30-day artifact) and never gates a merge; `bin/lighthouse` runs it locally against `rake dummy:server`. The dummy server now uses `Pgbus::Test::StubDataSource#fill_sample_data!` — the same rows the gate audits — instead of its own stub, which lacked 12 methods and answered 500 on `/pgbus/events`, `/pgbus/dlq` and `/pgbus/locks`; Events, Outbox and the Insights tables now render with data there. The static contrast guard learned the shades the browser caught. `AGENTS.md`, `.claude/rules/dashboard.md`, `.claude/rules/testing.md` and `/lfg` say how a dashboard change is tested. Closes #498.
36
+ ### Fixed
24
37
 
25
- - **feat(web): the Jobs page is one list that says why each job is there and when it will run.** The old page had two unrelated tables — "Failed Jobs" and "Enqueued Jobs" — that listed a retrying job twice (its failed-event row and its queue message) and never said what state a job was in. `/jobs` is now a single table with the same columns for every row (ID · Job · Queue · State · Attempts · Next run / Reason · Actions), filter tabs with counts (All / Ready / Scheduled / Running / Retrying / Blocked) and a pager. Each row says in words why it is waiting: scheduled with its run time; ready with the number of jobs ahead of it, or the reason nothing will pick it up (queue paused, no worker drains the queue, no healthy worker); running with when it was claimed and when its lease expires; retrying with the failed attempt, the error and the next attempt (or that the next read dead-letters it); blocked by `limits_concurrency` with its key and slot usage, linked to the Locks page. Failed-event rows whose message has left its queue show as retrying ("message no longer in queue"). The state is derived in SQL (new `Web::DataSource#job_rows` / `#job_state_counts` / `#jobs_ahead`, in `DataSource::JobList`) so tabs, counts and paging agree; tab counts are capped at 10k per queue so a huge queue never forces a full scan on auto-refresh. `DataSource#jobs` and `#failed_events` are unchanged. The dashboard's "failed jobs" links (`/jobs?status=failed`) land on the Retrying tab. **Dark mode:** every dashboard page now pairs each light colour with a `dark:` variant (hostnames, PIDs, headers and badges were near-invisible on dark cards), rows no longer paint a darker stripe inside their card, and the empty-state colspans match their tables. A static spec (`spec/pgbus/web/dark_mode_contrast_spec.rb`) keeps it that way. Closes #489.
38
+ - **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.
26
39
 
27
- - **`rake bench:worker_profile` measures the event consumer too.** A consumer cell drives a real `Process::Consumer` over an event-bus topic queue (plain handler, so the transport is measured, not the dedup table) with the same harness as the worker cell. `WP_BENCH_ROLE` (`worker`, `consumer`, `both`) and `WP_BENCH_READ_AHEAD` (passed as `read_ahead:`) are new knobs. Refs #486.
40
+ ## [0.18.1] - 2026-10-12
28
41
 
29
- - **`coalesce:` now works on the `Turbo::StreamsChannel` broadcast path.** `Pgbus::Streams::Stream#broadcast` has accepted `coalesce:` since #171, but the Turbo patch forwarded only four options (`durable:`, `exclude:`, `visible_to:`, `event:`), so nothing that broadcasts through `Turbo::StreamsChannel.broadcast_*_to` — every `Turbo::Broadcastable` model, and phlex-reactive's `Streamable.broadcast_to` — could debounce. A background job settling a 177-row collection therefore pushed 177 idempotent replaces of the same count badge at every peer. Any targeted helper now takes `coalesce:` (`@order.broadcast_replace_to :account, coalesce: 100`), as does a direct channel call (`Turbo::StreamsChannel.broadcast_replace_to(@account, target: "order-count", coalesce: true)`), with the same semantics as the direct API: opt-in, per `(stream, target)`, last-write-wins, `true` = the 50ms default window, an integer = the window in ms. Two things the option's plumbing needed beyond a fifth thread-local: the coalescing key, which dedupes on `(stream, target)` while `broadcast_stream_to` only ever sees the rendered `content:` — it is now resolved from the frame's own `target:`/`targets:` through the same `ActionView::RecordIdentifier.dom_id` conversion Turbo applies, so a record target keys on `order_7` rather than on a per-instance `#to_s`; and extraction of the pgbus options at the channel itself, since a direct `Turbo::StreamsChannel` call never passes through `Turbo::Broadcastable` and so previously leaked `durable: true` (and now `coalesce:`) into Turbo's renderer. `broadcast_refresh_to` and `broadcast_render_to` carry no target and so cannot coalesce — they raise the existing actionable "requires target:" error rather than guessing a key — and the `_later_to` variants remain out of scope, as the thread-local cannot follow into a job. The wire is byte-identical when `coalesce:` is absent. Closes #465.
42
+ ### Fixed
30
43
 
31
- - **Concurrency is now visible: a section on the Locks page, three gauges, a home stat card and an MCP tool.** `limits_concurrency … on_conflict: :block` became durable in #460 — a parked job is never dropped and a key never runs past `to:` — but nothing showed it, so an operator whose per-record pipeline looked "stuck" had no way to see that the key's slot was held, how many jobs were parked behind it, or how long the oldest had waited, short of SQL. `pgbus_semaphores` and `pgbus_blocked_executions` appeared on no page, in no gauge, in no tool. One new read, `Web::DataSource#concurrency_stats`, now feeds four surfaces: the Locks page's **Concurrency** section (parked jobs, oldest wait, slots held, keys at limit, then a row per key with value / limit, lease state, parked count and oldest wait, auto-refreshing like the other dashboard panels); a **Parked jobs** card on the dashboard home; three unlabelled gauges on both `/pgbus/api/metrics` and the AppSignal probe (`pgbus_concurrency_blocked_executions`, `pgbus_concurrency_blocked_oldest_age_seconds`, `pgbus_concurrency_slots_held` — keys are per-record, so a per-key label would be an unbounded series); and a read-only `pgbus_concurrency` MCP tool returning the same data. The key rows come from a FULL OUTER JOIN, not a LEFT one: a key can have parked jobs and no semaphore (its holder died and the sweep removed the row) or a semaphore and nothing parked, and both shapes matter to an operator. Each row carries two guarded escape hatches. **Release** drops the semaphore and promotes through `Concurrency::BlockedExecution.promote_next`, so it takes each slot through the same guarded upsert an enqueue uses and can never reintroduce the over-admission #460 closed; when the lease is still fresh the confirm says so, because a job is then probably still running and releasing lets another start beside it. **Discard parked** drops a single key's parked jobs, naming the exact count in the confirm, and resolves the bookkeeping those jobs will never resolve themselves — a parked batch child is marked failed so its batch stops waiting and `on_failure` fires (#413), and an `:until_executed` uniqueness lock is released rather than orphaned (#423); each discard logs a warning naming the job and emits `pgbus.blocked_execution_discarded`. There is deliberately no cross-key "discard all parked": not losing work is the whole point of `:block`. Nothing is added to the enqueue or execute path. Refs #461.
44
+ - **A draining worker no longer strands its read-ahead claims.** The worker loop checked `can_process?` once to decide whether to return buffered claims, then read the lifecycle again to decide whether to exit. A drain (`graceful_shutdown`) or stop (`immediate_shutdown`) from another thread that landed between the two reads let the loop exit with the buffer still full (a pause does not exit the loop, so its claims go back on the next step), so those messages stayed invisible until their visibility timeout instead of going straight back to the queue (`vt: 0`). The worker now returns whatever is still buffered after the loop, however it exits. This was the intermittent `worker_read_ahead_spec.rb:217` failure (about 1 run in 20). Refs #486.
32
45
 
33
- - **PGMQ: vendored schema v1.13.0, and the upgrade migration now carries upstream's table fixups.** The 1.12.0 → 1.13.0 delta is entirely partitioned-queue work plus one metrics attribute: `pgmq.create_partitioned` gains a `premake` argument (its three-argument form dropped, so pgmq-ruby's three-argument call binds to the new signature with the default), new partitioned queues use `msg_id … GENERATED BY DEFAULT AS IDENTITY` rather than `GENERATED ALWAYS`, and `pgmq.metrics_result` gains `default_partition_length` (NULL for non-partitioned queues, ignored by pgmq-ruby, which reads rows by name). Nothing pgbus calls through `Pgbus::Client` changes, and pgbus creates only partitioned *archives*, whose `msg_id` is a plain column — so no client or pgmq-ruby change. Fresh embedded installs get 1.13.0; existing *embedded* installs: `rake pgbus:pgmq:status`, then `rails generate pgbus:upgrade_pgmq` + `rails db:migrate` (`rails db:migrate:pgbus` on a separate-database install). An `:extension` install is upgraded by PostgreSQL itself (`ALTER EXTENSION pgmq UPDATE`) once the server has the new version packaged — not by this migration, whose function drop refuses to remove extension-owned objects. **New:** because the upgrade works by dropping every pgmq function and composite type and re-running the target schema, it could never carry an upstream change to an existing *table*. The generated migration now reads the version in `pgbus_pgmq_schema_versions` and applies the fixup vendored for each version between it and the target (`lib/pgbus/pgmq_schema/fixups/pgmq_v<VERSION>.sql`, discovered by the same glob as the schema files, via `PgmqSchema.fixups_sql`). This hop's fixup is upstream's own guarded block moving existing partitioned queues to `GENERATED BY DEFAULT`; it selects on `pgmq.meta.is_partitioned`, so it is a no-op unless partitioned queues were created through PGMQ directly. Fixups must be idempotent — an install with no recorded version gets all of them. Closes #459.
46
+ ## [0.18.0] - 2026-10-12
34
47
 
35
- - **The batches list and a batch's progress panel now refresh themselves.** Batches was the only dashboard list whose `turbo-frame` was missing `data-auto-refresh`, and the batch detail page had no frame at all, so watching a batch drain meant reloading by hand. Both now poll on the existing `web_refresh_interval` (5s, pauses while you interact) like every other list — the batch progress panel is served by `GET /batches/:id?frame=progress`. This reuses the dashboard's turbo-frame polling rather than the SSE stack in `lib/pgbus/web/stream_app.rb`: that is the host-app-facing stream feature, and pointing it at the dashboard would open a PG `LISTEN` connection per viewer to move four integers.
48
+ ### Added
36
49
 
37
- - **`pgbus doctor` reports the Ruby JIT, and the boot banner names it.** A new "Ruby JIT" check is `:ok` when YJIT (or ZJIT) is on, when the Ruby has no JIT, or in a local (development/test) environment, and warns otherwise — never fails, never enables anything; turning YJIT on stays with Rails' `config.yjit`. The supervisor's first boot line now ends `jit=yjit|zjit|none`. Measured reason: with Postgres close by, a worker is CPU-bound under the GVL and YJIT is worth ~14–20 % jobs/s (`docs/performance.md`). Refs #484.
50
+ - **`config.streams_authorize`: the engine's stream endpoint can supply a connection context.** The engine mounts `Pgbus::Web::StreamApp.new` with no `authorize:` hook, so every connection was authorized with no context. Connection-driven presence (`streams_presence_patterns`) then joined nobody, and `visible_to:` predicates that need a context (they receive `nil`, so `user&.admin?` is falsy) hid every broadcast, unless the app mounted its own `StreamApp`. `config.streams_authorize = ->(env, stream_name) { … }` takes the same contract as `StreamApp.new(authorize:)`: a falsy return answers 403, `true` authorizes with no context, any other value (the current user, say) becomes the connection context. It is read per request, so the order of initializers and route loading doesn't matter. An explicit `StreamApp.new(authorize:)` wins; any `StreamApp` without one, the engine's included, uses the configured hook, and nil (the default) keeps today's behaviour. `validate!` rejects a non-callable. Closes #522.
38
51
 
39
- - **`rake bench:worker_profile`: jobs/s and where a job's time goes, for a real worker.** Drives a real `Pgbus::Process::Worker` against real PGMQ and splits each thread's time (Postgres wait, pool-checkout wait, waiting for work, GVL wait, and whose code was on-CPU) with vernier, locally and optionally behind toxiproxy-injected latency. `vernier` joins the test group; it is not a runtime dependency. The same change repairs `benchmarks/integration_bench.rb`, which no longer ran against 1.0's `ensures_uniqueness`. Refs #484.
52
+ ### Fixed
53
+
54
+ - **`pgbus:connected` now fires only once the connection is registered and its presence join has run.** `StreamApp` wrote the frame at hijack time, before the streamer's dispatcher had handled the connect, so a page that acted on its connection id as soon as it heard the event — `Pgbus.stream(x).presence.set_state(connection_id:)` from an action, for instance — found no row (`set_state` returned nil) and had to retry. The dispatcher now writes the frame at the end of the connect, after the archive replay, `registry.register` and the presence join attempt (a stream without presence, or a member resolver that returns nil, has no seat to join; a join that raises is logged and the frame still follows), through the connection's mutex-serialised writer (`Connection#write_connected` / `FalconConnection#write_connected`). The event name, its `connectionId` payload and the element's `connection-id` attribute are unchanged; listeners hear it after any replayed frames instead of before them, and a connection that dies during the replay never gets one. A repeated disconnect for an already-unregistered connection no longer records a second `disconnect` stream stat. Closes #521.
55
+ - **A closed tab leaves its presence seat at once, not on the next heartbeat.** The server never reads a hijacked SSE socket after the request, so a client that went away was noticed only when a heartbeat write failed: up to `streams_heartbeat_interval` later (15 s by default), and often a beat later still, because the first write after the client's FIN succeeds. Every viewer of a presence roster kept the closed tab for that long. A new `PeerCloseWatcher` thread per streamer waits on every live hijacked socket with `IO.select` and probes a readable one with a non-blocking read: EOF or a reset marks the connection dead, closes it and posts the same `DisconnectMessage` the heartbeat posts, so the seat leaves (and `streams_presence_on_change` fires once) within milliseconds on Puma and on Falcon's default hijack path; a connection closed within its first `streams_peer_close_interval` after opening is noticed within that interval. A TLS client that vanishes without close_notify (OpenSSL's or Puma MiniSSL's SSL error on read) counts as closed, the probe skips a socket while a write holds its mutex, and `#stop` wakes the select through a self-pipe so shutdown does not wait out the interval. The client sends nothing after its request, so a readable socket means a close; stray bytes are read and discarded. Falcon's experimental streaming-body path has no socket and keeps the heartbeat. New setting `streams_peer_close_interval` (1.0 s by default): how long a newly opened connection waits to join the watched set; `nil` turns the watcher off. Closes #519.
56
+
57
+ ## [0.17.3] - 2026-10-11
58
+
59
+ ### Added
60
+
61
+ - **Streams presence keeps one row per tab, with per-tab state, a change hook and automatic expiry (experimental).** Connection-driven presence (`streams_presence_patterns`) kept one row per member, so a user with two tabs open vanished from the room when either tab closed. On a migrated table it now keeps one row per SSE connection: `pgbus_presence_members` gains `connection_id`, `seat` (the client's per-tab id) and `state jsonb`. `<pgbus-stream-source presence>` (`pgbus_stream_from @room, presence: true`) mints one seat per document and sends it as `?seat=`; the stream app stores it on the connection and the dispatcher joins with `connection_id:` and `seat:`. New API: `Presence#join(member_id:, connection_id:, seat:, metadata:)`, `#leave(connection_id:)`, `#touch(connection_id:)` (one statement per stream, returning the ids it found), `#set_state(connection_id:, state:)` (merges into that row) and `.seats_supported?`; `#members` rows carry `connection_id`, `seat` and `state`. Member-keyed calls keep working on their own row. New settings: `streams_presence_on_change = ->(stream_name, change) { }`, called after each seat join, leave and expiry with `{ type:, connection_id:, seat:, member_id: }` (errors logged and swallowed), and `streams_presence_ttl` (seconds, or a pattern => seconds Hash with `:default`; 60 by default). The heartbeat touches live seats each tick, re-joins a live connection whose row was expired, and at most every ttl / 2 expires silent seats across all streams, including rows of crashed processes, reporting each as a `:leave`. On a seat table `sweep!` only expires member-keyed rows, and pgbus warns at boot when a ttl does not exceed `streams_heartbeat_interval`. **Upgrade:** `rails generate pgbus:add_presence_seats` (queued by `pgbus:update`; fresh `pgbus:add_presence` tables already have the columns). It replaces the `(stream_name, member_id)` unique index with partial unique indexes on `(stream_name, member_id) WHERE connection_id IS NULL` and `(stream_name, connection_id) WHERE connection_id IS NOT NULL`, and is reversible. Until it runs, presence behaves as before; a running process notices the migrated table within 30 seconds (at once on a member-keyed `join` outside a transaction). Closes #515.
40
62
 
41
63
  ### Changed
42
64
 
43
65
  - **perf: pgbus ships minified JavaScript with linked sourcemaps, and loads ApexCharts only on Insights.** The dashboard served readable sources and an unminified vendored Turbo 8.0.12, and loaded ApexCharts (575 KB) on every page. `scripts/build_frontend.js` (bun 1.4.2, run by `bundle exec rake frontend:js`) now writes a committed `<name>.min.js` + `<name>.min.js.map` (with `sourcesContent`) beside the stream element, `application.js`, `modules/charts.js` and `vendor/turbo.js`; Turbo keeps its license banners. The dashboard serves only the `.min.js` modules, and each one's sourcemap at `/pgbus/frontend/modules/<version>/<id>.map`. `charts.js` imports ApexCharts on its first draw through the dashboard importmap, so no other page downloads it, and the global `<script src=apexcharts>` and its `/pgbus/frontend/static/<version>/apexcharts.js` URL are gone. Host apps: the importmap pin for `pgbus/stream_source_element` now resolves to `pgbus/stream_source_element.min.js` (14.3 KB → 4.7 KB), whose `.min.js.map` is precompiled too; `javascript_include_tag "pgbus/stream_source_element"` still serves the readable source. No host app needs bun or Node. Measured with Lighthouse (desktop preset, `rake dummy:server`, main vs this branch on the same machine): `/pgbus` script 773,005 → 99,846 bytes (3 → 2 requests), page weight 870,302 → 197,143 bytes; `/pgbus/insights` script 778,330 → 678,436 bytes, page weight 871,128 → 771,228 bytes. Lighthouse's `unminified-javascript` now passes and is raised from `warn` to `error` in `lighthouserc.dashboard.json`. CI's lint job runs `rake frontend:js_check`, which fails when a committed `.min.js` is stale. Closes #517.
44
66
 
45
- - **The Jobs page leaves event-handler queues to the Events page.** `/pgbus/jobs` without a queue filter no longer lists EventBus handler-queue messages (they appeared on both pages), and its Retry All, Discard All and Discard All Enqueued skip handler queues: Retry All would have re-sent a copy of a failed event whose message still sits in its handler queue, running the handler twice. A handler queue's own queue page (`/pgbus/queues/:name`, `/pgbus/jobs?queue=`) still lists its messages. `DataSource#jobs` and the MCP `jobs` tool are unchanged. Refs #494.
67
+ ## [0.17.2] - 2026-10-10
46
68
 
47
- - **feat(web): the queue page shows the unified Jobs list and explains the queue in words.** `/pgbus/queues/:name` used to render its own message table: raw `vt`, timestamps and `read_ct` columns, no state, no reason, no pager, and Retry/Discard hidden inside the expansion. It now renders the same list as `/pgbus/jobs` (state tabs with counts, state badge, attempts, reason, row expansion, bulk discard, pager, auto-refresh), scoped to that physical queue. On a priority sub-table (`_pN`) the scope follows `DataSource#job_rows` as it is: blocked jobs of the logical queue appear on every level's page (they are parked before a level is chosen), and a failed event whose message sits in a sibling level can show as "no longer in queue". Tabs, the pager and the auto-refresh source stay on the queue's own URL. Above the list, a summary says why the queue is in its state: paused by an operator (with the reason) or by the circuit breaker (failures, when it resumes, trip number); which worker capsules drain it, or that none does; whether a healthy worker is alive; how many jobs are claimable now, how long the oldest has waited and how many are parked; and, for a `_pN` table, its priority level, with links to the other levels. The header's `|`-separated raw seconds become labelled metrics (`pgbus_duration`). A dead-letter queue page shows its summary and a link to the Dead Letter page instead of a list. "Discard All Enqueued" acts on every queue, so it is hidden on a queue page and on `/pgbus/jobs?queue=…`; Purge Queue covers that case. Retry, Discard, Discard Selected and Discard All Enqueued on the Jobs list now redirect back to the page they were clicked on. New `Web::DataSource#queue_pause_state` / `#queue_drainers` (in `DataSource::QueueSummary`) and the pure presenter `Web::QueueSummary`. `DataSource#jobs`, `#queue_detail`, `#queue_paused?` and `#queue_health_detail` and the MCP tools are unchanged. Closes #491.
69
+ ### Added
48
70
 
49
- - **Dashboard: one timestamp and duration presentation, translated, always in `Time.zone`.** Each page used to render time its own way: raw `Time#to_s` or ISO strings in expanded rows and on show pages, bare integer seconds for queue ages ("3600" and "3600s"), and hard-coded English "5m ago" / "in 2h". A new pure presenter, `Pgbus::Web::TimeFormat`, accepts every shape the data sources return (`Time`, `TimeWithZone`, `DateTime`, ISO or `Time#to_s` strings, epoch seconds, nil). It formats them in `Time.zone`, with every word from `pgbus.helpers.time` in all 12 locales. Lists now show `<time datetime="…UTC…" title="…absolute…">5m ago</time>`, so the exact value is in the tooltip. Expanded rows and show pages show the absolute followed by the relative ("2026-10-10 02:08:54 CEST (5m ago)"), and every age is a duration with units. Job reasons render their times as `<time>` through `_html` locale keys, so `%{error}` and the other arguments are still escaped. Relative times refresh with the existing auto-refresh frames; no new JavaScript. Two fixes come with it: a future `vt` on the queue page rendered as "-1d ago", and the scheduled-job ETA raised `NoMethodError` on a String time. Removed (engine-internal): `pgbus_time_ago`, `pgbus_time_ago_future`, `pgbus_job_eta`, and the `pgbus.outbox.index.stats.oldest_age` / `no_age` keys. The `pgbus.jobs.list.reasons.*` keys are now `*_html`. No `Web::DataSource` return shape changed. Two guards keep it that way: the RuboCop cop `Pgbus/DashboardTimeFormatting` flags `strftime`, `time_ago_in_words`, `distance_of_time_in_words`, `to_fs` and `l`/`localize` in `app/` and `lib/pgbus/web/` (outside `TimeFormat`). `spec/pgbus/web/time_conventions_spec.rb` does the same for the ERB views, which RuboCop cannot parse, and also fails on a raw `*_at` / `vt` / `*_age_sec` field printed without a helper. The rules are written down in `.claude/rules/dashboard.md` and `AGENTS.md`. The queue-age column headers lose their "(s)" suffix, since the cells now carry units. Closes #497.
71
+ - **The home page and Insights explain themselves.** The home page (`/pgbus`) showed numbers without words ("Failed / DLQ 5 / 3", a Throughput card that was always `0.0` on the web, Queue Health percentages coloured against thresholds it never stated). It now opens with a health verdict strip — the OK / DEGRADED / STALLED verdict and reasons `/readyz` and the MCP compute (`HealthAnalyzer`), with links to the page that explains them. Eight cards each say what their number means and link to the page that explains it: Queues (paused, dead-letter, holding jobs nothing drains), Enqueued (claimable now vs scheduled, running or retrying), Processes (healthy · stalled · stale), Recurring, Retrying (the Jobs page's Retrying count, when the next attempt runs, how many are due now, the newest error), Dead-lettered (the newest message's reason, read as the DLQ page reads it), Throughput (jobs/min over the last 5 minutes from job stats, or "needs job stats") and Parked. The queues table gains a Status column in words (the queue page's summary line: paused and why, nothing drains it, what is waiting). Queue Health states each threshold ("12% on pgmq.q_pgbus_events — vacuum due (warns above 10%, critical above 20%)"; the MVCC card names both its own 30 s / 60 s warnings and the verdict's 300 s) and its table always renders, with an empty state. Recent failures show the job and attempt and link to the failure. New `Web::DataSource#dashboard_signals` and `#queue_status_lines` (in `DataSource::DashboardSignals`) and the pure presenters `Web::HealthVerdict` and `Web::InsightsCharts`; `summary_stats`, `/pgbus/api/stats`, `/pgbus/api/metrics` and the AppSignal probe are unchanged. On Insights each chart has a caption sentence (also the chart's accessible name) and a `<details>` data table rendered on the server (at most 60 rows: long ranges roll minutes up), empty series show an empty state, and the page draws from series it embeds instead of fetching `/pgbus/api/insights` a second time (that endpoint is unchanged). Closes #492.
72
+ - **The Processes page says what each process is doing, why its status is what it is, and what happens next.** `/pgbus/processes` showed kind · host · pid · a Healthy/Stalled/Stale badge · "45s ago" · a dump of raw metadata chips; the thresholds behind the badge lived only in Ruby, and nothing said what the process was doing or what would happen to it. Each row now has Process · Status · Activity · Recycle · Heartbeat cells: the Status cell says why ("Loop last ticked 2m ago (limit 1m 30s)", "No heartbeat for 7m 0s (limit 5m 0s)", "Heartbeat 5s ago (limit 5m 0s) · stall detection off") and what happens next ("the supervisor's watchdog restarts it", "no supervisor to restart it", "probably dead; removed at the dispatcher's next sweep (within 5m 0s)", "no live dispatcher to remove it", or what stops advancing when the dispatcher or scheduler stalls); Activity says what it drains or consumes, busy threads (or async slots), buffered claims, jobs/s and failures/s, what a dispatcher, scheduler or outbox poller does, and what a supervisor supervises; Recycle states progress against each configured limit ("1,240 / 10,000 jobs · 310 / 512 MB · up 42m 0s / 1h 0m"); Heartbeat adds uptime. Children are listed under their supervisor ("under supervisor 100"), grouped only when that supervisor's row is on the same host, so a standalone `pgbus worker` is never claimed by a stranger. Clicking a process expands its start time and the metadata nothing else renders. The home page's Processes panel puts the same sentence under each badge. Status derivation moved from `DataSource` into the pure presenter `Web::ProcessState` (with `Web::ProcessActivity` for the activity and recycle words), so the badge and its sentence come from the same moment and limits; `DataSource#processes` keeps its row shape and adds `supervisor_pid` and `started_at`. **New heartbeat keys**, written once per 60 s beat and never per job: every process `started_at`; forked kinds `supervisor_pid`; workers and consumers the pool's `capacity`/`busy`, `buffered`, `memory_mb` (an RSS read through `MemoryUsage`'s 5 s cache — a `ps` subprocess per minute on macOS) and the recycle limits the process resolved (a capsule's own when set); consumers gain a metadata supplier with their handled count. No schema change. Because `capacity`/`busy` are now persisted, `pgbus_worker_pool_capacity` and `pgbus_worker_pool_busy` on `/pgbus/api/metrics` emit for the first time. A process from before this release shows "—" where a fact is missing. Closes #493.
73
+ - **The Events page says what state each event is in, why, and what happens next.** `/pgbus/events` had three tables that explained nothing: a pending list that faked columns inside one cell and showed a raw `vt` and `read_ct`, a processed list of `event_id · handler · processed_at`, and a subscriber list. It now has one pending-events list with real cells (ID · Event · Handler · State · Attempts · Next run / Reason · Actions), state tabs with counts (All / Ready / Scheduled / Handling / Retrying), a pager, auto-refresh and the Jobs page's row expansion. Every row names the handler its queue belongs to (the first subscriber registered on that queue) and says in words why it waits (events ahead, queue paused, no running consumer subscribes to its pattern, no healthy consumer, lease expired), that it is being handled (claimed when, lease expires when), or which attempt failed with which error and when the next one is due (or that the next read dead-letters it). Mark Handled, Discard, Reroute, Edit & Retry and Discard Selected keep working; an orphaned failed row (message gone) is discarded through its failed event. The processed-events audit says whether each claim completed, is being handled (claim refreshed recently) or went silent (the next delivery re-runs the handler), pages on its own `processed_page`, and offers **Replay** for a completed claim whose handler is still registered and whose archived message still carries a routing key its pattern matches. The subscriber list says whether a healthy consumer's topics cover each pattern and links to that handler's pending events. Internally the list is the Jobs list's SQL scoped to the handler queues: `DataSource#job_rows` / `#job_state_counts` take optional `queues:` and `exclude:` (additive; without them the SQL is unchanged), and `DataSource::EventList` + the pure presenter `Web::EventState` add the event words. `EventBus::Handler.claim_ownership_window` is now a class method. Closes #494.
74
+ - **The dead-letter queue says why every message died.** A job that exhausted `max_retries` was moved to `<queue>_dlq` and, in the same breath, `FailedEventRecorder.clear!` deleted the only row that knew what went wrong, so `/pgbus/dlq` could show only the payload and a "Reads" column counting reads *in the DLQ*. The mover (the ActiveJob executor or the event consumer) now reads the last recorded error before the move and writes a `pgbus_dead_letter` block into the DLQ copy's PGMQ headers (`Pgbus::DeadLetterHeader`): reason (`max_retries_exceeded`), source (`worker`/`consumer`), the physical source queue (a priority sub-queue included), attempts, `max_retries`, `dead_lettered_at`, and the last error's class, message (1,000 chars), backtrace head (10 lines) and the attempt it came from. Existing headers (trace ids, `x-pgmq-group`) are kept. `Client#move_to_dead_letter` takes `headers:`. The DLQ page is a real table (ID · Job · Source queue · Died · Attempts · Reason · Actions) whose Reason cell says in words what killed the message; the row expansion shows the full error, the backtrace head and which later attempts recorded nothing; chips filter by DLQ (`?dlq=`) with counts and clicking an error class filters by it (`?error_class=`); `/pgbus/dlq/:id` opens with a "Why it died" card. Retrying from the DLQ strips the block (a live message must not claim to be dead) and carries `pgbus_dlq_retries`, so a second death reads "retried from the DLQ once before". Messages dead-lettered before this release read "Reason not recorded (dead-lettered before pgbus 0.18.0)" and keep working everywhere. `pgbus_dlq`/`pgbus_dlq_detail` (MCP) gain a `dead_letter` summary (error message and backtrace only when payloads are allowed); `pgbus dlq list` gains a REASON column and `pgbus dlq show` prints died, attempts, source, reason and error. `pgbus.job_dead_lettered` carries `error:` (the class). No schema change. The executor's success path is untouched: `executor_bench` main vs branch, same machine back to back, 64.6k vs 66.7k i/s (noise) and 6,448 B / 68 objects per job on both. Event handler errors appear in the block once the consumer records them (#494). Closes #495.
75
+ - **docs: a "Sizing capsules" page on the docs site** (Operations, `/docs/sizing-capsules`). It covers when to give a queue threads, fibers, `processes:` or a capsule of its own: how to tell a CPU-bound queue (symptoms, plus the `gvl_wait` baseline from `rake bench:worker_profile`), a decision table, use cases with before/after configs (rendering fan-out, a memory-heavy export, duplicated capsules, a bulk sweep starving latency-sensitive work, and the counter-example of work in an external binary), the memory and connection math for a job host, and a checklist for moving off the workarounds. The README's "Threads vs processes" section and the docs site's Running workers page link to it. Closes #505.
76
+ - **Capsules take `processes: N` for CPU-bound queues, and their own recycle limits.** A capsule was one forked process with `threads:` threads; threads share one GVL, so a `threads: 8` capsule running CPU-bound jobs (rendering, reports, PDFs) used about one core, and the only ways to more cores were several supervisors per host or sharding the queue. `c.capsule :render, queues: %w[render], threads: 1, processes: 4` now forks four identical workers reading the same queues. Each fork has its own slot — restart backoff, liveness pipe, `pgbus_processes` row, and a wake pipe when the supervisor-scope NotifyHub is active — so a crashed fork restarts alone; readiness counts every fork; the boot banner prints `processes=N`; worker heartbeat metadata gains `capsule` and `process` (`"2/4"`), which the dashboard's Processes page shows; and `pgbus doctor`'s connection budget counts each fork under `worker_notify_scope: :fork`. The default is 1, so every existing config boots unchanged, and the queue-overlap rule is untouched (the forks belong to one capsule). A capsule may also set `max_memory_mb:`, `max_jobs_per_worker:` and `max_worker_lifetime:`, which recycle only that capsule's workers and fall back to the global values when unset; all are validated at boot. README and the docs site gain a "Threads vs processes" section with the memory and connection math. Not in this change: `processes: :auto`, GVL-contention observability, and a string-DSL syntax for `processes`. Closes #503.
77
+ - **feat(web): every growing dashboard list has a pager, every button goes through one helper, and mobile card labels follow the locale.** Outbox, Batches, both Locks tables and Recurring tasks now render the shared pager. Before, Batches silently dropped the 101st batch, Locks and concurrency keys stopped at 100, Recurring tasks loaded every row, and Outbox fetched pages but had no links to them. Totals come from a bounded count (`Web::DataSource#list_count`, a `LIMIT 10_001` probe; past the cap the pager says "10,000+"). The two Locks tables page independently on `page` and `keys_page`. Framed lists advance the URL, so the auto-refresh stays on the page you opened. The DataSource readers take `page:`/`per_page:` with defaults that keep today's output. The `pgbus_locks`, `pgbus_concurrency` and `pgbus_recurring` MCP tools accept optional `page`/`per_page` (max 100). Omitting them returns the same rows as before; the response now also carries `page`, `per_page`, `total` and `has_more`. `Pgbus::ButtonHelper` (`pgbus_button_to`, `pgbus_link_to`, `pgbus_button_tag`, `pgbus_button_classes`) replaces about 56 hand-written class strings. Each variant has light and dark colours, a focus ring and a target of at least 24 px (WCAG 2.5.8); row-action links used to be about 16 px tall. Labels and confirms did not change. Every table cell's `data-label` (the label on the card shown below 1024 px) now reuses its column header's translation, so a German operator no longer sees English labels at phone width. Empty states go through `shared/_empty_row`. `spec/pgbus/web/view_conventions_spec.rb` pins all of this, and the contrast and compiled-CSS guards now scan every helper file. Closes #496.
78
+ - **Dashboard quality gates: an axe WCAG 2.1 AA gate in light and dark mode, monthly Lighthouse budgets, one stub data source.** `spec/system/accessibility_spec.rb` runs axe-core 4.13 (from `node_modules`, no gem) on every dashboard page — dashboard, queues + detail, jobs with every state tab and an expanded row, job detail, recurring + detail, processes, events + detail, batches + detail, DLQ + detail, outbox, locks, insights — in both themes, against rich sample data and against the empty states (tags `wcag2a`, `wcag2aa`, `wcag21aa`, and `wcag22aa` for target size, which Lighthouse scores too); any violation fails the system job, and there is no way to accept one. The page list is `lighthouserc.dashboard.json`, shared with Lighthouse, and a route-coverage example fails when an HTML GET route of the engine has no audited URL. Dark mode is entered the way a user enters it (stored preference, fresh page), so the body's colour transition never runs under the audit. Lighthouse runs monthly and on demand (`.github/workflows/lighthouse.yml`, `lhci collect → upload → assert`, score table in the job summary, 30-day artifact) and never gates a merge; `bin/lighthouse` runs it locally against `rake dummy:server`. The dummy server now uses `Pgbus::Test::StubDataSource#fill_sample_data!` — the same rows the gate audits — instead of its own stub, which lacked 12 methods and answered 500 on `/pgbus/events`, `/pgbus/dlq` and `/pgbus/locks`; Events, Outbox and the Insights tables now render with data there. The static contrast guard learned the shades the browser caught. `AGENTS.md`, `.claude/rules/dashboard.md`, `.claude/rules/testing.md` and `/lfg` say how a dashboard change is tested. Closes #498.
79
+ - **feat(web): the Jobs page is one list that says why each job is there and when it will run.** The old page had two unrelated tables — "Failed Jobs" and "Enqueued Jobs" — that listed a retrying job twice (its failed-event row and its queue message) and never said what state a job was in. `/jobs` is now a single table with the same columns for every row (ID · Job · Queue · State · Attempts · Next run / Reason · Actions), filter tabs with counts (All / Ready / Scheduled / Running / Retrying / Blocked) and a pager. Each row says in words why it is waiting: scheduled with its run time; ready with the number of jobs ahead of it, or the reason nothing will pick it up (queue paused, no worker drains the queue, no healthy worker); running with when it was claimed and when its lease expires; retrying with the failed attempt, the error and the next attempt (or that the next read dead-letters it); blocked by `limits_concurrency` with its key and slot usage, linked to the Locks page. Failed-event rows whose message has left its queue show as retrying ("message no longer in queue"). The state is derived in SQL (new `Web::DataSource#job_rows` / `#job_state_counts` / `#jobs_ahead`, in `DataSource::JobList`) so tabs, counts and paging agree; tab counts are capped at 10k per queue so a huge queue never forces a full scan on auto-refresh. `DataSource#jobs` and `#failed_events` are unchanged. The dashboard's "failed jobs" links (`/jobs?status=failed`) land on the Retrying tab. **Dark mode:** every dashboard page now pairs each light colour with a `dark:` variant (hostnames, PIDs, headers and badges were near-invisible on dark cards), rows no longer paint a darker stripe inside their card, and the empty-state colspans match their tables. A static spec (`spec/pgbus/web/dark_mode_contrast_spec.rb`) keeps it that way. Closes #489.
50
80
 
81
+ ### Changed
82
+
83
+ - **The Jobs page leaves event-handler queues to the Events page.** `/pgbus/jobs` without a queue filter no longer lists EventBus handler-queue messages (they appeared on both pages), and its Retry All, Discard All and Discard All Enqueued skip handler queues: Retry All would have re-sent a copy of a failed event whose message still sits in its handler queue, running the handler twice. A handler queue's own queue page (`/pgbus/queues/:name`, `/pgbus/jobs?queue=`) still lists its messages. `DataSource#jobs` and the MCP `jobs` tool are unchanged. Refs #494.
84
+ - **feat(web): the queue page shows the unified Jobs list and explains the queue in words.** `/pgbus/queues/:name` used to render its own message table: raw `vt`, timestamps and `read_ct` columns, no state, no reason, no pager, and Retry/Discard hidden inside the expansion. It now renders the same list as `/pgbus/jobs` (state tabs with counts, state badge, attempts, reason, row expansion, bulk discard, pager, auto-refresh), scoped to that physical queue. On a priority sub-table (`_pN`) the scope follows `DataSource#job_rows` as it is: blocked jobs of the logical queue appear on every level's page (they are parked before a level is chosen), and a failed event whose message sits in a sibling level can show as "no longer in queue". Tabs, the pager and the auto-refresh source stay on the queue's own URL. Above the list, a summary says why the queue is in its state: paused by an operator (with the reason) or by the circuit breaker (failures, when it resumes, trip number); which worker capsules drain it, or that none does; whether a healthy worker is alive; how many jobs are claimable now, how long the oldest has waited and how many are parked; and, for a `_pN` table, its priority level, with links to the other levels. The header's `|`-separated raw seconds become labelled metrics (`pgbus_duration`). A dead-letter queue page shows its summary and a link to the Dead Letter page instead of a list. "Discard All Enqueued" acts on every queue, so it is hidden on a queue page and on `/pgbus/jobs?queue=…`; Purge Queue covers that case. Retry, Discard, Discard Selected and Discard All Enqueued on the Jobs list now redirect back to the page they were clicked on. New `Web::DataSource#queue_pause_state` / `#queue_drainers` (in `DataSource::QueueSummary`) and the pure presenter `Web::QueueSummary`. `DataSource#jobs`, `#queue_detail`, `#queue_paused?` and `#queue_health_detail` and the MCP tools are unchanged. Closes #491.
85
+ - **Dashboard: one timestamp and duration presentation, translated, always in `Time.zone`.** Each page used to render time its own way: raw `Time#to_s` or ISO strings in expanded rows and on show pages, bare integer seconds for queue ages ("3600" and "3600s"), and hard-coded English "5m ago" / "in 2h". A new pure presenter, `Pgbus::Web::TimeFormat`, accepts every shape the data sources return (`Time`, `TimeWithZone`, `DateTime`, ISO or `Time#to_s` strings, epoch seconds, nil). It formats them in `Time.zone`, with every word from `pgbus.helpers.time` in all 12 locales. Lists now show `<time datetime="…UTC…" title="…absolute…">5m ago</time>`, so the exact value is in the tooltip. Expanded rows and show pages show the absolute followed by the relative ("2026-10-10 02:08:54 CEST (5m ago)"), and every age is a duration with units. Job reasons render their times as `<time>` through `_html` locale keys, so `%{error}` and the other arguments are still escaped. Relative times refresh with the existing auto-refresh frames; no new JavaScript. Two fixes come with it: a future `vt` on the queue page rendered as "-1d ago", and the scheduled-job ETA raised `NoMethodError` on a String time. Removed (engine-internal): `pgbus_time_ago`, `pgbus_time_ago_future`, `pgbus_job_eta`, and the `pgbus.outbox.index.stats.oldest_age` / `no_age` keys. The `pgbus.jobs.list.reasons.*` keys are now `*_html`. No `Web::DataSource` return shape changed. Two guards keep it that way: the RuboCop cop `Pgbus/DashboardTimeFormatting` flags `strftime`, `time_ago_in_words`, `distance_of_time_in_words`, `to_fs` and `l`/`localize` in `app/` and `lib/pgbus/web/` (outside `TimeFormat`). `spec/pgbus/web/time_conventions_spec.rb` does the same for the ERB views, which RuboCop cannot parse, and also fails on a raw `*_at` / `vt` / `*_age_sec` field printed without a helper. The rules are written down in `.claude/rules/dashboard.md` and `AGENTS.md`. The queue-age column headers lose their "(s)" suffix, since the cells now carry units. Closes #497.
51
86
  - **CI now runs every spec under `spec/`.** The Ruby × Rails matrix job ran only `spec/pgbus/` and `spec/generators/`, so the 17 dashboard/API request specs, the i18n-tasks checks across all 12 locales, the custom RuboCop cop specs, `spec/pgbus_spec.rb` and the worker-profile harness spec ran only on a developer's machine — a PR could break a controller or ship a missing translation and still go green. All of them are DB- and browser-free and now run on every matrix leg, so the request specs exercise the controllers on Rails 7.1 and 8.x. A new static spec, `spec/pgbus/ci_coverage_spec.rb`, parses the `rspec` paths out of `.github/workflows/main.yml` and fails when any spec file outside `spec/dummy` and `spec/support` is not run by a CI job (option values such as `-I spec` are not counted as paths), or when the workflow names a spec path that does not exist. Closes #499.
52
87
 
53
- - **perf(worker, consumer): opt-in read-ahead, so one read feeds many jobs when Postgres is on another host.** A worker read `qty = free threads`; once its pool was busy, threads freed one at a time, so it made one read round trip per job. Under +1 ms of latency that capped a 12-thread worker at ~1.3–1.7 k no-op jobs/s however many threads it had (#484), and the event consumer's loop had the same shape. New `config.read_ahead` (default **0**, off; overridable per capsule and per `event_consumers` entry with `read_ahead:`) lets either loop claim up to N messages beyond its free threads. Claimed messages wait in a new `Process::ClaimBuffer`, kept invisible by a new `VisibilityHeartbeat.hold`, until a thread frees. They are handed back to the queue (`set_vt` 0) the moment the worker drains, recycles, pauses or stops, or the consumer shuts down or recycles, with `read_ct` already counted. Buffered claims count as in flight, so `prefetch_limit` still caps everything claimed. Measured with `rake bench:worker_profile` (12 threads, no-op jobs, +1 ms per round trip, rounds 2–3 of three interleaved rounds on a loaded machine): **worker 1 286–1 731 → 2 300–2 462 jobs/s, consumer 1 326–1 779 → 2 288–2 599** at `read_ahead` 12. Locally both are within noise, because there the worker is CPU-bound under the GVL; `read_ahead` 0 overlaps the baseline's run-to-run range in 7 of 8 cells; consumer / proxied / no JIT sits 2–18 % below, plausibly load noise but unproven. A returned message's next claimant logs it as a zombie redelivery when `zombie_detection` is on, and `validate!` warns when `read_ahead > 0` meets `visibility_heartbeat = false`, since nothing then keeps a buffered message invisible. With read-ahead on, the next ceiling under latency is the connection pool (checkout wait 2–3 % → 14–15 %), so raise `pool_size` with it. `VisibilityHeartbeat`'s unregister now drops only the entry it registered, so a stale hold released after a job's own tracking took over cannot drop that job's heartbeat. Worker heartbeat metadata gains `"buffered"`. Refs #486.
88
+ ### Fixed
54
89
 
55
- - **perf(executor): a successful first delivery no longer deletes from `pgbus_failed_events`.** Every successful job ran a `DELETE … WHERE queue_name = $1 AND msg_id = $2` through ActiveRecord, but only a failed attempt ever writes that table, so a job on its first delivery cannot have a row. The DELETE now runs only on redelivery (`read_ct > 1`), still clearing the row a failed attempt left. Measured with `rake bench:worker_profile` (12 threads, no-op jobs, local Postgres): **4 569 → 5 823 jobs/s with YJIT (+27 %), 3 797 → 5 119 without (+35 %)**; under +1 ms injected latency the change is within noise, because there the worker's single reader is the ceiling. This is a per-job query removed, not faster job code. The executor's debug tag is also built only when debug logging is on (9 → 8 pgbus-owned objects per job, now a hard budget). `Client#drop_queue` now also deletes the dropped queue's `pgbus_failed_events` rows: PGMQ restarts `msg_id`s when a queue is recreated, and a surviving row would point the dashboard's retry/discard at an unrelated new message — previously a first-delivery success happened to sweep such a row, and that sweep is gone. Refs #484.
90
+ - **Insights charts are readable in dark mode.** The charts were themed only through `foreColor`: in dark mode the donut kept white slice separators, axis borders and the crosshair label stayed light, and the "No data" fallback was `text-gray-400 dark:text-gray-500` (below WCAG AA, and invisible to the contrast guard because it lived in JS). `charts.js` now themes every chart from one `palette(isDark)` (theme mode, axis and grid colours, donut separators in the card colour, legend and tooltip, animations off under `prefers-reduced-motion`) and redraws on the dark-mode toggle without a reload. The latency headers and chart titles no longer carry "(ms)" / "(jobs/min)"; the P95 latency card's yellow is now AA. The static contrast guard scans the dashboard's JS modules too. Refs #492.
91
+ - **The dashboard home page reads the process rows and the stream registry once per render.** `DataSource` memoizes the raw process rows, `stream_queue_names` and `queue_health_stats` per instance (cleared by `reset_cache!`, as `queues_with_metrics` already was), so the stats, the verdict and the panels share one query each. `HealthApp` now calls `reset_cache!` on an injected DataSource before every `/readyz` check, so a long-lived one never answers with the first check's facts (it already did that for queues). Refs #492.
92
+ - **A failed event handler now leaves a trace, like a failed job.** `Process::Consumer` only logged a handler error, tripped the circuit breaker and recorded a stat, so `pgbus_failed_events` never heard of it: the Jobs page showed a failed-and-waiting event as Ready ("lease expired, worker likely died") or Running, no page could say which attempt failed with what error, and a dead-lettered event's DLQ block had no error. The consumer now records the failure (logical queue name, `retry_count` = attempt − 1), clears it when a redelivery succeeds (only when `read_ct > 1`, so a first-delivery success adds no query, which a unit spec pins; `WP_BENCH_ROLE=consumer rake bench:worker_profile`, main vs branch interleaved three times on the same machine under load average ~17, gave 1,120–4,932 vs 2,120–3,197 jobs/s: no difference measurable through that noise), and clears it after the dead-letter move (read the error, move, then clear). The dashboard's Discard, Mark Handled, Edit & Retry and Reroute clear an event's failed row with its message. Refs #494.
93
+ - **Replay of a processed event works.** `DataSource#replay_event` published `event["payload"] || "{}"` to a topic named after the handler class (`pgbus_processed_events` has neither a payload nor a routing key), and no page offered it. It now finds the event's archived envelope in its handler queue's archive and re-delivers it to that handler only (not to every subscriber of the routing key), under a new `event_id` with `replayed_from` set to the original, so the idempotency claim is new and the handler runs again. The dashboard offers Replay only when the handler is still registered and the archive still holds the event (`archive_retention`), and otherwise says which of the two is missing. Refs #494.
94
+ - **fix(web): the recurring task toggle buttons had no dark-mode colours, and the pager's disabled Previous/Next failed contrast.** The Enable/Disable buttons built their class in a Ruby string concatenation that the static contrast guard could not read, so `bg-yellow-100`/`bg-green-100` glowed on dark cards. They now use the button helper, and the guard reads class strings built from `"…" \ "…"` continuations and `#{…}` ternaries. The shared pager's disabled span was gray-400 on gray-100 (axe colour-contrast); it is now gray-600 / dark gray-400 and marked `aria-disabled`. Refs #496.
95
+ - **Dashboard contrast and markup fixes (WCAG 2.1 AA, both themes).** The Queues list's Pause/Resume/Purge/Delete buttons are now 24px tall (WCAG 2.2 target size), and the Insights time-range switcher is a `div role="group"` rather than a `nav` with an invalid role. The queue detail Pause button and the Events Reroute button were white on yellow (1.9:1) and now carry dark text on `yellow-400`; process metadata chips were grey on grey in both themes; the batches "failed" count (`red-500`), the "tables need vacuum" note (`amber-500`), the "view tasks" link (`blue-500`), the queue list's Resume/Pause links (`green-600`/`yellow-600`), amber/green table cells and link hover colours (`indigo-500`) were below 4.5:1 on white and move to `-700`/`-800`; the indigo job-state badge and the Jobs list's row actions were below 4.5:1 in dark mode and move to `-300`. The Outbox and Insights stat cards now wrap their `<dt>`/`<dd>` in a `<dl>`, and the Insights section headings are `<h2>` under the page `<h1>` instead of skipping a level. No copy changes. Refs #498.
56
96
 
57
- ### Fixed
97
+ ## [0.17.1] - 2026-10-06
58
98
 
59
- - **A draining worker no longer strands its read-ahead claims.** The worker loop checked `can_process?` once to decide whether to return buffered claims, then read the lifecycle again to decide whether to exit. A drain (`graceful_shutdown`) or stop (`immediate_shutdown`) from another thread that landed between the two reads let the loop exit with the buffer still full (a pause does not exit the loop, so its claims go back on the next step), so those messages stayed invisible until their visibility timeout instead of going straight back to the queue (`vt: 0`). The worker now returns whatever is still buffered after the loop, however it exits. This was the intermittent `worker_read_ahead_spec.rb:217` failure (about 1 run in 20). Refs #486.
99
+ ### Added
60
100
 
61
- - **`pgbus:connected` now fires only once the connection is registered and its presence join has run.** `StreamApp` wrote the frame at hijack time, before the streamer's dispatcher had handled the connect, so a page that acted on its connection id as soon as it heard the event — `Pgbus.stream(x).presence.set_state(connection_id:)` from an action, for instance — found no row (`set_state` returned nil) and had to retry. The dispatcher now writes the frame at the end of the connect, after the archive replay, `registry.register` and the presence join attempt (a stream without presence, or a member resolver that returns nil, has no seat to join; a join that raises is logged and the frame still follows), through the connection's mutex-serialised writer (`Connection#write_connected` / `FalconConnection#write_connected`). The event name, its `connectionId` payload and the element's `connection-id` attribute are unchanged; listeners hear it after any replayed frames instead of before them, and a connection that dies during the replay never gets one. A repeated disconnect for an already-unregistered connection no longer records a second `disconnect` stream stat. Closes #521.
101
+ - **`rake bench:worker_profile` measures the event consumer too.** A consumer cell drives a real `Process::Consumer` over an event-bus topic queue (plain handler, so the transport is measured, not the dedup table) with the same harness as the worker cell. `WP_BENCH_ROLE` (`worker`, `consumer`, `both`) and `WP_BENCH_READ_AHEAD` (passed as `read_ahead:`) are new knobs. Refs #486.
102
+ - **`pgbus doctor` reports the Ruby JIT, and the boot banner names it.** A new "Ruby JIT" check is `:ok` when YJIT (or ZJIT) is on, when the Ruby has no JIT, or in a local (development/test) environment, and warns otherwise — never fails, never enables anything; turning YJIT on stays with Rails' `config.yjit`. The supervisor's first boot line now ends `jit=yjit|zjit|none`. Measured reason: with Postgres close by, a worker is CPU-bound under the GVL and YJIT is worth ~14–20 % jobs/s (`docs/performance.md`). Refs #484.
103
+ - **`rake bench:worker_profile`: jobs/s and where a job's time goes, for a real worker.** Drives a real `Pgbus::Process::Worker` against real PGMQ and splits each thread's time (Postgres wait, pool-checkout wait, waiting for work, GVL wait, and whose code was on-CPU) with vernier, locally and optionally behind toxiproxy-injected latency. `vernier` joins the test group; it is not a runtime dependency. The same change repairs `benchmarks/integration_bench.rb`, which no longer ran against 1.0's `ensures_uniqueness`. Refs #484.
62
104
 
63
- - **A closed tab leaves its presence seat at once, not on the next heartbeat.** The server never reads a hijacked SSE socket after the request, so a client that went away was noticed only when a heartbeat write failed: up to `streams_heartbeat_interval` later (15 s by default), and often a beat later still, because the first write after the client's FIN succeeds. Every viewer of a presence roster kept the closed tab for that long. A new `PeerCloseWatcher` thread per streamer waits on every live hijacked socket with `IO.select` and probes a readable one with a non-blocking read: EOF or a reset marks the connection dead, closes it and posts the same `DisconnectMessage` the heartbeat posts, so the seat leaves (and `streams_presence_on_change` fires once) within milliseconds on Puma and on Falcon's default hijack path; a connection closed within its first `streams_peer_close_interval` after opening is noticed within that interval. A TLS client that vanishes without close_notify (OpenSSL's or Puma MiniSSL's SSL error on read) counts as closed, the probe skips a socket while a write holds its mutex, and `#stop` wakes the select through a self-pipe so shutdown does not wait out the interval. The client sends nothing after its request, so a readable socket means a close; stray bytes are read and discarded. Falcon's experimental streaming-body path has no socket and keeps the heartbeat. New setting `streams_peer_close_interval` (1.0 s by default): how long a newly opened connection waits to join the watched set; `nil` turns the watcher off. Closes #519.
105
+ ### Changed
64
106
 
65
- - **Insights charts are readable in dark mode.** The charts were themed only through `foreColor`: in dark mode the donut kept white slice separators, axis borders and the crosshair label stayed light, and the "No data" fallback was `text-gray-400 dark:text-gray-500` (below WCAG AA, and invisible to the contrast guard because it lived in JS). `charts.js` now themes every chart from one `palette(isDark)` (theme mode, axis and grid colours, donut separators in the card colour, legend and tooltip, animations off under `prefers-reduced-motion`) and redraws on the dark-mode toggle without a reload. The latency headers and chart titles no longer carry "(ms)" / "(jobs/min)"; the P95 latency card's yellow is now AA. The static contrast guard scans the dashboard's JS modules too. Refs #492.
107
+ - **perf(worker, consumer): opt-in read-ahead, so one read feeds many jobs when Postgres is on another host.** A worker read `qty = free threads`; once its pool was busy, threads freed one at a time, so it made one read round trip per job. Under +1 ms of latency that capped a 12-thread worker at ~1.3–1.7 k no-op jobs/s however many threads it had (#484), and the event consumer's loop had the same shape. New `config.read_ahead` (default **0**, off; overridable per capsule and per `event_consumers` entry with `read_ahead:`) lets either loop claim up to N messages beyond its free threads. Claimed messages wait in a new `Process::ClaimBuffer`, kept invisible by a new `VisibilityHeartbeat.hold`, until a thread frees. They are handed back to the queue (`set_vt` 0) the moment the worker drains, recycles, pauses or stops, or the consumer shuts down or recycles, with `read_ct` already counted. Buffered claims count as in flight, so `prefetch_limit` still caps everything claimed. Measured with `rake bench:worker_profile` (12 threads, no-op jobs, +1 ms per round trip, rounds 2–3 of three interleaved rounds on a loaded machine): **worker 1 286–1 731 → 2 300–2 462 jobs/s, consumer 1 326–1 779 → 2 288–2 599** at `read_ahead` 12. Locally both are within noise, because there the worker is CPU-bound under the GVL; `read_ahead` 0 overlaps the baseline's run-to-run range in 7 of 8 cells; consumer / proxied / no JIT sits 2–18 % below, plausibly load noise but unproven. A returned message's next claimant logs it as a zombie redelivery when `zombie_detection` is on, and `validate!` warns when `read_ahead > 0` meets `visibility_heartbeat = false`, since nothing then keeps a buffered message invisible. With read-ahead on, the next ceiling under latency is the connection pool (checkout wait 2–3 % → 14–15 %), so raise `pool_size` with it. `VisibilityHeartbeat`'s unregister now drops only the entry it registered, so a stale hold released after a job's own tracking took over cannot drop that job's heartbeat. Worker heartbeat metadata gains `"buffered"`. Refs #486.
108
+ - **perf(executor): a successful first delivery no longer deletes from `pgbus_failed_events`.** Every successful job ran a `DELETE … WHERE queue_name = $1 AND msg_id = $2` through ActiveRecord, but only a failed attempt ever writes that table, so a job on its first delivery cannot have a row. The DELETE now runs only on redelivery (`read_ct > 1`), still clearing the row a failed attempt left. Measured with `rake bench:worker_profile` (12 threads, no-op jobs, local Postgres): **4 569 → 5 823 jobs/s with YJIT (+27 %), 3 797 → 5 119 without (+35 %)**; under +1 ms injected latency the change is within noise, because there the worker's single reader is the ceiling. This is a per-job query removed, not faster job code. The executor's debug tag is also built only when debug logging is on (9 → 8 pgbus-owned objects per job, now a hard budget). `Client#drop_queue` now also deletes the dropped queue's `pgbus_failed_events` rows: PGMQ restarts `msg_id`s when a queue is recreated, and a surviving row would point the dashboard's retry/discard at an unrelated new message — previously a first-delivery success happened to sweep such a row, and that sweep is gone. Refs #484.
66
109
 
67
- - **The dashboard home page reads the process rows and the stream registry once per render.** `DataSource` memoizes the raw process rows, `stream_queue_names` and `queue_health_stats` per instance (cleared by `reset_cache!`, as `queues_with_metrics` already was), so the stats, the verdict and the panels share one query each. `HealthApp` now calls `reset_cache!` on an injected DataSource before every `/readyz` check, so a long-lived one never answers with the first check's facts (it already did that for queues). Refs #492.
110
+ ## [0.17.0] - 2026-09-30
68
111
 
69
- - **A failed event handler now leaves a trace, like a failed job.** `Process::Consumer` only logged a handler error, tripped the circuit breaker and recorded a stat, so `pgbus_failed_events` never heard of it: the Jobs page showed a failed-and-waiting event as Ready ("lease expired, worker likely died") or Running, no page could say which attempt failed with what error, and a dead-lettered event's DLQ block had no error. The consumer now records the failure (logical queue name, `retry_count` = attempt − 1), clears it when a redelivery succeeds (only when `read_ct > 1`, so a first-delivery success adds no query, which a unit spec pins; `WP_BENCH_ROLE=consumer rake bench:worker_profile`, main vs branch interleaved three times on the same machine under load average ~17, gave 1,120–4,932 vs 2,120–3,197 jobs/s: no difference measurable through that noise), and clears it after the dead-letter move (read the error, move, then clear). The dashboard's Discard, Mark Handled, Edit & Retry and Reroute clear an event's failed row with its message. Refs #494.
112
+ ### Added
70
113
 
71
- - **Replay of a processed event works.** `DataSource#replay_event` published `event["payload"] || "{}"` to a topic named after the handler class (`pgbus_processed_events` has neither a payload nor a routing key), and no page offered it. It now finds the event's archived envelope in its handler queue's archive and re-delivers it to that handler only (not to every subscriber of the routing key), under a new `event_id` with `replayed_from` set to the original, so the idempotency claim is new and the handler runs again. The dashboard offers Replay only when the handler is still registered and the archive still holds the event (`archive_retention`), and otherwise says which of the two is missing. Refs #494.
114
+ - **PGMQ: vendored schema v1.13.0, and the upgrade migration now carries upstream's table fixups.** The 1.12.0 → 1.13.0 delta is entirely partitioned-queue work plus one metrics attribute: `pgmq.create_partitioned` gains a `premake` argument (its three-argument form dropped, so pgmq-ruby's three-argument call binds to the new signature with the default), new partitioned queues use `msg_id … GENERATED BY DEFAULT AS IDENTITY` rather than `GENERATED ALWAYS`, and `pgmq.metrics_result` gains `default_partition_length` (NULL for non-partitioned queues, ignored by pgmq-ruby, which reads rows by name). Nothing pgbus calls through `Pgbus::Client` changes, and pgbus creates only partitioned *archives*, whose `msg_id` is a plain column — so no client or pgmq-ruby change. Fresh embedded installs get 1.13.0; existing *embedded* installs: `rake pgbus:pgmq:status`, then `rails generate pgbus:upgrade_pgmq` + `rails db:migrate` (`rails db:migrate:pgbus` on a separate-database install). An `:extension` install is upgraded by PostgreSQL itself (`ALTER EXTENSION pgmq UPDATE`) once the server has the new version packaged — not by this migration, whose function drop refuses to remove extension-owned objects. **New:** because the upgrade works by dropping every pgmq function and composite type and re-running the target schema, it could never carry an upstream change to an existing *table*. The generated migration now reads the version in `pgbus_pgmq_schema_versions` and applies the fixup vendored for each version between it and the target (`lib/pgbus/pgmq_schema/fixups/pgmq_v<VERSION>.sql`, discovered by the same glob as the schema files, via `PgmqSchema.fixups_sql`). This hop's fixup is upstream's own guarded block moving existing partitioned queues to `GENERATED BY DEFAULT`; it selects on `pgmq.meta.is_partitioned`, so it is a no-op unless partitioned queues were created through PGMQ directly. Fixups must be idempotent — an install with no recorded version gets all of them. Closes #459.
72
115
 
73
- - **fix(web): the recurring task toggle buttons had no dark-mode colours, and the pager's disabled Previous/Next failed contrast.** The Enable/Disable buttons built their class in a Ruby string concatenation that the static contrast guard could not read, so `bg-yellow-100`/`bg-green-100` glowed on dark cards. They now use the button helper, and the guard reads class strings built from `"…" \ "…"` continuations and `#{…}` ternaries. The shared pager's disabled span was gray-400 on gray-100 (axe colour-contrast); it is now gray-600 / dark gray-400 and marked `aria-disabled`. Refs #496.
116
+ ## [0.16.7] - 2026-09-18
74
117
 
75
- - **Dashboard contrast and markup fixes (WCAG 2.1 AA, both themes).** The Queues list's Pause/Resume/Purge/Delete buttons are now 24px tall (WCAG 2.2 target size), and the Insights time-range switcher is a `div role="group"` rather than a `nav` with an invalid role. The queue detail Pause button and the Events Reroute button were white on yellow (1.9:1) and now carry dark text on `yellow-400`; process metadata chips were grey on grey in both themes; the batches "failed" count (`red-500`), the "tables need vacuum" note (`amber-500`), the "view tasks" link (`blue-500`), the queue list's Resume/Pause links (`green-600`/`yellow-600`), amber/green table cells and link hover colours (`indigo-500`) were below 4.5:1 on white and move to `-700`/`-800`; the indigo job-state badge and the Jobs list's row actions were below 4.5:1 in dark mode and move to `-300`. The Outbox and Insights stat cards now wrap their `<dt>`/`<dd>` in a `<dl>`, and the Insights section headings are `<h2>` under the page `<h1>` instead of skipping a level. No copy changes. Refs #498.
118
+ ### Fixed
76
119
 
77
120
  - **A still-running event handler is no longer re-run as if it had crashed, and a slow one is no longer redelivered mid-run.** Two halves of one hole, follow-up to #469. First: `Process::Consumer` had no equivalent of `ActiveJob::Executor#with_visibility_heartbeat`, so an event message's visibility timeout was never re-armed — a handler slower than `visibility_timeout` (30s by default) was redelivered *while it was still running*, `read_ct` climbed on every redelivery, and after `max_retries` the event was dead-lettered without the handler ever raising. Workers have been protected from this since the heartbeat landed; event consumers never were. The consumer now tracks each message through `VisibilityHeartbeat` for exactly as long as its handlers run, releasing the entry before the archive so a beat can never re-arm a message that is already gone. Second: `Handler#claim_idempotency?` read a `pgbus_processed_events` row with `completed_at IS NULL` as proof that the previous holder had been killed mid-handler, and re-ran. That state equally describes a handler that is simply still running — on another thread, another fork or another host — so a second delivery (a genuine redelivery, or a duplicate envelope, whose independent visibility timeouts no heartbeat can serialize) executed the handler *concurrently with* the live one, which is precisely the double-execution `idempotent!` exists to prevent. The claim now carries liveness rather than only a claim instant: `EventBus::ClaimBeat` refreshes every in-flight claim's `processed_at` from the same beat that re-arms the message's visibility timeout, so message visibility and claim liveness go quiet together when a process dies. A pending claim silent for longer than twice the heartbeat interval is abandoned and re-runs, exactly as before; a fresher one is owned and the delivery skips, deferring to the holder — which either completes (nothing is lost) or fails, leaving its own message for visibility-timeout redelivery to recover. A skip is also no longer silent: `pgbus.event_skipped` carries the reason (`:completed`, `:cached` or `:owned`), the claim's age in seconds and the delivery's `read_ct`, and the metrics subscriber counts it as `pgbus_event_count` with `status: "skipped"` and the reason as a tag. No migration, no new column, and no change on a table that has not run `pgbus:add_processed_event_completion` — a single-phase claim has no pending state and therefore no ownership question. Closes #470.
78
-
79
121
  - **An event is no longer handed to every handler whose pattern matches it, so a wildcard handler ran once per subscriber on the topic.** Each subscriber gets its own queue (`Subscriber#setup!` creates and binds one), so a topic with N matching subscribers puts N copies of every event on the bus — but `Consumer#handle_message` resolved handlers with `Registry#handlers_for(routing_key)`, a pattern match that ignored which queue the message had just been read from, and ran every match. Each of the N copies therefore fanned out to all N handlers: **every handler ran N times per event**, with four `"#"` subscribers meaning four invocations of each handler for every event on the bus. `idempotent!` was the only thing hiding it, and it hides it imperfectly by design: the two-phase claim (#385) deliberately *re-runs* when the existing claim is still pending, on the theory that the prior holder crashed — but a pending claim is also exactly what a handler still running on another host looks like. So one event could have host A win the claim and start a 700ms handler while host B, reading a different subscriber's copy of the same event a few hundred milliseconds later, dispatched to that same handler, lost the claim insert, read `completed_at IS NULL` as "crashed, re-run", and executed it concurrently. In one host app that meant a "task completed" record written twice and the user emailed twice, with a single row in `pgbus_processed_events`, `read_ct = 1` on every archived copy, and the second execution's side effects timestamped inside the first's window — dispatch, not redelivery. A non-idempotent handler simply ran N times with no guard at all. `handlers_for` now takes a required `queue_name:` and selects on ownership *and* pattern, so a message read from queue Q goes to Q's owner(s) alone and each handler runs exactly once per event; duplicate execution again requires a real redelivery, which is the at-least-once contract the docs describe. The keyword is required rather than defaulted precisely so a keyword-less call cannot silently restore the fan-out — the two callers that genuinely want the pattern view (the `Testing.inline!` publish path and `Testing::EventStore#drain!`, neither of which has a queue, since the event never reaches PGMQ) moved to an explicitly named `Registry#subscribers_matching`, where per-subscriber delivery counts already match what owner-only dispatch now produces in production. Two handlers registered against the same explicit `queue_name:` still both run on that queue's delivery. The pattern check is kept alongside the ownership check because a routing key the owner's pattern does not match means a stale `pgmq.topic_bindings` row, and a stale binding must not run the handler. A message on a queue no subscriber in this process owns is still archived rather than looped through visibility-timeout redelivery into the DLQ — that was already the behavior when `handlers_for` returned `[]` — but it is no longer silent: it logs a warning naming the queue and routing key, rate-limited to once per queue per process so a permanently stale binding cannot flood the log, and emits `pgbus.event_unrouted` on every occurrence so the real rate stays visible in metrics. The pending-claim re-run in `Handler#claim_idempotency?` is deliberately untouched here; it is a separate question now that dispatch no longer manufactures the concurrency it was misreading. Closes #469.
80
122
 
123
+ ## [0.16.6] - 2026-09-13
124
+
125
+ ### Added
126
+
127
+ - **`coalesce:` now works on the `Turbo::StreamsChannel` broadcast path.** `Pgbus::Streams::Stream#broadcast` has accepted `coalesce:` since #171, but the Turbo patch forwarded only four options (`durable:`, `exclude:`, `visible_to:`, `event:`), so nothing that broadcasts through `Turbo::StreamsChannel.broadcast_*_to` — every `Turbo::Broadcastable` model, and phlex-reactive's `Streamable.broadcast_to` — could debounce. A background job settling a 177-row collection therefore pushed 177 idempotent replaces of the same count badge at every peer. Any targeted helper now takes `coalesce:` (`@order.broadcast_replace_to :account, coalesce: 100`), as does a direct channel call (`Turbo::StreamsChannel.broadcast_replace_to(@account, target: "order-count", coalesce: true)`), with the same semantics as the direct API: opt-in, per `(stream, target)`, last-write-wins, `true` = the 50ms default window, an integer = the window in ms. Two things the option's plumbing needed beyond a fifth thread-local: the coalescing key, which dedupes on `(stream, target)` while `broadcast_stream_to` only ever sees the rendered `content:` — it is now resolved from the frame's own `target:`/`targets:` through the same `ActionView::RecordIdentifier.dom_id` conversion Turbo applies, so a record target keys on `order_7` rather than on a per-instance `#to_s`; and extraction of the pgbus options at the channel itself, since a direct `Turbo::StreamsChannel` call never passes through `Turbo::Broadcastable` and so previously leaked `durable: true` (and now `coalesce:`) into Turbo's renderer. `broadcast_refresh_to` and `broadcast_render_to` carry no target and so cannot coalesce — they raise the existing actionable "requires target:" error rather than guessing a key — and the `_later_to` variants remain out of scope, as the thread-local cannot follow into a job. The wire is byte-identical when `coalesce:` is absent. Closes #465.
128
+
129
+ ### Fixed
130
+
81
131
  - **A dropped ActiveRecord socket no longer pages the host app for an event that was handled successfully.** EventBus consumers are long-lived threads holding a leased AR connection, and a pooler restart, an admin disconnect or a brief failover can kill that socket at any point. Rails reconnects most statements transparently, but `Relation#update_all` is marked `allow_retry: false` — and that is exactly the phase-2 claim stamp in `Handler#complete_claim!`, the one AR write that happens *after* `handle` has already returned. A drop there surfaced as `ActiveRecord::ConnectionFailed` ("PQconsumeInput() SSL error: unexpected eof while reading") on a message whose work was done, so the host app's exception tracker paged for a false failure and PGMQ redelivered the event for a re-run. `EventBus::StaleConnectionRetry` now reconnects this thread's lease (via `ConnectionPool#active_connection?`, the accessor that exists across the supported Rails range) and repeats the stamp once; a second drop still raises, leaving the message to VT redelivery. Only the leased connections are reconnected — `clear_all_connections!` would yank sockets out from under sibling consumers in the same process, turning one recoverable drop into many. The retryable patterns are deliberately *broader* than `Client::STALE_CONNECTION_PATTERNS` and for the opposite reason: that list excludes mid-flight drops because a half-committed enqueue would duplicate a message, whereas this only ever repeats an idempotent `SET completed_at = <now>`. Phase 1 (`claim_idempotency?`) is deliberately not wrapped — its INSERT may have committed before the socket died, and on a legacy schema the retry's empty `result.rows` would read as "another consumer owns this claim", turning a recoverable drop into a silently skipped event.
82
132
 
133
+ ## [0.16.5] - 2026-09-10
134
+
135
+ ### Added
136
+
137
+ - **Concurrency is now visible: a section on the Locks page, three gauges, a home stat card and an MCP tool.** `limits_concurrency … on_conflict: :block` became durable in #460 — a parked job is never dropped and a key never runs past `to:` — but nothing showed it, so an operator whose per-record pipeline looked "stuck" had no way to see that the key's slot was held, how many jobs were parked behind it, or how long the oldest had waited, short of SQL. `pgbus_semaphores` and `pgbus_blocked_executions` appeared on no page, in no gauge, in no tool. One new read, `Web::DataSource#concurrency_stats`, now feeds four surfaces: the Locks page's **Concurrency** section (parked jobs, oldest wait, slots held, keys at limit, then a row per key with value / limit, lease state, parked count and oldest wait, auto-refreshing like the other dashboard panels); a **Parked jobs** card on the dashboard home; three unlabelled gauges on both `/pgbus/api/metrics` and the AppSignal probe (`pgbus_concurrency_blocked_executions`, `pgbus_concurrency_blocked_oldest_age_seconds`, `pgbus_concurrency_slots_held` — keys are per-record, so a per-key label would be an unbounded series); and a read-only `pgbus_concurrency` MCP tool returning the same data. The key rows come from a FULL OUTER JOIN, not a LEFT one: a key can have parked jobs and no semaphore (its holder died and the sweep removed the row) or a semaphore and nothing parked, and both shapes matter to an operator. Each row carries two guarded escape hatches. **Release** drops the semaphore and promotes through `Concurrency::BlockedExecution.promote_next`, so it takes each slot through the same guarded upsert an enqueue uses and can never reintroduce the over-admission #460 closed; when the lease is still fresh the confirm says so, because a job is then probably still running and releasing lets another start beside it. **Discard parked** drops a single key's parked jobs, naming the exact count in the confirm, and resolves the bookkeeping those jobs will never resolve themselves — a parked batch child is marked failed so its batch stops waiting and `on_failure` fires (#413), and an `:until_executed` uniqueness lock is released rather than orphaned (#423); each discard logs a warning naming the job and emits `pgbus.blocked_execution_discarded`. There is deliberately no cross-key "discard all parked": not losing work is the whole point of `:block`. Nothing is added to the enqueue or execute path. Refs #461.
138
+
139
+ ### Fixed
83
140
 
84
141
  - **The AppSignal probe's gauges and every MCP tool response were frozen at their first read.** `Web::DataSource` memoizes `queues_with_metrics` for the life of the instance — correct for the dashboard, which builds one instance per request, but the AppSignal `Probe::Runner` holds a single DataSource for the life of the process and `MCP::Server.build` injects one into the server context, so both replayed the first read forever: queue depth in AppSignal never moved after the first minute, and `pgbus_queues` returned the same snapshot for every call in a session. `DataSource#reset_cache!` drops the memos, and both long-lived consumers call it — the probe once per iteration, `MCP::BaseTool.data_source_from` once per tool call. Found while reviewing the concurrency aggregates added in the same release, which memoize the same way.
85
142
 
143
+ ## [0.16.4] - 2026-09-10
144
+
145
+ ### Fixed
146
+
86
147
  - **`limits_concurrency … on_conflict: :block` now constrains a job without ever losing it, and never runs more than `to:` at once.** The same bug class solid_queue closed in rails/solid_queue#712 and #761, checked against pgbus's implementation. Four defects, each with a regression spec:
87
148
  - **A parked job was silently deleted once it had waited longer than `duration`.** The dispatcher's sweep `delete_all`-ed blocked rows past their `expires_at` (a debug log, nothing else), and `release_next!` refused to promote one — so with `to: 1` and a slow holder, the N-th parked enqueue was simply gone. Blocked rows no longer expire; the only way out of `pgbus_blocked_executions` is promotion. `expires_at` now orders the sweep, nothing more.
88
149
  - **A semaphore expired under a job that was still running, and two runs of one key overlapped.** `duration` capped run time: once it passed, the sweep deleted the semaphore and promoted the next parked job beside the still-running holder — and that promotion took no slot at all, so a third enqueue then created a fresh semaphore and ran too. The visibility heartbeat now re-arms the semaphore alongside the message (`Semaphore.touch`), so `duration` bounds heartbeat silence, not run time; and every promotion — completion-time or sweep — takes its slot through the same `value < max_value` upsert an enqueue uses, so it can never push a key past `to:`.
@@ -108,133 +169,305 @@
108
169
 
109
170
  `Concurrency::BlockedExecution.expire_stale` is gone; `Concurrency::Semaphore.signal(key, client:)` replaces the executor's promote-then-release pair; `Concurrency::BlockedExecution.promote_pending(client:)` is the sweep; `Executor#execute` returns `:duplicate` when another worker archived its message. Docs: the concurrency page now spells out the `:block` guarantees, what `duration` means and the one window it cannot cover.
110
171
 
172
+ ## [0.16.3] - 2026-09-07
173
+
174
+ ### Added
175
+
176
+ - **The batches list and a batch's progress panel now refresh themselves.** Batches was the only dashboard list whose `turbo-frame` was missing `data-auto-refresh`, and the batch detail page had no frame at all, so watching a batch drain meant reloading by hand. Both now poll on the existing `web_refresh_interval` (5s, pauses while you interact) like every other list — the batch progress panel is served by `GET /batches/:id?frame=progress`. This reuses the dashboard's turbo-frame polling rather than the SSE stack in `lib/pgbus/web/stream_app.rb`: that is the host-app-facing stream feature, and pointing it at the dashboard would open a PG `LISTEN` connection per viewer to move four integers.
177
+
178
+ ### Fixed
179
+
111
180
  - **The batch progress bar rendered at zero width — because it rendered with no colour at all.** `app/frontend/pgbus/style.css` is a committed Tailwind build artifact that nothing rebuilds automatically; it was last compiled in #47, before the Batches tab existed. Every Tailwind class added to a view since then resolved to no CSS rule, so the progress fill on `/batches` and `/batches/:id` sized itself correctly from its inline `width:` and then painted nothing — a batch at 483/807 looked stuck at 0% no matter how often you reloaded. 33 classes were missing in total, including the bar's own `bg-green-500` / `bg-amber-500` fill and `bg-gray-200` track, the batches-table bar geometry (`h-2`, `w-24`), and `sm:grid-cols-4`, `hover:underline`, `gap-2`, `z-10`, `break-all`, `cursor-not-allowed` elsewhere on the dashboard. The artifact is rebuilt, `tailwind.css` now names its sources explicitly with `@source` (automatic detection would also walk the nested `docs/` app), `rake frontend:css` makes the build repeatable and version-pinned from `bun.lock`, and a new spec fails the suite whenever a class used in a view is absent from the artifact — the drift can no longer ship silently.
112
181
 
182
+ ## [0.16.2] - 2026-09-06
183
+
184
+ ### Fixed
185
+
113
186
  - **Concurrent first-broadcast notify-insert setup no longer pages on a Postgres deadlock / lock-wait (the residual of #403).** Two processes racing the first durable Turbo/Pgbus stream broadcast can both see "notify trigger not current" and both run `pgmq.enable_notify_insert` → `DROP TRIGGER` under `AccessExclusiveLock`. Postgres deadlocks, or hits `lock_timeout`, or — on pooled connections where `statement_timeout` < `lock_timeout` — `canceling statement due to statement timeout` **while locking**. PGMQ wraps those as `PGMQ::Errors::ConnectionError`. 0.16.1 already skips DROP when the trigger is current and retries stale sockets via `with_stale_connection_retry`, but it did not retry these lock races, so the residual still paged (getzazu/app#3817; AppSignal Zazu ma-prod #546 / za-prod #530). `ensure_stream_queue` now wraps the notify-setup path in a dedicated `with_notify_lock_retry` (up to 3 attempts, short backoff **outside** `@pgmq_mutex`): deadlock, lock-not-available, lock-timeout, and statement-timeout **with** lock-wait context retry; a bare statement timeout and permission errors still fail fast, while a missing-queue `ConnectionError` uses the existing one-time queue-recreation path without lock retries. `with_stale_connection_retry` is unchanged — that helper stays idle-socket / no-SQL-sent only. Consumers that prepended an app-level retry around `ensure_stream_queue` (getzazu/app#3827) can drop that prepend after upgrading.
114
187
 
188
+ ## [0.16.1] - 2026-09-05
189
+
190
+ ### Fixed
191
+
115
192
  - **A client that connects to the health port and never sends a request line no longer wedges every later probe (issue #455).** `Pgbus::Web::HealthServer` serves every probe from one accept-loop thread and read the request line with an unbounded `client.gets("\r\n", MAX_REQUEST_LINE)`, so a silent connection parked that read forever: docker's `--health-cmd` (`bin/pgbus-health`) then timed out on every attempt and the container stayed unhealthy for the rest of its life while the supervisor and its workers were fine. Each accepted connection now carries a 1-second `IO#timeout` — deliberately under `HealthProbe::DEFAULT_TIMEOUT` (2s), so one wedged client can't push the next probe past its own deadline — and the resulting `IO::TimeoutError` (an `IOError`) falls into the existing per-connection `rescue StandardError`, which logs it, drops the connection and frees the loop. `#stop` no longer leaks the thread either: a loop parked on an already-accepted client never sees the listening socket close, so `stop` kills the thread when it outlives the join and joins again afterwards (`Thread#kill` is asynchronous), instead of returning "stopped" while it is still running. Only reachable from inside the container on the `health_bind` 127.0.0.1 default — a wedged health gate, not a security issue. Refs #455.
193
+
194
+ ## [0.16.0] - 2026-08-31
195
+
196
+ ### Added
197
+
198
+ - **Unique batches: a run-scoped lock held from `enqueue` until the batch finishes.** `ensures_uniqueness` guards one job, but a multi-job run — an import fanned out into chunk jobs, a pipeline whose jobs enqueue the next stage — has no single job that spans the run, so a lock on the entry job releases the moment that job finishes and the next scheduler tick starts a second run on top of the first. `Pgbus::Batch.new(uniqueness_key: "perfecta-daily", on_conflict: :reject | :discard | :log)` takes a row in `pgbus_uniqueness_keys` at `#enqueue` (lock_key `batch:<key>`, queue_name `batch:<batch_id>`), keeps it through every open-batch add, and releases it in the single-winner finish (`finish_if_needed`) that also fires the callbacks — so completion, the stalled-batch sweep and cleanup all release it. A second batch with the same key raises `Pgbus::Batch::AlreadyRunning`, or skips its block and answers `discarded?`. The dispatcher's reaper judges these rows by the owning batch's status (`Batch.lock_orphaned?`: finished or missing → release) instead of scanning queues, so a nightly run may hold its lock for hours without being reaped, and a batch whose release failed is healed on the next cleanup cycle. `Batch#uniqueness_key` / `#on_conflict` / `#lock_key` readers are new; `Batch.find` handles stay non-unique (the lock belongs to the run that created it).
199
+ - **Visibility heartbeat: a running job's message no longer expires underneath it.** PGMQ hands a message to one reader for `visibility_timeout`; a job that ran longer was redelivered while still running — a second copy started, `read_ct` climbed on every redelivery, and after `max_retries` the message was dead-lettered without the job ever raising. Every long import, nightly cleanup and report job was exposed, and the usual workaround (a huge global `visibility_timeout`) made crash recovery equally slow for every queue. The executor now tracks each in-flight message with `Pgbus::VisibilityHeartbeat`: one thread per worker process re-arms the VT (`set_vt` back to `visibility_timeout`) every `visibility_heartbeat_interval` seconds — default a third of `visibility_timeout` — for as long as `perform` runs, and drops the entry before archive or the retry backoff touch the same message. The timeout therefore only fires for a process that is gone (crash, SIGKILL), which is what it exists for; `drain_timeout` still bounds what a deploy waits for. Works in both execution modes (the extension never runs inside the job's fiber), forgets the parent's entries after a fork, and is stopped by `Worker#shutdown`. New config: `visibility_heartbeat` (default `true`; `false` restores plain PGMQ semantics) and `visibility_heartbeat_interval` (seconds or Duration, must stay below `visibility_timeout`; validated at boot). Per-class opt-out: `pgbus_visibility_heartbeat false` (mixin included on `ActiveJob::Base` by the engine). New event `pgbus.job_visibility_extended` (`queue`, `job_class`, `msg_id`, `vt`, `extensions`) and counter `pgbus_visibility_extended` tagged `queue`/`job_class` — a class that shows up there is one that outlives the timeout. Event-bus handlers are not covered yet.
200
+
201
+ ### Fixed
202
+
116
203
  - **The unbound-lock reaper no longer counts a dead-lettered copy as a live message, so a `:until_executed` job that once dead-lettered can be enqueued again.** `Client#uniqueness_keys_present` scanned every queue in `pgmq.meta` for the payload's `pgbus_uniqueness_key` — including `*_dlq` queues. A dead-lettered copy keeps the original payload (key included) but is not in flight: the executor already released the lock when it moved the message. For a legacy `pending`/`msg_id=0` row (pre-#418 enqueue, or a bind that failed) the DLQ copy made the key look "still here" forever, the reaper kept the row, and an `on_conflict: :discard` job — a heartbeat, typically — was discarded on every enqueue until someone purged the DLQ by hand. Seen in production as three heartbeats silently dead for nine days behind a 468-message DLQ. Dead-letter queues are now skipped in the scan; bound-lock probes (`message_exists?`) were never affected because they resolve the logical queue's physical tables only.
204
+
205
+ ## [0.15.4] - 2026-08-31
206
+
207
+ ### Fixed
208
+
117
209
  - **`Pgbus::Testing.disabled!` can no longer turn a Capybara teardown race into a hung test process (issue #443).** The `streams_test_mode` stub closed immediately, so a page on a stream-bearing layout had its `EventSource` reconnect every ~3s for the whole example; a reconnect landing after `disabled!` had switched test mode off — a config-level `after` hook runs *before* `Capybara.reset_sessions!` — took the real path and started a live `Streamer` (listener/dispatcher/heartbeat threads + a LISTEN connection) inside the RSpec process. Its orphaned threads then shared the test's pinned AR connection and CI died at the job timeout with only `message type 0x5a arrived from server while idle`. Three changes: the stub now emits `retry: 86400000` so the browser does not reconnect at all; `Streamer::Instance#shutdown!` snapshots every component's threads (`#threads` on Listener/Dispatcher/Heartbeat/OutboundPump/HubClient/FailoverListener), logs one error naming those still alive after their bounded joins, and returns that list (`Streamer.reset!` forwards it, `nil` when nothing was live); and `Testing.disabled!` raises `Pgbus::Testing::StreamerLeakError` — pointing at `config.append_after` — when threads leaked, while a live streamer that shut down cleanly is only logged. README and docs now recommend `config.append_after` for the teardown hook.
210
+
211
+ ## [0.15.3] - 2026-08-30
212
+
213
+ ### Changed
214
+
215
+ - **The gem root's `Gemfile.lock` is now committed.** Every checkout and CI leg resolves the same gem set (the main-Gemfile legs and `release.yml` install it frozen, like the Rails 7.1 and docs locks already were), so a fresh checkout can no longer silently pick up a newer dev dependency than the last green run. The gemspec builds `spec.files` from a path allowlist, so the lock never ships in the gem (verified with `gem build`). `rake release` bumps the `pgbus (X.Y.Z)` pin in all three tracked lockfiles in the bump commit, and `spec/pgbus/frozen_lockfile_sync_spec.rb` guards the root lock too. Contributors: after pulling, `bundle install` is a no-op unless the lock moved; run `bundle install` (not `bundle lock`) when you change a dependency, and commit the lock with it.
216
+ - **Client build toolchain: bun 1.3.11 → 1.4.0.** `.bun-version`, the root `engines.bun` floor, and the docs-site CI job (now pinned to the same `.bun-version` file instead of `latest`) move together. Both `bun.lock` files install `--frozen-lockfile` unchanged; pgbus ships no bun-built artifacts, so nothing in the gem changes.
217
+
218
+ ### Fixed
219
+
118
220
  - **`Pgbus::MCP.rack_app` works on a real hostname again with `mcp` 0.23+ / 1.x.** Since mcp 0.23 the `StreamableHTTPTransport` validates the `Host` header (DNS-rebinding protection, on by default, loopback hosts only) and pgbus had no way to pass the transport's options through — so a gated mount at `https://app.example.com/pgbus/mcp` answered every request `403 "Invalid Host header"`, and consumers pinned `mcp < 1.0` to dodge it (which only helps while the lock stays on 0.22). The rack app now exposes `allowed_hosts:`, `allowed_origins:` and `dns_rebinding_protection:`; the check **follows the gate by default** — off when `token:`/`auth:` is configured (a rebound browser page can never carry the bearer secret, so the check is redundant there), on for the warned-about unauthenticated mount — and `true`/`false` forces it. `mcp >= 0.23` is the floor for `rack_app` (older gems raise `Pgbus::Error` naming the fix); the gem's own bundle now tracks `mcp` 1.x, so lift that `< 1.0` pin. Stdio (`pgbus mcp`) is unaffected.
119
221
 
222
+ ## [0.15.2] - 2026-08-26
223
+
224
+ ### Fixed
225
+
120
226
  - **Worker forks no longer kill the supervisor's shared LISTEN connection (issue #437).** Under `worker_notify_scope = :supervisor` every fork produced one `[Pgbus::NotifyListener] connection error (PG::ConnectionBad: PQconsumeInput() server closed the connection unexpectedly …) — reconnecting` in the supervisor ~2 s after `Worker started`, and a LISTEN gap (polling fallback) until `reconnect!` completed. The child's fork hygiene closed the Ruby `IO` wrapper of the inherited LISTEN socket, but pg builds `socket_io` with `autoclose=false`, so the fd stayed open — and when the child's GC freed the inherited `PG::Connection`, its `PQfinish` sent a libpq Terminate down that fd, i.e. down the **parent's** connection. `NotifyListener#close_inherited_socket!` now repoints the fd at `/dev/null` (`socket_io.reopen(IO::NULL)`, the ActiveRecord `PostgreSQLAdapter#discard!` idiom) so the eventual `PQfinish` is harmless. Regression-covered by a real-fork integration spec that asserts the parent's `pgbus-listen` backend pid is unchanged and no reconnect is logged. Refs #437.
121
227
  - **A process whose `pgbus_processes` row is deleted underneath it now re-registers instead of staying invisible forever (issue #438).** `Heartbeat#beat` updated its row by id; when the row was gone — the dispatcher's stale-process reaper after a ≥ 5-minute heartbeat gap, a manual cleanup, another host's clock skew — `update_all` matched 0 rows, raised nothing, and the process (healthy, logging, serving `/readyz`) was absent from `pgbus_processes` until it restarted, so the supervisor's DB loop-tick check silently fell back to the pipe and `ConsumerPriority` could not see it. The beat now treats an affected-row count of 0 as "row is gone": it logs one WARN naming the old id, kind and pid (so whatever deleted the row can be hunted), re-registers through the existing boot path, and lands that beat's `last_heartbeat_at` / metadata on the new row. A mutex plus a stopped flag keep a beat that races `stop` from resurrecting a row `deregister_process` just deleted. Zero extra queries on the happy path. Refs #438.
122
228
  - **Supervisor logs a worker recycle as a clean exit, not a crash (issue #438).** A worker or consumer hitting `max_jobs` / `max_memory` / `max_lifetime` exits 0 by design, but the supervisor logged `Child worker pid=N exited unexpectedly (status=0)`, burying real crashes. A clean exit outside shutdown is now INFO `exited cleanly (status=0) — restarting (worker recycle)`; non-zero exits keep the WARN, and a signaled exit reports `signal=N` instead of an empty status so an OOM SIGKILL is distinguishable. Restart policy is unchanged. Refs #438.
229
+
230
+ ## [0.15.1] - 2026-08-24
231
+
232
+ ### Fixed
233
+
123
234
  - **`current_attributes` capture skips an unpersisted record instead of raising at enqueue (issue #435).** An attribute holding an Active Record instance with no id — a dev-mode fallback record assigned to `Current`, a form-built model captured before `save`, a destroyed record whose locate is guaranteed to fail — made every `perform_later` in that context raise `Pgbus::CurrentAttributesError`, even though such a record can never round-trip (no id → no GlobalID) and capture is ambient: the enqueuer never opted into persisting that attribute per-call, so its momentary state must not abort the enqueue. `capture` now skips any attribute value that answers `persisted?` falsey (so destroyed-but-id-bearing records are skipped too, not just `new_record?`) with a debug log naming the class, attribute and why; the rest of the class's attributes still persist. The `except:` guidance and the loud `CurrentAttributesError` remain for genuinely unserializable values — objects without the Active Record duck-type (`respond_to?(:persisted?)`) are untouched. Applies to jobs and event-bus publish alike (same capture path). Refs #435.
124
235
 
236
+ ## [0.15.0] - 2026-08-23
237
+
125
238
  ### Added
126
239
 
127
240
  - **First-class `Current` support: ActiveSupport::CurrentAttributes persist across enqueue → perform (issue #430).** The executor already reset `CurrentAttributes` around every job (so nothing leaked) but nothing restored it — `Current.tenant` was always nil inside a job. New `config.current_attributes` (`nil` = off; `:auto` = every `ActiveSupport::CurrentAttributes` subclass; an Array of classes/names; or a Hash of class => `{ only: [...] }` / `{ except: [...] }`). The new `Pgbus::ActiveJob::CurrentAttributes` mixin (included on `ActiveJob::Base` by the engine next to `BatchId`) captures the assigned attributes of each persisted class in `serialize` — serialized with `ActiveJob::Arguments`, so records become GlobalIDs and fall under the `allowed_global_id_models` allowlist on the way back — under the job-hash key `pgbus_current`, and restores them by wrapping the **whole** `perform_now` in nested `Current.set`, so `before_perform`, `perform`, `rescue_from`, `retry_on` / `discard_on` blocks and jobs enqueued from inside `perform` all see the context, under the pgbus worker and Rails' `:test` / `:inline` adapters alike. A deserialized job re-serializes the context it was enqueued with, so a `retry_on` re-enqueue keeps the original; concurrency-blocked promotion, dead-letter / dashboard retry and `perform_all_later` carry it by construction. An unserializable attribute raises `Pgbus::CurrentAttributesError` at `perform_later` naming the class, attribute and the `except:` fix — nothing is dropped silently; a class that no longer exists or an attribute no longer defined is skipped with a log line (Sidekiq parity). Per job class: `self.pgbus_persist_current_attributes = false` or a spec override. Unconfigured installs have byte-identical payloads. Dashboard: failed-job and dead-letter detail pages gain a **Context** card (`Pgbus::Web::JobContext`, through `PayloadFilter`). Event bus follows in #431. Refs #430.
128
-
129
241
  - **Current attributes propagate publish → handler on the event bus (issue #431).** Follow-up to #430: `Pgbus.publish` from a request left `Current` empty in every event handler. With the same `config.current_attributes` switch on, the publisher now snapshots each persisted class (same `only:`/`except:` filters, same `ActiveJob::Arguments` serialization) into the **event envelope** under the same `pgbus_current` key jobs use (a sibling of `event_id`, never inside the user payload — `event.payload` is unchanged), PGMQ's topic fan-out copies it to every subscriber queue, and the handler pipeline restores it around `handle` (inside the Rails-executor wrap; previous values come back after each event — no cross-event leak on a consumer thread). `Pgbus::Event#context` exposes the raw stored form. `Pgbus::Outbox.publish_event` captures at write time — inside the app transaction, where `Current` is set — and the relay carries the envelope unchanged. GlobalIDs inside the context are gated by `allowed_global_id_models` in `Handler#build_event` before anything is located, the same boundary jobs gate at. `Pgbus::Testing` round-trips it (fake-mode events expose `context`; `drain!` and inline dispatch restore it), and the dashboard's pending-events rows show the same Context card as the failed-job pages. Default-off is byte-identical. Refs #431.
130
242
  - **Fix: outbox events now dispatch to handlers.** `Outbox.publish_event` built its envelope without `routing_key` and `pgmq.send_topic` stamps nothing on the message, so a relayed outbox event reached the right subscriber queues but the consumer read no routing key, matched **zero** handlers, and archived the event unrun. The envelope now carries `routing_key:` exactly like a direct `Pgbus.publish`. Entries written by older code (already relayed or pending) still lack it — re-publish those events if any are pending when you upgrade. Refs #431.
131
243
  - **Fair share scheduling for event-bus consumers (issue #427).** Follow-up to #426: a subscriber queue is FIFO, so a bulk import emitting `orders.created` 100 000 times for one tenant put every other tenant's events behind it in every handler subscribed to that topic. New `config.event_fair_share = ->(event) { key | [key, weight] | nil }` — the event twin of `fair_share` — receives the `Pgbus::Event` (routing key, the payload object as passed to publish, headers) at publish time on every path (`Pgbus.publish`, `publish_later`, `Pgbus::Outbox.publish_event`) and merges the same `pgbus_fair_key` / `pgbus_fair_weight` into the **event envelope** (a sibling of `event_id` / `published_at`, never inside the user payload, so `event.payload` is unchanged in handlers). Because the key lives at `message->>'pgbus_fair_key'` for events exactly as for jobs, #426's `read_batch_fair` SQL and `q_<queue>_fair_idx` index are reused verbatim; PGMQ's topic fan-out copies the tag to every bound subscriber queue, and the outbox (which stores the envelope) carries it across the relay with no extra work — a system writing `pgbus_outbox_entries` directly can set the key in the envelope JSON. `Consumer#fetch_messages` now fair-reads each active subscriber queue in list order with the remaining capacity (strict across queues, fair within; circuit-breaker-paused queues still skipped), ensures the fair index on its queues at boot, and `Subscriber#setup!` ensures it at queue creation. Independent of `fair_share` (enable either side or both); no interaction with `group_mode` (worker-only). Default-off is byte-identical. Refs #427.
132
244
  - **Fair share scheduling across tenants — weighted, work-conserving (issue #426).** A queue is FIFO, so one tenant enqueuing 100 000 jobs put every other tenant's work behind them; nothing in pgbus prevented it (multi-queue reads are strict list-order, `group_mode` serializes PGMQ FIFO groups and pgbus never set its header for jobs, `limits_concurrency` is a non-work-conserving cap). New `config.fair_share = ->(job) { key | [key, weight] | nil }` is evaluated at enqueue on every adapter path (`enqueue`, `enqueue_at`, `perform_all_later`); the key and weight ride inside the job payload as `pgbus_fair_key` / `pgbus_fair_weight` (same pattern as `pgbus_concurrency_key`, so they survive blocked-execution promotion, DLQ retry and dashboard retry). Workers then read with the new `Client#read_batch_fair` — one statement that enumerates the keys with visible messages via a loose index scan, ranks each key's oldest visible messages, and takes the `qty` lowest `rank / weight`: weight 3 vs 1 is a 3:1 split while both have work, a lone tenant still fills the whole batch, and the cost scales with the number of keys that have visible work, not backlog depth (`rake bench:fair_read`: 1.05 ms with 1 key, 4.7 ms with 200 keys, 100k-row backlog; numbers in `docs/performance.md`). Strict list-order priority across queues is preserved (fair within each queue); with `priority_levels` it is strict between levels and fair within a level; mutually exclusive with `group_mode` (`validate!` and `Worker.new` both raise). The supporting expression index `q_<queue>_fair_idx ((COALESCE(message->>'pgbus_fair_key','')), vt, msg_id)` is created at queue creation when the option is on, and built `CONCURRENTLY` by each worker for the queues it already serves (`Client#ensure_fair_index`, memoized, logs the `DROP INDEX` remediation if a concurrent build is interrupted). Within a key messages are taken oldest-visible first (`vt, msg_id`) — a deliberate deviation from `pgmq.read`'s pure `msg_id` order so a tenant's whole visible backlog is never sorted per read. Event-bus consumers get the same in #427 (below). Refs #426.
133
245
 
134
- - **Unique batches: a run-scoped lock held from `enqueue` until the batch finishes.** `ensures_uniqueness` guards one job, but a multi-job run — an import fanned out into chunk jobs, a pipeline whose jobs enqueue the next stage — has no single job that spans the run, so a lock on the entry job releases the moment that job finishes and the next scheduler tick starts a second run on top of the first. `Pgbus::Batch.new(uniqueness_key: "perfecta-daily", on_conflict: :reject | :discard | :log)` takes a row in `pgbus_uniqueness_keys` at `#enqueue` (lock_key `batch:<key>`, queue_name `batch:<batch_id>`), keeps it through every open-batch add, and releases it in the single-winner finish (`finish_if_needed`) that also fires the callbacks — so completion, the stalled-batch sweep and cleanup all release it. A second batch with the same key raises `Pgbus::Batch::AlreadyRunning`, or skips its block and answers `discarded?`. The dispatcher's reaper judges these rows by the owning batch's status (`Batch.lock_orphaned?`: finished or missing → release) instead of scanning queues, so a nightly run may hold its lock for hours without being reaped, and a batch whose release failed is healed on the next cleanup cycle. `Batch#uniqueness_key` / `#on_conflict` / `#lock_key` readers are new; `Batch.find` handles stay non-unique (the lock belongs to the run that created it).
135
- - **Visibility heartbeat: a running job's message no longer expires underneath it.** PGMQ hands a message to one reader for `visibility_timeout`; a job that ran longer was redelivered while still running — a second copy started, `read_ct` climbed on every redelivery, and after `max_retries` the message was dead-lettered without the job ever raising. Every long import, nightly cleanup and report job was exposed, and the usual workaround (a huge global `visibility_timeout`) made crash recovery equally slow for every queue. The executor now tracks each in-flight message with `Pgbus::VisibilityHeartbeat`: one thread per worker process re-arms the VT (`set_vt` back to `visibility_timeout`) every `visibility_heartbeat_interval` seconds — default a third of `visibility_timeout` — for as long as `perform` runs, and drops the entry before archive or the retry backoff touch the same message. The timeout therefore only fires for a process that is gone (crash, SIGKILL), which is what it exists for; `drain_timeout` still bounds what a deploy waits for. Works in both execution modes (the extension never runs inside the job's fiber), forgets the parent's entries after a fork, and is stopped by `Worker#shutdown`. New config: `visibility_heartbeat` (default `true`; `false` restores plain PGMQ semantics) and `visibility_heartbeat_interval` (seconds or Duration, must stay below `visibility_timeout`; validated at boot). Per-class opt-out: `pgbus_visibility_heartbeat false` (mixin included on `ActiveJob::Base` by the engine). New event `pgbus.job_visibility_extended` (`queue`, `job_class`, `msg_id`, `vt`, `extensions`) and counter `pgbus_visibility_extended` tagged `queue`/`job_class` — a class that shows up there is one that outlives the timeout. Event-bus handlers are not covered yet.
136
- - **Batches v2: open batches, a `batch` accessor on jobs and callbacks, and configured callback instances (issue #415).** ⚠️ **Breaking (pre-1.0)**: `Pgbus::Batch.find` now returns a rehydrated `Pgbus::Batch` handle instead of the raw attributes Hash — read the values off the handle (`status`, `total_jobs`, `completed_jobs`, `failed_jobs`, `pending_jobs`, `progress_percentage`, `finished?`, `description`, `properties`), or query `Pgbus::BatchEntry` for a row. Three capabilities: **(1) Open batches.** `batch.enqueue` is re-callable while the batch is unfinished — the second and later calls add to the existing group (`BatchEntry.increment_total_jobs!`, guarded on an unfinished row) instead of hitting the unique index on `batch_id`, so multi-stage workflows are possible. Adding to a finished batch raises `Pgbus::Batch::AlreadyFinished`. Execution rows are inserted as the block enqueues, before `total_jobs` is bumped, so the single-winner finish always sees outstanding work; a `check_finished!` after the bump covers a block whose jobs all completed while it was still open. Membership stays explicit: only jobs enqueued inside an `enqueue` block join the batch, so a fan-out from a batched job never silently extends it. **(2) `batch` accessor.** New `Pgbus::ActiveJob::BatchId` mixin (included on `ActiveJob::Base` from the engine alongside `Concurrency` and `Uniqueness`) adds `batch_id` / `callback_batch_id` accessors, round-trips both through `serialize`/`deserialize` — omitted from the payload when unset, so an unbatched job's serialized hash is unchanged — and exposes a memoized `batch` reader. The executor assigns `batch_id` from the payload's `pgbus_batch_id` before `perform`, so a running job can call `batch.enqueue` to add siblings. **(3) Configured callback instances.** `on_finish:` / `on_success:` / `on_failure:` accept an ActiveJob instance as well as a class: `on_finish: ReportJob.new.set(queue: :critical, wait: 5.minutes)`. `.set` options resolve at batch-creation time (matching solid_queue) into new `on_finish_job` / `on_success_job` / `on_failure_job` jsonb columns; at fire time the job is deserialized, given `callback_batch_id`, has its own `batch_id` cleared (a callback is never a member of the batch it reports on) and is enqueued on its configured queue and `scheduled_at`. Legacy class-name callbacks keep the `perform_later(properties)` signature — deprecated at 1.0 in favour of `batch.properties`. The dashboard shows a configured callback's `job_class`. Existing installs: `rails generate pgbus:add_batch_callback_jobs` (or `pgbus:update`); apps that have not migrated keep storing bare classes only. Refs #415.
246
+ ## [0.14.2] - 2026-08-23
247
+
248
+ ### Added
137
249
 
250
+ - **Batches v2: open batches, a `batch` accessor on jobs and callbacks, and configured callback instances (issue #415).** ⚠️ **Breaking (pre-1.0)**: `Pgbus::Batch.find` now returns a rehydrated `Pgbus::Batch` handle instead of the raw attributes Hash — read the values off the handle (`status`, `total_jobs`, `completed_jobs`, `failed_jobs`, `pending_jobs`, `progress_percentage`, `finished?`, `description`, `properties`), or query `Pgbus::BatchEntry` for a row. Three capabilities: **(1) Open batches.** `batch.enqueue` is re-callable while the batch is unfinished — the second and later calls add to the existing group (`BatchEntry.increment_total_jobs!`, guarded on an unfinished row) instead of hitting the unique index on `batch_id`, so multi-stage workflows are possible. Adding to a finished batch raises `Pgbus::Batch::AlreadyFinished`. Execution rows are inserted as the block enqueues, before `total_jobs` is bumped, so the single-winner finish always sees outstanding work; a `check_finished!` after the bump covers a block whose jobs all completed while it was still open. Membership stays explicit: only jobs enqueued inside an `enqueue` block join the batch, so a fan-out from a batched job never silently extends it. **(2) `batch` accessor.** New `Pgbus::ActiveJob::BatchId` mixin (included on `ActiveJob::Base` from the engine alongside `Concurrency` and `Uniqueness`) adds `batch_id` / `callback_batch_id` accessors, round-trips both through `serialize`/`deserialize` — omitted from the payload when unset, so an unbatched job's serialized hash is unchanged — and exposes a memoized `batch` reader. The executor assigns `batch_id` from the payload's `pgbus_batch_id` before `perform`, so a running job can call `batch.enqueue` to add siblings. **(3) Configured callback instances.** `on_finish:` / `on_success:` / `on_failure:` accept an ActiveJob instance as well as a class: `on_finish: ReportJob.new.set(queue: :critical, wait: 5.minutes)`. `.set` options resolve at batch-creation time (matching solid_queue) into new `on_finish_job` / `on_success_job` / `on_failure_job` jsonb columns; at fire time the job is deserialized, given `callback_batch_id`, has its own `batch_id` cleared (a callback is never a member of the batch it reports on) and is enqueued on its configured queue and `scheduled_at`. Legacy class-name callbacks keep the `perform_later(properties)` signature — deprecated at 1.0 in favour of `batch.properties`. The dashboard shows a configured callback's `job_class`. Existing installs: `rails generate pgbus:add_batch_callback_jobs` (or `pgbus:update`); apps that have not migrated keep storing bare classes only. Refs #415.
138
251
  - **Batch completion is now self-healing via execution-row tracking (issue #414).** ⚠️ **Restart pgbus workers after `rails generate pgbus:add_batch_executions` / `db:migrate`** — a process that still writes `discarded_jobs` will error once that column is gone. `pgbus_batches` no longer finishes on `completed + discarded == total` — that arithmetic stalled a batch *forever* if a worker crashed between `archive_from` and `signal_batch_completed`, and a crash mid-`Batch#enqueue` left an immortal `pending` row (`cleanup_batches` only deletes `finished`). Each batched job now gets a `pgbus_batch_executions` row (identity = ActiveJob `job_id`) inserted *before* `send_message`, with `msg_id` + `queue_name` backfilled after send. The batch finishes when no rows remain (single-winner `UPDATE … AND NOT EXISTS`, plus a fresh `exists?` re-check for Postgres READ COMMITTED). A dispatcher sweep (`config.batch_sweep_interval`, default 5 minutes) repairs four crash windows: stale rows whose PGMQ message is gone (archived → completed, DLQ → failed, missing → completed with a warning); orphan rows with `msg_id` NULL older than 5 minutes (enqueue crashed between insert and send — blocked concurrency jobs are excluded); stalled `pending` batches; stalled `processing` batches with zero rows. `on_failure:` / `failed_jobs` are canonical (`on_discard:` / `discarded_jobs` remain deprecated aliases until 1.0). `config.batch_retention` (default 7 days, `nil` disables) replaces the hardcoded cleanup window. Existing installs: `rails generate pgbus:add_batch_executions` (or `pgbus:update`). Unmigrated apps keep the counter path via `Batch.executions_migrated?`. Refs #414.
139
252
 
140
253
  ### Fixed
141
254
 
142
255
  - **A `retry_on` re-enqueue now stays in its batch — batch completion means terminal completion (issue #424).** `retry_on` rescues inside `perform_now`, re-enqueues the same job (new PGMQ message, same ActiveJob `job_id`, `executions > 0`) and returns normally; the executor then archived the original message and signalled `job_completed` — deleting the execution row and bumping `completed_jobs` — while the retry message carried no batch tag at all. `on_success` could fire with a retry still pending, and a retry that later dead-lettered never fired `on_failure`. The adapter now recognises a retry re-enqueue (a job with a `batch_id` from the `BatchId` mixin and `executions > 0`, outside any `Batch#enqueue` block), re-tags it into its batch **without** counting it again (`Batch.track_retry`: the existing row is kept via `ON CONFLICT DO NOTHING`, the backfill re-points it at the retry message) and records the `job_id` in a per-thread set once the retry is live (sent, or parked as a blocked execution); the executor consults that set after `perform` and skips the completion signal for an attempt that re-enqueued itself, clearing the set per execute. A first-attempt job that merely has a `batch_id` outside a block is still not tagged (membership stays explicit), and `callback_batch_id` never re-tags. Batches with `retry_on` jobs finish later — correctly. Refs #424.
143
-
144
256
  - **Batched jobs count themselves in per job, in one transaction, before the send — `AlreadyFinished` now raises at `perform_later`, and no batch path can leave `total_jobs` out of step with its rows (issue #423).** Open batches (#422) inserted execution rows during the block and bumped `total_jobs` once at the end. Three ways that went wrong: a re-opened block that raised mid-way left rows counted but the total un-bumped, so the batch could never satisfy `completed + failed = total` and lived forever; `AlreadyFinished` was checked against a stale memoized row and only enforced after the block, so adding to a finished batch enqueued every job *and then* raised; and the stalled-pending sweep recomputed `total_jobs` from the row count, discarding `completed_jobs`/`failed_jobs` already incremented while the block ran — a crashed `pending` batch with any early completions was immortal too. Every batched enqueue (individual, bulk, blocked-promote) now runs `BatchEntry.increment_total_jobs!` — guarded on an unfinished row — and the execution-row insert inside one `BatchEntry.transaction`, *before* `send_message`; a `perform_all_later` increments once by N. The invariant `total_jobs == outstanding rows + completed_jobs + failed_jobs` holds at every commit point, so the first block only flips `pending → processing` (guarded) and re-checks, a re-opened block never bumps, the empty-batch path goes through the same single-winner finish (and now emits `pgbus.batch_finished`), and the sweep never rewrites totals. Discards at enqueue time (`uncount`) decrement and delete in one transaction. The legacy counter path only auto-finishes a `processing` batch (totals now grow mid-block). **Internal:** `Thread.current[:pgbus_batch_job_count]` is gone — nothing reads it; a spec that stubbed it should assert on `Pgbus::Batch.track_enqueue` instead. Cost: one extra `UPDATE pgbus_batches` per batched job (one per bulk send); plain enqueue is untouched. Refs #423, #415.
145
-
146
257
  - **The stalled-batch sweep no longer un-counts a live job, and no longer flips a pending batch that is still actively enqueuing (issue #423).** Phase 2 deleted every `msg_id IS NULL` execution row older than the threshold and decremented `total_jobs`, assuming the enqueue died between row insert and send — but a send that landed whose backfill then failed leaves the same shape with a **live** message, and un-counting it let the batch finish (and fire `on_success`) before that job ran. The row now stores the logical queue at insert, and the sweep probes it by `job_id` first: message present → keep; probe inconclusive → keep; present in the DLQ → resolve as failed; nowhere → orphan as before. `Client#message_with_job_id?` expands a logical name to every `_pN` sub-queue under priority routing (it probed a single bare table that does not exist there and returned "unknown"). Phase 3 treats a `pending` batch as stalled only after `config.batch_stall_threshold` (new, default 5 minutes, replaces the hardcoded constant) with **no execution row inserted in that window**, so a long-running enqueue block is not closed under the caller. Refs #423.
147
-
148
258
  - **A `:while_executing` job that fails once can retry — the lock is released on failure and re-acquired by the same message after a crash (issue #423).** The lock was acquired at execution start with `msg_id = 0` and released only on success or dead-letter. After an exception the row stayed, and the retry of the *same message* hit `INSERT … ON CONFLICT DO NOTHING` against its own row → `:skipped`, on every read, until `read_ct` exceeded `max_retries` and the job dead-lettered without a second real attempt; a process kill mid-job did the same. The executor now binds the lock to the message it is executing (`msg_id` + queue) and releases it in the failure path (`:until_executed` is untouched — that lock is held until success/DLQ by design); `UniquenessKey.acquire!(reacquire_same_message: true)` treats a conflict with a row pointing at the **same** `msg_id` as this message's own previous attempt (PGMQ's visibility timeout guarantees nobody else holds it) and re-acquires, while a different `msg_id` is still a genuine duplicate. Refs #423.
149
-
150
259
  - **A batch callback configured as an ActiveJob instance on a database that has not run `add_batch_callback_jobs` is no longer silently dropped (issue #423).** It degrades to its class in the legacy `on_*_class` column — the callback still fires, on its default queue — and logs a WARN once per process that `.set` options are ignored until the migration runs. Refs #423.
151
-
152
260
  - **A concurrency-blocked job keeps its priority when promoted (issue #423).** `BlockedExecution.release_next!` returned only `queue_name` and `payload`, so `promote_next` re-sent without `priority:` and, under `priority_levels > 1`, every promoted job landed on the default sub-queue regardless of what the enqueuer asked for. The row's `priority` is now returned and passed to `send_message` and to the execution-row backfill's target queue. Refs #423.
153
-
154
261
  - **A batch whose `enqueue` block crashed before enqueuing anything is no longer immortal (#414 follow-up).** The stalled-batch sweep moved such a batch from `pending` to `processing` with `total_jobs = 0`, but `BatchEntry.finish_if_empty!` required `total_jobs > 0` and `Sweep#counters_terminal?` required `total_jobs.positive?` — guards added so a pre-migration in-flight batch (zero execution rows, counters short of `total_jobs`) is not closed empty. Nothing could then close the crashed batch: it sat in `processing` forever, `cleanup_batches` only deletes `finished` rows so it accumulated, and `on_finish` never fired — the same leak #414 set out to remove, relocated one status along. Both guards now test `completed_jobs + failed_jobs = total_jobs` alone: `total_jobs = 0` with zero counters *is* terminal, while a legacy in-flight batch still has counters short of a positive `total_jobs` and is still left on the counter path. Refs #414.
155
-
156
262
  - **`ActiveJob.perform_all_later` no longer raises `PG::UndefinedTable` when priority routing is enabled.** `Client#send_batch` targeted `config.queue_name(queue)` — the bare `pgbus_<queue>` table — while the priority strategy only ever creates `_p0.._pN`, so every bulk enqueue under `priority_levels > 1` failed outright (and, had the table existed, no worker would have read it: consumers poll the sub-queues). `send_batch` now routes through the same `QueueFactory` strategy as `send_message` and takes an optional `priority:`, `Adapter#enqueue_all` groups bulk jobs by queue **and** priority so a mixed-priority `perform_all_later` produces one batch per level, and `Outbox::Poller#publish_queue_batch` — which already grouped entries by priority and then discarded it — passes it through (its single-send fallback now does too, so both paths place messages identically). Under the default single-queue strategy the target is unchanged. This also stopped bulk-enqueued batch jobs from leaking `pgbus_batch_executions` rows on the failed send. Refs #413.
157
-
158
263
  - **`:until_executed` uniqueness keys no longer stick at `pending`/`msg_id=0` so the orphan reaper can now release true orphans (issue #418).** The adapter inserted the lock row as `queue_name: "pending", msg_id: 0` before `send_message` and never UPDATEd it with the real logical queue and PGMQ `msg_id`. The dispatcher's reaper then probed `pgmq.q_<prefix>_pending` — a table that does not exist — `Client#message_exists?` returned `nil` (UndefinedTable), and `nil` is treated as "still here" so leaked keys lived forever. Heartbeat jobs using `on_conflict: :discard` stayed discarded for hours or days with no matching queue message. Three cooperating fixes: **(1)** acquire with the logical queue name, then `UniquenessKey.bind!` after a successful send (adapter and recurring scheduler; retry re-enqueues do not re-bind — issue #333). A bind error is fail-soft. **(2)** Unbound rows (`pending` and/or `msg_id=0`) are reaped by scanning live `pgmq.q_*` tables via `Client#uniqueness_keys_present` — never by querying the synthetic pending queue. In-flight jobs that still look like `pending`/`0` are kept if any queue payload still carries the key. Age floor remains `2 * visibility_timeout`. **(3)** The executor releases the lock once, immediately after archive (fail-soft), so a kill during `pgbus.job_completed` cannot leak a lock whose message is already gone. There is deliberately **no** second `ensure` DELETE: a key-only DELETE after a committed release can drop a successor that re-acquired the same key; a failed release is instead healed by the reaper once the archived message is provably gone. Bound `message_exists?` lookups on a logical name also check priority `_pN` sub-queues. Do **not** bulk-delete `pending`/`msg_id=0` rows on upgrade — the reaper heals true orphans on the next cleanup cycle. Refs #418.
159
-
160
264
  - **A job that combines `ensures_uniqueness` (`:until_executed`) with `limits_concurrency on_conflict: :discard` no longer leaves the uniqueness lock held forever.** Enqueue acquires the uniqueness lock first, then the semaphore; a `:discard` conflict dropped the job without sending a message, so no executor could ever release the lock and later equivalent jobs were blocked until someone deleted the `pgbus_uniqueness_keys` row. The discard path now rolls back the lock this enqueue acquired. A uniqueness *duplicate* discard still does not release — that lock belongs to the in-flight job. `:block` conflicts still keep the lock, because `BlockedExecution` stores the tagged payload and the job runs when the semaphore frees. Refs #413.
161
-
162
265
  - **`ActiveJob.perform_all_later` no longer lets jobs escape batch tracking or bypass concurrency limits (issue #413).** `Adapter#enqueue_all`'s bulk path (`enqueue_immediate`) serialized payloads directly to `send_batch`, skipping the metadata-injection steps the individual `enqueue`/`enqueue_at` paths run. Two consequences: **(1)** jobs bulk-enqueued inside a `Pgbus::Batch#enqueue` block were neither tagged with `pgbus_batch_id` nor counted into `total_jobs`, so the batch could finish — and fire `on_finish`/`on_success` — before those jobs ever ran; **(2)** jobs whose class declares `pgbus_concurrency` were sent without semaphore acquisition, silently ignoring their concurrency limit. Bulk payloads now pass through `inject_batch_metadata` (tagging + counting), and concurrency-configured jobs are partitioned out of the bulk path into the individual enqueue path — exactly how uniqueness-configured jobs were already routed, and for the same reason: per-job locks cannot be acquired in a bulk send. Plain jobs keep the fast `send_batch` path unchanged. Refs #413.
163
266
 
267
+ ## [0.14.1] - 2026-08-20
268
+
269
+ ### Fixed
270
+
164
271
  - **The 0.14.0 purge/drop guard no longer crashes on Rails 8.0/8.1 — pool ownership is resolved version-tolerantly (issue #411).** `Pgbus::BusRecord.disconnect_all_pools!` (added in #410 for the #409 wedge) identified its own pools via `pool.connection_class` — an API that exists on Rails 7.1/7.2 but was replaced in Rails 8.0 by `ConnectionPool#connection_descriptor` (a `ConnectionDescriptor` whose `#name` is the owning class name; 8.x `PoolConfig` has no `connection_class` either, so the obvious fallback route doesn't exist). Since the guard is prepended onto every purge/drop path, the `NoMethodError` aborted **every** `db:test:purge` / `db:prepare` on current stable Rails, forcing consumers to cap at `< 0.14`. Ownership is now resolved against whichever API the running Rails exposes — `connection_descriptor&.name == name` on 8.0+ (a nil descriptor, e.g. a null pool, never matches), `connection_class == self` on 7.x — with identical semantics: only `connects_to`-created BusRecord pools are disconnected, `ActiveRecord::Base`'s pool is never touched. The reason CI missed this: the spec stubbed `connection_class` on plain RSpec doubles, which happily fake methods the running Rails doesn't define. The spec now also exercises verifying doubles (`instance_double`) against the real `ConnectionPool`, so both existing matrix legs (7.1 and latest 8.x) fail on any future drift in the ownership API. Refs #411.
165
272
 
273
+ ## [0.14.0] - 2026-08-19
274
+
275
+ ### Fixed
276
+
166
277
  - **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.
167
278
 
168
- - **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.
279
+ ## [0.13.9] - 2026-08-16
280
+
281
+ ### Fixed
169
282
 
283
+ - **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.
170
284
  - **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.
171
285
 
286
+ ## [0.13.8] - 2026-08-14
287
+
288
+ ### Fixed
289
+
172
290
  - **`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.
173
291
 
292
+ ## [0.13.7] - 2026-08-12
293
+
294
+ ### Fixed
295
+
174
296
  - **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.
175
297
 
298
+ ## [0.13.6] - 2026-08-12
299
+
300
+ ### Fixed
301
+
176
302
  - **PGMQ schema installation is now race-safe across clients and processes (issue #397).** `ensure_pgmq_schema` guarded check+install with `@schema_ensured` + `synchronized` — both per-instance, and `synchronized` is a no-op on the dedicated-connection path — so two Client instances (or two threads on the dedicated path) could install concurrently. The loser's `PG::UniqueViolation` (`Key (nspname)=(pgmq) already exists`) surfaced as `SchemaNotReady` even though the schema was fine, and on the shared-AR Proc path — where two instances each hold their *own* mutex around one shared libpq connection — the concurrent install traffic desynced the protocol (`message type 0x… arrived from server while idle`) and left a thread blocked on a socket read forever (downstream forensics: a CI shard going silent until the merge queue's timeout evicted the PR, getzazu/app#3413). Three changes: **(1)** schema bootstrap is serialized process-wide through a class-level mutex, not per-instance state; **(2)** check+install runs inside one explicit transaction holding `pg_advisory_xact_lock` on a fixed key (`Pgbus::Client::PGMQ_INSTALL_LOCK_KEY`), serializing installers across processes — xact-scoped so the lock releases itself at COMMIT/ROLLBACK and stays safe through transaction-pooling poolers, where a session lock's unlock could land on a different server connection; **(3)** a duplicate-object install failure (`PG::UniqueViolation`, `PG::DuplicateSchema`, `PG::DuplicateTable`, `PG::DuplicateObject`, `PG::DuplicateFunction` — a process without the advisory lock, e.g. older pgbus or the extension path, won the race) is rescued by re-checking `pgmq.meta`: present means proceed as installed, absent means the original error is re-raised wrapped in `SchemaNotReady`. Refs #397.
177
303
 
178
- - **SSE delivery no longer strips newlines from broadcast payloads — multiline payloads are framed as consecutive `data:` lines per the SSE spec (issue #392).** `Streams::Envelope.message` collapsed `\r`/`\n` in the payload to nothing before writing the single `data:` line, silently corrupting any whitespace-significant broadcast (pre-formatted `<pre>` content, textarea seeds, JSON-in-data frames) on **both** the ephemeral and durable delivery paths — HTML's whitespace tolerance is why it went unnoticed. A multiline payload is now split on `\r\n`/`\r`/`\n` into consecutive `data:` lines, which EventSource clients rejoin with `\n`, making delivery lossless (a trailing newline survives via an empty final `data:` line; `\r` variants normalize to `\n` — SSE line terminators cannot be carried raw). The original injection defense is preserved: every payload line carries the `data:` prefix followed by one space, so a crafted payload still cannot forge `id:`/`event:` fields, and single-line fields (event names, comments) still strip newlines. The `<pgbus-stream-source>` element's fetch-path parser had the matching client-side bug — it joined `data:` lines without `\n` *and* `trim()`ed payload whitespace — and now follows EventSource semantics (join with `\n`, strip only the single leading space). Refs #392.
304
+ ## [0.13.5] - 2026-08-04
179
305
 
306
+ ### Fixed
307
+
308
+ - **Streams: the transaction probe no longer leases an ActiveRecord connection (#395).** `Stream#current_open_transaction` probed through `ActiveRecord::Base.connection`, which on Rails 7.2+ takes a sticky, executor-scoped lease: on the durable broadcast path a non-executor thread (the Coalescer's flush thread, app worker threads) pinned one pool connection for its lifetime, and inside `connection_pool.with_connection` the caller's connection leaked when its thread died. It now probes `connection_pool.active_connection?` — the existing lease or nil, never a checkout.
309
+
310
+ ## [0.13.4] - 2026-08-04
311
+
312
+ ### Fixed
313
+
314
+ - **SSE delivery no longer strips newlines from broadcast payloads — multiline payloads are framed as consecutive `data:` lines per the SSE spec (issue #392).** `Streams::Envelope.message` collapsed `\r`/`\n` in the payload to nothing before writing the single `data:` line, silently corrupting any whitespace-significant broadcast (pre-formatted `<pre>` content, textarea seeds, JSON-in-data frames) on **both** the ephemeral and durable delivery paths — HTML's whitespace tolerance is why it went unnoticed. A multiline payload is now split on `\r\n`/`\r`/`\n` into consecutive `data:` lines, which EventSource clients rejoin with `\n`, making delivery lossless (a trailing newline survives via an empty final `data:` line; `\r` variants normalize to `\n` — SSE line terminators cannot be carried raw). The original injection defense is preserved: every payload line carries the `data:` prefix followed by one space, so a crafted payload still cannot forge `id:`/`event:` fields, and single-line fields (event names, comments) still strip newlines. The `<pgbus-stream-source>` element's fetch-path parser had the matching client-side bug — it joined `data:` lines without `\n` *and* `trim()`ed payload whitespace — and now follows EventSource semantics (join with `\n`, strip only the single leading space). Refs #392.
180
315
  - **Ephemeral broadcasts over the PG NOTIFY payload cap no longer fail — loudly on the sync path, silently in the coalescer — they auto-degrade to a durable publish (issue #391).** Ephemeral frames ride the NOTIFY payload itself, which PostgreSQL caps below 8000 bytes. Any rendered-component broadcast (a progress card with Tailwind classes easily exceeds it) previously raised `PGMQ::Errors::ConnectionError: … payload string too long` — an error class that sent diagnosis toward the connection, not the payload — and on the `coalesce:` path that raise happened inside the coalescer's flush thread, reaching no caller, no ErrorReporter, no log: small frames delivered, big frames vanished, and the operator saw "SSE works but updates don't arrive". Three changes: **(1)** `Stream#broadcast` now measures the wrapped JSON before the NOTIFY and publishes an over-budget frame durably instead (payload stored in PGMQ, the queue's insert trigger fires the NOTIFY as a bare wake on the same channel the subscriber already LISTENs on) — delivery semantics preserved on both the sync and coalesced paths, warn-logged and instrumented (`pgbus.stream.broadcast` with `ephemeral_fallback: true`). **(2)** Direct `Client#notify_stream` callers get publish-time validation: a typed `Pgbus::Streams::PayloadTooLarge` raised at the call site for payloads exceeding `Pgbus::Client::NotifyStream::NOTIFY_PAYLOAD_LIMIT_BYTES` (7999 bytes, the largest accepted payload), naming the stream, the byte count, and the durable-mode escape hatch. **(3)** The coalescer's flush thread routes every flush error through `ErrorReporter` (same report-don't-log reasoning as #352) — a background thread swallowing delivery failures is invisible to APM by construction. ⚠️ **Upgrade note for 0.13 installs:** `streams_default_broadcast_mode` defaults to `:ephemeral`, and that default is a **behavior change** for apps broadcasting rendered components (what `broadcast_render`-style usage produces) — before this fix, any frame over ~8KB was silently lost or misdiagnosed. Durable is the right mode for turbo-stream UI regardless (since-id replay needs the archive): pin `config.streams_default_broadcast_mode = :durable`, or use `streams_durable_patterns` for the streams that need it; the auto-fallback now covers whatever stays ephemeral. Refs #391.
181
316
 
317
+ ## [0.13.3] - 2026-08-04
318
+
319
+ ### Fixed
320
+
182
321
  - **Queue-age metrics no longer count vt-parked (scheduled/retrying) messages — one delayed job stops reading as a degraded queue (issue #389).** ⚠️ **Behavior change on the AppSignal `pgbus_queue_latency` gauge.** pgmq's `oldest_msg_age_sec` is computed from `enqueued_at` and ignores `vt`, but a job enqueued with `wait:` or parked on a long retry backoff lives in the queue table with a future `vt` — that *is* the delayed-delivery mechanism. So a single parked message made the age metric grow at wall-clock rate for hours on an otherwise drained queue, and any latency alert thresholding on it fired continuously ("oldest message is 17045s old" on a healthy queue with depth 1, `read_ct` 0). Every metrics surface now also exposes **`oldest_claimable_age_sec`** — `now() - min(vt)` over rows with `vt <= now()`, i.e. the age of the oldest message actually *eligible for pickup*: an immediately-enqueued message contributes from enqueue time (matching the old number on a plain backlog), a scheduled/backoff-parked message contributes nothing until due, an in-flight message (vt pushed forward) is excluded, and nil means "no claimable backlog" even when the table is non-empty. Surfaces: `Web::DataSource` (dashboard, JSON API, MCP `pgbus_queues` tool), a new Prometheus gauge `pgbus_queue_oldest_claimable_age_seconds`, a new AppSignal gauge `pgbus_queue_oldest_claimable_age_seconds`, `Pgbus::Client#oldest_claimable_ages` (raw-SQL reader, since pgmq's `metrics_result` type is frozen upstream), and a CLAIMABLE column in `pgbus queues`. The AppSignal **`pgbus_queue_latency` gauge now derives from the claimable age** and always emits — `(claimable_age || 0) * 1000`, 0 = no claimable backlog — so existing latency alerts stop false-firing with no dashboard changes; the raw `pgbus_queue_oldest_message_age_seconds` gauge keeps its enqueue-time semantics everywhere. The dashboard queue tables additionally split depth into **Parked** (`depth − visible`) and show the claimable age in place of the raw age, so a queue holding only backoff retries reads visibly healthy. Refs #389.
183
322
 
323
+ ## [0.13.2] - 2026-08-03
324
+
184
325
  ### Added
185
326
 
186
327
  - **Health-checked rolling restarts for the job container (issue #386).** ⚠️ **Behavior change on the standalone `/readyz`.** The supervisor's `health_port` server previously answered `/readyz` with the cluster-wide HealthAnalyzer verdict — so during a rolling deploy a freshly-booted container could pass an orchestrator's health gate on the strength of the *old* container's still-heartbeating workers, and the old container (with all its capacity) was stopped before the new one had forked a single child. The standalone `/readyz` is now **container-local**: 200 only when *this* supervisor verified its connection, bootstrapped queues, forked every configured child, and all of them are currently alive — with 503 bodies `BOOTING` (pre-boot), `DEGRADED` (a child died and is waiting out crash-restart backoff — precisely the state a deploy gate must fail on, keeping the old container running), and `DRAINING` (stop signal received). No database access on the probe path; the supervisor publishes an immutable snapshot per monitor pass and the accept thread reads it. The Rails-mounted `Pgbus::Web::HealthApp` keeps the cluster-wide verdict unchanged. Alongside it: **`pgbus-health`**, a shipped executable probe for docker `HEALTHCHECK` blocks (plain Ruby + stdlib sockets, loads neither Bundler nor the gem — cheap at 1–5s intervals, works in curl-less images; exit 0/1/2 = healthy/unhealthy/usage), and a README "Rolling restarts (Kamal, docker)" guide covering the healthcheck block, stop-timeout alignment, overlap-window duplicate-supervisor safety, and the `read_ct`-vs-deploy-kill DLQ caveat. Refs #386.
187
328
 
188
329
  ### Changed
189
330
 
190
- - **The gem root's `Gemfile.lock` is now committed.** Every checkout and CI leg resolves the same gem set (the main-Gemfile legs and `release.yml` install it frozen, like the Rails 7.1 and docs locks already were), so a fresh checkout can no longer silently pick up a newer dev dependency than the last green run. The gemspec builds `spec.files` from a path allowlist, so the lock never ships in the gem (verified with `gem build`). `rake release` bumps the `pgbus (X.Y.Z)` pin in all three tracked lockfiles in the bump commit, and `spec/pgbus/frozen_lockfile_sync_spec.rb` guards the root lock too. Contributors: after pulling, `bundle install` is a no-op unless the lock moved; run `bundle install` (not `bundle lock`) when you change a dependency, and commit the lock with it.
191
- - **Client build toolchain: bun 1.3.11 → 1.4.0.** `.bun-version`, the root `engines.bun` floor, and the docs-site CI job (now pinned to the same `.bun-version` file instead of `latest`) move together. Both `bun.lock` files install `--frozen-lockfile` unchanged; pgbus ships no bun-built artifacts, so nothing in the gem changes.
192
-
193
331
  - **Shutdown budgets are now alignable end-to-end (issue #386).** New `config.shutdown_timeout` bounds how long the supervisor waits for children after forwarding TERM before escalating to SIGKILL — previously a hardcoded 30s, which silently SIGKILLed workers mid-drain the moment `drain_timeout` was raised past it. Default derives `drain_timeout + 5` so the deadline tracks the drain window automatically; an explicit value below `drain_timeout` logs a boot warning. `Consumer#shutdown`'s pool wait (its only drain bound) now follows `config.drain_timeout` instead of a hardcoded 30s, and `Worker#shutdown`'s post-drain residual wait drops from a second full 30s window to 5s — the drain loop already waited `drain_timeout`, and a job still running has proven it won't finish. Rule of thumb: orchestrator stop grace period > `shutdown_timeout` > `drain_timeout`. Refs #386.
194
332
 
333
+ ### Fixed
334
+
335
+ - **`idempotent!` handlers no longer silently drop an execution when the consumer is killed mid-handler — the idempotency claim is now two-phase (issue #385).** `EventBus::Handler#process!` inserted the `pgbus_processed_events` claim row *before* `handle(event)` ran, so a SIGKILL between claim and completion (supervisor watchdog, container runtime after its stop timeout, OOM — i.e. every container-replacement deploy's worst case) turned the redelivered message into a skip: the mechanism meant to dedup *duplicate* executions converted a crash *during* the one permitted execution into at-most-once. The claim now lands with a NULL `completed_at` (*pending*) and is stamped completed only after `handle` returns; on redelivery a pending claim **re-runs** the handler while a completed claim still skips. The in-memory dedup cache only ever records completed executions, and a `handle` that raises leaves the claim pending so VT redelivery retries it. The only double-execution window is a still-alive handler running past its visibility timeout — the same at-least-once window every non-idempotent handler already has. **Upgrading:** new installs get the column from `pgbus:install`; existing installs run `rails generate pgbus:add_processed_event_completion` (also picked up by `pgbus:update`; `--database` supported) — legacy rows are backfilled as completed so history is not retroactively re-run. Until the migration runs, an upgraded gem detects the missing column once and falls back to the legacy single-phase claim with a warning naming the generator. Refs #385.
336
+
337
+ ## [0.13.1] - 2026-08-02
338
+
339
+ ### Changed
340
+
195
341
  - **Streams: one LISTEN connection per web host — `streams_listen_scope` (issue #382).** ⚠️ **Default behavior change.** Previously every Puma worker lazily opened its own dedicated streams LISTEN connection on first SSE use, so a web host pinned one direct connection per worker. Under the new default (`streams_listen_scope = :master`) the `pgbus_streams` Puma plugin runs a **MasterHub** in the preforking master: ONE `Web::Streamer::Listener` on the refcounted union of every worker's stream channels, fanning wakes — **including ephemeral payloads** — out to workers over a Unix domain socket with length-prefixed frames (`Streamer::HubProtocol`). Workers connect lazily (nothing is inherited across fork) and the synchronous `ensure_listening` ack contract is preserved cross-process: a sub is registered before LISTEN executes and acked only after, so the no-lost-broadcast guarantee holds. Backpressure follows the streams rules: durable wakes are droppable at a per-worker cap (they self-heal via `read_after`), **ephemeral wakes are never dropped** — a worker that stops draining is evicted, which triggers its own fallback. **Fallback is per-worker listeners, not loss**: whenever the hub is absent or dies (no `preload_app!`, single-mode Puma, crash, eviction) each worker's `FailoverListener` swaps in a real per-worker `Listener` and re-LISTENs its recorded subscriptions — connection footprint balloons back to pre-#382 levels (census-visible) but no broadcast semantics change; the worker stays local until it recycles. Measured (local PG, n=50): the master→worker hop is noise-level free — single-broadcast SSE roundtrip p50 16.00ms via the hub vs 16.93ms per-worker. **`:master` effectively requires `preload_app!`** (the hub waits for the app's pgbus initializer; without it the deadline expires quietly and workers stay per-worker). **Rollback:** `config.streams_listen_scope = :process`. Refs #382, builds on the #381 patterns.
196
342
 
343
+ ## [0.13.0] - 2026-08-02
344
+
345
+ ### Changed
346
+
197
347
  - **Host-level shared LISTEN: `worker_notify_scope` — the supervisor now owns ONE direct LISTEN connection for the whole host (issue #381).** ⚠️ **Default behavior change.** Previously every worker fork and every consumer fork opened its own dedicated LISTEN connection (`NotifyListener`), so a host's direct-connection footprint scaled with fork count — on transaction-pool PgBouncer platforms those connections come out of the scarcest slice of `max_connections`, and a 5-capsule + 2-consumer host pinned 7. Under the new default (`config.worker_notify_scope = :supervisor`) the supervisor runs a single `NotifyHub`: one `NotifyListener` on the union of every capsule's and consumer's queue channels (wildcards via the shared resolver, consumer sets via the registry), fanning wakes out to forks over per-fork pipes (`W` wake / `H` healthy / `P` degraded bytes; a fork whose pipe reports degraded or reaches EOF falls back to fast polling exactly like a failed local listener). Footprint drops to **1 direct LISTEN connection per job host**, verified by integration test: routing is per-fork (an insert wakes only the forks reading that queue, wildcard capsules unconditionally), and `pg_terminate_backend` on the shared connection is survived — reconnect, re-LISTEN, wakes flow again. **Rollback:** `config.worker_notify_scope = :fork` restores the previous per-fork listeners byte-for-byte. Dedicated LISTEN connections are now census-tagged `application_name=pgbus-listen` so `pg_stat_activity` can count them. Refs #381.
198
348
  - **`pgbus doctor`: new "Connection budget" check (issue #381).** Prints how many direct LISTEN connections the current config pins — 1 per host under `:supervisor` scope, capsules + consumers under `:fork` (honoring `config.roles`), plus a "+1 per web-server process (streams)" clause — so operators can do pooler capacity math from the doctor output alone. Informational, always `:ok`. Refs #381.
199
349
  - **Benchmarks: `rake bench:notify_wake` and `rake bench:notify_chaos` (issue #381).** Wake-path latency (send → wake, p50/p95/p99, direct vs hub-mediated), empty-read cost, LISTEN connection census, and failure-mode measurements (killed LISTEN backend, wedged fork, FD churn, fan-out cost). Refs #381.
350
+ - **Multi-queue strict-priority contract documented and pinned (issue #381).** The capsule DSL's "list order = strict priority" promise rides on Postgres's Append node filling `read_multi`'s `UNION ALL … LIMIT` from earlier-listed subqueries first — incidental behavior nothing upstream promises. The contract (and the vt-claim caveat: subquery rows discarded by the outer LIMIT are still claimed and go invisible for one visibility timeout) is now documented on `Client#read_multi` and the capsule DSL, and pinned by an integration canary (`spec/integration/multi_queue_priority_spec.rb`) so a planner or pgmq-ruby change breaks loudly instead of silently. `Consumer#wake_timeout` also gained the live-but-deaf listener consult (`delivering?`) for parity with Worker (issue #332). Refs #381.
200
351
 
201
- ### Changed
352
+ ## [0.12.4] - 2026-07-24
202
353
 
203
- - **Multi-queue strict-priority contract documented and pinned (issue #381).** The capsule DSL's "list order = strict priority" promise rides on Postgres's Append node filling `read_multi`'s `UNION ALL … LIMIT` from earlier-listed subqueries first — incidental behavior nothing upstream promises. The contract (and the vt-claim caveat: subquery rows discarded by the outer LIMIT are still claimed and go invisible for one visibility timeout) is now documented on `Client#read_multi` and the capsule DSL, and pinned by an integration canary (`spec/integration/multi_queue_priority_spec.rb`) so a planner or pgmq-ruby change breaks loudly instead of silently. `Consumer#wake_timeout` also gained the live-but-deaf listener consult (`delivering?`) for parity with Worker (issue #332). Refs #381.
354
+ ### Fixed
355
+
356
+ - **SIGTERM no longer segfaults the process: both LISTEN listeners stopped closing their PG connection from the stopping thread (issue #375).** `Process::NotifyListener#stop` (supervisor thread, on SIGTERM) closed the listener's dedicated `PG::Connection` to interrupt the blocking `wait_for_notify`, but left `@conn` pointing at it — so the listener thread unwound into `run_loop`'s `ensure` and ran `safe_unlisten_all`, exec'ing `UNLISTEN` on a connection the other thread was concurrently freeing. `PG::Connection#close` is `PQfinish`: it frees the PGconn **and its OpenSSL objects**, so `PQsendQuery` walked into freed TLS state and the whole worker died with `[BUG] Segmentation fault` — reproducibly, on essentially every container-replacement deploy against a TLS Postgres, sometimes in several forked workers at once. `rescue PG::Error` cannot catch a C-level SEGV, so the "safe" in `safe_unlisten_all` never held. **The connection is now single-owner**: the listener thread is the only thread that may exec, wait, or close on it, from build through teardown. `#stop` signals by clearing `@running` and joining — nothing more — and the listener thread closes its own connection in `run_loop`'s `ensure`. The stop is observed within one `wait_for_notify` timeout (`health_check_ms`, **250 ms** in the default configuration since workers/consumers derive it from `polling_interval`), so `#stop`'s join budget is now `health_check_ms + 5s` instead of a flat 5 s — a flat timeout could expire before a listener with a large `health_check_ms` had even one chance to observe the stop. The teardown `UNLISTEN` round-trip is **dropped entirely** rather than merely made safe: it ran immediately before the close, and closing a session deregisters every `LISTEN` server-side, so it bought nothing at any time. The identical defect in `Web::Streamer::Listener` — same close-from-`#stop`, same `UNLISTEN`-in-`ensure`, and there without even a mutex around `@conn` — is fixed the same way, so the dashboard's Puma worker stops crashing on the same deploy; its listener thread now also closes its own connection (previously only `#stop` did). Cost: shutdown can take up to one health-check cycle longer per listener — measured against a real PostgreSQL at the default `health_check_ms` of 250: **~207 ms on a fully idle queue, ~5 ms when the queue has any NOTIFY traffic** (a notification returns the wait immediately, and the loop then sees the cleared flag). 40 start/stop cycles under concurrent NOTIFY load leaked zero `LISTEN` backends. Thanks to the reporter for the crash dumps and the root-cause analysis. Refs #375.
357
+
358
+ ## [0.12.3] - 2026-07-18
359
+
360
+ ### Changed
204
361
 
205
362
  - **README: doctor table and configuration reference catch up to the shipped surface (issue #369).** The doctor section still said "six checks" and listed only the original six; `Pgbus::Doctor::CHECKS` has 10 (GlobalID allowlist, Broadcast queue, Primary affinity, Dedicated connections were missing — the first two are the ones most likely to warn after upgrade on nil defaults). The configuration reference table was missing ~20 shipped options operators actually hit: `allowed_global_id_models` (incl. `[]` = deny-all), streams GC (`streams_orphan_threshold` / `streams_orphan_sweep_interval` — 24h / hourly defaults that auto-drop dormant durable stream queues), retention/durable/presence/broadcast-queue knobs, `streams_pool_*` / LISTEN connection overrides, `doctor_on_boot`, `require_primary`, `connects_to`, `zombie_detection`, `connection_guc_mode`, `worker_notify_*`, `group_mode`, and neighbors. A unit drift guard pins the README doctor table to every `Doctor::CHECKS` name. Full typed reference remains on the docs site. Refs #369.
206
363
 
207
364
  ### Fixed
208
365
 
209
- - **`idempotent!` handlers no longer silently drop an execution when the consumer is killed mid-handler — the idempotency claim is now two-phase (issue #385).** `EventBus::Handler#process!` inserted the `pgbus_processed_events` claim row *before* `handle(event)` ran, so a SIGKILL between claim and completion (supervisor watchdog, container runtime after its stop timeout, OOM — i.e. every container-replacement deploy's worst case) turned the redelivered message into a skip: the mechanism meant to dedup *duplicate* executions converted a crash *during* the one permitted execution into at-most-once. The claim now lands with a NULL `completed_at` (*pending*) and is stamped completed only after `handle` returns; on redelivery a pending claim **re-runs** the handler while a completed claim still skips. The in-memory dedup cache only ever records completed executions, and a `handle` that raises leaves the claim pending so VT redelivery retries it. The only double-execution window is a still-alive handler running past its visibility timeout — the same at-least-once window every non-idempotent handler already has. **Upgrading:** new installs get the column from `pgbus:install`; existing installs run `rails generate pgbus:add_processed_event_completion` (also picked up by `pgbus:update`; `--database` supported) — legacy rows are backfilled as completed so history is not retroactively re-run. Until the migration runs, an upgraded gem detects the missing column once and falls back to the legacy single-phase claim with a warning naming the generator. Refs #385.
210
-
211
- - **SIGTERM no longer segfaults the process: both LISTEN listeners stopped closing their PG connection from the stopping thread (issue #375).** `Process::NotifyListener#stop` (supervisor thread, on SIGTERM) closed the listener's dedicated `PG::Connection` to interrupt the blocking `wait_for_notify`, but left `@conn` pointing at it — so the listener thread unwound into `run_loop`'s `ensure` and ran `safe_unlisten_all`, exec'ing `UNLISTEN` on a connection the other thread was concurrently freeing. `PG::Connection#close` is `PQfinish`: it frees the PGconn **and its OpenSSL objects**, so `PQsendQuery` walked into freed TLS state and the whole worker died with `[BUG] Segmentation fault` — reproducibly, on essentially every container-replacement deploy against a TLS Postgres, sometimes in several forked workers at once. `rescue PG::Error` cannot catch a C-level SEGV, so the "safe" in `safe_unlisten_all` never held. **The connection is now single-owner**: the listener thread is the only thread that may exec, wait, or close on it, from build through teardown. `#stop` signals by clearing `@running` and joining — nothing more — and the listener thread closes its own connection in `run_loop`'s `ensure`. The stop is observed within one `wait_for_notify` timeout (`health_check_ms`, **250 ms** in the default configuration since workers/consumers derive it from `polling_interval`), so `#stop`'s join budget is now `health_check_ms + 5s` instead of a flat 5 s — a flat timeout could expire before a listener with a large `health_check_ms` had even one chance to observe the stop. The teardown `UNLISTEN` round-trip is **dropped entirely** rather than merely made safe: it ran immediately before the close, and closing a session deregisters every `LISTEN` server-side, so it bought nothing at any time. The identical defect in `Web::Streamer::Listener` — same close-from-`#stop`, same `UNLISTEN`-in-`ensure`, and there without even a mutex around `@conn` — is fixed the same way, so the dashboard's Puma worker stops crashing on the same deploy; its listener thread now also closes its own connection (previously only `#stop` did). Cost: shutdown can take up to one health-check cycle longer per listener — measured against a real PostgreSQL at the default `health_check_ms` of 250: **~207 ms on a fully idle queue, ~5 ms when the queue has any NOTIFY traffic** (a notification returns the wait immediately, and the loop then sees the cleared flag). 40 start/stop cycles under concurrent NOTIFY load leaked zero `LISTEN` backends. Thanks to the reporter for the crash dumps and the root-cause analysis. Refs #375.
212
366
  - **The health verdict no longer emits false STALLED reports — `max_read_ct` was never populated, and the wedge signal counted queues no capsule drains (issue #367).** Two correctness defects in `Pgbus::MCP::HealthAnalyzer` (surfaced through `pgbus doctor` / `pgbus_health` / the MCP health tool). **(1)** The `all_unread?` wedge check read `:max_read_ct`, but the metrics query never selected it — so the guard always degenerated to "never claimed" and a busy-but-healthy queue caught mid-burst produced a STALLED verdict with a factually wrong "read_ct=0 (never claimed)" reason. The metrics query now exposes a per-queue **`visible_unread_length`** (`count(*) WHERE vt <= NOW() AND read_ct = 0`) and the analyzer keys the wedge off *visible, never-claimed* messages. Counting per visible message (not `max(read_ct)` over the whole table) means one retried message left in-queue after its backoff, or an in-flight message claimed by a peer, can no longer veto the signal for a pile of genuinely-unclaimed jobs. **(2)** The verdict reasoned about **every** non-DLQ/non-stream queue against the global worker fleet — but a worker can only claim from queues its capsule subscribes to, so "M workers alive but never claimed" was vacuous for a queue nobody drains (ad-hoc queues, unregistered stream queues — see #366). The analyzer now intersects the STALLED backlog with `Web::DataSource#drained_queue_names` (each configured capsule's queues, priority `_pN` sub-tables expanded via the client's queue strategy, unioned with EventBus handler queues; `nil` for a `*` wildcard = drains everything, fail-open on error). Queues nobody drains get their own DEGRADED signal — *"N queue(s) hold messages but no capsule is configured to drain them"* — instead of being folded into the worker-wedge verdict. A heart-beating-but-`:stalled` worker is still reported STALLED regardless of which queue holds the backlog. Reason strings now truncate to the first 10 queue names with `(+N more)` so a flagged fleet of hundreds of queues doesn't produce a multi-KB log line. Refs #367, #366.
213
367
  - **`allowed_global_id_models` now actually guards ActiveJob arguments, not only EventBus payloads (issue #368).** The doctor warned in production that `nil` means "allow-all GlobalID arguments", but the allowlist was only enforced in `Serializer.locate_global_id` — reached from EventBus `_global_id` payloads — while the ordinary job path (`Executor` → `ActiveJob::Base.deserialize` → Rails' unrestricted `GlobalID::Locator`) never checked it. Operators who set an allowlist after following the doctor had a false sense of security; the common `SomeJob.perform_later(record)` pattern was unguarded. Job deserialization now goes through `Serializer.deserialize_job_data`, which walks `_aj_globalid` keys (including nested arrays/hashes) and reuses the same gate as EventBus when the allowlist is set; `nil` remains zero-cost allow-all. Rejected models raise `Pgbus::SerializationError` and are treated as a normal job failure. Apps with ActiveStorage attachments should include `ActiveStorage::Blob` (and related models) on the allowlist. Docs + doctor copy updated. Refs #368.
214
368
  - **Dormant pre-registry stream queues no longer false-STALL the doctor or escape the orphan sweep (issue #366).** Stream queues created before the `pgbus_stream_queues` migration only register on their next broadcast — completed checkout-style flows, ended chats, and one-shot progress streams never broadcast again, so they stay unregistered forever. Health/doctor then treated their permanent `read_ct=0` visible backlog as a worker wedge (STALLED on ~hundreds of healthy stream queues), and the orphan sweep skipped them because it only iterated the registry. Detection is fingerprint-based (the archive `a_<queue>_msg_id_idx` index that only `ensure_stream_queue` creates — no name heuristics). `StreamQueue.known_names` = registry ∪ fingerprints, used by health exclusion, orphan sweep / stream-archive prune, and wildcard workers; `rake pgbus:streams:backfill_registry` persists the missing rows; doctor surfaces a DEGRADED hint pointing at the backfill when unregistered fingerprints remain; `add_stream_queues` generator post-install mentions the task. Refs #366.
369
+
370
+ ## [0.12.2] - 2026-07-17
371
+
372
+ ### Fixed
373
+
215
374
  - **`ensure_stream_queue` recovers when a process-local `@queues_created` memo is stale after another process drops the physical queue.** Stream queues are created lazily and memoized per process. The dispatcher orphan stream sweep (and dashboard / manual `drop_queue`) only clears the *local* process memo when it drops an empty or aged stream queue. Peer processes — typically long-lived web workers that already published or subscribed once — keep the memo, skip `pgmq.create` on the next durable broadcast, and fail in `pgmq.enable_notify_insert` with `Queue "…" does not exist. Create it first using pgmq.create()`. That exception often surfaces from an `after_commit` Turbo/stream broadcast and turns a successful write into a 500. On that specific missing-queue `PGMQ::Errors::ConnectionError`, `ensure_stream_queue` now forgets the local queue + archive-index memo and retries create/notify once. Unrelated connection errors still raise immediately. Happy path: no extra round-trips (recovery is error-path only).
375
+
376
+ ## [0.12.1] - 2026-07-15
377
+
378
+ ### Fixed
379
+
216
380
  - **`streams_pool_database_url` / `streams_pool_host` / `streams_pool_port` — route the streams PGMQ pool independently of the streamer's LISTEN connection (issue #358).** 0.12.0 started building the dedicated streams pool from `streams_connection_options` (correct for a separate streams database), but that method is also how pooler-bypass installs pin the LISTEN connection to the **direct** Postgres port (`streams_port = 5432`, the documented "workers go through PgBouncer, streamer goes direct" pattern) — so upgrading silently moved up to `streams_pool_size` connections **per process** onto the direct port, whose `max_connections` ceiling on managed Postgres is typically low. A modest fleet exhausts it (`FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute`): `StreamApp` then 500s every SSE connect and durable broadcasts fail at publish, intermittently, because the pools are lazy. Only LISTEN actually needs the direct port (it dies at transaction-pool COMMIT boundaries); the pool's broadcast INSERTs and replay reads are plain pooler-safe SQL. The new `streams_pool_*` triple routes the POOL independently — applied to the base options exactly like the `streams_*` and `worker_notify_*` groups: set `streams_pool_port` back to the pooled port and only the LISTEN pins remain direct. Default `nil` = follows `streams_connection_options`, byte-identical to 0.12.0, so separate-streams-DB installs are unchanged. Refs #358.
217
381
  - **The health verdict no longer reads durable stream queues as a wedged fleet (issue #359).** The Process-liveness signal (`pgbus doctor`, `pgbus_health`, the MCP health tool) treats *visible messages with `read_ct=0` while workers are alive* as the silent-worker-wedge signature — but durable stream delivery is a non-consuming peek, so **every** stream queue matches it permanently by design. On a streams-heavy install the verdict screamed STALLED listing hundreds of healthy stream queues, burying real wedges in noise. `HealthAnalyzer` now excludes queues registered in the `pgbus_stream_queues` registry from the operational set (exactly like DLQs) — out of the STALLED/DEGRADED reasons and the backlog totals — via a new `Web::DataSource#stream_queue_names` (loaded fresh per verdict; degrades to an empty set on pre-registry installs). Same bug class as #308/#309, same registry cure. Refs #359.
218
382
  - **The `upgrade_pgmq` migration no longer silently kills NOTIFY-gated wakeups fleet-wide (issue #360).** The generated migration drops every pgmq function **with CASCADE** — which also drops the `trigger_notify_queue_insert_listeners` trigger from every existing queue table (it depends on the dropped `pgmq.notify_queue_listeners()`), and `install_sql` re-created the functions but never the per-queue triggers. Result: after every PGMQ schema upgrade, NOTIFY wakeups (#174) silently died and workers fell back to `polling_interval` polling — nothing errored; latency degraded and DB poll load rose. Trigger re-install only happened in `ensure_single_queue` (per-process memoized), so on the common deploy ordering where the job supervisor boots *before* migrations run, the supervisor installed triggers at boot and the migration wiped them minutes later — with nothing left to re-install them until a full process restart. New `PgmqSchema.reinstall_notify_triggers_sql` replays `pgmq.enable_notify_insert` for every row in `pgmq.notify_insert_throttle` (a table, preserved by the upgrade, recording exactly which queues had notify and at what throttle; the function is idempotent), and the migration template runs it after `install_sql` — restoring every trigger at its original interval. No-op on vendored versions without the notify feature. **Already ran an upgrade migration?** Run `Pgbus::PgmqSchema.reinstall_notify_triggers_sql` once (e.g. from a console: `ActiveRecord::Base.connection.execute(...)`) or restart the fleet; `pgbus doctor`'s LISTEN/NOTIFY check confirms the repair. Refs #360.
383
+ - **`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.
219
384
 
220
- ### Breaking Changes
221
-
222
- - **`config.validate!` now rejects malformed values for 12 core config keys at boot instead of failing deep in a worker or silently misbehaving (issue #335).** Before the 1.0 surface freeze, these headline job-path keys had **zero** validation while streams keys had 14 checks — so a bad value surfaced as a crash inside a worker/dispatcher/poller/scheduler thread, per-enqueue, or by silently corrupting every derived queue name / leaving the dashboard open. `Pgbus.configure` (eager-validating by default) now raises `Pgbus::ConfigurationError` naming the key for: the worker-recycling trio `max_jobs_per_worker`/`max_memory_mb`/`max_worker_lifetime` (positive number or `nil` to disable); the intervals `dispatch_interval`/`outbox_poll_interval`/`recurring_schedule_interval` (positive number); `outbox_batch_size` (positive integer); `default_priority` (non-negative integer — `0` is a valid level); `queue_prefix`/`default_queue` (non-empty String — an empty prefix silently produced malformed queue names); `web_auth` (a callable or `nil` — a non-callable silently left the dashboard open); `error_reporters` (an Array); and `connects_to` (a Hash or `nil` — previously a malformed value raised a raw `TypeError` deep in engine boot). This only rejects configuration that was already broken; if a `Pgbus.configure` block set one of these to an invalid value it will now fail loudly at boot (the intended pre-freeze tightening). Set `config.eager_validation = false` to defer. Refs #335, #282.
223
- - **`config.streams_queue_prefix` is removed (issue #335).** It had been an inert no-op since #308 — stream queues are named like job queues (`#{queue_prefix}_<name>`) and identified via the `pgbus_stream_queues` registry, so the prefix affected nothing — yet it was still a writable accessor documented as "Prefix for stream queues" (false). Rather than carry a dead, misleadingly-documented key into the 1.0 semver promise, the accessor is **removed**: `c.streams_queue_prefix = ...` now raises `NoMethodError`. **Action:** delete the line from your initializer — it never did anything. Refs #335, #308.
224
- - **`ensures_uniqueness strategy: :until_executed` with no `key:` now raises when the job is enqueued *with arguments* — the class-name default silently collapsed per-record jobs (issue #333).** Without an explicit `key:`, the uniqueness key defaulted to the job class name. For a job that takes per-record arguments (`ImportOrderJob.perform_later(order_id)`), every distinct `order_id` resolved to the same class-name key and collapsed into ONE per-class singleton — order 1 and order 2 shared a lock, so the second was silently discarded/rejected — with no warning. The guard is at **resolve time, argument-aware**: a no-key `:until_executed` job enqueued **with arguments** now raises `ArgumentError` naming the collapse and pointing at the upgrade guide; **a no-argument job keeps the class-name default** (a legitimate "one logical instance at a time" — e.g. a recurring `CleanupJob` that must not overlap itself — is unaffected). `:while_executing` is unaffected (it acquires per-invocation at execution start, not by class-name identity at enqueue). Fix: pass `key: ->(*args) { "job-#{args.first}" }` on any argument-taking `:until_executed` job. Refs #333.
225
- - **YAML config (`config/pgbus.yml`) is removed for 1.0 — the Ruby initializer is the only config surface.** Ruby was always the real config surface (every production setup is a `Pgbus.configure` initializer); `config/pgbus.yml` was only ever a legacy convenience, and pre-1.0 is the last low-risk moment to drop it before the config shape becomes a compatibility promise. Concretely: the engine no longer loads `config/pgbus.yml` at boot (the `pgbus.configure` initializer and `Pgbus::ConfigLoader` are deleted); `rails generate pgbus:install` generates `config/initializers/pgbus.rb` (done for 1.0, see below); and `rails generate pgbus:update` no longer converts YAML → initializer (`Pgbus::Generators::ConfigConverter`, its `--source`/`--destination`/`--skip-config` options, and the `pgbus.yml.erb` template are deleted) — `pgbus:update` now only detects and installs missing migrations. **If you still have a `config/pgbus.yml`, it is no longer loaded** — the engine warns once at boot (pointing at the docs) so the inert file doesn't quietly diverge from your real config. Port its settings into `config/initializers/pgbus.rb` as a `Pgbus.configure` block and delete the YAML; the last release that auto-converts it via `pgbus:update` is 0.9.x, so run that conversion (or port by hand) **before** upgrading to 1.0. The recurring-tasks YAML (`config/recurring.yml`, `Pgbus::Recurring::ConfigLoader`) is a separate surface and is unchanged. Refs #317.
226
- - **The error hierarchy is unified under `Pgbus::Error` for the 1.0 API freeze.** Several call sites raised bare stdlib errors that no `rescue Pgbus::Error` could catch, freezing an inconsistent contract into 1.0. Now: **`Configuration#validate!` and every config setter raise `Pgbus::ConfigurationError`** (was `ArgumentError`) — this is the one that can break existing code: a `rescue ArgumentError` around `Pgbus.configure` or a boot-time config read will no longer catch config failures; switch to `rescue Pgbus::Error` (or `rescue Pgbus::ConfigurationError`). The ActiveJob adapter's `perform_all_later` msg_id-count mismatch now raises **`Pgbus::EnqueueError`** (new, was `RuntimeError`); `ExecutionPools::AsyncPool` shutdown/at-capacity raises now use **`Pgbus::ExecutionPoolError`** (new, was `RuntimeError`); `Serializer#locate_global_id` GlobalID rejections raise **`Pgbus::SerializationError`** (was `ArgumentError`). Three error classes that bypassed the base — `PgmqSchema::VersionNotFoundError` and `Streams::SignedName::InvalidSignedName`/`MissingSecret` — are re-parented under `Pgbus::Error`. Three classes that reject a malformed *argument shape* — `Configuration::CapsuleDSL::ParseError`, `Streams::Cursor::InvalidCursor`, `Streams::StreamNameTooLong` — **deliberately stay `ArgumentError` subclasses**; the policy ("argument-shape errors are `ArgumentError`, operational errors are `Pgbus::Error`") is documented in `lib/pgbus.rb` and pinned by `spec/pgbus/error_hierarchy_spec.rb`. Messages are unchanged. Refs #282.
385
+ ## [0.12.0] - 2026-07-15
227
386
 
228
387
  ### Added
229
388
 
230
389
  - **`Pgbus::Client#reload` — operator escape hatch that drops every pooled PGMQ connection (job pool AND the live streams pool) and lets the pools rebuild lazily on next checkout (issue #354).** Built on pgmq-ruby 0.7.1's `PGMQ::Client#reload`; use it to recover connections libpq still reports as `CONNECTION_OK` but that are in fact wedged (e.g. after a wall-clock interrupt cut a query mid-flight), which pgmq-ruby's checkout health check cannot detect. Unlike `#close` the pools stay usable, and connections checked out by other threads mid-reload are unaffected. The streams half goes through the `ResizablePool` wrapper, so it always targets the live (possibly hot-swapped, #323) client. No-op with a warning on the shared-AR Proc path — those pool slots wrap ActiveRecord's own raw connection, and pgbus won't close a socket it doesn't own. Refs #354.
231
390
  - **PGMQ: vendored schema v1.12.0.** Adds `pgmq.read_grouped_head_with_poll` — a polling wrapper over `read_grouped_head` (reads the heads of N FIFO groups, waiting up to `max_poll_seconds` for messages); the 1.11.1 → 1.12.0 upstream delta is otherwise comments/whitespace only, so nothing pgbus calls through `Pgbus::Client` changes. Fresh embedded installs get 1.12.0; existing installs: `rake pgbus:pgmq:status`, then `rails generate pgbus:upgrade_pgmq` + `rails db:migrate` (`rails db:migrate:pgbus` on a separate-database install). No `Pgbus::Client` API is added for the new function (the locked pgmq-ruby does not expose it; pgbus has no consumer). Refs #351.
391
+
392
+ ### Fixed
393
+
394
+ - **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.
395
+
396
+ ## [0.11.4] - 2026-07-15
397
+
398
+ ### Added
399
+
232
400
  - **Doctor check 10: "Dedicated connections" — the preflight now opens the streamer/notify-listener connections exactly the way the runtime does (issue #352).** The streamer's LISTEN connection and the worker `NotifyListener` bypass the Client's pgmq pools entirely (they own a single raw `PG` connection each), so every existing doctor check — all of which probe through `Pgbus::Client` — passed while both dedicated paths were broken. The new check builds each configured dedicated connection via `Pgbus::DedicatedConnection` (streams when `streams_enabled`, notify when `worker_notify_wakeup?`), runs `SELECT 1`, and closes it, failing with the underlying PG error and the affected path label. Not strict-fatal (same reasoning as the Database check: a transient DB blip at boot must not lockstep-abort a fleet), but with `doctor_on_boot` it turns "every SSE request 500s after deploy" into a named failing check in the boot report. Refs #352.
401
+
402
+ ### Changed
403
+
404
+ - **Streaming/notify failures now reach `config.error_reporters`, not just the log (issue #352).** Four error-level rescues were logger-only, so an error tracker (AppSignal/Sentry/…) never saw them: a `StreamApp` request rescued into a 500 (which, for a broken streamer config, is *every* SSE request on the worker), a `NotifyListener` fatal thread exit, a `NotifyListener` reconnect failure, and a streamer `Listener` reconnect failure. All four now go through `Pgbus::ErrorReporter.report` with an `action:` context tag (`stream_connect`, `notify_listener_fatal`, `notify_listener_reconnect`, `streamer_reconnect`); ErrorReporter logs as before, so operators without reporters lose nothing. Refs #352.
405
+
406
+ ### Fixed
407
+
408
+ - **`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.
409
+
410
+ ## [0.11.3] - 2026-07-11
411
+
412
+ ### Added
413
+
233
414
  - **`pgbus dashboard` — print the vendored AppSignal dashboards as import-ready JSON.** The gem ships four AppSignal dashboard definitions (the main one submitted to appsignal/public_config#80 plus the health/streams/throughput extras), but they're stored in the automated-dashboard format — a `metric_keys` trigger wrapping the `dashboard` object — which AppSignal's "Import dashboard" dialog doesn't accept, so trying one on a real app meant hand-unwrapping JSON. `pgbus dashboard [main|health|streams|throughput]` prints the inner dashboard object ready to paste into the import dialog (`pgbus dashboard health | pbcopy`); `pgbus dashboard --list` enumerates the available names and titles.
415
+
416
+ ### Fixed
417
+
418
+ - **AppSignal main dashboard: job and event lines now split by `job_class` and `routing_key`.** Applied the upstream review suggestions committed on appsignal/public_config#80 to the vendored `dashboard.json`: the "Job status per queue" panel gains a `job_class` wildcard tag filter and label (`%job_class% - %queue% - %status%`), and "Event handler status" gains `routing_key` (`%handler% - %status% - %routing_key%`). Without the wildcard tag entry a `%placeholder%` in the line label never resolves, so lines from different job classes / routing keys were collapsed together. The subscriber already emitted both tags; only the dashboard definition lagged.
419
+
420
+ ## [0.11.2] - 2026-07-07
421
+
422
+ ### Added
423
+
234
424
  - **`config.doctor_on_boot` (+ `pgbus start --doctor` / `--doctor-strict`) — run the doctor preflight inside the booting supervisor, one Rails boot instead of two (issue #347).** The recommended deploy preflight was `bin/pgbus doctor || true` in the container entrypoint before `bin/pgbus start` — but both commands `require config/environment`, so the job container boots the full Rails app **twice** on every deploy (cost scaling with app boot time), and running doctor pre-supervisor makes the `Process liveness` check false-fail (no workers exist yet), which is exactly why the entrypoint has to swallow the exit code with `|| true` — losing any chance to gate on a genuinely-fatal finding. Now `config.doctor_on_boot = :report` (or `pgbus start --doctor`) runs the checks in the already-booted supervisor, after the DB is verified reachable and queues are bootstrapped but **before any worker is forked**: one boot, and the worker-dependent `Process liveness` check is skipped (it has no workers to observe yet and would false-fail on stale prior-generation rows during a redeploy). `config.doctor_on_boot = :strict` (or `--doctor-strict`) additionally **refuses to boot** — raising before forking anything, so the `ensure shutdown` path tears down the heartbeat/health server and no child starts — but *only* on a genuinely-fatal, non-transient check: a `Configuration` failure (a real config bug) or an **absent** PGMQ schema. It deliberately does **not** abort on `Queues`/`Database` failures — those are the transients the lenient queue bootstrap is built to ride out (children crash-and-backoff until the DB recovers), and `verify_connection!` already gated a hard-down DB moments earlier; making them strict-fatal would take down a whole fleet's cold boot in lockstep on a momentarily-saturated primary. Default `nil`/`false` = off (byte-identical to today; the supervisor constructs no `Doctor` and does zero extra work). The standalone `pgbus doctor` command and its exit-code semantics are unchanged. Refs #347.
425
+
426
+ ## [0.11.1] - 2026-07-07
427
+
428
+ ### Fixed
429
+
430
+ - **Migration generators now route to the pgbus database when `connects_to` is configured, even without an explicit `--database` (issue #344).** `rails g pgbus:add_stream_queues` (and every sibling generator via `Generators::MigrationPath`) keyed the separate-database decision purely on the `--database` flag. An app that had already set `config.connects_to = { database: { writing: :pgbus } }` (so its pgbus migrations live in `db/pgbus_migrate/` per database.yml's `migrations_paths:`) still got the migration written to the **primary** DB's `db/migrate/` on a bare invocation — where it then ran against the wrong database — and the "Next steps" output told the operator to run `rails db:migrate` instead of `rails db:migrate:pgbus`. `MigrationPath` now auto-detects the target database from `connects_to` (via the existing `DatabaseTargetDetector`: runtime config → initializer scan → application.rb scan) when `--database` is absent; an explicit `--database` still wins. The detected name drives both the migration path (resolving `migrations_paths` for that database, falling back to the `db/pgbus_migrate` convention) and the `db:migrate:<name>` suffix in every generator's post-install output. An app with no separate database is unaffected. Refs #344.
431
+ - **`extract_ar_connection_hash` no longer forces `host: "localhost"` / `port: 5432` on a socket-based (host-less) database.yml — pgmq's connections now match ActiveRecord's on Unix-socket dev setups (issue #343).** A local database.yml with no `host:` is a Unix-socket connection: ActiveRecord connects via libpq's default socket (`PGHOST` / the default socket dir), but pgbus's AR-config extraction defaulted the absent `host`/`port` to TCP `localhost:5432`, silently diverging from AR and pointing pgmq's dedicated raw connections at a *different* server (or nothing) on any machine where the socket dir isn't `localhost`. Apps on socket-based dev DBs therefore couldn't use the AR-extraction path at all and had to pin an explicit `connection_params` Hash whose only real job was *not* defaulting `host` (cosmos carried an `after_initialize` block for exactly this). The two fallbacks are dropped: `host`/`port` now pass through as-is and `.compact` removes them when absent, so libpq applies its own socket defaults and matches AR. A config that *does* set `host`/`port` is byte-identical to before. Refs #343.
432
+
433
+ ## [0.11.0] - 2026-07-07
434
+
435
+ ### Breaking Changes
436
+
437
+ - **`config.validate!` now rejects malformed values for 12 core config keys at boot instead of failing deep in a worker or silently misbehaving (issue #335).** Before the 1.0 surface freeze, these headline job-path keys had **zero** validation while streams keys had 14 checks — so a bad value surfaced as a crash inside a worker/dispatcher/poller/scheduler thread, per-enqueue, or by silently corrupting every derived queue name / leaving the dashboard open. `Pgbus.configure` (eager-validating by default) now raises `Pgbus::ConfigurationError` naming the key for: the worker-recycling trio `max_jobs_per_worker`/`max_memory_mb`/`max_worker_lifetime` (positive number or `nil` to disable); the intervals `dispatch_interval`/`outbox_poll_interval`/`recurring_schedule_interval` (positive number); `outbox_batch_size` (positive integer); `default_priority` (non-negative integer — `0` is a valid level); `queue_prefix`/`default_queue` (non-empty String — an empty prefix silently produced malformed queue names); `web_auth` (a callable or `nil` — a non-callable silently left the dashboard open); `error_reporters` (an Array); and `connects_to` (a Hash or `nil` — previously a malformed value raised a raw `TypeError` deep in engine boot). This only rejects configuration that was already broken; if a `Pgbus.configure` block set one of these to an invalid value it will now fail loudly at boot (the intended pre-freeze tightening). Set `config.eager_validation = false` to defer. Refs #335, #282.
438
+ - **`config.streams_queue_prefix` is removed (issue #335).** It had been an inert no-op since #308 — stream queues are named like job queues (`#{queue_prefix}_<name>`) and identified via the `pgbus_stream_queues` registry, so the prefix affected nothing — yet it was still a writable accessor documented as "Prefix for stream queues" (false). Rather than carry a dead, misleadingly-documented key into the 1.0 semver promise, the accessor is **removed**: `c.streams_queue_prefix = ...` now raises `NoMethodError`. **Action:** delete the line from your initializer — it never did anything. Refs #335, #308.
439
+ - **`ensures_uniqueness strategy: :until_executed` with no `key:` now raises when the job is enqueued *with arguments* — the class-name default silently collapsed per-record jobs (issue #333).** Without an explicit `key:`, the uniqueness key defaulted to the job class name. For a job that takes per-record arguments (`ImportOrderJob.perform_later(order_id)`), every distinct `order_id` resolved to the same class-name key and collapsed into ONE per-class singleton — order 1 and order 2 shared a lock, so the second was silently discarded/rejected — with no warning. The guard is at **resolve time, argument-aware**: a no-key `:until_executed` job enqueued **with arguments** now raises `ArgumentError` naming the collapse and pointing at the upgrade guide; **a no-argument job keeps the class-name default** (a legitimate "one logical instance at a time" — e.g. a recurring `CleanupJob` that must not overlap itself — is unaffected). `:while_executing` is unaffected (it acquires per-invocation at execution start, not by class-name identity at enqueue). Fix: pass `key: ->(*args) { "job-#{args.first}" }` on any argument-taking `:until_executed` job. Refs #333.
440
+
441
+ ### Added
442
+
235
443
  - **`Pgbus::Streams::PhlexHelpers` — a Phlex-includable `pgbus_stream_from`, and `Pgbus.stream_name_budget` (issue #334).** Two small helpers that stop consuming apps reimplementing pgbus glue. (1) `pgbus_stream_from` is a Rails view helper, so a Phlex component couldn't call it without hand-registering an output-helper macro (phlex-rails ships no `Phlex::Rails::Helpers::PgbusStreamFrom`). `include Pgbus::Streams::PhlexHelpers` now bridges it exactly like phlex-rails' own `TurboStreamFrom`. phlex-rails stays an **optional** dependency — the module references its `HelperMacros` and is loaded on demand (`require "pgbus/streams/phlex_helpers"`), never eagerly. (2) `Pgbus.stream_name_budget` exposes the maximum stream-name length (the pgmq queue-name cap minus the queue prefix) so apps size/truncate stream identifiers up front instead of hand-computing `MAX_QUEUE_NAME_LENGTH - queue_prefix.length - 1` at every call site. Refs #334.
236
444
  - **`EventBus::Registry#setup_all!(safe: true)` — a boot/rake-safe way to eagerly set up event subscribers (issue #334).** Calling `setup_all!` opens a PGMQ connection per subscriber (to create its queue + bind its topic), which is wrong during a `db:`/schema rake task (a live connection blocks `DROP DATABASE`) and crashes boot if the database isn't up yet — so apps wrapped it in a hand-rolled multi-class rescue plus a rake-task skip. `setup_all!(safe: true)` bakes that in: it skips entirely in a schema/asset rake context and swallows a connection error with a warning instead of raising. The default `setup_all!` is unchanged (raises). Refs #334.
445
+
446
+ ### Fixed
447
+
448
+ - **`config.log_format` no longer breaks `ActiveSupport::TaggedLogging` — tags are preserved instead of dropped, and `logger.tagged` no longer raises (issue #334).** TaggedLogging works by extending the logger's formatter instance with a module that prepends the current tags. Installing a fresh pgbus formatter (`LogFormatter::JSON`/`Text`) stripped that extension, so `logger.tagged("req-42") { ... }` dropped the tag — and, worse, raised `NoMethodError: undefined method 'tagged'` on the pgbus formatter (a deploy-breaker several apps hit, then worked around with a dedicated `$stdout` logger). `log_format=` now re-extends the replacement formatter with `ActiveSupport::TaggedLogging::Formatter` when the previous one carried it, so tagging keeps working. Refs #334.
449
+ - **The "Dashboard is accessible without authentication" warning is no longer a false positive when a gating `base_controller_class` is set (issue #334).** The warning fired whenever `web_auth` was nil, ignoring that a non-default `base_controller_class` (e.g. an `AdminController` with its own `before_action`) already gates the dashboard — so apps set a redundant `web_auth` lambda purely to silence the log line. The warning now stays quiet when `base_controller_class` is anything other than the default `::ActionController::Base`. Refs #334.
450
+ - **`retry_on` on an `:until_executed` job no longer dead-letters the job instead of retrying (issue #333).** The uniqueness key is released only on success or DLQ, but ActiveJob's `retry_on` re-enqueues from *inside* `perform_now` (after incrementing `executions`), while the executor still holds the key. That retry re-enqueue hit the job's own still-held key, was rejected as a duplicate (`JobNotUnique` under `on_conflict: :reject`), and the original message dead-lettered — so a job with both `ensures_uniqueness :until_executed` and `retry_on` lost its retry and DLQ'd on the first transient failure. A retry re-enqueue (`executions > 0`) is now recognized as the same logical job re-acquiring its own key and is allowed through; the existing key row correctly stays held until the job finally succeeds or dead-letters. Cross-job uniqueness (a genuine fresh duplicate, `executions == 0`) is still rejected. Proven end-to-end with a DB-gated integration test (fail-once-then-succeed runs to success exactly once, no `JobNotUnique`, no DLQ, key cleaned up). Refs #333.
451
+ - **A wildcard (`queues: ['*']`) worker no longer adopts event-bus subscriber queues and dead-letters the events (issue #333).** Event-subscriber queues share the job namespace (`#{queue_prefix}_<handler>`) but carry event payloads, not ActiveJob jobs. `resolve_wildcard_queues` excluded only `_dlq` and stream queues, so a wildcard worker claimed event queues too, handed each event to the ActiveJob executor, failed to deserialize it, and DLQ-moved it out of the consumer's reach — forcing apps that run the event bus to hand-maintain an explicit queue list. Wildcard resolution now also excludes every registered event-subscriber queue (new `EventBus::Registry#event_queue_names`), mirroring the existing stream-queue exclusion. Refs #333.
452
+
453
+ ## [0.10.0] - 2026-07-07
454
+
455
+ ### Added
456
+
237
457
  - **Connection-pooler safety (`config.require_primary`, `config.connection_guc_mode`) — pgbus is now correct-and-loud behind a transaction/read-write-splitting pooler instead of silently stalling (issue #332).** Three linked fixes. (1) **Primary affinity:** a read/write-splitting pooler (pgdog/pgcat) can route pgmq's VOLATILE `read`/`archive`/`delete` to a read replica, where workers read nothing (`read_ct=0`) and jobs stop with a **healthy heartbeat** — the supervisor watchdog keys on loop advancement, not read progress, so nothing surfaced the stall. The job read path had zero primary-affinity defense (only the LISTEN/streamer paths validated the primary via `PrimaryValidator`). Setting `config.require_primary = true` (default **false** — a single-primary deployment is byte-identical) makes `verify_connection!` reject a connection that lands on a replica (`pg_is_in_recovery() => t`) at supervisor boot, raising a clear `ConfigurationError` instead of forking children that read nothing. (2) **GUC forwarding:** pgbus's AR-config extraction dropped database.yml's `variables:` block, so `client_min_messages` (and any GUC) never reached pgmq's dedicated raw connections (NOTICE flooding that three consuming apps hand-patched via `connection_params`). GUCs are now forwarded, on both the AR-extracted and explicit-`connection_params` paths, by `config.connection_guc_mode`: `:options` (default) bakes them into the libpq `options` STARTUP param (byte-compatible with today for the read-timeout `statement_timeout`, now also carrying `variables:`), while `:session` applies them via post-connect `SET` on a fresh connection instead — for a transaction-mode PgBouncer that **rejects** the `options` startup param with a FATAL. `pgbus doctor` gains a ninth check ("Primary affinity") that warns (never fails) when the job connection is currently on a replica, naming the direct-port remediation. DB-gated integration tests prove both GUC modes round-trip a forwarded GUC and that `require_primary` verifies on a real primary. Refs #332.
458
+
459
+ ### Fixed
460
+
461
+ - **A pooler-deaf NOTIFY listener no longer raises the worker poll floor to 15s — worse latency than a plain no-NOTIFY deployment (issue #332).** pgbus already detects a transaction-mode pooler / replica LISTEN connection with a self-NOTIFY probe (`NotifyProbe`) and logs a loud, actionable error, but the probe's boolean result was **discarded**: a live-but-deaf listener still reported `running? == true`, so `Worker#wake_timeout` treated it as a healthy NOTIFY source and raised the empty-fetch poll floor to `NOTIFY_FALLBACK_POLL_SECONDS` (15s) — behind a pooler the loop would then sleep up to 15s between polls while jobs sat ready. `NotifyListener` now records the probe result and exposes `delivering?`; `wake_timeout` treats a non-delivering listener as absent and keeps polling at the fast `polling_interval` until a direct-port connection is configured. The streamer path needed no change — its listener already waits on the short `streams_listen_health_check_ms` and has no equivalent 15s ceiling. Refs #332.
462
+
463
+ ## [0.9.11] - 2026-07-06
464
+
465
+ ### Breaking Changes
466
+
467
+ - **YAML config (`config/pgbus.yml`) is removed for 1.0 — the Ruby initializer is the only config surface.** Ruby was always the real config surface (every production setup is a `Pgbus.configure` initializer); `config/pgbus.yml` was only ever a legacy convenience, and pre-1.0 is the last low-risk moment to drop it before the config shape becomes a compatibility promise. Concretely: the engine no longer loads `config/pgbus.yml` at boot (the `pgbus.configure` initializer and `Pgbus::ConfigLoader` are deleted); `rails generate pgbus:install` generates `config/initializers/pgbus.rb` (done for 1.0, see below); and `rails generate pgbus:update` no longer converts YAML → initializer (`Pgbus::Generators::ConfigConverter`, its `--source`/`--destination`/`--skip-config` options, and the `pgbus.yml.erb` template are deleted) — `pgbus:update` now only detects and installs missing migrations. **If you still have a `config/pgbus.yml`, it is no longer loaded** — the engine warns once at boot (pointing at the docs) so the inert file doesn't quietly diverge from your real config. Port its settings into `config/initializers/pgbus.rb` as a `Pgbus.configure` block and delete the YAML; the last release that auto-converts it via `pgbus:update` is 0.9.x, so run that conversion (or port by hand) **before** upgrading to 1.0. The recurring-tasks YAML (`config/recurring.yml`, `Pgbus::Recurring::ConfigLoader`) is a separate surface and is unchanged. Refs #317.
468
+
469
+ ### Added
470
+
238
471
  - **Job-burst tuning guidance + gate benchmark (`rake bench:job_burst`), and a decision NOT to autoscale the job pool (issue #323 phase 3).** Following the issue's measure-first mandate, `benchmarks/job_burst_bench.rb` sweeps worker thread count under a job burst in two connection regimes (pool matched to threads vs. pool fixed small). Measured finding: a job holds a pgmq connection only for the `read_batch` + `archive` round-trip (not the job body), so **the DB connection pool is the hard burst ceiling, not the thread count** — in the fixed-pool regime `peak_busy` never exceeds `pool_size` no matter how many threads run (extra threads just queue on checkout, latency climbs, throughput plateaus), while matching the pool to the thread count scales throughput ~linearly (≈8× from 2→16). The conclusion — issue #323's explicitly sanctioned outcome — is that **elastic job threads on a fixed connection pool cannot help**, so pgbus does *not* autoscale the job execution pool; the fix for job bursts is **static headroom**: raise `threads` (and let `pool_size` follow, or keep it ≥ `threads`). Documented in the Performance & tuning docs. The streams-pool autoscaling below stays the elastic story where it *does* pay off (the streams pool's connection cost genuinely scales with its size). Refs #323.
239
472
  - **Fan-out writer throughput gate benchmark (`writer_burst_bench.rb`), and a decision NOT to make the writer pool elastic (issue #323 phase 1).** Following the issue's measure-first mandate, `benchmarks/writer_burst_bench.rb` drives a sustained fan-out burst (a fleet of connections × many wakes) through the `OutboundPump` while sweeping `streams_writer_threads`, across fast/typical socket-write profiles. Measured finding: throughput scales ~linearly with writer count (≈6× from 2→16) — but that is generic parallelism (N workers finish N independent blocking writes ~N× faster), which argues for the **existing static `streams_writer_threads` knob**, not for elastic writers: a fixed-N sweep can't demonstrate the *spikiness* that would make a static count wasteful enough to justify dynamic resize, and the genuinely harmful case (a slow/congested client) is already **shed** by the #320/#322 fan-out deadline + mark-dead path rather than absorbed by scaling writers. Given the elastic-writer rewrite is the issue's highest-risk phase (it would touch the ordering/cursor invariant #321's whole review exists to protect — mis-sizing risks out-of-order SSE frames), the conclusion — issue #323's sanctioned outcome — is that pgbus does **not** autoscale the writer pool; the fan-out-throughput knob is **static `streams_writer_threads`, sized for your peak fan-out fleet**. Documented in the Performance & tuning docs. Refs #323.
240
473
  - **Self-tuning elastic streams-pool autoscaling (`config.streams_pool_autoscale`, opt-in, default off).** The dedicated streams pool (durable-broadcast publish + dispatcher replay reads) is a fixed `streams_pool_size` (default 5); under a genuine burst of SSE clients it saturates, and a saturated pool *serialises* replay reads (it doesn't error — the checkout waits; see #324), slowing fan-out. Setting `config.streams_pool_autoscale = true` runs a **periodic maintenance check** (default every 5 minutes, `config.streams_pool_autoscale_interval`) that **grows the streams pool into a fair share of live Postgres connection headroom** while it's saturated and **shrinks it back to `streams_pool_size`** when the burst passes — with no connection-count target to tune: every threshold derives from live `max_connections`. The check adds **no extra connection and no extra thread** — it's a lightweight `pg_stat_activity` query that runs on the streamer's *existing idle LISTEN connection* (a pghero-`capture_query_stats`-style periodic task, run on the listener thread in its health-check window). It counts peer stream processes by a per-process `application_name` tag (`config.streams_application_name`, default `"pgbus_streams"`), and each process grows only into its own fair share of free connections (`free / peers`, claiming ¼, bounded by a per-grow step and half the remaining headroom) so N forked processes provably can't collectively exhaust the database — even in a synchronized cold-boot herd. If the database runs *critically* low on free connections, every process **emergency-shrinks to baseline immediately**, overriding the saturation signal (protecting the DB wins). `config.streams_pool_max` is an **optional** hard per-process ceiling (`nil` = the dynamic fair share is the cap). No per-sample hysteresis or cooldown is needed — the slow cadence is itself the debounce: one grow (or shrink) step per check, and a sustained burst converges over a few checks. Built on the #325 hot-swap primitive; the decision object is a pure function (no thread, no connection), DB-free unit-tested (scripted headroom, incl. convergence-across-checks and emergency-shrink), with a DB-gated integration test proving real grow/shrink/emergency and a `rake bench:pool_autoscale` benchmark (check cost ~0.3 ms; grow-under-saturation `3→7→10`). **Two prerequisite fixes ship with it:** streams-pool connections are now tagged with a per-process `application_name`, and the streams pool is now built from `streams_connection_options` (honoring `streams_database_url`/host/port) rather than the job-database options — the latter a latent wrong-DB bug in the existing streams pool that only surfaced with a separate streams database. Default-off = byte-identical (the Listener runs no maintenance). A pure-*publisher* worker process (no streamer, only `send_stream_message`) autoscales too: each publish opportunistically triggers the same check, throttled to at most once per `streams_pool_autoscale_interval` across all publisher threads (a lock-free compare-and-set claims the window). The publish thread does only that CAS and then hands the work to a background single-worker executor — it never runs the `pg_stat_activity` query or the resize inline (off the hot path), and a quiet process spawns no thread. The query runs through the *job* pool (so it never competes with the streams pool it grows), and the publisher decision is **grow-only**: it grows under its own publish pressure and emergency-shrinks on DB exhaustion, but leaves normal idle shrink to the streamer/consumer that sees the sustained-idle picture. Fail-soft throughout, so a broadcast never fails on a telemetry query. For steady load, a larger static `streams_pool_size` remains the simpler choice — autoscaling earns its keep under genuinely bursty streams traffic. `PoolAutoscaler` lives in `Pgbus::Streams::` (it is a streams-pool controller, not web-specific). Refs #323.
@@ -244,134 +477,83 @@
244
477
  - **Dedicated streams DB connection pool isolates durable-stream publish + replay from the job pool (`config.streams_pool_size`, `config.streams_pool_timeout`).** Both stream hot paths used to ride on the single job pool sized for worker threads: `Client#send_stream_message` (the broadcast INSERT) drew from `@pgmq`, so under a saturated job pool a broadcast blocked on pool checkout up to `pool_timeout` (~5s) — the browser waited on jobs at the *publish* step; and the dispatcher's per-wake replay read (`read_after` / `stream_current_msg_id` / `stream_oldest_msg_id`) went through `with_raw_connection`, which on the String/Hash config does a **full `PG.connect` + close per call** (TCP + auth + TLS) on the single dispatcher thread inside the fanout loop. Both now use a dedicated `@streams_pgmq` pool (own `PGMQ::Client` → own `connection_pool`), sized independently of worker thread counts via `streams_pool_size` (default 5) / `streams_pool_timeout` (default 5). A new `Client#with_streams_connection` checks out a persistent pooled connection for the replay reads; `Client#streams_pool_stats` exposes its counters alongside `pool_stats`. On the shared-ActiveRecord (Proc) connection path no second pool is created — libpq isn't thread-safe, so `@streams_pgmq` aliases `@pgmq` and streams keep using the single serialized connection. Because every forked process opens its own streams pool on the dedicated path, budget Postgres/PgBouncer `max_connections` for `resolved_pool_size + streams_pool_size` per process (both pools are lazy). Measured (real DB, 50-row stream, `read_after`): pooled replay reads run **~23× faster** than the old fresh-connect-per-call path (5.2k i/s / 191 μs vs 226 i/s / 4.42 ms), removing the per-wake connection-setup cost; this is a client-layer connection-setup win, not end-to-end broadcast-to-browser latency (which is dominated by the PGMQ round-trip + LISTEN/NOTIFY + socket write). Refs #315.
245
478
  - **`config.streams_broadcast_queue` isolates turbo-rails broadcast jobs from long-running work, and new installs get it by default.** The default `broadcasts_to`/`broadcasts_refreshes` macros enqueue turbo-rails' render+broadcast as ActiveJobs (`Turbo::Streams::ActionBroadcastJob` and siblings), which ship with no `queue_as` and so land on the **default** queue — meaning a browser SSE update can wait behind long-running jobs before it's rendered and broadcast (the delivery side is isolated, but the enqueue-render hop is not). Set `config.streams_broadcast_queue = "realtime"` and pgbus routes those three Turbo job classes to that queue at engine boot; back it with a dedicated worker capsule to give broadcasts their own thread pool. **`rails generate pgbus:install` now ships this setup by default** — the generated `config/initializers/pgbus.rb` sets `c.streams_broadcast_queue = "realtime"` and a dedicated `realtime` worker capsule (see the install-output change below). The **code default stays `nil`**, so a programmatic `Pgbus.configure` and existing installs are unchanged. `pgbus doctor` gains an eighth check that warns (never fails): in a Rails production env when streams+turbo-rails are on but no broadcast queue is set (latency), and — everywhere — when `streams_broadcast_queue` is set but **no worker capsule drains it** (broadcasts would pile up unread and never reach the browser). Refs #311.
246
479
  - **Stream queue registry (`pgbus_stream_queues` table + `Pgbus::StreamQueue`) so stream queues are distinguishable from job queues.** Stream and job queues share the `#{queue_prefix}_` namespace, so nothing could tell them apart by name — the root cause behind #308 and #309. `ensure_stream_queue` now records each stream's physical queue name in the registry on first broadcast (idempotent, per-process memoized, and a no-op on installs that have not migrated). Fresh installs get the table via `pgbus:install`; existing installs add it with `rails generate pgbus:add_stream_queues` (also auto-detected and offered by `pgbus:update`). Refs #308.
247
- - **`Pgbus.publish` / `Pgbus.publish_later` top-level shortcuts and a `config.drain_timeout` key (1.0 API freeze).** The event bus now has the same top-level ergonomics as streams: `Pgbus.publish(routing_key, payload, headers:, delay:)` and `Pgbus.publish_later(routing_key, payload, delay:, headers:)` delegate to `Pgbus::EventBus::Publisher` (long form still works), so tutorials no longer fossilize the fully-qualified call. `config.drain_timeout` (validated positive number, default 30) replaces the hardcoded `Worker::DRAIN_TIMEOUT = 30` constant — the graceful-shutdown drain deadline is now operator-tunable, so jobs that legitimately run longer than 30s aren't silently abandoned on every deploy/recycle. `pgbus doctor` gains a seventh check that warns (never fails) when `allowed_global_id_models` is `nil` (allow-all) in a Rails production env — the default is kept for upgrade continuity but is security-relevant. Refs #283.
248
480
 
249
481
  ### Changed
250
482
 
251
- - **Streaming/notify failures now reach `config.error_reporters`, not just the log (issue #352).** Four error-level rescues were logger-only, so an error tracker (AppSignal/Sentry/…) never saw them: a `StreamApp` request rescued into a 500 (which, for a broken streamer config, is *every* SSE request on the worker), a `NotifyListener` fatal thread exit, a `NotifyListener` reconnect failure, and a streamer `Listener` reconnect failure. All four now go through `Pgbus::ErrorReporter.report` with an `action:` context tag (`stream_connect`, `notify_listener_fatal`, `notify_listener_reconnect`, `streamer_reconnect`); ErrorReporter logs as before, so operators without reporters lose nothing. Refs #352.
252
483
  - **Fanout socket writes now use a 250ms deadline (was the full 5s `streams_write_deadline_ms`), so a transiently-slow SSE client is marked dead ~20× sooner during broadcast fanout.** This is the behavior change behind the head-of-line-blocking fix (see Added, #315): a client that can't absorb a broadcast frame within `streams_fanout_write_deadline_ms` (default 250ms) is dropped and reconnects, replaying the missed frames from the durable archive (durable streams) or getting a fresh re-render (ephemeral). Connect-time replay is unaffected (keeps the full deadline). To restore the previous timing, set `config.streams_fanout_write_deadline_ms = config.streams_write_deadline_ms`. Refs #315.
253
484
  - **The generated `config/initializers/pgbus.rb` documents every config group and bakes in realtime broadcast isolation.** The initializer that `rails generate pgbus:install` now writes (see Breaking Changes for the YAML removal) documents each config group inline — queues, pool, retries, recycling, dispatcher, event consumers — with the gem defaults shown as commented reference, and it sets the #311 realtime broadcast isolation by default: `c.streams_broadcast_queue = "realtime"` plus a dedicated `c.capsule :realtime` that drains it (so `pgbus doctor` stays quiet — no undrained-queue footgun). The **code default in `configuration.rb` stays `nil`**, so a programmatic `Pgbus.configure` and existing installs are unchanged; the recommended setup lives in the generated Ruby. Refs #317.
254
- - **1.0 API freeze: config renames as deprecated aliases, and removal of provably-dead surface.** Renaming now is cheap; renaming after 1.0 is a major-version break, so the semver-audit renames land as **deprecated aliases** (old name works, warns once, removed in 2.0): `skip_recurring` → `recurring_enabled` (positive polarity, the only negative toggle among the `*_enabled` switches — the alias inverts the boolean); `dashboard_filter_parameters`/`dashboard_filter_sensitive` → `web_filter_parameters`/`web_filter_sensitive` (unify on the incumbent `web_` prefix); and `recurring_tasks_file` (singular), which now warns once when set alongside the plural `recurring_tasks_files` instead of being silently ignored. Both spellings of each alias resolve to the same setter. **Removed** (each verified zero-caller in the audit): the `lock_ttl:` keyword on `ensures_uniqueness` (validated and stored in metadata but read by nothing — passing it now raises `ArgumentError` naming the removal and the upgrade guide); the `pgbus:add_job_locks` generator and the `Pgbus::JobLock` model (new installs use `pgbus:add_uniqueness_keys`; `pgbus:migrate_job_locks` still retires the legacy `pgbus_job_locks` table); the internal `with_pgbus_durable` streams helper (use `with_pgbus_broadcast_opts(durable:)`); and the streamer's `reconnect_via_reset` fallback (`connection_factory` is now required and always injected, so reconnect always rebuilds a fresh connection). `log_format=` no longer clobbers a custom logger's formatter, and `streams_presence_patterns`/`streams_presence_member`, `group_mode`, and `streams_falcon_streaming_body` are annotated **experimental** (exempt from the 1.0 stability promise). The `workers=` Array form is documented as permanent (not "legacy") — it is the only way to express N identical anonymous capsules. Refs #283.
255
485
 
256
486
  ### Fixed
257
487
 
258
- - **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.
259
-
260
- - **`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.
261
- - **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.
262
- - **`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.
263
- - **AppSignal main dashboard: job and event lines now split by `job_class` and `routing_key`.** Applied the upstream review suggestions committed on appsignal/public_config#80 to the vendored `dashboard.json`: the "Job status per queue" panel gains a `job_class` wildcard tag filter and label (`%job_class% - %queue% - %status%`), and "Event handler status" gains `routing_key` (`%handler% - %status% - %routing_key%`). Without the wildcard tag entry a `%placeholder%` in the line label never resolves, so lines from different job classes / routing keys were collapsed together. The subscriber already emitted both tags; only the dashboard definition lagged.
264
- - **Migration generators now route to the pgbus database when `connects_to` is configured, even without an explicit `--database` (issue #344).** `rails g pgbus:add_stream_queues` (and every sibling generator via `Generators::MigrationPath`) keyed the separate-database decision purely on the `--database` flag. An app that had already set `config.connects_to = { database: { writing: :pgbus } }` (so its pgbus migrations live in `db/pgbus_migrate/` per database.yml's `migrations_paths:`) still got the migration written to the **primary** DB's `db/migrate/` on a bare invocation — where it then ran against the wrong database — and the "Next steps" output told the operator to run `rails db:migrate` instead of `rails db:migrate:pgbus`. `MigrationPath` now auto-detects the target database from `connects_to` (via the existing `DatabaseTargetDetector`: runtime config → initializer scan → application.rb scan) when `--database` is absent; an explicit `--database` still wins. The detected name drives both the migration path (resolving `migrations_paths` for that database, falling back to the `db/pgbus_migrate` convention) and the `db:migrate:<name>` suffix in every generator's post-install output. An app with no separate database is unaffected. Refs #344.
265
- - **`extract_ar_connection_hash` no longer forces `host: "localhost"` / `port: 5432` on a socket-based (host-less) database.yml — pgmq's connections now match ActiveRecord's on Unix-socket dev setups (issue #343).** A local database.yml with no `host:` is a Unix-socket connection: ActiveRecord connects via libpq's default socket (`PGHOST` / the default socket dir), but pgbus's AR-config extraction defaulted the absent `host`/`port` to TCP `localhost:5432`, silently diverging from AR and pointing pgmq's dedicated raw connections at a *different* server (or nothing) on any machine where the socket dir isn't `localhost`. Apps on socket-based dev DBs therefore couldn't use the AR-extraction path at all and had to pin an explicit `connection_params` Hash whose only real job was *not* defaulting `host` (cosmos carried an `after_initialize` block for exactly this). The two fallbacks are dropped: `host`/`port` now pass through as-is and `.compact` removes them when absent, so libpq applies its own socket defaults and matches AR. A config that *does* set `host`/`port` is byte-identical to before. Refs #343.
266
- - **`config.log_format` no longer breaks `ActiveSupport::TaggedLogging` — tags are preserved instead of dropped, and `logger.tagged` no longer raises (issue #334).** TaggedLogging works by extending the logger's formatter instance with a module that prepends the current tags. Installing a fresh pgbus formatter (`LogFormatter::JSON`/`Text`) stripped that extension, so `logger.tagged("req-42") { ... }` dropped the tag — and, worse, raised `NoMethodError: undefined method 'tagged'` on the pgbus formatter (a deploy-breaker several apps hit, then worked around with a dedicated `$stdout` logger). `log_format=` now re-extends the replacement formatter with `ActiveSupport::TaggedLogging::Formatter` when the previous one carried it, so tagging keeps working. Refs #334.
267
- - **The "Dashboard is accessible without authentication" warning is no longer a false positive when a gating `base_controller_class` is set (issue #334).** The warning fired whenever `web_auth` was nil, ignoring that a non-default `base_controller_class` (e.g. an `AdminController` with its own `before_action`) already gates the dashboard — so apps set a redundant `web_auth` lambda purely to silence the log line. The warning now stays quiet when `base_controller_class` is anything other than the default `::ActionController::Base`. Refs #334.
268
- - **`retry_on` on an `:until_executed` job no longer dead-letters the job instead of retrying (issue #333).** The uniqueness key is released only on success or DLQ, but ActiveJob's `retry_on` re-enqueues from *inside* `perform_now` (after incrementing `executions`), while the executor still holds the key. That retry re-enqueue hit the job's own still-held key, was rejected as a duplicate (`JobNotUnique` under `on_conflict: :reject`), and the original message dead-lettered — so a job with both `ensures_uniqueness :until_executed` and `retry_on` lost its retry and DLQ'd on the first transient failure. A retry re-enqueue (`executions > 0`) is now recognized as the same logical job re-acquiring its own key and is allowed through; the existing key row correctly stays held until the job finally succeeds or dead-letters. Cross-job uniqueness (a genuine fresh duplicate, `executions == 0`) is still rejected. Proven end-to-end with a DB-gated integration test (fail-once-then-succeed runs to success exactly once, no `JobNotUnique`, no DLQ, key cleaned up). Refs #333.
269
- - **A wildcard (`queues: ['*']`) worker no longer adopts event-bus subscriber queues and dead-letters the events (issue #333).** Event-subscriber queues share the job namespace (`#{queue_prefix}_<handler>`) but carry event payloads, not ActiveJob jobs. `resolve_wildcard_queues` excluded only `_dlq` and stream queues, so a wildcard worker claimed event queues too, handed each event to the ActiveJob executor, failed to deserialize it, and DLQ-moved it out of the consumer's reach — forcing apps that run the event bus to hand-maintain an explicit queue list. Wildcard resolution now also excludes every registered event-subscriber queue (new `EventBus::Registry#event_queue_names`), mirroring the existing stream-queue exclusion. Refs #333.
270
- - **A pooler-deaf NOTIFY listener no longer raises the worker poll floor to 15s — worse latency than a plain no-NOTIFY deployment (issue #332).** pgbus already detects a transaction-mode pooler / replica LISTEN connection with a self-NOTIFY probe (`NotifyProbe`) and logs a loud, actionable error, but the probe's boolean result was **discarded**: a live-but-deaf listener still reported `running? == true`, so `Worker#wake_timeout` treated it as a healthy NOTIFY source and raised the empty-fetch poll floor to `NOTIFY_FALLBACK_POLL_SECONDS` (15s) — behind a pooler the loop would then sleep up to 15s between polls while jobs sat ready. `NotifyListener` now records the probe result and exposes `delivering?`; `wake_timeout` treats a non-delivering listener as absent and keeps polling at the fast `polling_interval` until a direct-port connection is configured. The streamer path needed no change — its listener already waits on the short `streams_listen_health_check_ms` and has no equivalent 15s ceiling. Refs #332.
271
488
  - **The dispatcher no longer logs a scary `Concurrency cleanup failed: ... terminating connection due to administrator command` WARN when an idle DB connection is killed between maintenance cycles.** The dispatcher is a long-lived loop that queries the DB on coarse intervals (concurrency cleanup every 300s, most tasks hourly) and, until now, kept its pooled ActiveRecord connection leased across the idle sleep. When the server terminated that idle backend — routine in **local development** (a Postgres restart, `rails db:migrate` calling `pg_terminate_backend` on idle backends, foreman/overmind restarting the DB) — the next cycle reused the dead socket and *every* maintenance task raised `PG::ConnectionBad` with PostgreSQL's `terminating connection due to administrator command` text, surfaced as an alarming WARN even though the process was healthy and self-heals on the next tick. Two changes: (1) `run_maintenance` now returns every leased AR connection to the pool after each cycle (`clear_active_connections!`, mirroring the Streamer's existing `release_ar_connections`), so an idle backend is never reused — the next cycle checks out afresh and AR's `verify!`-on-checkout transparently discards a dead socket and reconnects; (2) every maintenance task's failure log now routes through `log_maintenance_failure`, which recognizes the terminated-idle-connection signal and logs a calm INFO ("reconnecting on the next cycle") instead of a WARN. Non-connection failures still WARN unchanged, and maintenance backoff is unaffected (a self-healing cycle resets the failure streak on the next successful reconnect).
272
489
  - **`priority_levels > 1` no longer silently breaks durable streams.** With job priority enabled, `QueueFactory.for` selects `PriorityStrategy`, and `Client#send_message` routes *every* send — including durable stream broadcasts (which pass no `priority:`) — to a `_p<default_priority>` sub-queue. But the streamer LISTENs and replays exclusively on the **bare** `pgmq.q_<stream>` queue (`read_after`, `notify_stream`), so the broadcast never reached the browser; on a fresh DB, `ensure_stream_queue` also raised `PG::UndefinedTable` because `ensure_queue` created only `_p0.._pN`, never the bare table the NOTIFY trigger and archive index attach to. Durable broadcasts now go through a dedicated `Client#send_stream_message` that always targets the bare queue, and `ensure_stream_queue` creates the bare queue directly via `ensure_single_queue` — both independent of the priority strategy, mirroring the bare-name read path. Streams are delivered by a non-consuming peek; priority sub-queues are meaningless for them. Ephemeral broadcasts (the default mode) were never affected. Refs #310.
273
490
  - **Stream maintenance and wildcard workers now correctly identify stream queues (finishes the #306/#307 orphan-sweep fix and closes a durable-stream data-loss bug).** `streams_queue_prefix` (default `pgbus_stream`) was consulted only by the dispatcher's `prune_stream_archives` and `sweep_orphan_streams`, but no code ever *named* a queue with it — real stream queues are `#{queue_prefix}_<name>` (`pgbus_<name>`). So both filters matched **zero** real stream queues: per-stream retention never ran, and the age-based orphan drop shipped in #307 was inert. Worse, a *job* queue whose logical name started with `stream_` (physical `pgbus_stream_x`) *did* match and could be dropped. All stream detection now goes through the new `pgbus_stream_queues` registry (see Added): `prune_stream_archives` and `sweep_orphan_streams` iterate registered streams (reviving #306/#307), `compact_archives` skips registered streams so each stream archive is compacted by exactly one path at its per-stream retention (not the 7-day job default), and — closing #309 — a `queues: ['*']` worker's `resolve_wildcard_queues` now excludes registered stream queues, so wildcard workers no longer claim durable broadcasts, fail to deserialize them, and DLQ-move them out of the stream's replay history. `streams_queue_prefix` is retained for backward compatibility but is now inert (documented). Refs #308, #309.
274
- - **Durable stream `q_` queues no longer leak — the orphan sweep is now age-based, as its `streams_orphan_threshold` config always implied.** A durable stream's live `pgmq.q_<name>` table is never drained by normal delivery: the streamer peeks (`read_after` UNIONs `q_`+`a_` without claiming rows), so `queue_length` stays `> 0` for any durable stream ever broadcast to, and retention only prunes `a_` (which stays empty for streams). The orphan sweep dropped only *empty* queues, so it never collected a durable stream — a permanent leak of the queue table, archive table, indexes, and a `pgmq.meta` row, at worst one leaked queue *per request* for per-render/one-shot durable keys. `streams_orphan_threshold` (default 24h) was named and defaulted as an age but wired only as an on/off gate. `sweep_orphan_streams` now reads each queue's `pgmq.meta.created_at` (no schema change) and drops when the queue is empty **or** has aged past the threshold. Idempotent re-creation (`ensure_stream_queue`) plus the existing missing-queue tolerance in `read_after` keep it safe; a genuinely long-lived stream only loses replay history past the threshold, well clear of normal reconnect windows. A `nil` `created_at` is treated as young (never dropped on age). Refs #306.
275
- - **`Pgbus::MCP.rack_app` now returns a clean 401 on auth failure instead of a 500.** The `RackApp` auth gate returned a *frozen* `UNAUTHORIZED` response triple; once mounted in Rails, response-mutating middleware downstream (`Rack::TempfileReaper` assigns `response[2]`, `Rack::ETag` adds a header) raised `FrozenError`, so every request with a missing/mismatched `PGBUS_MCP_TOKEN` surfaced as an opaque 500 in the host app's error tracker rather than the intended 401. The gate now builds a fresh, mutable array and headers hash per call (only the JSON body string stays memoized+frozen). A request-level spec drives the app through the real `TempfileReaper`/`ETag` stack to guard against regression. Refs #304.
276
491
 
277
- ### Security
492
+ ## [0.9.10] - 2026-07-05
278
493
 
279
- ## [0.9.8.1] - 2026-07-04
494
+ ### Fixed
280
495
 
281
- ### Changed
496
+ - **Durable stream `q_` queues no longer leak — the orphan sweep is now age-based, as its `streams_orphan_threshold` config always implied.** A durable stream's live `pgmq.q_<name>` table is never drained by normal delivery: the streamer peeks (`read_after` UNIONs `q_`+`a_` without claiming rows), so `queue_length` stays `> 0` for any durable stream ever broadcast to, and retention only prunes `a_` (which stays empty for streams). The orphan sweep dropped only *empty* queues, so it never collected a durable stream — a permanent leak of the queue table, archive table, indexes, and a `pgmq.meta` row, at worst one leaked queue *per request* for per-render/one-shot durable keys. `streams_orphan_threshold` (default 24h) was named and defaulted as an age but wired only as an on/off gate. `sweep_orphan_streams` now reads each queue's `pgmq.meta.created_at` (no schema change) and drops when the queue is empty **or** has aged past the threshold. Idempotent re-creation (`ensure_stream_queue`) plus the existing missing-queue tolerance in `read_after` keep it safe; a genuinely long-lived stream only loses replay history past the threshold, well clear of normal reconnect windows. A `nil` `created_at` is treated as young (never dropped on age). Refs #306.
497
+ - **`Pgbus::MCP.rack_app` now returns a clean 401 on auth failure instead of a 500.** The `RackApp` auth gate returned a *frozen* `UNAUTHORIZED` response triple; once mounted in Rails, response-mutating middleware downstream (`Rack::TempfileReaper` assigns `response[2]`, `Rack::ETag` adds a header) raised `FrozenError`, so every request with a missing/mismatched `PGBUS_MCP_TOKEN` surfaced as an opaque 500 in the host app's error tracker rather than the intended 401. The gate now builds a fresh, mutable array and headers hash per call (only the JSON body string stays memoized+frozen). A request-level spec drives the app through the real `TempfileReaper`/`ETag` stack to guard against regression. Refs #304.
282
498
 
283
- - **CI now proves the `railties >= 7.1, < 9.0` support matrix at both endpoints, not just the top.** Every CI leg previously resolved a single `Gemfile.lock` pinned to the latest 8.x, so Rails 7.1 — the lower bound the gemspec promises — was never exercised; shipping 1.0 with an untested support claim is a semver-integrity risk. Added `gemfiles/rails_7_1.gemfile` (+ a checked-in `gemfiles/rails_7_1.gemfile.lock`) that pins the Rails components to `~> 7.1.0` via a plain `BUNDLE_GEMFILE` override (no appraisal gem), and the `test` job now runs the unit suite (`spec/pgbus/ spec/generators/`) against both the main 8.x Gemfile (Ruby 3.3/3.4/4.0) and the 7.1 endpoint (Ruby 3.3 floor + 4.0 ceiling). The 7.1 legs pass green — the only fix needed was test-side: `spec/pgbus/web/data_source_spec.rb` mocked the ActiveRecord connection but let real model side-paths (`QueueState.paused`, `RecurringTask.count`) hit it, and AR 7.1's eager schema load raised RSpec's `MockExpectationError` (an `Exception`, not a `StandardError`, so the methods' rescue missed it); AR 8.1 resolves schema lazily and never surfaced the gap. The main Gemfile now reads an optional `RAILS_VERSION` env override (defaulting to the full supported range) so the endpoint gemfile can pin the lower bound without duplicating gem declarations. A `lint_floor` CI job also runs rubocop + `rake build` on Ruby 3.3 (the gemspec's `required_ruby_version` floor). Refs #284.
499
+ ## [0.9.9] - 2026-07-04
284
500
 
285
- ### Fixed
501
+ ### Breaking Changes
286
502
 
287
- - **Metrics: `pgbus.consumer.recycle` now reaches both metrics subscribers, and `pgbus.client.pool` reaches `Metrics::Subscriber`.** Since v0.9.7 (#200, #205) two instrumentation events had no metrics consumer: `pgbus.client.pool` (emitted per worker heartbeat) was subscribed by neither `Metrics::Subscriber` nor the AppSignal subscriber — Prometheus/StatsD users got zero pool visibility, and even AppSignal only saw pool gauges via the separate minutely `Probe#track_pool` path; `pgbus.consumer.recycle` was subscribed by neither, so consumer recycling (#205) was invisible everywhere while `pgbus.worker.recycle` alone incremented `pgbus_worker_recycled`. `Metrics::Subscriber` now subscribes both: `pgbus.client.pool` forwards `size`/`available` as `pgbus_pool_size` / `pgbus_pool_available` gauges tagged `hostname` (matching the AppSignal probe's naming), and `pgbus.consumer.recycle` increments the *existing* `pgbus_worker_recycled` counter (no new metric) tagged `kind: "consumer"` alongside `reason`; `pgbus.worker.recycle` now also carries `kind: "worker"` so the tag is always present. The AppSignal subscriber gains the same `pgbus.consumer.recycle` handling but deliberately does **not** subscribe `pgbus.client.pool` — the minutely probe already covers AppSignal, and subscribing both would double-report and skew the gauges (documented as a comment at the subscription site). Also corrected the drifted `lib/pgbus/metrics.rb` docstring, which claimed a stale event count, to point at `Pgbus::Instrumentation` as the single source of truth for the emitted `pgbus.*` event catalog. Refs #275.
503
+ - **The error hierarchy is unified under `Pgbus::Error` for the 1.0 API freeze.** Several call sites raised bare stdlib errors that no `rescue Pgbus::Error` could catch, freezing an inconsistent contract into 1.0. Now: **`Configuration#validate!` and every config setter raise `Pgbus::ConfigurationError`** (was `ArgumentError`) — this is the one that can break existing code: a `rescue ArgumentError` around `Pgbus.configure` or a boot-time config read will no longer catch config failures; switch to `rescue Pgbus::Error` (or `rescue Pgbus::ConfigurationError`). The ActiveJob adapter's `perform_all_later` msg_id-count mismatch now raises **`Pgbus::EnqueueError`** (new, was `RuntimeError`); `ExecutionPools::AsyncPool` shutdown/at-capacity raises now use **`Pgbus::ExecutionPoolError`** (new, was `RuntimeError`); `Serializer#locate_global_id` GlobalID rejections raise **`Pgbus::SerializationError`** (was `ArgumentError`). Three error classes that bypassed the base — `PgmqSchema::VersionNotFoundError` and `Streams::SignedName::InvalidSignedName`/`MissingSecret` — are re-parented under `Pgbus::Error`. Three classes that reject a malformed *argument shape* — `Configuration::CapsuleDSL::ParseError`, `Streams::Cursor::InvalidCursor`, `Streams::StreamNameTooLong` — **deliberately stay `ArgumentError` subclasses**; the policy ("argument-shape errors are `ArgumentError`, operational errors are `Pgbus::Error`") is documented in `lib/pgbus.rb` and pinned by `spec/pgbus/error_hierarchy_spec.rb`. Messages are unchanged. Refs #282.
288
504
 
289
505
  ### Added
290
506
 
507
+ - **`Pgbus.publish` / `Pgbus.publish_later` top-level shortcuts and a `config.drain_timeout` key (1.0 API freeze).** The event bus now has the same top-level ergonomics as streams: `Pgbus.publish(routing_key, payload, headers:, delay:)` and `Pgbus.publish_later(routing_key, payload, delay:, headers:)` delegate to `Pgbus::EventBus::Publisher` (long form still works), so tutorials no longer fossilize the fully-qualified call. `config.drain_timeout` (validated positive number, default 30) replaces the hardcoded `Worker::DRAIN_TIMEOUT = 30` constant — the graceful-shutdown drain deadline is now operator-tunable, so jobs that legitimately run longer than 30s aren't silently abandoned on every deploy/recycle. `pgbus doctor` gains a seventh check that warns (never fails) when `allowed_global_id_models` is `nil` (allow-all) in a Rails production env — the default is kept for upgrade continuity but is security-relevant. Refs #283.
291
508
  - **CI: nightly dependency-watch Action for the PGMQ SQL and pgmq-ruby upstreams.** Pgbus hand-tracks two upstreams with nothing watching them: the vendored PGMQ SQL (`lib/pgbus/pgmq_schema/pgmq_v*.sql`) and the `pgmq-ruby` gem (`~> 0.7.0` in `pgbus.gemspec`). New `.github/workflows/dependency-watch.yml` runs nightly (`0 5 * * *`, plus `workflow_dispatch`) and does two plain bash + `gh api`/`curl` + `jq` checks — no Ruby application boot: (1) derives the highest vendored PGMQ version from the `pgmq_v*.sql` filenames (never hardcoded) and compares it against `pgmq/pgmq`'s `releases/latest` tag; (2) resolves the locked `pgmq-ruby` version via `bundle lock` (Gemfile.lock is gitignored for this gem, so a fresh checkout has none) and compares it against the RubyGems API. Both comparisons use `sort -V` so a newer patch release (e.g. 1.11.0 → 1.11.1) is caught correctly. On drift it opens a GitHub issue titled `Upstream release: <dep> <version>`, deduplicated by an exact-title search so repeat runs are a no-op, and best-effort posts to Slack via the optional `SLACK_WEBHOOK_URL` repo secret (logged and skipped, never failing the job, when unset). Notification only — it never auto-vendors SQL or auto-bumps the gem. Refs #287.
292
-
293
509
  - **Process model: Consumer brought to full operational parity with Worker — stall detection, circuit breaker, StatBuffer, and pool heartbeat.** #205 gave `Pgbus::Process::Consumer` recycling and NOTIFY wake-up, but four safety-net mechanisms Worker relies on were never carried over, leaving a consumer fork invisible to every operational net. All four now mirror Worker directly (no shared module — the third process type will trigger the extraction). **Stall detection:** the consumer stamps a wall-clock loop beacon (`@loop_tick_at`) at the top of every loop iteration and pokes an OS-level liveness pipe when the supervisor forked one — exactly like Worker — and its heartbeat now persists `loop_tick_at` via a `loop_tick_supplier`. `Supervisor#check_stalled_workers` and `#db_loop_tick_ages` were widened from worker-only to `%w[worker consumer]`, and `fork_consumer` now wires the same liveness pipe (`liveness_reader`/`last_pipe_tick_at`/`pipe_seen`) `fork_worker` does, so a wedged consumer is SIGKILLed and restarted through both the DB-row and the DB-independent pipe channel. `Web::DataSource#stall_threshold_for` gained a `"consumer"` branch — previously it returned `nil`, so `derive_process_status` reported a wedged consumer as `:healthy` forever. The consumer's empty-read wait can legitimately reach `NOTIFY_FALLBACK_POLL_SECONDS` (15s) when a live listener drives wake-up, so the threshold widens by `base + [polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max` when notify wake-up is enabled and `base + polling_interval` otherwise (mirroring how dispatcher/scheduler widen by their sleep interval). **Circuit breaker:** the consumer now builds a `Pgbus::CircuitBreaker` and consults it in the fetch path — a tripped queue is skipped instead of hammered forever — records success/failure per handled message, and rescues `Pgbus::ConnectionCircuitOpenError` before the generic rescue to idle the poll with no `ErrorReporter` call during a database outage (so a consumer pool no longer floods the error tracker). **StatBuffer:** when `config.stats_enabled`, the consumer builds a `Pgbus::StatBuffer` (using `stats_flush_size`/`stats_flush_interval`), records a `success`/`failed`/`dead_lettered` stat per handled message into the same `pgbus_job_stats` table workers use, and flushes at drain entry, on recycle, and on stop. **Pool heartbeat:** the consumer's heartbeat `on_beat` now emits `pgbus.client.pool` once per beat, so consumer processes surface connection-pool utilization like workers. Worker, Dispatcher, and Scheduler behavior is unchanged. Refs #274.
294
-
295
510
  - **PGMQ: vendored schema v1.11.1, proving the multi-version upgrade path.** Pgbus vendored exactly one PGMQ schema (`lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql`), so `PgmqSchema.latest_version`, the `pgbus_pgmq_schema_versions` tracking table, `rails g pgbus:upgrade_pgmq`, and `rake pgbus:pgmq:status`/`:versions` had never been exercised against a real delta. Vendoring upstream PGMQ v1.11.1 (2026-04-19) alongside v1.11.0 (keeping both) gives version discovery a genuine second target: dropping `pgmq_v1.11.1.sql` into the schema dir makes `available_versions` list both and `latest_version` return `"1.11.1"` with no code change (the loader globs `pgmq_v*.sql` and sorts by `Gem::Version`). The v1.11.1 delta is purely additive/cosmetic — the upstream release relocated the `pg_monitor` GRANT block to just after `pgmq.meta` and reworded one `pg_dump` comment; every function body is byte-identical to what pgbus already shipped (which was already a customized superset including `read_grouped_head` plus pgbus topic-routing and notify-insert extensions), so no function pgbus calls through `Pgbus::Client` (`create`, `send`, `read`, `archive`, `delete`, `purge_queue`, `metrics`, …) changed. No new `Client` API was added for `read_grouped_head` (the locked pgmq-ruby 0.7.0 client doesn't expose it and pgbus has no consumer). Proving the upgrade path end-to-end (install v1.11.0 → run the generated `upgrade_pgmq` migration's SQL → v1.11.1 with existing queues and messages surviving) surfaced and fixed a latent bug in `PgmqSchema.drop_pgmq_functions_sql`: its composite-type drop loop skipped every standalone `CREATE TYPE … AS (…)` (they get a `pg_class` shell with `relkind = 'c'`, which the old `pg_class.reltype` guard treated as a table-backed row-type), so `message_record`/`queue_record`/`metrics_result` were never dropped and the subsequent `install_sql`'s bare `CREATE TYPE` raised "type already exists" — meaning any upgrade migration failed. The guard now only skips true relation row-types (`relkind <> 'c'`), so the drop covers the composite type shells and the upgrade re-creates them cleanly. A DB-backed integration spec (guarded by `PGBUS_DATABASE_URL`, skipped cleanly without it) proves the full round-trip. Refs #286.
296
-
297
511
  - **Docs: backfilled seven streams features shipped since v0.9.7 (#165–#171) that were name-dropped or missing from the docs site and README.** The [Real-time streams](https://pgbus.zoolutions.llc/docs/streams) page gains a "Broadcast options" table (`exclude:`, `event:`, `coalesce:`, `durable:`, `visible_to:`) plus focused subsections: `exclude:`/actor-echo suppression with the full `X-Pgbus-Connection` header round-trip and `pgbus:connected` frame (#165); `broadcast_render`'s three renderable resolutions — `String` → `#call` → `#render_in` → `#to_s` — and content-less actions (#166); typed SSE event names — server `event:` kwarg → SSE frame → `pgbus:<event>`/`pgbus:event` client dispatch, with the `listen-events` reconnect caveat (#170); `coalesce:` publish-side debounce semantics — trailing-edge-with-max-wait, safe only for idempotent replace/update, superseded frames never hit PGMQ (#171); connection-driven presence — auto-join/leave/touch lifecycle, the context-extractor contract, alongside the existing manual presence API (#169); `stream_key` single-String idempotency and `stream_key!` (#167); and the `msg_id`/revision client-side reconciliation recipe — track the highest applied msgId per target, skip stale morphs (#168). `ConfigReference::streams_presence_patterns`/`streams_presence_member` moved from `INTERNAL_ONLY` to the "Streams (SSE)" `GROUPS` entry in `docs/app/models/config_reference.rb` since they're user-facing opt-in configuration, unlike the genuinely internal streams tuning keys around them — the drift spec (`docs/spec/config_reference_spec.rb`) keeps the move honest. README's "Real-time broadcasts" section gets the same parity pass. No runtime code changed. Refs #280.
298
-
299
512
  - **Docs: backfill operations & DX documentation shipped since 0.9.7.** `pgbus doctor`/`rake pgbus:doctor`, `pgbus dlq` (list/show/retry/retry-all/purge), the health endpoints (`Pgbus::Web::HealthApp`, `/livez`/`/readyz`, standalone `health_port`/`health_bind`), eager configuration validation (`eager_validation`), `Client#pool_stats`, the supervisor boot diagnostics banner, per-worker throughput rates on the dashboard's Processes panel, and libpq-native read timeouts (dedicated-connection `statement_timeout`/`tcp_user_timeout` vs. shared-AR `database.yml` guidance) were implemented in prior releases but undocumented on both the docs site and the README. Backfilled into `docs/app/views/docs/pages/cli_generators.rb`, `observability.rb`, `running_workers.rb`, `configuration.rb`, and `dashboard.rb`, plus matching README sections — the client-level `Client::ConnectionHealth` circuit breaker gets its own subsection distinguishing it from the per-queue `Pgbus::CircuitBreaker`, since conflating the two is the likeliest source of operator confusion. Refs #279.
300
-
301
513
  - **Docs: "Upgrading pgbus" guide covering the 0.9.7 → 0.9.8 → 1.0.0 path.** No version-to-version upgrade narrative existed — the Migrate group covered switching from other gems only, and the `pgbus:update` story lived as a callout inside the installation page. New `docs/app/views/docs/pages/upgrading_pgbus.rb` (Migrate group) documents the standard upgrade procedure every hop follows (`bundle update pgbus` → `rails generate pgbus:update --dry-run`/apply → `rails db:migrate` → `rake pgbus:pgmq:status` + `rails generate pgbus:upgrade_pgmq` if the vendored PGMQ schema moved → deploy → `bundle exec pgbus doctor`), with a rolling-deploy note on heartbeat/metadata compatibility across mixed versions. A 0.9.x → 0.9.8 section documents the two behavior changes (queue-name validation now rejects dashes; eager configuration validation at boot, with the `eager_validation = false` escape hatch) plus the PGMQ 1.11.0 → 1.11.1 vendored schema bump as a worked example, and links the new opt-in features (`metrics_backend`, `health_port`, `pgbus dlq`, `pgbus doctor`). A forward-looking 0.9.8 → 1.0.0 section stubs the semver commitment and the planned renames/removals tracked by #282 (unified `Pgbus::Error` hierarchy) and #283 (config renames + dead-surface removal) — explicitly marked as a stub those issues must update as they land. The README's "Upgrading from an older pgbus?" callout now links the page. Refs #278.
302
514
 
303
- - **Docs: a documentation site at [pgbus.zoolutions.llc](https://pgbus.zoolutions.llc).** A nested, stateless [docs-kit](https://github.com/mhenrixon/docs-kit) Rails app under `docs/` (the same monorepo pattern as docs-kit's own dogfood site and phlex-reactive) turns the single 1,786-line README into a browsable site: 25 pages across six groups (Getting started, Guide, Operations, Testing, Migrate, Reference), five hand-built theme-aware SVG flow diagrams (architecture, message lifecycle, event fanout, outbox, streams), full-text search, per-page Markdown twins, `/llms.txt`/`/llms-full.txt`, and a live read-only MCP endpoint (`POST /mcp`). Recurring tasks — previously implemented but undocumented — now have a dedicated page written from the source. The Configuration reference is generated from a data constant guarded by a drift spec that checks it against the real `Pgbus::Configuration` accessors in both directions, so a renamed or added option fails the build. The gem package is unchanged: `spec.files` globs only `app/ config/ exe/ lib/` + root files, so nothing under `docs/` ships in the gem. The three `docs/switch_from_*.md` migration guides now point to their site pages. Deployed via Kamal + the shared docs-kit reusable workflow; the docs site has its own CI job (`.github/workflows/docs-ci.yml`) scoped to `docs/**`. Refs #266.
515
+ ### Changed
304
516
 
305
- - **Observability: per-worker throughput rates persisted in heartbeat metadata.** Each worker already tracked live throughput in a `RateCounter` (incremented on `:dequeued`/`:processed`/`:failed` and snapshotted every heartbeat via the worker's `on_beat` hook), but those rates never left the process: `Worker#stats` was in-process only and `Heartbeat#beat` persisted just `last_heartbeat_at` plus `loop_tick_at`. The dashboard's Processes page could show only static boot-time metadata (queues/threads/pid) — operators had no cluster-wide view of per-worker throughput. `Pgbus::Process::Heartbeat#initialize` now accepts a `metadata_supplier:` (a callable returning a Hash, default `nil`); `#beat` calls it **after** `@on_beat` (so the rate counter's `snapshot!` has already refreshed the rates) and merges the result into the persisted `metadata` alongside `loop_tick_at`, all inside the existing `rescue StandardError` so a raising supplier logs a warning and the timer survives. `Worker#start_heartbeat` wires a supplier returning `{ "rates" => {processed, failed, dequeued}, "jobs_processed", "jobs_failed", "in_flight" }`. Heartbeats with no supplier (supervisor, dispatcher, consumer, scheduler, outbox poller, streamer) are untouched — `beat_metadata` returns `nil` for them and leaves the column exactly as before. No SQL changes: `Web::DataSource#format_process` already passes `metadata` through, keeping the no-raw-SQL-outside-`Web::DataSource` rule intact. The Processes table renders the rates human-readable via a new `pgbus_worker_rates` helper (e.g. `12.4/s processed · 0.2/s failed`, zero rates omitted), with i18n keys added under `pgbus.processes.processes_table.rates` across all 12 locales; the raw `rates`/counter keys are hidden from the generic metadata dump. Refs #221.
517
+ - **1.0 API freeze: config renames as deprecated aliases, and removal of provably-dead surface.** Renaming now is cheap; renaming after 1.0 is a major-version break, so the semver-audit renames land as **deprecated aliases** (old name works, warns once, removed in 2.0): `skip_recurring` → `recurring_enabled` (positive polarity, the only negative toggle among the `*_enabled` switches — the alias inverts the boolean); `dashboard_filter_parameters`/`dashboard_filter_sensitive` → `web_filter_parameters`/`web_filter_sensitive` (unify on the incumbent `web_` prefix); and `recurring_tasks_file` (singular), which now warns once when set alongside the plural `recurring_tasks_files` instead of being silently ignored. Both spellings of each alias resolve to the same setter. **Removed** (each verified zero-caller in the audit): the `lock_ttl:` keyword on `ensures_uniqueness` (validated and stored in metadata but read by nothing — passing it now raises `ArgumentError` naming the removal and the upgrade guide); the `pgbus:add_job_locks` generator and the `Pgbus::JobLock` model (new installs use `pgbus:add_uniqueness_keys`; `pgbus:migrate_job_locks` still retires the legacy `pgbus_job_locks` table); the internal `with_pgbus_durable` streams helper (use `with_pgbus_broadcast_opts(durable:)`); and the streamer's `reconnect_via_reset` fallback (`connection_factory` is now required and always injected, so reconnect always rebuilds a fresh connection). `log_format=` no longer clobbers a custom logger's formatter, and `streams_presence_patterns`/`streams_presence_member`, `group_mode`, and `streams_falcon_streaming_body` are annotated **experimental** (exempt from the 1.0 stability promise). The `workers=` Array form is documented as permanent (not "legacy") — it is the only way to express N identical anonymous capsules. Refs #283.
518
+ - **CI now proves the `railties >= 7.1, < 9.0` support matrix at both endpoints, not just the top.** Every CI leg previously resolved a single `Gemfile.lock` pinned to the latest 8.x, so Rails 7.1 — the lower bound the gemspec promises — was never exercised; shipping 1.0 with an untested support claim is a semver-integrity risk. Added `gemfiles/rails_7_1.gemfile` (+ a checked-in `gemfiles/rails_7_1.gemfile.lock`) that pins the Rails components to `~> 7.1.0` via a plain `BUNDLE_GEMFILE` override (no appraisal gem), and the `test` job now runs the unit suite (`spec/pgbus/ spec/generators/`) against both the main 8.x Gemfile (Ruby 3.3/3.4/4.0) and the 7.1 endpoint (Ruby 3.3 floor + 4.0 ceiling). The 7.1 legs pass green — the only fix needed was test-side: `spec/pgbus/web/data_source_spec.rb` mocked the ActiveRecord connection but let real model side-paths (`QueueState.paused`, `RecurringTask.count`) hit it, and AR 7.1's eager schema load raised RSpec's `MockExpectationError` (an `Exception`, not a `StandardError`, so the methods' rescue missed it); AR 8.1 resolves schema lazily and never surfaced the gap. The main Gemfile now reads an optional `RAILS_VERSION` env override (defaulting to the full supported range) so the endpoint gemfile can pin the lower bound without duplicating gem declarations. A `lint_floor` CI job also runs rubocop + `rake build` on Ruby 3.3 (the gemspec's `required_ruby_version` floor). Refs #284.
306
519
 
307
- - **Observability: HTTP liveness and readiness endpoints for orchestrators.** Pgbus had no HTTP health surface: the `OK`/`DEGRADED`/`STALLED` verdict in `Pgbus::MCP::HealthAnalyzer` was reachable only through the MCP JSON-RPC tool or the auth-gated dashboard, and the supervisor process (which forks and watches workers) had no HTTP surface at all — so a Kubernetes kubelet could not distinguish "supervisor alive" from "workers silently wedged". New `Pgbus::Web::HealthApp` (`lib/pgbus/web/health_app.rb`) is a plain Rack app exposing `GET /livez` (→ `200 text/plain "ok"`, unconditionally, **no** database access — the serving process is up) and `GET /readyz` (builds a `Web::DataSource`, runs `HealthAnalyzer#verdict`: `OK`/`DEGRADED` → `200`, `STALLED` → `503`, body = the verdict JSON; any `StandardError` such as an unreachable DB → `503 {"status":"ERROR"}`, logged via `Pgbus.logger` and never swallowed). Unknown paths return `404`; non-`GET` returns `405`. `DEGRADED` deliberately stays ready — only the silent-wedge `STALLED` signal fails readiness. Mount it in a web pod: `mount Pgbus::Web::HealthApp.new => "/pgbus/health"`. For worker pods that run the supervisor (no Puma to mount into), set `config.health_port` (Integer, default `nil` = disabled) and `config.health_bind` (default `"127.0.0.1"`) and `Supervisor#run` serves both paths standalone via `Pgbus::Web::HealthServer` (`lib/pgbus/web/health_server.rb`) — a single accept-loop thread over a `TCPServer` that parses only the HTTP request line, synthesizes a minimal Rack env, and dispatches to the same `HealthApp#call`, so routing and the verdict→status mapping live in exactly one place. The analyzer is required lazily (it has no `mcp`-gem dependency), so the standalone server works without the optional `mcp` gem installed. Refs #218.
520
+ ### Fixed
308
521
 
309
- - **Observability: generic metrics adapter with Prometheus and StatsD backends.** `lib/pgbus/instrumentation.rb` documents 16 `pgbus.*` `ActiveSupport::Notifications` events, but the only shipped consumer was the AppSignal integration (`lib/pgbus/integrations/appsignal/subscriber.rb` subscribes 13 of them) — teams on Prometheus, Datadog, or plain StatsD got no metrics without hand-writing subscribers. New `Pgbus::Metrics` namespace (Zeitwerk-managed under `lib/pgbus/metrics/`, unlike the explicitly-required `integrations/`) adds a vendor-neutral second consumer: `Metrics::Backend` (interface — `increment(name, value = 1, tags = {})`, `gauge(name, value, tags = {})`, `histogram(name, value, tags = {})` — plus a `Null` no-op subclass); `Metrics::Subscriber` (mirrors the AppSignal subscriber's 13 subscriptions and identical `pgbus_`-prefixed metric names/tags — `pgbus_queue_job_count` with `status`, `pgbus_job_duration_ms`, `pgbus_messages_sent`/`pgbus_messages_read`, `pgbus_event_count`/`pgbus_event_duration_ms`, `pgbus_outbox_published`, `pgbus_recurring_enqueued`, `pgbus_worker_recycled`, `pgbus_stream_broadcast_count` — forwarding to the configured backend, every handler wrapped in a `safely` rescue+log so a raising backend never propagates into the producer thread); `Metrics::Backends::Prometheus` (dependency-free, Mutex-guarded in-process registry, histograms as sum+count summaries, text-exposition rendering with label escaping); `Metrics::PrometheusExporter` (a self-contained Rack app rendering exposition format v0.0.4, mountable like `StreamApp`, `503` when the configured backend is not Prometheus); and `Metrics::Backends::Statsd` (`UDPSocket` datagrams — `|c`/`|g`/`|ms` with DogStatsD `|#tag:value` tags, no gem dependency). Config keys in `lib/pgbus/configuration.rb`: `metrics_backend` (`nil` default | `:prometheus` | `:statsd` | a `Backend` instance — validated eagerly in `validate!`), `statsd_host` (`"127.0.0.1"`), `statsd_port` (`8125`). The subscriber installs from a new `pgbus.metrics` engine initializer that mirrors the AppSignal one — `next if metrics_backend.nil?` so the default installs no subscription (zero overhead). AppSignal is untouched; both integrations can run simultaneously. README documents configuration, the emitted metrics, and mounting the exporter. Refs #219.
522
+ - **Metrics: `pgbus.consumer.recycle` now reaches both metrics subscribers, and `pgbus.client.pool` reaches `Metrics::Subscriber`.** Since v0.9.7 (#200, #205) two instrumentation events had no metrics consumer: `pgbus.client.pool` (emitted per worker heartbeat) was subscribed by neither `Metrics::Subscriber` nor the AppSignal subscriber — Prometheus/StatsD users got zero pool visibility, and even AppSignal only saw pool gauges via the separate minutely `Probe#track_pool` path; `pgbus.consumer.recycle` was subscribed by neither, so consumer recycling (#205) was invisible everywhere while `pgbus.worker.recycle` alone incremented `pgbus_worker_recycled`. `Metrics::Subscriber` now subscribes both: `pgbus.client.pool` forwards `size`/`available` as `pgbus_pool_size` / `pgbus_pool_available` gauges tagged `hostname` (matching the AppSignal probe's naming), and `pgbus.consumer.recycle` increments the *existing* `pgbus_worker_recycled` counter (no new metric) tagged `kind: "consumer"` alongside `reason`; `pgbus.worker.recycle` now also carries `kind: "worker"` so the tag is always present. The AppSignal subscriber gains the same `pgbus.consumer.recycle` handling but deliberately does **not** subscribe `pgbus.client.pool` — the minutely probe already covers AppSignal, and subscribing both would double-report and skew the gauges (documented as a comment at the subscription site). Also corrected the drifted `lib/pgbus/metrics.rb` docstring, which claimed a stale event count, to point at `Pgbus::Instrumentation` as the single source of truth for the emitted `pgbus.*` event catalog. Refs #275.
523
+ - **The documented uniqueness onboarding path now creates the table the runtime actually reads.** The README's uniqueness Setup section told a new user to run `rails generate pgbus:add_job_locks`, which creates `pgbus_job_locks` — a table nothing in the runtime reads. `ensures_uniqueness` (`Pgbus::UniquenessKey` in `app/models/pgbus/uniqueness_key.rb`, `lib/pgbus/uniqueness.rb`) and the dispatcher's reaper (`cleanup_job_locks`/`reap_orphaned_uniqueness_keys`) work exclusively against `pgbus_uniqueness_keys`; the modern `add_uniqueness_keys.rb.erb` migration template existed but was reachable only through `pgbus:update`'s `MigrationDetector`, so a fresh install following the README silently never locked anything. New standalone generator `pgbus:add_uniqueness_keys` (mirrors `pgbus:add_queue_states`, including `--database=`) exposes that template directly; `MigrationDetector::GENERATOR_MAP[:uniqueness_keys]` now invokes it instead of `pgbus:migrate_job_locks`. `pgbus:add_job_locks` is not removed — installs mid-migration still legitimately need it alongside `pgbus:migrate_job_locks` — but it now prints a deprecation warning pointing new installs at `pgbus:add_uniqueness_keys`. README and the docs-site `concurrency-uniqueness`/`cli-generators` pages retargeted to the new generator. Refs #277.
524
+ - **Config hardening: silent-failure holes closed.** `Configuration#validate!` now validates `statsd_host`/`statsd_port`/`health_bind` unconditionally alongside the other keys added in the same window — a typo'd host or an out-of-range port previously passed boot and only failed later when the UDP socket opened or the health server bound. (This entry originally also documented two YAML-config hardening fixes — `ConfigLoader.sectioned?` misclassification and a `ConfigConverter::DEPRECATED_SETTINGS` `pool_size` drop — but the entire `config/pgbus.yml` surface was removed in 1.0, so those no longer apply.) Refs #276.
310
525
 
311
- - **DX: `pgbus dlq` — dead-letter management from the CLI.** Dead-letter inspect/drain operations existed only behind the mounted web dashboard (`Web::DataSource#dlq_messages`/`#dlq_message_detail`/`#retry_dlq_message`/`#discard_dlq_message`/`#retry_all_dlq`/`#discard_all_dlq`), so headless deployments and incident runbooks could not inspect or drain a DLQ without ad-hoc SQL — which project rules forbid. New `Pgbus::CLI::DLQ` (`lib/pgbus/cli/dlq.rb`), dispatched from `CLI.start`, adds five subcommands that route through `Web::DataSource` so retry semantics stay identical to the dashboard (origin-queue re-enqueue by stripping `DEAD_LETTER_SUFFIX`, transactional produce+delete, lock release on discard) with zero raw SQL and no direct PGMQ calls: `pgbus dlq list [--page N] [--per-page N]` (table of msg_id, DLQ queue, origin queue, read_ct, enqueued_at plus a total-count footer — never payloads), `pgbus dlq show MSG_ID` (message metadata with the payload passed through `Web::PayloadFilter.filter_json` so the dashboard's sensitive-data filtering applies identically), `pgbus dlq retry MSG_ID` (resolve the DLQ queue via `dlq_message_detail`, then `retry_dlq_message`), `pgbus dlq retry-all` (prints the re-enqueued count), and `pgbus dlq purge MSG_ID` / `pgbus dlq purge --all --yes` (`--all` without `--yes` makes no changes and exits 1). An unknown msg_id and an unknown subcommand both exit 1. Refs #216.
526
+ ## [0.9.8] - 2026-07-04
312
527
 
313
- - **DX: boot diagnostics banner at supervisor start.** `Supervisor#run` logged only `"[Pgbus] Supervisor starting pid=…"` — the settings that decide whether a deployment actually works (which database it connected to, LISTEN/NOTIFY vs polling, the resolved pool size, which roles and capsules boot) were never stated, so a misconfigured deployment that sat idle forced operators to attach a console and inspect `Pgbus.configuration` by hand. `Supervisor#run` now emits a one-block banner (a run of `"[Pgbus] boot:"`-prefixed `info` lines that render cleanly under both the `:text` and `:json` log formatters) right after `start_heartbeat` and before `bootstrap_queues`: `Pgbus::VERSION`; the connection target reduced to `host/dbname` (never the password) across all three `connection_options` forms — `database_url` string, `connection_params` hash, and AR-derived hash; `config.resolved_pool_size`; `config.pgmq_schema_mode` and the installed PGMQ version (best-effort via `Client#pgmq_schema_version`, `unknown` on any error); `config.listen_notify` and `config.worker_notify_wakeup?`; the roles that will actually boot honoring `config.roles`; and one line per worker capsule (name or `anonymous`, queues, threads, execution mode via `config.execution_mode_for`) and per event consumer (topics, threads). Every DB-dependent field is wrapped so a transient failure degrades that field to `unknown` — the banner can never abort boot. Refs #213.
528
+ ### Added
314
529
 
530
+ - **Docs: a documentation site at [pgbus.zoolutions.llc](https://pgbus.zoolutions.llc).** A nested, stateless [docs-kit](https://github.com/mhenrixon/docs-kit) Rails app under `docs/` (the same monorepo pattern as docs-kit's own dogfood site and phlex-reactive) turns the single 1,786-line README into a browsable site: 25 pages across six groups (Getting started, Guide, Operations, Testing, Migrate, Reference), five hand-built theme-aware SVG flow diagrams (architecture, message lifecycle, event fanout, outbox, streams), full-text search, per-page Markdown twins, `/llms.txt`/`/llms-full.txt`, and a live read-only MCP endpoint (`POST /mcp`). Recurring tasks — previously implemented but undocumented — now have a dedicated page written from the source. The Configuration reference is generated from a data constant guarded by a drift spec that checks it against the real `Pgbus::Configuration` accessors in both directions, so a renamed or added option fails the build. The gem package is unchanged: `spec.files` globs only `app/ config/ exe/ lib/` + root files, so nothing under `docs/` ships in the gem. The three `docs/switch_from_*.md` migration guides now point to their site pages. Deployed via Kamal + the shared docs-kit reusable workflow; the docs site has its own CI job (`.github/workflows/docs-ci.yml`) scoped to `docs/**`. Refs #266.
531
+ - **Observability: per-worker throughput rates persisted in heartbeat metadata.** Each worker already tracked live throughput in a `RateCounter` (incremented on `:dequeued`/`:processed`/`:failed` and snapshotted every heartbeat via the worker's `on_beat` hook), but those rates never left the process: `Worker#stats` was in-process only and `Heartbeat#beat` persisted just `last_heartbeat_at` plus `loop_tick_at`. The dashboard's Processes page could show only static boot-time metadata (queues/threads/pid) — operators had no cluster-wide view of per-worker throughput. `Pgbus::Process::Heartbeat#initialize` now accepts a `metadata_supplier:` (a callable returning a Hash, default `nil`); `#beat` calls it **after** `@on_beat` (so the rate counter's `snapshot!` has already refreshed the rates) and merges the result into the persisted `metadata` alongside `loop_tick_at`, all inside the existing `rescue StandardError` so a raising supplier logs a warning and the timer survives. `Worker#start_heartbeat` wires a supplier returning `{ "rates" => {processed, failed, dequeued}, "jobs_processed", "jobs_failed", "in_flight" }`. Heartbeats with no supplier (supervisor, dispatcher, consumer, scheduler, outbox poller, streamer) are untouched — `beat_metadata` returns `nil` for them and leaves the column exactly as before. No SQL changes: `Web::DataSource#format_process` already passes `metadata` through, keeping the no-raw-SQL-outside-`Web::DataSource` rule intact. The Processes table renders the rates human-readable via a new `pgbus_worker_rates` helper (e.g. `12.4/s processed · 0.2/s failed`, zero rates omitted), with i18n keys added under `pgbus.processes.processes_table.rates` across all 12 locales; the raw `rates`/counter keys are hidden from the generic metadata dump. Refs #221.
532
+ - **Observability: HTTP liveness and readiness endpoints for orchestrators.** Pgbus had no HTTP health surface: the `OK`/`DEGRADED`/`STALLED` verdict in `Pgbus::MCP::HealthAnalyzer` was reachable only through the MCP JSON-RPC tool or the auth-gated dashboard, and the supervisor process (which forks and watches workers) had no HTTP surface at all — so a Kubernetes kubelet could not distinguish "supervisor alive" from "workers silently wedged". New `Pgbus::Web::HealthApp` (`lib/pgbus/web/health_app.rb`) is a plain Rack app exposing `GET /livez` (→ `200 text/plain "ok"`, unconditionally, **no** database access — the serving process is up) and `GET /readyz` (builds a `Web::DataSource`, runs `HealthAnalyzer#verdict`: `OK`/`DEGRADED` → `200`, `STALLED` → `503`, body = the verdict JSON; any `StandardError` such as an unreachable DB → `503 {"status":"ERROR"}`, logged via `Pgbus.logger` and never swallowed). Unknown paths return `404`; non-`GET` returns `405`. `DEGRADED` deliberately stays ready — only the silent-wedge `STALLED` signal fails readiness. Mount it in a web pod: `mount Pgbus::Web::HealthApp.new => "/pgbus/health"`. For worker pods that run the supervisor (no Puma to mount into), set `config.health_port` (Integer, default `nil` = disabled) and `config.health_bind` (default `"127.0.0.1"`) and `Supervisor#run` serves both paths standalone via `Pgbus::Web::HealthServer` (`lib/pgbus/web/health_server.rb`) — a single accept-loop thread over a `TCPServer` that parses only the HTTP request line, synthesizes a minimal Rack env, and dispatches to the same `HealthApp#call`, so routing and the verdict→status mapping live in exactly one place. The analyzer is required lazily (it has no `mcp`-gem dependency), so the standalone server works without the optional `mcp` gem installed. Refs #218.
533
+ - **Observability: generic metrics adapter with Prometheus and StatsD backends.** `lib/pgbus/instrumentation.rb` documents 16 `pgbus.*` `ActiveSupport::Notifications` events, but the only shipped consumer was the AppSignal integration (`lib/pgbus/integrations/appsignal/subscriber.rb` subscribes 13 of them) — teams on Prometheus, Datadog, or plain StatsD got no metrics without hand-writing subscribers. New `Pgbus::Metrics` namespace (Zeitwerk-managed under `lib/pgbus/metrics/`, unlike the explicitly-required `integrations/`) adds a vendor-neutral second consumer: `Metrics::Backend` (interface — `increment(name, value = 1, tags = {})`, `gauge(name, value, tags = {})`, `histogram(name, value, tags = {})` — plus a `Null` no-op subclass); `Metrics::Subscriber` (mirrors the AppSignal subscriber's 13 subscriptions and identical `pgbus_`-prefixed metric names/tags — `pgbus_queue_job_count` with `status`, `pgbus_job_duration_ms`, `pgbus_messages_sent`/`pgbus_messages_read`, `pgbus_event_count`/`pgbus_event_duration_ms`, `pgbus_outbox_published`, `pgbus_recurring_enqueued`, `pgbus_worker_recycled`, `pgbus_stream_broadcast_count` — forwarding to the configured backend, every handler wrapped in a `safely` rescue+log so a raising backend never propagates into the producer thread); `Metrics::Backends::Prometheus` (dependency-free, Mutex-guarded in-process registry, histograms as sum+count summaries, text-exposition rendering with label escaping); `Metrics::PrometheusExporter` (a self-contained Rack app rendering exposition format v0.0.4, mountable like `StreamApp`, `503` when the configured backend is not Prometheus); and `Metrics::Backends::Statsd` (`UDPSocket` datagrams — `|c`/`|g`/`|ms` with DogStatsD `|#tag:value` tags, no gem dependency). Config keys in `lib/pgbus/configuration.rb`: `metrics_backend` (`nil` default | `:prometheus` | `:statsd` | a `Backend` instance — validated eagerly in `validate!`), `statsd_host` (`"127.0.0.1"`), `statsd_port` (`8125`). The subscriber installs from a new `pgbus.metrics` engine initializer that mirrors the AppSignal one — `next if metrics_backend.nil?` so the default installs no subscription (zero overhead). AppSignal is untouched; both integrations can run simultaneously. README documents configuration, the emitted metrics, and mounting the exporter. Refs #219.
534
+ - **DX: `pgbus dlq` — dead-letter management from the CLI.** Dead-letter inspect/drain operations existed only behind the mounted web dashboard (`Web::DataSource#dlq_messages`/`#dlq_message_detail`/`#retry_dlq_message`/`#discard_dlq_message`/`#retry_all_dlq`/`#discard_all_dlq`), so headless deployments and incident runbooks could not inspect or drain a DLQ without ad-hoc SQL — which project rules forbid. New `Pgbus::CLI::DLQ` (`lib/pgbus/cli/dlq.rb`), dispatched from `CLI.start`, adds five subcommands that route through `Web::DataSource` so retry semantics stay identical to the dashboard (origin-queue re-enqueue by stripping `DEAD_LETTER_SUFFIX`, transactional produce+delete, lock release on discard) with zero raw SQL and no direct PGMQ calls: `pgbus dlq list [--page N] [--per-page N]` (table of msg_id, DLQ queue, origin queue, read_ct, enqueued_at plus a total-count footer — never payloads), `pgbus dlq show MSG_ID` (message metadata with the payload passed through `Web::PayloadFilter.filter_json` so the dashboard's sensitive-data filtering applies identically), `pgbus dlq retry MSG_ID` (resolve the DLQ queue via `dlq_message_detail`, then `retry_dlq_message`), `pgbus dlq retry-all` (prints the re-enqueued count), and `pgbus dlq purge MSG_ID` / `pgbus dlq purge --all --yes` (`--all` without `--yes` makes no changes and exits 1). An unknown msg_id and an unknown subcommand both exit 1. Refs #216.
535
+ - **DX: boot diagnostics banner at supervisor start.** `Supervisor#run` logged only `"[Pgbus] Supervisor starting pid=…"` — the settings that decide whether a deployment actually works (which database it connected to, LISTEN/NOTIFY vs polling, the resolved pool size, which roles and capsules boot) were never stated, so a misconfigured deployment that sat idle forced operators to attach a console and inspect `Pgbus.configuration` by hand. `Supervisor#run` now emits a one-block banner (a run of `"[Pgbus] boot:"`-prefixed `info` lines that render cleanly under both the `:text` and `:json` log formatters) right after `start_heartbeat` and before `bootstrap_queues`: `Pgbus::VERSION`; the connection target reduced to `host/dbname` (never the password) across all three `connection_options` forms — `database_url` string, `connection_params` hash, and AR-derived hash; `config.resolved_pool_size`; `config.pgmq_schema_mode` and the installed PGMQ version (best-effort via `Client#pgmq_schema_version`, `unknown` on any error); `config.listen_notify` and `config.worker_notify_wakeup?`; the roles that will actually boot honoring `config.roles`; and one line per worker capsule (name or `anonymous`, queues, threads, execution mode via `config.execution_mode_for`) and per event consumer (topics, threads). Every DB-dependent field is wrapped so a transient failure degrades that field to `unknown` — the banner can never abort boot. Refs #213.
315
536
  - **DX: configuration is now validated eagerly at boot.** `Configuration#validate!` had no call site in `lib/` — `Pgbus.configure` just yielded, so an invalid value like `visibility_timeout = 0` sat dormant until a worker code path consumed it, failing at runtime far from the misconfiguration (the opposite of the fail-loud-at-boot behavior `roles=` and the duration setters already had). `Pgbus.configure` now runs `configuration.validate!` after the yield, so a bad value aborts Rails boot with an error naming the offending key. `validate!` stays DB-free (pure value checks), so eager validation adds no boot-time DB dependency; defaults are valid, so partial and sequential `configure` blocks pass. A new `attr_accessor :eager_validation` (default `true`) is the escape hatch for exotic setups that intentionally hold a transiently-invalid config: set `eager_validation = false` before or inside a `configure` block to suppress the automatic call, and explicit `validate!` still works. **Backward-incompatible in one direction:** an invalid-but-previously-unread config now raises at boot instead of silently later; that is the intended change, and `eager_validation = false` restores the old behavior. (The 1.0 YAML-config removal later dropped the `config/pgbus.yml` load path referenced by the original version of this note.) Refs #215.
316
-
317
537
  - **DX: `pgbus doctor` — a single preflight command for deploy/CI gating.** Nothing answered "is this pgbus environment healthy enough to run?" in one place: config validity was never checked at boot (`Configuration#validate!` was never called), DB reachability only surfaced when the supervisor forked children that crash-looped, PGMQ version lived in `rake pgbus:pgmq:status`, and the process-liveness verdict was reachable only through the MCP stdio server (which needs the optional `mcp` gem). New `Pgbus::Doctor#run` runs six checks — each a `{ name:, status: :ok|:warn|:fail, detail: }` — and `#success?`: (1) `Configuration#validate!`, (2) DB connectivity via a new public `Client#ping` (`SELECT 1` through `with_raw_connection`), (3) PGMQ schema presence and installed-vs-vendored version via a new `Client#pgmq_schema_version` (the same tracking-table read `pgbus:pgmq:status` uses, kept inside the Client so no raw SQL escapes it), (4) queue existence — configured queues (via `Client#configured_queues`, prefixed through `config.queue_name`) diffed against `Client#list_queues`, (5) LISTEN/NOTIFY liveness per configured queue via a new `Client#notify_enabled?`, and (6) process liveness via `Pgbus::MCP::HealthAnalyzer` (required directly — it only needs a `Web::DataSource`, not the `mcp` gem). Doctor never touches PGMQ/PostgreSQL directly and never raises: a broken environment turns every probe into a `:fail` result rather than a crash. `#report` prints one line per check plus a resolved-config summary (queue_prefix, default_queue, pgmq_schema_mode, resolved_pool_size, roles, capsules) with passwords redacted from `database_url`/`connection_params`. Wired into `pgbus doctor` (exit 1 unless `success?`, so it gates a deploy or CI run) and `rake pgbus:doctor`. Refs #212.
318
-
319
538
  - **Process: exponential cooldown on the worker queue evict/restore cycle.** When every queue a worker owned had been evicted — the queue table permanently deleted — `restore_evicted_queues` reinstated the initial queue set immediately, both from `evict_missing_queues` and again at the top of every `fetch_messages`. The worker then oscillated forever: fetch → undefined-table error → evict → restore → fetch → error…, emitting an error+warn pair every loop tick (default 0.1s), spamming logs and hammering the database with doomed reads. Restore is now gated by an exponential cooldown: the first restore after a full eviction waits `RESTORE_COOLDOWN_BASE` (30s), and each consecutive failed restore doubles the wait up to `RESTORE_COOLDOWN_MAX` (300s), tracked by a monotonic `@last_evicted_at` window and a `@restore_streak` counter. While the cooldown is pending, `fetch_messages` leaves `@queues` empty (the caller idles on the existing empty-`active_queues` path) and logs exactly one deferral `warn` per window ("all queues evicted; next restore attempt in Ns") instead of an error pair per tick. A fetch that completes without an undefined-queue error resets the streak to 0, so a recreated queue is reinstated promptly after the next eviction. Wildcard workers are untouched — both restore call sites remain guarded by `!@wildcard`. Refs #209.
320
-
321
539
  - **Process model: Consumer brought to parity with Worker — recycling and NOTIFY wake-up.** `Pgbus::Process::Consumer` had neither mechanism: its loop never checked `max_jobs_per_worker` / `max_memory_mb` / `max_worker_lifetime`, so a long-lived consumer fork grew memory unboundedly (the exact solid_queue problem recycling exists to fix), and its empty-read path always slept `polling_interval` even when a matching event had just arrived. Consumer now mirrors Worker: a per-iteration `check_recycle` trips once when any configured limit is exceeded (`@jobs_processed`, an `AtomicFixnum` incremented in an `ensure` on every handled message — success, DLQ-routed, and rescued failure alike, mirroring `Worker#process_message` so `max_jobs` recycling still trips on a poison/all-failing queue; a monotonic `@started_at_monotonic`; and the shared, TTL-cached `Pgbus::Process::MemoryUsage`), logs the reason, emits a `pgbus.consumer.recycle` instrumentation event, and sets `@shutting_down` so the loop drains the pool and exits cleanly — the clean exit earns an immediate supervisor restart, replacing the fork before its memory grows. For wake-up, the two `interruptible_sleep(polling_interval)` calls are replaced with `@wake_signal.wait(timeout: wake_timeout)`, and (when `config.worker_notify_wakeup?`) a `Pgbus::Process::NotifyListener` over the physical names of the consumer's subscribed queues fires `WakeSignal#notify!` the moment a row is inserted, collapsing idle latency from a full poll interval to near-instant. A live listener raises the empty-read wait to a `NOTIFY_FALLBACK_POLL_SECONDS` (15s) safety-net ceiling; a listener that fails to start or whose thread dies logs and falls back to polling, with `ensure_notify_listener` self-healing it on a 5s→300s exponential backoff. With no recycle config and notify wakeup off, behavior is unchanged. All PGMQ access stays behind `Pgbus::Client`. Refs #205.
322
-
323
540
  - **Connections: connection-pool utilization surfaced, and pool-exhaustion errors made actionable.** The PGMQ connection pool was invisible: pgmq-ruby exposes `size`/`available` counters, but pgbus never read them, so the first sign of an undersized or leaking pool was an opaque `PGMQ::Errors::ConnectionError: Connection pool timeout` after `pool_timeout` seconds — with no pool state and no gauge to alert on *before* saturation. New `Pgbus::Client#pool_stats` returns `{ size:, available:, pool_timeout: }` (pgmq-ruby's `stats` merged with the configured timeout) and works on both the dedicated-pool path and the shared-`Proc` path (`size` is 1 there); it is purely observational and rescues internally to `{}` so reading the pool can never break job processing. The worker heartbeat now emits a `pgbus.client.pool` instrumentation event once per beat (heartbeat-only, never on a per-job hot path) carrying that payload, and the AppSignal minutely probe reports `pgbus_pool_size` / `pgbus_pool_available` gauges tagged by hostname (the pool is per-process, unlike the cluster-wide queue/summary gauges). Finally, a bare `Connection pool timeout` error is re-raised as the *same* `PGMQ::Errors::ConnectionError` class (so `with_stale_connection_retry` semantics are unchanged and pool timeouts remain non-retryable) but with the live pool state and an actionable hint appended — "raise `Pgbus.configuration.pool_size` or reduce worker threads" — so the first signal of saturation is diagnosable. Refs #200.
324
-
325
541
  - **Connections: client-level circuit breaker for database-down conditions.** Resilience was previously per-queue only: `Pgbus::CircuitBreaker` records failures from job *execution* and persists its pause state in the database — useless when the database itself is down (its `check_paused` rescues and returns `false`, so nothing trips). Meanwhile every worker's `fetch_messages` rescued each `PGMQ::Errors::ConnectionError`, reported it to `ErrorReporter`, and re-polled every `polling_interval`, so a whole fleet hammered a dead database and flooded the error tracker for the entire outage. A new in-memory, process-local latch — `Pgbus::Client::ConnectionHealth`, owned by `Pgbus::Client` — trips after `OPEN_THRESHOLD` (5) consecutive `PGMQ::Errors::ConnectionError` across any operation. Once open, the read paths (`read_message`, `read_batch`, `read_multi`, `read_batch_prioritized`, `read_grouped*`, `read_with_poll`) fail fast with a new `Pgbus::ConnectionCircuitOpenError` *without checking out a pool connection*; a single half-open probe is admitted after a monotonic backoff window (1s base, doubling per re-open, capped at 60s), whose success closes and resets the breaker and whose failure re-opens it with a doubled window. Enqueues (`send_message`/`send_batch`) are deliberately never short-circuited — callers must see enqueue failures. `Worker#fetch_messages` rescues `ConnectionCircuitOpenError` before its generic rescue and idles the poll with no `ErrorReporter` call, so an outage produces two log lines total (a `warn` on open, an `info` on close) instead of one per worker per poll. The latch is `Mutex`-guarded (single shared state, atomic transitions); a half-open probe that fails with *any* error — including a `Pgbus::ReadTimeoutError` from a hung socket, which the read paths raise from inside the guard — re-opens the breaker rather than wedging it half-open, and a straggler read admitted while closed cannot re-close a breaker another thread just tripped. Refs #197.
326
-
327
542
  - **Connections: LISTEN/NOTIFY delivery self-probe at listener start.** A worker or streamer pointed at a transaction-mode pooler (PgBouncer drops `LISTEN` at transaction boundaries) or a read-only replica (`pg_notify()` raises) previously "connected successfully" but never received a wake-up, silently degrading to slow fallback polling / stalled SSE with no explanation. Each listener now runs a one-shot self-probe right after building its dedicated `PG::Connection`: `LISTEN` a unique per-process channel, `SELECT pg_notify()` to itself, and wait up to 2s for delivery. On failure it logs a single actionable `error` naming the direct-connection overrides to set (`worker_notify_database_url` / `worker_notify_host` / `worker_notify_port` for workers; `streams_database_url` / `streams_host` / `streams_port` for the streamer), best-effort `UNLISTEN`s the probe channel, and returns — the worker/streamer still start and degrade gracefully rather than crash. The probe runs on the initial connection only; reconnects skip it to stay cheap. New `Pgbus::Process::NotifyProbe`. Refs #191 (epic #190).
328
-
329
- - **Streams: optional publish-side coalescing for high-frequency broadcasts.** A chatty reactive component (live cursor, typing indicator, progress bar) can fan out many small broadcasts per second. Pass `coalesce:` (a window in milliseconds, or `true` for the 50ms default) with `target:` to `broadcast`/`broadcast_render` to batch per `(stream, target)` and publish only the *latest* frame within the window — superseded frames never hit the bus (no PGMQ insert, no NOTIFY, no fan-out). Last-write-wins, so it is opt-in and only safe for idempotent `replace`/`update` of a stable target (exactly the high-frequency case). The new `Pgbus::Streams::Coalescer` is a process-wide, thread-safe, trailing-edge-with-max-wait debounce: the first submit per window schedules the flush (bounding latency to one window) and later submits only overwrite the buffered payload; the flush re-enters the normal broadcast path, so a coalesced frame is just a deferred ordinary broadcast that still composes with `visible_to`/`exclude`/`event`/`durable`. Refs #171.
330
-
331
- - **Streams: typed SSE event names on broadcasts.** A broadcast can set the SSE `event:` field — `Pgbus.stream(name).broadcast(html, event: "presence")` (and `broadcast_render(..., event:)`) — while keeping the payload a Turbo Stream, so clients route on the typed name instead of sniffing the HTML. The default (`nil` or `"turbo-stream"`) is omitted from the JSONB payload to avoid redundancy, but is still set on the SSE frame's `event:` line (the connection adapter falls back to `turbo-stream`), so default consumers still get the standard `message`/turbo-stream path. The event flows through the JSONB payload → `StreamEnvelope.event` → the SSE frame's `event:` line (both the Puma and Falcon connection adapters). On the client, `<pgbus-stream-source>` dispatches a typed broadcast as a generic `pgbus:event` (`{ event, data, msgId }`) and a named `pgbus:<event>` (`{ data, msgId }`) for `addEventListener` ergonomics; declare typed event names via the element's `listen-events` attribute so they survive the EventSource reconnect path. Refs #170.
332
-
333
- - **Streams: optional connection-driven presence.** Streams matching `config.streams_presence_patterns` (exact string or Regexp, mirroring `streams_durable_patterns`) now auto-join a member on SSE connect, auto-leave on disconnect, and refresh `last_seen_at` on the keepalive heartbeat — no explicit `join`/`leave`/sweeper wiring. Identity comes from the connection's authorize-hook context: the built-in extractor handles a Hash with `:member_id`/`:id` (plus optional `:metadata`) or any object responding to `#id`, and `config.streams_presence_member` accepts a custom `->(context) { { id:, metadata: } }`. Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched `PresenceTouchMessage` per tick. Presence failures are logged and swallowed so a presence-DB hiccup can't knock a live SSE connection out of the registry. Anonymous connections (no derivable member) are simply skipped. Refs #169.
334
-
335
- - **Streams: msg_id (revision) exposed to the client for optimistic-UI reconciliation.** Each delivered turbo-stream frame already carries its monotonic `msg_id` as the SSE `id:`; `<pgbus-stream-source>` now surfaces it two ways: the standard `message` MessageEvent sets `lastEventId` to the msg_id (Turbo ignores it; a reactive runtime listening for `message` reads the revision with no pgbus-specific API), and a new `pgbus:message` CustomEvent carries `{ msgId, data }` (msgId is a Number when numeric; a negative value marks an ephemeral frame). Documents the reconciliation pattern: track the highest applied msgId per target and skip the morph if a newer revision was already applied, so a late echo can't clobber a newer optimistic edit. Complements #165 (exclude handles the actor; the revision handles out-of-order delivery for everyone else). Refs #168.
336
-
337
- - **Streams: `Stream#broadcast_render` — render a renderable and broadcast it in one call.** `Pgbus.stream(name).broadcast_render(renderable:, target:, action: :replace, exclude:, visible_to:, durable:)` renders a Phlex component, a ViewComponent, or a pre-rendered HTML string into a complete `<turbo-stream action target><template>…</template></turbo-stream>` tag and broadcasts it atomically — removing the off-request render + tag-building boilerplate (and the easy-to-get-wrong view context) from every call site. `action` defaults to `:replace`; content-less actions (`remove`) emit no `<template>`. `exclude:`/`visible_to:`/`durable:` forward to `#broadcast`, so actor-echo suppression and audience filtering compose. Self-contained (no turbo-rails/ActionView/Phlex dependency); the renderable is resolved via `String` → `#call` (Phlex) → `#render_in` (ViewComponent) → `#to_s`. Refs #166.
338
-
339
- - **Streams: actor-echo suppression via `exclude:` on broadcasts.** A broadcast can now name a connection id to skip: `Pgbus.stream(name).broadcast(html, exclude: connection_id)` (and `visible_to:` composes with it). The dispatcher skips delivery to that one SSE connection, so an actor who triggered a change does not receive the echo of its own broadcast — it already applied the change via its action's HTTP response, and re-applying the SSE echo would double-apply (re-run animations, clobber optimistic edits). Every server-minted SSE connection now exposes its id to the page: the server sends a `pgbus:connected` frame right after the open handshake, and `<pgbus-stream-source>` captures it onto the element's `connection-id` attribute and re-dispatches it as a `pgbus:connected` event (also surfaced in `pgbus:open`'s detail). A page reads its connection id and sends it back as the `X-Pgbus-Connection` header on action requests; the broadcaster passes `exclude: request.headers["X-Pgbus-Connection"]`. Reuses the existing per-connection delivery (Filters) path. Refs #165.
340
-
341
- - **Streams: `Pgbus.stream_key` is idempotent for an already-built key, plus `Pgbus.stream_key!`.** A single `String` argument is now treated as a pre-built pgbus stream key and returned unchanged (after the queue-name budget check) instead of tripping the colon-separator guard. This lets a consumer hold one `stream_key` value and pass it to both `turbo_stream_from` and the broadcaster without `stream_key("chat:lobby")` raising `ArgumentError`. The guard still fires for the genuinely ambiguous multi-fragment join (`stream_key("a:b", :c)`), and `Symbol`/record fragments with colons are still rejected (a colon there never came from `stream_key`). `Pgbus.stream_key!(key)` accepts a pre-built key explicitly (String required, budget enforced). Refs #167.
342
-
343
543
  - **Observability: StatBuffer flushes on drain entry and its flush thresholds are now configurable.** `Pgbus::StatBuffer` accumulated up to `DEFAULT_FLUSH_SIZE = 100` entries / `DEFAULT_FLUSH_INTERVAL = 5`s of job stats in memory, flushed only by the worker's periodic `flush_if_due` tick and the drain-loop `stop`. When the supervisor watchdog `SIGKILL`s a stalled worker — which cannot be trapped — everything buffered since the last flush was silently lost, and the drain phase's up-to-30s wait widened that window. `Worker#graceful_shutdown` and `Worker#check_recycle` now call `@stat_buffer&.flush` immediately after `transition_to(:draining)`, so entering drain on either a graceful `TERM` or a recycle threshold (`max_jobs`/`max_memory`/`max_lifetime`) persists the buffer before the watchdog can intervene. Both run on the main loop thread (signals are dispatched via `process_signals`, never in trap context), so the DB write is safe; `flush` is thread-safe and no-ops on an empty buffer and when stats are disabled (`@stat_buffer` is nil). New config keys `stats_flush_size` (default `100`, positive integer) and `stats_flush_interval` (default `5`, positive number) on `Configuration` are validated in `validate!` and passed into `StatBuffer.new` by the worker — tune them down for a tighter loss window on high-throughput deployments or up to reduce insert frequency. The residual loss window (≤ `stats_flush_interval` seconds / `stats_flush_size` entries lost only on an un-trappable `SIGKILL`, affecting insights accuracy only — never a job payload) is documented in the README stats section. Refs #220.
344
544
 
345
545
  ### Changed
346
546
 
347
547
  - **DX: worker and event-consumer config keys are normalized once, at assignment.** Worker/consumer config hashes arrived with symbol keys (the Ruby DSL and `capsule`) or, from an explicit `Array` literal, string keys, so every read site did a dual `w[:x] || w["x"]` lookup — and any new option read without the string fallback silently ignored a string-keyed value, a recurring bug class. `Configuration#workers=` now maps each `Array`-form entry through a private `normalize_entry` (`transform_keys(&:to_sym)`; entries are flat hashes, no deep recursion) and raises `ArgumentError` for a non-Hash entry; `event_consumers` becomes an `attr_reader` plus a matching `event_consumers=` setter (nil passes through). The string/DSL path already yielded symbols, so it is unaffected. With both setters normalizing, the string fallbacks were deleted from every downstream site — `supervisor` (`fork_worker`, `fork_consumer`), `client` (`collect_configured_queues`), `configuration` (`execution_mode_for`, `validate!`, `capsule_name`, `validate_no_queue_overlap!`, `sum_thread_counts`), `doctor` (`capsule_summary`), and `cli` (`apply_capsule_filter`) — which now use symbol access only. The CLI `--queues`/`--capsule` overrides route through `workers=` too, so they get normalization for free. Out of scope: the DB `metadata` JSON read in `consumer_priority` (Postgres always yields string keys there, so no symbol fallback exists to remove). Refs #214.
348
548
 
349
- ### Breaking Changes
350
-
351
- - **Queue names must be alphanumeric and underscores only.** Queue names containing dashes (e.g., `my-app-queue`) will now raise `ArgumentError`. Rename to underscored form (e.g., `my_app_queue`) before upgrading. This restriction prevents SQL injection via PGMQ queue identifiers, which are interpolated into table names and cannot be parameterized.
352
-
353
- ### Security
354
-
355
- - Add `bundler-audit` to CI for dependency vulnerability scanning
356
- - Add `QueueNameValidator` to enforce strict queue name validation (alphanumeric + underscores, 61 char max)
357
- - Add `config.allowed_global_id_models` to restrict which models can be deserialized from event payloads
358
- - Add security headers to dashboard (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
359
- - Warn when dashboard `web_auth` is unconfigured
360
- - Add `globalid` as an explicit runtime dependency (was used but only transitively available via activejob)
361
-
362
549
  ### Fixed
363
550
 
364
- - **The documented uniqueness onboarding path now creates the table the runtime actually reads.** The README's uniqueness Setup section told a new user to run `rails generate pgbus:add_job_locks`, which creates `pgbus_job_locks` — a table nothing in the runtime reads. `ensures_uniqueness` (`Pgbus::UniquenessKey` in `app/models/pgbus/uniqueness_key.rb`, `lib/pgbus/uniqueness.rb`) and the dispatcher's reaper (`cleanup_job_locks`/`reap_orphaned_uniqueness_keys`) work exclusively against `pgbus_uniqueness_keys`; the modern `add_uniqueness_keys.rb.erb` migration template existed but was reachable only through `pgbus:update`'s `MigrationDetector`, so a fresh install following the README silently never locked anything. New standalone generator `pgbus:add_uniqueness_keys` (mirrors `pgbus:add_queue_states`, including `--database=`) exposes that template directly; `MigrationDetector::GENERATOR_MAP[:uniqueness_keys]` now invokes it instead of `pgbus:migrate_job_locks`. `pgbus:add_job_locks` is not removed — installs mid-migration still legitimately need it alongside `pgbus:migrate_job_locks` — but it now prints a deprecation warning pointing new installs at `pgbus:add_uniqueness_keys`. README and the docs-site `concurrency-uniqueness`/`cli-generators` pages retargeted to the new generator. Refs #277.
365
-
366
551
  - **The Turbo broadcast path now honors `streams_durable_patterns`.** `Pgbus::Streams::TurboBroadcastable#broadcast_stream_to` — the patch every `Turbo::Broadcastable` call (`broadcasts_to`, `broadcast_replace_to`, …) and phlex-reactive's `broadcast_*_to` funnel through — resolved durability from the thread-local override or `streams_default_broadcast_mode` only, then always passed an explicit `durable:` to `Pgbus.stream`. That short-circuited `Pgbus.stream`'s own resolver (`durable.nil? ? stream_durable?(name) : durable`), so `streams_durable_patterns` was dead configuration for the entire broadcast funnel: a matching pattern never promoted a broadcast to durable, and large component renders (> 8000 bytes) that were supposed to route through PGMQ instead went ephemeral through `ActionCable` and died with `PG::ProgramLimitExceeded: payload string too long`. `broadcast_stream_to` now resolves `override.nil? ? Pgbus.configuration.stream_durable?(name) : override`, letting the pattern resolver (which already falls back to `streams_default_broadcast_mode`) participate whenever no explicit thread-local override is present. Apps without `streams_durable_patterns` are unaffected; the thread-local override (set via `broadcastable_override`'s `durable:` kwarg) still wins over a matching pattern. Refs #267.
367
552
  - **Reads are now bounded by libpq-native timeouts instead of Ruby `Timeout`.** `Pgbus::Client#with_read_timeout` wrapped every read in `Timeout.timeout(config.read_timeout, Pgbus::ReadTimeoutError)`, which interrupts via `Thread#raise`. That can fire at an arbitrary point — including mid-libpq call — and leave a pooled `PG::Connection` in a state that re-hangs or returns wrong results on reuse; on the shared-AR path it does so on a connection ActiveRecord also queries. On the dedicated-connection path (`database_url` / `connection_params`), `Client#initialize` now bakes two libpq-native bounds into the connection options: (1) a server-side `statement_timeout` (`options=-c statement_timeout=<read_timeout>ms`) so Postgres cleanly cancels an overrunning query — surfaced by pgmq-ruby as `PGMQ::Errors::ConnectionError` ("canceling statement due to statement timeout") and re-raised as `Pgbus::ReadTimeoutError`; and (2) client-side `tcp_user_timeout` + `keepalives` (sized at `read_timeout + 5s`, and only when the linked libpq is ≥ 12 — older libpq rejects the `tcp_user_timeout` conninfo keyword and would fail the connection) so a dead/hung socket makes libpq raise `PG::ConnectionBad` synchronously — no `Thread#raise`, no buffer corruption — which the circuit breaker and stale-connection retry already handle. A caller-supplied `:options` connection param (e.g. `-c search_path=…`) is preserved — the `statement_timeout` flag is appended, not overwritten. On Linux with `read_timeout` set and libpq ≥ 12, these fully cover both a slow query and a hung socket, so Ruby `Timeout` is never wired in (detected at init via `Socket.const_defined?(:TCP_USER_TIMEOUT)` and `PG.library_version`). Ruby `Timeout` remains only as a narrow last resort on a *dedicated* connection where libpq can't bound the socket — non-Linux hosts (`tcp_user_timeout`/`keepalives` are no-ops there) or libpq < 12. The shared-AR `Proc` path gets neither a baked-in bound nor the Ruby `Timeout` (pgbus doesn't own that socket); instead `Client#initialize` logs a one-time hint to configure the same libpq timeouts in `database.yml` (`variables: { statement_timeout: <ms> }` plus `tcp_user_timeout`/`keepalives`), which ActiveRecord passes straight through. A new custom RuboCop cop `Pgbus/NoRubyTimeout` bans `Timeout.timeout` project-wide (no autocorrect) to prevent regressions. Note: on the dedicated path writes gain the same connection-wide `statement_timeout` (acceptable — an enqueue that can't complete within `read_timeout` is already failing). No per-call hot-path change (`client_bench` unchanged; the added cost is a one-time transform in `initialize`). Refs #198.
368
553
  - **Stream archive pruning no longer silently no-ops.** `Dispatcher#prune_stream_archives` read `config.archive_compaction_batch_size`, an accessor that was culled from `Configuration` into the `Pgbus::Process::Dispatcher::ARCHIVE_COMPACTION_BATCH_SIZE` constant in the 0.x config-cleanup refactor (#82). Because `prune_stream_archives` was introduced *after* that refactor and copied the pre-cull pattern, every invocation raised `NoMethodError`, which the method's outer `rescue StandardError` swallowed — so per-stream archive tables were never pruned and grew unbounded regardless of `streams_retention`/`streams_default_retention`. It now uses the constant, matching `#compact_archives`. Refs #204.
369
554
  - **Long dispatcher maintenance passes are now interruptible during shutdown.** `graceful_shutdown`/`immediate_shutdown` only set `@shutting_down`, which the run loop checked between maintenance passes but never inside them. With many queues or large bloated tables, a `TERM` arriving mid-pass blocked graceful shutdown until the entire pass finished — overrunning deploy windows until the supervisor escalated to `KILL`. The per-queue loops in `compact_archives`, `prune_stream_archives`, and `sweep_orphan_streams` now break before the next queue when a shutdown begins, and `TableMaintenance.run_maintenance` accepts a `stop_check:` callable (the dispatcher passes `-> { @shutting_down }`) checked before each table. An interrupted task simply re-runs next interval; retry-on-failure semantics are unchanged. Refs #204.
370
- - **EventBus handler invocations now run inside `Rails.application.executor` (or `.reloader` in dev).** `Pgbus::EventBus::Handler#process` previously ran outside any Rails executor wrapper, so `claim_idempotency?`'s `ProcessedEvent.insert` (and any `handle` body that touches AR) leased a connection that was never returned to the pool. With a small AR pool (Rails dev default with `RAILS_MAX_THREADS=3` is 6 connections), a handful of consumed events leaked the entire pool. The next request that triggers `clear_reloadable_connections!` (any reloadable change in dev) hung in `with_exclusively_acquired_all_connections`, surfacing as a confusing `Rack::Timeout` with a trace ending in `MonitorMixin#wait_for_cond`. Mirrors the executor-wrap pattern already used by `Pgbus::ActiveJob::Executor#execute_job`. No-op when Rails isn't loaded.
371
- - **Defensive retry on stale pooled pgmq connections in the enqueue path.** `Pgbus::Client#send_message`, `#send_batch`, and `#publish_to_topic` now retry once when `@pgmq.produce*` raises `PGMQ::Errors::ConnectionError` with a message indicating the pooled `PG::Connection` was killed beneath pgmq-ruby — typically by PgBouncer hitting `server_idle_timeout` / `client_idle_timeout`, an admin disconnect, or a TCP RST. Observed in production as `PQsocket() can't get socket descriptor` on the first produce following an idle window. pgmq-ruby's `auto_reconnect` recovers on the *next* pool checkout, so a single retry is sufficient; non-stale errors (pool timeout, misconfiguration, unreachable database) still propagate unchanged. Upstream pgmq-ruby fix for the underlying misclassification is in-flight at mensfeld/pgmq-ruby#94.
372
555
  - **Stale-connection retry now backs off and attempts twice.** The single immediate retry above recovers one dead pooled socket, but during a PgBouncer restart or a brief failover window the immediate second attempt lands in the same unavailable window and the error propagated — failing an enqueue the caller may never retry. `Pgbus::Client#with_stale_connection_retry` now retries a matched stale error up to `STALE_RETRY_ATTEMPTS` (2) times with a short backoff between attempts (`STALE_RETRY_DELAYS = [0.1, 0.5]`), giving a transient window time to clear. Safety is unchanged: the pattern list still matches only pre-flight/idle-socket errors (no SQL was sent), so retrying non-idempotent ops stays safe. The backoff runs error-path only — never on the success path — and the sleep is issued outside `synchronized`, so `@pgmq_mutex` is never held during a delay on the shared-connection path. No happy-path throughput or allocation change (`client_bench` identical before/after). Refs #196.
373
556
  - **Stalled dispatcher and scheduler are no longer reported healthy on the processes page.** `Web::DataSource#derive_process_status` evaluated the loop beacon only for workers, returning `:healthy` for every other kind, and neither the dispatcher (`{ pid }`) nor the scheduler (`{ pid, tasks }`) heartbeat carried a beacon. Because heartbeats run on an independent `Concurrent::TimerTask` thread, a dispatcher wedged inside `run_maintenance` (e.g. a stuck vacuum in `run_table_maintenance`) or a scheduler stuck in `tick` kept heartbeating and showed healthy forever while doing no work. Both processes now stamp a wall-clock loop beacon (`@loop_tick_at`, a `Concurrent::AtomicReference`) at the top of each main-loop iteration and pass `loop_tick_supplier:` to their heartbeat, mirroring `Worker#stamp_loop_tick`; the heartbeat persists it as `metadata.loop_tick_at` on every beat. `derive_process_status` now evaluates the beacon for `"dispatcher"` and `"scheduler"` too, with kind-aware thresholds that account for each loop's sleep — workers keep `config.stall_threshold` (90s), dispatcher uses `stall_threshold + config.dispatch_interval`, scheduler uses `stall_threshold + config.recurring_schedule_interval` — so a beacon older than the threshold renders the `:stalled` badge. A missing beacon (an older process during a rolling deploy) stays `:healthy`, and `stall_threshold = nil` disables the check as before. Worker stall detection is unchanged. Refs #222.
374
- - **Config hardening: silent-failure holes closed.** `Configuration#validate!` now validates `statsd_host`/`statsd_port`/`health_bind` unconditionally alongside the other keys added in the same window — a typo'd host or an out-of-range port previously passed boot and only failed later when the UDP socket opened or the health server bound. (This entry originally also documented two YAML-config hardening fixes — `ConfigLoader.sectioned?` misclassification and a `ConfigConverter::DEPRECATED_SETTINGS` `pool_size` drop — but the entire `config/pgbus.yml` surface was removed in 1.0, so those no longer apply.) Refs #276.
375
557
 
376
558
  ## [0.9.7] - 2026-06-29
377
559
 
@@ -419,7 +601,13 @@
419
601
 
420
602
  ### Added
421
603
 
422
- - **Streams: reactive primitives for phlex-reactive.** Idempotent `stream_key` for pre-built keys (+ `stream_key!`), actor-echo suppression via `exclude:` on broadcasts, `Stream#broadcast_render` (render + broadcast in one call), client-exposed `msg_id` revision for optimistic UI (`pgbus:message`), optional connection-driven presence (`streams_presence_patterns`), typed SSE `event:` names, and publish-side coalescing for high-frequency broadcasts (`coalesce:`). Refs #165, #166, #167, #168, #169, #170, #171, #173.
604
+ - **Streams: optional publish-side coalescing for high-frequency broadcasts.** A chatty reactive component (live cursor, typing indicator, progress bar) can fan out many small broadcasts per second. Pass `coalesce:` (a window in milliseconds, or `true` for the 50ms default) with `target:` to `broadcast`/`broadcast_render` to batch per `(stream, target)` and publish only the *latest* frame within the window — superseded frames never hit the bus (no PGMQ insert, no NOTIFY, no fan-out). Last-write-wins, so it is opt-in and only safe for idempotent `replace`/`update` of a stable target (exactly the high-frequency case). The new `Pgbus::Streams::Coalescer` is a process-wide, thread-safe, trailing-edge-with-max-wait debounce: the first submit per window schedules the flush (bounding latency to one window) and later submits only overwrite the buffered payload; the flush re-enters the normal broadcast path, so a coalesced frame is just a deferred ordinary broadcast that still composes with `visible_to`/`exclude`/`event`/`durable`. Refs #171.
605
+ - **Streams: typed SSE event names on broadcasts.** A broadcast can set the SSE `event:` field — `Pgbus.stream(name).broadcast(html, event: "presence")` (and `broadcast_render(..., event:)`) — while keeping the payload a Turbo Stream, so clients route on the typed name instead of sniffing the HTML. The default (`nil` or `"turbo-stream"`) is omitted from the JSONB payload to avoid redundancy, but is still set on the SSE frame's `event:` line (the connection adapter falls back to `turbo-stream`), so default consumers still get the standard `message`/turbo-stream path. The event flows through the JSONB payload → `StreamEnvelope.event` → the SSE frame's `event:` line (both the Puma and Falcon connection adapters). On the client, `<pgbus-stream-source>` dispatches a typed broadcast as a generic `pgbus:event` (`{ event, data, msgId }`) and a named `pgbus:<event>` (`{ data, msgId }`) for `addEventListener` ergonomics; declare typed event names via the element's `listen-events` attribute so they survive the EventSource reconnect path. Refs #170.
606
+ - **Streams: optional connection-driven presence.** Streams matching `config.streams_presence_patterns` (exact string or Regexp, mirroring `streams_durable_patterns`) now auto-join a member on SSE connect, auto-leave on disconnect, and refresh `last_seen_at` on the keepalive heartbeat — no explicit `join`/`leave`/sweeper wiring. Identity comes from the connection's authorize-hook context: the built-in extractor handles a Hash with `:member_id`/`:id` (plus optional `:metadata`) or any object responding to `#id`, and `config.streams_presence_member` accepts a custom `->(context) { { id:, metadata: } }`. Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched `PresenceTouchMessage` per tick. Presence failures are logged and swallowed so a presence-DB hiccup can't knock a live SSE connection out of the registry. Anonymous connections (no derivable member) are simply skipped. Refs #169.
607
+ - **Streams: msg_id (revision) exposed to the client for optimistic-UI reconciliation.** Each delivered turbo-stream frame already carries its monotonic `msg_id` as the SSE `id:`; `<pgbus-stream-source>` now surfaces it two ways: the standard `message` MessageEvent sets `lastEventId` to the msg_id (Turbo ignores it; a reactive runtime listening for `message` reads the revision with no pgbus-specific API), and a new `pgbus:message` CustomEvent carries `{ msgId, data }` (msgId is a Number when numeric; a negative value marks an ephemeral frame). Documents the reconciliation pattern: track the highest applied msgId per target and skip the morph if a newer revision was already applied, so a late echo can't clobber a newer optimistic edit. Complements #165 (exclude handles the actor; the revision handles out-of-order delivery for everyone else). Refs #168.
608
+ - **Streams: `Stream#broadcast_render` — render a renderable and broadcast it in one call.** `Pgbus.stream(name).broadcast_render(renderable:, target:, action: :replace, exclude:, visible_to:, durable:)` renders a Phlex component, a ViewComponent, or a pre-rendered HTML string into a complete `<turbo-stream action target><template>…</template></turbo-stream>` tag and broadcasts it atomically — removing the off-request render + tag-building boilerplate (and the easy-to-get-wrong view context) from every call site. `action` defaults to `:replace`; content-less actions (`remove`) emit no `<template>`. `exclude:`/`visible_to:`/`durable:` forward to `#broadcast`, so actor-echo suppression and audience filtering compose. Self-contained (no turbo-rails/ActionView/Phlex dependency); the renderable is resolved via `String` → `#call` (Phlex) → `#render_in` (ViewComponent) → `#to_s`. Refs #166.
609
+ - **Streams: actor-echo suppression via `exclude:` on broadcasts.** A broadcast can now name a connection id to skip: `Pgbus.stream(name).broadcast(html, exclude: connection_id)` (and `visible_to:` composes with it). The dispatcher skips delivery to that one SSE connection, so an actor who triggered a change does not receive the echo of its own broadcast — it already applied the change via its action's HTTP response, and re-applying the SSE echo would double-apply (re-run animations, clobber optimistic edits). Every server-minted SSE connection now exposes its id to the page: the server sends a `pgbus:connected` frame right after the open handshake, and `<pgbus-stream-source>` captures it onto the element's `connection-id` attribute and re-dispatches it as a `pgbus:connected` event (also surfaced in `pgbus:open`'s detail). A page reads its connection id and sends it back as the `X-Pgbus-Connection` header on action requests; the broadcaster passes `exclude: request.headers["X-Pgbus-Connection"]`. Reuses the existing per-connection delivery (Filters) path. Refs #165.
610
+ - **Streams: `Pgbus.stream_key` is idempotent for an already-built key, plus `Pgbus.stream_key!`.** A single `String` argument is now treated as a pre-built pgbus stream key and returned unchanged (after the queue-name budget check) instead of tripping the colon-separator guard. This lets a consumer hold one `stream_key` value and pass it to both `turbo_stream_from` and the broadcaster without `stream_key("chat:lobby")` raising `ArgumentError`. The guard still fires for the genuinely ambiguous multi-fragment join (`stream_key("a:b", :c)`), and `Symbol`/record fragments with colons are still rejected (a colon there never came from `stream_key`). `Pgbus.stream_key!(key)` accepts a pre-built key explicitly (String required, budget enforced). Refs #167.
423
611
 
424
612
  ### Security
425
613
 
@@ -489,7 +677,7 @@
489
677
 
490
678
  ### Fixed
491
679
 
492
- - **Event bus: `Handler#process` now runs inside the Rails executor** so ActiveRecord connections are checked back into the pool after each handler run, preventing connection leaks under sustained event processing. Refs #147.
680
+ - **EventBus handler invocations now run inside `Rails.application.executor` (or `.reloader` in dev).** `Pgbus::EventBus::Handler#process` previously ran outside any Rails executor wrapper, so `claim_idempotency?`'s `ProcessedEvent.insert` (and any `handle` body that touches AR) leased a connection that was never returned to the pool. With a small AR pool (Rails dev default with `RAILS_MAX_THREADS=3` is 6 connections), a handful of consumed events leaked the entire pool. The next request that triggers `clear_reloadable_connections!` (any reloadable change in dev) hung in `with_exclusively_acquired_all_connections`, surfacing as a confusing `Rack::Timeout` with a trace ending in `MonitorMixin#wait_for_cond`. Mirrors the executor-wrap pattern already used by `Pgbus::ActiveJob::Executor#execute_job`. No-op when Rails isn't loaded. Refs #147.
493
681
 
494
682
  ## [0.7.7] - 2026-04-25
495
683
 
@@ -512,7 +700,7 @@
512
700
 
513
701
  ### Fixed
514
702
 
515
- - **Client: retry enqueue once when a pooled pgmq connection was killed.** `send_message`, `send_batch`, and `publish_to_topic` now rescue `PGMQ::Errors::ConnectionError` and retry once when the pooled `PG::Connection` was closed server-side (e.g. behind PgBouncer); other variants propagate unchanged. Refs #140.
703
+ - **Defensive retry on stale pooled pgmq connections in the enqueue path.** `Pgbus::Client#send_message`, `#send_batch`, and `#publish_to_topic` now retry once when `@pgmq.produce*` raises `PGMQ::Errors::ConnectionError` with a message indicating the pooled `PG::Connection` was killed beneath pgmq-ruby — typically by PgBouncer hitting `server_idle_timeout` / `client_idle_timeout`, an admin disconnect, or a TCP RST. Observed in production as `PQsocket() can't get socket descriptor` on the first produce following an idle window. pgmq-ruby's `auto_reconnect` recovers on the *next* pool checkout, so a single retry is sufficient; non-stale errors (pool timeout, misconfiguration, unreachable database) still propagate unchanged. Upstream pgmq-ruby fix for the underlying misclassification is in-flight at mensfeld/pgmq-ruby#94. Refs #140.
516
704
 
517
705
  ## [0.7.4] - 2026-04-14
518
706
 
@@ -676,6 +864,21 @@
676
864
 
677
865
  No changes required to user configuration — legacy YAML patterns and the modern DSL both work as documented.
678
866
 
867
+ ## [0.2.4] - 2026-04-02
868
+
869
+ ### Breaking Changes
870
+
871
+ - **Queue names must be alphanumeric and underscores only.** Queue names containing dashes (e.g., `my-app-queue`) will now raise `ArgumentError`. Rename to underscored form (e.g., `my_app_queue`) before upgrading. This restriction prevents SQL injection via PGMQ queue identifiers, which are interpolated into table names and cannot be parameterized.
872
+
873
+ ### Security
874
+
875
+ - Add `bundler-audit` to CI for dependency vulnerability scanning
876
+ - Add `QueueNameValidator` to enforce strict queue name validation (alphanumeric + underscores, 61 char max)
877
+ - Add `config.allowed_global_id_models` to restrict which models can be deserialized from event payloads
878
+ - Add security headers to dashboard (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
879
+ - Warn when dashboard `web_auth` is unconfigured
880
+ - Add `globalid` as an explicit runtime dependency (was used but only transitively available via activejob)
881
+
679
882
  ## [0.1.0] - 2026-03-30
680
883
 
681
884
  - Initial release