solid_objects 0.14.6 → 0.15.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +1 -0
  4. data/Rakefile +1 -1
  5. data/app/models/solid_objects/effect_recovery.rb +10 -0
  6. data/benchmark/support.rb +3 -0
  7. data/db/migrate/20260915000000_add_solid_objects_effect_recoveries.rb +17 -0
  8. data/docs/architecture.md +48 -0
  9. data/docs/development.md +110 -0
  10. data/docs/effect-recovery.md +203 -0
  11. data/docs/operations.md +6 -0
  12. data/docs/reminders.md +8 -0
  13. data/docs/roadmap.md +11 -0
  14. data/examples/at_least_once/boot.rb +3 -1
  15. data/lib/solid_objects/actor.rb +43 -4
  16. data/lib/solid_objects/actor_signatures.rb +80 -0
  17. data/lib/solid_objects/database_adapter.rb +13 -0
  18. data/lib/solid_objects/doctor.rb +1 -0
  19. data/lib/solid_objects/effect_executor.rb +18 -15
  20. data/lib/solid_objects/effect_payload.rb +42 -0
  21. data/lib/solid_objects/effect_recovery_coordinator.rb +143 -0
  22. data/lib/solid_objects/executor.rb +14 -2
  23. data/lib/solid_objects/process_heartbeat.rb +64 -0
  24. data/lib/solid_objects/process_pruner.rb +1 -1
  25. data/lib/solid_objects/process_registry.rb +9 -6
  26. data/lib/solid_objects/test_helper.rb +1 -0
  27. data/lib/solid_objects/version.rb +1 -1
  28. data/lib/solid_objects.rb +3 -0
  29. data/sig/generated/lib/solid_objects/actor.rbs +40 -6
  30. data/sig/generated/lib/solid_objects/actor_signatures.rbs +24 -0
  31. data/sig/generated/lib/solid_objects/database_adapter.rbs +3 -0
  32. data/sig/generated/lib/solid_objects/effect_payload.rbs +23 -0
  33. data/sig/generated/lib/solid_objects/effect_recovery_coordinator.rbs +41 -0
  34. data/sig/generated/lib/solid_objects/process_heartbeat.rbs +38 -0
  35. data/sig/generated/models/solid_objects/effect_recovery.rbs +6 -0
  36. data/sig/public/effect_payload.rbs +33 -0
  37. metadata +15 -2
@@ -2,7 +2,9 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Actor
5
- EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
5
+ EffectIntent = Data.define(:effect_id, :name, :arguments, :success_operation, :failure_operation,
6
+ :recovery_operation, :status_operation, :recovery_timeout)
7
+ EffectRecoveryIntent = Data.define(:effect_id, :request_id)
6
8
  CommitActionIntent = Data.define(:name, :arguments)
7
9
  # The reminders table holds a name in 191 characters.
8
10
  REMINDER_NAME_LIMIT = 191
@@ -143,6 +145,7 @@ module SolidObjects
143
145
  # @rbs @actor_id: String
144
146
  # @rbs @state: State
145
147
  # @rbs @effect_intents: Array[EffectIntent]
148
+ # @rbs @effect_recovery_intents: Array[EffectRecoveryIntent]
146
149
  # @rbs @commit_action_intents: Array[CommitActionIntent]
147
150
  # @rbs @reminder_intents: Array[ReminderIntent]
148
151
  # @rbs @outbound_message_intents: Array[OutboundMessageIntent]
@@ -154,6 +157,7 @@ module SolidObjects
154
157
  @actor_id = actor_id
155
158
  @state = state
156
159
  @effect_intents = []
160
+ @effect_recovery_intents = []
157
161
  @commit_action_intents = []
158
162
  @reminder_intents = []
159
163
  @outbound_message_intents = []
@@ -175,18 +179,36 @@ module SolidObjects
175
179
  raise Rejected.new(code: rejection_code, message:, details:)
176
180
  end
177
181
 
178
- # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
179
- def emit(name, on_success: nil, on_failure: nil, **arguments)
182
+ # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, ?on_recovery: Symbol | String?, ?on_status: Symbol | String?, ?recovery_timeout: Numeric?, **untyped) -> effect_handle
183
+ def emit(name, on_success: nil, on_failure: nil, on_recovery: nil, on_status: nil, recovery_timeout: nil, **arguments)
180
184
  validate_effect_callback!(on_success)
181
185
  validate_effect_callback!(on_failure)
186
+ validate_effect_callback!(on_recovery)
187
+ validate_effect_callback!(on_status)
188
+ validate_recovery_timeout!(timeout: recovery_timeout, operation: on_recovery)
189
+ effect_id = SecureRandom.uuid
182
190
  EffectIntent.new(
191
+ effect_id:,
183
192
  name: name.to_s,
184
193
  arguments: Serialization.dump(arguments),
185
194
  success_operation: on_success&.to_s,
186
- failure_operation: on_failure&.to_s
195
+ failure_operation: on_failure&.to_s,
196
+ recovery_operation: on_recovery&.to_s,
197
+ status_operation: on_status&.to_s,
198
+ recovery_timeout: recovery_timeout&.to_f
187
199
  ).tap do |intent|
188
200
  effect_intents << intent
189
201
  end
202
+ { "effect_id" => effect_id }
203
+ end
204
+
205
+ # @rbs (effect_handle) -> nil
206
+ def request_effect_recovery(handle)
207
+ unless handle.is_a?(Hash) && handle["effect_id"].is_a?(String) && !handle.fetch("effect_id").empty?
208
+ raise InvalidPayload, "expected an effect handle returned by emit"
209
+ end
210
+
211
+ effect_recovery_intents << EffectRecoveryIntent.new(effect_id: handle.fetch("effect_id"), request_id: SecureRandom.uuid)
190
212
  nil
191
213
  end
192
214
 
@@ -360,6 +382,11 @@ module SolidObjects
360
382
  effect_intents.shift(effect_intents.length)
361
383
  end
362
384
 
385
+ # @rbs () -> Array[EffectRecoveryIntent]
386
+ def drain_effect_recovery_intents
387
+ effect_recovery_intents.shift(effect_recovery_intents.length)
388
+ end
389
+
363
390
  # @rbs () -> Array[CommitActionIntent]
364
391
  def drain_commit_action_intents
365
392
  commit_action_intents.shift(commit_action_intents.length)
@@ -378,6 +405,7 @@ module SolidObjects
378
405
  # @rbs () -> void
379
406
  def discard_intents
380
407
  effect_intents.clear
408
+ effect_recovery_intents.clear
381
409
  commit_action_intents.clear
382
410
  reminder_intents.clear
383
411
  outbound_message_intents.clear
@@ -386,6 +414,7 @@ module SolidObjects
386
414
  private
387
415
 
388
416
  attr_reader :effect_intents,
417
+ :effect_recovery_intents,
389
418
  :commit_action_intents,
390
419
  :reminder_intents,
391
420
  :outbound_message_intents
@@ -412,5 +441,15 @@ module SolidObjects
412
441
 
413
442
  raise UnknownMessage, "unknown effect callback operation #{operation.inspect}"
414
443
  end
444
+
445
+ # @rbs (timeout: Numeric?, operation: String | Symbol?) -> void
446
+ def validate_recovery_timeout!(timeout:, operation:)
447
+ return if timeout.nil?
448
+
449
+ unless timeout.is_a?(Numeric) && timeout.real? && timeout.to_f.finite? && timeout.to_f.positive?
450
+ raise ArgumentError, "recovery_timeout must be a positive finite duration in seconds"
451
+ end
452
+ raise ArgumentError, "recovery_timeout requires on_recovery" unless operation
453
+ end
415
454
  end
416
455
  end
@@ -0,0 +1,80 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "solid_objects"
4
+ require "rbs"
5
+ require "pathname"
6
+
7
+ module SolidObjects
8
+ class ActorSignatures
9
+ # @rbs (actors: Array[Class], signatures: Array[String]) -> String
10
+ def self.generate(actors:, signatures:)
11
+ new(signatures:).generate(actors:)
12
+ end
13
+
14
+ # @rbs @builder: untyped
15
+
16
+ # @rbs (signatures: Array[String]) -> void
17
+ def initialize(signatures:)
18
+ loader = RBS::EnvironmentLoader.new
19
+ loader.add(path: Pathname.new(File.expand_path("../../sig", __dir__)))
20
+ signatures.sort.each { |path| loader.add(path: Pathname.new(path)) }
21
+ environment = RBS::Environment.from_loader(loader).resolve_type_names
22
+ @builder = RBS::DefinitionBuilder.new(env: environment)
23
+ end
24
+
25
+ # @rbs (actors: Array[Class]) -> String
26
+ def generate(actors:)
27
+ actors.uniq.sort_by { |actor| actor.name.to_s }.map { |actor| actor_signature(actor) }.join("\n")
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs (untyped) -> String
33
+ def actor_signature(actor)
34
+ unless actor < Actor && actor.name
35
+ raise ArgumentError, "actor signatures require named SolidObjects::Actor subclasses"
36
+ end
37
+
38
+ name = RBS::TypeName.parse("::#{actor.name}")
39
+ definition = @builder.build_instance(name)
40
+ if definition.type_params.any?
41
+ raise ArgumentError, "generic actor classes require application-owned dispatcher signatures"
42
+ end
43
+ messages = actor.definition.messages.keys.sort
44
+ methods = messages.map do |operation|
45
+ method = definition.methods[operation]
46
+ unless method && method.accessibility == :public
47
+ raise ArgumentError, "declare a public RBS signature for #{actor.name}##{operation}"
48
+ end
49
+ types = method.method_types.map { |type| staged_type(type, actor.name, operation).to_s }
50
+ " def #{operation}: #{types.join("\n | ")}"
51
+ end
52
+ callback_names = messages.flat_map { |operation| [ operation.inspect, operation.to_s.inspect ] }
53
+ callbacks = (callback_names + [ "nil" ]).join(" | ")
54
+ <<~RBS
55
+ class #{name}
56
+ interface _SolidObjectsOperations
57
+ #{methods.join("\n")}
58
+ def public_send: (Symbol | String, **untyped) -> nil
59
+ end
60
+
61
+ def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> #{name}::_SolidObjectsOperations
62
+ def transmit: () -> #{name}::_SolidObjectsOperations
63
+ def emit: (Symbol | String, ?on_success: (#{callbacks}), ?on_failure: (#{callbacks}), ?on_recovery: (#{callbacks}), ?on_status: (#{callbacks}), ?recovery_timeout: Numeric?, **untyped) -> SolidObjects::effect_handle
64
+ end
65
+ RBS
66
+ end
67
+
68
+ # @rbs (untyped, String, Symbol) -> untyped
69
+ def staged_type(method_type, actor_name, operation)
70
+ function = method_type.type
71
+ if !function.is_a?(RBS::Types::Function) || method_type.block ||
72
+ function.required_positionals.any? || function.optional_positionals.any? ||
73
+ function.rest_positionals || function.trailing_positionals.any?
74
+ raise ArgumentError, "#{actor_name}##{operation} must declare keyword-only arguments without a block"
75
+ end
76
+
77
+ method_type.update(type: function.update(return_type: RBS::Types::Bases::Nil.new(location: nil)))
78
+ end
79
+ end
80
+ end
@@ -111,6 +111,19 @@ module SolidObjects
111
111
  ActiveSupport::IsolatedExecutionState[TRANSACTION_CLOCK] ||= read_database_now
112
112
  end
113
113
 
114
+ # @rbs () -> Time
115
+ def database_clock_now
116
+ value = with_connection do |connection|
117
+ expression = case self.class.family(connection)
118
+ when :postgresql then "clock_timestamp()"
119
+ when :mysql then "CURRENT_TIMESTAMP(6)"
120
+ else "STRFTIME('%Y-%m-%d %H:%M:%f', 'now')"
121
+ end
122
+ connection.select_value("SELECT #{expression}")
123
+ end
124
+ value.is_a?(Time) ? value.utc : Time.parse("#{value} UTC").utc
125
+ end
126
+
114
127
  # @rbs () { () -> untyped } -> untyped
115
128
  def with_lock_retry
116
129
  yield
@@ -78,6 +78,7 @@ module SolidObjects
78
78
  ],
79
79
  reminders: %w[id instance_id operation next_run_at status],
80
80
  effects: %w[id message_id instance_id effect_id status available_at],
81
+ effect_recoveries: %w[effect_id instance_id recovery_operation status_operation recovery_timeout retired_at],
81
82
  broadcasts: %w[id message_id instance_id broadcast_id status available_at],
82
83
  dead_letters: %w[id message_id instance_id actor_type actor_id attempts]
83
84
  }.freeze
@@ -45,12 +45,16 @@ module SolidObjects
45
45
  effect = claim_next
46
46
  return false unless effect
47
47
 
48
+ heartbeat = ProcessHeartbeat.new(process_registry:)
49
+ heartbeat.start
48
50
  result = deliver(effect)
49
51
  complete(effect, result)
50
52
  true
51
53
  rescue => error
52
54
  fail_effect(effect, error) if effect
53
55
  false
56
+ ensure
57
+ heartbeat&.stop
54
58
  end
55
59
 
56
60
  # @rbs () -> void
@@ -113,6 +117,7 @@ module SolidObjects
113
117
 
114
118
  # @rbs () -> Effect?
115
119
  def claim_next
120
+ EffectRecoveryCoordinator.new.recover_available
116
121
  database_adapter.transaction do
117
122
  now = database_adapter.database_now
118
123
  effect = database_adapter.lock_candidates(
@@ -178,17 +183,18 @@ module SolidObjects
178
183
  )
179
184
  result_message = nil
180
185
  database_adapter.transaction do
186
+ Instance.lock.find(effect.instance_id)
181
187
  locked_effect = Effect.lock.find(effect.id)
182
188
  verify_claim!(locked_effect)
183
189
  result_message = enqueue_result_message(
184
190
  effect: locked_effect,
185
191
  operation: locked_effect.success_operation,
186
192
  outcome: "success",
187
- arguments: {
188
- "effect_id" => locked_effect.effect_id,
189
- "arguments" => locked_effect.arguments,
190
- "result" => serialized_result
191
- }
193
+ arguments: EffectPayload.success(
194
+ effect_id: locked_effect.effect_id,
195
+ arguments: locked_effect.arguments,
196
+ result: serialized_result
197
+ )
192
198
  )
193
199
  locked_effect.update!(
194
200
  status: "completed",
@@ -213,24 +219,21 @@ module SolidObjects
213
219
  def fail_effect(effect, error)
214
220
  result_message = nil
215
221
  database_adapter.transaction do
222
+ Instance.lock.find(effect.instance_id)
216
223
  locked_effect = Effect.lock.find(effect.id)
217
224
  verify_claim!(locked_effect)
218
225
  dead = locked_effect.attempt_count >= locked_effect.max_attempts
219
- error_details = {
220
- "class" => error.class.name,
221
- "message" => error.message.to_s.byteslice(0, 8_192),
222
- "backtrace" => Array(error.backtrace).first(50)
223
- }
226
+ error_details = EffectPayload.error(error)
224
227
  if dead
225
228
  result_message = enqueue_result_message(
226
229
  effect: locked_effect,
227
230
  operation: locked_effect.failure_operation,
228
231
  outcome: "failure",
229
- arguments: {
230
- "effect_id" => locked_effect.effect_id,
231
- "arguments" => locked_effect.arguments,
232
- "error" => error_details
233
- }
232
+ arguments: EffectPayload.failure(
233
+ effect_id: locked_effect.effect_id,
234
+ arguments: locked_effect.arguments,
235
+ error: error_details
236
+ )
234
237
  )
235
238
  end
236
239
  locked_effect.update!(
@@ -0,0 +1,42 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module EffectPayload
5
+ class << self
6
+ # @rbs [Arguments] (effect_id: String, arguments: Arguments) -> effect_retired_payload[Arguments]
7
+ def retired(effect_id:, arguments:)
8
+ { "effect_id" => effect_id, "arguments" => arguments, "outcome" => EffectRecoveryOutcome::RETIRED }
9
+ end
10
+
11
+ # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_completed_recovery_payload[Arguments, Result]
12
+ def recovery_completed(effect_id:, arguments:, result:)
13
+ { "effect_id" => effect_id, "arguments" => arguments, "outcome" => EffectRecoveryOutcome::COMPLETED, "result" => result }
14
+ end
15
+
16
+ # @rbs (effect_id: String, outcome: effect_observation_outcome) -> effect_observation_payload
17
+ def recovery_observation(effect_id:, outcome:)
18
+ { "effect_id" => effect_id, "outcome" => outcome }
19
+ end
20
+
21
+ # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
22
+ def success(effect_id:, arguments:, result:)
23
+ { "effect_id" => effect_id, "arguments" => arguments, "result" => result }
24
+ end
25
+
26
+ # @rbs [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments]
27
+ def failure(effect_id:, arguments:, error:)
28
+ { "effect_id" => effect_id, "arguments" => arguments, "error" => error }
29
+ end
30
+
31
+ # @rbs (Exception) -> effect_error
32
+ def error(exception)
33
+ message = exception.message.to_s.byteslice(0, 8_192) # : String
34
+ {
35
+ "class" => exception.class.name,
36
+ "message" => message,
37
+ "backtrace" => Array(exception.backtrace).first(50)
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,143 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module EffectRecoveryOutcome
5
+ RETIRED = "retired".freeze #: "retired"
6
+ DEFERRED = "deferred".freeze #: "deferred"
7
+ PENDING = "pending".freeze #: "pending"
8
+ COMPLETED = "completed".freeze #: "completed"
9
+ DEAD = "dead".freeze #: "dead"
10
+ ALREADY_RETIRED = "already_retired".freeze #: "already_retired"
11
+ MISSING = "missing".freeze #: "missing"
12
+ end
13
+
14
+ class EffectRecoveryCoordinator
15
+ # @rbs (instance: Instance, intents: Array[Actor::EffectRecoveryIntent]) -> void
16
+ def check(instance:, intents:)
17
+ return if intents.empty?
18
+
19
+ effect_ids = intents.map(&:effect_id).uniq.sort
20
+ effects = Effect.where(instance_id: instance.id, effect_id: effect_ids).order(:effect_id).lock.to_a.index_by(&:effect_id)
21
+ recoveries = EffectRecovery.where(instance_id: instance.id, effect_id: effect_ids).order(:effect_id).lock.to_a.index_by(&:effect_id)
22
+ effect_ids.each do |effect_id|
23
+ recovery = recoveries[effect_id]
24
+ unless recovery&.recovery_operation && recovery.status_operation
25
+ raise InvalidPayload, "effect recovery requires an owned handle with on_recovery and on_status"
26
+ end
27
+ end
28
+ owner_ids = effects.values.filter_map(&:claimed_by).uniq.sort
29
+ owners = Process.where(id: owner_ids).order(:id).lock.to_a.index_by(&:id)
30
+ now = SolidObjects.database_adapter.database_clock_now
31
+ intents.each do |intent|
32
+ check_one(instance:, intent:, recovery: recoveries.fetch(intent.effect_id), effect: effects[intent.effect_id], owners:, now:)
33
+ end
34
+ end
35
+
36
+ # @rbs () -> void
37
+ def recover_available
38
+ recovery_candidates.each do |candidate|
39
+ notification = SolidObjects.database_adapter.transaction do
40
+ instance = Instance.lock.find_by(id: candidate.instance_id)
41
+ next unless instance
42
+
43
+ effect = Effect.lock.find_by(effect_id: candidate.effect_id, instance_id: instance.id)
44
+ recovery = EffectRecovery.lock.find_by(effect_id: candidate.effect_id, instance_id: instance.id)
45
+ next unless effect && recovery
46
+ next if recovery.retired_at || effect.status != "processing"
47
+
48
+ owner = Process.lock.find_by(id: effect.claimed_by) if effect.claimed_by
49
+ now = SolidObjects.database_adapter.database_clock_now
50
+ timeout = [ SolidObjects.configuration.process_alive_threshold, recovery.recovery_timeout || 0 ].max
51
+ next if owner && owner.last_heartbeat_at > now - timeout
52
+
53
+ retire(instance:, effect:, recovery:, now:)
54
+ end
55
+ Mailbox.new.announce(notification) if notification
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # @rbs () -> ActiveRecord::Relation[EffectRecovery]
62
+ def recovery_candidates
63
+ effects = Effect.table_name
64
+ owners = Process.table_name
65
+ bindings = EffectRecovery.table_name
66
+ heartbeat = case DatabaseAdapter.family(Record.connection)
67
+ when :postgresql then "EXTRACT(EPOCH FROM #{owners}.last_heartbeat_at)"
68
+ when :mysql then "UNIX_TIMESTAMP(#{owners}.last_heartbeat_at)"
69
+ else "CAST(STRFTIME('%s', #{owners}.last_heartbeat_at) AS REAL)"
70
+ end
71
+ now = SolidObjects.database_adapter.database_clock_now.to_f
72
+ threshold = SolidObjects.configuration.process_alive_threshold
73
+ EffectRecovery.joins("INNER JOIN #{effects} ON #{effects}.effect_id = #{bindings}.effect_id")
74
+ .joins("LEFT JOIN #{owners} ON #{owners}.id = #{effects}.claimed_by")
75
+ .where(retired_at: nil).where.not(recovery_operation: nil)
76
+ .where("#{effects}.status = ?", "processing")
77
+ .where("#{owners}.id IS NULL OR #{heartbeat} <= ? - CASE WHEN #{bindings}.recovery_timeout > ? THEN #{bindings}.recovery_timeout ELSE ? END", now, threshold, threshold)
78
+ .order(:effect_id).limit(SolidObjects.configuration.claim_scan_limit)
79
+ end
80
+
81
+ # @rbs (instance: Instance, intent: Actor::EffectRecoveryIntent, recovery: EffectRecovery, effect: Effect?, owners: Hash[String, Process], now: Time) -> void
82
+ def check_one(instance:, intent:, recovery:, effect:, owners:, now:)
83
+ key = "effect:#{intent.effect_id}:check:#{intent.request_id}"
84
+ return if Message.where(instance_id: instance.id, idempotency_key: key).exists?
85
+
86
+ outcome = observe(effect:, recovery:, owners:, now:)
87
+ retire(instance:, effect:, recovery:, now:) if outcome == EffectRecoveryOutcome::RETIRED
88
+ operation = recovery.status_operation
89
+ unless operation && SolidObjects.registry.fetch(instance.actor_type).definition.messages.key?(operation.to_sym)
90
+ raise UnknownMessage, "unknown effect status operation #{operation.inspect}"
91
+ end
92
+ arguments = case outcome
93
+ when EffectRecoveryOutcome::RETIRED
94
+ EffectPayload.retired(effect_id: intent.effect_id, arguments: effect.arguments)
95
+ when EffectRecoveryOutcome::COMPLETED
96
+ EffectPayload.recovery_completed(effect_id: intent.effect_id, arguments: effect.arguments, result: effect.result)
97
+ else
98
+ EffectPayload.recovery_observation(effect_id: intent.effect_id, outcome:)
99
+ end
100
+ Mailbox.new.enqueue_in_transaction(
101
+ reference: Reference.new(actor_type: instance.actor_type, actor_id: instance.actor_id),
102
+ operation:,
103
+ arguments:,
104
+ delivery_mode: "internal",
105
+ idempotency_key: key
106
+ )
107
+ end
108
+
109
+ # @rbs (effect: Effect?, recovery: EffectRecovery, owners: Hash[String, Process], now: Time) -> String
110
+ def observe(effect:, recovery:, owners:, now:)
111
+ return EffectRecoveryOutcome::ALREADY_RETIRED if recovery.retired_at
112
+ return EffectRecoveryOutcome::MISSING unless effect
113
+ return EffectRecoveryOutcome::PENDING if effect.status == "pending"
114
+ return EffectRecoveryOutcome::COMPLETED if effect.status == "completed"
115
+ return EffectRecoveryOutcome::DEAD if effect.status == "dead"
116
+
117
+ owner = owners[effect.claimed_by]
118
+ timeout = [ SolidObjects.configuration.process_alive_threshold, recovery.recovery_timeout || 0 ].max
119
+ return EffectRecoveryOutcome::DEFERRED if owner && owner.last_heartbeat_at > now - timeout
120
+
121
+ EffectRecoveryOutcome::RETIRED
122
+ end
123
+
124
+ # @rbs (instance: Instance, effect: Effect, recovery: EffectRecovery, now: Time) -> Message
125
+ def retire(instance:, effect:, recovery:, now:)
126
+ actor_class = SolidObjects.registry.fetch(instance.actor_type)
127
+ operation = recovery.recovery_operation
128
+ unless operation && actor_class.definition.messages.key?(operation.to_sym)
129
+ raise UnknownMessage, "unknown effect recovery operation #{operation.inspect}"
130
+ end
131
+
132
+ effect.update!(status: "completed", completed_at: now, claimed_by: nil, claimed_at: nil)
133
+ recovery.update!(retired_at: now)
134
+ Mailbox.new.enqueue_in_transaction(
135
+ reference: Reference.new(actor_type: instance.actor_type, actor_id: instance.actor_id),
136
+ operation:,
137
+ arguments: EffectPayload.retired(effect_id: effect.effect_id, arguments: effect.arguments),
138
+ delivery_mode: "internal",
139
+ idempotency_key: "effect:#{effect.effect_id}:recovery"
140
+ )
141
+ end
142
+ end
143
+ end
@@ -90,6 +90,7 @@ module SolidObjects
90
90
  max_bytes: SolidObjects.configuration.max_result_bytes
91
91
  )
92
92
  effect_intents = actor.drain_effect_intents
93
+ recovery_intents = actor.drain_effect_recovery_intents
93
94
  commit_action_intents = actor.drain_commit_action_intents
94
95
  reminder_intents = actor.drain_reminder_intents
95
96
  outbound_message_intents = actor.drain_outbound_message_intents
@@ -130,6 +131,7 @@ module SolidObjects
130
131
  observable_changes:,
131
132
  state_changed:
132
133
  )
134
+ EffectRecoveryCoordinator.new.check(instance:, intents: recovery_intents)
133
135
  claimed_message.destroy!
134
136
  end
135
137
 
@@ -241,10 +243,10 @@ module SolidObjects
241
243
  # @rbs (message: Message, instance: Instance, intents: Array[Actor::EffectIntent]) -> Array[Effect]
242
244
  def enqueue_effects(message:, instance:, intents:)
243
245
  intents.map do |intent|
244
- Effect.create!(
246
+ effect = Effect.create!(
245
247
  message:,
246
248
  instance:,
247
- effect_id: SecureRandom.uuid,
249
+ effect_id: intent.effect_id,
248
250
  name: intent.name,
249
251
  arguments: intent.arguments,
250
252
  success_operation: intent.success_operation,
@@ -253,6 +255,16 @@ module SolidObjects
253
255
  max_attempts: SolidObjects.configuration.max_attempts,
254
256
  available_at: SolidObjects.database_adapter.database_now
255
257
  )
258
+ if intent.recovery_operation || intent.status_operation
259
+ EffectRecovery.create!(
260
+ effect_id: intent.effect_id,
261
+ instance:,
262
+ recovery_operation: intent.recovery_operation,
263
+ status_operation: intent.status_operation,
264
+ recovery_timeout: intent.recovery_timeout
265
+ )
266
+ end
267
+ effect
256
268
  end
257
269
  end
258
270
 
@@ -0,0 +1,64 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ProcessHeartbeat
5
+ # @rbs @process_registry: ProcessRegistry
6
+ # @rbs @mutex: Thread::Mutex
7
+ # @rbs @condition: Thread::ConditionVariable
8
+ # @rbs @stopped: bool
9
+ # @rbs @thread: Thread?
10
+
11
+ # @rbs (process_registry: ProcessRegistry) -> void
12
+ def initialize(process_registry:)
13
+ @process_registry = process_registry
14
+ @mutex = Thread::Mutex.new
15
+ @condition = Thread::ConditionVariable.new
16
+ @stopped = false
17
+ @thread = nil
18
+ end
19
+
20
+ # @rbs () -> void
21
+ def start
22
+ @thread = Thread.new do
23
+ Thread.current.report_on_exception = false
24
+ loop do
25
+ break if wait_for_interval
26
+
27
+ Record.connection_pool.with_connection { process_registry.heartbeat }
28
+ rescue => error
29
+ report_failure(error)
30
+ end
31
+ end
32
+ end
33
+
34
+ # @rbs () -> void
35
+ def stop
36
+ mutex.synchronize do
37
+ @stopped = true
38
+ condition.broadcast
39
+ end
40
+ @thread&.join
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :process_registry, :mutex, :condition
46
+
47
+ # @rbs (Exception) -> void
48
+ def report_failure(error)
49
+ payload = { process_id: process_registry.process_record&.id, error_class: error.class.name }
50
+ SolidObjects.configuration.logger.warn({ event: "solid_objects.process.heartbeat_failed", **payload })
51
+ SolidObjects.instrument(:"process.heartbeat_failed", **payload)
52
+ rescue
53
+ nil
54
+ end
55
+
56
+ # @rbs () -> bool
57
+ def wait_for_interval
58
+ mutex.synchronize do
59
+ condition.wait(mutex, SolidObjects.configuration.process_heartbeat_interval) unless @stopped
60
+ @stopped
61
+ end
62
+ end
63
+ end
64
+ end
@@ -43,7 +43,7 @@ module SolidObjects
43
43
  Process.where(
44
44
  shutdown_state: "stopped",
45
45
  stopped_at: ...(now - SolidObjects.configuration.process_retention)
46
- )
46
+ ).where.not(id: Effect.where.not(claimed_by: nil).select(:claimed_by))
47
47
  end
48
48
  end
49
49
  end
@@ -10,6 +10,7 @@ module SolidObjects
10
10
  class << self
11
11
  # @rbs (?now: Time) -> Integer
12
12
  def cleanup_dead(now: SolidObjects.database_adapter.database_now)
13
+ EffectRecoveryCoordinator.new.recover_available
13
14
  stale_at = now - SolidObjects.configuration.process_alive_threshold
14
15
  dead_processes = Process
15
16
  .where.not(shutdown_state: "stopped")
@@ -32,12 +33,14 @@ module SolidObjects
32
33
  process_id: nil,
33
34
  activation_token: nil
34
35
  )
35
- Effect.where(claimed_by: process_record.id).update_all(
36
- status: "pending",
37
- claimed_by: nil,
38
- claimed_at: nil,
39
- available_at: now
40
- )
36
+ Effect.where(claimed_by: process_record.id)
37
+ .where.not(effect_id: EffectRecovery.where.not(recovery_operation: nil).select(:effect_id))
38
+ .update_all(
39
+ status: "pending",
40
+ claimed_by: nil,
41
+ claimed_at: nil,
42
+ available_at: now
43
+ )
41
44
  Reminder.where(claimed_by: process_record.id).update_all(
42
45
  claimed_by: nil,
43
46
  claimed_at: nil
@@ -35,6 +35,7 @@ module SolidObjects
35
35
  ClaimedMessage,
36
36
  ReadyMessage,
37
37
  Broadcast,
38
+ EffectRecovery,
38
39
  Effect,
39
40
  Reminder,
40
41
  Message,
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.14.6"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/solid_objects.rb CHANGED
@@ -55,6 +55,9 @@ require "solid_objects/wake_up_adapters/redis"
55
55
  require "solid_objects/wake_up_adapters"
56
56
  require "solid_objects/polling_backoff"
57
57
  require "solid_objects/effect_registry"
58
+ require "solid_objects/effect_payload"
59
+ require "solid_objects/effect_recovery_coordinator"
60
+ require "solid_objects/process_heartbeat"
58
61
  require "solid_objects/commit_action_registry"
59
62
  require "solid_objects/lease"
60
63
  require "solid_objects/lease_renewer"