cable_room 0.6.2 → 0.7.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +1190 -0
  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 +571 -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 +477 -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 +258 -0
  19. data/lib/cable_room/ports.rb +19 -11
  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 +57 -0
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +78 -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_member.rb +260 -84
  31. data/lib/cable_room/room_proxy_channel.rb +13 -2
  32. data/lib/cable_room/snapshot.rb +136 -0
  33. data/lib/cable_room/version.rb +1 -1
  34. data/lib/cable_room.rb +57 -2
  35. metadata +25 -9
  36. data/lib/cable_room/channel_base.rb +0 -262
  37. data/lib/cable_room/channel_tracker.rb +0 -130
  38. data/lib/cable_room/room/channel_adapter.rb +0 -18
@@ -0,0 +1,477 @@
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)
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
+ )
171
+
172
+ true
173
+ rescue => e
174
+ CableRoom.lock_manager.unlock(lock_info) if lock_info
175
+ raise e
176
+ end
177
+
178
+ # Start running a room here. The caller (`ensure_room`) has already taken the room's Redlock;
179
+ # the runner renews it from now on and releases it when the room dies.
180
+ def start_room(room_class, key, lock_info, watchdog_interval:, lock_duration:)
181
+ runner = Runner.new(self, room_class, key, lock_info, watchdog_interval:, lock_duration:)
182
+ runner.start!
183
+ runner
184
+ end
185
+
186
+ # Bring a room back from a CableRoom::Snapshot on this Host, the way a migration's adopter
187
+ # does. With no `lock_info`, takes the room's Redlock first and returns false if someone else
188
+ # holds it — the same rule as `ensure_room`. With one, the caller has already claimed the lock.
189
+ # Raises Snapshot::UnknownVersion or Snapshot::UnknownRoomClass before anything is built if
190
+ # the snapshot can't be used here. `hold_inbound: true` brings the room up frozen, holding
191
+ # its inbound until `Runner#thaw!` (see Runner#restore!).
192
+ #
193
+ # If the room fails to come up, the lock is released either way — the caller's too. A room
194
+ # that can't start here must not keep its key locked: the migration that handed it over
195
+ # takes the lock back and restarts it as soon as the lock is free.
196
+ def restore_room(snapshot, lock_info = nil, hold_inbound: false)
197
+ snapshot = Snapshot.validate!(snapshot)
198
+ room_class = Snapshot.room_class_for(snapshot)
199
+ key = Snapshot.key_for(snapshot)
200
+ lock_key = room_class.room_port_key(key)
201
+
202
+ if lock_info
203
+ unless lock_info[:resource] == lock_key
204
+ raise ArgumentError, "lock_info is for #{lock_info[:resource].inspect}, but the snapshot is #{lock_key}"
205
+ end
206
+ else
207
+ lock_info = CableRoom.lock_manager.lock(lock_key, room_class::LOCK_DURATION.in_milliseconds)
208
+ return false unless lock_info
209
+ end
210
+
211
+ runner = Runner.new(
212
+ self,
213
+ room_class,
214
+ key,
215
+ lock_info,
216
+ watchdog_interval: room_class::WATCH_DOG_INTERVAL,
217
+ lock_duration: room_class::LOCK_DURATION,
218
+ )
219
+ runner.restore!(snapshot, hold_inbound: hold_inbound)
220
+ runner
221
+ rescue => e
222
+ # `restore!` releases the lock itself when the room fails to come up; this covers a failure
223
+ # before the runner exists
224
+ CableRoom.lock_manager.unlock(lock_info) if lock_info && runner.nil?
225
+ raise e
226
+ end
227
+
228
+ # A name for this host in migration traffic and instrumentation (`from_host`, `to_host`):
229
+ # machine, pid, and a few random characters, so two hosts in one process (a spec) differ too.
230
+ def id
231
+ @id ||= "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(3)}"
232
+ end
233
+
234
+ # The Bus this host's rooms listen on, for the migration's list and key operations. Falls
235
+ # back to the process-wide Bus when `inbound` isn't Bus-backed.
236
+ def bus
237
+ inbound.respond_to?(:bus) ? inbound.bus : CableRoom.bus
238
+ end
239
+
240
+ def track(runner)
241
+ @monitor.synchronize do
242
+ raise "Cannot add Room after shutdown" if @shutdown
243
+ @runners << runner
244
+ end
245
+ end
246
+
247
+ def untrack(runner)
248
+ @monitor.synchronize { @runners.delete(runner) }
249
+ end
250
+
251
+ # A snapshot, so callers can iterate while rooms come and go
252
+ def runners
253
+ @monitor.synchronize { @runners.to_a }
254
+ end
255
+
256
+ def rooms
257
+ runners.map(&:room)
258
+ end
259
+
260
+ # How many member ports are attached to rooms on this Host right now. This is the load
261
+ # number the design scales the rooms pool on, so an app can publish it as a gauge. It reads
262
+ # each room's port table from outside the room's thread; a count that's a moment stale is
263
+ # fine for a metric.
264
+ def open_ports
265
+ rooms.sum(&:open_port_count)
266
+ end
267
+
268
+ # How many rooms this Host runs right now. This is the load number provisioning weights its
269
+ # delay by: a host with more rooms waits longer before racing for a new one.
270
+ def open_rooms_count
271
+ @monitor.synchronize { @runners.size }
272
+ end
273
+
274
+ def each_runner(&blk)
275
+ runners.each(&blk)
276
+ end
277
+
278
+ def draining?
279
+ @draining
280
+ end
281
+
282
+ # True once `drain!` has finished (every room moved or closed, and the host is shut down).
283
+ def drained?
284
+ !@drain_result.nil?
285
+ end
286
+
287
+ # The result of the drain, or nil until `drain!` has finished.
288
+ def drain_result
289
+ @drain_result
290
+ end
291
+
292
+ # Every CableRoom::Migration this host's drain has started, oldest room first. Each one's
293
+ # `state` says where it is; the list is complete once `drained?`.
294
+ def migrations
295
+ @monitor.synchronize { @migrations.dup }
296
+ end
297
+
298
+ # Planned shutdown: move every room here to a peer host, then shut down. This is what SIGTERM
299
+ # means to `cable_room server` (see CableRoom::Migration for the per-room protocol):
300
+ #
301
+ # 1. Mark the host draining, so its Placement claims nothing more (a room it took now would
302
+ # only have to move again), and instrument `host_draining.cable_room`.
303
+ # 2. Hand rooms off oldest first, DRAIN_CONCURRENCY at a time. A room nobody adopts within
304
+ # `handoff_timeout` closes with `room_closed`; so does one that can't be snapshotted.
305
+ # 3. When `drain_timeout` passes with rooms still waiting their turn, offer all of them at
306
+ # once — the same path, just no longer throttled — so the drain ends within about one
307
+ # more `handoff_timeout` whatever the fleet does.
308
+ # 4. `shutdown!`, run the `Host.after_drain` callbacks, and return a DrainResult.
309
+ #
310
+ # Blocks until done. Runs once: a second call (from another thread, or `at_exit` after
311
+ # `cable_room server` has already drained) waits for the first and returns its result.
312
+ # `drain_timeout` and `handoff_timeout` default to the config's; specs pass short ones.
313
+ #
314
+ # In :inline the peers are the other web processes hosting rooms, if any. The design gives
315
+ # :inline no fleet of its own, so the gem doesn't drain there on exit (see `at_exit`); an app
316
+ # running several inline processes may still call this from its own signal handling.
317
+ def drain!(reason:, drain_timeout: nil, handoff_timeout: nil, concurrency: DRAIN_CONCURRENCY)
318
+ other = @monitor.synchronize do
319
+ return @drain_result if @drain_result
320
+ @drain_thread.tap { @drain_thread ||= Thread.current }
321
+ end
322
+ if other
323
+ other.join unless other == Thread.current
324
+ return @drain_result
325
+ end
326
+
327
+ drain_timeout ||= CableRoom.config.drain_timeout
328
+ handoff_timeout ||= CableRoom.config.handoff_timeout
329
+ started = monotonic_now
330
+ self.draining = true
331
+
332
+ queued = runners.sort_by(&:started_at)
333
+ migrations = queued.map { |runner| Migration.new(self, runner, reason: reason, handoff_timeout: handoff_timeout) }
334
+ @monitor.synchronize { @migrations = migrations }
335
+ logger.info "Draining #{migrations.size} room(s): #{reason}"
336
+
337
+ payload = { host: self, rooms: queued.map(&:room), reason: reason }
338
+ ActiveSupport::Notifications.instrument("host_draining.cable_room", payload) do |event|
339
+ run_migrations(migrations, concurrency: concurrency, deadline: started + drain_timeout.to_f)
340
+ event[:migrated] = migrations.count(&:migrated?)
341
+ event[:closed] = migrations.count { |m| !m.migrated? }
342
+ end
343
+
344
+ shutdown!
345
+
346
+ result = DrainResult.new(
347
+ reason: reason,
348
+ migrated: migrations.select(&:migrated?),
349
+ closed: migrations.reject(&:migrated?),
350
+ duration: monotonic_now - started,
351
+ )
352
+ logger.info "Drained: #{result.migrated.size} room(s) migrated, #{result.closed.size} closed, in #{result.duration.round(2)}s"
353
+ @monitor.synchronize { @drain_result = result }
354
+
355
+ self.class.after_drain_callbacks.each do |callback|
356
+ callback.call(self, result)
357
+ rescue => e
358
+ CableRoom.report_error(e, host: self, drain_result: result)
359
+ end
360
+
361
+ result
362
+ end
363
+
364
+ def shutdown?
365
+ @shutdown
366
+ end
367
+
368
+ # Start listening for provision requests (see CableRoom::Placement). `Host.start!` does this
369
+ # for the process Host; a Host built with `new` (a spec harness) calls it itself. Pass a
370
+ # Placement to use one with non-default settings; calling it again re-arms the current one.
371
+ def start_placement(placement = nil)
372
+ previous = @monitor.synchronize do
373
+ raise "Cannot start placement after shutdown" if @shutdown
374
+ @placement.tap { @placement = placement || @placement || Placement.new(self) }
375
+ end
376
+ previous.stop if previous && previous != @placement
377
+ @placement.start
378
+ @placement
379
+ end
380
+
381
+ # Stop listening and drop any claim still waiting. Nothing starts a room here after this.
382
+ def stop_placement
383
+ @placement&.stop
384
+ end
385
+
386
+ # Stop everything: the beat, every room (gracefully, with a bounded wait), then the pool.
387
+ # Runs once; later calls do nothing.
388
+ def shutdown!
389
+ @monitor.synchronize do
390
+ return if @shutdown
391
+ @shutdown = true
392
+ end
393
+
394
+ # Stop claiming first: a room started after this point would only be shut down again
395
+ stop_placement
396
+ scheduler.shutdown
397
+ shutdown_rooms!
398
+ worker_pool.executor.shutdown
399
+ worker_pool.executor.wait_for_termination(5)
400
+ worker_pool.executor.kill
401
+ end
402
+
403
+ # Ask every room to finish what it has queued and shut down, then give them up to
404
+ # `wait` seconds to do it
405
+ def shutdown_rooms!(wait: 15)
406
+ each_runner { |runner| runner.initiate_shutdown("Server shutting down") }
407
+
408
+ wait.times do
409
+ break if runners.empty?
410
+ sleep 1
411
+ end
412
+ end
413
+
414
+ # Stop every room right now, dropping whatever work is still queued. ActionCable's `restart`
415
+ # calls this when the app reloads.
416
+ def stop_all_rooms!
417
+ each_runner(&:stop!)
418
+ end
419
+
420
+ private
421
+
422
+ # Run the migrations `concurrency` at a time until `deadline`, then everything still waiting
423
+ # at once. Each runs on a thread of its own rather than the worker pool: a handoff blocks for
424
+ # up to `handoff_timeout` waiting on a peer, and the rooms still need the pool to run.
425
+ def run_migrations(migrations, concurrency:, deadline:)
426
+ queue = Queue.new
427
+ migrations.each { |migration| queue << migration }
428
+ next_migration = -> { queue.pop(true) rescue nil } # non-blocking: nil once the queue is empty
429
+
430
+ workers = Array.new([concurrency, migrations.size].min) do |i|
431
+ migration_thread("cable_room-drain-#{i}") do
432
+ while (migration = next_migration.call)
433
+ migration.run
434
+ end
435
+ end
436
+ end
437
+
438
+ workers.each do |thread|
439
+ remaining = deadline - monotonic_now
440
+ break if remaining <= 0
441
+ thread.join(remaining)
442
+ end
443
+
444
+ forced = []
445
+ while (migration = next_migration.call)
446
+ forced << migration
447
+ end
448
+ if forced.any?
449
+ logger.warn "drain_timeout passed with #{forced.size} room(s) still waiting; handing them all off now"
450
+ end
451
+ forced_threads = forced.map { |migration| migration_thread("cable_room-drain-forced") { migration.run } }
452
+
453
+ (workers + forced_threads).each(&:join)
454
+ end
455
+
456
+ def migration_thread(name)
457
+ thread = Thread.new do
458
+ yield
459
+ rescue => e
460
+ # Migration#run handles its own failures; this is for a bug in the loop itself, which
461
+ # must not take the whole drain down with it
462
+ CableRoom.report_error(e, host: self)
463
+ end
464
+ thread.name = name
465
+ thread.report_on_exception = false
466
+ thread
467
+ end
468
+
469
+ def monotonic_now
470
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
471
+ end
472
+
473
+ def cable_server
474
+ ActionCable.server
475
+ end
476
+ end
477
+ 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