solid_objects 0.9.0 → 0.10.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +2 -1
  4. data/app/controllers/solid_objects/components_controller.rb +3 -18
  5. data/app/models/solid_objects/instance.rb +31 -4
  6. data/benchmark/support.rb +47 -5
  7. data/docs/architecture.md +4 -0
  8. data/docs/benchmarks.md +19 -2
  9. data/docs/local-testing.md +7 -0
  10. data/docs/realtime.md +59 -4
  11. data/docs/roadmap.md +49 -28
  12. data/exe/solid_objects +12 -1
  13. data/lib/generators/solid_objects/templates/solid_objects.rb +8 -0
  14. data/lib/solid_objects/actor_channel.rb +50 -10
  15. data/lib/solid_objects/callable_keywords.rb +29 -0
  16. data/lib/solid_objects/component_subscriptions.rb +19 -11
  17. data/lib/solid_objects/configuration.rb +13 -0
  18. data/lib/solid_objects/database_adapter.rb +24 -4
  19. data/lib/solid_objects/database_adapters/mysql.rb +17 -1
  20. data/lib/solid_objects/payload_broadcast.rb +29 -1
  21. data/lib/solid_objects/supervisor.rb +76 -0
  22. data/lib/solid_objects/version.rb +1 -1
  23. data/lib/solid_objects/wake_up_adapters.rb +1 -1
  24. data/lib/solid_objects.rb +2 -0
  25. data/sig/generated/controllers/solid_objects/components_controller.rbs +0 -8
  26. data/sig/generated/lib/solid_objects/actor_channel.rbs +19 -0
  27. data/sig/generated/lib/solid_objects/callable_keywords.rbs +16 -0
  28. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +5 -0
  29. data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
  30. data/sig/generated/lib/solid_objects/database_adapter.rbs +10 -0
  31. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +10 -0
  32. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +13 -0
  33. data/sig/generated/lib/solid_objects/supervisor.rbs +32 -0
  34. data/sig/generated/models/solid_objects/instance.rbs +11 -0
  35. metadata +17 -1
@@ -78,19 +78,59 @@ module SolidObjects
78
78
  return if payload_names.nil? || payload_names.empty?
79
79
  return unless newer_payload_revision?(snapshot)
80
80
 
81
- payload_names.each do |name|
82
- payload = PayloadBroadcast.new(
83
- snapshot:,
84
- name:,
85
- authorization_context: connection
86
- ).call
87
- transmit TurboStreamRenderer.state_payload(payload)
88
- rescue Unauthorized
89
- next
90
- end
81
+ # The watermark records what the subscriber has, so a revision with a
82
+ # failed payload must not advance it: dedup would skip every later
83
+ # attempt at that revision and the actor may not mutate again for a long
84
+ # time. Every name is still attempted before the decision is made.
85
+ attempts = payload_names.map { |name| transmit_state_payload(snapshot, name) }
86
+ return if attempts.any?(false)
87
+
91
88
  @payload_revision = [ snapshot.instance_id, snapshot.revision ]
92
89
  end
93
90
 
91
+ # A payload is one subscriber's view of one name. Letting it raise through
92
+ # here would reject the subscription or abandon the rest of a broadcast, so
93
+ # a failure is confined to the payload that caused it and reported. The
94
+ # exception message is deliberately not instrumented: a payload block reads
95
+ # actor state, so its message is the one place subscriber state could leak
96
+ # into logs.
97
+ #
98
+ # Returns whether this revision was settled for the name. An unauthorized
99
+ # payload is settled: the decision is stable, so retrying it would only
100
+ # re-deliver its authorized siblings.
101
+ # @rbs (ActorSnapshot, String) -> bool
102
+ def transmit_state_payload(snapshot, name)
103
+ payload = PayloadBroadcast.new(
104
+ snapshot:,
105
+ name:,
106
+ authorization_context: payload_authorization_context(name)
107
+ ).call
108
+ transmit TurboStreamRenderer.state_payload(payload)
109
+ true
110
+ rescue Unauthorized
111
+ true
112
+ rescue => error
113
+ SolidObjects.instrument(
114
+ :payload_broadcast_failed,
115
+ actor_type: reference.actor_type,
116
+ actor_id: reference.actor_id,
117
+ payload_name: name,
118
+ error_class: error.class.name
119
+ )
120
+ false
121
+ end
122
+
123
+ # Resolves the Cable connection to whatever the application uses as an
124
+ # authorization subject, so a payload block and `authorize_query` see the
125
+ # same object a controller render would pass.
126
+ # @rbs (String) -> untyped
127
+ def payload_authorization_context(name)
128
+ callable = SolidObjects.configuration.payload_authorization_context
129
+ return callable.call(connection:) unless CallableKeywords.accepts?(callable, :payload_name)
130
+
131
+ callable.call(connection:, payload_name: name)
132
+ end
133
+
94
134
  # @rbs (ActorSnapshot) -> bool
95
135
  def newer_payload_revision?(snapshot)
96
136
  current = @payload_revision
@@ -0,0 +1,29 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ # Authorization context resolvers gained keywords after applications had
5
+ # already written them, so a resolver is called with what it declared it
6
+ # accepts rather than with everything the caller could offer.
7
+ module CallableKeywords
8
+ class << self
9
+ # @rbs (untyped, Symbol) -> bool
10
+ def accepts?(callable, keyword)
11
+ parameters(callable).any? do |type, name|
12
+ type == :keyrest || (%i[key keyreq].include?(type) && name == keyword)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ # A lambda answers `parameters` directly; a callable object answers it
19
+ # through its `call` method.
20
+ # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
21
+ def parameters(callable)
22
+ return callable.parameters if callable.respond_to?(:parameters)
23
+ return callable.method(:call).parameters if callable.respond_to?(:call)
24
+
25
+ []
26
+ end
27
+ end
28
+ end
29
+ end
@@ -52,26 +52,19 @@ module SolidObjects
52
52
  registration.dependencies.include?(observable_name) &&
53
53
  newer_revision?(registration.dom_id, instance_id, revision)
54
54
  end
55
- batched, individual = changed.partition(&:batch)
56
- streams = individual.map { |registration| refresh(registration, instance_id, revision) }
57
- batched.group_by(&:batch).each_value do |group|
58
- group.each { |registration| record_revision(registration, instance_id, revision) }
59
- streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
60
- end
61
- streams
55
+ refresh_streams(changed, instance_id, revision)
62
56
  end
63
57
 
64
58
  # @rbs (ActorSnapshot) -> Array[String]
65
59
  def reconnect_refreshes(snapshot)
66
- registrations.filter_map do |registration|
67
- next unless newer_revision?(
60
+ stale = registrations.select do |registration|
61
+ newer_revision?(
68
62
  registration.dom_id,
69
63
  snapshot.instance_id,
70
64
  snapshot.revision
71
65
  )
72
-
73
- refresh(registration, snapshot.instance_id, snapshot.revision)
74
66
  end
67
+ refresh_streams(stale, snapshot.instance_id, snapshot.revision)
75
68
  end
76
69
 
77
70
  class << self
@@ -91,6 +84,21 @@ module SolidObjects
91
84
 
92
85
  attr_reader :registrations, :revisions
93
86
 
87
+ # Live invalidations and reconnect replays share this, so a reconnecting
88
+ # client pays the same number of requests a connected one does.
89
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
90
+ def refresh_streams(changed, instance_id, revision)
91
+ batched, individual = changed.partition(&:batch)
92
+ streams = individual.map do |registration|
93
+ refresh(registration, instance_id, revision)
94
+ end
95
+ batched.group_by(&:batch).each_value do |group|
96
+ group.each { |registration| record_revision(registration, instance_id, revision) }
97
+ streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
98
+ end
99
+ streams
100
+ end
101
+
94
102
  # @rbs (ComponentRegistration, Integer, Integer) -> void
95
103
  def record_revision(registration, instance_id, revision)
96
104
  revisions[registration.dom_id] = [ instance_id, revision ]
@@ -22,6 +22,7 @@ module SolidObjects
22
22
  # @rbs @process_alive_threshold: Float
23
23
  # @rbs @shutdown_timeout: Float
24
24
  # @rbs @supervisor_monitor_interval: Float
25
+ # @rbs @retention_interval: Float
25
26
  # @rbs @dead_process_cleanup_interval: Float
26
27
  # @rbs @message_retention: Numeric
27
28
  # @rbs @message_retention_by_actor_type: Hash[String, Numeric]
@@ -39,6 +40,7 @@ module SolidObjects
39
40
  # @rbs @wake_up_adapter: untyped
40
41
  # @rbs @component_path_resolver: Proc?
41
42
  # @rbs @component_authorization_context: Proc
43
+ # @rbs @payload_authorization_context: Proc
42
44
  # @rbs @authorize_message: Proc
43
45
  # @rbs @authorize_query: Proc
44
46
  # @rbs @authorize_destroy: Proc
@@ -65,6 +67,7 @@ module SolidObjects
65
67
  :process_alive_threshold,
66
68
  :shutdown_timeout,
67
69
  :supervisor_monitor_interval,
70
+ :retention_interval,
68
71
  :dead_process_cleanup_interval,
69
72
  :message_retention,
70
73
  :message_retention_by_actor_type,
@@ -82,6 +85,7 @@ module SolidObjects
82
85
  :wake_up_adapter,
83
86
  :component_path_resolver,
84
87
  :component_authorization_context,
88
+ :payload_authorization_context,
85
89
  :authorize_message,
86
90
  :authorize_query,
87
91
  :authorize_destroy,
@@ -108,6 +112,7 @@ module SolidObjects
108
112
  @lock_retry_attempts = 10
109
113
  @supervisor_monitor_interval = 1.0
110
114
  @dead_process_cleanup_interval = 60.0
115
+ @retention_interval = 3600.0
111
116
  @process_heartbeat_interval = 15.0
112
117
  @process_alive_threshold = 60.0
113
118
  @shutdown_timeout = 15.0
@@ -126,6 +131,7 @@ module SolidObjects
126
131
  @wake_up_adapter = nil
127
132
  @component_path_resolver = nil
128
133
  @component_authorization_context = ->(controller:) { controller }
134
+ @payload_authorization_context = ->(connection:) { connection }
129
135
  @logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
130
136
  Rails.logger
131
137
  else
@@ -144,6 +150,10 @@ module SolidObjects
144
150
  raise ArgumentError, "table_name_prefix must contain lowercase letters, digits, and underscores"
145
151
  end
146
152
 
153
+ if retention_interval.negative?
154
+ raise ArgumentError, "retention_interval must not be negative"
155
+ end
156
+
147
157
  unless supervisor_monitor_interval.positive?
148
158
  raise ArgumentError, "supervisor_monitor_interval must be positive"
149
159
  end
@@ -176,6 +186,9 @@ module SolidObjects
176
186
  unless component_authorization_context.respond_to?(:call)
177
187
  raise ArgumentError, "component_authorization_context must respond to call"
178
188
  end
189
+ unless payload_authorization_context.respond_to?(:call)
190
+ raise ArgumentError, "payload_authorization_context must respond to call"
191
+ end
179
192
 
180
193
  self
181
194
  end
@@ -7,15 +7,35 @@ module SolidObjects
7
7
  TRANSACTION_CLOCK = :solid_objects_transaction_clock
8
8
  TRANSACTION_CLOCK_SCOPE = :solid_objects_transaction_clock_scope
9
9
 
10
+ # An adapter name is a client name, not a protocol name. Trilogy reports
11
+ # "Trilogy" while speaking MySQL, so a pattern that only knows the mysql2
12
+ # gem rejects a database Solid Objects fully supports. Every decision that
13
+ # depends on the database goes through this one table, so a client cannot
14
+ # be accepted in one place and rejected in another.
15
+ FAMILIES = {
16
+ postgresql: /postgres/i,
17
+ mysql: /mysql|trilogy/i,
18
+ sqlite: /sqlite/i
19
+ }.freeze
20
+
10
21
  class << self
22
+ # @rbs (untyped) -> Symbol?
23
+ def family(connection)
24
+ adapter_name = connection.adapter_name
25
+ FAMILIES.each do |family, pattern|
26
+ return family if adapter_name.match?(pattern)
27
+ end
28
+ nil
29
+ end
30
+
11
31
  # @rbs (untyped) -> DatabaseAdapter
12
32
  def for(connection)
13
- case connection.adapter_name
14
- when /postgres/i
33
+ case family(connection)
34
+ when :postgresql
15
35
  DatabaseAdapters::Postgresql.new(connection)
16
- when /mysql/i
36
+ when :mysql
17
37
  DatabaseAdapters::Mysql.new(connection)
18
- when /sqlite/i
38
+ when :sqlite
19
39
  DatabaseAdapters::Sqlite.new(connection)
20
40
  else
21
41
  raise UnsupportedDatabase, "unsupported database adapter #{connection.adapter_name.inspect}"
@@ -3,6 +3,8 @@
3
3
  module SolidObjects
4
4
  module DatabaseAdapters
5
5
  class Mysql < DatabaseAdapter
6
+ MAXIMUM_EXECUTION_TIME_EXCEEDED = 3024
7
+
6
8
  # @rbs () -> bool
7
9
  def supports_skip_locked?
8
10
  true
@@ -78,19 +80,33 @@ module SolidObjects
78
80
  end
79
81
  end
80
82
 
83
+ # A deadline is enforced by asking the server to interrupt the statement,
84
+ # so recognising that interruption is what turns it back into a timeout
85
+ # the caller asked for. Active Record classifies it for every client, and
86
+ # the raw code is the fallback: mysql2 names it `error_number` and
87
+ # Trilogy names it `error_code`, so both are read.
81
88
  # @rbs (Exception) -> bool
82
89
  def deadline_error?(error)
83
90
  return false unless SyncDeadline.active?
84
91
  return true if error.is_a?(ActiveRecord::LockWaitTimeout)
92
+ return true if error.is_a?(ActiveRecord::StatementTimeout)
85
93
 
86
94
  cause = error
87
95
  while cause
88
- return true if cause.respond_to?(:error_number) && cause.error_number == 3024
96
+ return true if error_code(cause) == MAXIMUM_EXECUTION_TIME_EXCEEDED
89
97
 
90
98
  cause = cause.cause
91
99
  end
92
100
  false
93
101
  end
102
+
103
+ # @rbs (Exception) -> Integer?
104
+ def error_code(error)
105
+ return error.error_number if error.respond_to?(:error_number)
106
+ return error.error_code if error.respond_to?(:error_code)
107
+
108
+ nil
109
+ end
94
110
  end
95
111
  end
96
112
  end
@@ -41,7 +41,7 @@ module SolidObjects
41
41
  # @rbs (ActorDefinition::Handler) -> untyped
42
42
  def rendered_payload(handler)
43
43
  payload = Serialization.dump(
44
- handler.block.call(snapshot.actor, authorization_context),
44
+ evaluated_payload(handler),
45
45
  max_bytes: MAXIMUM_PAYLOAD_BYTES
46
46
  )
47
47
  return payload if payload.is_a?(Hash) || payload.is_a?(Array)
@@ -50,6 +50,34 @@ module SolidObjects
50
50
  "payload broadcast #{name.inspect} must return a JSON object or array"
51
51
  end
52
52
 
53
+ # The block runs against the actor instance, like every other block in the
54
+ # actor DSL, and still receives the actor and the resolved authorization
55
+ # context as arguments, so blocks written to the documented signature are
56
+ # unaffected.
57
+ # @rbs (ActorDefinition::Handler) -> untyped
58
+ def evaluated_payload(handler)
59
+ actor = snapshot.actor
60
+ actor.instance_exec(actor, authorization_context, &handler.block)
61
+ rescue NameError => error
62
+ raise unless class_level_receiver?(error)
63
+
64
+ raise InvalidPayloadBroadcast,
65
+ "payload broadcast #{name.inspect} called #{error.name.inspect} on the " \
66
+ "actor class. Payload blocks now run against the actor instance, like " \
67
+ "every other actor block. Call it on the class explicitly."
68
+ end
69
+
70
+ # Distinguishes a block that relied on the old class-level receiver from an
71
+ # ordinary typo, so the one behaviour change reports itself instead of
72
+ # surfacing as an unexplained NameError.
73
+ # @rbs (NameError[untyped]) -> bool
74
+ def class_level_receiver?(error)
75
+ error.receiver.equal?(snapshot.actor) &&
76
+ snapshot.actor_class.respond_to?(error.name)
77
+ rescue ArgumentError, NameError
78
+ false
79
+ end
80
+
53
81
  # @rbs () -> void
54
82
  def authorize!
55
83
  authorized = SolidObjects.configuration.authorize_query.call(
@@ -2,11 +2,14 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Supervisor
5
+ MAXIMUM_RETENTION_BACKOFF_DOUBLINGS = 16
6
+
5
7
  # @rbs @components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
6
8
  # @rbs @threads: Array[Thread]
7
9
  # @rbs @monitor: Thread?
8
10
  # @rbs @started: bool
9
11
  # @rbs @cleaned_up_at: Float
12
+ # @rbs @retention: Thread?
10
13
  # @rbs @lifecycle: Thread::Mutex
11
14
 
12
15
  # @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
@@ -26,6 +29,7 @@ module SolidObjects
26
29
  @monitor = nil
27
30
  @started = false
28
31
  @cleaned_up_at = nil
32
+ @retention = nil
29
33
  @lifecycle = Thread::Mutex.new
30
34
  end
31
35
 
@@ -44,6 +48,7 @@ module SolidObjects
44
48
  @started = true
45
49
  @threads = components.map { |component| supervise(component) }
46
50
  @monitor = Thread.new { monitor_loop }
51
+ @retention = Thread.new { retention_loop }
47
52
  SolidObjects.instrument(:"supervisor.started", component_count: components.length)
48
53
  end
49
54
 
@@ -57,6 +62,7 @@ module SolidObjects
57
62
  # list, or never starts.
58
63
  @lifecycle.synchronize { @started = false }
59
64
  stop_monitor
65
+ stop_retention
60
66
  components.each(&:request_shutdown)
61
67
  join_until_timeout
62
68
  components.reject(&:stopped?).each(&:stop)
@@ -133,6 +139,76 @@ module SolidObjects
133
139
  error.class.name
134
140
  end
135
141
 
142
+ # Retention gets its own thread rather than sharing the monitor's. A large
143
+ # backlog or a lock wait can make a pass slow, and role replacement must not
144
+ # wait behind housekeeping.
145
+ # @rbs () -> void
146
+ def retention_loop
147
+ failures = 0
148
+ while @started
149
+ begin
150
+ prune_expired_records
151
+ failures = 0
152
+ rescue => error
153
+ failures += 1
154
+ SolidObjects.instrument(
155
+ :"supervisor.retention_failed",
156
+ error_class: error.class.name,
157
+ error_message: error.message
158
+ )
159
+ end
160
+ wait_for_next_retention(failures)
161
+ end
162
+ end
163
+
164
+ # Sleeping the whole interval would make shutdown wait out an hour-long
165
+ # nap, so the pause is taken in short steps that notice a stop request.
166
+ # @rbs (Integer) -> void
167
+ def wait_for_next_retention(failures)
168
+ deadline = monotonic_now + retention_pause(failures)
169
+ step = SolidObjects.configuration.supervisor_monitor_interval
170
+ while @started && monotonic_now < deadline
171
+ sleep [ step, deadline - monotonic_now ].min
172
+ end
173
+ end
174
+
175
+ # Every actor call writes a durable message row, so retention that is only
176
+ # configured and never run leaves those rows to grow without bound. The
177
+ # supervisor runs it rather than requiring every application to schedule
178
+ # its own job.
179
+ # @rbs () -> void
180
+ def prune_expired_records
181
+ return unless SolidObjects.configuration.retention_interval.positive?
182
+
183
+ MessagePruner.new.prune
184
+ ProcessPruner.new.prune
185
+ end
186
+
187
+ # A transient lock or connection error must not defer retention for the
188
+ # whole interval, so a failed pass retries at monitor cadence. The pause
189
+ # then doubles per consecutive failure, capped by the interval, so a
190
+ # database that stays down is not polled once a second forever.
191
+ # @rbs (Integer) -> Float
192
+ def retention_pause(failures)
193
+ interval = SolidObjects.configuration.retention_interval
194
+ interval = SolidObjects.configuration.supervisor_monitor_interval unless interval.positive?
195
+ return interval if failures.zero?
196
+
197
+ backoff = SolidObjects.configuration.supervisor_monitor_interval *
198
+ (2**[ failures - 1, MAXIMUM_RETENTION_BACKOFF_DOUBLINGS ].min)
199
+ [ backoff, interval ].min
200
+ end
201
+
202
+ # @rbs () -> void
203
+ def stop_retention
204
+ retention = @retention
205
+ @retention = nil
206
+ return unless retention
207
+
208
+ retention.join(SolidObjects.configuration.shutdown_timeout)
209
+ retention.kill if retention.alive?
210
+ end
211
+
136
212
  # @rbs () -> void
137
213
  def cleanup_dead_processes
138
214
  interval = SolidObjects.configuration.dead_process_cleanup_interval
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.1"
5
5
  end
@@ -15,7 +15,7 @@ module SolidObjects
15
15
  #
16
16
  # @rbs (?untyped) -> untyped
17
17
  def for(connection = Record.connection)
18
- return Postgresql.new if connection.adapter_name.match?(/postgres/i)
18
+ return Postgresql.new if DatabaseAdapter.family(connection) == :postgresql
19
19
 
20
20
  WakeUp.new
21
21
  end
data/lib/solid_objects.rb CHANGED
@@ -13,6 +13,7 @@ require "securerandom"
13
13
  require "solid_objects/version"
14
14
  require "solid_objects/errors"
15
15
  require "solid_objects/sync_deadline"
16
+ require "solid_objects/callable_keywords"
16
17
  require "solid_objects/configuration"
17
18
  require "solid_objects/instrumentation"
18
19
  require "solid_objects/log_subscriber"
@@ -45,6 +46,7 @@ require "solid_objects/state_snapshot"
45
46
  require "solid_objects/actor_view"
46
47
  require "solid_objects/actor_channel"
47
48
  require "solid_objects/action_cable_broadcast_adapter"
49
+ require "solid_objects/database_adapter"
48
50
  require "solid_objects/wake_up"
49
51
  require "solid_objects/wake_up_adapters/postgresql"
50
52
  require "solid_objects/wake_up_adapters/redis"
@@ -27,14 +27,6 @@ module SolidObjects
27
27
  # @rbs (Array[ComponentRegistration]) -> untyped
28
28
  def component_authorization_context: (Array[ComponentRegistration]) -> untyped
29
29
 
30
- # A lambda answers `parameters` directly; a callable object answers it
31
- # through its `call` method.
32
- # @rbs (untyped) -> bool
33
- def accepts_registrations?: (untyped) -> bool
34
-
35
- # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
36
- def callable_parameters: (untyped) -> Array[[ Symbol, Symbol ]]
37
-
38
30
  # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
39
31
  def registration_payload: (ComponentRegistration) -> Hash[Symbol, untyped]
40
32
 
@@ -21,6 +21,25 @@ module SolidObjects
21
21
  # @rbs (ActorSnapshot) -> void
22
22
  def transmit_state_payloads: (ActorSnapshot) -> void
23
23
 
24
+ # A payload is one subscriber's view of one name. Letting it raise through
25
+ # here would reject the subscription or abandon the rest of a broadcast, so
26
+ # a failure is confined to the payload that caused it and reported. The
27
+ # exception message is deliberately not instrumented: a payload block reads
28
+ # actor state, so its message is the one place subscriber state could leak
29
+ # into logs.
30
+ #
31
+ # Returns whether this revision was settled for the name. An unauthorized
32
+ # payload is settled: the decision is stable, so retrying it would only
33
+ # re-deliver its authorized siblings.
34
+ # @rbs (ActorSnapshot, String) -> bool
35
+ def transmit_state_payload: (ActorSnapshot, String) -> bool
36
+
37
+ # Resolves the Cable connection to whatever the application uses as an
38
+ # authorization subject, so a payload block and `authorize_query` see the
39
+ # same object a controller render would pass.
40
+ # @rbs (String) -> untyped
41
+ def payload_authorization_context: (String) -> untyped
42
+
24
43
  # @rbs (ActorSnapshot) -> bool
25
44
  def newer_payload_revision?: (ActorSnapshot) -> bool
26
45
 
@@ -0,0 +1,16 @@
1
+ # Generated from lib/solid_objects/callable_keywords.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ # Authorization context resolvers gained keywords after applications had
5
+ # already written them, so a resolver is called with what it declared it
6
+ # accepts rather than with everything the caller could offer.
7
+ module CallableKeywords
8
+ # @rbs (untyped, Symbol) -> bool
9
+ def self.accepts?: (untyped, Symbol) -> bool
10
+
11
+ # A lambda answers `parameters` directly; a callable object answers it
12
+ # through its `call` method.
13
+ # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
14
+ private def self.parameters: (untyped) -> Array[[ Symbol, Symbol ]]
15
+ end
16
+ end
@@ -31,6 +31,11 @@ module SolidObjects
31
31
 
32
32
  attr_reader revisions: untyped
33
33
 
34
+ # Live invalidations and reconnect replays share this, so a reconnecting
35
+ # client pays the same number of requests a connected one does.
36
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
37
+ def refresh_streams: (Array[ComponentRegistration], Integer, Integer) -> Array[String]
38
+
34
39
  # @rbs (ComponentRegistration, Integer, Integer) -> void
35
40
  def record_revision: (ComponentRegistration, Integer, Integer) -> void
36
41
 
@@ -2,10 +2,10 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Configuration
5
- @shutdown_timeout: Float
6
-
7
5
  @supervisor_monitor_interval: Float
8
6
 
7
+ @retention_interval: Float
8
+
9
9
  @dead_process_cleanup_interval: Float
10
10
 
11
11
  @message_retention: Numeric
@@ -40,6 +40,8 @@ module SolidObjects
40
40
 
41
41
  @component_authorization_context: Proc
42
42
 
43
+ @payload_authorization_context: Proc
44
+
43
45
  @authorize_message: Proc
44
46
 
45
47
  @authorize_query: Proc
@@ -86,6 +88,8 @@ module SolidObjects
86
88
 
87
89
  @process_alive_threshold: Float
88
90
 
91
+ @shutdown_timeout: Float
92
+
89
93
  attr_accessor table_name_prefix: untyped
90
94
 
91
95
  attr_accessor polling_interval: untyped
@@ -126,6 +130,8 @@ module SolidObjects
126
130
 
127
131
  attr_accessor supervisor_monitor_interval: untyped
128
132
 
133
+ attr_accessor retention_interval: untyped
134
+
129
135
  attr_accessor dead_process_cleanup_interval: untyped
130
136
 
131
137
  attr_accessor message_retention: untyped
@@ -160,6 +166,8 @@ module SolidObjects
160
166
 
161
167
  attr_accessor component_authorization_context: untyped
162
168
 
169
+ attr_accessor payload_authorization_context: untyped
170
+
163
171
  attr_accessor authorize_message: untyped
164
172
 
165
173
  attr_accessor authorize_query: untyped
@@ -6,6 +6,16 @@ module SolidObjects
6
6
 
7
7
  TRANSACTION_CLOCK_SCOPE: ::Symbol
8
8
 
9
+ # An adapter name is a client name, not a protocol name. Trilogy reports
10
+ # "Trilogy" while speaking MySQL, so a pattern that only knows the mysql2
11
+ # gem rejects a database Solid Objects fully supports. Every decision that
12
+ # depends on the database goes through this one table, so a client cannot
13
+ # be accepted in one place and rejected in another.
14
+ FAMILIES: untyped
15
+
16
+ # @rbs (untyped) -> Symbol?
17
+ def self.family: (untyped) -> Symbol?
18
+
9
19
  # @rbs (untyped) -> DatabaseAdapter
10
20
  def self.for: (untyped) -> DatabaseAdapter
11
21
 
@@ -3,6 +3,8 @@
3
3
  module SolidObjects
4
4
  module DatabaseAdapters
5
5
  class Mysql < DatabaseAdapter
6
+ MAXIMUM_EXECUTION_TIME_EXCEEDED: ::Integer
7
+
6
8
  # @rbs () -> bool
7
9
  def supports_skip_locked?: () -> bool
8
10
 
@@ -28,8 +30,16 @@ module SolidObjects
28
30
  # @rbs (untyped) { () -> untyped } -> untyped
29
31
  def with_transaction_deadline: (untyped) { () -> untyped } -> untyped
30
32
 
33
+ # A deadline is enforced by asking the server to interrupt the statement,
34
+ # so recognising that interruption is what turns it back into a timeout
35
+ # the caller asked for. Active Record classifies it for every client, and
36
+ # the raw code is the fallback: mysql2 names it `error_number` and
37
+ # Trilogy names it `error_code`, so both are read.
31
38
  # @rbs (Exception) -> bool
32
39
  def deadline_error?: (Exception) -> bool
40
+
41
+ # @rbs (Exception) -> Integer?
42
+ def error_code: (Exception) -> Integer?
33
43
  end
34
44
  end
35
45
  end