Discord remove server: audit a removal a restart interrupted

DELETE /discord/guilds/{id} deletes channels one by one. If a channel was
restarted mid-removal, delete_stream raised "restarted" and the route
returned 409 with no audit entry, though the channels before it were
already deleted for good. The error now carries the channels already
removed and the route writes discord.guild.remove with outcome
"incomplete" before returning the 409. The server stays linked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 22:48:30 -04:00
co-authored by Claude Opus 5.5
parent c92bdfe841
commit 25553fbac6
3 changed files with 35 additions and 2 deletions
+9 -2
View File
@@ -374,8 +374,15 @@ async def remove_guild(guild_id: str, by: str) -> dict:
link = await fstore.doc_get(GUILDS, guild_id) or {} link = await fstore.doc_get(GUILDS, guild_id) or {}
removed = [] removed = []
for s in await fstore.collection_list(STREAMS, guild_id=guild_id): for s in await fstore.collection_list(STREAMS, guild_id=guild_id):
if s.get("stream_id") and await delete_stream(s["stream_id"], "server_removed"): try:
removed.append(s["stream_id"]) if s.get("stream_id") and await delete_stream(s["stream_id"], "server_removed"):
removed.append(s["stream_id"])
except StreamError as e:
# A channel restarted mid-removal: stop here, leave the server
# linked, and tell the caller which channels are already gone so
# the partial removal is still audited.
e.removed = removed
raise
now = _now_iso() now = _now_iso()
await fstore.doc_set(GUILDS, guild_id, { await fstore.doc_set(GUILDS, guild_id, {
"guild_id": guild_id, "org_id": None, "guild_id": guild_id, "org_id": None,
+5
View File
@@ -222,6 +222,11 @@ async def remove_guild(guild_id: str, leave_radio_bots: bool = False,
try: try:
out = await streams.remove_guild(guild_id, uid) out = await streams.remove_guild(guild_id, uid)
except streams.StreamError as e: except streams.StreamError as e:
# Channels deleted before the race are gone for good — audit them.
await write_audit(uid, email, "discord.guild.remove", details={
"guild_id": guild_id, "acting_org_id": acting_org, "outcome": "incomplete", "error": e.code,
"streams_removed": getattr(e, "removed", []), "leave_radio_bots": leave_radio_bots,
})
return _error(e) return _error(e)
left: list = [] left: list = []
if leave_radio_bots: if leave_radio_bots:
@@ -278,6 +278,27 @@ async def test_remove_guild_deletes_streams_and_unlinks_without_fallback(fs, mon
assert [_lease(fs, t) for t in toks] == [None, None] assert [_lease(fs, t) for t in toks] == [None, None]
def test_remove_guild_interrupted_by_a_restart_is_still_audited(fs, monkeypatch):
import asyncio
a = asyncio.run(_start("1001"))
b = asyncio.run(_start("1002", node_id="n2"))
real_stop = streams.stop_stream
async def _stop(stream_id, reason="stopped"):
await real_stop(stream_id, reason)
if stream_id == b["stream_id"]:
await _start("1002", node_id="n2") # restarted between stop and delete
monkeypatch.setattr(streams, "stop_stream", _stop)
app.dependency_overrides[require_admin_token] = lambda: ADMIN
r = client.delete("/discord/guilds/100")
assert r.status_code == 409 and r.json()["code"] == "restarted"
(entry,) = _audit(fs, "discord.guild.remove")
assert entry["details"]["outcome"] == "incomplete"
assert entry["details"]["streams_removed"] == [a["stream_id"]]
assert fs.get("discord_guilds", "100")["org_id"] == ORG # left linked: a live channel remains
def test_remove_guild_route_hides_server_and_relink_restores_it(fs): def test_remove_guild_route_hides_server_and_relink_restores_it(fs):
import asyncio import asyncio
asyncio.run(_start("1001")) asyncio.run(_start("1001"))