Files
ZY-Agent/docs/multi-tenant-redesign/03-impl/pr5-business-workspace-id.md
T
1445043649 430f4a1132 docs(impl): PR5 implementation note + STATUS update
11 PR5 commits: alembic 0002 (nullable workspace_id + FK + composite
index on 4 business tables) -> backfill_workspace_id.py (3-step
idempotent) -> alembic 0003 (pre-flight refuse + NOT NULL + UNIQUE).
18 new tests (9 alembic 0002+0003 + 9 backfill, including dry-run and
no-users error). 3150 passed + 30 skipped + 16 pre-existing caplog
flake (unchanged from PR4).

T5.11 (ORM nullable=False) deferred to PR6 — flipping the ORM-side
NOT NULL would break 6 INSERT sites whose repository signatures get
workspace_id wiring in PR6 anyway. DB-level invariant is already
enforced by alembic 0003 along the production path.

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

123 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR5 · alembic 0002 + 回填 + alembic 0003 — 4 张业务表 workspace_id
> 实现笔记。对应 [docs/superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md](../../superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md) PR5T5.1-T5.12)。
>
> 状态:**已落地**11 个 feat/test commits 提交到 `docs/multi-tenant-redesign`PR5 起始 `a7326978..` 结束 `30f2bd00`)。
## 范围
PR5 把 PR4 的 `users.default_workspace_id` 关系真正接到 4 张业务表上:
1. **alembic 0002**`threads_meta` / `runs` / `feedback` / `run_events`*nullable* `workspace_id String(36)` 列 + FK 到 `workspaces``ON DELETE CASCADE`+ `threads_meta` 复合索引 `idx_threads_meta_workspace_user_updated`
2. **backfill 脚本** `scripts/backfill_workspace_id.py`(带 `--dry-run`):三步幂等回填
- Step 1:每个 `default_workspace_id IS NULL` 的 user 建 1 人 workspace + owner membership + 回填 `users.default_workspace_id`
- Step 2correlated subquery `UPDATE` 4 张业务表,`workspace_id` 从行所属 user 的 `default_workspace_id`
- Step 3:剩余 `workspace_id IS NULL`user_id 本来就 NULL 的孤儿)→ `LEGACY_WORKSPACE_ID = 00000000-0000-0000-0000-000000000000`(按需建,owner = platform admin
3. **alembic 0003**pre-flight 拒迁(任一表残留 NULL → `RuntimeError`)→ 4 表 `workspace_id``NOT NULL``threads_meta``idx_threads_meta_workspace_thread UNIQUE(workspace_id, thread_id)`
4. **4 个 ORM model**:加 `Mapped[str | None] workspace_id` 列定义对齐 0002 schema`threads_meta` 模型也加 `idx_threads_meta_workspace_user_updated` Index 以便 `create_all()` 在 dev DB 上落同样形状
**不在范围**(推迟到 PR6):
- ORM model 把 `workspace_id` 翻成 `nullable=False`plan 的 T5.11,见下文"偏差说明"
- 仓储 `create()` 方法加 `workspace_id` 哨兵参数 + WHERE 子句
- `@require_permission` 升级 `check_access(thread_id, user_id, workspace_id)` 三参数
- 入口路由 `/api/threads``workspace_id`
- `Paths` 切 workspace 维度 + 文件系统迁移
## 验收
- [x] **17 个新单测全过**9 alembic 0002+0003 (5 SQLite + 4 PG-skip) + 9 backfill (含 dry-run + 端到端 orchestrator + no-users 报错)
- [x] **全套 `make test` 3150 passed + 30 skipped + 16 pre-existing caplog flake**PR4 末 3136 + 26 + 同 16 flake+14 passed / +4 skipped = +18 PR5 测试)。16 个 flake 在 isolate 跑里都 PASS,已 PR4 时确认与本 stage 无关,**继续 follow-up 推迟**
- [x] **alembic 0002 + 0003 在 SQLite tempdir 上 upgrade/downgrade 双驱动都过**PG 自动 skip 当 docker 未起)
- [x] **回填脚本 `--dry-run` 测试断言无写入**snapshot workspaces/memberships/users.default_workspace_id 计数 → 跑 backfill(dry_run=True) → 计数不变
- [x] **0003 拒迁断言**threads_meta 留 1 个 `workspace_id IS NULL` 行 → upgrade 抛 `RuntimeError``Cannot ALTER` + 提示去跑 backfill
- [x] **UNIQUE(workspace_id, thread_id) 触发**scratch table 验 `IntegrityError`(生产 schema thread_id 已是 PKshadow 了 UNIQUE,所以用独立表验语义)
- [x] **lint**`make lint` 全过(ruff check + format
- [ ] **真机 PG 完整跑 `alembic upgrade 0002` → `python scripts/backfill_workspace_id.py` → `alembic upgrade 0003`****待用户**agent 不能起 RDS 操作)
## 偏差说明:T5.11 推迟到 PR6
**plan 原 task**"4 个 model.py workspace_id 改 nullable=Falsecommit"
**为什么推迟**
- ORM model 的 `nullable=False` 影响两条链路:
1. **生产**alembic 0003 已经把 DB 列改 NOT NULL,这是真正承载不变式的边界。ORM 层翻不翻不影响数据库约束
2. **测试 / dev**`init_engine()``Base.metadata.create_all()`,从 ORM 推 DDL。如果模型 `nullable=False` 而仓储 `create()` 还不传 `workspace_id`PR6 才动),所有创建 `ThreadMetaRow / RunRow / FeedbackRow / RunEventRow` 的 production code path6 个 INSERT 站点)会在 DB 层炸 NOT NULL 约束
- PR6 的明确 scope 是"仓储 workspace_id 哨兵 + 路由层 workspace 化"——翻 ORM `nullable=False` 必须与 6 个 INSERT 站点 + repo 签名 + contextvar 接入一起做,**不能独立翻**
- plan T5.12 验收"`make test` 不破"与 T5.11 单独翻 `nullable=False` 在当前 codebase 状态下**互斥**。本 PR 选择维护 T5.12 验收,将 T5.11 推迟到 PR6 自然接入处
**承载不变式不变**alembic 0003 在 DB 层强制 NOT NULL;任何走 alembic 路径(生产)的部署都拿到正确约束。ORM 层 `nullable=True` 只在 dev `create_all()` 路径生效,PR6 翻完后整个体系收敛。
## 关键决策(与 plan §LOCK + workspace-schema-design 对齐)
| 项 | 选择 | 理由 |
|---|---|---|
| 4 表 FK 删除策略 | `ON DELETE CASCADE` | 删 workspace 时业务行随之清空(避免外键悬空) |
| `idx_threads_meta_workspace_user_updated` 列序 | `(workspace_id, user_id, updated_at)` | PR6 路由"列出 workspace X 下 user Y 的会话按时间倒序"查询的覆盖索引 |
| `idx_threads_meta_workspace_thread` UNIQUE 加在 0003 不是 0002 | nullable 列上 UNIQUE 在 PG 允许多 NULL,在 SQLite 默认也允许;但回填中途的 UNIQUE 校验语义模糊,等 NOT NULL 一起加更干净 | 风险更可控 |
| 回填 Step 2 SQL 形态 | SQLAlchemy `update().values(workspace_id=correlated_subquery)` | SQLite 3.33+ 才支持 `UPDATE ... FROM`correlated subquery 跨方言可移植 |
| 回填 Step 1 slug 走 walker + blacklist | `auto_slug_from_email` + `next_available_slug` + 把 `SLUG_BLACKLIST` 当"已占用" | 与 PR4 注册流程语义一致;admin@example.com → `admin-2`"admin" 在黑名单) |
| `legacy_workspace` owner 选取 | 先找 `system_role='admin'` 最早一个,没有则用最早的任意 user,都没有则 `RuntimeError` | 不能创建无 owner 的 workspace;空 DB 上明确报错强制操作员先 bootstrap admin |
| `_BUSINESS_TABLES` 顺序 | `threads_meta / runs / feedback / run_events` | 仅人眼可读性,无依赖 |
| `_ensure_legacy_workspace` 拆出 orchestrator 调一次 | 不内联 Step 3 内(避免循环 4 次 check) | 简单 + 显式步骤分隔 |
| 0003 pre-flight raise 类型 | `RuntimeError` with `_BackfillRequiredError` 子类 | Alembic 抛错语义最自然;消息含具体表 + 行数 + 修复命令 |
| Step 3 dry-run 报告 | `WOULD assign N orphan rows in {table}` 包括 Step 2 没 cover 的全部 NULL 行 | 操作员能看到具体多少行会被 lump 进 legacy_workspace |
## 跟进项(不在 PR5 范围)
- **PR6**4 个仓储 `create()``workspace_id` 参数 + INSERT;翻 ORM model `nullable=False``@require_permission` 三参数;`/api/threads` 路由层校验
- **真机 PG smoke**`alembic upgrade 0002``python scripts/backfill_workspace_id.py --dry-run` → 看输出 → 实跑 → `alembic upgrade 0003`。命令清单见下方
- **远程 RDS testcontainers**:本 PR 复用 PR3 测试 fixturePG twin 测试自动 skip 当 docker 未起;用户需起 docker daemon 实跑一遍
## 涉及文件
| 类别 | 文件 |
|---|---|
| **新增 (harness)** | `persistence/migrations/versions/0002_business_tables_workspace.py` |
| **新增 (harness)** | `persistence/migrations/versions/0003_business_tables_workspace_not_null.py` |
| **新增 (script)** | `backend/scripts/backfill_workspace_id.py`3 个 step + 1 个 ensure + 1 个 orchestrator + main|
| **新增 tests** | `test_alembic_business_tables.py`, `test_backfill_workspace_id.py` |
| **修改 (harness models)** | `persistence/thread_meta/model.py`+`workspace_id` FK + composite index),`persistence/run/model.py``persistence/feedback/model.py``persistence/models/run_event.py` |
| **修改 (test compat)** | `test_alembic_default_workspace_id.py`"head" → "0001_users_default_workspace" 显式 pin|
## 测试矩阵
| 测试文件 | 用例数 | 覆盖 |
|---|---|---|
| `test_alembic_business_tables.py` | 95 SQLite + 4 PG-skip| 0002 upgrade/downgrade + 0003 happy/refuse-NULL/UNIQUE |
| `test_backfill_workspace_id.py` | 9 | Step 1 创建/幂等/黑名单 + Step 2 4 表/多 user 隔离 + Step 3 orphan/无 user 报错 + orchestrator + dry-run no-write |
总计:**18 个 PR5 新测试** + 1 个改写(pin 0001 revision)。
## Live smoke 命令(用户跟进)
```bash
# 1. 当前 DB state(应在 PR4 之后状态)
psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM users WHERE default_workspace_id IS NULL;"
# 2. dry-run 看 backfill 会动什么
cd backend && PYTHONPATH=. python scripts/backfill_workspace_id.py --dry-run
# 3. 跑 0002(加 nullable 列)
cd backend/packages/harness/deerflow/persistence && \
PYTHONPATH=../../../.. alembic -c migrations/alembic.ini upgrade 0002_business_tables_workspace
# 4. 真跑 backfill
cd backend && PYTHONPATH=. python scripts/backfill_workspace_id.py
# 5. 应返 0
psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM threads_meta WHERE workspace_id IS NULL;"
psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM runs WHERE workspace_id IS NULL;"
psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM feedback WHERE workspace_id IS NULL;"
psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM run_events WHERE workspace_id IS NULL;"
# 6. 跑 0003(改 NOT NULL + UNIQUE
cd backend/packages/harness/deerflow/persistence && \
PYTHONPATH=../../../.. alembic -c migrations/alembic.ini upgrade 0003_business_tables_workspace_not_null
# 7. 确认 legacy_workspace
psql "$DEER_FLOW_PG_URL" -c "SELECT id, name, slug FROM workspaces WHERE id = '00000000-0000-0000-0000-000000000000';"
```
如果 dry-run 输出里 `legacy_workspace_created=True` 而 DB 里还没用户,先去 `/auth/initialize` 建管理员再跑——backfill 不会替你 bootstrap。