feat(release): bound durable run retention

This commit is contained in:
2026-07-22 20:16:19 +02:00
parent ddee5c00dc
commit 4edbc11fe5
5 changed files with 409 additions and 57 deletions

View File

@@ -792,6 +792,97 @@ class ReleaseRunStoreTests(unittest.TestCase):
self.assertEqual("unavailable", listed[-1]["status"])
self.assertEqual(older["run_id"], self.store.list(limit=1)[0]["run_id"])
def test_cursor_pagination_is_stable_and_keeps_unavailable_records_visible(
self,
) -> None:
created: list[dict[str, object]] = []
for index in range(3):
with patch(
"govoplan_release.release_run._timestamp",
return_value=f"2026-07-22T0{index}:00:00Z",
):
created.append(
create_run(
self.store,
request_id=f"cursor-page-create-request-{index:04d}",
)
)
corrupt_path = self.root / f"{created[0]['run_id']}.json"
corrupt_path.write_text("{}", encoding="utf-8")
corrupt_path.chmod(0o600)
first = self.store.list_page(limit=1)
second = self.store.list_page(limit=1, cursor=first["next_cursor"])
third = self.store.list_page(limit=1, cursor=second["next_cursor"])
self.assertEqual(created[2]["run_id"], first["runs"][0]["run_id"])
self.assertEqual(created[1]["run_id"], second["runs"][0]["run_id"])
self.assertEqual("unavailable", third["runs"][0]["status"])
self.assertIsNone(third["next_cursor"])
with self.assertRaisesRegex(ReleaseRunConflict, "cursor is invalid"):
self.store.list_page(limit=1, cursor=f"{first['next_cursor']}tampered")
def test_retention_prunes_only_oldest_completed_records_and_fails_closed(
self,
) -> None:
with patch("govoplan_release.release_run.MAX_RUN_RECORDS", 3):
completed: list[dict[str, object]] = []
for index in range(2):
with patch(
"govoplan_release.release_run._timestamp",
return_value=f"2026-07-22T0{index}:00:00Z",
):
run = create_run(
self.store,
request_id=f"retention-completed-create-{index:04d}",
plan_snapshot=mutating_first_plan(),
)
attempt_id = f"retention-completed-attempt-{index:04d}"
self.store.start_step(
run["run_id"], "core:tag", attempt_id=attempt_id
)
self.store.finish_step(
run["run_id"],
"core:tag",
attempt_id=attempt_id,
succeeded=True,
result_code="tag_created",
)
completed.append(run)
active = create_run(
self.store,
request_id="retention-active-create-0001",
)
replacement = create_run(
self.store,
request_id="retention-replacement-create-0001",
)
self.assertFalse(
(self.root / f"{completed[0]['run_id']}.json").exists()
)
self.assertTrue((self.root / f"{completed[1]['run_id']}.json").exists())
self.assertTrue((self.root / f"{active['run_id']}.json").exists())
self.assertTrue((self.root / f"{replacement['run_id']}.json").exists())
corrupt_path = self.root / f"{completed[1]['run_id']}.json"
corrupt_path.write_text("{}", encoding="utf-8")
corrupt_path.chmod(0o600)
with self.assertRaisesRegex(ReleaseRunConflict, "retention limit"):
create_run(
self.store,
request_id="retention-refused-create-0001",
)
self.assertEqual(3, len(list(self.root.glob("*.json"))))
self.assertIn(
completed[1]["run_id"],
{
item["run_id"]
for item in self.store.list_page(limit=3)["runs"]
if item["status"] == "unavailable"
},
)
def test_list_enumeration_oserror_is_normalized(self) -> None:
self.store.list()
with patch.object(
@@ -872,6 +963,51 @@ class ReleaseRunStoreTests(unittest.TestCase):
class ReleaseRunApiTests(unittest.TestCase):
def test_api_exposes_and_validates_cursor_pagination(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(
workspace_root=Path(temp_dir),
run_state_root=Path(temp_dir) / "runs",
token="token",
)
headers = {"X-Release-Console-Token": "token"}
with (
patch("server.app.build_dashboard", return_value=object()),
patch(
"server.app.build_selective_release_plan",
return_value=release_plan(),
),
TestClient(app) as client,
):
for index in range(2):
response = client.post(
"/api/release-runs",
headers=headers,
json=api_run_input(
request_id=f"api-page-create-request-{index:04d}"
),
)
self.assertEqual(200, response.status_code)
first = client.get(
"/api/release-runs?limit=1", headers=headers
).json()
second = client.get(
"/api/release-runs",
headers=headers,
params={"limit": 1, "cursor": first["next_cursor"]},
).json()
invalid = client.get(
"/api/release-runs?cursor=not-a-cursor", headers=headers
)
self.assertEqual(1, len(first["runs"]))
self.assertEqual(1, len(second["runs"]))
self.assertNotEqual(
first["runs"][0]["run_id"], second["runs"][0]["run_id"]
)
self.assertIsNone(second["next_cursor"])
self.assertEqual(409, invalid.status_code)
def test_token_guarded_create_list_read_resume_and_retry(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir) / "runs"