solid_objects 0.2.0 → 0.3.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 +23 -0
- data/README.md +223 -15
- data/benchmark/adoption_latency.rb +5 -0
- data/benchmark/support.rb +35 -0
- data/docs/architecture.md +62 -15
- data/docs/authorization.md +98 -0
- data/docs/benchmarks.md +75 -1
- data/docs/correctness.md +19 -1
- data/docs/database-schema.md +5 -0
- data/docs/development.md +45 -4
- data/docs/fit.md +98 -0
- data/docs/migrating-existing-state.md +140 -0
- data/docs/operations.md +94 -4
- data/docs/roadmap.md +12 -3
- data/docs/security.md +28 -3
- data/docs/state-migrations.md +6 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +45 -0
- data/lib/solid_objects/activation.rb +33 -4
- data/lib/solid_objects/actor.rb +47 -6
- data/lib/solid_objects/actor_definition.rb +2 -0
- data/lib/solid_objects/actor_snapshot.rb +10 -4
- data/lib/solid_objects/application_write_guard.rb +24 -0
- data/lib/solid_objects/caller_process.rb +28 -0
- data/lib/solid_objects/cli.rb +44 -5
- data/lib/solid_objects/client.rb +98 -5
- data/lib/solid_objects/commit_action_registry.rb +42 -0
- data/lib/solid_objects/configuration.rb +27 -1
- data/lib/solid_objects/database_adapter.rb +28 -1
- data/lib/solid_objects/database_adapters/mysql.rb +46 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +28 -0
- data/lib/solid_objects/doctor.rb +311 -0
- data/lib/solid_objects/errors.rb +144 -0
- data/lib/solid_objects/executor.rb +64 -4
- data/lib/solid_objects/instance_pruner.rb +97 -0
- data/lib/solid_objects/message_pruner.rb +97 -0
- data/lib/solid_objects/message_reference.rb +9 -0
- data/lib/solid_objects/process_pruner.rb +49 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/state_snapshot.rb +41 -0
- data/lib/solid_objects/sync_deadline.rb +57 -0
- data/lib/solid_objects/sync_diagnostics.rb +133 -0
- data/lib/solid_objects/synchronous_invocation.rb +26 -7
- data/lib/solid_objects/test_helper.rb +78 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/worker.rb +1 -1
- data/lib/solid_objects.rb +34 -0
- data/lib/tasks/solid_objects_tasks.rake +10 -0
- data/sig/generated/lib/solid_objects/activation.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor.rbs +26 -0
- data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
- data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
- data/sig/generated/lib/solid_objects/cli.rbs +11 -2
- data/sig/generated/lib/solid_objects/client.rbs +15 -0
- data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +27 -7
- data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +8 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
- data/sig/generated/lib/solid_objects/errors.rbs +118 -0
- data/sig/generated/lib/solid_objects/executor.rbs +12 -0
- data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
- data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
- data/sig/generated/lib/solid_objects/reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
- data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
- data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
- data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
- data/sig/generated/lib/solid_objects.rbs +12 -0
- metadata +26 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
CommitActionContext = Data.define(
|
|
5
|
+
:message_id,
|
|
6
|
+
:request_id,
|
|
7
|
+
:actor_type,
|
|
8
|
+
:actor_id,
|
|
9
|
+
:activation_generation
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
class CommitActionRegistry
|
|
13
|
+
# @rbs @handlers: Hash[String, Proc]
|
|
14
|
+
# @rbs @mutex: Mutex
|
|
15
|
+
|
|
16
|
+
# @rbs () -> void
|
|
17
|
+
def initialize
|
|
18
|
+
@handlers = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs (String | Symbol, Proc) -> Proc
|
|
23
|
+
def register(name, handler)
|
|
24
|
+
action_name = name.to_s
|
|
25
|
+
raise ArgumentError, "commit action name cannot be empty" if action_name.empty?
|
|
26
|
+
|
|
27
|
+
mutex.synchronize { handlers[action_name] = handler }
|
|
28
|
+
handler
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @rbs (String | Symbol) -> Proc
|
|
32
|
+
def fetch(name)
|
|
33
|
+
handlers.fetch(name.to_s) do
|
|
34
|
+
raise UnknownCommitAction, "no commit action registered for #{name.inspect}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :handlers, :mutex
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -20,6 +20,11 @@ module SolidObjects
|
|
|
20
20
|
# @rbs @process_heartbeat_interval: Float
|
|
21
21
|
# @rbs @process_alive_threshold: Float
|
|
22
22
|
# @rbs @shutdown_timeout: Float
|
|
23
|
+
# @rbs @message_retention: Numeric
|
|
24
|
+
# @rbs @message_retention_by_actor_type: Hash[String, Numeric]
|
|
25
|
+
# @rbs @instance_retention_by_actor_type: Hash[String, Numeric]
|
|
26
|
+
# @rbs @process_retention: Numeric
|
|
27
|
+
# @rbs @prune_batch_size: Integer
|
|
23
28
|
# @rbs @worker_count: Integer
|
|
24
29
|
# @rbs @effect_worker_count: Integer
|
|
25
30
|
# @rbs @broadcast_worker_count: Integer
|
|
@@ -53,6 +58,11 @@ module SolidObjects
|
|
|
53
58
|
:process_heartbeat_interval,
|
|
54
59
|
:process_alive_threshold,
|
|
55
60
|
:shutdown_timeout,
|
|
61
|
+
:message_retention,
|
|
62
|
+
:message_retention_by_actor_type,
|
|
63
|
+
:instance_retention_by_actor_type,
|
|
64
|
+
:process_retention,
|
|
65
|
+
:prune_batch_size,
|
|
56
66
|
:worker_count,
|
|
57
67
|
:effect_worker_count,
|
|
58
68
|
:broadcast_worker_count,
|
|
@@ -88,6 +98,11 @@ module SolidObjects
|
|
|
88
98
|
@process_heartbeat_interval = 15.0
|
|
89
99
|
@process_alive_threshold = 60.0
|
|
90
100
|
@shutdown_timeout = 15.0
|
|
101
|
+
@message_retention = 30.days
|
|
102
|
+
@message_retention_by_actor_type = {}
|
|
103
|
+
@instance_retention_by_actor_type = {}
|
|
104
|
+
@process_retention = 7.days
|
|
105
|
+
@prune_batch_size = 1_000
|
|
91
106
|
@worker_count = 1
|
|
92
107
|
@effect_worker_count = 1
|
|
93
108
|
@broadcast_worker_count = 1
|
|
@@ -128,6 +143,14 @@ module SolidObjects
|
|
|
128
143
|
positive_values.each do |name, value|
|
|
129
144
|
raise ArgumentError, "#{name} must be positive" unless value.positive?
|
|
130
145
|
end
|
|
146
|
+
message_retention_by_actor_type.each do |actor_type, retention|
|
|
147
|
+
raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
|
|
148
|
+
raise ArgumentError, "message retention must be positive" unless retention.positive?
|
|
149
|
+
end
|
|
150
|
+
instance_retention_by_actor_type.each do |actor_type, retention|
|
|
151
|
+
raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
|
|
152
|
+
raise ArgumentError, "instance retention must be positive" unless retention.positive?
|
|
153
|
+
end
|
|
131
154
|
|
|
132
155
|
self
|
|
133
156
|
end
|
|
@@ -151,7 +174,10 @@ module SolidObjects
|
|
|
151
174
|
max_attempts:,
|
|
152
175
|
process_heartbeat_interval:,
|
|
153
176
|
process_alive_threshold:,
|
|
154
|
-
shutdown_timeout
|
|
177
|
+
shutdown_timeout:,
|
|
178
|
+
message_retention:,
|
|
179
|
+
process_retention:,
|
|
180
|
+
prune_batch_size:
|
|
155
181
|
}
|
|
156
182
|
end
|
|
157
183
|
|
|
@@ -54,9 +54,22 @@ module SolidObjects
|
|
|
54
54
|
|
|
55
55
|
# @rbs () { () -> untyped } -> untyped
|
|
56
56
|
def transaction(&block)
|
|
57
|
+
raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?
|
|
58
|
+
|
|
57
59
|
with_connection do |connection|
|
|
58
|
-
connection
|
|
60
|
+
with_transaction_deadline(connection) do
|
|
61
|
+
connection.transaction(requires_new: true) do
|
|
62
|
+
configure_transaction_deadline(connection)
|
|
63
|
+
block.call
|
|
64
|
+
end
|
|
65
|
+
end
|
|
59
66
|
end
|
|
67
|
+
rescue => error
|
|
68
|
+
raise unless deadline_error?(error)
|
|
69
|
+
|
|
70
|
+
raise DatabaseDeadlineExceeded,
|
|
71
|
+
"database lock wait exceeded the synchronous invocation deadline",
|
|
72
|
+
cause: error
|
|
60
73
|
end
|
|
61
74
|
|
|
62
75
|
# @rbs (ActiveRecord::Relation[untyped]) -> ActiveRecord::Relation[untyped]
|
|
@@ -68,6 +81,20 @@ module SolidObjects
|
|
|
68
81
|
|
|
69
82
|
attr_reader :connection_pool, :fixed_connection
|
|
70
83
|
|
|
84
|
+
# @rbs (untyped) { () -> untyped } -> untyped
|
|
85
|
+
def with_transaction_deadline(_connection)
|
|
86
|
+
yield
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @rbs (untyped) -> void
|
|
90
|
+
def configure_transaction_deadline(_connection)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @rbs (Exception) -> bool
|
|
94
|
+
def deadline_error?(_error)
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
71
98
|
# @rbs () { (untyped) -> untyped } -> untyped
|
|
72
99
|
def with_connection
|
|
73
100
|
return yield fixed_connection unless connection_pool
|
|
@@ -17,6 +17,52 @@ module SolidObjects
|
|
|
17
17
|
def current_time_expression
|
|
18
18
|
"CURRENT_TIMESTAMP(6)"
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @rbs (untyped) { () -> untyped } -> untyped
|
|
24
|
+
def with_transaction_deadline(connection)
|
|
25
|
+
return yield unless SyncDeadline.active?
|
|
26
|
+
|
|
27
|
+
previous_lock_timeout = connection.select_value(
|
|
28
|
+
"SELECT @@SESSION.innodb_lock_wait_timeout"
|
|
29
|
+
).to_i
|
|
30
|
+
previous_execution_timeout = connection.select_value(
|
|
31
|
+
"SELECT @@SESSION.max_execution_time"
|
|
32
|
+
).to_i
|
|
33
|
+
connection.execute(
|
|
34
|
+
"SET SESSION innodb_lock_wait_timeout = #{SyncDeadline.remaining_seconds}"
|
|
35
|
+
)
|
|
36
|
+
connection.execute(
|
|
37
|
+
"SET SESSION max_execution_time = #{SyncDeadline.remaining_milliseconds}"
|
|
38
|
+
)
|
|
39
|
+
yield
|
|
40
|
+
ensure
|
|
41
|
+
if previous_lock_timeout
|
|
42
|
+
connection.execute(
|
|
43
|
+
"SET SESSION innodb_lock_wait_timeout = #{previous_lock_timeout}"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
if previous_execution_timeout
|
|
47
|
+
connection.execute(
|
|
48
|
+
"SET SESSION max_execution_time = #{previous_execution_timeout}"
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @rbs (Exception) -> bool
|
|
54
|
+
def deadline_error?(error)
|
|
55
|
+
return false unless SyncDeadline.active?
|
|
56
|
+
return true if error.is_a?(ActiveRecord::LockWaitTimeout)
|
|
57
|
+
|
|
58
|
+
cause = error
|
|
59
|
+
while cause
|
|
60
|
+
return true if cause.respond_to?(:error_number) && cause.error_number == 3024
|
|
61
|
+
|
|
62
|
+
cause = cause.cause
|
|
63
|
+
end
|
|
64
|
+
false
|
|
65
|
+
end
|
|
20
66
|
end
|
|
21
67
|
end
|
|
22
68
|
end
|
|
@@ -12,6 +12,35 @@ module SolidObjects
|
|
|
12
12
|
def claim_lock
|
|
13
13
|
"FOR UPDATE SKIP LOCKED"
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# @rbs (untyped) -> void
|
|
19
|
+
def configure_transaction_deadline(connection)
|
|
20
|
+
return unless SyncDeadline.active?
|
|
21
|
+
|
|
22
|
+
connection.execute(
|
|
23
|
+
"SET LOCAL lock_timeout = #{SyncDeadline.remaining_milliseconds}"
|
|
24
|
+
)
|
|
25
|
+
connection.execute(
|
|
26
|
+
"SET LOCAL statement_timeout = #{SyncDeadline.remaining_milliseconds}"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @rbs (Exception) -> bool
|
|
31
|
+
def deadline_error?(error)
|
|
32
|
+
return false unless SyncDeadline.active?
|
|
33
|
+
return true if error.is_a?(ActiveRecord::LockWaitTimeout)
|
|
34
|
+
|
|
35
|
+
cause = error
|
|
36
|
+
while cause
|
|
37
|
+
result = cause.respond_to?(:result) ? cause.result : nil
|
|
38
|
+
return true if result&.error_field(PG::Result::PG_DIAG_SQLSTATE) == "57014"
|
|
39
|
+
|
|
40
|
+
cause = cause.cause
|
|
41
|
+
end
|
|
42
|
+
false
|
|
43
|
+
end
|
|
15
44
|
end
|
|
16
45
|
end
|
|
17
46
|
end
|
|
@@ -7,6 +7,34 @@ module SolidObjects
|
|
|
7
7
|
def current_time_expression
|
|
8
8
|
"STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# @rbs (untyped) { () -> untyped } -> untyped
|
|
14
|
+
def with_transaction_deadline(connection)
|
|
15
|
+
return yield unless SyncDeadline.active?
|
|
16
|
+
|
|
17
|
+
previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i
|
|
18
|
+
connection.execute(
|
|
19
|
+
"PRAGMA busy_timeout = #{SyncDeadline.remaining_milliseconds}"
|
|
20
|
+
)
|
|
21
|
+
yield
|
|
22
|
+
ensure
|
|
23
|
+
connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @rbs (Exception) -> bool
|
|
27
|
+
def deadline_error?(error)
|
|
28
|
+
return false unless SyncDeadline.active?
|
|
29
|
+
|
|
30
|
+
cause = error
|
|
31
|
+
while cause
|
|
32
|
+
return true if cause.class.name.match?(/BusyException|BusyError/)
|
|
33
|
+
|
|
34
|
+
cause = cause.cause
|
|
35
|
+
end
|
|
36
|
+
false
|
|
37
|
+
end
|
|
10
38
|
end
|
|
11
39
|
end
|
|
12
40
|
end
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "solid_objects/mailbox"
|
|
4
|
+
require "solid_objects/synchronous_invocation"
|
|
5
|
+
|
|
6
|
+
module SolidObjects
|
|
7
|
+
class Doctor
|
|
8
|
+
class Check
|
|
9
|
+
# @rbs @name: Symbol
|
|
10
|
+
# @rbs @status: Symbol
|
|
11
|
+
# @rbs @message: String
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :status, :message
|
|
14
|
+
|
|
15
|
+
# @rbs (name: Symbol, status: Symbol, message: String) -> void
|
|
16
|
+
def initialize(name:, status:, message:)
|
|
17
|
+
@name = name
|
|
18
|
+
@status = status
|
|
19
|
+
@message = message
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs () -> bool
|
|
24
|
+
def failed?
|
|
25
|
+
status == :fail
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class Report
|
|
30
|
+
# @rbs @checks: Array[Check]
|
|
31
|
+
|
|
32
|
+
attr_reader :checks
|
|
33
|
+
|
|
34
|
+
# @rbs (checks: Array[Check]) -> void
|
|
35
|
+
def initialize(checks:)
|
|
36
|
+
@checks = checks.freeze
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @rbs () -> bool
|
|
41
|
+
def healthy?
|
|
42
|
+
checks.none?(&:failed?)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @rbs (Symbol) -> Check
|
|
46
|
+
def check(name)
|
|
47
|
+
checks.find { |candidate| candidate.name == name } ||
|
|
48
|
+
raise(KeyError, "unknown doctor check #{name.inspect}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs () -> String
|
|
52
|
+
def to_s
|
|
53
|
+
lines = [ "Solid Objects doctor #{SolidObjects::VERSION}" ]
|
|
54
|
+
lines.concat(
|
|
55
|
+
checks.map do |check|
|
|
56
|
+
"#{check.status.to_s.upcase.ljust(4)} #{check.name}: #{check.message}"
|
|
57
|
+
end
|
|
58
|
+
)
|
|
59
|
+
lines.join("\n")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
EXPECTED_COLUMNS = {
|
|
64
|
+
processes: %w[id kind hostname pid last_heartbeat_at shutdown_state],
|
|
65
|
+
instances: %w[
|
|
66
|
+
id actor_type actor_id state state_version next_message_sequence
|
|
67
|
+
activation_owner_id activation_token activation_expires_at
|
|
68
|
+
activation_generation
|
|
69
|
+
],
|
|
70
|
+
messages: %w[
|
|
71
|
+
id instance_id message_kind arguments sequence attempt_count request_id
|
|
72
|
+
result error rejection completed_at rejected_at
|
|
73
|
+
],
|
|
74
|
+
ready_messages: %w[id message_id instance_id sequence available_at],
|
|
75
|
+
claimed_messages: %w[
|
|
76
|
+
id message_id instance_id process_id activation_token
|
|
77
|
+
activation_generation claimed_at
|
|
78
|
+
],
|
|
79
|
+
reminders: %w[id instance_id message_name next_run_at status],
|
|
80
|
+
effects: %w[id message_id instance_id effect_id status available_at],
|
|
81
|
+
broadcasts: %w[id message_id instance_id broadcast_id status available_at],
|
|
82
|
+
dead_letters: %w[id message_id instance_id actor_type actor_id attempts]
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
class ProbeActor < Actor
|
|
86
|
+
actor_type "solid_objects_doctor"
|
|
87
|
+
|
|
88
|
+
# @rbs (value: String) -> String
|
|
89
|
+
def ping(value:)
|
|
90
|
+
value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @rbs @connection: untyped
|
|
95
|
+
# @rbs @configuration: Configuration
|
|
96
|
+
|
|
97
|
+
# @rbs (?connection: untyped, ?configuration: Configuration) -> void
|
|
98
|
+
def initialize(
|
|
99
|
+
connection: SolidObjects::Record.connection,
|
|
100
|
+
configuration: SolidObjects.configuration
|
|
101
|
+
)
|
|
102
|
+
@connection = connection
|
|
103
|
+
@configuration = configuration
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @rbs () -> Report
|
|
107
|
+
def call
|
|
108
|
+
configuration_check = check_configuration
|
|
109
|
+
schema_check = check_schema
|
|
110
|
+
checks = [
|
|
111
|
+
configuration_check,
|
|
112
|
+
schema_check,
|
|
113
|
+
check_authorization,
|
|
114
|
+
schema_check.failed? ? skipped_runtime : check_runtime,
|
|
115
|
+
ready_for_round_trip?(configuration_check, schema_check) ?
|
|
116
|
+
check_sync_round_trip :
|
|
117
|
+
skipped_round_trip
|
|
118
|
+
]
|
|
119
|
+
Report.new(checks:)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
attr_reader :connection, :configuration
|
|
125
|
+
|
|
126
|
+
# @rbs () -> Check
|
|
127
|
+
def check_configuration
|
|
128
|
+
configuration.validate!
|
|
129
|
+
pass(:configuration, "configuration is valid")
|
|
130
|
+
rescue => error
|
|
131
|
+
fail_check(:configuration, "#{error.class}: #{error.message}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @rbs () -> Check
|
|
135
|
+
def check_schema
|
|
136
|
+
missing_tables = expected_table_names - connection.data_sources
|
|
137
|
+
unless missing_tables.empty?
|
|
138
|
+
return fail_check(:schema, "missing tables: #{missing_tables.join(", ")}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
missing_columns = EXPECTED_COLUMNS.each_with_object([]) do |(name, expected), missing|
|
|
142
|
+
table_name = SolidObjects.table_name(name)
|
|
143
|
+
actual = connection.columns(table_name).map(&:name)
|
|
144
|
+
(expected - actual).each { |column| missing << "#{table_name}.#{column}" }
|
|
145
|
+
end
|
|
146
|
+
unless missing_columns.empty?
|
|
147
|
+
return fail_check(:schema, "missing columns: #{missing_columns.join(", ")}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
pass(:schema, "schema matches the #{SolidObjects::VERSION} runtime")
|
|
151
|
+
rescue => error
|
|
152
|
+
fail_check(:schema, "#{error.class}: #{error.message}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @rbs () -> Check
|
|
156
|
+
def check_authorization
|
|
157
|
+
outcomes = policy_probes.to_h do |name, arguments|
|
|
158
|
+
outcome = configuration.public_send(name).call(**arguments) ? :allow : :deny
|
|
159
|
+
[ name, outcome ]
|
|
160
|
+
rescue
|
|
161
|
+
[ name, :unknown ]
|
|
162
|
+
end
|
|
163
|
+
allowed = outcomes.select { |_, outcome| outcome == :allow }.keys
|
|
164
|
+
unknown = outcomes.select { |_, outcome| outcome == :unknown }.keys
|
|
165
|
+
|
|
166
|
+
if allowed.empty? && unknown.empty?
|
|
167
|
+
return warn_check(
|
|
168
|
+
:authorization,
|
|
169
|
+
"all five policies denied a neutral context; review the generated initializer before use"
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
risky = allowed & %i[
|
|
173
|
+
authorize_destroy
|
|
174
|
+
authorize_subscription
|
|
175
|
+
authorize_administration
|
|
176
|
+
]
|
|
177
|
+
unless risky.empty?
|
|
178
|
+
return warn_check(
|
|
179
|
+
:authorization,
|
|
180
|
+
"sensitive policies allowed a neutral context: #{risky.join(", ")}"
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
unless unknown.empty?
|
|
184
|
+
return warn_check(
|
|
185
|
+
:authorization,
|
|
186
|
+
"#{allowed.length} of 5 policies allowed a neutral context; " \
|
|
187
|
+
"#{unknown.join(", ")} could not evaluate without application context"
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @rbs () -> Check
|
|
195
|
+
def check_runtime
|
|
196
|
+
cutoff = SolidObjects.database_adapter.database_now -
|
|
197
|
+
configuration.process_alive_threshold
|
|
198
|
+
counts = Process
|
|
199
|
+
.where(shutdown_state: "running", last_heartbeat_at: cutoff..)
|
|
200
|
+
.group(:kind)
|
|
201
|
+
.count
|
|
202
|
+
if counts.empty?
|
|
203
|
+
return info(
|
|
204
|
+
:runtime,
|
|
205
|
+
"no live runtime roles; workerless synchronous calls are available, asynchronous features are not"
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
summary = counts.sort.map { |kind, count| "#{kind}=#{count}" }.join(", ")
|
|
210
|
+
pass(:runtime, "live runtime roles: #{summary}")
|
|
211
|
+
rescue => error
|
|
212
|
+
fail_check(:runtime, "#{error.class}: #{error.message}")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @rbs () -> Check
|
|
216
|
+
def check_sync_round_trip
|
|
217
|
+
actor_id = SecureRandom.uuid
|
|
218
|
+
value = SecureRandom.hex(8)
|
|
219
|
+
process_registry = SolidObjects.caller_process.process_registry
|
|
220
|
+
reference = ProbeActor.ref(actor_id)
|
|
221
|
+
message_reference = Mailbox.new.enqueue(
|
|
222
|
+
reference,
|
|
223
|
+
:ping,
|
|
224
|
+
{ value: },
|
|
225
|
+
kind: "sync"
|
|
226
|
+
)
|
|
227
|
+
result = SynchronousInvocation.new.call(message_reference, timeout: 5.seconds)
|
|
228
|
+
raise Error, "unexpected round-trip result" unless result == value
|
|
229
|
+
|
|
230
|
+
pass(:sync_round_trip, "durable synchronous actor call completed without a worker")
|
|
231
|
+
rescue => error
|
|
232
|
+
fail_check(:sync_round_trip, "#{error.class}: #{error.message}")
|
|
233
|
+
ensure
|
|
234
|
+
Instance.where(actor_type: ProbeActor.actor_type, actor_id:).delete_all if actor_id
|
|
235
|
+
process_registry&.stop
|
|
236
|
+
process_registry&.process_record&.delete
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# @rbs (Check, Check) -> bool
|
|
240
|
+
def ready_for_round_trip?(configuration_check, schema_check)
|
|
241
|
+
!configuration_check.failed? && !schema_check.failed?
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @rbs () -> Check
|
|
245
|
+
def skipped_runtime
|
|
246
|
+
skip(:runtime, "schema check failed")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @rbs () -> Check
|
|
250
|
+
def skipped_round_trip
|
|
251
|
+
skip(:sync_round_trip, "configuration or schema check failed")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# @rbs () -> Hash[Symbol, Hash[Symbol, untyped]]
|
|
255
|
+
def policy_probes
|
|
256
|
+
actor_arguments = {
|
|
257
|
+
actor_type: ProbeActor.actor_type,
|
|
258
|
+
actor_id: "doctor",
|
|
259
|
+
authorization_context: nil
|
|
260
|
+
}
|
|
261
|
+
{
|
|
262
|
+
authorize_message: actor_arguments.merge(
|
|
263
|
+
message_name: "ping",
|
|
264
|
+
arguments: { "value" => "doctor" }
|
|
265
|
+
),
|
|
266
|
+
authorize_query: actor_arguments.merge(
|
|
267
|
+
message_name: "value",
|
|
268
|
+
arguments: {}
|
|
269
|
+
),
|
|
270
|
+
authorize_destroy: actor_arguments,
|
|
271
|
+
authorize_subscription: actor_arguments,
|
|
272
|
+
authorize_administration: {
|
|
273
|
+
action: "doctor",
|
|
274
|
+
resource: "runtime",
|
|
275
|
+
resource_id: nil,
|
|
276
|
+
authorization_context: nil
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# @rbs () -> Array[String]
|
|
282
|
+
def expected_table_names
|
|
283
|
+
EXPECTED_COLUMNS.keys.map { |name| SolidObjects.table_name(name) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# @rbs (Symbol, String) -> Check
|
|
287
|
+
def pass(name, message)
|
|
288
|
+
Check.new(name:, status: :pass, message:)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @rbs (Symbol, String) -> Check
|
|
292
|
+
def info(name, message)
|
|
293
|
+
Check.new(name:, status: :info, message:)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# @rbs (Symbol, String) -> Check
|
|
297
|
+
def warn_check(name, message)
|
|
298
|
+
Check.new(name:, status: :warn, message:)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# @rbs (Symbol, String) -> Check
|
|
302
|
+
def fail_check(name, message)
|
|
303
|
+
Check.new(name:, status: :fail, message:)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# @rbs (Symbol, String) -> Check
|
|
307
|
+
def skip(name, message)
|
|
308
|
+
Check.new(name:, status: :skip, message:)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|