Files
knowledge-governance/docs/P6_RELEASE_WORKER_ACCEPTANCE.md
T
姚章浩 4455d02262 test(backup): 留档回填演练脚本 + 实测通过
scripts/restore_drill.py:把留档 PG 行按原文 \copy FROM 回填进目标库,核对每张表行数与清单、
抽样页面正文/标题逐字段比对,再用临时跟踪表 _drill_restored 精确删除并断言残留为 0。

实测(本地测试库 weknora_full,留档 20261009T041913Z-0b217e8a):13,539 行全部回填成功、
8 张表行数与清单逐一致、冲突 0、抽样页正文一致(388 字符)、残留 0。

同时记录一个格式事实:留档 JSONL 是 psql \copy text 格式(转义),按行 json.loads 会失败
(knowledges 3830/3830、wiki_pages 218/1982),回填/程序化读取须先按 text 格式反转义。
2026-10-09 13:07:41 +08:00

20 KiB
Raw Blame History

P6 生产发布 Worker 与回滚验收

结论

P6 的发布控制面、v0.7.1 三通道 Sidecar、Release Worker、归档、补偿和回滚代码已经完成,并通过本地真实依赖 E2E。

生产发布仍未授权、未配置、未启动、未执行:

PRODUCTION_RELEASE_REQUESTS_ENABLED=false
RELEASE_WORKER_ENABLED=false
persistent release_executions=0
persistent release_item_executions=0
production release jobs=0

因此本文的“通过”表示实现和隔离验收通过,不表示生产已发布。

范围

P5 sealed batch
→ 受保护的生产授权请求
→ ReleaseExecution + release.publish Job 原子入账
→ Release Worker 按 KB 路由
→ 从测试 WeKnora 导出经过验收的同一 Knowledge
→ PostgreSQL / Elasticsearch / 对象存储三通道发布
→ 生产 WeKnora 官方 API 验收
→ 成功记录 production revision

回滚:

已完成的 publish execution
→ 受保护的 rollback 授权
→ release.rollback Job
→ 载入旧 0600 release bundle
→ 三通道恢复
→ 官方 API 验收

首次发布没有旧 revision 时,失败补偿和回滚采用禁用而不是删除:

PostgreSQL Knowledge enable_status=disabled
PostgreSQL Chunks is_enabled=false
Elasticsearch 文档保留但 is_enabled=false
对象保留
官方 knowledge-scoped Search 必须零命中

人工授权与双门禁

发布请求门禁:

PRODUCTION_RELEASE_REQUESTS_ENABLED=false

执行门禁:

RELEASE_WORKER_ENABLED=false

两个门禁默认都关闭且职责不同:

  • 请求门禁关闭时,授权 HTTP API 返回 403,Store 不会被调用。
  • 执行门禁关闭时,Release Worker 不加载 release config、不读取生产 secret、不连接生产数据面。
  • P5 的 sealed 仍不创建 Job。
  • 只有受保护的生产授权请求才会原子创建 ReleaseExecution 和 Job。

授权 HTTP API 不接受调用方提交的审核人身份。审核人来自服务端固定配置:

PRODUCTION_RELEASE_AUTHORIZER_REF

门禁开启时还必须提供普通、非软链接、权限 0600 的 Bearer token 文件:

PRODUCTION_RELEASE_AUTH_TOKEN_FILE

缺少固定 actor 或 token 文件时 API 拒绝启动。两条授权通道(2026-10-08 起):

  • 运维通道:Authorization: Bearer <token>,审计记 PRODUCTION_RELEASE_AUTHORIZER_REF;
  • 页面通道:管理员会话 + 请求体里的固定确认语句(发布管理「授权发布 / 重排 / 回滚」按钮),审计记该管理员用户名。

UI 仍然不录入、不读取、不显示 token,也没有"一键生产发布":每次动作都要在页面逐字输入确认语句。请求门禁、备份恢复门禁与执行门禁三者不受通道选择影响——放宽"谁能调"不等于放宽"能不能跑"。

固定确认语句:

publish-to-production
rollback-production-release
retry-production-release

API

POST /api/v1/release-batches/{batch_id}/authorize-production
POST /api/v1/release-executions/{execution_id}/authorize-rollback
POST /api/v1/release-executions/{execution_id}/retry
GET  /api/v1/release-capabilities
GET  /api/v1/release-executions
GET  /api/v1/release-executions/{execution_id}

授权响应和只读审计响应不返回:

  • authorization token
  • authorization_code
  • bundle SHA
  • WeKnora API key
  • Elasticsearch / PostgreSQL / 对象存储凭据或 URL
  • 测试 KB 内部连接信息

审计响应显示:

  • publish / rollback
  • pending / running / completed / failed
  • 固定授权人
  • 总项数、成功数、失败数
  • 每项固定错误码
  • production revision / previous production revision
  • 开始和完成时间

Migration 7

新增:

release_executions
release_item_executions

约束:

  • operation 只允许 publish / rollback。
  • target_environment 固定为 production。
  • state 使用固定枚举。
  • publish execution 每个 sealed batch 唯一。
  • rollback execution 每个 prior publish execution 唯一。
  • Job 外键、逐项 Publication 外键和计数约束保留审计关联。
  • Migration 7 不回填历史 release execution,也不自动创建 Job。

往返验收:

up 7 → down 6 → up 7

Migration 7 应用前备份:

tmp/backups/governance-pre-migration7-20260906T133742+0800.dump
bytes=263001
mode=0600
pg_restore catalog=valid

Release Worker 状态机

批次 Job 在 Worker 中按每批最多50项展开,并持久记录当前批次/总批次;批内逐项执行:

pending execution
→ running
→ 为 batch items 幂等建立 release_item_executions
→ 每个 item 执行 publish 或 rollback
→ verified / rolled_back / failed
→ execution completed 或 failed

行为:

  • 一项永久失败会立即停止本项之后的同批项目与全部后续批次;未执行项记录固定release_stopped_after_failure,随后仅对已验证成功项安排自动回滚。
  • 可重试故障在重试次数耗尽前保留重试语义,不把尚未终态的项目误标为永久失败。
  • 可重试失败在 Job 还有 attempt 时返回固定 release_retryable。
  • 最后一次 attempt 仍失败时,逐项写固定错误码并收口,不留下永远 running 的 Execution。
  • 外部响应正文、对象 key、路径、凭据和错误正文不写入治理库。
  • Worker 重启后跳过已经 verified / rolled_back 的项。
  • 终态failed execution可由同一Bearer门禁和固定retry-production-release确认重排;只重置failed items,verified / rolled_back项保持完成,Job从completed/failed回pending并从attempt 0重新开始。
  • publish重排前重新验证P5 sealed snapshot;过期审核或Publication变化时返回冲突。
  • 未知 KB 不会落入默认适配器,而是 release_contract_mismatch。

v0.7.1 release bundle 契约

固定:

format             weknora-release-bundle/v1
WeKnora            v0.7.1
migration          74
retriever          elasticsearch_v8
embedding dimension 1024

每个 bundle 绑定:

  • ReleaseExecution ID
  • Publication ID
  • SourceItemVersion ID
  • source revision
  • parse revision(parser generation + index generation)
  • release revision(封批时锁定的 review ID)
  • tenant / KB / Knowledge ID
  • embedding model identity
  • Elasticsearch mapping SHA-256
  • 各 PostgreSQL 表行数和 canonical SHA-256
  • ES 文档数和 canonical SHA-256
  • 资产数量、总字节和 canonical SHA-256
  • 整包 SHA-256

任何 revision、模型、维度、KB、tenant、mapping 或 bundle digest 不一致都在目标写入前 fail-closed。

PostgreSQL 通道

只允许以下 v0.7.1 内容表:

knowledges
chunks
knowledge_tags
knowledge_tag_relations
resources
resource_bindings

没有读取或复制:

  • users / sessions / messages
  • tenants / memberships / permissions
  • API keys / model provider credentials
  • vector store connection credentials
  • Jobs / audit logs / chat history

导出范围同时绑定:

tenant_id + knowledge_base_id + knowledge_id

目标导入:

  • 必须是 migration 74 且 dirty=false。
  • 使用 per-Knowledge PostgreSQL advisory transaction lock。
  • 单 Knowledge 单事务替换。
  • ID 在其他 tenant / KB 已存在时拒绝。
  • Tag 定义和 relation 一起复制;共享 Tag 不删除。
  • Resource stable handle 保留。
  • production storage_backend_id、provider、storage URI 和 location_hash 按目标配置重写。
  • Chunk seq_id 冲突时整个事务失败,不静默改 ID。
  • 不调用 WeKnora 解析和 Embedding。

wiki 通道的归属门禁(2026-10-08 起)

WeKnora 的 wiki 图谱与页面同表(wiki_pages,节点/边靠 slug + in_links/out_links 表达), 所以"库级快照整库替换"在共享真实知识库上会把其他文档的页面和 agent 手写页一起抹掉。 导入端现在先判定归属,再决定写入范围:

  • 归属来自页面 source_refs(形如 <knowledge_id>|<doc_title>;chunk_refs 只有 chunk UUID, 不带知识 id)。一页引用的知识全部属于本包才算 InScope;引用包外知识的是外来页;完全没有 引用的页(index/log、agent 手写页)永不触碰。
  • 目标库没有外来页 → 镜像库对原件的前提成立,保持整库替换语义(行为与旧实现一致,仍不删 "无引用且不在包内"的页)。
  • 目标库存在外来页 → 自动降级为"只替换包内页集":按 slug 删除重建包内页、只删包内 folder id、 外来页上仅摘掉本包知识的引用(正文不动)、包内库级页(index/log)不写以免毁掉目标库自己的聚合。 作用域以一行日志落盘:release wiki replace scope in_scope=… foreign=… foreign_ref_stripped=… untouched=… skipped_library=… degraded=…。
  • 包内会写入的页与外来页同 slug → 拒绝发布(fail-closed),事务不落任何改动,错误信息点名该 slug。
  • 撤权(delete)与回滚走同一套判定(ReplaceWiki),不再整库清空。
  • 已知取舍:降级路径上共享页的正文仍由目标库的库级合成结果决定,无法在不重算(不额外烧 token)的 前提下与本包一致;这类页只做引用级差集,并在作用域日志里计入 foreign_ref_stripped。

发布前备份(2026-10-08 起)

发布链本身不做发布前备份:DocumentTarget.Prepare 只归档本包、解析 previousRevision (首次发布为 absent,此时回滚只能停用、无法恢复),PRODUCTION_BACKUP_RESTORE_VERIFIED 只是"备份/恢复能力已验证"的人工开关。目标库现场必须显式留档:

bash scripts/target_kb_snapshot.sh                    # 用 production_kb_bindings 里绑定的目标库
bash scripts/target_kb_snapshot.sh <kb_id> [<kb_id>]  # 指定目标库
INCLUDE_CHUNKS=0 bash scripts/target_kb_snapshot.sh   # 不导 chunks(快照很小,适合每次发布前)

输出 $KB_BACKUP_ROOT/target-kb-snapshots/<UTC>-<kb前8位>/(契约目录见 docs/PRODUCTION_BACKUP_RECOVERY.md;云内执行时先在云主机产出,再把小表与 ES 导出拉回本地):

  • KB 维度行级 JSONL(to_jsonb(row),含软删行,8 张表);
  • es_documents.jsonl.gz + es_documents.jsonl.gz.manifest.json:该库在 ES 里的全部文档 (_id + _source,只读 scroll 导出,按 _id 可重建);
  • MANIFEST.json(行数/字节/sha256/覆盖范围/每库 ES 文档数)+ export.sql(原始查询)+ README.md(恢复步骤)。

全过程只有 SELECT / _count / _search,不修改目标库与索引;backups/ 已在 .gitignore。

覆盖范围与边界:

  • 覆盖:knowledge_bases、knowledges、chunks(可选)、knowledge_tags、 knowledge_tag_relations、resources、resource_bindings、wiki_pages、wiki_folders(PG 行), 以及 Elasticsearch 里该库的文档(含向量字段,按 _id 可重建索引)。
  • 不覆盖:OSS 对象本体(resources.jsonl 只给路径/大小/哈希清单)、ES 索引的 settings/mappings、 列出的知识库之外的行、表结构。整库级恢复用阿里云 RDS 快照/按时间点恢复(PITR), 自管整库逻辑备份用 scripts/logical-backup.sh。

约定:对共享真实知识库发布、撤权、回滚前必须先跑一次快照,并把快照目录与 MANIFEST.json 的 sha256 记进发布单;previous_production_revision=absent 时必须写明 "无前版可回退,只有这份快照"。

硬门禁(写目标库之前)

"必须有快照"已从人工约定升级为发布前置硬门禁(internal/releasesnapshot):

  • Worker 配置新增:

    "target_snapshot": {
      "root": "/var/lib/knowledge-governance/target-kb-snapshots",
      "max_age_hours": 24,
      "policy": "mapped"
    }
    

    root 由 compose.release-sim.yaml 以只读方式挂载快照目录;policy=mapped(默认) 只对源库 → 目标库映射的项强制(同 ID 目标的本地演练不受影响),always 则一律强制, 且此时 root 缺失直接判定配置非法。

  • Publish / Delete / Rollback 在任何写目标库的动作之前检查:存在覆盖目标库、 未过期(max_age_hours,默认 24h)、含 wiki_pages.jsonl 及其 sha256、**且含该库的 ES 文档留档 (es_documents.jsonl.gz + 计数 > 0)**的快照;本地文件会再按哈希逐份校验 ("可恢复"的最低证明)。只留 PG 行、或 ES 计数为 0 的留档一律拒绝。任一条不满足—— 包括门禁本身未接线——都以 release_target_snapshot_missing(不可重试)拒绝发布, 且不触碰目标库一行。页面把该码显示为"缺少目标知识库的发布前快照留档,已拒绝发布"。

  • 回填演练:python3 scripts/restore_drill.py --snapshot-dir <留档目录> --target-url <本地测试库> 实测把留档 13,539 行按原文回填进测试库(8 张表行数与清单逐一致、冲突 0、抽样页面正文与标题 逐字段相同),核对后精确删除、残留 0。注意留档 JSONL 是 psql text 格式(\copy 转义), 回填要用对称的 \copy FROM,按行 json.loads 会失败。

  • 云内执行:bash scripts/target_kb_snapshot.sh 默认在与 DB 同 VPC 的云主机上跑 (凭据取自已部署的 WeKnora 容器,不经家用带宽),PG 8 张表 44 秒;ES 导出走云主机直连 ES 容器(只读 scroll),两个库共 131,122 条文档、gzip 后 692 MB;全流程实测 2 分 52 秒(ES 字节留云主机),拉回本机则 19 分 40 秒(家用上行 ~0.6 MB/s)—— 故 PULL_ES 默认 0,清单以 location=cloud:<主机>:<路径> 记字节位置。 隧道模式保留为 EXECUTION=tunnel 兜底。

Elasticsearch 通道

源导出:

  • tenant / KB / Knowledge 三重 term filter。
  • 原样保存 _id、chunk文本、向量和其他 _source 字段。
  • mapping 只计算 mappings canonical SHA,不把索引名混入指纹。
  • 导出后按 _id canonical排序。
  • 单 Knowledge 超过 10000 个 ES 文档时 fail-closed,不截断。

目标替换:

  1. 先 bulk upsert 新文档,refresh=true。
  2. 再只删除同 tenant / KB / Knowledge 下、不属于新 release 的旧 _id。
  3. 读回数量和 canonical SHA。
  4. 失败时自动恢复 prior SearchSnapshot。

检索禁用使用 knowledge-scoped _update_by_query 将 is_enabled=false,不删除 ES 文档,并读回确认文档仍在且全部禁用。

对象存储通道

支持 S3-compatible MinIO / S3 / OSS:

  • secret 仅从 0600 文件读取。
  • 不在 URL 中携带 token。
  • 支持 v0.7.1 storage://<backend-id>/<provider-path>。
  • 拒绝 traversal、query、fragment、控制字符和越界 locator。
  • 导出时核对 resource size / content SHA。
  • staging:.governance-release-stage/<release_revision>/<relative-key>。
  • staging 写入后做完整流式 SHA-256。
  • Copy 到 final 相同 relative key 后再次完整流式 SHA-256。
  • ObjectStore 接口没有 Delete 方法。
  • 回滚重传旧 bundle 中的资产;不删除新资产。

Release bundle 归档

每个 KB 使用独立目录:

<archive_root>/<knowledge_base_id>/

要求:

  • archive root 和 KB 子目录是真实 0700 目录。
  • 不允许目录或文件软链接。
  • bundle 文件为普通 0600 文件。
  • 原子写、文件 fsync、目录 fsync。
  • 相同 release revision + 相同 SHA 可幂等重写。
  • 相同 revision + 不同 SHA 为碰撞并拒绝。
  • 加载时重验 manifest、PG、ES、资产和整包 SHA。
  • 默认每 bundle 最大 1 GiB。

Bundle payload 可能包含对象 locator 和资产内容,因此不能进入 Web/API/普通日志;只有摘要进入治理库。

Release Worker 配置

模板:

config/release.v071.example.json

Runtime 配置文件必须:

  • 使用绝对路径;
  • 是普通文件;
  • 不是软链接;
  • 权限为 0600;
  • JSON 不允许未知字段或尾随数据;
  • 每个 KB 只允许一个 route;
  • source 与 target 的数据库值、ES endpoint、对象 endpoint+bucket 必须不同;
  • 所有数据库URL、ES token、对象AK/SK、WeKnora API key 只通过各自 0600 文件引用。

模板不能直接启用生产。启用前必须根据真实数据面生成独立受限配置和 Compose override,并由人工授权。

本地真实 E2E

精确 schema

使用:

ParadeDB PostgreSQL 17
WeKnora v0.7.1 migrations 000000..000074
source DB + target DB

结果:

source migration=74
目标 migration=74
合成 source:
  knowledges=1
  chunks=1
  knowledge_tags=1
  knowledge_tag_relations=1
  resources=2
  resource_bindings=1

通过:

TestPostgresExporterV071Integration
TestPostgresImporterV071ReplacesAndRestoresDocument

覆盖:

old → new → restore old
disable keeps 1 Knowledge + 1 Chunk
Knowledge=disabled
chunk enabled count=0

对象存储

使用本机已有的 disposable MinIO,无卷、loopback随机端口、合成凭据和合成bucket。

通过:

TestS3ObjectStoreIntegration

覆盖:

Open storage:// source
Put staging
streaming SHA Stat
Copy final
streaming SHA Stat

三通道发布与回滚

通过:

TestThreeLaneReleaseAndRollbackV071Integration

链路:

publish review-release-1
→ publish review-release-2
→ rollback review-release-1
→ target PG chunks=1
→ target ES has es-release
→ target two assets non-empty

说明:

  • PostgreSQL 和 MinIO 为真实 disposable 服务。
  • Elasticsearch 为状态化 HTTP contract server,真实执行 mapping/search/bulk/delete-by-query/update-by-query 协议和 digest 回读。
  • WeKnora verifier 使用现有官方 client 接口 contract stub,真实执行 GetKnowledge/ListChunks/SearchKnowledge 的判断逻辑;没有连接生产 WeKnora。
  • 所有 disposable 容器最终均已停止并由 --rm 删除。

Worker装配

通过:

TestBuildReleaseRuntimeWithRestrictedFilesIntegration

验证:

两个真实 v0.7.1 DB连接
source/target secret文件0600
独立ES endpoint
独立对象bucket
独立官方WeKnora endpoint
KB archive 0700
RoutingPublisher构建
关闭时两个DB pool均释放

Web验收

Firefox Selenium只读验收:

review_candidates=5
release_batches=0
release_executions=0
production_request_gate=false
unauthorized_release_controls=0

截图:

tmp/P6_RELEASE_AUDIT_FIREFOX.png

可见:

  • 左侧发布管理导航和顶部标签。
  • P5审核候选与封批记录。
  • P6生产执行审计。
  • 生产请求门禁关闭。
  • Release Worker保持关闭。
  • 当前无生产执行记录。
  • 页面没有生产授权按钮。

当前持久治理库

schema migrations=1,2,3,4,5,6,7
publication_reviews=0
release_batches=0
release_batch_items=0
release_executions=0
release_item_executions=0
release publish/rollback jobs=0

现有 5 条候选仍为知识工程师业务待审,未由测试代替人工批准。

生产前仍需人工完成

以下不属于本地自动验收,也未执行:

  1. 明确批准生产发布窗口。
  2. 对测试和生产各选一份同ID真实Knowledge,做只读PG/ES/OSS依赖盘点。
  3. 确认两端均是WeKnora v0.7.1 migration 74、1024维、同模型和同mapping。
  4. 为source创建只读数据面凭据。
  5. 为target创建仅允许约定KB内容表、ES索引和对象bucket的最小写权限凭据。
  6. 创建0600 release config和secret文件,并让Release Worker独占挂载。
  7. 先做1份无敏感canary。
  8. 官方API读取、Chunks、检索、资产和审计全部通过后再扩批。
  9. 保留Release Worker关闭状态,直到上述材料由人工核对。

不得因为本文完成而自动执行生产发布、创建生产Job、连接生产数据面、删除任何WeKnora Knowledge或对象。