feat: find a message by request id or idempotency key - #79
Conversation
A caller that lost its `MessageReference` had no way to rebuild one, so
a web request that timed out, a process that restarted, or a retry that
arrived on another node could not read what the first attempt produced.
The enqueue path already deduplicates by idempotency key, so the row
could be found. Nothing public could find it.
`find_by` is shaped like `ActiveRecord::Base.find_by`, and the receiver
carries the scope the key needs:
SolidObjects.client.find_by(request_id: id)
CartActor.ref("alice").find_by(idempotency_key: key)
A request id is unique across the table, so the client answers it. An
idempotency key is unique per instance, so a reference answers it. A
caller cannot write a lookup the indexes cannot serve.
Every lookup runs the same authorization hook the original call ran,
against the stored operation and arguments, because a request id is not
a capability. An absent row, an actor this process no longer registers,
and a caller the policy refuses all return `nil`, so the lookup cannot
be used to ask whether a request id exists.
`MessageReference#outcome` reports the status, the result, the
persisted error, the rejection, and the attempt count, so a terminal
failure answers as well as a success.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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
|
@greptileai Please review the latest commit b60dec5. All outstanding findings are addressed; single-use helpers are inlined and pruned lookups now retain original authorization arguments within the byte bound. |
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
|
@greptileai Please review 32cba11. Both remaining single-use wrappers are inlined and their generated signatures removed. Focused tests, formatting, lint, RBS validation, and Steep pass. |
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
|
@greptileai Please review 4314869. The optional history update now uses a single-line conditional modifier, satisfying both the remaining finding and Standard Ruby. Lookup tests and both style checks pass. |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Works on #74.
Why
A caller reads a result through a
MessageReference, andClient#waitrefusesanything less than the whole reference. The check is right; the problem is that
a caller who loses the reference cannot rebuild one. A web request that timed
out, a process that restarted, or a retry that arrived on another node knows its
own request id or its own idempotency key, and neither was enough.
The enqueue path already deduplicates by idempotency key, so the row could be
found. Nothing public could find it.
The receiver carries the scope
request_idSolidObjects.clientidempotency_keyReferenceA caller cannot express a lookup the indexes cannot serve, because the receiver
supplies the scope the key needs. Naming neither key, naming both, or naming an
idempotency key without a reference each raise
ArgumentError. An unknownkeyword raises from Ruby itself.
Authorization
A request id is not a capability, so every lookup runs the same
authorize_messageorauthorize_queryhook the original call ran, against thestored operation and arguments.
An absent row, an actor this process no longer registers, and a caller the
policy refuses all return
nil. That is deliberate: a lookup that raised whereit was refused and returned
nilwhere it was absent would be a way to askwhether a request id exists.
Outcome
A lookup that only answered for successes would not be usable for recovery, so
outcomecarries the persisted error for a dead message and the rejection for arejected one, beside the status and the attempt count.
Two things the issue assumes that the code does not
The
request_idindex already exists. The issue asks for a migration addingidx_so_messages_request_id.db/migrate/20260805000000line 122 alreadydeclares
index :request_id, unique: true, name: "idx_so_messages_request". Thelookup is an index hit today and this branch adds no migration.
An async enqueue stores no result.
Executor#completeserializes the resultonly when
delivery_mode == "sync"; an async message storesnilon purpose.So the recovery example in the issue finds its message and reports
completed,and reads
nilfor the result. That is already true ofMessageReference#resultand is not introduced here.
find_byanswers status, error, rejection, andattempts for any message, and a result for one enqueued with
sync. A testpins both halves, so the boundary is recorded rather than discovered later.
Making async results durable is a separate decision about storage and retention,
and it belongs in its own change.
Tests
test/integration/result_lookup_test.rb, 24 tests, each watched failing firstwith
undefined method 'find_by':lookup by request id and by idempotency key, the client and the reference
agreeing, a message that has not run, a dead message with its error and attempt
count, a rejected message with its rejection, a completed outcome with its
result, an async message reporting no result, an unknown key and an unknown
request id, all three
ArgumentErrorcases, an unknown keyword, an unauthorizedcaller receiving
nilthat absence cannot be told from, authorization againstthe stored operation and arguments, the query hook for a query, a key scoped to
its own instance, and a rebuilt reference that waits and sees the commit.
The pruned tests: a pruned key raising for a completed, a rejected, and a dead
message, the error naming its key, a refused caller reading
nil, the boundholding at three keys, and a keyless message remembering nothing. Without the
raise they failed with
SolidObjects::MessagePruned expected but nothing was raised; without the gate the refused caller receivedMessagePruned; withoutthe bound
key-0was still remembered.Validation
bundle exec rakepasses: 781 runs, 0 failures, 39 skips, with Standard,RuboCop, RBS, Steep, and Brakeman clean.
Every backend runs the whole suite: SQLite, PostgreSQL 16, MySQL through
mysql2, and MySQL throughtrilogy, each 780 runs with 0 failures.A pruned message and one that never existed
The issue also asks that the two be told apart. Orleans answers this in grain
state: a grain keeps its deduplication history with the grain, not in a separate
tombstone store. An actor now does the same, so neither shape the earlier draft
weighed is needed.
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.
retained_idempotency_keysbounds the memory and defaults to 64 keys for eachactor. A key the actor already remembers moves to the end rather than repeating,
and a message that carried no key remembers nothing.
Each remembered key retains the original operation and arguments. Pruned
lookups apply the same message or query authorization policy as live lookups.
Arguments count toward the existing serialized byte limit. Older entries
without arguments return absence because authorization cannot be reproduced.
A denied caller cannot distinguish a pruned key from an unused key.
Only a key lookup can make the distinction. The runtime generates a request id,
so no actor remembers one, and
find_by(request_id:)answersnilin bothcases. A caller that must tell them apart sends its own idempotency key.
db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rbaddsthe column as
jsonbon PostgreSQL andjsonelsewhere.Seven hand-copied migration lists
Three of them ran migrations against a full schema, and all three broke on the
new column:
SolidObjects::SchemaBootstrapreadsdb/migrateinstead, andtest/unit/migration_bootstrap_test.rbfails if any script names a migrationclass again.
load_contract_test.rbrecords why the new file is not loaded byrequiring the gem.
Review fixes and regression evidence
Argument-dependent authorization is covered by a regression that first failed
with
MessagePruned: the message for idempotency key "protected" was prunedfor a denied caller. The fixed test verifies absence for that caller and
MessagePrunedfor an authorized caller. Legacy history without argumentsalso returns absence. Serialized size tests account for the retained arguments.
The single-use
bounded,remembered_message, anddead_rowhelpers areinlined and generated RBS signatures updated.
bundle exec rakepasses:796 runs, 0 failures, 0 errors, 39 skips; Standard, RuboCop, RBS validation,
Steep, and Brakeman pass. The focused suite including the additional legacy
regression passes 36 tests with 0 skips.