Skip to content

Commit eddfca9

Browse files
authored
test(retention): cover snapshot budget clock and cancellation boundaries (#331)
1 parent bc2b874 commit eddfca9

1 file changed

Lines changed: 339 additions & 0 deletions

File tree

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
"""Local fault injection at the reviewed retained-planning boundary; no publication."""
2+
3+
import unittest
4+
from dataclasses import replace
5+
from unittest.mock import patch
6+
7+
from aethermesh_core import artifact_retained_repair as integration
8+
from aethermesh_core.artifact_admission import AdmissionConflict, ArtifactAdmission
9+
from aethermesh_core.artifact_owned_inventory import (
10+
OwnedInventorySelection,
11+
audit_owned_inventory,
12+
)
13+
from aethermesh_core.artifact_repair_plan import RepairPlanLimits
14+
from aethermesh_core.artifact_retention import RetentionPlan, retention_terms
15+
from aethermesh_core.artifact_retention_store import RetentionStore
16+
from tests import test_artifact_retained_repair as fixture
17+
from tests.test_artifact_codec import package
18+
19+
20+
class RetentionBoundaryTests(unittest.TestCase):
21+
setUp = fixture.RetainedRepairTests.setUp
22+
make_source = fixture.RetainedRepairTests.make_source
23+
run_plan = fixture.RetainedRepairTests.run_plan
24+
assert_unchanged = fixture.RetainedRepairTests.assert_unchanged
25+
26+
def test_cross_store_report_with_same_revision_and_charge_is_rejected(self):
27+
source = self.sources[1]
28+
report = audit_owned_inventory(
29+
source.store.path,
30+
source.selections,
31+
ledger_directory=source.store.ledger_directory,
32+
store_id=source.store.store_id,
33+
limits=source.store.limits,
34+
max_read_bytes=4,
35+
)
36+
self.assertEqual(report.ledger.revision, self.before[0].ledger.revision)
37+
self.assertEqual(
38+
report.ledger.charged_bytes, self.before[0].ledger.charged_bytes
39+
)
40+
with (
41+
patch.object(integration, "_audit_locked", return_value=report),
42+
self.assertRaises(AdmissionConflict),
43+
):
44+
self.run_plan()
45+
self.assert_unchanged()
46+
47+
def test_change_in_not_yet_locked_store_rejects_mixed_snapshot(self):
48+
original = RetentionStore._state
49+
changed = False
50+
51+
def state_then_change_later_store(store, ledger):
52+
nonlocal changed
53+
state = original(store, ledger)
54+
if not changed:
55+
changed = True
56+
target = self.sources[-1].store
57+
writer = ArtifactAdmission(
58+
target.ledger_directory,
59+
store_id=target.store_id,
60+
limits=target.limits,
61+
)
62+
try:
63+
row = writer.snapshot().records[0]
64+
writer.invalidate(row.request_id, expected_revision=row.revision)
65+
finally:
66+
writer.close()
67+
return state
68+
69+
with (
70+
patch.object(RetentionStore, "_state", state_then_change_later_store),
71+
patch.object(integration, "_audit_locked") as audit,
72+
self.assertRaises(AdmissionConflict),
73+
):
74+
self.run_plan()
75+
audit.assert_not_called()
76+
after = tuple(source.store.snapshot() for source in self.sources)
77+
self.assertEqual(after[:-1], self.before[:-1])
78+
self.assertEqual(after[-1].ledger.revision, self.before[-1].ledger.revision + 1)
79+
self.assertEqual(
80+
after[-1].ledger.charged_bytes, self.before[-1].ledger.charged_bytes
81+
)
82+
sources = self.sources[:-1] + (
83+
replace(
84+
self.sources[-1], expected_ledger_revision=after[-1].ledger.revision
85+
),
86+
)
87+
result = self.run_plan(sources=sources)
88+
self.assertEqual(result.candidate.status, "blocked")
89+
self.assertEqual(result.evidence[-1].eligibility[0].reason, "stale_admission")
90+
91+
def test_transaction_change_after_observation_before_proposal_rolls_back(self):
92+
original = integration._audit_locked
93+
ledgers = []
94+
95+
def audit_then_change_first(*args, **kwargs):
96+
report = original(*args, **kwargs)
97+
ledgers.append(kwargs["ledger"])
98+
if len(ledgers) == 4:
99+
# Fault injection inside the held transaction, after the first audit.
100+
ledgers[0]._db.execute("UPDATE catalog SET revision=revision+1")
101+
return report
102+
103+
with (
104+
patch.object(integration, "_audit_locked", audit_then_change_first),
105+
patch.object(
106+
integration,
107+
"plan_artifact_repair",
108+
wraps=integration.plan_artifact_repair,
109+
) as propose,
110+
self.assertRaisesRegex(AdmissionConflict, "changed during planning"),
111+
):
112+
self.run_plan()
113+
propose.assert_called_once()
114+
self.assert_unchanged()
115+
self.assertEqual(self.run_plan().candidate.status, "candidate")
116+
117+
def test_exact_clock_boundaries_and_read_only_clock_limit(self):
118+
for now, status, reason in (
119+
(2, "candidate", "eligible"),
120+
(39, "candidate", "eligible"),
121+
(40, "blocked", "expired"),
122+
(41, "blocked", "expired"),
123+
(100, "blocked", "expired"),
124+
):
125+
with self.subTest(now=now):
126+
result = self.run_plan(now=now)
127+
self.assertEqual(result.candidate.status, status)
128+
self.assertTrue(
129+
all(row.eligibility[0].reason == reason for row in result.evidence)
130+
)
131+
self.assertFalse(
132+
result.serving_authorized or result.retention_confirmed
133+
)
134+
self.assert_unchanged()
135+
with self.assertRaises(AdmissionConflict):
136+
self.run_plan(now=1)
137+
# Documented limit: read-only observations do not persist clock advancement.
138+
self.assertEqual(self.run_plan(now=39).candidate.status, "candidate")
139+
self.assert_unchanged()
140+
141+
def test_later_clock_in_one_store_fences_the_whole_call(self):
142+
source = self.sources[0]
143+
store = source.store
144+
report = audit_owned_inventory(
145+
store.path,
146+
source.selections,
147+
ledger_directory=store.ledger_directory,
148+
store_id=store.store_id,
149+
limits=store.limits,
150+
max_read_bytes=4,
151+
)
152+
renewed = (
153+
self.before[0]
154+
.plans[0]
155+
.advance(
156+
"renew",
157+
operation_id="d" * 64,
158+
expected_revision=1,
159+
at=20,
160+
until=60,
161+
evidence=report,
162+
)
163+
)
164+
state = store.commit(
165+
renewed,
166+
source.selections,
167+
expected_revision=1,
168+
expected_ledger_revision=report.ledger.revision,
169+
max_read_bytes=4,
170+
).state
171+
sources = (
172+
replace(source, expected_ledger_revision=state.ledger.revision),
173+
) + self.sources[1:]
174+
with self.assertRaises(AdmissionConflict):
175+
self.run_plan(sources=sources, now=19)
176+
self.assertEqual(
177+
self.run_plan(sources=sources, now=20).candidate.status, "candidate"
178+
)
179+
at_other_deadline = self.run_plan(sources=sources, now=40)
180+
self.assertEqual(at_other_deadline.candidate.status, "blocked")
181+
self.assertEqual(
182+
at_other_deadline.evidence[0].eligibility[0].reason, "eligible"
183+
)
184+
self.assertTrue(
185+
all(
186+
row.eligibility[0].reason == "expired"
187+
for row in at_other_deadline.evidence[1:]
188+
)
189+
)
190+
self.assertEqual(store.snapshot(), state)
191+
192+
def test_all_stripe_and_audit_budgets_with_unequal_final_stripe(self):
193+
self.root = self.root / "two-stripes"
194+
self.root.mkdir(mode=0o700)
195+
self.pkg = package(bytes(range(21)), fragment_bytes=4)
196+
sources = []
197+
for index in range(4):
198+
source = self.make_source(index)
199+
store = source.store
200+
ledger = ArtifactAdmission(
201+
store.ledger_directory, store_id=store.store_id, limits=store.limits
202+
)
203+
try:
204+
key = "d" * 64
205+
ledger.reserve(
206+
key,
207+
self.pkg.manifest,
208+
index + 6,
209+
expected_digest=self.pkg.manifest.digest,
210+
)
211+
path = store.path / (key + ".fragment")
212+
path.touch(mode=0o600)
213+
path.write_bytes(self.pkg.fragments[index + 6])
214+
selections = source.selections + (
215+
OwnedInventorySelection(key, self.pkg.manifest),
216+
)
217+
report = audit_owned_inventory(
218+
store.path,
219+
selections,
220+
ledger_directory=store.ledger_directory,
221+
store_id=store.store_id,
222+
limits=store.limits,
223+
max_read_bytes=8,
224+
)
225+
terms = retention_terms(
226+
self.pkg.manifest,
227+
report,
228+
key,
229+
store_id=store.store_id,
230+
limits=store.limits,
231+
max_duration_seconds=50,
232+
max_records=4,
233+
)
234+
plan = RetentionPlan("e" * 64, terms).advance(
235+
"accept",
236+
operation_id="f" * 64,
237+
expected_revision=0,
238+
at=2,
239+
until=40,
240+
evidence=report,
241+
)
242+
state = store.commit(
243+
plan,
244+
selections,
245+
expected_revision=0,
246+
expected_ledger_revision=report.ledger.revision,
247+
max_read_bytes=8,
248+
).state
249+
sources.append(
250+
replace(
251+
source,
252+
selections=selections,
253+
expected_ledger_revision=state.ledger.revision,
254+
)
255+
)
256+
finally:
257+
ledger.close()
258+
self.sources = tuple(sources)
259+
self.before = tuple(source.store.snapshot() for source in self.sources)
260+
limits = RepairPlanLimits(24, 2, 3, 1)
261+
result = self.run_plan(targets=(10,), limits=limits, max_audit_read_bytes=24)
262+
self.assertEqual(result.candidate.status, "candidate")
263+
self.assertEqual(result.audit_read_bytes, 24)
264+
self.assertEqual(result.candidate.required_read_bytes, 24)
265+
self.assertEqual(result.candidate.required_write_bytes, 2)
266+
self.assertEqual(
267+
[row.size for row in result.candidate.inputs], [4] * 4 + [2] * 4
268+
)
269+
self.assertEqual(
270+
[len(batch) for batch in result.candidate.input_batches], [3, 3, 2]
271+
)
272+
for short in (
273+
replace(limits, max_read_bytes=23),
274+
replace(limits, max_write_bytes=1),
275+
):
276+
with self.subTest(short=short):
277+
blocked = self.run_plan(
278+
targets=(10,), limits=short, max_audit_read_bytes=24
279+
)
280+
self.assertEqual(blocked.candidate.reason, "byte_budget")
281+
self.assertEqual(blocked.candidate.required_read_bytes, 24)
282+
self.assertEqual(blocked.candidate.inputs, ())
283+
with (
284+
patch.object(integration, "_audit_locked") as audit,
285+
self.assertRaisesRegex(ValueError, "aggregate"),
286+
):
287+
self.run_plan(targets=(10,), limits=limits, max_audit_read_bytes=23)
288+
audit.assert_not_called()
289+
self.assert_unchanged()
290+
291+
def test_cancellation_after_first_store_releases_partial_locks(self):
292+
original = RetentionStore._state
293+
opened = []
294+
295+
def observe_open(store, ledger):
296+
state = original(store, ledger)
297+
opened.append(store.store_id)
298+
return state
299+
300+
def cancel_after_first():
301+
if opened:
302+
raise InterruptedError("cancel after first locked snapshot")
303+
304+
with (
305+
patch.object(RetentionStore, "_state", observe_open),
306+
patch.object(integration, "_audit_locked") as audit,
307+
self.assertRaises(InterruptedError),
308+
):
309+
self.run_plan(check_cancel=cancel_after_first)
310+
self.assertEqual(len(opened), 1)
311+
audit.assert_not_called()
312+
self.assert_unchanged()
313+
self.assertEqual(self.run_plan().candidate.status, "candidate")
314+
315+
def test_cancellation_inside_payload_audit_releases_all_locks(self):
316+
from aethermesh_core import artifact_inventory
317+
318+
original = artifact_inventory.verify_fragment_file
319+
verifying = False
320+
321+
def verify(*args, **kwargs):
322+
nonlocal verifying
323+
verifying = True
324+
return original(*args, **kwargs)
325+
326+
def cancel_in_verify():
327+
if verifying:
328+
raise InterruptedError("cancel during fragment verification")
329+
330+
with (
331+
patch.object(
332+
artifact_inventory, "verify_fragment_file", side_effect=verify
333+
) as observed,
334+
self.assertRaises(InterruptedError),
335+
):
336+
self.run_plan(check_cancel=cancel_in_verify)
337+
observed.assert_called_once()
338+
self.assert_unchanged()
339+
self.assertEqual(self.run_plan().candidate.status, "candidate")

0 commit comments

Comments
 (0)