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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +160 -43
  4. data/app/models/solid_objects/claimed_message.rb +2 -0
  5. data/app/models/solid_objects/message.rb +6 -1
  6. data/benchmark/adoption_latency.rb +5 -0
  7. data/benchmark/support.rb +45 -15
  8. data/benchmark/{ask_latency.rb → sync_latency.rb} +1 -1
  9. data/db/migrate/20260805000000_create_solid_objects_tables.rb +15 -3
  10. data/docs/adr/0006-at-least-once-delivery.md +1 -1
  11. data/docs/adr/0008-actor-communication.md +4 -1
  12. data/docs/adr/0011-wake-up-strategy.md +13 -4
  13. data/docs/architecture.md +51 -20
  14. data/docs/authorization.md +85 -0
  15. data/docs/benchmarks.md +80 -10
  16. data/docs/correctness.md +28 -9
  17. data/docs/database-schema.md +15 -7
  18. data/docs/development.md +6 -5
  19. data/docs/fit.md +90 -0
  20. data/docs/implementation-plan.md +22 -16
  21. data/docs/migrating-existing-state.md +133 -0
  22. data/docs/operations.md +56 -4
  23. data/docs/research/solid_queue.md +2 -1
  24. data/docs/roadmap.md +9 -3
  25. data/docs/security.md +7 -2
  26. data/docs/state-migrations.md +4 -0
  27. data/lib/generators/solid_objects/templates/solid_objects.rb +17 -0
  28. data/lib/solid_objects/activation.rb +35 -19
  29. data/lib/solid_objects/activation_manager.rb +22 -6
  30. data/lib/solid_objects/actor.rb +16 -3
  31. data/lib/solid_objects/caller_process.rb +57 -0
  32. data/lib/solid_objects/client.rb +6 -37
  33. data/lib/solid_objects/configuration.rb +4 -4
  34. data/lib/solid_objects/doctor.rb +311 -0
  35. data/lib/solid_objects/engine.rb +1 -0
  36. data/lib/solid_objects/errors.rb +17 -1
  37. data/lib/solid_objects/executor.rb +42 -2
  38. data/lib/solid_objects/lease.rb +22 -10
  39. data/lib/solid_objects/message_reference.rb +1 -0
  40. data/lib/solid_objects/process_registry.rb +5 -1
  41. data/lib/solid_objects/reference.rb +8 -8
  42. data/lib/solid_objects/synchronous_invocation.rb +93 -0
  43. data/lib/solid_objects/version.rb +1 -1
  44. data/lib/solid_objects.rb +7 -0
  45. data/lib/tasks/solid_objects_tasks.rake +10 -0
  46. data/sig/generated/lib/solid_objects/activation.rbs +6 -0
  47. data/sig/generated/lib/solid_objects/activation_manager.rbs +6 -0
  48. data/sig/generated/lib/solid_objects/actor.rbs +9 -6
  49. data/sig/generated/lib/solid_objects/caller_process.rbs +32 -0
  50. data/sig/generated/lib/solid_objects/client.rbs +2 -8
  51. data/sig/generated/lib/solid_objects/configuration.rbs +2 -2
  52. data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
  53. data/sig/generated/lib/solid_objects/errors.rbs +18 -1
  54. data/sig/generated/lib/solid_objects/executor.rbs +3 -0
  55. data/sig/generated/lib/solid_objects/lease.rbs +14 -10
  56. data/sig/generated/lib/solid_objects/reference.rbs +3 -3
  57. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +28 -0
  58. data/sig/generated/lib/solid_objects.rbs +3 -0
  59. data/sig/generated/models/solid_objects/message.rbs +3 -0
  60. metadata +15 -8
@@ -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 tell(reference, message_name, arguments, available_at: nil, idempotency_key: nil, authorization_context: nil)
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: "tell",
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 ask(reference, message_name, arguments, timeout:, idempotency_key: nil, authorization_context: nil)
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: "ask",
57
+ kind: "sync",
57
58
  idempotency_key:
58
59
  )
59
- wait_for_result(message_reference, timeout:)
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 @ask_polling_interval: Float
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
- :ask_polling_interval,
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
- @ask_polling_interval = 0.05
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
- ask_polling_interval:,
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
@@ -21,6 +21,7 @@ module SolidObjects
21
21
 
22
22
  initializer "solid_objects.helpers" do
23
23
  ActiveSupport.on_load(:action_view) do
24
+ require_relative "../../app/helpers/solid_objects/actor_helper"
24
25
  include SolidObjects::ActorHelper
25
26
  end
26
27
  end
@@ -40,7 +40,23 @@ module SolidObjects
40
40
  class ActorDestroyed < LostActivation
41
41
  end
42
42
 
43
- class AskTimeout < Error
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 == "ask"
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 == "ask") ? result : nil,
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
@@ -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, owner_id, now)
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, owner_id, now)
65
+ def held_by_another_live_owner?(instance, activation_token, now)
62
66
  instance.activation_owner_id.present? &&
63
- instance.activation_owner_id != owner_id &&
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!(activation_owner_id: nil, activation_expires_at: nil)
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
 
@@ -36,6 +36,7 @@ module SolidObjects
36
36
  # @rbs () -> String
37
37
  def status
38
38
  message = Message.find(id)
39
+ return "rejected" if message.rejected?
39
40
  return "completed" if message.completed?
40
41
  return "dead" if message.dead?
41
42
  return "claimed" if message.claimed?
@@ -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(process_id: nil)
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,