From acc7e03ffb83501a8f880f8742129a6672cd0da5 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 22 Sep 2026 18:43:12 -0700 Subject: [PATCH 01/16] 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) --- lib/solid_objects.rb | 1 + lib/solid_objects/client.rb | 59 +++++ lib/solid_objects/message_reference.rb | 14 ++ lib/solid_objects/outcome.rb | 31 +++ lib/solid_objects/reference.rb | 5 + sig/generated/lib/solid_objects/client.rbs | 12 + .../lib/solid_objects/message_reference.rbs | 3 + sig/generated/lib/solid_objects/outcome.rbs | 52 ++++ sig/generated/lib/solid_objects/reference.rbs | 7 +- test/integration/result_lookup_test.rb | 231 ++++++++++++++++++ 10 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 lib/solid_objects/outcome.rb create mode 100644 sig/generated/lib/solid_objects/outcome.rbs create mode 100644 test/integration/result_lookup_test.rb diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index faf9e9c..c78ab04 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -26,6 +26,7 @@ require "solid_objects/application_write_guard" require "solid_objects/actor" require "solid_objects/reference" +require "solid_objects/outcome" require "solid_objects/message_reference" require "solid_objects/administration_audit" require "solid_objects/redrive_task" diff --git a/lib/solid_objects/client.rb b/lib/solid_objects/client.rb index 0db8ac1..b04de3f 100644 --- a/lib/solid_objects/client.rb +++ b/lib/solid_objects/client.rb @@ -94,6 +94,21 @@ def wait(message_reference, timeout:, authorization_context: nil) raise SyncDiagnostics.new.database_contention_for(message_reference, timeout:) end + # @rbs (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference? + def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization_context: nil) + unless [ request_id, idempotency_key ].compact.one? + raise ArgumentError, "find_by expects exactly one of request_id: or idempotency_key:" + end + if idempotency_key && reference.nil? + raise ArgumentError, "find_by with idempotency_key: requires reference:" + end + + readable_message( + looked_up_message(reference:, request_id:, idempotency_key:), + authorization_context: + ) + end + # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot def snapshot(reference, authorization_context: nil) SolidObjects.registry.fetch(reference.actor_type) @@ -164,6 +179,50 @@ def enqueue_sync(reference:, operation:, arguments:, idempotency_key:, timeout:) ) end + # @rbs (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message? + def looked_up_message(reference:, request_id:, idempotency_key:) + return Message.uncached { Message.find_by(request_id:) } if request_id + + instance = Instance.find_by( + actor_type: reference.actor_type, + actor_id: reference.actor_id + ) + return nil unless instance + + Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) } + end + + # @rbs (Message?, authorization_context: untyped) -> MessageReference? + def readable_message(message, authorization_context:) + return nil unless message + return nil unless authorized_to_read?(message, authorization_context:) + + MessageReference.from_message(message) + end + + # @rbs (Message, authorization_context: untyped) -> bool + def authorized_to_read?(message, authorization_context:) + actor_class = SolidObjects.registry.fetch(message.actor_type) + operation = message.operation.to_sym + query = actor_class.definition.queries.key?(operation) + return false unless query || actor_class.definition.messages.key?(operation) + + hook = if query + SolidObjects.configuration.authorize_query + else + SolidObjects.configuration.authorize_message + end + hook.call( + actor_type: message.actor_type, + actor_id: message.actor_id, + operation: message.operation.to_s, + arguments: message.arguments, + authorization_context: + ) + rescue UnknownActor + false + end + # @rbs (MessageReference, Message) -> void def validate_message_reference!(message_reference, message) valid = message_reference.request_id == message.request_id && diff --git a/lib/solid_objects/message_reference.rb b/lib/solid_objects/message_reference.rb index d015cb5..5de4d06 100644 --- a/lib/solid_objects/message_reference.rb +++ b/lib/solid_objects/message_reference.rb @@ -52,6 +52,20 @@ def result Message.uncached { Message.find(id).result } end + # @rbs () -> Outcome + def outcome + Message.uncached do + message = Message.find(id) + Outcome.new( + status: status, + result: message.result, + error: ErrorRecord.from(message.error), + rejection: RejectionRecord.from(message.rejection), + attempts: message.attempt_count + ) + end + end + # @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped def wait(timeout: 5.seconds, authorization_context: nil) SolidObjects.client.wait( diff --git a/lib/solid_objects/outcome.rb b/lib/solid_objects/outcome.rb new file mode 100644 index 0000000..62960a8 --- /dev/null +++ b/lib/solid_objects/outcome.rb @@ -0,0 +1,31 @@ +# rbs_inline: enabled + +module SolidObjects + ErrorRecord = Data.define(:class_name, :message, :backtrace) do + # @rbs (Hash[String, untyped]?) -> ErrorRecord? + def self.from(error) + return nil if error.blank? + + new( + class_name: error["class"], + message: error["message"], + backtrace: Array(error["backtrace"]).freeze + ) + end + end + + RejectionRecord = Data.define(:code, :message, :details) do + # @rbs (Hash[String, untyped]?) -> RejectionRecord? + def self.from(rejection) + return nil if rejection.blank? + + new( + code: rejection["code"], + message: rejection["message"], + details: Serialization.readonly_copy(rejection["details"]) + ) + end + end + + Outcome = Data.define(:status, :result, :error, :rejection, :attempts) +end diff --git a/lib/solid_objects/reference.rb b/lib/solid_objects/reference.rb index 139e41c..5a52f57 100644 --- a/lib/solid_objects/reference.rb +++ b/lib/solid_objects/reference.rb @@ -67,6 +67,11 @@ def destroy(authorization_context: nil) SolidObjects.client.destroy(self, authorization_context:) end + # @rbs (idempotency_key: String, ?authorization_context: untyped) -> MessageReference? + def find_by(idempotency_key:, authorization_context: nil) + SolidObjects.client.find_by(reference: self, idempotency_key:, authorization_context:) + end + # @rbs (?authorization_context: untyped) -> StateSnapshot def snapshot(authorization_context: nil) SolidObjects.client.snapshot(self, authorization_context:) diff --git a/sig/generated/lib/solid_objects/client.rbs b/sig/generated/lib/solid_objects/client.rbs index cfd369b..c580849 100644 --- a/sig/generated/lib/solid_objects/client.rbs +++ b/sig/generated/lib/solid_objects/client.rbs @@ -16,6 +16,9 @@ module SolidObjects # @rbs (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped def wait: (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped + # @rbs (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference? + def find_by: (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference? + # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot def snapshot: (Reference, ?authorization_context: untyped) -> StateSnapshot @@ -29,6 +32,15 @@ module SolidObjects # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference def enqueue_sync: (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference + # @rbs (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message? + def looked_up_message: (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message? + + # @rbs (Message?, authorization_context: untyped) -> MessageReference? + def readable_message: (Message?, authorization_context: untyped) -> MessageReference? + + # @rbs (Message, authorization_context: untyped) -> bool + def authorized_to_read?: (Message, authorization_context: untyped) -> bool + # @rbs (MessageReference, Message) -> void def validate_message_reference!: (MessageReference, Message) -> void diff --git a/sig/generated/lib/solid_objects/message_reference.rbs b/sig/generated/lib/solid_objects/message_reference.rbs index 4020ed3..9579c3d 100644 --- a/sig/generated/lib/solid_objects/message_reference.rbs +++ b/sig/generated/lib/solid_objects/message_reference.rbs @@ -34,6 +34,9 @@ module SolidObjects # @rbs () -> untyped def result: () -> untyped + # @rbs () -> Outcome + def outcome: () -> Outcome + # @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped def wait: (?timeout: Numeric, ?authorization_context: untyped) -> untyped end diff --git a/sig/generated/lib/solid_objects/outcome.rbs b/sig/generated/lib/solid_objects/outcome.rbs new file mode 100644 index 0000000..2b7e6c6 --- /dev/null +++ b/sig/generated/lib/solid_objects/outcome.rbs @@ -0,0 +1,52 @@ +# Generated from lib/solid_objects/outcome.rb with RBS::Inline + +module SolidObjects + class ErrorRecord < Data + attr_reader class_name(): untyped + + attr_reader message(): untyped + + attr_reader backtrace(): untyped + + def self.new: (untyped class_name, untyped message, untyped backtrace) -> instance + | (class_name: untyped, message: untyped, backtrace: untyped) -> instance + + def self.members: () -> [ :class_name, :message, :backtrace ] + + def members: () -> [ :class_name, :message, :backtrace ] + end + + class RejectionRecord < Data + attr_reader code(): untyped + + attr_reader message(): untyped + + attr_reader details(): untyped + + def self.new: (untyped code, untyped message, untyped details) -> instance + | (code: untyped, message: untyped, details: untyped) -> instance + + def self.members: () -> [ :code, :message, :details ] + + def members: () -> [ :code, :message, :details ] + end + + class Outcome < Data + attr_reader status(): untyped + + attr_reader result(): untyped + + attr_reader error(): untyped + + attr_reader rejection(): untyped + + attr_reader attempts(): untyped + + def self.new: (untyped status, untyped result, untyped error, untyped rejection, untyped attempts) -> instance + | (status: untyped, result: untyped, error: untyped, rejection: untyped, attempts: untyped) -> instance + + def self.members: () -> [ :status, :result, :error, :rejection, :attempts ] + + def members: () -> [ :status, :result, :error, :rejection, :attempts ] + end +end diff --git a/sig/generated/lib/solid_objects/reference.rbs b/sig/generated/lib/solid_objects/reference.rbs index 8f0a418..fa555e1 100644 --- a/sig/generated/lib/solid_objects/reference.rbs +++ b/sig/generated/lib/solid_objects/reference.rbs @@ -2,10 +2,10 @@ module SolidObjects class Reference - @actor_type: String - @actor_id: String + @actor_type: String + attr_reader actor_type: untyped attr_reader actor_id: untyped @@ -22,6 +22,9 @@ module SolidObjects # @rbs (?authorization_context: untyped) -> bool def destroy: (?authorization_context: untyped) -> bool + # @rbs (idempotency_key: String, ?authorization_context: untyped) -> MessageReference? + def find_by: (idempotency_key: String, ?authorization_context: untyped) -> MessageReference? + # @rbs (?authorization_context: untyped) -> StateSnapshot def snapshot: (?authorization_context: untyped) -> StateSnapshot diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb new file mode 100644 index 0000000..9faf5b5 --- /dev/null +++ b/test/integration/result_lookup_test.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require "database_test_helper" + +class ResultLookupTest < ActiveSupport::TestCase + class CartActor < SolidObjects::Actor + actor_type "lookup-carts" + + attribute :items, default: 0 + + class << self + attr_accessor :fail + end + + def checkout(order_id:) + raise "payment declined" if self.class.fail + + self.items += 1 + { "order_id" => order_id } + end + + def reject_checkout + reject("closed", "the cart is closed") + end + + query :total do + items + end + end + + setup do + SolidObjects.configuration.retry_delay = ->(_attempt) { 0 } + SolidObjects.configuration.max_attempts = 1 + CartActor.fail = false + end + + test "finds a completed message by request id and reads its result" do + CartActor.ref("alice").sync.checkout(order_id: 4210) + original = SolidObjects::Message.sole + + found = SolidObjects.client.find_by( + request_id: original.request_id, + authorization_context: "operator" + ) + + assert_equal original.id, found.id + assert_equal "completed", found.status + assert_equal({ "order_id" => 4210 }, found.result) + end + + test "reports no result for a message that was enqueued asynchronously" do + original = CartActor.ref("alice").async.checkout(order_id: 4210) + run_actors + + found = SolidObjects.client.find_by(request_id: original.request_id) + + assert_equal "completed", found.status + assert_nil found.result + end + + test "finds a completed message by idempotency key on its reference" do + reference = CartActor.ref("alice") + reference.sync(idempotency_key: "checkout-7f3a").checkout(order_id: 4210) + original = SolidObjects::Message.sole + + found = reference.find_by(idempotency_key: "checkout-7f3a", authorization_context: "operator") + + assert_equal original.id, found.id + assert_equal({ "order_id" => 4210 }, found.result) + end + + test "the client and the reference find the same message" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + + through_reference = reference.find_by(idempotency_key: "checkout-7f3a") + through_client = SolidObjects.client.find_by( + reference: reference, + idempotency_key: "checkout-7f3a" + ) + + assert_equal through_reference.id, through_client.id + end + + test "finds a message that has not run yet" do + original = CartActor.ref("alice").async.checkout(order_id: 1) + + found = SolidObjects.client.find_by(request_id: original.request_id) + + assert_equal "ready", found.status + end + + test "finds a dead message and reports its error and attempts" do + CartActor.fail = true + original = CartActor.ref("alice").async.checkout(order_id: 1) + run_actors + + found = SolidObjects.client.find_by(request_id: original.request_id) + outcome = found.outcome + + assert_equal "dead", found.status + assert_equal "dead", outcome.status + assert_equal 1, outcome.attempts + assert_equal "RuntimeError", outcome.error.class_name + assert_equal "payment declined", outcome.error.message + assert_nil outcome.result + end + + test "finds a rejected message and reports its rejection" do + original = CartActor.ref("alice").async.reject_checkout + run_actors + + found = SolidObjects.client.find_by(request_id: original.request_id) + outcome = found.outcome + + assert_equal "rejected", found.status + assert_equal "rejected", outcome.status + assert_equal "closed", outcome.rejection.code + assert_equal "the cart is closed", outcome.rejection.message + end + + test "reports a completed outcome with its result" do + CartActor.ref("alice").sync.checkout(order_id: 9) + original = SolidObjects::Message.sole + + outcome = SolidObjects.client.find_by(request_id: original.request_id).outcome + + assert_equal "completed", outcome.status + assert_equal({ "order_id" => 9 }, outcome.result) + assert_nil outcome.error + assert_nil outcome.rejection + end + + test "returns nil for an unknown request id and an unknown key" do + reference = CartActor.ref("alice") + reference.async.checkout(order_id: 1) + + assert_nil SolidObjects.client.find_by(request_id: SecureRandom.uuid) + assert_nil reference.find_by(idempotency_key: "never-used") + end + + test "refuses a lookup that names no key" do + error = assert_raises(ArgumentError) { SolidObjects.client.find_by } + + assert_match(/exactly one of/, error.message) + end + + test "refuses a lookup that names both keys" do + error = assert_raises(ArgumentError) do + SolidObjects.client.find_by(request_id: "one", idempotency_key: "two") + end + + assert_match(/exactly one of/, error.message) + end + + test "refuses an idempotency key without a reference" do + error = assert_raises(ArgumentError) do + SolidObjects.client.find_by(idempotency_key: "checkout-7f3a") + end + + assert_match(/requires reference/, error.message) + end + + test "refuses an unknown keyword" do + assert_raises(ArgumentError) { SolidObjects.client.find_by(bogus: "one") } + end + + test "returns nil to a caller that cannot read the message" do + original = CartActor.ref("alice").async.checkout(order_id: 1) + SolidObjects.configuration.authorize_message = ->(**) { false } + + assert_nil SolidObjects.client.find_by( + request_id: original.request_id, + authorization_context: "stranger" + ) + assert_nil CartActor.ref("alice").find_by(idempotency_key: "never-used") + end + + test "authorizes against the stored operation and arguments" do + reference = CartActor.ref("alice") + original = reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 4210) + seen = [] + SolidObjects.configuration.authorize_message = lambda do |operation:, arguments:, **| + seen << [ operation, arguments ] + true + end + + SolidObjects.client.find_by(request_id: original.request_id) + + assert_equal [ [ "checkout", { "order_id" => 4210 } ] ], seen + end + + test "uses the query hook for a query message" do + CartActor.ref("alice").sync.total + original = SolidObjects::Message.sole + hooks = [] + SolidObjects.configuration.authorize_query = ->(**) { hooks << :query and true } + SolidObjects.configuration.authorize_message = ->(**) { hooks << :message and true } + + SolidObjects.client.find_by(request_id: original.request_id) + + assert_equal [ :query ], hooks + end + + test "does not find a key that belongs to another instance" do + CartActor.ref("alice").async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + + assert_nil CartActor.ref("bob").find_by(idempotency_key: "checkout-7f3a") + end + + test "rebuilds a reference that can wait for its result" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 4210) + found = reference.find_by(idempotency_key: "checkout-7f3a") + + run_actors + found.wait(timeout: 2.0) + + assert_equal "completed", found.status + assert_equal 1, CartActor.ref("alice").snapshot.items + end + + private + + def run_actors + worker = SolidObjects::Worker.new + worker.run_until_idle + ensure + worker&.stop + end +end From 63bec6d68472bead6beb785bf8110497545cf10b Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 07:45:06 -0700 Subject: [PATCH 02/16] docs: describe the result lookup The branch added an API and documented none of it. The changelog, the architecture guide, the pruning note, and the roadmap entry the issue quotes now say what exists, including that a result is stored for sync delivery only and that a pruned message still answers nil. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 16 ++++++++++++++++ docs/architecture.md | 12 +++++++++++- docs/operations.md | 5 +++++ docs/roadmap.md | 4 +++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c34a76..58c6bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +- Find a message whose reference a caller lost. + `SolidObjects.client.find_by(request_id:)` answers a request id, which is + unique across the table, and `reference.find_by(idempotency_key:)` answers a + key, which is unique per instance, so the receiver supplies the scope the key + needs. Naming neither key, naming both, or naming an idempotency key without a + reference raises `ArgumentError`. +- Authorize every lookup with the 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 a lookup cannot be used to ask whether a request + id exists. +- Add `MessageReference#outcome`, which reports the status, the result, the + persisted error, the rejection, and the attempt count, so a terminal failure + answers as well as a success. A result is stored for `sync` delivery only, so + an asynchronous message reports its status and error and no result. + - Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its message meaning and answers `effects` and `broadcasts`, so the kind rides on the receiver. `retry` returns a dead row to pending with a zero attempt count diff --git a/docs/architecture.md b/docs/architecture.md index 2936787..d3de2cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -424,6 +424,16 @@ outer commit, and callers timing out on work they indirectly block. waiting and immediately returns a `MessageReference`. Runtime workers process it normally. +A caller that loses that reference rebuilds one. `SolidObjects.client.find_by` +answers a request id, which is unique across the table, and +`reference.find_by` answers an idempotency key, which is unique per instance. +Each lookup runs the authorization hook the original call ran, against the +stored operation and arguments, and answers `nil` for an absent row, an +unregistered actor, and a refused caller alike, so it 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. A result is +stored for `sync` delivery only. + An executing caller receives an inline after-commit callback error even though the turn committed. An independently waiting caller observes the durable result and may return before that callback raises in the worker. Completed @@ -867,7 +877,7 @@ All backends use unique identity and sequence constraints, short transactions, a 12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease. 13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped. 14. **How does synchronous invocation work across processes?** The caller first tries to claim and execute the actor locally. If another process owns it, a wake-up adapter prompts a durable result query and bounded polling remains the fallback. -15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference. An enqueue timeout leaves no message. Running Ruby code is not preempted. +15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference, or with `find_by` from the request id or the idempotency key when that reference is gone. An enqueue timeout leaves no message. Running Ruby code is not preempted. 16. **How are results cleaned up?** `prune_messages` deletes eligible terminal history in bounded batches after global or per-actor retention. It previews by default and preserves live work, dead letters, retry links, and unfinished outboxes. 17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields; rate and global admission controls remain roadmap work. 18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically. diff --git a/docs/operations.md b/docs/operations.md index f11563f..1bc4860 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -543,6 +543,11 @@ broadcasts, and other message-owned rows. Choose a cutoff longer than every `sync` timeout because a caller whose result row disappears can no longer observe it. +`find_by` reads the same rows, so a lookup answers only while the message it +names survives retention. A pruned message and one that never existed both +answer `nil` today, which is why a cutoff longer than the window in which a +caller may retry matters. + Actor expiration is disabled by default. `prune_instances` considers only actor types listed in `instance_retention_by_actor_type`, excludes active or paused actors, and preserves ready/claimed mailbox work, scheduled reminders, diff --git a/docs/roadmap.md b/docs/roadmap.md index 8fe2c27..d296e8a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -197,7 +197,9 @@ ## Next milestones -1. Add result lookup by request ID and broader deadlock retry classification. +1. Broaden deadlock retry classification. Result lookup by request ID and by + idempotency key is implemented; what remains is telling a pruned message + from one that never existed. 2. Add Turbo append intents. 3. Add distributed rate limits, global admission hooks, and cache-capacity eviction. From c6e94478d5b456794270149deac481b819f57f4f Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 08:17:05 -0700 Subject: [PATCH 03/16] feat: tell a pruned message from a missing one `find_by` answered `nil` for a message retention removed and for a message that never existed, so a client that retried after a timeout could not tell a lost result from a request that never arrived. Orleans solves this in grain state: a grain keeps its deduplication history with the grain rather than in a separate tombstone store. An actor now does the same. The executor already writes the instance row in the transaction that completes, rejects, or kills a turn, so the remembered keys ride on a write that happens anyway. No second store, no second write, and no separate retention. `reference.find_by(idempotency_key:)` raises `MessagePruned` for a key the actor remembers and answers `nil` for a key no caller ever sent. `retained_idempotency_keys` bounds the memory at 64 keys per actor. A lookup by request id cannot make the distinction, because the runtime generates a request id and no actor remembers one. Seven scripts each carried a hand-copied migration list. The three that run migrations against a full schema failed on the new column: undefined method `completed_idempotency_keys' for an instance of SolidObjects::Instance `SolidObjects::SchemaBootstrap` reads `db/migrate` instead, and a test fails if any script names a migration class again. Validation: - bundle exec rake - SOLID_OBJECTS_DATABASE_URL=postgresql://... bundle exec rake test - SOLID_OBJECTS_DATABASE_URL=mysql2://... bundle exec rake test - SOLID_OBJECTS_DATABASE_URL=trilogy://... bundle exec rake test Each new test was watched to fail first. Without the raise: `SolidObjects::MessagePruned expected but nothing was raised` in the completed, rejected, and dead paths. Without the bound: `SolidObjects::MessagePruned: the message for idempotency key "key-0" was pruned`. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 +++ benchmark/support.rb | 10 +--- ...olid_objects_completed_idempotency_keys.rb | 10 ++++ docs/architecture.md | 14 +++++ docs/operations.md | 13 ++++- docs/roadmap.md | 12 +++- examples/at_least_once/boot.rb | 10 +--- lib/solid_objects/client.rb | 22 ++++--- lib/solid_objects/configuration.rb | 4 ++ lib/solid_objects/errors.rb | 11 ++++ lib/solid_objects/executor.rb | 21 ++++++- lib/solid_objects/schema_bootstrap.rb | 34 +++++++++++ sig/generated/lib/solid_objects/client.rbs | 7 ++- .../lib/solid_objects/configuration.rbs | 8 ++- sig/generated/lib/solid_objects/errors.rbs | 8 +++ sig/generated/lib/solid_objects/executor.rbs | 3 + .../lib/solid_objects/schema_bootstrap.rbs | 14 +++++ test/database_test_helper.rb | 16 +---- test/dummy/prepare_cli_reminder.rb | 10 +--- test/dummy/prepare_cli_worker.rb | 10 +--- test/dummy/web_mount_check.rb | 10 +--- test/integration/load_contract_test.rb | 1 + test/integration/result_lookup_test.rb | 58 +++++++++++++++++++ test/integration/separate_database_test.rb | 11 +--- test/unit/migration_bootstrap_test.rb | 31 ++++++++++ 25 files changed, 273 insertions(+), 84 deletions(-) create mode 100644 db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb create mode 100644 lib/solid_objects/schema_bootstrap.rb create mode 100644 sig/generated/lib/solid_objects/schema_bootstrap.rbs create mode 100644 test/unit/migration_bootstrap_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c6bde..16508d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ persisted error, the rejection, and the attempt count, so a terminal failure answers as well as a success. A result is stored for `sync` delivery only, so an asynchronous message reports its status and error and no result. +- Tell a pruned message from one that never existed. An actor remembers the + idempotency keys of its own finished turns, the way an Orleans grain keeps + its deduplication history in grain state, so the memory needs no second + store and no second write. `reference.find_by(idempotency_key:)` raises + `SolidObjects::MessagePruned` for a key the actor remembers and whose message + retention removed, and still answers `nil` for a key no caller ever sent. + `retained_idempotency_keys` bounds the memory and defaults to 64 keys for + each actor. A lookup by request id cannot make the distinction, because the + runtime, not the caller, generates a request id and no actor remembers one. - Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its message meaning and answers `effects` and `broadcasts`, so the kind rides on diff --git a/benchmark/support.rb b/benchmark/support.rb index 30136e4..ec7eda0 100644 --- a/benchmark/support.rb +++ b/benchmark/support.rb @@ -425,14 +425,8 @@ def establish_connection # @rbs () -> void def migrate - require_relative "../db/migrate/20260805000000_create_solid_objects_tables" - require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" - require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns" - require_relative "../db/migrate/20260915000000_add_solid_objects_effect_recoveries" - CreateSolidObjectsTables.new.migrate(:up) - AddStateRevisionToSolidObjectsInstances.new.migrate(:up) - RenameMessageDispatchColumns.new.migrate(:up) - AddSolidObjectsEffectRecoveries.new.migrate(:up) + require "solid_objects/schema_bootstrap" + SolidObjects::SchemaBootstrap.install end # @rbs () -> void diff --git a/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb b/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb new file mode 100644 index 0000000..251559f --- /dev/null +++ b/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb @@ -0,0 +1,10 @@ +# rbs_inline: enabled + +class AddSolidObjectsCompletedIdempotencyKeys < ActiveRecord::Migration[7.1] + # @rbs () -> void + def change + add_column SolidObjects.table_name(:instances), + :completed_idempotency_keys, + connection.adapter_name.match?(/postgres/i) ? :jsonb : :json + end +end diff --git a/docs/architecture.md b/docs/architecture.md index d3de2cb..c7fc490 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -434,6 +434,20 @@ whether a request id exists. `MessageReference#outcome` reports the status, the result, the persisted error, the rejection, and the attempt count. A result is stored for `sync` delivery only. +An actor remembers the idempotency keys of its own finished turns. The executor +already writes the instance row in the transaction that completes, rejects, or +kills a turn, so the memory rides on a write that happens anyway. This is the +Orleans answer: a grain keeps its deduplication history in grain state rather +than in a separate tombstone table, which needs no second store, no second +write, and no separate retention. `reference.find_by(idempotency_key:)` raises +`MessagePruned` for a key the actor remembers and whose message retention +removed, and answers `nil` for a key no caller ever sent, so a client can tell +a lost result from a request that never arrived. +`retained_idempotency_keys` bounds the memory and defaults to 64 keys for each +actor. Only a lookup by idempotency key can make the distinction. A request id +is generated by the runtime rather than by the caller, so no actor remembers +one, and `client.find_by(request_id:)` answers `nil` in both cases. + An executing caller receives an inline after-commit callback error even though the turn committed. An independently waiting caller observes the durable result and may return before that callback raises in the worker. Completed diff --git a/docs/operations.md b/docs/operations.md index 1bc4860..f69ae87 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -215,6 +215,7 @@ end | `instance_retention_by_actor_type` | `{}`; instances never expire unless listed | | `process_retention` | 7 days | | `prune_batch_size` | 1,000 | +| `retained_idempotency_keys` | 64 | | `worker_count` | 1 | | `effect_worker_count` | 1 | | `broadcast_worker_count` | 1 | @@ -517,6 +518,7 @@ SolidObjects.configure do |configuration| } configuration.process_retention = 7.days configuration.prune_batch_size = 1_000 + configuration.retained_idempotency_keys = 64 end ``` @@ -544,9 +546,14 @@ broadcasts, and other message-owned rows. Choose a cutoff longer than every observe it. `find_by` reads the same rows, so a lookup answers only while the message it -names survives retention. A pruned message and one that never existed both -answer `nil` today, which is why a cutoff longer than the window in which a -caller may retry matters. +names survives retention. A lookup by idempotency key still tells the two cases +apart after pruning, because the actor remembers the keys of its own last +`retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key +the actor remembers and answers `nil` for a key no caller ever sent. Raise +`retained_idempotency_keys` above the default of 64 when an actor finishes more +keyed turns than that inside the window in which a caller may retry. A lookup +by request id answers `nil` in both cases, so a caller that must tell them apart +sends its own idempotency key. Actor expiration is disabled by default. `prune_instances` considers only actor types listed in `instance_retention_by_actor_type`, excludes active or diff --git a/docs/roadmap.md b/docs/roadmap.md index d296e8a..00784de 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -117,6 +117,14 @@ batched and unbatched components, an inert replay of an applied revision, cancellation of the request left in flight by the drop, incarnation ordering after a destroy and recreate, and payload delivery exactly once per revision +- Result lookup by request ID and by idempotency key, authorized with the hook + the original call ran and against the stored operation and arguments. An + actor remembers the idempotency keys of its own last `retained_idempotency_keys` + finished turns, written in the instance row the executor updates anyway, so a + lookup by key raises `MessagePruned` for a message that retention removed and + answers `nil` for a message that never existed. A lookup by request ID cannot + make that distinction, because the runtime generates a request ID and no + actor remembers one ## Partially implemented @@ -197,9 +205,7 @@ ## Next milestones -1. Broaden deadlock retry classification. Result lookup by request ID and by - idempotency key is implemented; what remains is telling a pruned message - from one that never existed. +1. Broaden deadlock retry classification. 2. Add Turbo append intents. 3. Add distributed rate limits, global admission hooks, and cache-capacity eviction. diff --git a/examples/at_least_once/boot.rb b/examples/at_least_once/boot.rb index cd68a4c..c46dfb9 100644 --- a/examples/at_least_once/boot.rb +++ b/examples/at_least_once/boot.rb @@ -37,13 +37,7 @@ def self.call(database_path) # @rbs () -> void def self.migrate - require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables") - require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances") - require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns") - require File.join(ROOT, "db/migrate/20260915000000_add_solid_objects_effect_recoveries") - CreateSolidObjectsTables.new.migrate(:up) - AddStateRevisionToSolidObjectsInstances.new.migrate(:up) - RenameMessageDispatchColumns.new.migrate(:up) - AddSolidObjectsEffectRecoveries.new.migrate(:up) + require "solid_objects/schema_bootstrap" + SolidObjects::SchemaBootstrap.install end end diff --git a/lib/solid_objects/client.rb b/lib/solid_objects/client.rb index b04de3f..dc07357 100644 --- a/lib/solid_objects/client.rb +++ b/lib/solid_objects/client.rb @@ -103,10 +103,9 @@ def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization raise ArgumentError, "find_by with idempotency_key: requires reference:" end - readable_message( - looked_up_message(reference:, request_id:, idempotency_key:), - authorization_context: - ) + return readable_message(requested_message(request_id), authorization_context:) if request_id + + readable_message(remembered_message(reference, idempotency_key), authorization_context:) end # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot @@ -179,17 +178,24 @@ def enqueue_sync(reference:, operation:, arguments:, idempotency_key:, timeout:) ) end - # @rbs (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message? - def looked_up_message(reference:, request_id:, idempotency_key:) - return Message.uncached { Message.find_by(request_id:) } if request_id + # @rbs (String) -> Message? + def requested_message(request_id) + Message.uncached { Message.find_by(request_id:) } + end + # @rbs (Reference, String) -> Message? + def remembered_message(reference, idempotency_key) instance = Instance.find_by( actor_type: reference.actor_type, actor_id: reference.actor_id ) return nil unless instance - Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) } + message = Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) } + return message if message + raise MessagePruned, idempotency_key if Array(instance.completed_idempotency_keys).include?(idempotency_key) + + nil end # @rbs (Message?, authorization_context: untyped) -> MessageReference? diff --git a/lib/solid_objects/configuration.rb b/lib/solid_objects/configuration.rb index 7a33ec9..c5cacd2 100644 --- a/lib/solid_objects/configuration.rb +++ b/lib/solid_objects/configuration.rb @@ -31,6 +31,7 @@ class Configuration # @rbs @instance_retention_by_actor_type: Hash[String, Numeric] # @rbs @process_retention: Numeric # @rbs @prune_batch_size: Integer + # @rbs @retained_idempotency_keys: Integer # @rbs @redrive_batch_size: Integer # @rbs @redrive_batch_pause: Float # @rbs @worker_count: Integer @@ -83,6 +84,7 @@ class Configuration :instance_retention_by_actor_type, :process_retention, :prune_batch_size, + :retained_idempotency_keys, :redrive_batch_size, :redrive_batch_pause, :worker_count, @@ -140,6 +142,7 @@ def initialize @instance_retention_by_actor_type = {} @process_retention = 7.days @prune_batch_size = 1_000 + @retained_idempotency_keys = 64 @redrive_batch_size = 100 @redrive_batch_pause = 0.05 @worker_count = 1 @@ -311,6 +314,7 @@ def positive_values message_retention:, process_retention:, prune_batch_size:, + retained_idempotency_keys:, redrive_batch_size: } end diff --git a/lib/solid_objects/errors.rb b/lib/solid_objects/errors.rb index d3f3cbe..dbad8da 100644 --- a/lib/solid_objects/errors.rb +++ b/lib/solid_objects/errors.rb @@ -263,4 +263,15 @@ class StateMigrationError < Error class ActorCallCycle < Error end + + class MessagePruned < Error + # @rbs @idempotency_key: String + attr_reader :idempotency_key + + # @rbs (String) -> void + def initialize(idempotency_key) + @idempotency_key = idempotency_key + super("the message for idempotency key #{idempotency_key.inspect} was pruned") + end + end end diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index f47e435..383bce9 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -111,7 +111,8 @@ def complete(result, observable_changes, state_after:, state_changed:) state: state_after.value, state_version: actor.class.state_version, state_revision: locked_message.sequence, - last_used_at: SolidObjects.database_adapter.database_now + last_used_at: SolidObjects.database_adapter.database_now, + completed_idempotency_keys: remembered_keys(instance, locked_message) ) locked_message.update!( result: serialized_result, @@ -394,7 +395,7 @@ def fail_message(error) error_details = serialized_error(error) dead = false - activation.lease.fenced_transaction do + activation.lease.fenced_transaction do |instance| claimed_message = matching_claim! locked_message = Message.lock.find(message.id) now = SolidObjects.database_adapter.database_now @@ -404,6 +405,7 @@ def fail_message(error) if error.is_a?(NonRetryableError) || locked_message.attempt_count >= locked_message.max_attempts create_dead_letter(message: locked_message, error_details:, now:) + instance.update!(completed_idempotency_keys: remembered_keys(instance, locked_message)) dead = true else ReadyMessage.create!( @@ -439,7 +441,10 @@ def reject_message(rejection) claimed_message = matching_claim! locked_message = Message.lock.find(message.id) now = SolidObjects.database_adapter.database_now - instance.update!(last_used_at: now) + instance.update!( + last_used_at: now, + completed_idempotency_keys: remembered_keys(instance, locked_message) + ) locked_message.update!( result: nil, rejection: rejection_data, @@ -470,6 +475,16 @@ def matching_claim! raise LostActivation, "message claim changed" end + # @rbs (Instance, Message) -> Array[String] + def remembered_keys(instance, message) + remembered = Array(instance.completed_idempotency_keys) + key = message.idempotency_key + return remembered unless key + return remembered if remembered.last == key + + (remembered - [ key ] + [ key ]).last(SolidObjects.configuration.retained_idempotency_keys) + end + # @rbs (Exception) -> Hash[String, untyped] def serialized_error(error) Serialization.dump( diff --git a/lib/solid_objects/schema_bootstrap.rb b/lib/solid_objects/schema_bootstrap.rb new file mode 100644 index 0000000..77945ef --- /dev/null +++ b/lib/solid_objects/schema_bootstrap.rb @@ -0,0 +1,34 @@ +# rbs_inline: enabled + +require "active_record" +require "active_support/core_ext/string/inflections" + +module SolidObjects + module SchemaBootstrap + class << self + # @rbs (?connection: untyped) -> void + def install(connection: nil) + migrations.each do |migration_class| + migration = migration_class.new + migration.define_singleton_method(:connection) { connection } if connection + migration.migrate(:up) + end + end + + # @rbs () -> Array[Class] + def migrations + migration_files.map do |file| + require file + Object.const_get(File.basename(file, ".rb").sub(/\A\d+_/, "").camelize) + end + end + + private + + # @rbs () -> Array[String] + def migration_files + Dir[File.expand_path("../../db/migrate/*.rb", __dir__)].sort + end + end + end +end diff --git a/sig/generated/lib/solid_objects/client.rbs b/sig/generated/lib/solid_objects/client.rbs index c580849..425fc1f 100644 --- a/sig/generated/lib/solid_objects/client.rbs +++ b/sig/generated/lib/solid_objects/client.rbs @@ -32,8 +32,11 @@ module SolidObjects # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference def enqueue_sync: (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference - # @rbs (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message? - def looked_up_message: (reference: Reference?, request_id: String?, idempotency_key: String?) -> Message? + # @rbs (String) -> Message? + def requested_message: (String) -> Message? + + # @rbs (Reference, String) -> Message? + def remembered_message: (Reference, String) -> Message? # @rbs (Message?, authorization_context: untyped) -> MessageReference? def readable_message: (Message?, authorization_context: untyped) -> MessageReference? diff --git a/sig/generated/lib/solid_objects/configuration.rbs b/sig/generated/lib/solid_objects/configuration.rbs index af49e26..340b441 100644 --- a/sig/generated/lib/solid_objects/configuration.rbs +++ b/sig/generated/lib/solid_objects/configuration.rbs @@ -2,8 +2,6 @@ module SolidObjects class Configuration - @table_name_prefix: String - @shutdown_timeout: Float @supervisor_monitor_interval: Float @@ -22,6 +20,8 @@ module SolidObjects @prune_batch_size: Integer + @retained_idempotency_keys: Integer + @redrive_batch_size: Integer @redrive_batch_pause: Float @@ -66,6 +66,8 @@ module SolidObjects @transmission_actor_type_resolver: Proc + @table_name_prefix: String + @polling_interval: Float @idle_polling_interval: Float @@ -162,6 +164,8 @@ module SolidObjects attr_accessor prune_batch_size: untyped + attr_accessor retained_idempotency_keys: untyped + attr_accessor redrive_batch_size: untyped attr_accessor redrive_batch_pause: untyped diff --git a/sig/generated/lib/solid_objects/errors.rbs b/sig/generated/lib/solid_objects/errors.rbs index b3705a3..53c74be 100644 --- a/sig/generated/lib/solid_objects/errors.rbs +++ b/sig/generated/lib/solid_objects/errors.rbs @@ -232,4 +232,12 @@ module SolidObjects class ActorCallCycle < Error end + + class MessagePruned < Error + # @rbs @idempotency_key: String + attr_reader idempotency_key: untyped + + # @rbs (String) -> void + def initialize: (String) -> void + end end diff --git a/sig/generated/lib/solid_objects/executor.rbs b/sig/generated/lib/solid_objects/executor.rbs index 98e4235..717503b 100644 --- a/sig/generated/lib/solid_objects/executor.rbs +++ b/sig/generated/lib/solid_objects/executor.rbs @@ -94,6 +94,9 @@ module SolidObjects # @rbs () -> ClaimedMessage def matching_claim!: () -> ClaimedMessage + # @rbs (Instance, Message) -> Array[String] + def remembered_keys: (Instance, Message) -> Array[String] + # @rbs (Exception) -> Hash[String, untyped] def serialized_error: (Exception) -> Hash[String, untyped] diff --git a/sig/generated/lib/solid_objects/schema_bootstrap.rbs b/sig/generated/lib/solid_objects/schema_bootstrap.rbs new file mode 100644 index 0000000..3bc8320 --- /dev/null +++ b/sig/generated/lib/solid_objects/schema_bootstrap.rbs @@ -0,0 +1,14 @@ +# Generated from lib/solid_objects/schema_bootstrap.rb with RBS::Inline + +module SolidObjects + module SchemaBootstrap + # @rbs (?connection: untyped) -> void + def self.install: (?connection: untyped) -> void + + # @rbs () -> Array[Class] + def self.migrations: () -> Array[Class] + + # @rbs () -> Array[String] + private def self.migration_files: () -> Array[String] + end +end diff --git a/test/database_test_helper.rb b/test/database_test_helper.rb index 0ff0f22..66d8db5 100644 --- a/test/database_test_helper.rb +++ b/test/database_test_helper.rb @@ -21,19 +21,9 @@ ) ActiveRecord::Migration.verbose = false -require_relative "../db/migrate/20260805000000_create_solid_objects_tables" -require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" -require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns" -require_relative "../db/migrate/20260915000000_add_solid_objects_effect_recoveries" -require_relative "../db/migrate/20260922000000_add_solid_objects_administration_events" -require_relative "../db/migrate/20260922000001_add_solid_objects_redrives" - -CreateSolidObjectsTables.new.migrate(:up) -AddStateRevisionToSolidObjectsInstances.new.migrate(:up) -RenameMessageDispatchColumns.new.migrate(:up) -AddSolidObjectsEffectRecoveries.new.migrate(:up) -AddSolidObjectsAdministrationEvents.new.migrate(:up) -AddSolidObjectsRedrives.new.migrate(:up) +require "solid_objects/schema_bootstrap" + +SolidObjects::SchemaBootstrap.install ActiveRecord::Base.connection.create_table(:solid_objects_test_domain_records) do |table| table.string :name, null: false diff --git a/test/dummy/prepare_cli_reminder.rb b/test/dummy/prepare_cli_reminder.rb index 23570dd..77c594e 100644 --- a/test/dummy/prepare_cli_reminder.rb +++ b/test/dummy/prepare_cli_reminder.rb @@ -3,15 +3,9 @@ ENV["RAILS_ENV"] = "test" require_relative "config/environment" -require_relative "../../db/migrate/20260805000000_create_solid_objects_tables" -require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" -require_relative "../../db/migrate/20260813000000_rename_message_dispatch_columns" -require_relative "../../db/migrate/20260915000000_add_solid_objects_effect_recoveries" +require "solid_objects/schema_bootstrap" -CreateSolidObjectsTables.new.migrate(:up) -AddStateRevisionToSolidObjectsInstances.new.migrate(:up) -RenameMessageDispatchColumns.new.migrate(:up) -AddSolidObjectsEffectRecoveries.new.migrate(:up) +SolidObjects::SchemaBootstrap.install # A reminder that is already due, so the scheduler claims and enqueues it on # its first pass rather than waiting. diff --git a/test/dummy/prepare_cli_worker.rb b/test/dummy/prepare_cli_worker.rb index 0893b40..f7c1ef6 100644 --- a/test/dummy/prepare_cli_worker.rb +++ b/test/dummy/prepare_cli_worker.rb @@ -3,15 +3,9 @@ ENV["RAILS_ENV"] = "test" require_relative "config/environment" -require_relative "../../db/migrate/20260805000000_create_solid_objects_tables" -require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" -require_relative "../../db/migrate/20260813000000_rename_message_dispatch_columns" -require_relative "../../db/migrate/20260915000000_add_solid_objects_effect_recoveries" +require "solid_objects/schema_bootstrap" -CreateSolidObjectsTables.new.migrate(:up) -AddStateRevisionToSolidObjectsInstances.new.migrate(:up) -RenameMessageDispatchColumns.new.migrate(:up) -AddSolidObjectsEffectRecoveries.new.migrate(:up) +SolidObjects::SchemaBootstrap.install now = Time.current instance = SolidObjects::Instance.create!( diff --git a/test/dummy/web_mount_check.rb b/test/dummy/web_mount_check.rb index 9e5f258..0a71bee 100644 --- a/test/dummy/web_mount_check.rb +++ b/test/dummy/web_mount_check.rb @@ -11,16 +11,10 @@ require_relative "config/environment" require "solid_objects/web" require "rack/mock_request" -require_relative "../../db/migrate/20260805000000_create_solid_objects_tables" -require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" -require_relative "../../db/migrate/20260813000000_rename_message_dispatch_columns" -require_relative "../../db/migrate/20260915000000_add_solid_objects_effect_recoveries" +require "solid_objects/schema_bootstrap" ActiveRecord::Migration.verbose = false -CreateSolidObjectsTables.new.migrate(:up) -AddStateRevisionToSolidObjectsInstances.new.migrate(:up) -RenameMessageDispatchColumns.new.migrate(:up) -AddSolidObjectsEffectRecoveries.new.migrate(:up) +SolidObjects::SchemaBootstrap.install instance = SolidObjects::Instance.create!( actor_type: "MountCheckActor", diff --git a/test/integration/load_contract_test.rb b/test/integration/load_contract_test.rb index 680455c..9e413d7 100644 --- a/test/integration/load_contract_test.rb +++ b/test/integration/load_contract_test.rb @@ -19,6 +19,7 @@ class LoadContractTest < ActiveSupport::TestCase "client" => "the caller path, required by SolidObjects.client", "doctor" => "an operator tool, loaded by the doctor command", "errors" => "defines error classes individually, so no SolidObjects::Errors exists", + "schema_bootstrap" => "a setup helper, required by a script that builds the schema", "sync_diagnostics" => "the caller path, required with the client", "synchronous_invocation" => "the caller path, required with the client", "test_helper" => "opt-in, required by host application tests", diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb index 9faf5b5..37a8170 100644 --- a/test/integration/result_lookup_test.rb +++ b/test/integration/result_lookup_test.rb @@ -220,6 +220,64 @@ def reject_checkout assert_equal 1, CartActor.ref("alice").snapshot.items end + test "tells a pruned message from one that never existed" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + + error = assert_raises(SolidObjects::MessagePruned) do + reference.find_by(idempotency_key: "checkout-7f3a") + end + + assert_equal "checkout-7f3a", error.idempotency_key + assert_nil reference.find_by(idempotency_key: "never-used") + end + + test "remembers a key whose message was rejected" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "rejected-7f3a").reject_checkout + run_actors + SolidObjects::Message.delete_all + + assert_raises(SolidObjects::MessagePruned) do + reference.find_by(idempotency_key: "rejected-7f3a") + end + end + + test "remembers a key whose message died" do + CartActor.fail = true + reference = CartActor.ref("alice") + reference.async(idempotency_key: "dead-7f3a").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + SolidObjects::DeadLetter.delete_all + + assert_raises(SolidObjects::MessagePruned) do + reference.find_by(idempotency_key: "dead-7f3a") + end + end + + test "bounds what an instance remembers" do + SolidObjects.configuration.retained_idempotency_keys = 3 + reference = CartActor.ref("alice") + 5.times { |index| reference.async(idempotency_key: "key-#{index}").checkout(order_id: index) } + run_actors + SolidObjects::Message.delete_all + + assert_nil reference.find_by(idempotency_key: "key-0") + assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "key-4") } + assert_equal 3, SolidObjects::Instance.sole.completed_idempotency_keys.size + end + + test "remembers nothing for a message that carried no key" do + reference = CartActor.ref("alice") + reference.async.checkout(order_id: 1) + run_actors + + assert_empty SolidObjects::Instance.sole.completed_idempotency_keys + end + private def run_actors diff --git a/test/integration/separate_database_test.rb b/test/integration/separate_database_test.rb index 16cf46c..af5b6b3 100644 --- a/test/integration/separate_database_test.rb +++ b/test/integration/separate_database_test.rb @@ -52,16 +52,7 @@ def connect_solid_objects_to(database) pool: 10, timeout: 5_000 ) - [ - CreateSolidObjectsTables, - AddStateRevisionToSolidObjectsInstances, - RenameMessageDispatchColumns, - AddSolidObjectsEffectRecoveries - ].each do |migration_class| - migration = migration_class.new - migration.define_singleton_method(:connection) { SolidObjects::Record.connection } - migration.migrate(:up) - end + SolidObjects::SchemaBootstrap.install(connection: SolidObjects::Record.connection) SolidObjects.reset! authorize_all_actor_operations SolidObjects::Record.descendants.each(&:reset_column_information) diff --git a/test/unit/migration_bootstrap_test.rb b/test/unit/migration_bootstrap_test.rb new file mode 100644 index 0000000..c55996a --- /dev/null +++ b/test/unit/migration_bootstrap_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" + +class MigrationBootstrapTest < ActiveSupport::TestCase + ROOT = File.expand_path("../..", __dir__) + + test "no script names a migration class from a hand-copied list" do + offenders = ruby_files.select do |file| + body = File.read(file) + migration_classes.any? { |name| body.include?(name) } + end + + assert_empty offenders.map { |file| file.delete_prefix("#{ROOT}/") }, + "apply migrations through SolidObjects::SchemaBootstrap so the list cannot drift" + end + + private + + def migration_classes + Dir[File.join(ROOT, "db/migrate/*.rb")].map do |file| + File.basename(file, ".rb").sub(/\A\d+_/, "").camelize + end + end + + def ruby_files + Dir[File.join(ROOT, "{lib,test,examples,benchmark}/**/*.rb")] - [ + File.join(ROOT, "test/unit/migration_bootstrap_test.rb") + ] + end +end From 21c35bb9bd3ba6b65a55b42ece7d4eddf45f1938 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 08:34:26 -0700 Subject: [PATCH 04/16] feat: gate the pruned answer on the query hook An actor's remembered idempotency keys are actor state, so a caller the policy refuses must not learn from them that a key was used. The pruned answer now runs `authorize_query` with the same `__snapshot__` operation that `snapshot` uses, and a refused caller reads `nil` for a pruned message and for one that never existed alike. Without the gate the new test fails with: SolidObjects::MessagePruned: the message for idempotency key "checkout-7f3a" was pruned Validation: bundle exec rake (781 runs, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 ++ docs/architecture.md | 4 +++- docs/operations.md | 3 ++- lib/solid_objects/client.rb | 28 ++++++++++++++++++---- sig/generated/lib/solid_objects/client.rbs | 7 ++++-- test/integration/result_lookup_test.rb | 13 ++++++++++ 6 files changed, 48 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16508d8..d27d3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ store and no second write. `reference.find_by(idempotency_key:)` raises `SolidObjects::MessagePruned` for a key the actor remembers and whose message retention removed, and still answers `nil` for a key no caller ever sent. + The memory is actor state, so `authorize_query` gates the pruned answer and a + caller the policy refuses reads `nil` for both. `retained_idempotency_keys` bounds the memory and defaults to 64 keys for each actor. A lookup by request id cannot make the distinction, because the runtime, not the caller, generates a request id and no actor remembers one. diff --git a/docs/architecture.md b/docs/architecture.md index c7fc490..3b41e38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -442,7 +442,9 @@ than in a separate tombstone table, which needs no second store, no second write, and no separate retention. `reference.find_by(idempotency_key:)` raises `MessagePruned` for a key the actor remembers and whose message retention removed, and answers `nil` for a key no caller ever sent, so a client can tell -a lost result from a request that never arrived. +a lost result from a request that never arrived. The memory is actor state, so +`authorize_query` gates the pruned answer the way it gates `snapshot`, and a +caller the policy refuses reads `nil` for both. `retained_idempotency_keys` bounds the memory and defaults to 64 keys for each actor. Only a lookup by idempotency key can make the distinction. A request id is generated by the runtime rather than by the caller, so no actor remembers diff --git a/docs/operations.md b/docs/operations.md index f69ae87..d9abd55 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -549,7 +549,8 @@ observe it. names survives retention. A lookup by idempotency key still tells the two cases apart after pruning, because the actor remembers the keys of its own last `retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key -the actor remembers and answers `nil` for a key no caller ever sent. Raise +the actor remembers and answers `nil` for a key no caller ever sent. The +memory is actor state, so `authorize_query` gates the pruned answer. Raise `retained_idempotency_keys` above the default of 64 when an actor finishes more keyed turns than that inside the window in which a caller may retry. A lookup by request id answers `nil` in both cases, so a caller that must tell them apart diff --git a/lib/solid_objects/client.rb b/lib/solid_objects/client.rb index dc07357..d3ea3a1 100644 --- a/lib/solid_objects/client.rb +++ b/lib/solid_objects/client.rb @@ -105,7 +105,10 @@ def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization return readable_message(requested_message(request_id), authorization_context:) if request_id - readable_message(remembered_message(reference, idempotency_key), authorization_context:) + readable_message( + remembered_message(reference, idempotency_key, authorization_context:), + authorization_context: + ) end # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot @@ -183,8 +186,8 @@ def requested_message(request_id) Message.uncached { Message.find_by(request_id:) } end - # @rbs (Reference, String) -> Message? - def remembered_message(reference, idempotency_key) + # @rbs (Reference, String, authorization_context: untyped) -> Message? + def remembered_message(reference, idempotency_key, authorization_context:) instance = Instance.find_by( actor_type: reference.actor_type, actor_id: reference.actor_id @@ -193,9 +196,24 @@ def remembered_message(reference, idempotency_key) message = Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) } return message if message - raise MessagePruned, idempotency_key if Array(instance.completed_idempotency_keys).include?(idempotency_key) + return nil unless Array(instance.completed_idempotency_keys).include?(idempotency_key) + return nil unless readable_state?(reference, authorization_context:) + + raise MessagePruned, idempotency_key + end - nil + # @rbs (Reference, authorization_context: untyped) -> bool + def readable_state?(reference, authorization_context:) + authorize!( + hook: SolidObjects.configuration.authorize_query, + reference:, + operation: "__snapshot__", + arguments: {}, + authorization_context: + ) + true + rescue Unauthorized + false end # @rbs (Message?, authorization_context: untyped) -> MessageReference? diff --git a/sig/generated/lib/solid_objects/client.rbs b/sig/generated/lib/solid_objects/client.rbs index 425fc1f..661330f 100644 --- a/sig/generated/lib/solid_objects/client.rbs +++ b/sig/generated/lib/solid_objects/client.rbs @@ -35,8 +35,11 @@ module SolidObjects # @rbs (String) -> Message? def requested_message: (String) -> Message? - # @rbs (Reference, String) -> Message? - def remembered_message: (Reference, String) -> Message? + # @rbs (Reference, String, authorization_context: untyped) -> Message? + def remembered_message: (Reference, String, authorization_context: untyped) -> Message? + + # @rbs (Reference, authorization_context: untyped) -> bool + def readable_state?: (Reference, authorization_context: untyped) -> bool # @rbs (Message?, authorization_context: untyped) -> MessageReference? def readable_message: (Message?, authorization_context: untyped) -> MessageReference? diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb index 37a8170..0666e16 100644 --- a/test/integration/result_lookup_test.rb +++ b/test/integration/result_lookup_test.rb @@ -234,6 +234,19 @@ def reject_checkout assert_nil reference.find_by(idempotency_key: "never-used") end + test "does not tell a refused caller that a key was pruned" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + SolidObjects.configuration.authorize_query = ->(**) { false } + + assert_nil reference.find_by( + idempotency_key: "checkout-7f3a", + authorization_context: "stranger" + ) + end + test "remembers a key whose message was rejected" do reference = CartActor.ref("alice") reference.async(idempotency_key: "rejected-7f3a").reject_checkout From a902aa58089ebf55e54c0fe475e40a1290b40777 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 09:13:29 -0700 Subject: [PATCH 05/16] fix: report a missing migration in the doctor A parity audit against the TypeScript branch found three gaps. The doctor passed on an instance table without `completed_idempotency_keys`, so an operator who installed the gem and skipped the migration learned about it from a worker crash rather than from `solid_objects doctor`. The TypeScript doctor verifies its whole migration list, so it already caught the equivalent. Without the column in `EXPECTED_COLUMNS` the new test fails with `Expected true to not be truthy`. The dead path wrote the instance row for every dead message, including one that carried no key. `remember_key` now returns first, which matches what the TypeScript repository does and removes a write per dead message. The complete and reject paths fold the column into an update they already perform. The changelog described neither the migration an application must run nor `SolidObjects::SchemaBootstrap`. A test proves two keyed messages in one activation pass are both remembered, because the executor reads the locked instance row rather than a claim-time copy. Validation: bundle exec rake, plus the full suite on PostgreSQL 16, mysql2, and trilogy. 783 runs, 0 failures on each. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++++++++++ lib/solid_objects/doctor.rb | 2 +- lib/solid_objects/executor.rb | 9 ++++++++- sig/generated/lib/solid_objects/executor.rbs | 3 +++ test/integration/doctor_test.rb | 19 +++++++++++++++++++ test/integration/result_lookup_test.rb | 11 +++++++++++ 6 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d27d3c4..3e91443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,16 @@ `retained_idempotency_keys` bounds the memory and defaults to 64 keys for each actor. A lookup by request id cannot make the distinction, because the runtime, not the caller, generates a request id and no actor remembers one. +- Add `db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb`, + which adds `instances.completed_idempotency_keys` as `jsonb` on PostgreSQL and + `json` elsewhere. An application installs it with + `bin/rails solid_objects:install:migrations` and runs it before it upgrades a + worker, because the executor writes the column on every finished turn. The + doctor now reports the column as missing when it is not installed. +- Apply migrations through `SolidObjects::SchemaBootstrap`, which reads + `db/migrate`. Seven scripts each carried a hand-copied migration list, and + three of them applied an incomplete schema. A test fails if any script names a + migration class again. - Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its message meaning and answers `effects` and `broadcasts`, so the kind rides on diff --git a/lib/solid_objects/doctor.rb b/lib/solid_objects/doctor.rb index 0a26561..0949140 100644 --- a/lib/solid_objects/doctor.rb +++ b/lib/solid_objects/doctor.rb @@ -65,7 +65,7 @@ def to_s instances: %w[ id actor_type actor_id state state_version next_message_sequence activation_owner_id activation_token activation_expires_at - activation_generation + activation_generation completed_idempotency_keys ], messages: %w[ id instance_id delivery_mode arguments sequence attempt_count request_id diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index 383bce9..6da2057 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -405,7 +405,7 @@ def fail_message(error) if error.is_a?(NonRetryableError) || locked_message.attempt_count >= locked_message.max_attempts create_dead_letter(message: locked_message, error_details:, now:) - instance.update!(completed_idempotency_keys: remembered_keys(instance, locked_message)) + remember_key(instance, locked_message) dead = true else ReadyMessage.create!( @@ -475,6 +475,13 @@ def matching_claim! raise LostActivation, "message claim changed" end + # @rbs (Instance, Message) -> void + def remember_key(instance, message) + return unless message.idempotency_key + + instance.update!(completed_idempotency_keys: remembered_keys(instance, message)) + end + # @rbs (Instance, Message) -> Array[String] def remembered_keys(instance, message) remembered = Array(instance.completed_idempotency_keys) diff --git a/sig/generated/lib/solid_objects/executor.rbs b/sig/generated/lib/solid_objects/executor.rbs index 717503b..65e94d0 100644 --- a/sig/generated/lib/solid_objects/executor.rbs +++ b/sig/generated/lib/solid_objects/executor.rbs @@ -94,6 +94,9 @@ module SolidObjects # @rbs () -> ClaimedMessage def matching_claim!: () -> ClaimedMessage + # @rbs (Instance, Message) -> void + def remember_key: (Instance, Message) -> void + # @rbs (Instance, Message) -> Array[String] def remembered_keys: (Instance, Message) -> Array[String] diff --git a/test/integration/doctor_test.rb b/test/integration/doctor_test.rb index b669c58..a815dc7 100644 --- a/test/integration/doctor_test.rb +++ b/test/integration/doctor_test.rb @@ -113,6 +113,25 @@ class DoctorTest < ActiveSupport::TestCase assert_equal :skip, report.check(:sync_round_trip).status end + test "fails when a migration that a runtime path needs is missing" do + installed = SolidObjects::Record.connection + instances = SolidObjects.table_name(:instances) + connection = Object.new + connection.define_singleton_method(:data_sources) { installed.data_sources } + connection.define_singleton_method(:columns) do |table| + columns = installed.columns(table) + next columns unless table == instances + + columns.reject { |column| column.name == "completed_idempotency_keys" } + end + + report = SolidObjects::Doctor.new(connection:).call + + refute report.healthy? + assert_equal :fail, report.check(:schema).status + assert_match(/completed_idempotency_keys/, report.check(:schema).message) + end + test "reports live runtime roles" do now = SolidObjects.database_adapter.database_now SolidObjects::Process.create!( diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb index 0666e16..379336b 100644 --- a/test/integration/result_lookup_test.rb +++ b/test/integration/result_lookup_test.rb @@ -283,6 +283,17 @@ def reject_checkout assert_equal 3, SolidObjects::Instance.sole.completed_idempotency_keys.size end + test "remembers every key of one activation pass" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "first").checkout(order_id: 1) + reference.async(idempotency_key: "second").checkout(order_id: 2) + run_actors + SolidObjects::Message.delete_all + + assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "first") } + assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "second") } + end + test "remembers nothing for a message that carried no key" do reference = CartActor.ref("alice") reference.async.checkout(order_id: 1) From f3c6f8417c2187325947bae8a19f363188e77e2f Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 09:40:55 -0700 Subject: [PATCH 06/16] fix: name every migrated column in the doctor `EXPECTED_COLUMNS` omitted `instances.state_revision`, so the doctor passed on a schema that stops a worker. The list had drifted the same way four other times, and the earlier fix named one column rather than the rule. A test now applies the first migration to one scratch database and every migration to another, and fails when the doctor does not name a column that the later migrations add. A new table is skipped, because the missing-table check already reports it. The test failed with: Expected ["instances.state_revision", "messages.operation", "effects.success_operation", "effects.failure_operation", "dead_letters.operation"] to be empty. The TypeScript doctor needs no equivalent. It compares the recorded migration versions against `SCHEMA_VERSIONS`, which reports any migration that did not run. The gem cannot do that, because a host application copies these migrations under its own timestamps, so the column list is the only signal Ruby has. Validation: bundle exec rake (784 runs, 0 failures) and the full suite on PostgreSQL 16 (784 runs, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) --- lib/solid_objects/doctor.rb | 13 ++++---- test/integration/doctor_test.rb | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/lib/solid_objects/doctor.rb b/lib/solid_objects/doctor.rb index 0949140..6864d50 100644 --- a/lib/solid_objects/doctor.rb +++ b/lib/solid_objects/doctor.rb @@ -65,11 +65,11 @@ def to_s instances: %w[ id actor_type actor_id state state_version next_message_sequence activation_owner_id activation_token activation_expires_at - activation_generation completed_idempotency_keys + activation_generation state_revision completed_idempotency_keys ], messages: %w[ - id instance_id delivery_mode arguments sequence attempt_count request_id - result error rejection completed_at rejected_at + id instance_id operation delivery_mode arguments sequence attempt_count + request_id result error rejection completed_at rejected_at ], ready_messages: %w[id message_id instance_id sequence available_at], claimed_messages: %w[ @@ -77,10 +77,13 @@ def to_s activation_generation claimed_at ], reminders: %w[id instance_id operation next_run_at status], - effects: %w[id message_id instance_id effect_id status available_at], + effects: %w[ + id message_id instance_id effect_id status available_at + success_operation failure_operation + ], effect_recoveries: %w[effect_id instance_id recovery_operation status_operation recovery_timeout retired_at], broadcasts: %w[id message_id instance_id broadcast_id status available_at], - dead_letters: %w[id message_id instance_id actor_type actor_id attempts], + dead_letters: %w[id message_id instance_id actor_type actor_id operation attempts], administration_events: %w[id action kind subject_id actor occurred_at], redrives: %w[id kind filters status active_scope moved move_limit started_at finished_at] }.freeze diff --git a/test/integration/doctor_test.rb b/test/integration/doctor_test.rb index a815dc7..687a25e 100644 --- a/test/integration/doctor_test.rb +++ b/test/integration/doctor_test.rb @@ -3,6 +3,12 @@ require "database_test_helper" require "rake" require "solid_objects/doctor" +require "solid_objects/schema_bootstrap" +require "tmpdir" + +class DoctorScratchSchema < ActiveRecord::Base + self.abstract_class = true +end class DoctorTest < ActiveSupport::TestCase test "verifies a workerless synchronous installation" do @@ -132,6 +138,18 @@ class DoctorTest < ActiveSupport::TestCase assert_match(/completed_idempotency_keys/, report.check(:schema).message) end + test "names a column from every migration that follows the first" do + added = columns_later_migrations_add + + refute_empty added, "the schema has no migration after the first to verify" + unnamed = added.flat_map do |table, columns| + listed = SolidObjects::Doctor::EXPECTED_COLUMNS.fetch(table, []) + (columns - listed).map { |column| "#{table}.#{column}" } + end + + assert_empty unnamed, "the doctor cannot report these half-applied migrations" + end + test "reports live runtime roles" do now = SolidObjects.database_adapter.database_now SolidObjects::Process.create!( @@ -166,6 +184,41 @@ class DoctorTest < ActiveSupport::TestCase private + # @rbs () -> Hash[Symbol, Array[String]] + def columns_later_migrations_add + Dir.mktmpdir do |directory| + migrations = SolidObjects::SchemaBootstrap.migrations + first = schema_columns(directory, "first", migrations.first(1)) + whole = schema_columns(directory, "whole", migrations) + first.each_with_object({}) do |(table, columns), added| + later = whole.fetch(table) - columns + added[table] = later unless later.empty? + end + end + end + + # @rbs (String, String, Array[Class]) -> Hash[Symbol, Array[String]] + def schema_columns(directory, name, migrations) + DoctorScratchSchema.establish_connection( + adapter: "sqlite3", + database: File.join(directory, "#{name}.sqlite3") + ) + connection = DoctorScratchSchema.connection + migrations.each do |migration_class| + migration = migration_class.new + migration.define_singleton_method(:connection) { connection } + migration.migrate(:up) + end + SolidObjects::Doctor::EXPECTED_COLUMNS.keys.each_with_object({}) do |table, columns| + name = SolidObjects.table_name(table) + next unless connection.data_sources.include?(name) + + columns[table] = connection.columns(name).map(&:name) + end + ensure + DoctorScratchSchema.remove_connection + end + def hold_sqlite_write_lock locked = Queue.new release = Queue.new From 79d850351ae882935dc0b5ada1b59df0a5f90576 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 09:46:13 -0700 Subject: [PATCH 07/16] test: remember a re-sent key once A parity audit found the same untested branch in both languages. A key can finish twice on one actor: retention removes the message, the enqueue path no longer deduplicates it, and the caller sends it again. The list must move the key to the end rather than repeat it, and nothing proved that. Without the move the new test reads `["first", "second", "first"]`. The changelog and the roadmap did not record that the doctor now catches a half-applied migration. Validation: bundle exec rake (785 runs, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++++++ docs/roadmap.md | 4 +++- test/integration/result_lookup_test.rb | 13 +++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e91443..6bfe199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ `db/migrate`. Seven scripts each carried a hand-copied migration list, and three of them applied an incomplete schema. A test fails if any script names a migration class again. +- Report a half-applied migration in `solid_objects doctor`. The column list + omitted `instances.state_revision`, `messages.operation`, + `effects.success_operation`, `effects.failure_operation`, and + `dead_letters.operation`, so an application that skipped a migration read as + healthy and found out from a worker crash. A test fails when the list does not + name a column that a migration after the first adds. - Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its message meaning and answers `effects` and `broadcasts`, so the kind rides on diff --git a/docs/roadmap.md b/docs/roadmap.md index 00784de..b061ac9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -58,7 +58,9 @@ cannot reject the subscription or stop its siblings - Reconciliation read APIs - Installation doctor, authorization reference, fit guide, and legacy-state - migration cookbook + migration cookbook. The doctor names every column that a migration after the + first adds, so a half-applied migration fails the schema check rather than + reaching a worker. A test holds the list to that rule - Database server verification: each adapter reports its version against a tested minimum, MySQL confirms Solid Objects tables use InnoDB, and the doctor warns rather than refusing to run on an untested server diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb index 379336b..5ef5730 100644 --- a/test/integration/result_lookup_test.rb +++ b/test/integration/result_lookup_test.rb @@ -294,6 +294,19 @@ def reject_checkout assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "second") } end + test "remembers a re-sent key once" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "first").checkout(order_id: 1) + reference.async(idempotency_key: "second").checkout(order_id: 2) + run_actors + SolidObjects::Message.delete_all + reference.async(idempotency_key: "first").checkout(order_id: 3) + run_actors + + assert_equal [ "second", "first" ], + SolidObjects::Instance.sole.completed_idempotency_keys + end + test "remembers nothing for a message that carried no key" do reference = CartActor.ref("alice") reference.async.checkout(order_id: 1) From 028f4bcdac0703c47799f8f5259fff96ba0a9f4a Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 10:06:44 -0700 Subject: [PATCH 08/16] fix: bound the remembered keys by size An idempotency key has no length limit on every adapter. SQLite ignores the `limit: 191` the schema declares, so a 1000-character key is stored whole: sent 1000 chars, stored 1000 chars remembered entry length: 1000 Before this branch such a key sat in one message row that retention removed. The remembered list holds 64 of them on the instance row, which survives as long as the actor, so the growth became unbounded and persistent. `retained_idempotency_keys_bytes` bounds the serialized list at 16 KB. An actor drops its oldest keys until the list fits, so a key long enough to fill the limit by itself is never remembered and its lookup answers `nil` rather than raising. Validation: bundle exec rake (787 runs, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++ docs/architecture.md | 4 +- docs/operations.md | 8 +++ lib/solid_objects/configuration.rb | 4 ++ lib/solid_objects/executor.rb | 10 ++- .../lib/solid_objects/configuration.rbs | 68 ++++++++++--------- sig/generated/lib/solid_objects/executor.rbs | 3 + test/integration/result_lookup_test.rb | 25 +++++++ 8 files changed, 92 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfe199..79c525b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ `retained_idempotency_keys` bounds the memory and defaults to 64 keys for each actor. A lookup by request id cannot make the distinction, because the runtime, not the caller, generates a request id and no actor remembers one. + `retained_idempotency_keys_bytes` bounds the serialized memory as well, + because an idempotency key has no length limit on every adapter and the memory + outlives the message row. An actor drops its oldest keys until the list fits, + so a key long enough to fill the limit by itself is never remembered. - Add `db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb`, which adds `instances.completed_idempotency_keys` as `jsonb` on PostgreSQL and `json` elsewhere. An application installs it with diff --git a/docs/architecture.md b/docs/architecture.md index 3b41e38..1911d2d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -446,7 +446,9 @@ a lost result from a request that never arrived. The memory is actor state, so `authorize_query` gates the pruned answer the way it gates `snapshot`, and a caller the policy refuses reads `nil` for both. `retained_idempotency_keys` bounds the memory and defaults to 64 keys for each -actor. Only a lookup by idempotency key can make the distinction. A request id +actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB, +because an idempotency key has no length limit on every adapter and the memory +outlives the message row. An actor drops its oldest keys until the list fits. Only a lookup by idempotency key can make the distinction. A request id is generated by the runtime rather than by the caller, so no actor remembers one, and `client.find_by(request_id:)` answers `nil` in both cases. diff --git a/docs/operations.md b/docs/operations.md index d9abd55..2b746fb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -216,6 +216,7 @@ end | `process_retention` | 7 days | | `prune_batch_size` | 1,000 | | `retained_idempotency_keys` | 64 | +| `retained_idempotency_keys_bytes` | 16 KB | | `worker_count` | 1 | | `effect_worker_count` | 1 | | `broadcast_worker_count` | 1 | @@ -519,6 +520,7 @@ SolidObjects.configure do |configuration| configuration.process_retention = 7.days configuration.prune_batch_size = 1_000 configuration.retained_idempotency_keys = 64 + configuration.retained_idempotency_keys_bytes = 16.kilobytes end ``` @@ -556,6 +558,12 @@ keyed turns than that inside the window in which a caller may retry. A lookup by request id answers `nil` in both cases, so a caller that must tell them apart sends its own idempotency key. +`retained_idempotency_keys_bytes` bounds the serialized memory as well, because +an idempotency key has no length limit on every adapter and the memory outlives +the message row. An actor drops its oldest keys until the list fits, so a key +long enough to fill the limit by itself is never remembered and its lookup +answers `nil` rather than raising. + Actor expiration is disabled by default. `prune_instances` considers only actor types listed in `instance_retention_by_actor_type`, excludes active or paused actors, and preserves ready/claimed mailbox work, scheduled reminders, diff --git a/lib/solid_objects/configuration.rb b/lib/solid_objects/configuration.rb index c5cacd2..5187c9d 100644 --- a/lib/solid_objects/configuration.rb +++ b/lib/solid_objects/configuration.rb @@ -32,6 +32,7 @@ class Configuration # @rbs @process_retention: Numeric # @rbs @prune_batch_size: Integer # @rbs @retained_idempotency_keys: Integer + # @rbs @retained_idempotency_keys_bytes: Integer # @rbs @redrive_batch_size: Integer # @rbs @redrive_batch_pause: Float # @rbs @worker_count: Integer @@ -85,6 +86,7 @@ class Configuration :process_retention, :prune_batch_size, :retained_idempotency_keys, + :retained_idempotency_keys_bytes, :redrive_batch_size, :redrive_batch_pause, :worker_count, @@ -143,6 +145,7 @@ def initialize @process_retention = 7.days @prune_batch_size = 1_000 @retained_idempotency_keys = 64 + @retained_idempotency_keys_bytes = 16.kilobytes @redrive_batch_size = 100 @redrive_batch_pause = 0.05 @worker_count = 1 @@ -315,6 +318,7 @@ def positive_values process_retention:, prune_batch_size:, retained_idempotency_keys:, + retained_idempotency_keys_bytes:, redrive_batch_size: } end diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index 6da2057..e7efc30 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -489,7 +489,15 @@ def remembered_keys(instance, message) return remembered unless key return remembered if remembered.last == key - (remembered - [ key ] + [ key ]).last(SolidObjects.configuration.retained_idempotency_keys) + bounded(remembered - [ key ] + [ key ]) + end + + # @rbs (Array[String]) -> Array[String] + def bounded(keys) + kept = keys.last(SolidObjects.configuration.retained_idempotency_keys) + limit = SolidObjects.configuration.retained_idempotency_keys_bytes + kept.shift while kept.any? && kept.to_json.bytesize > limit + kept end # @rbs (Exception) -> Hash[String, untyped] diff --git a/sig/generated/lib/solid_objects/configuration.rbs b/sig/generated/lib/solid_objects/configuration.rbs index 340b441..cd1c4ca 100644 --- a/sig/generated/lib/solid_objects/configuration.rbs +++ b/sig/generated/lib/solid_objects/configuration.rbs @@ -2,71 +2,71 @@ module SolidObjects class Configuration - @shutdown_timeout: Float + @transmission_actor_type_resolver: Proc - @supervisor_monitor_interval: Float + @broadcast_worker_count: Integer - @retention_interval: Float + @effect_worker_count: Integer - @dead_process_cleanup_interval: Float + @worker_count: Integer - @message_retention: Numeric + @redrive_batch_pause: Float - @message_retention_by_actor_type: Hash[String, Numeric] + @redrive_batch_size: Integer - @instance_retention_by_actor_type: Hash[String, Numeric] + @retained_idempotency_keys_bytes: Integer - @process_retention: Numeric + @retained_idempotency_keys: Integer @prune_batch_size: Integer - @retained_idempotency_keys: Integer - - @redrive_batch_size: Integer + @process_retention: Numeric - @redrive_batch_pause: Float + @instance_retention_by_actor_type: Hash[String, Numeric] - @worker_count: Integer + @message_retention_by_actor_type: Hash[String, Numeric] - @effect_worker_count: Integer + @message_retention: Numeric - @broadcast_worker_count: Integer + @dead_process_cleanup_interval: Float - @reminder_scheduler_count: Integer + @retention_interval: Float - @connects_to: Hash[Symbol, untyped]? + @supervisor_monitor_interval: Float - @logger: untyped + @table_name_prefix: String - @stream_signing_secret: String? + @administration_identity: Proc - @broadcast_adapter: Proc? + @authorize_transmission: Proc - @wake_up_adapter: untyped + @authorize_administration: Proc - @component_path_resolver: Proc? + @authorize_subscription: Proc - @component_authorization_context: Proc + @authorize_destroy: Proc - @payload_authorization_context: Proc + @authorize_query: Proc @authorize_message: Proc - @authorize_query: Proc + @payload_authorization_context: Proc - @authorize_destroy: Proc + @component_authorization_context: Proc - @authorize_subscription: Proc + @component_path_resolver: Proc? - @authorize_administration: Proc + @wake_up_adapter: untyped - @authorize_transmission: Proc + @broadcast_adapter: Proc? - @administration_identity: Proc + @stream_signing_secret: String? - @transmission_actor_type_resolver: Proc + @logger: untyped - @table_name_prefix: String + @connects_to: Hash[Symbol, untyped]? + + @reminder_scheduler_count: Integer @polling_interval: Float @@ -106,6 +106,8 @@ module SolidObjects @process_alive_threshold: Float + @shutdown_timeout: Float + attr_accessor table_name_prefix: untyped attr_accessor polling_interval: untyped @@ -166,6 +168,8 @@ module SolidObjects attr_accessor retained_idempotency_keys: untyped + attr_accessor retained_idempotency_keys_bytes: untyped + attr_accessor redrive_batch_size: untyped attr_accessor redrive_batch_pause: untyped diff --git a/sig/generated/lib/solid_objects/executor.rbs b/sig/generated/lib/solid_objects/executor.rbs index 65e94d0..42196e3 100644 --- a/sig/generated/lib/solid_objects/executor.rbs +++ b/sig/generated/lib/solid_objects/executor.rbs @@ -100,6 +100,9 @@ module SolidObjects # @rbs (Instance, Message) -> Array[String] def remembered_keys: (Instance, Message) -> Array[String] + # @rbs (Array[String]) -> Array[String] + def bounded: (Array[String]) -> Array[String] + # @rbs (Exception) -> Hash[String, untyped] def serialized_error: (Exception) -> Hash[String, untyped] diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb index 5ef5730..1fef034 100644 --- a/test/integration/result_lookup_test.rb +++ b/test/integration/result_lookup_test.rb @@ -294,6 +294,31 @@ def reject_checkout assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "second") } end + test "bounds what an instance remembers by size" do + SolidObjects.configuration.retained_idempotency_keys_bytes = 64 + reference = CartActor.ref("alice") + keys = 3.times.map { |index| "#{index}-#{"k" * 20}" } + keys.each_with_index { |key, index| reference.async(idempotency_key: key).checkout(order_id: index) } + run_actors + + remembered = SolidObjects::Instance.sole.completed_idempotency_keys + + assert_equal keys.last(2), remembered + assert_operator remembered.to_json.bytesize, :<=, 64 + end + + test "remembers nothing for a key larger than what it retains" do + SolidObjects.configuration.retained_idempotency_keys_bytes = 16 + reference = CartActor.ref("alice") + key = "k" * 100 + reference.async(idempotency_key: key).checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + + assert_empty SolidObjects::Instance.sole.completed_idempotency_keys + assert_nil reference.find_by(idempotency_key: key) + end + test "remembers a re-sent key once" do reference = CartActor.ref("alice") reference.async(idempotency_key: "first").checkout(order_id: 1) From 627555e775185173daee37f471f167abb9f959b4 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 10:27:57 -0700 Subject: [PATCH 09/16] fix: list a dead row by the id retry accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two host applications in /tmp/testruby and /tmp/testnode exercised every unreleased feature as a consumer, and Ruby failed a call TypeScript answers. `dead_letters.effects.all` returned Active Record rows, so `row.id` was the primary key while `retry` reads `effect_id`: row.id # => 3 row.effect_id # => "b6d51a55-…" retry(row.id) # => ActiveRecord::RecordNotFound retry(row.effect_id) # => ok TypeScript returns `DeadRow` values whose `id` is what `retry` takes, so `retry(row.id)` has always worked there and the obvious Ruby call raised. `SolidObjects::DeadRow` now carries the identifier, the kind, the actor, the status, the attempt count, and the error, and `dead` still answers the relation for a caller that wants to scope it further. Wake-up selection rescued every exception, so a `NameError` from an unloaded model read as "the database could not be reached to select an adapter" and silently downgraded the process to in-process signalling. It now rescues database, system call, and IO errors only, and the new test fails without that with `NameError expected but nothing was raised`. `json` 3.0.2 breaks `ActiveSupport::JSON.decode`, and with it every JSON column this gem writes. The defect is in Active Support and a new Rails 8.1 application resolves that version today, so operations.md names the combination and the pin. Validation: bundle exec rake (789 runs, 0 failures), plus a Rails 8.1 host application where all 24 consumer checks pass. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 +++++++++ docs/operations.md | 16 ++++++++++ lib/solid_objects.rb | 3 +- lib/solid_objects/dead_letter_scope.rb | 19 ++++++++++-- lib/solid_objects/dead_row.rb | 15 ++++++++++ .../lib/solid_objects/dead_letter_scope.rbs | 7 +++-- sig/generated/lib/solid_objects/dead_row.rbs | 30 +++++++++++++++++++ test/integration/dead_letter_scopes_test.rb | 21 +++++++++++-- test/integration/wake_up_selection_test.rb | 11 +++++++ 9 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 lib/solid_objects/dead_row.rb create mode 100644 sig/generated/lib/solid_objects/dead_row.rbs diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c525b..accce1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,20 @@ healthy and found out from a worker crash. A test fails when the list does not name a column that a migration after the first adds. +- List a dead effect or broadcast as a `SolidObjects::DeadRow` rather than as + an Active Record row. `all` returned rows whose `id` was the primary key while + `retry` reads `effect_id` or `broadcast_id`, so the obvious + `scope.retry(scope.all.first.id)` raised `ActiveRecord::RecordNotFound`. + `DeadRow#id` is now the value `retry` accepts, which is what the TypeScript + runtime has always returned. `dead` still answers the relation for a caller + that wants to scope it further. +- Raise a load error rather than report an unreachable database. Wake-up + selection rescued every exception, so a `NameError` from an unloaded model + read as "the database could not be reached" and downgraded the process to + in-process signalling. It now rescues database, system call, and IO errors + only. +- Note that `json` 3.0.2 breaks `ActiveSupport::JSON.decode`, and therefore + every JSON column, in [docs/operations.md](docs/operations.md). - Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its message meaning and answers `effects` and `broadcasts`, so the kind rides on the receiver. `retry` returns a dead row to pending with a zero attempt count diff --git a/docs/operations.md b/docs/operations.md index 2b746fb..a919039 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -25,6 +25,22 @@ reports a failed or warned check rather than raising out of the command. ## Installing and upgrading +Solid Objects keeps actor state, message arguments, results, and the remembered +idempotency keys in JSON columns. Active Support decodes every one of them, and +`ActiveSupport::JSON.decode` raises with the `json` gem at 3.0.2: + +``` +ArgumentError: wrong number of arguments (given 2, expected 1) +``` + +The failure is in Active Support rather than in Solid Objects, and it reaches +every JSON column in a Rails application. A new Rails 8.1 application resolves +`json` 3.0.2 today, so pin the 2.x series until Rails ships a fix: + +```ruby +gem "json", "~> 2" +``` + Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order notes, then update the gem: diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index c78ab04..d74df6d 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -32,6 +32,7 @@ require "solid_objects/redrive_task" require "solid_objects/redrive_manager" require "solid_objects/redrive_runner" +require "solid_objects/dead_row" require "solid_objects/dead_letter_scope" require "solid_objects/dead_letter_manager" require "solid_objects/message_pruner" @@ -229,7 +230,7 @@ def resolve_wake_up WakeUpAdapters.build(configuration.wake_up_adapter) rescue ArgumentError raise - rescue => error + rescue ActiveRecord::ActiveRecordError, SystemCallError, IOError => error unreachable_wake_up(error) end diff --git a/lib/solid_objects/dead_letter_scope.rb b/lib/solid_objects/dead_letter_scope.rb index 1cb9b5f..7881c05 100644 --- a/lib/solid_objects/dead_letter_scope.rb +++ b/lib/solid_objects/dead_letter_scope.rb @@ -27,10 +27,25 @@ def self.for_kind(kind) raise ArgumentError, "unknown dead letter kind #{kind.inspect}" end - # @rbs (?authorization_context: untyped) -> ActiveRecord::Relation[untyped] + # @rbs (?authorization_context: untyped) -> Array[DeadRow] def all(authorization_context: nil) authorize!(:inspect, authorization_context:) - dead.order(updated_at: :desc, id: :desc) + dead.includes(:instance).order(updated_at: :desc, id: :desc).map { |row| dead_row(row) } + end + + # @rbs (untyped) -> DeadRow + def dead_row(row) + DeadRow.new( + id: row.public_send(identifier), + kind:, + actor_type: row.instance.actor_type, + actor_id: row.instance.actor_id, + status: row.status, + attempt_count: row.attempt_count, + available_at: row.available_at, + failed_at: row.updated_at, + error: row.error + ) end # @rbs (String, ?authorization_context: untyped) -> untyped diff --git a/lib/solid_objects/dead_row.rb b/lib/solid_objects/dead_row.rb new file mode 100644 index 0000000..4b89396 --- /dev/null +++ b/lib/solid_objects/dead_row.rb @@ -0,0 +1,15 @@ +# rbs_inline: enabled + +module SolidObjects + DeadRow = Data.define( + :id, + :kind, + :actor_type, + :actor_id, + :status, + :attempt_count, + :available_at, + :failed_at, + :error + ) +end diff --git a/sig/generated/lib/solid_objects/dead_letter_scope.rbs b/sig/generated/lib/solid_objects/dead_letter_scope.rbs index 3f65fd2..762d555 100644 --- a/sig/generated/lib/solid_objects/dead_letter_scope.rbs +++ b/sig/generated/lib/solid_objects/dead_letter_scope.rbs @@ -22,8 +22,11 @@ module SolidObjects # @rbs (String) -> DeadLetterScope def self.for_kind: (String) -> DeadLetterScope - # @rbs (?authorization_context: untyped) -> ActiveRecord::Relation[untyped] - def all: (?authorization_context: untyped) -> ActiveRecord::Relation[untyped] + # @rbs (?authorization_context: untyped) -> Array[DeadRow] + def all: (?authorization_context: untyped) -> Array[DeadRow] + + # @rbs (untyped) -> DeadRow + def dead_row: (untyped) -> DeadRow # @rbs (String, ?authorization_context: untyped) -> untyped def retry: (String, ?authorization_context: untyped) -> untyped diff --git a/sig/generated/lib/solid_objects/dead_row.rbs b/sig/generated/lib/solid_objects/dead_row.rbs new file mode 100644 index 0000000..eaebf71 --- /dev/null +++ b/sig/generated/lib/solid_objects/dead_row.rbs @@ -0,0 +1,30 @@ +# Generated from lib/solid_objects/dead_row.rb with RBS::Inline + +module SolidObjects + class DeadRow < Data + attr_reader id(): untyped + + attr_reader kind(): untyped + + attr_reader actor_type(): untyped + + attr_reader actor_id(): untyped + + attr_reader status(): untyped + + attr_reader attempt_count(): untyped + + attr_reader available_at(): untyped + + attr_reader failed_at(): untyped + + attr_reader error(): untyped + + def self.new: (untyped id, untyped kind, untyped actor_type, untyped actor_id, untyped status, untyped attempt_count, untyped available_at, untyped failed_at, untyped error) -> instance + | (id: untyped, kind: untyped, actor_type: untyped, actor_id: untyped, status: untyped, attempt_count: untyped, available_at: untyped, failed_at: untyped, error: untyped) -> instance + + def self.members: () -> [ :id, :kind, :actor_type, :actor_id, :status, :attempt_count, :available_at, :failed_at, :error ] + + def members: () -> [ :id, :kind, :actor_type, :actor_id, :status, :attempt_count, :available_at, :failed_at, :error ] + end +end diff --git a/test/integration/dead_letter_scopes_test.rb b/test/integration/dead_letter_scopes_test.rb index 2169879..42b3c8b 100644 --- a/test/integration/dead_letter_scopes_test.rb +++ b/test/integration/dead_letter_scopes_test.rb @@ -144,11 +144,26 @@ def run broadcasts = SolidObjects.dead_letters.broadcasts.all(authorization_context: "operator") messages = SolidObjects.dead_letters.all(authorization_context: "operator") - assert_equal [ effect.effect_id ], effects.map(&:effect_id) - assert_equal [ broadcast.broadcast_id ], broadcasts.map(&:broadcast_id) + assert_equal [ effect.effect_id ], effects.map(&:id) + assert_equal [ broadcast.broadcast_id ], broadcasts.map(&:id) assert_equal 1, messages.count end + test "lists a dead row whose id retry accepts" do + effect = dead_effect + + row = SolidObjects.dead_letters.effects.all(authorization_context: "operator").sole + + assert_equal effect.effect_id, row.id + assert_equal "effect", row.kind + assert_equal "dead", row.status + assert_equal "scoped-dead-letter-orders", row.actor_type + assert_equal "one", row.actor_id + SolidObjects.dead_letters.effects.retry(row.id, authorization_context: "operator") + + assert_equal "pending", effect.reload.status + end + test "reads only dead rows, not pending ones" do dead = dead_effect OrderActor.ref("two").async.place @@ -157,7 +172,7 @@ def run effects = SolidObjects.dead_letters.effects.all(authorization_context: "operator") assert_equal 2, SolidObjects::Effect.count - assert_equal [ dead.effect_id ], effects.map(&:effect_id) + assert_equal [ dead.effect_id ], effects.map(&:id) end test "refuses an unauthorized caller" do diff --git a/test/integration/wake_up_selection_test.rb b/test/integration/wake_up_selection_test.rb index a3af384..656a53b 100644 --- a/test/integration/wake_up_selection_test.rb +++ b/test/integration/wake_up_selection_test.rb @@ -260,6 +260,17 @@ def wait(timeout:) = false end end + test "a load error is not reported as an unreachable database" do + SolidObjects::WakeUpAdapters.singleton_class.alias_method(:built, :build) + SolidObjects::WakeUpAdapters.define_singleton_method(:build) do |_name| + raise NameError, "uninitialized constant SolidObjects::Record" + end + + assert_raises(NameError) { SolidObjects.wake_up } + ensure + SolidObjects::WakeUpAdapters.singleton_class.alias_method(:build, :built) + end + test "the doctor reports the selected adapter" do SolidObjects.configuration.authorize_administration = ->(**) { true } check = SolidObjects::Doctor.new.call.check(:wake_up) From d6d35f61ef0c4319aef25e2c9267556f7c89ee0d Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 12:05:55 -0700 Subject: [PATCH 10/16] fix: answer the Greptile review Four findings, all valid. `authorized_to_read?` rescued `UnknownActor`, a constant that does not exist, so a message whose actor type this process no longer registers raised `NameError: uninitialized constant SolidObjects::UnknownActor` instead of answering the deliberately indistinguishable nil. It rescues `UnknownActorType`. `outcome` read the message once and then called `status`, which read it again. A worker that finished the message between the two reads produced an outcome no snapshot ever held. `status_of` derives the status from the loaded row, and a test counts the reads of the messages table. `outcome` handed out the stored result and the backtrace by reference, so a caller could mutate a durable value. Both go through `Serialization.readonly_copy`, as the rejection details already did. `requested_message` was a private wrapper with one caller; it is inlined. A hook that raises is an outage, not a refusal, so `find_by` propagates it rather than reporting absence. Ruby already did, and a test now holds it there while TypeScript is corrected to match. Validation: bundle exec rake (794 runs, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) --- lib/solid_objects/client.rb | 14 +++--- lib/solid_objects/message_reference.rb | 28 ++++++----- lib/solid_objects/outcome.rb | 2 +- sig/generated/lib/solid_objects/client.rbs | 3 -- .../lib/solid_objects/message_reference.rbs | 5 ++ test/integration/result_lookup_test.rb | 50 +++++++++++++++++++ 6 files changed, 79 insertions(+), 23 deletions(-) diff --git a/lib/solid_objects/client.rb b/lib/solid_objects/client.rb index d3ea3a1..295395e 100644 --- a/lib/solid_objects/client.rb +++ b/lib/solid_objects/client.rb @@ -103,7 +103,12 @@ def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization raise ArgumentError, "find_by with idempotency_key: requires reference:" end - return readable_message(requested_message(request_id), authorization_context:) if request_id + if request_id + return readable_message( + Message.uncached { Message.find_by(request_id:) }, + authorization_context: + ) + end readable_message( remembered_message(reference, idempotency_key, authorization_context:), @@ -181,11 +186,6 @@ def enqueue_sync(reference:, operation:, arguments:, idempotency_key:, timeout:) ) end - # @rbs (String) -> Message? - def requested_message(request_id) - Message.uncached { Message.find_by(request_id:) } - end - # @rbs (Reference, String, authorization_context: untyped) -> Message? def remembered_message(reference, idempotency_key, authorization_context:) instance = Instance.find_by( @@ -243,7 +243,7 @@ def authorized_to_read?(message, authorization_context:) arguments: message.arguments, authorization_context: ) - rescue UnknownActor + rescue UnknownActorType false end diff --git a/lib/solid_objects/message_reference.rb b/lib/solid_objects/message_reference.rb index 5de4d06..afed7c1 100644 --- a/lib/solid_objects/message_reference.rb +++ b/lib/solid_objects/message_reference.rb @@ -35,16 +35,7 @@ def initialize(id:, request_id:, actor_type:, actor_id:, sequence:) # @rbs () -> String def status - Message.uncached do - message = Message.find(id) - return "rejected" if message.rejected? - return "completed" if message.completed? - return "dead" if message.dead? - return "claimed" if message.claimed? - return "ready" if message.ready? - - "unknown" - end + Message.uncached { status_of(Message.find(id)) } end # @rbs () -> untyped @@ -57,8 +48,8 @@ def outcome Message.uncached do message = Message.find(id) Outcome.new( - status: status, - result: message.result, + status: status_of(message), + result: Serialization.readonly_copy(message.result), error: ErrorRecord.from(message.error), rejection: RejectionRecord.from(message.rejection), attempts: message.attempt_count @@ -74,5 +65,18 @@ def wait(timeout: 5.seconds, authorization_context: nil) authorization_context: ) end + + private + + # @rbs (Message) -> String + def status_of(message) + return "rejected" if message.rejected? + return "completed" if message.completed? + return "dead" if message.dead? + return "claimed" if message.claimed? + return "ready" if message.ready? + + "unknown" + end end end diff --git a/lib/solid_objects/outcome.rb b/lib/solid_objects/outcome.rb index 62960a8..2a87e46 100644 --- a/lib/solid_objects/outcome.rb +++ b/lib/solid_objects/outcome.rb @@ -9,7 +9,7 @@ def self.from(error) new( class_name: error["class"], message: error["message"], - backtrace: Array(error["backtrace"]).freeze + backtrace: Serialization.readonly_copy(Array(error["backtrace"])) ) end end diff --git a/sig/generated/lib/solid_objects/client.rbs b/sig/generated/lib/solid_objects/client.rbs index 661330f..24a4b12 100644 --- a/sig/generated/lib/solid_objects/client.rbs +++ b/sig/generated/lib/solid_objects/client.rbs @@ -32,9 +32,6 @@ module SolidObjects # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference def enqueue_sync: (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference - # @rbs (String) -> Message? - def requested_message: (String) -> Message? - # @rbs (Reference, String, authorization_context: untyped) -> Message? def remembered_message: (Reference, String, authorization_context: untyped) -> Message? diff --git a/sig/generated/lib/solid_objects/message_reference.rbs b/sig/generated/lib/solid_objects/message_reference.rbs index 9579c3d..44390a0 100644 --- a/sig/generated/lib/solid_objects/message_reference.rbs +++ b/sig/generated/lib/solid_objects/message_reference.rbs @@ -39,5 +39,10 @@ module SolidObjects # @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped def wait: (?timeout: Numeric, ?authorization_context: untyped) -> untyped + + private + + # @rbs (Message) -> String + def status_of: (Message) -> String end end diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb index 1fef034..a77ed1f 100644 --- a/test/integration/result_lookup_test.rb +++ b/test/integration/result_lookup_test.rb @@ -176,6 +176,56 @@ def reject_checkout assert_nil CartActor.ref("alice").find_by(idempotency_key: "never-used") end + test "answers nil for a message whose actor type is not registered" do + original = CartActor.ref("alice").async.checkout(order_id: 1) + SolidObjects::Message.update_all(actor_type: "retired-carts") + + assert_nil SolidObjects.client.find_by(request_id: original.request_id) + end + + test "reports one snapshot for every outcome field" do + CartActor.ref("alice").sync.checkout(order_id: 9) + found = SolidObjects.client.find_by(request_id: SolidObjects::Message.sole.request_id) + reads = 0 + subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + reads += 1 if payload[:sql].include?(SolidObjects::Message.table_name) + end + + found.outcome + + assert_equal 1, reads, "every outcome field must describe one read" + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + test "hands out a frozen result" do + CartActor.ref("alice").sync.checkout(order_id: 9) + outcome = SolidObjects.client.find_by(request_id: SolidObjects::Message.sole.request_id).outcome + + assert_predicate outcome.result, :frozen? + assert_raises(FrozenError) { outcome.result["order_id"] = 1 } + end + + test "hands out a frozen backtrace" do + CartActor.fail = true + original = CartActor.ref("alice").async.checkout(order_id: 1) + run_actors + outcome = SolidObjects.client.find_by(request_id: original.request_id).outcome + + assert_predicate outcome.error.backtrace.first, :frozen? + end + + test "propagates an authorization failure rather than reporting absence" do + original = CartActor.ref("alice").async.checkout(order_id: 1) + SolidObjects.configuration.authorize_message = ->(**) { raise "authorization service is down" } + + error = assert_raises(RuntimeError) do + SolidObjects.client.find_by(request_id: original.request_id) + end + + assert_equal "authorization service is down", error.message + end + test "authorizes against the stored operation and arguments" do reference = CartActor.ref("alice") original = reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 4210) From 3111ddcdaffb83e25335def97fb1e64623e3d3e5 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 12:56:08 -0700 Subject: [PATCH 11/16] fix: authorize a pruned key like the message it replaces Greptile found a real disclosure. The pruned answer ran `authorize_query` against the synthetic `__snapshot__` operation, while a lookup whose row survives runs the hook for the stored operation. A caller allowed to read state but denied the operation could therefore tell `MessagePruned` from nil and learn that the operation had run. `snapshot` does not expose the remembered keys, so the gate granted more than the thing it borrowed. An actor now remembers the operation beside each key, and the pruned answer runs the same hook against the same operation a surviving row would. Without that the new test raises `SolidObjects::MessagePruned` where it expects nil. `authorized_to_invoke?` carries the check both paths share, and `authorized_to_read?` passes a message to it. Validation: bundle exec rake (795 runs, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++- docs/architecture.md | 9 ++-- docs/operations.md | 5 ++- lib/solid_objects/client.rb | 50 ++++++++++++---------- lib/solid_objects/executor.rb | 6 ++- sig/generated/lib/solid_objects/client.rbs | 6 +-- test/integration/result_lookup_test.rb | 20 +++++++-- 7 files changed, 65 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index accce1a..448e057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,11 @@ store and no second write. `reference.find_by(idempotency_key:)` raises `SolidObjects::MessagePruned` for a key the actor remembers and whose message retention removed, and still answers `nil` for a key no caller ever sent. - The memory is actor state, so `authorize_query` gates the pruned answer and a - caller the policy refuses reads `nil` for both. + An actor remembers the operation beside each key, so the pruned answer runs + the same hook against the same operation a lookup of the surviving row would, + and a caller the policy refuses reads `nil` for both. Gating it on `snapshot` + would have told a caller who may read state, but not the operation, that the + operation had run. `retained_idempotency_keys` bounds the memory and defaults to 64 keys for each actor. A lookup by request id cannot make the distinction, because the runtime, not the caller, generates a request id and no actor remembers one. diff --git a/docs/architecture.md b/docs/architecture.md index 1911d2d..e974a23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -442,9 +442,12 @@ than in a separate tombstone table, which needs no second store, no second write, and no separate retention. `reference.find_by(idempotency_key:)` raises `MessagePruned` for a key the actor remembers and whose message retention removed, and answers `nil` for a key no caller ever sent, so a client can tell -a lost result from a request that never arrived. The memory is actor state, so -`authorize_query` gates the pruned answer the way it gates `snapshot`, and a -caller the policy refuses reads `nil` for both. +a lost result from a request that never arrived. An actor remembers the operation beside each key, so +the pruned answer runs the same hook against the same operation that a lookup +of the surviving row would, and a caller the policy refuses reads `nil` whether +the message is pruned or never existed. Gating it on `snapshot` instead would +tell a caller who may read state, but not the operation, that the operation had +run. `retained_idempotency_keys` bounds the memory and defaults to 64 keys for each actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB, because an idempotency key has no length limit on every adapter and the memory diff --git a/docs/operations.md b/docs/operations.md index a919039..f7ff8c4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -567,8 +567,9 @@ observe it. names survives retention. A lookup by idempotency key still tells the two cases apart after pruning, because the actor remembers the keys of its own last `retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key -the actor remembers and answers `nil` for a key no caller ever sent. The -memory is actor state, so `authorize_query` gates the pruned answer. Raise +the actor remembers and answers `nil` for a key no caller ever sent. The actor +remembers the operation beside each key, so the pruned answer runs the same +authorization the surviving row would. Raise `retained_idempotency_keys` above the default of 64 when an actor finishes more keyed turns than that inside the window in which a caller may retry. A lookup by request id answers `nil` in both cases, so a caller that must tell them apart diff --git a/lib/solid_objects/client.rb b/lib/solid_objects/client.rb index 295395e..828cc1d 100644 --- a/lib/solid_objects/client.rb +++ b/lib/solid_objects/client.rb @@ -196,24 +196,19 @@ def remembered_message(reference, idempotency_key, authorization_context:) message = Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) } return message if message - return nil unless Array(instance.completed_idempotency_keys).include?(idempotency_key) - return nil unless readable_state?(reference, authorization_context:) - raise MessagePruned, idempotency_key - end - - # @rbs (Reference, authorization_context: untyped) -> bool - def readable_state?(reference, authorization_context:) - authorize!( - hook: SolidObjects.configuration.authorize_query, - reference:, - operation: "__snapshot__", + remembered = Array(instance.completed_idempotency_keys) + .find { |entry| entry["key"] == idempotency_key } + return nil unless remembered + return nil unless authorized_to_invoke?( + actor_type: reference.actor_type, + actor_id: reference.actor_id, + operation: remembered["operation"], arguments: {}, authorization_context: ) - true - rescue Unauthorized - false + + raise MessagePruned, idempotency_key end # @rbs (Message?, authorization_context: untyped) -> MessageReference? @@ -226,10 +221,21 @@ def readable_message(message, authorization_context:) # @rbs (Message, authorization_context: untyped) -> bool def authorized_to_read?(message, authorization_context:) - actor_class = SolidObjects.registry.fetch(message.actor_type) - operation = message.operation.to_sym - query = actor_class.definition.queries.key?(operation) - return false unless query || actor_class.definition.messages.key?(operation) + authorized_to_invoke?( + actor_type: message.actor_type, + actor_id: message.actor_id, + operation: message.operation, + arguments: message.arguments, + authorization_context: + ) + end + + # @rbs (actor_type: String, actor_id: String, operation: String, arguments: Hash[String, untyped], authorization_context: untyped) -> bool + def authorized_to_invoke?(actor_type:, actor_id:, operation:, arguments:, authorization_context:) + actor_class = SolidObjects.registry.fetch(actor_type) + operation_symbol = operation.to_sym + query = actor_class.definition.queries.key?(operation_symbol) + return false unless query || actor_class.definition.messages.key?(operation_symbol) hook = if query SolidObjects.configuration.authorize_query @@ -237,10 +243,10 @@ def authorized_to_read?(message, authorization_context:) SolidObjects.configuration.authorize_message end hook.call( - actor_type: message.actor_type, - actor_id: message.actor_id, - operation: message.operation.to_s, - arguments: message.arguments, + actor_type:, + actor_id:, + operation: operation.to_s, + arguments:, authorization_context: ) rescue UnknownActorType diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index e7efc30..d3b1cda 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -487,9 +487,11 @@ def remembered_keys(instance, message) remembered = Array(instance.completed_idempotency_keys) key = message.idempotency_key return remembered unless key - return remembered if remembered.last == key - bounded(remembered - [ key ] + [ key ]) + entry = { "key" => key, "operation" => message.operation } + return remembered if remembered.last == entry + + bounded(remembered.reject { |value| value["key"] == key } + [ entry ]) end # @rbs (Array[String]) -> Array[String] diff --git a/sig/generated/lib/solid_objects/client.rbs b/sig/generated/lib/solid_objects/client.rbs index 24a4b12..38d81bb 100644 --- a/sig/generated/lib/solid_objects/client.rbs +++ b/sig/generated/lib/solid_objects/client.rbs @@ -35,15 +35,15 @@ module SolidObjects # @rbs (Reference, String, authorization_context: untyped) -> Message? def remembered_message: (Reference, String, authorization_context: untyped) -> Message? - # @rbs (Reference, authorization_context: untyped) -> bool - def readable_state?: (Reference, authorization_context: untyped) -> bool - # @rbs (Message?, authorization_context: untyped) -> MessageReference? def readable_message: (Message?, authorization_context: untyped) -> MessageReference? # @rbs (Message, authorization_context: untyped) -> bool def authorized_to_read?: (Message, authorization_context: untyped) -> bool + # @rbs (actor_type: String, actor_id: String, operation: String, arguments: Hash[String, untyped], authorization_context: untyped) -> bool + def authorized_to_invoke?: (actor_type: String, actor_id: String, operation: String, arguments: Hash[String, untyped], authorization_context: untyped) -> bool + # @rbs (MessageReference, Message) -> void def validate_message_reference!: (MessageReference, Message) -> void diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb index a77ed1f..f21b667 100644 --- a/test/integration/result_lookup_test.rb +++ b/test/integration/result_lookup_test.rb @@ -289,6 +289,7 @@ def reject_checkout reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) run_actors SolidObjects::Message.delete_all + SolidObjects.configuration.authorize_message = ->(**) { false } SolidObjects.configuration.authorize_query = ->(**) { false } assert_nil reference.find_by( @@ -297,6 +298,17 @@ def reject_checkout ) end + test "does not tell a snapshot-only caller that a key was pruned" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + SolidObjects.configuration.authorize_query = ->(**) { true } + SolidObjects.configuration.authorize_message = ->(**) { false } + + assert_nil reference.find_by(idempotency_key: "checkout-7f3a") + end + test "remembers a key whose message was rejected" do reference = CartActor.ref("alice") reference.async(idempotency_key: "rejected-7f3a").reject_checkout @@ -345,7 +357,7 @@ def reject_checkout end test "bounds what an instance remembers by size" do - SolidObjects.configuration.retained_idempotency_keys_bytes = 64 + SolidObjects.configuration.retained_idempotency_keys_bytes = 128 reference = CartActor.ref("alice") keys = 3.times.map { |index| "#{index}-#{"k" * 20}" } keys.each_with_index { |key, index| reference.async(idempotency_key: key).checkout(order_id: index) } @@ -353,8 +365,8 @@ def reject_checkout remembered = SolidObjects::Instance.sole.completed_idempotency_keys - assert_equal keys.last(2), remembered - assert_operator remembered.to_json.bytesize, :<=, 64 + assert_equal keys.last(2), remembered.map { |entry| entry["key"] } + assert_operator remembered.to_json.bytesize, :<=, 128 end test "remembers nothing for a key larger than what it retains" do @@ -379,7 +391,7 @@ def reject_checkout run_actors assert_equal [ "second", "first" ], - SolidObjects::Instance.sole.completed_idempotency_keys + SolidObjects::Instance.sole.completed_idempotency_keys.map { |entry| entry["key"] } end test "remembers nothing for a message that carried no key" do From 863dea6f351b689599be7913f56c25a60fcc0266 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 13:12:06 -0700 Subject: [PATCH 12/16] docs: point scaling limits at Pro, not at this roadmap `fit.md` told a reader that a request-path rate limiter is a poor actor and sold the shape that answers it, while `roadmap.md` promised "distributed rate limits, global admission hooks, and cache-capacity eviction" in this gem. The two documents gave opposite advice about one use case, and the roadmap gave a reader a reason to wait for something this gem should not build. Each of the three is hot, request-critical, and loss-tolerant, and every invocation here writes one permanent message row, so the cost model in `fit.md` already rules out checking on the request path. They are answered by Solid Objects Pro's grouped and ephemeral operations, and `fit.md`, `roadmap.md`, and `architecture.md` now say so in the same words. Turbo append intents stay an open milestone. They cost nothing at high QPS, the reactive layer implements every other verb, and the renderer already emits `turbo-stream action="append"` for batch refreshes and payload delivery, so what remains is letting an application direct one. The README listed "Rate limits and account quotas" among the fits. The low-rate quota is the case that fits, so it reads that way now. Validation: bundle exec rake (795 runs, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- docs/architecture.md | 8 ++++---- docs/fit.md | 6 ++++++ docs/roadmap.md | 16 ++++++++++------ 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7322961..7dcda16 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Solid Object Rails Actors elegantly fit anything where one identifiable thing mu - Ticket holds and reservations - Multiplayer games and shared rooms - Shopping carts and checkout recovery -- Rate limits and account quotas +- Low-rate quotas and account limits - Session expiration - Job leases and workflows - Connected devices diff --git a/docs/architecture.md b/docs/architecture.md index e974a23..8411534 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ through the client. ### Client and mailbox -The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented. +The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented and are not planned here. Message execution state is table membership, not a status column. The durable message remains for results, retention, and diagnostics. Only live work occupies `ready_messages` or `claimed_messages`, so completed history cannot inflate the polling index. @@ -781,11 +781,11 @@ Enqueue counts unfinished rows under the locked actor instance and rejects with ### Per-actor rate limits -The initial implementation supplies the mailbox cap. Distributed token buckets or time-window counters are a hardening milestone. +This runtime supplies the mailbox cap. Distributed token buckets and time-window counters are not planned here, because a request-path limiter is hot and loss-tolerant while every invocation writes one permanent message row. Solid Objects Pro answers that shape with grouped and ephemeral operations, which [fit](fit.md) describes. ### Global enqueue limits -Global admission hooks are not implemented. A future hook can reject based on database health or application policy without introducing a strict global counter as a contention hotspot. +Global admission hooks are not implemented and are not planned here, for the same reason as per-actor rate limits. A strict global counter would also be a contention hotspot. Reject on database health or application policy in front of the actor instead. ### Payload size @@ -900,7 +900,7 @@ All backends use unique identity and sequence constraints, short transactions, a 14. **How does synchronous invocation work across processes?** The caller first tries to claim and execute the actor locally. If another process owns it, a wake-up adapter prompts a durable result query and bounded polling remains the fallback. 15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference, or with `find_by` from the request id or the idempotency key when that reference is gone. An enqueue timeout leaves no message. Running Ruby code is not preempted. 16. **How are results cleaned up?** `prune_messages` deletes eligible terminal history in bounded batches after global or per-actor retention. It previews by default and preserves live work, dead letters, retry links, and unfinished outboxes. -17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields; rate and global admission controls remain roadmap work. +17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields. Rate and global admission controls are not planned here; Solid Objects Pro answers that shape. 18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically. 19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit. 20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers. diff --git a/docs/fit.md b/docs/fit.md index 84ff868..b3be31b 100644 --- a/docs/fit.md +++ b/docs/fit.md @@ -68,6 +68,12 @@ a presence signal, or a view count. Reactive projections materialize a read model from the durable broadcast outbox, so request-path reads stop competing with mailbox work. +Distributed per-actor rate limits, global admission control, and cache-capacity +eviction are answered there rather than in this gem. Each one is hot and +request-critical, and this gem writes one permanent message row for every +invocation, so the cost model above rules out a limiter that checks on the +request path. They are not open roadmap items here. + ## Cost model Every synchronous or asynchronous invocation: diff --git a/docs/roadmap.md b/docs/roadmap.md index b061ac9..e3563fc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -170,7 +170,11 @@ loads them in every process, and a rejected subscription reports which condition caused it instead of closing the socket silently. - Backpressure: mailbox/payload/state/result caps and fair yields exist; - distributed per-actor rate limits and global admission control do not. The + distributed per-actor rate limits, global admission control, and + cache-capacity eviction are not planned here. They are hot, request-path, and + loss-tolerant, so one durable ordered message per check is the wrong shape, + which [fit](fit.md) already says. Solid Objects Pro answers them with grouped + and ephemeral operations. The state cap is a limit rather than an operating point. `max_state_bytes` defaults to 5 MB, and committed throughput measured on SQLite falls about 53 times between an empty state and 1 MB of state, which `docs/benchmarks.md` @@ -208,12 +212,12 @@ ## Next milestones 1. Broaden deadlock retry classification. -2. Add Turbo append intents. -3. Add distributed rate limits, global admission hooks, and cache-capacity - eviction. -4. Expand security scanning beyond the Brakeman scan, such as dependency +2. Add Turbo append intents. The renderer already emits the `append` action for + batch refreshes and payload delivery, so what remains is letting an + application direct one. +3. Expand security scanning beyond the Brakeman scan, such as dependency auditing and secret scanning. -5. Benchmark all workloads under documented hardware/database settings and +4. Benchmark all workloads under documented hardware/database settings and publish adapter-specific adoption measurements. Throughput, synchronous latency, query counts, and the three reactive delivery paths are measured on SQLite; adapter-specific and end-to-end browser measurements are not. From b60dec528084ea9b93a079cf21c6e9d6b12a8f2e Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 13:28:20 -0700 Subject: [PATCH 13/16] fix: preserve pruned lookup authorization Retain original arguments within the history byte bound and deny legacy entries without them. Inline the single-use lookup, bound, and dead-row helpers requested by review. See #79 --- docs/architecture.md | 9 +++- docs/operations.md | 7 ++- docs/roadmap.md | 3 +- lib/solid_objects/client.rb | 46 ++++++++----------- lib/solid_objects/dead_letter_scope.rb | 29 ++++++------ lib/solid_objects/executor.rb | 12 ++--- sig/generated/lib/solid_objects/client.rbs | 3 -- .../lib/solid_objects/dead_letter_scope.rbs | 3 -- sig/generated/lib/solid_objects/executor.rbs | 7 +-- test/integration/result_lookup_test.rb | 29 +++++++++++- 10 files changed, 81 insertions(+), 67 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8411534..845a388 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -442,12 +442,17 @@ than in a separate tombstone table, which needs no second store, no second write, and no separate retention. `reference.find_by(idempotency_key:)` raises `MessagePruned` for a key the actor remembers and whose message retention removed, and answers `nil` for a key no caller ever sent, so a client can tell -a lost result from a request that never arrived. An actor remembers the operation beside each key, so -the pruned answer runs the same hook against the same operation that a lookup +a lost result from a request that never arrived. An actor remembers the operation and original arguments beside each key, so +the pruned answer runs the same hook against the same operation and arguments that a lookup of the surviving row would, and a caller the policy refuses reads `nil` whether the message is pruned or never existed. Gating it on `snapshot` instead would tell a caller who may read state, but not the operation, that the operation had run. +Remembered arguments count toward the serialized memory limit and remain until +the entry is evicted or the instance is removed. Entries from older versions +that lack arguments return absence after pruning because their original +authorization cannot be reproduced. + `retained_idempotency_keys` bounds the memory and defaults to 64 keys for each actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB, because an idempotency key has no length limit on every adapter and the memory diff --git a/docs/operations.md b/docs/operations.md index f7ff8c4..df585c4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -568,13 +568,18 @@ names survives retention. A lookup by idempotency key still tells the two cases apart after pruning, because the actor remembers the keys of its own last `retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key the actor remembers and answers `nil` for a key no caller ever sent. The actor -remembers the operation beside each key, so the pruned answer runs the same +remembers the operation and original arguments beside each key, so the pruned answer runs the same authorization the surviving row would. Raise `retained_idempotency_keys` above the default of 64 when an actor finishes more keyed turns than that inside the window in which a caller may retry. A lookup by request id answers `nil` in both cases, so a caller that must tell them apart sends its own idempotency key. +Remembered arguments count toward the serialized memory limit and remain until +the entry is evicted or the instance is removed. Entries from older versions +that lack arguments return absence after pruning because their original +authorization cannot be reproduced. + `retained_idempotency_keys_bytes` bounds the serialized memory as well, because an idempotency key has no length limit on every adapter and the memory outlives the message row. An actor drops its oldest keys until the list fits, so a key diff --git a/docs/roadmap.md b/docs/roadmap.md index e3563fc..db7e4f2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -126,7 +126,8 @@ lookup by key raises `MessagePruned` for a message that retention removed and answers `nil` for a message that never existed. A lookup by request ID cannot make that distinction, because the runtime generates a request ID and no - actor remembers one + actor remembers one. Pruned lookups retain the original arguments for authorization + within the byte limit; older entries without arguments answer `nil` ## Partially implemented diff --git a/lib/solid_objects/client.rb b/lib/solid_objects/client.rb index 828cc1d..20c4936 100644 --- a/lib/solid_objects/client.rb +++ b/lib/solid_objects/client.rb @@ -110,10 +110,27 @@ def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization ) end - readable_message( - remembered_message(reference, idempotency_key, authorization_context:), + instance = Instance.find_by( + actor_type: reference.actor_type, + actor_id: reference.actor_id + ) + return nil unless instance + + message = Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) } + return readable_message(message, authorization_context:) if message + + remembered = Array(instance.completed_idempotency_keys) + .find { |entry| entry["key"] == idempotency_key } + return nil unless remembered && remembered["arguments"].is_a?(Hash) + return nil unless authorized_to_invoke?( + actor_type: reference.actor_type, + actor_id: reference.actor_id, + operation: remembered["operation"], + arguments: remembered["arguments"], authorization_context: ) + + raise MessagePruned, idempotency_key end # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot @@ -186,31 +203,6 @@ def enqueue_sync(reference:, operation:, arguments:, idempotency_key:, timeout:) ) end - # @rbs (Reference, String, authorization_context: untyped) -> Message? - def remembered_message(reference, idempotency_key, authorization_context:) - instance = Instance.find_by( - actor_type: reference.actor_type, - actor_id: reference.actor_id - ) - return nil unless instance - - message = Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) } - return message if message - - remembered = Array(instance.completed_idempotency_keys) - .find { |entry| entry["key"] == idempotency_key } - return nil unless remembered - return nil unless authorized_to_invoke?( - actor_type: reference.actor_type, - actor_id: reference.actor_id, - operation: remembered["operation"], - arguments: {}, - authorization_context: - ) - - raise MessagePruned, idempotency_key - end - # @rbs (Message?, authorization_context: untyped) -> MessageReference? def readable_message(message, authorization_context:) return nil unless message diff --git a/lib/solid_objects/dead_letter_scope.rb b/lib/solid_objects/dead_letter_scope.rb index 7881c05..2150c52 100644 --- a/lib/solid_objects/dead_letter_scope.rb +++ b/lib/solid_objects/dead_letter_scope.rb @@ -30,22 +30,19 @@ def self.for_kind(kind) # @rbs (?authorization_context: untyped) -> Array[DeadRow] def all(authorization_context: nil) authorize!(:inspect, authorization_context:) - dead.includes(:instance).order(updated_at: :desc, id: :desc).map { |row| dead_row(row) } - end - - # @rbs (untyped) -> DeadRow - def dead_row(row) - DeadRow.new( - id: row.public_send(identifier), - kind:, - actor_type: row.instance.actor_type, - actor_id: row.instance.actor_id, - status: row.status, - attempt_count: row.attempt_count, - available_at: row.available_at, - failed_at: row.updated_at, - error: row.error - ) + dead.includes(:instance).order(updated_at: :desc, id: :desc).map do |row| + DeadRow.new( + id: row.public_send(identifier), + kind:, + actor_type: row.instance.actor_type, + actor_id: row.instance.actor_id, + status: row.status, + attempt_count: row.attempt_count, + available_at: row.available_at, + failed_at: row.updated_at, + error: row.error + ) + end end # @rbs (String, ?authorization_context: untyped) -> untyped diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index d3b1cda..8474a6a 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -482,21 +482,17 @@ def remember_key(instance, message) instance.update!(completed_idempotency_keys: remembered_keys(instance, message)) end - # @rbs (Instance, Message) -> Array[String] + # @rbs (Instance, Message) -> Array[Hash[String, untyped]] def remembered_keys(instance, message) remembered = Array(instance.completed_idempotency_keys) key = message.idempotency_key return remembered unless key - entry = { "key" => key, "operation" => message.operation } + entry = { "key" => key, "operation" => message.operation, "arguments" => message.arguments } return remembered if remembered.last == entry - bounded(remembered.reject { |value| value["key"] == key } + [ entry ]) - end - - # @rbs (Array[String]) -> Array[String] - def bounded(keys) - kept = keys.last(SolidObjects.configuration.retained_idempotency_keys) + kept = (remembered.reject { |value| value["key"] == key } + [ entry ]) + .last(SolidObjects.configuration.retained_idempotency_keys) limit = SolidObjects.configuration.retained_idempotency_keys_bytes kept.shift while kept.any? && kept.to_json.bytesize > limit kept diff --git a/sig/generated/lib/solid_objects/client.rbs b/sig/generated/lib/solid_objects/client.rbs index 38d81bb..f378eb0 100644 --- a/sig/generated/lib/solid_objects/client.rbs +++ b/sig/generated/lib/solid_objects/client.rbs @@ -32,9 +32,6 @@ module SolidObjects # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference def enqueue_sync: (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference - # @rbs (Reference, String, authorization_context: untyped) -> Message? - def remembered_message: (Reference, String, authorization_context: untyped) -> Message? - # @rbs (Message?, authorization_context: untyped) -> MessageReference? def readable_message: (Message?, authorization_context: untyped) -> MessageReference? diff --git a/sig/generated/lib/solid_objects/dead_letter_scope.rbs b/sig/generated/lib/solid_objects/dead_letter_scope.rbs index 762d555..1110b03 100644 --- a/sig/generated/lib/solid_objects/dead_letter_scope.rbs +++ b/sig/generated/lib/solid_objects/dead_letter_scope.rbs @@ -25,9 +25,6 @@ module SolidObjects # @rbs (?authorization_context: untyped) -> Array[DeadRow] def all: (?authorization_context: untyped) -> Array[DeadRow] - # @rbs (untyped) -> DeadRow - def dead_row: (untyped) -> DeadRow - # @rbs (String, ?authorization_context: untyped) -> untyped def retry: (String, ?authorization_context: untyped) -> untyped diff --git a/sig/generated/lib/solid_objects/executor.rbs b/sig/generated/lib/solid_objects/executor.rbs index 42196e3..697d0c2 100644 --- a/sig/generated/lib/solid_objects/executor.rbs +++ b/sig/generated/lib/solid_objects/executor.rbs @@ -97,11 +97,8 @@ module SolidObjects # @rbs (Instance, Message) -> void def remember_key: (Instance, Message) -> void - # @rbs (Instance, Message) -> Array[String] - def remembered_keys: (Instance, Message) -> Array[String] - - # @rbs (Array[String]) -> Array[String] - def bounded: (Array[String]) -> Array[String] + # @rbs (Instance, Message) -> Array[Hash[String, untyped]] + def remembered_keys: (Instance, Message) -> Array[Hash[String, untyped]] # @rbs (Exception) -> Hash[String, untyped] def serialized_error: (Exception) -> Hash[String, untyped] diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb index f21b667..2477e47 100644 --- a/test/integration/result_lookup_test.rb +++ b/test/integration/result_lookup_test.rb @@ -309,6 +309,33 @@ def reject_checkout assert_nil reference.find_by(idempotency_key: "checkout-7f3a") end + test "authorizes pruned keys with the original arguments" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "protected").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + seen = [] + SolidObjects.configuration.authorize_message = lambda do |arguments:, **| + seen << arguments + arguments["order_id"] != 1 + end + + assert_nil reference.find_by(idempotency_key: "protected") + assert_equal [ { "order_id" => 1 } ], seen + SolidObjects.configuration.authorize_message = ->(arguments:, **) { arguments["order_id"] == 1 } + assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "protected") } + end + + test "does not disclose legacy keys without authorization arguments" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "legacy").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + SolidObjects::Instance.sole.update!(completed_idempotency_keys: [ { "key" => "legacy", "operation" => "checkout" } ]) + + assert_nil reference.find_by(idempotency_key: "legacy") + end + test "remembers a key whose message was rejected" do reference = CartActor.ref("alice") reference.async(idempotency_key: "rejected-7f3a").reject_checkout @@ -365,7 +392,7 @@ def reject_checkout remembered = SolidObjects::Instance.sole.completed_idempotency_keys - assert_equal keys.last(2), remembered.map { |entry| entry["key"] } + assert_equal keys.last(1), remembered.map { |entry| entry["key"] } assert_operator remembered.to_json.bytesize, :<=, 128 end From 32cba112127562ce7c7d19bfb1ae5f1d9973dc9d Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 13:34:36 -0700 Subject: [PATCH 14/16] refactor: inline remaining single-use wrappers Inline terminal key persistence and migration file discovery at their only call sites. Focused lookup/bootstrap tests, lint, RBS validation, and Steep pass. See #79 --- lib/solid_objects/executor.rb | 11 +++-------- lib/solid_objects/schema_bootstrap.rb | 9 +-------- sig/generated/lib/solid_objects/executor.rbs | 3 --- sig/generated/lib/solid_objects/schema_bootstrap.rbs | 3 --- 4 files changed, 4 insertions(+), 22 deletions(-) diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index 8474a6a..3073a4e 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -405,7 +405,9 @@ def fail_message(error) if error.is_a?(NonRetryableError) || locked_message.attempt_count >= locked_message.max_attempts create_dead_letter(message: locked_message, error_details:, now:) - remember_key(instance, locked_message) + if locked_message.idempotency_key + instance.update!(completed_idempotency_keys: remembered_keys(instance, locked_message)) + end dead = true else ReadyMessage.create!( @@ -475,13 +477,6 @@ def matching_claim! raise LostActivation, "message claim changed" end - # @rbs (Instance, Message) -> void - def remember_key(instance, message) - return unless message.idempotency_key - - instance.update!(completed_idempotency_keys: remembered_keys(instance, message)) - end - # @rbs (Instance, Message) -> Array[Hash[String, untyped]] def remembered_keys(instance, message) remembered = Array(instance.completed_idempotency_keys) diff --git a/lib/solid_objects/schema_bootstrap.rb b/lib/solid_objects/schema_bootstrap.rb index 77945ef..e49536e 100644 --- a/lib/solid_objects/schema_bootstrap.rb +++ b/lib/solid_objects/schema_bootstrap.rb @@ -17,18 +17,11 @@ def install(connection: nil) # @rbs () -> Array[Class] def migrations - migration_files.map do |file| + Dir[File.expand_path("../../db/migrate/*.rb", __dir__)].sort.map do |file| require file Object.const_get(File.basename(file, ".rb").sub(/\A\d+_/, "").camelize) end end - - private - - # @rbs () -> Array[String] - def migration_files - Dir[File.expand_path("../../db/migrate/*.rb", __dir__)].sort - end end end end diff --git a/sig/generated/lib/solid_objects/executor.rbs b/sig/generated/lib/solid_objects/executor.rbs index 697d0c2..1733c1a 100644 --- a/sig/generated/lib/solid_objects/executor.rbs +++ b/sig/generated/lib/solid_objects/executor.rbs @@ -94,9 +94,6 @@ module SolidObjects # @rbs () -> ClaimedMessage def matching_claim!: () -> ClaimedMessage - # @rbs (Instance, Message) -> void - def remember_key: (Instance, Message) -> void - # @rbs (Instance, Message) -> Array[Hash[String, untyped]] def remembered_keys: (Instance, Message) -> Array[Hash[String, untyped]] diff --git a/sig/generated/lib/solid_objects/schema_bootstrap.rbs b/sig/generated/lib/solid_objects/schema_bootstrap.rbs index 3bc8320..de4b604 100644 --- a/sig/generated/lib/solid_objects/schema_bootstrap.rbs +++ b/sig/generated/lib/solid_objects/schema_bootstrap.rbs @@ -7,8 +7,5 @@ module SolidObjects # @rbs () -> Array[Class] def self.migrations: () -> Array[Class] - - # @rbs () -> Array[String] - private def self.migration_files: () -> Array[String] end end From 43148691a6fff7c16b64bbeba15f968455b3edff Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 13:51:02 -0700 Subject: [PATCH 15/16] refactor: simplify optional key persistence Use a single-line conditional modifier for the terminal history update. This satisfies both review and Standard Ruby. All 36 lookup tests pass. See #79 --- lib/solid_objects/executor.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index 3073a4e..0df5724 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -405,9 +405,7 @@ def fail_message(error) if error.is_a?(NonRetryableError) || locked_message.attempt_count >= locked_message.max_attempts create_dead_letter(message: locked_message, error_details:, now:) - if locked_message.idempotency_key - instance.update!(completed_idempotency_keys: remembered_keys(instance, locked_message)) - end + instance.update!(completed_idempotency_keys: remembered_keys(instance, locked_message)) if locked_message.idempotency_key dead = true else ReadyMessage.create!( From 914ce0bfe3be8aac025b0b11514f3825777b1af0 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 23 Sep 2026 15:21:15 -0700 Subject: [PATCH 16/16] chore: prepare version 0.16.0 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- Gemfile.lock | 4 ++-- lib/solid_objects/version.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 448e057..3c049c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.16.0 - 2026-09-23 - Find a message whose reference a caller lost. `SolidObjects.client.find_by(request_id:)` answers a request id, which is diff --git a/Gemfile.lock b/Gemfile.lock index 0c0f2e8..42ee6f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.15.2) + solid_objects (0.16.0) actioncable (>= 7.1) actionpack (>= 7.1) actionview (>= 7.1) @@ -384,7 +384,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_objects (0.15.2) + solid_objects (0.16.0) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index aaaac93..d9c362b 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.15.2" + VERSION = "0.16.0" end