Files
ZY-Agent/backend/scripts/backfill_workspace_id.py
T
1445043649 e6bb220979 feat(persistence): backfill Step 1 — workspace per user without default
For each user with NULL default_workspace_id, generate a base slug
from the email, walk past collisions/blacklist via next_available_slug,
create the workspace + owner membership, and set default_workspace_id.
Idempotent: candidates list is filtered by IS NULL, so re-running on a
populated DB is a no-op. 3 new unit tests (creation, idempotence,
blacklist-walker behaviour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:09:16 +08:00

200 lines
7.0 KiB
Python

"""Backfill ``workspace_id`` on PR5 business tables.
Three-step backfill (each idempotent — re-running picks up where a crash
left off because every step's WHERE clause filters already-processed rows):
1. For each user without ``default_workspace_id``: create a personal
workspace + ``owner`` membership + write back the user's
``default_workspace_id``.
2. ``UPDATE`` each of ``threads_meta`` / ``runs`` / ``feedback`` /
``run_events`` setting ``workspace_id`` from the row's owner's
``users.default_workspace_id``. Only touches rows where
``workspace_id IS NULL`` and ``user_id IS NOT NULL``.
3. Any rows still with ``workspace_id IS NULL`` (truly orphan — they had
``user_id = NULL`` to begin with) are assigned the *legacy* workspace
UUID ``00000000-0000-0000-0000-000000000000``. The script creates
that workspace on demand, owned by the platform admin.
Usage::
PYTHONPATH=. python scripts/backfill_workspace_id.py [--dry-run]
T5.4 only ships the skeleton — the three step bodies are filled in by
T5.5 / T5.6 / T5.7 along with their per-step tests.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.gateway.auth.workspace_slug import auto_slug_from_email, next_available_slug
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace import WorkspaceRepository
from deerflow.persistence.workspace.sql import SLUG_BLACKLIST
from deerflow.persistence.workspace_membership import WorkspaceMembershipRepository
logger = logging.getLogger(__name__)
# The legacy workspace anchor. Stage 0 LOCK'd UUID — chosen as the standard
# nil UUID so SQL log scans can spot it instantly.
LEGACY_WORKSPACE_ID = "00000000-0000-0000-0000-000000000000"
LEGACY_WORKSPACE_SLUG = "legacy"
LEGACY_WORKSPACE_NAME = "Legacy Workspace"
# The four business tables that gained ``workspace_id`` in alembic 0002.
_BUSINESS_TABLES: tuple[str, ...] = ("threads_meta", "runs", "feedback", "run_events")
async def _step1_create_workspaces_for_users(
session_factory: async_sessionmaker[AsyncSession],
*,
dry_run: bool,
) -> int:
"""Create one workspace + owner membership for each user missing default_workspace_id.
Returns the count of users a workspace was created for. Idempotent —
users that already have ``default_workspace_id`` are skipped so a
crashed run can resume safely.
"""
async with session_factory() as session:
result = await session.execute(select(UserRow.id, UserRow.email).where(UserRow.default_workspace_id.is_(None)))
candidates = [(row.id, row.email) for row in result]
if not candidates:
return 0
ws_repo = WorkspaceRepository(session_factory)
m_repo = WorkspaceMembershipRepository(session_factory)
created = 0
for user_id, email in candidates:
base_slug = auto_slug_from_email(email)
async def slug_exists(s: str) -> bool:
if s in SLUG_BLACKLIST:
return True
return (await ws_repo.get_by_slug(s)) is not None
unique_slug = await next_available_slug(base_slug, exists_check=slug_exists)
if dry_run:
logger.info("WOULD create workspace for user=%s email=%s slug=%s", user_id, email, unique_slug)
created += 1
continue
display_local = email.split("@", 1)[0]
workspace = await ws_repo.create(
name=f"{display_local}'s Workspace"[:64],
slug=unique_slug,
owner_id=user_id,
)
await m_repo.add(workspace_id=workspace["id"], user_id=user_id, role="owner")
async with session_factory() as session:
await session.execute(update(UserRow).where(UserRow.id == user_id).values(default_workspace_id=workspace["id"]))
await session.commit()
created += 1
logger.info("Created workspace %s (slug=%s) for user=%s", workspace["id"], unique_slug, user_id)
return created
async def _step2_update_table_from_users(
session_factory: async_sessionmaker[AsyncSession],
table: str,
*,
dry_run: bool,
) -> int:
"""UPDATE *table* setting workspace_id from owner's users.default_workspace_id.
Filled in by T5.6.
"""
_ = session_factory
_ = table
_ = dry_run
return 0
async def _step3_assign_legacy_workspace(
session_factory: async_sessionmaker[AsyncSession],
table: str,
*,
dry_run: bool,
) -> int:
"""Assign LEGACY_WORKSPACE_ID to *table* rows still missing workspace_id.
Filled in by T5.7 (also responsible for ensuring the legacy workspace row exists).
"""
_ = session_factory
_ = table
_ = dry_run
return 0
async def backfill(
session_factory: async_sessionmaker[AsyncSession],
*,
dry_run: bool = False,
) -> dict[str, Any]:
"""Run all three backfill steps; return a per-step row-count report.
Order matters: Step 1 must populate ``users.default_workspace_id``
before Step 2 can correlate business rows back through ``users``.
"""
report: dict[str, Any] = {"dry_run": dry_run}
report["users_workspaces_created"] = await _step1_create_workspaces_for_users(session_factory, dry_run=dry_run)
for table in _BUSINESS_TABLES:
report[f"{table}_from_users"] = await _step2_update_table_from_users(session_factory, table, dry_run=dry_run)
for table in _BUSINESS_TABLES:
report[f"{table}_legacy"] = await _step3_assign_legacy_workspace(session_factory, table, dry_run=dry_run)
return report
def _build_session_factory_from_config() -> async_sessionmaker[AsyncSession]:
"""Build an async session factory from the active config.yaml.
Avoids importing on module load so unit tests can stub
``session_factory`` directly without booting the full config pipeline.
"""
from deerflow.config import get_app_config
from deerflow.persistence.engine import get_session_factory, init_engine_from_config
asyncio.run(init_engine_from_config(get_app_config().database))
sf = get_session_factory()
if sf is None:
raise RuntimeError(
"database.backend=memory: nothing to backfill. Switch config.yaml to sqlite/postgres first.",
)
return sf
def main() -> None:
parser = argparse.ArgumentParser(description="Backfill workspace_id on Stage 0 business tables (idempotent).")
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the rows each step would touch without writing.",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
sf = _build_session_factory_from_config()
report = asyncio.run(backfill(sf, dry_run=args.dry_run))
logger.info("Backfill report (dry_run=%s):", args.dry_run)
for key, value in report.items():
if key == "dry_run":
continue
logger.info(" %s: %s", key, value)
if __name__ == "__main__":
main()