cable_room 0.7.0.beta2 → 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: 230a3cec91f557f72ae459ed02fdf93ca4cc72d33a09a1e886ba503d27d665df
4
- data.tar.gz: 4bc92906a5db6b957265f09b7fb3b596e587208fa01057890ad273ed1d9d0a94
3
+ metadata.gz: 196a159b658502db6be07f62ae5987283192a3f76580f5ed79b3790c8c387732
4
+ data.tar.gz: c2ae315fee416f4e823f51fb2bea539692e6161f4849dd590fe845ffa3fe5f6e
5
5
  SHA512:
6
- metadata.gz: '0268892f4951a1c869caf8e5ab8d2218e4e06def83df3d5b32a3ae21b768678175668276dc47f4cb4a8fb3abcff42bc28092989aa9385c1df345e5c677de33af'
7
- data.tar.gz: 186b3001f84e157c856164b22aff29984a7ec8471bc2e4ea28ac9c830a26a638ceae8852b3a5301f43eb27e26d4e0294f294543637353dbe04999dd91d71f70c
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
 
@@ -578,29 +588,38 @@ carries around for you, the same way `key` or `extra` are. **It never switches a
578
588
  Whatever ends up on `Runner#tenant` still has to actually be applied before a Room's DB calls run,
579
589
  the same way a request's tenant has to be applied before a controller action's do.
580
590
 
581
- If you're wiring this up yourself, here's the shape of that hook:
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:
582
599
 
583
600
  ```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
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
604
623
  end
605
624
 
606
625
  ActiveSupport.on_load(:active_record) do
@@ -616,10 +635,22 @@ ActiveSupport.on_load(:active_record) do
616
635
  end
617
636
  ```
618
637
 
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.
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.
623
654
 
624
655
  ### The hello handshake.
625
656
 
@@ -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.
@@ -122,7 +127,7 @@ module CableRoom
122
127
  room.send(:_shutdown_reason=, reason) unless reason.nil?
123
128
  unsubscribe_all
124
129
  begin
125
- with_executor { room.send(:_shutdown) }
130
+ with_executor { with_room_context { room.send(:_shutdown) } }
126
131
  ensure
127
132
  terminate!
128
133
  end
@@ -281,7 +286,7 @@ module CableRoom
281
286
  def snapshot
282
287
  raise "#{room_class.name}[#{key}] must be frozen before it can be snapshotted (state: #{state})" unless state == :frozen
283
288
 
284
- with_executor { Snapshot.take(room) }
289
+ with_executor { with_room_context { Snapshot.take(room) } }
285
290
  end
286
291
 
287
292
  # Give the room's lock up while staying alive, so another host can claim the room and this
@@ -365,7 +370,7 @@ module CableRoom
365
370
  # message can't take the room down with it.
366
371
  def post_work(async: false, silent: false, &blk)
367
372
  work = proc do
368
- worker_pool.invoke(blk, :call, connection: self)
373
+ worker_pool.invoke(-> { with_room_context(&blk) }, :call, connection: self)
369
374
  rescue => e
370
375
  report_work_error(e)
371
376
  end
@@ -454,8 +459,10 @@ module CableRoom
454
459
  def start_with(final_state: :started)
455
460
  @current_state = :starting
456
461
  with_executor do
457
- yield
458
- start_periodic_timers unless final_state == :frozen
462
+ with_room_context do
463
+ yield
464
+ start_periodic_timers unless final_state == :frozen
465
+ end
459
466
  end
460
467
  @current_state = final_state
461
468
  rescue => e
@@ -572,6 +579,48 @@ module CableRoom
572
579
  yield
573
580
  end
574
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
575
624
  end
576
625
  end
577
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module CableRoom
2
- VERSION = "0.7.0.beta2".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.beta2
4
+ version: 0.7.0.beta3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Knapp