solid_objects 0.15.2 → 0.16.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 +151 -0
- data/README.md +1 -1
- data/app/models/solid_objects/administration_event.rb +7 -0
- data/app/models/solid_objects/redrive.rb +7 -0
- data/benchmark/support.rb +2 -8
- data/db/migrate/20260922000000_add_solid_objects_administration_events.rb +26 -0
- data/db/migrate/20260922000001_add_solid_objects_redrives.rb +32 -0
- data/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb +10 -0
- data/docs/adr/0011-wake-up-strategy.md +11 -1
- data/docs/architecture.md +41 -5
- data/docs/dashboard.md +10 -0
- data/docs/fit.md +6 -0
- data/docs/operations.md +153 -7
- data/docs/realtime.md +32 -26
- data/docs/roadmap.md +60 -32
- data/examples/at_least_once/boot.rb +2 -8
- data/lib/solid_objects/activation.rb +13 -6
- data/lib/solid_objects/actor.rb +134 -8
- data/lib/solid_objects/actor_snapshot.rb +8 -5
- data/lib/solid_objects/administration_audit.rb +30 -0
- data/lib/solid_objects/client.rb +81 -0
- data/lib/solid_objects/configuration.rb +47 -2
- data/lib/solid_objects/context.rb +10 -4
- data/lib/solid_objects/dead_letter_manager.rb +42 -6
- data/lib/solid_objects/dead_letter_scope.rb +143 -0
- data/lib/solid_objects/dead_row.rb +15 -0
- data/lib/solid_objects/doctor.rb +24 -5
- data/lib/solid_objects/errors.rb +11 -0
- data/lib/solid_objects/executor.rb +41 -3
- data/lib/solid_objects/message_reference.rb +29 -9
- data/lib/solid_objects/outcome.rb +31 -0
- data/lib/solid_objects/process_registry.rb +3 -1
- data/lib/solid_objects/redrive_manager.rb +130 -0
- data/lib/solid_objects/redrive_runner.rb +60 -0
- data/lib/solid_objects/redrive_task.rb +14 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/schema_bootstrap.rb +27 -0
- data/lib/solid_objects/supervisor.rb +43 -0
- data/lib/solid_objects/test_helper.rb +2 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up.rb +12 -0
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +12 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +12 -0
- data/lib/solid_objects/wake_up_adapters.rb +206 -11
- data/lib/solid_objects/wake_up_capability.rb +15 -0
- data/lib/solid_objects.rb +46 -3
- data/sig/generated/lib/solid_objects/activation.rbs +7 -0
- data/sig/generated/lib/solid_objects/actor.rbs +93 -2
- data/sig/generated/lib/solid_objects/administration_audit.rbs +11 -0
- data/sig/generated/lib/solid_objects/client.rbs +12 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +54 -28
- data/sig/generated/lib/solid_objects/context.rbs +11 -6
- data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +9 -0
- data/sig/generated/lib/solid_objects/dead_letter_scope.rbs +58 -0
- data/sig/generated/lib/solid_objects/dead_row.rbs +30 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
- data/sig/generated/lib/solid_objects/errors.rbs +8 -0
- data/sig/generated/lib/solid_objects/executor.rbs +9 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +8 -0
- data/sig/generated/lib/solid_objects/outcome.rbs +52 -0
- data/sig/generated/lib/solid_objects/redrive_manager.rbs +43 -0
- data/sig/generated/lib/solid_objects/redrive_runner.rbs +22 -0
- data/sig/generated/lib/solid_objects/redrive_task.rbs +33 -0
- data/sig/generated/lib/solid_objects/reference.rbs +5 -2
- data/sig/generated/lib/solid_objects/schema_bootstrap.rbs +11 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +15 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +75 -9
- data/sig/generated/lib/solid_objects/wake_up_capability.rbs +28 -0
- data/sig/generated/lib/solid_objects.rbs +14 -2
- data/sig/generated/models/solid_objects/administration_event.rbs +6 -0
- data/sig/generated/models/solid_objects/redrive.rbs +6 -0
- data/sig/public/reminder_payload.rbs +3 -0
- metadata +28 -2
|
@@ -31,6 +31,10 @@ module SolidObjects
|
|
|
31
31
|
# @rbs @instance_retention_by_actor_type: Hash[String, Numeric]
|
|
32
32
|
# @rbs @process_retention: Numeric
|
|
33
33
|
# @rbs @prune_batch_size: Integer
|
|
34
|
+
# @rbs @retained_idempotency_keys: Integer
|
|
35
|
+
# @rbs @retained_idempotency_keys_bytes: Integer
|
|
36
|
+
# @rbs @redrive_batch_size: Integer
|
|
37
|
+
# @rbs @redrive_batch_pause: Float
|
|
34
38
|
# @rbs @worker_count: Integer
|
|
35
39
|
# @rbs @effect_worker_count: Integer
|
|
36
40
|
# @rbs @broadcast_worker_count: Integer
|
|
@@ -49,6 +53,7 @@ module SolidObjects
|
|
|
49
53
|
# @rbs @authorize_subscription: Proc
|
|
50
54
|
# @rbs @authorize_administration: Proc
|
|
51
55
|
# @rbs @authorize_transmission: Proc
|
|
56
|
+
# @rbs @administration_identity: Proc
|
|
52
57
|
# @rbs @transmission_actor_type_resolver: Proc
|
|
53
58
|
|
|
54
59
|
attr_accessor :table_name_prefix,
|
|
@@ -80,6 +85,10 @@ module SolidObjects
|
|
|
80
85
|
:instance_retention_by_actor_type,
|
|
81
86
|
:process_retention,
|
|
82
87
|
:prune_batch_size,
|
|
88
|
+
:retained_idempotency_keys,
|
|
89
|
+
:retained_idempotency_keys_bytes,
|
|
90
|
+
:redrive_batch_size,
|
|
91
|
+
:redrive_batch_pause,
|
|
83
92
|
:worker_count,
|
|
84
93
|
:effect_worker_count,
|
|
85
94
|
:broadcast_worker_count,
|
|
@@ -98,6 +107,7 @@ module SolidObjects
|
|
|
98
107
|
:authorize_subscription,
|
|
99
108
|
:authorize_administration,
|
|
100
109
|
:authorize_transmission,
|
|
110
|
+
:administration_identity,
|
|
101
111
|
:transmission_actor_type_resolver
|
|
102
112
|
|
|
103
113
|
# @rbs @additional_components: Array[untyped]
|
|
@@ -134,6 +144,10 @@ module SolidObjects
|
|
|
134
144
|
@instance_retention_by_actor_type = {}
|
|
135
145
|
@process_retention = 7.days
|
|
136
146
|
@prune_batch_size = 1_000
|
|
147
|
+
@retained_idempotency_keys = 64
|
|
148
|
+
@retained_idempotency_keys_bytes = 16.kilobytes
|
|
149
|
+
@redrive_batch_size = 100
|
|
150
|
+
@redrive_batch_pause = 0.05
|
|
137
151
|
@worker_count = 1
|
|
138
152
|
@effect_worker_count = 1
|
|
139
153
|
@broadcast_worker_count = 1
|
|
@@ -141,7 +155,7 @@ module SolidObjects
|
|
|
141
155
|
@connects_to = nil
|
|
142
156
|
@stream_signing_secret = nil
|
|
143
157
|
@broadcast_adapter = nil
|
|
144
|
-
@wake_up_adapter =
|
|
158
|
+
@wake_up_adapter = :automatic
|
|
145
159
|
@component_path_resolver = nil
|
|
146
160
|
@component_authorization_context = ->(controller:) { controller }
|
|
147
161
|
@payload_authorization_context = ->(connection:) { connection }
|
|
@@ -156,6 +170,7 @@ module SolidObjects
|
|
|
156
170
|
@authorize_subscription = ->(**) { false }
|
|
157
171
|
@authorize_administration = ->(**) { false }
|
|
158
172
|
@authorize_transmission = ->(**) { false }
|
|
173
|
+
@administration_identity = ->(authorization_context) { authorization_context.to_s }
|
|
159
174
|
@transmission_actor_type_resolver = ->(actor_type) { actor_type }
|
|
160
175
|
@additional_components = []
|
|
161
176
|
end
|
|
@@ -226,6 +241,9 @@ module SolidObjects
|
|
|
226
241
|
positive_values.each do |name, value|
|
|
227
242
|
raise ArgumentError, "#{name} must be positive" unless value.positive?
|
|
228
243
|
end
|
|
244
|
+
if redrive_batch_pause.negative?
|
|
245
|
+
raise ArgumentError, "redrive_batch_pause must not be negative"
|
|
246
|
+
end
|
|
229
247
|
if warn_state_bytes > max_state_bytes
|
|
230
248
|
raise ArgumentError, "warn_state_bytes must not exceed max_state_bytes"
|
|
231
249
|
end
|
|
@@ -237,6 +255,7 @@ module SolidObjects
|
|
|
237
255
|
raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
|
|
238
256
|
raise ArgumentError, "instance retention must be positive" unless retention.positive?
|
|
239
257
|
end
|
|
258
|
+
validate_wake_up_adapter!
|
|
240
259
|
unless component_path_resolver.nil? || component_path_resolver.respond_to?(:call)
|
|
241
260
|
raise ArgumentError, "component_path_resolver must respond to call"
|
|
242
261
|
end
|
|
@@ -246,12 +265,35 @@ module SolidObjects
|
|
|
246
265
|
unless payload_authorization_context.respond_to?(:call)
|
|
247
266
|
raise ArgumentError, "payload_authorization_context must respond to call"
|
|
248
267
|
end
|
|
268
|
+
unless administration_identity.respond_to?(:call)
|
|
269
|
+
raise ArgumentError, "administration_identity must respond to call"
|
|
270
|
+
end
|
|
249
271
|
|
|
250
272
|
self
|
|
251
273
|
end
|
|
252
274
|
|
|
253
275
|
private
|
|
254
276
|
|
|
277
|
+
# @rbs () -> void
|
|
278
|
+
def validate_wake_up_adapter!
|
|
279
|
+
return if wake_up_adapter.nil?
|
|
280
|
+
return validate_wake_up_object! unless wake_up_adapter.is_a?(Symbol)
|
|
281
|
+
return if WakeUpAdapters::NAMES.include?(wake_up_adapter)
|
|
282
|
+
|
|
283
|
+
raise ArgumentError,
|
|
284
|
+
"unknown wake_up_adapter #{wake_up_adapter.inspect}, " \
|
|
285
|
+
"expected one of #{WakeUpAdapters::NAMES.join(", ")} or an adapter"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# @rbs () -> void
|
|
289
|
+
def validate_wake_up_object!
|
|
290
|
+
%i[signal wait].each do |method_name|
|
|
291
|
+
next if wake_up_adapter.respond_to?(method_name)
|
|
292
|
+
|
|
293
|
+
raise ArgumentError, "wake_up_adapter must respond to #{method_name}"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
255
297
|
# @rbs () -> Hash[Symbol, Numeric]
|
|
256
298
|
def positive_values
|
|
257
299
|
{
|
|
@@ -274,7 +316,10 @@ module SolidObjects
|
|
|
274
316
|
shutdown_timeout:,
|
|
275
317
|
message_retention:,
|
|
276
318
|
process_retention:,
|
|
277
|
-
prune_batch_size
|
|
319
|
+
prune_batch_size:,
|
|
320
|
+
retained_idempotency_keys:,
|
|
321
|
+
retained_idempotency_keys_bytes:,
|
|
322
|
+
redrive_batch_size:
|
|
278
323
|
}
|
|
279
324
|
end
|
|
280
325
|
|
|
@@ -5,7 +5,7 @@ module SolidObjects
|
|
|
5
5
|
|
|
6
6
|
module Context
|
|
7
7
|
STORAGE_KEY = :solid_objects_context
|
|
8
|
-
Frame = Data.define(:actor, :message, :authorization_context)
|
|
8
|
+
Frame = Data.define(:actor, :message, :authorization_context, :instance_id)
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
11
|
# @rbs () -> Frame?
|
|
@@ -28,10 +28,16 @@ module SolidObjects
|
|
|
28
28
|
current&.authorization_context
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
# @rbs (
|
|
32
|
-
def
|
|
31
|
+
# @rbs () -> Integer?
|
|
32
|
+
def current_instance_id
|
|
33
|
+
current&.instance_id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @rbs (actor: Actor?, message: MessageContext?, ?authorization_context: untyped, ?instance_id: Integer?) { () -> untyped } -> untyped
|
|
37
|
+
def with(actor:, message:, authorization_context: nil, instance_id: nil)
|
|
33
38
|
previous = current
|
|
34
|
-
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] =
|
|
39
|
+
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] =
|
|
40
|
+
Frame.new(actor:, message:, authorization_context:, instance_id:)
|
|
35
41
|
yield
|
|
36
42
|
ensure
|
|
37
43
|
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = previous
|
|
@@ -11,10 +11,48 @@ module SolidObjects
|
|
|
11
11
|
# @rbs (Integer, ?authorization_context: untyped) -> MessageReference
|
|
12
12
|
def retry(dead_letter_id, authorization_context: nil)
|
|
13
13
|
authorize!(:retry, authorization_context:, dead_letter_id:)
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
actor = AdministrationAudit.identity(authorization_context)
|
|
15
|
+
SolidObjects.database_adapter.transaction do
|
|
16
|
+
dead_letter = DeadLetter.find(dead_letter_id)
|
|
17
|
+
reference = if dead_letter.retried_message_id
|
|
18
|
+
MessageReference.from_message(Message.find(dead_letter.retried_message_id))
|
|
19
|
+
else
|
|
20
|
+
enqueue_retry(dead_letter)
|
|
21
|
+
end
|
|
22
|
+
AdministrationAudit.record(
|
|
23
|
+
action: "dead_letter.retry",
|
|
24
|
+
kind: "message",
|
|
25
|
+
subject_id: dead_letter.id,
|
|
26
|
+
actor:
|
|
27
|
+
)
|
|
28
|
+
reference
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @rbs () -> DeadLetterScope
|
|
33
|
+
def effects
|
|
34
|
+
@effects ||= DeadLetterScope.new(
|
|
35
|
+
model: Effect,
|
|
36
|
+
resource: "effect_dead_letters",
|
|
37
|
+
identifier: :effect_id,
|
|
38
|
+
kind: "effect"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
16
41
|
|
|
17
|
-
|
|
42
|
+
# @rbs () -> DeadLetterScope
|
|
43
|
+
def broadcasts
|
|
44
|
+
@broadcasts ||= DeadLetterScope.new(
|
|
45
|
+
model: Broadcast,
|
|
46
|
+
resource: "broadcast_dead_letters",
|
|
47
|
+
identifier: :broadcast_id,
|
|
48
|
+
kind: "broadcast"
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# @rbs (DeadLetter) -> MessageReference
|
|
55
|
+
def enqueue_retry(dead_letter)
|
|
18
56
|
message_reference = Mailbox.new.enqueue(
|
|
19
57
|
reference: Reference.new(
|
|
20
58
|
actor_type: dead_letter.actor_type,
|
|
@@ -22,15 +60,13 @@ module SolidObjects
|
|
|
22
60
|
),
|
|
23
61
|
operation: dead_letter.operation,
|
|
24
62
|
arguments: dead_letter.arguments,
|
|
25
|
-
delivery_mode:
|
|
63
|
+
delivery_mode: dead_letter.message.delivery_mode,
|
|
26
64
|
idempotency_key: "dead-letter:#{dead_letter.id}"
|
|
27
65
|
)
|
|
28
66
|
dead_letter.update!(retried_message_id: message_reference.id)
|
|
29
67
|
message_reference
|
|
30
68
|
end
|
|
31
69
|
|
|
32
|
-
private
|
|
33
|
-
|
|
34
70
|
# @rbs (Symbol, authorization_context: untyped, ?dead_letter_id: Integer?) -> void
|
|
35
71
|
def authorize!(action, authorization_context:, dead_letter_id: nil)
|
|
36
72
|
authorized = SolidObjects.configuration.authorize_administration.call(
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class DeadLetterScope
|
|
5
|
+
DEAD = "dead"
|
|
6
|
+
PENDING = "pending"
|
|
7
|
+
|
|
8
|
+
# @rbs @model: untyped
|
|
9
|
+
# @rbs @resource: String
|
|
10
|
+
# @rbs @identifier: Symbol
|
|
11
|
+
|
|
12
|
+
attr_reader :resource, :kind
|
|
13
|
+
|
|
14
|
+
# @rbs (model: untyped, resource: String, identifier: Symbol, kind: String) -> void
|
|
15
|
+
def initialize(model:, resource:, identifier:, kind:)
|
|
16
|
+
@model = model
|
|
17
|
+
@resource = resource
|
|
18
|
+
@identifier = identifier
|
|
19
|
+
@kind = kind
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs (String) -> DeadLetterScope
|
|
23
|
+
def self.for_kind(kind)
|
|
24
|
+
return SolidObjects.dead_letters.effects if kind == "effect"
|
|
25
|
+
return SolidObjects.dead_letters.broadcasts if kind == "broadcast"
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, "unknown dead letter kind #{kind.inspect}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @rbs (?authorization_context: untyped) -> Array[DeadRow]
|
|
31
|
+
def all(authorization_context: nil)
|
|
32
|
+
authorize!(:inspect, authorization_context:)
|
|
33
|
+
dead.includes(:instance).order(updated_at: :desc, id: :desc).map do |row|
|
|
34
|
+
DeadRow.new(
|
|
35
|
+
id: row.public_send(identifier),
|
|
36
|
+
kind:,
|
|
37
|
+
actor_type: row.instance.actor_type,
|
|
38
|
+
actor_id: row.instance.actor_id,
|
|
39
|
+
status: row.status,
|
|
40
|
+
attempt_count: row.attempt_count,
|
|
41
|
+
available_at: row.available_at,
|
|
42
|
+
failed_at: row.updated_at,
|
|
43
|
+
error: row.error
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @rbs (String, ?authorization_context: untyped) -> untyped
|
|
49
|
+
def retry(identifier_value, authorization_context: nil)
|
|
50
|
+
authorize!(:retry, authorization_context:, resource_id: identifier_value)
|
|
51
|
+
actor = AdministrationAudit.identity(authorization_context)
|
|
52
|
+
SolidObjects.database_adapter.transaction do
|
|
53
|
+
row = model.find_by!(identifier => identifier_value)
|
|
54
|
+
revive(row) if row.status == DEAD
|
|
55
|
+
AdministrationAudit.record(
|
|
56
|
+
action: "dead_letter.retry",
|
|
57
|
+
kind: kind,
|
|
58
|
+
subject_id: identifier_value,
|
|
59
|
+
actor:
|
|
60
|
+
)
|
|
61
|
+
row
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @rbs (?actor_type: String?, ?failed_after: untyped, ?limit: Integer?, ?authorization_context: untyped) -> RedriveTask
|
|
66
|
+
def redrive(actor_type: nil, failed_after: nil, limit: nil, authorization_context: nil)
|
|
67
|
+
if failed_after && !failed_after.respond_to?(:utc)
|
|
68
|
+
raise ArgumentError, "failed_after must be a time"
|
|
69
|
+
end
|
|
70
|
+
if limit && !(limit.is_a?(Integer) && limit.positive?)
|
|
71
|
+
raise ArgumentError, "limit must be a positive integer"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
SolidObjects.redrives.start(
|
|
75
|
+
scope: self,
|
|
76
|
+
filters: {
|
|
77
|
+
"actor_type" => actor_type,
|
|
78
|
+
"failed_after" => failed_after&.utc&.iso8601(6),
|
|
79
|
+
"limit" => limit
|
|
80
|
+
},
|
|
81
|
+
authorization_context:
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @rbs () -> ActiveRecord::Relation[untyped]
|
|
86
|
+
def dead
|
|
87
|
+
model.where(status: DEAD)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @rbs (Hash[String, untyped], ?dead_before: untyped) -> ActiveRecord::Relation[untyped]
|
|
91
|
+
def matching(filters, dead_before: nil)
|
|
92
|
+
relation = dead
|
|
93
|
+
actor_type = filters["actor_type"]
|
|
94
|
+
failed_after = filters["failed_after"]
|
|
95
|
+
relation = relation.joins(:instance).where(Instance.table_name => { actor_type: }) if actor_type
|
|
96
|
+
relation = relation.where(updated_at: Time.parse(failed_after)..) if failed_after
|
|
97
|
+
relation = relation.where(updated_at: ..dead_before) if dead_before
|
|
98
|
+
relation
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @rbs (Array[untyped]) -> Integer
|
|
102
|
+
def revive_all(identifiers)
|
|
103
|
+
model.where(id: identifiers, status: DEAD).update_all(revival_attributes)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @rbs (untyped) -> Integer
|
|
107
|
+
def revive(row)
|
|
108
|
+
model.where(id: row.id, status: DEAD).update_all(revival_attributes).tap do
|
|
109
|
+
row.reload
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @rbs (Symbol, authorization_context: untyped, ?resource_id: String?) -> void
|
|
114
|
+
def authorize!(action, authorization_context:, resource_id: nil)
|
|
115
|
+
authorized = SolidObjects.configuration.authorize_administration.call(
|
|
116
|
+
action: action.to_s,
|
|
117
|
+
resource: resource,
|
|
118
|
+
resource_id: resource_id,
|
|
119
|
+
authorization_context:
|
|
120
|
+
)
|
|
121
|
+
return if authorized
|
|
122
|
+
|
|
123
|
+
raise Unauthorized, "actor administration is not authorized"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
attr_reader :model, :identifier
|
|
129
|
+
|
|
130
|
+
# @rbs () -> Hash[Symbol, untyped]
|
|
131
|
+
def revival_attributes
|
|
132
|
+
now = SolidObjects.database_adapter.database_now
|
|
133
|
+
{
|
|
134
|
+
status: PENDING,
|
|
135
|
+
attempt_count: 0,
|
|
136
|
+
available_at: now,
|
|
137
|
+
claimed_by: nil,
|
|
138
|
+
claimed_at: nil,
|
|
139
|
+
updated_at: now
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/solid_objects/doctor.rb
CHANGED
|
@@ -65,11 +65,11 @@ module SolidObjects
|
|
|
65
65
|
instances: %w[
|
|
66
66
|
id actor_type actor_id state state_version next_message_sequence
|
|
67
67
|
activation_owner_id activation_token activation_expires_at
|
|
68
|
-
activation_generation
|
|
68
|
+
activation_generation state_revision completed_idempotency_keys
|
|
69
69
|
],
|
|
70
70
|
messages: %w[
|
|
71
|
-
id instance_id delivery_mode arguments sequence attempt_count
|
|
72
|
-
result error rejection completed_at rejected_at
|
|
71
|
+
id instance_id operation delivery_mode arguments sequence attempt_count
|
|
72
|
+
request_id result error rejection completed_at rejected_at
|
|
73
73
|
],
|
|
74
74
|
ready_messages: %w[id message_id instance_id sequence available_at],
|
|
75
75
|
claimed_messages: %w[
|
|
@@ -77,10 +77,15 @@ module SolidObjects
|
|
|
77
77
|
activation_generation claimed_at
|
|
78
78
|
],
|
|
79
79
|
reminders: %w[id instance_id operation next_run_at status],
|
|
80
|
-
effects: %w[
|
|
80
|
+
effects: %w[
|
|
81
|
+
id message_id instance_id effect_id status available_at
|
|
82
|
+
success_operation failure_operation
|
|
83
|
+
],
|
|
81
84
|
effect_recoveries: %w[effect_id instance_id recovery_operation status_operation recovery_timeout retired_at],
|
|
82
85
|
broadcasts: %w[id message_id instance_id broadcast_id status available_at],
|
|
83
|
-
dead_letters: %w[id message_id instance_id actor_type actor_id attempts]
|
|
86
|
+
dead_letters: %w[id message_id instance_id actor_type actor_id operation attempts],
|
|
87
|
+
administration_events: %w[id action kind subject_id actor occurred_at],
|
|
88
|
+
redrives: %w[id kind filters status active_scope moved move_limit started_at finished_at]
|
|
84
89
|
}.freeze
|
|
85
90
|
|
|
86
91
|
class ProbeActor < Actor
|
|
@@ -113,6 +118,7 @@ module SolidObjects
|
|
|
113
118
|
schema_check,
|
|
114
119
|
check_authorization,
|
|
115
120
|
check_database_server,
|
|
121
|
+
check_wake_up,
|
|
116
122
|
schema_check.failed? ? skipped_runtime : check_runtime,
|
|
117
123
|
ready_for_round_trip?(configuration_check, schema_check) ?
|
|
118
124
|
check_sync_round_trip :
|
|
@@ -208,6 +214,19 @@ module SolidObjects
|
|
|
208
214
|
warn_check(:database_server, "#{error.class}: #{error.message}")
|
|
209
215
|
end
|
|
210
216
|
|
|
217
|
+
# @rbs () -> Check
|
|
218
|
+
def check_wake_up
|
|
219
|
+
capability = SolidObjects.wake_up.capability
|
|
220
|
+
floor = capability.measured_floor_ms
|
|
221
|
+
summary = "#{capability.adapter}: #{capability.reason}"
|
|
222
|
+
summary += ", floor #{floor} ms" if floor
|
|
223
|
+
return pass(:wake_up, summary) if capability.crosses_processes
|
|
224
|
+
|
|
225
|
+
warn_check(:wake_up, "#{summary}; a commit in one process cannot wake another")
|
|
226
|
+
rescue => error
|
|
227
|
+
warn_check(:wake_up, "#{error.class}: #{error.message}")
|
|
228
|
+
end
|
|
229
|
+
|
|
211
230
|
# @rbs () -> Check
|
|
212
231
|
def check_runtime
|
|
213
232
|
cutoff = SolidObjects.database_adapter.database_now -
|
data/lib/solid_objects/errors.rb
CHANGED
|
@@ -263,4 +263,15 @@ module SolidObjects
|
|
|
263
263
|
|
|
264
264
|
class ActorCallCycle < Error
|
|
265
265
|
end
|
|
266
|
+
|
|
267
|
+
class MessagePruned < Error
|
|
268
|
+
# @rbs @idempotency_key: String
|
|
269
|
+
attr_reader :idempotency_key
|
|
270
|
+
|
|
271
|
+
# @rbs (String) -> void
|
|
272
|
+
def initialize(idempotency_key)
|
|
273
|
+
@idempotency_key = idempotency_key
|
|
274
|
+
super("the message for idempotency key #{idempotency_key.inspect} was pruned")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
266
277
|
end
|
|
@@ -111,7 +111,8 @@ module SolidObjects
|
|
|
111
111
|
state: state_after.value,
|
|
112
112
|
state_version: actor.class.state_version,
|
|
113
113
|
state_revision: locked_message.sequence,
|
|
114
|
-
last_used_at: SolidObjects.database_adapter.database_now
|
|
114
|
+
last_used_at: SolidObjects.database_adapter.database_now,
|
|
115
|
+
completed_idempotency_keys: remembered_keys(instance, locked_message)
|
|
115
116
|
)
|
|
116
117
|
locked_message.update!(
|
|
117
118
|
result: serialized_result,
|
|
@@ -280,6 +281,8 @@ module SolidObjects
|
|
|
280
281
|
# @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
|
|
281
282
|
def schedule_reminders(instance, intents)
|
|
282
283
|
intents.filter_map do |intent|
|
|
284
|
+
next cancel_reminder(instance, intent) unless intent.is_a?(Actor::ReminderIntent)
|
|
285
|
+
|
|
283
286
|
reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
|
|
284
287
|
previous_run_at = reminder.next_run_at
|
|
285
288
|
reminder.assign_attributes(
|
|
@@ -300,6 +303,21 @@ module SolidObjects
|
|
|
300
303
|
end
|
|
301
304
|
end
|
|
302
305
|
|
|
306
|
+
# A cancel reports nothing, because a reminder that no longer exists did not
|
|
307
|
+
# move. Deleting the row rather than marking it keeps a later schedule of the
|
|
308
|
+
# same name free of a tombstone.
|
|
309
|
+
# @rbs (Instance, Actor::UnscheduleIntent | Actor::UnscheduleAllIntent) -> nil
|
|
310
|
+
def cancel_reminder(instance, intent)
|
|
311
|
+
scope = Reminder.where(instance:)
|
|
312
|
+
scope = if intent.is_a?(Actor::UnscheduleAllIntent)
|
|
313
|
+
scope.where(operation: intent.operation)
|
|
314
|
+
else
|
|
315
|
+
scope.where(name: intent.name)
|
|
316
|
+
end
|
|
317
|
+
scope.delete_all
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
|
|
303
321
|
# Arguments are omitted deliberately: a reminder carries application data
|
|
304
322
|
# and this event exists to be logged.
|
|
305
323
|
# @rbs (Reminder, Time?) -> Hash[Symbol, untyped]?
|
|
@@ -377,7 +395,7 @@ module SolidObjects
|
|
|
377
395
|
error_details = serialized_error(error)
|
|
378
396
|
dead = false
|
|
379
397
|
|
|
380
|
-
activation.lease.fenced_transaction do
|
|
398
|
+
activation.lease.fenced_transaction do |instance|
|
|
381
399
|
claimed_message = matching_claim!
|
|
382
400
|
locked_message = Message.lock.find(message.id)
|
|
383
401
|
now = SolidObjects.database_adapter.database_now
|
|
@@ -387,6 +405,7 @@ module SolidObjects
|
|
|
387
405
|
if error.is_a?(NonRetryableError) ||
|
|
388
406
|
locked_message.attempt_count >= locked_message.max_attempts
|
|
389
407
|
create_dead_letter(message: locked_message, error_details:, now:)
|
|
408
|
+
instance.update!(completed_idempotency_keys: remembered_keys(instance, locked_message)) if locked_message.idempotency_key
|
|
390
409
|
dead = true
|
|
391
410
|
else
|
|
392
411
|
ReadyMessage.create!(
|
|
@@ -422,7 +441,10 @@ module SolidObjects
|
|
|
422
441
|
claimed_message = matching_claim!
|
|
423
442
|
locked_message = Message.lock.find(message.id)
|
|
424
443
|
now = SolidObjects.database_adapter.database_now
|
|
425
|
-
instance.update!(
|
|
444
|
+
instance.update!(
|
|
445
|
+
last_used_at: now,
|
|
446
|
+
completed_idempotency_keys: remembered_keys(instance, locked_message)
|
|
447
|
+
)
|
|
426
448
|
locked_message.update!(
|
|
427
449
|
result: nil,
|
|
428
450
|
rejection: rejection_data,
|
|
@@ -453,6 +475,22 @@ module SolidObjects
|
|
|
453
475
|
raise LostActivation, "message claim changed"
|
|
454
476
|
end
|
|
455
477
|
|
|
478
|
+
# @rbs (Instance, Message) -> Array[Hash[String, untyped]]
|
|
479
|
+
def remembered_keys(instance, message)
|
|
480
|
+
remembered = Array(instance.completed_idempotency_keys)
|
|
481
|
+
key = message.idempotency_key
|
|
482
|
+
return remembered unless key
|
|
483
|
+
|
|
484
|
+
entry = { "key" => key, "operation" => message.operation, "arguments" => message.arguments }
|
|
485
|
+
return remembered if remembered.last == entry
|
|
486
|
+
|
|
487
|
+
kept = (remembered.reject { |value| value["key"] == key } + [ entry ])
|
|
488
|
+
.last(SolidObjects.configuration.retained_idempotency_keys)
|
|
489
|
+
limit = SolidObjects.configuration.retained_idempotency_keys_bytes
|
|
490
|
+
kept.shift while kept.any? && kept.to_json.bytesize > limit
|
|
491
|
+
kept
|
|
492
|
+
end
|
|
493
|
+
|
|
456
494
|
# @rbs (Exception) -> Hash[String, untyped]
|
|
457
495
|
def serialized_error(error)
|
|
458
496
|
Serialization.dump(
|
|
@@ -35,19 +35,26 @@ module SolidObjects
|
|
|
35
35
|
|
|
36
36
|
# @rbs () -> String
|
|
37
37
|
def status
|
|
38
|
-
|
|
39
|
-
return "rejected" if message.rejected?
|
|
40
|
-
return "completed" if message.completed?
|
|
41
|
-
return "dead" if message.dead?
|
|
42
|
-
return "claimed" if message.claimed?
|
|
43
|
-
return "ready" if message.ready?
|
|
44
|
-
|
|
45
|
-
"unknown"
|
|
38
|
+
Message.uncached { status_of(Message.find(id)) }
|
|
46
39
|
end
|
|
47
40
|
|
|
48
41
|
# @rbs () -> untyped
|
|
49
42
|
def result
|
|
50
|
-
Message.find(id).result
|
|
43
|
+
Message.uncached { Message.find(id).result }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @rbs () -> Outcome
|
|
47
|
+
def outcome
|
|
48
|
+
Message.uncached do
|
|
49
|
+
message = Message.find(id)
|
|
50
|
+
Outcome.new(
|
|
51
|
+
status: status_of(message),
|
|
52
|
+
result: Serialization.readonly_copy(message.result),
|
|
53
|
+
error: ErrorRecord.from(message.error),
|
|
54
|
+
rejection: RejectionRecord.from(message.rejection),
|
|
55
|
+
attempts: message.attempt_count
|
|
56
|
+
)
|
|
57
|
+
end
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
# @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped
|
|
@@ -58,5 +65,18 @@ module SolidObjects
|
|
|
58
65
|
authorization_context:
|
|
59
66
|
)
|
|
60
67
|
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @rbs (Message) -> String
|
|
72
|
+
def status_of(message)
|
|
73
|
+
return "rejected" if message.rejected?
|
|
74
|
+
return "completed" if message.completed?
|
|
75
|
+
return "dead" if message.dead?
|
|
76
|
+
return "claimed" if message.claimed?
|
|
77
|
+
return "ready" if message.ready?
|
|
78
|
+
|
|
79
|
+
"unknown"
|
|
80
|
+
end
|
|
61
81
|
end
|
|
62
82
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
ErrorRecord = Data.define(:class_name, :message, :backtrace) do
|
|
5
|
+
# @rbs (Hash[String, untyped]?) -> ErrorRecord?
|
|
6
|
+
def self.from(error)
|
|
7
|
+
return nil if error.blank?
|
|
8
|
+
|
|
9
|
+
new(
|
|
10
|
+
class_name: error["class"],
|
|
11
|
+
message: error["message"],
|
|
12
|
+
backtrace: Serialization.readonly_copy(Array(error["backtrace"]))
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
RejectionRecord = Data.define(:code, :message, :details) do
|
|
18
|
+
# @rbs (Hash[String, untyped]?) -> RejectionRecord?
|
|
19
|
+
def self.from(rejection)
|
|
20
|
+
return nil if rejection.blank?
|
|
21
|
+
|
|
22
|
+
new(
|
|
23
|
+
code: rejection["code"],
|
|
24
|
+
message: rejection["message"],
|
|
25
|
+
details: Serialization.readonly_copy(rejection["details"])
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Outcome = Data.define(:status, :result, :error, :rejection, :attempts)
|
|
31
|
+
end
|
|
@@ -61,7 +61,9 @@ module SolidObjects
|
|
|
61
61
|
|
|
62
62
|
# @rbs () -> void
|
|
63
63
|
def warn_if_polling_is_only_cross_process_wake_up
|
|
64
|
-
|
|
64
|
+
wake_up = SolidObjects.wake_up
|
|
65
|
+
return unless wake_up.respond_to?(:capability)
|
|
66
|
+
return if wake_up.capability.crosses_processes
|
|
65
67
|
|
|
66
68
|
polling_warning_mutex.synchronize do
|
|
67
69
|
return if polling_warning_emitted?
|