Skip to content

Commit c18b82b

Browse files
authored
Merge pull request #79 from cardmagic/feat/result-lookup
feat: find a message by request id or idempotency key
2 parents d208789 + 914ce0b commit c18b82b

44 files changed

Lines changed: 1204 additions & 140 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,71 @@
11
# Changelog
22

3-
## Unreleased
4-
3+
## 0.16.0 - 2026-09-23
4+
5+
- Find a message whose reference a caller lost.
6+
`SolidObjects.client.find_by(request_id:)` answers a request id, which is
7+
unique across the table, and `reference.find_by(idempotency_key:)` answers a
8+
key, which is unique per instance, so the receiver supplies the scope the key
9+
needs. Naming neither key, naming both, or naming an idempotency key without a
10+
reference raises `ArgumentError`.
11+
- Authorize every lookup with the hook the original call ran, against the stored
12+
operation and arguments, because a request id is not a capability. An absent
13+
row, an actor this process no longer registers, and a caller the policy
14+
refuses all return `nil`, so a lookup cannot be used to ask whether a request
15+
id exists.
16+
- Add `MessageReference#outcome`, which reports the status, the result, the
17+
persisted error, the rejection, and the attempt count, so a terminal failure
18+
answers as well as a success. A result is stored for `sync` delivery only, so
19+
an asynchronous message reports its status and error and no result.
20+
- Tell a pruned message from one that never existed. An actor remembers the
21+
idempotency keys of its own finished turns, the way an Orleans grain keeps
22+
its deduplication history in grain state, so the memory needs no second
23+
store and no second write. `reference.find_by(idempotency_key:)` raises
24+
`SolidObjects::MessagePruned` for a key the actor remembers and whose message
25+
retention removed, and still answers `nil` for a key no caller ever sent.
26+
An actor remembers the operation beside each key, so the pruned answer runs
27+
the same hook against the same operation a lookup of the surviving row would,
28+
and a caller the policy refuses reads `nil` for both. Gating it on `snapshot`
29+
would have told a caller who may read state, but not the operation, that the
30+
operation had run.
31+
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for
32+
each actor. A lookup by request id cannot make the distinction, because the
33+
runtime, not the caller, generates a request id and no actor remembers one.
34+
`retained_idempotency_keys_bytes` bounds the serialized memory as well,
35+
because an idempotency key has no length limit on every adapter and the memory
36+
outlives the message row. An actor drops its oldest keys until the list fits,
37+
so a key long enough to fill the limit by itself is never remembered.
38+
- Add `db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb`,
39+
which adds `instances.completed_idempotency_keys` as `jsonb` on PostgreSQL and
40+
`json` elsewhere. An application installs it with
41+
`bin/rails solid_objects:install:migrations` and runs it before it upgrades a
42+
worker, because the executor writes the column on every finished turn. The
43+
doctor now reports the column as missing when it is not installed.
44+
- Apply migrations through `SolidObjects::SchemaBootstrap`, which reads
45+
`db/migrate`. Seven scripts each carried a hand-copied migration list, and
46+
three of them applied an incomplete schema. A test fails if any script names a
47+
migration class again.
48+
- Report a half-applied migration in `solid_objects doctor`. The column list
49+
omitted `instances.state_revision`, `messages.operation`,
50+
`effects.success_operation`, `effects.failure_operation`, and
51+
`dead_letters.operation`, so an application that skipped a migration read as
52+
healthy and found out from a worker crash. A test fails when the list does not
53+
name a column that a migration after the first adds.
54+
55+
- List a dead effect or broadcast as a `SolidObjects::DeadRow` rather than as
56+
an Active Record row. `all` returned rows whose `id` was the primary key while
57+
`retry` reads `effect_id` or `broadcast_id`, so the obvious
58+
`scope.retry(scope.all.first.id)` raised `ActiveRecord::RecordNotFound`.
59+
`DeadRow#id` is now the value `retry` accepts, which is what the TypeScript
60+
runtime has always returned. `dead` still answers the relation for a caller
61+
that wants to scope it further.
62+
- Raise a load error rather than report an unreachable database. Wake-up
63+
selection rescued every exception, so a `NameError` from an unloaded model
64+
read as "the database could not be reached" and downgraded the process to
65+
in-process signalling. It now rescues database, system call, and IO errors
66+
only.
67+
- Note that `json` 3.0.2 breaks `ActiveSupport::JSON.decode`, and therefore
68+
every JSON column, in [docs/operations.md](docs/operations.md).
569
- Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its
670
message meaning and answers `effects` and `broadcasts`, so the kind rides on
771
the receiver. `retry` returns a dead row to pending with a zero attempt count

‎Gemfile.lock‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.15.2)
4+
solid_objects (0.16.0)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.15.2)
387+
solid_objects (0.16.0)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Solid Object Rails Actors elegantly fit anything where one identifiable thing mu
1616
- Ticket holds and reservations
1717
- Multiplayer games and shared rooms
1818
- Shopping carts and checkout recovery
19-
- Rate limits and account quotas
19+
- Low-rate quotas and account limits
2020
- Session expiration
2121
- Job leases and workflows
2222
- Connected devices

‎benchmark/support.rb‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -425,14 +425,8 @@ def establish_connection
425425

426426
# @rbs () -> void
427427
def migrate
428-
require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
429-
require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
430-
require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns"
431-
require_relative "../db/migrate/20260915000000_add_solid_objects_effect_recoveries"
432-
CreateSolidObjectsTables.new.migrate(:up)
433-
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
434-
RenameMessageDispatchColumns.new.migrate(:up)
435-
AddSolidObjectsEffectRecoveries.new.migrate(:up)
428+
require "solid_objects/schema_bootstrap"
429+
SolidObjects::SchemaBootstrap.install
436430
end
437431

438432
# @rbs () -> void
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# rbs_inline: enabled
2+
3+
class AddSolidObjectsCompletedIdempotencyKeys < ActiveRecord::Migration[7.1]
4+
# @rbs () -> void
5+
def change
6+
add_column SolidObjects.table_name(:instances),
7+
:completed_idempotency_keys,
8+
connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
9+
end
10+
end

‎docs/architecture.md‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ through the client.
7272

7373
### Client and mailbox
7474

75-
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.
75+
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.
7676

7777
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.
7878

@@ -424,6 +424,42 @@ outer commit, and callers timing out on work they indirectly block.
424424
waiting and immediately returns a `MessageReference`. Runtime workers process
425425
it normally.
426426

427+
A caller that loses that reference rebuilds one. `SolidObjects.client.find_by`
428+
answers a request id, which is unique across the table, and
429+
`reference.find_by` answers an idempotency key, which is unique per instance.
430+
Each lookup runs the authorization hook the original call ran, against the
431+
stored operation and arguments, and answers `nil` for an absent row, an
432+
unregistered actor, and a refused caller alike, so it cannot be used to ask
433+
whether a request id exists. `MessageReference#outcome` reports the status, the
434+
result, the persisted error, the rejection, and the attempt count. A result is
435+
stored for `sync` delivery only.
436+
437+
An actor remembers the idempotency keys of its own finished turns. The executor
438+
already writes the instance row in the transaction that completes, rejects, or
439+
kills a turn, so the memory rides on a write that happens anyway. This is the
440+
Orleans answer: a grain keeps its deduplication history in grain state rather
441+
than in a separate tombstone table, which needs no second store, no second
442+
write, and no separate retention. `reference.find_by(idempotency_key:)` raises
443+
`MessagePruned` for a key the actor remembers and whose message retention
444+
removed, and answers `nil` for a key no caller ever sent, so a client can tell
445+
a lost result from a request that never arrived. An actor remembers the operation and original arguments beside each key, so
446+
the pruned answer runs the same hook against the same operation and arguments that a lookup
447+
of the surviving row would, and a caller the policy refuses reads `nil` whether
448+
the message is pruned or never existed. Gating it on `snapshot` instead would
449+
tell a caller who may read state, but not the operation, that the operation had
450+
run.
451+
Remembered arguments count toward the serialized memory limit and remain until
452+
the entry is evicted or the instance is removed. Entries from older versions
453+
that lack arguments return absence after pruning because their original
454+
authorization cannot be reproduced.
455+
456+
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for each
457+
actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB,
458+
because an idempotency key has no length limit on every adapter and the memory
459+
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
460+
is generated by the runtime rather than by the caller, so no actor remembers
461+
one, and `client.find_by(request_id:)` answers `nil` in both cases.
462+
427463
An executing caller receives an inline after-commit callback error even though
428464
the turn committed. An independently waiting caller observes the durable
429465
result and may return before that callback raises in the worker. Completed
@@ -750,11 +786,11 @@ Enqueue counts unfinished rows under the locked actor instance and rejects with
750786

751787
### Per-actor rate limits
752788

753-
The initial implementation supplies the mailbox cap. Distributed token buckets or time-window counters are a hardening milestone.
789+
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.
754790

755791
### Global enqueue limits
756792

757-
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.
793+
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.
758794

759795
### Payload size
760796

@@ -867,9 +903,9 @@ All backends use unique identity and sequence constraints, short transactions, a
867903
12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
868904
13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
869905
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.
870-
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.
906+
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.
871907
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.
872-
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.
908+
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.
873909
18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
874910
19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
875911
20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.

‎docs/fit.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ a presence signal, or a view count. Reactive projections materialize a read
6868
model from the durable broadcast outbox, so request-path reads stop competing
6969
with mailbox work.
7070

71+
Distributed per-actor rate limits, global admission control, and cache-capacity
72+
eviction are answered there rather than in this gem. Each one is hot and
73+
request-critical, and this gem writes one permanent message row for every
74+
invocation, so the cost model above rules out a limiter that checks on the
75+
request path. They are not open roadmap items here.
76+
7177
## Cost model
7278

7379
Every synchronous or asynchronous invocation:

‎docs/operations.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ reports a failed or warned check rather than raising out of the command.
2525

2626
## Installing and upgrading
2727

28+
Solid Objects keeps actor state, message arguments, results, and the remembered
29+
idempotency keys in JSON columns. Active Support decodes every one of them, and
30+
`ActiveSupport::JSON.decode` raises with the `json` gem at 3.0.2:
31+
32+
```
33+
ArgumentError: wrong number of arguments (given 2, expected 1)
34+
```
35+
36+
The failure is in Active Support rather than in Solid Objects, and it reaches
37+
every JSON column in a Rails application. A new Rails 8.1 application resolves
38+
`json` 3.0.2 today, so pin the 2.x series until Rails ships a fix:
39+
40+
```ruby
41+
gem "json", "~> 2"
42+
```
43+
2844
Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
2945
notes, then update the gem:
3046

@@ -215,6 +231,8 @@ end
215231
| `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
216232
| `process_retention` | 7 days |
217233
| `prune_batch_size` | 1,000 |
234+
| `retained_idempotency_keys` | 64 |
235+
| `retained_idempotency_keys_bytes` | 16 KB |
218236
| `worker_count` | 1 |
219237
| `effect_worker_count` | 1 |
220238
| `broadcast_worker_count` | 1 |
@@ -517,6 +535,8 @@ SolidObjects.configure do |configuration|
517535
}
518536
configuration.process_retention = 7.days
519537
configuration.prune_batch_size = 1_000
538+
configuration.retained_idempotency_keys = 64
539+
configuration.retained_idempotency_keys_bytes = 16.kilobytes
520540
end
521541
```
522542

@@ -543,6 +563,29 @@ broadcasts, and other message-owned rows. Choose a cutoff longer than every
543563
`sync` timeout because a caller whose result row disappears can no longer
544564
observe it.
545565

566+
`find_by` reads the same rows, so a lookup answers only while the message it
567+
names survives retention. A lookup by idempotency key still tells the two cases
568+
apart after pruning, because the actor remembers the keys of its own last
569+
`retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key
570+
the actor remembers and answers `nil` for a key no caller ever sent. The actor
571+
remembers the operation and original arguments beside each key, so the pruned answer runs the same
572+
authorization the surviving row would. Raise
573+
`retained_idempotency_keys` above the default of 64 when an actor finishes more
574+
keyed turns than that inside the window in which a caller may retry. A lookup
575+
by request id answers `nil` in both cases, so a caller that must tell them apart
576+
sends its own idempotency key.
577+
578+
Remembered arguments count toward the serialized memory limit and remain until
579+
the entry is evicted or the instance is removed. Entries from older versions
580+
that lack arguments return absence after pruning because their original
581+
authorization cannot be reproduced.
582+
583+
`retained_idempotency_keys_bytes` bounds the serialized memory as well, because
584+
an idempotency key has no length limit on every adapter and the memory outlives
585+
the message row. An actor drops its oldest keys until the list fits, so a key
586+
long enough to fill the limit by itself is never remembered and its lookup
587+
answers `nil` rather than raising.
588+
546589
Actor expiration is disabled by default. `prune_instances` considers only
547590
actor types listed in `instance_retention_by_actor_type`, excludes active or
548591
paused actors, and preserves ready/claimed mailbox work, scheduled reminders,

0 commit comments

Comments
 (0)