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 +4 -4
- data/README.md +98 -1
- data/lib/cable_room/host/runner.rb +62 -7
- data/lib/cable_room/host/worker_pool.rb +40 -27
- data/lib/cable_room/host.rb +9 -4
- data/lib/cable_room/placement.rb +4 -2
- data/lib/cable_room/room/base.rb +1 -1
- data/lib/cable_room/room/callbacks.rb +22 -0
- data/lib/cable_room/room/snapshotting.rb +4 -0
- data/lib/cable_room/room_harness.rb +168 -0
- data/lib/cable_room/room_member.rb +15 -0
- data/lib/cable_room/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 196a159b658502db6be07f62ae5987283192a3f76580f5ed79b3790c8c387732
|
|
4
|
+
data.tar.gz: c2ae315fee416f4e823f51fb2bea539692e6161f4849dd590fe845ffa3fe5f6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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.
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
#
|
|
20
|
-
# `handle_exception
|
|
21
|
-
# through here, so report it properly
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
data/lib/cable_room/host.rb
CHANGED
|
@@ -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
|
-
|
|
181
|
-
|
|
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
|
data/lib/cable_room/placement.rb
CHANGED
|
@@ -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
|
data/lib/cable_room/room/base.rb
CHANGED
|
@@ -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(
|
data/lib/cable_room/version.rb
CHANGED
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.
|
|
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
|