mirror of
https://github.com/Significant-Gravitas/AutoModerate.git
synced 2026-11-11 04:31:36 -10:00
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.
153 lines
5.5 KiB
Python
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()
|