fix(v071): 全新目标实例的空索引按空基线处理
build / build (push) Successful in 1m15s

全新 WeKnora 会用空映射({})先建好索引,范围字段等第一次写入才动态生成。
首次同步在读目标当前状态时就报 "missing release scope fields" 并失败,等于
"往空的生产实例做第一次同步"这条路根本走不通。

现在:"没有范围字段 + 索引确实 0 篇文档" = 空基线;有文档却看不出归属仍然报错
(绝不把无法归属的文档当空库覆盖)。Count 与整库导出共用同一判断。

试过:make verify 15 项全绿;新增空/非空两种索引的用例;真实 sync 复现原错误。
This commit is contained in:
姚章浩
2026-10-29 06:17:26 +08:00
parent 84e8a0dfdc
commit 7d4ffdbfe5
5 changed files with 109 additions and 1 deletions
+5
View File
@@ -27,8 +27,13 @@
治理侧同步才补齐;只按 `term tenant_id` 过滤会把整库正文漏掉(真跑:索引有 2 条、
导出 0 条、候选校验报 legacy coverage)。改为"本租户 或 未写租户",写了别的租户仍排除。
- **空目标索引不能当成"索引被改坏"**:全新 WeKnora 实例先用空映射建索引(`{}`),
字段等第一次写入才动态生成;首次同步因此在读取目标状态时就失败。现在"没有范围字段
+ 确实 0 篇文档"按空基线处理,有文档却看不出归属仍然报错。
### Tests
- `internal/releaseadapter/v071/search_empty_index_test.go`:空索引=空基线、有文档无范围字段必须报错。
- `internal/httpapi/library_capture_test.go`:队列作业 id 必须放得进 `varchar(36)` 且形状固定。
- `internal/releaseadapter/v071/search_tenant_filter_test.go`:接受未写租户、排除他租户、
无租户字段时不造条件、整库过滤仍带知识库身份。
@@ -26,6 +26,22 @@ func (e *SearchExporter) ExportLibrary(ctx context.Context, identity SearchIdent
}
return SearchSnapshot{Documents: []SearchDocument{}, MappingSHA256: "", DocumentsSHA256: emptyDigest}, nil
}
if errors.Is(err, errScopeFieldsMissing) {
// 全新实例先用空映射建索引:没有范围字段、又确实 0 篇文档时就是空基线;
// 有文档却看不出归属必须报错(不能当成空库覆盖)。
empty, probeErr := e.emptyScopeIndex(ctx)
if probeErr != nil {
return SearchSnapshot{}, probeErr
}
if empty {
emptyDigest, digestErr := searchDocumentsDigest([]SearchDocument{})
if digestErr != nil {
return SearchSnapshot{}, digestErr
}
return SearchSnapshot{Documents: []SearchDocument{}, MappingSHA256: "", DocumentsSHA256: emptyDigest}, nil
}
return SearchSnapshot{}, err
}
if err != nil {
return SearchSnapshot{}, err
}
+18 -1
View File
@@ -67,6 +67,23 @@ type searchFieldNames struct {
// errIndexMissing 表示目标索引根本不存在(空生产基线)。
var errIndexMissing = errors.New("Elasticsearch index missing")
// errScopeFieldsMissing 表示索引存在但还没有发布范围字段:
// 全新的 WeKnora 实例会先用空映射建好索引(`{}`),字段要等第一次写入才动态生成。
// 这跟"索引被改坏了"必须分开:只有在索引里确实一篇文档都没有时才能当成空基线。
var errScopeFieldsMissing = errors.New("Elasticsearch index mapping is missing release scope fields")
// emptyScopeIndex 判断"没有范围字段"的索引是不是真的空。
// 有文档却没有范围字段 = 无法判断这批文档属于谁,必须报错,不能当成 0。
func (exporter *SearchExporter) emptyScopeIndex(ctx context.Context) (bool, error) {
var response struct {
Count int64 `json:"count"`
}
if err := exporter.requestJSON(ctx, http.MethodGet, "/"+exporter.index+"/_count", nil, &response); err != nil {
return false, err
}
return response.Count == 0, nil
}
type mappingInspection struct {
digest string
fields searchFieldNames
@@ -127,7 +144,7 @@ func (exporter *SearchExporter) inspectMapping(ctx context.Context) (mappingInsp
base: resolve("knowledge_base_id"),
}
if fields.knowledge == "" || fields.base == "" {
return mappingInspection{}, errors.New("Elasticsearch index mapping is missing release scope fields")
return mappingInspection{}, errScopeFieldsMissing
}
return mappingInspection{digest: hex.EncodeToString(digest[:]), fields: fields}, nil
}
@@ -0,0 +1,59 @@
package v071
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// 全新的 WeKnora 实例会先用空映射建索引(`{}`),字段等第一次写入才动态生成。
// 这种索引不能被当成"索引被改坏了":只有确实 0 篇文档时才是空基线;
// 有文档却没有范围字段 = 不知道这批文档属于谁,必须报错。
func TestEmptyScopeIndexCountsAsEmptyBaselineOnlyWhenTrulyEmpty(t *testing.T) {
for _, testCase := range []struct {
name string
docCount int
wantErr bool
}{
{"空索引当空基线", 0, false},
{"有文档但没有范围字段要报错", 7, true},
} {
t.Run(testCase.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/chunks/_mapping":
_, _ = w.Write([]byte(`{"chunks":{"mappings":{}}}`))
case "/chunks/_count":
_ = json.NewEncoder(w).Encode(map[string]any{"count": testCase.docCount})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
exporter, err := NewSearchExporter(SearchExporterConfig{BaseURL: server.URL, Index: "chunks", PageSize: 100})
if err != nil {
t.Fatalf("exporter: %v", err)
}
identity := SearchIdentity{TenantID: 10001, KnowledgeBaseID: "kb-1"}
count, countErr := exporter.Count(context.Background(), identity)
if testCase.wantErr {
if countErr == nil {
t.Fatalf("有文档却当成空基线:count=%d", count)
}
} else {
if countErr != nil || count != 0 {
t.Fatalf("空索引没有被当成空基线:count=%d err=%v", count, countErr)
}
}
_, exportErr := exporter.ExportLibrary(context.Background(), identity, 100)
if testCase.wantErr && exportErr == nil {
t.Fatal("导出把有文档的空映射索引当成空库")
}
if !testCase.wantErr && exportErr != nil {
t.Fatalf("空索引导出失败:%v", exportErr)
}
})
}
}
@@ -19,6 +19,17 @@ func (exporter *SearchExporter) Count(ctx context.Context, identity SearchIdenti
return 0, errors.New("invalid search identity")
}
inspection, err := exporter.inspectMapping(ctx)
if errors.Is(err, errScopeFieldsMissing) {
// 新建索引还没有范围字段:只有确实为空时才等于"这个范围 0 条"。
empty, probeErr := exporter.emptyScopeIndex(ctx)
if probeErr != nil {
return 0, probeErr
}
if empty {
return 0, nil
}
return 0, err
}
if err != nil {
return 0, err
}