全新 WeKnora 会用空映射({})先建好索引,范围字段等第一次写入才动态生成。
首次同步在读目标当前状态时就报 "missing release scope fields" 并失败,等于
"往空的生产实例做第一次同步"这条路根本走不通。
现在:"没有范围字段 + 索引确实 0 篇文档" = 空基线;有文档却看不出归属仍然报错
(绝不把无法归属的文档当空库覆盖)。Count 与整库导出共用同一判断。
试过:make verify 15 项全绿;新增空/非空两种索引的用例;真实 sync 复现原错误。
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user