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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +238 -0
  3. data/Rakefile +8 -1
  4. data/app/controllers/pgbus/insights_controller.rb +6 -0
  5. data/app/helpers/pgbus/streams_helper.rb +115 -0
  6. data/app/javascript/pgbus/stream_source_element.js +212 -0
  7. data/app/models/pgbus/stream_stat.rb +118 -0
  8. data/app/views/pgbus/insights/show.html.erb +59 -0
  9. data/config/locales/en.yml +16 -0
  10. data/config/routes.rb +11 -0
  11. data/lib/generators/pgbus/add_presence_generator.rb +55 -0
  12. data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
  13. data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
  14. data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
  15. data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
  16. data/lib/pgbus/client/read_after.rb +100 -0
  17. data/lib/pgbus/client.rb +6 -0
  18. data/lib/pgbus/configuration.rb +65 -0
  19. data/lib/pgbus/engine.rb +31 -0
  20. data/lib/pgbus/process/dispatcher.rb +62 -4
  21. data/lib/pgbus/streams/cursor.rb +71 -0
  22. data/lib/pgbus/streams/envelope.rb +58 -0
  23. data/lib/pgbus/streams/filters.rb +98 -0
  24. data/lib/pgbus/streams/presence.rb +216 -0
  25. data/lib/pgbus/streams/signed_name.rb +69 -0
  26. data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
  27. data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
  28. data/lib/pgbus/streams.rb +151 -0
  29. data/lib/pgbus/version.rb +1 -1
  30. data/lib/pgbus/web/data_source.rb +29 -0
  31. data/lib/pgbus/web/stream_app.rb +179 -0
  32. data/lib/pgbus/web/streamer/connection.rb +122 -0
  33. data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
  34. data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
  35. data/lib/pgbus/web/streamer/instance.rb +176 -0
  36. data/lib/pgbus/web/streamer/io_writer.rb +73 -0
  37. data/lib/pgbus/web/streamer/listener.rb +228 -0
  38. data/lib/pgbus/web/streamer/registry.rb +103 -0
  39. data/lib/pgbus/web/streamer.rb +53 -0
  40. data/lib/pgbus.rb +28 -0
  41. data/lib/puma/plugin/pgbus_streams.rb +54 -0
  42. data/lib/tasks/pgbus_streams.rake +52 -0
  43. metadata +29 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb5b86968b21bebedf9b33d6d69338b86f7dfb5304b5efa714b270bc8128fa10
4
- data.tar.gz: f5d6118149ebcd7c7f773ab399fbdc97079883dd895339c5918c156fc416bbfe
3
+ metadata.gz: 9428608c5f9419017f78a37cfe59c70ec03ff276654e516444ec25b8d9ee4fed
4
+ data.tar.gz: 8ad2651f7b6f25203442c819649c81d90c55c057a2d77219c10f6f74d2db18e9
5
5
  SHA512:
6
- metadata.gz: 7649ee8dfd9d9ff155e510c014a28fb070d79357d34ed4e8a8c0369c502c90b806d01775a0b01f480709fa9399d7ecc3edddb7d4e092813d42014d412833d9ce
7
- data.tar.gz: 756c31dd3fd970dca11c78cbb17af8b74e798cd42e3782b9993e4081982cc340a1224b6c0b829c259ea6d13abcec035510c2b4e0e6b8c39f73a34b1b9cd2bc48
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
- task default: %i[spec rubocop]
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 }