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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -0
  3. data/README.md +1 -1
  4. data/app/models/solid_objects/administration_event.rb +7 -0
  5. data/app/models/solid_objects/redrive.rb +7 -0
  6. data/benchmark/support.rb +2 -8
  7. data/db/migrate/20260922000000_add_solid_objects_administration_events.rb +26 -0
  8. data/db/migrate/20260922000001_add_solid_objects_redrives.rb +32 -0
  9. data/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb +10 -0
  10. data/docs/adr/0011-wake-up-strategy.md +11 -1
  11. data/docs/architecture.md +41 -5
  12. data/docs/dashboard.md +10 -0
  13. data/docs/fit.md +6 -0
  14. data/docs/operations.md +153 -7
  15. data/docs/realtime.md +32 -26
  16. data/docs/roadmap.md +60 -32
  17. data/examples/at_least_once/boot.rb +2 -8
  18. data/lib/solid_objects/activation.rb +13 -6
  19. data/lib/solid_objects/actor.rb +134 -8
  20. data/lib/solid_objects/actor_snapshot.rb +8 -5
  21. data/lib/solid_objects/administration_audit.rb +30 -0
  22. data/lib/solid_objects/client.rb +81 -0
  23. data/lib/solid_objects/configuration.rb +47 -2
  24. data/lib/solid_objects/context.rb +10 -4
  25. data/lib/solid_objects/dead_letter_manager.rb +42 -6
  26. data/lib/solid_objects/dead_letter_scope.rb +143 -0
  27. data/lib/solid_objects/dead_row.rb +15 -0
  28. data/lib/solid_objects/doctor.rb +24 -5
  29. data/lib/solid_objects/errors.rb +11 -0
  30. data/lib/solid_objects/executor.rb +41 -3
  31. data/lib/solid_objects/message_reference.rb +29 -9
  32. data/lib/solid_objects/outcome.rb +31 -0
  33. data/lib/solid_objects/process_registry.rb +3 -1
  34. data/lib/solid_objects/redrive_manager.rb +130 -0
  35. data/lib/solid_objects/redrive_runner.rb +60 -0
  36. data/lib/solid_objects/redrive_task.rb +14 -0
  37. data/lib/solid_objects/reference.rb +5 -0
  38. data/lib/solid_objects/schema_bootstrap.rb +27 -0
  39. data/lib/solid_objects/supervisor.rb +43 -0
  40. data/lib/solid_objects/test_helper.rb +2 -0
  41. data/lib/solid_objects/version.rb +1 -1
  42. data/lib/solid_objects/wake_up.rb +12 -0
  43. data/lib/solid_objects/wake_up_adapters/postgresql.rb +12 -0
  44. data/lib/solid_objects/wake_up_adapters/redis.rb +12 -0
  45. data/lib/solid_objects/wake_up_adapters.rb +206 -11
  46. data/lib/solid_objects/wake_up_capability.rb +15 -0
  47. data/lib/solid_objects.rb +46 -3
  48. data/sig/generated/lib/solid_objects/activation.rbs +7 -0
  49. data/sig/generated/lib/solid_objects/actor.rbs +93 -2
  50. data/sig/generated/lib/solid_objects/administration_audit.rbs +11 -0
  51. data/sig/generated/lib/solid_objects/client.rbs +12 -0
  52. data/sig/generated/lib/solid_objects/configuration.rbs +54 -28
  53. data/sig/generated/lib/solid_objects/context.rbs +11 -6
  54. data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +9 -0
  55. data/sig/generated/lib/solid_objects/dead_letter_scope.rbs +58 -0
  56. data/sig/generated/lib/solid_objects/dead_row.rbs +30 -0
  57. data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
  58. data/sig/generated/lib/solid_objects/errors.rbs +8 -0
  59. data/sig/generated/lib/solid_objects/executor.rbs +9 -0
  60. data/sig/generated/lib/solid_objects/message_reference.rbs +8 -0
  61. data/sig/generated/lib/solid_objects/outcome.rbs +52 -0
  62. data/sig/generated/lib/solid_objects/redrive_manager.rbs +43 -0
  63. data/sig/generated/lib/solid_objects/redrive_runner.rbs +22 -0
  64. data/sig/generated/lib/solid_objects/redrive_task.rbs +33 -0
  65. data/sig/generated/lib/solid_objects/reference.rbs +5 -2
  66. data/sig/generated/lib/solid_objects/schema_bootstrap.rbs +11 -0
  67. data/sig/generated/lib/solid_objects/supervisor.rbs +15 -0
  68. data/sig/generated/lib/solid_objects/wake_up.rbs +5 -0
  69. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +5 -0
  70. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +5 -0
  71. data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +75 -9
  72. data/sig/generated/lib/solid_objects/wake_up_capability.rbs +28 -0
  73. data/sig/generated/lib/solid_objects.rbs +14 -2
  74. data/sig/generated/models/solid_objects/administration_event.rbs +6 -0
  75. data/sig/generated/models/solid_objects/redrive.rbs +6 -0
  76. data/sig/public/reminder_payload.rbs +3 -0
  77. 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 = nil
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 (actor: Actor?, message: MessageContext?, authorization_context: untyped) { () -> untyped } -> untyped
32
- def with(actor:, message:, authorization_context: nil)
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] = Frame.new(actor:, message:, authorization_context:)
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
- dead_letter = DeadLetter.find(dead_letter_id)
15
- return MessageReference.from_message(Message.find(dead_letter.retried_message_id)) if dead_letter.retried_message_id
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
- original_message = dead_letter.message
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: original_message.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
@@ -0,0 +1,15 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ DeadRow = Data.define(
5
+ :id,
6
+ :kind,
7
+ :actor_type,
8
+ :actor_id,
9
+ :status,
10
+ :attempt_count,
11
+ :available_at,
12
+ :failed_at,
13
+ :error
14
+ )
15
+ end
@@ -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 request_id
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[id message_id instance_id effect_id status available_at],
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 -
@@ -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!(last_used_at: now)
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
- message = Message.find(id)
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
- return if SolidObjects.configuration.wake_up_adapter
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?