aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:28:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:28:28 +0200
commit0bd3f805ffbd04b40b5150474336a5e5d200e72b (patch)
tree2a92a21e02bae823e9ed52d489d74ba8db3de9b6 /packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
parent0231d240b92a2a11042fa62c0222d4c4b96a859d (diff)
downloadmeshbay-0bd3f805ffbd04b40b5150474336a5e5d200e72b.tar.gz
feat(hub): leaving a group, a cap on public ones, and hosting as a precondition
Leaving is its own endpoint rather than a relaxation of the owner's removal check — an authorization rule with an exception in it is the one that gets read wrong later. The owner cannot leave: the group would be left with nobody able to admit, edit or delete it, which is the answer removal and account deletion already give. Public groups are capped at ten live ones per owner. They are the ones that cost other people something — listed in Discover, joinable by anyone — so a script that opens hundreds fills the directory for everybody. Private groups are invisible to non-members and are not capped. Hub staff are exempt; the cap is anti-spam, not a rule about running an instance. Creation is the only place it can be checked, and deliberately so, because PATCH refuses to change visibility at all. A group is now listed only once a node has announced that it hosts it. Before that it has no files, no key and nothing to connect to, so showing it to a member produces a name they cannot open and cannot be told why; its owner still sees it while they set the node up. `meshbay-hub prune-groups` collects the ones that never got a node, meant for cron, with --dry-run. The migration backfills hosted_at from created_at: without that the first run would have deleted every live group. Presence rides on the group list itself, read from the signaling registry the hub already keeps — no poll, no timer. It says a node is connected *to the hub*, which is not a promise that this browser can reach it and not something a dishonest hub could not fake; the client downgrades it on a connection it tried and failed, which is the evidence that concerns the reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py47
1 files changed, 44 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
index 7674c6e..c387100 100644
--- a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
+++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
@@ -1,13 +1,13 @@
-"""Scheduled cleanup tasks — IP log purge (1-year retention)."""
+"""Scheduled cleanup tasks — IP log purge, and unhosted group collection."""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
-from sqlalchemy import delete
+from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.db.models import IPLog
+from meshbay_hub.db.models import Group, GroupMember, IPLog
log = logging.getLogger(__name__)
@@ -38,3 +38,44 @@ async def cleanup_loop(get_session):
await asyncio.sleep(CLEANUP_INTERVAL_HOURS * 3600)
except asyncio.CancelledError:
return
+
+
+# ── Groups that never got a node ──────────────────────────────────────────────
+
+UNHOSTED_GRACE_DAYS = 7
+
+
+async def find_unhosted_groups(db: AsyncSession, grace_days: int = UNHOSTED_GRACE_DAYS):
+ """Groups created more than `grace_days` ago that no node has ever announced.
+
+ `hosted_at` is set the first time a node registers claiming the group and is
+ never cleared, so this finds groups that were created and then abandoned —
+ not ones whose node happens to be offline today. That distinction is the
+ whole reason the column exists rather than a check against the live socket
+ registry, which would delete every group during a hub restart.
+ """
+ cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
+ result = await db.execute(
+ select(Group).where(Group.hosted_at.is_(None), Group.created_at < cutoff))
+ return list(result.scalars().all())
+
+
+async def prune_unhosted_groups(db: AsyncSession, grace_days: int = UNHOSTED_GRACE_DAYS,
+ dry_run: bool = False) -> list[tuple[str, str]]:
+ """Delete abandoned groups. Returns [(id, name)] of what was (or would be) removed.
+
+ Memberships go with the group — there is no cascade configured, and leaving
+ orphan rows behind would keep the group in everyone's /mine query through the
+ join. Nothing on a node is touched: the hub does not command those machines,
+ and by definition no node ever claimed this group anyway.
+ """
+ doomed = await find_unhosted_groups(db, grace_days)
+ if not doomed or dry_run:
+ return [(g.id, g.name) for g in doomed]
+
+ ids = [g.id for g in doomed]
+ await db.execute(delete(GroupMember).where(GroupMember.group_id.in_(ids)))
+ await db.execute(delete(Group).where(Group.id.in_(ids)))
+ await db.commit()
+ log.info("Pruned %d group(s) that no node ever hosted", len(doomed))
+ return [(g.id, g.name) for g in doomed]