cable_room 0.7.0.beta3 → 0.8.0.beta1

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.
data/CHANGELOG.md DELETED
@@ -1,122 +0,0 @@
1
- # Changelog.
2
-
3
- ## 1.0.0
4
-
5
- Rooms can now run in their own process pool, and browsers can be served by anycable-go. Every
6
- Room definition from 0.6 works unchanged. The channel and deployment contracts do change; the
7
- README's "Upgrading from 0.6 to 1.0" section is the checklist.
8
-
9
- ### Breaking.
10
-
11
- - **Clients must send `hello`.** A membership no longer announces itself to the Room when the
12
- channel subscribes. The browser performs `hello` once ActionCable confirms the subscription
13
- (`connected()` in the JS client), and only then does `port_connected` go out. There is no
14
- fallback: a client that never says `hello` receives broadcasts on the shared stream, but the
15
- Room never sees it and everything it sends is dropped. Browser tabs open across the deploy
16
- speak the old handshake and stay deaf until reloaded.
17
- - **`ChannelBase`, `ChannelTracker`, and `DummyConnection` are gone.** Rooms no longer run inside
18
- a synthetic ActionCable channel. `CableRoom::Host` (one per process) and `Host::Runner` (one
19
- per Room) own the work queue, worker pool, scheduler, Redlock renewal, watchdog, and shutdown.
20
- Internal names apps may have reached for moved with them:
21
- - inside a Room, `@cable_channel` is now `@runner` (a `Host::Runner`);
22
- - the `CableRoom.error_handler` context key `channel:` is now `runner:` (with `room:`,
23
- `room_class:`, and `room_key:` alongside it);
24
- - `Room#params` is gone (it delegated to the synthetic channel, which had none);
25
- - `ChannelTracker::BEAT_INTERVAL` is `Host::BEAT_INTERVAL`;
26
- - `Room::ChannelAdapter` is `Room::HostAdapter`, and the `MyRoom::Channel` constant no longer
27
- exists;
28
- - `CableRoom::Room.locally_open_rooms` and `MyRoom.locally_running_instances` read from the
29
- Host and return `[]` in a process that hosts no Rooms.
30
- - **Provisioning happens over the bus.** `join_room(..., create: true)` never starts a Room in
31
- the member's process. It publishes a `provision` request (at join and on every ping until the
32
- Room acknowledges the port), and one of the hosts starts the Room after a load-weighted
33
- delay. `Room::Base.ensure` still starts a Room directly, but only in a process that hosts
34
- Rooms: in a `room_host = :remote` web process it raises `CableRoom::Host::NotHosting`.
35
- - **Member→room traffic rides cable_room's own Redis.** Everything a member sends (`to_room`,
36
- `port_connected`, `port_ping`, `port_disconnected`, custom ports) and `Room::Base.send_message`
37
- publish on `CableRoom::Bus`, over the `CABLEROOM_*` connection. The Room side no longer
38
- touches ActionCable pubsub for inbound. That connection must be reachable from every web
39
- process and every rooms host.
40
- - **`redis` is a runtime dependency** (`>= 5.0`). It was a development dependency; rediconn
41
- needed it all along, and the bus uses it directly.
42
- - **`PortManagement::PORT_TIMEOUT` is 45 seconds**, up from 30. A member that stops pinging
43
- keeps its port 15 seconds longer.
44
- - **`room_closed` always carries a reason.** In 0.6 a close the host decided (process exit,
45
- watchdog timeout, lost lock) sent `reason: nil`; only `shutdown!` from inside the Room set one.
46
- Now the runner sets it too: `"Server shutting down"`, `"Watchdog timeout"`, the signal name on
47
- a `cable_room server` drain, and the failure on a migration that closed instead of moving.
48
- - **Instrumentation payloads.** `error.cable_room` and the error handler get the new context
49
- keys above. Every 0.6 event still fires with the same name.
50
-
51
- ### Added.
52
-
53
- - `CableRoom.configure` / `CableRoom.config` (`CableRoom::Config`) with five knobs:
54
- `room_host` (`:inline` | `:remote`), `broadcaster` (`:action_cable` | `:anycable`),
55
- `provision_delay_ms` (20), `drain_timeout` (10 minutes), and `handoff_timeout` (10 seconds).
56
- `CABLE_ROOM_HOST` and `CABLE_ROOM_BROADCASTER` override the first two, even when the block sets
57
- them. Unknown values raise at boot and name the valid options. Defaults reproduce 0.6.
58
- - `CableRoom::Broadcaster`: room→member traffic goes through `ActionCableBroadcaster`
59
- (`ActionCable.server.broadcast`, the default) or `AnyCableBroadcaster` (`AnyCable.broadcast`).
60
- Stream names are identical in both. `anycable-rails` is optional; selecting `:anycable`
61
- without it raises `Broadcaster::MissingDependency` naming the gem.
62
- - `CableRoom::Bus`: Redis pub/sub and the handoff storage on the `CABLEROOM_*` connection.
63
- Channel names: `cr:{RoomClass:key}:in` (and `:in:{port}` for custom ports), `cr:provision`,
64
- `cr:{RoomClass:key}:handoff`, `cr:{RoomClass:key}:snapshot`, `cr:{RoomClass:key}:adopted`.
65
- `Room::Base.inbound_channel(key, port)` names a Room's inbound channel.
66
- - `CableRoom::Host`: `Host.current` (nil when the process hosts nothing), `Host.instance`,
67
- `Host.start!`, `Host#open_ports` (the load gauge to scale on), `Host#open_rooms_count`,
68
- `Host#drain!`, `Host.after_drain`, `Host::NotHosting`.
69
- - `cable_room server` (`exe/cable_room`, `CableRoom::CLI`): boots the Rails app in the current
70
- directory (or `--require PATH`), refuses to run unless `room_host` is `:remote`, hosts Rooms
71
- until SIGTERM or SIGINT, and migrates open Rooms to peers on the way out. `--workers N`
72
- (default: core count) preloads the app and forks N single-GVL workers through
73
- `Host::Supervisor`, which replaces a worker that dies (with a growing delay for one that dies
74
- at boot), relays SIGTERM and SIGINT, and waits for every worker. `--version` prints the
75
- version. `CableRoom.after_fork!` resets the bus, lock manager, Redis pool, and Host in a
76
- forked child.
77
- - Load-weighted provisioning (`CableRoom::Placement`): every host hears each `provision`
78
- request, waits `provision_delay_ms × (open_rooms + jitter)`, then races for the Redlock.
79
- Exactly one wins. A draining host never claims.
80
- - Snapshot and restore: optional `snapshot_state` / `restore_state` hooks on a Room,
81
- `restored?`, `CableRoom::Snapshot` (version 1, gem-owned ports, users, reaper deadlines, and
82
- `app_state`), `Snapshot::NotSerializable` raised at snapshot time for non-JSON state, and
83
- `Host#restore_room`. A restored Room runs `restore_state` instead of `startup` when it defines
84
- it, and never broadcasts `room_opened`.
85
- - Live migration on planned shutdown (`CableRoom::Migration`): freeze → snapshot → offer (Redis,
86
- 60 second TTL) → peer adopts → relay and replay with per-stream markers → let go. Members keep
87
- their streams and lose nothing. A Room nobody adopts within `handoff_timeout` closes with
88
- `room_closed`; `drain_timeout` forces whatever is still waiting through. New lifecycle states
89
- `:freezing` and `:frozen`.
90
- - Channel actions `hello` and `ping` on every `RoomMember` channel (and so on
91
- `RoomProxyChannel`), plus the server-side `hello_room_memberships`, `RoomMembership#hello!`,
92
- `#hello_received?`, `#accepts_input?`, and the protected `room_memberships` reader.
93
- - AnyCable channel state: `CableRoom::MembershipStore` keeps each membership's identity in
94
- anycable-rails' `state_attr_accessor`, so `hello`, `receive`, `ping`, and `unsubscribed` work
95
- across anycable-rails' per-call channel instances. Under plain ActionCable nothing changes.
96
- - New `ActiveSupport::Notifications` events: `hello_received.cable_room`,
97
- `provision_requested.cable_room`, `provision_claimed.cable_room`, `room_snapshotted.cable_room`,
98
- `room_restored.cable_room`, `host_draining.cable_room`, and `room_migrated.cable_room`.
99
- - CI: an `e2e-anycable` job runs `spec/e2e` against a real `anycable/anycable-go:1.6` under both
100
- Appraisals. Locally it runs only with `CABLE_ROOM_E2E=1`.
101
- - `CHANGELOG.md` and `exe/` ship in the gem.
102
-
103
- ### Changed.
104
-
105
- - `Room::Base.room_port_key` builds the stream name itself (`"RoomClass:key:port"`, records by
106
- GlobalID). The format is the same one ActionCable produced in 0.6.
107
- - The ActionCable `restart` hook (development code reload) stops Rooms through the Host.
108
- - `RoomMembership#stream_port` takes `auto_close:`; the room-side `stream_port` takes `on_live:`.
109
- - The `Railtie` no longer declares empty `rake_tasks`, `console`, and `runner` blocks.
110
- - The README describes the 1.0 architecture: configuration, deployment modes, `cable_room
111
- server`, provisioning, the hello handshake, AnyCable's browser contract, migration, every
112
- instrumentation event, and the 0.6 → 1.0 upgrade guide.
113
-
114
- ### Fixed.
115
-
116
- - The gemspec declares `redis`, which rediconn loads but never listed. An app that didn't have
117
- `redis` in its own Gemfile could fail to boot with 0.6.
118
-
119
- ## 0.6.2.beta1
120
-
121
- The last release before distributed rooms. Rooms ran inside a synthetic ActionCable channel in
122
- every process that joined them.
data/exe/cable_room DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Loads only the CLI, not the gem: `cable_room server` boots the Rails app itself, and the app's
5
- # Gemfile is what should load CableRoom (so its Railtie runs at the usual time).
6
- require "cable_room/cli"
7
-
8
- exit CableRoom::CLI.new(ARGV).run
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CableRoom
4
- # How a Room pushes messages out to its members. Every room→member message (`from_room`, tag,
5
- # user, and `mtok` streams, plus `Room::Base.send_message`) goes through one of these, so
6
- # swapping the transport is a config change, not a code change.
7
- #
8
- # `CableRoom.config.broadcaster` picks the implementation:
9
- #
10
- # :action_cable -> ActionCableBroadcaster (the default; today's behavior)
11
- # :anycable -> AnyCableBroadcaster (needs the optional `anycable-rails` gem)
12
- #
13
- # Every broadcaster answers `broadcast(stream, payload)`. Specs can swap in a recording fake
14
- # with `Broadcaster.current = fake`; `CableRoom.reset_config!` drops it again.
15
- module Broadcaster
16
- # Raised when the config asks for a broadcaster whose gem isn't in the bundle.
17
- class MissingDependency < LoadError; end
18
-
19
- class << self
20
- # The broadcaster the current config selects. Built once per config object and reused,
21
- # so `CableRoom.reset_config!` or `CableRoom.configure` picks up a fresh one on the next
22
- # call without anyone having to remember to clear a cache.
23
- def current
24
- config = CableRoom.config
25
- return @current if @current && @current_config.equal?(config)
26
-
27
- @current_config = config
28
- @current = build(config.broadcaster)
29
- end
30
-
31
- # Replace the broadcaster for the life of the current config object. Meant for specs that
32
- # need a fake without rspec-mocks; `CableRoom.reset_config!` builds a new config, which
33
- # makes `current` rebuild from it and forget this one.
34
- def current=(broadcaster)
35
- @current_config = CableRoom.config
36
- @current = broadcaster
37
- end
38
-
39
- # Builds the broadcaster named by `name`. Config already rejects unknown names, so the
40
- # `else` branch only fires if someone bypasses config.
41
- def build(name)
42
- case name&.to_sym
43
- when :action_cable
44
- ActionCableBroadcaster.new
45
- when :anycable
46
- AnyCableBroadcaster.new
47
- else
48
- raise ArgumentError,
49
- "CableRoom broadcaster must be one of #{Config::BROADCASTERS.map(&:inspect).join(', ')} " \
50
- "(got #{name.inspect})"
51
- end
52
- end
53
- end
54
-
55
- # Publishes through the ActionCable server, which hands the message to whatever pubsub
56
- # adapter the app configured (Redis, async, ...).
57
- class ActionCableBroadcaster
58
- def broadcast(stream, payload)
59
- ActionCable.server.broadcast(stream, payload)
60
- end
61
- end
62
-
63
- # Publishes through `AnyCable.broadcast`, so anycable-go fans the message out to sockets.
64
- # Stream names are the same ones ActionCableBroadcaster uses, so a member subscribed through
65
- # anycable-go sees the same traffic it would through ActionCable.
66
- #
67
- # `anycable-rails` is optional at runtime: it's only loaded when this broadcaster is built, and
68
- # a missing gem raises MissingDependency naming what to add.
69
- #
70
- # Payload encoding: `ActionCable.server.broadcast` runs every message through its coder
71
- # (ActiveSupport::JSON by default) and hands the pubsub adapter a JSON string; anycable-rails's
72
- # own ActionCable adapter forwards that string to `AnyCable.broadcast` untouched, and
73
- # anycable-go expects the `data` field to be a pre-encoded string. So we encode here with the
74
- # same coder, and a member decodes the message exactly as it would from ActionCable.
75
- class AnyCableBroadcaster
76
- CODER = ActiveSupport::JSON
77
- GEM_NAME = "anycable-rails"
78
-
79
- class << self
80
- # Loads AnyCable on first use. `anycable` (a dependency of anycable-rails) is what defines
81
- # `AnyCable.broadcast`; requiring it rather than `anycable-rails` avoids re-running the
82
- # Rails integration in a process that already booted without it.
83
- def ensure_loaded!
84
- return if defined?(::AnyCable) && ::AnyCable.respond_to?(:broadcast)
85
-
86
- begin
87
- require "anycable"
88
- rescue LoadError
89
- raise MissingDependency,
90
- "CableRoom broadcaster :anycable needs the #{GEM_NAME} gem. " \
91
- "Add `gem \"#{GEM_NAME}\"` to your Gemfile, or set broadcaster to :action_cable."
92
- end
93
- end
94
- end
95
-
96
- def initialize
97
- self.class.ensure_loaded!
98
- end
99
-
100
- def broadcast(stream, payload)
101
- publish(stream, encode(payload))
102
- end
103
-
104
- protected
105
-
106
- # The one call that leaves the process. Fakes override this to record instead of publish.
107
- def publish(stream, encoded_payload)
108
- ::AnyCable.broadcast(stream, encoded_payload)
109
- end
110
-
111
- def encode(payload)
112
- CODER.encode(payload)
113
- end
114
- end
115
- end
116
- end
@@ -1,372 +0,0 @@
1
- require 'set'
2
- require 'active_support/json'
3
-
4
- module CableRoom
5
- # The Redis bus that carries member-to-room traffic in every mode: `to_room` messages,
6
- # provision requests, and the handoff data a room leaves behind when it migrates to another
7
- # host. It's a thin layer over the `CABLEROOM_*` Redis connection (see `CableRoom.redis_pool`)
8
- # that adds three things:
9
- #
10
- # * One place that knows the channel and key names (`cr:{room_key}:in` and friends).
11
- # * JSON encoding on the way in and decoding on the way out, using the same coder the room
12
- # ports already use for ActionCable streams, so a bad payload fails at the publisher.
13
- # * A single subscriber thread that owns a dedicated pub/sub connection and hands each
14
- # decoded message to the handler registered for its channel.
15
- #
16
- # Ordering: Redis delivers messages on one channel in publish order, and one thread dispatches
17
- # them one at a time, so a handler sees them in the order they were published. `subscribe`
18
- # blocks until Redis confirms the subscription, so anything published after it returns is
19
- # delivered.
20
- #
21
- # Handlers run on the subscriber thread. Keep them short (hand the work to a queue) and never
22
- # block in them waiting on the bus itself. A handler is allowed to call `subscribe` and
23
- # `unsubscribe`; those calls return without waiting when made from the subscriber thread,
24
- # because the confirmation they'd wait for is delivered by that same thread.
25
- class Bus
26
- # Raised at the publisher when a payload can't be turned into JSON.
27
- class EncodeError < ArgumentError; end
28
-
29
- # Raised when Redis doesn't confirm a subscribe or unsubscribe within the timeout.
30
- class TimeoutError < StandardError; end
31
-
32
- KEY_PREFIX = "cr".freeze
33
- DEFAULT_TIMEOUT = 5
34
-
35
- class << self
36
- # Pub/sub channel that a room's host listens on for member-to-room messages. A room's main
37
- # inbound port has no suffix (`cr:{room_key}:in`); a custom inbound port gets one
38
- # (`cr:{room_key}:in:{port}`), so every channel a room listens on shares one prefix.
39
- # `Room::Base.inbound_channel` is what turns a room class, key, and port into these.
40
- def inbound_channel(room_key, port = nil)
41
- channel = room_channel(room_key, "in")
42
- port.nil? ? channel : "#{channel}:#{port}"
43
- end
44
-
45
- # Pub/sub channel every host listens on for "someone wants room X to exist" requests.
46
- def provision_channel
47
- "#{KEY_PREFIX}:provision"
48
- end
49
-
50
- # Redis list where a migrating room's old host parks inbound messages until the new host
51
- # has adopted the room.
52
- def handoff_list(room_key)
53
- room_channel(room_key, "handoff")
54
- end
55
-
56
- # Redis string (with a TTL) holding the frozen room's snapshot during a migration.
57
- def snapshot_key(room_key)
58
- room_channel(room_key, "snapshot")
59
- end
60
-
61
- # Pub/sub channel the adopting host publishes on once it has taken over the room.
62
- def adopted_channel(room_key)
63
- room_channel(room_key, "adopted")
64
- end
65
-
66
- private
67
-
68
- def room_channel(room_key, suffix)
69
- key = room_key.to_s
70
- raise ArgumentError, "room_key can't be blank" if key.empty?
71
- "#{KEY_PREFIX}:#{key}:#{suffix}"
72
- end
73
- end
74
-
75
- # `redis_pool` defaults to `CableRoom.redis_pool` (looked up lazily so specs can swap it).
76
- def initialize(redis_pool: nil)
77
- @redis_pool = redis_pool
78
-
79
- # Everything below is guarded by @mutex and changes are announced on @changed
80
- @mutex = Mutex.new
81
- @changed = ConditionVariable.new
82
- @handlers = {} # channel => handler; the channels we want to be subscribed to
83
- @requested = Set.new # channels we've sent SUBSCRIBE for in the current session
84
- @confirmed = Set.new # channels Redis has confirmed in the current session
85
- @thread = nil # the subscriber thread, nil when no channels are wanted
86
- @session_live = false # true while @subscriber_redis accepts commands from other threads
87
- @subscriber_redis = nil
88
- end
89
-
90
- # ---- Publisher and plain key operations --------------------------------------------------
91
-
92
- # Publish a JSON-encodable payload. Returns the number of subscribers that received it.
93
- def publish(channel, payload)
94
- encoded = encode(payload)
95
- redis { |r| r.publish(channel, encoded) }
96
- end
97
-
98
- # Append one or more payloads to the end of a list. Returns the list's new length. With `ttl`
99
- # (seconds or a Duration) the list's expiry is reset in the same round trip, so a list nobody
100
- # deletes (a host that died mid-handoff) still goes away on its own.
101
- def rpush(key, *payloads, ttl: nil)
102
- encoded = payloads.map { |payload| encode(payload) }
103
- return redis { |r| r.rpush(key, encoded) } unless ttl
104
-
105
- seconds = ttl.to_i
106
- raise ArgumentError, "ttl must be a positive number of seconds" unless seconds.positive?
107
- length, = redis { |r| r.pipelined { |p| p.rpush(key, encoded); p.expire(key, seconds) } }
108
- length
109
- end
110
-
111
- # The whole list, oldest first, decoded.
112
- def lrange(key)
113
- redis { |r| r.lrange(key, 0, -1) }.map { |raw| decode(raw) }
114
- end
115
-
116
- # Delete keys. Returns how many existed.
117
- def del(*keys)
118
- redis { |r| r.del(*keys) }
119
- end
120
-
121
- # Store a payload under a key that expires after `ttl` (seconds or an
122
- # ActiveSupport::Duration). Migration uses this for snapshots so a dead handoff cleans
123
- # itself up.
124
- def set(key, payload, ttl:)
125
- seconds = ttl.to_i
126
- raise ArgumentError, "ttl must be a positive number of seconds" unless seconds.positive?
127
- encoded = encode(payload)
128
- redis { |r| r.set(key, encoded, ex: seconds) }
129
- end
130
-
131
- # The decoded payload stored under a key, or nil when there isn't one.
132
- def get(key)
133
- raw = redis { |r| r.get(key) }
134
- raw.nil? ? nil : decode(raw)
135
- end
136
-
137
- # ---- Subscriber -------------------------------------------------------------------------
138
-
139
- # Register `handler` for `channel` and block until Redis confirms the subscription. The
140
- # handler is called as `handler.call(message, channel)` with the decoded message on the
141
- # subscriber thread. Subscribing again to the same channel replaces the handler.
142
- #
143
- # Raises TimeoutError (and forgets the handler) if the subscription isn't confirmed in time,
144
- # which usually means Redis is unreachable.
145
- def subscribe(channel, timeout: DEFAULT_TIMEOUT, &handler)
146
- raise ArgumentError, "subscribe needs a block to handle messages" unless handler
147
- channel = channel.to_s
148
-
149
- @mutex.synchronize do
150
- @handlers[channel] = handler
151
- if @thread
152
- reconcile
153
- else
154
- start_subscriber_thread
155
- end
156
-
157
- return true if on_subscriber_thread?
158
-
159
- begin
160
- wait_until(timeout, "subscription to #{channel}") { @confirmed.include?(channel) }
161
- rescue TimeoutError
162
- @handlers.delete(channel)
163
- reconcile
164
- raise
165
- end
166
- end
167
- true
168
- end
169
-
170
- # Drop the handler for `channel` and block until Redis confirms the unsubscribe. When no
171
- # channels are left the subscriber thread exits, and this also waits for that. Returns
172
- # false if the channel wasn't subscribed.
173
- def unsubscribe(channel, timeout: DEFAULT_TIMEOUT)
174
- channel = channel.to_s
175
-
176
- @mutex.synchronize do
177
- return false unless @handlers.delete(channel)
178
- reconcile
179
-
180
- return true if on_subscriber_thread?
181
-
182
- wait_until(timeout, "unsubscribe from #{channel}") do
183
- !@confirmed.include?(channel) && (@thread.nil? || @handlers.any?)
184
- end
185
- end
186
- true
187
- end
188
-
189
- # Unsubscribe from everything and wait for the subscriber thread to exit.
190
- def shutdown(timeout: DEFAULT_TIMEOUT)
191
- @mutex.synchronize do
192
- @handlers.clear
193
- reconcile
194
- return true if on_subscriber_thread?
195
- wait_until(timeout, "subscriber thread to exit") { @thread.nil? }
196
- end
197
- true
198
- end
199
-
200
- # Channels Redis has confirmed we're subscribed to right now.
201
- def subscribed_channels
202
- @mutex.synchronize { @confirmed.to_a }
203
- end
204
-
205
- def subscribed?(channel)
206
- @mutex.synchronize { @confirmed.include?(channel.to_s) }
207
- end
208
-
209
- def subscriber_alive?
210
- thread = @mutex.synchronize { @thread }
211
- !!thread&.alive?
212
- end
213
-
214
- private
215
-
216
- def redis_pool
217
- @redis_pool || CableRoom.redis_pool
218
- end
219
-
220
- def redis(&blk)
221
- redis_pool.with(&blk)
222
- end
223
-
224
- def encode(payload)
225
- ActiveSupport::JSON.encode(payload)
226
- rescue StandardError => e
227
- raise EncodeError, "payload can't be encoded as JSON: #{e.class}: #{e.message}"
228
- end
229
-
230
- def decode(raw)
231
- ActiveSupport::JSON.decode(raw)
232
- end
233
-
234
- def on_subscriber_thread?
235
- Thread.current == @thread
236
- end
237
-
238
- # The subscriber needs a connection of its own: a pub/sub connection can't run other
239
- # commands, and it lives as long as there are subscriptions, so it must not be a pooled
240
- # one (a small pool would run dry). Borrow a pooled client just long enough to dup it,
241
- # which gives an unconnected client with the same settings and no pool ties.
242
- def build_subscriber_redis
243
- redis(&:dup)
244
- end
245
-
246
- def start_subscriber_thread
247
- @thread = Thread.new { subscriber_loop }
248
- @thread.name = "cable_room-bus-subscriber"
249
- @thread.report_on_exception = false
250
- end
251
-
252
- # Runs on the subscriber thread. Each pass opens one pub/sub session covering every wanted
253
- # channel; a session ends when the last channel is unsubscribed (normal) or the connection
254
- # fails (reported, then retried with backoff). The loop exits once nothing is wanted.
255
- def subscriber_loop
256
- backoff = 0.1
257
- loop do
258
- channels = @mutex.synchronize do
259
- if @handlers.empty?
260
- @thread = nil
261
- @changed.broadcast
262
- return
263
- end
264
- @handlers.keys
265
- end
266
-
267
- begin
268
- run_session(channels)
269
- backoff = 0.1
270
- rescue StandardError => e
271
- CableRoom.report_error(e, bus: self, channels: channels)
272
- sleep backoff
273
- backoff = [backoff * 2, 5].min
274
- end
275
- end
276
- end
277
-
278
- def run_session(channels)
279
- redis = build_subscriber_redis
280
- @mutex.synchronize { @requested = Set.new(channels) }
281
-
282
- # redis-rb blocks here until the subscription count drops to zero, calling back for every
283
- # event. Other threads add and remove channels by sending commands on the same client.
284
- redis.subscribe(*channels) do |on|
285
- on.subscribe do |channel, _count|
286
- @mutex.synchronize do
287
- unless @session_live
288
- # redis-rb has the pub/sub socket up once the first confirmation arrives, so from
289
- # here on other threads may send SUBSCRIBE/UNSUBSCRIBE through it
290
- @session_live = true
291
- @subscriber_redis = redis
292
- end
293
- @confirmed << channel
294
- reconcile
295
- @changed.broadcast
296
- end
297
- end
298
-
299
- on.unsubscribe do |channel, count|
300
- @mutex.synchronize do
301
- @confirmed.delete(channel)
302
- @requested.delete(channel)
303
- if count.zero?
304
- # redis-rb closes the socket as soon as this callback returns, so stop other
305
- # threads from writing to it. Anything wanted by then starts a fresh session.
306
- @session_live = false
307
- @subscriber_redis = nil
308
- else
309
- reconcile
310
- end
311
- @changed.broadcast
312
- end
313
- end
314
-
315
- on.message do |channel, raw|
316
- dispatch(channel, raw)
317
- end
318
- end
319
- ensure
320
- @mutex.synchronize do
321
- @session_live = false
322
- @subscriber_redis = nil
323
- @requested.clear
324
- @confirmed.clear
325
- @changed.broadcast
326
- end
327
- redis&.close
328
- end
329
-
330
- # Bring the server-side subscription set in line with the handlers we hold. Only possible
331
- # while a session is live; before that, the thread's first confirmation calls this to pick
332
- # up anything added or removed while it was connecting. Caller must hold @mutex.
333
- def reconcile
334
- return unless @session_live
335
-
336
- wanted = @handlers.keys
337
- (wanted - @requested.to_a).each do |channel|
338
- @requested << channel
339
- @subscriber_redis.subscribe(channel)
340
- end
341
- (@requested.to_a - wanted).each do |channel|
342
- @requested.delete(channel)
343
- @subscriber_redis.unsubscribe(channel)
344
- end
345
- end
346
-
347
- # Decode and hand one message to its handler. A handler that raises is reported and the
348
- # thread carries on; one bad message must never take the bus down.
349
- def dispatch(channel, raw)
350
- handler = @mutex.synchronize { @handlers[channel] }
351
- return unless handler # unsubscribed while the message was in flight
352
-
353
- handler.call(decode(raw), channel)
354
- rescue StandardError => e
355
- CableRoom.report_error(e, bus: self, channel: channel, raw_message: raw)
356
- end
357
-
358
- # Wait on @changed until the block is true or the timeout passes. Caller must hold @mutex.
359
- def wait_until(timeout, reason)
360
- deadline = monotonic_now + timeout
361
- until yield
362
- remaining = deadline - monotonic_now
363
- raise TimeoutError, "Timed out after #{timeout}s waiting for #{reason}" if remaining <= 0
364
- @changed.wait(@mutex, remaining)
365
- end
366
- end
367
-
368
- def monotonic_now
369
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
370
- end
371
- end
372
- end