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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user