firehose 1.2.20 → 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 +36 -0
- data/{LICENSE.txt → LICENSE} +6 -6
- data/README.md +389 -139
- 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 +931 -18
- 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 +40 -428
- data/.env.sample +0 -17
- data/.gitignore +0 -12
- data/.rbenv-version +0 -1
- data/.rspec +0 -1
- data/.travis.yml +0 -16
- data/Gemfile +0 -4
- data/Guardfile +0 -31
- data/Procfile +0 -1
- data/Procfile.dev +0 -4
- data/Rakefile +0 -53
- data/bin/firehose +0 -9
- data/config/evergreen.rb +0 -9
- data/config/rainbows.rb +0 -17
- data/firehose.gemspec +0 -49
- data/lib/assets/javascripts/firehose/base.js.coffee +0 -4
- data/lib/assets/javascripts/firehose/consumer.js.coffee +0 -70
- data/lib/assets/javascripts/firehose/core.js.coffee +0 -5
- data/lib/assets/javascripts/firehose/long_poll.js.coffee +0 -149
- data/lib/assets/javascripts/firehose/transport.js.coffee +0 -42
- data/lib/assets/javascripts/firehose/version.js.coffee.erb +0 -2
- data/lib/assets/javascripts/firehose/web_socket.js.coffee +0 -102
- data/lib/assets/javascripts/firehose.js.coffee +0 -3
- data/lib/assets/javascripts/vendor/json2.js +0 -487
- data/lib/firehose/assets.rb +0 -56
- data/lib/firehose/cli.rb +0 -78
- data/lib/firehose/client/consumer.rb +0 -94
- data/lib/firehose/client/producer.rb +0 -108
- 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 -86
- data/lib/firehose/rack/consumer/web_socket.rb +0 -95
- data/lib/firehose/rack/consumer.rb +0 -39
- data/lib/firehose/rack/ping.rb +0 -88
- data/lib/firehose/rack/publisher.rb +0 -53
- 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 -80
- data/lib/firehose/server/publisher.rb +0 -134
- data/lib/firehose/server/subscriber.rb +0 -51
- data/lib/firehose/version.rb +0 -4
- data/package.json +0 -5
- data/spec/integrations/integration_test_helper.rb +0 -22
- data/spec/integrations/rainbows_spec.rb +0 -7
- data/spec/integrations/shared_examples.rb +0 -120
- 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/transport_spec.coffee +0 -32
- data/spec/javascripts/firehose/websocket_spec.coffee +0 -25
- data/spec/javascripts/helpers/spec_helper.js +0 -30
- data/spec/javascripts/support/sinon-1.7.3.js +0 -4290
- 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 -12
- 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 -164
- data/spec/lib/server/publisher_spec.rb +0 -84
- data/spec/lib/server/subscriber_spec.rb +0 -69
- data/spec/spec_helper.rb +0 -77
data/README.md
CHANGED
|
@@ -1,228 +1,478 @@
|
|
|
1
|
-
|
|
2
|
-
/ _(_) | |
|
|
3
|
-
| |_ _ _ __ ___| |__ ___ ___ ___
|
|
4
|
-
| _| | '__/ _ \ '_ \ / _ \/ __|/ _ \
|
|
5
|
-
| | | | | | __/ | | | (_) \__ \ __/
|
|
6
|
-
|_| |_|_| \___|_| |_|\___/|___/\___|
|
|
7
|
-
|
|
8
|
-
Build realtime web applications in Ruby and JS
|
|
1
|
+
# Firehose 2.0
|
|
9
2
|
|
|
10
|
-
|
|
3
|
+
Real-time streaming for Rails with WebSocket and SSE transports, database-backed persistence, and automatic replay on reconnect.
|
|
11
4
|
|
|
12
|
-
|
|
5
|
+
Built on a dedicated PostgreSQL LISTEN/NOTIFY connection — no ActionCable, no polling, no connection pool pressure.
|
|
13
6
|
|
|
14
|
-
|
|
7
|
+
## Why not ActionCable?
|
|
15
8
|
|
|
16
|
-
|
|
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.
|
|
17
10
|
|
|
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.
|
|
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.
|
|
20
12
|
|
|
21
|
-
|
|
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*.
|
|
22
14
|
|
|
23
|
-
|
|
24
|
-
$ gem install firehose
|
|
25
|
-
```
|
|
15
|
+
Firehose fixes this:
|
|
26
16
|
|
|
27
|
-
|
|
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.
|
|
28
18
|
|
|
29
|
-
|
|
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.
|
|
30
20
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
22
|
+
|
|
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.
|
|
55
79
|
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
Publish from anywhere in your app:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
Firehose.channel("dashboard").publish("refresh")
|
|
58
84
|
```
|
|
59
85
|
|
|
60
|
-
|
|
86
|
+
Subscribe in your views:
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<firehose-stream-source streams="dashboard"></firehose-stream-source>
|
|
90
|
+
```
|
|
61
91
|
|
|
62
|
-
|
|
92
|
+
The page will automatically refresh via Turbo when an event arrives.
|
|
63
93
|
|
|
64
|
-
|
|
94
|
+
## Features
|
|
65
95
|
|
|
66
|
-
|
|
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
|
|
67
103
|
|
|
68
|
-
|
|
104
|
+
## Channels
|
|
69
105
|
|
|
70
|
-
|
|
106
|
+
Channels are the primary API for publishing and subscribing:
|
|
71
107
|
|
|
72
|
-
```
|
|
108
|
+
```ruby
|
|
109
|
+
# Get a channel
|
|
110
|
+
channel = Firehose.channel("dashboard")
|
|
73
111
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}).connect();
|
|
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
|
-
|
|
130
|
+
## Queues
|
|
131
|
+
|
|
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.
|
|
94
133
|
|
|
95
|
-
```
|
|
96
|
-
|
|
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
|
|
97
145
|
```
|
|
98
146
|
|
|
99
|
-
|
|
147
|
+
## Controller
|
|
100
148
|
|
|
101
|
-
|
|
149
|
+
The install generator creates `app/controllers/firehose_controller.rb`:
|
|
102
150
|
|
|
103
|
-
|
|
151
|
+
```ruby
|
|
152
|
+
class FirehoseController < ApplicationController
|
|
153
|
+
include Firehose::Stream # Includes both WebSocket and SSE
|
|
104
154
|
|
|
105
|
-
#
|
|
155
|
+
# Or include just one:
|
|
156
|
+
# include Firehose::WebSocket
|
|
157
|
+
# include Firehose::SSE
|
|
158
|
+
end
|
|
159
|
+
```
|
|
106
160
|
|
|
107
|
-
|
|
161
|
+
Add authentication and stream authorization:
|
|
108
162
|
|
|
109
163
|
```ruby
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
164
|
+
class FirehoseController < ApplicationController
|
|
165
|
+
include Firehose::Stream
|
|
166
|
+
|
|
167
|
+
before_action :authenticate_user!
|
|
168
|
+
|
|
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
|
|
115
178
|
```
|
|
116
179
|
|
|
117
|
-
|
|
180
|
+
## Routes
|
|
118
181
|
|
|
119
|
-
|
|
182
|
+
The install generator adds these routes:
|
|
120
183
|
|
|
121
|
-
|
|
184
|
+
```ruby
|
|
185
|
+
match "firehose", to: "firehose#websocket", via: [:get, :connect]
|
|
186
|
+
get "firehose/sse", to: "firehose#sse"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## JavaScript Client
|
|
122
190
|
|
|
123
|
-
|
|
191
|
+
The install generator adds `import "firehose"` to your `application.js`. The importmap pin is set up automatically by the engine.
|
|
192
|
+
|
|
193
|
+
### Declarative (Custom Element)
|
|
194
|
+
|
|
195
|
+
```html
|
|
196
|
+
<firehose-stream-source path="/firehose" streams="dashboard,user:42"></firehose-stream-source>
|
|
197
|
+
```
|
|
124
198
|
|
|
125
|
-
|
|
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"])
|
|
208
|
+
|
|
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
|
|
126
220
|
|
|
127
221
|
```ruby
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
consumer = Firehose::Rack::Consumer.new do |app|
|
|
132
|
-
# Configure how long the server should wait before send the client a 204
|
|
133
|
-
# with a request to reconnect. Typically browsers time-out the client connection
|
|
134
|
-
# after 30 seconds, so we set the `Firehose.Consumer` JS client to 25, and the
|
|
135
|
-
# server to 20 to make sure latency or timing doesn't cause any problems.
|
|
136
|
-
app.http_long_poll.timeout = 20
|
|
222
|
+
class Components::Base < Phlex::HTML
|
|
223
|
+
include Firehose::Helper
|
|
137
224
|
end
|
|
225
|
+
```
|
|
138
226
|
|
|
139
|
-
|
|
227
|
+
```ruby
|
|
228
|
+
firehose_stream_from @report
|
|
229
|
+
firehose_stream_from "dashboard", "user:#{current_user.id}"
|
|
230
|
+
firehose_stream_from @model, path: "/admin/firehose"
|
|
140
231
|
```
|
|
141
232
|
|
|
142
|
-
|
|
233
|
+
## Configuration
|
|
143
234
|
|
|
144
|
-
|
|
145
|
-
# Kitchen-sink rack configuration file example
|
|
146
|
-
require 'firehose'
|
|
235
|
+
Create `config/firehose.rb` for Ruby configuration, or `config/firehose.yml` for YAML. Ruby config takes precedence.
|
|
147
236
|
|
|
148
|
-
|
|
149
|
-
# you might include rack middleware authorization mechanisms here to control
|
|
150
|
-
# who can publish to Firehose.
|
|
237
|
+
### Ruby
|
|
151
238
|
|
|
152
|
-
|
|
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
|
|
153
259
|
```
|
|
154
260
|
|
|
155
|
-
|
|
261
|
+
### YAML
|
|
156
262
|
|
|
157
|
-
|
|
263
|
+
```yaml
|
|
264
|
+
# config/firehose.yml
|
|
265
|
+
development:
|
|
266
|
+
cleanup_threshold: 100
|
|
158
267
|
|
|
159
|
-
|
|
268
|
+
production:
|
|
269
|
+
database_url: postgres://direct-db:5432/myapp
|
|
270
|
+
cleanup_threshold: 100
|
|
271
|
+
reconnect_attempts: 10
|
|
272
|
+
```
|
|
160
273
|
|
|
161
|
-
|
|
274
|
+
### Logger
|
|
162
275
|
|
|
163
276
|
```ruby
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
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
|
|
167
280
|
```
|
|
168
281
|
|
|
169
|
-
|
|
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:
|
|
170
295
|
|
|
171
296
|
```ruby
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
176
311
|
```
|
|
177
312
|
|
|
178
|
-
|
|
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.
|
|
179
317
|
|
|
180
|
-
###
|
|
318
|
+
### Replay gap
|
|
181
319
|
|
|
182
|
-
|
|
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:
|
|
183
324
|
|
|
184
|
-
```
|
|
185
|
-
|
|
325
|
+
```json
|
|
326
|
+
{ "error": "replay_gap", "stream": "foo",
|
|
327
|
+
"last_event_id": 123, "oldest_retained_id": 450, "current_id": 500 }
|
|
186
328
|
```
|
|
187
329
|
|
|
188
|
-
|
|
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)`.
|
|
333
|
+
|
|
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.
|
|
337
|
+
|
|
338
|
+
### WebSocket heartbeat
|
|
339
|
+
|
|
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.
|
|
189
346
|
|
|
190
|
-
|
|
347
|
+
SSE uses a comment-line keepalive (`: keepalive\n\n`) on the same 30s
|
|
348
|
+
cadence — dead clients surface via the write failing.
|
|
191
349
|
|
|
192
|
-
|
|
350
|
+
### NOTIFY worker pool
|
|
193
351
|
|
|
194
|
-
|
|
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.
|
|
195
357
|
|
|
196
|
-
|
|
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
|
|
197
385
|
|
|
198
386
|
```ruby
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
+
# }
|
|
207
400
|
```
|
|
208
401
|
|
|
209
|
-
|
|
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
|
|
409
|
+
|
|
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.
|
|
417
|
+
|
|
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.
|
|
210
431
|
|
|
211
|
-
|
|
432
|
+
## Protocol
|
|
212
433
|
|
|
213
|
-
|
|
434
|
+
### WebSocket
|
|
214
435
|
|
|
215
436
|
```
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 }
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### SSE
|
|
443
|
+
|
|
444
|
+
```
|
|
445
|
+
GET /firehose/sse?streams=a,b
|
|
446
|
+
Last-Event-ID: 123
|
|
447
|
+
|
|
448
|
+
id: 456
|
|
449
|
+
event: a
|
|
450
|
+
data: {"data":"refresh","channel_id":3,"sequence":7}
|
|
222
451
|
```
|
|
223
452
|
|
|
224
|
-
|
|
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
|
+
```
|
|
469
|
+
|
|
470
|
+
**Live path**: NOTIFY carries the full event inline — no database round-trip.
|
|
471
|
+
|
|
472
|
+
**Replay path**: On reconnect, missed messages fetched from database by ID.
|
|
473
|
+
|
|
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).
|
|
225
475
|
|
|
226
|
-
|
|
476
|
+
## License
|
|
227
477
|
|
|
228
|
-
|
|
478
|
+
MIT
|