Skip to content

Commit 028f4bc

Browse files
cardmagicclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 79d8503 commit 028f4bc

8 files changed

Lines changed: 92 additions & 34 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for
2929
each actor. A lookup by request id cannot make the distinction, because the
3030
runtime, not the caller, generates a request id and no actor remembers one.
31+
`retained_idempotency_keys_bytes` bounds the serialized memory as well,
32+
because an idempotency key has no length limit on every adapter and the memory
33+
outlives the message row. An actor drops its oldest keys until the list fits,
34+
so a key long enough to fill the limit by itself is never remembered.
3135
- Add `db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb`,
3236
which adds `instances.completed_idempotency_keys` as `jsonb` on PostgreSQL and
3337
`json` elsewhere. An application installs it with

‎docs/architecture.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,9 @@ a lost result from a request that never arrived. The memory is actor state, so
446446
`authorize_query` gates the pruned answer the way it gates `snapshot`, and a
447447
caller the policy refuses reads `nil` for both.
448448
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for each
449-
actor. Only a lookup by idempotency key can make the distinction. A request id
449+
actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB,
450+
because an idempotency key has no length limit on every adapter and the memory
451+
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
450452
is generated by the runtime rather than by the caller, so no actor remembers
451453
one, and `client.find_by(request_id:)` answers `nil` in both cases.
452454

‎docs/operations.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ end
216216
| `process_retention` | 7 days |
217217
| `prune_batch_size` | 1,000 |
218218
| `retained_idempotency_keys` | 64 |
219+
| `retained_idempotency_keys_bytes` | 16 KB |
219220
| `worker_count` | 1 |
220221
| `effect_worker_count` | 1 |
221222
| `broadcast_worker_count` | 1 |
@@ -519,6 +520,7 @@ SolidObjects.configure do |configuration|
519520
configuration.process_retention = 7.days
520521
configuration.prune_batch_size = 1_000
521522
configuration.retained_idempotency_keys = 64
523+
configuration.retained_idempotency_keys_bytes = 16.kilobytes
522524
end
523525
```
524526

@@ -556,6 +558,12 @@ keyed turns than that inside the window in which a caller may retry. A lookup
556558
by request id answers `nil` in both cases, so a caller that must tell them apart
557559
sends its own idempotency key.
558560

561+
`retained_idempotency_keys_bytes` bounds the serialized memory as well, because
562+
an idempotency key has no length limit on every adapter and the memory outlives
563+
the message row. An actor drops its oldest keys until the list fits, so a key
564+
long enough to fill the limit by itself is never remembered and its lookup
565+
answers `nil` rather than raising.
566+
559567
Actor expiration is disabled by default. `prune_instances` considers only
560568
actor types listed in `instance_retention_by_actor_type`, excludes active or
561569
paused actors, and preserves ready/claimed mailbox work, scheduled reminders,

‎lib/solid_objects/configuration.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class Configuration
3232
# @rbs @process_retention: Numeric
3333
# @rbs @prune_batch_size: Integer
3434
# @rbs @retained_idempotency_keys: Integer
35+
# @rbs @retained_idempotency_keys_bytes: Integer
3536
# @rbs @redrive_batch_size: Integer
3637
# @rbs @redrive_batch_pause: Float
3738
# @rbs @worker_count: Integer
@@ -85,6 +86,7 @@ class Configuration
8586
:process_retention,
8687
:prune_batch_size,
8788
:retained_idempotency_keys,
89+
:retained_idempotency_keys_bytes,
8890
:redrive_batch_size,
8991
:redrive_batch_pause,
9092
:worker_count,
@@ -143,6 +145,7 @@ def initialize
143145
@process_retention = 7.days
144146
@prune_batch_size = 1_000
145147
@retained_idempotency_keys = 64
148+
@retained_idempotency_keys_bytes = 16.kilobytes
146149
@redrive_batch_size = 100
147150
@redrive_batch_pause = 0.05
148151
@worker_count = 1
@@ -315,6 +318,7 @@ def positive_values
315318
process_retention:,
316319
prune_batch_size:,
317320
retained_idempotency_keys:,
321+
retained_idempotency_keys_bytes:,
318322
redrive_batch_size:
319323
}
320324
end

‎lib/solid_objects/executor.rb‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,15 @@ def remembered_keys(instance, message)
489489
return remembered unless key
490490
return remembered if remembered.last == key
491491

492-
(remembered - [ key ] + [ key ]).last(SolidObjects.configuration.retained_idempotency_keys)
492+
bounded(remembered - [ key ] + [ key ])
493+
end
494+
495+
# @rbs (Array[String]) -> Array[String]
496+
def bounded(keys)
497+
kept = keys.last(SolidObjects.configuration.retained_idempotency_keys)
498+
limit = SolidObjects.configuration.retained_idempotency_keys_bytes
499+
kept.shift while kept.any? && kept.to_json.bytesize > limit
500+
kept
493501
end
494502

495503
# @rbs (Exception) -> Hash[String, untyped]

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

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,71 @@
22

33
module SolidObjects
44
class Configuration
5-
@shutdown_timeout: Float
5+
@transmission_actor_type_resolver: Proc
66

7-
@supervisor_monitor_interval: Float
7+
@broadcast_worker_count: Integer
88

9-
@retention_interval: Float
9+
@effect_worker_count: Integer
1010

11-
@dead_process_cleanup_interval: Float
11+
@worker_count: Integer
1212

13-
@message_retention: Numeric
13+
@redrive_batch_pause: Float
1414

15-
@message_retention_by_actor_type: Hash[String, Numeric]
15+
@redrive_batch_size: Integer
1616

17-
@instance_retention_by_actor_type: Hash[String, Numeric]
17+
@retained_idempotency_keys_bytes: Integer
1818

19-
@process_retention: Numeric
19+
@retained_idempotency_keys: Integer
2020

2121
@prune_batch_size: Integer
2222

23-
@retained_idempotency_keys: Integer
24-
25-
@redrive_batch_size: Integer
23+
@process_retention: Numeric
2624

27-
@redrive_batch_pause: Float
25+
@instance_retention_by_actor_type: Hash[String, Numeric]
2826

29-
@worker_count: Integer
27+
@message_retention_by_actor_type: Hash[String, Numeric]
3028

31-
@effect_worker_count: Integer
29+
@message_retention: Numeric
3230

33-
@broadcast_worker_count: Integer
31+
@dead_process_cleanup_interval: Float
3432

35-
@reminder_scheduler_count: Integer
33+
@retention_interval: Float
3634

37-
@connects_to: Hash[Symbol, untyped]?
35+
@supervisor_monitor_interval: Float
3836

39-
@logger: untyped
37+
@table_name_prefix: String
4038

41-
@stream_signing_secret: String?
39+
@administration_identity: Proc
4240

43-
@broadcast_adapter: Proc?
41+
@authorize_transmission: Proc
4442

45-
@wake_up_adapter: untyped
43+
@authorize_administration: Proc
4644

47-
@component_path_resolver: Proc?
45+
@authorize_subscription: Proc
4846

49-
@component_authorization_context: Proc
47+
@authorize_destroy: Proc
5048

51-
@payload_authorization_context: Proc
49+
@authorize_query: Proc
5250

5351
@authorize_message: Proc
5452

55-
@authorize_query: Proc
53+
@payload_authorization_context: Proc
5654

57-
@authorize_destroy: Proc
55+
@component_authorization_context: Proc
5856

59-
@authorize_subscription: Proc
57+
@component_path_resolver: Proc?
6058

61-
@authorize_administration: Proc
59+
@wake_up_adapter: untyped
6260

63-
@authorize_transmission: Proc
61+
@broadcast_adapter: Proc?
6462

65-
@administration_identity: Proc
63+
@stream_signing_secret: String?
6664

67-
@transmission_actor_type_resolver: Proc
65+
@logger: untyped
6866

69-
@table_name_prefix: String
67+
@connects_to: Hash[Symbol, untyped]?
68+
69+
@reminder_scheduler_count: Integer
7070

7171
@polling_interval: Float
7272

@@ -106,6 +106,8 @@ module SolidObjects
106106

107107
@process_alive_threshold: Float
108108

109+
@shutdown_timeout: Float
110+
109111
attr_accessor table_name_prefix: untyped
110112

111113
attr_accessor polling_interval: untyped
@@ -166,6 +168,8 @@ module SolidObjects
166168

167169
attr_accessor retained_idempotency_keys: untyped
168170

171+
attr_accessor retained_idempotency_keys_bytes: untyped
172+
169173
attr_accessor redrive_batch_size: untyped
170174

171175
attr_accessor redrive_batch_pause: untyped

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ module SolidObjects
100100
# @rbs (Instance, Message) -> Array[String]
101101
def remembered_keys: (Instance, Message) -> Array[String]
102102

103+
# @rbs (Array[String]) -> Array[String]
104+
def bounded: (Array[String]) -> Array[String]
105+
103106
# @rbs (Exception) -> Hash[String, untyped]
104107
def serialized_error: (Exception) -> Hash[String, untyped]
105108

‎test/integration/result_lookup_test.rb‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,31 @@ def reject_checkout
294294
assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "second") }
295295
end
296296

297+
test "bounds what an instance remembers by size" do
298+
SolidObjects.configuration.retained_idempotency_keys_bytes = 64
299+
reference = CartActor.ref("alice")
300+
keys = 3.times.map { |index| "#{index}-#{"k" * 20}" }
301+
keys.each_with_index { |key, index| reference.async(idempotency_key: key).checkout(order_id: index) }
302+
run_actors
303+
304+
remembered = SolidObjects::Instance.sole.completed_idempotency_keys
305+
306+
assert_equal keys.last(2), remembered
307+
assert_operator remembered.to_json.bytesize, :<=, 64
308+
end
309+
310+
test "remembers nothing for a key larger than what it retains" do
311+
SolidObjects.configuration.retained_idempotency_keys_bytes = 16
312+
reference = CartActor.ref("alice")
313+
key = "k" * 100
314+
reference.async(idempotency_key: key).checkout(order_id: 1)
315+
run_actors
316+
SolidObjects::Message.delete_all
317+
318+
assert_empty SolidObjects::Instance.sole.completed_idempotency_keys
319+
assert_nil reference.find_by(idempotency_key: key)
320+
end
321+
297322
test "remembers a re-sent key once" do
298323
reference = CartActor.ref("alice")
299324
reference.async(idempotency_key: "first").checkout(order_id: 1)

0 commit comments

Comments
 (0)