WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,50 @@
package opensearch
import "context"
// AuditSink receives audit events emitted from within the driver at the
// exact moment they occur (index provisioned, reindex executed). The driver
// owns this abstraction so it imports no service package — the dependency
// arrow stays one-way (service implements AuditSink; the driver only invokes
// it). A nil sink is a no-op, so tests and the env-path (no audit service)
// need no special casing.
type AuditSink interface {
// EmitIndexCreated fires once when the driver provisions a new k-NN
// index. alias is the per-dimension alias (or the keyword-only index
// name); dim is the embedding dimension (0 for the keyword-only index).
EmitIndexCreated(ctx context.Context, alias string, dim int)
// EmitReindexExecuted fires when CopyIndices finishes copying docs from
// one index to another.
EmitReindexExecuted(ctx context.Context, srcAlias, dstAlias string, docs int64)
}
// nopSink is the null-object used when no sink is configured, so emit call
// sites never need a nil check.
type nopSink struct{}
func (nopSink) EmitIndexCreated(context.Context, string, int) {}
func (nopSink) EmitReindexExecuted(context.Context, string, string, int64) {}
var _ AuditSink = nopSink{}
// Option configures a Repository at construction time.
type Option func(*Repository)
// WithAuditSink injects an audit sink. A nil sink is ignored (the Repository
// keeps its default no-op behavior).
func WithAuditSink(s AuditSink) Option {
return func(r *Repository) {
if s != nil {
r.sink = s
}
}
}
// auditSink returns the configured sink, or a no-op if none was set (e.g. a
// Repository built directly in tests, or constructed without WithAuditSink).
func (r *Repository) auditSink() AuditSink {
if r.sink == nil {
return nopSink{}
}
return r.sink
}
@@ -0,0 +1,153 @@
package opensearch
import (
"context"
"strings"
"sync"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
// spySink records audit events for assertions.
type spySink struct {
mu sync.Mutex
indexCreated []indexCreatedEvent
reindex []reindexEvent
}
type indexCreatedEvent struct {
alias string
dim int
}
type reindexEvent struct {
src, dst string
docs int64
}
func (s *spySink) EmitIndexCreated(_ context.Context, alias string, dim int) {
s.mu.Lock()
defer s.mu.Unlock()
s.indexCreated = append(s.indexCreated, indexCreatedEvent{alias, dim})
}
func (s *spySink) EmitReindexExecuted(_ context.Context, src, dst string, docs int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.reindex = append(s.reindex, reindexEvent{src, dst, docs})
}
// TestBuildInternalCfg_ReadsHNSWFields verifies the HNSW IndexConfig fields
// flow into internalCfg, falling back to defaults when unset.
func TestBuildInternalCfg_ReadsHNSWFields(t *testing.T) {
t.Run("reads set fields", func(t *testing.T) {
cfg, err := buildInternalCfg(&types.IndexConfig{
HNSWM: 24,
HNSWEFConstruction: 200,
HNSWEFSearch: 128,
KNNEngine: "faiss",
NumberOfShards: 2,
})
if err != nil {
t.Fatal(err)
}
if cfg.hnswM != 24 || cfg.hnswEFConstruction != 200 || cfg.efSearch != 128 || cfg.knnEngine != "faiss" {
t.Errorf("HNSW not wired: %+v", cfg)
}
if cfg.shards != 2 {
t.Errorf("shards: want 2, got %d", cfg.shards)
}
})
t.Run("defaults when unset", func(t *testing.T) {
cfg, err := buildInternalCfg(&types.IndexConfig{})
if err != nil {
t.Fatal(err)
}
if cfg.hnswM != 16 || cfg.hnswEFConstruction != 100 || cfg.efSearch != 100 || cfg.knnEngine != "lucene" {
t.Errorf("defaults not applied: %+v", cfg)
}
})
t.Run("nil config is all defaults", func(t *testing.T) {
cfg, err := buildInternalCfg(nil)
if err != nil {
t.Fatal(err)
}
if cfg.knnEngine != "lucene" || cfg.hnswM != 16 {
t.Errorf("nil defaults wrong: %+v", cfg)
}
})
}
// TestBuildIndexMapping_ReflectsHNSWConfig verifies the end-to-end path
// IndexConfig → buildInternalCfg → buildIndexMapping carries the operator's
// HNSW values into the cluster mapping JSON (regression guard for the wire-
// through, since defaults coincide with common values).
func TestBuildIndexMapping_ReflectsHNSWConfig(t *testing.T) {
cfg, err := buildInternalCfg(&types.IndexConfig{
HNSWM: 24,
HNSWEFConstruction: 200,
HNSWEFSearch: 128,
KNNEngine: "faiss",
})
if err != nil {
t.Fatal(err)
}
body, err := buildIndexMapping(cfg, 768)
if err != nil {
t.Fatal(err)
}
s := string(body)
for _, want := range []string{`"m":24`, `"ef_construction":200`, `"engine":"faiss"`, `"knn.algo_param.ef_search":128`} {
if !strings.Contains(s, want) {
t.Errorf("mapping JSON missing %q\n%s", want, s)
}
}
}
// TestWithAuditSink_SetsAndNilSafe verifies the functional option.
func TestWithAuditSink_SetsAndNilSafe(t *testing.T) {
spy := &spySink{}
r := &Repository{}
WithAuditSink(spy)(r)
if r.sink != spy {
t.Fatal("WithAuditSink did not set the sink")
}
// nil must not clobber an already-set sink
WithAuditSink(nil)(r)
if r.sink != spy {
t.Fatal("WithAuditSink(nil) clobbered the sink")
}
}
// TestAuditSink_NopByDefault verifies a Repository with no sink does not panic
// when the audit accessor is used (nopSink fallback).
func TestAuditSink_NopByDefault(t *testing.T) {
var _ AuditSink = nopSink{} // compile-time assertion
r := &Repository{} // sink left nil
// Must not panic.
r.auditSink().EmitIndexCreated(context.Background(), "weknora_768", 768)
r.auditSink().EmitReindexExecuted(context.Background(), "a", "b", 3)
}
// TestAuditSink_EmitIndexCreated_OnEnsureReady verifies createIndexAndAlias
// emits exactly one index-created event with the per-dim alias when a new
// index is provisioned.
func TestAuditSink_EmitIndexCreated_OnEnsureReady(t *testing.T) {
repo, ts := newTestRepo(t, (&indexLifecycleHandler{}).ServeHTTP)
defer ts.Close()
spy := &spySink{}
repo.sink = spy
if err := repo.ensureReady(context.Background(), 768); err != nil {
t.Fatalf("ensureReady: %v", err)
}
if len(spy.indexCreated) != 1 {
t.Fatalf("want 1 index_created event, got %d", len(spy.indexCreated))
}
got := spy.indexCreated[0]
if got.alias != "weknora_test_768" || got.dim != 768 {
t.Errorf("event mismatch: %+v", got)
}
}
@@ -0,0 +1,107 @@
package opensearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"sort"
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
)
// BatchUpdateChunkEnabledStatus flips is_enabled for the given chunks. The
// status map is grouped by value into one _update_by_query per distinct value
// (mirrors the Qdrant grouping pattern), so the request body carries the
// chunk ids via a terms filter and the new value via bound script params —
// never per-chunk string interpolation.
//
// Targets the cross-dim <base>_* pattern: a chunk's embedding dimension is
// not known here, and the same chunk_id is unique across the store's dim
// indices + the keyword-only index.
func (r *Repository) BatchUpdateChunkEnabledStatus(ctx context.Context, chunkStatusMap map[string]bool) error {
if len(chunkStatusMap) == 0 {
return nil
}
groups := map[bool][]string{}
for id, v := range chunkStatusMap {
groups[v] = append(groups[v], id)
}
// Deterministic order (false then true) for predictable behavior/tests.
for _, v := range []bool{false, true} {
ids := groups[v]
if len(ids) == 0 {
continue
}
sort.Strings(ids)
if err := r.updateByQueryScript(ctx, ids,
"ctx._source.is_enabled = params.v", map[string]any{"v": v}); err != nil {
return err
}
}
return nil
}
// BatchUpdateChunkTagID sets tag_id for the given chunks, grouped by tag.
func (r *Repository) BatchUpdateChunkTagID(ctx context.Context, chunkTagMap map[string]string) error {
if len(chunkTagMap) == 0 {
return nil
}
groups := map[string][]string{}
for id, tag := range chunkTagMap {
groups[tag] = append(groups[tag], id)
}
tags := make([]string, 0, len(groups))
for tag := range groups {
tags = append(tags, tag)
}
sort.Strings(tags)
for _, tag := range tags {
ids := groups[tag]
sort.Strings(ids)
if err := r.updateByQueryScript(ctx, ids,
"ctx._source.tag_id = params.v", map[string]any{"v": tag}); err != nil {
return err
}
}
return nil
}
// updateByQueryScript runs an _update_by_query over the cross-dim <base>_*
// pattern, matching the given chunk ids via a terms filter and applying a
// constant Painless source with caller values flowing only through bound
// params (Painless-injection-safe).
func (r *Repository) updateByQueryScript(
ctx context.Context, chunkIDs []string, source string, params map[string]any,
) error {
body, err := json.Marshal(map[string]any{
"query": map[string]any{
"terms": map[string]any{"chunk_id": chunkIDs},
},
"script": map[string]any{
"lang": "painless",
"source": source,
"params": params,
},
})
if err != nil {
return fmt.Errorf("opensearch: marshal update_by_query body: %w", err)
}
// Q2: UpdateByQueryParams.Refresh is *bool — the wire value "wait_for" is
// not expressible via the typed SDK, so we force an immediate refresh.
refresh := true
resp, err := r.client.UpdateByQuery(ctx, osapi.UpdateByQueryReq{
Indices: []string{r.baseIndex + "_*"},
Body: bytes.NewReader(body),
Params: osapi.UpdateByQueryParams{Refresh: &refresh},
})
if err != nil {
return wrapTransport(err)
}
if resp == nil {
return nil
}
defer drainAndClose(resp.Inspect().Response.Body)
return inspectByQueryResponse(io.LimitReader(resp.Inspect().Response.Body, 16<<20))
}
@@ -0,0 +1,51 @@
package opensearch
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
)
// inspectByQueryResponse parses an _update_by_query / _delete_by_query
// response and surfaces partial failures without leaking cluster-side reason
// strings (which may embed document content). Mirrors inspectBulkResponse:
// the full reason goes to the debug log only; the returned error carries the
// bounded id + type. A non-zero version_conflicts count with no hard failures
// is logged as a warning but not treated as an error.
func inspectByQueryResponse(body io.Reader) error {
var r struct {
VersionConflicts int `json:"version_conflicts"`
Failures []struct {
ID string `json:"id"`
Cause struct {
Type string `json:"type"`
Reason string `json:"reason"`
} `json:"cause"`
} `json:"failures"`
}
if err := json.NewDecoder(body).Decode(&r); err != nil {
return fmt.Errorf("opensearch: parse by-query response: %w", ErrTransport)
}
log := logger.GetLogger(context.Background())
if len(r.Failures) == 0 {
if r.VersionConflicts > 0 {
log.Warnf("[OpenSearch] by-query had %d version conflicts (proceeded)", r.VersionConflicts)
}
return nil
}
var msgs []string
for _, f := range r.Failures {
// Full reason → debug log only (may contain document content).
log.Debugf("[OpenSearch] by-query failure: id=%s type=%s reason=%s",
f.ID, f.Cause.Type, f.Cause.Reason)
if len(msgs) < 5 {
msgs = append(msgs, fmt.Sprintf("[%s] %s", f.ID, f.Cause.Type))
}
}
return fmt.Errorf("opensearch: by-query partial failure (%d failed, first 5: %s): %w",
len(r.Failures), strings.Join(msgs, "; "), ErrTransport)
}
@@ -0,0 +1,58 @@
package opensearch
import "github.com/Tencent/WeKnora/internal/types"
// internalCfg is the driver-internal, immutable view of IndexConfig.
// Defaults are chosen so the env-path (no IndexConfig) and DB-store path
// (with IndexConfig) produce identical mappings.
type internalCfg struct {
shards int
replicas int
knnEngine string // "lucene" | "faiss"
hnswM int
hnswEFConstruction int
efSearch int
}
// buildInternalCfg projects IndexConfig to the driver-internal view,
// substituting defaults for unset (zero / empty) fields. Validation of value
// ranges (e.g. hnsw_m / ef_construction caps) is a service-layer concern
// handled elsewhere (validateOpenSearchIndexConfig at CreateStore); this
// function applies defaults only and never rejects. The env-path bypasses
// service validation entirely, so the defaults below are its safety net.
//
// The OpenSearch-specific HNSW fields (knn_engine, hnsw_m,
// hnsw_ef_construction, hnsw_ef_search) are read here. They are omitempty on
// IndexConfig, so they do not affect other drivers' serialized config.
func buildInternalCfg(c *types.IndexConfig) (internalCfg, error) {
cfg := internalCfg{
shards: 4, // matches the keyword-index default upstream
replicas: 1, // assumes >= 2-node cluster
knnEngine: "lucene", // OS default; Faiss preferred only at >= 10M docs
hnswM: 16, // OS official default
hnswEFConstruction: 100, // OS official default
efSearch: 100, // OS default
}
if c == nil {
return cfg, nil
}
if c.NumberOfShards > 0 {
cfg.shards = c.NumberOfShards
}
if c.NumberOfReplicas > 0 {
cfg.replicas = c.NumberOfReplicas
}
if c.KNNEngine != "" {
cfg.knnEngine = c.KNNEngine
}
if c.HNSWM > 0 {
cfg.hnswM = c.HNSWM
}
if c.HNSWEFConstruction > 0 {
cfg.hnswEFConstruction = c.HNSWEFConstruction
}
if c.HNSWEFSearch > 0 {
cfg.efSearch = c.HNSWEFSearch
}
return cfg, nil
}
@@ -0,0 +1,194 @@
package opensearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/google/uuid"
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// copyBatchSize is the pagination size for the source scan. Kept under the
// BatchSave per-call cap so each copied page is a single bulk request.
const copyBatchSize = 500
// copySourceDoc is the full _source read during CopyIndices — it includes the
// embedding vector and is_recommended, which the retrieve-path hit struct
// omits because retrieval does not need them.
type copySourceDoc struct {
Content string `json:"content"`
SourceID string `json:"source_id"`
SourceType int `json:"source_type"`
ChunkID string `json:"chunk_id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
TagID string `json:"tag_id"`
IsEnabled bool `json:"is_enabled"`
IsRecommended bool `json:"is_recommended"`
Embedding []float32 `json:"embedding"`
}
// transformSourceID mirrors the sibling drivers' source_id remap:
// - regular chunk (source_id == chunk_id) → target chunk id
// - generated question (source_id == "<chunk>-<q>") → "<targetChunk>-<q>"
// - anything else → a fresh uuid
func transformSourceID(sourceID, chunkID, targetChunkID string) string {
switch {
case sourceID == chunkID:
return targetChunkID
case strings.HasPrefix(sourceID, chunkID+"-"):
return targetChunkID + "-" + strings.TrimPrefix(sourceID, chunkID+"-")
default:
return uuid.New().String()
}
}
// CopyIndices copies all docs of one knowledge base into another (within the
// same store) by scanning the source and re-saving via BatchSave — mirroring
// the Elasticsearch / Qdrant drivers (search→BatchSave), which yields the
// source_id transformation and dim/keyword routing for free. Runs
// synchronously and paginates; the large-batch background-task path is a
// later change.
//
// NOTE: from/size pagination is bounded by the index's max_result_window
// (default 10000). Copies larger than that require the scroll-based async
// path (a later change).
func (r *Repository) CopyIndices(
ctx context.Context,
sourceKnowledgeBaseID string,
sourceToTargetKBIDMap map[string]string, // keyed by source knowledge_id (mirrors sibling drivers)
sourceToTargetChunkIDMap map[string]string,
targetKnowledgeBaseID string,
dimension int,
knowledgeType string,
) error {
log := logger.GetLogger(ctx)
if len(sourceToTargetChunkIDMap) == 0 {
log.Warn("[OpenSearch] CopyIndices: empty chunk mapping, skipping")
return nil
}
if dimension <= 0 {
return fmt.Errorf("opensearch: CopyIndices requires dim > 0, got %d: %w",
dimension, ErrDimensionMismatch)
}
if err := r.ensureReady(ctx, dimension); err != nil {
return err
}
alias := r.indexAlias(dimension)
var total int64
for from := 0; ; from += copyBatchSize {
docs, err := r.copyScanBatch(ctx, alias, sourceKnowledgeBaseID, from, copyBatchSize)
if err != nil {
return err
}
if len(docs) == 0 {
break
}
infos := make([]*types.IndexInfo, 0, len(docs))
embMap := make(map[string][]float32, len(docs))
enabledMap := make(map[string]bool, len(docs))
for i := range docs {
d := &docs[i]
targetChunkID, ok := sourceToTargetChunkIDMap[d.ChunkID]
if !ok {
log.Warnf("[OpenSearch] CopyIndices: source chunk %s not mapped, skipping", d.ChunkID)
continue
}
targetKnowledgeID, ok := sourceToTargetKBIDMap[d.KnowledgeID]
if !ok {
log.Warnf("[OpenSearch] CopyIndices: source knowledge %s not mapped, skipping", d.KnowledgeID)
continue
}
targetSourceID := transformSourceID(d.SourceID, d.ChunkID, targetChunkID)
if len(d.Embedding) > 0 {
// BatchSave looks up embeddings by SourceID (lookupEmbedding),
// so key by the target source id — not the chunk id, which is
// the Elasticsearch driver's convention.
embMap[targetSourceID] = d.Embedding
}
enabledMap[targetChunkID] = d.IsEnabled
infos = append(infos, &types.IndexInfo{
Content: d.Content,
SourceID: targetSourceID,
SourceType: types.SourceType(d.SourceType),
ChunkID: targetChunkID,
KnowledgeID: targetKnowledgeID,
KnowledgeBaseID: targetKnowledgeBaseID,
KnowledgeType: knowledgeType,
TagID: d.TagID,
IsEnabled: d.IsEnabled,
IsRecommended: d.IsRecommended,
})
}
if len(infos) > 0 {
params := map[string]any{
"embedding": embMap,
"chunk_enabled": enabledMap,
}
if err := r.BatchSave(ctx, infos, params); err != nil {
return fmt.Errorf("opensearch: CopyIndices batch save: %w", err)
}
total += int64(len(infos))
}
if len(docs) < copyBatchSize {
break
}
}
log.Infof("[OpenSearch] CopyIndices: copied %d docs (KB %s → %s, dim=%d)",
total, sourceKnowledgeBaseID, targetKnowledgeBaseID, dimension)
r.auditSink().EmitReindexExecuted(ctx, alias, alias, total)
return nil
}
// copyScanBatch reads one page of docs belonging to sourceKB from the per-dim
// index, decoding the full _source (including the embedding vector).
func (r *Repository) copyScanBatch(
ctx context.Context, index, sourceKB string, from, size int,
) ([]copySourceDoc, error) {
body, err := json.Marshal(map[string]any{
"from": from,
"size": size,
"query": map[string]any{
"bool": map[string]any{
"filter": []any{
map[string]any{"term": map[string]any{"knowledge_base_id": sourceKB}},
},
},
},
})
if err != nil {
return nil, fmt.Errorf("opensearch: marshal copy scan body: %w", err)
}
req := osapi.SearchReq{Indices: []string{index}, Body: bytes.NewReader(body)}
resp, err := r.client.Search(ctx, &req)
if err != nil {
if isNotFound(err) {
return nil, fmt.Errorf("opensearch: index %s missing: %w", index, ErrIndexNotFound)
}
return nil, wrapTransport(err)
}
defer drainAndClose(resp.Inspect().Response.Body)
var parsed struct {
Hits struct {
Hits []struct {
Source copySourceDoc `json:"_source"`
} `json:"hits"`
} `json:"hits"`
}
if err := json.NewDecoder(io.LimitReader(resp.Inspect().Response.Body, 64<<20)).Decode(&parsed); err != nil {
return nil, fmt.Errorf("opensearch: parse copy scan response: %w", ErrTransport)
}
out := make([]copySourceDoc, len(parsed.Hits.Hits))
for i, h := range parsed.Hits.Hits {
out[i] = h.Source
}
return out, nil
}
@@ -0,0 +1,193 @@
package opensearch
import (
"context"
"io"
"net/http"
"strings"
"sync"
"testing"
)
// TestTransformSourceID covers the generated-question / regular / fallback
// branches that CopyIndices uses to remap source_id.
func TestTransformSourceID(t *testing.T) {
t.Run("regular chunk uses target chunk id", func(t *testing.T) {
if got := transformSourceID("chunk1", "chunk1", "tgt1"); got != "tgt1" {
t.Errorf("want tgt1, got %s", got)
}
})
t.Run("generated question preserves question id", func(t *testing.T) {
if got := transformSourceID("chunk1-q7", "chunk1", "tgt1"); got != "tgt1-q7" {
t.Errorf("want tgt1-q7, got %s", got)
}
})
t.Run("unrelated source id gets fresh uuid", func(t *testing.T) {
got := transformSourceID("totally-different", "chunk1", "tgt1")
if got == "totally-different" || got == "tgt1" || len(got) != 36 {
t.Errorf("want fresh uuid, got %q", got)
}
})
}
// TestCopyIndices_EmptyMapping_NoOp verifies an empty chunk map short-circuits
// before any HTTP call.
func TestCopyIndices_EmptyMapping_NoOp(t *testing.T) {
repo, ts := newTestRepo(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected HTTP call: %s %s", r.Method, r.URL.Path)
})
defer ts.Close()
err := repo.CopyIndices(context.Background(), "kbSrc", map[string]string{}, map[string]string{}, "kbDst", 768, "manual")
if err != nil {
t.Fatalf("want nil, got %v", err)
}
}
// TestCopyIndices_ScanThenBatchSave verifies the search→BatchSave path:
// remaps IDs, keys the embedding by the *target source id* (OpenSearch
// BatchSave's lookup key), and emits one reindex audit event.
func TestCopyIndices_ScanThenBatchSave(t *testing.T) {
var (
mu sync.Mutex
bulkBody string
searchCnt int
)
handler := func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodHead:
w.WriteHeader(http.StatusOK) // alias exists → ensureReady no-op
case strings.Contains(r.URL.Path, "_search"):
mu.Lock()
searchCnt++
first := searchCnt == 1
mu.Unlock()
if first {
_, _ = w.Write([]byte(`{"hits":{"hits":[
{"_source":{"content":"c","source_id":"srcChunk","source_type":1,"chunk_id":"srcChunk","knowledge_id":"srcKnow","knowledge_base_id":"kbSrc","tag_id":"t","is_enabled":true,"is_recommended":false,"embedding":[0.1,0.2,0.3]}}
]}}`))
} else {
_, _ = w.Write([]byte(`{"hits":{"hits":[]}}`))
}
case strings.HasSuffix(r.URL.Path, "/_bulk"):
b, _ := io.ReadAll(r.Body)
mu.Lock()
bulkBody = string(b)
mu.Unlock()
_, _ = w.Write([]byte(`{"errors":false,"items":[]}`))
default:
_, _ = w.Write([]byte(`{}`))
}
}
repo, ts := newTestRepo(t, handler)
defer ts.Close()
spy := &spySink{}
repo.sink = spy
err := repo.CopyIndices(context.Background(), "kbSrc",
map[string]string{"srcKnow": "tgtKnow"}, // knowledge_id remap (sourceToTargetKBIDMap is keyed by knowledge_id, mirroring ES)
map[string]string{"srcChunk": "tgtChunk"},
"kbDst", 768, "manual")
if err != nil {
t.Fatalf("CopyIndices: %v", err)
}
mu.Lock()
defer mu.Unlock()
if bulkBody == "" {
t.Fatal("no bulk request captured")
}
// Target IDs present, source IDs gone from the written doc.
for _, want := range []string{`"chunk_id":"tgtChunk"`, `"knowledge_id":"tgtKnow"`, `"knowledge_base_id":"kbDst"`, `"source_id":"tgtChunk"`} {
if !strings.Contains(bulkBody, want) {
t.Errorf("bulk body missing %q\n%s", want, bulkBody)
}
}
if strings.Contains(bulkBody, `"knowledge_base_id":"kbSrc"`) {
t.Errorf("bulk body leaked source KB id\n%s", bulkBody)
}
// Embedding written (keyed by target source id internally).
if !strings.Contains(bulkBody, "0.1") {
t.Errorf("embedding not written\n%s", bulkBody)
}
if len(spy.reindex) != 1 || spy.reindex[0].docs != 1 {
t.Errorf("want 1 reindex event with docs=1, got %+v", spy.reindex)
}
}
// TestBatchUpdateChunkEnabledStatus_GroupedUpdateByQuery verifies the status
// map is grouped by value into one _update_by_query per distinct value, each
// passing chunk ids via terms + the new value via bound script params.
func TestBatchUpdateChunkEnabledStatus_GroupedUpdateByQuery(t *testing.T) {
var (
mu sync.Mutex
bodies []string
)
handler := func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "_update_by_query") {
b, _ := io.ReadAll(r.Body)
mu.Lock()
bodies = append(bodies, string(b))
mu.Unlock()
_, _ = w.Write([]byte(`{"updated":1,"version_conflicts":0,"failures":[]}`))
return
}
_, _ = w.Write([]byte(`{}`))
}
repo, ts := newTestRepo(t, handler)
defer ts.Close()
err := repo.BatchUpdateChunkEnabledStatus(context.Background(), map[string]bool{
"c1": true, "c2": false, "c3": true,
})
if err != nil {
t.Fatalf("BatchUpdateChunkEnabledStatus: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(bodies) != 2 {
t.Fatalf("want 2 grouped update_by_query calls (true/false), got %d: %v", len(bodies), bodies)
}
joined := strings.Join(bodies, "\n")
for _, want := range []string{"c1", "c2", "c3", "is_enabled", "params"} {
if !strings.Contains(joined, want) {
t.Errorf("update_by_query bodies missing %q\n%s", want, joined)
}
}
}
func TestBatchUpdateChunkEnabledStatus_Empty_NoOp(t *testing.T) {
repo, ts := newTestRepo(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected HTTP call: %s %s", r.Method, r.URL.Path)
})
defer ts.Close()
if err := repo.BatchUpdateChunkEnabledStatus(context.Background(), map[string]bool{}); err != nil {
t.Fatalf("want nil, got %v", err)
}
}
// TestInspectByQueryResponse covers the success path and the failure path,
// asserting cluster-side reason text is NOT surfaced in the returned error.
func TestInspectByQueryResponse(t *testing.T) {
t.Run("clean response", func(t *testing.T) {
body := strings.NewReader(`{"updated":5,"version_conflicts":0,"failures":[]}`)
if err := inspectByQueryResponse(body); err != nil {
t.Fatalf("want nil, got %v", err)
}
})
t.Run("failures do not leak reason", func(t *testing.T) {
body := strings.NewReader(`{"updated":1,"version_conflicts":0,"failures":[
{"id":"c9","cause":{"type":"version_conflict_engine_exception","reason":"SECRET document body leaked here"}}
]}`)
err := inspectByQueryResponse(body)
if err == nil {
t.Fatal("want error for failures, got nil")
}
if strings.Contains(err.Error(), "SECRET") {
t.Errorf("error leaked cluster reason: %v", err)
}
if !strings.Contains(err.Error(), "version_conflict_engine_exception") {
t.Errorf("error should surface bounded type: %v", err)
}
})
}
@@ -0,0 +1,405 @@
package opensearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// additionalParams contract (verified against
// elasticsearch/structs.go:ToDBVectorEmbedding and
// keywords_vector_hybrid_indexer.go:concurrentBatchSave on
// upstream/main):
//
// additionalParams["embedding"] map[string][]float32 // keyed by IndexInfo.SourceID
// additionalParams["chunk_enabled"] map[string]bool // keyed by IndexInfo.ChunkID
//
// Both keys may be absent. Missing "embedding" → keyword-only path
// (vector field omitted from the OpenSearch document, routed to the
// dim-less keywords index). Missing "chunk_enabled" → IndexInfo's own
// IsEnabled field is used as-is.
// Save indexes a single chunk. Idempotent: _id is set to chunk_id so
// re-indexing the same chunk overwrites instead of creating a duplicate.
func (r *Repository) Save(
ctx context.Context,
info *types.IndexInfo,
params map[string]any,
) error {
emb := lookupEmbedding(params, info.SourceID)
enabled := lookupChunkEnabled(params, info.ChunkID, info.IsEnabled)
dim := len(emb)
var targetIndex string
if dim > 0 {
if err := r.ensureReady(ctx, dim); err != nil {
return err
}
targetIndex = r.indexAlias(dim)
} else {
// Keyword-only path: no embedding supplied. Route to the
// dim-less <base>_keywords index.
if err := r.ensureKeywordsIndex(ctx); err != nil {
return err
}
targetIndex = r.keywordsIndex()
}
doc, err := json.Marshal(toDoc(info, emb, enabled))
if err != nil {
return fmt.Errorf("opensearch: marshal doc: %w", err)
}
req := osapi.IndexReq{
Index: targetIndex,
DocumentID: info.ChunkID, // _id = chunk_id (idempotent)
Body: bytes.NewReader(doc),
}
resp, err := r.client.Index(ctx, req)
if err != nil {
return wrapTransport(err)
}
if resp != nil {
drainAndClose(resp.Inspect().Response.Body)
}
return nil
}
// BatchSave NDJSON-bulk-indexes chunks. Inspects per-item errors and
// returns a typed error if ANY item fails.
//
// Dim-aware pre-marshal size cap: estimateBulkBodyBytes predicts NDJSON
// size before allocating; over-cap callers get ErrBatchTooLarge instead
// of a 50MB allocation that's then rejected. The service layer is
// responsible for dim-aware sub-batching above this.
func (r *Repository) BatchSave(
ctx context.Context,
infos []*types.IndexInfo,
params map[string]any,
) error {
if len(infos) == 0 {
return nil
}
emb, dim, err := extractBatchEmbeddings(params, infos)
if err != nil {
return err
}
// Pre-marshal size + count check.
if estimated := estimateBulkBodyBytes(len(infos), dim); estimated > 10*1024*1024 {
return fmt.Errorf(
"opensearch: estimated bulk body %dB exceeds 10MB cap (n=%d, dim=%d): %w",
estimated, len(infos), dim, ErrBatchTooLarge,
)
}
if len(infos) > 1000 {
return fmt.Errorf("opensearch: bulk n=%d exceeds 1000-doc cap: %w",
len(infos), ErrBatchTooLarge)
}
// Route by dim: dim==0 means the entire batch is keyword-only
// (concurrentBatchSaveNoEmbedding path), use the dim-less index.
var alias string
if dim == 0 {
if err := r.ensureKeywordsIndex(ctx); err != nil {
return err
}
alias = r.keywordsIndex()
} else {
if err := r.ensureReady(ctx, dim); err != nil {
return err
}
alias = r.indexAlias(dim)
}
body, err := buildBulkBody(alias, infos, emb, params)
if err != nil {
return err
}
req := osapi.BulkReq{Body: bytes.NewReader(body)}
resp, err := r.client.Bulk(ctx, req)
if err != nil {
return wrapTransport(err)
}
if resp != nil {
defer drainAndClose(resp.Inspect().Response.Body)
}
// Cap response body at 64MB (bulks of 1000 items are ~1-2MB success,
// can balloon with full per-item errors).
return inspectBulkResponse(io.LimitReader(resp.Inspect().Response.Body, 64<<20))
}
// estimateBulkBodyBytes returns a rough NDJSON size in bytes. Slight
// overestimate so the cap fires before real allocation. Per-item:
// - action header `{"index":{"_index":"...","_id":"..."}}\n` ≈ 100B
// - doc body: dim*5 (float ASCII) + ~700B metadata + content ≈ 1024B
func estimateBulkBodyBytes(n, dim int) int {
return n * (100 + dim*5 + 1024)
}
// buildBulkBody constructs the NDJSON request body. emb[i] may be nil/
// empty — toDoc handles the keyword-only case by omitting the
// "embedding" field. chunk_enabled lookup also happens per-doc.
func buildBulkBody(alias string, infos []*types.IndexInfo, emb [][]float32, params map[string]any) ([]byte, error) {
var buf bytes.Buffer
for i, info := range infos {
action := map[string]any{
"index": map[string]any{
"_index": alias,
"_id": info.ChunkID,
},
}
actionJSON, err := json.Marshal(action)
if err != nil {
return nil, fmt.Errorf("opensearch: marshal bulk action %d: %w", i, err)
}
buf.Write(actionJSON)
buf.WriteByte('\n')
enabled := lookupChunkEnabled(params, info.ChunkID, info.IsEnabled)
docJSON, err := json.Marshal(toDoc(info, emb[i], enabled))
if err != nil {
return nil, fmt.Errorf("opensearch: marshal doc %d: %w", i, err)
}
buf.Write(docJSON)
buf.WriteByte('\n')
}
return buf.Bytes(), nil
}
// inspectBulkResponse parses OpenSearch bulk response, returns a typed
// error if any item has a per-item .error. Surfaces up to 5 distinct
// error messages + total count for operator diagnosis.
//
// Does NOT include det.Error.Reason in the wrapped public error
// (Reason can contain document content fragments). Only ID + bounded
// enum Type is included. Full per-item Reason goes to log.Debug.
func inspectBulkResponse(body io.Reader) error {
var r struct {
Errors bool `json:"errors"`
Items []map[string]struct {
ID string `json:"_id"`
Status int `json:"status"`
Error *struct {
Type string `json:"type"`
Reason string `json:"reason"`
} `json:"error,omitempty"`
} `json:"items"`
}
if err := json.NewDecoder(body).Decode(&r); err != nil {
return fmt.Errorf("opensearch: parse bulk response: %w", ErrTransport)
}
if !r.Errors {
return nil
}
log := logger.GetLogger(context.Background())
var msgs []string
total := 0
for _, item := range r.Items {
for op, det := range item { // {"index": {...}}
if det.Error == nil {
continue
}
total++
// Full reason → debug log only (may contain doc content).
log.Debugf("[OpenSearch] bulk item err: op=%s id=%s type=%s reason=%s",
op, det.ID, det.Error.Type, det.Error.Reason)
if len(msgs) < 5 {
// Public message: ID (operator-meaningful) + Type (bounded enum).
msgs = append(msgs, fmt.Sprintf("[%s %s] %s", op, det.ID, det.Error.Type))
}
}
}
if total == 0 {
return nil // shouldn't happen if r.Errors=true, but defensive
}
return fmt.Errorf("opensearch: bulk partial failure (%d items failed, first 5: %s): %w",
total, strings.Join(msgs, "; "), ErrTransport)
}
// toDoc projects IndexInfo + embedding to the OpenSearch document
// shape. source_type is `int` (not stringified) — mapping declared as
// `integer` field for stable cross-enum-extension behavior. Omits the
// "embedding" field entirely when emb is empty (keyword-only doc, no
// k-NN graph entry).
func toDoc(info *types.IndexInfo, emb []float32, enabled bool) map[string]any {
doc := map[string]any{
"chunk_id": info.ChunkID,
"knowledge_id": info.KnowledgeID,
"knowledge_base_id": info.KnowledgeBaseID,
"source_id": info.SourceID,
"source_type": int(info.SourceType),
"tag_id": info.TagID,
"content": info.Content,
"is_enabled": enabled,
"is_recommended": info.IsRecommended,
}
if len(emb) > 0 {
doc["embedding"] = emb
}
return doc
}
// lookupEmbedding returns the embedding vector for info.SourceID.
// Empty slice (not nil) indicates "no embedding provided" — caller
// MUST check len(emb) before calling ensureReady.
//
// Defense in depth: tolerates the wrong shape rather than crash. The
// service layer is responsible for getting params right; we degrade
// to keyword-only for this doc and log a warning instead of panicking
// downstream.
func lookupEmbedding(params map[string]any, sourceID string) []float32 {
if params == nil {
return nil
}
raw, ok := params["embedding"]
if !ok {
return nil
}
embMap, ok := raw.(map[string][]float32)
if !ok {
logger.GetLogger(context.Background()).Warnf(
"[OpenSearch] additionalParams[\"embedding\"] is %T, want map[string][]float32", raw)
return nil
}
return embMap[sourceID] // nil/empty if SourceID not in map
}
// lookupChunkEnabled overlays the chunk_enabled map on top of
// info.IsEnabled. Returns the overlay value if present, else the
// default (info.IsEnabled).
func lookupChunkEnabled(params map[string]any, chunkID string, def bool) bool {
if params == nil {
return def
}
raw, ok := params["chunk_enabled"]
if !ok {
return def
}
enMap, ok := raw.(map[string]bool)
if !ok {
return def
}
if v, present := enMap[chunkID]; present {
return v
}
return def
}
// extractBatchEmbeddings projects the params["embedding"] map onto the
// info list, returning per-info embeddings (some may be empty) and the
// dimension inferred from the first non-empty entry. Returns dim=0 if
// no info has an embedding — caller routes to the keywords-only path.
//
// Defense in depth: all non-empty embeddings MUST share the same
// dimension. Mixed-dim → ErrDimensionMismatch.
func extractBatchEmbeddings(params map[string]any, infos []*types.IndexInfo) ([][]float32, int, error) {
out := make([][]float32, len(infos))
dim := 0
for i, info := range infos {
emb := lookupEmbedding(params, info.SourceID)
out[i] = emb
if len(emb) > 0 {
if dim == 0 {
dim = len(emb)
} else if len(emb) != dim {
return nil, 0, fmt.Errorf(
"opensearch: embedding[%s] dim=%d != first non-empty dim=%d: %w",
info.SourceID, len(emb), dim, ErrDimensionMismatch,
)
}
}
}
return out, dim, nil
}
// DeleteByChunkIDList runs _delete_by_query with a terms filter on
// chunk_id. Sync only — large batches (>1000) are rejected with
// ErrBatchTooLarge; the async task path that lifts the cap arrives in
// a later change.
//
// Refresh policy: opensearch-go v4's typed Params.Refresh is *bool, so
// the wire-level "wait_for" value documented in the OpenSearch REST
// API is not directly expressible. Refresh:&true forces immediate
// segment flush (read-your-writes guaranteed, but expensive at scale).
// A follow-up can revisit via a custom raw request if telemetry shows
// the cost is material at production batch frequencies.
func (r *Repository) DeleteByChunkIDList(
ctx context.Context, chunkIDs []string, dim int, _ string,
) error {
return r.deleteByList(ctx, chunkIDs, dim, "chunk_id")
}
func (r *Repository) DeleteBySourceIDList(
ctx context.Context, sourceIDs []string, dim int, _ string,
) error {
return r.deleteByList(ctx, sourceIDs, dim, "source_id")
}
func (r *Repository) DeleteByKnowledgeIDList(
ctx context.Context, knowledgeIDs []string, dim int, _ string,
) error {
return r.deleteByList(ctx, knowledgeIDs, dim, "knowledge_id")
}
// deleteByList factors the common cap / empty / ensureReady / dispatch
// logic out of the three DeleteBy* methods. dim==0 routes to the
// dim-less keywords index.
func (r *Repository) deleteByList(
ctx context.Context, ids []string, dim int, field string,
) error {
if len(ids) == 0 {
return nil
}
if len(ids) > 1000 {
return fmt.Errorf("opensearch: %s-delete batch %d > 1000 cap: %w",
field, len(ids), ErrBatchTooLarge)
}
var index string
if dim == 0 {
if err := r.ensureKeywordsIndex(ctx); err != nil {
return err
}
index = r.keywordsIndex()
} else {
if err := r.ensureReady(ctx, dim); err != nil {
return err
}
index = r.indexAlias(dim)
}
return r.deleteByTerms(ctx, index, field, ids)
}
func (r *Repository) deleteByTerms(
ctx context.Context, index, field string, values []string,
) error {
body, err := json.Marshal(map[string]any{
"query": map[string]any{
"terms": map[string]any{field: values},
},
})
if err != nil {
return fmt.Errorf("opensearch: marshal delete body: %w", err)
}
refresh := true
req := osapi.DocumentDeleteByQueryReq{
Indices: []string{index},
Body: bytes.NewReader(body),
Params: osapi.DocumentDeleteByQueryParams{Refresh: &refresh},
}
resp, err := r.client.Document.DeleteByQuery(ctx, req)
if err != nil {
return wrapTransport(err)
}
if resp != nil {
drainAndClose(resp.Inspect().Response.Body)
}
return nil
}
@@ -0,0 +1,62 @@
// Package opensearch implements the RetrieveEngineRepository interface
// for OpenSearch k-NN native vector search.
package opensearch
import "errors"
// Sentinel errors returned by Repository. The service-layer factory wraps
// these into typed AppError values (2200/2201) — the repository itself
// never imports internal/errors. The boundary is intentional (directional
// dependency).
var (
// ErrIndexNotFound — alias / underlying index missing. Search and
// delete-by-query operations return this when the per-dim alias has
// not been created yet (no Save has been issued for that dim).
ErrIndexNotFound = errors.New("opensearch: index not found")
// ErrDimensionMismatch — embedding dimension violates the per-dim
// invariant (e.g. dim <= 0, dim > 16000, or embeddings within a
// single batch disagree).
ErrDimensionMismatch = errors.New("opensearch: embedding dimension mismatch")
// ErrAuth — cluster returned 401 / 403. Distinguished from ErrTransport
// so the service layer can map to a clean 4xx instead of 503.
ErrAuth = errors.New("opensearch: authentication failed")
// ErrTransport — network / 5xx / opaque cluster error. Classified as
// transient: ensureReady does NOT persist this in initErr, so the next
// caller will retry.
ErrTransport = errors.New("opensearch: transport error")
// ErrVersionUnsupported — cluster is not OpenSearch, is OS 1.x, or is
// OS 2.0~2.3 (pre-Lucene-HNSW-GA). probeVersion enforces.
ErrVersionUnsupported = errors.New("opensearch: cluster version unsupported")
// ErrConfigInvalid — IndexConfig / storeID / sanitizeIndexName guard
// failed, or the k-NN plugin is missing on one or more cluster nodes.
ErrConfigInvalid = errors.New("opensearch: invalid index config")
// ErrFeatureNotEnabled — stubs.go returns this from methods whose real
// implementation has not landed yet (CopyIndices / BatchUpdateChunk* /
// swapToVersion, plus the read/write methods that a follow-up commit
// will replace with production code).
ErrFeatureNotEnabled = errors.New("opensearch: feature not enabled in this build")
// ErrBatchTooLarge — Save / Delete batch exceeded the driver's sync
// cap. Distinct from ErrFeatureNotEnabled so the service layer can
// chunk + retry rather than treat the failure as "waiting on a future
// implementation."
ErrBatchTooLarge = errors.New("opensearch: batch size exceeds driver cap")
// ErrCircuitBreaker — OpenSearch k-NN circuit breaker returned 429
// (knn_circuit_breaker_exception). Classified as transient so the
// caller can retry after the operator scales the cluster.
ErrCircuitBreaker = errors.New("opensearch: knn circuit breaker open")
)
// isTransientErr classifies sentinel errors. Transient errors do not get
// persisted in ensureReady's initErr cache — they can be retried by the
// next caller after the underlying cause clears.
func isTransientErr(err error) bool {
return errors.Is(err, ErrTransport) || errors.Is(err, ErrCircuitBreaker)
}
@@ -0,0 +1,23 @@
package opensearch
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// TestConnection verifies an OpenSearch cluster is reachable, runs a
// supported version, and has the k-NN plugin installed on every node. It is
// the connectivity probe used by the VectorStore service's CreateStore
// health-check (the driver's unexported probes are reused here). Returns a
// wrapped sentinel error on failure; nil on success.
func TestConnection(ctx context.Context, cfg *types.ConnectionConfig) error {
client, err := NewOpenSearchClient(cfg)
if err != nil {
return err
}
if err := probeVersion(ctx, client); err != nil {
return err
}
return probeKNNPlugin(ctx, client)
}
@@ -0,0 +1,50 @@
package opensearch
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/Tencent/WeKnora/internal/types"
)
// clusterHandler serves both GET / (version info) and /_cat/plugins, so the
// full TestConnection probe (version + k-NN plugin) can run end to end.
func clusterHandler(distribution, number string, plugins []osapi.CatPluginResp) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/_cat/plugins" {
_ = json.NewEncoder(w).Encode(plugins)
return
}
_, _ = w.Write([]byte(`{"version":{"distribution":"` + distribution + `","number":"` + number + `"}}`))
}
}
func TestTestConnection_Success(t *testing.T) {
ts := httptest.NewServer(clusterHandler("opensearch", "3.3.2", []osapi.CatPluginResp{
{Name: "node-1", Component: "opensearch-knn"},
}))
defer ts.Close()
if err := TestConnection(context.Background(), &types.ConnectionConfig{Addr: ts.URL}); err != nil {
t.Errorf("healthy cluster: want nil, got %v", err)
}
}
func TestTestConnection_RejectsElasticsearch(t *testing.T) {
ts := httptest.NewServer(clusterHandler("elasticsearch", "8.10.4", nil))
defer ts.Close()
if err := TestConnection(context.Background(), &types.ConnectionConfig{Addr: ts.URL}); err == nil {
t.Error("elasticsearch cluster should be rejected")
}
}
func TestTestConnection_EmptyAddr(t *testing.T) {
if err := TestConnection(context.Background(), &types.ConnectionConfig{}); err == nil {
t.Error("empty addr should be rejected")
}
}
@@ -0,0 +1,420 @@
package opensearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/Tencent/WeKnora/internal/logger"
)
// createIndexAndAlias creates "<alias>_v1" with the full k-NN mapping
// and then aliases "<alias>" → "<alias>_v1". Idempotent: returns nil if
// the alias already exists (so concurrent NewRepository calls in
// different processes don't race the PUT).
//
// Orphan cleanup: if aliasPut fails after a successful indicesCreate,
// the function attempts best-effort DELETE of the orphan _v1 index.
// Cleanup failure is logged but does not mask the original aliasPut
// error.
//
// Mapping-drift detection: if indicesCreate returns
// resource_already_exists_exception (cross-process race), the existing
// mapping's structural fingerprint is checked against the expected
// fingerprint. Drift returns ErrConfigInvalid wrapping a clear "manual
// reindex required" message instead of silently overlaying a foreign
// mapping.
func (r *Repository) createIndexAndAlias(ctx context.Context, dim int) error {
log := logger.GetLogger(ctx)
alias := r.indexAlias(dim)
realIndex := alias + "_v1"
exists, err := r.aliasExists(ctx, alias)
if err != nil {
return fmt.Errorf("alias check %s: %w", alias, err)
}
if exists {
return nil // another writer already set it up
}
body, err := buildIndexMapping(r.cfg, dim)
if err != nil {
return fmt.Errorf("build mapping for dim=%d: %w", dim, err)
}
indexCreated := false
if err := r.indicesCreate(ctx, realIndex, body); err != nil {
if isAlreadyExistsError(err) {
// Cross-process race or stale orphan. Verify the existing
// mapping matches what we expected; drift → ErrConfigInvalid.
if driftErr := r.verifyMappingMatches(ctx, realIndex, body); driftErr != nil {
return fmt.Errorf(
"mapping drift on %s: existing index has incompatible mapping; "+
"manual reindex required: %w",
realIndex, driftErr,
)
}
// Mapping matches → safe to proceed with alias setup.
} else {
return fmt.Errorf("create index %s: %w", realIndex, err)
}
} else {
indexCreated = true
}
if err := r.aliasPut(ctx, realIndex, alias); err != nil {
// Best-effort orphan cleanup so future retries find a clean slate.
if indexCreated {
if delErr := r.indicesDelete(ctx, realIndex); delErr != nil {
log.Warnf("[OpenSearch] orphan cleanup failed for %s: %v "+
"(operator must DELETE manually)", realIndex, delErr)
} else {
log.Infof("[OpenSearch] cleaned up orphan %s after aliasPut failure", realIndex)
}
}
return fmt.Errorf("put alias %s → %s: %w", alias, realIndex, err)
}
if indexCreated {
// Emit only when we actually provisioned the index (not when a
// concurrent writer / existing index short-circuited above).
r.auditSink().EmitIndexCreated(ctx, alias, dim)
}
return nil
}
// buildIndexMapping returns the full mapping JSON for a new OpenSearch
// k-NN index. content uses the standard analyzer (BM25 default); all
// *_id fields are explicit keyword sub-fields so DeleteByQuery / terms
// filters hit them without the ES v8 "useKeywordSuffix" auto-detection
// dance.
//
// match_type is intentionally NOT in the mapping — it's a per-result
// classification derived from RetrieverType at parse time. source_type
// is stored as `integer` (not stringified) so the wire format remains
// stable across SourceType enum extension.
//
// Pure function — no IO, no logging. Easy to unit-test (byte-exact diff).
func buildIndexMapping(cfg internalCfg, dim int) ([]byte, error) {
type mapping struct {
Settings struct {
Index struct {
Knn bool `json:"knn"`
NumberOfShards int `json:"number_of_shards"`
NumberOfReplicas int `json:"number_of_replicas"`
RefreshInterval string `json:"refresh_interval"`
KnnAlgoParamEFSearch int `json:"knn.algo_param.ef_search"`
} `json:"index"`
} `json:"settings"`
Mappings struct {
Properties map[string]any `json:"properties"`
} `json:"mappings"`
}
var m mapping
m.Settings.Index.Knn = true
m.Settings.Index.NumberOfShards = cfg.shards
m.Settings.Index.NumberOfReplicas = cfg.replicas
m.Settings.Index.RefreshInterval = "1s"
m.Settings.Index.KnnAlgoParamEFSearch = cfg.efSearch
m.Mappings.Properties = map[string]any{
"embedding": map[string]any{
"type": "knn_vector",
"dimension": dim,
"method": map[string]any{
"name": "hnsw",
"space_type": "cosinesimil",
"engine": cfg.knnEngine,
"parameters": map[string]any{
"m": cfg.hnswM,
"ef_construction": cfg.hnswEFConstruction,
},
},
},
"content": map[string]any{"type": "text", "analyzer": "standard"},
"chunk_id": map[string]any{"type": "keyword"},
"knowledge_id": map[string]any{"type": "keyword"},
"knowledge_base_id": map[string]any{"type": "keyword"},
"tag_id": map[string]any{"type": "keyword"},
"source_id": map[string]any{"type": "keyword"},
"source_type": map[string]any{"type": "integer"},
"is_enabled": map[string]any{"type": "boolean"},
"is_recommended": map[string]any{"type": "boolean"},
}
out, err := json.Marshal(&m)
if err != nil {
// Cannot happen for our internal struct, but propagate for symmetry
// with the other JSON builders in the package.
return nil, fmt.Errorf("marshal mapping: %w", err)
}
return out, nil
}
// buildKeywordsMapping returns the mapping for the dim-less keyword-only
// index. Same as buildIndexMapping but with the embedding field omitted
// — used by the no-embedding save path (the service layer dispatches
// there when the knowledge base has vector indexing disabled).
func buildKeywordsMapping(cfg internalCfg) ([]byte, error) {
type mapping struct {
Settings struct {
Index struct {
NumberOfShards int `json:"number_of_shards"`
NumberOfReplicas int `json:"number_of_replicas"`
RefreshInterval string `json:"refresh_interval"`
} `json:"index"`
} `json:"settings"`
Mappings struct {
Properties map[string]any `json:"properties"`
} `json:"mappings"`
}
var m mapping
m.Settings.Index.NumberOfShards = cfg.shards
m.Settings.Index.NumberOfReplicas = cfg.replicas
m.Settings.Index.RefreshInterval = "1s"
m.Mappings.Properties = map[string]any{
"content": map[string]any{"type": "text", "analyzer": "standard"},
"chunk_id": map[string]any{"type": "keyword"},
"knowledge_id": map[string]any{"type": "keyword"},
"knowledge_base_id": map[string]any{"type": "keyword"},
"tag_id": map[string]any{"type": "keyword"},
"source_id": map[string]any{"type": "keyword"},
"source_type": map[string]any{"type": "integer"},
"is_enabled": map[string]any{"type": "boolean"},
"is_recommended": map[string]any{"type": "boolean"},
}
out, err := json.Marshal(&m)
if err != nil {
return nil, fmt.Errorf("marshal keywords mapping: %w", err)
}
return out, nil
}
// ensureKeywordsIndex creates the dim-less keyword-only index used by
// the no-embedding save path. mutex + flag pattern so transient
// failures can be retried by the next caller (sync.Once cannot be
// reset).
func (r *Repository) ensureKeywordsIndex(ctx context.Context) error {
r.keywordsMu.Lock()
defer r.keywordsMu.Unlock()
if r.keywordsReady {
return nil
}
if r.keywordsErr != nil && !isTransientErr(r.keywordsErr) {
// Permanent failure — don't retry, surface the same error.
return r.keywordsErr
}
name := r.keywordsIndex()
exists, err := r.aliasExists(ctx, name) // returns true if index exists too
if err != nil {
r.keywordsErr = err
return err
}
if exists {
r.keywordsReady = true
r.keywordsErr = nil
return nil
}
body, err := buildKeywordsMapping(r.cfg)
if err != nil {
r.keywordsErr = err
return err
}
created := false
if err := r.indicesCreate(ctx, name, body); err != nil {
if !isAlreadyExistsError(err) {
r.keywordsErr = err
return err
}
// resource_already_exists_exception — race with concurrent process,
// treat as success.
} else {
created = true
}
r.keywordsReady = true
r.keywordsErr = nil
if created {
// dim=0 marks the dim-less keyword-only index.
r.auditSink().EmitIndexCreated(ctx, name, 0)
}
return nil
}
// indicesCreate is the low-level wrapper around Indices.Create. Body is
// the JSON mapping produced by buildIndexMapping.
func (r *Repository) indicesCreate(ctx context.Context, name string, body []byte) error {
req := osapi.IndicesCreateReq{Index: name, Body: bytes.NewReader(body)}
resp, err := r.client.Indices.Create(ctx, req)
if err != nil {
return wrapTransport(err)
}
if resp != nil {
drainAndClose(resp.Inspect().Response.Body)
}
return nil
}
// indicesDelete is used by createIndexAndAlias's orphan cleanup path.
// Caller decides what to do with the error.
func (r *Repository) indicesDelete(ctx context.Context, name string) error {
req := osapi.IndicesDeleteReq{Indices: []string{name}}
resp, err := r.client.Indices.Delete(ctx, req)
if err != nil {
return wrapTransport(err)
}
if resp != nil {
drainAndClose(resp.Inspect().Response.Body)
}
return nil
}
// aliasExists uses HEAD /_alias/<name> (Indices.Alias.Exists) — returns
// true on 200, false on 404, error on 5xx / transport.
//
// SDK quirk (opensearch-go v4.6.0): the Exists method passes
// dataPointer=nil to its internal do(), which means non-2xx responses
// come back as a plain *errors.errorString ("status: 404 Not Found")
// rather than as a *opensearch.StructError. We therefore inspect
// resp.StatusCode directly (resp is always returned even when err is
// non-nil) and only fall back to wrapTransport for the "no response at
// all" case (true network failure).
func (r *Repository) aliasExists(ctx context.Context, alias string) (bool, error) {
req := osapi.AliasExistsReq{Alias: []string{alias}}
resp, err := r.client.Indices.Alias.Exists(ctx, req)
if resp != nil {
statusCode := resp.StatusCode
drainAndClose(resp.Body)
switch statusCode {
case 200:
return true, nil
case 404:
return false, nil
}
// Unexpected status (5xx) — fall through to the err path so
// wrapTransport can classify (and so the test handler's
// errBody is logged for diagnosis).
}
if err != nil {
return false, wrapTransport(err)
}
return false, nil
}
// aliasPut creates an alias pointing at index. Single add operation
// (rolling-reindex atomic swap is deferred to a follow-up PR — see
// stubs.go swapToVersion).
func (r *Repository) aliasPut(ctx context.Context, index, alias string) error {
req := osapi.AliasPutReq{
Indices: []string{index},
Alias: alias,
}
resp, err := r.client.Indices.Alias.Put(ctx, req)
if err != nil {
return wrapTransport(err)
}
if resp != nil {
drainAndClose(resp.Inspect().Response.Body)
}
return nil
}
// verifyMappingMatches GETs the existing index's mapping and compares
// the embedding field's structural fingerprint (dimension, HNSW
// parameters, engine) against the expected. A full field-by-field diff
// would be ideal but expensive; we check only the parts that matter for
// query correctness.
func (r *Repository) verifyMappingMatches(ctx context.Context, index string, expected []byte) error {
req := osapi.MappingGetReq{Indices: []string{index}}
resp, err := r.client.Indices.Mapping.Get(ctx, &req)
if err != nil {
return fmt.Errorf("get mapping %s: %w", index, wrapTransport(err))
}
if resp != nil {
drainAndClose(resp.Inspect().Response.Body)
}
// Both fingerprints are derived from the mapping's embedding-field
// HNSW parameters: dimension, m, ef_construction, engine, space_type.
// We parse the expected (our own JSON) and the actual (cluster
// response) and compare just those bits.
expectedFP, err := extractEmbeddingFingerprint(expected)
if err != nil {
return fmt.Errorf("parse expected fingerprint: %w", err)
}
actualFP, err := extractMappingFingerprintFromResp(resp, index)
if err != nil {
return fmt.Errorf("parse actual fingerprint: %w", err)
}
if expectedFP != actualFP {
return fmt.Errorf("mapping fingerprint mismatch: expected %+v, got %+v: %w",
expectedFP, actualFP, ErrConfigInvalid)
}
return nil
}
// embFingerprint is the minimal set of HNSW parameters that uniquely
// determine query semantics for a k-NN index. If any of these differ
// between create-time and an existing index, retrieval is incorrect.
type embFingerprint struct {
Dimension int
M int
EFConstruction int
Engine string
SpaceType string
}
// extractEmbeddingFingerprint parses our own mapping JSON (produced by
// buildIndexMapping) to pull out the HNSW parameters.
func extractEmbeddingFingerprint(body []byte) (embFingerprint, error) {
var m struct {
Mappings struct {
Properties map[string]any `json:"properties"`
} `json:"mappings"`
}
if err := json.Unmarshal(body, &m); err != nil {
return embFingerprint{}, err
}
return extractFingerprintFromProps(m.Mappings.Properties)
}
// extractMappingFingerprintFromResp parses the cluster's GET-mapping
// response and pulls out the embedding-field HNSW fingerprint for the
// given index.
func extractMappingFingerprintFromResp(resp *osapi.MappingGetResp, index string) (embFingerprint, error) {
indexMapping, ok := resp.Indices[index]
if !ok {
return embFingerprint{}, fmt.Errorf("index %s not in mapping response", index)
}
// MappingGetResp.Indices[index].Mappings is a json.RawMessage in
// the SDK — we decode the inner properties shape ourselves.
var m struct {
Properties map[string]any `json:"properties"`
}
if err := json.Unmarshal(indexMapping.Mappings, &m); err != nil {
return embFingerprint{}, err
}
return extractFingerprintFromProps(m.Properties)
}
func extractFingerprintFromProps(props map[string]any) (embFingerprint, error) {
emb, ok := props["embedding"].(map[string]any)
if !ok {
return embFingerprint{}, fmt.Errorf("embedding field missing or wrong type")
}
dim, _ := emb["dimension"].(float64) // json.Unmarshal produces float64 for numbers
method, _ := emb["method"].(map[string]any)
engine, _ := method["engine"].(string)
spaceType, _ := method["space_type"].(string)
params, _ := method["parameters"].(map[string]any)
m, _ := params["m"].(float64)
efc, _ := params["ef_construction"].(float64)
return embFingerprint{
Dimension: int(dim),
M: int(m),
EFConstruction: int(efc),
Engine: engine,
SpaceType: spaceType,
}, nil
}
@@ -0,0 +1,154 @@
package opensearch
import (
"encoding/json"
"fmt"
"github.com/Tencent/WeKnora/internal/types"
)
// retrieveFilters is the typed shape of OpenSearch query filter
// clauses. It is the ONLY way callers (Retrieve) can constrain a
// query — there is no JSON-fragment escape hatch. This prevents the
// "map[string]interface{} injection" failure mode where a hostile
// chunk_id like {"$or":[...]} would otherwise be inlined as JSON DSL.
//
// All slice fields are interpreted as IN-set membership (OS terms
// query). Empty slice = no constraint on that dimension.
//
// IncludeDisabled controls the implicit is_enabled = true clause:
// - false (default) — appends is_enabled term, hiding disabled chunks
// - true — omits the clause; admin / audit callers can
// observe disabled chunks. Service layer pins false for user-facing
// APIs.
type retrieveFilters struct {
KBIDs []string
KnowledgeIDs []string
TagIDs []string
ExcludeChunkIDs []string
ExcludeKnowledgeIDs []string
IncludeDisabled bool
}
// fromParams projects RetrieveParams to retrieveFilters, applying the
// multi-KB input from the service-layer dispatcher as terms.
// ExcludeKnowledgeIDs is included so the OpenSearch driver matches the
// behaviour of the Qdrant / pgvector drivers.
func fromParams(p types.RetrieveParams) *retrieveFilters {
return &retrieveFilters{
KBIDs: p.KnowledgeBaseIDs,
KnowledgeIDs: p.KnowledgeIDs,
TagIDs: p.TagIDs,
ExcludeChunkIDs: p.ExcludeChunkIDs,
ExcludeKnowledgeIDs: p.ExcludeKnowledgeIDs,
// IncludeDisabled stays false — set explicitly by admin callers
// only. Driver receives this from a typed field, not from
// AdditionalParams, so the contract is checked at compile time.
}
}
// toBoolMust emits the OpenSearch bool.must clause list for this
// filter. Returns a freshly allocated slice each call so callers can
// safely append additional clauses (e.g. the keyword match clause in
// buildKeywordQuery) without aliasing.
func (f *retrieveFilters) toBoolMust() []map[string]any {
var must []map[string]any
if len(f.KBIDs) > 0 {
must = append(must, map[string]any{
"terms": map[string]any{"knowledge_base_id": f.KBIDs},
})
}
if len(f.KnowledgeIDs) > 0 {
must = append(must, map[string]any{
"terms": map[string]any{"knowledge_id": f.KnowledgeIDs},
})
}
if len(f.TagIDs) > 0 {
must = append(must, map[string]any{
"terms": map[string]any{"tag_id": f.TagIDs},
})
}
if len(f.ExcludeChunkIDs) > 0 {
must = append(must, map[string]any{
"bool": map[string]any{
"must_not": map[string]any{
"terms": map[string]any{"chunk_id": f.ExcludeChunkIDs},
},
},
})
}
if len(f.ExcludeKnowledgeIDs) > 0 {
must = append(must, map[string]any{
"bool": map[string]any{
"must_not": map[string]any{
"terms": map[string]any{"knowledge_id": f.ExcludeKnowledgeIDs},
},
},
})
}
if !f.IncludeDisabled {
must = append(must, map[string]any{
"term": map[string]any{"is_enabled": true},
})
}
return must
}
// buildKNNQuery composes a pure k-NN ANN query with caller filters. No
// hybrid pipeline — native hybrid is out of scope for this PR; fusion
// stays in the service-layer RRF.
//
// Applies `min_score` when threshold > 0. The OpenSearch k-NN plugin's
// SpaceType.COSINESIMIL.scoreTranslation produces (1 + cos) / 2 ∈ [0, 1]
// at the client, so the caller's Threshold value passes through to
// min_score directly without further scaling.
//
// Signature returns ([]byte, error) so callers can propagate
// json.Marshal failures (reachable for NaN / +/-Inf in embedding).
func buildKNNQuery(embedding []float32, topK int, threshold float64, f *retrieveFilters) ([]byte, error) {
knn := map[string]any{
"embedding": map[string]any{
"vector": embedding,
"k": topK,
"filter": map[string]any{
"bool": map[string]any{"must": f.toBoolMust()},
},
},
}
body := map[string]any{
"size": topK,
"query": map[string]any{"knn": knn},
}
if threshold > 0 {
body["min_score"] = threshold
}
out, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal knn query: %w", err)
}
return out, nil
}
// buildKeywordQuery composes a BM25 match query against the content
// field with caller filters. Score range is OpenSearch BM25 native (no
// upper bound) — the normalizer keyword-passthrough rule treats keyword
// retrievers as rank-based for RRF, so the score scale does not need
// normalization here. min_score still applied if requested.
func buildKeywordQuery(queryText string, topK int, threshold float64, f *retrieveFilters) ([]byte, error) {
must := f.toBoolMust()
must = append(must, map[string]any{
"match": map[string]any{"content": queryText},
})
body := map[string]any{
"size": topK,
"query": map[string]any{"bool": map[string]any{"must": must}},
}
if threshold > 0 {
body["min_score"] = threshold
}
out, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal keyword query: %w", err)
}
return out, nil
}
@@ -0,0 +1,456 @@
package opensearch
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"github.com/opensearch-project/opensearch-go/v4"
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// EngineAwareNormalizer applies the documented per-engine cosine-score
// formula. (Repository implements interfaces.RetrieveEngineRepository for
// OpenSearch k-NN. Safe for concurrent use after construction: all fields
// except the once / initErr maps are read-only after NewRepository returns;
// per-dimension index initialization is guarded by sync.Once.
//
// Lifecycle: NewRepository validates connectivity + cluster version +
// k-NN plugin, but does NOT create any index. Index creation happens
// lazily on first Save / BatchSave / Retrieve once the embedding
// dimension is known (see ensureReady — per-dimension index naming).
//
// Concurrency: A single Repository instance is shared across N goroutines
// concurrently retrieving from the same store (the multi-store fan-out
// dispatcher in the service layer). The struct is therefore designed to
// be allocation-stable after construction; the only mutable state is the
// sync.Once + initErr maps, each guarded by its own mutex.
//
// CRITICAL INVARIANT: initErr[dim] = err must be written INSIDE the
// once.Do closure — moving it outside opens a TOCTOU where a concurrent
// caller reads zero before the first caller persists.
type Repository struct {
client *osapi.Client
baseIndex string // pre-suffix base name, e.g. "weknora_abcdef012345"
cfg internalCfg // immutable after construction (value, not pointer)
// Per-dimension lazy initialization. ensureReady(ctx, dim) inserts an
// entry the first time dim is seen, runs the create-index work inside
// sync.Once, and persists any permanent error so subsequent callers
// see the same failure without re-attempting the PUT. Transient
// errors (ErrTransport, ErrCircuitBreaker) are NOT persisted — the
// next caller retries.
onceMu sync.Mutex
once map[int]*sync.Once
initMu sync.Mutex
initErr map[int]error
// Lazy init state for the dim-less keyword-only index used by the
// no-embedding path (concurrentBatchSaveNoEmbedding in the service
// layer). Mutex + flag pattern (rather than sync.Once) so transient
// failures can be retried by the next caller — sync.Once does not
// support reset.
keywordsMu sync.Mutex
keywordsReady bool
keywordsErr error
// sink receives audit events (index created / reindex executed). nil
// means no auditing; use r.auditSink() to get a non-nil sink. Set via
// WithAuditSink at construction.
sink AuditSink
}
// Compile-time interface satisfaction (Go best practice — keeps the build
// red if the interface drifts and our implementation lags).
var _ interfaces.RetrieveEngineRepository = (*Repository)(nil)
// NewRepository builds a new OpenSearch k-NN repository and verifies the
// backing cluster is reachable + version-compatible + has the k-NN
// plugin installed on every cluster node. It does NOT create any index —
// callers (Save / Retrieve) trigger lazy per-dimension creation via
// ensureReady on first use.
//
// storeID is the VectorStore.ID owning this repository instance. It is
// folded into the base index name so multiple OpenSearch VectorStores
// pointing at the same cluster do not collide:
// - storeID == "" — env-path; no prefix, base index = "weknora".
// - len(storeID) >= 16 — DB-store path; prefix = storeID[:12]
// (48-bit collision space).
// - 1..15 chars — rejected with ErrConfigInvalid.
//
// indexCfg is optional — pass nil to use env var (OPENSEARCH_INDEX) or
// default ("weknora") values.
//
// Optional behavior is configured via functional options (e.g.
// WithAuditSink). Passing no options keeps audit emission as a no-op, so the
// env-path and tests need no extra wiring.
//
// Returns a typed sentinel error wrapped with %w; callers translate to
// AppError at the engine-factory boundary.
func NewRepository(
ctx context.Context,
client *osapi.Client,
storeID string,
indexCfg *types.IndexConfig,
opts ...Option,
) (interfaces.RetrieveEngineRepository, error) {
log := logger.GetLogger(ctx)
if storeID != "" && len(storeID) < 16 {
return nil, fmt.Errorf(
"opensearch: storeID must be empty or >=16 chars, got %d: %w",
len(storeID), ErrConfigInvalid,
)
}
base := types.ResolveIndexName(indexCfg, "OPENSEARCH_INDEX", "weknora")
if storeID != "" {
// 12 hex chars → 48-bit collision space (UUID is 32 chars; using
// half gives strong-enough collision avoidance for the realistic
// "two OpenSearch VectorStores on the same cluster" case).
base = fmt.Sprintf("%s_%s", base, storeID[:12])
}
base, err := sanitizeIndexName(base)
if err != nil {
return nil, fmt.Errorf("opensearch: invalid index base name: %w", err)
}
icfg, err := buildInternalCfg(indexCfg)
if err != nil {
return nil, fmt.Errorf("opensearch: invalid index config: %w", err)
}
if err := probeVersion(ctx, client); err != nil {
return nil, err // already wraps ErrVersionUnsupported / ErrTransport
}
if err := probeKNNPlugin(ctx, client); err != nil {
return nil, err // already wraps ErrConfigInvalid / ErrTransport
}
r := &Repository{
client: client,
baseIndex: base,
cfg: icfg,
once: make(map[int]*sync.Once),
initErr: make(map[int]error),
}
for _, opt := range opts {
opt(r)
}
log.Infof("[OpenSearch] repository ready (baseIndex=%s, knn_engine=%s, hnsw_m=%d)",
base, icfg.knnEngine, icfg.hnswM)
return r, nil
}
// ensureReady creates the per-dimension index (alias-backed) the first
// time a given embedding dimension is seen. Concurrent callers for the
// same dim block on the same sync.Once.
//
// Transient vs permanent error handling:
// - PERMANENT failures (ErrConfigInvalid, ErrAuth, ErrVersionUnsupported,
// ErrDimensionMismatch) are persisted in initErr[dim] and re-served to
// all future callers without retry — caller must restart or fix config.
// - TRANSIENT failures (ErrTransport, ErrCircuitBreaker) are NOT
// persisted; the sync.Once is reset for that dim so the next caller
// can retry. This prevents a single cluster blip from permanently
// locking out a dim.
//
// Caller MUST pass a real ctx; nil is rejected. dim must be in (0, 16000]
// (OpenSearch knn_vector hard limit).
//
// CRITICAL INVARIANT: initErr[dim] = err is written INSIDE the once.Do
// closure. Moving it outside opens a TOCTOU where a concurrent caller
// reads zero before the first caller persists.
func (r *Repository) ensureReady(ctx context.Context, dim int) error {
if ctx == nil {
return fmt.Errorf("opensearch: ensureReady requires non-nil ctx")
}
if dim <= 0 || dim > 16000 {
return fmt.Errorf("opensearch: dim %d out of range (1..16000): %w",
dim, ErrDimensionMismatch)
}
r.onceMu.Lock()
once, ok := r.once[dim]
if !ok {
once = &sync.Once{}
r.once[dim] = once
}
r.onceMu.Unlock()
once.Do(func() {
err := r.createIndexAndAlias(ctx, dim)
if err != nil && isTransientErr(err) {
// Do NOT persist transient errors. Reset the sync.Once so
// the next caller retries (requires once map mutation).
r.onceMu.Lock()
delete(r.once, dim)
r.onceMu.Unlock()
// Fall through — caller still sees this attempt's err.
}
r.initMu.Lock()
if err == nil || !isTransientErr(err) {
r.initErr[dim] = err // nil on success, persist permanent failures
}
r.initMu.Unlock()
})
r.initMu.Lock()
err := r.initErr[dim]
r.initMu.Unlock()
return err
}
// indexAlias returns the alias name for a given dimension. All reads /
// writes target the alias; the alias points at the actual <alias>_v1
// index, enabling future rolling-reindex swap workflows (the swap path
// itself is deferred to a follow-up PR; see stubs.go swapToVersion).
func (r *Repository) indexAlias(dim int) string {
return fmt.Sprintf("%s_%d", r.baseIndex, dim)
}
// keywordsIndex returns the name of the dim-less keyword-only index used
// by the no-embedding save path (the service layer dispatches there when
// the knowledge base has vector indexing disabled). The mapping is the
// same as the per-dim index minus the knn_vector field.
func (r *Repository) keywordsIndex() string {
return r.baseIndex + "_keywords"
}
// EngineType returns the engine constant for OpenSearch. Used by the
// fan-out normalizer to route to the [0, 1] passthrough group.
func (r *Repository) EngineType() types.RetrieverEngineType {
return types.OpenSearchRetrieverEngineType
}
// Support reports the retrieval modes this driver implements. OpenSearch
// k-NN supports both ANN (vector) and BM25 (keyword) within a single
// document; the keyword-only index keeps BM25 functional for callers
// that omit the embedding map.
func (r *Repository) Support() []types.RetrieverType {
return []types.RetrieverType{types.KeywordsRetrieverType, types.VectorRetrieverType}
}
// probeVersion sends GET / and validates the cluster is OpenSearch in
// a supported version range:
// - Reject: ES (any), OS 1.x, OS 2.0~2.3 (Lucene HNSW preview).
// - Warn-but-accept: OS 2.4~2.10 (Lucene HNSW GA, pre-LTS).
// - Clean-accept: OS 2.11+, OS 3.x (primary tested: 3.3.2).
func probeVersion(ctx context.Context, client *osapi.Client) error {
resp, err := client.Info(ctx, nil)
if err != nil {
return fmt.Errorf("opensearch: cluster info: %w", wrapTransport(err))
}
distribution := resp.Version.Distribution
number := resp.Version.Number
if distribution != "opensearch" {
return fmt.Errorf("opensearch: unsupported distribution %q: %w",
distribution, ErrVersionUnsupported)
}
maj, min := parseMajorMinor(number)
switch {
case maj == 1:
return fmt.Errorf("opensearch: 1.x EOL: %w", ErrVersionUnsupported)
case maj == 2 && min >= 0 && min <= 3:
return fmt.Errorf("opensearch: %s lacks Lucene HNSW GA (need 2.4+): %w",
number, ErrVersionUnsupported)
case maj == 2 && min >= 4 && min <= 10:
logger.GetLogger(ctx).Warnf(
"[OpenSearch] using pre-2.11 cluster %s; recommend 2.11+ LTS", number)
return nil
case maj == 2 || maj == 3:
return nil
default:
return fmt.Errorf("opensearch: unsupported version %s: %w",
number, ErrVersionUnsupported)
}
}
// parseMajorMinor extracts (major, minor) from a semver string such as
// "3.3.2", "2.10.0-SNAPSHOT", "2.10". Returns (0, 0) for unparseable
// input. Robust to pre-release suffixes (strips after '-') and missing
// patch component (e.g. "2.10").
func parseMajorMinor(num string) (major, minor int) {
base := strings.SplitN(num, "-", 2)[0]
parts := strings.Split(base, ".")
if len(parts) < 2 {
return 0, 0
}
m1, err1 := strconv.Atoi(parts[0])
m2, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return 0, 0
}
return m1, m2
}
// probeKNNPlugin verifies the OpenSearch cluster has the opensearch-knn
// plugin installed on EVERY node. Without it on at least one node,
// knn_vector queries that route a shard to that node fail with opaque
// shard-failure errors — we surface this as a clear "plugin missing on
// N nodes" message at registration.
//
// Implementation: _cat/plugins returns one row per (node, plugin) pair.
// We group by node name and verify every distinct node has the
// opensearch-knn component.
func probeKNNPlugin(ctx context.Context, client *osapi.Client) error {
// CatPluginsReq's Params struct has no Format field — the SDK
// negotiates JSON via Accept header and returns a typed
// CatPluginsResp with .Plugins []CatPluginResp already parsed.
resp, err := client.Cat.Plugins(ctx, &osapi.CatPluginsReq{})
if err != nil {
return fmt.Errorf("opensearch: cat plugins: %w", wrapTransport(err))
}
// Group by node, verify every distinct node has opensearch-knn.
nodes := make(map[string]bool) // nodeName -> hasKNN
for _, p := range resp.Plugins {
if p.Name == "" {
continue
}
if _, seen := nodes[p.Name]; !seen {
nodes[p.Name] = false
}
if p.Component == "opensearch-knn" {
nodes[p.Name] = true
}
}
if len(nodes) == 0 {
return fmt.Errorf("opensearch: _cat/plugins returned no rows: %w", ErrConfigInvalid)
}
var missing []string
for node, hasKNN := range nodes {
if !hasKNN {
missing = append(missing, node)
}
}
if len(missing) > 0 {
return fmt.Errorf(
"opensearch: opensearch-knn plugin missing on %d/%d nodes (%v): %w",
len(missing), len(nodes), missing, ErrConfigInvalid,
)
}
return nil
}
// wrapTransport classifies a raw transport error into one of the
// driver's sentinel errors based on HTTP status ONLY. The cluster-side
// error message (err.Error()) is intentionally NOT included in the
// wrapped public error — it may contain internal index names, shard
// IDs, node hostnames, or document content snippets. The full SDK
// error is routed to log.Debug for operator forensics.
//
// Classification:
// - 401 / 403 → ErrAuth
// - 404 → caller decides (use isNotFound separately for index ops)
// - 429 + knn_circuit_breaker_exception → ErrCircuitBreaker
// - 5xx / network / timeout → ErrTransport
//
// This is the ONE place in the package that maps wire errors to
// sentinels; downstream functions just propagate.
func wrapTransport(err error) error {
if err == nil {
return nil
}
// Route the verbose SDK message to debug log (operator-visible, not
// user-visible). Background ctx is intentional — wrapTransport may
// be invoked from any caller including the constructor.
logger.GetLogger(context.Background()).Debugf("[OpenSearch] transport err: %v", err)
var se *opensearch.StructError
if errors.As(err, &se) {
switch se.Status {
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("authentication failed: %w", ErrAuth)
case http.StatusTooManyRequests:
if se.Err.Type == "knn_circuit_breaker_exception" {
return fmt.Errorf("circuit breaker open: %w", ErrCircuitBreaker)
}
}
}
return fmt.Errorf("transport error: %w", ErrTransport)
}
// isNotFound returns true if err carries an HTTP 404. Used by index /
// search code paths that want to distinguish "missing alias" from
// "transport problem".
func isNotFound(err error) bool {
var se *opensearch.StructError
return errors.As(err, &se) && se.Status == http.StatusNotFound
}
// isAlreadyExistsError returns true if err is a 400 with OpenSearch's
// resource_already_exists_exception type. Used by createIndexAndAlias
// to handle the cross-process race where two replicas try to create
// the same per-dim index simultaneously.
func isAlreadyExistsError(err error) bool {
if err == nil {
return false
}
var se *opensearch.StructError
if !errors.As(err, &se) {
return false
}
return se.Status == http.StatusBadRequest &&
se.Err.Type == "resource_already_exists_exception"
}
// sanitizeRe enforces the OpenSearch index-name contract:
// - lowercase ASCII letters, digits, underscore, hyphen only
// - must start with [a-z0-9]
// - length 1..255
var sanitizeRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,254}$`)
// sanitizeIndexName rejects names that violate the OpenSearch index-name
// rules. Any non-conforming input returns ErrConfigInvalid — the driver
// does NOT silently rewrite invalid input so operators get a clear
// error at registration.
func sanitizeIndexName(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("empty index name: %w", ErrConfigInvalid)
}
// Explicit reject of OpenSearch-reserved wildcards / separators /
// control characters. Some of these would already be caught by the
// regexp below, but a dedicated check keeps the error message
// specific.
if strings.ContainsAny(name, "*?,\n\r\t/\\") {
return "", fmt.Errorf("invalid char in %q: %w", name, ErrConfigInvalid)
}
if !sanitizeRe.MatchString(name) {
return "", fmt.Errorf("name %q must match %s: %w",
name, sanitizeRe.String(), ErrConfigInvalid)
}
return name, nil
}
// drainAndClose closes the response body after draining unread bytes —
// required for HTTP keep-alive connection reuse. opensearch-go does NOT
// drain the body for us; failing to drain on every response will starve
// the per-host connection pool under load.
func drainAndClose(body io.ReadCloser) {
if body == nil {
return
}
_, _ = io.Copy(io.Discard, body)
_ = body.Close()
}
// limitedDecode wraps json.NewDecoder with an io.LimitReader so a
// pathological or malicious cluster response cannot exhaust process
// memory. Callers pass the documented per-endpoint cap (e.g. 64MB for
// bulk, 16MB for search, 1MB for plugin probe).
func limitedDecode(body io.Reader, maxBytes int64, v any) error {
return json.NewDecoder(io.LimitReader(body, maxBytes)).Decode(v)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,205 @@
package opensearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// Retrieve dispatches to vectorRetrieve or keywordsRetrieve based on
// params.RetrieverType. Returns exactly one *RetrieveResult — the
// service-layer multi-store dispatcher depends on this one-bundle-per-
// driver protocol. Score in result is RAW [0, 1] passthrough (the
// OpenSearch k-NN plugin's SpaceType.COSINESIMIL.scoreTranslation has
// already mapped to (1 + cos) / 2 before the score reaches us).
//
// Dim resolution order:
// 1. params.AdditionalParams["dim"] if present and positive
// 2. len(params.Embedding) if positive
// 3. cross-dim multi-index search for keyword-only paths via
// "<base>_*" wildcard pattern
//
// Vector retrieve always requires a positive dim (it's intrinsic to k-NN).
func (r *Repository) Retrieve(
ctx context.Context,
params types.RetrieveParams,
) ([]*types.RetrieveResult, error) {
dim, multiIndex := resolveDim(params)
switch params.RetrieverType {
case types.VectorRetrieverType:
if dim == 0 {
return nil, fmt.Errorf(
"opensearch: vector retrieve requires embedding or AdditionalParams[\"dim\"]: %w",
ErrDimensionMismatch)
}
if err := r.ensureReady(ctx, dim); err != nil {
return nil, err
}
return r.vectorRetrieve(ctx, params, dim)
case types.KeywordsRetrieverType:
if multiIndex {
// BM25-only path without dim — search across "<base>_*"
// wildcard pattern. Slower than a single-index search but
// correct for admin / keyword-only callers.
return r.keywordsRetrieve(ctx, params, r.baseIndex+"_*")
}
if err := r.ensureReady(ctx, dim); err != nil {
return nil, err
}
return r.keywordsRetrieve(ctx, params, r.indexAlias(dim))
default:
return nil, fmt.Errorf("opensearch: unsupported retriever type %q", params.RetrieverType)
}
}
// resolveDim returns (dim, multiIndex). multiIndex=true means dim is
// unknown and the caller should use a wildcard alias pattern (keyword
// path only — vector retrieve cannot be dim-less).
func resolveDim(p types.RetrieveParams) (int, bool) {
if p.AdditionalParams != nil {
if v, ok := p.AdditionalParams["dim"].(int); ok && v > 0 {
return v, false
}
}
if len(p.Embedding) > 0 {
return len(p.Embedding), false
}
return 0, true // unknown — multi-index fallback for keyword
}
func (r *Repository) vectorRetrieve(
ctx context.Context, p types.RetrieveParams, dim int,
) ([]*types.RetrieveResult, error) {
body, err := buildKNNQuery(p.Embedding, effectiveTopK(ctx, p), p.Threshold, fromParams(p))
if err != nil {
return nil, err
}
hits, err := r.search(ctx, r.indexAlias(dim), body)
if err != nil {
return nil, err
}
return wrapResults(ctx, hits, types.VectorRetrieverType, types.MatchTypeEmbedding), nil
}
func (r *Repository) keywordsRetrieve(
ctx context.Context, p types.RetrieveParams, indexPattern string,
) ([]*types.RetrieveResult, error) {
body, err := buildKeywordQuery(p.Query, effectiveTopK(ctx, p), p.Threshold, fromParams(p))
if err != nil {
return nil, err
}
hits, err := r.search(ctx, indexPattern, body)
if err != nil {
return nil, err
}
return wrapResults(ctx, hits, types.KeywordsRetrieverType, types.MatchTypeKeywords), nil
}
// effectiveTopK returns the caller's TopK or 10 if unset. The 10 is a
// defensive default — every real caller sets TopK, so a TopK=10 in
// production logs indicates a caller bug. Caps at 10000 to stay below
// OpenSearch's index.max_result_window default.
func effectiveTopK(ctx context.Context, p types.RetrieveParams) int {
if p.TopK <= 0 {
logger.GetLogger(ctx).Warnf("[OpenSearch] Retrieve called with TopK<=0; defaulting to 10 (caller bug?)")
return 10
}
if p.TopK > 10000 {
return 10000
}
return p.TopK
}
func (r *Repository) search(ctx context.Context, indexPattern string, body []byte) ([]hit, error) {
req := osapi.SearchReq{Indices: []string{indexPattern}, Body: bytes.NewReader(body)}
resp, err := r.client.Search(ctx, &req)
if err != nil {
if isNotFound(err) {
return nil, fmt.Errorf("opensearch: index %s missing: %w", indexPattern, ErrIndexNotFound)
}
return nil, wrapTransport(err)
}
defer func() {
if resp != nil {
drainAndClose(resp.Inspect().Response.Body)
}
}()
// Cap response body at 16MB (search results bounded by max_result_window * doc_size).
return parseSearchHits(io.LimitReader(resp.Inspect().Response.Body, 16<<20))
}
// hit is the structural shape we pull from each OpenSearch search hit.
// Field-by-field decode (vs map[string]any) keeps the JSON shape
// pinned at compile time.
type hit struct {
ID string `json:"_id"` // equals chunk_id per the indexing invariant
Score float64 `json:"_score"`
Source struct {
Content string `json:"content"`
ChunkID string `json:"chunk_id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
SourceID string `json:"source_id"`
SourceType int `json:"source_type"` // integer, not stringified
TagID string `json:"tag_id"`
IsEnabled bool `json:"is_enabled"`
} `json:"_source"`
}
func parseSearchHits(body io.Reader) ([]hit, error) {
var resp struct {
Hits struct {
Hits []hit `json:"hits"`
} `json:"hits"`
}
if err := json.NewDecoder(body).Decode(&resp); err != nil {
return nil, fmt.Errorf("opensearch: parse search response: %w", ErrTransport)
}
return resp.Hits.Hits, nil
}
// wrapResults converts OpenSearch hits to the upstream IndexWithScore
// shape. Always returns exactly one *RetrieveResult — the service-layer
// multi-store dispatcher depends on this "one engine result per driver"
// contract.
//
// Defensive: warns if hit._id != _source.chunk_id (we set _id=chunk_id
// on every write, so a mismatch means external tooling has been writing
// into the index) but does not fail the query — the result is still
// usable for ranking, just suspicious.
func wrapResults(ctx context.Context, hits []hit, rt types.RetrieverType, mt types.MatchType) []*types.RetrieveResult {
log := logger.GetLogger(ctx)
out := make([]*types.IndexWithScore, 0, len(hits))
for _, h := range hits {
if h.ID != h.Source.ChunkID {
log.Warnf("[OpenSearch] hit._id=%q != _source.chunk_id=%q (D12 invariant violation)",
h.ID, h.Source.ChunkID)
}
out = append(out, &types.IndexWithScore{
ID: h.ID,
ChunkID: h.Source.ChunkID,
KnowledgeID: h.Source.KnowledgeID,
KnowledgeBaseID: h.Source.KnowledgeBaseID,
SourceID: h.Source.SourceID,
SourceType: types.SourceType(h.Source.SourceType),
TagID: h.Source.TagID,
Content: h.Source.Content,
Score: h.Score,
MatchType: mt,
IsEnabled: h.Source.IsEnabled,
})
}
return []*types.RetrieveResult{{
Results: out,
RetrieverEngineType: types.OpenSearchRetrieverEngineType,
RetrieverType: rt,
}}
}
@@ -0,0 +1,16 @@
package opensearch
import (
"os"
"testing"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func TestMain(m *testing.M) {
// End-to-end repository tests intentionally use loopback httptest servers.
secutils.SetSSRFWhitelistFromRaw("127.0.0.1,::1,localhost,opensearch.example.com")
code := m.Run()
secutils.SetSSRFWhitelistFromRaw("")
os.Exit(code)
}
@@ -0,0 +1,52 @@
package opensearch
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// This file holds the remaining stubs for methods whose real implementation
// has not landed yet — the rolling-reindex swap (swapToVersion) and the
// precise storage estimate. CopyIndices (copy.go), BatchUpdateChunkEnabledStatus
// and BatchUpdateChunkTagID (bulk_update.go) are now implemented. Each
// remaining stub returns ErrFeatureNotEnabled (or, for EstimateStorageSize, a
// conservative lower-bound) so any accidental invocation surfaces loudly.
// EstimateStorageSize: the real implementation that reads cluster
// `_stats` for the per-dim alias arrives in a later change. For now we
// return a conservative lower-bound estimate using the HNSW memory
// formula so the upstream KB-delete guard fails-closed (treats non-empty
// KBs as "may free non-trivial storage, force confirmation") rather than
// failing open.
//
// Formula: N * (1024 content bytes + 4*dimGuess float + 128 HNSW M=16 overhead)
// dimGuess = 768 (common embedding size; the real implementation reads
// the actual dim from the cluster).
func (r *Repository) EstimateStorageSize(
_ context.Context,
indexInfoList []*types.IndexInfo,
_ map[string]any,
) int64 {
if len(indexInfoList) == 0 {
return 0
}
const (
contentBytes = 1024 // average chunk content
embDimGuess = 768 // common embedding size
hnswOverhead = 128 // M=16 → 8*M = 128 bytes/vector
)
return int64(len(indexInfoList)) * int64(contentBytes+4*embDimGuess+hnswOverhead)
}
// swapToVersion is a stub for the future rolling-reindex swap path.
// Calling it is illegal at this stage — alias is fixed at "_v1" and only
// a follow-up change ships the swap orchestration. The surface exists so
// the future API contract is reviewable now.
//
// Unexported because it is not part of the public
// RetrieveEngineRepository interface — exposing it would require an
// interface widening in internal/types/interfaces.
func (r *Repository) swapToVersion(_ context.Context, _ int, _ int) error {
return ErrFeatureNotEnabled
}
@@ -0,0 +1,80 @@
package opensearch
import (
"crypto/tls"
"fmt"
"net/http"
"time"
"github.com/opensearch-project/opensearch-go/v4"
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// NewOpenSearchClient builds a TLS-hardened, pool-tuned *osapi.Client for
// the OpenSearch driver. The caller wires it into the registry from the
// env path (container) and the DB-store path (engine factory); the
// Repository constructor itself receives the pre-built client.
//
// TLS posture:
// - MinVersion: TLS 1.2 (TLS 1.3 negotiated when both ends support).
// - InsecureSkipVerify: opt-in via ConnectionConfig.InsecureSkipVerify.
// Default false. Operators set true ONLY for self-signed dev clusters.
// - CipherSuites: explicit forward-secrecy-only list. TLS 1.2's default
// cipher list includes TLS_RSA_* (no forward secrecy); we drop them.
// TLS 1.3 cipher selection is automatic and safe.
//
// Transport tuning:
// - MaxIdleConnsPerHost: 32 (Go default 2 is too low when callers
// dispatch concurrent queries across multiple knowledge bases)
// - IdleConnTimeout: 90s (typical LB keep-alive)
// - ResponseHeaderTimeout: 30s (per-request safety net)
// - ExpectContinueTimeout: 1s
func NewOpenSearchClient(cfg *types.ConnectionConfig) (*osapi.Client, error) {
if cfg == nil || cfg.Addr == "" {
return nil, fmt.Errorf("opensearch: ConnectionConfig.Addr required: %w", ErrConfigInvalid)
}
if err := secutils.ValidateURLForSSRF(cfg.Addr); err != nil {
return nil, fmt.Errorf("opensearch: address failed SSRF validation: %w", err)
}
transport := buildHTTPTransport(cfg.InsecureSkipVerify)
return osapi.NewClient(osapi.Config{
Client: opensearch.Config{
Addresses: []string{cfg.Addr},
Username: cfg.Username,
Password: cfg.Password,
Transport: &secutils.SSRFValidatingRoundTripper{Base: transport},
},
})
}
// buildHTTPTransport constructs the TLS-hardened, pool-tuned
// *http.Transport used by every OpenSearch client this driver creates.
// Extracted from newOpenSearchClient so unit tests can pin the TLS
// posture directly (the opensearch-go SDK wraps the transport in an
// internal interface, making reflection-based introspection of the
// final client fragile across SDK versions).
func buildHTTPTransport(insecureSkipVerify bool) *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: secutils.SSRFSafeDialContext,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: insecureSkipVerify, //nolint:gosec — operator opt-in flag
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
},
},
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
}
@@ -0,0 +1,137 @@
package opensearch
import (
"crypto/tls"
"errors"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
// TestNewOpenSearchClient_RejectsEmptyAddr verifies the constructor
// refuses an empty Addr — operators who forget to set OPENSEARCH_ADDR
// get a clear ErrConfigInvalid at registration instead of a cryptic
// transport error later.
func TestNewOpenSearchClient_RejectsEmptyAddr(t *testing.T) {
t.Parallel()
_, err := NewOpenSearchClient(&types.ConnectionConfig{Addr: ""})
if !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("empty addr: want ErrConfigInvalid, got %v", err)
}
_, err = NewOpenSearchClient(nil)
if !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("nil cfg: want ErrConfigInvalid, got %v", err)
}
}
// TestNewOpenSearchClient_Succeeds_OnValidAddr is a smoke test for the
// happy path — a non-empty Addr must produce a non-nil *osapi.Client.
// We don't probe the cluster (no network), just verify the constructor
// returns successfully.
func TestNewOpenSearchClient_Succeeds_OnValidAddr(t *testing.T) {
t.Parallel()
client, err := NewOpenSearchClient(&types.ConnectionConfig{
Addr: "https://opensearch.example.com:9200",
Username: "admin",
Password: "secret", // not a real password — wire-format only
})
if err != nil {
t.Fatalf("NewOpenSearchClient: %v", err)
}
if client == nil {
t.Fatal("client must be non-nil on success")
}
}
// TestBuildHTTPTransport_DefaultsToSecureTLS pins the default
// InsecureSkipVerify=false. Operators who don't opt in MUST get a
// strict-verify HTTPS client.
func TestBuildHTTPTransport_DefaultsToSecureTLS(t *testing.T) {
t.Parallel()
tr := buildHTTPTransport(false)
if tr.TLSClientConfig == nil {
t.Fatal("TLSClientConfig must be set")
}
if tr.TLSClientConfig.InsecureSkipVerify {
t.Fatal("default InsecureSkipVerify must be false (secure)")
}
}
// TestBuildHTTPTransport_InsecureSkipVerifyOptIn confirms the flag
// flows through to tls.Config. Self-signed dev clusters can opt in;
// production deployments MUST leave it false.
func TestBuildHTTPTransport_InsecureSkipVerifyOptIn(t *testing.T) {
t.Parallel()
tr := buildHTTPTransport(true)
if tr.TLSClientConfig == nil {
t.Fatal("TLSClientConfig must be set")
}
if !tr.TLSClientConfig.InsecureSkipVerify {
t.Fatal("InsecureSkipVerify=true must propagate to tls.Config")
}
}
// TestBuildHTTPTransport_TLS12MinVersion pins the TLS 1.2 hard floor.
// TLS 1.3 negotiates automatically when both ends support it; 1.0 / 1.1
// must be rejected at handshake time.
func TestBuildHTTPTransport_TLS12MinVersion(t *testing.T) {
t.Parallel()
tr := buildHTTPTransport(false)
if tr.TLSClientConfig.MinVersion != tls.VersionTLS12 {
t.Fatalf("MinVersion: want tls.VersionTLS12 (0x%x), got 0x%x",
tls.VersionTLS12, tr.TLSClientConfig.MinVersion)
}
}
// TestBuildHTTPTransport_CipherSuitesExcludeNonECDHE pins the explicit
// cipher list to forward-secrecy-only ECDHE suites. TLS 1.2's default
// list includes TLS_RSA_* (no forward secrecy); we drop them. TLS 1.3
// cipher selection is automatic so this only matters for the 1.2 path.
func TestBuildHTTPTransport_CipherSuitesExcludeNonECDHE(t *testing.T) {
t.Parallel()
tr := buildHTTPTransport(false)
cs := tr.TLSClientConfig.CipherSuites
if len(cs) == 0 {
t.Fatal("CipherSuites must be explicitly pinned (got empty)")
}
allowed := map[uint16]bool{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: true,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: true,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: true,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: true,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305: true,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305: true,
}
for _, suite := range cs {
if !allowed[suite] {
t.Fatalf("cipher suite 0x%x is not in the forward-secrecy whitelist", suite)
}
}
// Spot-check that a known-weak non-ECDHE cipher is NOT in the list.
for _, suite := range cs {
if suite == tls.TLS_RSA_WITH_AES_128_GCM_SHA256 {
t.Fatal("TLS_RSA_WITH_AES_128_GCM_SHA256 must NOT be present (no forward secrecy)")
}
}
}
// TestBuildHTTPTransport_PoolTuning pins the connection-pool settings.
// The Go default MaxIdleConnsPerHost is 2, which is too low for concurrent
// queries across multiple knowledge bases.
func TestBuildHTTPTransport_PoolTuning(t *testing.T) {
t.Parallel()
tr := buildHTTPTransport(false)
if tr.MaxIdleConnsPerHost < 32 {
t.Errorf("MaxIdleConnsPerHost: want >=32 for concurrent KB queries, got %d",
tr.MaxIdleConnsPerHost)
}
if tr.IdleConnTimeout == 0 {
t.Error("IdleConnTimeout must be set (Go default of 0 leaks idle conns)")
}
if tr.ResponseHeaderTimeout == 0 {
t.Error("ResponseHeaderTimeout must be set (Go default of 0 means unbounded)")
}
if tr.ExpectContinueTimeout == 0 {
t.Error("ExpectContinueTimeout must be set (avoids 100-continue stalls)")
}
}