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
+66
View File
@@ -0,0 +1,66 @@
package container
import (
"context"
"encoding/json"
"github.com/Tencent/WeKnora/internal/application/repository/retriever/opensearch"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// auditSinkAdapter bridges the OpenSearch driver's AuditSink (which the driver
// owns so it imports no service package) to the service-layer AuditLogService.
// This keeps the dependency one-way: the driver depends only on its own
// AuditSink abstraction; the container implements it.
type auditSinkAdapter struct {
svc interfaces.AuditLogService
}
// newAuditSinkAdapter returns an opensearch.AuditSink backed by svc. A nil svc
// yields a sink whose emits are no-ops.
func newAuditSinkAdapter(svc interfaces.AuditLogService) opensearch.AuditSink {
return auditSinkAdapter{svc: svc}
}
func (a auditSinkAdapter) EmitIndexCreated(ctx context.Context, alias string, dim int) {
a.emit(ctx, types.AuditActionOpenSearchIndexCreated, alias,
map[string]any{"alias": alias, "dim": dim})
}
func (a auditSinkAdapter) EmitReindexExecuted(ctx context.Context, srcAlias, dstAlias string, docs int64) {
a.emit(ctx, types.AuditActionOpenSearchReindexExecuted, dstAlias,
map[string]any{"src_alias": srcAlias, "dst_alias": dstAlias, "docs": docs})
}
// emit writes one audit entry. It skips (with a warning) when the context
// carries no tenant — driver events can fire from background task contexts
// (e.g. lazy index creation under an async copy task), and writing tenant_id=0
// would collide with the system-scope sentinel and corrupt the audit trail.
func (a auditSinkAdapter) emit(ctx context.Context, action types.AuditAction, target string, detail map[string]any) {
if a.svc == nil {
return
}
tid, ok := types.TenantIDFromContext(ctx)
if !ok {
logger.GetLogger(ctx).Warnf("[audit] %s: no tenant in context, skipping audit (target=%s)", action, target)
return
}
// Details is a typed JSON blob — only bounded, non-secret fields. Never
// include cluster reason strings or connection secrets.
b, err := json.Marshal(detail)
if err != nil {
logger.GetLogger(ctx).Warnf("[audit] %s: marshal details failed: %v", action, err)
b = []byte("{}")
}
if err := a.svc.Log(ctx, &types.AuditLog{
TenantID: tid,
Action: action,
TargetType: "opensearch_index",
TargetID: target,
Details: types.JSON(b),
}); err != nil {
logger.GetLogger(ctx).Warnf("[audit] %s emit failed: %v", action, err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package container
import (
"context"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// fakeAuditSvc records Log calls.
type fakeAuditSvc struct {
logged []*types.AuditLog
err error
}
func (f *fakeAuditSvc) Log(_ context.Context, e *types.AuditLog) error {
f.logged = append(f.logged, e)
return f.err
}
func (f *fakeAuditSvc) LogDenied(context.Context, *gin.Context, uint64, string, string, types.TenantRole) error {
return nil
}
func (f *fakeAuditSvc) List(context.Context, uint64, *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
return nil, nil
}
func (f *fakeAuditSvc) Purge(context.Context, int) (int64, error) { return 0, nil }
func ctxWithTenant(id uint64) context.Context {
return context.WithValue(context.Background(), types.TenantIDContextKey, id)
}
func TestAuditSinkAdapter_EmitsWithTenant(t *testing.T) {
f := &fakeAuditSvc{}
sink := newAuditSinkAdapter(f)
sink.EmitIndexCreated(ctxWithTenant(42), "weknora_768", 768)
if len(f.logged) != 1 {
t.Fatalf("want 1 audit entry, got %d", len(f.logged))
}
e := f.logged[0]
if e.TenantID != 42 {
t.Errorf("tenant: want 42, got %d", e.TenantID)
}
if e.Action != types.AuditActionOpenSearchIndexCreated {
t.Errorf("action: want %s, got %s", types.AuditActionOpenSearchIndexCreated, e.Action)
}
if e.Details == nil {
t.Error("details should be populated")
}
sink.EmitReindexExecuted(ctxWithTenant(7), "src", "dst", 9)
if len(f.logged) != 2 || f.logged[1].Action != types.AuditActionOpenSearchReindexExecuted {
t.Errorf("reindex audit not recorded: %+v", f.logged)
}
}
func TestAuditSinkAdapter_SkipsWithoutTenant(t *testing.T) {
f := &fakeAuditSvc{}
sink := newAuditSinkAdapter(f)
// background ctx carries no tenant → adapter must skip (never write tenant=0)
sink.EmitIndexCreated(context.Background(), "weknora_768", 768)
sink.EmitReindexExecuted(context.Background(), "a", "b", 1)
if len(f.logged) != 0 {
t.Errorf("want 0 audit entries without tenant, got %d", len(f.logged))
}
}
func TestAuditSinkAdapter_NilServiceNoPanic(t *testing.T) {
sink := newAuditSinkAdapter(nil)
sink.EmitIndexCreated(ctxWithTenant(1), "x", 1) // must not panic
}
+86
View File
@@ -0,0 +1,86 @@
package container
import (
"context"
"log"
"sync"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// ResourceCleaner is a resource cleaner that can be used to clean up resources
type ResourceCleaner struct {
mu sync.Mutex
cleanups []types.CleanupFunc
}
// NewResourceCleaner creates a new resource cleaner
func NewResourceCleaner() interfaces.ResourceCleaner {
return &ResourceCleaner{
cleanups: make([]types.CleanupFunc, 0),
}
}
// Register registers a cleanup function
// Note: the cleanup function will be executed in reverse order (the last registered will be executed first)
func (c *ResourceCleaner) Register(cleanup types.CleanupFunc) {
if cleanup == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.cleanups = append(c.cleanups, cleanup)
}
// RegisterWithName registers a cleanup function with a name, for logging tracking
func (c *ResourceCleaner) RegisterWithName(name string, cleanup types.CleanupFunc) {
if cleanup == nil {
return
}
wrappedCleanup := func() error {
log.Printf("Cleaning up resource: %s", name)
err := cleanup()
if err != nil {
log.Printf("Error cleaning up resource %s: %v", name, err)
} else {
log.Printf("Successfully cleaned up resource: %s", name)
}
return err
}
c.Register(wrappedCleanup)
}
// Cleanup executes all cleanup functions
// Even if a cleanup function fails, other cleanup functions will still be executed
func (c *ResourceCleaner) Cleanup(ctx context.Context) (errs []error) {
c.mu.Lock()
defer c.mu.Unlock()
// Execute cleanup functions in reverse order (the last registered will be executed first)
for i := len(c.cleanups) - 1; i >= 0; i-- {
select {
case <-ctx.Done():
errs = append(errs, ctx.Err())
return errs
default:
if err := c.cleanups[i](); err != nil {
errs = append(errs, err)
}
}
}
return errs
}
// Reset clears all registered cleanup functions
func (c *ResourceCleaner) Reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.cleanups = make([]types.CleanupFunc, 0)
}
File diff suppressed because it is too large Load Diff
+391
View File
@@ -0,0 +1,391 @@
package container
import (
"context"
"database/sql"
"fmt"
"net"
"strconv"
"strings"
"time"
esv7 "github.com/elastic/go-elasticsearch/v7"
"github.com/elastic/go-elasticsearch/v8"
"github.com/go-sql-driver/mysql" // 通过 database/sql 注册 mysql 驱动给 Doris 使用
"github.com/milvus-io/milvus/client/v2/milvusclient"
"github.com/qdrant/go-client/qdrant"
"github.com/weaviate/weaviate-go-client/v5/weaviate"
"github.com/weaviate/weaviate-go-client/v5/weaviate/auth"
wgrpc "github.com/weaviate/weaviate-go-client/v5/weaviate/grpc"
"google.golang.org/grpc"
"gorm.io/gorm"
dorisRepo "github.com/Tencent/WeKnora/internal/application/repository/retriever/doris"
elasticsearchRepoV7 "github.com/Tencent/WeKnora/internal/application/repository/retriever/elasticsearch/v7"
elasticsearchRepoV8 "github.com/Tencent/WeKnora/internal/application/repository/retriever/elasticsearch/v8"
milvusRepo "github.com/Tencent/WeKnora/internal/application/repository/retriever/milvus"
openSearchRepo "github.com/Tencent/WeKnora/internal/application/repository/retriever/opensearch"
postgresRepo "github.com/Tencent/WeKnora/internal/application/repository/retriever/postgres"
qdrantRepo "github.com/Tencent/WeKnora/internal/application/repository/retriever/qdrant"
sqliteRetrieverRepo "github.com/Tencent/WeKnora/internal/application/repository/retriever/sqlite"
tencentVectorDBRepo "github.com/Tencent/WeKnora/internal/application/repository/retriever/tencentvectordb"
weaviateRepo "github.com/Tencent/WeKnora/internal/application/repository/retriever/weaviate"
"github.com/Tencent/WeKnora/internal/application/service/retriever"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/Tencent/WeKnora/internal/utils"
"github.com/tencent/vectordatabase-sdk-go/tcvectordb"
)
// NewEngineFactory returns an EngineFactory function closed over db, cfg, and
// an audit sink (built from the AuditLogService). Registered in dig and
// injected into VectorStoreService for dynamic registry updates. The
// EngineFactory type itself is unchanged — the audit sink is captured in the
// closure rather than added to the signature.
func NewEngineFactory(db *gorm.DB, cfg *config.Config, auditSvc interfaces.AuditLogService) interfaces.EngineFactory {
sink := newAuditSinkAdapter(auditSvc)
return func(ctx context.Context, store types.VectorStore) (interfaces.RetrieveEngineService, error) {
return createEngineServiceFromStore(ctx, store, db, cfg, sink)
}
}
// createEngineServiceFromStore creates a RetrieveEngineService from a VectorStore's config.
// This is the DB store counterpart of the env-based initialization in initRetrieveEngineRegistry.
// auditSink may be nil (audit becomes a no-op).
func createEngineServiceFromStore(
ctx context.Context,
store types.VectorStore,
db *gorm.DB,
cfg *config.Config,
auditSink openSearchRepo.AuditSink,
) (interfaces.RetrieveEngineService, error) {
if err := validateRuntimeVectorStoreAddresses(store); err != nil {
return nil, err
}
switch store.EngineType {
case types.PostgresRetrieverEngineType:
return createPostgresEngine(store, db)
case types.ElasticsearchRetrieverEngineType:
return createElasticsearchEngine(store, cfg)
case types.QdrantRetrieverEngineType:
return createQdrantEngine(store)
case types.MilvusRetrieverEngineType:
return createMilvusEngine(ctx, store)
case types.WeaviateRetrieverEngineType:
return createWeaviateEngine(store)
case types.DorisRetrieverEngineType:
return createDorisEngine(store)
case types.SQLiteRetrieverEngineType:
return createSQLiteEngine(store, db)
case types.TencentVectorDBRetrieverEngineType:
return createTencentVectorDBEngine(store)
case types.OpenSearchRetrieverEngineType:
return createOpenSearchEngine(ctx, store, auditSink)
default:
return nil, fmt.Errorf("unsupported engine type: %s", store.EngineType)
}
}
// validateRuntimeVectorStoreAddresses is the final guard for persisted or
// imported vector-store rows. Create/test handlers validate the same fields at
// the input boundary, but runtime construction must not assume every stored row
// was written through those handlers.
func validateRuntimeVectorStoreAddresses(store types.VectorStore) error {
cc := store.ConnectionConfig
check := func(label, endpoint string) error {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return nil
}
if err := utils.ValidateURLForSSRF(endpoint); err != nil {
return fmt.Errorf("%s failed SSRF validation: %w", label, err)
}
return nil
}
switch store.EngineType {
case types.PostgresRetrieverEngineType, types.SQLiteRetrieverEngineType:
return nil
case types.ElasticsearchRetrieverEngineType,
types.OpenSearchRetrieverEngineType,
types.MilvusRetrieverEngineType,
types.TencentVectorDBRetrieverEngineType,
types.DorisRetrieverEngineType:
return check("vector store address", cc.Addr)
case types.QdrantRetrieverEngineType:
endpoint := cc.Host
if endpoint != "" && cc.Port != 0 {
endpoint = net.JoinHostPort(strings.Trim(cc.Host, "[]"), strconv.Itoa(cc.Port))
}
return check("qdrant address", endpoint)
case types.WeaviateRetrieverEngineType:
if err := check("weaviate HTTP address", cc.Host); err != nil {
return err
}
return check("weaviate gRPC address", cc.GrpcAddress)
default:
return fmt.Errorf("vector store engine %q has no SSRF address policy", store.EngineType)
}
}
// createOpenSearchEngine builds an OpenSearch k-NN retrieve engine. Mirrors
// createElasticsearchV8Engine but uses the driver's TLS-hardened client
// constructor and injects the audit sink. NewRepository probes the cluster
// (version + k-NN plugin), so an unreachable cluster fails here at
// registration rather than on first query.
func createOpenSearchEngine(
ctx context.Context, store types.VectorStore, auditSink openSearchRepo.AuditSink,
) (interfaces.RetrieveEngineService, error) {
client, err := openSearchRepo.NewOpenSearchClient(&store.ConnectionConfig)
if err != nil {
return nil, fmt.Errorf("create opensearch client: %w", err)
}
// Env stores share the cluster without a per-store index prefix; DB stores
// fold their (>=16-char) ID into the index name. NewRepository enforces the
// length rule, so map env-store IDs to "".
storeID := store.ID
if types.IsEnvStoreID(storeID) {
storeID = ""
}
repo, err := openSearchRepo.NewRepository(ctx, client, storeID, &store.IndexConfig,
openSearchRepo.WithAuditSink(auditSink))
if err != nil {
return nil, fmt.Errorf("create opensearch repository: %w", err)
}
return retriever.NewKVHybridRetrieveEngine(repo, types.OpenSearchRetrieverEngineType), nil
}
func createPostgresEngine(store types.VectorStore, db *gorm.DB) (interfaces.RetrieveEngineService, error) {
if store.ConnectionConfig.UseDefaultConnection {
repo := postgresRepo.NewPostgresRetrieveEngineRepository(db)
return retriever.NewKVHybridRetrieveEngine(repo, types.PostgresRetrieverEngineType), nil
}
// Phase 1: only UseDefaultConnection is supported.
// Custom connections require connection pool management and migration handling.
return nil, fmt.Errorf("custom postgres connections not yet supported; use use_default_connection=true")
}
func createSQLiteEngine(_ types.VectorStore, db *gorm.DB) (interfaces.RetrieveEngineService, error) {
repo := sqliteRetrieverRepo.NewSQLiteRetrieveEngineRepository(db)
return retriever.NewKVHybridRetrieveEngine(repo, types.SQLiteRetrieverEngineType), nil
}
func createElasticsearchEngine(store types.VectorStore, cfg *config.Config) (interfaces.RetrieveEngineService, error) {
cc := store.ConnectionConfig
// Version-based v7/v8 SDK selection.
// Version is auto-detected by PR2's TestConnection and saved to connection_config.
// Empty version defaults to v8 (latest SDK).
if isESv7(cc.Version) {
return createElasticsearchV7Engine(store, cfg)
}
return createElasticsearchV8Engine(store, cfg)
}
// isESv7 checks if the detected ES version is 7.x.
func isESv7(version string) bool {
return strings.HasPrefix(version, "7.")
}
func createElasticsearchV8Engine(store types.VectorStore, cfg *config.Config) (interfaces.RetrieveEngineService, error) {
cc := store.ConnectionConfig
if err := utils.ValidateURLForSSRF(cc.Addr); err != nil {
return nil, fmt.Errorf("elasticsearch address failed SSRF validation: %w", err)
}
client, err := elasticsearch.NewTypedClient(elasticsearch.Config{
Addresses: []string{cc.Addr},
Username: cc.Username,
Password: cc.Password,
Transport: &utils.SSRFValidatingRoundTripper{
Base: utils.NewSSRFSafeTransport(utils.DefaultSSRFSafeHTTPClientConfig()),
},
})
if err != nil {
return nil, fmt.Errorf("create elasticsearch v8 client: %w", err)
}
repo := elasticsearchRepoV8.NewElasticsearchEngineRepository(client, cfg, &store.IndexConfig)
return retriever.NewKVHybridRetrieveEngine(repo, types.ElasticsearchRetrieverEngineType), nil
}
func createElasticsearchV7Engine(store types.VectorStore, cfg *config.Config) (interfaces.RetrieveEngineService, error) {
cc := store.ConnectionConfig
if err := utils.ValidateURLForSSRF(cc.Addr); err != nil {
return nil, fmt.Errorf("elasticsearch address failed SSRF validation: %w", err)
}
client, err := esv7.NewClient(esv7.Config{
Addresses: []string{cc.Addr},
Username: cc.Username,
Password: cc.Password,
Transport: &utils.SSRFValidatingRoundTripper{
Base: utils.NewSSRFSafeTransport(utils.DefaultSSRFSafeHTTPClientConfig()),
},
})
if err != nil {
return nil, fmt.Errorf("create elasticsearch v7 client: %w", err)
}
repo := elasticsearchRepoV7.NewElasticsearchEngineRepository(client, cfg, &store.IndexConfig)
return retriever.NewKVHybridRetrieveEngine(repo, types.ElasticsearchRetrieverEngineType), nil
}
func createQdrantEngine(store types.VectorStore) (interfaces.RetrieveEngineService, error) {
cc := store.ConnectionConfig
port := cc.Port
if port == 0 {
port = 6334
}
client, err := qdrant.NewClient(&qdrant.Config{
Host: cc.Host,
Port: port,
APIKey: cc.APIKey,
UseTLS: cc.UseTLS,
GrpcOptions: []grpc.DialOption{grpc.WithContextDialer(utils.SSRFSafeGRPCDialer)},
})
if err != nil {
return nil, fmt.Errorf("create qdrant client: %w", err)
}
repo := qdrantRepo.NewQdrantRetrieveEngineRepository(client, &store.IndexConfig)
return retriever.NewKVHybridRetrieveEngine(repo, types.QdrantRetrieverEngineType), nil
}
func createMilvusEngine(ctx context.Context, store types.VectorStore) (interfaces.RetrieveEngineService, error) {
milvusCfg := buildMilvusClientConfig(store.ConnectionConfig)
client, err := milvusclient.New(ctx, &milvusCfg)
if err != nil {
return nil, fmt.Errorf("create milvus client: %w", err)
}
repo := milvusRepo.NewMilvusRetrieveEngineRepository(client, &store.IndexConfig)
return retriever.NewKVHybridRetrieveEngine(repo, types.MilvusRetrieverEngineType), nil
}
func buildMilvusClientConfig(cc types.ConnectionConfig) milvusclient.ClientConfig {
addr := cc.Addr
if addr == "" {
addr = "localhost:19530"
}
milvusCfg := milvusclient.ClientConfig{
Address: addr,
DialOptions: []grpc.DialOption{
grpc.WithTimeout(5 * time.Second),
grpc.WithContextDialer(utils.SSRFSafeGRPCDialer),
},
}
if cc.Username != "" {
milvusCfg.Username = cc.Username
}
if cc.Password != "" {
milvusCfg.Password = cc.Password
}
if cc.Database != "" {
milvusCfg.DBName = cc.Database
}
return milvusCfg
}
func createWeaviateEngine(store types.VectorStore) (interfaces.RetrieveEngineService, error) {
cc := store.ConnectionConfig
host := cc.Host
if host == "" {
host = "weaviate:8080"
}
grpcAddress := cc.GrpcAddress
if grpcAddress == "" {
grpcAddress = "weaviate:50051"
}
scheme := cc.Scheme
if scheme == "" {
scheme = "http"
}
weaviateCfg := weaviate.Config{
Host: host,
ConnectionClient: utils.NewSSRFSafeHTTPClient(utils.DefaultSSRFSafeHTTPClientConfig()),
GrpcConfig: &wgrpc.Config{
Host: grpcAddress,
},
Scheme: scheme,
}
// Unlike the env path (which checks WEAVIATE_AUTH_ENABLED), the factory uses
// APIKey directly — if a user provides it, they intend to use it.
if cc.APIKey != "" {
weaviateCfg.AuthConfig = auth.ApiKey{Value: cc.APIKey}
}
client, err := weaviate.NewClient(weaviateCfg)
if err != nil {
return nil, fmt.Errorf("create weaviate client: %w", err)
}
repo := weaviateRepo.NewWeaviateRetrieveEngineRepository(client, &store.IndexConfig)
return retriever.NewKVHybridRetrieveEngine(repo, types.WeaviateRetrieverEngineType), nil
}
// createDorisEngine 创建 Apache Doris 检索引擎服务。
//
// Doris 同时使用两个端口:
// - MySQL 协议(默认 9030)走 database/sql 做主链路读写;
// - HTTP(默认 FE 8030)走 Stream Load 做 partial update。
//
// Addr 字段承担 host:9030 的 MySQL 端点;HTTPPort + Addr 的 host 部分组成 HTTP base URL。
func createDorisEngine(store types.VectorStore) (interfaces.RetrieveEngineService, error) {
cc := store.ConnectionConfig
if cc.Addr == "" {
return nil, fmt.Errorf("doris connection requires addr (host:port)")
}
if cc.Database == "" {
return nil, fmt.Errorf("doris connection requires database")
}
mc := mysql.NewConfig()
mc.User = cc.Username
mc.Passwd = cc.Password
utils.RegisterMySQLSSRFDialer()
mc.Net = utils.MySQLSSRFNetwork
mc.Addr = cc.Addr
mc.DBName = cc.Database
mc.Params = map[string]string{"charset": "utf8mb4"}
mc.ParseTime = true
mc.Loc = time.Local
db, err := sql.Open("mysql", mc.FormatDSN())
if err != nil {
return nil, fmt.Errorf("create doris client: %w", err)
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
httpPort := cc.HTTPPort
if httpPort <= 0 {
httpPort = 8030
}
httpBase := "http://" + hostFromAddr(cc.Addr) + ":" + strconv.Itoa(httpPort)
repo := dorisRepo.NewDorisRetrieveEngineRepository(
db, httpBase, cc.Username, cc.Password, cc.Database, &store.IndexConfig,
)
return retriever.NewKVHybridRetrieveEngine(repo, types.DorisRetrieverEngineType), nil
}
// hostFromAddr 从 "host:port" 中拆出 host 部分;Addr 没有冒号时整段当作 host。
func hostFromAddr(addr string) string {
if i := strings.LastIndex(addr, ":"); i > 0 {
return addr[:i]
}
return addr
}
func createTencentVectorDBEngine(store types.VectorStore) (interfaces.RetrieveEngineService, error) {
cc := store.ConnectionConfig
client, err := tcvectordb.NewRpcClient(cc.Addr, cc.Username, cc.APIKey, &tcvectordb.ClientOption{
ReadConsistency: tcvectordb.EventualConsistency,
Timeout: 10 * time.Second,
Transport: &utils.SSRFValidatingRoundTripper{
Base: utils.NewSSRFSafeTransport(utils.DefaultSSRFSafeHTTPClientConfig()),
},
})
if err != nil {
return nil, fmt.Errorf("create tencent vectordb client: %w", err)
}
repo := tencentVectorDBRepo.NewTencentVectorDBRetrieveEngineRepository(client, cc.Database, &store.IndexConfig)
return retriever.NewKVHybridRetrieveEngine(repo, types.TencentVectorDBRetrieverEngineType), nil
}
@@ -0,0 +1,106 @@
package container
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
)
// osClusterHandler simulates an OpenSearch cluster for the driver's
// construction probe: GET / (version) + /_cat/plugins (k-NN on every node).
func osClusterHandler(distribution, number string, knnInstalled bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/_cat/plugins" {
if knnInstalled {
_, _ = w.Write([]byte(`[{"name":"node-1","component":"opensearch-knn"}]`))
} else {
_, _ = w.Write([]byte(`[{"name":"node-1","component":"opensearch-sql"}]`))
}
return
}
_, _ = w.Write([]byte(`{"version":{"distribution":"` + distribution + `","number":"` + number + `"}}`))
}
}
func TestCreateOpenSearchEngine_WiresClientAndRepo(t *testing.T) {
ts := httptest.NewServer(osClusterHandler("opensearch", "3.3.2", true))
defer ts.Close()
store := types.VectorStore{
ID: "", // env-style (no index prefix)
EngineType: types.OpenSearchRetrieverEngineType,
ConnectionConfig: types.ConnectionConfig{Addr: ts.URL},
}
svc, err := createOpenSearchEngine(context.Background(), store, nil)
if err != nil {
t.Fatalf("createOpenSearchEngine: %v", err)
}
if svc == nil {
t.Fatal("want non-nil engine service")
}
if svc.EngineType() != types.OpenSearchRetrieverEngineType {
t.Errorf("engine type: want opensearch, got %s", svc.EngineType())
}
}
func TestCreateOpenSearchEngine_RejectsBadCluster(t *testing.T) {
// Elasticsearch distribution must be rejected at construction.
ts := httptest.NewServer(osClusterHandler("elasticsearch", "8.10.4", true))
defer ts.Close()
_, err := createOpenSearchEngine(context.Background(),
types.VectorStore{EngineType: types.OpenSearchRetrieverEngineType,
ConnectionConfig: types.ConnectionConfig{Addr: ts.URL}}, nil)
if err == nil {
t.Error("elasticsearch cluster should be rejected at engine creation")
}
}
func TestCreateEngineServiceFromStore_OpenSearchCaseReached(t *testing.T) {
ts := httptest.NewServer(osClusterHandler("opensearch", "2.11.0", true))
defer ts.Close()
svc, err := createEngineServiceFromStore(context.Background(),
types.VectorStore{EngineType: types.OpenSearchRetrieverEngineType,
ConnectionConfig: types.ConnectionConfig{Addr: ts.URL}},
nil, &config.Config{}, nil)
if err != nil {
t.Fatalf("createEngineServiceFromStore (opensearch case): %v", err)
}
if svc == nil || svc.EngineType() != types.OpenSearchRetrieverEngineType {
t.Errorf("opensearch case not wired correctly: %v", svc)
}
}
// TestInitRetrieveEngineRegistry_OpenSearchEnvPath exercises the
// RETRIEVE_DRIVER=opensearch env-path registration block end to end.
func TestInitRetrieveEngineRegistry_OpenSearchEnvPath(t *testing.T) {
ts := httptest.NewServer(osClusterHandler("opensearch", "3.3.2", true))
defer ts.Close()
// In-memory DB: the vector_stores table is absent, so loadDBStores logs
// and returns (non-fatal) — only the env-path block matters here.
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open in-mem db: %v", err)
}
t.Setenv("RETRIEVE_DRIVER", "opensearch")
t.Setenv("OPENSEARCH_ADDR", ts.URL)
// nil store repository and engine factory: this exercises the env-driver
// path, which never rebuilds a database-backed store.
registry, err := initRetrieveEngineRegistry(db, &config.Config{}, &fakeAuditSvc{}, nil, nil)
if err != nil {
t.Fatalf("initRetrieveEngineRegistry: %v", err)
}
if _, err := registry.GetRetrieveEngineService(types.OpenSearchRetrieverEngineType); err != nil {
t.Errorf("opensearch engine not registered via env path: %v", err)
}
}
+46
View File
@@ -0,0 +1,46 @@
package container
import (
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestBuildMilvusClientConfig_UsesDatabaseName(t *testing.T) {
cfg := buildMilvusClientConfig(types.ConnectionConfig{
Addr: "milvus.example.com:19530",
Username: "tester",
Password: "secret",
Database: "regdi_ram_haom1",
})
if cfg.Address != "milvus.example.com:19530" {
t.Fatalf("expected address to be preserved, got %q", cfg.Address)
}
if cfg.Username != "tester" {
t.Fatalf("expected username to be preserved, got %q", cfg.Username)
}
if cfg.Password != "secret" {
t.Fatalf("expected password to be preserved, got %q", cfg.Password)
}
if cfg.DBName != "regdi_ram_haom1" {
t.Fatalf("expected DBName to be propagated, got %q", cfg.DBName)
}
if len(cfg.DialOptions) != 2 {
t.Fatalf("expected timeout and SSRF-safe dial options, got %d", len(cfg.DialOptions))
}
}
func TestBuildMilvusClientConfig_DefaultsAddressWhenMissing(t *testing.T) {
cfg := buildMilvusClientConfig(types.ConnectionConfig{})
if cfg.Address != "localhost:19530" {
t.Fatalf("expected default address localhost:19530, got %q", cfg.Address)
}
if cfg.DBName != "" {
t.Fatalf("expected empty DBName by default, got %q", cfg.DBName)
}
if len(cfg.DialOptions) != 2 {
t.Fatalf("expected timeout and SSRF-safe dial options, got %d", len(cfg.DialOptions))
}
}
@@ -0,0 +1,160 @@
package container
import (
"context"
"encoding/json"
"errors"
"time"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/hibiken/asynq"
"gorm.io/gorm"
)
// wikiRestartClaimGrace bounds how long a wiki claim may outlive the worker that
// stamped it. It must exceed the longest plausible batch turn (the per-turn model
// call budget is four attempts bounded by wikiLLMCallTimeout) and stay well below
// wikiClaimStaleAfter so a restart resumes faster than the steady-state rule.
const wikiRestartClaimGrace = 15 * time.Minute
// sweepOrphanedWikiClaims releases wiki queue claims stamped before now-grace, which
// no live worker of this deployment can still own.
func sweepOrphanedWikiClaims(ctx context.Context, db *gorm.DB, now time.Time) (int64, error) {
if db == nil || !db.Migrator().HasTable("task_pending_ops") {
return 0, nil
}
result := db.WithContext(ctx).
Table("task_pending_ops").
Where("task_type IN ?", []string{types.TypeWikiIngest, types.TypeWikiFinalize}).
Where("claimed_at IS NOT NULL AND claimed_at < ?", now.UTC().Add(-wikiRestartClaimGrace)).
Update("claimed_at", nil)
return result.RowsAffected, result.Error
}
// pendingWikiScope is the minimum routing information needed to recreate an
// ephemeral wiki trigger from the durable task_pending_ops queue.
type pendingWikiScope struct {
TenantID uint64 `gorm:"column:tenant_id"`
TaskType string `gorm:"column:task_type"`
ScopeID string `gorm:"column:scope_id"`
}
// recoverPendingWikiTasks recreates one trigger per pending wiki queue lane.
//
// task_pending_ops is deliberately durable, but the trigger that wakes its
// consumer is not durable in Lite mode (SyncTaskExecutor is process-local) and
// may also be absent after an interrupted Redis enqueue. Running this after all
// handlers are registered closes that gap. Duplicate triggers are harmless:
// ingest claims/peeks disjoint rows and finalize coalesces its pending lane.
func recoverPendingWikiTasks(db *gorm.DB, task interfaces.TaskEnqueuer) {
if db == nil || task == nil {
return
}
ctx := context.Background()
const activeKnowledgeBase = `EXISTS (
SELECT 1 FROM knowledge_bases kb
WHERE kb.id = task_pending_ops.scope_id
AND kb.tenant_id = task_pending_ops.tenant_id
AND kb.deleted_at IS NULL
)`
wikiTaskTypes := []string{types.TypeWikiIngest, types.TypeWikiFinalize}
if db.Migrator().HasTable(&types.WikiRecoveryBudget{}) {
var stopped []types.WikiRecoveryBudget
if err := db.WithContext(ctx).Where("status = ? OR (status = ? AND deadline_at <= ?)", "stopped_budget", "active", time.Now().UTC()).Find(&stopped).Error; err != nil {
logger.Errorf(ctx, "[WikiRecovery] cannot verify recovery budgets: %v", err)
return
}
repo := apprepo.WikiRecoveryBudgetRepository{DB: db}
for _, budget := range stopped {
reason := budget.StopReason
if reason == "" {
reason = "deadline"
}
if err := repo.StopAndArchive(ctx, budget, reason, "startup"); err != nil {
logger.Errorf(ctx, "[WikiRecovery] cannot settle stopped recovery %d: %v", budget.PendingOpID, err)
return
}
}
}
// Durable rows for a deleted/missing KB must not recreate ephemeral
// triggers at startup. Fail closed if this cleanup cannot be verified.
cleanup := db.WithContext(ctx).
Where("scope = ? AND task_type IN ?", types.TaskScopeKnowledgeBase, wikiTaskTypes).
Where("NOT " + activeKnowledgeBase).
Delete(&types.TaskPendingOp{})
if cleanup.Error != nil {
logger.Warnf(ctx, "[WikiRecovery] failed to clear deleted KB queues: %v", cleanup.Error)
return
}
if cleanup.RowsAffected > 0 {
logger.Infof(ctx, "[WikiRecovery] removed %d pending row(s) for deleted knowledge bases", cleanup.RowsAffected)
}
// Claims are not renewed, so a claim older than the longest plausible batch turn
// can only belong to a worker that is gone. This runs at startup precisely because
// a restart means the previous incarnation of this process died: releasing those
// claims lets the queue resume at once instead of waiting out the steady-state
// stale threshold (wikiClaimStaleAfter, 90 minutes).
released, sweepErr := sweepOrphanedWikiClaims(ctx, db, time.Now())
if sweepErr != nil {
logger.Warnf(ctx, "[WikiRecovery] failed to sweep stale wiki claims: %v", sweepErr)
} else if released > 0 {
logger.Infof(ctx, "[WikiRecovery] released %d wiki claim(s) left by a stopped worker", released)
}
var scopes []pendingWikiScope
if err := db.WithContext(ctx).
Model(&types.TaskPendingOp{}).
Distinct("tenant_id", "task_type", "scope_id").
Where("scope = ? AND task_type IN ?", types.TaskScopeKnowledgeBase, wikiTaskTypes).
Where(activeKnowledgeBase).
Find(&scopes).Error; err != nil {
logger.Warnf(ctx, "[WikiRecovery] failed to list pending queues: %v", err)
return
}
recovered := 0
for _, scope := range scopes {
if scope.ScopeID == "" {
continue
}
payload, err := json.Marshal(service.WikiIngestPayload{
TenantID: scope.TenantID,
KnowledgeBaseID: scope.ScopeID,
})
if err != nil {
logger.Warnf(ctx, "[WikiRecovery] marshal trigger for KB %s failed: %v", scope.ScopeID, err)
continue
}
opts := []asynq.Option{
asynq.Queue(types.QueueWiki),
asynq.MaxRetry(10), // keep aligned with the wiki ingest retry policy
asynq.Timeout(60 * time.Minute),
}
if scope.TaskType == types.TypeWikiFinalize {
opts[2] = asynq.Timeout(30 * time.Minute)
// Match scheduleFinalize so simultaneous replica startups collapse
// into the same per-KB finalize trigger.
opts = append(opts, asynq.TaskID("wiki-finalize-"+scope.ScopeID))
}
trigger := asynq.NewTask(scope.TaskType, payload, opts...)
if _, err := task.Enqueue(trigger); err != nil {
if errors.Is(err, asynq.ErrTaskIDConflict) || errors.Is(err, asynq.ErrDuplicateTask) {
recovered++
continue
}
logger.Warnf(ctx, "[WikiRecovery] enqueue %s trigger for KB %s failed: %v",
scope.TaskType, scope.ScopeID, err)
continue
}
recovered++
}
if recovered > 0 {
logger.Infof(ctx, "[WikiRecovery] recreated %d trigger(s) from durable pending queues", recovered)
}
}
@@ -0,0 +1,103 @@
package container
import (
"context"
"database/sql"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const pendingOpsDDL = `
CREATE TABLE IF NOT EXISTS task_pending_ops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL DEFAULT 0,
task_type VARCHAR(64) NOT NULL DEFAULT '',
scope VARCHAR(32) NOT NULL DEFAULT '',
scope_id VARCHAR(64) NOT NULL DEFAULT '',
op VARCHAR(32) NOT NULL DEFAULT '',
dedup_key VARCHAR(128) NOT NULL DEFAULT '',
payload TEXT,
fail_count INTEGER NOT NULL DEFAULT 0,
enqueued_at DATETIME,
claimed_at DATETIME
);
`
func openPendingOpsDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_uniq="+t.Name()), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.Exec(pendingOpsDDL).Error)
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
// claimOf reads the claim column as nullable, so a released claim is distinguishable
// from a timestamp.
func claimOf(t *testing.T, db *gorm.DB, id int64) (time.Time, bool) {
t.Helper()
var got sql.NullTime
require.NoError(t, db.Table("task_pending_ops").Select("claimed_at").Where("id = ?", id).Scan(&got).Error)
return got.Time, got.Valid
}
func seedPendingOp(t *testing.T, db *gorm.DB, taskType, scopeID string, claimedAt *time.Time) int64 {
t.Helper()
row := types.TaskPendingOp{
TenantID: 10059, TaskType: taskType, Scope: types.TaskScopeKnowledgeBase,
ScopeID: scopeID, Op: "ingest", EnqueuedAt: time.Now().UTC(), ClaimedAt: claimedAt,
}
require.NoError(t, db.Create(&row).Error)
return row.ID
}
// A claim stamped by a worker that has since died must be released at startup, so a
// restarted deployment resumes instead of waiting out the 90 minute stale threshold.
func TestSweepOrphanedWikiClaimsReleasesStaleClaimsOnly(t *testing.T) {
db := openPendingOpsDB(t)
now := time.Now().UTC()
stale := now.Add(-20 * time.Minute)
fresh := now.Add(-1 * time.Minute)
nonWikiStale := now.Add(-45 * time.Minute)
staleID := seedPendingOp(t, db, types.TypeWikiIngest, "kb-stale", &stale)
freshID := seedPendingOp(t, db, types.TypeWikiIngest, "kb-fresh", &fresh)
unclaimedID := seedPendingOp(t, db, types.TypeWikiIngest, "kb-unclaimed", nil)
nonWikiID := seedPendingOp(t, db, "some:other:task", "kb-other", &nonWikiStale)
released, err := sweepOrphanedWikiClaims(context.Background(), db, now)
require.NoError(t, err)
require.EqualValues(t, 1, released)
_, staleStillClaimed := claimOf(t, db, staleID)
require.False(t, staleStillClaimed, "the stale wiki claim is released")
_, freshStillClaimed := claimOf(t, db, freshID)
require.True(t, freshStillClaimed, "a live worker's fresh claim is untouched")
_, unclaimedStillClaimed := claimOf(t, db, unclaimedID)
require.False(t, unclaimedStillClaimed)
_, nonWikiStillClaimed := claimOf(t, db, nonWikiID)
require.True(t, nonWikiStillClaimed, "non-wiki queues keep their own claim policy")
}
// Without the durable table the sweep is a no-op rather than an error, because the
// Lite deployment may run without the task queue at all.
func TestSweepOrphanedWikiClaimsWithoutTableIsNoop(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_uniq=notable"), &gorm.Config{})
require.NoError(t, err)
released, err := sweepOrphanedWikiClaims(context.Background(), db, time.Now())
require.NoError(t, err)
require.EqualValues(t, 0, released)
}
+188
View File
@@ -0,0 +1,188 @@
package container
import (
"context"
"os"
"time"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
)
const resetPendingStaleWindow = 30 * time.Minute
const restartInterruptedMessage = "Task interrupted due to application restart"
// resetPendingTasks resets the state of any knowledge items or sync logs stuck in processing
// due to an unexpected application restart.
//
// In Lite mode (no REDIS_ADDR) normal queued tasks live in process memory, so
// a "processing" row at startup is orphaned unless it has a durable wiki op
// that can be re-triggered.
//
// Distributed mode is intentionally different: Asynq persists queued/retry
// tasks, and another replica may still be executing the same knowledge. A
// startup hook cannot safely distinguish an orphan from a backlogged task that
// has not opened a span yet. HousekeepingService owns that decision because it
// checks BOTH recent span activity and the real Asynq queue. Consequently this
// hook never resets knowledge/summary rows in distributed mode; it only keeps
// the separate sync-log cleanup below.
func resetPendingTasks(db *gorm.DB) {
distributed := os.Getenv("REDIS_ADDR") != ""
ctx := context.Background()
spanRepo := repository.NewKnowledgeSpanRepository(db)
var staleCutoff time.Time
if distributed {
staleCutoff = time.Now().Add(-resetPendingStaleWindow)
}
// Resolve Lite-mode orphaned knowledge rows first. A finalizing row whose
// ONLY remaining slot is backed by a durable wiki op is excluded and resumed
// by recoverPendingWikiTasks after handlers are registered. Rows with other
// outstanding in-memory subtasks still fail: the wiki op cannot recover them.
var stuckKnowledge []types.Knowledge
if !distributed {
if err := stuckKnowledgeParseQuery(db).
Select("id").Find(&stuckKnowledge).Error; err != nil {
logger.Warnf(ctx, "resetPendingTasks: list stuck knowledge failed: %v", err)
}
}
// 1. Reset knowledge parsing tasks (including finalizing rows whose
// enrichment subtasks were lost with the process).
// Update by the resolved ids rather than reusing the GORM chain after
// Find() (which makes PostgreSQL emit an invalid UPDATE ... FROM self).
stuckIDs := knowledgeIDs(stuckKnowledge)
var resetErr error
var resetCount int64
if len(stuckIDs) > 0 {
// Rebuild the query instead of reusing the chain after Find(); reusing it
// makes PostgreSQL emit an invalid UPDATE ... FROM self statement.
result := stuckKnowledgeParseQuery(db).
Where("id IN ?", stuckIDs).
Updates(map[string]interface{}{
"parse_status": types.ParseStatusFailed,
"error_message": restartInterruptedMessage,
"pending_subtasks_count": 0,
})
resetErr = result.Error
resetCount = result.RowsAffected
}
if resetErr != nil {
logger.Warnf(context.Background(), "Failed to reset pending knowledge tasks: %v", resetErr)
} else if resetCount > 0 {
logger.Infof(context.Background(),
"Reset %d stuck knowledge parsing tasks to failed state (distributed=%v)",
resetCount, distributed)
// Cancel orphaned trace spans only after the owning knowledge rows are
// terminal. This prevents the UI from showing duplicate running
// postprocess.* subspans when a later manual retry opens fresh spans.
// Re-read the successfully reset ids so a row whose status changed in
// the small SELECT/UPDATE gap is not accidentally cancelled.
var resetKnowledge []types.Knowledge
if err := db.Select("id").
Where("id IN ? AND parse_status = ? AND error_message = ?",
stuckIDs, types.ParseStatusFailed, restartInterruptedMessage).
Find(&resetKnowledge).Error; err != nil {
logger.Warnf(ctx, "resetPendingTasks: list reset knowledge failed: %v", err)
}
for _, k := range resetKnowledge {
attempt, err := spanRepo.LatestAttempt(ctx, k.ID)
if err != nil || attempt <= 0 {
continue
}
if n, err := spanRepo.CancelAllOpenSpans(ctx, k.ID, attempt,
"SERVER_RESTART", restartInterruptedMessage); err != nil {
logger.Warnf(ctx, "resetPendingTasks: cancel spans for %s failed: %v", k.ID, err)
} else if n > 0 {
logger.Infof(ctx, "resetPendingTasks: cancelled %d open span(s) for knowledge %s attempt %d",
n, k.ID, attempt)
}
}
}
// 2. Lite summary tasks are process-local too. Distributed summary tasks
// stay in Asynq and must not be failed merely because this replica started.
if !distributed {
resultSummary := stuckKnowledgeSummaryQuery(db).Updates(map[string]interface{}{
"summary_status": types.SummaryStatusFailed,
})
if resultSummary.Error != nil {
logger.Warnf(context.Background(), "Failed to reset pending summary tasks: %v", resultSummary.Error)
} else if resultSummary.RowsAffected > 0 {
logger.Infof(context.Background(),
"Reset %d stuck summary generation tasks to failed state (distributed=false)",
resultSummary.RowsAffected)
}
}
// 3. Reset data source sync tasks
now := time.Now()
resultSync := stuckSyncLogQuery(db, distributed, staleCutoff).Updates(map[string]interface{}{
"status": types.SyncLogStatusFailed,
"error_message": "Sync interrupted due to application restart",
"finished_at": &now,
})
if resultSync.Error != nil {
logger.Warnf(context.Background(), "Failed to reset pending data source sync tasks: %v", resultSync.Error)
} else if resultSync.RowsAffected > 0 {
logger.Infof(context.Background(),
"Reset %d stuck data source sync tasks to failed state (distributed=%v)",
resultSync.RowsAffected, distributed)
}
}
func stuckKnowledgeParseQuery(db *gorm.DB) *gorm.DB {
q := db.Model(&types.Knowledge{}).
Where("parse_status IN ?", resettableParseStatuses()).
// Wiki ingest ops are persisted independently from the task trigger.
// When wiki owns the only outstanding slot, keeping this finalizing row
// alive lets startup recreate the trigger and finish cleanly. A count
// above one means at least one non-wiki in-memory subtask was also lost.
Where(`NOT (parse_status = ? AND pending_subtasks_count = 1 AND EXISTS (
SELECT 1 FROM task_pending_ops
WHERE task_pending_ops.task_type = ?
AND task_pending_ops.scope = ?
AND task_pending_ops.dedup_key = knowledges.id
AND task_pending_ops.op = ?
))`, types.ParseStatusFinalizing, types.TypeWikiIngest,
types.TaskScopeKnowledgeBase, "ingest")
return q
}
func stuckKnowledgeSummaryQuery(db *gorm.DB) *gorm.DB {
return db.Model(&types.Knowledge{}).
Where("summary_status IN ?", []string{types.SummaryStatusPending, types.SummaryStatusProcessing})
}
func stuckSyncLogQuery(db *gorm.DB, distributed bool, staleCutoff time.Time) *gorm.DB {
q := db.Model(&types.SyncLog{}).
Where("status = ?", types.SyncLogStatusRunning)
if distributed {
q = q.Where("started_at < ?", staleCutoff)
}
return q
}
func resettableParseStatuses() []string {
return []string{
types.ParseStatusPending,
types.ParseStatusProcessing,
types.ParseStatusFinalizing,
types.ParseStatusDeleting,
}
}
func knowledgeIDs(rows []types.Knowledge) []string {
ids := make([]string, 0, len(rows))
for _, row := range rows {
if row.ID != "" {
ids = append(ids, row.ID)
}
}
return ids
}
@@ -0,0 +1,392 @@
package container
import (
"encoding/json"
"os"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const resetPendingKnowledgeDDL = `
CREATE TABLE IF NOT EXISTS knowledges (
id VARCHAR(64) PRIMARY KEY,
parse_status VARCHAR(32) NOT NULL DEFAULT 'pending',
summary_status VARCHAR(32) NOT NULL DEFAULT 'none',
pending_subtasks_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
`
const resetPendingSyncLogDDL = `
CREATE TABLE IF NOT EXISTS sync_logs (
id VARCHAR(64) PRIMARY KEY,
data_source_id VARCHAR(64) NOT NULL DEFAULT '',
tenant_id INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
started_at DATETIME,
finished_at DATETIME,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
const resetPendingSpansDDL = `
CREATE TABLE IF NOT EXISTS knowledge_processing_spans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
knowledge_id VARCHAR(64) NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
span_id VARCHAR(64) NOT NULL,
parent_span_id VARCHAR(64),
name VARCHAR(255) NOT NULL,
kind VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
error_code VARCHAR(64),
error_message TEXT,
started_at DATETIME,
finished_at DATETIME,
duration_ms INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (knowledge_id, attempt, span_id)
);
`
const resetPendingOpsDDL = `
CREATE TABLE IF NOT EXISTS task_pending_ops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL DEFAULT 0,
task_type VARCHAR(64) NOT NULL,
scope VARCHAR(32) NOT NULL,
scope_id VARCHAR(64) NOT NULL,
op VARCHAR(32) NOT NULL,
dedup_key VARCHAR(128) NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
fail_count INTEGER NOT NULL DEFAULT 0,
enqueued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
claimed_at DATETIME
);
`
const resetPendingKnowledgeBasesDDL = `
CREATE TABLE IF NOT EXISTS knowledge_bases (
id VARCHAR(64) PRIMARY KEY,
tenant_id INTEGER NOT NULL DEFAULT 0,
deleted_at DATETIME
);
`
func setupResetPendingDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.Exec(resetPendingKnowledgeDDL).Error)
require.NoError(t, db.Exec(resetPendingSyncLogDDL).Error)
require.NoError(t, db.Exec(resetPendingSpansDDL).Error)
require.NoError(t, db.Exec(resetPendingOpsDDL).Error)
require.NoError(t, db.Exec(resetPendingKnowledgeBasesDDL).Error)
return db
}
func TestResetPendingTasks_KnowledgeFindThenUpdate(t *testing.T) {
db := setupResetPendingDB(t)
stale := time.Now().Add(-2 * time.Hour)
require.NoError(t, db.Exec(
`INSERT INTO knowledges (id, parse_status, updated_at) VALUES (?, ?, ?)`,
"k-stuck", types.ParseStatusProcessing, stale,
).Error)
os.Unsetenv("REDIS_ADDR")
resetPendingTasks(db)
var status, errMsg string
require.NoError(t, db.Raw(
`SELECT parse_status, error_message FROM knowledges WHERE id = ?`, "k-stuck",
).Row().Scan(&status, &errMsg))
assert.Equal(t, types.ParseStatusFailed, status)
assert.Contains(t, errMsg, "application restart")
}
func TestResetPendingTasks_KnowledgeFreshInDistributedMode(t *testing.T) {
db := setupResetPendingDB(t)
fresh := time.Now().Add(-5 * time.Minute)
require.NoError(t, db.Exec(
`INSERT INTO knowledges (id, parse_status, updated_at) VALUES (?, ?, ?)`,
"k-fresh", types.ParseStatusProcessing, fresh,
).Error)
t.Setenv("REDIS_ADDR", "redis:6379")
resetPendingTasks(db)
var status string
require.NoError(t, db.Raw(
`SELECT parse_status FROM knowledges WHERE id = ?`, "k-fresh",
).Row().Scan(&status))
assert.Equal(t, types.ParseStatusProcessing, status)
}
func TestResetPendingTasks_DistributedModePreservesEveryStage(t *testing.T) {
cases := []struct {
name string
parseStatus string
}{
{"pending", types.ParseStatusPending},
{"docreader_chunking_embedding", types.ParseStatusProcessing},
{"multimodal", types.ParseStatusProcessing},
{"postprocess", types.ParseStatusFinalizing},
{"wiki", types.ParseStatusFinalizing},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := setupResetPendingDB(t)
stale := time.Now().Add(-2 * time.Hour)
require.NoError(t, db.Exec(
`INSERT INTO knowledges (id, parse_status, updated_at) VALUES (?, ?, ?)`,
"k-active-span", tc.parseStatus, stale,
).Error)
t.Setenv("REDIS_ADDR", "redis:6379")
resetPendingTasks(db)
var status string
require.NoError(t, db.Raw(
`SELECT parse_status FROM knowledges WHERE id = ?`, "k-active-span",
).Row().Scan(&status))
assert.Equal(t, tc.parseStatus, status,
"distributed tasks belong to Asynq/housekeeping, not startup reset")
})
}
}
func TestResetPendingTasks_DistributedSummaryTaskSurvivesRestart(t *testing.T) {
db := setupResetPendingDB(t)
stale := time.Now().Add(-2 * time.Hour)
require.NoError(t, db.Exec(
`INSERT INTO knowledges (id, parse_status, summary_status, updated_at)
VALUES (?, ?, ?, ?)`,
"k-summary", types.ParseStatusCompleted, types.SummaryStatusProcessing, stale,
).Error)
t.Setenv("REDIS_ADDR", "redis:6379")
resetPendingTasks(db)
var status string
require.NoError(t, db.Raw(
`SELECT summary_status FROM knowledges WHERE id = ?`, "k-summary",
).Row().Scan(&status))
assert.Equal(t, types.SummaryStatusProcessing, status)
}
func TestResetPendingTasks_DurableWikiOpSurvivesLiteRestart(t *testing.T) {
db := setupResetPendingDB(t)
require.NoError(t, db.Exec(
`INSERT INTO knowledges (id, parse_status, pending_subtasks_count)
VALUES (?, ?, 1)`, "k-wiki", types.ParseStatusFinalizing,
).Error)
require.NoError(t, db.Exec(
`INSERT INTO task_pending_ops
(tenant_id, task_type, scope, scope_id, op, dedup_key, payload)
VALUES (7, ?, ?, 'kb-wiki', 'ingest', 'k-wiki', '{}')`,
types.TypeWikiIngest, types.TaskScopeKnowledgeBase,
).Error)
os.Unsetenv("REDIS_ADDR")
resetPendingTasks(db)
var status string
var pending int
require.NoError(t, db.Raw(
`SELECT parse_status, pending_subtasks_count FROM knowledges WHERE id = ?`, "k-wiki",
).Row().Scan(&status, &pending))
assert.Equal(t, types.ParseStatusFinalizing, status)
assert.Equal(t, 1, pending, "resumed wiki worker still owns its finalizing slot")
}
func TestResetPendingTasks_LiteWikiDoesNotHideOtherLostSubtasks(t *testing.T) {
db := setupResetPendingDB(t)
require.NoError(t, db.Exec(
`INSERT INTO knowledges (id, parse_status, pending_subtasks_count)
VALUES (?, ?, 2)`, "k-wiki-plus-summary", types.ParseStatusFinalizing,
).Error)
require.NoError(t, db.Exec(
`INSERT INTO task_pending_ops
(tenant_id, task_type, scope, scope_id, op, dedup_key, payload)
VALUES (7, ?, ?, 'kb-wiki', 'ingest', 'k-wiki-plus-summary', '{}')`,
types.TypeWikiIngest, types.TaskScopeKnowledgeBase,
).Error)
os.Unsetenv("REDIS_ADDR")
resetPendingTasks(db)
var status string
require.NoError(t, db.Raw(
`SELECT parse_status FROM knowledges WHERE id = ?`, "k-wiki-plus-summary",
).Row().Scan(&status))
assert.Equal(t, types.ParseStatusFailed, status,
"a durable wiki op cannot recover another lost in-memory subtask")
}
func TestResetPendingTasks_SyncLogStaleRunning(t *testing.T) {
db := setupResetPendingDB(t)
stale := time.Now().Add(-2 * time.Hour)
require.NoError(t, db.Exec(
`INSERT INTO sync_logs (id, status, started_at) VALUES (?, ?, ?)`,
"sync-1", types.SyncLogStatusRunning, stale,
).Error)
t.Setenv("REDIS_ADDR", "redis:6379")
resetPendingTasks(db)
var status string
var finishedAt *time.Time
require.NoError(t, db.Raw(
`SELECT status, finished_at FROM sync_logs WHERE id = ?`, "sync-1",
).Row().Scan(&status, &finishedAt))
assert.Equal(t, types.SyncLogStatusFailed, status)
require.NotNil(t, finishedAt)
}
func TestResetPendingTasks_SyncLogLiteMode(t *testing.T) {
db := setupResetPendingDB(t)
os.Unsetenv("REDIS_ADDR")
require.NoError(t, db.Exec(
`INSERT INTO sync_logs (id, status, started_at) VALUES (?, ?, ?)`,
"sync-lite", types.SyncLogStatusRunning, time.Now(),
).Error)
resetPendingTasks(db)
var status string
require.NoError(t, db.Raw(
`SELECT status FROM sync_logs WHERE id = ?`, "sync-lite",
).Row().Scan(&status))
assert.Equal(t, types.SyncLogStatusFailed, status)
}
func TestStuckKnowledgeParseQuery_ReuseAfterFindDoesNotBreakUpdate(t *testing.T) {
db := setupResetPendingDB(t)
stale := time.Now().Add(-2 * time.Hour)
require.NoError(t, db.Exec(
`INSERT INTO knowledges (id, parse_status, updated_at) VALUES (?, ?, ?)`,
"k-reuse", types.ParseStatusProcessing, stale,
).Error)
var rows []types.Knowledge
q := stuckKnowledgeParseQuery(db)
require.NoError(t, q.Select("id").Find(&rows).Error)
require.Len(t, rows, 1)
result := stuckKnowledgeParseQuery(db).Updates(map[string]interface{}{
"parse_status": types.ParseStatusFailed,
})
require.NoError(t, result.Error)
assert.Equal(t, int64(1), result.RowsAffected)
}
type recordingTaskEnqueuer struct {
tasks []*asynq.Task
}
func (r *recordingTaskEnqueuer) Enqueue(task *asynq.Task, _ ...asynq.Option) (*asynq.TaskInfo, error) {
r.tasks = append(r.tasks, task)
return &asynq.TaskInfo{ID: "test", Type: task.Type()}, nil
}
func TestRecoverPendingWikiTasks_RecreatesOneTriggerPerLaneAndKB(t *testing.T) {
db := setupResetPendingDB(t)
require.NoError(t, db.Exec(
`INSERT INTO knowledge_bases (id, tenant_id, deleted_at)
VALUES (?, ?, NULL), (?, ?, NULL), (?, ?, ?)`,
"kb-a", 7, "kb-b", 8, "kb-deleted", 9, time.Now(),
).Error)
rows := []struct {
tenantID uint64
taskType string
kbID string
dedup string
}{
{7, types.TypeWikiIngest, "kb-a", "k-1"},
{7, types.TypeWikiIngest, "kb-a", "k-2"}, // same lane: one trigger
{7, types.TypeWikiFinalize, "kb-a", "slug-a"},
{8, types.TypeWikiIngest, "kb-b", "k-3"},
{9, types.TypeWikiIngest, "kb-deleted", "k-deleted"},
{10, types.TypeWikiFinalize, "kb-missing", "k-missing"},
}
for _, row := range rows {
require.NoError(t, db.Exec(
`INSERT INTO task_pending_ops
(tenant_id, task_type, scope, scope_id, op, dedup_key, payload)
VALUES (?, ?, ?, ?, 'ingest', ?, '{}')`,
row.tenantID, row.taskType, types.TaskScopeKnowledgeBase, row.kbID, row.dedup,
).Error)
}
recorder := &recordingTaskEnqueuer{}
recoverPendingWikiTasks(db, recorder)
require.Len(t, recorder.tasks, 3)
seen := map[string]service.WikiIngestPayload{}
for _, task := range recorder.tasks {
var payload service.WikiIngestPayload
require.NoError(t, json.Unmarshal(task.Payload(), &payload))
seen[task.Type()+":"+payload.KnowledgeBaseID] = payload
}
assert.Equal(t, uint64(7), seen[types.TypeWikiIngest+":kb-a"].TenantID)
assert.Equal(t, uint64(7), seen[types.TypeWikiFinalize+":kb-a"].TenantID)
assert.Equal(t, uint64(8), seen[types.TypeWikiIngest+":kb-b"].TenantID)
var orphaned int64
require.NoError(t, db.Model(&types.TaskPendingOp{}).
Where("scope_id IN ?", []string{"kb-deleted", "kb-missing"}).
Count(&orphaned).Error)
assert.Zero(t, orphaned)
}
func TestRecoverPendingWikiTasks_ArchivesExpiredBudgetBeforeRecreatingTriggers(t *testing.T) {
db := setupResetPendingDB(t)
require.NoError(t, db.AutoMigrate(&types.WikiRecoveryBudget{}))
require.NoError(t, db.Exec(`INSERT INTO knowledge_bases (id,tenant_id) VALUES ('kb-budget',7),('kb-ordinary',7)`).Error)
budget := types.WikiRecoveryBudget{TenantID: 7, DeadLetterID: 10, AttemptNumber: 5, PendingOpID: 42, KnowledgeBaseID: "kb-budget", KnowledgeID: "doc", ModelID: "model",
MaxProviderRequests: 3, ReservedProviderRequests: 2, DeadlineAt: time.Now().UTC().Add(-time.Minute), Status: "active", PausedPayload: "[]", VerifiedProgress: "{}"}
require.NoError(t, db.Create(&budget).Error)
payload, err := json.Marshal(budget.Identity())
require.NoError(t, err)
for _, row := range []types.TaskPendingOp{
{ID: 42, TenantID: 7, TaskType: types.TypeWikiIngest, Scope: types.TaskScopeKnowledgeBase, ScopeID: "kb-budget", Payload: payload},
{ID: 43, TenantID: 7, TaskType: types.TypeWikiFinalize, Scope: types.TaskScopeKnowledgeBase, ScopeID: "kb-budget", Payload: payload},
{ID: 44, TenantID: 7, TaskType: types.TypeWikiIngest, Scope: types.TaskScopeKnowledgeBase, ScopeID: "kb-ordinary", Payload: []byte(`{}`)},
} {
require.NoError(t, db.Create(&row).Error)
}
recorder := &recordingTaskEnqueuer{}
recoverPendingWikiTasks(db, recorder)
require.Len(t, recorder.tasks, 1)
var trigger service.WikiIngestPayload
require.NoError(t, json.Unmarshal(recorder.tasks[0].Payload(), &trigger))
require.Equal(t, "kb-ordinary", trigger.KnowledgeBaseID)
var saved types.WikiRecoveryBudget
require.NoError(t, db.First(&saved).Error)
require.Equal(t, "stopped_budget", saved.Status)
require.Equal(t, "deadline", saved.StopReason)
require.Equal(t, int64(2), saved.ReservedProviderRequests)
var paused []types.TaskPendingOp
require.NoError(t, json.Unmarshal([]byte(saved.PausedPayload), &paused))
require.Len(t, paused, 2)
var ids []int64
require.NoError(t, db.Model(&types.TaskPendingOp{}).Pluck("id", &ids).Error)
require.Equal(t, []int64{44}, ids)
// A second restart cannot recreate the archived controlled lanes.
recorder.tasks = nil
recoverPendingWikiTasks(db, recorder)
require.Len(t, recorder.tasks, 1)
}
@@ -0,0 +1,59 @@
package container
import (
"testing"
"go.uber.org/dig"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/application/service/retriever"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// TestRetrieveEngineRegistryWiring checks that the container can still build
// the retrieval engine registry, and that what it builds can rebuild a missing
// store engine.
//
// The registry depends on the vector store repository and the engine factory,
// and the engine factory must not in turn depend on the registry. A cycle or a
// missing provider surfaces only when the process starts, so it is worth
// pinning here rather than discovering it at deploy time. The nil check at the
// end is the part that matters: a registry resolved without those two
// dependencies still satisfies the interface and still serves lookups, so it
// would pass every other test while silently never rebuilding anything.
func TestRetrieveEngineRegistryWiring(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open in-mem db: %v", err)
}
c := dig.New()
provide := func(constructor interface{}) {
t.Helper()
if err := c.Provide(constructor); err != nil {
t.Fatalf("provide: %v", err)
}
}
provide(func() *gorm.DB { return db })
provide(func() *config.Config { return &config.Config{} })
provide(func() interfaces.AuditLogService { return &fakeAuditSvc{} })
provide(repository.NewVectorStoreRepository)
provide(NewEngineFactory)
provide(initRetrieveEngineRegistry)
err = c.Invoke(func(registry interfaces.RetrieveEngineRegistry) {
concrete, ok := registry.(*retriever.RetrieveEngineRegistry)
if !ok {
t.Fatalf("expected *retriever.RetrieveEngineRegistry, got %T", registry)
}
if !concrete.CanRebuildStores() {
t.Error("registry was built without the dependencies it needs to rebuild a store engine")
}
})
if err != nil {
t.Fatalf("container could not build the registry: %v", err)
}
}
+142
View File
@@ -0,0 +1,142 @@
// Package container - workspace sandbox provider wiring.
package container
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// newSandboxManager is deliberately disabled. Every executable backend now
// comes from a named workspace configuration resolved at request time.
func newSandboxManager(
_ *redis.Client,
_ interfaces.SessionRepository,
) sandbox.Manager {
return sandbox.NewDisabledManager()
}
func selectSessionBindingStore(
redisClient *redis.Client,
requireRedis bool,
) (sandbox.SessionSandboxBindingStore, string, error) {
namespace := strings.TrimSpace(os.Getenv("WEKNORA_REDIS_NAMESPACE"))
if namespace == "" {
namespace = "weknora"
}
if redisClient != nil {
store, err := sandbox.NewRedisSessionSandboxBindingStore(redisClient, namespace)
if err != nil {
return nil, "", fmt.Errorf("build redis binding store: %w", err)
}
return store, "redis", nil
}
_ = requireRedis
logger.Warnf(context.Background(),
"[sandbox] No Redis configured, using in-memory binding store (single-instance)")
return sandbox.NewMemorySessionSandboxBindingStore(), "memory", nil
}
// sessionExistenceLookup is the narrow slice of SessionRepository the
// session existence checker actually needs. Declaring it here (rather than
// depending on interfaces.SessionRepository) keeps the checker easy to test
// and lets the container inject a nil repository in Lite mode without
// dragging the whole database contract along.
type sessionExistenceLookup interface {
GetByID(ctx context.Context, tenantID uint64, id string) (*types.Session, error)
}
// sessionExistenceCheckerFor returns a SessionExistenceChecker backed by the
// tenant session repository. When the repository is unavailable (Lite mode
// without a database) the returned checker is permissive so single-process
// deployments still work; multi-instance production paths always resolve a
// real repository because the container refuses to boot without one.
func sessionExistenceCheckerFor(
lookup sessionExistenceLookup,
) sandbox.SessionExistenceChecker {
if lookup == nil {
return sandbox.PermissiveSessionExistenceChecker{}
}
return &repositorySessionExistenceChecker{lookup: lookup}
}
// repositorySessionExistenceChecker adapts SessionRepository.GetByID onto the
// SessionExistenceChecker contract. gorm.ErrRecordNotFound → false, other
// errors propagate so the lifecycle coordinator preserves bindings under
// transient database failures.
type repositorySessionExistenceChecker struct {
lookup sessionExistenceLookup
}
func (c *repositorySessionExistenceChecker) SessionExists(
ctx context.Context,
key sandbox.SessionSandboxKey,
) (bool, error) {
if c == nil || c.lookup == nil {
return true, nil
}
session, err := c.lookup.GetByID(ctx, key.TenantID, key.SessionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, apperrors.ErrSessionNotFound) {
return false, nil
}
return false, fmt.Errorf("session existence check: %w", err)
}
return session != nil, nil
}
// buildGlobalSandboxConfig returns the process-wide *sandbox.Config that
// per-tenant overrides are merged onto.
func buildGlobalSandboxConfig() *sandbox.Config {
cfg := sandbox.DefaultConfig()
cfg.Type = sandbox.SandboxTypeDisabled
return cfg
}
// newTenantSandboxResolver wires the workspace-config resolver. The
// process-wide manager is disabled; agents without a selected config stay
// disabled as well.
func newTenantSandboxResolver(
defaultManager sandbox.Manager,
loader sandbox.TenantSandboxConfigLoader,
redisClient *redis.Client,
sessionRepo interfaces.SessionRepository,
) sandbox.TenantSandboxResolver {
ctx := context.Background()
// Tenants may configure any supported backend regardless of process startup
// mode. Remote configs use this binding store for session persistence.
store, storeKind, err := selectSessionBindingStore(redisClient, true)
if err != nil {
logger.Warnf(ctx,
"Per-tenant sandbox config disabled: %v", err)
return nil
}
resolver, err := sandbox.NewTenantSandboxResolver(sandbox.TenantSandboxResolverDeps{
GlobalConfig: buildGlobalSandboxConfig(),
DefaultManager: defaultManager,
Loader: loader,
Store: store,
Checker: sessionExistenceCheckerFor(sessionRepo),
SharedTransport: sandbox.NewGuardedTransport(),
})
if err != nil {
logger.Warnf(ctx,
"Failed to initialize tenant sandbox resolver: %v "+
"(per-tenant sandbox config disabled)", err)
return nil
}
logger.Infof(ctx, "Tenant sandbox resolver configured: binding=%s", storeKind)
return resolver
}
+96
View File
@@ -0,0 +1,96 @@
package container
import (
"context"
"errors"
"strconv"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
)
func TestSelectSessionBindingStorePrefersRedis(t *testing.T) {
mini := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mini.Addr()})
t.Cleanup(func() { _ = client.Close() })
store, kind, err := selectSessionBindingStore(client, true)
require.NoError(t, err)
require.Equal(t, "redis", kind)
require.IsType(t, &sandbox.RedisSessionSandboxBindingStore{}, store)
}
func TestSelectSessionBindingStoreUsesMemoryWithoutRedis(t *testing.T) {
store, kind, err := selectSessionBindingStore(nil, true)
require.NoError(t, err)
require.Equal(t, "memory", kind)
require.IsType(t, &sandbox.MemorySessionSandboxBindingStore{}, store)
}
func TestSelectSessionBindingStoreFallsBackToMemoryWhenNotRequired(t *testing.T) {
store, kind, err := selectSessionBindingStore(nil, false)
require.NoError(t, err)
require.Equal(t, "memory", kind)
require.IsType(t, &sandbox.MemorySessionSandboxBindingStore{}, store)
}
// fakeSessionLookup implements the narrow sessionExistenceLookup surface the
// checker depends on. Keeping the surface narrow means the test double is a
// couple of lines instead of the full 16-method SessionRepository stub.
type fakeSessionLookup struct {
byID map[string]*types.Session
err error
}
func (f *fakeSessionLookup) GetByID(_ context.Context, tenantID uint64, id string) (*types.Session, error) {
if f.err != nil {
return nil, f.err
}
if s, ok := f.byID[id+"@"+strconv.FormatUint(tenantID, 10)]; ok {
return s, nil
}
return nil, gorm.ErrRecordNotFound
}
func TestSessionExistenceCheckerBackedByRepository(t *testing.T) {
repo := &fakeSessionLookup{
byID: map[string]*types.Session{
"session-a@42": {ID: "session-a", TenantID: 42},
},
}
checker := sessionExistenceCheckerFor(repo)
ok, err := checker.SessionExists(context.Background(), sandbox.SessionSandboxKey{TenantID: 42, SessionID: "session-a"})
require.NoError(t, err)
require.True(t, ok)
ok, err = checker.SessionExists(context.Background(), sandbox.SessionSandboxKey{TenantID: 42, SessionID: "missing"})
require.NoError(t, err)
require.False(t, ok)
}
func TestSessionExistenceCheckerPropagatesTransientErrors(t *testing.T) {
fault := errors.New("database offline")
repo := &fakeSessionLookup{err: fault}
checker := sessionExistenceCheckerFor(repo)
ok, err := checker.SessionExists(context.Background(), sandbox.SessionSandboxKey{TenantID: 42, SessionID: "session-a"})
require.ErrorIs(t, err, fault)
require.False(t, ok)
}
func TestSessionExistenceCheckerFallsBackToPermissiveWhenLookupNil(t *testing.T) {
// Lite deployments (no database) still need to boot; a nil lookup
// hands us the permissive checker so the lifecycle coordinator never
// blocks resolution on a missing dependency.
checker := sessionExistenceCheckerFor(nil)
ok, err := checker.SessionExists(context.Background(), sandbox.SessionSandboxKey{TenantID: 42, SessionID: "session-a"})
require.NoError(t, err)
require.True(t, ok)
}
+43
View File
@@ -0,0 +1,43 @@
package container
import (
"os"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/utils"
)
func TestMain(m *testing.M) {
// Engine wiring tests intentionally use loopback httptest servers.
utils.SetSSRFWhitelistFromRaw("127.0.0.1,::1,localhost")
code := m.Run()
utils.SetSSRFWhitelistFromRaw("")
os.Exit(code)
}
func TestValidateRuntimeVectorStoreAddressesRejectsUnsafeEndpoints(t *testing.T) {
tests := []types.VectorStore{
{EngineType: types.QdrantRetrieverEngineType, ConnectionConfig: types.ConnectionConfig{Host: "169.254.169.254", Port: 6334}},
{EngineType: types.MilvusRetrieverEngineType, ConnectionConfig: types.ConnectionConfig{Addr: "169.254.169.254:19530"}},
{EngineType: types.WeaviateRetrieverEngineType, ConnectionConfig: types.ConnectionConfig{Host: "https://example.com", GrpcAddress: "169.254.169.254:50051"}},
{EngineType: types.DorisRetrieverEngineType, ConnectionConfig: types.ConnectionConfig{Addr: "169.254.169.254:9030"}},
{EngineType: types.TencentVectorDBRetrieverEngineType, ConnectionConfig: types.ConnectionConfig{Addr: "169.254.169.254:80"}},
}
for _, store := range tests {
if err := validateRuntimeVectorStoreAddresses(store); err == nil {
t.Fatalf("expected %s unsafe address to be rejected", store.EngineType)
}
}
}
func TestValidateRuntimeVectorStoreAddressesAllowsNonNetworkEngines(t *testing.T) {
for _, engineType := range []types.RetrieverEngineType{
types.PostgresRetrieverEngineType,
types.SQLiteRetrieverEngineType,
} {
if err := validateRuntimeVectorStoreAddresses(types.VectorStore{EngineType: engineType}); err != nil {
t.Fatalf("unexpected %s validation error: %v", engineType, err)
}
}
}
@@ -0,0 +1,45 @@
package container
import (
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestMigrateLegacyStorageBackends(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
// Schema is owned by the SQL migrations in production; provision it here so
// the test exercises only the data-migration logic.
require.NoError(t, db.Exec(`CREATE TABLE tenants (
id INTEGER PRIMARY KEY, name TEXT, storage_engine_config TEXT,
default_storage_backend_id TEXT, updated_at DATETIME, deleted_at DATETIME
)`).Error)
require.NoError(t, db.Exec(`CREATE TABLE knowledge_bases (
id TEXT PRIMARY KEY, tenant_id INTEGER, storage_provider_config TEXT,
storage_backend_id TEXT, cos_config TEXT, updated_at DATETIME, deleted_at DATETIME
)`).Error)
require.NoError(t, db.AutoMigrate(&types.StorageBackend{}))
tenantConfig, err := (&types.StorageEngineConfig{DefaultProvider: "local", Local: &types.LocalEngineConfig{PathPrefix: "workspace-a"}}).Value()
require.NoError(t, err)
providerConfig, err := (types.StorageProviderConfig{Provider: "local"}).Value()
require.NoError(t, err)
require.NoError(t, db.Exec("INSERT INTO tenants(id, name, storage_engine_config) VALUES (?, ?, ?)", 7, "workspace", tenantConfig).Error)
require.NoError(t, db.Exec("INSERT INTO knowledge_bases(id, tenant_id, storage_provider_config, cos_config) VALUES (?, ?, ?, ?)", "kb-a", 7, providerConfig, []byte("{}")).Error)
migrateLegacyStorageBackends(db)
var backend types.StorageBackend
require.NoError(t, db.Where("tenant_id = ? AND provider = ? AND legacy_alias = ?", 7, "local", true).First(&backend).Error)
assert.Equal(t, "workspace-a", backend.Config.PathPrefix)
var tenantDefault, kbBackend string
require.NoError(t, db.Raw("SELECT default_storage_backend_id FROM tenants WHERE id = 7").Scan(&tenantDefault).Error)
require.NoError(t, db.Raw("SELECT storage_backend_id FROM knowledge_bases WHERE id = 'kb-a'").Scan(&kbBackend).Error)
assert.Equal(t, backend.ID, tenantDefault)
assert.Equal(t, backend.ID, kbBackend)
}