cable_room 0.6.2 → 0.7.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +1190 -0
  4. data/cable_room.gemspec +5 -2
  5. data/exe/cable_room +8 -0
  6. data/lib/cable_room/broadcaster.rb +116 -0
  7. data/lib/cable_room/bus.rb +372 -0
  8. data/lib/cable_room/cli.rb +237 -0
  9. data/lib/cable_room/config.rb +112 -0
  10. data/lib/cable_room/host/bus_inbound.rb +36 -0
  11. data/lib/cable_room/host/runner.rb +571 -0
  12. data/lib/cable_room/host/supervisor.rb +275 -0
  13. data/lib/cable_room/host/worker_pool.rb +37 -0
  14. data/lib/cable_room/host.rb +477 -0
  15. data/lib/cable_room/membership_store.rb +105 -0
  16. data/lib/cable_room/migration.rb +586 -0
  17. data/lib/cable_room/periodic_timer.rb +18 -0
  18. data/lib/cable_room/placement.rb +258 -0
  19. data/lib/cable_room/ports.rb +19 -11
  20. data/lib/cable_room/railtie.rb +3 -12
  21. data/lib/cable_room/room/base.rb +45 -39
  22. data/lib/cable_room/room/host_adapter.rb +52 -0
  23. data/lib/cable_room/room/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +57 -0
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +78 -0
  27. data/lib/cable_room/room/threading.rb +2 -2
  28. data/lib/cable_room/room/user_management.rb +27 -0
  29. data/lib/cable_room/room.rb +5 -2
  30. data/lib/cable_room/room_member.rb +260 -84
  31. data/lib/cable_room/room_proxy_channel.rb +13 -2
  32. data/lib/cable_room/snapshot.rb +136 -0
  33. data/lib/cable_room/version.rb +1 -1
  34. data/lib/cable_room.rb +57 -2
  35. metadata +25 -9
  36. data/lib/cable_room/channel_base.rb +0 -262
  37. data/lib/cable_room/channel_tracker.rb +0 -130
  38. data/lib/cable_room/room/channel_adapter.rb +0 -18
@@ -13,12 +13,17 @@ module CableRoom
13
13
  @room_membership = subscribe_to_room
14
14
  end
15
15
 
16
+ # The channel's actions are `hello` and `ping` (inherited from RoomMember: the client performs
17
+ # hello once its subscription is confirmed, and the membership announces itself to the room)
18
+ # and `receive`, which pipes everything else the client sends into the room. Until the room
19
+ # has acknowledged the membership (or, under AnyCable, until hello has gone out — see
20
+ # RoomMembership#accepts_input?) there is nowhere for input to go, so it is dropped.
16
21
  def receive(data)
17
- @room_membership << data if @room_membership&.connected?
22
+ room_membership << data if room_membership&.accepts_input?
18
23
  end
19
24
 
20
25
  def unsubscribed
21
- @room_membership&.leave!
26
+ room_membership&.leave!
22
27
  end
23
28
 
24
29
  protected
@@ -27,6 +32,12 @@ module CableRoom
27
32
  raise NotImplementedError
28
33
  end
29
34
 
35
+ # The membership `subscribed` created. Under AnyCable this channel instance may not be the one
36
+ # that ran `subscribed`, so fall back to the memberships rebuilt from the channel state.
37
+ def room_membership
38
+ @room_membership ||= room_memberships.first
39
+ end
40
+
30
41
  def join_room(*args, **kwargs, &blk)
31
42
  kwargs[:forward] = true
32
43
  super
@@ -0,0 +1,136 @@
1
+ module CableRoom
2
+ # The gem-owned picture of a running room, as one JSON document, so a room can be rebuilt on
3
+ # another host with its members none the wiser. `take` produces it from a frozen room (see
4
+ # Host::Runner#freeze!); `Host#restore_room` consumes it. Version 1 looks like this (string
5
+ # keys, because it has been through JSON):
6
+ #
7
+ # {
8
+ # "version" => 1,
9
+ # "room_class" => "QuizRoom",
10
+ # "key" => <room key>, # ActiveJob-serialized, so a record key travels as a GlobalID
11
+ # "port_clients" => [
12
+ # {
13
+ # "token" => "3f9a...",
14
+ # "tags" => ["admin"],
15
+ # "as" => <user or nil>, # ActiveJob-serialized (GlobalID for records)
16
+ # "last_seen_at" => "2026-08-27T18:02:11.123456Z",
17
+ # "metadata" => { ... } # everything else on the PortClient: what `extra:`
18
+ # } # merged in, plus anything the room set on it
19
+ # ],
20
+ # "user_state" => [{ "user" => <user>, "port_tokens" => ["3f9a..."] }],
21
+ # "reaper_state" => [{ "key" => "idle", "index" => 0, "last_keep_at" => "<ISO8601>" | nil }],
22
+ # "app_state" => <whatever the Room's snapshot_state returned, or nil>
23
+ # }
24
+ #
25
+ # `reaper_state` carries wall-clock times, not remaining durations, so a room restored on
26
+ # another host keeps the same deadline it had. `app_state` has to be JSON: plain hashes,
27
+ # arrays, strings, numbers, booleans, and nil. We check that here, at snapshot time, because a
28
+ # value that JSON would quietly turn into something else (a Time into a string, a record into
29
+ # its attributes) is a bug that would otherwise only show up in `restore_state` on some other
30
+ # machine.
31
+ module Snapshot
32
+ VERSION = 1
33
+
34
+ class Error < StandardError; end
35
+
36
+ # `snapshot_state` returned something JSON can't carry faithfully.
37
+ class NotSerializable < Error; end
38
+
39
+ # The snapshot was written by a gem version this one doesn't understand.
40
+ class UnknownVersion < Error; end
41
+
42
+ # The snapshot doesn't name a room class this process knows.
43
+ class UnknownRoomClass < Error; end
44
+
45
+ class << self
46
+ # The snapshot of `room`, already round-tripped through JSON, so what you get back is
47
+ # exactly what a restore will see after a trip through Redis.
48
+ def take(room)
49
+ room.send(:_snapshot)
50
+ end
51
+
52
+ # Check a snapshot before anything is built from it. Returns the snapshot with string keys,
53
+ # so callers can read it the same way whether it came straight from `take` or from Redis.
54
+ def validate!(snapshot)
55
+ snapshot = snapshot.to_h.deep_stringify_keys
56
+ version = snapshot["version"]
57
+ unless version == VERSION
58
+ raise UnknownVersion, "Snapshot version #{version.inspect} isn't supported (this gem writes version #{VERSION})"
59
+ end
60
+
61
+ snapshot
62
+ end
63
+
64
+ def room_class_for(snapshot)
65
+ name = snapshot["room_class"]
66
+ klass = name.to_s.safe_constantize
67
+ unless klass.is_a?(Class) && klass <= Room::Base
68
+ raise UnknownRoomClass, "Snapshot names #{name.inspect}, which isn't a CableRoom::Room::Base subclass here"
69
+ end
70
+ klass
71
+ end
72
+
73
+ def key_for(snapshot)
74
+ deserialize_argument(snapshot["key"])
75
+ end
76
+
77
+ # Encode and decode the whole document, so the result is the post-JSON shape (string keys,
78
+ # ISO8601 times) rather than the Ruby objects the room held.
79
+ def round_trip(document)
80
+ ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(document))
81
+ end
82
+
83
+ # Walk `value` and raise NotSerializable at the first thing JSON can't carry unchanged. Hash
84
+ # keys may be symbols (they come back as strings, and restore_state gets an indifferent-access
85
+ # hash); symbol *values* are refused because they'd come back as plain strings.
86
+ def assert_json!(value, room:, path: "app_state")
87
+ case value
88
+ when nil, true, false, String, Integer
89
+ nil
90
+ when Float
91
+ raise_not_serializable(room, path, "#{value} isn't a finite number") unless value.finite?
92
+ when Hash
93
+ value.each do |k, v|
94
+ unless k.is_a?(String) || k.is_a?(Symbol)
95
+ raise_not_serializable(room, path, "has a #{k.class} key (#{k.inspect}); JSON object keys must be strings")
96
+ end
97
+ assert_json!(v, room: room, path: "#{path}[#{k.inspect}]")
98
+ end
99
+ when Array
100
+ value.each_with_index { |v, i| assert_json!(v, room: room, path: "#{path}[#{i}]") }
101
+ when Symbol
102
+ raise_not_serializable(room, path, "is the Symbol #{value.inspect}; JSON has no symbols, so restore_state would get a String back. Use a string")
103
+ else
104
+ raise_not_serializable(room, path, "is a #{value.class}, which JSON can't carry. Reduce it to hashes, arrays, strings, numbers, booleans, and nil (records by id)")
105
+ end
106
+ end
107
+
108
+ # Room keys and users go through the same serializer members use on the wire
109
+ # (RoomMembership#transmit_port_connected), so a record travels as its GlobalID and
110
+ # strings, numbers, and symbols come back as themselves.
111
+ def serialize_argument(value)
112
+ ::ActiveJob::Arguments.serialize([value]).first
113
+ end
114
+
115
+ def deserialize_argument(value)
116
+ ::ActiveJob::Arguments.deserialize([value]).first
117
+ end
118
+
119
+ def encode_time(time)
120
+ time&.getutc&.iso8601(6)
121
+ end
122
+
123
+ def decode_time(value)
124
+ return nil if value.blank?
125
+ value.is_a?(Time) ? value : Time.iso8601(value)
126
+ end
127
+
128
+ private
129
+
130
+ def raise_not_serializable(room, path, detail)
131
+ raise NotSerializable,
132
+ "#{room.class.name}[#{room.key.inspect}]: snapshot_state returned something that isn't JSON: #{path} #{detail}"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -1,3 +1,3 @@
1
1
  module CableRoom
2
- VERSION = "0.6.2".freeze
2
+ VERSION = "0.7.0.beta1".freeze
3
3
  end
data/lib/cable_room.rb CHANGED
@@ -8,10 +8,18 @@ require 'redlock'
8
8
  require 'rufus-scheduler'
9
9
 
10
10
  require_relative 'cable_room/railtie'
11
+ require_relative 'cable_room/config'
12
+ require_relative 'cable_room/broadcaster'
11
13
 
12
- require_relative 'cable_room/channel_base'
13
- require_relative 'cable_room/channel_tracker'
14
+ require_relative 'cable_room/bus'
15
+
16
+ require_relative 'cable_room/periodic_timer'
17
+ require_relative 'cable_room/snapshot'
18
+ require_relative 'cable_room/host'
19
+ require_relative 'cable_room/placement'
20
+ require_relative 'cable_room/migration'
14
21
  require_relative 'cable_room/ports'
22
+ require_relative 'cable_room/membership_store'
15
23
  require_relative 'cable_room/room_member'
16
24
  require_relative 'cable_room/room_proxy_channel'
17
25
  require_relative 'cable_room/room/'
@@ -36,6 +44,27 @@ module CableRoom
36
44
  warn "CableRoom.error_handler raised #{handler_error.class}: #{handler_error.message}"
37
45
  end
38
46
 
47
+ # The active CableRoom::Config. Built on first use with the defaults plus any env
48
+ # overrides, so an app that never calls `configure` still gets a valid config.
49
+ def config
50
+ @config ||= Config.new.apply_env_overrides.validate!
51
+ end
52
+
53
+ # Yields the config so an app can set the knobs, then re-applies the env overrides
54
+ # (CABLE_ROOM_HOST, CABLE_ROOM_BROADCASTER) and checks every value. Bad values raise
55
+ # here, at boot, instead of somewhere deep in a room later.
56
+ def configure
57
+ cfg = @config || Config.new
58
+ yield cfg if block_given?
59
+ @config = cfg.apply_env_overrides.validate!
60
+ end
61
+
62
+ # Drops the current config so the next `config` call rebuilds it, which also makes
63
+ # `Broadcaster.current` rebuild (and forget any `Broadcaster.current=` override). Meant for specs.
64
+ def reset_config!
65
+ @config = nil
66
+ end
67
+
39
68
  def redis_pool
40
69
  require 'rediconn'
41
70
  @redis_pool ||= RediConn::RedisConnection.create(env_prefix: "CABLEROOM")
@@ -50,5 +79,31 @@ module CableRoom
50
79
  CableRoom.redis,
51
80
  ])
52
81
  end
82
+
83
+ # The process-wide Redis bus (see CableRoom::Bus). Built on first use.
84
+ def bus
85
+ @bus ||= Bus.new
86
+ end
87
+
88
+ # Call this in a child process right after `fork`, before it touches Redis or hosts a room.
89
+ # `cable_room server --workers N` does it for every worker (see Host::Supervisor).
90
+ #
91
+ # A forked child starts with copies of the parent's objects but only one thread, so anything
92
+ # that owns a socket or a thread is broken in the child: the Bus subscriber thread doesn't
93
+ # exist, its mutex may be held by nobody, and a Redis socket is shared with the parent, so
94
+ # both processes would read each other's replies. Dropping the memos means the next caller
95
+ # builds fresh ones. The connection pool and redis-client also notice the fork on their own
96
+ # (both hook `Process._fork` and drop inherited sockets), and Rails does the same for
97
+ # ActiveRecord, so those need nothing from us; this keeps the gem's rule simple: nothing
98
+ # Redis-backed survives a fork.
99
+ #
100
+ # The Host goes too. A parent that forks workers must never host rooms itself, and a Host
101
+ # copied from a parent would have no worker threads and no scheduler thread behind it.
102
+ def after_fork!
103
+ @bus = nil
104
+ @lock_manager = nil
105
+ @redis_pool = nil
106
+ Host.replace_current(nil)
107
+ end
53
108
  end
54
109
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cable_room
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Knapp
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -77,14 +77,14 @@ dependencies:
77
77
  requirements:
78
78
  - - ">="
79
79
  - !ruby/object:Gem::Version
80
- version: '0'
81
- type: :development
80
+ version: '5.0'
81
+ type: :runtime
82
82
  prerelease: false
83
83
  version_requirements: !ruby/object:Gem::Requirement
84
84
  requirements:
85
85
  - - ">="
86
86
  - !ruby/object:Gem::Version
87
- version: '0'
87
+ version: '5.0'
88
88
  - !ruby/object:Gem::Dependency
89
89
  name: rspec
90
90
  requirement: !ruby/object:Gem::Requirement
@@ -101,32 +101,48 @@ dependencies:
101
101
  version: '3'
102
102
  email:
103
103
  - eknapp@instructure.com
104
- executables: []
104
+ executables:
105
+ - cable_room
105
106
  extensions: []
106
107
  extra_rdoc_files: []
107
108
  files:
109
+ - CHANGELOG.md
108
110
  - README.md
109
111
  - cable_room.gemspec
112
+ - exe/cable_room
110
113
  - lib/cable_room.rb
111
- - lib/cable_room/channel_base.rb
112
- - lib/cable_room/channel_tracker.rb
114
+ - lib/cable_room/broadcaster.rb
115
+ - lib/cable_room/bus.rb
116
+ - lib/cable_room/cli.rb
117
+ - lib/cable_room/config.rb
118
+ - lib/cable_room/host.rb
119
+ - lib/cable_room/host/bus_inbound.rb
120
+ - lib/cable_room/host/runner.rb
121
+ - lib/cable_room/host/supervisor.rb
122
+ - lib/cable_room/host/worker_pool.rb
123
+ - lib/cable_room/membership_store.rb
124
+ - lib/cable_room/migration.rb
125
+ - lib/cable_room/periodic_timer.rb
126
+ - lib/cable_room/placement.rb
113
127
  - lib/cable_room/ports.rb
114
128
  - lib/cable_room/railtie.rb
115
129
  - lib/cable_room/room.rb
116
130
  - lib/cable_room/room/base.rb
117
131
  - lib/cable_room/room/broadcasting.rb
118
132
  - lib/cable_room/room/callbacks.rb
119
- - lib/cable_room/room/channel_adapter.rb
133
+ - lib/cable_room/room/host_adapter.rb
120
134
  - lib/cable_room/room/input_handling.rb
121
135
  - lib/cable_room/room/lifecycle.rb
122
136
  - lib/cable_room/room/port_management.rb
123
137
  - lib/cable_room/room/port_policies.rb
124
138
  - lib/cable_room/room/port_scoping.rb
125
139
  - lib/cable_room/room/reaping.rb
140
+ - lib/cable_room/room/snapshotting.rb
126
141
  - lib/cable_room/room/threading.rb
127
142
  - lib/cable_room/room/user_management.rb
128
143
  - lib/cable_room/room_member.rb
129
144
  - lib/cable_room/room_proxy_channel.rb
145
+ - lib/cable_room/snapshot.rb
130
146
  - lib/cable_room/version.rb
131
147
  homepage: https://instructure.com
132
148
  licenses: []
@@ -1,262 +0,0 @@
1
- module CableRoom
2
- class ChannelBase < ActionCable::Channel::Base
3
- attr_reader :room, :tenant
4
- attr_reader :server, :logger
5
- delegate :event_loop, :worker_pool, :pubsub, to: :server
6
-
7
- def initialize(lock_info, room_class, key, config)
8
- # We don't really have a "connection" in the ActionCable sense, so
9
- # stuff in something that half looks like one
10
- super(DummyConnection.new(self), "Room[]", {})
11
-
12
- # Used mainly for logs and being able to follow a specific Room instance
13
- @uuid = SecureRandom.hex(6)
14
-
15
- @mutex = Monitor.new
16
- @lock_info = lock_info
17
- @current_state = :initializing
18
-
19
- @tenant = Apartment::Tenant.current if defined?(Apartment)
20
-
21
- @server = ChannelTracker.instance
22
- @logger = ActionCable::Connection::TaggedLoggerProxy.new(
23
- @server.logger,
24
- tags: ["#{room_class.name} #{@uuid}"]
25
- )
26
-
27
- logger.info "Initializing new #{room_class.name}"
28
- logger.info " UUID: #{@uuid}"
29
- logger.info " Key: #{room_class.room_port_key(key)}"
30
-
31
- @watchdog_interval = config[:watchdog_interval]
32
- @lock_duration = config[:lock_duration]
33
-
34
- @processing_work = false
35
- @work_queue = []
36
-
37
- @room = room_class.new(self, key)
38
- @server.track_room_channel self
39
- ping_watchdog
40
- end
41
-
42
- def self.channel_name
43
- module_parent.name
44
- end
45
-
46
- # def stream_from(...) # TODO Try making sync
47
- # raise ArgumentError, "Block required" unless block_given?
48
- # super
49
- # end
50
-
51
- def stream_from(broadcasting, callback = nil, coder: nil, &block)
52
- raise ArgumentError, "Block required" unless block.present?
53
-
54
- broadcasting = String(broadcasting)
55
-
56
- # Build a stream handler by wrapping the user-provided callback with a decoder
57
- # or defaulting to a JSON-decoding retransmitter.
58
- handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
59
- streams[broadcasting] = handler
60
-
61
- pubsub.subscribe(broadcasting, handler, lambda do
62
- logger.info "#{self.class.name} is streaming from #{broadcasting}"
63
- end)
64
- end
65
-
66
- def state
67
- @current_state
68
- end
69
-
70
- after_subscribe do
71
- begin
72
- @current_state = :starting
73
- @room.send(:_startup)
74
- @current_state = :started
75
- rescue => e
76
- terminate!
77
- raise e
78
- end
79
- end
80
-
81
- after_unsubscribe do
82
- @mutex.synchronize do
83
- @current_state = :shutting_down
84
- stop_all_streams
85
- begin
86
- @room.send(:_shutdown)
87
- ensure
88
- terminate!
89
- end
90
- end
91
- end
92
-
93
- def ping_watchdog
94
- return if state == :dead
95
-
96
- logger.debug "Ping watchdog"
97
- @last_watchdog_ping_at = Time.current
98
- end
99
-
100
- def check_room_watchdog
101
- @mutex.synchronize do
102
- return if state == :dead || state == :shutting_down
103
- end
104
-
105
- relock = CableRoom.lock_manager.lock(@lock_info[:resource], @lock_duration.in_milliseconds, extend: @lock_info)
106
- unless relock
107
- logger.warn "Lost lock, shutting down"
108
- unsubscribe_from_channel
109
- return
110
- end
111
-
112
- unless @last_watchdog_ping_at && @last_watchdog_ping_at > @watchdog_interval.ago
113
- logger.warn "Watchdog timeout for room #{@room.class.name}[#{@room.key}], shutting down"
114
- initiate_shutdown("Watchdog timeout")
115
- return
116
- end
117
- end
118
-
119
- def initiate_shutdown(reason)
120
- @mutex.synchronize do
121
- return if @current_state == :dead || @current_state == :shutting_down
122
-
123
- logger.info "Initiating shutdown: #{reason}"
124
-
125
- # Stop streams immediately to prevent further messages from being added
126
- stop_all_streams
127
-
128
- # Append the final unsubscribe to the work queue so we can process remaining messages first
129
- post_work(async: false) do
130
- unsubscribe_from_channel
131
- end
132
-
133
- @current_state = :shutting_down
134
- end
135
- end
136
-
137
- def terminate!
138
- @mutex.synchronize do
139
- stop_all_streams
140
- @current_state = :dead
141
- CableRoom.lock_manager.unlock(@lock_info) if @lock_info
142
- server.untrack_room_channel self
143
- end
144
- end
145
-
146
- def _post_wrapped_work(async: false, silent: false, &blk)
147
- if async
148
- # Async stuff is mostly untracked - we just post it to the worker pool and forget about it
149
- worker_pool.executor.post(&blk)
150
- else
151
- @mutex.synchronize do
152
- if @current_state == :dead || @current_state == :shutting_down
153
- raise "Attempt to post work to dead or shutting down room" unless silent
154
- return
155
- end
156
- @work_queue << blk
157
- end
158
- schedule_work
159
- end
160
- end
161
-
162
- def post_work(**kwargs, &blk)
163
- _post_wrapped_work(**kwargs) do
164
- worker_pool.invoke(self, :instance_exec, connection: self, &blk)
165
- rescue => e
166
- report_work_error(e)
167
- end
168
- end
169
-
170
- # Work errors are swallowed so one bad message can't take the Room down with it. Log the
171
- # backtrace and hand the error to the application so the failure is still discoverable.
172
- def report_work_error(error)
173
- logger.error "Error during work execution: #{error.class.name}: #{error.message}"
174
- Array(error.backtrace).first(20).each { |line| logger.error " #{line}" }
175
-
176
- CableRoom.report_error(
177
- error,
178
- room: room,
179
- room_class: room&.class,
180
- room_key: room&.key,
181
- channel: self
182
- )
183
- end
184
-
185
- def beat
186
- post_work(async: true) do
187
- check_room_watchdog
188
- end
189
- end
190
-
191
- def transmit(*args)
192
- logger.info("Channel.transmit called, ignoring: #{args.inspect}")
193
- end
194
-
195
- protected
196
-
197
- def start_periodic_timer(callback, every:)
198
- raise "Attempt to start periodic timer on a dead room" if state == :dead || state == :shutting_down
199
-
200
- job = connection.server.scheduler.schedule_every(every) do
201
- post_work(async: false, silent: true) do
202
- instance_exec(&callback)
203
- end
204
- end
205
-
206
- PeriodicTimer.new(job)
207
- end
208
-
209
- def schedule_work
210
- @mutex.synchronize do
211
- return if @processing_work
212
-
213
- work = @work_queue.shift
214
- return unless work
215
-
216
- @processing_work = true
217
-
218
- worker_pool.executor.post do
219
- begin
220
- work.call
221
- ensure
222
- @mutex.synchronize do
223
- @processing_work = false
224
- end
225
- schedule_work
226
- end
227
- end
228
- end
229
- end
230
- end
231
-
232
- # ActionCable's `stop_periodic_timers` calls #shutdown on whatever #start_periodic_timer
233
- # returned, but Rufus jobs are cancelled with #unschedule. Adapt the one to the other here
234
- # rather than aliasing #shutdown onto Rufus::Scheduler::Job for the whole process.
235
- class PeriodicTimer
236
- attr_reader :job
237
-
238
- delegate :unschedule, :scheduled?, :next_time, to: :job
239
-
240
- def initialize(job)
241
- @job = job
242
- end
243
-
244
- def shutdown
245
- job.unschedule
246
- end
247
- end
248
-
249
- class DummyConnection
250
- attr_reader :channel
251
- delegate :server, :logger, :tenant, :transmit, :post_work, :_post_wrapped_work,
252
- :report_work_error, to: :channel
253
- delegate :event_loop, :pubsub, :worker_pool, to: :server
254
-
255
- attr_reader :identifiers
256
-
257
- def initialize(channel)
258
- @channel = channel
259
- @identifiers = []
260
- end
261
- end
262
- end