solid_objects 0.8.0 → 0.10.0
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 +81 -0
- data/app/controllers/solid_objects/components_controller.rb +3 -18
- data/benchmark/support.rb +47 -5
- data/docs/benchmarks.md +19 -2
- data/docs/local-testing.md +91 -0
- data/docs/realtime.md +74 -6
- data/docs/roadmap.md +56 -36
- 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 +37 -0
- data/lib/solid_objects/database_adapters/mysql.rb +28 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +15 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +5 -0
- data/lib/solid_objects/doctor.rb +16 -0
- 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/redis.rb +183 -0
- 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 +17 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +11 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +3 -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/lib/solid_objects/wake_up_adapters/redis.rbs +96 -0
- metadata +6 -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
|
|
@@ -32,6 +32,43 @@ module SolidObjects
|
|
|
32
32
|
@fixed_connection = connection_pool ? nil : connection
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# The oldest server the adapter has been exercised against. Reported rather
|
|
36
|
+
# than enforced: refusing to boot on an untested server would be a worse
|
|
37
|
+
# failure than running on one.
|
|
38
|
+
# @rbs () -> Gem::Version?
|
|
39
|
+
def minimum_server_version
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs () -> Gem::Version
|
|
44
|
+
def server_version
|
|
45
|
+
with_connection do |connection|
|
|
46
|
+
Gem::Version.new(connection.database_version.to_s)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# One observed version decides both the status and the message. Reading it
|
|
51
|
+
# again could let a transient failure replace an already determined result.
|
|
52
|
+
# @rbs (?Gem::Version?) -> Array[String]
|
|
53
|
+
def unsupported_server_reasons(observed = nil)
|
|
54
|
+
observed ||= server_version
|
|
55
|
+
reasons = []
|
|
56
|
+
minimum = minimum_server_version
|
|
57
|
+
if minimum && observed < minimum
|
|
58
|
+
reasons << "#{self.class.name.demodulize} #{observed} is older than " \
|
|
59
|
+
"Solid Objects requires, which is #{minimum}"
|
|
60
|
+
end
|
|
61
|
+
reasons.concat(additional_server_reasons)
|
|
62
|
+
reasons
|
|
63
|
+
rescue => error
|
|
64
|
+
[ "the database server could not be verified: #{error.class}: #{error.message}" ]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @rbs () -> Array[String]
|
|
68
|
+
def additional_server_reasons
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
|
|
35
72
|
# @rbs () -> bool
|
|
36
73
|
def supports_skip_locked?
|
|
37
74
|
false
|
|
@@ -13,6 +13,34 @@ module SolidObjects
|
|
|
13
13
|
"FOR UPDATE SKIP LOCKED"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# A non-transactional engine would silently break fenced commits, so the
|
|
17
|
+
# storage engine is verified rather than assumed.
|
|
18
|
+
# @rbs () -> Array[String]
|
|
19
|
+
def additional_server_reasons
|
|
20
|
+
tables = non_innodb_tables
|
|
21
|
+
return [] if tables.empty?
|
|
22
|
+
|
|
23
|
+
[ "these Solid Objects tables do not use InnoDB, so their commits are " \
|
|
24
|
+
"not transactional: #{tables.join(", ")}" ]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @rbs () -> Array[String]
|
|
28
|
+
def non_innodb_tables
|
|
29
|
+
names = SolidObjects::Doctor::EXPECTED_COLUMNS.keys.map { |name| SolidObjects.table_name(name) }
|
|
30
|
+
with_connection do |connection|
|
|
31
|
+
connection.select_rows(<<~SQL.squish).filter_map { |table, engine| table if engine != "InnoDB" }
|
|
32
|
+
SELECT table_name, engine FROM information_schema.tables
|
|
33
|
+
WHERE table_schema = DATABASE()
|
|
34
|
+
AND table_name IN (#{names.map { |name| connection.quote(name) }.join(", ")})
|
|
35
|
+
SQL
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @rbs () -> Gem::Version?
|
|
40
|
+
def minimum_server_version
|
|
41
|
+
Gem::Version.new("8.0")
|
|
42
|
+
end
|
|
43
|
+
|
|
16
44
|
# @rbs () -> String
|
|
17
45
|
def current_time_expression
|
|
18
46
|
"CURRENT_TIMESTAMP(6)"
|
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module DatabaseAdapters
|
|
5
5
|
class Postgresql < DatabaseAdapter
|
|
6
|
+
# @rbs () -> Gem::Version?
|
|
7
|
+
def minimum_server_version
|
|
8
|
+
Gem::Version.new("13")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it
|
|
12
|
+
# directly would make every server look newer than any minimum.
|
|
13
|
+
# @rbs () -> Gem::Version
|
|
14
|
+
def server_version
|
|
15
|
+
packed = with_connection { |connection| connection.database_version.to_i }
|
|
16
|
+
return super unless packed.positive?
|
|
17
|
+
|
|
18
|
+
Gem::Version.new("#{packed / 10_000}.#{packed % 10_000}")
|
|
19
|
+
end
|
|
20
|
+
|
|
6
21
|
# @rbs () -> bool
|
|
7
22
|
def supports_skip_locked?
|
|
8
23
|
true
|
|
@@ -8,6 +8,11 @@ module SolidObjects
|
|
|
8
8
|
LOCK_RETRY_MUTEX = Thread::Mutex.new
|
|
9
9
|
LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
|
|
10
10
|
|
|
11
|
+
# @rbs () -> Gem::Version?
|
|
12
|
+
def minimum_server_version
|
|
13
|
+
Gem::Version.new("3.35")
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
# @rbs () -> String
|
|
12
17
|
def current_time_expression
|
|
13
18
|
"STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
|
data/lib/solid_objects/doctor.rb
CHANGED
|
@@ -111,6 +111,7 @@ module SolidObjects
|
|
|
111
111
|
configuration_check,
|
|
112
112
|
schema_check,
|
|
113
113
|
check_authorization,
|
|
114
|
+
check_database_server,
|
|
114
115
|
schema_check.failed? ? skipped_runtime : check_runtime,
|
|
115
116
|
ready_for_round_trip?(configuration_check, schema_check) ?
|
|
116
117
|
check_sync_round_trip :
|
|
@@ -191,6 +192,21 @@ module SolidObjects
|
|
|
191
192
|
pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context")
|
|
192
193
|
end
|
|
193
194
|
|
|
195
|
+
# @rbs () -> Check
|
|
196
|
+
def check_database_server
|
|
197
|
+
adapter = SolidObjects.database_adapter
|
|
198
|
+
observed = adapter.server_version
|
|
199
|
+
reasons = adapter.unsupported_server_reasons(observed)
|
|
200
|
+
return warn_check(:database_server, reasons.join("; ")) unless reasons.empty?
|
|
201
|
+
|
|
202
|
+
pass(
|
|
203
|
+
:database_server,
|
|
204
|
+
"#{adapter.class.name.demodulize} #{observed} meets the tested minimum"
|
|
205
|
+
)
|
|
206
|
+
rescue => error
|
|
207
|
+
warn_check(:database_server, "#{error.class}: #{error.message}")
|
|
208
|
+
end
|
|
209
|
+
|
|
194
210
|
# @rbs () -> Check
|
|
195
211
|
def check_runtime
|
|
196
212
|
cutoff = SolidObjects.database_adapter.database_now -
|
|
@@ -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
|