Files
AutoModerate/scripts/dedup_before_unique_constraints.py
Bentlybro b92bdf694e Phase 1: architectural fixes (concurrency, async/sync, data integrity)
Concurrency / performance
- Drop the global threading.RLock in DatabaseService. It was wrapping every
  _safe_execute and turning the 8-wide thread pool into a 1-wide one;
  Flask-SQLAlchemy's scoped_session already gives each thread its own
  session, so the lock was protecting nothing while serialising every DB
  op in the process.
- moderate_content now awaits _process_rules and _handle_no_matches; both
  push the blocking OpenAI call to asyncio.to_thread so the event loop
  stays responsive while waiting on the model.
- DiscordNotifier shares a single module-level requests.Session (instead
  of one per instance) and exposes an async wrapper that runs the POST
  via asyncio.to_thread, so the moderation request doesn't pin its loop
  on Discord latency / 429 retry backoff.
- load_user simplified to a single User.query.get(user_id). Previously it
  spun a fresh ThreadPoolExecutor and asyncio event loop on every
  authenticated request just to await an async wrapper around the same
  primary-key lookup.
- ResultCache now LRU-evicts the oldest entries when at capacity with no
  expired entries to free, instead of silently dropping new writes.
  _cleanup_expired_entries now sets _last_cleanup_time so the periodic
  throttle applies even when called from the hot-path fallback.

Data integrity
- APIUser gained UniqueConstraint(project_id, external_user_id).
  get_or_create_api_user catches IntegrityError, rolls back, and reads the
  winner's row so concurrent submits for the same external user no longer
  produce duplicate APIUser rows that split stats.
- ProjectMember gained UniqueConstraint(project_id, user_id). The
  invitation accept flow catches the IntegrityError and treats it as a
  benign duplicate-click.
- New scripts/dedup_before_unique_constraints.py to merge any pre-existing
  duplicate rows so the new constraints can be added cleanly. Dry-run by
  default, --apply to commit.
- Fix the API-user stats detached-instance bug: increment_api_user_stats
  in the DB service now does query + mutation + commit in one
  _safe_execute call. Previously the orchestrator mutated a detached ORM
  instance outside any session and the changes silently never persisted,
  so per-user counters drifted.
- Manual-review decision flow now commits the moderation result first,
  then atomically commits the APIUser stat bump in the same request
  context (instead of relying on the detached-instance pattern).

Correctness
- ContentType enum is now {text, markdown, html} to match the route's
  whitelist. Previously the schema declared text/image/video/audio while
  the route accepted text/markdown/html, so markdown/html submissions
  died with a Pydantic 400 before reaching the route.
- Project invitation emails are normalised to lowercase on store and
  compared case-insensitively on accept/decline. Mixed-case stored
  invitations were silently locking recipients out of accepting their
  own invite.
- Admin create_user lowercases the email and requires 8-char passwords
  (matching the public registration policy). The previous 6-char floor
  was weaker than the public path despite admin-created accounts often
  being privileged.
- AI moderator response parsing now uses (content or "").strip() — OpenAI
  returns content=None on safety refusals and the raw .strip() raised
  AttributeError that the surrounding except didn't catch.
2026-04-21 16:14:43 +01:00

153 lines
5.5 KiB
Python

"""One-shot data-migration helper: dedup APIUser and ProjectMember rows so
the new unique constraints can be added cleanly.
Background
----------
AutoModerate recently added two uniqueness invariants:
APIUser UNIQUE (project_id, external_user_id)
ProjectMember UNIQUE (project_id, user_id)
Databases that existed before this change can legitimately contain duplicate
rows (race-winners from check-then-insert patterns). Alembic / SQLAlchemy
will refuse to add the UNIQUE constraint while duplicates exist, so run this
script once before you bring up the app with the new models.
Usage
-----
# dry run — prints what would be merged
python scripts/dedup_before_unique_constraints.py
# actually apply (commits)
python scripts/dedup_before_unique_constraints.py --apply
Strategy
--------
Keep the oldest row of each duplicate group (by created_at / id) as the
winner, repoint any foreign keys from the losers onto the winner, merge
counters by summing them, then delete the losers.
"""
import argparse
import os
import sys
from collections import defaultdict
# Make the project importable whether you run this from the repo root or from
# inside scripts/.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from app import create_app, db # noqa: E402
from app.models.api_user import APIUser # noqa: E402
from app.models.content import Content # noqa: E402
from app.models.project import ProjectMember # noqa: E402
def _group_duplicates_api_users():
"""Returns {(project_id, external_user_id): [APIUser, ...sorted oldest first]}
for groups with more than one row."""
groups = defaultdict(list)
for row in APIUser.query.all():
groups[(row.project_id, row.external_user_id)].append(row)
return {key: sorted(rows, key=lambda r: (r.created_at or r.first_seen, r.id))
for key, rows in groups.items() if len(rows) > 1}
def _group_duplicates_memberships():
groups = defaultdict(list)
for row in ProjectMember.query.all():
groups[(row.project_id, row.user_id)].append(row)
return {key: sorted(rows, key=lambda r: (r.joined_at, r.id))
for key, rows in groups.items() if len(rows) > 1}
def dedup_api_users(apply: bool) -> int:
groups = _group_duplicates_api_users()
if not groups:
print("api_users: no duplicates found")
return 0
merged = 0
for (project_id, ext_id), rows in groups.items():
winner, losers = rows[0], rows[1:]
loser_ids = [r.id for r in losers]
print(f"api_users: merging {len(losers)} duplicate(s) of "
f"(project_id={project_id}, external_user_id={ext_id}) -> "
f"keeping {winner.id}")
if apply:
# Repoint content rows at the winner
Content.query.filter(Content.api_user_id.in_(loser_ids)).update(
{Content.api_user_id: winner.id}, synchronize_session=False)
# Sum counters from losers onto the winner
for loser in losers:
winner.total_requests += loser.total_requests or 0
winner.approved_count += loser.approved_count or 0
winner.rejected_count += loser.rejected_count or 0
winner.flagged_count += loser.flagged_count or 0
if loser.last_seen and (not winner.last_seen or loser.last_seen > winner.last_seen):
winner.last_seen = loser.last_seen
if loser.first_seen and (not winner.first_seen or loser.first_seen < winner.first_seen):
winner.first_seen = loser.first_seen
# Delete losers
APIUser.query.filter(APIUser.id.in_(loser_ids)).delete(
synchronize_session=False)
merged += len(losers)
if apply:
db.session.commit()
return merged
def dedup_memberships(apply: bool) -> int:
groups = _group_duplicates_memberships()
if not groups:
print("project_members: no duplicates found")
return 0
merged = 0
for (project_id, user_id), rows in groups.items():
# Keep the highest-privilege row; tiebreak by oldest.
role_rank = {'owner': 0, 'admin': 1, 'member': 2}
rows.sort(key=lambda r: (role_rank.get(r.role, 99), r.joined_at, r.id))
winner, losers = rows[0], rows[1:]
loser_ids = [r.id for r in losers]
print(f"project_members: merging {len(losers)} duplicate(s) of "
f"(project_id={project_id}, user_id={user_id}) -> "
f"keeping {winner.id} (role={winner.role})")
if apply:
ProjectMember.query.filter(ProjectMember.id.in_(loser_ids)).delete(
synchronize_session=False)
merged += len(losers)
if apply:
db.session.commit()
return merged
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--apply', action='store_true',
help='Actually commit the dedup. Omit for dry-run.')
args = parser.parse_args()
app = create_app(os.getenv('FLASK_CONFIG') or 'default')
with app.app_context():
mode = "APPLY" if args.apply else "DRY RUN"
print(f"=== dedup_before_unique_constraints.py ({mode}) ===")
api_user_merged = dedup_api_users(args.apply)
member_merged = dedup_memberships(args.apply)
print(f"\nSummary: {api_user_merged} APIUser duplicates + "
f"{member_merged} ProjectMember duplicates "
f"{'MERGED' if args.apply else '(dry run, no changes)'}")
if __name__ == '__main__':
main()