pgbus 0.5.1 → 0.6.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/README.md +238 -0
- data/Rakefile +8 -1
- data/app/controllers/pgbus/insights_controller.rb +6 -0
- data/app/helpers/pgbus/streams_helper.rb +115 -0
- data/app/javascript/pgbus/stream_source_element.js +212 -0
- data/app/models/pgbus/stream_stat.rb +118 -0
- data/app/views/pgbus/insights/show.html.erb +59 -0
- data/config/locales/en.yml +16 -0
- data/config/routes.rb +11 -0
- data/lib/generators/pgbus/add_presence_generator.rb +55 -0
- data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
- data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
- data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
- data/lib/pgbus/client/read_after.rb +100 -0
- data/lib/pgbus/client.rb +6 -0
- data/lib/pgbus/configuration.rb +65 -0
- data/lib/pgbus/engine.rb +31 -0
- data/lib/pgbus/process/dispatcher.rb +62 -4
- data/lib/pgbus/streams/cursor.rb +71 -0
- data/lib/pgbus/streams/envelope.rb +58 -0
- data/lib/pgbus/streams/filters.rb +98 -0
- data/lib/pgbus/streams/presence.rb +216 -0
- data/lib/pgbus/streams/signed_name.rb +69 -0
- data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
- data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
- data/lib/pgbus/streams.rb +151 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +29 -0
- data/lib/pgbus/web/stream_app.rb +179 -0
- data/lib/pgbus/web/streamer/connection.rb +122 -0
- data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
- data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
- data/lib/pgbus/web/streamer/instance.rb +176 -0
- data/lib/pgbus/web/streamer/io_writer.rb +73 -0
- data/lib/pgbus/web/streamer/listener.rb +228 -0
- data/lib/pgbus/web/streamer/registry.rb +103 -0
- data/lib/pgbus/web/streamer.rb +53 -0
- data/lib/pgbus.rb +28 -0
- data/lib/puma/plugin/pgbus_streams.rb +54 -0
- data/lib/tasks/pgbus_streams.rake +52 -0
- metadata +29 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9428608c5f9419017f78a37cfe59c70ec03ff276654e516444ec25b8d9ee4fed
|
|
4
|
+
data.tar.gz: 8ad2651f7b6f25203442c819649c81d90c55c057a2d77219c10f6f74d2db18e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d63781a6eac94d51d295ce57f47729f7e6d6fffcc7c9a1883db110dd5833f35bfdf14559e832431e0c1759dfb8fd060c6f09701da47f65355b966c35f5aaa2c0
|
|
7
|
+
data.tar.gz: 4acb342915df5bb5761ab534edcfbd051592735640d3fa9551b2c16b0e785ec8ffe82e45fec1e7dbc81c92d2e5408f23457c80c088dc7c2c49a1aa03584a80c8
|
data/README.md
CHANGED
|
@@ -31,6 +31,7 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
|
|
|
31
31
|
- [Batches](#batches)
|
|
32
32
|
- [Transactional outbox](#transactional-outbox)
|
|
33
33
|
- [Archive compaction](#archive-compaction)
|
|
34
|
+
- [Real-time broadcasts](#real-time-broadcasts-turbo-streams-replacement)
|
|
34
35
|
- [Operations](#operations)
|
|
35
36
|
- [CLI](#cli)
|
|
36
37
|
- [Dashboard](#dashboard)
|
|
@@ -45,6 +46,7 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
|
|
|
45
46
|
## Features
|
|
46
47
|
|
|
47
48
|
- **ActiveJob adapter** -- drop-in replacement, zero config migration from other backends
|
|
49
|
+
- **Turbo Streams replacement** -- `pgbus_stream_from` drops into turbo-rails apps with no ActionCable, no Redis, no lost messages on reconnect. Includes transactional broadcasts (deferred until commit), backlog replay on connect, server-side audience filtering, and presence tracking. Fixes rails/rails#52420, hotwired/turbo#1261, and hotwired/turbo-rails#674.
|
|
48
50
|
- **Event bus** -- publish/subscribe with AMQP-style topic routing (`orders.#`, `payments.*`)
|
|
49
51
|
- **Dead letter queues** -- automatic DLQ routing after configurable retries
|
|
50
52
|
- **Worker recycling** -- memory, job count, and lifetime limits prevent runaway processes
|
|
@@ -625,6 +627,234 @@ as configuration. The dispatcher runs archive compaction as part of its
|
|
|
625
627
|
maintenance loop, deleting archived messages older than `archive_retention`
|
|
626
628
|
in batches to avoid long-running transactions.
|
|
627
629
|
|
|
630
|
+
## Real-time broadcasts (turbo-streams replacement)
|
|
631
|
+
|
|
632
|
+
Pgbus ships a drop-in replacement for turbo-rails' `turbo_stream_from` helper that fixes several well-known ActionCable correctness bugs by using PGMQ message IDs as a replay cursor. Same API as turbo-rails. No Redis. No ActionCable. No lost messages on reconnect.
|
|
633
|
+
|
|
634
|
+
**Bugs fixed:**
|
|
635
|
+
|
|
636
|
+
- [**rails/rails#52420**](https://github.com/rails/rails/issues/52420) -- "page born stale": a broadcast that fires between controller render and WebSocket subscribe is silently lost with ActionCable. Pgbus captures a PGMQ `msg_id` watermark at render time and replays any messages published in the gap via the SSE `Last-Event-ID` mechanism.
|
|
637
|
+
- [**hotwired/turbo#1261**](https://github.com/hotwired/turbo/issues/1261) -- missed messages on reconnect. Pgbus persists the cursor on the client (EventSource's built-in `Last-Event-ID`) and replays from the PGMQ archive on every reconnect.
|
|
638
|
+
- [**hotwired/turbo-rails#674**](https://github.com/hotwired/turbo-rails/issues/674) -- no way to detect disconnect. Pgbus dispatches `pgbus:open`, `pgbus:gap-detected`, and `pgbus:close` DOM events on the stream element.
|
|
639
|
+
|
|
640
|
+
### Usage
|
|
641
|
+
|
|
642
|
+
Swap `turbo_stream_from` for `pgbus_stream_from` in your view:
|
|
643
|
+
|
|
644
|
+
```erb
|
|
645
|
+
<%# Before %>
|
|
646
|
+
<%= turbo_stream_from @order %>
|
|
647
|
+
|
|
648
|
+
<%# After %>
|
|
649
|
+
<%= pgbus_stream_from @order %>
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
Everything else stays the same. The model concern keeps working unchanged:
|
|
653
|
+
|
|
654
|
+
```ruby
|
|
655
|
+
class Order < ApplicationRecord
|
|
656
|
+
broadcasts_to ->(order) { [order.account, :orders] }
|
|
657
|
+
end
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`broadcasts_to`, `broadcast_replace_to`, `broadcasts_refreshes`, `broadcast_append_later_to`, and every other `Turbo::Broadcastable` helper funnels through a single `Turbo::StreamsChannel.broadcast_stream_to` method that pgbus monkey-patches at engine boot. The signed-stream-name verification reuses `Turbo.signed_stream_verifier_key` so existing signed tokens Just Work.
|
|
661
|
+
|
|
662
|
+
Add the Puma plugin to `config/puma.rb` so SSE connections drain cleanly on deploy:
|
|
663
|
+
|
|
664
|
+
```ruby
|
|
665
|
+
# config/puma.rb
|
|
666
|
+
plugin :pgbus_streams
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
Without the plugin, Puma closes hijacked SSE sockets abruptly during graceful restart, which looks to browsers like a network error and triggers an immediate reconnect. With the plugin, the streamer writes a `pgbus:shutdown` sentinel before the socket closes; browsers reconnect to the new worker and replay missed messages via `Last-Event-ID`.
|
|
670
|
+
|
|
671
|
+
### Requirements
|
|
672
|
+
|
|
673
|
+
- **Puma 6.1+ or Falcon.** Streams use `rack.hijack`. Puma 6.1+ supports it via partial hijack (thread-releasing, see [puma/puma#1009](https://github.com/puma/puma/issues/1009)). Falcon supports it via [protocol-rack](https://github.com/socketry/protocol-rack)'s adapter layer — same `env["rack.hijack?"]` + `env["rack.hijack"]` shape as Puma, so `Pgbus::Web::StreamApp` needs no server-specific code paths. Unicorn, Pitchfork, and Passenger return HTTP 501 from the streams endpoint.
|
|
674
|
+
- **PostgreSQL LISTEN/NOTIFY.** `config.listen_notify = true` (the default). Stream queues override PGMQ's 250ms NOTIFY throttle to 0 so every broadcast fires individually.
|
|
675
|
+
- **HTTP/2 or HTTP/3 in production.** SSE has a 6-connection-per-origin limit on HTTP/1.1; HTTP/2 lifts it. Falcon supports HTTP/2 natively without a reverse proxy.
|
|
676
|
+
|
|
677
|
+
### Configuration
|
|
678
|
+
|
|
679
|
+
```ruby
|
|
680
|
+
Pgbus.configure do |c|
|
|
681
|
+
c.streams_enabled = true # default
|
|
682
|
+
c.streams_queue_prefix = "pgbus_stream"
|
|
683
|
+
c.streams_default_retention = 5 * 60 # 5 minutes
|
|
684
|
+
c.streams_retention = { # per-stream overrides
|
|
685
|
+
/^chat_/ => 7 * 24 * 3600, # 7 days for chat history
|
|
686
|
+
"presence_room" => 30 # 30 seconds for presence
|
|
687
|
+
}
|
|
688
|
+
c.streams_heartbeat_interval = 15 # seconds
|
|
689
|
+
c.streams_max_connections = 2_000 # per web-server process (Puma worker or Falcon process)
|
|
690
|
+
c.streams_idle_timeout = 3_600 # close idle connections after 1h
|
|
691
|
+
c.streams_listen_health_check_ms = 250 # PG LISTEN keepalive + ensure_listening ack budget
|
|
692
|
+
c.streams_write_deadline_ms = 5_000 # write_nonblock deadline
|
|
693
|
+
end
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
### How it works
|
|
697
|
+
|
|
698
|
+
Stream broadcasts are stored in PGMQ queues prefixed `pgbus_stream_*`. Each broadcast is assigned a monotonic `msg_id` by PGMQ. The `pgbus_stream_from` helper captures the current `MAX(msg_id)` at render time and embeds it in the HTML as `since-id`. When the SSE client connects, it sends that cursor as `?since=` on the first request and as `Last-Event-ID` on reconnects. The streamer replays from `pgmq.q_*` (live) UNION `pgmq.a_*` (archive) for any `msg_id > cursor`, then switches to LISTEN/NOTIFY for the live path. There is no message identity gap between the render and the subscribe — the cursor model guarantees every broadcast is delivered exactly once, in order, even across reconnects.
|
|
699
|
+
|
|
700
|
+
One Puma worker (or Falcon reactor) hosts one `Pgbus::Web::Streamer::Instance` singleton with three threads (Listener / Dispatcher / Heartbeat) and one dedicated PG connection for LISTEN. Hijacked SSE sockets are held outside the web server's thread pool on Puma (confirmed by an integration test that fires 20 concurrent hijacked connections and observes them complete in parallel on an 8-thread Puma server, [puma/puma#1009](https://github.com/puma/puma/issues/1009)) and inside a fiber on Falcon (one fiber per hijacked connection, scheduler-backed non-blocking IO).
|
|
701
|
+
|
|
702
|
+
> **Don't use `ActionController::Live` for pgbus streams.** It's the conventional Rails answer for SSE and it's the wrong one. `Live` blocks inside `@app.call(env)` for the lifetime of the connection, which ties up a Puma thread *per subscriber* — the exact problem the `rack.hijack`-based architecture above exists to avoid. Pgbus's streams endpoint is a mounted Rack app (not a Rails controller) so the temptation isn't even available, and a `rake pgbus:streams:lint_no_live` task fails CI if any pgbus controller `include`s it. If you're tempted to wire SSE through a Rails controller, use `pgbus_stream_from` in your view instead — the helper handles the cursor, replay, reconnect, and Puma-thread-release concerns for you.
|
|
703
|
+
|
|
704
|
+
Per-stream retention is handled by the main pgbus dispatcher process on the same interval as the dispatcher's `ARCHIVE_COMPACTION_INTERVAL` constant. Streams default to a 5-minute retention because SSE clients reconnect within seconds; chat-style applications override the retention to days via `streams_retention`.
|
|
705
|
+
|
|
706
|
+
### Transactional broadcasts
|
|
707
|
+
|
|
708
|
+
**This is the feature no other Rails real-time stack can offer.** A broadcast issued inside an open ActiveRecord transaction is deferred until the transaction commits. If it rolls back, the broadcast silently drops — clients never see the change that the database never persisted.
|
|
709
|
+
|
|
710
|
+
```ruby
|
|
711
|
+
ActiveRecord::Base.transaction do
|
|
712
|
+
@order.update!(status: "shipped")
|
|
713
|
+
@order.broadcast_replace_to :account # ← deferred until commit
|
|
714
|
+
RelatedService.update_counters!(@order) # ← might raise, rolling back the update
|
|
715
|
+
end
|
|
716
|
+
# If RelatedService raised, the database state is unchanged AND no SSE client
|
|
717
|
+
# ever saw a "shipped" broadcast. The broadcast and the data mutation are
|
|
718
|
+
# atomic with respect to each other.
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
ActionCable can't do this because its broadcast path goes through Redis pub/sub, which has no concept of your application's transaction boundary. Pgbus detects the open AR transaction via `ActiveRecord::Base.connection.current_transaction.after_commit`, which is a first-class Rails API — no outbox table, no background worker, no extra storage.
|
|
722
|
+
|
|
723
|
+
Outside an open transaction, broadcasts are synchronous and return the assigned `msg_id` as before. Inside a transaction, they return `nil` (the id isn't known until commit time).
|
|
724
|
+
|
|
725
|
+
### Replaying history on connect (`replay:`)
|
|
726
|
+
|
|
727
|
+
By default `pgbus_stream_from @room` captures `MAX(msg_id)` at render time and replays only broadcasts published after that — the page-born-stale fix. For chat-history-style applications where the page should show backlog on load, pass `replay:`:
|
|
728
|
+
|
|
729
|
+
```erb
|
|
730
|
+
<%# Show the last 50 messages on load, then stream live %>
|
|
731
|
+
<%= pgbus_stream_from @room, replay: 50 %>
|
|
732
|
+
|
|
733
|
+
<%# Show everything in PGMQ retention on load %>
|
|
734
|
+
<%= pgbus_stream_from @room, replay: :all %>
|
|
735
|
+
|
|
736
|
+
<%# Default behavior (post-render only) — same as omitting the option %>
|
|
737
|
+
<%= pgbus_stream_from @room, replay: :watermark %>
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
The replay cap is applied server-side: the helper computes `since_id = max(0, current_msg_id - N)` for integer `N` and writes that into the HTML attribute. The client just reads the attribute and sends it as `?since=` on connect. Nothing else changes about the transport.
|
|
741
|
+
|
|
742
|
+
How much history is actually available depends on the stream's retention setting (`streams_retention` or `streams_default_retention`, both in seconds). A chat stream configured with `streams_retention = { /^chat_/ => 7.days }` will replay up to seven days of history with `replay: :all`; a notification stream with the 5-minute default will only go back five minutes.
|
|
743
|
+
|
|
744
|
+
### Server-side audience filtering
|
|
745
|
+
|
|
746
|
+
Some broadcasts shouldn't reach every subscriber on a stream. Pgbus supports per-connection filtering via a registry of named predicates evaluated against each connection's authorize-hook context:
|
|
747
|
+
|
|
748
|
+
```ruby
|
|
749
|
+
# config/initializers/pgbus_streams.rb
|
|
750
|
+
Pgbus::Streams.filters.register(:admin_only) { |user| user&.admin? }
|
|
751
|
+
Pgbus::Streams.filters.register(:workspace_member) do |user, stream_name|
|
|
752
|
+
user&.workspace_ids&.include?(stream_name.split(":").last.to_i)
|
|
753
|
+
end
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
The authorize hook on `Pgbus::Web::StreamApp` doubles as a context provider — return any non-boolean value (typically a `User` model) and pgbus will pass it to the filter predicate when evaluating broadcasts:
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
759
|
+
Pgbus::Web::StreamApp.new(authorize: ->(env, _stream_name) {
|
|
760
|
+
user = User.find_by(id: env["rack.session"][:user_id])
|
|
761
|
+
return false unless user
|
|
762
|
+
user # ← context attached to the connection
|
|
763
|
+
})
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
Then label broadcasts with the filter you want to apply:
|
|
767
|
+
|
|
768
|
+
```ruby
|
|
769
|
+
@order.broadcast_replace_to :account # delivered to everyone
|
|
770
|
+
Pgbus.stream("ops").broadcast(html, visible_to: :admin_only) # admins only
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
Failure semantics:
|
|
774
|
+
|
|
775
|
+
- **Unknown filter label** → fail-CLOSED with a warning log. Audience filtering is a data-isolation feature; failing open on a typo would turn a restricted broadcast into a public one. The warning log is loud enough that typos still get noticed in dev ("why are no subscribers receiving my broadcast?" → check the log).
|
|
776
|
+
- **Filter predicate raises** → fail-CLOSED. A buggy predicate that crashes is treated as "deny" so private data doesn't leak on an exception path.
|
|
777
|
+
- **No `visible_to` on the broadcast** → no filter applied; everyone sees it.
|
|
778
|
+
|
|
779
|
+
The filter registry is process-local. Each Puma worker (or Falcon reactor) has its own copy populated at boot. Filter predicates run **on the subscriber side** — the predicate itself can't be serialized through PGMQ, so the broadcast carries only the label name.
|
|
780
|
+
|
|
781
|
+
### Presence
|
|
782
|
+
|
|
783
|
+
Pgbus tracks who is currently subscribed to a stream via a `pgbus_presence_members` table. This is the standard "X people are in this room" feature that chat apps and collaboration tools need:
|
|
784
|
+
|
|
785
|
+
```ruby
|
|
786
|
+
rails generate pgbus:add_presence
|
|
787
|
+
rails db:migrate
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
```ruby
|
|
791
|
+
class RoomsController < ApplicationController
|
|
792
|
+
def show
|
|
793
|
+
@room = Room.find(params[:id])
|
|
794
|
+
Pgbus.stream(@room).presence.join(
|
|
795
|
+
member_id: current_user.id.to_s,
|
|
796
|
+
metadata: { name: current_user.name, avatar: current_user.avatar_url }
|
|
797
|
+
) do |member|
|
|
798
|
+
render_to_string(partial: "presence/joined", locals: { member: member })
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def destroy
|
|
803
|
+
Pgbus.stream(@room).presence.leave(member_id: current_user.id.to_s) do |member|
|
|
804
|
+
"<turbo-stream action=\"remove\" target=\"presence-#{member['id']}\"></turbo-stream>"
|
|
805
|
+
end
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
The block passed to `join`/`leave` is rendered into HTML and broadcast through the regular pgbus stream pipeline — so it shows up in every connected client's DOM in real time, alongside the normal `broadcasts_to` output. Reading the current member list:
|
|
811
|
+
|
|
812
|
+
```ruby
|
|
813
|
+
Pgbus.stream(@room).presence.members
|
|
814
|
+
# => [{ "id" => "7", "metadata" => {...}, "joined_at" => "...", "last_seen_at" => "..." }]
|
|
815
|
+
|
|
816
|
+
Pgbus.stream(@room).presence.count
|
|
817
|
+
# => 5
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
**Heartbeat and expiry.** Members that don't ping `presence.touch(member_id: ...)` periodically can be expired by a sweeper:
|
|
821
|
+
|
|
822
|
+
```ruby
|
|
823
|
+
# Run from a cron, ActiveJob, or after each subscriber heartbeat
|
|
824
|
+
Pgbus.stream(@room).presence.sweep!(older_than: 60.seconds.ago)
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
The sweep uses `DELETE ... RETURNING` so multiple workers running it concurrently won't double-emit leave events.
|
|
828
|
+
|
|
829
|
+
**Deliberately left to the application**:
|
|
830
|
+
|
|
831
|
+
- Join/leave is explicit, not connection-driven. The controller decides who is "present" — a connected SSE client is not always a present user (think tab-in-background, multi-tab dedup).
|
|
832
|
+
- The stale-member sweep is manual. Run it from a cron, an ActiveJob, or your existing heartbeat — pgbus does not assume one over the others.
|
|
833
|
+
- The DOM markup for join/leave is whatever your `join`/`leave` block returns. Pgbus does not impose a fixed presence schema on `<pgbus-stream-source>`.
|
|
834
|
+
|
|
835
|
+
### Stream stats (opt-in)
|
|
836
|
+
|
|
837
|
+
Pgbus can record one row in `pgbus_stream_stats` per broadcast, connect, and disconnect so the `/pgbus/insights` dashboard shows stream throughput alongside job throughput. This is **disabled by default** because stream event volume can dwarf job volume in chat-style apps — enable it deliberately when you want the observability.
|
|
838
|
+
|
|
839
|
+
```ruby
|
|
840
|
+
# config/initializers/pgbus.rb
|
|
841
|
+
Pgbus.configure do |c|
|
|
842
|
+
c.streams_stats_enabled = true
|
|
843
|
+
end
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
Then run the migration generator once:
|
|
847
|
+
|
|
848
|
+
```bash
|
|
849
|
+
rails generate pgbus:add_stream_stats # Add the migration
|
|
850
|
+
rails generate pgbus:add_stream_stats --database=pgbus # For separate database
|
|
851
|
+
rails db:migrate
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
The Insights tab gains a "Real-time Streams" section with counts of broadcasts / connects / disconnects, an "active" estimate (connects − disconnects in the selected window), average fanout per broadcast, and a "Top Streams by Broadcast Volume" table. The existing `stats_retention` config covers cleanup, so there is no separate retention knob.
|
|
855
|
+
|
|
856
|
+
Overhead on a real Puma + PGMQ setup (`bundle exec rake bench:streams`): the most visible cost is an INSERT per connect/disconnect pair, which shows up under thundering-herd connect scenarios (K=50 concurrent connects: ~+20% per-connect latency). Steady-state broadcast and fanout numbers stay in the run-to-run noise band. Enable it if Insights is useful; leave it off if the write traffic worries you.
|
|
857
|
+
|
|
628
858
|
## Operations
|
|
629
859
|
|
|
630
860
|
Day-to-day running of Pgbus: starting and stopping processes, observing what is happening on the dashboard, the database tables Pgbus relies on, and how to migrate from an existing job backend.
|
|
@@ -820,6 +1050,14 @@ bunx --bun playwright install chromium
|
|
|
820
1050
|
bundle exec rspec spec/system/
|
|
821
1051
|
```
|
|
822
1052
|
|
|
1053
|
+
End-to-end streams benchmarks (real Puma + real PGMQ + real SSE clients):
|
|
1054
|
+
|
|
1055
|
+
```bash
|
|
1056
|
+
PGBUS_DATABASE_URL=postgres://user@host/db bundle exec rake bench:streams
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
The harness measures single-broadcast roundtrip latency, burst throughput, fanout to many clients, and concurrent connect under thundering herd. See `benchmarks/streams_bench.rb`.
|
|
1060
|
+
|
|
823
1061
|
## License
|
|
824
1062
|
|
|
825
1063
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
CHANGED
|
@@ -36,6 +36,11 @@ namespace :bench do
|
|
|
36
36
|
ruby "benchmarks/integration_bench.rb"
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
desc "Run streams benchmarks (requires PGBUS_DATABASE_URL; boots real Puma + SSE)"
|
|
40
|
+
task :streams do
|
|
41
|
+
ruby "benchmarks/streams_bench.rb"
|
|
42
|
+
end
|
|
43
|
+
|
|
39
44
|
desc "Run all benchmarks (unit-level, no DB required)"
|
|
40
45
|
task all: %i[serialization client executor]
|
|
41
46
|
end
|
|
@@ -191,4 +196,6 @@ namespace :dummy do
|
|
|
191
196
|
end
|
|
192
197
|
end
|
|
193
198
|
|
|
194
|
-
|
|
199
|
+
load File.expand_path("lib/tasks/pgbus_streams.rake", __dir__)
|
|
200
|
+
|
|
201
|
+
task default: %i[spec rubocop pgbus:streams:lint_no_live]
|
|
@@ -8,6 +8,12 @@ module Pgbus
|
|
|
8
8
|
@slowest = data_source.slowest_job_classes(minutes: @minutes)
|
|
9
9
|
@latency_by_queue = data_source.latency_by_queue(minutes: @minutes)
|
|
10
10
|
@latency_available = Pgbus::JobStat.latency_columns?
|
|
11
|
+
|
|
12
|
+
@stream_stats_available = data_source.stream_stats_available?
|
|
13
|
+
return unless @stream_stats_available
|
|
14
|
+
|
|
15
|
+
@stream_summary = data_source.stream_stats_summary(minutes: @minutes)
|
|
16
|
+
@top_streams = data_source.top_streams(minutes: @minutes)
|
|
11
17
|
end
|
|
12
18
|
end
|
|
13
19
|
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
# View helper for subscribing a page to one or more pgbus streams. This
|
|
7
|
+
# is the drop-in replacement for `turbo_stream_from` — same API, same
|
|
8
|
+
# streamable resolution (GlobalID objects, symbols, arrays), same
|
|
9
|
+
# signed-name verification path — but the rendered element speaks to
|
|
10
|
+
# `Pgbus::Web::StreamApp` via SSE instead of ActionCable.
|
|
11
|
+
#
|
|
12
|
+
# The critical difference from `turbo_stream_from` is the `since-id`
|
|
13
|
+
# attribute: it carries the current PGMQ `msg_id` watermark at render
|
|
14
|
+
# time so the streamer can replay anything published in the gap between
|
|
15
|
+
# the controller render and the client connecting. This is the fix for
|
|
16
|
+
# rails/rails#52420.
|
|
17
|
+
module StreamsHelper
|
|
18
|
+
# Renders a <pgbus-stream-source> custom element. The element's JS
|
|
19
|
+
# (shipped separately under app/javascript/pgbus/stream_source_element.js)
|
|
20
|
+
# opens an SSE connection to /pgbus/streams/<signed-name>?since=<cursor>,
|
|
21
|
+
# listens for messages, and forwards each turbo-stream HTML payload
|
|
22
|
+
# into Turbo via `connectStreamSource`.
|
|
23
|
+
#
|
|
24
|
+
# The `replay:` option controls which messages get delivered on the
|
|
25
|
+
# initial connect:
|
|
26
|
+
# - :watermark (default) — only broadcasts published after this
|
|
27
|
+
# render's moment (the page-born-stale fix). since_id = current_msg_id.
|
|
28
|
+
# - :all — deliver every message still in PGMQ retention. since_id = 0.
|
|
29
|
+
# Useful for chat rooms where the page should show backlog on load.
|
|
30
|
+
# - N (Integer) — deliver the last N messages. since_id = max(0, current_msg_id - N).
|
|
31
|
+
# Useful when the backlog is large and you want a capped history.
|
|
32
|
+
def pgbus_stream_from(*streamables, replay: :watermark, **html_attributes)
|
|
33
|
+
stream_name = Pgbus::Streams::Stream.name_from(streamables.length == 1 ? streamables.first : streamables)
|
|
34
|
+
signed_name = Pgbus::Streams::SignedName.sign(stream_name)
|
|
35
|
+
|
|
36
|
+
since_id = compute_since_id(stream_name, replay)
|
|
37
|
+
|
|
38
|
+
attributes = {
|
|
39
|
+
"src" => pgbus_stream_src(signed_name),
|
|
40
|
+
"signed-stream-name" => signed_name,
|
|
41
|
+
"since-id" => since_id.to_s,
|
|
42
|
+
# Compatibility shim: turbo-rails' cable_stream_source_element reads
|
|
43
|
+
# `channel` to decide which ActionCable channel to subscribe to.
|
|
44
|
+
# We emit the same attribute so a page that mistakenly uses the
|
|
45
|
+
# turbo-rails element still renders (with ActionCable semantics).
|
|
46
|
+
"channel" => "Turbo::StreamsChannel"
|
|
47
|
+
}.merge(html_attributes.transform_keys(&:to_s))
|
|
48
|
+
|
|
49
|
+
render_tag("pgbus-stream-source", attributes)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def compute_since_id(stream_name, replay)
|
|
55
|
+
case replay
|
|
56
|
+
when :watermark
|
|
57
|
+
# The page-born-stale fix: capture MAX(msg_id) at render time.
|
|
58
|
+
fetch_watermark(stream_name)
|
|
59
|
+
when :all
|
|
60
|
+
# Replay everything still in retention. The streamer's read_after
|
|
61
|
+
# handles both live and archive tables, so this pulls the full
|
|
62
|
+
# backlog bounded by `streams_retention`.
|
|
63
|
+
0
|
|
64
|
+
when Integer
|
|
65
|
+
raise ArgumentError, "replay: must be non-negative (got #{replay})" if replay.negative?
|
|
66
|
+
|
|
67
|
+
# Clamp to 0 so a fresh queue with replay: 1000 doesn't return
|
|
68
|
+
# a negative cursor. fetch_watermark goes through the per-request
|
|
69
|
+
# thread-local cache, so mixing :watermark and :N in the same
|
|
70
|
+
# render only queries once.
|
|
71
|
+
watermark = fetch_watermark(stream_name)
|
|
72
|
+
[watermark - replay, 0].max
|
|
73
|
+
else
|
|
74
|
+
raise ArgumentError, "replay: must be :watermark, :all, or a non-negative Integer (got #{replay.inspect})"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Build the SSE endpoint URL by asking the engine where its
|
|
79
|
+
# `:streams` mount point lives, then appending the signed name.
|
|
80
|
+
# The base comes from Pgbus::Engine.routes.url_helpers.streams_path
|
|
81
|
+
# so the URL follows whatever mount point the host app chose for
|
|
82
|
+
# the engine ("/pgbus", "/admin/dashboard", etc.). A
|
|
83
|
+
# `NoMethodError` fallback covers the test-only context where
|
|
84
|
+
# the helper is included in a plain class outside a Rails
|
|
85
|
+
# request and the engine's url_helpers aren't wired in.
|
|
86
|
+
def pgbus_stream_src(signed_name)
|
|
87
|
+
base = Pgbus::Engine.routes.url_helpers.streams_path
|
|
88
|
+
"#{base}/#{signed_name}"
|
|
89
|
+
rescue NameError
|
|
90
|
+
# NameError covers both uninitialized-constant (Pgbus::Engine
|
|
91
|
+
# not loaded, e.g. plain-Ruby unit specs) and NoMethodError
|
|
92
|
+
# (a NameError subclass) when the routes helper chain isn't
|
|
93
|
+
# wired in.
|
|
94
|
+
"/pgbus/streams/#{signed_name}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fetch_watermark(stream_name)
|
|
98
|
+
# Avoid hitting Postgres multiple times within a single render when
|
|
99
|
+
# the page uses several pgbus_stream_from helpers. We cache per
|
|
100
|
+
# thread-local for the duration of the current request — Rails'
|
|
101
|
+
# RequestStore gem is the idiomatic fit but we don't want to add
|
|
102
|
+
# a runtime dep, so a plain Thread.current hash is used. The engine
|
|
103
|
+
# initializer clears this hash between requests via a Rack middleware
|
|
104
|
+
# (Phase 4.5).
|
|
105
|
+
cache = Thread.current[:pgbus_streams_watermark_cache] ||= {}
|
|
106
|
+
cache[stream_name] ||= Pgbus.stream(stream_name).current_msg_id
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def render_tag(name, attributes)
|
|
110
|
+
attr_string = attributes.map { |k, v| %(#{k}="#{CGI.escape_html(v.to_s)}") }.join(" ")
|
|
111
|
+
html = "<#{name} #{attr_string}></#{name}>"
|
|
112
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// <pgbus-stream-source> — the browser-side counterpart to
|
|
2
|
+
// Pgbus::StreamsHelper#pgbus_stream_from. Drop-in replacement for
|
|
3
|
+
// turbo-rails' <turbo-cable-stream-source> that speaks SSE + pgbus
|
|
4
|
+
// instead of WebSocket + ActionCable.
|
|
5
|
+
//
|
|
6
|
+
// Attributes (set by the view helper):
|
|
7
|
+
// src — absolute path to the SSE endpoint, including
|
|
8
|
+
// the URL-safe signed stream name
|
|
9
|
+
// since-id — PGMQ msg_id watermark at render time; the
|
|
10
|
+
// client sends this as ?since= on the FIRST
|
|
11
|
+
// connect so the server can replay any
|
|
12
|
+
// broadcasts from the render-to-connect gap
|
|
13
|
+
// (rails/rails#52420 fix)
|
|
14
|
+
// signed-stream-name — present for parity with turbo-rails; unused
|
|
15
|
+
// by this element because the signed name is
|
|
16
|
+
// already in the URL path
|
|
17
|
+
// channel — compatibility shim; ignored
|
|
18
|
+
//
|
|
19
|
+
// Events (dispatched on the element):
|
|
20
|
+
// pgbus:open { lastEventId }
|
|
21
|
+
// pgbus:replay-start { fromId, toId }
|
|
22
|
+
// pgbus:replay-end {}
|
|
23
|
+
// pgbus:gap-detected { lastSeenId, archiveOldestId }
|
|
24
|
+
// pgbus:close { code, reason }
|
|
25
|
+
//
|
|
26
|
+
// The element integrates with Turbo via connectStreamSource /
|
|
27
|
+
// disconnectStreamSource + dispatching MessageEvent("message") so Turbo
|
|
28
|
+
// Stream HTML is automatically consumed by the existing StreamObserver.
|
|
29
|
+
//
|
|
30
|
+
// Transport strategy:
|
|
31
|
+
// - FIRST connect: use fetch() + ReadableStream to include ?since=
|
|
32
|
+
// in the URL. Native EventSource cannot send Last-Event-ID on the
|
|
33
|
+
// initial request, so we need fetch to carry the watermark.
|
|
34
|
+
// - RECONNECT: switch to native EventSource, which sends Last-Event-ID
|
|
35
|
+
// automatically based on the last id: we observed. The native
|
|
36
|
+
// client is more battle-tested for reconnection backoff.
|
|
37
|
+
|
|
38
|
+
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"
|
|
39
|
+
|
|
40
|
+
class PgbusStreamSourceElement extends HTMLElement {
|
|
41
|
+
static get observedAttributes() {
|
|
42
|
+
return ["src", "since-id"]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
constructor() {
|
|
46
|
+
super()
|
|
47
|
+
this.abortController = null
|
|
48
|
+
this.eventSource = null
|
|
49
|
+
this.lastEventId = null
|
|
50
|
+
this.closed = false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
connectedCallback() {
|
|
54
|
+
connectStreamSource(this)
|
|
55
|
+
const sinceId = this.getAttribute("since-id")
|
|
56
|
+
this.lastEventId = sinceId && sinceId !== "" ? sinceId : null
|
|
57
|
+
this.openFetchStream()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
disconnectedCallback() {
|
|
61
|
+
this.closed = true
|
|
62
|
+
disconnectStreamSource(this)
|
|
63
|
+
this.teardown()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
teardown() {
|
|
67
|
+
if (this.abortController) {
|
|
68
|
+
this.abortController.abort()
|
|
69
|
+
this.abortController = null
|
|
70
|
+
}
|
|
71
|
+
if (this.eventSource) {
|
|
72
|
+
this.eventSource.close()
|
|
73
|
+
this.eventSource = null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// First connect: use fetch() so we can include ?since=<watermark> on
|
|
78
|
+
// the URL. Parses the SSE event stream by hand because EventSource
|
|
79
|
+
// doesn't expose custom query strings uniformly across browsers.
|
|
80
|
+
async openFetchStream() {
|
|
81
|
+
const url = this.buildUrl({ includeSince: true })
|
|
82
|
+
this.abortController = new AbortController()
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(url, {
|
|
86
|
+
headers: { Accept: "text/event-stream" },
|
|
87
|
+
credentials: "same-origin",
|
|
88
|
+
signal: this.abortController.signal
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
this.dispatchEvent(new CustomEvent("pgbus:close", {
|
|
93
|
+
detail: { code: response.status, reason: response.statusText }
|
|
94
|
+
}))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.setAttribute("connected", "")
|
|
99
|
+
this.dispatchEvent(new CustomEvent("pgbus:open", {
|
|
100
|
+
detail: { lastEventId: this.lastEventId }
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
const reader = response.body.getReader()
|
|
104
|
+
const decoder = new TextDecoder("utf-8")
|
|
105
|
+
let buffer = ""
|
|
106
|
+
|
|
107
|
+
while (!this.closed) {
|
|
108
|
+
const { value, done } = await reader.read()
|
|
109
|
+
if (done) break
|
|
110
|
+
|
|
111
|
+
buffer += decoder.decode(value, { stream: true })
|
|
112
|
+
const events = buffer.split("\n\n")
|
|
113
|
+
buffer = events.pop() // last chunk may be incomplete
|
|
114
|
+
|
|
115
|
+
for (const block of events) {
|
|
116
|
+
this.handleBlock(block)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err.name !== "AbortError") {
|
|
121
|
+
this.removeAttribute("connected")
|
|
122
|
+
// Fall through to reconnect via native EventSource, which
|
|
123
|
+
// will carry Last-Event-ID from this.lastEventId forward.
|
|
124
|
+
this.switchToEventSource()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Reconnect path: native EventSource with Last-Event-ID baked into
|
|
130
|
+
// the initial handshake by the browser.
|
|
131
|
+
switchToEventSource() {
|
|
132
|
+
if (this.closed) return
|
|
133
|
+
|
|
134
|
+
const url = this.buildUrl({ includeSince: false })
|
|
135
|
+
this.eventSource = new EventSource(url, { withCredentials: true })
|
|
136
|
+
|
|
137
|
+
this.eventSource.addEventListener("open", () => {
|
|
138
|
+
this.setAttribute("connected", "")
|
|
139
|
+
this.dispatchEvent(new CustomEvent("pgbus:open", {
|
|
140
|
+
detail: { lastEventId: this.lastEventId }
|
|
141
|
+
}))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
this.eventSource.addEventListener("error", () => {
|
|
145
|
+
this.removeAttribute("connected")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
this.eventSource.addEventListener("turbo-stream", (event) => {
|
|
149
|
+
this.lastEventId = event.lastEventId
|
|
150
|
+
this.dispatchEvent(new MessageEvent("message", { data: event.data }))
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
this.eventSource.addEventListener("pgbus:gap-detected", (event) => {
|
|
154
|
+
const detail = this.safeJsonParse(event.data)
|
|
155
|
+
this.dispatchEvent(new CustomEvent("pgbus:gap-detected", { detail }))
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
this.eventSource.addEventListener("pgbus:shutdown", () => {
|
|
159
|
+
this.dispatchEvent(new CustomEvent("pgbus:close", {
|
|
160
|
+
detail: { code: "shutdown", reason: "worker restart" }
|
|
161
|
+
}))
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)
|
|
166
|
+
handleBlock(block) {
|
|
167
|
+
if (!block || block.startsWith(":")) return // comment
|
|
168
|
+
|
|
169
|
+
let id = null
|
|
170
|
+
let event = "message"
|
|
171
|
+
let data = ""
|
|
172
|
+
|
|
173
|
+
for (const line of block.split("\n")) {
|
|
174
|
+
if (line.startsWith("id:")) id = line.slice(3).trim()
|
|
175
|
+
else if (line.startsWith("event:")) event = line.slice(6).trim()
|
|
176
|
+
else if (line.startsWith("data:")) data += line.slice(5).trim()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (id !== null) this.lastEventId = id
|
|
180
|
+
|
|
181
|
+
if (event === "turbo-stream") {
|
|
182
|
+
this.dispatchEvent(new MessageEvent("message", { data }))
|
|
183
|
+
} else if (event === "pgbus:gap-detected") {
|
|
184
|
+
this.dispatchEvent(new CustomEvent("pgbus:gap-detected", {
|
|
185
|
+
detail: this.safeJsonParse(data)
|
|
186
|
+
}))
|
|
187
|
+
} else if (event === "pgbus:shutdown") {
|
|
188
|
+
this.dispatchEvent(new CustomEvent("pgbus:close", {
|
|
189
|
+
detail: { code: "shutdown", reason: "worker restart" }
|
|
190
|
+
}))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
buildUrl({ includeSince }) {
|
|
195
|
+
const src = this.getAttribute("src")
|
|
196
|
+
if (!includeSince || !this.lastEventId) return src
|
|
197
|
+
|
|
198
|
+
const url = new URL(src, window.location.origin)
|
|
199
|
+
url.searchParams.set("since", this.lastEventId)
|
|
200
|
+
return url.toString()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
safeJsonParse(str) {
|
|
204
|
+
try { return JSON.parse(str) } catch { return null }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!customElements.get("pgbus-stream-source")) {
|
|
209
|
+
customElements.define("pgbus-stream-source", PgbusStreamSourceElement)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { PgbusStreamSourceElement }
|