firehose 1.3.6 → 2.1.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 +5 -5
- data/CHANGELOG.md +31 -10
- data/{LICENSE.txt → LICENSE} +6 -6
- data/README.md +379 -187
- data/app/assets/javascripts/firehose.js +258 -0
- data/config/importmap.rb +1 -0
- data/db/migrate/20260214185244_create_firehose_events.rb +23 -0
- data/lib/commands/firehose_command.rb +119 -0
- data/lib/firehose/channel.rb +40 -0
- data/lib/firehose/cleanup_job.rb +21 -0
- data/lib/firehose/engine.rb +36 -0
- data/lib/firehose/helper.rb +40 -0
- data/lib/firehose/models/channel.rb +12 -0
- data/lib/firehose/models/message.rb +11 -0
- data/lib/firehose/queue.rb +44 -0
- data/lib/firehose/server.rb +930 -17
- data/lib/firehose/sse.rb +187 -0
- data/lib/firehose/stream.rb +32 -0
- data/lib/firehose/streamable.rb +25 -0
- data/lib/firehose/websocket.rb +242 -0
- data/lib/firehose.rb +67 -15
- data/lib/generators/firehose/install/install_generator.rb +43 -0
- data/lib/generators/firehose/install/templates/firehose_controller.rb +16 -0
- metadata +39 -366
- data/.codeclimate.yml +0 -29
- data/.dockerignore +0 -2
- data/.env.sample +0 -17
- data/.gitignore +0 -14
- data/.rspec +0 -1
- data/.rubocop.yml +0 -1156
- data/.ruby-version +0 -1
- data/.travis.yml +0 -12
- data/Dockerfile +0 -11
- data/Gemfile +0 -6
- data/Procfile +0 -1
- data/Procfile.dev +0 -3
- data/Rakefile +0 -64
- data/bin/firehose +0 -9
- data/coffeelint.json +0 -129
- data/config/rainbows.rb +0 -17
- data/docker-compose.yml +0 -17
- data/firehose.gemspec +0 -45
- data/karma.config.coffee +0 -89
- data/lib/assets/javascripts/firehose/base.js.coffee +0 -4
- data/lib/assets/javascripts/firehose/consumer.js.coffee +0 -86
- data/lib/assets/javascripts/firehose/core.js.coffee +0 -6
- data/lib/assets/javascripts/firehose/long_poll.js.coffee +0 -210
- data/lib/assets/javascripts/firehose/multiplexed_consumer.js.coffee +0 -74
- data/lib/assets/javascripts/firehose/transport.js.coffee +0 -44
- data/lib/assets/javascripts/firehose/version.js.coffee.erb +0 -2
- data/lib/assets/javascripts/firehose/web_socket.js.coffee +0 -148
- data/lib/assets/javascripts/firehose.js.coffee +0 -2
- data/lib/firehose/assets.rb +0 -56
- data/lib/firehose/cli.rb +0 -79
- data/lib/firehose/client/consumer.rb +0 -94
- data/lib/firehose/client/producer.rb +0 -114
- data/lib/firehose/client.rb +0 -10
- data/lib/firehose/logging.rb +0 -32
- data/lib/firehose/rack/app.rb +0 -49
- data/lib/firehose/rack/consumer/http_long_poll.rb +0 -159
- data/lib/firehose/rack/consumer/web_socket.rb +0 -200
- data/lib/firehose/rack/consumer.rb +0 -78
- data/lib/firehose/rack/ping.rb +0 -88
- data/lib/firehose/rack/publisher.rb +0 -59
- data/lib/firehose/rack.rb +0 -32
- data/lib/firehose/rails.rb +0 -7
- data/lib/firehose/server/app.rb +0 -52
- data/lib/firehose/server/channel.rb +0 -72
- data/lib/firehose/server/message_buffer.rb +0 -59
- data/lib/firehose/server/publisher.rb +0 -133
- data/lib/firehose/server/redis.rb +0 -32
- data/lib/firehose/server/subscriber.rb +0 -51
- data/lib/firehose/version.rb +0 -4
- data/package.json +0 -17
- data/spec/integrations/integration_test_helper.rb +0 -22
- data/spec/integrations/rainbows_spec.rb +0 -7
- data/spec/integrations/shared_examples.rb +0 -202
- data/spec/integrations/thin_spec.rb +0 -7
- data/spec/javascripts/firehose/base_spec.coffee +0 -2
- data/spec/javascripts/firehose/consumer_spec.coffee +0 -45
- data/spec/javascripts/firehose/long_poll_spec.coffee +0 -44
- data/spec/javascripts/firehose/multiplexed_consumer_spec.coffee +0 -72
- data/spec/javascripts/firehose/transport_spec.coffee +0 -30
- data/spec/javascripts/firehose/websocket_spec.coffee +0 -27
- data/spec/javascripts/helpers/spec_helper.js +0 -31
- data/spec/javascripts/support/jquery-1.11.1.js +0 -10308
- data/spec/javascripts/support/json2.js +0 -487
- data/spec/javascripts/support/sinon-1.7.3.js +0 -4290
- data/spec/javascripts/support/spec_helper.coffee +0 -3
- data/spec/lib/assets_spec.rb +0 -35
- data/spec/lib/client/consumer_spec.rb +0 -13
- data/spec/lib/client/producer_spec.rb +0 -71
- data/spec/lib/firehose_spec.rb +0 -7
- data/spec/lib/rack/consumer/http_long_poll_spec.rb +0 -30
- data/spec/lib/rack/consumer_spec.rb +0 -19
- data/spec/lib/rack/ping_spec.rb +0 -28
- data/spec/lib/rack/publisher_spec.rb +0 -34
- data/spec/lib/server/app_spec.rb +0 -15
- data/spec/lib/server/channel_spec.rb +0 -178
- data/spec/lib/server/message_buffer_spec.rb +0 -148
- data/spec/lib/server/publisher_spec.rb +0 -91
- data/spec/lib/server/redis_spec.rb +0 -13
- data/spec/lib/server/subscriber_spec.rb +0 -70
- data/spec/spec_helper.rb +0 -84
data/README.md
CHANGED
|
@@ -1,286 +1,478 @@
|
|
|
1
|
-
|
|
2
|
-
/ _(_) | |
|
|
3
|
-
| |_ _ _ __ ___| |__ ___ ___ ___
|
|
4
|
-
| _| | '__/ _ \ '_ \ / _ \/ __|/ _ \
|
|
5
|
-
| | | | | | __/ | | | (_) \__ \ __/
|
|
6
|
-
|_| |_|_| \___|_| |_|\___/|___/\___|
|
|
1
|
+
# Firehose 2.0
|
|
7
2
|
|
|
8
|
-
|
|
3
|
+
Real-time streaming for Rails with WebSocket and SSE transports, database-backed persistence, and automatic replay on reconnect.
|
|
9
4
|
|
|
10
|
-
|
|
5
|
+
Built on a dedicated PostgreSQL LISTEN/NOTIFY connection — no ActionCable, no polling, no connection pool pressure.
|
|
11
6
|
|
|
12
|
-
|
|
7
|
+
## Why not ActionCable?
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
ActionCable is fire-and-forget. Messages are published into Redis pub/sub with no persistence — if a client is disconnected when a message arrives, that message is gone. There's no way to know what was missed, and no way to catch up.
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
This matters more than it sounds. Mobile users tunnel through subways. Laptops go to sleep. WiFi drops for a few seconds during a video call. Browser tabs get suspended by the OS to save memory. In every case, the WebSocket closes, messages fly by, and when the client reconnects it has no idea it missed anything. The UI is silently stale.
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
Version 2.6 is required because Firehose uses [Lua/EVAL](http://redis.io/commands/eval) for its transactions, which is not available in earlier versions of Redis.
|
|
13
|
+
ActionCable also has no concept of message ordering. If you broadcast three updates to a stream, there's no sequence number, no monotonic ID, nothing for the client to compare against. You can't detect a gap because there's nothing to detect a gap *in*.
|
|
20
14
|
|
|
21
|
-
|
|
15
|
+
Firehose fixes this:
|
|
22
16
|
|
|
23
|
-
|
|
24
|
-
$ gem install firehose
|
|
25
|
-
```
|
|
17
|
+
- **Every message is persisted** in PostgreSQL with a monotonic sequence per stream and a global auto-incrementing ID. Messages are real database rows, not ephemeral pub/sub pings.
|
|
26
18
|
|
|
27
|
-
|
|
19
|
+
- **Clients track their position** via `last_event_id`. The JavaScript client tracks the highest event ID it has seen. On reconnect, it sends this ID back to the server.
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+
- **Replay on reconnect.** The server queries all messages with `id > last_event_id` across the client's subscribed streams and replays them in order before resuming live delivery. The client sees every message, in order, with no gaps.
|
|
30
22
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
- **Sequence numbers detect channel resets.** Each stream has its own monotonic sequence counter. If a client sees sequence 5 followed by sequence 1, it knows the channel was reset (e.g., data was cleared, the stream was recreated). The client can react accordingly — full refresh, re-fetch state, whatever makes sense for the application.
|
|
24
|
+
|
|
25
|
+
- **The browser does most of the work.** The `<firehose-stream-source>` custom element handles subscribe, unsubscribe, reconnection with exponential backoff, and `last_event_id` tracking automatically. Drop the element in your HTML and forget about it.
|
|
26
|
+
|
|
27
|
+
- **No Redis.** Everything runs on PostgreSQL, which you already have. LISTEN/NOTIFY for real-time fan-out, regular tables for persistence. One fewer piece of infrastructure.
|
|
28
|
+
|
|
29
|
+
## Prior Art
|
|
30
|
+
|
|
31
|
+
### ActionCable + Redis
|
|
32
|
+
|
|
33
|
+
The default Rails real-time stack. Redis pub/sub distributes messages across processes — fast, simple, and well-documented. But Redis pub/sub is ephemeral: messages exist only in the moment they're published. No persistence, no replay, no sequence numbers. The JavaScript client reconnects automatically but starts from zero every time. Works fine when "best effort" delivery is acceptable and you don't mind the occasional stale UI after a reconnect.
|
|
34
|
+
|
|
35
|
+
### SolidCable
|
|
36
|
+
|
|
37
|
+
37signals' database-backed ActionCable adapter, shipping with Rails 8. Replaces Redis with a `solid_cable_messages` table — each process polls for new messages by ID. Eliminates Redis as a dependency, which is great. The messages *are* in the database with auto-incrementing IDs, so the raw material for replay is right there. But SolidCable doesn't expose it: the ActionCable client has no `last_event_id` concept, so reconnection still means missed messages. Polling also introduces a small latency floor (default 100ms) compared to LISTEN/NOTIFY which is near-instant.
|
|
38
|
+
|
|
39
|
+
### AnyCable
|
|
40
|
+
|
|
41
|
+
Moves WebSocket connection handling to a Go or Rust server, calling back to Rails via gRPC for channel logic. Dramatically better connection scalability — tens of thousands of connections instead of hundreds. The commercial Pro version adds reliable streams with epoch+offset tracking and replay on reconnect, which solves the persistence gap. The open-source version is fire-and-forget like ActionCable. Requires deploying and operating a separate server process, plus Redis or NATS for pub/sub between the Go server and Rails.
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
### Turbo Streams / Broadcasts
|
|
39
44
|
|
|
40
|
-
|
|
45
|
+
A presentation layer on top of ActionCable, not a transport. Broadcasts `<turbo-stream>` HTML fragments that surgically update the DOM. Turbo 8's page refresh (morphing) simplifies this — broadcast `"refresh"` and the client re-fetches the full page, sidestepping DOM-ID coupling. Page refresh partially mitigates missed messages since each refresh fetches complete state, but the refresh *signal* itself can still be lost during a disconnection window. Inherits all of ActionCable's delivery guarantees (none).
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
### Mercure
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
A standalone SSE hub (written in Go) with built-in persistence and `Last-Event-ID` replay. Closest in philosophy to Firehose — messages are persisted, clients resume from where they left off, and SSE's native reconnection handles the transport. Not Rails-specific and requires deploying a separate server. SSE is unidirectional (server-to-client), so client-to-server communication needs separate HTTP requests.
|
|
50
|
+
|
|
51
|
+
### How Firehose Compares
|
|
52
|
+
|
|
53
|
+
| | ActionCable | SolidCable | AnyCable Pro | Mercure | **Firehose** |
|
|
54
|
+
|---|---|---|---|---|---|
|
|
55
|
+
| **Replay on reconnect** | No | No | Yes | Yes | **Yes** |
|
|
56
|
+
| **Message persistence** | No | Yes (no replay API) | Yes | Yes | **Yes** |
|
|
57
|
+
| **Sequence numbers** | No | No | Epoch+offset | Event IDs | **Per-stream monotonic** |
|
|
58
|
+
| **Infrastructure** | Redis | Database | Go/Rust + Redis/NATS | Go hub | **PostgreSQL** |
|
|
59
|
+
| **Transport** | WebSocket | WebSocket | WebSocket | SSE | **WebSocket + SSE** |
|
|
60
|
+
| **Latency** | ~instant | ~100ms (polling) | ~instant | ~instant | **~instant (NOTIFY)** |
|
|
61
|
+
| **Extra processes** | No | No | Yes | Yes | **No** |
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
Add to your Gemfile:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
gem "firehose"
|
|
46
69
|
```
|
|
47
70
|
|
|
48
|
-
|
|
71
|
+
Install:
|
|
49
72
|
|
|
50
|
-
```
|
|
51
|
-
|
|
73
|
+
```bash
|
|
74
|
+
bundle install
|
|
75
|
+
bin/rails generate firehose:install
|
|
52
76
|
```
|
|
53
77
|
|
|
54
|
-
|
|
78
|
+
This creates a `FirehoseController`, adds routes, wires up the JavaScript client, and runs migrations.
|
|
79
|
+
|
|
80
|
+
Publish from anywhere in your app:
|
|
55
81
|
|
|
56
|
-
```
|
|
57
|
-
|
|
82
|
+
```ruby
|
|
83
|
+
Firehose.channel("dashboard").publish("refresh")
|
|
58
84
|
```
|
|
59
85
|
|
|
60
|
-
|
|
86
|
+
Subscribe in your views:
|
|
61
87
|
|
|
62
|
-
|
|
88
|
+
```html
|
|
89
|
+
<firehose-stream-source streams="dashboard"></firehose-stream-source>
|
|
90
|
+
```
|
|
63
91
|
|
|
64
|
-
|
|
92
|
+
The page will automatically refresh via Turbo when an event arrives.
|
|
65
93
|
|
|
66
|
-
|
|
94
|
+
## Features
|
|
67
95
|
|
|
68
|
-
|
|
96
|
+
- **Dual transports**: WebSocket and Server-Sent Events (SSE)
|
|
97
|
+
- **Database persistence**: Messages stored in PostgreSQL for replay on reconnect
|
|
98
|
+
- **Automatic replay**: Missed events delivered via `last_event_id` (WebSocket) or `Last-Event-ID` (SSE)
|
|
99
|
+
- **Dedicated PG connection**: LISTEN/NOTIFY outside the ActiveRecord pool, PgBouncer-safe with direct connection
|
|
100
|
+
- **Inline delivery**: Small payloads sent inline via NOTIFY (no DB round-trip), large payloads fall back to DB fetch
|
|
101
|
+
- **Auto-cleanup**: Configurable threshold keeps the last N messages per stream
|
|
102
|
+
- **Falcon-compatible**: Built for async Ruby with async-websocket
|
|
69
103
|
|
|
70
|
-
|
|
104
|
+
## Channels
|
|
71
105
|
|
|
72
|
-
|
|
106
|
+
Channels are the primary API for publishing and subscribing:
|
|
73
107
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
```ruby
|
|
109
|
+
# Get a channel
|
|
110
|
+
channel = Firehose.channel("dashboard")
|
|
111
|
+
|
|
112
|
+
# Publish a message (persists to DB + NOTIFY)
|
|
113
|
+
channel.publish("refresh")
|
|
114
|
+
channel.publish({ action: "update", id: 42 }.to_json)
|
|
115
|
+
|
|
116
|
+
# Subscribe to live events (returns a closeable subscription)
|
|
117
|
+
sub = channel.subscribe { |payload| puts payload }
|
|
118
|
+
sub.close
|
|
119
|
+
|
|
120
|
+
# From a model
|
|
121
|
+
class Comment < ApplicationRecord
|
|
122
|
+
after_commit :notify_post
|
|
123
|
+
|
|
124
|
+
def notify_post
|
|
125
|
+
Firehose.channel(post.to_gid_param).publish("refresh")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
91
128
|
```
|
|
92
129
|
|
|
93
|
-
|
|
94
|
-
The multiplexed consumer is useful for scenarios where you want to subscribe
|
|
95
|
-
to messages from many channels at once, without having to use one connection
|
|
96
|
-
per channel. You can specify a list of channels to subscribe to, including a
|
|
97
|
-
handler function per channel that gets called with all messages coming from that
|
|
98
|
-
channel.
|
|
130
|
+
## Queues
|
|
99
131
|
|
|
100
|
-
|
|
132
|
+
Queues provide ephemeral push/pop signaling over PG LISTEN/NOTIFY — no database persistence, no replay. Use them for transient coordination like auth nonces, job completion signals, or request/response patterns between processes.
|
|
101
133
|
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Note that we don't specify a general message handler function
|
|
114
|
-
// but instead define one per channel below
|
|
115
|
-
|
|
116
|
-
// Note that we do NOT specify a protocol here because we don't
|
|
117
|
-
// know that yet. We also don't specify a specific channel name as part of
|
|
118
|
-
// the URI but instead pass in a list of subscriptions below
|
|
119
|
-
uri: '//localhost:7474/',
|
|
120
|
-
|
|
121
|
-
// List of channel subscriptions:
|
|
122
|
-
channels: {
|
|
123
|
-
"/my/channel/1": {
|
|
124
|
-
last_sequence: 10, // defaults to 0 and can be ommitted
|
|
125
|
-
message: function(msg) {
|
|
126
|
-
console.log("got message on channel 1:");
|
|
127
|
-
console.log(msg);
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
"/my/channel/2": {
|
|
131
|
-
message: function(msg) {
|
|
132
|
-
console.log("got message on channel 2:");
|
|
133
|
-
console.log(msg);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}).connect();
|
|
134
|
+
```ruby
|
|
135
|
+
queue = Firehose.server.queue("my-queue")
|
|
136
|
+
|
|
137
|
+
# Producer
|
|
138
|
+
queue.push("hello")
|
|
139
|
+
|
|
140
|
+
# Consumer (blocks until a message arrives)
|
|
141
|
+
message = queue.pop
|
|
142
|
+
message = queue.pop(timeout: 5) # raises Firehose::Queue::TimeoutError
|
|
143
|
+
|
|
144
|
+
queue.close
|
|
138
145
|
```
|
|
139
146
|
|
|
140
|
-
|
|
147
|
+
## Controller
|
|
141
148
|
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
The install generator creates `app/controllers/firehose_controller.rb`:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class FirehoseController < ApplicationController
|
|
153
|
+
include Firehose::Stream # Includes both WebSocket and SSE
|
|
154
|
+
|
|
155
|
+
# Or include just one:
|
|
156
|
+
# include Firehose::WebSocket
|
|
157
|
+
# include Firehose::SSE
|
|
158
|
+
end
|
|
144
159
|
```
|
|
145
160
|
|
|
146
|
-
|
|
161
|
+
Add authentication and stream authorization:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class FirehoseController < ApplicationController
|
|
165
|
+
include Firehose::Stream
|
|
147
166
|
|
|
148
|
-
|
|
167
|
+
before_action :authenticate_user!
|
|
149
168
|
|
|
150
|
-
|
|
169
|
+
def authorize_streams(streams)
|
|
170
|
+
streams.select { |s| current_user.can_access?(s) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build_event(event)
|
|
174
|
+
# Transform events before sending, or return nil to skip
|
|
175
|
+
event
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
```
|
|
151
179
|
|
|
152
|
-
|
|
180
|
+
## Routes
|
|
153
181
|
|
|
154
|
-
|
|
182
|
+
The install generator adds these routes:
|
|
155
183
|
|
|
156
184
|
```ruby
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
json = {'hello'=> 'world'}.to_json
|
|
160
|
-
firehose = Firehose::Client::Producer::Http.new('//127.0.0.1:7474')
|
|
161
|
-
firehose.publish(json).to("/my/messages/path")
|
|
185
|
+
match "firehose", to: "firehose#websocket", via: [:get, :connect]
|
|
186
|
+
get "firehose/sse", to: "firehose#sse"
|
|
162
187
|
```
|
|
163
188
|
|
|
164
|
-
|
|
189
|
+
## JavaScript Client
|
|
165
190
|
|
|
166
|
-
|
|
191
|
+
The install generator adds `import "firehose"` to your `application.js`. The importmap pin is set up automatically by the engine.
|
|
167
192
|
|
|
168
|
-
|
|
193
|
+
### Declarative (Custom Element)
|
|
169
194
|
|
|
170
|
-
|
|
195
|
+
```html
|
|
196
|
+
<firehose-stream-source path="/firehose" streams="dashboard,user:42"></firehose-stream-source>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
When the element connects, it opens a WebSocket and subscribes to the listed streams. When it disconnects (page navigation, element removal), it unsubscribes. Reconnection with exponential backoff is automatic.
|
|
200
|
+
|
|
201
|
+
Multiple elements with the same `path` share a single WebSocket connection.
|
|
202
|
+
|
|
203
|
+
### Programmatic
|
|
204
|
+
|
|
205
|
+
```javascript
|
|
206
|
+
Firehose.subscribe("/firehose", ["dashboard", "user:42"])
|
|
207
|
+
Firehose.unsubscribe("/firehose", ["dashboard"])
|
|
171
208
|
|
|
172
|
-
|
|
209
|
+
// Listen for events
|
|
210
|
+
document.addEventListener("firehose:message", (e) => {
|
|
211
|
+
console.log(e.detail) // { id: 123, stream: "dashboard", data: "refresh" }
|
|
212
|
+
})
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Turbo Integration
|
|
216
|
+
|
|
217
|
+
When an event has `data: "refresh"`, the client automatically triggers a Turbo page refresh. No additional setup needed — just publish `"refresh"` as your data payload.
|
|
218
|
+
|
|
219
|
+
## Phlex Helper
|
|
173
220
|
|
|
174
221
|
```ruby
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
consumer = Firehose::Rack::Consumer.new do |app|
|
|
179
|
-
# Configure how long the server should wait before send the client a 204
|
|
180
|
-
# with a request to reconnect. Typically browsers time-out the client connection
|
|
181
|
-
# after 30 seconds, so we set the `Firehose.Consumer` JS client to 25, and the
|
|
182
|
-
# server to 20 to make sure latency or timing doesn't cause any problems.
|
|
183
|
-
app.http_long_poll.timeout = 20
|
|
222
|
+
class Components::Base < Phlex::HTML
|
|
223
|
+
include Firehose::Helper
|
|
184
224
|
end
|
|
225
|
+
```
|
|
185
226
|
|
|
186
|
-
|
|
227
|
+
```ruby
|
|
228
|
+
firehose_stream_from @report
|
|
229
|
+
firehose_stream_from "dashboard", "user:#{current_user.id}"
|
|
230
|
+
firehose_stream_from @model, path: "/admin/firehose"
|
|
187
231
|
```
|
|
188
232
|
|
|
189
|
-
|
|
233
|
+
## Configuration
|
|
190
234
|
|
|
191
|
-
|
|
192
|
-
# Kitchen-sink rack configuration file example
|
|
193
|
-
require 'firehose'
|
|
235
|
+
Create `config/firehose.rb` for Ruby configuration, or `config/firehose.yml` for YAML. Ruby config takes precedence.
|
|
194
236
|
|
|
195
|
-
|
|
196
|
-
# you might include rack middleware authorization mechanisms here to control
|
|
197
|
-
# who can publish to Firehose.
|
|
237
|
+
### Ruby
|
|
198
238
|
|
|
199
|
-
|
|
239
|
+
```ruby
|
|
240
|
+
# config/firehose.rb
|
|
241
|
+
Firehose.server.configure do |config|
|
|
242
|
+
config.database_url = ENV["FIREHOSE_DATABASE_URL"] # Direct PG connection (bypasses PgBouncer)
|
|
243
|
+
config.cleanup_threshold = 100 # Keep last N messages per stream (default: 100)
|
|
244
|
+
config.reconnect_attempts = nil # Max reconnect attempts (default: nil = unlimited)
|
|
245
|
+
config.reconnect_delay = 1 # Base delay in seconds, doubles each attempt (default: 1)
|
|
246
|
+
config.reconnect_max_delay = 30 # Cap on exponential backoff (default: 30)
|
|
247
|
+
config.notify_max_bytes = 7999 # PG NOTIFY payload limit (default: 7999)
|
|
248
|
+
config.watchdog_enabled = true # Detect wedged consumer thread (default: true)
|
|
249
|
+
config.watchdog_deadline = 60 # Seconds without heartbeat before forcing reconnect (default: 60)
|
|
250
|
+
config.watchdog_interval = 10 # Seconds between watchdog checks (default: 10)
|
|
251
|
+
config.tcp_keepalives_idle = 30 # Seconds idle before keepalive probes (default: 30)
|
|
252
|
+
config.tcp_keepalives_interval = 10 # Seconds between keepalive probes (default: 10)
|
|
253
|
+
config.tcp_keepalives_count = 3 # Failed probes before peer declared dead (default: 3)
|
|
254
|
+
config.replay_on_reconnect = true # Re-fetch missed messages after a reconnect (default: true)
|
|
255
|
+
config.metrics_interval = 15 # Seconds between metric emissions (default: 15)
|
|
256
|
+
config.max_command_queue_depth = nil # Cap before NOTIFY commands are dropped (default: nil = unbounded)
|
|
257
|
+
config.notify_pool_size = 4 # NOTIFY worker threads; 0 = run inline (default: 4)
|
|
258
|
+
end
|
|
200
259
|
```
|
|
201
260
|
|
|
202
|
-
|
|
261
|
+
### YAML
|
|
203
262
|
|
|
204
|
-
|
|
263
|
+
```yaml
|
|
264
|
+
# config/firehose.yml
|
|
265
|
+
development:
|
|
266
|
+
cleanup_threshold: 100
|
|
205
267
|
|
|
206
|
-
|
|
268
|
+
production:
|
|
269
|
+
database_url: postgres://direct-db:5432/myapp
|
|
270
|
+
cleanup_threshold: 100
|
|
271
|
+
reconnect_attempts: 10
|
|
272
|
+
```
|
|
207
273
|
|
|
208
|
-
|
|
274
|
+
### Logger
|
|
209
275
|
|
|
210
276
|
```ruby
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
Firehose
|
|
277
|
+
Firehose.logger = Rails.logger # default in Rails
|
|
278
|
+
Firehose.logger = Logger.new($stdout) # outside Rails
|
|
279
|
+
Firehose.logger.level = Logger::DEBUG # verbose: LISTEN/UNLISTEN/NOTIFY
|
|
214
280
|
```
|
|
215
281
|
|
|
216
|
-
|
|
282
|
+
### Error reporting
|
|
283
|
+
|
|
284
|
+
Firehose reports prolonged connection losses and unexpected errors through
|
|
285
|
+
`Rails.error.report` (Rails 7+), which Sentry, Honeybadger, Appsignal,
|
|
286
|
+
Rollbar, and similar services already hook into. Errors are reported once
|
|
287
|
+
per outage (not once per failed retry) so you don't get paged for every
|
|
288
|
+
backoff tick. Reports include `source: "firehose.server"` and a context
|
|
289
|
+
hash with the current stage, reconnect count, subscriber count, and
|
|
290
|
+
command queue depth.
|
|
291
|
+
|
|
292
|
+
### Metrics
|
|
293
|
+
|
|
294
|
+
Wire `Firehose.on_metrics` to any reporter:
|
|
217
295
|
|
|
218
296
|
```ruby
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
297
|
+
# config/initializers/firehose.rb
|
|
298
|
+
Firehose.on_metrics = ->(metrics) {
|
|
299
|
+
metrics.each { |name, value| StatsD.gauge("firehose.#{name}", value) }
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The hook is called every `metrics_interval` seconds (default 15) with a
|
|
304
|
+
flat hash containing:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
command_queue_depth, subscribed_channels, seconds_since_heartbeat,
|
|
308
|
+
thread_alive, watchdog_alive, reconnects_current,
|
|
309
|
+
broadcasts_total, notifies_received, reconnects_total, watchdog_kicks,
|
|
310
|
+
commands_dropped, replay_messages_delivered
|
|
223
311
|
```
|
|
224
312
|
|
|
225
|
-
|
|
313
|
+
Mix of gauges and monotonic counters — pick based on the metric name
|
|
314
|
+
when you forward them. The emitter runs in its own thread, swallows any
|
|
315
|
+
exception raised by your reporter, and starts only when `on_metrics` is
|
|
316
|
+
set.
|
|
226
317
|
|
|
227
|
-
###
|
|
318
|
+
### Replay gap
|
|
228
319
|
|
|
229
|
-
|
|
320
|
+
When a client reconnects with `last_event_id` older than the oldest
|
|
321
|
+
retained message on a stream, Firehose can't honor the replay. Instead
|
|
322
|
+
of silently delivering a partial replay, the server emits a typed error
|
|
323
|
+
event per stream:
|
|
230
324
|
|
|
231
|
-
```
|
|
232
|
-
|
|
325
|
+
```json
|
|
326
|
+
{ "error": "replay_gap", "stream": "foo",
|
|
327
|
+
"last_event_id": 123, "oldest_retained_id": 450, "current_id": 500 }
|
|
233
328
|
```
|
|
234
329
|
|
|
235
|
-
|
|
330
|
+
- WebSocket: delivered as a JSON text frame on the same connection.
|
|
331
|
+
- SSE: delivered as `event: firehose_replay_gap` so clients can listen
|
|
332
|
+
targeted: `eventSource.addEventListener("firehose_replay_gap", handler)`.
|
|
236
333
|
|
|
237
|
-
|
|
334
|
+
Clients decide how to recover: full page reload, API refetch, or a
|
|
335
|
+
banner prompting the user. The gem's JS client dispatches a
|
|
336
|
+
`firehose:replay_gap` CustomEvent that apps can observe.
|
|
238
337
|
|
|
239
|
-
|
|
338
|
+
### WebSocket heartbeat
|
|
240
339
|
|
|
241
|
-
|
|
340
|
+
When a stream is idle, the server sends a protocol-level ping frame
|
|
341
|
+
every 30s. Browsers auto-respond with pong frames at the network layer
|
|
342
|
+
per RFC 6455 — no client code needed. If no pong is received within 90s,
|
|
343
|
+
the server closes the connection to reclaim resources held by half-open
|
|
344
|
+
clients. The heartbeat piggybacks on the writer fiber to avoid
|
|
345
|
+
concurrent-write races.
|
|
242
346
|
|
|
243
|
-
|
|
347
|
+
SSE uses a comment-line keepalive (`: keepalive\n\n`) on the same 30s
|
|
348
|
+
cadence — dead clients surface via the write failing.
|
|
349
|
+
|
|
350
|
+
### NOTIFY worker pool
|
|
351
|
+
|
|
352
|
+
By default Firehose runs `SELECT pg_notify(...)` calls on a pool of 4
|
|
353
|
+
worker threads, each with its own PG connection. This decouples NOTIFY
|
|
354
|
+
dispatch from the listener connection so `receive_notifications` can
|
|
355
|
+
interleave with dispatch instead of blocking behind a backlog of
|
|
356
|
+
pending NOTIFYs.
|
|
357
|
+
|
|
358
|
+
Set `notify_pool_size = 0` to disable the pool and run NOTIFYs inline
|
|
359
|
+
on the listener connection (original behavior).
|
|
360
|
+
|
|
361
|
+
Ordering across the pool is not preserved — clients that care about
|
|
362
|
+
event order rely on each message's `id` and `sequence` fields the same
|
|
363
|
+
way they already do for multi-writer broadcasts. The `firehose.js`
|
|
364
|
+
client tracks `lastEventId` monotonically, so out-of-order delivery is
|
|
365
|
+
handled naturally.
|
|
366
|
+
|
|
367
|
+
Failed NOTIFYs are counted as `notify_failures` in metrics. The
|
|
368
|
+
underlying message stays persisted in `firehose_messages`, so
|
|
369
|
+
reconnecting clients catch up via replay.
|
|
370
|
+
|
|
371
|
+
### Per-connection backpressure
|
|
372
|
+
|
|
373
|
+
Each WebSocket handler bounds its outgoing event queue at 1000. A client
|
|
374
|
+
whose consumption falls that far behind is disconnected (not silently
|
|
375
|
+
buffered until OOM). On reconnect they may receive `replay_gap` if their
|
|
376
|
+
cursor has aged past retention.
|
|
377
|
+
|
|
378
|
+
The server's command queue (which fans out NOTIFYs to PG) can also be
|
|
379
|
+
capped via `max_command_queue_depth`. When exceeded, new NOTIFY commands
|
|
380
|
+
are dropped — messages remain persisted in `firehose_messages` so
|
|
381
|
+
reconnecting clients catch up via replay. `listen`/`unlisten`/`shutdown`
|
|
382
|
+
commands are never dropped.
|
|
383
|
+
|
|
384
|
+
### Diagnostics
|
|
244
385
|
|
|
245
386
|
```ruby
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
387
|
+
Firehose.server.diagnostics
|
|
388
|
+
# => {
|
|
389
|
+
# pid: 12345,
|
|
390
|
+
# running: true,
|
|
391
|
+
# started: true,
|
|
392
|
+
# thread_alive: true,
|
|
393
|
+
# thread_status: "sleep",
|
|
394
|
+
# watchdog_alive: true,
|
|
395
|
+
# seconds_since_heartbeat: 0.12,
|
|
396
|
+
# command_queue_depth: 0,
|
|
397
|
+
# reconnects: 0,
|
|
398
|
+
# subscribed_channels: 3
|
|
399
|
+
# }
|
|
254
400
|
```
|
|
255
401
|
|
|
256
|
-
|
|
402
|
+
Use this from a Rails admin endpoint or console to verify the consumer
|
|
403
|
+
thread is alive and draining. `seconds_since_heartbeat > 60` or
|
|
404
|
+
`command_queue_depth` growing across samples is a sign the consumer is
|
|
405
|
+
stuck — the watchdog will normally force a reconnect before that becomes
|
|
406
|
+
serious.
|
|
407
|
+
|
|
408
|
+
### Callback rule
|
|
257
409
|
|
|
258
|
-
|
|
410
|
+
Subscriber callbacks run **synchronously on the single consumer thread**.
|
|
411
|
+
A slow callback delays every other subscriber on every channel in that
|
|
412
|
+
process. The built-in WebSocket, SSE, and Queue handlers all push to
|
|
413
|
+
thread-safe queues in microseconds — safe. If you use
|
|
414
|
+
`Firehose.channel("x").subscribe { ... }` with custom code, keep the
|
|
415
|
+
block tight (push onto your own queue, enqueue a job, signal a fiber)
|
|
416
|
+
and do the heavy work elsewhere.
|
|
259
417
|
|
|
260
|
-
|
|
418
|
+
### Broadcast connection pool
|
|
419
|
+
|
|
420
|
+
`broadcast` writes through ActiveRecord, so it uses the app's AR pool.
|
|
421
|
+
Under heavy broadcast volume, that contends with request handling. Two
|
|
422
|
+
mitigations:
|
|
423
|
+
|
|
424
|
+
- Set `config.database_url` to a direct PG URL — this only affects the
|
|
425
|
+
dedicated LISTEN connection, not broadcast writes.
|
|
426
|
+
- Run Firehose writes against a separate AR role with its own pool using
|
|
427
|
+
`ActiveRecord::Base.connects_to(database: { writing: :primary, firehose: :firehose })`
|
|
428
|
+
and call `Firehose::Models::Channel.connected_to(role: :firehose)` in an
|
|
429
|
+
initializer. Out of scope for the gem itself, but worth setting up on
|
|
430
|
+
busy deployments.
|
|
431
|
+
|
|
432
|
+
## Protocol
|
|
433
|
+
|
|
434
|
+
### WebSocket
|
|
261
435
|
|
|
262
436
|
```
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
gem "rack", "~> 1.4.0" # if you're using Rainbows. See https://github.com/firehoseio/firehose/commit/dfe55fff
|
|
267
|
-
gem "foreman", :require => false
|
|
268
|
-
gem "capistrano", :require => false
|
|
437
|
+
Client → Server: { "command": "subscribe", "streams": ["a", "b"], "last_event_id": 123 }
|
|
438
|
+
Client → Server: { "command": "unsubscribe", "streams": ["a"] }
|
|
439
|
+
Server → Client: { "id": 456, "stream": "a", "data": "refresh", "sequence": 7, "channel_id": 3 }
|
|
269
440
|
```
|
|
270
441
|
|
|
271
|
-
|
|
442
|
+
### SSE
|
|
272
443
|
|
|
273
|
-
|
|
444
|
+
```
|
|
445
|
+
GET /firehose/sse?streams=a,b
|
|
446
|
+
Last-Event-ID: 123
|
|
274
447
|
|
|
275
|
-
|
|
448
|
+
id: 456
|
|
449
|
+
event: a
|
|
450
|
+
data: {"data":"refresh","channel_id":3,"sequence":7}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
## Architecture
|
|
454
|
+
|
|
455
|
+
```
|
|
456
|
+
Firehose.channel("stream").publish("refresh")
|
|
457
|
+
│
|
|
458
|
+
├── INSERT into firehose_messages (persistence for replay)
|
|
459
|
+
│
|
|
460
|
+
└── PG NOTIFY with inline event JSON (or message ID if > 8KB)
|
|
461
|
+
│
|
|
462
|
+
▼
|
|
463
|
+
Server background thread (IO.select on PG socket)
|
|
464
|
+
│
|
|
465
|
+
├── WebSocket handler → browser → Turbo page refresh
|
|
466
|
+
├── SSE handler → browser → Turbo page refresh
|
|
467
|
+
└── Firehose::Queue → Ruby consumer
|
|
468
|
+
```
|
|
276
469
|
|
|
277
|
-
|
|
470
|
+
**Live path**: NOTIFY carries the full event inline — no database round-trip.
|
|
278
471
|
|
|
279
|
-
|
|
280
|
-
Make sure, they have the same version number.
|
|
472
|
+
**Replay path**: On reconnect, missed messages fetched from database by ID.
|
|
281
473
|
|
|
282
|
-
|
|
474
|
+
**Connection model**: One dedicated PG connection per process. LISTEN stays on this connection (PG requirement). NOTIFY commands are non-blocking (queued to background thread).
|
|
283
475
|
|
|
284
|
-
##
|
|
476
|
+
## License
|
|
285
477
|
|
|
286
|
-
|
|
478
|
+
MIT
|