ea6e56b304
发布通道原来只有三条:PostgreSQL 关系行、Elasticsearch 分片、OSS 对象。 WeKnora 的 wiki 图谱是 wiki_pages 一张纯 PG 表(in_links/out_links 存 slug), 所以它不在任何通道里——撤权后生产 wiki 会一直留着已撤权文档的页面与引用。 本次新增第 5 条通道:库级 wiki 快照,挂在现有文档发布包里,检索与 wiki 同一个 release 单元(同一次执行、同一次校验、同一次回滚): - 导出:ExportWiki 按 knowledge_base_id 全量读 wiki_pages/wiki_folders; 收敛门禁(库内无解析中/合成中文档)不满足直接失败并给中文原因; 未启用 wiki 且无 wiki 行的库不参与同步(生产 wiki 保持原样)。 - 撤权:item.Action == "delete" 时在导出端剥离该文档的 wiki 贡献 (source_refs/chunk_refs 去条目、无引用页面整页不带、摘死链、清 parent_slug、 无页面引用的空文件夹及其祖先一并去掉),使“源端仍在只停止发布”和 “源端删除”两种情况收敛到同一结果。 - 导入:库级 replace-by-kb(同一事务内 DELETE by kb 后灌入快照,文件夹先于页面), 新增 ReplaceWiki 供撤权路径使用(文档行只 disable,wiki 整体替换)。 - 校验:VerifyWiki 用生产 WeKnora wiki API 比对页面 slug 集合,撤权时额外断言 生产 wiki 不再引用被撤权文档。 驱动与验收:新增 cmd/release-channel-e2e(复用发布 worker 真实代码路径, 支持 publish/withdraw/rollback),scripts/wiki_e2e_test5.sh、 scripts/wiki_e2e_newkb.sh 两套端到端脚本;证据见 docs/WIKI_CO_TRANSPORT_ACCEPTANCE.md。
182 lines
6.5 KiB
Go
182 lines
6.5 KiB
Go
package v071
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"knowledge-governance/internal/releasebundle"
|
|
releaseworkflow "knowledge-governance/internal/workflows/release"
|
|
)
|
|
|
|
func TestDocumentTargetCoordinatesPrepareActivateVerifyAndRollback(t *testing.T) {
|
|
root := t.TempDir()
|
|
_ = os.Chmod(root, 0o700)
|
|
archive, _ := NewBundleArchive(root, 16*1024*1024)
|
|
current := testDocumentBundle(t)
|
|
prior := cloneBundleWithRevision(t, current, "prod-v0")
|
|
if err := archive.Save(prior); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls := []string{}
|
|
target, err := NewDocumentTarget(DocumentTargetConfig{Archive: archive, Contract: &stubContractInspector{contract: targetContractForBundle(current)}, Postgres: &stubPostgresTarget{calls: &calls}, Search: &stubSearchTarget{calls: &calls}, Objects: &stubObjectTarget{calls: &calls}, Verifier: &stubProductionVerifier{calls: &calls}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
item := releasePlanForArchive()
|
|
item.PreviousProductionRevision = "prod-v0"
|
|
change, err := target.Prepare(context.Background(), item, current)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if change.PreviousRevision != "prod-v0" || change.ProductionRevision != "review-v1" {
|
|
t.Fatalf("change=%#v", change)
|
|
}
|
|
if err := target.Activate(context.Background(), item, change); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := target.Verify(context.Background(), item, change); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := target.Compensate(context.Background(), item, change); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := target.Restore(context.Background(), item, "prod-v0"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := target.VerifyRestored(context.Background(), item, "prod-v0"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"object-stage:review-v1", "object-stage:review-v1", "object-promote:review-v1", "postgres-replace", "search-replace", "verify:review-v1", "verify-wiki:review-v1", "object-stage:prod-v0", "object-promote:prod-v0", "postgres-replace", "search-replace", "object-stage:prod-v0", "object-promote:prod-v0", "postgres-replace", "search-replace", "verify:prod-v0", "verify-wiki:prod-v0"}
|
|
if !reflect.DeepEqual(calls, want) {
|
|
t.Fatalf("calls=%v\nwant=%v", calls, want)
|
|
}
|
|
}
|
|
|
|
func TestDocumentTargetCompensatesFirstReleaseByDisableWithoutDelete(t *testing.T) {
|
|
root := t.TempDir()
|
|
_ = os.Chmod(root, 0o700)
|
|
archive, _ := NewBundleArchive(root, 16*1024*1024)
|
|
bundle := testDocumentBundle(t)
|
|
calls := []string{}
|
|
target, _ := NewDocumentTarget(DocumentTargetConfig{Archive: archive, Contract: &stubContractInspector{contract: targetContractForBundle(bundle)}, Postgres: &stubPostgresTarget{calls: &calls}, Search: &stubSearchTarget{calls: &calls}, Objects: &stubObjectTarget{calls: &calls}, Verifier: &stubProductionVerifier{calls: &calls}})
|
|
item := releasePlanForArchive()
|
|
change, err := target.Prepare(context.Background(), item, bundle)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if change.PreviousRevision != "absent" {
|
|
t.Fatalf("change=%#v", change)
|
|
}
|
|
if err := target.Compensate(context.Background(), item, change); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(calls, []string{"object-stage:review-v1", "postgres-disable", "search-disable", "verify-disabled"}) {
|
|
t.Fatalf("calls=%v", calls)
|
|
}
|
|
}
|
|
|
|
type stubContractInspector struct {
|
|
contract releasebundle.TargetContract
|
|
err error
|
|
}
|
|
|
|
func (s *stubContractInspector) Contract(context.Context, releaseworkflow.PlanItem) (releasebundle.TargetContract, error) {
|
|
return s.contract, s.err
|
|
}
|
|
|
|
type stubPostgresTarget struct {
|
|
calls *[]string
|
|
err error
|
|
}
|
|
|
|
func (s *stubPostgresTarget) Replace(_ context.Context, _ PostgresIdentity, _ PostgresSnapshot) error {
|
|
*s.calls = append(*s.calls, "postgres-replace")
|
|
return s.err
|
|
}
|
|
func (s *stubPostgresTarget) Disable(context.Context, PostgresIdentity) error {
|
|
*s.calls = append(*s.calls, "postgres-disable")
|
|
return s.err
|
|
}
|
|
func (s *stubPostgresTarget) ReplaceWiki(_ context.Context, _ PostgresIdentity, _ PostgresSnapshot) error {
|
|
*s.calls = append(*s.calls, "postgres-replace-wiki")
|
|
return s.err
|
|
}
|
|
|
|
type stubSearchTarget struct {
|
|
calls *[]string
|
|
err error
|
|
}
|
|
|
|
func (s *stubSearchTarget) Replace(_ context.Context, _ SearchIdentity, _ SearchSnapshot) (SearchSnapshot, error) {
|
|
*s.calls = append(*s.calls, "search-replace")
|
|
return SearchSnapshot{}, s.err
|
|
}
|
|
func (s *stubSearchTarget) Disable(context.Context, SearchIdentity) error {
|
|
*s.calls = append(*s.calls, "search-disable")
|
|
return s.err
|
|
}
|
|
|
|
type stubObjectTarget struct {
|
|
calls *[]string
|
|
err error
|
|
}
|
|
|
|
func (s *stubObjectTarget) Stage(_ context.Context, revision string, _ ObjectSnapshot) (ObjectStage, error) {
|
|
*s.calls = append(*s.calls, "object-stage:"+revision)
|
|
return ObjectStage{ReleaseRevision: revision}, s.err
|
|
}
|
|
func (s *stubObjectTarget) Promote(_ context.Context, stage ObjectStage) error {
|
|
*s.calls = append(*s.calls, "object-promote:"+stage.ReleaseRevision)
|
|
return s.err
|
|
}
|
|
|
|
type stubProductionVerifier struct {
|
|
calls *[]string
|
|
err error
|
|
}
|
|
|
|
func (s *stubProductionVerifier) Verify(_ context.Context, _ releaseworkflow.PlanItem, bundle *DocumentBundle) error {
|
|
*s.calls = append(*s.calls, "verify:"+bundle.Manifest.ReleaseRevision)
|
|
return s.err
|
|
}
|
|
func (s *stubProductionVerifier) VerifyDisabled(context.Context, releaseworkflow.PlanItem) error {
|
|
*s.calls = append(*s.calls, "verify-disabled")
|
|
return s.err
|
|
}
|
|
func (s *stubProductionVerifier) VerifyWiki(_ context.Context, _ releaseworkflow.PlanItem, bundle *DocumentBundle) error {
|
|
*s.calls = append(*s.calls, "verify-wiki:"+bundle.Manifest.ReleaseRevision)
|
|
return s.err
|
|
}
|
|
|
|
func cloneBundleWithRevision(t *testing.T, bundle *DocumentBundle, revision string) *DocumentBundle {
|
|
t.Helper()
|
|
clone := *bundle
|
|
clone.Manifest = bundle.Manifest
|
|
clone.Manifest.ReleaseRevision = revision
|
|
clone.Manifest.BundleSHA256 = ""
|
|
sealed, err := releasebundle.Seal(clone.Manifest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clone.Manifest = sealed
|
|
return &clone
|
|
}
|
|
func targetContractForBundle(bundle *DocumentBundle) releasebundle.TargetContract {
|
|
c := bundle.Manifest.Contract
|
|
return releasebundle.TargetContract{WeKnoraVersion: c.WeKnoraVersion, MigrationVersion: c.MigrationVersion, TenantID: c.TenantID, KnowledgeBaseID: c.KnowledgeBaseID, RetrieverEngine: c.RetrieverEngine, EmbeddingModel: c.EmbeddingModel, EmbeddingDimension: c.EmbeddingDimension, ElasticsearchMappingSHA256: c.ElasticsearchMappingSHA256}
|
|
}
|
|
func revisionFromPostgres(snapshot PostgresSnapshot) string {
|
|
if snapshot.Table("chunks").Rows[0][0] == '{' {
|
|
return "review-v1"
|
|
}
|
|
return "unknown"
|
|
}
|
|
func revisionFromSearch(snapshot SearchSnapshot) string {
|
|
if len(snapshot.Documents) > 0 {
|
|
return "review-v1"
|
|
}
|
|
return "unknown"
|
|
}
|