cable_room 0.7.0.beta2 → 0.8.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.
@@ -1,260 +0,0 @@
1
- require 'concurrent'
2
- require 'active_support/inflector'
3
-
4
- module CableRoom
5
- # The host side of provisioning. A `create: true` membership publishes a provision request on
6
- # `Bus.provision_channel`; every Host runs one Placement, which hears every request and decides
7
- # whether this host should be the one to start the room.
8
- #
9
- # The decision is a race weighted by load. Each host waits `provision_delay_ms` for every room
10
- # it already runs (plus up to one room's worth of jitter, to break ties), then tries to take the
11
- # room's Redlock through `Host#ensure_room`. The lightest host wakes first and usually wins;
12
- # everyone else finds the lock held and does nothing. A host that is draining or shut down never
13
- # claims. The same code runs in :inline and :remote — the only difference is which process the
14
- # Host lives in.
15
- #
16
- # Requests arrive on the Bus subscriber thread, which must stay quick, so `handle` only checks
17
- # and schedules; the wait and the claim run later on the Host's worker pool via a timer. That way
18
- # a long wait never blocks other Bus traffic, and a slow room startup never blocks another claim.
19
- #
20
- # Specs make the race deterministic by passing `jitter: -> { 0 }` (or any callable returning
21
- # 0..1) and a small `provision_delay_ms`; `delay_for(load)` is the only place the numbers meet.
22
- class Placement
23
- # Raised (and reported, never propagated) for a request this host can't act on: not a hash, an
24
- # unknown room class, a class that isn't a Room, or a key that won't deserialize.
25
- class InvalidRequest < ArgumentError; end
26
-
27
- REQUEST_TYPE = "provision".freeze
28
-
29
- attr_reader :host, :channel
30
-
31
- # `jitter` returns a Float in 0..1 (defaults to `rand`); `executor` is where the claim runs
32
- # (defaults to the Host's worker pool); `config` defaults to `CableRoom.config`.
33
- def initialize(host, jitter: nil, executor: nil, config: nil)
34
- @host = host
35
- @jitter = jitter || -> { Kernel.rand }
36
- @executor = executor
37
- @config = config
38
- @channel = Bus.provision_channel
39
-
40
- @mutex = Mutex.new
41
- @idle = ConditionVariable.new
42
- @pending = {} # room identity => the scheduled claim, so one host never queues two claims for one room
43
- @running = false # subscribed to the channel
44
- @stopped = false # `stop` was called; no claim may go through until `start` again
45
- @handle = nil
46
- end
47
-
48
- # Subscribe to the provision channel on the Host's inbound transport. Blocks until the Bus
49
- # confirms, so a request published after this returns is heard. Calling it again while running
50
- # re-subscribes (harmless; the Bus replaces the handler), which lets a Host re-arm its listener.
51
- def start
52
- @mutex.synchronize do
53
- @running = true
54
- @stopped = false
55
- end
56
- @handle = host.inbound.subscribe(channel) { |request| handle(request) }
57
- self
58
- end
59
-
60
- # Stop hearing requests and drop every claim still waiting on its timer. Waits (up to `wait`
61
- # seconds) for a claim that is already mid-attempt to finish, so nothing starts a room after
62
- # this returns. The unsubscribe blocks until Redis confirms it, which doubles as a barrier: a
63
- # request published before the stop has either been handled or is gone for good.
64
- def stop(wait: 5)
65
- was_running, handle = @mutex.synchronize do
66
- @stopped = true
67
- [@running, @handle].tap do
68
- @running = false
69
- @handle = nil
70
- end
71
- end
72
-
73
- host.inbound.unsubscribe(channel, handle) if was_running
74
-
75
- # A cancelled timer never runs its block (and so never runs the `finish` in it), so forget
76
- # it here. A claim that already started keeps its entry until it finishes; wait for those.
77
- tasks = @mutex.synchronize { @pending.to_a }
78
- tasks.each { |room_id, task| finish(room_id) if task.cancel }
79
- wait_idle(timeout: wait)
80
- self
81
- end
82
-
83
- def running?
84
- @mutex.synchronize { @running }
85
- end
86
-
87
- def stopped?
88
- @mutex.synchronize { @stopped }
89
- end
90
-
91
- # How many claims are waiting on their timer or mid-attempt right now.
92
- def pending_count
93
- @mutex.synchronize { @pending.size }
94
- end
95
-
96
- def idle?
97
- pending_count.zero?
98
- end
99
-
100
- # Block until every scheduled claim has finished (won, lost, or been cancelled). Returns true,
101
- # or false if `timeout` passes first. Specs use this to observe a losing host give up.
102
- def wait_idle(timeout: 5)
103
- deadline = monotonic_now + timeout
104
- @mutex.synchronize do
105
- until @pending.empty?
106
- remaining = deadline - monotonic_now
107
- return false if remaining <= 0
108
- @idle.wait(@mutex, remaining)
109
- end
110
- end
111
- true
112
- end
113
-
114
- # One request off the Bus. Runs on the subscriber thread, so it does only the cheap checks
115
- # (is this a room we know, do we already run it, are we allowed to claim) and then schedules
116
- # the delayed claim. Returns the scheduled task, or nil when there is nothing to do.
117
- def handle(request)
118
- room_class, room_key = parse(request)
119
- room_id = room_class.room_port_key(room_key)
120
-
121
- return skip(room_id, "placement is stopped") if stopped?
122
- return skip(room_id, "host is shutting down") if host.shutdown?
123
- return skip(room_id, "host is draining") if host.draining?
124
- return skip(room_id, "already running here") if running_here?(room_id)
125
-
126
- load = host.open_rooms_count
127
- delay = delay_for(load)
128
-
129
- # A handoff request gets a claim of its own even while a member's plain request for the
130
- # same room is pending: the plain claim may have lost the lock to the old host moments
131
- # before it let go, and the handoff must not be skipped for it. The lock keeps them safe.
132
- pending_key = request["handoff"] == true ? "#{room_id} (handoff)" : room_id
133
-
134
- @mutex.synchronize do
135
- return skip(room_id, "a claim is already pending here") if @pending.key?(pending_key)
136
-
137
- task = Concurrent::ScheduledTask.new(delay, executor: executor) do
138
- begin
139
- claim(room_class, room_key, room_id, request, delay: delay, load: load)
140
- ensure
141
- finish(pending_key)
142
- end
143
- end
144
- @pending[pending_key] = task
145
- task.execute
146
- end
147
- rescue InvalidRequest => e
148
- CableRoom.report_error(e, placement: self, request: request)
149
- nil
150
- end
151
-
152
- # Seconds to wait before racing for a room, given how many rooms this host already runs:
153
- # `provision_delay_ms × (load + jitter)`, jitter in 0..1. A host with more rooms always waits
154
- # longer than one with fewer, and the jitter only decides the order between hosts with the same
155
- # load. A host running nothing waits at most one `provision_delay_ms`.
156
- def delay_for(load)
157
- base = config.provision_delay_ms / 1000.0
158
- base * (load + @jitter.call.to_f)
159
- end
160
-
161
- private
162
-
163
- # The claim itself, after the delay, on the worker pool. Re-checks the cheap conditions first,
164
- # since the world may have moved while we waited, then races for the Redlock. Losing is
165
- # normal — it means a lighter host got there first — so it's only a debug line.
166
- def claim(room_class, room_key, room_id, request, delay:, load:)
167
- return if stopped?
168
- return if host.shutdown? || host.draining?
169
- return if running_here?(room_id)
170
-
171
- payload = {
172
- host: host,
173
- room_class: room_class,
174
- room_key: room_key,
175
- delay: delay,
176
- open_rooms: load,
177
- request: request,
178
- }
179
-
180
- claimed = ActiveSupport::Notifications.instrument("provision_claimed.cable_room", payload) do |notification|
181
- # `handoff: true` (with a `reason`) is a draining host offering a room to its peers. The
182
- # race is the same — the lock decides — but the winner rebuilds the room from the offered
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. 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.
187
- handoff = request["handoff"] == true
188
- notification[:handoff] = handoff
189
- notification[:claimed] = host.ensure_room(room_class, room_key, handoff_only: handoff, tenant: request["tenant"])
190
- end
191
-
192
- if claimed
193
- logger.info "Claimed #{room_id} after #{(delay * 1000).round}ms with #{load} room(s) open"
194
- else
195
- logger.debug "Lost the race for #{room_id}: another host holds its lock"
196
- end
197
- rescue => e
198
- logger.error "Failed to claim #{room_id}: #{e.class}: #{e.message}"
199
- CableRoom.report_error(e, placement: self, room_class: room_class, room_key: room_key, request: request)
200
- end
201
-
202
- def finish(room_id)
203
- @mutex.synchronize do
204
- @pending.delete(room_id)
205
- @idle.broadcast if @pending.empty?
206
- end
207
- end
208
-
209
- def skip(room_id, why)
210
- logger.debug "Ignoring provision request for #{room_id}: #{why}"
211
- nil
212
- end
213
-
214
- def running_here?(room_id)
215
- host.runners.any? { |runner| runner.room_class.room_port_key(runner.key) == room_id }
216
- end
217
-
218
- # Turn a decoded Bus message into a Room class and a key. Only `Room::Base` descendants may be
219
- # provisioned: the class name comes off the wire, and `constantize` on an arbitrary string is
220
- # not something a request should be able to do.
221
- def parse(request)
222
- raise InvalidRequest, "provision request must be a Hash (got #{request.class})" unless request.is_a?(Hash)
223
-
224
- type = request["type"]
225
- raise InvalidRequest, "expected a #{REQUEST_TYPE.inspect} request (got #{type.inspect})" unless type == REQUEST_TYPE
226
-
227
- name = request["room_class"]
228
- klass = name.is_a?(String) ? ActiveSupport::Inflector.safe_constantize(name) : nil
229
- unless klass.is_a?(Class) && klass < Room::Base
230
- raise InvalidRequest, "#{name.inspect} is not a CableRoom::Room::Base subclass"
231
- end
232
-
233
- [klass, deserialize_key(request["room_key"])]
234
- end
235
-
236
- # Keys travel the way `extra` does (ActiveJob's argument serializer), so a record key comes
237
- # back as the record and a string comes back as a string.
238
- def deserialize_key(serialized)
239
- ::ActiveJob::Arguments.deserialize(Array(serialized)).first
240
- rescue StandardError => e
241
- raise InvalidRequest, "room_key can't be deserialized: #{e.class}: #{e.message}"
242
- end
243
-
244
- def executor
245
- @executor || host.worker_pool.executor
246
- end
247
-
248
- def config
249
- @config || CableRoom.config
250
- end
251
-
252
- def logger
253
- host.logger
254
- end
255
-
256
- def monotonic_now
257
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
258
- end
259
- end
260
- end
@@ -1,82 +0,0 @@
1
- module CableRoom
2
- module Room
3
- # The room's side of a migration: turning itself into a CableRoom::Snapshot and coming back
4
- # from one. The gem carries what it owns (ports, tags, users, reaper deadlines); a Room can
5
- # carry its own state too with two optional hooks:
6
- #
7
- # class QuizRoom < CableRoom::Room::Base
8
- # def snapshot_state
9
- # { question_id: @question.id, answers: @answers } # JSON only; records by id
10
- # end
11
- #
12
- # def restore_state(state)
13
- # @question = Question.find(state[:question_id]) # indifferent access: :key or "key"
14
- # @answers = state[:answers]
15
- # end
16
- # end
17
- #
18
- # A room that defines `restore_state` gets it instead of `startup` when it's restored, with
19
- # whatever `snapshot_state` returned (nil if the room that was snapshotted had no
20
- # `snapshot_state`). A room without `restore_state` just runs `startup` again and rebuilds its
21
- # state the way it did the first time. Either way the `before_startup`/`after_startup`
22
- # callbacks run as usual, except that the gem does not broadcast `room_opened`: the members
23
- # were there the whole time and never saw the room go away. `restored?` tells a callback which
24
- # case it's in.
25
- module Snapshotting
26
- extend ActiveSupport::Concern
27
-
28
- # True for a room that was rebuilt from a snapshot rather than started fresh.
29
- def restored?
30
- @_restored == true
31
- end
32
-
33
- private
34
-
35
- # Build the snapshot (see CableRoom::Snapshot for the shape). Only meaningful once the room
36
- # is frozen: nothing else may be touching its state while this reads it.
37
- def _snapshot
38
- document = {
39
- version: Snapshot::VERSION,
40
- room_class: self.class.name,
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,
46
- port_clients: _snapshot_port_clients,
47
- user_state: _snapshot_user_state,
48
- reaper_state: _snapshot_reaper_state,
49
- app_state: _snapshot_app_state,
50
- }
51
-
52
- ActiveSupport::Notifications.instrument("room_snapshotted.cable_room", { room: self }) do
53
- Snapshot.round_trip(document)
54
- end
55
- end
56
-
57
- def _snapshot_app_state
58
- return nil unless respond_to?(:snapshot_state, true)
59
-
60
- state = snapshot_state
61
- Snapshot.assert_json!(state, room: self)
62
- state
63
- end
64
-
65
- # Rebuild from `snapshot`: gem state first, quietly (no port_connected or user_joined
66
- # callbacks, since none of it is new), then the startup chain with `restore_state` in place
67
- # of `startup`. Runs where `_startup` would, before the runner starts the periodic timers.
68
- def _restore(snapshot)
69
- snapshot = Snapshot.validate!(snapshot)
70
- @_restored = true
71
-
72
- _restore_port_clients(snapshot["port_clients"])
73
- _restore_user_state(snapshot["user_state"])
74
- _restore_reaper_state(snapshot["reaper_state"])
75
-
76
- app_state = snapshot["app_state"]
77
- app_state = app_state.with_indifferent_access if app_state.is_a?(Hash)
78
- _startup(restoring: true, app_state: app_state)
79
- end
80
- end
81
- end
82
- end
@@ -1,168 +0,0 @@
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
@@ -1,136 +0,0 @@
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