23 KiB
Handover — 近期变更与待办
面向接手运维 / 二次开发的人。记录最近几次 commit 的动机、改动范围、未尽事项,
以及「线上有旧数据时怎么过渡」。详细架构见 ARCHITECTURE.md;本仓库的开发规约
见 DEVELOP.md。
0. 当前交接状态(2026-08-20)
0.1 代码与服务
- 基线代码:
ae3b6d6(develop)。本次改动尚未提交,修改范围见下方「0.4」。 - Docker Compose 项目名:
model-platform-develop14。 - 主入口:http://127.0.0.1:9120;浏览器通过 web/Nginx 访问后端 API。
- 当前启用的
.env已配置 MySQL 连接和存储后端。不要把.env中的连接串、密码或 Token 提交到 Git。
检查服务:
docker compose -p model-platform-develop14 ps
Invoke-WebRequest http://127.0.0.1:9120/api/v1/health
完整重建并启动:
docker compose -p model-platform-develop14 up -d --build
仅后端 Python 代码变动时,使用下面命令即可;它不会删除数据库或 data/:
docker compose -p model-platform-develop14 build backend
docker compose -p model-platform-develop14 up -d --force-recreate --no-deps backend
0.2 调度模块本次行为
调度页面位于 frontend/app/features/schedules/,对应 API 位于
backend/src/backend/schedules.py。
- 删除调度方案:删除该方案的节点、连线、运行记录,以及可删除的运行日志/结果产物;若方案 仍有运行中的任务,后端返回 409,避免删到一半。
- 删除节点:运行中的节点不可删除;有历史运行记录时,前端会提示“该节点有运行日志,是否一并删除?”。
用户确认后会删除节点、关联连线、该节点的
schedule_node_runs记录和可删除日志/结果。 StorageObjects.is_immutable=1的审计原件受存储层保护,不能物理删除;删除节点时会删除其 运行记录引用,使其不再在调度页面展示,但保留原件,避免再次出现immutable object cannot be deleted并导致节点删除回滚。- 运行记录列表已加入轮询:有运行中任务时约 1.5 秒刷新一次,空闲时约 5 秒刷新一次;右侧刷新 按钮仍可手动刷新。
0.3 关键排查位置
| 现象 | 首先查看 |
|---|---|
| 页面请求报错 | 浏览器 F12 → Network → Fetch/XHR,查看请求 URL、状态码和响应内容 |
| API / 数据库异常 | docker compose -p model-platform-develop14 logs --tail=200 backend |
| Cron 未触发 | docker compose -p model-platform-develop14 logs --tail=200 schedule |
| 脚本执行失败 | 调度页面“运行记录”→“查看结果”→ 节点日志 |
| 容器状态或端口问题 | docker compose -p model-platform-develop14 ps |
0.4 未提交改动清单
当前工作区包含以下代码改动,提交前应按需执行 typecheck / 后端测试:
| 文件 | 作用 |
|---|---|
backend/src/backend/schedule_schemas.py |
节点删除请求支持历史记录确认参数 |
backend/src/backend/schedules.py |
调度/节点删除、运行中保护、日志清理与不可变产物兼容 |
frontend/app/services/api.ts |
节点删除 API 参数 |
frontend/app/context/AuthContext.tsx |
API 参数透传 |
frontend/app/features/schedules/state/schedulesStore.ts |
删除确认及删除后刷新状态 |
frontend/app/features/schedules/SchedulePage.tsx |
运行记录轮询 |
data/ 是本地运行数据,不应作为本次代码改动一起提交。
1. 最近 8 个 commit(按时间倒序)
85b2916 — fix: local_storage_base_dir(bucket 标识符语义统一:最终 fix)
背景:见 §3。「删除文件存在报错 failed to move object to trash: 'workspace'」
的真实根因 —— StorageObjects.bucket_name 写的是裸字符串("workspace"),
app.state.object_stores 的 key 是 local 模式拼出来的路径("data/workspace"),
两边错位导致 KeyError。
改动(1 个文件,19+/15-):
| 文件 | 改动 |
|---|---|
common/src/common/storage/factory.py |
actual_bucket_name(purpose) 合并 local/s3 两个分支,单一返回 settings.s3_<purpose>_bucket;build_storage_uri local 分支从 Path(bucket_name).absolute() 改为 (Path(local_storage_base_dir) / bucket_name).resolve();注释 / docstring 同步重写 |
重要语义区分:
actual_bucket_name(purpose)→ 返回 s3 风格 bucket 标识符(如"workspace"、"run-logs"),local 和 s3 模式返回相同字符串。这是app.state.object_stores的 dict key,也是StorageObjects.bucket_name/UploadSessions.bucket_name存储的值。build_storage_config(purpose)→ 内部仍然Path(local_storage_base_dir) / purpose拼base_dir,所以 local backend 实例的磁盘根仍是./data/<purpose>/。这层 与bucket_name字段解耦,是设计上正确的。
25e563d — update: ruff check --fix
ruff 自动格式化,13 个文件,0 逻辑改动。可视为「25e563d 之前最近一次大改
(5d49ff5 / 2e070fa / 5c5dec9 / d855912 / 85b2916)的格式统一」。如果
review 时看到大量 import 重排 / 引号风格变化,都是 ruff 干的。
d855912 — fix: soft delete helper(写入端 bucket_name 修复)
背景:承接 85b2916 的语义重写。85b2916 之前 scripts.py:482 用的是
settings.s3_workspace_bucket(裸字符串);修完 actual_bucket_name 之后
该函数返回的是 s3 风格标识符,逻辑等价,但 scripts.py 调用方式必须跟着改
才能保持一致性。_resolve_bucket_for_usage 的 fallback 路径有同一类 bug。
改动(2 个文件,4+/2-):
| 文件 | 改动 |
|---|---|
backend/src/backend/scripts.py |
import 增加 actual_bucket_name;bucket_name = settings.s3_workspace_bucket → bucket_name = actual_bucket_name("workspace") |
backend/src/backend/services/storage.py |
_resolve_bucket_for_usage fallback 从 BUCKET_FOR_USAGE.get(usage_type, settings.s3_workspace_bucket) 改为 BUCKET_FOR_USAGE.get(usage_type, actual_bucket_name(USAGE_TYPE_TO_PURPOSE.get(usage_type, "workspace"))) |
139f2c0 — fix: delete error(trash 路径统一 + 删除后 DB 字段更新)
背景:删除文件统一改为移动到 trash 桶后,存在三个连锁 bug:
- local 模式 trash 路径双重
data:trash_key = f"{item.bucket_name}/{item.object_key}"。 local 模式下bucket_name是绝对路径(如/data/workspace),最终落到data/trash/data/workspace/{ws_id}/{user_id}/...。 - s3 模式桶名 / 路径名错位:
.env之前是S3_WORKSPACE_BUCKET=workspaces(复数), 代码 default /PURPOSE_BUCKETS/USAGE_TYPE_TO_PURPOSE全部是单数workspace。 跨环境部署产生KeyError: 'workspaces'(软删时object_stores[item.bucket_name]抛错)。 - 软删后
storage_objects路径字段未更新:只翻trash_key/object_status/deleted_at/is_deleted,bucket_name/object_key/storage_uri仍指向源, 与物理位置不一致,purge_trash_object必须读旧trash_key才能定位 trash 文件。
改动(5 个文件,44+/24-):
| 文件 | 改动 |
|---|---|
backend/src/backend/services/storage.py |
trash_key = f"{source_purpose}/{item.object_key}";软删成功后更新 bucket_name / object_key / storage_uri 指向 trash 位置 |
backend/src/backend/storage_api.py |
restore_object 用 item.object_key 直接定位 trash,按 / 拆 source_purpose,写回源桶,更新 DB 字段指回 source;purge_trash_object 用 (actual_bucket_name("trash"), item.object_key) 删除,加 bucket_name != trash 的 409 防御 |
common/src/common/storage/factory.py |
引入 USAGE_TYPE_TO_PURPOSE 字典(替代 storage_api.py 的本地定义) |
common/src/common/storage/__init__.py |
导出 USAGE_TYPE_TO_PURPOSE |
.env.example |
S3_WORKSPACE_BUCKET=workspaces → workspace、S3_VERSION_BUCKET=versions → version、S3_RUN_LOG_BUCKET=run-logs → run-log(统一单数) |
注意点:
workspaces_root()函数名 /WORKSPACES_ROOT环境变量名不动(这是 path 概念,不是 bucket 名)。Workspaces数据库表名 /auth.py的"workspaces"JSON key(API 响应)不动(这些不是桶名)。visibility="workspace"是 ACL Literal,不动。
2e070fa — fix: pre check(前端 script 创建/上传前置校验)
CreateScriptModal.tsx(+25 行)— 文件名 / workspace_id / 类型前置校验。
ScriptsPage.tsx(+1)/ scriptWorkspaceStore.ts(+19/-10)— store action 增加
precheck 步骤,提前拦截无效请求。
5c5dec9 — fix: allows_same_name_different_parent
允许同名 script 出现在不同父目录下。scripts.py(+32/-?)、services/storage.py、
storage_api.py 微调;test_scripts.py 新增 57 行回归测试。
5d49ff5 — fix: file upload error(大重写)
修复 file upload 路径上一连串 schema / API / 上传流程的不一致。改动面最大 (14 个文件,872+/604-):
- 新增索引 / 调整唯一约束(去掉了 scripts 和 data_resources 的旧 unique index)
- 把多个小 migration 合并进
e1f2a3b4c5d6_rebuild_baseline.py一份大 baseline resources.py/scripts.py上传流程对齐test_resources.py/test_scripts.py大量回归API.md增加 3 行
c65d6dc — fix: build error
backend/Dockerfile 单行修复(镜像构建报错)。
2. Trash 修复详解(commit 139f2c0)
2.1 修复后的语义
-
trash_key:
f"{source_purpose}/{item.object_key}"。source_purpose来自USAGE_TYPE_TO_PURPOSE[item.usage_type],与item.bucket_name解耦 — 避免 local 模式绝对路径泄漏 + 命名错位风险。 -
软删后
storage_objects行的 7 个字段同步:字段 软删前 软删后 bucket_name源桶 trash 桶 object_key源 key trash key trash_keyNULL trash key storage_uri源 URI trash URI( build_storage_uri重建)object_statusavailabledeletedis_deleted01deleted_atNULL <utcnow> -
物理文件落点:
模式 源文件(被删) trash 文件(新落) S3 s3://<source_bucket>/<key>s3://trash/<source_purpose>/<key>local <LOCAL_STORAGE_BASE_DIR>/<source_purpose>/<key><LOCAL_STORAGE_BASE_DIR>/trash/<source_purpose>/<key>source_purpose∈{workspace, version, run_log}。
2.2 restore_object 反向流程
data = await object_stores[trash_bucket].get(item.object_key) # 从 trash 读
source_purpose, _, source_key = item.object_key.partition("/") # 拆 source_purpose
target_bucket = actual_bucket_name(source_purpose) # 还原目标桶
await object_stores[target_bucket].put(source_key, data) # 写回源
item.bucket_name = target_bucket
item.object_key = source_key
item.storage_uri = build_storage_uri(target_bucket, source_key)
# trash_key 保留不动 → 未来 reaper 可据此清理 trash 里的 orphan 副本
item.object_status = "available"
item.deleted_at = None
trash 文件不会被 restore 主动删除(保留现有安全语义:restore 失败时
数据仍在 trash 里有兜底)。未实现的 reaper 未来可扫
object_status='available' AND trash_key IS NOT NULL 清理 orphan。
2.3 purge_trash_object 防御
加 409 防御:if item.bucket_name != actual_bucket_name("trash"): raise 409。
避免误删源数据(用 item.object_key 直接删 trash 文件,不再读旧 trash_key)。
2.4 USAGE_TYPE_TO_PURPOSE 提升到 common 层
backend/storage_api.py 与 backend/services/storage.py 都依赖这个映射。
提到 common/storage/factory.py 后两边都 from common.storage import USAGE_TYPE_TO_PURPOSE,
避免互相 import 循环。
3. Bucket 标识符语义统一(commits d855912 + 85b2916)
3.1 错位现象
actual_bucket_name(purpose) 在 85b2916 之前有两个分支:
def actual_bucket_name(purpose: str) -> str:
if settings.storage_backend == "local":
return str(Path(settings.local_storage_base_dir) / purpose) # → "data/workspace"
return getattr(settings, f"s3_{purpose}_bucket") # → "workspace"
这条函数被三个地方用:
| 消费方 | 期望的值 | 实际拿到的(local 模式,修前) |
|---|---|---|
app.state.object_stores dict key |
与 DB bucket_name 一致 |
"data/workspace" |
StorageObjects.bucket_name 写入 |
与 dict key 一致 | "data/workspace"(来自 scripts.py:482 的旧 settings.s3_workspace_bucket) |
UploadSessions.bucket_name 写入 |
与 dict key 一致 | settings.s3_workspace_bucket = "workspace"(来自 _resolve_bucket_for_usage 的 fallback) |
表里第三行就是本次出问题的根本:UploadSessions.bucket_name 写的是裸字符串 "workspace",但
app.state.object_stores 的 key 是路径 "data/workspace"。两者 lookup 时必然 KeyError。
注意:本部署所有 DB row 的 bucket_name 都是裸字符串("workspace"),跟
object_stores dict key 错位 — 软删时 object_stores["workspace"] 直接抛
KeyError: 'workspace',被 soft_delete_object 的 try/except 包成
HTTPException(502, "failed to move object to trash: 'workspace'")。
scripts.py:482 写出的 row 反而是错的 "data/workspace",但所有现存 row 写于本会话
之前的旧版本,所以 DB 里没有这种 row。scripts.py 是用户从 Jupyter 创建新脚本时触发的
路径,本次会话没有产生新 row。
3.2 85b2916 的最终语义
actual_bucket_name(purpose) 合并为单一返回:
def actual_bucket_name(purpose: str) -> str:
from common.config import settings # 延迟 import 避免循环
return getattr(settings, f"s3_{purpose}_bucket")
返回值约定(local / s3 两种模式相同):
| purpose | 返回(与 .env 对齐) |
|---|---|
workspace |
settings.s3_workspace_bucket |
version |
settings.s3_version_bucket |
run_log |
settings.s3_run_log_bucket |
trash |
settings.s3_trash_bucket |
这是纯标识符,不是文件系统路径。
build_storage_config(purpose) 内部仍然走 Path(local_storage_base_dir) / purpose 拼
base_dir,所以 local backend 实例的磁盘根目录没变(仍是 ./data/<purpose>/)。
这里有个两层抽象:
bucket_name字段(DB 存储 / dict key / URI 字符串)— s3 风格标识符,actual_bucket_name负责base_dir(local backend 实际写入的磁盘根)— 路径,由build_storage_config内部负责
两层解耦后,修改 actual_bucket_name 不会移动任何字节。
3.3 build_storage_uri 配套修复
旧版本 local 分支:
absolute_path = Path(bucket_name).absolute().as_posix() # 假设 bucket_name 是路径
修后:
absolute_path = (Path(settings.local_storage_base_dir) / bucket_name).resolve().as_posix()
因为 bucket_name 不再是路径,必须显式拼上 local_storage_base_dir。
3.4 写入端对齐(commit d855912)
| 文件 | 改动 |
|---|---|
backend/src/backend/scripts.py:482 |
bucket_name = settings.s3_workspace_bucket → bucket_name = actual_bucket_name("workspace") |
backend/src/backend/services/storage.py:_resolve_bucket_for_usage |
fallback 从 settings.s3_workspace_bucket 改为 actual_bucket_name(USAGE_TYPE_TO_PURPOSE.get(usage_type, "workspace")) |
为什么 d855912 是必要的:85b2916 改了 actual_bucket_name 的返回语义,
但 scripts.py:482 直接读 settings.s3_workspace_bucket、_resolve_bucket_for_usage
直接读 settings.s3_workspace_bucket —— 这两处必须改成 actual_bucket_name(...) ,
否则它们绕过了新语义,写出的 row 又是裸字符串,新 dict key 是 s3 风格标识符,
两边重新错位。两 commit 必须一起部署,单独 85b2916 会让 dict key 是 "workspace", DB 仍是 settings.s3_workspace_bucket写出的"workspace"—— 看似 OK 但万一某天.env改了S3_WORKSPACE_BUCKET=workspaces`,就立刻重新炸。
4. 线上数据兼容(重要)
4.1 当前部署(local 模式 + 单数桶名)
d855912 + 85b2916 之后,新代码对 DB 里已有的旧行完全兼容:
现有 7 个 bucket_name='workspace' 的 row 直接命中 dict key,无需迁移。
重启 backend 后就能正常软删。
4.2 历史异构部署(如果你接手的环境曾跑过下面任一形态)
4.2.1 .env 曾是复数桶名(S3_WORKSPACE_BUCKET=workspaces 等)
如果该部署曾用复数 .env 上传过文件,storage_objects.bucket_name 列里会有
复数残留。新代码 dict key 是单数,访问旧行仍会 KeyError: 'workspaces'。
部署新代码前必须先跑一次数据修复(先 SELECT 看数量):
SELECT storage_backend, bucket_name, COUNT(*)
FROM storage_objects
WHERE object_status = 'available'
GROUP BY storage_backend, bucket_name
ORDER BY storage_backend, bucket_name;
UPDATE storage_objects
SET bucket_name = 'workspace'
WHERE bucket_name = 'workspaces'
AND object_status = 'available';
-- 同样模式处理 version / run-log / trash 残留
4.2.2 bucket_name 残留 Path 形式(/data/workspace 等)
如果历史版本曾把 actual_bucket_name("workspace") 的路径形式(/data/workspace、
./data/workspace、data/workspace)写进 bucket_name 列,需要归一化成裸字符串:
UPDATE storage_objects
SET bucket_name = 'workspace'
WHERE bucket_name LIKE '%/workspace' AND storage_backend = 'local'
AND object_status IN ('available', 'deleted');
只改 available / deleted 行;正在上传中的 row(object_status='uploading')
不要碰,会丢数据。
4.2.3 异构 backend 的旧行
如果某个 row 的 storage_backend 与当前部署不一致(例如旧 row 是 s3、当前部署是
local),物理文件位置在旧 backend 那边,软删时只翻 DB 状态,物理文件没移动。
处理:人工导出后清理,或者重新上传。
4.3 监控(建议加)
storage_objects 行 bucket_name 与 actual_bucket_name(purpose) 不一致的比例
(按 usage_type 分组 COUNT)。生产环境应长期趋近于 0。
SELECT usage_type, bucket_name, COUNT(*)
FROM storage_objects
WHERE object_status = 'available'
GROUP BY usage_type, bucket_name
ORDER BY usage_type, bucket_name;
5. 本地验收清单
# 1. 语法
uv run python -m compileall common/src backend/src runtime/src schedule/src
# 2. 测试
uv run --package backend pytest backend/tests -q
# 期望:30 passed
# 3. bucket 标识符一致性(s3 风格字符串,不带路径)
grep -rn "bucket_name = settings\." \
--include='*.py' backend/src common/src 2>/dev/null
# 期望:零结果 — bucket_name 全部走 actual_bucket_name(...)
# 4. 单复数对齐
grep -n 'S3_WORKSPACE_BUCKET\|S3_VERSION_BUCKET\|S3_RUN_LOG_BUCKET\|S3_TRASH_BUCKET' \
.env.example
# 期望:所有桶名与代码 default 一致(参考 .env.example 注释)
# 5. 命名残留扫
grep -rn '"workspaces"\|"versions"\|"run-logs"' \
--include='*.py' --include='*.yml' --include='*.env*' \
backend/src common/src docker-compose.yml .env .env.example 2>/dev/null
# 期望:除 `__tablename__ = "workspaces"`(DB 表名)和 `auth.py` 的 JSON key 外,零结果
6. 回归测试覆盖
| 场景 | 触发方式 | 期望 |
|---|---|---|
| 同名 script 不同父目录 | test_soft_delete_object_sets_is_deleted 旁边的新测试 |
两条 row 同名共存 |
软删 → 物理文件落到 trash 桶的 source_purpose/{key} |
手工:上传 → 删 → mc ls s3/trash/ |
文件在;源桶里已删 |
软删后 storage_objects 7 字段同步 |
软删后 SELECT * FROM storage_objects WHERE storage_object_id=... |
见 §2.1 表格 |
restore_object 把 trash 文件写回源桶 |
软删 → restore → 查源桶 | 源桶恢复;trash 文件保留(orphan) |
purge_trash_object 拒绝误删源数据 |
手工:把 bucket_name 改成源桶后调 purge |
409 |
| local 模式软删走通 | local 部署下软删一个 script | 不抛 KeyError: 'workspace';trash 落到 ./data/trash/<purpose>/<key> |
actual_bucket_name 两种模式返回相同字符串 |
写测试或 trace | s3 模式 = local 模式(与 .env 对齐) |
7. 监控项(建议加)
storage_objects行bucket_name与actual_bucket_name(purpose)不一致的比例 (按usage_type分组 COUNT)。生产环境应长期趋近于 0。object_status='deleted'但bucket_name != actual_bucket_name('trash')的行数 — 应为 0(软删失败回滚残留)。- trash 桶目录大小 vs
object_status='deleted'行数(孤儿文件比例)。 没实现 reaper 前这个会单调上升。
8. 已知未实现项
- Trash reaper:
s3_trash_retention_days已声明,但代码没有扫表清理。restore_object故意保留 trash 文件作 orphan,等 reaper 兜底。 第一次实现建议在schedule/src/schedule/加一个 tick job:然后调SELECT storage_object_id FROM storage_objects WHERE deleted_at < UTC_TIMESTAMP() - INTERVAL :retention_days DAY AND object_status = 'deleted' LIMIT 100;/internal/v1/admin/trash/purge。 - 跨 backend trash 兼容:当前只在
item.storage_backend == settings.storage_backend的行上做物理移动。异构 backend 的旧行(少)软删时只翻 DB 状态,trash 文件实际没移动。 远程 8.153.151.51:8890 应该都是同一 backend,目前无影响。 - 历史 trash_key 兼容读:新代码用
item.object_key读 trash,不回退到item.trash_key。如果迁移前有按旧trash_key = f"{bucket_name}/{object_key}"写入的已删行,object_key还是源 key,restore 会找不到 trash 文件(404)。 处理:在restore_object加 fallback — 先按item.object_key试,失败再按item.trash_key试一次(兼容窗口期)。建议在 reaper 上线后删除 fallback。 - DB 测试种子数据:
storage_objects表里有workspace_id='00000000000000000000000002'的行(全 0 + 02,不是有效 ULID 格式),对应的object_key在磁盘data/workspace/下 找不到对应文件(磁盘上是真实 workspace ULID 子目录00000000000BM630VT9ARVFZPC、01KZR60SWWTXXM0KNZ2GJMT36A)。这些是测试种子。修复 bucket 标识符后,软删会从 KeyError 变成 StorageNotFoundError —— KeyError 解决后真实地暴露了「字节缺失」。处理:清掉这些 测试 row,或补齐磁盘文件。
9. 仍未合并到 develop 的本地改动
M frontend/vite.config.ts (4+/2-)
?? data/ (untracked — 本地 docker volume 残留,可忽略)
frontend/vite.config.ts 的改动不在本次 trash / bucket 修复范围内,等下次 commit 一起提。
data/ 是 STORAGE_BACKEND=local 的对象存储本地根,git ignore 不应跟踪。