mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[chore] logical-gaps: release-proposal already_published closes proposal + heartbeat-lock-loss cancels execute (2 gaps)
approve() closed the proposal only on status=='published'. A retry that finds the tag already shipped returns 'already_published' (is_already_published), so if a prior publish's route commit failed / HTTP 504'd, the proposal stayed non-terminal forever — every retry returned already_published and never closed it; only a manual cancel unstuck it. Close on both published and already_published: the release shipped either way. _heartbeat_loop returned silently when the lock was no longer owned (a >TTL Redis outage let the mutex expire mid-execute), leaving executor.execute running UNGUARDED — a concurrent approve (once Redis returns) could then acquire the lock and _prepare_release_clone rm -rf the in-flight shared release clone while the first execute was still mid-run_gate, re-opening the very rm -rf-clone race the mutex+heartbeat exist to prevent. Run execute as a task; on lock-loss the heartbeat sets a flag and cancels it, and approve() turns the CancelledError into a structured 'lock_lost' result (an external cancellation of approve itself still propagates — distinguished by the flag). TDD: 2 red→green (already_published → COMPLETED not wedged; heartbeat lock-loss → lock_lost + execute cancelled, proposal not completed). 8 concurrency tests green; ruff/mypy clean.
This commit is contained in:
@@ -140,13 +140,46 @@ class ReleaseProposalService(BaseService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
heartbeat_task: asyncio.Task[None] | None = None
|
heartbeat_task: asyncio.Task[None] | None = None
|
||||||
|
execute_task: asyncio.Task[ReleaseResult] | None = None
|
||||||
|
# Set by the heartbeat when IT cancels execute on lock-loss, so the
|
||||||
|
# CancelledError handler below can distinguish a lock-loss abort (→
|
||||||
|
# structured ``lock_lost`` result) from an external cancellation of the
|
||||||
|
# approve coroutine itself (→ must propagate).
|
||||||
|
lock_lost = asyncio.Event()
|
||||||
try:
|
try:
|
||||||
executor = await get_release_executor(self.session)
|
executor = await get_release_executor(self.session)
|
||||||
|
execute_task = asyncio.create_task(executor.execute(report))
|
||||||
heartbeat_task = asyncio.create_task(
|
heartbeat_task = asyncio.create_task(
|
||||||
self._heartbeat_loop(lock_key, lock_token)
|
self._heartbeat_loop(lock_key, lock_token, execute_task, lock_lost)
|
||||||
)
|
)
|
||||||
result = await executor.execute(report)
|
try:
|
||||||
if result.status == "published":
|
result = await execute_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not lock_lost.is_set():
|
||||||
|
# External cancellation of approve itself — propagate, do not
|
||||||
|
# mask it as a lock-loss.
|
||||||
|
raise
|
||||||
|
logger.critical(
|
||||||
|
"release execute aborted: lock lost mid-execute (fail-closed)"
|
||||||
|
)
|
||||||
|
return ReleaseResult(
|
||||||
|
status="lock_lost",
|
||||||
|
version=report.proposed_version,
|
||||||
|
files_changed=[],
|
||||||
|
commit_sha=None,
|
||||||
|
release_url=None,
|
||||||
|
detail=(
|
||||||
|
"The release lock was lost mid-execute (an extended Redis"
|
||||||
|
" outage let the mutex TTL expire); the execute was"
|
||||||
|
" aborted fail-closed so a concurrent approve could not"
|
||||||
|
" rm -rf the in-flight release clone. Retry the approve."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Close the proposal when the release actually shipped — including a
|
||||||
|
# retry that finds the tag already published (a prior publish whose
|
||||||
|
# route commit/HTTP 504'd left the proposal non-terminal). The old
|
||||||
|
# `== "published"`-only check wedged already_published open forever.
|
||||||
|
if result.status in ("published", "already_published"):
|
||||||
task.status = TaskStatus.COMPLETED
|
task.status = TaskStatus.COMPLETED
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return result
|
return result
|
||||||
@@ -154,6 +187,9 @@ class ReleaseProposalService(BaseService):
|
|||||||
if heartbeat_task is not None:
|
if heartbeat_task is not None:
|
||||||
heartbeat_task.cancel()
|
heartbeat_task.cancel()
|
||||||
await asyncio.gather(heartbeat_task, return_exceptions=True)
|
await asyncio.gather(heartbeat_task, return_exceptions=True)
|
||||||
|
if execute_task is not None and not execute_task.done():
|
||||||
|
execute_task.cancel()
|
||||||
|
await asyncio.gather(execute_task, return_exceptions=True)
|
||||||
await self._release_release_lock(lock_key, lock_token)
|
await self._release_release_lock(lock_key, lock_token)
|
||||||
|
|
||||||
async def _acquire_release_lock(self, lock_key: str) -> str | None:
|
async def _acquire_release_lock(self, lock_key: str) -> str | None:
|
||||||
@@ -205,22 +241,37 @@ class ReleaseProposalService(BaseService):
|
|||||||
finally:
|
finally:
|
||||||
await conn.aclose()
|
await conn.aclose()
|
||||||
|
|
||||||
async def _heartbeat_loop(self, lock_key: str, token: str) -> None:
|
async def _heartbeat_loop(
|
||||||
|
self,
|
||||||
|
lock_key: str,
|
||||||
|
token: str,
|
||||||
|
execute_task: asyncio.Task[ReleaseResult],
|
||||||
|
lock_lost: asyncio.Event,
|
||||||
|
) -> None:
|
||||||
"""Refresh the lock TTL while the execute owns it.
|
"""Refresh the lock TTL while the execute owns it.
|
||||||
|
|
||||||
Refreshes before the first sleep so a fast execute still extends the
|
Refreshes before the first sleep so a fast execute still extends the
|
||||||
TTL. A refresh error logs and continues (never crashes the execute); if
|
TTL. A refresh error logs and continues (never crashes the execute); if
|
||||||
the lock is no longer ours (returned 0) we stop — the TTL backstop and
|
the lock is no longer ours (returned 0 — only reachable after a >TTL
|
||||||
the fencing token still hold the line.
|
Redis outage lets the mutex expire mid-execute) we CANCEL the in-flight
|
||||||
|
execute fail-closed rather than ``return`` silently and leave it running
|
||||||
|
unguarded — otherwise a concurrent approve (once Redis returns) can
|
||||||
|
acquire the lock and ``_prepare_release_clone`` ``rm -rf``'s the shared
|
||||||
|
release clone while the first execute is still mid-``run_gate``. The
|
||||||
|
fencing token still prevents the first finally from deleting the
|
||||||
|
usurper's lock; this prevents the usurper's rm -rf from corrupting the
|
||||||
|
first execute.
|
||||||
"""
|
"""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if not await self._heartbeat_release_lock(lock_key, token):
|
if not await self._heartbeat_release_lock(lock_key, token):
|
||||||
logger.critical(
|
logger.critical(
|
||||||
"release lock no longer owned during execute — "
|
"release lock no longer owned during execute — "
|
||||||
"TTL backstop active; a concurrent approve was refused "
|
"aborting execute fail-closed so a concurrent approve"
|
||||||
"by the fencing token"
|
" cannot rm -rf the in-flight release clone"
|
||||||
)
|
)
|
||||||
|
lock_lost.set()
|
||||||
|
execute_task.cancel()
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("release lock heartbeat failed (redis): %s", exc)
|
logger.warning("release lock heartbeat failed (redis): %s", exc)
|
||||||
|
|||||||
@@ -393,3 +393,94 @@ async def test_heartbeat_refreshes_lock_and_is_cancelled_in_finally(
|
|||||||
assert all(k == name for k, _ttl in fake_redis.expire_calls)
|
assert all(k == name for k, _ttl in fake_redis.expire_calls)
|
||||||
# And the lock was released at the end.
|
# And the lock was released at the end.
|
||||||
assert await fake_redis.get(name) is None
|
assert await fake_redis.get(name) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_already_published_closes_proposal_not_wedges_open() -> None:
|
||||||
|
"""#149: when a prior publish landed (tag exists) but the route commit
|
||||||
|
failed / 504'd, a retry sees the tag and execute returns ``already_published``.
|
||||||
|
approve() must STILL mark the proposal COMPLETED — the release shipped —
|
||||||
|
else the old ``if status == 'published'`` check wedged it open forever (every
|
||||||
|
retry returns already_published and never closes it; only a manual cancel
|
||||||
|
unsticks it)."""
|
||||||
|
task = _task()
|
||||||
|
fake_redis = _FakeRedis()
|
||||||
|
already = ReleaseResult(
|
||||||
|
status="already_published",
|
||||||
|
version="0.13.0",
|
||||||
|
files_changed=[],
|
||||||
|
commit_sha=None,
|
||||||
|
release_url=None,
|
||||||
|
detail="v0.13.0 is already published; nothing to do.",
|
||||||
|
)
|
||||||
|
w = _wire(task, _REPORT, already, fake_redis)
|
||||||
|
svc = ReleaseProposalService(_session())
|
||||||
|
|
||||||
|
with (
|
||||||
|
w["patches"][0],
|
||||||
|
w["patches"][1],
|
||||||
|
w["patches"][2],
|
||||||
|
w["patches"][3],
|
||||||
|
w["patches"][4],
|
||||||
|
):
|
||||||
|
result = await svc.approve(task.id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.status == "already_published"
|
||||||
|
# The proposal is CLOSED — the release shipped — not wedged open.
|
||||||
|
assert task.status == TaskStatus.COMPLETED.value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_heartbeat_lock_loss_cancels_execute_fail_closed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""#218: when the heartbeat finds the lock no longer owned (a >TTL Redis
|
||||||
|
outage let the mutex expire and a usurper re-acquired), it must CANCEL the
|
||||||
|
in-flight execute fail-closed — not ``return`` silently and leave execute
|
||||||
|
running unguarded, which lets the usurper's approve ``rm -rf`` the in-flight
|
||||||
|
release clone (re-opening the race the mutex+heartbeat exist to prevent).
|
||||||
|
approve() surfaces a structured ``lock_lost`` result."""
|
||||||
|
monkeypatch.setattr(rp, "_RELEASE_LOCK_HEARTBEAT_SECONDS", 0.001)
|
||||||
|
|
||||||
|
task = _task()
|
||||||
|
fake_redis = _FakeRedis()
|
||||||
|
published = ReleaseResult(
|
||||||
|
status="published",
|
||||||
|
version="0.13.0",
|
||||||
|
files_changed=[],
|
||||||
|
commit_sha=None,
|
||||||
|
release_url=None,
|
||||||
|
detail="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_started = asyncio.Event()
|
||||||
|
|
||||||
|
async def _blocking_execute(_report: Any) -> ReleaseResult:
|
||||||
|
# Block until the heartbeat cancels us — proves execute was running and
|
||||||
|
# got cancelled, not never-started.
|
||||||
|
execute_started.set()
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
return published
|
||||||
|
|
||||||
|
w = _wire(task, _REPORT, published, fake_redis)
|
||||||
|
w["executor"].execute = AsyncMock(side_effect=_blocking_execute)
|
||||||
|
svc = ReleaseProposalService(_session())
|
||||||
|
# The heartbeat's compare-and-expire reports the lock lost (token mismatch —
|
||||||
|
# a usurper re-acquired after TTL expiry).
|
||||||
|
svc._heartbeat_release_lock = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
w["patches"][0],
|
||||||
|
w["patches"][1],
|
||||||
|
w["patches"][2],
|
||||||
|
w["patches"][3],
|
||||||
|
w["patches"][4],
|
||||||
|
):
|
||||||
|
result = await svc.approve(task.id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.status == "lock_lost"
|
||||||
|
assert execute_started.is_set() # execute did start, then was cancelled
|
||||||
|
# Fail-closed: the proposal is NOT marked COMPLETED.
|
||||||
|
assert task.status != TaskStatus.COMPLETED.value
|
||||||
|
|||||||
Reference in New Issue
Block a user