cable_room 0.6.2.beta1 → 0.7.0.beta2

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +662 -48
  4. data/cable_room.gemspec +5 -2
  5. data/exe/cable_room +8 -0
  6. data/lib/cable_room/broadcaster.rb +116 -0
  7. data/lib/cable_room/bus.rb +372 -0
  8. data/lib/cable_room/cli.rb +237 -0
  9. data/lib/cable_room/config.rb +112 -0
  10. data/lib/cable_room/host/bus_inbound.rb +36 -0
  11. data/lib/cable_room/host/runner.rb +577 -0
  12. data/lib/cable_room/host/supervisor.rb +275 -0
  13. data/lib/cable_room/host/worker_pool.rb +37 -0
  14. data/lib/cable_room/host.rb +482 -0
  15. data/lib/cable_room/membership_store.rb +105 -0
  16. data/lib/cable_room/migration.rb +586 -0
  17. data/lib/cable_room/periodic_timer.rb +18 -0
  18. data/lib/cable_room/placement.rb +260 -0
  19. data/lib/cable_room/ports.rb +20 -50
  20. data/lib/cable_room/railtie.rb +3 -12
  21. data/lib/cable_room/room/base.rb +45 -39
  22. data/lib/cable_room/room/host_adapter.rb +52 -0
  23. data/lib/cable_room/room/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +70 -2
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +82 -0
  27. data/lib/cable_room/room/threading.rb +2 -2
  28. data/lib/cable_room/room/user_management.rb +27 -0
  29. data/lib/cable_room/room.rb +5 -2
  30. data/lib/cable_room/room_harness.rb +168 -0
  31. data/lib/cable_room/room_member.rb +293 -69
  32. data/lib/cable_room/room_proxy_channel.rb +13 -2
  33. data/lib/cable_room/snapshot.rb +136 -0
  34. data/lib/cable_room/version.rb +1 -1
  35. data/lib/cable_room.rb +57 -2
  36. metadata +26 -9
  37. data/lib/cable_room/channel_base.rb +0 -247
  38. data/lib/cable_room/channel_tracker.rb +0 -130
  39. data/lib/cable_room/room/channel_adapter.rb +0 -18
@@ -0,0 +1,482 @@
1
+ require 'socket'
2
+ require_relative 'host/worker_pool'
3
+ require_relative 'host/bus_inbound'
4
+ require_relative 'host/runner'
5
+ require_relative 'host/supervisor'
6
+
7
+ module CableRoom
8
+ # One Host per process. It owns every Room running here: the worker pool their work runs on,
9
+ # the scheduler behind `periodically` timers and the watchdog beat, the inbound transport they
10
+ # receive on, the list of open rooms, and shutting all of them down when the process exits.
11
+ #
12
+ # Each room gets a Host::Runner, which keeps that room's own ordered work queue. A room is only
13
+ # ever processed by one thread at a time, in arrival order. The Host is what lets many rooms
14
+ # share one pool of threads without breaking that promise.
15
+ class Host
16
+ BEAT_INTERVAL = 5.seconds
17
+
18
+ # Raised when a process that doesn't host rooms asks for its Host. With `room_host = :remote`
19
+ # only `cable_room server` hosts rooms; a web process must never start one.
20
+ class NotHosting < StandardError; end
21
+
22
+ NOT_HOSTING_MESSAGE =
23
+ "This process doesn't host rooms: CableRoom.config.room_host is :remote, so rooms run " \
24
+ "under `cable_room server`. Start rooms there (or call CableRoom::Host.start! first if this " \
25
+ "process really should host them).".freeze
26
+
27
+ @start_mutex = Mutex.new
28
+
29
+ class << self
30
+ # The Host this process runs, or nil when it runs none (a web process in :remote, or an
31
+ # :inline process that hasn't started a room yet). Use this for introspection and cleanup,
32
+ # where "no rooms here" is a normal answer.
33
+ def current
34
+ @current
35
+ end
36
+
37
+ # The Host this process runs. In :inline every process hosts rooms, so the first caller
38
+ # builds it. In :remote only a process that declared itself a rooms host with `start!`
39
+ # (`cable_room server`) has one; anyone else gets NotHosting, so a room can never quietly
40
+ # start in the web tier because some code path reached for the Host.
41
+ def instance
42
+ current || (CableRoom.config.inline? ? start! : raise(NotHosting, NOT_HOSTING_MESSAGE))
43
+ end
44
+
45
+ # Make this process a rooms host. Builds the Host once and starts its provisioning listener,
46
+ # so this process hears `create: true` requests from then on; later calls return the same
47
+ # Host. `cable_room server` calls this explicitly; in :inline `instance` calls it for you.
48
+ def start!
49
+ @start_mutex.synchronize { @current ||= new.tap(&:start_placement) }
50
+ end
51
+
52
+ # Swap this process's Host for `host` (nil allowed) and return the previous one. Only specs
53
+ # that play two process roles in one Ruby process should need this (see RoomHostHarness):
54
+ # the "web" side has to see no Host while another Host runs the rooms.
55
+ def replace_current(host)
56
+ @start_mutex.synchronize { @current.tap { @current = host } }
57
+ end
58
+ end
59
+
60
+ # How many rooms `drain!` hands off at once before `drain_timeout`. Each handoff costs the
61
+ # adopting peer a full room restore (its startup chain, often database reads), so a host with
62
+ # hundreds of rooms must not offer them all in the same second; and each one holds a thread
63
+ # here for up to `handoff_timeout`. Four keeps a big host draining in well under the default
64
+ # ten minutes (a handoff normally takes well under a second) without swamping the fleet.
65
+ DRAIN_CONCURRENCY = 4
66
+
67
+ # What `drain!` returns: how the rooms ended up, and how long it took. `migrated` and
68
+ # `closed` are Migration objects (see their `state` and `room`).
69
+ DrainResult = Struct.new(:reason, :migrated, :closed, :duration, keyword_init: true) do
70
+ def rooms
71
+ migrated.size + closed.size
72
+ end
73
+ end
74
+
75
+ attr_reader :scheduler, :inbound, :placement
76
+
77
+ # True once this host has started shutting down for good and is handing its rooms off (see
78
+ # `drain!`). A draining host never claims a provision request, so it can't take on a room it
79
+ # would only have to hand off again.
80
+ attr_writer :draining
81
+
82
+ delegate :logger, to: :cable_server
83
+
84
+ class << self
85
+ # Register a block to run when a `drain!` finishes, with the Host and its DrainResult. An
86
+ # app that scales in through an ASG termination lifecycle hook completes the hook here
87
+ # (`complete-lifecycle-action`), once every room has moved or closed. Blocks run on the
88
+ # draining thread before `drain!` returns; one that raises is reported and doesn't stop
89
+ # the others.
90
+ def after_drain(&blk)
91
+ raise ArgumentError, "after_drain needs a block" unless blk
92
+ after_drain_callbacks << blk
93
+ blk
94
+ end
95
+
96
+ def after_drain_callbacks
97
+ @after_drain_callbacks ||= []
98
+ end
99
+ end
100
+
101
+ # `inbound` is how rooms receive messages: by default the process-wide Bus, so a room hears
102
+ # its members the same way whether it runs inside the web process or on a remote host.
103
+ def initialize(inbound: BusInbound.new)
104
+ @runners = Set.new
105
+ @monitor = Monitor.new
106
+ @inbound = inbound
107
+ @draining = false
108
+ @drain_result = nil
109
+ @drain_thread = nil
110
+ @migrations = []
111
+ @scheduler = Rufus::Scheduler.new
112
+
113
+ # A rooms host that leaves with rooms still open (a crash on the main thread, say; on a
114
+ # SIGTERM `cable_room server` has already drained by now, so this is a no-op) gives them
115
+ # the same chance to move as a planned shutdown would. In :inline there is no fleet of
116
+ # peers by design (see `drain!`), so exit closes rooms the way it always has.
117
+ at_exit do
118
+ logger.info "Shutting down CableRoom"
119
+ if CableRoom.config.remote? && !shutdown? && runners.any?
120
+ drain!(reason: "process exit")
121
+ else
122
+ shutdown!
123
+ end
124
+ end
125
+
126
+ # Every few seconds each room renews its Redlock and checks its watchdog
127
+ scheduler.every(BEAT_INTERVAL) do
128
+ each_runner(&:beat)
129
+ end
130
+ end
131
+
132
+ def worker_pool
133
+ @worker_pool || @monitor.synchronize do
134
+ @worker_pool ||= WorkerPool.new(max_size: cable_server.config.worker_pool_size)
135
+ end
136
+ end
137
+
138
+ # Start `room_class`'s room for `key` on this Host if nobody else is running it. Takes the
139
+ # room's Redlock first, so exactly one process cluster-wide wins; returns false when the lock
140
+ # is already held. This is the host-side way to bring a room up: `Room::Base.ensure` calls it
141
+ # on the process Host, Placement calls it for a provision request, and `cable_room server`
142
+ # (or a spec harness) can call it on a Host it owns without any member asking.
143
+ #
144
+ # If a snapshot of the room is on offer (another host is handing it off, see
145
+ # CableRoom::Migration), winning the lock means adopting it: the room comes back here with
146
+ # its members and state rather than starting fresh underneath the old host. The check is
147
+ # made after the lock is won, so it can't be raced. `handoff_only: true` (a handoff request)
148
+ # starts nothing when there is no snapshot to adopt — it has expired, or the old host took
149
+ # the room back — and lets the lock go.
150
+ def ensure_room(room_class, key = nil, handoff_only: false, tenant: nil)
151
+ lock_key = room_class.room_port_key(key)
152
+ lock_info = CableRoom.lock_manager.lock(lock_key, room_class::LOCK_DURATION.in_milliseconds)
153
+ return false unless lock_info
154
+
155
+ snapshot = bus.get(Bus.snapshot_key(lock_key))
156
+ return Migration.adopt(self, room_class, key, lock_info: lock_info, snapshot: snapshot) if snapshot
157
+
158
+ if handoff_only
159
+ logger.info "No snapshot to adopt for #{lock_key}; letting the lock go"
160
+ CableRoom.lock_manager.unlock(lock_info)
161
+ return false
162
+ end
163
+
164
+ start_room(
165
+ room_class,
166
+ key,
167
+ lock_info,
168
+ watchdog_interval: room_class::WATCH_DOG_INTERVAL,
169
+ lock_duration: room_class::LOCK_DURATION,
170
+ tenant: tenant,
171
+ )
172
+
173
+ true
174
+ rescue => e
175
+ CableRoom.lock_manager.unlock(lock_info) if lock_info
176
+ raise e
177
+ end
178
+
179
+ # Start running a room here. The caller (`ensure_room`) has already taken the room's Redlock;
180
+ # the runner renews it from now on and releases it when the room dies. `tenant:` comes from the
181
+ # member's provision request (see RoomMembership#initialize / #maybe_provision_room and
182
+ # Placement#claim), already defaulted there to the ambient tenant if the app didn't pass one
183
+ # explicitly -- nil past that point just means no Apartment multi-tenancy is in play.
184
+ def start_room(room_class, key, lock_info, watchdog_interval:, lock_duration:, tenant: nil)
185
+ runner = Runner.new(self, room_class, key, lock_info, watchdog_interval:, lock_duration:, tenant:)
186
+ runner.start!
187
+ runner
188
+ end
189
+
190
+ # Bring a room back from a CableRoom::Snapshot on this Host, the way a migration's adopter
191
+ # does. With no `lock_info`, takes the room's Redlock first and returns false if someone else
192
+ # holds it — the same rule as `ensure_room`. With one, the caller has already claimed the lock.
193
+ # Raises Snapshot::UnknownVersion or Snapshot::UnknownRoomClass before anything is built if
194
+ # the snapshot can't be used here. `hold_inbound: true` brings the room up frozen, holding
195
+ # its inbound until `Runner#thaw!` (see Runner#restore!).
196
+ #
197
+ # If the room fails to come up, the lock is released either way — the caller's too. A room
198
+ # that can't start here must not keep its key locked: the migration that handed it over
199
+ # takes the lock back and restarts it as soon as the lock is free.
200
+ def restore_room(snapshot, lock_info = nil, hold_inbound: false)
201
+ snapshot = Snapshot.validate!(snapshot)
202
+ room_class = Snapshot.room_class_for(snapshot)
203
+ key = Snapshot.key_for(snapshot)
204
+ lock_key = room_class.room_port_key(key)
205
+
206
+ if lock_info
207
+ unless lock_info[:resource] == lock_key
208
+ raise ArgumentError, "lock_info is for #{lock_info[:resource].inspect}, but the snapshot is #{lock_key}"
209
+ end
210
+ else
211
+ lock_info = CableRoom.lock_manager.lock(lock_key, room_class::LOCK_DURATION.in_milliseconds)
212
+ return false unless lock_info
213
+ end
214
+
215
+ runner = Runner.new(
216
+ self,
217
+ room_class,
218
+ key,
219
+ lock_info,
220
+ watchdog_interval: room_class::WATCH_DOG_INTERVAL,
221
+ lock_duration: room_class::LOCK_DURATION,
222
+ tenant: snapshot["tenant"],
223
+ )
224
+ runner.restore!(snapshot, hold_inbound: hold_inbound)
225
+ runner
226
+ rescue => e
227
+ # `restore!` releases the lock itself when the room fails to come up; this covers a failure
228
+ # before the runner exists
229
+ CableRoom.lock_manager.unlock(lock_info) if lock_info && runner.nil?
230
+ raise e
231
+ end
232
+
233
+ # A name for this host in migration traffic and instrumentation (`from_host`, `to_host`):
234
+ # machine, pid, and a few random characters, so two hosts in one process (a spec) differ too.
235
+ def id
236
+ @id ||= "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(3)}"
237
+ end
238
+
239
+ # The Bus this host's rooms listen on, for the migration's list and key operations. Falls
240
+ # back to the process-wide Bus when `inbound` isn't Bus-backed.
241
+ def bus
242
+ inbound.respond_to?(:bus) ? inbound.bus : CableRoom.bus
243
+ end
244
+
245
+ def track(runner)
246
+ @monitor.synchronize do
247
+ raise "Cannot add Room after shutdown" if @shutdown
248
+ @runners << runner
249
+ end
250
+ end
251
+
252
+ def untrack(runner)
253
+ @monitor.synchronize { @runners.delete(runner) }
254
+ end
255
+
256
+ # A snapshot, so callers can iterate while rooms come and go
257
+ def runners
258
+ @monitor.synchronize { @runners.to_a }
259
+ end
260
+
261
+ def rooms
262
+ runners.map(&:room)
263
+ end
264
+
265
+ # How many member ports are attached to rooms on this Host right now. This is the load
266
+ # number the design scales the rooms pool on, so an app can publish it as a gauge. It reads
267
+ # each room's port table from outside the room's thread; a count that's a moment stale is
268
+ # fine for a metric.
269
+ def open_ports
270
+ rooms.sum(&:open_port_count)
271
+ end
272
+
273
+ # How many rooms this Host runs right now. This is the load number provisioning weights its
274
+ # delay by: a host with more rooms waits longer before racing for a new one.
275
+ def open_rooms_count
276
+ @monitor.synchronize { @runners.size }
277
+ end
278
+
279
+ def each_runner(&blk)
280
+ runners.each(&blk)
281
+ end
282
+
283
+ def draining?
284
+ @draining
285
+ end
286
+
287
+ # True once `drain!` has finished (every room moved or closed, and the host is shut down).
288
+ def drained?
289
+ !@drain_result.nil?
290
+ end
291
+
292
+ # The result of the drain, or nil until `drain!` has finished.
293
+ def drain_result
294
+ @drain_result
295
+ end
296
+
297
+ # Every CableRoom::Migration this host's drain has started, oldest room first. Each one's
298
+ # `state` says where it is; the list is complete once `drained?`.
299
+ def migrations
300
+ @monitor.synchronize { @migrations.dup }
301
+ end
302
+
303
+ # Planned shutdown: move every room here to a peer host, then shut down. This is what SIGTERM
304
+ # means to `cable_room server` (see CableRoom::Migration for the per-room protocol):
305
+ #
306
+ # 1. Mark the host draining, so its Placement claims nothing more (a room it took now would
307
+ # only have to move again), and instrument `host_draining.cable_room`.
308
+ # 2. Hand rooms off oldest first, DRAIN_CONCURRENCY at a time. A room nobody adopts within
309
+ # `handoff_timeout` closes with `room_closed`; so does one that can't be snapshotted.
310
+ # 3. When `drain_timeout` passes with rooms still waiting their turn, offer all of them at
311
+ # once — the same path, just no longer throttled — so the drain ends within about one
312
+ # more `handoff_timeout` whatever the fleet does.
313
+ # 4. `shutdown!`, run the `Host.after_drain` callbacks, and return a DrainResult.
314
+ #
315
+ # Blocks until done. Runs once: a second call (from another thread, or `at_exit` after
316
+ # `cable_room server` has already drained) waits for the first and returns its result.
317
+ # `drain_timeout` and `handoff_timeout` default to the config's; specs pass short ones.
318
+ #
319
+ # In :inline the peers are the other web processes hosting rooms, if any. The design gives
320
+ # :inline no fleet of its own, so the gem doesn't drain there on exit (see `at_exit`); an app
321
+ # running several inline processes may still call this from its own signal handling.
322
+ def drain!(reason:, drain_timeout: nil, handoff_timeout: nil, concurrency: DRAIN_CONCURRENCY)
323
+ other = @monitor.synchronize do
324
+ return @drain_result if @drain_result
325
+ @drain_thread.tap { @drain_thread ||= Thread.current }
326
+ end
327
+ if other
328
+ other.join unless other == Thread.current
329
+ return @drain_result
330
+ end
331
+
332
+ drain_timeout ||= CableRoom.config.drain_timeout
333
+ handoff_timeout ||= CableRoom.config.handoff_timeout
334
+ started = monotonic_now
335
+ self.draining = true
336
+
337
+ queued = runners.sort_by(&:started_at)
338
+ migrations = queued.map { |runner| Migration.new(self, runner, reason: reason, handoff_timeout: handoff_timeout) }
339
+ @monitor.synchronize { @migrations = migrations }
340
+ logger.info "Draining #{migrations.size} room(s): #{reason}"
341
+
342
+ payload = { host: self, rooms: queued.map(&:room), reason: reason }
343
+ ActiveSupport::Notifications.instrument("host_draining.cable_room", payload) do |event|
344
+ run_migrations(migrations, concurrency: concurrency, deadline: started + drain_timeout.to_f)
345
+ event[:migrated] = migrations.count(&:migrated?)
346
+ event[:closed] = migrations.count { |m| !m.migrated? }
347
+ end
348
+
349
+ shutdown!
350
+
351
+ result = DrainResult.new(
352
+ reason: reason,
353
+ migrated: migrations.select(&:migrated?),
354
+ closed: migrations.reject(&:migrated?),
355
+ duration: monotonic_now - started,
356
+ )
357
+ logger.info "Drained: #{result.migrated.size} room(s) migrated, #{result.closed.size} closed, in #{result.duration.round(2)}s"
358
+ @monitor.synchronize { @drain_result = result }
359
+
360
+ self.class.after_drain_callbacks.each do |callback|
361
+ callback.call(self, result)
362
+ rescue => e
363
+ CableRoom.report_error(e, host: self, drain_result: result)
364
+ end
365
+
366
+ result
367
+ end
368
+
369
+ def shutdown?
370
+ @shutdown
371
+ end
372
+
373
+ # Start listening for provision requests (see CableRoom::Placement). `Host.start!` does this
374
+ # for the process Host; a Host built with `new` (a spec harness) calls it itself. Pass a
375
+ # Placement to use one with non-default settings; calling it again re-arms the current one.
376
+ def start_placement(placement = nil)
377
+ previous = @monitor.synchronize do
378
+ raise "Cannot start placement after shutdown" if @shutdown
379
+ @placement.tap { @placement = placement || @placement || Placement.new(self) }
380
+ end
381
+ previous.stop if previous && previous != @placement
382
+ @placement.start
383
+ @placement
384
+ end
385
+
386
+ # Stop listening and drop any claim still waiting. Nothing starts a room here after this.
387
+ def stop_placement
388
+ @placement&.stop
389
+ end
390
+
391
+ # Stop everything: the beat, every room (gracefully, with a bounded wait), then the pool.
392
+ # Runs once; later calls do nothing.
393
+ def shutdown!
394
+ @monitor.synchronize do
395
+ return if @shutdown
396
+ @shutdown = true
397
+ end
398
+
399
+ # Stop claiming first: a room started after this point would only be shut down again
400
+ stop_placement
401
+ scheduler.shutdown
402
+ shutdown_rooms!
403
+ worker_pool.executor.shutdown
404
+ worker_pool.executor.wait_for_termination(5)
405
+ worker_pool.executor.kill
406
+ end
407
+
408
+ # Ask every room to finish what it has queued and shut down, then give them up to
409
+ # `wait` seconds to do it
410
+ def shutdown_rooms!(wait: 15)
411
+ each_runner { |runner| runner.initiate_shutdown("Server shutting down") }
412
+
413
+ wait.times do
414
+ break if runners.empty?
415
+ sleep 1
416
+ end
417
+ end
418
+
419
+ # Stop every room right now, dropping whatever work is still queued. ActionCable's `restart`
420
+ # calls this when the app reloads.
421
+ def stop_all_rooms!
422
+ each_runner(&:stop!)
423
+ end
424
+
425
+ private
426
+
427
+ # Run the migrations `concurrency` at a time until `deadline`, then everything still waiting
428
+ # at once. Each runs on a thread of its own rather than the worker pool: a handoff blocks for
429
+ # up to `handoff_timeout` waiting on a peer, and the rooms still need the pool to run.
430
+ def run_migrations(migrations, concurrency:, deadline:)
431
+ queue = Queue.new
432
+ migrations.each { |migration| queue << migration }
433
+ next_migration = -> { queue.pop(true) rescue nil } # non-blocking: nil once the queue is empty
434
+
435
+ workers = Array.new([concurrency, migrations.size].min) do |i|
436
+ migration_thread("cable_room-drain-#{i}") do
437
+ while (migration = next_migration.call)
438
+ migration.run
439
+ end
440
+ end
441
+ end
442
+
443
+ workers.each do |thread|
444
+ remaining = deadline - monotonic_now
445
+ break if remaining <= 0
446
+ thread.join(remaining)
447
+ end
448
+
449
+ forced = []
450
+ while (migration = next_migration.call)
451
+ forced << migration
452
+ end
453
+ if forced.any?
454
+ logger.warn "drain_timeout passed with #{forced.size} room(s) still waiting; handing them all off now"
455
+ end
456
+ forced_threads = forced.map { |migration| migration_thread("cable_room-drain-forced") { migration.run } }
457
+
458
+ (workers + forced_threads).each(&:join)
459
+ end
460
+
461
+ def migration_thread(name)
462
+ thread = Thread.new do
463
+ yield
464
+ rescue => e
465
+ # Migration#run handles its own failures; this is for a bug in the loop itself, which
466
+ # must not take the whole drain down with it
467
+ CableRoom.report_error(e, host: self)
468
+ end
469
+ thread.name = name
470
+ thread.report_on_exception = false
471
+ thread
472
+ end
473
+
474
+ def monotonic_now
475
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
476
+ end
477
+
478
+ def cable_server
479
+ ActionCable.server
480
+ end
481
+ end
482
+ end
@@ -0,0 +1,105 @@
1
+ module CableRoom
2
+ # Where a channel keeps its RoomMemberships between calls. `RoomMember#_room_memberships` is one
3
+ # of these, and RoomMembership adds and removes itself from it.
4
+ #
5
+ # Under ActionCable the channel object lives as long as the socket, so an in-memory set is the
6
+ # whole story: this base class. Under anycable-rails every RPC call (subscribe, message,
7
+ # unsubscribe, disconnect) builds a fresh channel instance and never re-runs `subscribed`, so an
8
+ # in-memory set is empty by the time `hello` or `receive` arrive. The AnyCable subclass writes
9
+ # each membership's identity into the channel's AnyCable state, which anycable-go hands back on
10
+ # every call, and rebuilds the memberships from it on first use.
11
+ class MembershipStore
12
+ include Enumerable
13
+
14
+ def self.for(channel)
15
+ channel.anycable_channel? ? AnyCable.new(channel) : new(channel)
16
+ end
17
+
18
+ def initialize(channel)
19
+ @channel = channel
20
+ @memberships = Set.new
21
+ end
22
+
23
+ def <<(membership)
24
+ memberships << membership
25
+ persist!
26
+ self
27
+ end
28
+
29
+ def delete(membership)
30
+ memberships.delete(membership)
31
+ persist!
32
+ end
33
+
34
+ def clear
35
+ memberships.clear
36
+ persist!
37
+ end
38
+
39
+ def each(&blk)
40
+ memberships.each(&blk)
41
+ end
42
+
43
+ def to_a
44
+ memberships.to_a
45
+ end
46
+
47
+ def empty?
48
+ memberships.empty?
49
+ end
50
+
51
+ def size
52
+ memberships.size
53
+ end
54
+
55
+ # Write the memberships back to wherever they live. Call it after changing something about a
56
+ # membership that has to survive the call (RoomMembership#hello! does). In memory there is
57
+ # nothing to do.
58
+ def persist!; end
59
+
60
+ protected
61
+
62
+ def memberships
63
+ @memberships
64
+ end
65
+
66
+ # The anycable-rails flavor: the memberships also live in the channel's AnyCable state
67
+ # (`state_attr_accessor`), as one JSON string of RoomMembership#persisted_identity records.
68
+ #
69
+ # One string rather than an array of hashes because anycable-rails runs every value in the
70
+ # state through its own serializer, which locates any GlobalID-looking string through the
71
+ # database. The `extra` a member joined with is ActiveJob-serialized and may well contain one,
72
+ # and the room is the only party that should deserialize it. Encoding the records ourselves
73
+ # keeps that serializer out of them.
74
+ class AnyCable < MembershipStore
75
+ STATE_ATTRIBUTE = :_cable_room_memberships
76
+
77
+ def persist!
78
+ # The accessor is private on the channel (see RoomMember) so it can't be performed as a
79
+ # channel action; `send` is deliberate.
80
+ @channel.send(:"#{STATE_ATTRIBUTE}=", ActiveSupport::JSON.encode(memberships.map(&:persisted_identity)))
81
+ end
82
+
83
+ protected
84
+
85
+ # Rebuild on first use, whatever the use is. The subscribe call that creates the first
86
+ # membership finds nothing to rebuild; every later call finds what subscribe (and hello)
87
+ # wrote. Restored memberships are added straight to the set: they exist already as far as
88
+ # anycable-go is concerned, so there is nothing to persist and no stream to open.
89
+ def memberships
90
+ restore! unless @restored
91
+ super
92
+ end
93
+
94
+ def restore!
95
+ @restored = true
96
+ raw = @channel.send(STATE_ATTRIBUTE)
97
+ return if raw.blank?
98
+
99
+ ActiveSupport::JSON.decode(raw).each do |record|
100
+ @memberships << RoomMembership.restore(@channel, record)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end