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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +87 -0
- data/README.md +2 -1
- data/app/controllers/solid_objects/components_controller.rb +3 -18
- data/app/models/solid_objects/instance.rb +31 -4
- data/benchmark/support.rb +47 -5
- data/docs/architecture.md +4 -0
- data/docs/benchmarks.md +19 -2
- data/docs/local-testing.md +7 -0
- data/docs/realtime.md +59 -4
- data/docs/roadmap.md +49 -28
- data/exe/solid_objects +12 -1
- data/lib/generators/solid_objects/templates/solid_objects.rb +8 -0
- data/lib/solid_objects/actor_channel.rb +50 -10
- data/lib/solid_objects/callable_keywords.rb +29 -0
- data/lib/solid_objects/component_subscriptions.rb +19 -11
- data/lib/solid_objects/configuration.rb +13 -0
- data/lib/solid_objects/database_adapter.rb +24 -4
- data/lib/solid_objects/database_adapters/mysql.rb +17 -1
- data/lib/solid_objects/payload_broadcast.rb +29 -1
- data/lib/solid_objects/supervisor.rb +76 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up_adapters.rb +1 -1
- data/lib/solid_objects.rb +2 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +0 -8
- data/sig/generated/lib/solid_objects/actor_channel.rbs +19 -0
- data/sig/generated/lib/solid_objects/callable_keywords.rbs +16 -0
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +5 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
- data/sig/generated/lib/solid_objects/database_adapter.rbs +10 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +10 -0
- data/sig/generated/lib/solid_objects/payload_broadcast.rbs +13 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +32 -0
- data/sig/generated/models/solid_objects/instance.rbs +11 -0
- 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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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.
|
|
67
|
-
|
|
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
|
|
14
|
-
when
|
|
33
|
+
case family(connection)
|
|
34
|
+
when :postgresql
|
|
15
35
|
DatabaseAdapters::Postgresql.new(connection)
|
|
16
|
-
when
|
|
36
|
+
when :mysql
|
|
17
37
|
DatabaseAdapters::Mysql.new(connection)
|
|
18
|
-
when
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|