solid_objects 0.1.0 → 0.2.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 +22 -0
- data/README.md +160 -43
- data/app/models/solid_objects/claimed_message.rb +2 -0
- data/app/models/solid_objects/message.rb +6 -1
- data/benchmark/adoption_latency.rb +5 -0
- data/benchmark/support.rb +45 -15
- data/benchmark/{ask_latency.rb → sync_latency.rb} +1 -1
- data/db/migrate/20260805000000_create_solid_objects_tables.rb +15 -3
- data/docs/adr/0006-at-least-once-delivery.md +1 -1
- data/docs/adr/0008-actor-communication.md +4 -1
- data/docs/adr/0011-wake-up-strategy.md +13 -4
- data/docs/architecture.md +51 -20
- data/docs/authorization.md +85 -0
- data/docs/benchmarks.md +80 -10
- data/docs/correctness.md +28 -9
- data/docs/database-schema.md +15 -7
- data/docs/development.md +6 -5
- data/docs/fit.md +90 -0
- data/docs/implementation-plan.md +22 -16
- data/docs/migrating-existing-state.md +133 -0
- data/docs/operations.md +56 -4
- data/docs/research/solid_queue.md +2 -1
- data/docs/roadmap.md +9 -3
- data/docs/security.md +7 -2
- data/docs/state-migrations.md +4 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +17 -0
- data/lib/solid_objects/activation.rb +35 -19
- data/lib/solid_objects/activation_manager.rb +22 -6
- data/lib/solid_objects/actor.rb +16 -3
- data/lib/solid_objects/caller_process.rb +57 -0
- data/lib/solid_objects/client.rb +6 -37
- data/lib/solid_objects/configuration.rb +4 -4
- data/lib/solid_objects/doctor.rb +311 -0
- data/lib/solid_objects/engine.rb +1 -0
- data/lib/solid_objects/errors.rb +17 -1
- data/lib/solid_objects/executor.rb +42 -2
- data/lib/solid_objects/lease.rb +22 -10
- data/lib/solid_objects/message_reference.rb +1 -0
- data/lib/solid_objects/process_registry.rb +5 -1
- data/lib/solid_objects/reference.rb +8 -8
- data/lib/solid_objects/synchronous_invocation.rb +93 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +7 -0
- data/lib/tasks/solid_objects_tasks.rake +10 -0
- data/sig/generated/lib/solid_objects/activation.rbs +6 -0
- data/sig/generated/lib/solid_objects/activation_manager.rbs +6 -0
- data/sig/generated/lib/solid_objects/actor.rbs +9 -6
- data/sig/generated/lib/solid_objects/caller_process.rbs +32 -0
- data/sig/generated/lib/solid_objects/client.rbs +2 -8
- data/sig/generated/lib/solid_objects/configuration.rbs +2 -2
- data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
- data/sig/generated/lib/solid_objects/errors.rbs +18 -1
- data/sig/generated/lib/solid_objects/executor.rbs +3 -0
- data/sig/generated/lib/solid_objects/lease.rbs +14 -10
- data/sig/generated/lib/solid_objects/reference.rbs +3 -3
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +28 -0
- data/sig/generated/lib/solid_objects.rbs +3 -0
- data/sig/generated/models/solid_objects/message.rbs +3 -0
- metadata +15 -8
data/lib/solid_objects/client.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# rbs_inline: enabled
|
|
2
2
|
|
|
3
3
|
require "solid_objects/mailbox"
|
|
4
|
+
require "solid_objects/synchronous_invocation"
|
|
4
5
|
|
|
5
6
|
module SolidObjects
|
|
6
7
|
class Client
|
|
@@ -12,7 +13,7 @@ module SolidObjects
|
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference
|
|
15
|
-
def
|
|
16
|
+
def async(reference, message_name, arguments, available_at: nil, idempotency_key: nil, authorization_context: nil)
|
|
16
17
|
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
17
18
|
message_symbol = message_name.to_sym
|
|
18
19
|
raise UnknownMessage, "unknown message #{message_name.inspect}" unless actor_class.definition.messages.key?(message_symbol)
|
|
@@ -28,14 +29,14 @@ module SolidObjects
|
|
|
28
29
|
reference,
|
|
29
30
|
message_name,
|
|
30
31
|
arguments,
|
|
31
|
-
kind: "
|
|
32
|
+
kind: "async",
|
|
32
33
|
available_at:,
|
|
33
34
|
idempotency_key:
|
|
34
35
|
)
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], timeout: Numeric, ?idempotency_key: String?, ?authorization_context: untyped) -> untyped
|
|
38
|
-
def
|
|
39
|
+
def sync(reference, message_name, arguments, timeout:, idempotency_key: nil, authorization_context: nil)
|
|
39
40
|
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
40
41
|
message_symbol = message_name.to_sym
|
|
41
42
|
query = actor_class.definition.queries.key?(message_symbol)
|
|
@@ -53,10 +54,10 @@ module SolidObjects
|
|
|
53
54
|
reference,
|
|
54
55
|
message_name,
|
|
55
56
|
arguments,
|
|
56
|
-
kind: "
|
|
57
|
+
kind: "sync",
|
|
57
58
|
idempotency_key:
|
|
58
59
|
)
|
|
59
|
-
|
|
60
|
+
SynchronousInvocation.new.call(message_reference, timeout:)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
# @rbs (Reference, ?authorization_context: untyped) -> bool
|
|
@@ -117,37 +118,5 @@ module SolidObjects
|
|
|
117
118
|
|
|
118
119
|
raise Unauthorized, "actor destruction is not authorized"
|
|
119
120
|
end
|
|
120
|
-
|
|
121
|
-
# @rbs (MessageReference, timeout: Numeric) -> untyped
|
|
122
|
-
def wait_for_result(message_reference, timeout:)
|
|
123
|
-
deadline = monotonic_now + timeout.to_f
|
|
124
|
-
|
|
125
|
-
loop do
|
|
126
|
-
message = Message.uncached { Message.find(message_reference.id) }
|
|
127
|
-
return message.result if message.completed?
|
|
128
|
-
|
|
129
|
-
if message.dead?
|
|
130
|
-
raise MessageFailed.new(
|
|
131
|
-
"actor message failed permanently",
|
|
132
|
-
message_id: message.id,
|
|
133
|
-
details: message.error || {}
|
|
134
|
-
)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
remaining = deadline - monotonic_now
|
|
138
|
-
raise AskTimeout, "actor request timed out after #{timeout} seconds" unless remaining.positive?
|
|
139
|
-
|
|
140
|
-
SolidObjects.wake_up.wait(
|
|
141
|
-
timeout: [ remaining, SolidObjects.configuration.ask_polling_interval ].min
|
|
142
|
-
)
|
|
143
|
-
end
|
|
144
|
-
rescue ActiveRecord::RecordNotFound
|
|
145
|
-
raise ActorDestroyed, "actor was destroyed while waiting for its result"
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# @rbs () -> Float
|
|
149
|
-
def monotonic_now
|
|
150
|
-
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
151
|
-
end
|
|
152
121
|
end
|
|
153
122
|
end
|
|
@@ -4,7 +4,7 @@ module SolidObjects
|
|
|
4
4
|
class Configuration
|
|
5
5
|
# @rbs @table_name_prefix: String
|
|
6
6
|
# @rbs @polling_interval: Float
|
|
7
|
-
# @rbs @
|
|
7
|
+
# @rbs @sync_polling_interval: Float
|
|
8
8
|
# @rbs @lease_duration: Float
|
|
9
9
|
# @rbs @lease_renewal_interval: Float
|
|
10
10
|
# @rbs @idle_deactivation_timeout: Float
|
|
@@ -37,7 +37,7 @@ module SolidObjects
|
|
|
37
37
|
|
|
38
38
|
attr_accessor :table_name_prefix,
|
|
39
39
|
:polling_interval,
|
|
40
|
-
:
|
|
40
|
+
:sync_polling_interval,
|
|
41
41
|
:lease_duration,
|
|
42
42
|
:lease_renewal_interval,
|
|
43
43
|
:idle_deactivation_timeout,
|
|
@@ -72,7 +72,7 @@ module SolidObjects
|
|
|
72
72
|
def initialize
|
|
73
73
|
@table_name_prefix = "solid_objects_"
|
|
74
74
|
@polling_interval = 0.1
|
|
75
|
-
@
|
|
75
|
+
@sync_polling_interval = 0.05
|
|
76
76
|
@lease_duration = 30.0
|
|
77
77
|
@lease_renewal_interval = 10.0
|
|
78
78
|
@idle_deactivation_timeout = 30.0
|
|
@@ -138,7 +138,7 @@ module SolidObjects
|
|
|
138
138
|
def positive_values
|
|
139
139
|
{
|
|
140
140
|
polling_interval:,
|
|
141
|
-
|
|
141
|
+
sync_polling_interval:,
|
|
142
142
|
lease_duration:,
|
|
143
143
|
lease_renewal_interval:,
|
|
144
144
|
max_messages_per_activation_pass:,
|
|
@@ -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
|
data/lib/solid_objects/engine.rb
CHANGED
data/lib/solid_objects/errors.rb
CHANGED
|
@@ -40,7 +40,23 @@ module SolidObjects
|
|
|
40
40
|
class ActorDestroyed < LostActivation
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
class
|
|
43
|
+
class SyncTimeout < Error
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Rejected < Error
|
|
47
|
+
# @rbs @code: String
|
|
48
|
+
# @rbs @details: Hash[String, untyped]
|
|
49
|
+
# @rbs @message_id: Integer?
|
|
50
|
+
|
|
51
|
+
attr_reader :code, :details, :message_id
|
|
52
|
+
|
|
53
|
+
# @rbs (code: String | Symbol, message: String, ?details: Hash[String | Symbol, untyped], ?message_id: Integer?) -> void
|
|
54
|
+
def initialize(code:, message:, details: {}, message_id: nil)
|
|
55
|
+
@code = code.to_s
|
|
56
|
+
@details = Serialization.dump(details)
|
|
57
|
+
@message_id = message_id
|
|
58
|
+
super(message)
|
|
59
|
+
end
|
|
44
60
|
end
|
|
45
61
|
|
|
46
62
|
class MessageFailed < Error
|
|
@@ -33,6 +33,11 @@ module SolidObjects
|
|
|
33
33
|
true
|
|
34
34
|
rescue LostActivation
|
|
35
35
|
raise
|
|
36
|
+
rescue Rejected => rejection
|
|
37
|
+
activation.restore_state(state_before) if state_before
|
|
38
|
+
actor.discard_intents
|
|
39
|
+
reject_message(rejection)
|
|
40
|
+
false
|
|
36
41
|
rescue => error
|
|
37
42
|
activation.restore_state(state_before) if state_before
|
|
38
43
|
actor.discard_intents
|
|
@@ -51,7 +56,7 @@ module SolidObjects
|
|
|
51
56
|
|
|
52
57
|
# @rbs (Hash[String, untyped]) -> void
|
|
53
58
|
def ensure_query_did_not_mutate_state!(state_before)
|
|
54
|
-
return unless message.message_kind == "
|
|
59
|
+
return unless message.message_kind == "sync"
|
|
55
60
|
return unless actor.class.definition.queries.key?(message.message_name.to_sym)
|
|
56
61
|
return if actor.state.to_h == state_before
|
|
57
62
|
|
|
@@ -72,7 +77,7 @@ module SolidObjects
|
|
|
72
77
|
max_bytes: SolidObjects.configuration.max_state_bytes
|
|
73
78
|
)
|
|
74
79
|
serialized_result = Serialization.dump(
|
|
75
|
-
(message.message_kind == "
|
|
80
|
+
(message.message_kind == "sync") ? result : nil,
|
|
76
81
|
max_bytes: SolidObjects.configuration.max_result_bytes
|
|
77
82
|
)
|
|
78
83
|
effect_intents = actor.drain_effect_intents
|
|
@@ -235,11 +240,46 @@ module SolidObjects
|
|
|
235
240
|
SolidObjects.wake_up.signal
|
|
236
241
|
end
|
|
237
242
|
|
|
243
|
+
# @rbs (Rejected) -> void
|
|
244
|
+
def reject_message(rejection)
|
|
245
|
+
rejection_data = Serialization.dump(
|
|
246
|
+
{
|
|
247
|
+
"code" => rejection.code,
|
|
248
|
+
"message" => rejection.message.to_s.byteslice(0, 8_192),
|
|
249
|
+
"details" => rejection.details
|
|
250
|
+
},
|
|
251
|
+
max_bytes: SolidObjects.configuration.max_result_bytes
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
activation.lease.fenced_transaction do |instance|
|
|
255
|
+
claimed_message = matching_claim!
|
|
256
|
+
locked_message = Message.lock.find(message.id)
|
|
257
|
+
now = SolidObjects.database_adapter.database_now
|
|
258
|
+
instance.update!(last_used_at: now)
|
|
259
|
+
locked_message.update!(
|
|
260
|
+
result: nil,
|
|
261
|
+
rejection: rejection_data,
|
|
262
|
+
error: nil,
|
|
263
|
+
rejected_at: now,
|
|
264
|
+
completed_at: now
|
|
265
|
+
)
|
|
266
|
+
claimed_message.destroy!
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
SolidObjects.instrument(
|
|
270
|
+
:"message.rejected",
|
|
271
|
+
**instrumentation_payload,
|
|
272
|
+
code: rejection.code
|
|
273
|
+
)
|
|
274
|
+
SolidObjects.wake_up.signal
|
|
275
|
+
end
|
|
276
|
+
|
|
238
277
|
# @rbs () -> ClaimedMessage
|
|
239
278
|
def matching_claim!
|
|
240
279
|
ClaimedMessage.lock.find_by!(
|
|
241
280
|
message_id: message.id,
|
|
242
281
|
process_id: activation.lease.owner_id,
|
|
282
|
+
activation_token: activation.lease.activation_token,
|
|
243
283
|
activation_generation: activation.lease.generation
|
|
244
284
|
)
|
|
245
285
|
rescue ActiveRecord::RecordNotFound
|
data/lib/solid_objects/lease.rb
CHANGED
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Lease
|
|
5
5
|
class << self
|
|
6
|
-
# @rbs (instance: Instance, owner_id: String, now: Time, ?duration: Numeric, database_adapter: DatabaseAdapter) -> Lease?
|
|
6
|
+
# @rbs (instance: Instance, owner_id: String, activation_token: String, now: Time, ?duration: Numeric, database_adapter: DatabaseAdapter) -> Lease?
|
|
7
7
|
def claim(
|
|
8
8
|
instance:,
|
|
9
9
|
owner_id:,
|
|
10
|
+
activation_token:,
|
|
10
11
|
now:,
|
|
11
12
|
database_adapter:, duration: SolidObjects.configuration.lease_duration
|
|
12
13
|
)
|
|
13
|
-
return if held_by_another_live_owner?(instance,
|
|
14
|
+
return if held_by_another_live_owner?(instance, activation_token, now)
|
|
14
15
|
|
|
15
16
|
generation = instance.activation_generation + 1
|
|
16
17
|
expires_at = now + duration
|
|
17
18
|
instance.update!(
|
|
18
19
|
activation_owner_id: owner_id,
|
|
20
|
+
activation_token:,
|
|
19
21
|
activation_expires_at: expires_at,
|
|
20
22
|
activation_generation: generation,
|
|
21
23
|
activated_at: now,
|
|
@@ -24,23 +26,25 @@ module SolidObjects
|
|
|
24
26
|
new(
|
|
25
27
|
instance_id: instance.id,
|
|
26
28
|
owner_id:,
|
|
29
|
+
activation_token:,
|
|
27
30
|
generation:,
|
|
28
31
|
expires_at:,
|
|
29
32
|
database_adapter:
|
|
30
33
|
)
|
|
31
34
|
end
|
|
32
35
|
|
|
33
|
-
# @rbs (instance_id: Integer, owner_id: String, ?duration: Numeric, ?database_adapter: DatabaseAdapter) -> Lease?
|
|
36
|
+
# @rbs (instance_id: Integer, owner_id: String, ?activation_token: String, ?duration: Numeric, ?database_adapter: DatabaseAdapter) -> Lease?
|
|
34
37
|
def acquire(
|
|
35
38
|
instance_id:,
|
|
36
39
|
owner_id:,
|
|
40
|
+
activation_token: SecureRandom.uuid,
|
|
37
41
|
duration: SolidObjects.configuration.lease_duration,
|
|
38
42
|
database_adapter: SolidObjects.database_adapter
|
|
39
43
|
)
|
|
40
44
|
lease = database_adapter.transaction do
|
|
41
45
|
instance = Instance.lock.find(instance_id)
|
|
42
46
|
now = database_adapter.database_now
|
|
43
|
-
claim(instance:, owner_id:, now:, duration:, database_adapter:)
|
|
47
|
+
claim(instance:, owner_id:, activation_token:, now:, duration:, database_adapter:)
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
if lease
|
|
@@ -58,9 +62,9 @@ module SolidObjects
|
|
|
58
62
|
private
|
|
59
63
|
|
|
60
64
|
# @rbs (Instance, String, Time) -> bool
|
|
61
|
-
def held_by_another_live_owner?(instance,
|
|
65
|
+
def held_by_another_live_owner?(instance, activation_token, now)
|
|
62
66
|
instance.activation_owner_id.present? &&
|
|
63
|
-
instance.
|
|
67
|
+
instance.activation_token != activation_token &&
|
|
64
68
|
instance.activation_expires_at.present? &&
|
|
65
69
|
instance.activation_expires_at > now
|
|
66
70
|
end
|
|
@@ -68,16 +72,18 @@ module SolidObjects
|
|
|
68
72
|
|
|
69
73
|
# @rbs @instance_id: Integer
|
|
70
74
|
# @rbs @owner_id: String
|
|
75
|
+
# @rbs @activation_token: String
|
|
71
76
|
# @rbs @generation: Integer
|
|
72
77
|
# @rbs @expires_at: Time
|
|
73
78
|
# @rbs @database_adapter: DatabaseAdapter
|
|
74
79
|
|
|
75
|
-
attr_reader :instance_id, :owner_id, :generation, :expires_at
|
|
80
|
+
attr_reader :instance_id, :owner_id, :activation_token, :generation, :expires_at
|
|
76
81
|
|
|
77
|
-
# @rbs (instance_id: Integer, owner_id: String, generation: Integer, expires_at: Time, database_adapter: DatabaseAdapter) -> void
|
|
78
|
-
def initialize(instance_id:, owner_id:, generation:, expires_at:, database_adapter:)
|
|
82
|
+
# @rbs (instance_id: Integer, owner_id: String, activation_token: String, generation: Integer, expires_at: Time, database_adapter: DatabaseAdapter) -> void
|
|
83
|
+
def initialize(instance_id:, owner_id:, activation_token:, generation:, expires_at:, database_adapter:)
|
|
79
84
|
@instance_id = instance_id
|
|
80
85
|
@owner_id = owner_id
|
|
86
|
+
@activation_token = activation_token
|
|
81
87
|
@generation = generation
|
|
82
88
|
@expires_at = expires_at
|
|
83
89
|
@database_adapter = database_adapter
|
|
@@ -95,6 +101,7 @@ module SolidObjects
|
|
|
95
101
|
self.class.new(
|
|
96
102
|
instance_id:,
|
|
97
103
|
owner_id:,
|
|
104
|
+
activation_token:,
|
|
98
105
|
generation:,
|
|
99
106
|
expires_at: renewed_expiration,
|
|
100
107
|
database_adapter:
|
|
@@ -117,7 +124,11 @@ module SolidObjects
|
|
|
117
124
|
next false unless instance
|
|
118
125
|
next false unless owned_generation?(instance)
|
|
119
126
|
|
|
120
|
-
instance.update!(
|
|
127
|
+
instance.update!(
|
|
128
|
+
activation_owner_id: nil,
|
|
129
|
+
activation_token: nil,
|
|
130
|
+
activation_expires_at: nil
|
|
131
|
+
)
|
|
121
132
|
true
|
|
122
133
|
end
|
|
123
134
|
|
|
@@ -159,6 +170,7 @@ module SolidObjects
|
|
|
159
170
|
# @rbs (Instance) -> bool
|
|
160
171
|
def owned_generation?(instance)
|
|
161
172
|
instance.activation_owner_id == owner_id &&
|
|
173
|
+
instance.activation_token == activation_token &&
|
|
162
174
|
instance.activation_generation == generation
|
|
163
175
|
end
|
|
164
176
|
|
|
@@ -22,9 +22,13 @@ module SolidObjects
|
|
|
22
22
|
SolidObjects.database_adapter.transaction do
|
|
23
23
|
Instance.where(activation_owner_id: process_record.id).update_all(
|
|
24
24
|
activation_owner_id: nil,
|
|
25
|
+
activation_token: nil,
|
|
25
26
|
activation_expires_at: nil
|
|
26
27
|
)
|
|
27
|
-
ClaimedMessage.where(process_id: process_record.id).update_all(
|
|
28
|
+
ClaimedMessage.where(process_id: process_record.id).update_all(
|
|
29
|
+
process_id: nil,
|
|
30
|
+
activation_token: nil
|
|
31
|
+
)
|
|
28
32
|
Effect.where(claimed_by: process_record.id).update_all(
|
|
29
33
|
status: "pending",
|
|
30
34
|
claimed_by: nil,
|