cable_room 0.6.1 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +597 -49
  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/input_handling.rb +1 -1
  24. data/lib/cable_room/room/lifecycle.rb +26 -9
  25. data/lib/cable_room/room/port_management.rb +70 -2
  26. data/lib/cable_room/room/reaping.rb +34 -1
  27. data/lib/cable_room/room/snapshotting.rb +78 -0
  28. data/lib/cable_room/room/threading.rb +2 -2
  29. data/lib/cable_room/room/user_management.rb +27 -0
  30. data/lib/cable_room/room.rb +5 -2
  31. data/lib/cable_room/room_member.rb +282 -21
  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 +25 -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
@@ -3,7 +3,12 @@ module CableRoom
3
3
  module PortManagement
4
4
  extend ActiveSupport::Concern
5
5
 
6
- PORT_TIMEOUT = 30.seconds
6
+ # A port that the room has not heard from for this long is dropped. Any inbound message
7
+ # counts as hearing from it (see the receive_message callback below), and members only
8
+ # ping when they have been otherwise silent for RoomMember::PING_INTERVAL, so the longest
9
+ # gap a live member produces is 2 x PING_INTERVAL. Keep this comfortably above that: under
10
+ # load the room processes its queue late, and a reaped port is a lost student.
11
+ PORT_TIMEOUT = 45.seconds
7
12
 
8
13
  class_methods do
9
14
  def on_port_connected(...)
@@ -29,7 +34,13 @@ module CableRoom
29
34
  on_port_connected { reply({type: 'port_acknowledged' }) }
30
35
 
31
36
  set_callback(:receive_message, :around) do |_, blk|
32
- with_message_origin(message['mtok']) { blk.call }
37
+ with_message_origin(message['mtok']) do
38
+ # Every message from a known port is proof of life, not just port_ping — so a member
39
+ # that is actively sending never needs to ping (RoomMembership#ping!). Unknown origins
40
+ # (a port_connected creating its port) are touched when the port is created.
41
+ message_origin&.touch_activity!
42
+ blk.call
43
+ end
33
44
  end
34
45
 
35
46
  system_message_types(:port_connected, :port_disconnected, :port_ping)
@@ -57,6 +68,13 @@ module CableRoom
57
68
  { client_port: client_port || tag, **kwargs }
58
69
  end
59
70
 
71
+ # How many member ports are attached right now. Public, unlike `connected_clients`, because
72
+ # the Host reads it from outside the room's thread to total up `Host#open_ports`; a plain
73
+ # size is safe to read that way and a moment's staleness doesn't matter for a gauge.
74
+ def open_port_count
75
+ @_port_clients.size
76
+ end
77
+
60
78
  def reply(message = nil, **kwargs)
61
79
  raise ArgumentError, "Can only use reply when handling a message" unless message_origin
62
80
  raise ArgumentError, "Must provide message or block" unless message || block_given?
@@ -140,6 +158,29 @@ module CableRoom
140
158
  end
141
159
  end
142
160
 
161
+ # -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
162
+
163
+ def _snapshot_port_clients
164
+ @_port_clients.values.map(&:to_snapshot)
165
+ end
166
+
167
+ # Re-create every port exactly as it was, without running port_connected callbacks: the
168
+ # members are still there and were already acknowledged. A port whose user can't be found
169
+ # any more (its record was deleted) is dropped rather than failing the whole room; that
170
+ # member will be re-acknowledged when it next pings.
171
+ def _restore_port_clients(entries)
172
+ Array(entries).each do |entry|
173
+ client = PortClient.new(self, entry["token"])
174
+ begin
175
+ client.restore!(entry)
176
+ rescue ::ActiveJob::DeserializationError => e
177
+ logger.warn "Dropping port #{entry['token']} from the snapshot: #{e.message}"
178
+ next
179
+ end
180
+ @_port_clients[client.token] = client
181
+ end
182
+ end
183
+
143
184
  def check_port_inactivity
144
185
  return unless @_port_clients
145
186
 
@@ -197,6 +238,33 @@ module CableRoom
197
238
  def tag!(*tags)
198
239
  self[:tags].merge(tags.flatten.map(&:to_sym))
199
240
  end
241
+
242
+ SNAPSHOT_OWN_KEYS = %w[tags as last_seen_at].freeze
243
+
244
+ # This port as one `port_clients` entry of a CableRoom::Snapshot. `as` goes through the
245
+ # same serializer the member used to send it (a GlobalID for a record). Everything else on
246
+ # the port — what the member's `extra:` merged in, plus anything the room stored with
247
+ # `message_origin[:x] = ...` — travels under `metadata`, the same way, so records survive.
248
+ def to_snapshot
249
+ metadata = @metadata.to_h.except(*SNAPSHOT_OWN_KEYS)
250
+ {
251
+ token: token,
252
+ tags: self[:tags].map(&:to_s),
253
+ as: Snapshot.serialize_argument(self[:as]),
254
+ last_seen_at: Snapshot.encode_time(self[:last_seen_at]),
255
+ metadata: Snapshot.serialize_argument(metadata),
256
+ }
257
+ end
258
+
259
+ # The inverse of `to_snapshot`. `last_seen_at` is kept, not reset: the port is exactly as
260
+ # old as it was, and its member's next ping refreshes it as usual.
261
+ def restore!(entry)
262
+ merge!(Snapshot.deserialize_argument(entry["metadata"]))
263
+ tag!(entry["tags"])
264
+ self[:as] = Snapshot.deserialize_argument(entry["as"])
265
+ self[:last_seen_at] = Snapshot.decode_time(entry["last_seen_at"]) || self[:last_seen_at]
266
+ self
267
+ end
200
268
  end
201
269
  end
202
270
  end
@@ -59,7 +59,7 @@ module CableRoom
59
59
  protected
60
60
 
61
61
  def ping_watchdog
62
- @cable_channel.ping_watchdog
62
+ @runner.ping_watchdog
63
63
  end
64
64
 
65
65
  def check_reapers_now!
@@ -74,6 +74,39 @@ module CableRoom
74
74
  ping_watchdog
75
75
  end
76
76
 
77
+ # -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
78
+
79
+ # One entry per `reap_when`, in declaration order. `last_keep_at` is the wall-clock moment
80
+ # the reaper's grace period started counting from (nil until it has run once), so the
81
+ # deadline — `last_keep_at + grace` — is the same on whichever host restores it.
82
+ def _snapshot_reaper_state
83
+ self.class.reaper_checkers.each_with_index.map do |cfg, index|
84
+ state = _reaper_states[cfg] || {}
85
+ { key: cfg[:key]&.to_s, index: index, last_keep_at: Snapshot.encode_time(state[:last_keep_at]) }
86
+ end
87
+ end
88
+
89
+ # Runs before the startup callbacks, which add the timer to each state and leave the rest
90
+ # alone. A named reaper matches by name and an anonymous one by position, so a Room whose
91
+ # reapers changed between the two hosts' code versions keeps what still lines up and drops
92
+ # the rest.
93
+ def _restore_reaper_state(entries)
94
+ checkers = self.class.reaper_checkers
95
+ Array(entries).each do |entry|
96
+ cfg =
97
+ if entry["key"].present?
98
+ checkers.find { |c| c[:key].to_s == entry["key"] }
99
+ else
100
+ candidate = checkers[entry["index"].to_i]
101
+ candidate if candidate && candidate[:key].nil?
102
+ end
103
+ next unless cfg
104
+
105
+ last_keep_at = Snapshot.decode_time(entry["last_keep_at"])
106
+ (_reaper_states[cfg] ||= {})[:last_keep_at] = last_keep_at if last_keep_at
107
+ end
108
+ end
109
+
77
110
  private
78
111
 
79
112
  def _reaper_states
@@ -0,0 +1,78 @@
1
+ module CableRoom
2
+ module Room
3
+ # The room's side of a migration: turning itself into a CableRoom::Snapshot and coming back
4
+ # from one. The gem carries what it owns (ports, tags, users, reaper deadlines); a Room can
5
+ # carry its own state too with two optional hooks:
6
+ #
7
+ # class QuizRoom < CableRoom::Room::Base
8
+ # def snapshot_state
9
+ # { question_id: @question.id, answers: @answers } # JSON only; records by id
10
+ # end
11
+ #
12
+ # def restore_state(state)
13
+ # @question = Question.find(state[:question_id]) # indifferent access: :key or "key"
14
+ # @answers = state[:answers]
15
+ # end
16
+ # end
17
+ #
18
+ # A room that defines `restore_state` gets it instead of `startup` when it's restored, with
19
+ # whatever `snapshot_state` returned (nil if the room that was snapshotted had no
20
+ # `snapshot_state`). A room without `restore_state` just runs `startup` again and rebuilds its
21
+ # state the way it did the first time. Either way the `before_startup`/`after_startup`
22
+ # callbacks run as usual, except that the gem does not broadcast `room_opened`: the members
23
+ # were there the whole time and never saw the room go away. `restored?` tells a callback which
24
+ # case it's in.
25
+ module Snapshotting
26
+ extend ActiveSupport::Concern
27
+
28
+ # True for a room that was rebuilt from a snapshot rather than started fresh.
29
+ def restored?
30
+ @_restored == true
31
+ end
32
+
33
+ private
34
+
35
+ # Build the snapshot (see CableRoom::Snapshot for the shape). Only meaningful once the room
36
+ # is frozen: nothing else may be touching its state while this reads it.
37
+ def _snapshot
38
+ document = {
39
+ version: Snapshot::VERSION,
40
+ room_class: self.class.name,
41
+ key: Snapshot.serialize_argument(key),
42
+ port_clients: _snapshot_port_clients,
43
+ user_state: _snapshot_user_state,
44
+ reaper_state: _snapshot_reaper_state,
45
+ app_state: _snapshot_app_state,
46
+ }
47
+
48
+ ActiveSupport::Notifications.instrument("room_snapshotted.cable_room", { room: self }) do
49
+ Snapshot.round_trip(document)
50
+ end
51
+ end
52
+
53
+ def _snapshot_app_state
54
+ return nil unless respond_to?(:snapshot_state, true)
55
+
56
+ state = snapshot_state
57
+ Snapshot.assert_json!(state, room: self)
58
+ state
59
+ end
60
+
61
+ # Rebuild from `snapshot`: gem state first, quietly (no port_connected or user_joined
62
+ # callbacks, since none of it is new), then the startup chain with `restore_state` in place
63
+ # of `startup`. Runs where `_startup` would, before the runner starts the periodic timers.
64
+ def _restore(snapshot)
65
+ snapshot = Snapshot.validate!(snapshot)
66
+ @_restored = true
67
+
68
+ _restore_port_clients(snapshot["port_clients"])
69
+ _restore_user_state(snapshot["user_state"])
70
+ _restore_reaper_state(snapshot["reaper_state"])
71
+
72
+ app_state = snapshot["app_state"]
73
+ app_state = app_state.with_indifferent_access if app_state.is_a?(Hash)
74
+ _startup(restoring: true, app_state: app_state)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -36,13 +36,13 @@ module CableRoom
36
36
  # results back with `on_room_thread` over waiting for them.
37
37
  def async(&blk)
38
38
  room = self
39
- @cable_channel.post_work(async: true) { room.instance_exec(&blk) }
39
+ @runner.post_work(async: true) { room.instance_exec(&blk) }
40
40
  end
41
41
 
42
42
  # Queue the block back onto the Room's own thread, where touching Room state is safe again.
43
43
  def on_room_thread(&blk)
44
44
  room = self
45
- @cable_channel.post_work(async: false, silent: true) { room.instance_exec(&blk) }
45
+ @runner.post_work(async: false, silent: true) { room.instance_exec(&blk) }
46
46
  end
47
47
  end
48
48
  end
@@ -98,6 +98,33 @@ module CableRoom
98
98
  tags
99
99
  end
100
100
 
101
+ # -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
102
+
103
+ # The user map is carried on its own rather than rebuilt from the ports: a port whose join
104
+ # the tag policy refused has no user entry, and a rebuild would invent one.
105
+ def _snapshot_user_state
106
+ @_user_map_mutex.synchronize do
107
+ @_user_state_map.map do |user, usm|
108
+ { user: Snapshot.serialize_argument(user), port_tokens: usm[:port_tokens].to_a }
109
+ end
110
+ end
111
+ end
112
+
113
+ # No user_joined callbacks here: these users joined long ago, on the previous host.
114
+ def _restore_user_state(entries)
115
+ @_user_map_mutex.synchronize do
116
+ Array(entries).each do |entry|
117
+ begin
118
+ user = Snapshot.deserialize_argument(entry["user"])
119
+ rescue ::ActiveJob::DeserializationError => e
120
+ logger.warn "Dropping a user from the snapshot: #{e.message}"
121
+ next
122
+ end
123
+ @_user_state_map[user] = { port_tokens: Set.new(entry["port_tokens"]) }
124
+ end
125
+ end
126
+ end
127
+
101
128
  def _apply_port_scope(user: nil, tag: nil, **kwargs)
102
129
  if user && tag
103
130
  user_tags = all_user_tags(user) || []
@@ -7,9 +7,10 @@ module CableRoom
7
7
 
8
8
  autoload :Callbacks
9
9
  autoload :Threading
10
- autoload :ChannelAdapter
10
+ autoload :HostAdapter
11
11
 
12
12
  autoload :Lifecycle
13
+ autoload :Snapshotting
13
14
  autoload :Reaping
14
15
  autoload :InputHandling
15
16
 
@@ -20,8 +21,10 @@ module CableRoom
20
21
  autoload :Broadcasting
21
22
  end
22
23
 
24
+ # Every room running in this process. Asking never creates a Host, so a process that hosts
25
+ # no rooms (a web process in :remote) just gets an empty list.
23
26
  def self.locally_open_rooms
24
- ChannelTracker.instance.room_channels.map(&:room)
27
+ Host.current&.rooms || []
25
28
  end
26
29
  end
27
30
  end