pgbus 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +36 -14
- data/Rakefile +7 -1
- data/app/assets/javascripts/pgbus/stream_source_element.js +17 -3
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +4 -4
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -1
- data/app/controllers/pgbus/dead_letter_controller.rb +3 -1
- data/app/controllers/pgbus/jobs_controller.rb +2 -1
- data/app/models/pgbus/blocked_execution.rb +3 -2
- data/app/models/pgbus/queue_state.rb +6 -0
- data/app/models/pgbus/uniqueness_key.rb +11 -0
- data/config/locales/da.yml +1 -0
- data/config/locales/de.yml +1 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/locales/fi.yml +1 -0
- data/config/locales/fr.yml +1 -0
- data/config/locales/it.yml +1 -0
- data/config/locales/ja.yml +1 -0
- data/config/locales/nb.yml +1 -0
- data/config/locales/nl.yml +1 -0
- data/config/locales/pt.yml +1 -0
- data/config/locales/sv.yml +1 -0
- data/lib/generators/pgbus/fold_queue_name_case_generator.rb +57 -0
- data/lib/generators/pgbus/templates/fold_queue_name_case.rb.erb +12 -0
- data/lib/generators/pgbus/templates/tune_autovacuum.rb.erb +2 -2
- data/lib/generators/pgbus/templates/tune_fillfactor.rb.erb +1 -1
- data/lib/pgbus/active_job/adapter.rb +3 -2
- data/lib/pgbus/active_job/executor.rb +142 -20
- data/lib/pgbus/active_job/quiet_logs.rb +57 -0
- data/lib/pgbus/autovacuum_tuning.rb +2 -2
- data/lib/pgbus/cli/dlq.rb +2 -1
- data/lib/pgbus/client/async_commit.rb +149 -0
- data/lib/pgbus/client/native_statements.rb +28 -16
- data/lib/pgbus/client.rb +145 -17
- data/lib/pgbus/concurrency/blocked_execution.rb +9 -1
- data/lib/pgbus/configuration.rb +71 -6
- data/lib/pgbus/dead_letter_header.rb +3 -0
- data/lib/pgbus/doctor.rb +55 -2
- data/lib/pgbus/failed_event_recorder.rb +5 -2
- data/lib/pgbus/generators/migration_detector.rb +29 -2
- data/lib/pgbus/instrumentation.rb +16 -1
- data/lib/pgbus/metrics/subscriber.rb +27 -1
- data/lib/pgbus/process/completer.rb +4 -2
- data/lib/pgbus/process/worker.rb +28 -3
- data/lib/pgbus/queue_name_fold.rb +145 -0
- data/lib/pgbus/queue_name_validator.rb +20 -2
- data/lib/pgbus/recurring/schedule.rb +22 -13
- data/lib/pgbus/stat_buffer.rb +67 -26
- data/lib/pgbus/streams/cursor.rb +17 -6
- data/lib/pgbus/streams/envelope.rb +16 -0
- data/lib/pgbus/streams/key.rb +1 -1
- data/lib/pgbus/table_maintenance.rb +1 -1
- data/lib/pgbus/uniqueness.rb +43 -16
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/dead_letter.rb +14 -5
- data/lib/pgbus/web/data_source/resend.rb +185 -0
- data/lib/pgbus/web/data_source/resend_slot.rb +139 -0
- data/lib/pgbus/web/data_source.rb +55 -37
- data/lib/pgbus/web/dead_letter_reason.rb +6 -0
- data/lib/pgbus/web/streamer/connection.rb +36 -29
- data/lib/pgbus/web/streamer/falcon_connection.rb +29 -25
- data/lib/pgbus/web/streamer/heartbeat.rb +14 -3
- data/lib/pgbus/web/streamer/instance.rb +8 -5
- data/lib/pgbus/web/streamer/listener.rb +239 -62
- data/lib/pgbus/web/streamer/outbound_pump.rb +6 -4
- data/lib/pgbus/web/streamer/registry.rb +55 -29
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +73 -12
- data/lib/tasks/pgbus_queues.rake +27 -0
- data/sig/pgbus/active_job/adapter.rbs +1 -1
- data/sig/pgbus/active_job/quiet_logs.rbs +11 -0
- data/sig/pgbus/client.rbs +6 -5
- data/sig/pgbus/configuration.rbs +4 -0
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3456584745e787627f4a24ccf2bc9ee4d9e6703c8e507aa696d1390660c032f
|
|
4
|
+
data.tar.gz: a9abc1e458a6f06e4ebd9bf96dae36e3a93a880c338374e53c62a80ab2a6da0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a63b3ca0d61efffa0cdd9545eb911aaffd3732f60f646b0d9ff6a3711a7ac83332c13e1d3d9703ecc6dde35e0b3fc0e51ebe53f37ff6c8b6bceaad924f77a423
|
|
7
|
+
data.tar.gz: 6cca1439453ad78ef45824a1ffbd29891c2451295af842c0efb2c38abdcd58db5e09ba6980d84a6721e9e7bca9c533cbdd0ad76ca29f07c607e7d2c9ab858e75
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.23.0] - 2026-11-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`ensures_uniqueness strategy: :until_executing`: a debounce — the lock is taken at enqueue and released when execution starts (issue #628).** For the "recompute a parent's rollup" job every finished child enqueues: while one copy for the key is queued, further enqueues follow `on_conflict:` (`:reject` raises `Pgbus::JobNotUnique`, `:discard` and `:log` drop it), because the queued copy has not run yet and will see their state. The executor releases the lock before `perform`, through the same `release_held_by!` as completion, so it deletes only the row this message holds, never a newer copy's; an enqueue that arrives while the copy runs is accepted, so at most one copy is queued at a time and a run that starts after an enqueue sees the state written before it. `:until_executed` dropped that enqueue (the trailing state was never seen) and `:while_executing` sent every enqueue as a message. Two runs can overlap; for a debounce with no overlap, add `limits_concurrency to: 1, on_conflict: :block` (the trailing copy parks with the free key, further enqueues are dropped, it runs when the first finishes). A redelivered message (worker crash, visibility timeout) whose row is still held releases it and runs; one whose row is already gone just runs. A scheduled copy holds the lock for its whole wait, a recurring task is skipped while its previous copy is queued, `perform_all_later` sends one message per key, and the orphan-lock reaper and the dashboard's discard of a parked job treat the row as they do an `:until_executed` one. A `retry_on` re-enqueue goes out without a lock (the attempt released its own at start) and releases none, so it can sit beside one fresh copy; both run, in no guaranteed order. Without a `key:`, a job enqueued with arguments raises `ArgumentError`, as for `:until_executed`. `:until_executed` and `:while_executing` are unchanged. Refs #628.
|
|
8
|
+
|
|
9
|
+
- **Opt-in `config.completion_synchronous_commit = false`: a job's completion commits with `synchronous_commit` off, in one pipelined round trip (issue #619).** The fleet bench put the multi-process bend on one commit per job waiting for its WAL flush (`LWLock:WALWrite` + `IO:WalWrite`). With the option `false`, the executor's and the completer's archive, delete, `archive_batch` and `delete_batch` run as `BEGIN`, `SET LOCAL synchronous_commit TO off`, the prepared statement and `COMMIT`, sent in libpq pipeline mode with one sync: still one round trip (the first completion of a queue on a new connection prepares its statement and commits synchronously once), the statement stays prepared, and `SET LOCAL` ends with the transaction, so the pooled connection never keeps the setting. With `completion_synchronous_commit = false`, a completion is acknowledged before its WAL record reaches disk. A Postgres server crash (not a client crash, not a planned switchover), or a failover to a standby that has not yet received that WAL, within about three times `wal_writer_delay` (default 200 ms) can lose completions committed in that window. Each lost completion redelivers its job after the visibility timeout, so the job runs again. Under synchronous replication it also stops waiting for the standby for that transaction, which is what lets a failover lose it. Sends, retries and dead-letter moves keep full durability. A durable commit on any connection to the same server flushes WAL up to its LSN, so a job whose completion is followed by a synchronous write (a concurrency signal, a batch completion, a uniqueness release, the stat buffer's flush) closes its own window (the job's own ActiveRecord writes run before the completion, so they do not); a lost archive cannot double-signal a semaphore, because the signal's commit makes the archive durable first. The EventBus consumer's archive (its replay reads the archive) and dashboard actions stay durable: only callers passing the new `job_completion: true` keyword on `Client#archive_message`, `#delete_message`, `#archive_batch` and `#delete_batch` (the executor and the completer) get the asynchronous commit. A completion inside a caller's transaction is left to that transaction. A failed statement leaves pipeline mode, rolls the open transaction back and raises for the existing re-run and short-row checks; a statement the session lost is prepared and run outside the pipeline for that one call. Native statements on pgbus's own connections only (no effect with `native_statements = false` or on ActiveRecord's shared connection through a `connection_params` Proc, and the new "Completion commit" `pgbus doctor` check warns); `validate!` requires libpq 14+ and pg 1.5+. Untested behind PgBouncer in transaction mode (no PgBouncer release notes mention pipeline mode): use session mode or leave it on. Default `true`, byte-identical to before; anything but `true` / `false` raises `ConfigurationError`. The fleet bench takes `WP_BENCH_COMPLETION_COMMIT=sync|async` and `rake bench:integration` takes `PGBUS_BENCH_COMPLETION_COMMIT=sync|async` (new). On one local Postgres shared with other sessions (five alternated rounds, 8 threads, medians): the WAL-flush wait `LWLock:WALWrite` fell from 12–23 % of sampled backend waits to 2 % at four processes and left the top six at one; Postgres time per job fell in five of six rows against main (0.417 → 0.331 ms at four YJIT processes, 0.223 → 0.138 with batched delete); jobs/s stayed inside the run-to-run noise (median 9 550 → 10 620 at four YJIT processes, 10 692 → 15 302 with batched delete, every range overlapping), and the single call ("execute: plain job") did not slow down. `docs/performance.md` → "Async completion commit (issue #619)". Refs #617.
|
|
10
|
+
|
|
11
|
+
- **Streams scale bench: 100 / 1 000 SSE clients on one Puma web process (issue #620).** `PGBUS_DATABASE_URL=… bundle exec rake bench:streams_scale` runs each (M, mode) cell in its own subprocess: one in-process Puma server and streamer (`streams_listen_scope = :process`) over real PGMQ and LISTEN/NOTIFY, with the M clients in spawned client processes. Per M and per broadcast mode (ephemeral, durable) it prints the connect storm (barrier → every client registered, and p99 of each client's `pgbus:connected`), 20 broadcasts' time to the first and the last client (p50/p99), the dispatcher's wake wall time, the heartbeat tick, the peer-close watcher's socket-set build, server RSS per connection and CPU, timed by prepending on the cell's own dispatcher, heartbeat and watcher (no `lib/` change). Knobs: `STREAMS_SCALE_CLIENTS` (default `100,1000`), `STREAMS_SCALE_CLIENT_PROCESSES` (4), `STREAMS_SCALE_BROADCASTS` (20), `STREAMS_SCALE_MODES`, `STREAMS_SCALE_WRITERS` (`streams_writer_threads`), `STREAMS_SCALE_SLOW` (K clients that read 1 s per event). It raises the fd soft limit (to `OPEN_MAX` on macOS) and skips an M whose descriptors do not fit, printing the `ulimit -n` hint; connects the kernel resets (macOS's `somaxconn` of 128) reconnect after 2 s, as an EventSource would, and are counted. The test SSE client gains `received_at` on every event, `status`, `error`, `start_nowait` and a `read_timeout:` option (default 30 s, as before). The baseline in `docs/performance.md` → "Streamer at scale (issue #620)": at 1 000 clients, 0 missed broadcasts; fan-out reaches the last client in 15–24 ms (p50) with ~10–20 µs per connection, of which framing is ~1 µs; the heartbeat tick is 6–7 ms; a connection costs ~25 KB of RSS; and registering 1 000 clients takes ~255 s, because every connect waits one `streams_listen_health_check_ms` cycle for the listener's ack (100 connects: 27 s at 250 ms, 6 s at 50 ms). Refs #617.
|
|
12
|
+
|
|
13
|
+
- **Opt-in `config.quiet_active_job_logs`: worker processes drop ActiveJob's per-job `Performing …` / `Performed …` info lines (issue #618).** When true, each worker process unsubscribes `ActiveJob::LogSubscriber` from `perform_start.active_job` and `perform.active_job` once, first thing in `Process::Worker#run` (`Pgbus::ActiveJob::QuietLogs`, which loads `ActiveJob::Base` first so the subscriber is attached). The `perform` listener also writes ActiveJob's `Error performing …` line, for a job that raised (with its backtrace) and for one a `before_perform` callback halted, so those go too: an exception that escapes ActiveJob's handlers is still logged by pgbus's `ErrorReporter` (`[Pgbus] <class>: <message>` at `error`, the backtrace at `debug`) and kept in `pgbus_failed_events` with its backtrace and the `pgbus.job_failed` event; one `retry_on` catches still logs ActiveJob's `Retrying …` line (and `Stopped retrying …` at `error` when it gives up); a halted job logs nothing. `Enqueued`, `Retrying`, `Stopped retrying` and `Discarded` still log as the host configured them, and the web process, `Rails.logger` and `ActiveJob::Base.logger` are untouched. A subscriber silenced at boot (a logger above `info`) is removed too, so lowering the level later does not bring the lines back. Event consumers are unaffected (their handlers are not ActiveJob jobs); without ActiveJob the option does nothing. Default `false`; anything but `true` / `false` raises `ConfigurationError`. In the worker profile the two lines are about 5 % (YJIT) and 9–10 % (no JIT) of a no-op job's on-CPU time; without a JIT the knob and the throughput checklist's `warn`-logger recipe each buy +6–10 % jobs/s (equal within noise), with YJIT neither moves jobs/s measurably. `docs/performance.md` → "The Rails share of a job (issue #618)". Refs #617.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **The job stats flush builds its multi-row INSERT itself instead of `JobStat.insert_all` (issue #622).** `StatBuffer#write_to_database` was 12–15 % of a no-op job's on-CPU time in the worker profile: ActiveRecord's per-attribute check, per-value type cast, Arel visit and `RETURNING "id"` on 100 rows per flush. The flush now sends one `INSERT INTO pgbus_job_stats (…) VALUES (…), (…)` through `execute` on a connection from `JobStat.connection_pool.with_connection` (the model's database, `connects_to` included; the connection goes back to the pool after the flush unless the thread already held one). Integers are written through `Integer()` and nil as `NULL`; an entry with a value `Integer()` refuses is logged and dropped on its own, the rest of the flush is still written; strings (`job_class` comes from the message) go through the connection's quoting, once per distinct value per flush. Rows, columns and the missing-latency-columns fallback (six columns first, four on PostgreSQL's `undefined_column` error naming `enqueue_latency_ms` or `retry_count`, warned once) are unchanged, except that the fallback now keys on the error class (`PG::UndefinedColumn`) rather than any message containing a column name, so an unrelated error whose `LINE 1:` excerpt shows the column list is logged, not retried, and a server with non-English `lc_messages` still falls back; `ON CONFLICT DO NOTHING` is gone, which changes nothing because the table has no unique index besides its primary key. Measured on one shared machine, main and branch alternated: Ruby CPU per 100-row flush 936–1 999 → 120–229 µs on ActiveRecord 8.1 (9–20 → 1.2–2.3 µs per job) and 1 440 → 101 µs on 7.1, objects per flush 3 161 → 518; the worker profile's `pgbus_rails` share of on-CPU time 17–18 % → 0.7–7.8 % (the rest is the per-drain heartbeat row); jobs/s 5 139–6 452 → 7 499–7 840 with YJIT and within noise without a JIT. `rake bench:one[stat_flush_bench]` (new, real database) times the flush. `docs/performance.md` → "Job stats flush (issue #622)". Refs #622.
|
|
18
|
+
- **`ensures_uniqueness strategy: :while_executing, on_conflict: :discard` now drops a duplicate that is read while another copy runs (issue #629).** `on_conflict:` was validated and stored for `:while_executing` but never read, so every policy skipped the copy and redelivered it. Now `:discard` completes the copy without running it (archived, or deleted under `completed_jobs = :delete`), signals its concurrency slot and its batch as discarded, clears an earlier attempt's failed-event row, and emits `pgbus.job_discarded` (`reason: "uniqueness"`); the executor returns `:discarded`. `:reject` (the default) and `:log` keep the redelivery (see Fixed); `:log` logs each skip at warn. The executor reads the policy from a new payload key, `pgbus_uniqueness_on_conflict`, which the adapter and the recurring scheduler write for `:while_executing` only; a message enqueued before it carries none and is treated as `:reject`. **Upgrade note:** a `:while_executing, on_conflict: :discard` job that relied on the trailing run ("a run that starts after the current one sees the latest state") got it by accident of the skip path and loses it; move it to `strategy: :until_executing` (#628, in the same release), which gives that guarantee by design. See the upgrade guide. Refs #629.
|
|
19
|
+
- **The metrics adapter tags every `pgbus_queue_job_count{status="dead_lettered"}` increment with `reason` (`max_retries_exceeded` or `uniqueness_conflict`) (issue #629).** Dead letters counted after the upgrade land in a new series that carries the label; a query or alert that matched the old label set exactly needs `reason` added or aggregated away (`sum without (reason)`). The new `skipped` and `discarded` statuses carry `reason="uniqueness"`. Refs #629.
|
|
20
|
+
- **The worker counts a uniqueness skip or discard neither as a success nor as a failure for the circuit breaker (issue #629).** `record_success` reset the queue's failure streak, so duplicates skipped between real failures hid a failing queue from the breaker. Both still count in `jobs_processed`; a skip no longer counts in the processed rate (the copy comes back and is counted when it runs), a discard does. Refs #629.
|
|
21
|
+
- **The streams heartbeat writes under `streams_fanout_write_deadline_ms` (250 ms), not `streams_write_deadline_ms` (5 s) (issue #621).** The heartbeat writes its comment to every connection one after another on its own thread, and each socket write waited up to 5 s; now each is bounded by 250 ms once it holds the connection's mutex, the bound #315 gave the dispatcher's fan-out writes (a concurrent fan-out or replay write on the same connection can still delay its turn; Falcon's streaming body ignores the deadline, as before). A client that cannot take the heartbeat comment in 250 ms is marked dead and reconnects: a durable stream replays from `Last-Event-ID`, an ephemeral one loses nothing a heartbeat carried. Set `streams_fanout_write_deadline_ms = streams_write_deadline_ms` to restore the old timing for both. `Connection#write_raw(bytes, deadline_ms:)` (and `FalconConnection#write_raw`) is the one raw writer: `write_sentinel` delegates to it, so a failed shutdown-sentinel write now marks the connection dead, which `close` does right after anyway. Refs #617.
|
|
22
|
+
- **Streams fan-out frames each SSE frame once per wake, the heartbeat builds one frame per tick, and the connection registry hands out cached frozen snapshots (issue #621).** The dispatcher builds `msg_id → frozen frame` once per wake (durable and ephemeral) and hands the same frozen Hash to every connection on the stream, inline or through the `OutboundPump` (`Connection#enqueue(…, frames:)`, `OutboundPump#post(…, frames:)`); before, every connection re-framed every envelope. The connect replay and the in-flight drain still frame per connection; the bytes on the wire are unchanged. `Streams::Envelope.data_lines` skips the split/map/join for a one-line payload, and `Envelope.sse_event_for` replaces the connections' private copies. `Registry#connections_for` and `#each_connection` return a frozen snapshot built on the first read after a membership change and reused until the next (no copy per wake or tick), `#streams` a frozen list; each stream's members are an identity Hash, so register and unregister stay O(1). Measured on one machine: a durable wake of 3 broadcasts to 100 connections 377 → 102 µs and 2 641 → 256 objects; `Connection#enqueue` with the frame given 771 → 148 ns; `Envelope.message` for a one-line payload 990 → 620 ns (multi-line unchanged); pgbus-owned allocations per connection per wake 22.5 → 1.8, now gated below 3 by `spec/pgbus/web/streamer/fanout_allocation_budget_spec.rb`. `rake bench:streams_scale` takes `STREAMS_SCALE_PROFILE=<dir>` (new): vernier over each cell's broadcast window, printing the dispatcher thread's top self frames and the inclusive share of the socket writes, framing and the audience filter. At 1 000 clients the dispatcher's wake is 74–81 % `IO#write_nonblock` and 0.1–0.2 % framing, but stays under half the last client's p99, so the #617 Ractor fan-out gate stays shut. `docs/performance.md` → "Frame once per wake (issue #621)" has the interleaved 1 000-client rounds, the profile and the verdict. Refs #617.
|
|
23
|
+
- **The worker profile bench splits the on-CPU `rails` bucket by owner and can run jobs inside a Rails app (issue #618).** Under the headline on-CPU row, a new "inside rails" table gives each sub-bucket as a share of the bucket and of all on-CPU time: `aj_log`, `aj_arguments`, `aj_callbacks`, `notif:activejob`, `notif:pgbus`, `executor_wrap`, `pgbus_rails` (Rails code pgbus calls itself: the stat buffer's `insert_all`, the heartbeat's process row) and `aj_other`. `WP_BENCH_RAILS_APP=1` (new) boots a minimal `Rails::Application` in each cell so jobs run inside `Rails.application.executor.wrap`; #484's profile never had it, because without Rails the executor runs `perform_now` bare. `WP_BENCH_AJ_LOGS=default|warn|quiet` (new) measures ActiveJob's per-job log lines three ways. Findings: of #484's 37 % "Rails", 35–53 % was pgbus's own ActiveRecord work (job stats' `insert_all` alone is 12–15 % of on-CPU, followed up in #622), 19–29 % the two log lines, 24–39 % ActiveJob's per-job core; the executor wrap is 13–18 % of on-CPU and stays. `docs/performance.md` also records the campfire-on-Ractors verdict (2026-11-02, #617). Refs #617.
|
|
24
|
+
- **pgmq-ruby 0.8.2 (was 0.8.1); the gemspec now requires `~> 0.8.2` (issue #624).** The release changes one thing in code: `PGMQ::Errors::InvalidQueueNameError` for a too-long name now says "exceeds maximum length of 47 characters" (it said 48, while a 48-character name was already rejected). The limit itself is unchanged, so pgbus's own check, `Pgbus::QueueNameValidator::MAX_QUEUE_NAME_LENGTH = 47`, still runs first with the same ceiling, and nothing in pgbus works around the old message. The rest is pgmq-ruby's README: queue names that differ only in case share one PGMQ table (tracked for pgbus in #633), and `wait_for_notify` returns the channel name, which `Pgbus::Client#wait_for_notify` already documented. `spec/pgbus/queue_name_validator_spec.rb` now compares pgbus's ceiling with the real `PGMQ::QueueName::MAX_LENGTH` instead of a hard-coded 48, and checks pgmq-ruby's error names the same number. Refs #624.
|
|
25
|
+
- **pgmq-ruby 0.8.1 (was 0.7.2); the gemspec now requires `~> 0.8.1` (issues #593, #616).** 0.8.0 is skipped: it raised `NoMethodError` (`undefined method 'to_time'`) on an `ActiveSupport::Duration` delay such as `5.minutes`, which 0.8.1 fixes. What reaches pgbus users: a fractional `delay:` (`1.5`, `1.5.seconds`) on `Pgbus.client.send_message`, `#send_batch`, `#send_stream_message` and `Pgbus.publish` now makes the message visible that many seconds later; it raised `invalid input syntax for type integer` before. The native statements bind the delay as an integer, so `#send_message` and `#send_batch` send a fractional delay through pgmq's functions, and a whole-second delay (Integer or Duration) stays native. Code that calls pgmq-ruby directly gets its breaking changes: `produce` and `produce_batch` take only String messages and String or nil headers (a Hash was stored as NULL; now it raises `PGMQ::Errors::MessageInvalidError`), and argument checks raise `PGMQ::Errors::MessageInvalidError`, `MultiQueueInvalidError` or `AutovacuumSettingsInvalidError` (subclasses of `PGMQ::Errors::BaseError`, not of `ArgumentError`) where they raised `ArgumentError`. A transactional client, including the one `Pgbus::Client#transaction` yields, now raises `PGMQ::Errors::TransactionError` when used from another thread or fiber or after its block has returned. pgbus serializes every payload and header to a JSON String before it produces and uses its transactional clients inline, so its own calls are unaffected. Refs #593, #616.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Queue names that differ only in case get one queue: pgbus spells every queue name in lower case, the way PGMQ spells its tables (issue #633).** PGMQ lower-cases table names (`pgmq.format_table_name`) but records each spelling in `pgmq.meta`, so `Orders` and `orders` read and wrote one table `pgmq.q_pgbus_orders` while the dashboard listed two queues, and a GlobalID stream (`pgbus_stream_from Current.user`, a base64 name) was recorded in mixed case. `Configuration#queue_name` (through `QueueNameValidator.normalize`) and `queue_prefix=` now return lower case, so `queue_name("Orders") == queue_name("orders") == "pgbus_orders"`; no table changes, because PGMQ already kept every queue in its lower-case table. Code that stores or compares queue names follows: `Pgbus::QueueState` stores and finds pauses in lower case (a dashboard pause, named from `pgmq.meta`, now reaches a worker configured with `Critical`); dropping a queue clears its failed events in any case; a worker evicts a queue configured in mixed case whose table is gone; and the insert-NOTIFY check now also compares the trigger's argument, so a trigger armed under another spelling (an older process re-arming the shared table) is re-armed under the lower-case one that listeners use. `pgbus:tune_fillfactor` and `pgbus:tune_autovacuum`, which raised a syntax error on a mixed-case `pgmq.meta` row, now name the lower-case table. **Upgrade:** an install that has a queue with an upper-case letter keeps working (pgbus adds the lower-case `pgmq.meta` row beside the old one and re-arms its NOTIFY trigger), but lists it twice; the new `pgbus doctor` check "Queue name case" warns and names them, and `rails generate pgbus:fold_queue_name_case` (listed by `pgbus:update`, ahead of the tuning migrations) renames the `pgmq.meta` rows, moves their NOTIFY throttle and topic bindings, and lower-cases `pgbus_stream_queues`, `pgbus_queue_states`, `pgbus_uniqueness_keys.queue_name` and `pgbus_failed_events.source_queue` (a message that failed under both spellings keeps its newer row); it never drops a table. Run it once every process runs this version: an older process that restarts recreates its mixed-case row, and `rake pgbus:queues:fold_case` (new) runs the same fold again. Until then, `Client#drop_queue` on the mixed-case duplicate of a queue (the dashboard's drop, the orphan stream sweep) removes only that `pgmq.meta` row and keeps the shared table, and dropping the lower-case queue also removes its other spellings' rows. Between the deploy and the last old process, an old web process LISTENs on the mixed-case channel, so its SSE clients miss broadcasts until they reconnect, an old worker falls back to polling, and a uniqueness lock an old process takes under the mixed-case name after the fold stays held until the orphan-lock reaper frees it (one taken before is folded with it). The fold touches pgbus's queues only (named after `queue_prefix`), and merges rows that collide once lower-cased: one `pgbus_stream_queues` / `pgbus_queue_states` row per queue (a pause wins), one failed-event row per message and source queue (the lower-case one, else the newest). A `queue_prefix` set in mixed case (`"MyApp"`) becomes `"myapp"`; its tables were already `myapp_*`. Two stream names that differ only in case share one queue, as their table already did. Refs #633.
|
|
30
|
+
|
|
31
|
+
- **A burst of SSE connects no longer queues behind the LISTEN health check: 1 000 clients register in 4.5–6.6 s instead of ~255 s (issue #630).** Every connect waited one `streams_listen_health_check_ms` cycle (250 ms) on the single dispatcher thread, because `handle_connect` called `Listener#ensure_listening` for every connect and the listener acked it only when its current `wait_for_notify` ended; a client got no byte, not even a heartbeat, until then, so a Puma restart behind 1 000 open `EventSource`s took over four minutes to recover. Two changes: a connect on a stream that already has a registered connection no longer calls the listener at all (only the stream's first subscriber does: `remove_listening` is issued only when the registry holds no connection on the stream), and the listener now parks in `IO.select` on its PG socket and a wake pipe, so `ensure_listening`, `remove_listening`, `stop` and the master hub's subs are processed within a thread hop. The keepalive runs on a deadline (one `SELECT 1` per idle cycle, from the end of the last one), the connection stays single-owner (#375), and a `LISTEN` that raises now reconnects and re-LISTENs instead of ending the listener thread. Measured on `rake bench:streams_scale` (M2 Max, two rounds, before and after): `handle_connect` p50 254–258 ms → 0.1–1.0 ms; M = 100 registers in 0.08–0.17 s instead of 25 s; at M = 1 000 the remaining seconds are macOS's 128-deep accept queue (reset connects retry after 2 s), on one stream and on 100 (`STREAMS_SCALE_STREAMS=N`, new, where 100 first connects each go to the listener); fan-out and the NOTIFY roundtrip unchanged. `rake bench:streams` section 4 now waits for each client's `pgbus:connected` frame (it returned at the response headers, before registration): K = 50 registered in ~5.2 s before and does in 49–64 ms now. A connect the dispatcher drains during shutdown no longer waits out the ack budget on the stopped listener. Details in `docs/performance.md` → "Connect storm (issue #630)". Refs #630.
|
|
32
|
+
|
|
33
|
+
- **A skipped `:while_executing` duplicate no longer burns through `max_retries` on the flat visibility timeout and dead-letters with no error (issue #629).** The skip left the message's visibility timeout alone, and PGMQ counts every read, so with defaults a holder that ran longer than about `max_retries × visibility_timeout` (5 × 30 s) dead-lettered every queued copy with reason `max_retries_exceeded`, no last error and no `pgbus.job_failed`. Each skip now sets the copy's visibility timeout to `visibility_timeout × 2^(read_ct − 1)` (jittered by `retry_backoff_jitter`, capped at the job's or the global `retry_backoff_max`, never below `visibility_timeout`; on the `_pN` sub-queue under priority routing), so with defaults five skips span about 12 minutes instead of 2.5, and emits `pgbus.job_skipped` (`on_conflict`, `vt`). A copy still skipped past `max_retries` dead-letters with the new reason `uniqueness_conflict` (`DeadLetterHeader::REASON_UNIQUENESS_CONFLICT`) when no attempt of it failed, whether or not the holder still holds the key at that last read (the holder can finish inside the last backed-off wait); a copy whose own crashed attempt left the key's row bound to it keeps `max_retries_exceeded` (`UniquenessKey.bound_to?`). `pgbus.job_dead_lettered` now carries `reason:`, the metrics adapter counts `skipped` and `discarded`, and the dashboard's DLQ says the copy was skipped while another copy held its key instead of "every failing attempt crashed before it could report". Refs #629.
|
|
34
|
+
|
|
35
|
+
- **A recurring `:while_executing` task takes its execution lock (issue #629).** The scheduler injected uniqueness metadata only for the enqueue-locked strategies, and the executor takes a `:while_executing` lock only when the payload carries the key, so a run that outlasted its interval overlapped the next one unprotected. The scheduler now carries key, strategy and `on_conflict` for `:while_executing` too (it still takes no lock at schedule time); a key proc that raises skips the tick instead of enqueueing an unprotected run. A recurring key proc that returns an ActiveRecord record (any GlobalID-compatible object) is now keyed by its GlobalID for every strategy, as an ordinary enqueue is; before, such a key failed the lock row's type cast at every tick and the task never enqueued. Refs #629.
|
|
36
|
+
|
|
37
|
+
- **A dashboard retry of a `limits_concurrency` job takes its slot like an enqueue (issue #636).** DLQ Retry (and Retry All, and `pgbus dlq retry`) and failed-job Retry when the original message is gone put the payload back on its queue as a new message without taking a slot, but the payload still named its concurrency key, so the copy ran beside the slot's holder and its completion signal gave back a slot it never took and promoted a parked job: the key was over-admitted by one. The re-send now resolves the job class's limits and takes a slot before the send, under the semaphore row lock as an enqueue does. At the limit, an `on_conflict: :block` copy is parked as a blocked execution at the job's own priority (the dead-lettered copy or failed row goes; the parked copy runs when a slot frees, its uniqueness row unbound as a parked enqueue's is), and a `:discard` or `:raise` retry is refused, leaving the dead-lettered copy or failed row in place; the Retry alerts ("Could not retry …") and `pgbus dlq retry`'s error ("Failed to retry …") now list a full concurrency limit among the causes, and the log names the key. A class that no longer declares its limit, or no longer resolves, is judged against the limit the semaphore records and parked when it is full, as a promotion judges it. A send that fails gives the slot back; one whose outcome is unknown keeps it until its lease expires. If removing the source fails after a park, the park is withdrawn while it still waits; when that removal's outcome is unknown (a lost reply), the park stays and the source may stay too, since a duplicate is better than a lost job. A parked copy keeps the headers it would have been sent with (a DLQ retry's `retries_from_dlq` count): they ride in the parked payload under `pgbus_park_headers` and the promotion sends them as headers. A database error from the slot upsert or the park's insert no longer counts as an ambiguous send, so the uniqueness key taken for the copy is given back; one from the park's commit keeps it. Refs #636.
|
|
38
|
+
|
|
39
|
+
- **A dashboard retry of a uniquely-keyed job takes its lock like an enqueue (issue #631).** DLQ Retry (and Retry All, and `pgbus dlq retry`) and failed-job Retry when the original message is gone put the payload back on its queue as a new message without acquiring its `:until_executed` / `:until_executing` key, so the copy's release (at start, or on success) could match a newer copy's unbound row, one parked behind `limits_concurrency … on_conflict: :block`, and free the key while that copy still waited. The re-send now holds the key: a row still held by the message it came from (a release that failed, a failed job whose message is gone) moves onto the new message, a free key is acquired before the send and bound to the message after it, and while another copy holds the key the retry is refused, leaving the dead-lettered copy or failed job in place; the dashboard's Retry alert ("Could not retry …") and `pgbus dlq retry`'s error ("Failed to retry …") now list a held uniqueness key among the possible causes, and the log names the key. A send that fails gives the key back; one whose outcome is unknown (a lost connection) keeps it held, since the copy may be live, and the orphan-lock reaper takes it if not. A `:while_executing` payload and an `:until_executing` `retry_on` re-enqueue go out without a lock, as they did. Failed-job Retry All now resets the visibility timeout of a message still in its queue, as Retry has since #68, instead of sending a second copy beside it, and walks past a row it cannot retry instead of re-reading it.
|
|
40
|
+
|
|
41
|
+
- **A stream client reconnecting after an ephemeral broadcast resumes instead of failing with 400 (issue #621).** Ephemeral broadcasts go out with a negative SSE `id:` (no archive position). The `<pgbus-stream-source>` element kept it as its resume cursor and a native EventSource echoes it as `Last-Event-ID`, and the stream endpoint rejected a negative cursor with 400, so the EventSource stopped for good. The element now keeps only durable ids as its `?since=` cursor (listeners still get the negative id as the frame's revision), and `Streams::Cursor` skips a negative `Last-Event-ID` in favour of `?since=`. A native EventSource reconnect resumes from the `?since=` it was opened with, so durable frames received after the element switched to EventSource can arrive once more. Refs #621.
|
|
42
|
+
|
|
43
|
+
- **Falcon's streaming-body connections deliver ephemeral broadcasts (issue #621).** `FalconConnection#enqueue` skipped every envelope whose `msg_id` was at or below its cursor, and an ephemeral broadcast's id is negative, so with `streams_falcon_streaming_body = true` no ephemeral (`durable: false`) broadcast reached the client. It now treats them as `Connection#enqueue` does: written, cursor unchanged. Refs #621.
|
|
44
|
+
|
|
45
|
+
- **`pgbus queues` lists queues again (issue #593).** Its VISIBLE column reads `PGMQ::Metrics#queue_visible_length`, which pgmq-ruby only exposes since 0.8.0, so since 0.13.3 the command raised `NoMethodError` (`undefined method 'queue_visible_length'`) on any install with a queue. The pgmq-ruby bump supplies the field; its spec now builds a real `PGMQ::Metrics` instead of a double that answered any method. Refs #593.
|
|
46
|
+
|
|
47
|
+
## [0.22.0] - 2026-10-30
|
|
48
|
+
|
|
3
49
|
### Added
|
|
4
50
|
|
|
5
51
|
- **Docs: a "Throughput checklist" on the docs site's Performance & tuning page (issue #608).** One ordered list of every knob that moves jobs/s — `processes:`, `native_statements`, `completion_batching`, `completed_jobs = :delete`, `read_ahead` with `pool_size`, `listen_notify`, YJIT, ActiveJob's per-job log lines, `stats_enabled` and the Postgres side — each with its key and default, the number `docs/performance.md` measured for it (or "not measured") and what it costs, plus one line each on why Ractors, a C or Rust extension and Spinel / Roundhouse are not levers. It gives ActiveJob its own `warn` logger through `config.active_job.logger`, because `ActiveJob::Base.logger` is `Rails.logger` and lowering its level silences the app. The Running workers page's `processes:` and `read_ahead` sections link to it, and `docs/performance.md` links to it from the top and summarises its native-compilation verdict in one line. No new measurements. Refs #601.
|
data/README.md
CHANGED
|
@@ -322,19 +322,36 @@ end
|
|
|
322
322
|
| Strategy | Lock acquired | Lock released | Prevents |
|
|
323
323
|
|----------|--------------|---------------|----------|
|
|
324
324
|
| `:until_executed` | At enqueue | On completion or DLQ | Duplicate enqueue AND execution |
|
|
325
|
+
| `:until_executing` | At enqueue | When execution starts | Duplicate enqueue while a copy is queued (a debounce) |
|
|
325
326
|
| `:while_executing` | At execution start | On completion or DLQ | Duplicate execution only |
|
|
326
327
|
|
|
328
|
+
`:until_executing` fits the "recompute a rollup" job that every finished child enqueues. While one copy is queued, further enqueues for the key follow `on_conflict:` (the queued copy has not run yet and will see their state). The executor releases the lock before `perform`, so an enqueue that arrives while the copy runs is accepted and queues the trailing copy. At most one copy for the key is queued at a time, and every write made before an accepted or dropped enqueue is seen by a run that starts after it.
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
class RollupJob < ApplicationJob
|
|
332
|
+
ensures_uniqueness strategy: :until_executing, key: ->(parent_id) { "rollup-#{parent_id}" }, on_conflict: :discard
|
|
333
|
+
|
|
334
|
+
def perform(parent_id) = Parent.find(parent_id).recompute_rollup!
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Two runs can overlap: the trailing copy can start while the first is still running. For "debounce **and** no overlap", add `limits_concurrency to: 1, key: ->(parent_id) { "rollup-#{parent_id}" }, on_conflict: :block`. The trailing enqueue takes the free uniqueness key and parks behind the semaphore, further enqueues are dropped by the key, and the parked copy runs when the first finishes. A `retry_on` re-enqueue goes out without a lock (the attempt released its own at start), so it can sit beside one fresh copy. Both run, in no guaranteed order and possibly at once; each sees the state written before it started.
|
|
339
|
+
|
|
327
340
|
#### Conflict policies
|
|
328
341
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
| `:
|
|
332
|
-
|
|
333
|
-
| `:
|
|
342
|
+
`:until_executed` and `:until_executing` meet a duplicate at enqueue; `:while_executing` meets it when a worker reads a copy while another copy holds the key.
|
|
343
|
+
|
|
344
|
+
| Policy | At enqueue (`:until_executed`, `:until_executing`) | At execution (`:while_executing`) |
|
|
345
|
+
|--------|----------|----------|
|
|
346
|
+
| `:reject` (default) | Raise `Pgbus::JobNotUnique` | Leave the copy queued for a backed-off redelivery: it runs once the holder is done, or dead-letters as `uniqueness_conflict` if skipped past `max_retries` |
|
|
347
|
+
| `:discard` | Silently drop the duplicate | Complete the copy without running it (`pgbus.job_discarded`) |
|
|
348
|
+
| `:log` | Log a warning and drop | As `:reject`, and log the skip at warn |
|
|
349
|
+
|
|
350
|
+
A `:while_executing` copy left queued comes back after `visibility_timeout × 2^(read_ct − 1)` (jittered, capped at `retry_backoff_max`, never below `visibility_timeout`), so repeated skips stretch over a long holder's run, and each skip emits `pgbus.job_skipped`. PGMQ counts every read toward `max_retries`, so a copy skipped past it still dead-letters; when none of its attempts failed, its DLQ headers say `reason: uniqueness_conflict`, not `max_retries_exceeded` with no error. A recurring `:while_executing` task carries its key, so a run that outlasts its interval is not overlapped by the next. For a trailing run that sees writes enqueued while the current run executes, use `:until_executing`.
|
|
334
351
|
|
|
335
352
|
#### Lock lifecycle
|
|
336
353
|
|
|
337
|
-
The lock is **never released by a timer**. It is held as long as the job's message exists in the queue:
|
|
354
|
+
The lock is **never released by a timer**. It is held as long as the job's message exists in the queue (an `:until_executing` lock only until the job starts):
|
|
338
355
|
|
|
339
356
|
```text
|
|
340
357
|
Enqueue ──→ pgbus_uniqueness_keys (lock_key, queue_name, msg_id)
|
|
@@ -368,9 +385,9 @@ Enqueue ──→ pgbus_uniqueness_keys (lock_key, queue_name, msg_id)
|
|
|
368
385
|
|---|---|---|
|
|
369
386
|
| **Purpose** | Prevent duplicate jobs | Limit concurrent execution slots |
|
|
370
387
|
| **Lock type** | Binary lock (one or none) | Counting semaphore (up to N) |
|
|
371
|
-
| **At enqueue** | `:until_executed`
|
|
388
|
+
| **At enqueue** | `:until_executed` and `:until_executing` block duplicates | Checks semaphore, blocks/discards/raises |
|
|
372
389
|
| **At execution** | `:while_executing` blocks duplicate runs | Not checked (semaphore acquired at enqueue) |
|
|
373
|
-
| **Duplicate in queue** | `:until_executed`: impossible. `:while_executing`: allowed, only one runs | Allowed up to N, rest blocked |
|
|
390
|
+
| **Duplicate in queue** | `:until_executed`: impossible. `:until_executing`: impossible for ordinary enqueues (a `retry_on` re-enqueue takes no lock and can sit beside one fresh copy). `:while_executing`: allowed, only one runs | Allowed up to N, rest blocked |
|
|
374
391
|
| **Crash recovery** | Reaper checks heartbeats | Semaphore `expires_at` + dispatcher cleanup |
|
|
375
392
|
| **Use when** | "This exact job must not run twice" | "At most N of these can run at once" |
|
|
376
393
|
|
|
@@ -1057,7 +1074,7 @@ c.metrics_backend = MyOpenTelemetryBackend.new
|
|
|
1057
1074
|
|
|
1058
1075
|
| Metric | Type | Tags |
|
|
1059
1076
|
|--------|------|------|
|
|
1060
|
-
| `pgbus_queue_job_count` | counter | `queue`, `job_class`, `status` (`processed`/`failed`/`dead_lettered`) |
|
|
1077
|
+
| `pgbus_queue_job_count` | counter | `queue`, `job_class`, `status` (`processed`/`failed`/`skipped`/`discarded`/`dead_lettered`), `reason` (skips, discards and dead letters) |
|
|
1061
1078
|
| `pgbus_visibility_extended` | counter | `queue`, `job_class` — one per re-armed visibility timeout; a class listed here outlives `visibility_timeout` |
|
|
1062
1079
|
| `pgbus_job_duration_ms` | histogram | `queue`, `job_class` |
|
|
1063
1080
|
| `pgbus_event_count` | counter | `handler`, `routing_key`, `status` (`processed`/`failed`/`skipped`/`dead_lettered`), plus `reason` on `skipped` only (`completed`/`cached`/`owned`) |
|
|
@@ -1103,7 +1120,7 @@ ActiveSupport::Notifications.subscribe(/^pgbus\./) do |name, start, finish, _id,
|
|
|
1103
1120
|
end
|
|
1104
1121
|
```
|
|
1105
1122
|
|
|
1106
|
-
Metrics-relevant events emitted (the subset the metrics subscriber maps; see `lib/pgbus/instrumentation.rb` for the full `pgbus.*` catalog): `pgbus.executor.execute`, `pgbus.job_completed`, `pgbus.job_failed`, `pgbus.job_dead_lettered`, `pgbus.event_processed`, `pgbus.event_failed`, `pgbus.event_dead_lettered`, `pgbus.event_skipped`, `pgbus.job_visibility_extended`, `pgbus.client.send_message`, `pgbus.client.send_batch`, `pgbus.client.read_batch`, `pgbus.client.pool`, `pgbus.stream.broadcast`, `pgbus.outbox.publish`, `pgbus.recurring.enqueue`, `pgbus.worker.recycle`, `pgbus.consumer.recycle`, `pgbus.workflow.step`, `pgbus.workflow_finished`, `pgbus.workflow_paused`, `pgbus.workflow_cancelled`, `pgbus.workflow.stall_detected`, `pgbus.workflow.wait_timed_out`, `pgbus.workflow.gauge`. Payload keys are documented in `lib/pgbus/instrumentation.rb`.
|
|
1123
|
+
Metrics-relevant events emitted (the subset the metrics subscriber maps; see `lib/pgbus/instrumentation.rb` for the full `pgbus.*` catalog): `pgbus.executor.execute`, `pgbus.job_completed`, `pgbus.job_failed`, `pgbus.job_dead_lettered`, `pgbus.job_skipped`, `pgbus.job_discarded`, `pgbus.event_processed`, `pgbus.event_failed`, `pgbus.event_dead_lettered`, `pgbus.event_skipped`, `pgbus.job_visibility_extended`, `pgbus.client.send_message`, `pgbus.client.send_batch`, `pgbus.client.read_batch`, `pgbus.client.pool`, `pgbus.stream.broadcast`, `pgbus.outbox.publish`, `pgbus.recurring.enqueue`, `pgbus.worker.recycle`, `pgbus.consumer.recycle`, `pgbus.workflow.step`, `pgbus.workflow_finished`, `pgbus.workflow_paused`, `pgbus.workflow_cancelled`, `pgbus.workflow.stall_detected`, `pgbus.workflow.wait_timed_out`, `pgbus.workflow.gauge`. Payload keys are documented in `lib/pgbus/instrumentation.rb`.
|
|
1107
1124
|
|
|
1108
1125
|
### Connection pool metrics
|
|
1109
1126
|
|
|
@@ -1534,8 +1551,9 @@ Pgbus.configure do |c|
|
|
|
1534
1551
|
c.streams_peer_close_interval = 1.0 # notice a closed client at once; nil = heartbeat only
|
|
1535
1552
|
c.streams_max_connections = 2_000 # per web-server process (Puma worker or Falcon process)
|
|
1536
1553
|
c.streams_idle_timeout = 3_600 # close idle connections after 1h
|
|
1537
|
-
c.streams_listen_health_check_ms = 250 # PG LISTEN keepalive +
|
|
1538
|
-
c.streams_write_deadline_ms = 5_000 # write_nonblock deadline
|
|
1554
|
+
c.streams_listen_health_check_ms = 250 # PG LISTEN keepalive cadence + the ack budget for a stuck listener; a stream's first connect waits a thread hop on a live one, later connects not at all
|
|
1555
|
+
c.streams_write_deadline_ms = 5_000 # hijacked-socket write_nonblock deadline (connect replay, sentinels)
|
|
1556
|
+
c.streams_fanout_write_deadline_ms = 250 # hijacked-socket fan-out and heartbeat writes; a slower client reconnects (Falcon's streaming body ignores both)
|
|
1539
1557
|
c.streams_falcon_streaming_body = false # opt-in: Falcon-native streaming body
|
|
1540
1558
|
c.streams_broadcast_queue = nil # dedicated queue for turbo-rails broadcast jobs (see below)
|
|
1541
1559
|
end
|
|
@@ -2219,7 +2237,7 @@ pgbus help # Show help
|
|
|
2219
2237
|
|
|
2220
2238
|
#### pgbus doctor
|
|
2221
2239
|
|
|
2222
|
-
A single preflight command that answers "is this environment healthy enough to run?" — useful as a deploy or CI gate. It runs
|
|
2240
|
+
A single preflight command that answers "is this environment healthy enough to run?" — useful as a deploy or CI gate. It runs 14 checks and never raises; a broken environment turns every probe into a failed/warned check instead of a crash:
|
|
2223
2241
|
|
|
2224
2242
|
| Check | Fails (`:fail`) when | Warns (`:warn`) when |
|
|
2225
2243
|
|---|---|---|
|
|
@@ -2228,6 +2246,7 @@ A single preflight command that answers "is this environment healthy enough to r
|
|
|
2228
2246
|
| Database | Unreachable (`SELECT 1` via `Client#ping`) | — |
|
|
2229
2247
|
| PGMQ schema | Schema not installed | Installed but untracked, or behind the vendored version |
|
|
2230
2248
|
| Queues | A configured queue has no PGMQ table | — |
|
|
2249
|
+
| Queue name case | — | `pgmq.meta` holds a queue spelled in mixed case (created before 0.23.0): it shares its lower-case table with the spelling pgbus now uses, so the dashboard lists it twice until it is folded: once every process runs 0.23.0+, `rails generate pgbus:fold_queue_name_case` and `rails db:migrate` (`rake pgbus:queues:fold_case` for a row an older process recreated after that); dropping the mixed-case entry removes only its `pgmq.meta` row, never the shared table |
|
|
2231
2250
|
| LISTEN/NOTIFY | — | A configured queue is missing its insert trigger (falls back to polling) |
|
|
2232
2251
|
| Process liveness | Verdict is `STALLED` | Verdict is `DEGRADED` |
|
|
2233
2252
|
| GlobalID allowlist | — | `allowed_global_id_models` is `nil` (allow-all) in production |
|
|
@@ -2235,6 +2254,7 @@ A single preflight command that answers "is this environment healthy enough to r
|
|
|
2235
2254
|
| Primary affinity | — | Job connection is on a read-only replica (`pg_is_in_recovery`) — a read/write-splitting pooler may be stalling jobs |
|
|
2236
2255
|
| Dedicated connections | Streamer LISTEN and/or worker notify dedicated path cannot connect | — |
|
|
2237
2256
|
| Connection budget | — (informational: prints how many direct LISTEN connections the current config pins — 1 per host under `worker_notify_scope: :supervisor`, one per fork under `:fork`; streams add 1 per web host under `streams_listen_scope: :master` or 1 per web process under `:process`) | — |
|
|
2257
|
+
| Completion commit | — (informational: prints whether completions commit synchronously, the default, or with `synchronous_commit` off under `completion_synchronous_commit = false`) | `completion_synchronous_commit = false` with `native_statements = false`, or with pgbus on ActiveRecord's shared connection, where it has no effect |
|
|
2238
2258
|
|
|
2239
2259
|
```bash
|
|
2240
2260
|
pgbus doctor # prints the report; exit 1 unless every check passed
|
|
@@ -2474,7 +2494,7 @@ Curated headline options for the README. The full operator reference (with types
|
|
|
2474
2494
|
| `connects_to` | `nil` | Rails multi-database config for a dedicated pgbus database (`{ database: { writing: :pgbus } }`) |
|
|
2475
2495
|
| `require_primary` | `false` | Reject a job connection that lands on a read-only replica (`pg_is_in_recovery`) at boot — pooler safety against a read/write splitter |
|
|
2476
2496
|
| `connection_guc_mode` | `:options` | How database.yml GUCs (`variables:`) reach pgbus connections — `:options` (libpq startup) or `:session` (post-connect `SET`, for transaction-mode PgBouncer) |
|
|
2477
|
-
| `queue_prefix` | `"pgbus"` | Prefix for all PGMQ queue names |
|
|
2497
|
+
| `queue_prefix` | `"pgbus"` | Prefix for all PGMQ queue names. Queue names are lower case (`Orders` and `orders` are one queue, `pgbus_orders`), the way PGMQ spells its tables; an install from before 0.23.0 folds its mixed-case queues with `rails generate pgbus:fold_queue_name_case` and `rails db:migrate`, once every process runs 0.23.0+ |
|
|
2478
2498
|
| `default_queue` | `"default"` | Default queue for jobs without explicit queue |
|
|
2479
2499
|
| `pool_size` | `nil` (auto) | Connection pool size. Auto-tuned from worker thread counts: `sum(workers.threads) + sum(event_consumers.threads) + 2`. Set explicitly to override. |
|
|
2480
2500
|
| `pool_timeout` | `5` | Seconds to wait for a pooled connection |
|
|
@@ -2496,6 +2516,8 @@ Curated headline options for the README. The full operator reference (with types
|
|
|
2496
2516
|
| `worker_notify_wakeup` | `nil` (follows `listen_notify`) | Dedicated LISTEN connection for worker wake-up. `nil` follows `listen_notify`; set `false` to force polling. |
|
|
2497
2517
|
| `worker_notify_host` / `worker_notify_port` / `worker_notify_database_url` | `nil` | Override host/port/URL for the worker notify LISTEN connection (e.g. direct primary port when jobs go through PgBouncer) |
|
|
2498
2518
|
| `prefetch_limit` | `nil` | Max in-flight messages per worker (nil = unlimited) |
|
|
2519
|
+
| `completion_synchronous_commit` | `true` | `false` commits each job's completion (archive or delete, one or batched) with `SET LOCAL synchronous_commit TO off`, in one pipelined round trip (the first completion of a queue on a new connection prepares its statement and commits synchronously once), so the job stops waiting for its WAL flush. With `completion_synchronous_commit = false`, a completion is acknowledged before its WAL record reaches disk. A Postgres server crash (not a client crash, not a planned switchover), or a failover to a standby that has not yet received that WAL, within about three times `wal_writer_delay` (default 200 ms) can lose completions committed in that window. Each lost completion redelivers its job after the visibility timeout, so the job runs again. Under synchronous replication it also stops waiting for the standby for that transaction, which is what lets a failover lose it. Sends, retries and dead-letter moves keep full durability. A durable commit on any connection to the same server flushes WAL up to its LSN, earlier asynchronous completion records included, so a job whose completion is followed by a synchronous write (a concurrency signal, a batch completion, a uniqueness release, the stat buffer's flush) closes its own window within microseconds; the job's own ActiveRecord writes run in `perform`, before the completion, so they do not; the exposure is a plain job with no follow-up write, whose re-run is the consequence above. For a fleet on one Postgres whose fleet bench shows `WALWrite` waits; not for jobs that are not idempotent and cannot tolerate one extra run after a database crash or failover. Native statements on pgbus's own connections only (no effect with `native_statements = false` or a `connection_params` Proc sharing ActiveRecord's connection); needs libpq 14+ and the pg gem 1.5+; untested behind PgBouncer in transaction mode (use session mode or leave it on) |
|
|
2520
|
+
| `quiet_active_job_logs` | `false` | Worker processes unsubscribe ActiveJob's log subscriber from `perform_start` and `perform`: the per-job `Performing …` / `Performed …` lines stop, and so does ActiveJob's `Error performing …` line (an exception that escapes ActiveJob's handlers is still logged by pgbus's `ErrorReporter` and kept in `pgbus_failed_events`; one `retry_on` catches still logs ActiveJob's `Retrying …` line; a job halted by `before_perform` logs nothing). `Enqueued`, `Retrying`, `Stopped retrying`, `Discarded` and the web process are unchanged |
|
|
2499
2521
|
| `dispatch_interval` | `1.0` | Seconds between dispatcher maintenance ticks |
|
|
2500
2522
|
| `circuit_breaker_enabled` | `true` | Enable auto-pause on consecutive failures (threshold and backoff are tuned via `Pgbus::CircuitBreaker` constants) |
|
|
2501
2523
|
| `zombie_detection` | `true` | Detect and reclaim work from crashed workers |
|
data/Rakefile
CHANGED
|
@@ -26,7 +26,7 @@ namespace :bench do
|
|
|
26
26
|
db_benches = %w[connection_pool_bench integration_bench streams_bench streams_read_pool_bench
|
|
27
27
|
execution_modes_bench pool_swap_bench pool_autoscale_bench job_burst_bench
|
|
28
28
|
notify_wake_bench notify_chaos_bench streams_hub_bench fair_read_bench
|
|
29
|
-
worker_profile_bench workflow_insights_bench].freeze
|
|
29
|
+
worker_profile_bench workflow_insights_bench streams_scale_bench stat_flush_bench].freeze
|
|
30
30
|
# The unit suite is every *_bench.rb that doesn't need a database, derived
|
|
31
31
|
# from the directory so a new unit bench is picked up automatically (kept in
|
|
32
32
|
# sync with bench:one, which globs the same files).
|
|
@@ -88,6 +88,12 @@ namespace :bench do
|
|
|
88
88
|
ruby "benchmarks/streams_bench.rb"
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
desc "Run the streams scale bench: STREAMS_SCALE_CLIENTS (default 100,1000) SSE clients on one Puma process — " \
|
|
92
|
+
"connect storm, fan-out p99, dispatcher wake, heartbeat tick, RSS per connection (requires PGBUS_DATABASE_URL)"
|
|
93
|
+
task :streams_scale do
|
|
94
|
+
ruby "benchmarks/streams_scale_bench.rb"
|
|
95
|
+
end
|
|
96
|
+
|
|
91
97
|
desc "Run execution-mode connection benchmark (threads vs async pool usage; requires PGBUS_DATABASE_URL)"
|
|
92
98
|
task :execution_modes do
|
|
93
99
|
ruby "benchmarks/execution_modes_bench.rb"
|
|
@@ -220,7 +220,7 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
220
220
|
})
|
|
221
221
|
|
|
222
222
|
this.eventSource.addEventListener("turbo-stream", (event) => {
|
|
223
|
-
this.
|
|
223
|
+
this.rememberEventId(event.lastEventId)
|
|
224
224
|
this.emitTurboStream(event.data, event.lastEventId)
|
|
225
225
|
})
|
|
226
226
|
|
|
@@ -243,7 +243,7 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
243
243
|
// registered by name, so we register one per declared typed event.
|
|
244
244
|
for (const name of this.declaredTypedEvents()) {
|
|
245
245
|
this.eventSource.addEventListener(name, (event) => {
|
|
246
|
-
|
|
246
|
+
this.rememberEventId(event.lastEventId)
|
|
247
247
|
this.emitTypedEvent(name, event.data, event.lastEventId)
|
|
248
248
|
})
|
|
249
249
|
}
|
|
@@ -267,7 +267,7 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
267
267
|
}
|
|
268
268
|
const data = dataLines.join("\n")
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
this.rememberEventId(id)
|
|
271
271
|
|
|
272
272
|
if (event === "turbo-stream") {
|
|
273
273
|
this.emitTurboStream(data, id)
|
|
@@ -388,6 +388,20 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
388
388
|
return url.toString()
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
// The resume cursor (?since= on the next connect) is the last DURABLE
|
|
392
|
+
// msg_id. An ephemeral frame's id is negative (it has no archive
|
|
393
|
+
// position), so it is handed to listeners as the frame's revision but never
|
|
394
|
+
// kept here: sent back as ?since= it would be rejected (issue #621). A
|
|
395
|
+
// native EventSource still echoes it as Last-Event-ID on its own
|
|
396
|
+
// reconnects; the server skips a negative Last-Event-ID and uses ?since=,
|
|
397
|
+
// which is the cursor as of the switch to EventSource, so durable frames
|
|
398
|
+
// received after that switch can be delivered once more.
|
|
399
|
+
rememberEventId(id) {
|
|
400
|
+
if (id === null || id === undefined || id === "") return
|
|
401
|
+
if (String(id).startsWith("-")) return
|
|
402
|
+
this.lastEventId = id
|
|
403
|
+
}
|
|
404
|
+
|
|
391
405
|
safeJsonParse(str) {
|
|
392
406
|
try { return JSON.parse(str) } catch { return null }
|
|
393
407
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import{Turbo as u}from"@hotwired/turbo-rails";var{connectStreamSource:h,disconnectStreamSource:v}=u,
|
|
1
|
+
import{Turbo as u}from"@hotwired/turbo-rails";var{connectStreamSource:h,disconnectStreamSource:v}=u,o=null;function m(){if(!o)o=globalThis.crypto&&crypto.randomUUID?crypto.randomUUID():`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`;return o}class a extends HTMLElement{static get observedAttributes(){return["src","since-id"]}constructor(){super();this.abortController=null,this.eventSource=null,this.lastEventId=null,this.connectionId=null,this.closed=!1}get pgbusConnectionId(){return this.connectionId}get pgbusSeat(){return this.hasAttribute("presence")?m():null}connectedCallback(){this.closed=!1,h(this);let e=this.getAttribute("since-id");this.lastEventId=e&&e!==""?e:null,this.openFetchStream()}disconnectedCallback(){this.closed=!0,v(this),this.teardown()}teardown(){if(this.abortController)this.abortController.abort(),this.abortController=null;if(this.eventSource)this.eventSource.close(),this.eventSource=null}async openFetchStream(){this.resetConnectionId();let e=this.buildUrl({includeSince:!0});this.abortController=new AbortController;try{let t=await fetch(e,{headers:{Accept:"text/event-stream"},credentials:"same-origin",signal:this.abortController.signal});if(!t.ok){this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:t.status,reason:t.statusText}}));return}this.setAttribute("connected",""),this.dispatchEvent(new CustomEvent("pgbus:open",{detail:{lastEventId:this.lastEventId,connectionId:this.connectionId}}));let n=t.body.getReader(),s=new TextDecoder("utf-8"),r="";while(!this.closed){let{value:i,done:d}=await n.read();if(d){this.removeAttribute("connected"),this.switchToEventSource();return}r+=s.decode(i,{stream:!0});let c=r.split(`
|
|
2
2
|
|
|
3
|
-
`);
|
|
4
|
-
`))if(
|
|
5
|
-
`);if(
|
|
3
|
+
`);r=c.pop();for(let l of c)this.handleBlock(l)}}catch(t){if(t.name!=="AbortError")this.removeAttribute("connected"),this.switchToEventSource()}}switchToEventSource(){if(this.closed)return;this.resetConnectionId();let e=this.buildUrl({includeSince:!0});this.eventSource=new EventSource(e,{withCredentials:!0}),this.eventSource.addEventListener("open",()=>{this.setAttribute("connected",""),this.dispatchEvent(new CustomEvent("pgbus:open",{detail:{lastEventId:this.lastEventId,connectionId:this.connectionId}}))}),this.eventSource.addEventListener("error",()=>{this.removeAttribute("connected")}),this.eventSource.addEventListener("turbo-stream",(t)=>{this.rememberEventId(t.lastEventId),this.emitTurboStream(t.data,t.lastEventId)}),this.eventSource.addEventListener("pgbus:connected",(t)=>{this.handleConnected(t.data)}),this.eventSource.addEventListener("pgbus:gap-detected",(t)=>{let n=this.safeJsonParse(t.data);this.dispatchEvent(new CustomEvent("pgbus:gap-detected",{detail:n}))}),this.eventSource.addEventListener("pgbus:shutdown",()=>{this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:"shutdown",reason:"worker restart"}}))});for(let t of this.declaredTypedEvents())this.eventSource.addEventListener(t,(n)=>{this.rememberEventId(n.lastEventId),this.emitTypedEvent(t,n.data,n.lastEventId)})}handleBlock(e){if(!e||e.startsWith(":"))return;let t=null,n="message",s=[];for(let i of e.split(`
|
|
4
|
+
`))if(i.startsWith("id:"))t=i.slice(3).trim();else if(i.startsWith("event:"))n=i.slice(6).trim();else if(i.startsWith("data:"))s.push(i.slice(5).replace(/^ /,""));let r=s.join(`
|
|
5
|
+
`);if(this.rememberEventId(t),n==="turbo-stream")this.emitTurboStream(r,t);else if(n==="pgbus:connected")this.handleConnected(r);else if(n==="pgbus:gap-detected")this.dispatchEvent(new CustomEvent("pgbus:gap-detected",{detail:this.safeJsonParse(r)}));else if(n==="pgbus:shutdown")this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:"shutdown",reason:"worker restart"}}));else this.emitTypedEvent(n,r,t)}handleConnected(e){let t=this.safeJsonParse(e),n=t&&t.connectionId;if(!n)return;this.connectionId=n,this.setAttribute("connection-id",n),this.dispatchEvent(new CustomEvent("pgbus:connected",{detail:{connectionId:n}}))}resetConnectionId(){this.connectionId=null,this.removeAttribute("connection-id")}emitTurboStream(e,t){let n=t===null||t===void 0||t===""?null:Number.isNaN(Number(t))?t:Number(t),s=new MessageEvent("message",{data:e,lastEventId:t==null?"":String(t)});this.dispatchEvent(s),this.dispatchEvent(new CustomEvent("pgbus:message",{detail:{msgId:n,data:e}}))}emitTypedEvent(e,t,n){let s=n===null||n===void 0||n===""?null:Number.isNaN(Number(n))?n:Number(n);this.dispatchEvent(new CustomEvent("pgbus:event",{detail:{event:e,data:t,msgId:s}})),this.dispatchEvent(new CustomEvent(`pgbus:${e}`,{detail:{data:t,msgId:s}}))}declaredTypedEvents(){let e=this.getAttribute("listen-events");if(!e)return[];return e.split(/[\s,]+/).filter((t)=>t&&t!=="turbo-stream")}buildUrl({includeSince:e}){let t=this.getAttribute("src"),n=e&&this.lastEventId?this.lastEventId:null,s=this.pgbusSeat;if(!n&&!s)return t;let r=new URL(t,window.location.origin);if(n)r.searchParams.set("since",n);if(s)r.searchParams.set("seat",s);return r.toString()}rememberEventId(e){if(e===null||e===void 0||e==="")return;if(String(e).startsWith("-"))return;this.lastEventId=e}safeJsonParse(e){try{return JSON.parse(e)}catch{return null}}}if(!customElements.get("pgbus-stream-source"))customElements.define("pgbus-stream-source",a);export{a as PgbusStreamSourceElement};
|
|
6
6
|
//# sourceMappingURL=stream_source_element.min.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["src/stream_source_element.js"],"sourcesContent":["// <pgbus-stream-source> — the browser-side counterpart to\n// Pgbus::StreamsHelper#pgbus_stream_from. Drop-in replacement for\n// turbo-rails' <turbo-cable-stream-source> that speaks SSE + pgbus\n// instead of WebSocket + ActionCable.\n//\n// Attributes (set by the view helper):\n// src — absolute path to the SSE endpoint, including\n// the URL-safe signed stream name\n// since-id — PGMQ msg_id watermark at render time; the\n// client sends this as ?since= on the FIRST\n// connect so the server can replay any\n// broadcasts from the render-to-connect gap\n// (rails/rails#52420 fix)\n// signed-stream-name — present for parity with turbo-rails; unused\n// by this element because the signed name is\n// already in the URL path\n// channel — compatibility shim; ignored\n// presence — boolean; when present the element sends this\n// document's presence seat as ?seat= on every\n// connect (issue #515)\n//\n// Events (dispatched on the element):\n// message (MessageEvent) data = turbo-stream HTML;\n// lastEventId = the frame's msg_id (revision)\n// pgbus:message { msgId, data } — same frame, for optimistic-UI\n// reconciliation (skip morph if a newer rev for the\n// target was already applied). See #168.\n// pgbus:event { event, data, msgId } — a typed broadcast (any SSE\n// event name other than turbo-stream). See #170.\n// pgbus:<event> { data, msgId } — the same typed broadcast, named\n// for ergonomic addEventListener(\"pgbus:presence\").\n// pgbus:open { lastEventId, connectionId }\n// pgbus:connected { connectionId }\n// pgbus:replay-start { fromId, toId }\n// pgbus:replay-end {}\n// pgbus:gap-detected { lastSeenId, archiveOldestId }\n// pgbus:close { code, reason }\n//\n// Connection id (issue #165 — actor-echo suppression): the server sends a\n// `pgbus:connected` frame carrying the server-minted connection id once the\n// streamer has registered the connection and joined its presence seat\n// (issue #521), after the connect replay. This element captures it, reflects it onto\n// the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.\n// A page reads it (from the element or a `<meta name=\"pgbus-connection-id\">`\n// the app mirrors it to) and sends it back as the `X-Pgbus-Connection`\n// header on action requests. The broadcaster then passes\n// `exclude: request.headers[\"X-Pgbus-Connection\"]` so the actor does not\n// receive the echo of its own broadcast.\n//\n// The element integrates with Turbo via connectStreamSource /\n// disconnectStreamSource + dispatching MessageEvent(\"message\") so Turbo\n// Stream HTML is automatically consumed by the existing StreamObserver.\n//\n// Presence seat (issue #515): with the `presence` attribute, the element\n// appends `?seat=<id>` to the SSE request. The seat is minted once per\n// document (shared by every presence element on the page and kept across\n// Turbo Drive visits, so it names the browser tab) and stays the same across\n// reconnects. The server stores it on the connection's presence row, which\n// lets a page tell its own tab apart from the member's other tabs. Read it\n// from `element.pgbusSeat`.\n//\n// Transport strategy:\n// - FIRST connect: use fetch() + ReadableStream to include ?since=\n// in the URL. Native EventSource cannot send Last-Event-ID on the\n// initial request, so we need fetch to carry the watermark.\n// - RECONNECT: switch to native EventSource, which sends Last-Event-ID\n// automatically based on the last id: we observed. The native\n// client is more battle-tested for reconnection backoff.\n\nimport { Turbo } from \"@hotwired/turbo-rails\"\nconst { connectStreamSource, disconnectStreamSource } = Turbo\n\nlet documentSeat = null\n\n// One seat per document, minted on first use (see \"Presence seat\" above).\nfunction seatForDocument() {\n if (!documentSeat) {\n documentSeat = globalThis.crypto && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`\n }\n return documentSeat\n}\n\nclass PgbusStreamSourceElement extends HTMLElement {\n static get observedAttributes() {\n return [\"src\", \"since-id\"]\n }\n\n constructor() {\n super()\n this.abortController = null\n this.eventSource = null\n this.lastEventId = null\n this.connectionId = null\n this.closed = false\n }\n\n // The server-minted connection id for this SSE connection, or null\n // until the `pgbus:connected` frame arrives. Public read accessor so\n // a reactive runtime can grab it without poking at attributes.\n get pgbusConnectionId() {\n return this.connectionId\n }\n\n // This document's presence seat when the element has the `presence`\n // attribute, otherwise null.\n get pgbusSeat() {\n return this.hasAttribute(\"presence\") ? seatForDocument() : null\n }\n\n connectedCallback() {\n this.closed = false\n connectStreamSource(this)\n const sinceId = this.getAttribute(\"since-id\")\n this.lastEventId = sinceId && sinceId !== \"\" ? sinceId : null\n this.openFetchStream()\n }\n\n disconnectedCallback() {\n this.closed = true\n disconnectStreamSource(this)\n this.teardown()\n }\n\n teardown() {\n if (this.abortController) {\n this.abortController.abort()\n this.abortController = null\n }\n if (this.eventSource) {\n this.eventSource.close()\n this.eventSource = null\n }\n }\n\n // First connect: use fetch() so we can include ?since=<watermark> on\n // the URL. Parses the SSE event stream by hand because EventSource\n // doesn't expose custom query strings uniformly across browsers.\n async openFetchStream() {\n // Each transport open is a fresh connection: the server mints a new id\n // and sends a new pgbus:connected frame. Clear the previous id so\n // pgbus:open (which fires before that frame arrives) can't surface a\n // stale connection id — a stale id would produce a wrong X-Pgbus-Connection\n // exclude header during reconnect windows.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.abortController = new AbortController()\n\n try {\n const response = await fetch(url, {\n headers: { Accept: \"text/event-stream\" },\n credentials: \"same-origin\",\n signal: this.abortController.signal\n })\n\n if (!response.ok) {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: response.status, reason: response.statusText }\n }))\n return\n }\n\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n\n const reader = response.body.getReader()\n const decoder = new TextDecoder(\"utf-8\")\n let buffer = \"\"\n\n while (!this.closed) {\n const { value, done } = await reader.read()\n if (done) {\n this.removeAttribute(\"connected\")\n this.switchToEventSource()\n return\n }\n\n buffer += decoder.decode(value, { stream: true })\n const events = buffer.split(\"\\n\\n\")\n buffer = events.pop() // last chunk may be incomplete\n\n for (const block of events) {\n this.handleBlock(block)\n }\n }\n } catch (err) {\n if (err.name !== \"AbortError\") {\n this.removeAttribute(\"connected\")\n // Fall through to reconnect via native EventSource, which\n // will carry Last-Event-ID from this.lastEventId forward.\n this.switchToEventSource()\n }\n }\n }\n\n // Reconnect path: native EventSource with Last-Event-ID baked into\n // the initial handshake by the browser.\n switchToEventSource() {\n if (this.closed) return\n\n // Fresh connection on reconnect — drop the previous connection id so\n // pgbus:open can't emit a stale one before the new pgbus:connected\n // frame lands. See openFetchStream.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.eventSource = new EventSource(url, { withCredentials: true })\n\n this.eventSource.addEventListener(\"open\", () => {\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n })\n\n this.eventSource.addEventListener(\"error\", () => {\n this.removeAttribute(\"connected\")\n })\n\n this.eventSource.addEventListener(\"turbo-stream\", (event) => {\n this.lastEventId = event.lastEventId\n this.emitTurboStream(event.data, event.lastEventId)\n })\n\n this.eventSource.addEventListener(\"pgbus:connected\", (event) => {\n this.handleConnected(event.data)\n })\n\n this.eventSource.addEventListener(\"pgbus:gap-detected\", (event) => {\n const detail = this.safeJsonParse(event.data)\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", { detail }))\n })\n\n this.eventSource.addEventListener(\"pgbus:shutdown\", () => {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n })\n\n // Typed broadcasts (issue #170): EventSource only fires listeners\n // registered by name, so we register one per declared typed event.\n for (const name of this.declaredTypedEvents()) {\n this.eventSource.addEventListener(name, (event) => {\n if (event.lastEventId) this.lastEventId = event.lastEventId\n this.emitTypedEvent(name, event.data, event.lastEventId)\n })\n }\n }\n\n // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)\n handleBlock(block) {\n if (!block || block.startsWith(\":\")) return // comment\n\n let id = null\n let event = \"message\"\n const dataLines = []\n\n for (const line of block.split(\"\\n\")) {\n if (line.startsWith(\"id:\")) id = line.slice(3).trim()\n else if (line.startsWith(\"event:\")) event = line.slice(6).trim()\n // Per the SSE spec: strip only a single leading space after the colon\n // (never trim — payload whitespace is significant, issue #392) and\n // rejoin consecutive data: lines with \\n, matching native EventSource.\n else if (line.startsWith(\"data:\")) dataLines.push(line.slice(5).replace(/^ /, \"\"))\n }\n const data = dataLines.join(\"\\n\")\n\n if (id !== null) this.lastEventId = id\n\n if (event === \"turbo-stream\") {\n this.emitTurboStream(data, id)\n } else if (event === \"pgbus:connected\") {\n this.handleConnected(data)\n } else if (event === \"pgbus:gap-detected\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", {\n detail: this.safeJsonParse(data)\n }))\n } else if (event === \"pgbus:shutdown\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n } else {\n // A typed broadcast (issue #170): event: presence | reactive | ...\n this.emitTypedEvent(event, data, id)\n }\n }\n\n // Captures the server-minted connection id from a `pgbus:connected`\n // frame: stores it, reflects it onto the `connection-id` attribute (so\n // it's visible in the DOM / to MutationObservers), and re-dispatches it\n // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the\n // server mints a fresh id per connection, so a reconnect updates it.\n handleConnected(data) {\n const detail = this.safeJsonParse(data)\n const connectionId = detail && detail.connectionId\n if (!connectionId) return\n\n this.connectionId = connectionId\n this.setAttribute(\"connection-id\", connectionId)\n this.dispatchEvent(new CustomEvent(\"pgbus:connected\", {\n detail: { connectionId }\n }))\n }\n\n // Clears the cached connection id and its reflected attribute. Called at\n // the start of every transport open so a reconnect doesn't carry the\n // previous connection's id into pgbus:open before the new pgbus:connected\n // frame arrives.\n resetConnectionId() {\n this.connectionId = null\n this.removeAttribute(\"connection-id\")\n }\n\n // Delivers a turbo-stream frame to two audiences (issue #168):\n //\n // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`\n // is the turbo-stream HTML. The MessageEvent's standard\n // `lastEventId` field carries the frame's msg_id, so a reactive\n // runtime that listens for `message` can read the revision without\n // any pgbus-specific API. (Turbo ignores lastEventId.)\n //\n // 2. A reactive runtime doing optimistic-UI reconciliation, via a\n // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the\n // monotonic per-stream revision (a negative value marks an ephemeral\n // frame that was never persisted). Pattern: track the highest msgId\n // you have applied per target; when a frame arrives, skip the morph\n // if you have already applied a newer revision for that target —\n // this stops a late echo from clobbering a newer optimistic edit.\n // Complements #165: exclude handles the actor; this handles\n // out-of-order delivery for everyone else.\n //\n // msgId is parsed to a Number when numeric so consumers can compare\n // revisions with `>` directly; left as-is otherwise.\n emitTurboStream(data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n const message = new MessageEvent(\"message\", { data, lastEventId: id == null ? \"\" : String(id) })\n this.dispatchEvent(message)\n\n this.dispatchEvent(new CustomEvent(\"pgbus:message\", {\n detail: { msgId, data }\n }))\n }\n\n // Delivers a typed broadcast (issue #170) — a frame whose SSE event name\n // is something other than turbo-stream (e.g. \"presence\", \"reactive\").\n // The payload is still whatever the broadcaster sent (usually a Turbo\n // Stream); the typed name lets a client route without sniffing the HTML.\n // Dispatched two ways for ergonomics:\n // - a generic `pgbus:event` { event, data, msgId } (one listener for all)\n // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)\n emitTypedEvent(event, data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n this.dispatchEvent(new CustomEvent(\"pgbus:event\", {\n detail: { event, data, msgId }\n }))\n this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {\n detail: { data, msgId }\n }))\n }\n\n // Typed event names the EventSource (reconnect) path should listen for,\n // declared by the app via the `listen-events` attribute (comma- or\n // space-separated). EventSource only invokes listeners registered by\n // name, so unlike the fetch path it cannot route unknown typed events\n // generically; declaring them here keeps typed delivery working across\n // reconnects. The fetch (initial) path always routes typed events.\n declaredTypedEvents() {\n const raw = this.getAttribute(\"listen-events\")\n if (!raw) return []\n return raw.split(/[\\s,]+/).filter((name) => name && name !== \"turbo-stream\")\n }\n\n buildUrl({ includeSince }) {\n const src = this.getAttribute(\"src\")\n const since = includeSince && this.lastEventId ? this.lastEventId : null\n const seat = this.pgbusSeat\n if (!since && !seat) return src\n\n const url = new URL(src, window.location.origin)\n if (since) url.searchParams.set(\"since\", since)\n if (seat) url.searchParams.set(\"seat\", seat)\n return url.toString()\n }\n\n safeJsonParse(str) {\n try { return JSON.parse(str) } catch { return null }\n }\n}\n\nif (!customElements.get(\"pgbus-stream-source\")) {\n customElements.define(\"pgbus-stream-source\", PgbusStreamSourceElement)\n}\n\nexport { PgbusStreamSourceElement }\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["src/stream_source_element.js"],"sourcesContent":["// <pgbus-stream-source> — the browser-side counterpart to\n// Pgbus::StreamsHelper#pgbus_stream_from. Drop-in replacement for\n// turbo-rails' <turbo-cable-stream-source> that speaks SSE + pgbus\n// instead of WebSocket + ActionCable.\n//\n// Attributes (set by the view helper):\n// src — absolute path to the SSE endpoint, including\n// the URL-safe signed stream name\n// since-id — PGMQ msg_id watermark at render time; the\n// client sends this as ?since= on the FIRST\n// connect so the server can replay any\n// broadcasts from the render-to-connect gap\n// (rails/rails#52420 fix)\n// signed-stream-name — present for parity with turbo-rails; unused\n// by this element because the signed name is\n// already in the URL path\n// channel — compatibility shim; ignored\n// presence — boolean; when present the element sends this\n// document's presence seat as ?seat= on every\n// connect (issue #515)\n//\n// Events (dispatched on the element):\n// message (MessageEvent) data = turbo-stream HTML;\n// lastEventId = the frame's msg_id (revision)\n// pgbus:message { msgId, data } — same frame, for optimistic-UI\n// reconciliation (skip morph if a newer rev for the\n// target was already applied). See #168.\n// pgbus:event { event, data, msgId } — a typed broadcast (any SSE\n// event name other than turbo-stream). See #170.\n// pgbus:<event> { data, msgId } — the same typed broadcast, named\n// for ergonomic addEventListener(\"pgbus:presence\").\n// pgbus:open { lastEventId, connectionId }\n// pgbus:connected { connectionId }\n// pgbus:replay-start { fromId, toId }\n// pgbus:replay-end {}\n// pgbus:gap-detected { lastSeenId, archiveOldestId }\n// pgbus:close { code, reason }\n//\n// Connection id (issue #165 — actor-echo suppression): the server sends a\n// `pgbus:connected` frame carrying the server-minted connection id once the\n// streamer has registered the connection and joined its presence seat\n// (issue #521), after the connect replay. This element captures it, reflects it onto\n// the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.\n// A page reads it (from the element or a `<meta name=\"pgbus-connection-id\">`\n// the app mirrors it to) and sends it back as the `X-Pgbus-Connection`\n// header on action requests. The broadcaster then passes\n// `exclude: request.headers[\"X-Pgbus-Connection\"]` so the actor does not\n// receive the echo of its own broadcast.\n//\n// The element integrates with Turbo via connectStreamSource /\n// disconnectStreamSource + dispatching MessageEvent(\"message\") so Turbo\n// Stream HTML is automatically consumed by the existing StreamObserver.\n//\n// Presence seat (issue #515): with the `presence` attribute, the element\n// appends `?seat=<id>` to the SSE request. The seat is minted once per\n// document (shared by every presence element on the page and kept across\n// Turbo Drive visits, so it names the browser tab) and stays the same across\n// reconnects. The server stores it on the connection's presence row, which\n// lets a page tell its own tab apart from the member's other tabs. Read it\n// from `element.pgbusSeat`.\n//\n// Transport strategy:\n// - FIRST connect: use fetch() + ReadableStream to include ?since=\n// in the URL. Native EventSource cannot send Last-Event-ID on the\n// initial request, so we need fetch to carry the watermark.\n// - RECONNECT: switch to native EventSource, which sends Last-Event-ID\n// automatically based on the last id: we observed. The native\n// client is more battle-tested for reconnection backoff.\n\nimport { Turbo } from \"@hotwired/turbo-rails\"\nconst { connectStreamSource, disconnectStreamSource } = Turbo\n\nlet documentSeat = null\n\n// One seat per document, minted on first use (see \"Presence seat\" above).\nfunction seatForDocument() {\n if (!documentSeat) {\n documentSeat = globalThis.crypto && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`\n }\n return documentSeat\n}\n\nclass PgbusStreamSourceElement extends HTMLElement {\n static get observedAttributes() {\n return [\"src\", \"since-id\"]\n }\n\n constructor() {\n super()\n this.abortController = null\n this.eventSource = null\n this.lastEventId = null\n this.connectionId = null\n this.closed = false\n }\n\n // The server-minted connection id for this SSE connection, or null\n // until the `pgbus:connected` frame arrives. Public read accessor so\n // a reactive runtime can grab it without poking at attributes.\n get pgbusConnectionId() {\n return this.connectionId\n }\n\n // This document's presence seat when the element has the `presence`\n // attribute, otherwise null.\n get pgbusSeat() {\n return this.hasAttribute(\"presence\") ? seatForDocument() : null\n }\n\n connectedCallback() {\n this.closed = false\n connectStreamSource(this)\n const sinceId = this.getAttribute(\"since-id\")\n this.lastEventId = sinceId && sinceId !== \"\" ? sinceId : null\n this.openFetchStream()\n }\n\n disconnectedCallback() {\n this.closed = true\n disconnectStreamSource(this)\n this.teardown()\n }\n\n teardown() {\n if (this.abortController) {\n this.abortController.abort()\n this.abortController = null\n }\n if (this.eventSource) {\n this.eventSource.close()\n this.eventSource = null\n }\n }\n\n // First connect: use fetch() so we can include ?since=<watermark> on\n // the URL. Parses the SSE event stream by hand because EventSource\n // doesn't expose custom query strings uniformly across browsers.\n async openFetchStream() {\n // Each transport open is a fresh connection: the server mints a new id\n // and sends a new pgbus:connected frame. Clear the previous id so\n // pgbus:open (which fires before that frame arrives) can't surface a\n // stale connection id — a stale id would produce a wrong X-Pgbus-Connection\n // exclude header during reconnect windows.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.abortController = new AbortController()\n\n try {\n const response = await fetch(url, {\n headers: { Accept: \"text/event-stream\" },\n credentials: \"same-origin\",\n signal: this.abortController.signal\n })\n\n if (!response.ok) {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: response.status, reason: response.statusText }\n }))\n return\n }\n\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n\n const reader = response.body.getReader()\n const decoder = new TextDecoder(\"utf-8\")\n let buffer = \"\"\n\n while (!this.closed) {\n const { value, done } = await reader.read()\n if (done) {\n this.removeAttribute(\"connected\")\n this.switchToEventSource()\n return\n }\n\n buffer += decoder.decode(value, { stream: true })\n const events = buffer.split(\"\\n\\n\")\n buffer = events.pop() // last chunk may be incomplete\n\n for (const block of events) {\n this.handleBlock(block)\n }\n }\n } catch (err) {\n if (err.name !== \"AbortError\") {\n this.removeAttribute(\"connected\")\n // Fall through to reconnect via native EventSource, which\n // will carry Last-Event-ID from this.lastEventId forward.\n this.switchToEventSource()\n }\n }\n }\n\n // Reconnect path: native EventSource with Last-Event-ID baked into\n // the initial handshake by the browser.\n switchToEventSource() {\n if (this.closed) return\n\n // Fresh connection on reconnect — drop the previous connection id so\n // pgbus:open can't emit a stale one before the new pgbus:connected\n // frame lands. See openFetchStream.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.eventSource = new EventSource(url, { withCredentials: true })\n\n this.eventSource.addEventListener(\"open\", () => {\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n })\n\n this.eventSource.addEventListener(\"error\", () => {\n this.removeAttribute(\"connected\")\n })\n\n this.eventSource.addEventListener(\"turbo-stream\", (event) => {\n this.rememberEventId(event.lastEventId)\n this.emitTurboStream(event.data, event.lastEventId)\n })\n\n this.eventSource.addEventListener(\"pgbus:connected\", (event) => {\n this.handleConnected(event.data)\n })\n\n this.eventSource.addEventListener(\"pgbus:gap-detected\", (event) => {\n const detail = this.safeJsonParse(event.data)\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", { detail }))\n })\n\n this.eventSource.addEventListener(\"pgbus:shutdown\", () => {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n })\n\n // Typed broadcasts (issue #170): EventSource only fires listeners\n // registered by name, so we register one per declared typed event.\n for (const name of this.declaredTypedEvents()) {\n this.eventSource.addEventListener(name, (event) => {\n this.rememberEventId(event.lastEventId)\n this.emitTypedEvent(name, event.data, event.lastEventId)\n })\n }\n }\n\n // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)\n handleBlock(block) {\n if (!block || block.startsWith(\":\")) return // comment\n\n let id = null\n let event = \"message\"\n const dataLines = []\n\n for (const line of block.split(\"\\n\")) {\n if (line.startsWith(\"id:\")) id = line.slice(3).trim()\n else if (line.startsWith(\"event:\")) event = line.slice(6).trim()\n // Per the SSE spec: strip only a single leading space after the colon\n // (never trim — payload whitespace is significant, issue #392) and\n // rejoin consecutive data: lines with \\n, matching native EventSource.\n else if (line.startsWith(\"data:\")) dataLines.push(line.slice(5).replace(/^ /, \"\"))\n }\n const data = dataLines.join(\"\\n\")\n\n this.rememberEventId(id)\n\n if (event === \"turbo-stream\") {\n this.emitTurboStream(data, id)\n } else if (event === \"pgbus:connected\") {\n this.handleConnected(data)\n } else if (event === \"pgbus:gap-detected\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", {\n detail: this.safeJsonParse(data)\n }))\n } else if (event === \"pgbus:shutdown\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n } else {\n // A typed broadcast (issue #170): event: presence | reactive | ...\n this.emitTypedEvent(event, data, id)\n }\n }\n\n // Captures the server-minted connection id from a `pgbus:connected`\n // frame: stores it, reflects it onto the `connection-id` attribute (so\n // it's visible in the DOM / to MutationObservers), and re-dispatches it\n // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the\n // server mints a fresh id per connection, so a reconnect updates it.\n handleConnected(data) {\n const detail = this.safeJsonParse(data)\n const connectionId = detail && detail.connectionId\n if (!connectionId) return\n\n this.connectionId = connectionId\n this.setAttribute(\"connection-id\", connectionId)\n this.dispatchEvent(new CustomEvent(\"pgbus:connected\", {\n detail: { connectionId }\n }))\n }\n\n // Clears the cached connection id and its reflected attribute. Called at\n // the start of every transport open so a reconnect doesn't carry the\n // previous connection's id into pgbus:open before the new pgbus:connected\n // frame arrives.\n resetConnectionId() {\n this.connectionId = null\n this.removeAttribute(\"connection-id\")\n }\n\n // Delivers a turbo-stream frame to two audiences (issue #168):\n //\n // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`\n // is the turbo-stream HTML. The MessageEvent's standard\n // `lastEventId` field carries the frame's msg_id, so a reactive\n // runtime that listens for `message` can read the revision without\n // any pgbus-specific API. (Turbo ignores lastEventId.)\n //\n // 2. A reactive runtime doing optimistic-UI reconciliation, via a\n // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the\n // monotonic per-stream revision (a negative value marks an ephemeral\n // frame that was never persisted). Pattern: track the highest msgId\n // you have applied per target; when a frame arrives, skip the morph\n // if you have already applied a newer revision for that target —\n // this stops a late echo from clobbering a newer optimistic edit.\n // Complements #165: exclude handles the actor; this handles\n // out-of-order delivery for everyone else.\n //\n // msgId is parsed to a Number when numeric so consumers can compare\n // revisions with `>` directly; left as-is otherwise.\n emitTurboStream(data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n const message = new MessageEvent(\"message\", { data, lastEventId: id == null ? \"\" : String(id) })\n this.dispatchEvent(message)\n\n this.dispatchEvent(new CustomEvent(\"pgbus:message\", {\n detail: { msgId, data }\n }))\n }\n\n // Delivers a typed broadcast (issue #170) — a frame whose SSE event name\n // is something other than turbo-stream (e.g. \"presence\", \"reactive\").\n // The payload is still whatever the broadcaster sent (usually a Turbo\n // Stream); the typed name lets a client route without sniffing the HTML.\n // Dispatched two ways for ergonomics:\n // - a generic `pgbus:event` { event, data, msgId } (one listener for all)\n // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)\n emitTypedEvent(event, data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n this.dispatchEvent(new CustomEvent(\"pgbus:event\", {\n detail: { event, data, msgId }\n }))\n this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {\n detail: { data, msgId }\n }))\n }\n\n // Typed event names the EventSource (reconnect) path should listen for,\n // declared by the app via the `listen-events` attribute (comma- or\n // space-separated). EventSource only invokes listeners registered by\n // name, so unlike the fetch path it cannot route unknown typed events\n // generically; declaring them here keeps typed delivery working across\n // reconnects. The fetch (initial) path always routes typed events.\n declaredTypedEvents() {\n const raw = this.getAttribute(\"listen-events\")\n if (!raw) return []\n return raw.split(/[\\s,]+/).filter((name) => name && name !== \"turbo-stream\")\n }\n\n buildUrl({ includeSince }) {\n const src = this.getAttribute(\"src\")\n const since = includeSince && this.lastEventId ? this.lastEventId : null\n const seat = this.pgbusSeat\n if (!since && !seat) return src\n\n const url = new URL(src, window.location.origin)\n if (since) url.searchParams.set(\"since\", since)\n if (seat) url.searchParams.set(\"seat\", seat)\n return url.toString()\n }\n\n // The resume cursor (?since= on the next connect) is the last DURABLE\n // msg_id. An ephemeral frame's id is negative (it has no archive\n // position), so it is handed to listeners as the frame's revision but never\n // kept here: sent back as ?since= it would be rejected (issue #621). A\n // native EventSource still echoes it as Last-Event-ID on its own\n // reconnects; the server skips a negative Last-Event-ID and uses ?since=,\n // which is the cursor as of the switch to EventSource, so durable frames\n // received after that switch can be delivered once more.\n rememberEventId(id) {\n if (id === null || id === undefined || id === \"\") return\n if (String(id).startsWith(\"-\")) return\n this.lastEventId = id\n }\n\n safeJsonParse(str) {\n try { return JSON.parse(str) } catch { return null }\n }\n}\n\nif (!customElements.get(\"pgbus-stream-source\")) {\n customElements.define(\"pgbus-stream-source\", PgbusStreamSourceElement)\n}\n\nexport { PgbusStreamSourceElement }\n"],"mappings":"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","names":[]}
|
|
@@ -33,7 +33,9 @@ module Pgbus
|
|
|
33
33
|
if data_source.retry_dlq_message(queue_name, params[:id])
|
|
34
34
|
redirect_to dead_letter_index_path, notice: "Message re-enqueued to original queue."
|
|
35
35
|
else
|
|
36
|
-
redirect_to dead_letter_index_path,
|
|
36
|
+
redirect_to dead_letter_index_path,
|
|
37
|
+
alert: "Could not retry message: it is gone, the retry failed, another copy of the job holds " \
|
|
38
|
+
"its uniqueness key, or its concurrency limit is full."
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -20,7 +20,8 @@ module Pgbus
|
|
|
20
20
|
if data_source.retry_failed_event(params[:id])
|
|
21
21
|
redirect_after_failed_event_action notice: "Job re-enqueued."
|
|
22
22
|
else
|
|
23
|
-
redirect_after_failed_event_action alert: "Could not retry job
|
|
23
|
+
redirect_after_failed_event_action alert: "Could not retry job: it is gone, the retry failed, another copy of " \
|
|
24
|
+
"the job holds its uniqueness key, or its concurrency limit is full."
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -64,8 +64,9 @@ module Pgbus
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# The uniqueness keys, out of +lock_keys+, that a parked job carries. Its
|
|
67
|
-
# :until_executed row stays unbound until it runs and
|
|
68
|
-
# so the uniqueness reaper asks here before calling
|
|
67
|
+
# :until_executed or :until_executing row stays unbound until it runs and
|
|
68
|
+
# it sits in no queue, so the uniqueness reaper asks here before calling
|
|
69
|
+
# the row an orphan. A
|
|
69
70
|
# row parked before the double-encoding fix is read through its string,
|
|
70
71
|
# since the reaper can run before the promote sweep repairs it.
|
|
71
72
|
def self.parked_uniqueness_keys(lock_keys)
|
|
@@ -4,6 +4,12 @@ module Pgbus
|
|
|
4
4
|
class QueueState < BusRecord
|
|
5
5
|
self.table_name = "pgbus_queue_states"
|
|
6
6
|
|
|
7
|
+
# One row per queue whatever the spelling (issue #633): the dashboard
|
|
8
|
+
# names a queue from its pgmq.meta row, which PGMQ's lower-case table
|
|
9
|
+
# names make lower case, while a worker names it as configured
|
|
10
|
+
# ("Critical"). Applies to writes and to find_by/where.
|
|
11
|
+
normalizes :queue_name, with: ->(name) { name.to_s.downcase }
|
|
12
|
+
|
|
7
13
|
scope :paused, -> { where(paused: true) }
|
|
8
14
|
|
|
9
15
|
def self.paused?(queue_name)
|
|
@@ -191,6 +191,17 @@ module Pgbus
|
|
|
191
191
|
)
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
+
# True when the key's row is bound to this message (queue + msg_id): its
|
|
195
|
+
# own attempt holds it, as a crashed attempt leaves it. The executor asks
|
|
196
|
+
# this when it dead-letters a :while_executing message that never failed
|
|
197
|
+
# (issue #629): a crash loop, not a uniqueness conflict.
|
|
198
|
+
def self.bound_to?(lock_key, queue_name:, msg_id:)
|
|
199
|
+
!connection.select_value(
|
|
200
|
+
"SELECT 1 FROM #{table_name} WHERE lock_key = $1 AND queue_name = $2 AND msg_id = $3 LIMIT 1",
|
|
201
|
+
"UniquenessKey Bound To", [lock_key, queue_name.to_s, msg_id.to_i]
|
|
202
|
+
).nil?
|
|
203
|
+
end
|
|
204
|
+
|
|
194
205
|
# Check if a key is currently locked.
|
|
195
206
|
def self.locked?(lock_key)
|
|
196
207
|
result = connection.select_value(
|
data/config/locales/da.yml
CHANGED
|
@@ -224,6 +224,7 @@ da:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Genforsøgt fra DLQ én gang før.
|
|
226
226
|
other: Genforsøgt fra DLQ %{count} gange før.
|
|
227
|
+
uniqueness_conflict_html: Sprunget over ved alle %{attempts} læsninger (maks. %{max}), mens en anden kopi af jobbet holdt dets unikhedsnøgle — intet forsøg kørte
|
|
227
228
|
show:
|
|
228
229
|
back: Tilbage til DLQ
|
|
229
230
|
context: Kontekst
|
data/config/locales/de.yml
CHANGED
|
@@ -224,6 +224,7 @@ de:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Schon einmal aus der DLQ wiederholt.
|
|
226
226
|
other: Schon %{count}-mal aus der DLQ wiederholt.
|
|
227
|
+
uniqueness_conflict_html: Bei allen %{attempts} Lesevorgängen übersprungen (max. %{max}), weil eine andere Kopie des Jobs seinen Eindeutigkeitsschlüssel hielt — kein Versuch lief
|
|
227
228
|
show:
|
|
228
229
|
back: Zurück zur DLQ
|
|
229
230
|
context: Kontext
|
data/config/locales/en.yml
CHANGED
|
@@ -224,6 +224,7 @@ en:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Retried from the DLQ once before.
|
|
226
226
|
other: Retried from the DLQ %{count} times before.
|
|
227
|
+
uniqueness_conflict_html: Skipped on all %{attempts} reads (max %{max}) while another copy of the job held its uniqueness key — no attempt ran
|
|
227
228
|
show:
|
|
228
229
|
back: Back to DLQ
|
|
229
230
|
context: Context
|
data/config/locales/es.yml
CHANGED
|
@@ -224,6 +224,7 @@ es:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Reintentado desde la DLQ una vez antes.
|
|
226
226
|
other: Reintentado desde la DLQ %{count} veces antes.
|
|
227
|
+
uniqueness_conflict_html: Omitido en las %{attempts} lecturas (máx. %{max}) mientras otra copia del job tenía su clave de unicidad — no se ejecutó ningún intento
|
|
227
228
|
show:
|
|
228
229
|
back: Volver a DLQ
|
|
229
230
|
context: Contexto
|