Skip to content

Commit 075af9a

Browse files
authored
Merge pull request #31 from cardmagic/agent/test-helper-isolation
fix: clear every actor-owned table in reset_actors!
2 parents 99cfd5a + c7ae922 commit 075af9a

5 files changed

Lines changed: 174 additions & 2 deletions

File tree

CHANGELOG.md

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

3+
## Unreleased
4+
5+
- Delete every actor-owned row in `SolidObjects::TestHelper#reset_actors!`. It
6+
deleted actor instances and processes and left the other seven tables to the
7+
database cascade. That cascade is not enforced everywhere: SQLite has to be
8+
asked for foreign keys, MySQL has to be on InnoDB, and a host application may
9+
have stripped the constraints out of the copied migration. Where it does not
10+
fire, messages, ready and claimed mailbox rows, reminders, effects,
11+
broadcasts, and dead letters all survived into the next test with an
12+
`instance_id` pointing at nothing, so a test reading any of them saw another
13+
test's rows and failed depending on order. Reported as reminders leaking,
14+
which is where it surfaces first because reminders outlive the message that
15+
created them.
16+
317
## 0.10.2 - 2026-08-10
418

519
- Load the mailbox when the gem is required. `SolidObjects::Mailbox` was

docs/development.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,13 @@ Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
6666
reminders pending.
6767

6868
`SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
69-
boundaries.
69+
boundaries. It deletes every actor-owned row itself rather than deleting actor
70+
instances and letting the database cascade remove the rest: SQLite has to be
71+
asked for foreign keys, MySQL has to be on InnoDB, and a host application may
72+
have stripped the constraints out of the copied migration. Where the cascade
73+
does not fire, a row that survives a reset carries an `instance_id` pointing at
74+
nothing, and the next test that reads reminders or dead letters sees another
75+
test's data.
7076

7177
## Inline RBS
7278

lib/solid_objects/test_helper.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,35 @@ def included(test_case)
1212
test_case.teardown { reset_actors! }
1313
end
1414

15+
# Deleting instances alone left every other actor-owned row to the
16+
# database cascade. That cascade is not enforced everywhere: SQLite has
17+
# to be asked for foreign keys, MySQL has to be on InnoDB, and a host
18+
# application may have stripped the constraints out of the copied
19+
# migration. Where it does not fire, rows survive into the next test with
20+
# an instance_id pointing at nothing, and a test that reads them sees
21+
# another test's data. Deleting each table costs nothing and does not
22+
# depend on referential integrity.
1523
# @rbs () -> void
1624
def reset_actors!
1725
SolidObjects.reset_caller_process!
18-
Instance.delete_all
26+
actor_owned_models.each(&:delete_all)
1927
Process.delete_all
2028
end
29+
30+
# Children first, so the order is safe whether or not the cascade fires.
31+
# @rbs () -> Array[Class]
32+
def actor_owned_models
33+
[
34+
DeadLetter,
35+
ClaimedMessage,
36+
ReadyMessage,
37+
Broadcast,
38+
Effect,
39+
Reminder,
40+
Message,
41+
Instance
42+
]
43+
end
2144
end
2245

2346
# @rbs () -> void

sig/generated/lib/solid_objects/test_helper.rbs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@ module SolidObjects
55
# @rbs (Class) -> void
66
def self.included: (Class) -> void
77

8+
# Deleting instances alone left every other actor-owned row to the
9+
# database cascade. That cascade is not enforced everywhere: SQLite has
10+
# to be asked for foreign keys, MySQL has to be on InnoDB, and a host
11+
# application may have stripped the constraints out of the copied
12+
# migration. Where it does not fire, rows survive into the next test with
13+
# an instance_id pointing at nothing, and a test that reads them sees
14+
# another test's data. Deleting each table costs nothing and does not
15+
# depend on referential integrity.
816
# @rbs () -> void
917
def self.reset_actors!: () -> void
1018

19+
# Children first, so the order is safe whether or not the cascade fires.
20+
# @rbs () -> Array[Class]
21+
def self.actor_owned_models: () -> Array[Class]
22+
1123
# @rbs () -> void
1224
def reset_actors!: () -> void
1325

test/integration/public_test_helper_test.rb

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,37 @@ class ActorTestCase < ActiveSupport::TestCase
4848
assert_empty SolidObjects::Process.all
4949
end
5050

51+
# The helper used to delete instances and let the database cascade remove
52+
# everything else. Where the cascade does not fire, rows survive into the
53+
# next test pointing at an instance that no longer exists, and a test that
54+
# reads them sees another test's data.
55+
test "reset actors clears actor-owned rows without the database cascade" do
56+
skip unless database_family == :sqlite
57+
58+
instance = create_actor_owned_rows
59+
without_foreign_keys do
60+
SolidObjects::TestHelper.reset_actors!
61+
end
62+
63+
remaining = SolidObjects::TestHelper.actor_owned_models.reject { |model| model.count.zero? }
64+
assert_empty remaining.map(&:table_name),
65+
"these tables survived a reset that could not rely on the cascade"
66+
refute_nil instance
67+
end
68+
69+
# A table added later is only covered if the helper is told about it, and the
70+
# cascade would hide the omission on every database that enforces it.
71+
test "every actor-owned table is in the reset list" do
72+
owned = SolidObjects::Record.connection.tables
73+
.grep(/\Asolid_objects_/)
74+
.reject { |table| table == "solid_objects_test_domain_records" }
75+
.sort
76+
listed = SolidObjects::TestHelper.actor_owned_models.map(&:table_name)
77+
78+
assert_equal owned, (listed + [ SolidObjects::Process.table_name ]).sort,
79+
"a Solid Objects table is missing from reset_actors!"
80+
end
81+
5182
test "drain actor messages processes queued work deterministically" do
5283
test_case = ActorTestCase.new("unused")
5384
message_reference = HelperActor.ref("async").async(:increment)
@@ -90,4 +121,90 @@ class ActorTestCase < ActiveSupport::TestCase
90121

91122
assert_includes error.message, "unknown"
92123
end
124+
125+
private
126+
127+
# One row in every actor-owned table, so an omission from the reset list
128+
# shows up as a surviving table rather than as a passing test.
129+
def create_actor_owned_rows
130+
now = Time.current
131+
instance = SolidObjects::Instance.create!(
132+
actor_type: "reset-probe",
133+
actor_id: "one",
134+
state: {},
135+
state_version: 1
136+
)
137+
message = SolidObjects::Message.create!(
138+
instance:,
139+
actor_type: instance.actor_type,
140+
actor_id: instance.actor_id,
141+
message_name: "noop",
142+
message_kind: "async",
143+
arguments: {},
144+
sequence: 1,
145+
max_attempts: 1,
146+
request_id: SecureRandom.uuid,
147+
enqueued_at: now,
148+
available_at: now
149+
)
150+
SolidObjects::ReadyMessage.create!(message:, instance:, sequence: 1, available_at: now)
151+
SolidObjects::ClaimedMessage.create!(
152+
message:,
153+
instance:,
154+
activation_generation: 1,
155+
claimed_at: now
156+
)
157+
SolidObjects::Reminder.create!(
158+
instance:,
159+
actor_type: instance.actor_type,
160+
actor_id: instance.actor_id,
161+
name: "probe",
162+
message_name: "noop",
163+
arguments: {},
164+
next_run_at: now,
165+
status: "scheduled"
166+
)
167+
SolidObjects::Effect.create!(
168+
instance:,
169+
message:,
170+
effect_id: SecureRandom.uuid,
171+
name: "probe",
172+
arguments: {},
173+
max_attempts: 1,
174+
available_at: now
175+
)
176+
SolidObjects::Broadcast.create!(
177+
instance:,
178+
message:,
179+
broadcast_id: SecureRandom.uuid,
180+
observable_name: "probe",
181+
value: {},
182+
state_version: 1,
183+
activation_generation: 1,
184+
available_at: now
185+
)
186+
SolidObjects::DeadLetter.create!(
187+
instance:,
188+
message:,
189+
actor_type: instance.actor_type,
190+
actor_id: instance.actor_id,
191+
message_name: "noop",
192+
arguments: {},
193+
attempts: 1,
194+
exception_class: "RuntimeError",
195+
exception_message: "probe",
196+
backtrace: [],
197+
first_failed_at: now,
198+
last_failed_at: now
199+
)
200+
instance
201+
end
202+
203+
def without_foreign_keys
204+
connection = SolidObjects::Record.connection
205+
connection.execute("PRAGMA foreign_keys = OFF")
206+
yield
207+
ensure
208+
connection.execute("PRAGMA foreign_keys = ON")
209+
end
93210
end

0 commit comments

Comments
 (0)