Skip to content

Commit bd6ed07

Browse files
committed
fix: read message and snapshot state uncached
MessageReference#status, MessageReference#result, and ActorSnapshot read through the Active Record query cache. A caller that polls holds one cache for the whole poll, and the worker that finishes the message is a different process, so its write cannot clear that cache. The reader saw its first answer forever. This reaches any poll inside one executor block: a controller action, an Active Job, or a rails runner script. A real application reproduced it, reporting "ready" twenty times over six seconds while the row had completed. Read those three uncached, as SynchronousInvocation and SyncDiagnostics already do for the same reason. The suite never caught it because the query cache is off in tests. The new test opens one, then writes from a second connection so the write cannot clear it, which is what a worker process does.
1 parent a984f84 commit bd6ed07

4 files changed

Lines changed: 95 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Read the durable row rather than the query cache in `MessageReference#status`,
6+
`MessageReference#result`, and an actor snapshot. A caller that polls holds one
7+
query cache for the whole poll, and the worker that finishes the message is
8+
another process, so its write cannot clear that cache. A poll inside a request,
9+
a job, or `rails runner` reported the first answer forever. The synchronous
10+
wait already read uncached.
11+
312
## 0.15.2 - 2026-09-21
413

514
- Find the actor instance before the insert when an enqueue starts, and lock

‎lib/solid_objects/actor_snapshot.rb‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ class ActorSnapshot
1616
def initialize(reference)
1717
@reference = reference
1818
@actor_class = SolidObjects.registry.fetch(reference.actor_type)
19-
@instance = Instance.find_by(
20-
actor_type: reference.actor_type,
21-
actor_id: reference.actor_id
22-
)
19+
@instance = Instance.uncached do
20+
Instance.find_by(
21+
actor_type: reference.actor_type,
22+
actor_id: reference.actor_id
23+
)
24+
end
2325
@instance_id = @instance&.id || 0
2426
@revision = @instance&.state_revision || 0
2527
@actor = build_actor

‎lib/solid_objects/message_reference.rb‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,21 @@ def initialize(id:, request_id:, actor_type:, actor_id:, sequence:)
3535

3636
# @rbs () -> String
3737
def status
38-
message = Message.find(id)
39-
return "rejected" if message.rejected?
40-
return "completed" if message.completed?
41-
return "dead" if message.dead?
42-
return "claimed" if message.claimed?
43-
return "ready" if message.ready?
44-
45-
"unknown"
38+
Message.uncached do
39+
message = Message.find(id)
40+
return "rejected" if message.rejected?
41+
return "completed" if message.completed?
42+
return "dead" if message.dead?
43+
return "claimed" if message.claimed?
44+
return "ready" if message.ready?
45+
46+
"unknown"
47+
end
4648
end
4749

4850
# @rbs () -> untyped
4951
def result
50-
Message.find(id).result
52+
Message.uncached { Message.find(id).result }
5153
end
5254

5355
# @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
require "database_test_helper"
4+
5+
class QueryCacheReadsTest < ActiveSupport::TestCase
6+
class CounterActor < SolidObjects::Actor
7+
actor_type "query-cache-counter"
8+
9+
attribute :count, default: 0
10+
11+
def increment
12+
self.count += 1
13+
end
14+
end
15+
16+
setup { CounterActor.ensure_registered! }
17+
18+
def from_another_connection
19+
Thread.new do
20+
SolidObjects::Record.connection_pool.with_connection { yield }
21+
end.join
22+
end
23+
24+
test "message status refreshes while a query cache is open" do
25+
message = CounterActor.ref("alice").async.increment
26+
27+
ActiveRecord::Base.cache do
28+
assert_equal "ready", message.status
29+
30+
from_another_connection do
31+
SolidObjects::ReadyMessage.where(message_id: message.id).delete_all
32+
SolidObjects::Message.find(message.id).update!(completed_at: Time.current)
33+
end
34+
35+
assert_equal "completed", message.status
36+
end
37+
end
38+
39+
test "message result refreshes while a query cache is open" do
40+
message = CounterActor.ref("alice").async.increment
41+
42+
ActiveRecord::Base.cache do
43+
assert_nil message.result
44+
45+
from_another_connection do
46+
SolidObjects::Message.find(message.id).update!(result: { "value" => 7 })
47+
end
48+
49+
assert_equal({ "value" => 7 }, message.result)
50+
end
51+
end
52+
53+
test "an actor snapshot refreshes while a query cache is open" do
54+
reference = CounterActor.ref("alice")
55+
reference.async.increment
56+
SolidObjects::Worker.new.run_until_idle
57+
58+
ActiveRecord::Base.cache do
59+
assert_equal 1, reference.snapshot.count
60+
61+
from_another_connection do
62+
instance = SolidObjects::Instance.find_by!(actor_type: "query-cache-counter", actor_id: "alice")
63+
instance.update!(state: instance.state.merge("count" => 9))
64+
end
65+
66+
assert_equal 9, reference.snapshot.count
67+
end
68+
end
69+
end

0 commit comments

Comments
 (0)