运维与验收
运维与验收
1. 任务表与八种 kind
jobs 是 Postgres 的一张表,没有 Redis。任务与业务数据同库,ON DELETE CASCADE 能随文档一起清掉待处理任务;规模是几个人的团队,峰值几百条。
| kind | 谁入队 | 成本 | 说明 |
|---|---|---|---|
ingest | 上传、恢复、重解析、重试 | 约 50ms/页 + 88ms/chunk | 解析 → 切分 → 嵌入 → 写两条文本腿,一次做完 |
embed_visual | 摄取时按扫描页/照片 | 约 15s/页 | 一页一个任务,不与 ingest 合并 |
thumbnail | PDF 摄取后 | 约 30ms/页 | 前 20 页,宽 400px |
pdf_preview | Office 摄取后 | 0.7s 起,大 PPT 可 30s+ | LibreOffice 转 PDF,顺带补坐标 |
cleanup | 删库后、定时 | 变量 | 30 天到期清理、容器清理 |
import_chat | POST /kb/import/chat | 分钟级 | 聊天 jsonl 切片成文档 |
import_dir | POST /kb/{id}/import/dir | 分钟级 | 服务器目录里的图片批量入库 |
reindex | POST /admin/reindex | 分钟级 | 重建词表与两条文本腿 |
视觉任务必须独立,原因是成本:colSmol 单页 15 秒,塞进 ingest 会让一次上传的进度卡在 90% 很长时间,而 API 侧看不出"是在编码还是在解析"。
前三个 ingest / chunk / embed 合并成一个任务,是因为三步加起来不到 1 秒(解析约 40ms/页、编码 88ms/chunk),拆开只多三次事务,进度还要跨三个任务拼装。
2. 领取、租约、重试与结算
jobs.status:pending → running → done / failed / cancelled。
入队去重按 (doc_id, kind, payload)。视觉任务一页一个,payload 是 {"page": n},去重必须比 payload;只比 (doc_id, kind) 会让 3 页扫描件只入队第 1 页,另两页不会编码,而任务状态全是 done。
领取用 FOR UPDATE SKIP LOCKED:
SELECT * FROM jobs
WHERE status = 'pending' AND (locked_at IS NULL OR locked_at < now() - interval '15 min')
ORDER BY created_at LIMIT 1
FOR UPDATE SKIP LOCKED没有 pending 时再找 locked_at 超过 15 分钟的 running 接管。租约要大于单个任务的最长耗时;视觉任务实际会周期性心跳续租,15 分钟只是兜底。
心跳把 progress 和业务计数器写进 jobs,走独立事务,主任务还在跑时 /jobs 就能读到。payload 用 JSONB || 浅合并,不覆盖用户下发的参数。
进度的封顶在 worker 侧:handler 上报的值压到 0.999,1.0 只归 finish_job。否则两次提交之间会出现 running + progress=1.0 的采样,前端以为完成了。
结算:
| 情况 | 结果 |
|---|---|
| 成功 | done + progress=1.0 |
失败,可重试且 attempts < max_attempts(3) | 回 pending,清空锁 |
| 失败,重试耗尽 | failed |
失败,IngestError.retryable=False | 第一次就 failed,不占重试额度 |
| 文档删除或覆盖版本 | 未完成任务置 cancelled |
is_retryable 默认 True:把"其实永久"的错误多试两次,代价有界;把"其实瞬时"的错误当永久,用户要手动重试。所以只有明确声明 retryable=False 的异常才不重试。
任务到终态后,worker 调 settle_doc_state 给文档结算。它在 finish_job 的事务之外:改文档状态失败不能把已经做完的活回滚,否则 15 秒的 colSmol 前向白跑,任务还会被反复重试。
worker 空闲时轮询间隔从 1s 递增到 2s、5s,有任务立刻回到 1s。空闲分支还做两件旁路工作:
| 工作 | 首次延迟 | 间隔 | 做什么 |
|---|---|---|---|
| 索引对账 | 180s | 900s | 只报告,不修 |
| 非当前点清理 | 120s | 300s | 删已提交为非当前的文档的三腿点 |
忙的时候不查对账:那时漂移多半正在被正常摄取与清理修掉,报出来只是噪音。
3. 巡检:12 个函数与三态
uv run python deploy/monitor.py # 人看
uv run python deploy/monitor.py --json # cron / CI
uv run python deploy/monitor.py --quiet # 只在有问题时输出
uv run python deploy/monitor.py --only df index12 个检查函数,报告通常 14 项(Qdrant 函数加两条,另加备份)。退出码 0 正常 / 1 有问题。
| 检查 | 抓什么 |
|---|---|
| Postgres 可连 | 库挂了全站 500 |
| Qdrant 活着 / 可列集合 | 索引服务挂了检索降级、摄取全失败 |
| API 健康 | 进程活着但依赖坏了 |
| BM25 df 一致 | corpus_stats.n_docs 与 chunk 行数对不上,检索质量悄悄变差 |
| Embedding 模型一致 | 建索引模型与当前配置不一致,dense 腿静默错排序 |
| 索引对账(dense / sparse) | 分腿漂移 |
| 视觉腿对账 | kb_visual 的页级点与 scan_pages 对不上 |
| 没有卡住的文档 | 中间态却无待办任务 |
| 磁盘占用 | 写满会让摄取静默失败(上传仍 202) |
| 没有卡住的任务 | running 超过 30 分钟 |
| 失败任务积压(近 24h) | 有没有需要人看的失败 |
| 队列滞后 | 最老 pending 等了多久,阈值 30 分钟 |
| 最近有备份(≤3 天) | 恢复点太旧 |
巡检是三态,不是两态:通过 / 失败 / 跳过。跳过是"这项没法验",既不是通过也不是失败。最典型的是巡检自己没配好 API Key、VDR_ENABLED=false、索引模型还没有记录。
第一版只判失败与警告,于是两项鉴权检查被跳过时报告仍写"一切正常"、退出码 0。这一条现在写成硬判据:跳过不算健康。报告里跳过的标记是 ? 而不是 ✓,因为 ✓ 会让人以为查过了。
索引对账不走 API。巡检账号无法加入别人的工作区,走 /admin/consistency 只能看到自己的库。判据直接来自 common/consistency.py,与 API 端点、worker 的定期对账共用同一份。
4. 备份、恢复演练与重建对账
bash deploy/backup.sh # 做一份,写完立刻自检
bash deploy/backup.sh --keep 7 # 只留最近 7 份(默认 14)
bash deploy/backup.sh --list # 列出已有备份与自检结果
bash deploy/backup.sh --verify DIR # 只校验| 内容 | 备份 | 理由 |
|---|---|---|
| Postgres | ✅ pg_dump -Fc,拷出容器 | 唯一的权威数据源 |
data/blobs/ | ✅ tar,包内路径是 blobs/... | 内容寻址的原件 |
| Qdrant | ❌ | 索引可重建 |
Qdrant 不备份,但 manifest 里记三个 collection 的期望点数(qdrant_expect),重建后按它对账。manifest 还记逐表行数与每个产物的 sha256。
pg_dump 的输出要先落到容器里的文件再拷出来,不用管道重定向:管道中途断了会得到一个大小正常但截断的 dump,恢复时才炸。
恢复默认走演练:
bash deploy/restore.sh --scratch data/backups/<时间戳> # 临时库 → 对账 → 删掉
bash deploy/restore.sh --force --into pkh data/backups/<时间戳> # 真恢复,先停 API/worker演练会建临时库并 pg_restore,逐表行数对账,逐个校验 blob 的 sha256,然后删临时库。2026-10-11 在真数据上跑过一次:18 张表行数全对,126 个 blob 的 sha256 全对。
恢复后必须重建索引,再把重建点数与 manifest 的 qdrant_expect 对账。超出 5% 要查原因。
5. 测试分层

| 层 | 规模 | 需要什么 | 抓什么 |
|---|---|---|---|
| 后端全量 pytest | 1335 项,78 个 test_*.py,22936 行 | 真 Postgres + Qdrant + 本地模型 | 端到端、权限、状态机、索引对账 |
| CI 免基础设施单测 | 449 项,27 个文件 | 不需要 DB、模型、服务、特定 locale | 纯函数、契约解析、文档指向、结构守卫 |
| 三套 HTTP 验收 | 138 / 218 / 199(measured,2026-10-19 那轮) | API + worker,1c/1d 还要真实多页 PDF | 权限边界、上传到检索、SSE、MCP/A2A |
| 前端 vitest | 316 项,18 个文件(measured) | Node | 组件、纯函数、状态 |
| 运维巡检 | 12 个检查函数,通常 14 项 | 真服务 + PG + Qdrant | 队列、对账、磁盘、卡住文档、备份 |
全量里的集成用例用 pytest.mark.skipif 探测依赖。跳过只说明这个 checkout 没有依赖。CI 不跑这些用例,CI 通过不覆盖它们。
测试共用一个 session 级 event loop。common/db.py 的异步引擎是模块级单例,asyncpg 连接池绑定创建它的 loop;每个用例各调一次 asyncio.run() 会报 attached to a different loop。
清理测试账号走 eval/_reset.py::reset_users_and_bm25,不裸 DELETE FROM users。硬删会级联删掉 chunks,而 BM25 的 df 只在入库与重建里维护,删完 corpus_stats.n_docs 虚高。
6. CI 门禁与契约检查
bash deploy/ci.sh # 全部
bash deploy/ci.sh web # typecheck + vitest + build
bash deploy/ci.sh py # 契约覆盖率 + 27 个文件的免基础设施单测deploy/ci.sh 是 CI 与本地共用的同一条命令,.github/workflows/ci.yml 只负责装依赖再调它。加文件进 PURE_TESTS 前要验三个维度:
- 把数据库指向不存在的库,仍应全绿。
- 把服务地址指向没人听的端口、或确认该文件不发真 HTTP,仍应全绿。
LANG=C LC_ALL=C下前端测试仍应全绿(防localeCompare/toLocaleString只在本机绿)。
契约检查:
uv run python eval/contract_check.py --strict契约声明 82 个端点 · 实现 81 个 operation(其中 1 个已决议不做)
覆盖率 81/82(实现里有 0 个契约没写)三个数字都要看懂:声明 82、实现 81、明确不做 1。POST /api/v1/jobs/{id}/retry 是决议不做,不算缺口;如果它被实现了,检查会报"与决议相反地实现了"。
它只查路径、方法与 query 参数面。字段名、返回类型这类形状漂移查不出来,那些靠 pytest 与前端测试守。
7. 排障顺序
遇到"搜不到":
GET /api/v1/health看available_legs、queue、index_models。POST /kb/{id}/search/debug看每条腿的n/ms/skipped。- 某腿
skipped就按原因修依赖;全跑了但零命中,看 query 与库范围。 - 点数与文档数对不上,
GET /admin/consistency分腿看缺与多。 - 文档一直处理中,
GET /jobs?doc_id=看任务终态,巡检看"卡住的文档"。
遇到"看到了不该看的":
- 先确认
GET /kb是否列出了它——列表用visible_kb_clause。 - 再确认检索结果——Qdrant 过滤只按
kb_id,范围来自readable_kb_ids。 - 检查 payload 里的
visibility/workspace_id是否被谁拿去做过滤。 - 检查工作区软删的
EXISTS是否被写成独立or、是否correlate到外层行。
遇到"改了没生效":
- 后端代码改动是否
dev.sh restart,有没有残留 worker 抢任务。 - 切分策略与 PII 开关只对之后摄取的文档生效,旧文档要
reparse。 - 新增 payload 字段有重摄取欠账,旧点没有该字段。
- 别名模式下确认当前别名指向哪个物理集合。
dev.sh 只有 start / restart / stop,没有 status / logs。状态用 /health 与三个日志文件看:
tail -20 /tmp/pkh_api.log /tmp/pkh_worker.log /tmp/pkh_visual.log磁盘写满时上传仍返回 202,blob 写失败在 handler 里,用户看到的是"文档一直 pending"。巡检的磁盘项是少数能提前发现它的地方。
8. 相关文件
| 文件 | 说明 |
|---|---|
services/common/jobs.py | 八种 kind、入队去重、领取、心跳、结算 |
services/worker/main.py | 主循环、进度封顶、补扫、空闲对账 |
services/worker/doc_state.py | 文档终态判定 |
deploy/monitor.py | 12 个检查函数、三态报告、退出码 |
deploy/backup.sh | 备份、自检、保留策略 |
deploy/restore.sh | 临时库演练、真恢复 |
deploy/ci.sh | 27 个免基础设施测试文件、前后端门禁 |
eval/contract_check.py | 82 / 81 / 1 的对账 |
eval/_reset.py | 测试账号与 BM25 统计的安全清理 |
.github/workflows/ci.yml | 两个 job,调用 deploy/ci.sh |