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.
Files changed (91) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +36 -0
  3. data/{LICENSE.txt → LICENSE} +6 -6
  4. data/README.md +389 -139
  5. data/app/assets/javascripts/firehose.js +258 -0
  6. data/config/importmap.rb +1 -0
  7. data/db/migrate/20260214185244_create_firehose_events.rb +23 -0
  8. data/lib/commands/firehose_command.rb +119 -0
  9. data/lib/firehose/channel.rb +40 -0
  10. data/lib/firehose/cleanup_job.rb +21 -0
  11. data/lib/firehose/engine.rb +36 -0
  12. data/lib/firehose/helper.rb +40 -0
  13. data/lib/firehose/models/channel.rb +12 -0
  14. data/lib/firehose/models/message.rb +11 -0
  15. data/lib/firehose/queue.rb +44 -0
  16. data/lib/firehose/server.rb +931 -18
  17. data/lib/firehose/sse.rb +187 -0
  18. data/lib/firehose/stream.rb +32 -0
  19. data/lib/firehose/streamable.rb +25 -0
  20. data/lib/firehose/websocket.rb +242 -0
  21. data/lib/firehose.rb +67 -15
  22. data/lib/generators/firehose/install/install_generator.rb +43 -0
  23. data/lib/generators/firehose/install/templates/firehose_controller.rb +16 -0
  24. metadata +40 -428
  25. data/.env.sample +0 -17
  26. data/.gitignore +0 -12
  27. data/.rbenv-version +0 -1
  28. data/.rspec +0 -1
  29. data/.travis.yml +0 -16
  30. data/Gemfile +0 -4
  31. data/Guardfile +0 -31
  32. data/Procfile +0 -1
  33. data/Procfile.dev +0 -4
  34. data/Rakefile +0 -53
  35. data/bin/firehose +0 -9
  36. data/config/evergreen.rb +0 -9
  37. data/config/rainbows.rb +0 -17
  38. data/firehose.gemspec +0 -49
  39. data/lib/assets/javascripts/firehose/base.js.coffee +0 -4
  40. data/lib/assets/javascripts/firehose/consumer.js.coffee +0 -70
  41. data/lib/assets/javascripts/firehose/core.js.coffee +0 -5
  42. data/lib/assets/javascripts/firehose/long_poll.js.coffee +0 -149
  43. data/lib/assets/javascripts/firehose/transport.js.coffee +0 -42
  44. data/lib/assets/javascripts/firehose/version.js.coffee.erb +0 -2
  45. data/lib/assets/javascripts/firehose/web_socket.js.coffee +0 -102
  46. data/lib/assets/javascripts/firehose.js.coffee +0 -3
  47. data/lib/assets/javascripts/vendor/json2.js +0 -487
  48. data/lib/firehose/assets.rb +0 -56
  49. data/lib/firehose/cli.rb +0 -78
  50. data/lib/firehose/client/consumer.rb +0 -94
  51. data/lib/firehose/client/producer.rb +0 -108
  52. data/lib/firehose/client.rb +0 -10
  53. data/lib/firehose/logging.rb +0 -32
  54. data/lib/firehose/rack/app.rb +0 -49
  55. data/lib/firehose/rack/consumer/http_long_poll.rb +0 -86
  56. data/lib/firehose/rack/consumer/web_socket.rb +0 -95
  57. data/lib/firehose/rack/consumer.rb +0 -39
  58. data/lib/firehose/rack/ping.rb +0 -88
  59. data/lib/firehose/rack/publisher.rb +0 -53
  60. data/lib/firehose/rack.rb +0 -32
  61. data/lib/firehose/rails.rb +0 -7
  62. data/lib/firehose/server/app.rb +0 -52
  63. data/lib/firehose/server/channel.rb +0 -80
  64. data/lib/firehose/server/publisher.rb +0 -134
  65. data/lib/firehose/server/subscriber.rb +0 -51
  66. data/lib/firehose/version.rb +0 -4
  67. data/package.json +0 -5
  68. data/spec/integrations/integration_test_helper.rb +0 -22
  69. data/spec/integrations/rainbows_spec.rb +0 -7
  70. data/spec/integrations/shared_examples.rb +0 -120
  71. data/spec/integrations/thin_spec.rb +0 -7
  72. data/spec/javascripts/firehose/base_spec.coffee +0 -2
  73. data/spec/javascripts/firehose/consumer_spec.coffee +0 -45
  74. data/spec/javascripts/firehose/long_poll_spec.coffee +0 -44
  75. data/spec/javascripts/firehose/transport_spec.coffee +0 -32
  76. data/spec/javascripts/firehose/websocket_spec.coffee +0 -25
  77. data/spec/javascripts/helpers/spec_helper.js +0 -30
  78. data/spec/javascripts/support/sinon-1.7.3.js +0 -4290
  79. data/spec/lib/assets_spec.rb +0 -35
  80. data/spec/lib/client/consumer_spec.rb +0 -13
  81. data/spec/lib/client/producer_spec.rb +0 -71
  82. data/spec/lib/firehose_spec.rb +0 -7
  83. data/spec/lib/rack/consumer/http_long_poll_spec.rb +0 -12
  84. data/spec/lib/rack/consumer_spec.rb +0 -19
  85. data/spec/lib/rack/ping_spec.rb +0 -28
  86. data/spec/lib/rack/publisher_spec.rb +0 -34
  87. data/spec/lib/server/app_spec.rb +0 -15
  88. data/spec/lib/server/channel_spec.rb +0 -164
  89. data/spec/lib/server/publisher_spec.rb +0 -84
  90. data/spec/lib/server/subscriber_spec.rb +0 -69
  91. data/spec/spec_helper.rb +0 -77
@@ -1,22 +1,935 @@
1
- require 'faye/websocket'
2
- require 'em-hiredis'
3
-
4
- # Set the EM::Hiredis logger to be the same as the Firehose logger.
5
- EM::Hiredis.logger = Firehose.logger
1
+ require "json"
2
+ require "digest"
6
3
 
7
4
  module Firehose
8
- # Firehose components that sit between the Rack HTTP software and the Redis server.
9
- # This mostly handles message sequencing and different HTTP channel names.
10
- module Server
11
- autoload :Subscriber, 'firehose/server/subscriber'
12
- autoload :Publisher, 'firehose/server/publisher'
13
- autoload :Channel, 'firehose/server/channel'
14
- autoload :App, 'firehose/server/app'
15
-
16
- # Generates keys for all firehose interactions with Redis. Ensures a root
17
- # key of `firehose`
18
- def self.key(*segments)
19
- segments.unshift(:firehose).join(':')
5
+ # Single-connection PG pubsub server. One per process.
6
+ #
7
+ # Handles LISTEN/NOTIFY over a dedicated PG connection outside the AR pool.
8
+ # Producers enqueue commands (non-blocking), the background thread dispatches
9
+ # them and fans out notifications to in-process subscribers.
10
+ #
11
+ # Scaling: this server uses a single PG connection for all operations.
12
+ # LISTEN must stay on one connection (PG requirement), but NOTIFY could be
13
+ # dispatched across a pool of connections for higher write throughput.
14
+ # To implement: replace the single @conn with a pool in process_commands,
15
+ # routing :notify commands to pool connections while keeping :listen/:unlisten
16
+ # on the dedicated listener connection.
17
+ class Server
18
+ QUEUE_DEPTH_WARNING = 10_000
19
+
20
+ # PG NOTIFY payload hard limit (7999 bytes + null terminator).
21
+ # Not queryable at runtime — hardcoded in PG source as NOTIFY_PAYLOAD_MAX_LENGTH.
22
+ NOTIFY_MAX_BYTES = 7999
23
+
24
+ attr_accessor :notify_max_bytes, :reconnect_attempts, :reconnect_delay,
25
+ :reconnect_max_delay, :database_url, :cleanup_threshold,
26
+ :watchdog_enabled, :watchdog_deadline, :watchdog_interval,
27
+ :tcp_keepalives_idle, :tcp_keepalives_interval, :tcp_keepalives_count,
28
+ :replay_on_reconnect, :metrics_interval, :max_command_queue_depth,
29
+ :notify_pool_size
30
+
31
+ def initialize
32
+ @notify_max_bytes = NOTIFY_MAX_BYTES
33
+ @reconnect_attempts = nil # nil = unlimited
34
+ @reconnect_delay = 1
35
+ @reconnect_max_delay = 30
36
+ @database_url = nil
37
+ @cleanup_threshold = 100
38
+ @watchdog_enabled = true
39
+ @watchdog_deadline = 60 # seconds without heartbeat before force-reconnect
40
+ @watchdog_interval = 10 # seconds between watchdog checks
41
+ @tcp_keepalives_idle = 30
42
+ @tcp_keepalives_interval = 10
43
+ @tcp_keepalives_count = 3
44
+ @replay_on_reconnect = true
45
+ @metrics_interval = 15 # seconds between metric emissions
46
+ @max_command_queue_depth = nil # nil = unbounded; numeric = drop :notify beyond cap
47
+ @notify_pool_size = 4 # NOTIFY worker threads; 0 = run NOTIFYs inline on consumer thread
48
+ @registry = Registry.new
49
+ @commands = ::Queue.new
50
+ @wakeup_read, @wakeup_write = IO.pipe
51
+ @running = false
52
+ @started = false
53
+ @start_mutex = Mutex.new
54
+ @reconnects = 0
55
+ @heartbeat_at = monotonic_now
56
+ @outage_reported = false
57
+ @cursors = {}
58
+ @cursors_mutex = Mutex.new
59
+ @counters = {
60
+ broadcasts_total: 0,
61
+ notifies_received: 0,
62
+ notify_failures: 0,
63
+ reconnects_total: 0,
64
+ watchdog_kicks: 0,
65
+ commands_dropped: 0,
66
+ replay_messages_delivered: 0
67
+ }
68
+ @counters_mutex = Mutex.new
69
+ end
70
+
71
+ # Snapshot of runtime state. Safe to call from any thread — useful
72
+ # from an admin endpoint or the firehose CLI to verify the consumer
73
+ # thread is alive and draining.
74
+ def diagnostics
75
+ {
76
+ pid: Process.pid,
77
+ running: @running,
78
+ started: @started,
79
+ thread_alive: @thread&.alive?,
80
+ thread_status: @thread&.status,
81
+ watchdog_alive: @watchdog&.alive?,
82
+ seconds_since_heartbeat: (monotonic_now - @heartbeat_at).round(3),
83
+ command_queue_depth: @commands.size,
84
+ reconnects: @reconnects,
85
+ subscribed_channels: @registry.size
86
+ }
87
+ end
88
+
89
+ # Flat hash of current metric values. Safe to call from any thread.
90
+ # Emitted periodically via Firehose.on_metrics when configured.
91
+ def metrics_snapshot
92
+ counters = @counters_mutex.synchronize { @counters.dup }
93
+ {
94
+ command_queue_depth: @commands.size,
95
+ subscribed_channels: @registry.size,
96
+ seconds_since_heartbeat: (monotonic_now - @heartbeat_at).round(3),
97
+ thread_alive: (@thread&.alive? ? 1 : 0),
98
+ watchdog_alive: (@watchdog&.alive? ? 1 : 0),
99
+ reconnects_current: @reconnects
100
+ }.merge(counters)
101
+ end
102
+
103
+ # Thread-safe counter increment. Called from producer threads,
104
+ # consumer thread, and watchdog — synchronized so values don't
105
+ # drift under concurrent updates.
106
+ def incr(key, by = 1)
107
+ @counters_mutex.synchronize { @counters[key] += by }
108
+ end
109
+
110
+ def configure
111
+ yield self
112
+ end
113
+
114
+ def configure_from_yaml(path)
115
+ settings = YAML.safe_load_file(path, aliases: true).fetch(Rails.env, {})
116
+ configure_from_hash(settings)
117
+ end
118
+
119
+ def configure_from_hash(hash)
120
+ hash.each { |key, value| public_send(:"#{key}=", value) }
121
+ end
122
+
123
+ # Start the consumer thread + watchdog + metrics emitter + notify pool
124
+ # eagerly. Safe to call repeatedly; ensure_started! is idempotent.
125
+ #
126
+ # Calling start() at boot makes health probes accurate immediately
127
+ # (thread_alive: true the moment the Rails engine initializer runs)
128
+ # instead of relying on the first broadcast/subscribe to trigger
129
+ # lazy-start. broadcast/subscribe/notify still call ensure_started!
130
+ # themselves so apps that never call start() explicitly keep working.
131
+ def start
132
+ @running = true
133
+ ensure_started!
134
+ self
135
+ end
136
+
137
+ def shutdown
138
+ return unless @running
139
+ @running = false
140
+ return unless @started
141
+ Firehose.logger.info { "[Firehose] Server shutting down" }
142
+ @watchdog&.stop
143
+ @metrics_emitter&.stop
144
+ @notify_pool&.stop
145
+ enqueue([:shutdown])
146
+ @thread&.join(5)
147
+ @conn&.close
148
+ @wakeup_read&.close
149
+ @wakeup_write&.close
150
+ end
151
+
152
+ def broadcast(stream, data)
153
+ ensure_started!
154
+ stream = stream.to_s
155
+ data = data.to_s
156
+
157
+ channel = Models::Channel.find_or_create_by!(name: stream)
158
+
159
+ message = channel.with_lock do
160
+ channel.increment!(:sequence)
161
+ channel.messages.create!(sequence: channel.sequence, data:)
162
+ end
163
+
164
+ Firehose.logger.info { "[Firehose] broadcast stream=#{stream} sequence=#{message.sequence} id=#{message.id}" }
165
+
166
+ event = { id: message.id, channel_id: message.channel_id, sequence: message.sequence, stream:, data: }
167
+ payload = event.to_json
168
+ payload = event.except(:data).to_json if payload.bytesize > @notify_max_bytes
169
+
170
+ notify(channel_name(stream), payload)
171
+
172
+ if channel.reload.messages_count > @cleanup_threshold
173
+ CleanupJob.perform_later(stream)
174
+ end
175
+
176
+ incr(:broadcasts_total)
177
+ message
178
+ end
179
+
180
+ class PayloadTooLarge < StandardError; end
181
+
182
+ def notify(channel, payload)
183
+ if payload.bytesize > @notify_max_bytes
184
+ raise PayloadTooLarge, "Payload #{payload.bytesize}B exceeds PG NOTIFY limit of #{@notify_max_bytes}B on #{channel}"
185
+ end
186
+ pg_id = pg_identifier(channel)
187
+ Firehose.logger.debug { "[Firehose] NOTIFY #{channel} [#{pg_id}] (#{payload.bytesize}B)" }
188
+ enqueue([:notify, pg_id, payload])
189
+ end
190
+
191
+ def channel_name(stream)
192
+ "firehose:#{stream}"
193
+ end
194
+
195
+ def subscribe(channel, callback)
196
+ ensure_started!
197
+ pg_id = pg_identifier(channel)
198
+ if @registry.add(pg_id, callback, channel_name: channel)
199
+ Firehose.logger.debug { "[Firehose] LISTEN #{channel} [#{pg_id}]" }
200
+ enqueue([:listen, pg_id])
201
+ end
202
+ end
203
+
204
+ def unsubscribe(channel, callback)
205
+ pg_id = pg_identifier(channel)
206
+ if @registry.remove(pg_id, callback)
207
+ Firehose.logger.debug { "[Firehose] UNLISTEN #{channel} [#{pg_id}]" }
208
+ enqueue([:unlisten, pg_id])
209
+ end
210
+ end
211
+
212
+ def subscriptions
213
+ Subscriptions.new(server: self)
214
+ end
215
+
216
+ def queue(channel)
217
+ Queue.new(channel, server: self)
218
+ end
219
+
220
+ # Called by the watchdog (from another thread) to break a wedged
221
+ # consumer out of IO.select / a hung PG call. Idempotent and safe
222
+ # to invoke concurrently with the consumer thread: closing the
223
+ # socket makes any in-flight PG op raise, and the wakeup_write
224
+ # forces IO.select to return. The supervisor catches the error
225
+ # and connect_with_retry reopens the connection.
226
+ def force_reconnect!(source: :external)
227
+ incr(:watchdog_kicks) if source == :watchdog
228
+ conn = @conn
229
+ begin
230
+ conn&.close
231
+ rescue
232
+ # Already closed, in a bad state, etc. — fine.
233
+ end
234
+ begin
235
+ @wakeup_write.write_nonblock("x")
236
+ rescue IO::WaitWritable, IOError
237
+ # Pipe full or closed — we did what we could.
238
+ end
239
+ end
240
+
241
+ # Recorded by the consumer thread on each loop iteration and read
242
+ # by the watchdog. Plain instance-variable read/write on Ruby
243
+ # references is atomic; no mutex needed.
244
+ def heartbeat!
245
+ @heartbeat_at = monotonic_now
246
+ end
247
+
248
+ def seconds_since_heartbeat
249
+ monotonic_now - @heartbeat_at
250
+ end
251
+
252
+ # Watchdog liveness probe. Non-blocking; consumer updates its
253
+ # heartbeat when processing the :ping. Safe to call from any thread.
254
+ def enqueue_ping
255
+ enqueue([:ping])
256
+ end
257
+
258
+ private
259
+
260
+ PG_IDENTIFIER_MAX = 63
261
+
262
+ # SHA256 hex digest of the channel name, used as the PG LISTEN/NOTIFY
263
+ # identifier. PG silently truncates identifiers beyond 63 chars, which
264
+ # would cause LISTEN and NOTIFY to target different channels. Hashing
265
+ # guarantees a safe, fixed-length identifier for any input.
266
+ def pg_identifier(channel)
267
+ Digest::SHA256.hexdigest(channel).first(PG_IDENTIFIER_MAX)
268
+ end
269
+
270
+ def assert_pg_identifier!(identifier)
271
+ raise ArgumentError, "PG channel identifier too long (#{identifier.length}/#{PG_IDENTIFIER_MAX}): #{identifier}" if identifier.length > PG_IDENTIFIER_MAX
272
+ end
273
+
274
+ def ensure_started!
275
+ # Once shutdown has started, do not spawn anything new. The wakeup
276
+ # pipe is closed and supporting threads are joining; a respawn here
277
+ # would produce a zombie thread that can't drain commands.
278
+ return unless @running
279
+ return if @started && @thread&.alive?
280
+
281
+ @start_mutex.synchronize do
282
+ return unless @running
283
+ return if @started && @thread&.alive?
284
+
285
+ if @started
286
+ Firehose.logger.warn { "[Firehose] Consumer thread died, respawning pid=#{Process.pid}" }
287
+ end
288
+
289
+ @thread = Thread.new { run }
290
+ @thread.name = "firehose-server"
291
+ @started = true
292
+
293
+ if @watchdog_enabled && !@watchdog&.alive?
294
+ @watchdog = Watchdog.new(self).tap(&:start)
295
+ end
296
+
297
+ if Firehose.on_metrics && !@metrics_emitter&.alive?
298
+ @metrics_emitter = MetricsEmitter.new(self).tap(&:start)
299
+ end
300
+
301
+ if @notify_pool_size && @notify_pool_size > 0 && !@notify_pool&.running?
302
+ @notify_pool = NotifyPool.new(self, size: @notify_pool_size).tap(&:start)
303
+ end
304
+
305
+ Firehose.logger.info { "[Firehose] Server started pid=#{Process.pid}" }
306
+ end
307
+ end
308
+
309
+ def enqueue(command)
310
+ depth = @commands.size
311
+
312
+ # Backpressure: if a cap is configured and we're past it, drop new
313
+ # NOTIFYs. Messages are already persisted in firehose_messages; any
314
+ # reconnecting client will catch up via replay (or get replay_gap
315
+ # if their cursor is stale). We never drop :listen/:unlisten/:ping/
316
+ # :shutdown because those affect correctness, not just delivery.
317
+ if @max_command_queue_depth && depth >= @max_command_queue_depth && command.first == :notify
318
+ incr(:commands_dropped)
319
+ if (@counters[:commands_dropped] % 100).zero?
320
+ Firehose.logger.warn {
321
+ "[Firehose] Command queue at cap (#{depth}/#{@max_command_queue_depth}); " \
322
+ "dropping NOTIFY (total dropped: #{@counters[:commands_dropped]})"
323
+ }
324
+ end
325
+ return
326
+ end
327
+
328
+ if depth > QUEUE_DEPTH_WARNING
329
+ Firehose.logger.warn {
330
+ "[Firehose] Command queue depth: #{depth}. " \
331
+ "Single PG connection may be saturated. " \
332
+ "See Server docs for connection pool guidance."
333
+ }
334
+ end
335
+
336
+ @commands.push(command)
337
+ @wakeup_write.write_nonblock("x")
338
+ rescue IO::WaitWritable
339
+ # Pipe buffer full — thread will drain it
340
+ rescue IOError, Errno::EBADF
341
+ # Pipe closed (shutdown race). The consumer thread has already
342
+ # stopped; no one will read this command. Silent drop is correct —
343
+ # raising here would crash callers that arrived during shutdown.
344
+ end
345
+
346
+ def connect
347
+ @conn&.close rescue nil
348
+ @conn = open_connection
349
+ @registry.each_channel { |ch| @conn.exec("LISTEN #{@conn.escape_identifier(ch)}") }
350
+ Firehose.logger.debug { "[Firehose] Connected" }
351
+
352
+ if @reconnects > 0
353
+ incr(:reconnects_total)
354
+ replay_missed_messages if @replay_on_reconnect
355
+ end
356
+ end
357
+
358
+ # After a reconnect, fan out any messages from the DB that postdate the
359
+ # last id seen on each subscribed channel. Callbacks must be idempotent —
360
+ # they may see messages already delivered if the NOTIFY landed before the
361
+ # connection dropped. Client-side handlers typically dedupe via event id.
362
+ def replay_missed_messages
363
+ cursors = @cursors_mutex.synchronize { @cursors.dup }
364
+ return if cursors.empty?
365
+
366
+ cursors.each do |pg_id, last_id|
367
+ channel_name = @registry.channel_name(pg_id)
368
+ next unless channel_name
369
+ stream = channel_name.delete_prefix(channel_prefix)
370
+
371
+ messages = Models::Message.joins(:channel)
372
+ .where(firehose_channels: { name: stream })
373
+ .where("firehose_messages.id > ?", last_id)
374
+ .order(:id)
375
+ .limit(1000)
376
+
377
+ count = 0
378
+ messages.find_each do |msg|
379
+ event = { id: msg.id, channel_id: msg.channel_id, sequence: msg.sequence, stream: stream, data: msg.data }
380
+ @registry.notify(pg_id, event.to_json)
381
+ count += 1
382
+ end
383
+
384
+ if count > 0
385
+ Firehose.logger.info { "[Firehose] Replayed #{count} missed message(s) on #{stream}" }
386
+ incr(:replay_messages_delivered, count)
387
+ end
388
+ end
389
+ rescue => e
390
+ # Don't let replay failures crash the supervisor — log and move on.
391
+ Firehose.logger.error { "[Firehose] Replay failed: #{e.class}: #{e.message}" }
392
+ report_error(e, stage: :replay)
393
+ end
394
+
395
+ def channel_prefix = "firehose:"
396
+
397
+ # Force OS-level TCP keepalives so a dead network peer surfaces as a
398
+ # socket error within ~60 seconds instead of blocking IO.select
399
+ # indefinitely waiting for traffic on a half-open connection.
400
+ def open_connection
401
+ if @database_url
402
+ PG.connect(append_keepalives_to_url(@database_url))
403
+ else
404
+ config = ActiveRecord::Base.connection_db_config.configuration_hash
405
+ PG.connect(**keepalive_options, **{
406
+ host: config[:host],
407
+ port: config[:port],
408
+ dbname: config[:database],
409
+ user: config[:username],
410
+ password: config[:password]
411
+ }.compact)
412
+ end
413
+ end
414
+
415
+ def keepalive_options
416
+ {
417
+ keepalives: 1,
418
+ keepalives_idle: @tcp_keepalives_idle,
419
+ keepalives_interval: @tcp_keepalives_interval,
420
+ keepalives_count: @tcp_keepalives_count
421
+ }
422
+ end
423
+
424
+ def append_keepalives_to_url(url)
425
+ require "uri"
426
+ uri = URI.parse(url)
427
+ params = URI.decode_www_form(uri.query || "")
428
+ keepalive_options.each { |k, v| params << [k.to_s, v.to_s] }
429
+ uri.query = URI.encode_www_form(params)
430
+ uri.to_s
431
+ rescue URI::InvalidURIError
432
+ url # malformed URL — skip keepalives rather than refuse to connect
433
+ end
434
+
435
+ # Supervised main loop. Any exception restarts the loop with backoff
436
+ # rather than killing the thread. The only clean exit is :shutdown.
437
+ def run
438
+ loop do
439
+ begin
440
+ connect_with_retry
441
+ @outage_reported = false
442
+ serve
443
+ return # clean shutdown
444
+ rescue PG::Error, IOError => e
445
+ Firehose.logger.warn { "[Firehose] Connection error: #{e.class}: #{e.message}. Reconnecting." }
446
+ report_error(e, stage: :connection_lost) unless @outage_reported
447
+ @outage_reported = true
448
+ # drop through — connect_with_retry will back off
449
+ rescue => e
450
+ # A bug in processing — don't let it kill the thread. Log and retry.
451
+ Firehose.logger.error {
452
+ "[Firehose] Unexpected error: #{e.class}: #{e.message}\n" +
453
+ (e.backtrace&.first(5)&.join("\n").to_s)
454
+ }
455
+ report_error(e, stage: :supervisor)
456
+ sleep 1 # prevent hot loop on a persistent bug
457
+ end
458
+ end
459
+ end
460
+
461
+ def serve
462
+ loop do
463
+ heartbeat!
464
+ IO.select([@conn.socket_io, @wakeup_read])
465
+ drain_wakeup
466
+ return unless process_commands
467
+ receive_notifications
468
+ end
469
+ end
470
+
471
+ # Connect to PG, retrying forever (or up to @reconnect_attempts) with
472
+ # capped exponential backoff. Re-issues LISTEN for every registered channel.
473
+ def connect_with_retry
474
+ loop do
475
+ begin
476
+ connect
477
+ @reconnects = 0
478
+ return
479
+ rescue PG::Error, IOError => e
480
+ @reconnects += 1
481
+ if @reconnect_attempts && @reconnects > @reconnect_attempts
482
+ Firehose.logger.error {
483
+ "[Firehose] Giving up after #{@reconnect_attempts} reconnect attempts: #{e.message}"
484
+ }
485
+ report_error(e, stage: :reconnect_gave_up)
486
+ raise
487
+ end
488
+
489
+ delay = [@reconnect_delay * (2 ** (@reconnects - 1)), @reconnect_max_delay].min
490
+ Firehose.logger.warn {
491
+ "[Firehose] Connect failed (attempt #{@reconnects}): #{e.message}. Retrying in #{delay}s."
492
+ }
493
+ heartbeat! # so the watchdog doesn't hammer us during a long reconnect
494
+ sleep delay
495
+ end
496
+ end
497
+ end
498
+
499
+ # Forward errors to Rails' error reporter (Sentry/Honeybadger/etc hook
500
+ # into this), plus any user-supplied handler. Never raise from here.
501
+ def report_error(error, stage:)
502
+ if defined?(Rails) && Rails.respond_to?(:error) && Rails.error
503
+ Rails.error.report(error, source: "firehose.server", context: {
504
+ stage: stage,
505
+ reconnects: @reconnects,
506
+ subscribed_channels: @registry.size,
507
+ command_queue_depth: @commands.size
508
+ }, handled: true)
509
+ end
510
+ rescue => inner
511
+ Firehose.logger.warn { "[Firehose] Error reporter failed: #{inner.class}: #{inner.message}" }
512
+ end
513
+
514
+ def drain_wakeup
515
+ @wakeup_read.read_nonblock(1024)
516
+ rescue IO::WaitReadable
517
+ # Nothing to drain
518
+ end
519
+
520
+ def process_commands
521
+ loop do
522
+ cmd = @commands.pop(true)
523
+ heartbeat! # proves the thread is draining — watchdog relies on this
524
+
525
+ case cmd
526
+ in [:listen, channel]
527
+ assert_pg_identifier!(channel)
528
+ @conn.exec("LISTEN #{@conn.escape_identifier(channel)}")
529
+ in [:unlisten, channel]
530
+ assert_pg_identifier!(channel)
531
+ @conn.exec("UNLISTEN #{@conn.escape_identifier(channel)}")
532
+ in [:notify, channel, payload]
533
+ assert_pg_identifier!(channel)
534
+ dispatch_notify(channel, payload)
535
+ in [:ping]
536
+ # watchdog liveness probe — just touching the heartbeat above is enough.
537
+ nil
538
+ in [:shutdown]
539
+ return false
540
+ end
541
+ end
542
+ rescue ThreadError
543
+ # Queue empty — done processing
544
+ true
545
+ end
546
+
547
+ # Route NOTIFYs through the worker pool when configured, otherwise
548
+ # execute inline on the listener connection (backward-compatible).
549
+ # The pool keeps NOTIFYs off the single LISTEN connection so
550
+ # receive_notifications can interleave with dispatch.
551
+ def dispatch_notify(channel, payload)
552
+ if @notify_pool&.running?
553
+ @notify_pool.enqueue(channel, payload)
554
+ else
555
+ @conn.exec_params("SELECT pg_notify($1, $2)", [channel, payload])
556
+ end
557
+ end
558
+
559
+ def pg_notify(channel, payload)
560
+ @conn.exec_params("SELECT pg_notify($1, $2)", [channel, payload])
561
+ end
562
+
563
+ def receive_notifications
564
+ @conn.consume_input
565
+ while (notification = @conn.notifies)
566
+ heartbeat!
567
+ incr(:notifies_received)
568
+ channel = notification[:relname]
569
+ payload = notification[:extra]
570
+ Firehose.logger.debug { "[Firehose] received #{channel}" }
571
+ record_cursor(channel, payload)
572
+ @registry.notify(channel, payload)
573
+ end
574
+ end
575
+
576
+ # Track the newest message id seen per channel so that replay_missed_messages
577
+ # knows where to resume after a reconnect. Parse defensively — user-facing
578
+ # payloads are controlled by the broadcaster, but malformed JSON should not
579
+ # crash the consumer.
580
+ def record_cursor(pg_id, payload)
581
+ id = JSON.parse(payload)["id"]
582
+ return unless id.is_a?(Integer)
583
+ @cursors_mutex.synchronize do
584
+ current = @cursors[pg_id]
585
+ @cursors[pg_id] = id if current.nil? || id > current
586
+ end
587
+ rescue JSON::ParserError
588
+ # Non-JSON payload (e.g., from a non-firehose source on the same channel) — skip.
589
+ end
590
+
591
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
592
+
593
+ # Thread-safe subscriber registry.
594
+ # Tracks callbacks per channel and reports first-add / last-remove
595
+ # so the server knows when to LISTEN / UNLISTEN. Also remembers the
596
+ # original channel name (pre-hash) so replay can look up messages
597
+ # in the DB after a reconnect.
598
+ class Registry
599
+ def initialize
600
+ @channels = {}
601
+ @names = {}
602
+ @mutex = Mutex.new
603
+ end
604
+
605
+ def add(channel, callback, channel_name: nil)
606
+ @mutex.synchronize do
607
+ first = !@channels.key?(channel)
608
+ (@channels[channel] ||= []) << callback
609
+ @names[channel] ||= channel_name if channel_name
610
+ first
611
+ end
612
+ end
613
+
614
+ def remove(channel, callback)
615
+ @mutex.synchronize do
616
+ return false unless @channels[channel]&.delete(callback)
617
+
618
+ if @channels[channel].empty?
619
+ @channels.delete(channel)
620
+ @names.delete(channel)
621
+ true
622
+ else
623
+ false
624
+ end
625
+ end
626
+ end
627
+
628
+ def channel_name(pg_id)
629
+ @mutex.synchronize { @names[pg_id] }
630
+ end
631
+
632
+ def notify(channel, payload)
633
+ callbacks = @mutex.synchronize { @channels[channel]&.dup }
634
+ callbacks&.each do |cb|
635
+ cb.call(payload)
636
+ rescue => e
637
+ Firehose.logger.error { "[Firehose] Callback error on #{channel}: #{e.message}" }
638
+ end
639
+ end
640
+
641
+ def each_channel(&)
642
+ channels = @mutex.synchronize { @channels.keys }
643
+ channels.each(&)
644
+ end
645
+
646
+ def size
647
+ @mutex.synchronize { @channels.size }
648
+ end
649
+ end
650
+
651
+ # Manages a set of stream subscriptions with automatic cleanup.
652
+ # Compose this instead of manually tracking subscription hashes.
653
+ #
654
+ # Notifications carry event JSON. When the data fits within PG's
655
+ # 8KB NOTIFY limit, the full event is inline (no DB hit). For
656
+ # oversized payloads, data is omitted and consumers fetch by ID.
657
+ # Replay on reconnect always fetches from the database.
658
+ class Subscriptions
659
+ def initialize(server: Firehose.server)
660
+ @server = server
661
+ @subscriptions = {}
662
+ end
663
+
664
+ def add(stream, &on_notify)
665
+ remove(stream) if @subscriptions.key?(stream)
666
+ @server.subscribe(@server.channel_name(stream), on_notify)
667
+ @subscriptions[stream] = on_notify
668
+ end
669
+
670
+ def remove(stream)
671
+ return unless (callback = @subscriptions.delete(stream))
672
+ @server.unsubscribe(@server.channel_name(stream), callback)
673
+ end
674
+
675
+ def close
676
+ @subscriptions.keys.each { |stream| remove(stream) }
677
+ end
678
+ end
679
+
680
+ # Watchdog thread — detects a wedged-but-alive consumer and forces
681
+ # reconnection. Runs in its own thread with catch-all rescues so the
682
+ # watchdog itself can never take the server down.
683
+ #
684
+ # Detection: consumer updates `heartbeat!` on every loop iteration
685
+ # and on every command/notification processed. Watchdog checks the
686
+ # heartbeat age on a fixed interval. If nothing has moved in
687
+ # `watchdog_deadline` seconds AND there are commands backed up or
688
+ # subscribers attached, it calls `server.force_reconnect!` — which
689
+ # closes the PG socket and pokes the wakeup pipe. The supervisor
690
+ # loop catches the resulting PG::Error and reconnects.
691
+ #
692
+ # The watchdog never calls `Thread#kill` or `Thread#raise` on the
693
+ # consumer. Those primitives can corrupt shared state or leave the
694
+ # Ruby VM in inconsistent states. Force-closing the socket is safe
695
+ # and precisely targets what we need to recover from: a hung I/O
696
+ # call. If the consumer legitimately isn't making progress for other
697
+ # reasons, the reconnect is harmless — it re-issues LISTEN on all
698
+ # subscribed channels.
699
+ class Watchdog
700
+ def initialize(server)
701
+ @server = server
702
+ @thread = nil
703
+ @running = false
704
+ end
705
+
706
+ def start
707
+ return if @thread&.alive?
708
+ @running = true
709
+ @thread = Thread.new { run }
710
+ @thread.name = "firehose-watchdog"
711
+ @thread.report_on_exception = false # we log ourselves
712
+ end
713
+
714
+ def stop
715
+ @running = false
716
+ @thread&.join(2)
717
+ @thread = nil
718
+ end
719
+
720
+ def alive?
721
+ @thread&.alive? == true
722
+ end
723
+
724
+ private
725
+
726
+ def run
727
+ # Outer loop exists purely so a bug in the watchdog body can't
728
+ # kill the watchdog itself. Every path back to this loop must
729
+ # have slept to avoid burning CPU.
730
+ loop do
731
+ break unless @running
732
+ begin
733
+ tick
734
+ rescue => e
735
+ begin
736
+ Firehose.logger.error { "[Firehose] Watchdog error: #{e.class}: #{e.message}" }
737
+ rescue
738
+ # Even logging can fail (e.g., closed IO during shutdown). Swallow.
739
+ end
740
+ sleep 1
741
+ end
742
+ end
743
+ end
744
+
745
+ def tick
746
+ sleep @server.watchdog_interval
747
+ return unless @running
748
+
749
+ age = @server.seconds_since_heartbeat
750
+ if age > @server.watchdog_deadline
751
+ Firehose.logger.warn {
752
+ "[Firehose] Watchdog: no consumer heartbeat in #{age.round(1)}s " \
753
+ "(deadline #{@server.watchdog_deadline}s). Forcing reconnect."
754
+ }
755
+ @server.force_reconnect!(source: :watchdog)
756
+ # Reset our own expectations so we don't immediately re-trigger
757
+ # while the supervisor is reconnecting.
758
+ sleep @server.watchdog_deadline
759
+ else
760
+ # Probe liveness without doing anything PG-side. The consumer
761
+ # will update the heartbeat when it processes this command.
762
+ @server.enqueue_ping
763
+ end
764
+ end
765
+ end
766
+
767
+ # Periodic metrics emission. Independent of the watchdog so users can
768
+ # run one without the other. Same hardening pattern: own thread,
769
+ # catch-all rescue, never crashes the caller even if the user-supplied
770
+ # reporter raises.
771
+ class MetricsEmitter
772
+ def initialize(server)
773
+ @server = server
774
+ @thread = nil
775
+ @running = false
776
+ end
777
+
778
+ def start
779
+ return if @thread&.alive?
780
+ @running = true
781
+ @thread = Thread.new { run }
782
+ @thread.name = "firehose-metrics"
783
+ @thread.report_on_exception = false
784
+ end
785
+
786
+ def stop
787
+ @running = false
788
+ @thread&.join(2)
789
+ @thread = nil
790
+ end
791
+
792
+ def alive?
793
+ @thread&.alive? == true
794
+ end
795
+
796
+ private
797
+
798
+ def run
799
+ loop do
800
+ break unless @running
801
+ begin
802
+ sleep @server.metrics_interval
803
+ next unless @running
804
+ emit
805
+ rescue => e
806
+ begin
807
+ Firehose.logger.error { "[Firehose] MetricsEmitter error: #{e.class}: #{e.message}" }
808
+ rescue
809
+ # Even logging failed — swallow.
810
+ end
811
+ sleep 1
812
+ end
813
+ end
814
+ end
815
+
816
+ def emit
817
+ reporter = Firehose.on_metrics
818
+ return unless reporter
819
+ reporter.call(@server.metrics_snapshot)
820
+ rescue => e
821
+ # User-supplied reporter failed. Log and move on so the emitter
822
+ # thread keeps running.
823
+ Firehose.logger.warn { "[Firehose] on_metrics reporter raised: #{e.class}: #{e.message}" }
824
+ end
825
+ end
826
+
827
+ # Pool of worker threads that dispatch NOTIFY commands off the
828
+ # listener connection. Lets the consumer thread interleave
829
+ # receive_notifications with dispatch instead of blocking behind
830
+ # a backlog of NOTIFYs.
831
+ #
832
+ # Design: each worker owns its own PG connection, pops from a
833
+ # shared Thread::Queue, and executes pg_notify. Failures are
834
+ # isolated per worker — one bad connection doesn't take the pool
835
+ # down. A NOTIFY that hits a dead connection is retried once on a
836
+ # fresh one. Other failures are counted (:notify_failures) and
837
+ # dropped; broadcast messages are already persisted in
838
+ # firehose_messages so reconnecting clients catch up via replay.
839
+ # Queue messages are not persisted, which is why the retry matters.
840
+ #
841
+ # NOTIFY ordering across the pool is not preserved. Consumers
842
+ # that care about order rely on the message's id/sequence rather
843
+ # than delivery order — the same way they handle multi-writer
844
+ # broadcasts today.
845
+ class NotifyPool
846
+ def initialize(server, size:)
847
+ @server = server
848
+ @size = size
849
+ @queue = ::Queue.new
850
+ @threads = []
851
+ @running = false
852
+ end
853
+
854
+ def running?
855
+ @running && @threads.any?(&:alive?)
856
+ end
857
+
858
+ def start
859
+ return if @running
860
+ @running = true
861
+ @size.times do |i|
862
+ @threads << spawn_worker(i)
863
+ end
864
+ end
865
+
866
+ def stop
867
+ return unless @running
868
+ @running = false
869
+ @size.times { @queue.push(:shutdown) }
870
+ @threads.each { |t| t.join(2) }
871
+ @threads.clear
872
+ end
873
+
874
+ def enqueue(channel, payload)
875
+ @queue.push([channel, payload])
876
+ end
877
+
878
+ def depth
879
+ @queue.size
880
+ end
881
+
882
+ private
883
+
884
+ # Each worker runs in its own supervised loop: fetch a command,
885
+ # try to execute on its PG conn, replace the conn on error.
886
+ # Never raises out — catch-all rescue keeps the thread alive.
887
+ def spawn_worker(index)
888
+ Thread.new do
889
+ Thread.current.name = "firehose-notify-#{index}"
890
+ Thread.current.report_on_exception = false
891
+ conn = nil
892
+ loop do
893
+ cmd = @queue.pop
894
+ break if cmd == :shutdown
895
+
896
+ channel, payload = cmd
897
+ attempts = 0
898
+ begin
899
+ attempts += 1
900
+ conn ||= @server.send(:open_connection)
901
+ conn.exec_params("SELECT pg_notify($1, $2)", [channel, payload])
902
+ rescue PG::Error => e
903
+ Firehose.logger.warn {
904
+ "[Firehose] NotifyPool worker #{index}: #{e.class}: #{e.message}; reopening"
905
+ }
906
+ begin
907
+ conn&.close
908
+ rescue
909
+ # Already dead
910
+ end
911
+ conn = nil
912
+ # A dead connection says nothing about the NOTIFY itself, so
913
+ # send it once more on a fresh one. Idle connections get
914
+ # closed server-side and the worker only learns on its next
915
+ # send; without the retry that message is lost.
916
+ retry if attempts < 2 && e.is_a?(PG::ConnectionBad)
917
+ @server.incr(:notify_failures)
918
+ rescue => e
919
+ Firehose.logger.error {
920
+ "[Firehose] NotifyPool worker #{index} unexpected: #{e.class}: #{e.message}"
921
+ }
922
+ @server.incr(:notify_failures)
923
+ end
924
+ end
925
+ ensure
926
+ begin
927
+ conn&.close
928
+ rescue
929
+ # Shutting down — any close error is ignorable
930
+ end
931
+ end
932
+ end
20
933
  end
21
934
  end
22
- end
935
+ end