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/lib/firehose/server.rb
CHANGED
|
@@ -1,22 +1,935 @@
|
|
|
1
|
-
require
|
|
2
|
-
require
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|