Files
knowledge-governance/docs/library-capture.md
T
姚章浩 bc49b85b56
build / build (push) Successful in 1m12s
docs(acceptance): 封存闭环进"已完成",补死信最小外部契约
- 矩阵:新增 S06/S06-a/S06-b/V03-c/Neo4j 拒绝行(都有真跑证据);把"封存入口未接线"、
  "CLI 库级窗口"从缺口里移走;排空时序旧结论保留但标注被确定性做法取代。
- 死信解除仍缺产品路径,写出最小外部治理契约(保留原始行、审计、验证真实处理成功后
  按精确项解除、未解除继续阻断)。
- 台账补 0.1.169–0.1.180 的 SHA 与 run(含 211/215 failure、218 cancelled,不涂改)。
- library-capture / 8088 部署方案同步成本轮真实结果。
2026-10-29 06:34:05 +08:00

136 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.
# 整库封存(libraryctl)运维手册
本文只讲**测试库**的整库封存链:怎么跑、它证明了什么、它**没有**证明什么。生产发布与恢复、清理不在本文范围。
## 它做什么
`libraryctl capture -scope internal|external`:
1. 从**本机真实测试栈**(默认 Compose 项目 `weknora071full`)读来源配置:容器环境里的数据库/检索/对象存储参数,运行时读取,不落盘、不回显。
2. 用**只读会话**(`default_transaction_read_only=on`)连来源 PostgreSQL;连接串只在进程内存里。
3. 核对来源已结清:所有文档 `parse_status=completed`、无待处理操作、无死信(任一不满足就拒绝,不看其他 KB 的局部状态)。
4. 从持久数据推出兼容契约:迁移版本、dirty、租户、Embedding 模型与维度、检索引擎、索引名、索引映射摘要(`text-embedding-v4` / 1024 维 / schema 74)。
5. 在**维护窗口**内停住声明过的写入者(只对声明为 writer 的服务发 SIGTERM,等它真的停下),导出 PostgreSQL 十表 + Elasticsearch 正文与向量 + 对象资源,做双向覆盖与摘要核验,封存为内容寻址产物。
6. 无论成败都恢复写入者并等到 healthy;崩溃留下的日志用 `libraryctl recover` 收尾。
7. 打印快照 ID、分项(kind/name/数量/字节/SHA256)与兼容契约。
## 写入者声明(安全边界)
`scripts/library-capture.sh` 里逐项声明了项目内**每一个**服务:一个 writer(`app`),其余每个都写了"为什么它不会写知识"。`ProjectController` 的规则:
- 整个 Compose 项目被枚举(含停止的),只按声明停机;
- 项目里出现未声明的服务 → **拒绝执行**,不会"只停已知的那几个";
- 首次清点后固定容器 ID,容器被重建(同名新实例)→ 拒绝,避免停到一个没被声明的实例;
- 只发 SIGTERM,没有会在恢复后才触发的 SIGKILL 定时器;
- 启动后必须等到 healthy 才算恢复。
## 命令
```bash
# 接线与来源探针(只读,不停服务)
./scripts/library-capture.sh status
# 预检:读来源 + 结清检查 + 候选校验,不写盘、不停服务
./scripts/library-capture.sh preflight -scope internal
# 真实封存(停 app → 导出 → 恢复 app)
./scripts/library-capture.sh capture -scope internal
./scripts/library-capture.sh capture -scope external
# 已封存产物
./scripts/library-capture.sh list
./scripts/library-capture.sh verify -snapshot <id>
# 崩溃后的收尾(恢复写入者)
./scripts/library-capture.sh recover
```
产物默认写到 `~/.local/share/knowledge-governance/library`(0700),日志写到 `~/.local/share/knowledge-governance/freeze`(0700)。api 容器只以只读方式挂载前者;发布管理的「版本与快照」抽屉与系统设置 → 存储管理据此展示。
同一个作业号重跑是**幂等重放**:直接返回已封存的同一份候选(`replay=true`、`source_read=false`、`writers_stopped=false`),不读来源、不停写入者;同一作业号换了 scope 或绑定库则明确拒绝。
## 实例级维护接线(封存窗口的唯一入口证据)
`capture` 与 `recover` 现在都按**实例级维护**开窗口,接线来自与 worker 同一组环境变量
(名字只在 `internal/librarymaintenance/env.go` 出现一次):
```
LIBRARY_TARGET_INSTANCE 实例名(写进维护记录与目标指纹)
LIBRARY_TARGET_INGRESS_URL 入口地址(恢复后要能 200)
LIBRARY_TARGET_INGRESS_SERVICE Compose 里的入口闸门服务名
LIBRARY_TARGET_INGRESS_DRAIN_FILE 闸门写排空证据的宿主机文件
LIBRARY_TARGET_REDIS_ADDR/_PASSWORD/_DB 后台队列(asynq)地址
```
另外 `-source-s3-endpoint <URL>` 用来覆盖"源对象存储的访问入口":容器里写的
`MINIO_ENDPOINT` 往往是服务名(`langfuse-minio:9000`),封存跑在宿主机上时解析不到,
资源导出会失败(`read library resource`)。覆盖只换入口,桶与对象键不变;`status`
会如实打印实际使用的 endpoint。
为什么不再保留"库级窗口":被代理的 WeKnora 收到 SIGTERM 会直接 `exit(1)`,写者退出码
永远不能当作"在途请求已排空"。排空证据只能在真正的入口上取——关闸门、等它把在途连接
放完(`drain.json` 里的 `active_at_close` / `drained`)。因此缺少上面任何一项时 `capture`
会**在开窗口前拒绝**(报出缺少的变量名),而不是退回一个没有排空证据的窗口。
开窗口的顺序仍是:关入口并等在途排空 → 证明后台静默(稳定窗口 + 任务集合指纹)→ 停写入者
→ 停后复核队列/指纹与入口 → 导出 → 恢复写入者与入口。停写者后仍会**再查一次**源是否结清,
所以窗口内派生的真实任务必须真的跑完,否则封存被拒(不是等一会儿就算了)。
## 从页面发起封存(主机侧 agent)
页面点「发起对内/对外封存」只是**投递一个作业**;执行发生在这台主机上的 agent:
- `scripts/library-agent-install.sh` 构建并安装 `libraryagent`(systemd **user** 服务),
监听 `~/.local/share/knowledge-governance/agent/agent.sock`,目录 0700、socket 0600,
并额外用 `SO_PEERCRED` 要求对端与 agent 同 uid。
- 维护接线由 `~/.local/share/knowledge-governance/agent.env`(0600,`EnvironmentFile=`)提供;
没有这个文件安装脚本直接拒绝(`AGENT_ENV_MISSING`),免得装出一个"没有闸门"的 agent。
源容器/项目/写入者声明可用 `AGENT_SOURCE_APP`、`AGENT_SOURCE_PG`、`AGENT_SOURCE_ES`、
`AGENT_PROJECT`、`AGENT_WRITERS`、`AGENT_NON_WRITERS` 覆盖,默认是本机 `weknora071full`。
- agent 自己固定 `libraryctl` 的路径、归档与日志根、Compose 项目与写入者声明;
请求只能选 `scope`(internal/external)和作业号,**不能**指定路径、项目、容器或命令。
- 只有 worker 通过挂载的 socket 目录投递作业(`LIBRARY_AGENT_SOCKET`)。容器里
**没有** Docker socket,**没有**来源凭据:停写入者、读来源、恢复都在主机进程里完成。
- worker 启动时会先调一次 `recover`,把上次崩溃留下的停机窗口收尾;
失败会记日志而不是假装就绪。
- 同一 `scope + 作业号` 重复投递时,agent 走幂等重放,不会第二次停源。
安全边界要说清:socket 目录对 worker 可写,因此被攻破的 worker 可以让 agent 执行
"封存 internal/external"这一类动作(这正是页面能力),但拿不到 Docker API、
拿不到来源凭据,也不能重定向到别的项目或路径。
## 2026-10-28 实测(本机测试栈)
| 项 | 对内 | 对外 |
| --- | --- | --- |
| 快照 | `f992d64ea45bf737a0721a465b5aef2b150ef7757157eb84f3aaf8b6972d98f9` | `01343b0b310b3977c2fdda05f5b8a6272148c4f7bfa124a6c5944123cf0c51c3` |
| 文档 / 分块 / 索引 | 4 / 38 / 122 | 0 / 0 / 0 |
| 资源 / Wiki 页 | 21 / 31 | 0 / 1 |
| 分项数 / 逻辑字节 | 32 / 55,309,044 | 13 / 1,121 |
| 策略 | artifact-acceptance-v1 | artifact-acceptance-v1 |
两次封存都记录了 `stop_verified=true`、`restored=true`,`WKNFull-app` 恢复后 healthy;来源未做任何写操作。
## 2026-10-29 实测:页面入口 → agent → 实例级窗口(真实撤销后的库)
- 请求:`POST /api/v1/library/captures {scope:internal, job:lib-internal-20261029T0614}` → 202,
worker 投递 agent,CLI 以**实例级窗口**执行。
- 排空证据(确定性顺序,不抢时序):先发一半 multipart 正文 → 闸门日志 `conn:open active=1`
→ 再发起封存 → 入口拒绝新连接 → 补完正文,应用返回 200 并完成解析/嵌入 →
`drain.json`:`phase=drained active_at_close=1 served=126 drain_ms=302`。
- 冻结:`kgmaint-app` 收到 SIGTERM 后 `exit=1`(上游行为),因入口已排空,`drain_verified=true`;
后台静默 `quiesce.samples=18 stable_ms=1617`,停写者后指纹不变;随后恢复写入者与入口。
- 产物:快照 `1196c518…`(KB `e64e82b5…`,tenant 10001):1 文档 / 2 分块 / 9 Wiki 页 / 1 资源,
与源库逐页比对(正文 md5、来源、引用、目录、18 条边)全部一致。
- 同一份快照随后同步到**独立实例**(自己的 PG/索引/桶)并成功恢复,逐项读回一致。
## 它证明了什么 / 没有证明什么
已证明(A04 数据完整性口径):这一份整库内容在**写入者停住**之后被完整导出,PostgreSQL 与 Elasticsearch 双向覆盖、生成问题、资源与摘要逐项核验通过,产物可寻址、可复读、可校验。这正是封版所需的完整性口径。
用户已确认取消 A06:**不要求**内容、问答、引用/图片效果与性能测试及其报告,也不作为封版/生产发布门槛。因此这里不再把"没有做效果测试"列为缺口。
仍然不成立(不许把完整性通过说成这些):
- **跨 PG / ES / 资源的一致切点**只在维护窗口内取得;写入者组完整性有检查,但没有独立的切点证据,不能当成已验收的一致性证明;
- **Wiki 删除贡献的语义重算**没有做;Wiki 队列空不等于语义正确;
- 对外库当前是空的(0 文档 1 Wiki 页),这份快照只说明"它当时确实是空的";
- 生产独立预备份、恢复演练、双闸授权、整库同步协调与失败续作都没有接线,本文不涉及;
- 从页面发起封存、持久作业互斥、启动恢复还没有接线(封存目前只能在目标主机执行命令);
- 上游父子分块漏正文索引的风险仍在:本次通过是因为库里现有数据已补齐,**以后再重解析**仍可能漏项,届时封存会因覆盖核验失败而阻断(这是设计如此)。