Delete guest accounts left idle for five days

The app had no cleanup of any kind: in multi-user mode every first visit
mints a users row, so the demo has been accumulating one permanent account
per visitor along with everything they generated.

cleanup.py sweeps guests idle for AIDND_GUEST_RETENTION_DAYS (default 5),
once at startup and then every few hours. Startup is the load-bearing
trigger — the free tier sleeps after ~15 minutes, so a long timer rarely
gets to fire.

Idle is COALESCE(last_seen_at, created_at), not last_seen_at: _touch only
writes that column hourly, and a guest minted by /auth/me has it NULL until
its second request, so the simpler query would have deleted brand-new
visitors mid-session.

It's one Core DELETE rather than db.delete(user), which would SELECT every
adventure, action and memory into Python purely to delete them — the same
egress pattern as the 189x fix. Every FK from users down is ON DELETE
CASCADE, so the database does the whole graph and returns a count.

The filter requires is_guest AND email IS NULL, so registered users (who
upgrade in place) and local mode's implicit user are both out of reach, and
is_public is output-only so a guest can never own content another user can
see. Session cookies have no expiry and can outlive a swept row; that path
401s and the frontend's existing retry re-mints a session.

Guests are told: /auth/me serves guest_retention_days and the signup modal
states the window, sourced from the server so it can't drift from what is
enforced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CYEJKobJ2Re4Dv7qUoSA7
This commit is contained in:
parththakkar106
2026-08-15 20:14:12 +05:30
co-authored by Claude Opus 5
parent c500203270
commit bbcb07c6be
10 changed files with 453 additions and 6 deletions
+10
View File
@@ -66,6 +66,16 @@ AIDND_DEMO_TURNS_PER_DAY=
# Local (single-user) installs are always treated as power users.
AIDND_POWER_USERS=
# --- Guest retention (only active when AIDND_MULTI_USER=1) ---
# Every first visit mints a guest account, so a public demo collects one row
# per visitor. A guest with no activity for this many days is deleted along
# with its scenarios, adventures and actions. Registered accounts are never
# touched. Default: 5. Set 0 to keep guests forever.
AIDND_GUEST_RETENTION_DAYS=
# How often a running process re-checks. The sweep also runs once at startup,
# which is what actually fires on hosts that sleep. Default: 6
AIDND_CLEANUP_INTERVAL_HOURS=
# The AI endpoint/API key/model are NOT env vars — they are configured at
# runtime in the app's Settings page and stored (encrypted) in the database.
#
+158
View File
@@ -0,0 +1,158 @@
"""Retention policy for throwaway guest accounts.
In multi-user mode every first visit mints a `users` row (GET /api/auth/me),
so a public demo accumulates one account per curious visitor — most of whom
never come back, each leaving behind whatever scenarios, adventures, actions
and memories they generated. This drops guests that have gone quiet for
AIDND_GUEST_RETENTION_DAYS (default 5) along with everything they made.
Why this is safe to run unattended:
- Only rows with `is_guest` AND `email IS NULL` are ever touched, and both
clauses are checked rather than either alone. Registering upgrades the row
in place (is_guest -> False), so a guest who signs up keeps everything;
local mode's implicit single user is also is_guest=False.
- Idle time is COALESCE(last_seen_at, created_at). `auth._touch` only writes
last_seen_at once an hour, and a guest minted by /auth/me has NULL until
its *second* request, so created_at is the honest floor for a brand-new
visitor — without the coalesce those rows look infinitely old.
- Nothing a guest owns is reachable by anyone else: `is_public` is an
output-only field (see schemas.ScenarioBase), so only seeded scenarios —
which have user_id NULL and are therefore outside this filter entirely —
are shared. Deleting a guest can't take content away from another user.
Why one Core DELETE instead of an ORM cascade: `db.delete(user)` would SELECT
every adventure, action, memory and story card into Python purely to delete
them, which on Neon is exactly the egress pattern that has already cost this
project once. Every foreign key from users downwards is ON DELETE CASCADE
(users -> scenarios/adventures/scripts/settings -> actions/memories/cards), so
the database does the whole graph in one statement and ships back a row count.
No index is added for the scan: the sweep runs a handful of times a day
against a table with at most a few thousand rows, which is not worth a
migration and the schema surface that comes with it.
"""
import asyncio
import logging
import os
from datetime import datetime, timedelta
from sqlalchemy import delete, func
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from . import auth, models
from .database import SessionLocal
logger = logging.getLogger(__name__)
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "").strip() or default)
except ValueError:
logger.warning("%s is not an integer; using %d.", name, default)
return default
# Days of inactivity before a guest account is dropped. 0 or less disables the
# policy entirely, for a deployment that would rather keep everything.
RETENTION_DAYS = _int_env("AIDND_GUEST_RETENTION_DAYS", 5)
# How often a long-lived process re-checks. Hours, not minutes: nothing here is
# time-critical, and on Render's free tier the service sleeps and cold-starts
# often enough that the startup sweep does most of the work by itself.
SWEEP_INTERVAL_SECONDS = _int_env("AIDND_CLEANUP_INTERVAL_HOURS", 6) * 3600
def enabled() -> bool:
"""Guests only exist in multi-user mode, so local runs skip the sweep
rather than pointing a DELETE at a database that has nothing to collect."""
return auth.MULTI_USER and RETENTION_DAYS > 0
def delete_stale_guests(db: Session, *, now: datetime | None = None) -> int:
"""Delete guests idle for RETENTION_DAYS or more. Returns the row count.
The caller owns error handling; `sweep` is the safe wrapper.
"""
if RETENTION_DAYS <= 0:
return 0
# Stored timestamps are naive UTC on both backends (SQLite drops tzinfo;
# Postgres columns are TIMESTAMP WITHOUT TIME ZONE with the session pinned
# to UTC in database.py). Match that exactly so the comparison can't hinge
# on how a given dialect renders an aware value.
reference = now or models.utcnow()
cutoff = reference.replace(tzinfo=None) - timedelta(days=RETENTION_DAYS)
stmt = (
delete(models.User)
.where(
models.User.is_guest.is_(True),
models.User.email.is_(None),
func.coalesce(models.User.last_seen_at, models.User.created_at) < cutoff,
)
# Without this, "auto" can't evaluate coalesce in Python and falls back
# to fetching every matching primary key first — a second round trip
# for nothing, since this session holds no User objects to synchronize.
.execution_options(synchronize_session=False)
)
removed = db.execute(stmt).rowcount or 0
db.commit()
return removed
def sweep() -> int:
"""One pass, with its own session. Never raises: a failed cleanup must not
be able to take the app down (same rule as seeding)."""
if not enabled():
return 0
db = SessionLocal()
try:
removed = delete_stale_guests(db)
if removed:
logger.info(
"Cleaned up %d guest account(s) idle for %d+ days.",
removed,
RETENTION_DAYS,
)
return removed
except Exception:
db.rollback()
logger.exception("Guest cleanup failed; continuing without it.")
return 0
finally:
db.close()
async def _sweep_loop() -> None:
while True:
# Blocking DB work: keep it off the event loop, which is also serving
# SSE turn streams.
await run_in_threadpool(sweep)
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
def start_sweeper() -> asyncio.Task | None:
"""Kick off the periodic sweep; None when the policy is off."""
if not enabled():
logger.info("Guest cleanup disabled (multi_user=%s, retention_days=%d).",
auth.MULTI_USER, RETENTION_DAYS)
return None
logger.info(
"Guest cleanup on: deleting guests idle %d+ days, every %d hour(s).",
RETENTION_DAYS,
SWEEP_INTERVAL_SECONDS // 3600,
)
return asyncio.create_task(_sweep_loop())
async def stop_sweeper(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
+15
View File
@@ -1,4 +1,5 @@
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
@@ -6,6 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from . import cleanup
from .auth import MULTI_USER
from .database import engine
from .limits import BodySizeLimitMiddleware
@@ -24,6 +26,18 @@ CORS_ORIGINS = [
if o.strip()
] or ["http://localhost:5173", "http://127.0.0.1:5173"]
@asynccontextmanager
async def lifespan(_app: FastAPI):
# Sweeps once on boot, then on an interval. Booting is the reliable
# trigger on Render's free tier, where the service sleeps after ~15
# minutes and a long-running timer rarely gets to fire.
sweeper = cleanup.start_sweeper()
try:
yield
finally:
await cleanup.stop_sweeper(sweeper)
# The interactive API docs stay local-only: in multi-user mode they just hand
# strangers a map of the API surface.
app = FastAPI(
@@ -31,6 +45,7 @@ app = FastAPI(
docs_url=None if MULTI_USER else "/docs",
redoc_url=None,
openapi_url=None if MULTI_USER else "/openapi.json",
lifespan=lifespan,
)
app.add_middleware(
+5 -1
View File
@@ -3,7 +3,7 @@ import re
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from .. import auth, limits, models, schemas, security
from .. import auth, cleanup, limits, models, schemas, security
from ..database import get_db
from .settings import get_settings
@@ -34,6 +34,10 @@ def me_payload(user: models.User, db: Session) -> dict:
"is_guest": user.is_guest,
# Trusted testers: unmetered demo turns, plus the AI Chat scratchpad.
"power_user": auth.is_power_user(user),
# How long an idle guest is kept before cleanup deletes it (None when
# the policy is off). Served rather than hardcoded in the UI so the
# number a guest is shown is the number actually enforced.
"guest_retention_days": cleanup.RETENTION_DAYS if cleanup.enabled() else None,
"demo": {
"enabled": auth.demo_enabled(),
"using_demo": cfg.using_demo,
+210
View File
@@ -0,0 +1,210 @@
"""Guest retention policy — app/cleanup.py.
Covers the two things that matter: that idle guests and their whole data
graph actually go, and that nothing else ever does.
python -m pytest tests/test_guest_cleanup.py -v
"""
import os
import tempfile
from datetime import timedelta
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["AIDND_DB_PATH"] = _tmp.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from app import cleanup, models
from app.database import Base
from app.migrations import bootstrap
@pytest.fixture()
def db(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path/'t.db'}", connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def _fk(dbapi_connection, _record):
# The whole policy leans on ON DELETE CASCADE; SQLite ignores every
# one of them unless this is set (same as database.py does).
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
bootstrap(engine)
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
yield session
session.close()
NOW = models.utcnow().replace(tzinfo=None)
def make_user(db, *, days_idle=None, days_old=0, guest=True, email=None):
"""A user last seen `days_idle` ago (None = never seen, only created)."""
user = models.User(
is_guest=guest,
email=email,
password_hash=None if email is None else "x",
created_at=NOW - timedelta(days=days_old),
last_seen_at=None if days_idle is None else NOW - timedelta(days=days_idle),
)
db.add(user)
db.commit()
return user
def sweep(db):
return cleanup.delete_stale_guests(db, now=NOW)
def alive(db, user_id):
# A count, not db.get: the sweep deletes with synchronize_session=False, so
# the session's identity map still holds the object and db.get would answer
# from memory without ever asking the database.
return db.query(models.User).filter(models.User.id == user_id).count() == 1
# ---------- what goes ----------
def test_deletes_guest_idle_past_the_window(db):
user = make_user(db, days_idle=6)
assert sweep(db) == 1
assert not alive(db, user.id)
def test_keeps_guest_inside_the_window(db):
user = make_user(db, days_idle=4)
assert sweep(db) == 0
assert alive(db, user.id)
def test_boundary_is_not_yet_stale(db):
# Exactly 5 days survives; the comparison is strict.
user = make_user(db, days_idle=cleanup.RETENTION_DAYS)
assert sweep(db) == 0
assert alive(db, user.id)
def test_never_seen_guest_falls_back_to_created_at(db):
"""last_seen_at is NULL until a guest's second request (auth._touch runs
hourly), so a coalesce-less query would delete brand-new visitors."""
fresh = make_user(db, days_idle=None, days_old=0)
stale = make_user(db, days_idle=None, days_old=9)
assert sweep(db) == 1
assert alive(db, fresh.id)
assert not alive(db, stale.id)
def test_recent_visit_beats_an_old_created_at(db):
# A long-standing guest who came back yesterday stays.
user = make_user(db, days_idle=1, days_old=90)
assert sweep(db) == 0
assert alive(db, user.id)
# ---------- what must never go ----------
def test_spares_registered_users(db):
"""Registering upgrades the guest row in place, so an idle account here is
a real user with real data — the whole point of signing up."""
user = make_user(db, days_idle=400, guest=False, email="a@b.com")
assert sweep(db) == 0
assert alive(db, user.id)
def test_spares_the_local_mode_user(db):
# email NULL but is_guest False: local mode's implicit owner of everything.
user = make_user(db, days_idle=400, guest=False)
assert sweep(db) == 0
assert alive(db, user.id)
def test_spares_a_guest_flagged_row_that_has_an_email(db):
# Shouldn't exist, but both clauses are checked so it can't be collected.
user = make_user(db, days_idle=400, guest=True, email="odd@b.com")
assert sweep(db) == 0
assert alive(db, user.id)
def test_leaves_seeded_public_scenarios_alone(db):
"""Seeded demo content has user_id NULL, so it is outside the filter."""
seeded = models.Scenario(user_id=None, is_public=True, title="Demo")
db.add(seeded)
make_user(db, days_idle=30)
db.commit()
assert sweep(db) == 1
assert db.query(models.Scenario).filter(models.Scenario.id == seeded.id).count() == 1
def test_disabled_when_retention_is_zero(db, monkeypatch):
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
user = make_user(db, days_idle=999)
assert sweep(db) == 0
assert alive(db, user.id)
def test_enabled_requires_multi_user(monkeypatch):
from app import auth
monkeypatch.setattr(auth, "MULTI_USER", False)
assert cleanup.enabled() is False
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 5)
assert cleanup.enabled() is True
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
assert cleanup.enabled() is False
# ---------- the cascade ----------
def test_deletes_the_whole_data_graph(db):
"""One DELETE has to take the adventure, its actions and memories, the
story cards and the settings row with it — nothing is loaded into Python,
so if the FK cascade isn't reaching, rows are silently orphaned (or the
statement errors) rather than tidied."""
user = make_user(db, days_idle=30)
scenario = models.Scenario(user_id=user.id, title="S")
db.add(scenario)
db.commit()
adventure = models.Adventure(user_id=user.id, scenario_id=scenario.id, title="A")
db.add(adventure)
db.commit()
db.add_all([
models.Action(adventure_id=adventure.id, index=0, type="ai", text="t"),
models.Memory(adventure_id=adventure.id, text="m", source_start=0, source_end=0),
models.StoryCard(adventure_id=adventure.id, name="c"),
models.Settings(user_id=user.id),
])
db.commit()
assert sweep(db) == 1
for model in (models.Scenario, models.Adventure, models.Action,
models.Memory, models.StoryCard, models.Settings):
assert db.query(model).count() == 0, f"{model.__name__} rows survived"
def test_one_users_cleanup_does_not_touch_another(db):
keeper = make_user(db, days_idle=1)
keep_adv = models.Adventure(user_id=keeper.id, title="mine")
goner = make_user(db, days_idle=30)
db.add_all([keep_adv, models.Adventure(user_id=goner.id, title="theirs")])
db.commit()
assert sweep(db) == 1
remaining = db.query(models.Adventure).all()
assert [a.title for a in remaining] == ["mine"]
def test_sweep_swallows_errors(monkeypatch):
"""A broken cleanup must not take the app down (same rule as seeding)."""
monkeypatch.setattr(cleanup.auth, "MULTI_USER", True)
monkeypatch.setattr(cleanup, "delete_stale_guests",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
assert cleanup.sweep() == 0