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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +1 -0
- data/Rakefile +1 -1
- data/app/models/solid_objects/effect_recovery.rb +10 -0
- data/benchmark/support.rb +3 -0
- data/db/migrate/20260915000000_add_solid_objects_effect_recoveries.rb +17 -0
- data/docs/architecture.md +48 -0
- data/docs/development.md +110 -0
- data/docs/effect-recovery.md +203 -0
- data/docs/operations.md +6 -0
- data/docs/reminders.md +8 -0
- data/docs/roadmap.md +11 -0
- data/examples/at_least_once/boot.rb +3 -1
- data/lib/solid_objects/actor.rb +43 -4
- data/lib/solid_objects/actor_signatures.rb +80 -0
- data/lib/solid_objects/database_adapter.rb +13 -0
- data/lib/solid_objects/doctor.rb +1 -0
- data/lib/solid_objects/effect_executor.rb +18 -15
- data/lib/solid_objects/effect_payload.rb +42 -0
- data/lib/solid_objects/effect_recovery_coordinator.rb +143 -0
- data/lib/solid_objects/executor.rb +14 -2
- data/lib/solid_objects/process_heartbeat.rb +64 -0
- data/lib/solid_objects/process_pruner.rb +1 -1
- data/lib/solid_objects/process_registry.rb +9 -6
- data/lib/solid_objects/test_helper.rb +1 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +3 -0
- data/sig/generated/lib/solid_objects/actor.rbs +40 -6
- data/sig/generated/lib/solid_objects/actor_signatures.rbs +24 -0
- data/sig/generated/lib/solid_objects/database_adapter.rbs +3 -0
- data/sig/generated/lib/solid_objects/effect_payload.rbs +23 -0
- data/sig/generated/lib/solid_objects/effect_recovery_coordinator.rbs +41 -0
- data/sig/generated/lib/solid_objects/process_heartbeat.rbs +38 -0
- data/sig/generated/models/solid_objects/effect_recovery.rbs +6 -0
- data/sig/public/effect_payload.rbs +33 -0
- metadata +15 -2
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -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) ->
|
|
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
|
data/lib/solid_objects/doctor.rb
CHANGED
|
@@ -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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
|
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
|
|
@@ -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)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
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"
|