Skip to content

Commit acc7e03

Browse files
cardmagicclaude
andcommitted
feat: find a message by request id or idempotency key
A caller that lost its `MessageReference` had no way to rebuild one, so a web request that timed out, a process that restarted, or a retry that arrived on another node could not read what the first attempt produced. The enqueue path already deduplicates by idempotency key, so the row could be found. Nothing public could find it. `find_by` is shaped like `ActiveRecord::Base.find_by`, and the receiver carries the scope the key needs: SolidObjects.client.find_by(request_id: id) CartActor.ref("alice").find_by(idempotency_key: key) A request id is unique across the table, so the client answers it. An idempotency key is unique per instance, so a reference answers it. A caller cannot write a lookup the indexes cannot serve. Every lookup runs the same authorization hook the original call ran, against the stored operation and arguments, because a request id is not a capability. An absent row, an actor this process no longer registers, and a caller the policy refuses all return `nil`, so the lookup cannot be used to ask whether a request id exists. `MessageReference#outcome` reports the status, the result, the persisted error, the rejection, and the attempt count, so a terminal failure answers as well as a success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d208789 commit acc7e03

10 files changed

Lines changed: 413 additions & 2 deletions

File tree

‎lib/solid_objects.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
require "solid_objects/application_write_guard"
2727
require "solid_objects/actor"
2828
require "solid_objects/reference"
29+
require "solid_objects/outcome"
2930
require "solid_objects/message_reference"
3031
require "solid_objects/administration_audit"
3132
require "solid_objects/redrive_task"

‎lib/solid_objects/client.rb‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ def wait(message_reference, timeout:, authorization_context: nil)
9494
raise SyncDiagnostics.new.database_contention_for(message_reference, timeout:)
9595
end
9696

97+
# @rbs (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference?
98+
def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization_context: nil)
99+
unless [ request_id, idempotency_key ].compact.one?
100+
raise ArgumentError, "find_by expects exactly one of request_id: or idempotency_key:"
101+
end
102+
if idempotency_key && reference.nil?
103+
raise ArgumentError, "find_by with idempotency_key: requires reference:"
104+
end
105+
106+
readable_message(
107+
looked_up_message(reference:, request_id:, idempotency_key:),
108+
authorization_context:
109+
)
110+
end
111+
97112
# @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
98113
def snapshot(reference, authorization_context: nil)
99114
SolidObjects.registry.fetch(reference.actor_type)
@@ -164,6 +179,50 @@ def enqueue_sync(reference:, operation:, arguments:, idempotency_key:, timeout:)
164179
)
165180
end
166181

182+
# @rbs (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message?
183+
def looked_up_message(reference:, request_id:, idempotency_key:)
184+
return Message.uncached { Message.find_by(request_id:) } if request_id
185+
186+
instance = Instance.find_by(
187+
actor_type: reference.actor_type,
188+
actor_id: reference.actor_id
189+
)
190+
return nil unless instance
191+
192+
Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) }
193+
end
194+
195+
# @rbs (Message?, authorization_context: untyped) -> MessageReference?
196+
def readable_message(message, authorization_context:)
197+
return nil unless message
198+
return nil unless authorized_to_read?(message, authorization_context:)
199+
200+
MessageReference.from_message(message)
201+
end
202+
203+
# @rbs (Message, authorization_context: untyped) -> bool
204+
def authorized_to_read?(message, authorization_context:)
205+
actor_class = SolidObjects.registry.fetch(message.actor_type)
206+
operation = message.operation.to_sym
207+
query = actor_class.definition.queries.key?(operation)
208+
return false unless query || actor_class.definition.messages.key?(operation)
209+
210+
hook = if query
211+
SolidObjects.configuration.authorize_query
212+
else
213+
SolidObjects.configuration.authorize_message
214+
end
215+
hook.call(
216+
actor_type: message.actor_type,
217+
actor_id: message.actor_id,
218+
operation: message.operation.to_s,
219+
arguments: message.arguments,
220+
authorization_context:
221+
)
222+
rescue UnknownActor
223+
false
224+
end
225+
167226
# @rbs (MessageReference, Message) -> void
168227
def validate_message_reference!(message_reference, message)
169228
valid = message_reference.request_id == message.request_id &&

‎lib/solid_objects/message_reference.rb‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ def result
5252
Message.uncached { Message.find(id).result }
5353
end
5454

55+
# @rbs () -> Outcome
56+
def outcome
57+
Message.uncached do
58+
message = Message.find(id)
59+
Outcome.new(
60+
status: status,
61+
result: message.result,
62+
error: ErrorRecord.from(message.error),
63+
rejection: RejectionRecord.from(message.rejection),
64+
attempts: message.attempt_count
65+
)
66+
end
67+
end
68+
5569
# @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped
5670
def wait(timeout: 5.seconds, authorization_context: nil)
5771
SolidObjects.client.wait(

‎lib/solid_objects/outcome.rb‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: Array(error["backtrace"]).freeze
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

‎lib/solid_objects/reference.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def destroy(authorization_context: nil)
6767
SolidObjects.client.destroy(self, authorization_context:)
6868
end
6969

70+
# @rbs (idempotency_key: String, ?authorization_context: untyped) -> MessageReference?
71+
def find_by(idempotency_key:, authorization_context: nil)
72+
SolidObjects.client.find_by(reference: self, idempotency_key:, authorization_context:)
73+
end
74+
7075
# @rbs (?authorization_context: untyped) -> StateSnapshot
7176
def snapshot(authorization_context: nil)
7277
SolidObjects.client.snapshot(self, authorization_context:)

‎sig/generated/lib/solid_objects/client.rbs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ module SolidObjects
1616
# @rbs (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped
1717
def wait: (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped
1818

19+
# @rbs (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference?
20+
def find_by: (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference?
21+
1922
# @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
2023
def snapshot: (Reference, ?authorization_context: untyped) -> StateSnapshot
2124

@@ -29,6 +32,15 @@ module SolidObjects
2932
# @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference
3033
def enqueue_sync: (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference
3134

35+
# @rbs (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message?
36+
def looked_up_message: (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message?
37+
38+
# @rbs (Message?, authorization_context: untyped) -> MessageReference?
39+
def readable_message: (Message?, authorization_context: untyped) -> MessageReference?
40+
41+
# @rbs (Message, authorization_context: untyped) -> bool
42+
def authorized_to_read?: (Message, authorization_context: untyped) -> bool
43+
3244
# @rbs (MessageReference, Message) -> void
3345
def validate_message_reference!: (MessageReference, Message) -> void
3446

‎sig/generated/lib/solid_objects/message_reference.rbs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ module SolidObjects
3434
# @rbs () -> untyped
3535
def result: () -> untyped
3636

37+
# @rbs () -> Outcome
38+
def outcome: () -> Outcome
39+
3740
# @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped
3841
def wait: (?timeout: Numeric, ?authorization_context: untyped) -> untyped
3942
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Generated from lib/solid_objects/outcome.rb with RBS::Inline
2+
3+
module SolidObjects
4+
class ErrorRecord < Data
5+
attr_reader class_name(): untyped
6+
7+
attr_reader message(): untyped
8+
9+
attr_reader backtrace(): untyped
10+
11+
def self.new: (untyped class_name, untyped message, untyped backtrace) -> instance
12+
| (class_name: untyped, message: untyped, backtrace: untyped) -> instance
13+
14+
def self.members: () -> [ :class_name, :message, :backtrace ]
15+
16+
def members: () -> [ :class_name, :message, :backtrace ]
17+
end
18+
19+
class RejectionRecord < Data
20+
attr_reader code(): untyped
21+
22+
attr_reader message(): untyped
23+
24+
attr_reader details(): untyped
25+
26+
def self.new: (untyped code, untyped message, untyped details) -> instance
27+
| (code: untyped, message: untyped, details: untyped) -> instance
28+
29+
def self.members: () -> [ :code, :message, :details ]
30+
31+
def members: () -> [ :code, :message, :details ]
32+
end
33+
34+
class Outcome < Data
35+
attr_reader status(): untyped
36+
37+
attr_reader result(): untyped
38+
39+
attr_reader error(): untyped
40+
41+
attr_reader rejection(): untyped
42+
43+
attr_reader attempts(): untyped
44+
45+
def self.new: (untyped status, untyped result, untyped error, untyped rejection, untyped attempts) -> instance
46+
| (status: untyped, result: untyped, error: untyped, rejection: untyped, attempts: untyped) -> instance
47+
48+
def self.members: () -> [ :status, :result, :error, :rejection, :attempts ]
49+
50+
def members: () -> [ :status, :result, :error, :rejection, :attempts ]
51+
end
52+
end

‎sig/generated/lib/solid_objects/reference.rbs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
module SolidObjects
44
class Reference
5-
@actor_type: String
6-
75
@actor_id: String
86

7+
@actor_type: String
8+
99
attr_reader actor_type: untyped
1010

1111
attr_reader actor_id: untyped
@@ -22,6 +22,9 @@ module SolidObjects
2222
# @rbs (?authorization_context: untyped) -> bool
2323
def destroy: (?authorization_context: untyped) -> bool
2424

25+
# @rbs (idempotency_key: String, ?authorization_context: untyped) -> MessageReference?
26+
def find_by: (idempotency_key: String, ?authorization_context: untyped) -> MessageReference?
27+
2528
# @rbs (?authorization_context: untyped) -> StateSnapshot
2629
def snapshot: (?authorization_context: untyped) -> StateSnapshot
2730

0 commit comments

Comments
 (0)