cable_room 0.7.0.beta1 → 0.7.0.beta3

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: 196a159b658502db6be07f62ae5987283192a3f76580f5ed79b3790c8c387732
4
+ data.tar.gz: c2ae315fee416f4e823f51fb2bea539692e6161f4849dd590fe845ffa3fe5f6e
5
5
  SHA512:
6
- metadata.gz: 3304bd77c3d455a2b867d83d82d4985ff7e1a3ec10bd80ec6a097f50be040fadbbaac501d7f26234384ba50dbe9f09864c92e1114d8cecff75d308ae2a770261
7
- data.tar.gz: 408c73ab717cd8aaa5a7fbc8ce6b5d209ee2169fe50a731ac8b6d7b2b417b61e64c5a9ceac55155e07e677f6b3a952bb56edd4043a16b624bb264cd6ca1fb21d
6
+ metadata.gz: c3210f7c372f5085b2acf3de7fd8ee98f9e7d81a951b319d2476faa71266d57e48ba21b8018815504b969ca86e2431e907462bc08dedb14fb5fbbae2a6bc4ada
7
+ data.tar.gz: 03adad866d9348d12acd61b848ca0a5cfd45b4557b0dd978d43a9b8d8581fa863674fe2e993b2404b0d7db4376d134e7e0a9d1f1912c29b0784704bbd871ceb0
data/README.md CHANGED
@@ -154,12 +154,22 @@ class MyRoom < CableRoom::Room::Base
154
154
  after_startup { } # aliased as on_startup
155
155
  before_shutdown { } # last chance to broadcast
156
156
  after_shutdown { } # aliased as on_shutdown
157
+
158
+ before_work { } # about to run on a Host thread: a message/timer handler, startup, shutdown...
159
+ after_work { } # ...same set, on the way back out
160
+ around_work { |room, blk| blk.call } # wrap the whole thing (see Multi-tenancy)
157
161
  end
158
162
  ```
159
163
 
160
164
  You can also just define `startup` and `shutdown` methods; they run inside the corresponding
161
165
  callback chain.
162
166
 
167
+ `before_work`/`after_work`/`around_work` wrap *every* piece of Room code that ever runs on a Host
168
+ thread — not just messages, but `startup`, `restore_state`, `snapshot_state`, and `shutdown` too
169
+ (see [Multi-tenancy](#multi-tenancy)). Define them on `MyRoom` for just this Room, or on your own
170
+ shared base Room class for all of them — ordinary callback inheritance, so a subclass's own
171
+ `around_work` nests inside whatever its ancestors already declared.
172
+
163
173
  Out of the box a Room broadcasts `{ type: "room_opened" }` after startup and
164
174
  `{ type: "room_closed", reason: ... }` before shutdown.
165
175
 
@@ -218,7 +228,8 @@ Timer bodies run on the Room's thread, so they're serialized against message han
218
228
  When a host shuts down on purpose, it can move its Rooms to another host instead of closing them.
219
229
  Members don't notice: their streams stay open, and the new host picks up where the old one left
220
230
  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:
231
+ user map; each reaper's deadline; and the Room's tenant, if `join_room` was given one (see
232
+ [Multi-tenancy](#multi-tenancy)). A Room's own state moves through two optional hooks:
222
233
 
223
234
  ```ruby
224
235
  class QuizRoom < CableRoom::Room::Base
@@ -534,6 +545,7 @@ end
534
545
  | `as:` | The user identity. Defaults to `current_user` when the channel has one. |
535
546
  | `tags:` | Tags this port carries, for policies and targeted broadcasts. |
536
547
  | `extra:` | Extra metadata, readable on the Room's `PortClient`. |
548
+ | `tenant:` | This Room's tenant, for a multi-tenant app. Defaults to the ambient tenant right here (see [Multi-tenancy](#multi-tenancy)). |
537
549
  | `forward:` | Pipe every Room message straight to the websocket. |
538
550
  | `on_joined:` | The Room acknowledged this port. |
539
551
  | `on_message:` | Any message from the Room. |
@@ -555,6 +567,91 @@ request on the bus, and one of the Rooms hosts starts the Room (see
555
567
  Room acknowledges the port, so a lost request costs at most one ping interval, and if the Room's
556
568
  host process dies the next ping from any member brings it back somewhere else.
557
569
 
570
+ ### Multi-tenancy.
571
+
572
+ A Room's own work runs on its Host's worker pool, not on the joining member's request thread, so
573
+ there's no Rack middleware and no request to derive an app's tenant from once the Room is up. Pass
574
+ `tenant:` to `join_room` for exactly this — whatever it's given rides along on the provision
575
+ request, comes back out as `Runner#tenant` (and `Room#tenant`), and survives a host migration
576
+ (it's part of what a Room's snapshot carries, alongside its ports and users; see
577
+ [Migration hooks](#migration-hooks)). Leave it out and it defaults to whatever
578
+ `Apartment::Tenant.current` returns right where `join_room` is called — the member's own request
579
+ thread, the last place with real request context before the Room's work moves to a Host thread:
580
+
581
+ ```ruby
582
+ join_room(QuizRoom, params[:quiz_id], create: true) # tenant: Apartment::Tenant.current, captured here
583
+ join_room(QuizRoom, params[:quiz_id], create: true, tenant: "some-other-org") # explicit override
584
+ ```
585
+
586
+ cable_room doesn't depend on Apartment (or anything else) to use this — `tenant` is just a value it
587
+ carries around for you, the same way `key` or `extra` are. **It never switches anything itself.**
588
+ Whatever ends up on `Runner#tenant` still has to actually be applied before a Room's DB calls run,
589
+ the same way a request's tenant has to be applied before a controller action's do.
590
+
591
+ `around_work` (alongside `before_work`/`after_work`, see [Lifecycle](#lifecycle)) is the hook for
592
+ that. It wraps every piece of Room code that runs on a Host thread — `startup`/`restore_state`,
593
+ message and timer handlers, `snapshot_state`, and `shutdown` alike — not just message dispatch, so
594
+ there's exactly one place to apply a tenant no matter which of those runs first for a given Room.
595
+ Define it once on your own shared base Room class (every app Room already inherits from
596
+ `CableRoom::Room::Base`, directly or through one of your own) to cover every Room, or again on a
597
+ specific Room subclass for one that needs something different — ordinary callback inheritance,
598
+ nothing cable_room-specific:
599
+
600
+ ```ruby
601
+ class ApplicationRoom < CableRoom::Room::Base
602
+ # Lazily switch a worker thread to the right tenant only when it's about to touch the DB.
603
+ # Actively switching up front checks out a connection and runs SET search_path even for a
604
+ # message that never queries anything, so this only stages the tenant (no DB call) and lets
605
+ # the connection pool's own checkout hook apply it the moment a connection is actually acquired.
606
+ around_work do |room, blk|
607
+ Thread.current[:cable_tenant] = {
608
+ adapter: Apartment::Tenant.adapter,
609
+ tenant: room.tenant,
610
+ }
611
+
612
+ # If this thread already holds a connection from earlier work, release it so the checkout
613
+ # hook below gets a fresh checkout to apply the schema to.
614
+ pool = Apartment.connection_class.connection_pool
615
+ pool.release_connection if pool.active_connection?
616
+
617
+ Apartment::Tenant.adapter.instance_variable_set(:@current, room.tenant)
618
+
619
+ blk.call
620
+ ensure
621
+ Thread.current[:cable_tenant] = nil
622
+ end
623
+ end
624
+
625
+ ActiveSupport.on_load(:active_record) do
626
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.set_callback :checkout, :after do |conn|
627
+ next unless (ct = Thread.current[:cable_tenant]).present?
628
+ next unless Apartment::Tenant.adapter.is_a?(Apartment::Adapters::PostgresqlSchemaAdapter)
629
+ next unless conn.pool == Apartment.connection_class.connection_pool
630
+
631
+ adapter = ct[:adapter]
632
+ adapter.instance_variable_set(:@current, ct[:tenant])
633
+ conn.schema_search_path = adapter.send :full_search_path
634
+ end
635
+ end
636
+ ```
637
+
638
+ `room` is the Room instance itself — `room.tenant` (delegated to its `Host::Runner`) is read fresh
639
+ on every call, so a Room only ever sees its own tenant even though many Rooms for many orgs share
640
+ the same small pool of Host worker threads. `around_work` never has to guard against running
641
+ twice: cable_room only ever enters it once per thread, even when one piece of Room code calls
642
+ another (a message handler that shuts the Room down, say) — the inner call just runs inside the
643
+ outer one's context.
644
+
645
+ This replaces reaching for `ActionCable::Server::Worker.set_callback :work, :around` the way
646
+ PandaPal does for ordinary channels — that hook only ever fired for message dispatch, and never
647
+ for a Room's `startup`, `snapshot_state`, or `shutdown`, which run directly on a Host thread
648
+ instead. It's structural, not just a convention: `Host::WorkerPool` isn't an
649
+ `ActionCable::Server::Worker` subclass, so it doesn't share ActionCable's `:work` callback chain at
650
+ all. If PandaPal (or anything else) already has a `:work` hook installed, it's harmless to leave in
651
+ place — it simply has nothing to attach to for Room work, so it can neither conflict with,
652
+ double-apply with, nor be relied on in place of `around_work`. Define `around_work` and that's
653
+ the one thing actually switching a Room's tenant.
654
+
558
655
  ### The hello handshake.
559
656
 
560
657
  A membership doesn't announce itself to the Room when the channel subscribes. Stream subscriptions
@@ -18,6 +18,11 @@ module CableRoom
18
18
  class Runner
19
19
  FROZEN_STATES = %i[freezing frozen].freeze
20
20
 
21
+ # Thread-local flag guarding re-entry into a Room's `:work` callbacks (see
22
+ # `with_room_context`). Namespaced so it can never collide with a key an app's own callback
23
+ # (PandaPal's, or anything else touching `Thread.current`) happens to use for its own purposes.
24
+ APP_WORK_KEY = :"cable_room.in_room_work_callbacks"
25
+
21
26
  attr_reader :host, :room, :room_class, :key, :uuid, :tenant, :logger
22
27
 
23
28
  # Monotonic time this runner was built. `Host#drain!` migrates rooms oldest first.
@@ -25,7 +30,7 @@ module CableRoom
25
30
 
26
31
  delegate :worker_pool, :inbound, to: :host
27
32
 
28
- def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:)
33
+ def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:, tenant: nil)
29
34
  @host = host
30
35
  @room_class = room_class
31
36
  @key = key
@@ -48,7 +53,13 @@ module CableRoom
48
53
  @handlers = {} # stream => the room's handler, so a message can be fed in by hand (see `inject`)
49
54
  @periodic_timers = []
50
55
 
51
- @tenant = Apartment::Tenant.current if defined?(Apartment)
56
+ # Whatever the member's provision request said (RoomMembership defaults that to the
57
+ # ambient tenant at join_room's own call site -- see RoomMembership#initialize) or, for a
58
+ # migrated room, whatever its snapshot carried forward. Nothing here parses it out of
59
+ # `key` or guesses from ambient state itself: a Host thread has no request of its own, so
60
+ # anything read here would be leftover from whatever this thread ran last, not this room's
61
+ # tenant.
62
+ @tenant = tenant
52
63
 
53
64
  @logger = ActionCable::Connection::TaggedLoggerProxy.new(
54
65
  host.logger,
@@ -116,7 +127,7 @@ module CableRoom
116
127
  room.send(:_shutdown_reason=, reason) unless reason.nil?
117
128
  unsubscribe_all
118
129
  begin
119
- with_executor { room.send(:_shutdown) }
130
+ with_executor { with_room_context { room.send(:_shutdown) } }
120
131
  ensure
121
132
  terminate!
122
133
  end
@@ -275,7 +286,7 @@ module CableRoom
275
286
  def snapshot
276
287
  raise "#{room_class.name}[#{key}] must be frozen before it can be snapshotted (state: #{state})" unless state == :frozen
277
288
 
278
- with_executor { Snapshot.take(room) }
289
+ with_executor { with_room_context { Snapshot.take(room) } }
279
290
  end
280
291
 
281
292
  # Give the room's lock up while staying alive, so another host can claim the room and this
@@ -359,7 +370,7 @@ module CableRoom
359
370
  # message can't take the room down with it.
360
371
  def post_work(async: false, silent: false, &blk)
361
372
  work = proc do
362
- worker_pool.invoke(blk, :call, connection: self)
373
+ worker_pool.invoke(-> { with_room_context(&blk) }, :call, connection: self)
363
374
  rescue => e
364
375
  report_work_error(e)
365
376
  end
@@ -448,8 +459,10 @@ module CableRoom
448
459
  def start_with(final_state: :started)
449
460
  @current_state = :starting
450
461
  with_executor do
451
- yield
452
- start_periodic_timers unless final_state == :frozen
462
+ with_room_context do
463
+ yield
464
+ start_periodic_timers unless final_state == :frozen
465
+ end
453
466
  end
454
467
  @current_state = final_state
455
468
  rescue => e
@@ -566,6 +579,48 @@ module CableRoom
566
579
  yield
567
580
  end
568
581
  end
582
+
583
+ # The one seam a Room hooks to run its own around-work logic -- Apartment switching,
584
+ # tracing, whatever -- instead of reaching for ActionCable::Server::Worker's `:work`
585
+ # callback the way PandaPal does (see README's Multi-tenancy section). Deliberately the
586
+ # Room's own `before_work`/`after_work`/`around_work` (see Room::Callbacks), not a second,
587
+ # separately-configured extension point: a Room class already is cable_room's own
588
+ # (non-shared, non-leaky) namespace, ordinary Ruby inheritance already gives "every Room"
589
+ # (define it on your own base Room class, or reopen CableRoom::Room::Base itself) and
590
+ # "just this one" (define it again on a specific subclass) for free, and there's no reason
591
+ # to make an app choose between two different mechanisms for the same thing. Still supports
592
+ # PandaPal's lazy pattern: stage a thread-local here, apply it from an ActiveRecord
593
+ # `checkout` hook, so a message that never queries anything never pays for a schema switch.
594
+ #
595
+ # Every place this Runner touches Room code goes through here: `post_work`'s dispatched
596
+ # work, and the lifecycle methods (`start_with`, `stop!`, `snapshot`) that -- unlike
597
+ # `post_work` -- run directly on the calling thread rather than through the worker pool.
598
+ # A Room's `around_work` never has to know which of those it's wrapping, or guard against
599
+ # being entered twice: called from inside work that's already running (`Room::Lifecycle#stop!`
600
+ # from a message handler, say, or the watchdog's own `stop!`), this just yields through.
601
+ #
602
+ # AR query-log tagging (tag the log with this Room's own tags, the way
603
+ # `ActiveRecordConnectionManagement` did when WorkerPool was still a Worker subclass) wraps
604
+ # every call here regardless of re-entry -- tagging nests safely, unlike `:work` callbacks
605
+ # that stage-and-clear a thread-local.
606
+ def with_room_context(&blk)
607
+ with_ar_log_tagging do
608
+ next yield if Thread.current[APP_WORK_KEY]
609
+
610
+ Thread.current[APP_WORK_KEY] = true
611
+ begin
612
+ room.send(:run_callbacks, :work, &blk)
613
+ ensure
614
+ Thread.current[APP_WORK_KEY] = false
615
+ end
616
+ end
617
+ end
618
+
619
+ def with_ar_log_tagging(&blk)
620
+ return yield unless defined?(ActiveRecord::Base)
621
+
622
+ logger.tag(ActiveRecord::Base.logger, &blk)
623
+ end
569
624
  end
570
625
  end
571
626
  end
@@ -1,37 +1,50 @@
1
+ require 'concurrent'
2
+
1
3
  module CableRoom
2
4
  class Host
3
- # The thread pool every room's work runs on. It's an ActionCable Worker so the `:work`
4
- # callbacks Rails installs (the executor wrap, ActiveRecord log tagging) still apply to room
5
- # work exactly as they did when rooms were channels.
6
- #
7
- # The "connection" passed around here is the room's Host::Runner. ActionCable's Worker was
8
- # written for connections; rooms don't have one, but the runner fills the same role: it's
9
- # the thing with a logger and an error reporter.
10
- class WorkerPool < ActionCable::Server::Worker
11
- set_callback :work, :around do |_, blk|
12
- pconn = ActionCable::Server::Worker.connection
13
- ActionCable::Server::Worker.connection = connection
14
- blk.call
15
- ensure
16
- ActionCable::Server::Worker.connection = pconn
5
+ # The thread pool every room's work runs on. Deliberately *not* an ActionCable::Server::Worker
6
+ # subclass: that would put Room work on the same shared `:work` callback chain as ordinary
7
+ # ActionCable connections, and that chain is leaky by construction -- `ActiveSupport::Callbacks`
8
+ # re-injects a callback added to the base class into every existing descendant regardless of
9
+ # load order, so an app's own tenant-switching hook (PandaPal's, say, registered from a Rails
10
+ # initializer well after this class is defined) ends up wrapping Room work too, whether we want
11
+ # it to or not. Owning a distinct namespace here avoids that category of problem entirely:
12
+ # nothing outside cable_room can attach to Room work by surprise. The one seam an app gets is
13
+ # a Room's own `before_work`/`after_work`/`around_work` (see Room::Callbacks and
14
+ # Host::Runner#with_room_context).
15
+ class WorkerPool
16
+ attr_reader :executor
17
+
18
+ def initialize(max_size: 5)
19
+ @executor = Concurrent::ThreadPoolExecutor.new(
20
+ name: "CableRoom",
21
+ min_threads: 1,
22
+ max_threads: max_size,
23
+ max_queue: 0,
24
+ )
17
25
  end
18
26
 
19
- # ActionCable's Worker#invoke reduces every exception to a log line and a no-argument
20
- # `handle_exception` call, which discards the error itself. Rooms run all of their work
21
- # through here, so report it properly instead.
27
+ # Reduces every exception to a proper report instead of ActionCable's Worker#invoke, which
28
+ # logs a line and calls a no-argument `handle_exception`, discarding the error itself. Rooms
29
+ # run all of their work through here, so report it properly. `connection:` is always a
30
+ # Host::Runner in practice (see Runner#post_work); the else branch is a defensive fallback.
22
31
  def invoke(receiver, method, *args, connection:, &block)
23
- work(connection) do
24
- receiver.send method, *args, &block
25
- rescue Exception => e
26
- if connection.respond_to?(:report_work_error)
27
- connection.report_work_error(e)
28
- else
29
- logger.error "There was an exception - #{e.class}(#{e.message})"
30
- logger.error Array(e.backtrace).join("\n")
31
- CableRoom.report_error(e, connection: connection)
32
- end
32
+ receiver.send method, *args, &block
33
+ rescue Exception => e
34
+ if connection.respond_to?(:report_work_error)
35
+ connection.report_work_error(e)
36
+ else
37
+ logger.error "There was an exception - #{e.class}(#{e.message})"
38
+ logger.error Array(e.backtrace).join("\n")
39
+ CableRoom.report_error(e, connection: connection)
33
40
  end
34
41
  end
42
+
43
+ private
44
+
45
+ def logger
46
+ ActionCable.server.logger
47
+ end
35
48
  end
36
49
  end
37
50
  end
@@ -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()
@@ -7,6 +7,7 @@ module CableRoom
7
7
  included do
8
8
  define_callbacks :startup
9
9
  define_callbacks :shutdown
10
+ define_callbacks :work
10
11
  end
11
12
 
12
13
  class_methods do
@@ -27,6 +28,27 @@ module CableRoom
27
28
  set_callback(:shutdown, :after, *methods, &block)
28
29
  end
29
30
  alias_method :on_shutdown, :after_shutdown
31
+
32
+ # Wraps every piece of Room code that runs on a Host thread: startup/restore, message and
33
+ # timer handlers, snapshot_state, and shutdown alike (see Host::Runner#with_room_context,
34
+ # the one thing that ever triggers the :work callback chain). This is the seam for
35
+ # anything that has to be true before a Room's own code runs on a given thread -- Apartment
36
+ # switching first among them (see README's Multi-tenancy section).
37
+ #
38
+ # Define it once on your own shared base Room class (or reopen CableRoom::Room::Base
39
+ # itself) to cover every Room; define it again on a specific Room subclass for one that
40
+ # needs something different -- ordinary callback inheritance, nothing cable_room-specific.
41
+ def before_work(*methods, &block)
42
+ set_callback(:work, :before, *methods, &block)
43
+ end
44
+
45
+ def after_work(*methods, &block)
46
+ set_callback(:work, :after, *methods, &block)
47
+ end
48
+
49
+ def around_work(*methods, &block)
50
+ set_callback(:work, :around, *methods, &block)
51
+ end
30
52
  end
31
53
  end
32
54
  end
@@ -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.beta3".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.beta3
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