cable_room 0.7.0.beta3 → 0.8.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.
data/lib/cable_room.rb CHANGED
@@ -4,28 +4,27 @@ require 'active_support/time'
4
4
  require 'active_support/core_ext/module'
5
5
 
6
6
  require 'action_cable'
7
+ require 'concurrent'
7
8
  require 'redlock'
8
9
  require 'rufus-scheduler'
9
10
 
10
- require_relative 'cable_room/railtie'
11
11
  require_relative 'cable_room/config'
12
- require_relative 'cable_room/broadcaster'
12
+ require_relative 'cable_room/railtie'
13
13
 
14
14
  require_relative 'cable_room/bus'
15
15
 
16
16
  require_relative 'cable_room/periodic_timer'
17
- require_relative 'cable_room/snapshot'
18
17
  require_relative 'cable_room/host'
19
- require_relative 'cable_room/placement'
20
- require_relative 'cable_room/migration'
21
18
  require_relative 'cable_room/ports'
22
- require_relative 'cable_room/membership_store'
23
19
  require_relative 'cable_room/room_member'
24
20
  require_relative 'cable_room/room_proxy_channel'
25
21
  require_relative 'cable_room/room/'
26
22
  require_relative 'cable_room/version'
27
23
 
28
24
  module CableRoom
25
+ BUS_LOCK = Mutex.new
26
+ private_constant :BUS_LOCK
27
+
29
28
  class << self
30
29
  # Called as `handler.call(error, context)` whenever work inside a Room raises. Rooms swallow
31
30
  # exceptions so that one bad message can't kill the Room, which makes this the only way to
@@ -44,25 +43,29 @@ module CableRoom
44
43
  warn "CableRoom.error_handler raised #{handler_error.class}: #{handler_error.message}"
45
44
  end
46
45
 
47
- # The active CableRoom::Config. Built on first use with the defaults plus any env
48
- # overrides, so an app that never calls `configure` still gets a valid config.
49
- def config
50
- @config ||= Config.new.apply_env_overrides.validate!
46
+ # The Apartment tenant the calling thread is in, or nil in an app without Apartment
47
+ def current_tenant
48
+ Apartment::Tenant.current if defined?(Apartment)
51
49
  end
52
50
 
53
- # Yields the config so an app can set the knobs, then re-applies the env overrides
54
- # (CABLE_ROOM_HOST, CABLE_ROOM_BROADCASTER) and checks every value. Bad values raise
55
- # here, at boot, instead of somewhere deep in a room later.
56
- def configure
57
- cfg = @config || Config.new
58
- yield cfg if block_given?
59
- @config = cfg.apply_env_overrides.validate!
51
+ # Run the block switched into `tenant`. Does nothing extra when there's no tenant, no
52
+ # Apartment, or the thread is already in it, so an :inline join (which starts its room in its
53
+ # own tenant) never switches at all.
54
+ def with_tenant(tenant, &blk)
55
+ return yield if tenant.nil? || !defined?(Apartment) || Apartment::Tenant.current == tenant
56
+
57
+ Apartment::Tenant.switch(tenant, &blk)
60
58
  end
61
59
 
62
- # Drops the current config so the next `config` call rebuilds it, which also makes
63
- # `Broadcaster.current` rebuild (and forget any `Broadcaster.current=` override). Meant for specs.
64
- def reset_config!
65
- @config = nil
60
+ # Run the block inside the Rails app's executor (database connections, reloading, and so on),
61
+ # the way ActionCable wraps channel callbacks. Nesting is fine: the executor yields straight
62
+ # through when it's already active on this thread. Without Rails it just yields.
63
+ def with_app_executor(&blk)
64
+ if defined?(Rails) && Rails.application
65
+ Rails.application.executor.wrap(&blk)
66
+ else
67
+ yield
68
+ end
66
69
  end
67
70
 
68
71
  def redis_pool
@@ -80,30 +83,40 @@ module CableRoom
80
83
  ])
81
84
  end
82
85
 
83
- # The process-wide Redis bus (see CableRoom::Bus). Built on first use.
86
+ # The process-wide Redis bus (see CableRoom::Bus). Built on first use. Guarded so two threads
87
+ # racing to it can't each build one: a handler registered on the loser would be stranded.
84
88
  def bus
85
- @bus ||= Bus.new
89
+ @bus || BUS_LOCK.synchronize { @bus ||= Bus.new }
86
90
  end
87
91
 
88
92
  # Call this in a child process right after `fork`, before it touches Redis or hosts a room.
89
- # `cable_room server --workers N` does it for every worker (see Host::Supervisor).
93
+ # `cable_room server --workers N` does it in every worker (see Host::Supervisor); an app that
94
+ # forks for its own reasons (Puma's `on_worker_boot` with `preload_app!`, a job runner) should
95
+ # call it there too.
96
+ #
97
+ # A forked child starts with copies of the parent's objects but only the forking thread, so
98
+ # anything that owns a thread or a socket is broken in the child: the Bus subscriber thread
99
+ # doesn't exist, a mutex may be held by a thread that's gone, and a Redis socket is shared
100
+ # with the parent, so both processes would read each other's replies. This drops the memos,
101
+ # so the next caller builds fresh ones:
90
102
  #
91
- # A forked child starts with copies of the parent's objects but only one thread, so anything
92
- # that owns a socket or a thread is broken in the child: the Bus subscriber thread doesn't
93
- # exist, its mutex may be held by nobody, and a Redis socket is shared with the parent, so
94
- # both processes would read each other's replies. Dropping the memos means the next caller
95
- # builds fresh ones. The connection pool and redis-client also notice the fork on their own
96
- # (both hook `Process._fork` and drop inherited sockets), and Rails does the same for
97
- # ActiveRecord, so those need nothing from us; this keeps the gem's rule simple: nothing
98
- # Redis-backed survives a fork.
103
+ # * the Bus. The copy is only dropped, not shut down: its pub/sub socket is still the
104
+ # parent's, and anything sent on it (even a TLS close_notify) would land in the parent's
105
+ # session. GC closes the child's copy of the descriptor quietly.
106
+ # * the Redlock client, whose monitor may be held by a parent thread.
107
+ # * the Host, whose rooms, locks, and threads are the parent's. It's abandoned (see
108
+ # Host#abandon!) so its `at_exit` hook can't close the parent's rooms when the child exits.
99
109
  #
100
- # The Host goes too. A parent that forks workers must never host rooms itself, and a Host
101
- # copied from a parent would have no worker threads and no scheduler thread behind it.
110
+ # Pooled connections need nothing from us. `redis_pool` is RediConn's shared pool for the
111
+ # URL (the same object comes back if we rebuild it), and connection_pool drops the connections
112
+ # it inherited on its own (it hooks `Process._fork`); redis-client also reconnects any client
113
+ # it finds in a new pid. Rails does the same for ActiveRecord (`PoolConfig.discard_pools!`
114
+ # runs after every fork), and its pools reconnect lazily on first use.
102
115
  def after_fork!
103
- @bus = nil
116
+ BUS_LOCK.synchronize { @bus = nil }
104
117
  @lock_manager = nil
105
- @redis_pool = nil
106
- Host.replace_current(nil)
118
+ Host.replace_current(nil)&.abandon!
119
+ nil
107
120
  end
108
121
  end
109
122
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cable_room
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0.beta3
4
+ version: 0.8.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Knapp
@@ -77,14 +77,20 @@ dependencies:
77
77
  requirements:
78
78
  - - ">="
79
79
  - !ruby/object:Gem::Version
80
- version: '5.0'
80
+ version: '5'
81
+ - - "<"
82
+ - !ruby/object:Gem::Version
83
+ version: '7'
81
84
  type: :runtime
82
85
  prerelease: false
83
86
  version_requirements: !ruby/object:Gem::Requirement
84
87
  requirements:
85
88
  - - ">="
86
89
  - !ruby/object:Gem::Version
87
- version: '5.0'
90
+ version: '5'
91
+ - - "<"
92
+ - !ruby/object:Gem::Version
93
+ version: '7'
88
94
  - !ruby/object:Gem::Dependency
89
95
  name: rspec
90
96
  requirement: !ruby/object:Gem::Requirement
@@ -106,24 +112,20 @@ executables:
106
112
  extensions: []
107
113
  extra_rdoc_files: []
108
114
  files:
109
- - CHANGELOG.md
110
115
  - README.md
111
116
  - cable_room.gemspec
112
117
  - exe/cable_room
113
118
  - lib/cable_room.rb
114
- - lib/cable_room/broadcaster.rb
115
119
  - lib/cable_room/bus.rb
116
120
  - lib/cable_room/cli.rb
117
121
  - lib/cable_room/config.rb
118
122
  - lib/cable_room/host.rb
119
123
  - lib/cable_room/host/bus_inbound.rb
124
+ - lib/cable_room/host/placement.rb
120
125
  - lib/cable_room/host/runner.rb
121
126
  - lib/cable_room/host/supervisor.rb
122
127
  - lib/cable_room/host/worker_pool.rb
123
- - lib/cable_room/membership_store.rb
124
- - lib/cable_room/migration.rb
125
128
  - lib/cable_room/periodic_timer.rb
126
- - lib/cable_room/placement.rb
127
129
  - lib/cable_room/ports.rb
128
130
  - lib/cable_room/railtie.rb
129
131
  - lib/cable_room/room.rb
@@ -137,13 +139,10 @@ files:
137
139
  - lib/cable_room/room/port_policies.rb
138
140
  - lib/cable_room/room/port_scoping.rb
139
141
  - lib/cable_room/room/reaping.rb
140
- - lib/cable_room/room/snapshotting.rb
141
142
  - lib/cable_room/room/threading.rb
142
143
  - lib/cable_room/room/user_management.rb
143
- - lib/cable_room/room_harness.rb
144
144
  - lib/cable_room/room_member.rb
145
145
  - lib/cable_room/room_proxy_channel.rb
146
- - lib/cable_room/snapshot.rb
147
146
  - lib/cable_room/version.rb
148
147
  homepage: https://instructure.com
149
148
  licenses: []
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.
@@ -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,105 +0,0 @@
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