Discord: several radio streams per voice channel; adding never overwrites (#213)
Owner-reported on live: adding a radio channel for a server + voice channel that already had one (different label, system, even bot) overwrote the existing stream and made its bot leave, because stream_id was guild:channel. - New streams get guild:channel:<12 hex> ids (leases.new_stream_id); old guild:channel docs keep working for read/stop/remove/node reports. Route validators accept both (leases.STREAM_ID_PATTERN). - start_stream: no stream_id = ADD. Same system already on in that channel is idempotent (already_on, label/mode untouched); a stopped one of that system is restarted; anything else is a new doc. stream_id = explicit restart/edit (admin Start button, reconciler), incl. moving to another bot. - preferred_token_id is strict: bot already in this server -> 409 preferred_busy naming the stream; on another node -> preferred_other_node; unusable -> preferred_unavailable. No silent swap. pool_exhausted now says one-bot-per-server and to add + invite another bot. - One-token-per-guild was already enforced inside the lease transaction; now covered by race tests. Per-guild asyncio lock serialises the already-on / server-cap check with the create (guild half of #223). - Stop by stream_id (POST /discord/guilds/{id}/stop {stream_id}); admin UI Stop/Start/Remove act on the row's stream; node picker lists every stream in a channel and adds beside other systems. - Bot: /leave stream: (autocomplete) stops one; /leave channel: with several streams asks which unless all:True; bare /leave still stops the server. /status and /join name each stream's bot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
16a609e35b
commit
8af410aede
@@ -34,6 +34,7 @@ def fs(monkeypatch):
|
||||
from app.internal.mqtt_handler import mqtt_handler
|
||||
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", _publish)
|
||||
streams._publish_locks.clear()
|
||||
streams._guild_locks.clear()
|
||||
|
||||
async def _no_discord(token_ids):
|
||||
return 0
|
||||
@@ -70,6 +71,15 @@ def _lease(fake, token_id):
|
||||
return (fake.get("bot_tokens", token_id) or {}).get("lease")
|
||||
|
||||
|
||||
def _sid(fake, guild="g1", channel="c1"):
|
||||
"""The id of the one stream start_stream created in this channel (new
|
||||
streams get a unique ``guild:channel:suffix`` id — server-26#213)."""
|
||||
ids = [k for k, d in (fake.data.get("discord_streams") or {}).items()
|
||||
if d.get("guild_id") == guild and d.get("channel_id") == channel]
|
||||
assert len(ids) == 1, ids
|
||||
return ids[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pick_token — pure choice
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -118,7 +128,8 @@ async def test_rejoin_reuses_the_lease_instead_of_taking_a_second_token(fs):
|
||||
assert again["token_id"] == first["token_id"] and again["reused"] is True
|
||||
leased = [t for t in ("t1", "t2") if _lease(fs, t)]
|
||||
assert leased == [first["token_id"]] # server-26#204 item 2
|
||||
assert _lease(fs, first["token_id"])["streams"] == ["g1:c1"]
|
||||
assert _lease(fs, first["token_id"])["streams"] == [first["stream_id"]]
|
||||
assert again["stream_id"] == first["stream_id"] and again["already_on"] is True
|
||||
|
||||
|
||||
async def test_preferred_token_is_honoured(fs):
|
||||
@@ -144,9 +155,9 @@ async def test_join_never_moves_another_guilds_stream(fs):
|
||||
_system(fs); _node(fs, "n1"); _token(fs, "t1")
|
||||
await _start("gA", "c1")
|
||||
await _start("gB", "c1")
|
||||
a = fs.get("discord_streams", "gA:c1")
|
||||
a = fs.get("discord_streams", _sid(fs, "gA", "c1"))
|
||||
assert a["desired"] == "on" and a["token_id"] == "t1"
|
||||
assert sorted(_lease(fs, "t1")["streams"]) == ["gA:c1", "gB:c1"]
|
||||
assert sorted(_lease(fs, "t1")["streams"]) == sorted([_sid(fs, "gA", "c1"), _sid(fs, "gB", "c1")])
|
||||
|
||||
|
||||
async def test_desired_state_never_carries_the_token_string(fs):
|
||||
@@ -236,7 +247,7 @@ async def test_two_guilds_same_node_racing_share_one_session(fs):
|
||||
async def test_stop_marks_releasing_and_holds_until_the_node_acks(fs):
|
||||
_system(fs); _node(fs, "n1"); _node(fs, "n2"); _token(fs, "t1")
|
||||
await _start()
|
||||
await streams.stop_stream("g1:c1")
|
||||
await streams.stop_stream(_sid(fs, "g1", "c1"))
|
||||
lease = _lease(fs, "t1")
|
||||
assert lease["releasing"] is True and lease["streams"] == []
|
||||
drop_seq = lease["release_seq"]
|
||||
@@ -260,25 +271,26 @@ async def test_stop_marks_releasing_and_holds_until_the_node_acks(fs):
|
||||
async def test_restart_before_release_reuses_the_logged_in_session(fs):
|
||||
_system(fs); _node(fs, "n1"); _token(fs, "t1"); _token(fs, "t2")
|
||||
first = await _start()
|
||||
await streams.stop_stream("g1:c1")
|
||||
await streams.stop_stream(_sid(fs, "g1", "c1"))
|
||||
again = await _start()
|
||||
assert again["token_id"] == first["token_id"]
|
||||
lease = _lease(fs, first["token_id"])
|
||||
assert lease["releasing"] is False and lease["streams"] == ["g1:c1"]
|
||||
assert lease["releasing"] is False and lease["streams"] == [first["stream_id"]]
|
||||
assert again["stream_id"] == first["stream_id"] # restarted, not a second doc
|
||||
|
||||
|
||||
async def test_handle_actual_settles_and_ignores_other_nodes_streams(fs):
|
||||
_system(fs); _node(fs, "n1"); _node(fs, "n2"); _token(fs, "t1"); _token(fs, "t2")
|
||||
await _start("g1", "c1", node_id="n1")
|
||||
await _start("g2", "c1", node_id="n2")
|
||||
a = await _start("g1", "c1", node_id="n1")
|
||||
b = await _start("g2", "c1", node_id="n2")
|
||||
await streams.handle_actual("n1", {
|
||||
"applied_seq": 1, "sessions": ["t1"],
|
||||
"streams": {"g1:c1": {"state": "connected"}, "g2:c1": {"state": "error", "error": "spoof"}},
|
||||
"streams": {a["stream_id"]: {"state": "connected"}, b["stream_id"]: {"state": "error", "error": "spoof"}},
|
||||
})
|
||||
assert fs.get("discord_streams", "g1:c1")["actual"]["state"] == "connected"
|
||||
assert fs.get("discord_streams", "g2:c1")["actual"]["state"] == "pending" # not n1's to report
|
||||
assert fs.get("discord_streams", _sid(fs, "g1", "c1"))["actual"]["state"] == "connected"
|
||||
assert fs.get("discord_streams", _sid(fs, "g2", "c1"))["actual"]["state"] == "pending" # not n1's to report
|
||||
|
||||
await streams.stop_stream("g1:c1")
|
||||
await streams.stop_stream(_sid(fs, "g1", "c1"))
|
||||
seq = _lease(fs, "t1")["release_seq"]
|
||||
await streams.handle_actual("n1", {"applied_seq": seq, "sessions": [], "streams": {}})
|
||||
assert _lease(fs, "t1") is None
|
||||
@@ -295,7 +307,7 @@ async def test_failed_publish_is_retried_by_the_reconciler(fs):
|
||||
n = len(fs.published)
|
||||
await streams.reconcile_once()
|
||||
assert len(fs.published) > n and fs.get("discord_nodes", "n1")["published"] is True
|
||||
assert fs.published[-1][1]["streams"][0]["stream_id"] == "g1:c1"
|
||||
assert fs.published[-1][1]["streams"][0]["stream_id"] == doc["stream_id"]
|
||||
|
||||
|
||||
async def test_concurrent_publishes_keep_the_retained_list_newest(fs):
|
||||
@@ -315,9 +327,9 @@ async def test_leave_in_one_guild_never_touches_another(fs):
|
||||
await _start("gA", "c1")
|
||||
await _start("gB", "c1")
|
||||
stopped = await streams.stop_guild("gA", "leave")
|
||||
assert stopped == ["gA:c1"]
|
||||
assert fs.get("discord_streams", "gB:c1")["desired"] == "on"
|
||||
assert fs.get("discord_streams", "gA:c1")["desired"] == "off"
|
||||
assert stopped == [_sid(fs, "gA", "c1")]
|
||||
assert fs.get("discord_streams", _sid(fs, "gB", "c1"))["desired"] == "on"
|
||||
assert fs.get("discord_streams", _sid(fs, "gA", "c1"))["desired"] == "off"
|
||||
|
||||
|
||||
async def test_guild_org_resolution(fs, monkeypatch):
|
||||
@@ -340,7 +352,7 @@ async def test_offline_node_keeps_its_lease_inside_the_grace(fs, monkeypatch):
|
||||
_node(fs, "n1", status="offline", age=120)
|
||||
await streams.reconcile_once()
|
||||
assert _lease(fs, "t1")["node_id"] == "n1"
|
||||
assert fs.get("discord_streams", "g1:c1")["actual"]["state"] == "node_offline"
|
||||
assert fs.get("discord_streams", _sid(fs, "g1", "c1"))["actual"]["state"] == "node_offline"
|
||||
|
||||
|
||||
async def test_persistent_stream_moves_after_grace_adhoc_stops(fs, monkeypatch):
|
||||
@@ -351,10 +363,10 @@ async def test_persistent_stream_moves_after_grace_adhoc_stops(fs, monkeypatch):
|
||||
_node(fs, "n1", status="offline", age=900)
|
||||
_node(fs, "n2")
|
||||
await streams.reconcile_once()
|
||||
moved = fs.get("discord_streams", "g1:c1")
|
||||
moved = fs.get("discord_streams", _sid(fs, "g1", "c1"))
|
||||
assert moved["desired"] == "on" and moved["node_id"] == "n2" and moved["token_id"]
|
||||
assert _lease(fs, moved["token_id"])["node_id"] == "n2"
|
||||
assert fs.get("discord_streams", "g2:c1")["desired"] == "off"
|
||||
assert fs.get("discord_streams", _sid(fs, "g2", "c1"))["desired"] == "off"
|
||||
assert all((_lease(fs, t) or {}).get("node_id") != "n1" for t in ("t1", "t2"))
|
||||
|
||||
|
||||
@@ -365,7 +377,7 @@ async def test_leave_while_node_offline_frees_token_after_grace(fs, monkeypatch)
|
||||
_system(fs); _node(fs, "n1"); _token(fs, "t1")
|
||||
await _start()
|
||||
_node(fs, "n1", status="offline", age=120)
|
||||
await streams.stop_stream("g1:c1")
|
||||
await streams.stop_stream(_sid(fs, "g1", "c1"))
|
||||
await streams.reconcile_once()
|
||||
assert _lease(fs, "t1")["releasing"] is True # inside the grace: held
|
||||
_node(fs, "n1", status="offline", age=900)
|
||||
@@ -377,7 +389,7 @@ async def test_deleted_node_lease_freed_after_grace_from_last_report(fs, monkeyp
|
||||
monkeypatch.setattr(streams.settings, "discord_lease_offline_release_seconds", 600)
|
||||
_system(fs); _node(fs, "n1"); _token(fs, "t1")
|
||||
await _start()
|
||||
await streams.stop_stream("g1:c1")
|
||||
await streams.stop_stream(_sid(fs, "g1", "c1"))
|
||||
fs.put("discord_nodes", "n1", {**(fs.get("discord_nodes", "n1") or {}),
|
||||
"actual_at": datetime.now(timezone.utc).isoformat()})
|
||||
await fs.doc_delete("nodes", "n1")
|
||||
@@ -401,12 +413,12 @@ async def test_release_seq_never_names_a_list_that_still_holds_the_stream(fs, mo
|
||||
out = await real(node_id)
|
||||
if not fired:
|
||||
fired.append(1)
|
||||
await leases.detach_stream("g1:c1", {"desired": "off"})
|
||||
await leases.detach_stream(_sid(fs, "g1", "c1"), {"desired": "off"})
|
||||
return out
|
||||
monkeypatch.setattr(streams, "desired_for_node", _racy)
|
||||
await streams.publish_desired("n1")
|
||||
racy_seq = fs.published[-1][1]["seq"]
|
||||
assert any(d["stream_id"] == "g1:c1" for d in fs.published[-1][1]["streams"])
|
||||
assert any(d["stream_id"] == _sid(fs, "g1", "c1") for d in fs.published[-1][1]["streams"])
|
||||
rseq = _lease(fs, "t1")["release_seq"]
|
||||
assert rseq is None or rseq > racy_seq
|
||||
assert await leases.settle_releases("n1", set(), racy_seq) == []
|
||||
@@ -417,7 +429,7 @@ async def test_retuned_node_gives_up_a_persistent_stream(fs):
|
||||
await _start(mode="persistent")
|
||||
_node(fs, "n1", system="sys-2")
|
||||
await streams.reconcile_once()
|
||||
s = fs.get("discord_streams", "g1:c1")
|
||||
s = fs.get("discord_streams", _sid(fs, "g1", "c1"))
|
||||
assert s["desired"] == "on" and s["token_id"] is None and s["actual"]["state"] == "error"
|
||||
assert _lease(fs, "t1")["releasing"] is True
|
||||
|
||||
@@ -432,7 +444,7 @@ async def test_force_release_detaches_streams(fs):
|
||||
old = await leases.force_release_token("t1")
|
||||
assert old["node_id"] == "n1"
|
||||
assert _lease(fs, "t1") is None
|
||||
assert fs.get("discord_streams", "g1:c1")["token_id"] is None
|
||||
assert fs.get("discord_streams", _sid(fs, "g1", "c1"))["token_id"] is None
|
||||
|
||||
|
||||
def test_node_token_pickup_only_for_the_lease_holder(fs):
|
||||
|
||||
Reference in New Issue
Block a user