cable_room 0.7.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 890aa545d8211663c2270322da9440b0dbd685e7cdcfa9858df04390d06e7533
4
- data.tar.gz: 074e6c49a9886c90483500bdb61cdf40fa4d3c9e83ddc583415cc9ac051e90f6
3
+ metadata.gz: 230a3cec91f557f72ae459ed02fdf93ca4cc72d33a09a1e886ba503d27d665df
4
+ data.tar.gz: 4bc92906a5db6b957265f09b7fb3b596e587208fa01057890ad273ed1d9d0a94
5
5
  SHA512:
6
- metadata.gz: 3304bd77c3d455a2b867d83d82d4985ff7e1a3ec10bd80ec6a097f50be040fadbbaac501d7f26234384ba50dbe9f09864c92e1114d8cecff75d308ae2a770261
7
- data.tar.gz: 408c73ab717cd8aaa5a7fbc8ce6b5d209ee2169fe50a731ac8b6d7b2b417b61e64c5a9ceac55155e07e677f6b3a952bb56edd4043a16b624bb264cd6ca1fb21d
6
+ metadata.gz: '0268892f4951a1c869caf8e5ab8d2218e4e06def83df3d5b32a3ae21b768678175668276dc47f4cb4a8fb3abcff42bc28092989aa9385c1df345e5c677de33af'
7
+ data.tar.gz: 186b3001f84e157c856164b22aff29984a7ec8471bc2e4ea28ac9c830a26a638ceae8852b3a5301f43eb27e26d4e0294f294543637353dbe04999dd91d71f70c
data/README.md CHANGED
@@ -218,7 +218,8 @@ Timer bodies run on the Room's thread, so they're serialized against message han
218
218
  When a host shuts down on purpose, it can move its Rooms to another host instead of closing them.
219
219
  Members don't notice: their streams stay open, and the new host picks up where the old one left
220
220
  off. The gem carries what it owns — every port with its token, tags, user, and last-seen time; the
221
- user map; and each reaper's deadline. A Room's own state moves through two optional hooks:
221
+ user map; each reaper's deadline; and the Room's tenant, if `join_room` was given one (see
222
+ [Multi-tenancy](#multi-tenancy)). A Room's own state moves through two optional hooks:
222
223
 
223
224
  ```ruby
224
225
  class QuizRoom < CableRoom::Room::Base
@@ -534,6 +535,7 @@ end
534
535
  | `as:` | The user identity. Defaults to `current_user` when the channel has one. |
535
536
  | `tags:` | Tags this port carries, for policies and targeted broadcasts. |
536
537
  | `extra:` | Extra metadata, readable on the Room's `PortClient`. |
538
+ | `tenant:` | This Room's tenant, for a multi-tenant app. Defaults to the ambient tenant right here (see [Multi-tenancy](#multi-tenancy)). |
537
539
  | `forward:` | Pipe every Room message straight to the websocket. |
538
540
  | `on_joined:` | The Room acknowledged this port. |
539
541
  | `on_message:` | Any message from the Room. |
@@ -555,6 +557,70 @@ request on the bus, and one of the Rooms hosts starts the Room (see
555
557
  Room acknowledges the port, so a lost request costs at most one ping interval, and if the Room's
556
558
  host process dies the next ping from any member brings it back somewhere else.
557
559
 
560
+ ### Multi-tenancy.
561
+
562
+ A Room's own work runs on its Host's worker pool, not on the joining member's request thread, so
563
+ there's no Rack middleware and no request to derive an app's tenant from once the Room is up. Pass
564
+ `tenant:` to `join_room` for exactly this — whatever it's given rides along on the provision
565
+ request, comes back out as `Runner#tenant` (and `Room#tenant`), and survives a host migration
566
+ (it's part of what a Room's snapshot carries, alongside its ports and users; see
567
+ [Migration hooks](#migration-hooks)). Leave it out and it defaults to whatever
568
+ `Apartment::Tenant.current` returns right where `join_room` is called — the member's own request
569
+ thread, the last place with real request context before the Room's work moves to a Host thread:
570
+
571
+ ```ruby
572
+ join_room(QuizRoom, params[:quiz_id], create: true) # tenant: Apartment::Tenant.current, captured here
573
+ join_room(QuizRoom, params[:quiz_id], create: true, tenant: "some-other-org") # explicit override
574
+ ```
575
+
576
+ cable_room doesn't depend on Apartment (or anything else) to use this — `tenant` is just a value it
577
+ carries around for you, the same way `key` or `extra` are. **It never switches anything itself.**
578
+ Whatever ends up on `Runner#tenant` still has to actually be applied before a Room's DB calls run,
579
+ the same way a request's tenant has to be applied before a controller action's do.
580
+
581
+ If you're wiring this up yourself, here's the shape of that hook:
582
+
583
+ ```ruby
584
+ # Lazily switch a worker thread to the right tenant only when it's about to touch the DB.
585
+ # Actively switching up front checks out a connection and runs SET search_path even for a
586
+ # message that never queries anything, so this only stages the tenant (no DB call) and lets
587
+ # the connection pool's own checkout hook apply it the moment a connection is actually acquired.
588
+ ActionCable::Server::Worker.set_callback :work, :around do |_, blk|
589
+ Thread.current[:cable_tenant] = {
590
+ adapter: Apartment::Tenant.adapter,
591
+ tenant: connection.tenant,
592
+ }
593
+
594
+ # If this thread already holds a connection from earlier work, release it so the checkout
595
+ # hook below gets a fresh checkout to apply the schema to.
596
+ pool = Apartment.connection_class.connection_pool
597
+ pool.release_connection if pool.active_connection?
598
+
599
+ Apartment::Tenant.adapter.instance_variable_set(:@current, connection.tenant)
600
+
601
+ blk.call
602
+ ensure
603
+ Thread.current[:cable_tenant] = nil
604
+ end
605
+
606
+ ActiveSupport.on_load(:active_record) do
607
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.set_callback :checkout, :after do |conn|
608
+ next unless (ct = Thread.current[:cable_tenant]).present?
609
+ next unless Apartment::Tenant.adapter.is_a?(Apartment::Adapters::PostgresqlSchemaAdapter)
610
+ next unless conn.pool == Apartment.connection_class.connection_pool
611
+
612
+ adapter = ct[:adapter]
613
+ adapter.instance_variable_set(:@current, ct[:tenant])
614
+ conn.schema_search_path = adapter.send :full_search_path
615
+ end
616
+ end
617
+ ```
618
+
619
+ `connection` here is whatever `Worker#work(connection)` was handed — a real
620
+ `ActionCable::Connection::Base` for ordinary channel work, a `Host::Runner` for Room work. Either
621
+ way it's read fresh on every unit of work, so a Room only ever sees its own tenant even though many
622
+ Rooms for many orgs share the same small pool of Host worker threads.
623
+
558
624
  ### The hello handshake.
559
625
 
560
626
  A membership doesn't announce itself to the Room when the channel subscribes. Stream subscriptions
@@ -25,7 +25,7 @@ module CableRoom
25
25
 
26
26
  delegate :worker_pool, :inbound, to: :host
27
27
 
28
- def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:)
28
+ def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:, tenant: nil)
29
29
  @host = host
30
30
  @room_class = room_class
31
31
  @key = key
@@ -48,7 +48,13 @@ module CableRoom
48
48
  @handlers = {} # stream => the room's handler, so a message can be fed in by hand (see `inject`)
49
49
  @periodic_timers = []
50
50
 
51
- @tenant = Apartment::Tenant.current if defined?(Apartment)
51
+ # Whatever the member's provision request said (RoomMembership defaults that to the
52
+ # ambient tenant at join_room's own call site -- see RoomMembership#initialize) or, for a
53
+ # migrated room, whatever its snapshot carried forward. Nothing here parses it out of
54
+ # `key` or guesses from ambient state itself: a Host thread has no request of its own, so
55
+ # anything read here would be leftover from whatever this thread ran last, not this room's
56
+ # tenant.
57
+ @tenant = tenant
52
58
 
53
59
  @logger = ActionCable::Connection::TaggedLoggerProxy.new(
54
60
  host.logger,
@@ -147,7 +147,7 @@ module CableRoom
147
147
  # made after the lock is won, so it can't be raced. `handoff_only: true` (a handoff request)
148
148
  # starts nothing when there is no snapshot to adopt — it has expired, or the old host took
149
149
  # the room back — and lets the lock go.
150
- def ensure_room(room_class, key = nil, handoff_only: false)
150
+ def ensure_room(room_class, key = nil, handoff_only: false, tenant: nil)
151
151
  lock_key = room_class.room_port_key(key)
152
152
  lock_info = CableRoom.lock_manager.lock(lock_key, room_class::LOCK_DURATION.in_milliseconds)
153
153
  return false unless lock_info
@@ -167,6 +167,7 @@ module CableRoom
167
167
  lock_info,
168
168
  watchdog_interval: room_class::WATCH_DOG_INTERVAL,
169
169
  lock_duration: room_class::LOCK_DURATION,
170
+ tenant: tenant,
170
171
  )
171
172
 
172
173
  true
@@ -176,9 +177,12 @@ module CableRoom
176
177
  end
177
178
 
178
179
  # 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:)
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:)
182
186
  runner.start!
183
187
  runner
184
188
  end
@@ -215,6 +219,7 @@ module CableRoom
215
219
  lock_info,
216
220
  watchdog_interval: room_class::WATCH_DOG_INTERVAL,
217
221
  lock_duration: room_class::LOCK_DURATION,
222
+ tenant: snapshot["tenant"],
218
223
  )
219
224
  runner.restore!(snapshot, hold_inbound: hold_inbound)
220
225
  runner
@@ -181,10 +181,12 @@ module CableRoom
181
181
  # `handoff: true` (with a `reason`) is a draining host offering a room to its peers. The
182
182
  # race is the same — the lock decides — but the winner rebuilds the room from the offered
183
183
  # snapshot and replays what the old host relayed instead of starting it from scratch (see
184
- # CableRoom::Migration.adopt). A plain request starts the room fresh.
184
+ # CableRoom::Migration.adopt). A plain request starts the room fresh. A handoff request
185
+ # carries no `tenant` of its own -- the snapshot it adopts from carries it instead (see
186
+ # Room::Snapshotting#_snapshot), so `ensure_room` only needs it for the fresh-start path.
185
187
  handoff = request["handoff"] == true
186
188
  notification[:handoff] = handoff
187
- notification[:claimed] = host.ensure_room(room_class, room_key, handoff_only: handoff)
189
+ notification[:claimed] = host.ensure_room(room_class, room_key, handoff_only: handoff, tenant: request["tenant"])
188
190
  end
189
191
 
190
192
  if claimed
@@ -73,7 +73,7 @@ module CableRoom
73
73
 
74
74
  attr_reader :key
75
75
 
76
- delegate :logger, to: :@runner
76
+ delegate :logger, :tenant, to: :@runner
77
77
 
78
78
  def initialize(runner, key = nil)
79
79
  super()
@@ -39,6 +39,10 @@ module CableRoom
39
39
  version: Snapshot::VERSION,
40
40
  room_class: self.class.name,
41
41
  key: Snapshot.serialize_argument(key),
42
+ # Carried forward so the host that adopts this room (Host#restore_room) gets the same
43
+ # tenant this one was given at provisioning, instead of falling back to a guess (see
44
+ # Runner#initialize). nil for a room that was never given one either.
45
+ tenant: @runner.tenant,
42
46
  port_clients: _snapshot_port_clients,
43
47
  user_state: _snapshot_user_state,
44
48
  reaper_state: _snapshot_reaper_state,
@@ -0,0 +1,168 @@
1
+ require "cable_room"
2
+ require "active_support/tagged_logging"
3
+
4
+ module CableRoom
5
+ # Builds a Room without a Host, a live pubsub subscription, or a Redis lock, so the parts of a
6
+ # Room that are pure logic (authorization, port scoping, callbacks, reaping) can be tested
7
+ # directly and synchronously. Anything that needs real message delivery belongs in an e2e spec.
8
+ #
9
+ # Not loaded by `require "cable_room"` -- an app (or this gem's own suite) opts in with
10
+ # `require "cable_room/room_harness"`, typically from spec_helper.
11
+ #
12
+ # Stands in for CableRoom::Host::Runner.
13
+ class StubRoomRunner
14
+ attr_reader :logger, :state, :shutdown_reason, :watchdog_pings, :tenant
15
+
16
+ def initialize(tenant: nil)
17
+ @logger = ActiveSupport::TaggedLogging.new(Logger.new(IO::NULL))
18
+ @state = :started
19
+ @watchdog_pings = 0
20
+ @timers = []
21
+ @tenant = tenant
22
+ end
23
+
24
+ def ping_watchdog
25
+ @watchdog_pings += 1
26
+ end
27
+
28
+ # Record streams rather than subscribing, so startup can run without a live pubsub. There is
29
+ # nothing to wait for, so `on_live` runs at once.
30
+ def subscribe(stream, on_live: nil, &blk)
31
+ streams[stream] = blk
32
+ on_live&.call
33
+ end
34
+
35
+ def unsubscribe(stream)
36
+ streams.delete(stream)
37
+ end
38
+
39
+ def streams
40
+ @streams ||= {}
41
+ end
42
+
43
+ # Feed a message to whatever the room streamed from this port, the way pubsub would
44
+ def deliver_to_stream(stream, message)
45
+ streams.fetch(stream).call(message)
46
+ end
47
+
48
+ def initiate_shutdown(reason)
49
+ @shutdown_reason = reason
50
+ @state = :shutting_down
51
+ end
52
+
53
+ def stop!
54
+ @state = :dead
55
+ end
56
+
57
+ # Run inline so specs stay synchronous
58
+ def post_work(**_kwargs, &blk)
59
+ blk.call
60
+ end
61
+
62
+ # Record timers rather than scheduling them, so specs can fire them on demand
63
+ def start_periodic_timer(callback, every:)
64
+ timer = StubTimer.new(callback, every)
65
+ @timers << timer
66
+ timer
67
+ end
68
+
69
+ def timers
70
+ @timers.reject(&:shutdown?)
71
+ end
72
+
73
+ class StubTimer
74
+ attr_reader :interval
75
+
76
+ def initialize(callback, interval)
77
+ @callback = callback
78
+ @interval = interval
79
+ @shutdown = false
80
+ end
81
+
82
+ def fire!
83
+ @callback.call
84
+ end
85
+
86
+ def shutdown
87
+ @shutdown = true
88
+ end
89
+
90
+ def shutdown?
91
+ @shutdown
92
+ end
93
+ end
94
+ end
95
+
96
+ module RoomHarness
97
+ # A fully-initialized Room backed by a StubRoomRunner. Pass `started: true` to run the
98
+ # startup callbacks (which is what registers reaper timers and the inbound stream). Pass
99
+ # `tenant:` the way a member's provision request would (see RoomMembership#initialize) for a
100
+ # Room whose logic needs Apartment scoped correctly -- this switches into it for the duration
101
+ # of startup, the way PandaPal's ActionCable::Server::Worker hook does in production for a
102
+ # real Runner, so a Room can't tell the difference from here.
103
+ def build_room(room_class, key: "harness-key", started: false, tenant: nil)
104
+ runner = StubRoomRunner.new(tenant: tenant)
105
+ room = room_class.allocate
106
+ room.send(:initialize, runner, key)
107
+ runner.define_singleton_method(:room) { room }
108
+ with_room_tenant(runner) { room.send(:_startup) } if started
109
+ room
110
+ end
111
+
112
+ # A Room rebuilt from `snapshot` on a fresh StubRoomRunner, the way Host#restore_room would
113
+ # build it: gem state restored, then the startup chain with `restore_state` in place of
114
+ # `startup`. Pass the class explicitly so a spec reads as "restore a FooRoom", even though the
115
+ # snapshot names it too. `tenant` comes off the snapshot itself, same as Host#restore_room.
116
+ def restore_room(room_class, snapshot)
117
+ runner = StubRoomRunner.new(tenant: snapshot["tenant"])
118
+ room = room_class.allocate
119
+ room.send(:initialize, runner, Snapshot.key_for(snapshot))
120
+ runner.define_singleton_method(:room) { room }
121
+ with_room_tenant(runner) { room.send(:_restore, snapshot) }
122
+ room
123
+ end
124
+
125
+ def stub_runner_for(room)
126
+ room.instance_variable_get(:@runner)
127
+ end
128
+
129
+ # Push a message through the same callback chain the real stream handler uses.
130
+ # Returns false when authorization halted the chain, otherwise :handled.
131
+ def deliver(room, message)
132
+ message = message.deep_stringify_keys if message.is_a?(Hash)
133
+ Room.current_message = message
134
+ with_room_tenant(stub_runner_for(room)) do
135
+ room.send(:run_callbacks, :receive_message) do
136
+ room.send(:handle_received_message, message)
137
+ :handled
138
+ end
139
+ end
140
+ ensure
141
+ Room.current_message = nil
142
+ end
143
+
144
+ # Register a port on the room the way a real RoomMembership would, returning its token.
145
+ def connect_port(room, token: SecureRandom.hex(16), tags: [], as: nil)
146
+ message = { type: 'port_connected', mtok: token, tags: tags }
147
+ message[:extra] = ::ActiveJob::Arguments.serialize([{ as: as }]) if as
148
+ deliver(room, message)
149
+ token
150
+ end
151
+
152
+ def port_client(room, token)
153
+ room.send(:resolve_client_port, token)
154
+ end
155
+
156
+ private
157
+
158
+ # Stands in for PandaPal's ActionCable::Server::Worker hook, which is what actually switches
159
+ # Apartment before a Host thread's DB calls in production. A Room without a `tenant` (a
160
+ # single-tenant app, or a spec that doesn't need one) gets no-op behavior; the gem itself
161
+ # stays Apartment-optional either way.
162
+ def with_room_tenant(runner, &blk)
163
+ return blk.call unless runner.tenant && defined?(Apartment)
164
+
165
+ Apartment::Tenant.switch(runner.tenant, &blk)
166
+ end
167
+ end
168
+ end
@@ -148,6 +148,7 @@ module CableRoom
148
148
  on_left: nil,
149
149
  tags: [],
150
150
  extra: nil,
151
+ tenant: nil,
151
152
  &preconfigure
152
153
  )
153
154
  @mutex = Monitor.new
@@ -161,6 +162,14 @@ module CableRoom
161
162
  @room_key = room_key
162
163
  @allow_create = create
163
164
 
165
+ # Defaults to the ambient tenant right here, at join_room's own call site -- the member's
166
+ # own request thread, the last point before this room's work moves to a Host thread with no
167
+ # request of its own to derive one from (see Runner#initialize). An app only needs to pass
168
+ # `tenant:` explicitly to ask for something other than "whatever tenant this join is
169
+ # happening under", which is the common case. nil isn't a real tenant either way, so it
170
+ # doubles as "not given".
171
+ @tenant = tenant || (Apartment::Tenant.current if defined?(Apartment))
172
+
164
173
  @on_room_opened = on_room_opened
165
174
  @on_joined = on_joined
166
175
  @on_message = on_message
@@ -230,6 +239,7 @@ module CableRoom
230
239
  "extra" => serialized_extra,
231
240
  "create" => @allow_create,
232
241
  "hello_received" => @hello_received,
242
+ "tenant" => @tenant,
233
243
  }
234
244
  end
235
245
 
@@ -446,6 +456,7 @@ module CableRoom
446
456
  @serialized_extra = record["extra"]
447
457
  @allow_create = record["create"] == true
448
458
  @hello_received = record["hello_received"] == true
459
+ @tenant = record["tenant"]
449
460
  end
450
461
 
451
462
  def transmit_port_connected
@@ -491,6 +502,10 @@ module CableRoom
491
502
  # Keys travel the way `extra` does, so a record key arrives on the host as the record
492
503
  room_key: ::ActiveJob::Arguments.serialize([@room_key]),
493
504
  requested_at: Time.current,
505
+ # Whatever `join_room` was given for `tenant:` (nil for a single-tenant app). The Host has
506
+ # no request of its own to derive this from, so a multi-tenant app has to hand it over
507
+ # explicitly here rather than have the Runner guess at ambient state (see Runner#initialize).
508
+ tenant: @tenant,
494
509
  }
495
510
 
496
511
  ActiveSupport::Notifications.instrument(
@@ -1,3 +1,3 @@
1
1
  module CableRoom
2
- VERSION = "0.7.0.beta1".freeze
2
+ VERSION = "0.7.0.beta2".freeze
3
3
  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.beta1
4
+ version: 0.7.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Knapp
@@ -140,6 +140,7 @@ files:
140
140
  - lib/cable_room/room/snapshotting.rb
141
141
  - lib/cable_room/room/threading.rb
142
142
  - lib/cable_room/room/user_management.rb
143
+ - lib/cable_room/room_harness.rb
143
144
  - lib/cable_room/room_member.rb
144
145
  - lib/cable_room/room_proxy_channel.rb
145
146
  - lib/cable_room/snapshot.rb