From 25553fbac65ce130fe2b14dbf55928edc42c6fc5 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 4 Oct 2026 22:48:30 -0400 Subject: [PATCH] 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 --- drb-c2-core/app/internal/discord_streams.py | 11 ++++++++-- drb-c2-core/app/routers/discord.py | 5 +++++ .../tests/test_discord_remove_and_logging.py | 21 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/drb-c2-core/app/internal/discord_streams.py b/drb-c2-core/app/internal/discord_streams.py index f42cfa4..4786a71 100644 --- a/drb-c2-core/app/internal/discord_streams.py +++ b/drb-c2-core/app/internal/discord_streams.py @@ -374,8 +374,15 @@ async def remove_guild(guild_id: str, by: str) -> dict: link = await fstore.doc_get(GUILDS, guild_id) or {} removed = [] 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"): - removed.append(s["stream_id"]) + try: + 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() await fstore.doc_set(GUILDS, guild_id, { "guild_id": guild_id, "org_id": None, diff --git a/drb-c2-core/app/routers/discord.py b/drb-c2-core/app/routers/discord.py index a51be4f..5e0e9b6 100644 --- a/drb-c2-core/app/routers/discord.py +++ b/drb-c2-core/app/routers/discord.py @@ -222,6 +222,11 @@ async def remove_guild(guild_id: str, leave_radio_bots: bool = False, try: out = await streams.remove_guild(guild_id, uid) 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) left: list = [] if leave_radio_bots: diff --git a/drb-c2-core/tests/test_discord_remove_and_logging.py b/drb-c2-core/tests/test_discord_remove_and_logging.py index b4dacdd..b010c0c 100644 --- a/drb-c2-core/tests/test_discord_remove_and_logging.py +++ b/drb-c2-core/tests/test_discord_remove_and_logging.py @@ -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] +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): import asyncio asyncio.run(_start("1001"))