fix(release): preserve durable recovery capacity
This commit is contained in:
@@ -25,6 +25,7 @@ if str(RELEASE_ROOT) not in sys.path:
|
||||
|
||||
from govoplan_release.release_run import ( # noqa: E402
|
||||
MAX_EVENTS,
|
||||
MAX_RECORD_BYTES,
|
||||
ReleaseRunConflict,
|
||||
ReleaseRunCorrupt,
|
||||
ReleaseRunNotFound,
|
||||
@@ -268,6 +269,93 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual("completed", completed["state"]["status"])
|
||||
|
||||
def test_attempt_start_reserves_serialized_recovery_capacity(self) -> None:
|
||||
rejected_plan = mutating_first_plan()
|
||||
rejected_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 2_500)]
|
||||
rejected_run_id = create_run(
|
||||
self.store,
|
||||
request_id="byte-capacity-rejected-create-0001",
|
||||
plan_snapshot=rejected_plan,
|
||||
)["run_id"]
|
||||
rejected_path = self.root / f"{rejected_run_id}.json"
|
||||
self.assertGreater(rejected_path.stat().st_size, MAX_RECORD_BYTES - 1_000)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseRunConflict, "serialized recovery capacity"
|
||||
):
|
||||
self.store.start_step(
|
||||
rejected_run_id,
|
||||
"core:tag",
|
||||
attempt_id="byte-capacity-rejected-attempt-0001",
|
||||
)
|
||||
untouched = self.store.get(rejected_run_id)
|
||||
self.assertEqual("pending", untouched["state"]["steps"][0]["state"])
|
||||
self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"])
|
||||
|
||||
accepted_plan = mutating_first_plan()
|
||||
accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)]
|
||||
accepted_run_id = create_run(
|
||||
self.store,
|
||||
request_id="byte-capacity-accepted-create-0001",
|
||||
plan_snapshot=accepted_plan,
|
||||
)["run_id"]
|
||||
accepted_path = self.root / f"{accepted_run_id}.json"
|
||||
self.store.start_step(
|
||||
accepted_run_id,
|
||||
"core:tag",
|
||||
attempt_id="byte-capacity-accepted-attempt-0001",
|
||||
)
|
||||
self.store.resume(
|
||||
accepted_run_id,
|
||||
request_id="byte-capacity-accepted-resume-0001",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseRunConflict, "reserved for a terminal reconciliation"
|
||||
):
|
||||
self.store.reconcile_step(
|
||||
accepted_run_id,
|
||||
"core:tag",
|
||||
request_id="byte-capacity-unresolved-request-0001",
|
||||
outcome="unresolved",
|
||||
confirmation="RECONCILE",
|
||||
)
|
||||
completed = self.store.reconcile_step(
|
||||
accepted_run_id,
|
||||
"core:tag",
|
||||
request_id="byte-capacity-terminal-request-0001",
|
||||
outcome="effect_succeeded",
|
||||
confirmation="RECONCILE",
|
||||
)
|
||||
self.assertEqual("completed", completed["state"]["status"])
|
||||
self.assertLessEqual(accepted_path.stat().st_size, MAX_RECORD_BYTES)
|
||||
|
||||
def test_read_only_start_reserves_serialized_resume_and_retry_capacity(
|
||||
self,
|
||||
) -> None:
|
||||
plan = mutating_first_plan()
|
||||
plan["dry_run_steps"][0]["mutating"] = False # type: ignore[index]
|
||||
plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)]
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
request_id="byte-capacity-read-only-create-0001",
|
||||
plan_snapshot=plan,
|
||||
)["run_id"]
|
||||
self.store.start_step(
|
||||
run_id,
|
||||
"core:tag",
|
||||
attempt_id="byte-capacity-read-only-attempt-0001",
|
||||
)
|
||||
self.store.resume(
|
||||
run_id,
|
||||
request_id="byte-capacity-read-only-resume-0001",
|
||||
)
|
||||
retried = self.store.retry_step(
|
||||
run_id,
|
||||
"core:tag",
|
||||
request_id="byte-capacity-read-only-retry-0001",
|
||||
)
|
||||
self.assertEqual("pending", retried["state"]["steps"][0]["state"])
|
||||
|
||||
def test_unresolved_reconciliation_is_bounded_and_preserves_terminal_slot(self) -> None:
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
@@ -659,6 +747,61 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
listed = self.store.list(limit=1)
|
||||
self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed])
|
||||
|
||||
def test_list_orders_verified_runs_by_updated_timestamp_before_unavailable(
|
||||
self,
|
||||
) -> None:
|
||||
with patch(
|
||||
"govoplan_release.release_run._timestamp",
|
||||
return_value="2026-07-22T00:00:00Z",
|
||||
):
|
||||
older = create_run(
|
||||
self.store,
|
||||
request_id="timestamp-order-request-older-0001",
|
||||
)
|
||||
with patch(
|
||||
"govoplan_release.release_run._timestamp",
|
||||
return_value="2026-07-22T01:00:00Z",
|
||||
):
|
||||
newer = create_run(
|
||||
self.store,
|
||||
request_id="timestamp-order-request-newer-0001",
|
||||
)
|
||||
with patch(
|
||||
"govoplan_release.release_run._timestamp",
|
||||
return_value="2026-07-22T02:00:00Z",
|
||||
):
|
||||
self.store.start_step(
|
||||
older["run_id"],
|
||||
"core:preflight",
|
||||
attempt_id="timestamp-order-attempt-0001",
|
||||
)
|
||||
|
||||
corrupt = create_run(
|
||||
self.store,
|
||||
request_id="timestamp-order-request-corrupt-0001",
|
||||
)
|
||||
corrupt_path = self.root / f"{corrupt['run_id']}.json"
|
||||
corrupt_path.write_text("{}", encoding="utf-8")
|
||||
corrupt_path.chmod(0o600)
|
||||
|
||||
listed = self.store.list(limit=3)
|
||||
self.assertEqual(
|
||||
[older["run_id"], newer["run_id"], corrupt["run_id"]],
|
||||
[item["run_id"] for item in listed],
|
||||
)
|
||||
self.assertEqual("unavailable", listed[-1]["status"])
|
||||
self.assertEqual(older["run_id"], self.store.list(limit=1)[0]["run_id"])
|
||||
|
||||
def test_list_enumeration_oserror_is_normalized(self) -> None:
|
||||
self.store.list()
|
||||
with patch.object(
|
||||
Path,
|
||||
"iterdir",
|
||||
side_effect=PermissionError("denied"),
|
||||
):
|
||||
with self.assertRaisesRegex(ReleaseRunCorrupt, "enumerated safely"):
|
||||
self.store.list()
|
||||
|
||||
def test_concurrent_claims_have_one_winner_and_no_lost_update(self) -> None:
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
@@ -1103,6 +1246,7 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
self.assertIn('const runsRequest = api("/api/release-runs")', webui)
|
||||
self.assertIn("pendingReleaseRunPayload", webui)
|
||||
self.assertIn("recoverPendingReleaseRun", webui)
|
||||
self.assertIn("recoverPendingRunCommands", webui)
|
||||
self.assertIn("inFlightRunCommandId", webui)
|
||||
self.assertIn("Run saved; list refresh unavailable", webui)
|
||||
self.assertIn("This foundation tracks state only.", runbook)
|
||||
@@ -1127,6 +1271,9 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
"submitReleaseRunCreate",
|
||||
"refreshReleaseRunListAfterCreate",
|
||||
"readInFlightRunCommands",
|
||||
"pendingRunCommandRequest",
|
||||
"recoverPendingRunCommands",
|
||||
"deterministicRunCommandError",
|
||||
"inFlightRunCommandId",
|
||||
"clearInFlightRunCommand",
|
||||
"requestId",
|
||||
@@ -1163,10 +1310,17 @@ const renderReleaseRun = (run) => { elements.runOutput.innerHTML = `RUN:${run.ru
|
||||
const renderReleaseRunList = () => {};
|
||||
let failCreate = true;
|
||||
let failList = true;
|
||||
let failureStatus = null;
|
||||
const postedRequestIds = [];
|
||||
async function postJson(_path, payload) {
|
||||
const postedPaths = [];
|
||||
async function postJson(path, payload) {
|
||||
postedPaths.push(path);
|
||||
postedRequestIds.push(payload.request_id);
|
||||
if (failCreate) throw new Error("uncertain transport failure");
|
||||
if (failCreate) {
|
||||
const error = new Error(failureStatus ? `server rejected ${failureStatus}` : "uncertain transport failure");
|
||||
if (failureStatus) error.status = failureStatus;
|
||||
throw error;
|
||||
}
|
||||
return { run_id: "rr-known", immutable: { input: {} }, state: { steps: [] } };
|
||||
}
|
||||
async function api(_path) {
|
||||
@@ -1194,11 +1348,40 @@ async function api(_path) {
|
||||
assert(!elements.runOutput.innerHTML.includes("Run creation failed"), "list failure was labeled as create failure");
|
||||
assert(releaseState.currentRun.run_id === "rr-known", "saved run was not retained");
|
||||
|
||||
const commandOne = inFlightRunCommandId("retry:rr-known:step", "retry");
|
||||
const commandReplay = inFlightRunCommandId("retry:rr-known:step", "retry");
|
||||
const commandKey = "retry:rr-known:core:preflight";
|
||||
const commandOne = inFlightRunCommandId(commandKey, "retry");
|
||||
const commandReplay = inFlightRunCommandId(commandKey, "retry");
|
||||
assert(commandOne === commandReplay, "uncertain command did not reuse its request ID");
|
||||
clearInFlightRunCommand("retry:rr-known:step", commandOne);
|
||||
const commandAfterSuccess = inFlightRunCommandId("retry:rr-known:step", "retry");
|
||||
const retryRequest = pendingRunCommandRequest(commandKey, commandOne);
|
||||
assert(retryRequest.path.endsWith("/steps/core%3Apreflight/retry"), "retry recovery path was not executable");
|
||||
const reconcileRequest = pendingRunCommandRequest("reconcile:rr-known:core:tag:effect_succeeded", "reconcile-request");
|
||||
assert(reconcileRequest.path.endsWith("/steps/core%3Atag/reconcile"), "reconciliation recovery path was not executable");
|
||||
assert(reconcileRequest.body.confirm === "RECONCILE" && reconcileRequest.body.outcome === "effect_succeeded", "reconciliation recovery body changed");
|
||||
|
||||
failCreate = true;
|
||||
const uncertainCommand = await recoverPendingRunCommands();
|
||||
assert(uncertainCommand === false, "uncertain command recovery unexpectedly succeeded");
|
||||
assert(readInFlightRunCommands()[commandKey] === commandOne, "uncertain command ID was cleared");
|
||||
failCreate = false;
|
||||
const recoveredCommand = await recoverPendingRunCommands();
|
||||
assert(recoveredCommand === true, "pending command did not recover after reload");
|
||||
assert(readInFlightRunCommands()[commandKey] === undefined, "replayed command ID was not cleared");
|
||||
assert(postedRequestIds.slice(-2).every((value) => value === commandOne), "command replay changed request ID");
|
||||
assert(postedPaths.at(-1).endsWith("/steps/core%3Apreflight/retry"), "command replay used the wrong endpoint");
|
||||
assert(releaseState.currentRun.run_id === "rr-known", "recovered command did not select its run");
|
||||
|
||||
const conflictKey = "resume:rr-known";
|
||||
const conflictId = inFlightRunCommandId(conflictKey, "resume");
|
||||
failCreate = true;
|
||||
failureStatus = 409;
|
||||
const rejectedCommand = await recoverPendingRunCommands();
|
||||
assert(rejectedCommand === false, "state-conflict recovery unexpectedly succeeded");
|
||||
assert(readInFlightRunCommands()[conflictKey] === undefined, "deterministic 409 retained a stale command ID");
|
||||
assert(elements.runOutput.innerHTML.includes("Saved command was not accepted"), "deterministic conflict was not explained");
|
||||
failCreate = false;
|
||||
failureStatus = null;
|
||||
|
||||
const commandAfterSuccess = inFlightRunCommandId(commandKey, "retry");
|
||||
assert(commandAfterSuccess !== commandOne, "successful command did not clear its request ID");
|
||||
})().catch((error) => { console.error(error.stack || error.message); process.exit(1); });
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user