Files
xinmi/internal/application/service/datasource_service.go
T

1473 lines
52 KiB
Go
Raw Normal View History

package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/textproto"
"reflect"
"slices"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/hibiken/asynq"
)
// DataSourceService implements the DataSourceService interface
type DataSourceService struct {
dsRepo interfaces.DataSourceRepository
syncLogRepo interfaces.SyncLogRepository
knowledgeService interfaces.KnowledgeService
kbService interfaces.KnowledgeBaseService
taskEnqueuer interfaces.TaskEnqueuer
connectorRegistry *datasource.ConnectorRegistry
scheduler *datasource.Scheduler
tenantRepo interfaces.TenantRepository
tagService interfaces.KnowledgeTagService
audit interfaces.AuditLogService
}
// NewDataSourceService creates a new data source service
func NewDataSourceService(
dsRepo interfaces.DataSourceRepository,
syncLogRepo interfaces.SyncLogRepository,
knowledgeService interfaces.KnowledgeService,
kbService interfaces.KnowledgeBaseService,
taskEnqueuer interfaces.TaskEnqueuer,
connectorRegistry *datasource.ConnectorRegistry,
scheduler *datasource.Scheduler,
tenantRepo interfaces.TenantRepository,
tagService interfaces.KnowledgeTagService,
audit interfaces.AuditLogService,
) interfaces.DataSourceService {
return &DataSourceService{
dsRepo: dsRepo,
syncLogRepo: syncLogRepo,
knowledgeService: knowledgeService,
kbService: kbService,
taskEnqueuer: taskEnqueuer,
connectorRegistry: connectorRegistry,
scheduler: scheduler,
tenantRepo: tenantRepo,
tagService: tagService,
audit: audit,
}
}
// CreateDataSource creates a new data source configuration
func (s *DataSourceService) CreateDataSource(ctx context.Context, ds *types.DataSource) (*types.DataSource, error) {
if ds == nil {
return nil, datasource.ErrDataSourceInvalid
}
// Validate knowledge base exists
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, ds.KnowledgeBaseID)
if err != nil || kb == nil {
return nil, datasource.ErrKnowledgeBaseNotFound
}
if kb.TenantID != ds.TenantID {
return nil, datasource.ErrKnowledgeBaseNotFound
}
// Validate connector type
_, err = s.connectorRegistry.Get(ds.Type)
if err != nil {
return nil, err
}
// Validate configuration
if cfg, err := ds.ParseConfig(); err == nil && cfg != nil {
cfg.StripNonSecretCredentials(ds.Type)
if blob, err := cfg.ToJSON(); err == nil {
ds.Config = blob
}
}
if err := s.validateDataSourceConfig(ctx, ds); err != nil {
return nil, err
}
// Create in database
if err := s.dsRepo.Create(ctx, ds); err != nil {
logger.Errorf(ctx, "failed to create data source: %v", err)
return nil, err
}
// Register cron schedule if configured
if ds.SyncSchedule != "" && ds.Status == types.DataSourceStatusActive {
if err := s.scheduler.AddOrUpdate(ds); err != nil {
logger.Warnf(ctx, "failed to register cron for ds=%s: %v", ds.ID, err)
}
}
logger.Infof(ctx, "data source created: id=%s type=%s kb=%s", ds.ID, ds.Type, ds.KnowledgeBaseID)
recordKBActivity(ctx, s.audit, ds.TenantID, ds.KnowledgeBaseID, types.AuditActionDataSourceCreated,
"data_source", ds.ID, types.AuditOutcomeSuccess,
map[string]any{"name": ds.Name, "type": ds.Type})
return ds, nil
}
// GetDataSource retrieves a data source by ID
func (s *DataSourceService) GetDataSource(ctx context.Context, id string) (*types.DataSource, error) {
ds, err := s.dsRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
return ds, nil
}
// ListDataSources lists all data sources for a knowledge base
func (s *DataSourceService) ListDataSources(ctx context.Context, kbID string) ([]*types.DataSource, error) {
dataSources, err := s.dsRepo.FindByKnowledgeBase(ctx, kbID)
if err != nil {
logger.Errorf(ctx, "failed to list data sources: %v", err)
return nil, err
}
// Attach latest sync log to each data source
for _, ds := range dataSources {
log, _ := s.syncLogRepo.FindLatest(ctx, ds.ID)
if log != nil {
ds.LatestSyncLog = log
}
}
return dataSources, nil
}
// UpdateDataSource updates an existing data source
func (s *DataSourceService) UpdateDataSource(ctx context.Context, ds *types.DataSource) (*types.DataSource, error) {
if ds == nil || ds.ID == "" {
return nil, datasource.ErrDataSourceInvalid
}
// Verify data source exists
existing, err := s.dsRepo.FindByID(ctx, ds.ID)
if err != nil {
return nil, err
}
if ds.KnowledgeBaseID == "" {
ds.KnowledgeBaseID = existing.KnowledgeBaseID
}
if ds.KnowledgeBaseID != existing.KnowledgeBaseID {
return nil, fmt.Errorf("changing knowledge base is not allowed")
}
if ds.TenantID == 0 {
ds.TenantID = existing.TenantID
}
if ds.TenantID != existing.TenantID {
return nil, datasource.ErrDataSourceInvalid
}
// Credentials NEVER flow through this endpoint — they live behind the
// /credentials subresource. Force-preserve the stored credentials map
// regardless of what the body says. Log a warning if a stale caller
// passes one so we can spot them and migrate later. Non-credential
// fields of Config (Type / ResourceIDs / Settings) flow through.
var mergedCfg, existingParsedCfg *types.DataSourceConfig
if len(ds.Config) > 0 {
incomingCfg, parseIncErr := ds.ParseConfig()
existingCfg, parseExErr := existing.ParseConfig()
if parseIncErr == nil && parseExErr == nil && incomingCfg != nil {
if incomingCfg.HasCredentials() {
logger.Warnf(ctx,
"deprecated: credentials in PUT /datasource/%s body are ignored; use PUT /credentials instead",
secutils.SanitizeForLog(ds.ID))
}
merged := *incomingCfg
if existingCfg != nil {
merged.Credentials = existingCfg.Credentials
} else {
merged.Credentials = nil
}
merged.StripNonSecretCredentials(ds.Type)
if blob, err := merged.ToJSON(); err == nil {
ds.Config = blob
}
mergedCfg = &merged
existingParsedCfg = existingCfg
}
}
// Validate new configuration if non-credential fields changed. Skip
// when there are no stored credentials yet (validators would fail with
// no token to call the live API) and when the parsed config is
// structurally identical.
configActuallyChanged := true
if mergedCfg != nil && existingParsedCfg != nil {
configActuallyChanged = !reflect.DeepEqual(*mergedCfg, *existingParsedCfg)
}
hasCreds := mergedCfg != nil && mergedCfg.HasConfiguredCredentials(ds.Type)
if hasCreds && (ds.Type != existing.Type || configActuallyChanged) {
if err := s.validateDataSourceConfig(ctx, ds); err != nil {
return nil, err
}
}
if err := s.dsRepo.Update(ctx, ds); err != nil {
logger.Errorf(ctx, "failed to update data source: %v", err)
return nil, err
}
// Update cron schedule
if err := s.scheduler.AddOrUpdate(ds); err != nil {
logger.Warnf(ctx, "failed to update cron for ds=%s: %v", ds.ID, err)
}
logger.Infof(ctx, "data source updated: id=%s", ds.ID)
recordKBActivity(ctx, s.audit, ds.TenantID, ds.KnowledgeBaseID, types.AuditActionDataSourceUpdated,
"data_source", ds.ID, types.AuditOutcomeSuccess,
map[string]any{"name": ds.Name, "type": ds.Type, "changed_fields": []string{"settings"}})
return ds, nil
}
// UpdateDataSourceCredentials replaces the connector credential map. This is
// a single atomic write; the previous credential set is discarded entirely
// (callers cannot patch individual keys because half-configured connector
// auth is meaningless). After persisting, the live connection is validated
// so the caller learns immediately if the new credentials are wrong.
func (s *DataSourceService) UpdateDataSourceCredentials(
ctx context.Context, id string, credentials map[string]interface{},
) (*types.DataSource, error) {
if id == "" {
return nil, datasource.ErrDataSourceInvalid
}
existing, err := s.dsRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
parsed, err := existing.ParseConfig()
if err != nil {
return nil, err
}
if parsed == nil {
parsed = &types.DataSourceConfig{Type: existing.Type}
}
parsed.Credentials = credentials
parsed.StripNonSecretCredentials(existing.Type)
blob, err := parsed.ToJSON()
if err != nil {
return nil, err
}
existing.Config = blob
// Run live validation now that the credentials are in place — surfaces
// "wrong token" feedback immediately to the user instead of waiting for
// the next scheduled sync.
if err := s.validateDataSourceConfig(ctx, existing); err != nil {
return nil, err
}
if err := s.dsRepo.Update(ctx, existing); err != nil {
return nil, err
}
logger.Infof(ctx, "DataSource credentials updated: id=%s", secutils.SanitizeForLog(id))
recordKBActivity(ctx, s.audit, existing.TenantID, existing.KnowledgeBaseID, types.AuditActionDataSourceUpdated,
"data_source", existing.ID, types.AuditOutcomeSuccess,
map[string]any{"name": existing.Name, "type": existing.Type, "changed_fields": []string{"credentials"}})
return existing, nil
}
// ClearDataSourceCredentials wipes the connector credential map without
// touching any other config field. Idempotent.
func (s *DataSourceService) ClearDataSourceCredentials(ctx context.Context, id string) error {
if id == "" {
return datasource.ErrDataSourceInvalid
}
existing, err := s.dsRepo.FindByID(ctx, id)
if err != nil {
return err
}
parsed, err := existing.ParseConfig()
if err != nil {
return err
}
if parsed == nil {
return nil
}
parsed.StripNonSecretCredentials(existing.Type)
if !parsed.HasConfiguredCredentials(existing.Type) {
blob, err := parsed.ToJSON()
if err != nil {
return err
}
existing.Config = blob
return s.dsRepo.Update(ctx, existing)
}
parsed.Credentials = nil
blob, err := parsed.ToJSON()
if err != nil {
return err
}
existing.Config = blob
if err := s.dsRepo.Update(ctx, existing); err != nil {
return err
}
logger.Infof(ctx, "DataSource credentials cleared by user: id=%s", secutils.SanitizeForLog(id))
recordKBActivity(ctx, s.audit, existing.TenantID, existing.KnowledgeBaseID, types.AuditActionDataSourceUpdated,
"data_source", existing.ID, types.AuditOutcomeSuccess,
map[string]any{"name": existing.Name, "type": existing.Type, "changed_fields": []string{"credentials"}})
return nil
}
// DeleteDataSource deletes a data source (soft delete)
func (s *DataSourceService) DeleteDataSource(ctx context.Context, id string) error {
// Verify data source exists
existing, err := s.dsRepo.FindByID(ctx, id)
if err != nil {
return err
}
if err := s.dsRepo.Delete(ctx, id); err != nil {
logger.Errorf(ctx, "failed to delete data source: %v", err)
return err
}
// Remove cron schedule
s.scheduler.Remove(id)
// Cancel any pending/running sync logs so queued asynq tasks won't retry
if err := s.syncLogRepo.CancelPendingByDataSource(ctx, id); err != nil {
logger.Warnf(ctx, "failed to cancel pending sync logs for ds=%s: %v", id, err)
}
logger.Infof(ctx, "data source deleted: id=%s", id)
recordKBActivity(ctx, s.audit, existing.TenantID, existing.KnowledgeBaseID, types.AuditActionDataSourceDeleted,
"data_source", existing.ID, types.AuditOutcomeSuccess,
map[string]any{"name": existing.Name, "type": existing.Type})
return nil
}
// ValidateConnection tests the connection to an external data source
func (s *DataSourceService) ValidateConnection(ctx context.Context, dsID string) error {
ds, err := s.GetDataSource(ctx, dsID)
if err != nil {
return err
}
// Get connector
connector, err := s.connectorRegistry.Get(ds.Type)
if err != nil {
return err
}
// Parse configuration
config, err := ds.ParseConfig()
if err != nil {
return datasource.ErrInvalidConfig
}
// Validate connection
if err := connector.Validate(ctx, config); err != nil {
// Update data source with error
ds.Status = types.DataSourceStatusError
ds.ErrorMessage = err.Error()
_ = s.dsRepo.Update(ctx, ds)
return err
}
// Clear error if it was previously in error state
if ds.Status == types.DataSourceStatusError {
ds.Status = types.DataSourceStatusActive
ds.ErrorMessage = ""
_ = s.dsRepo.Update(ctx, ds)
}
return nil
}
// ListAvailableResources lists resources available for sync in the external system.
// parentID enables lazy (on-demand) loading of hierarchical resources: pass "" to
// list the top level, or a resource's ExternalID to list only its direct children.
func (s *DataSourceService) ListAvailableResources(
ctx context.Context, dsID string, parentID string,
) ([]types.Resource, error) {
ds, err := s.GetDataSource(ctx, dsID)
if err != nil {
return nil, err
}
// Get connector
connector, err := s.connectorRegistry.Get(ds.Type)
if err != nil {
return nil, err
}
// Parse configuration
config, err := ds.ParseConfig()
if err != nil {
return nil, datasource.ErrInvalidConfig
}
// List resources
resources, err := connector.ListResources(ctx, config, parentID)
if err != nil {
logger.Errorf(ctx, "failed to list resources: %v", err)
return nil, err
}
return resources, nil
}
// ResolveResourceAncestors resolves the ancestor ExternalIDs needed to reveal the
// given resources in a lazily-loaded picker (see the connector method for details).
func (s *DataSourceService) ResolveResourceAncestors(
ctx context.Context, dsID string, resourceIDs []string,
) ([]string, error) {
if len(resourceIDs) == 0 {
return []string{}, nil
}
ds, err := s.GetDataSource(ctx, dsID)
if err != nil {
return nil, err
}
connector, err := s.connectorRegistry.Get(ds.Type)
if err != nil {
return nil, err
}
config, err := ds.ParseConfig()
if err != nil {
return nil, datasource.ErrInvalidConfig
}
ancestors, err := connector.ResolveResourceAncestors(ctx, config, resourceIDs)
if err != nil {
logger.Errorf(ctx, "failed to resolve resource ancestors: %v", err)
return nil, err
}
return ancestors, nil
}
// ManualSync triggers an immediate sync for a data source
func (s *DataSourceService) ManualSync(ctx context.Context, dsID string) (*types.SyncLog, error) {
ds, err := s.GetDataSource(ctx, dsID)
if err != nil {
return nil, err
}
if ds.Status != types.DataSourceStatusActive &&
ds.Status != types.DataSourceStatusError &&
ds.Status != types.DataSourceStatusPaused {
return nil, datasource.ErrDataSourceNotActive
}
// Create sync log
syncLog := &types.SyncLog{
DataSourceID: dsID,
TenantID: ds.TenantID,
Status: types.SyncLogStatusRunning,
StartedAt: time.Now().UTC(),
}
if err := s.syncLogRepo.Create(ctx, syncLog); err != nil {
logger.Errorf(ctx, "failed to create sync log: %v", err)
return nil, err
}
// Enqueue sync task
payload := &types.DataSourceSyncPayload{
DataSourceID: dsID,
TenantID: ds.TenantID,
SyncLogID: syncLog.ID,
ForceFull: false,
Initiator: types.TaskInitiatorFromContext(ctx),
Trigger: "manual",
}
langfuse.InjectTracing(ctx, payload)
payloadJSON, _ := json.Marshal(payload)
task := asynq.NewTask(types.TypeDataSourceSync, payloadJSON,
asynq.Queue(types.QueueSync), asynq.MaxRetry(5), asynq.Timeout(2*time.Hour))
info, err := s.taskEnqueuer.Enqueue(task)
if err != nil {
logger.Errorf(ctx, "failed to enqueue sync task: %v", err)
syncLog.Status = types.SyncLogStatusFailed
syncLog.FinishedAt = timePtr(time.Now().UTC())
syncLog.ErrorMessage = err.Error()
_ = s.syncLogRepo.Update(ctx, syncLog)
if ds.Status != types.DataSourceStatusPaused {
ds.Status = types.DataSourceStatusError
}
ds.ErrorMessage = fmt.Sprintf("Failed to enqueue sync: %v", err)
_ = s.dsRepo.Update(ctx, ds)
recordKBActivity(ctx, s.audit, ds.TenantID, ds.KnowledgeBaseID, types.AuditActionDataSourceSyncFailed,
"data_source", ds.ID, types.AuditOutcomeFailed,
map[string]any{"name": ds.Name, "type": ds.Type, "sync_log_id": syncLog.ID, "trigger": "manual"})
return nil, err
}
logger.Infof(ctx, "sync task enqueued: ds=%s syncLog=%s", dsID, syncLog.ID)
recordKBActivity(ctx, s.audit, ds.TenantID, ds.KnowledgeBaseID, types.AuditActionDataSourceSyncStarted,
"data_source", ds.ID, types.AuditOutcomeAccepted,
map[string]any{"name": ds.Name, "type": ds.Type, "sync_log_id": syncLog.ID,
"task_id": info.ID, "trigger": "manual", "processing_status": "pending"})
return syncLog, nil
}
// PauseDataSource pauses a data source's scheduled syncs
func (s *DataSourceService) PauseDataSource(ctx context.Context, id string) error {
ds, err := s.GetDataSource(ctx, id)
if err != nil {
return err
}
ds.Status = types.DataSourceStatusPaused
if err := s.dsRepo.Update(ctx, ds); err != nil {
logger.Errorf(ctx, "failed to pause data source: %v", err)
return err
}
// Remove cron schedule
s.scheduler.Remove(id)
logger.Infof(ctx, "data source paused: id=%s", id)
recordKBActivity(ctx, s.audit, ds.TenantID, ds.KnowledgeBaseID, types.AuditActionDataSourcePaused,
"data_source", ds.ID, types.AuditOutcomeSuccess, map[string]any{"name": ds.Name, "type": ds.Type})
return nil
}
// ResumeDataSource resumes a paused data source
func (s *DataSourceService) ResumeDataSource(ctx context.Context, id string) error {
ds, err := s.GetDataSource(ctx, id)
if err != nil {
return err
}
ds.Status = types.DataSourceStatusActive
if err := s.dsRepo.Update(ctx, ds); err != nil {
logger.Errorf(ctx, "failed to resume data source: %v", err)
return err
}
// Re-register cron schedule
if err := s.scheduler.AddOrUpdate(ds); err != nil {
logger.Warnf(ctx, "failed to re-register cron for ds=%s: %v", ds.ID, err)
}
logger.Infof(ctx, "data source resumed: id=%s", id)
recordKBActivity(ctx, s.audit, ds.TenantID, ds.KnowledgeBaseID, types.AuditActionDataSourceResumed,
"data_source", ds.ID, types.AuditOutcomeSuccess, map[string]any{"name": ds.Name, "type": ds.Type})
return nil
}
// GetSyncLogs retrieves sync history for a data source
func (s *DataSourceService) GetSyncLogs(ctx context.Context, dsID string, limit int, offset int) ([]*types.SyncLog, error) {
logs, err := s.syncLogRepo.FindByDataSource(ctx, dsID, limit, offset)
if err != nil {
logger.Errorf(ctx, "failed to get sync logs: %v", err)
return nil, err
}
return logs, nil
}
// GetSyncLog retrieves a specific sync log entry
func (s *DataSourceService) GetSyncLog(ctx context.Context, syncLogID string) (*types.SyncLog, error) {
log, err := s.syncLogRepo.FindByID(ctx, syncLogID)
if err != nil {
return nil, err
}
return log, nil
}
// ProcessSync handles the actual sync operation (called by asynq task)
func (s *DataSourceService) ProcessSync(ctx context.Context, task *asynq.Task) error {
var payload types.DataSourceSyncPayload
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
logger.Errorf(ctx, "failed to unmarshal sync payload: %v", err)
return err
}
ctx = payload.Initiator.Apply(ctx)
taskID, _ := asynq.GetTaskID(ctx)
ctx = withKBActivityTask(ctx, taskID, payload.Trigger)
logger.Infof(ctx, "processing data source sync: ds=%s syncLog=%s", payload.DataSourceID, payload.SyncLogID)
// Get data source
ds, err := s.GetDataSource(ctx, payload.DataSourceID)
if err != nil {
logger.Warnf(ctx, "data source not found (likely deleted), cancelling sync: ds=%s err=%v", payload.DataSourceID, err)
if syncLog, slErr := s.syncLogRepo.FindByID(ctx, payload.SyncLogID); slErr == nil && syncLog != nil {
syncLog.Status = types.SyncLogStatusCanceled
syncLog.FinishedAt = timePtr(time.Now().UTC())
syncLog.ErrorMessage = "data source has been deleted"
_ = s.syncLogRepo.Update(ctx, syncLog)
}
return nil
}
// Get sync log
syncLog, err := s.syncLogRepo.FindByID(ctx, payload.SyncLogID)
if err != nil {
logger.Errorf(ctx, "failed to get sync log: %v", err)
return nil
}
kb, kbErr := s.kbService.GetKnowledgeBaseByID(ctx, ds.KnowledgeBaseID)
if kbErr != nil {
logger.Warnf(ctx, "knowledge base not found (likely deleted), cancelling sync: kb=%s ds=%s err=%v",
ds.KnowledgeBaseID, payload.DataSourceID, kbErr)
syncLog.Status = types.SyncLogStatusCanceled
syncLog.FinishedAt = timePtr(time.Now().UTC())
syncLog.ErrorMessage = "knowledge base has been deleted"
_ = s.syncLogRepo.Update(ctx, syncLog)
return nil
}
wasPaused := ds.Status == types.DataSourceStatusPaused
// Get connector
connector, err := s.connectorRegistry.Get(ds.Type)
if err != nil {
logger.Errorf(ctx, "connector not found: type=%s", ds.Type)
syncLog.Status = types.SyncLogStatusFailed
syncLog.FinishedAt = timePtr(time.Now().UTC())
syncLog.ErrorMessage = fmt.Sprintf("Connector not found: %s", ds.Type)
_ = s.syncLogRepo.Update(ctx, syncLog)
if !wasPaused {
ds.Status = types.DataSourceStatusError
}
ds.ErrorMessage = syncLog.ErrorMessage
_ = s.dsRepo.Update(ctx, ds)
return err
}
// Parse configuration
config, err := ds.ParseConfig()
if err != nil {
logger.Errorf(ctx, "failed to parse config: %v", err)
syncLog.Status = types.SyncLogStatusFailed
syncLog.FinishedAt = timePtr(time.Now().UTC())
syncLog.ErrorMessage = fmt.Sprintf("Invalid configuration: %v", err)
_ = s.syncLogRepo.Update(ctx, syncLog)
if !wasPaused {
ds.Status = types.DataSourceStatusError
}
ds.ErrorMessage = syncLog.ErrorMessage
_ = s.dsRepo.Update(ctx, ds)
return err
}
// Surface the KB's multimodal/VLM state to the connector so it only extracts
// embedded images for OCR when the KB can actually ingest them (never persisted).
config.MultimodalEnabled = kb.IsMultimodalEnabled()
// Streaming path: connectors that support it interleave fetch→ingest→
// checkpoint so a large sync bounds memory and resumes after a timeout
// instead of restarting (Tencent/WeKnora#2136). Others fall back below.
if sc, ok := connector.(datasource.StreamingConnector); ok {
return s.processSyncStreaming(ctx, sc, ds, syncLog, config, payload, wasPaused)
}
// Fetch items based on sync mode
var items []types.FetchedItem
var nextCursor *types.SyncCursor
var fetchErr error
if payload.ForceFull || ds.SyncMode == types.SyncModeFull {
// Full sync
items, fetchErr = connector.FetchAll(ctx, config, config.ResourceIDs)
logger.Infof(ctx, "full sync fetched %d items", len(items))
} else {
// Incremental sync
cursor, _ := ds.ParseSyncCursor()
items, nextCursor, fetchErr = connector.FetchIncremental(ctx, config, cursor)
logger.Infof(ctx, "incremental sync fetched %d items", len(items))
}
var fetchWarnings []string
var partialFetch *datasource.PartialFetchError
if errors.As(fetchErr, &partialFetch) {
fetchWarnings = partialFetch.Details
fetchErr = nil
}
if fetchErr != nil {
// Persist connector cursor even when fetch failed so transient outages
// (e.g. RSS feed downtime) do not force a full re-ingest on recovery.
if nextCursor != nil {
if cursorJSON, cerr := nextCursor.ToJSON(); cerr == nil {
ds.LastSyncCursor = cursorJSON
if uerr := s.dsRepo.UpdateSyncState(ctx, ds); uerr != nil {
logger.Warnf(ctx, "failed to persist sync cursor after fetch error: %v", uerr)
}
}
}
logger.Errorf(ctx, "fetch operation failed: %v", fetchErr)
syncLog.Status = types.SyncLogStatusFailed
syncLog.FinishedAt = timePtr(time.Now().UTC())
syncLog.ErrorMessage = fmt.Sprintf("Fetch failed: %v", fetchErr)
_ = s.syncLogRepo.Update(ctx, syncLog)
if !wasPaused {
ds.Status = types.DataSourceStatusError
}
ds.ErrorMessage = syncLog.ErrorMessage
_ = s.dsRepo.Update(ctx, ds)
return fetchErr
}
// Process fetched items and write to knowledge base
var result = &types.SyncResult{
Total: len(items),
}
// Set tenant context so KnowledgeService can resolve tenant info correctly
ctx = context.WithValue(ctx, types.TenantIDContextKey, ds.TenantID)
tenant, err := s.tenantRepo.GetTenantByID(ctx, ds.TenantID)
if err != nil {
logger.Errorf(ctx, "failed to get tenant info: %v", err)
syncLog.Status = types.SyncLogStatusFailed
syncLog.FinishedAt = timePtr(time.Now().UTC())
syncLog.ErrorMessage = fmt.Sprintf("Failed to get tenant info: %v", err)
_ = s.syncLogRepo.Update(ctx, syncLog)
if !wasPaused {
ds.Status = types.DataSourceStatusError
}
ds.ErrorMessage = syncLog.ErrorMessage
_ = s.dsRepo.Update(ctx, ds)
return err
}
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant)
// Auto-tag: find or create a tag for this data source so synced items are easily identifiable
autoTagIDs := s.resolveAutoTagIDs(ctx, ds)
for _, item := range items {
item := item
s.applyFetchedItem(withKBActivitySuppressed(ctx), ds, &item, autoTagIDs, result)
}
resultJSON, _ := result.ToJSON()
if err := allFetchedItemsFailedError(result); err != nil {
logger.Errorf(ctx, "data source sync failed while processing fetched items: %v", err)
s.updateSyncRunResult(ctx, ds, syncLog, result, resultJSON, types.SyncLogStatusFailed, err.Error(), wasPaused)
return err
}
// Update cursor for next incremental sync
if nextCursor != nil {
cursorJSON, _ := nextCursor.ToJSON()
ds.LastSyncCursor = cursorJSON
}
ds.LastSyncAt = timePtr(time.Now().UTC())
syncStatus := types.SyncLogStatusSuccess
syncErrorMessage := ""
if len(fetchWarnings) > 0 {
syncStatus = types.SyncLogStatusPartial
syncErrorMessage = fmt.Sprintf("Some feeds failed: %s", strings.Join(fetchWarnings, "; "))
for _, w := range fetchWarnings {
result.Errors = append(result.Errors, types.SyncItemError{Message: w})
}
resultJSON, _ = result.ToJSON()
}
if result.Failed > 0 {
// Per-document failures flip the sync to partial so the drawer shows
// which docs didn't make it (mirrors the streaming path). Deletion
// failures additionally only retry on a later full sync.
syncStatus = types.SyncLogStatusPartial
if syncErrorMessage != "" {
syncErrorMessage += "; "
}
syncErrorMessage += fmt.Sprintf("%d document(s) failed to sync", result.Failed)
if result.DeletionFailed > 0 {
syncErrorMessage += fmt.Sprintf(
"; %d deletion failure(s) will only retry on the next full sync", result.DeletionFailed)
}
}
s.updateSyncRunResult(ctx, ds, syncLog, result, resultJSON, syncStatus, syncErrorMessage, wasPaused)
logger.Infof(ctx, "data source sync completed: ds=%s created=%d updated=%d deleted=%d",
payload.DataSourceID, syncLog.ItemsCreated, syncLog.ItemsUpdated, syncLog.ItemsDeleted)
return nil
}
// resolveAutoTagIDs finds or creates the per-data-source tag applied to every
// synced item so results are identifiable in the KB. A tag failure is
// non-fatal: the sync proceeds untagged.
func (s *DataSourceService) resolveAutoTagIDs(ctx context.Context, ds *types.DataSource) []string {
autoTagIDs := []string{}
if autoTag, tagErr := s.tagService.FindOrCreateTagByName(ctx, ds.KnowledgeBaseID, ds.Name); tagErr != nil {
logger.Warnf(ctx, "failed to find/create auto-tag %q: %v (proceeding without tag)", ds.Name, tagErr)
} else if autoTag != nil {
autoTagIDs = append(autoTagIDs, autoTag.ID)
logger.Infof(ctx, "using auto-tag %q (id=%s) for data source sync", ds.Name, autoTag.ID)
}
return autoTagIDs
}
// maxSyncResultErrors bounds the per-item error sample retained in
// SyncResult.Errors. That slice is persisted as jsonb and returned in every
// sync-log list response, so an unbounded list on a sync that fails thousands of
// documents means multi-MB DB rows and payloads. The accurate failure count
// lives in SyncResult.Failed (a bounded int); this list only keeps a sample for
// display (Tencent/WeKnora#2136 / #1262).
const maxSyncResultErrors = 100
// recordSyncError appends an error sample to result.Errors, capped at
// maxSyncResultErrors. Callers still increment result.Failed for the exact count.
func recordSyncError(result *types.SyncResult, item types.SyncItemError) {
if len(result.Errors) < maxSyncResultErrors {
result.Errors = append(result.Errors, item)
}
}
// fetchFailureSyncError maps a connector error item into a structured, user-
// facing sample. Connectors that classify their errors (Feishu) provide a stable
// i18n code + params via metadata so the frontend localises it to the viewer's
// language; the raw status/body/log_id never leaves the server logs. Connectors
// without codes keep the raw text as a Message fallback. Best practice per
// Airbyte/Fivetran/Onyx: humanised, actionable, localised UI; raw detail in logs.
func fetchFailureSyncError(item *types.FetchedItem, rawMsg string) types.SyncItemError {
e := types.SyncItemError{Title: item.Title}
if code := item.Metadata["error_reason_code"]; code != "" {
e.Code = code
if v := item.Metadata["error_reason_code_value"]; v != "" {
e.Params = map[string]string{"code": v}
}
e.Message = item.Metadata["error_reason"] // fallback if the client lacks the key
} else {
e.Message = rawMsg
}
return e
}
// applyFetchedItem writes a single fetched item into the knowledge base and
// updates result counters. It is the shared core of the batch loop and the
// streaming handler so item classification (deleted / empty / ingest outcome)
// stays identical across both fetch paths.
func (s *DataSourceService) applyFetchedItem(
ctx context.Context, ds *types.DataSource, item *types.FetchedItem,
tagIDs []string, result *types.SyncResult,
) {
if item.IsDeleted {
if !ds.SyncDeletions {
// Sync deletion disabled: neither count nor delete.
return
}
if item.ExternalID == "" {
logger.Warnf(ctx, "skipping deletion for item %q: empty external_id", item.Title)
result.Skipped++
return
}
// Perform real KB deletion, scoped to items owned by this data source
// so identical external IDs from different data sources cannot collide.
repo := s.knowledgeService.GetRepository()
existing, lookupErr := repo.FindByDataSourceExternalID(
ctx, ds.TenantID, ds.KnowledgeBaseID, ds.ID, item.ExternalID,
)
if lookupErr != nil {
logger.Errorf(ctx, "failed to find deleted knowledge for external_id=%s (ds=%s, kb=%s): %v",
item.ExternalID, ds.ID, ds.KnowledgeBaseID, lookupErr)
result.Failed++
result.DeletionFailed++
recordSyncError(result, types.SyncItemError{
Title: item.Title,
Code: "deletion_lookup_failed",
Message: "Failed to look up the item before deletion; see server logs",
})
return
}
if existing == nil {
// Deletion is idempotent: the source item may already have been
// removed manually or by an earlier sync.
result.Skipped++
return
}
if deleteErr := s.knowledgeService.DeleteKnowledge(ctx, existing.ID); deleteErr != nil {
// The cursor is already past this item, so a failed deletion normally
// retries only on a later full sync. Counted separately so the
// sync-log message can warn the operator about this gap.
result.Failed++
result.DeletionFailed++
logger.Errorf(ctx, "failed to delete knowledge %s for external_id=%s (ds=%s): %v",
existing.ID, item.ExternalID, ds.ID, deleteErr)
recordSyncError(result, types.SyncItemError{
Title: item.Title,
Code: "deletion_failed",
Message: "Deletion failed; see server logs",
})
return
}
if herr := repo.HardDeleteKnowledge(ctx, ds.TenantID, existing.ID); herr != nil {
result.Failed++
result.DeletionFailed++
logger.Errorf(ctx, "failed to hard-delete knowledge %s for external_id=%s (ds=%s): %v",
existing.ID, item.ExternalID, ds.ID, herr)
recordSyncError(result, types.SyncItemError{
Title: item.Title,
Code: "deletion_failed",
Message: "Deletion failed; see server logs",
})
return
}
result.Deleted++
return
}
if len(item.Content) == 0 && item.URL == "" {
// Check if this is an error item from the connector (failed to fetch content)
if errMsg, hasErr := item.Metadata["error"]; hasErr {
logger.Warnf(ctx, "item %q (external_id=%s) fetch failed: %s", item.Title, item.ExternalID, errMsg)
result.Failed++
recordSyncError(result, fetchFailureSyncError(item, errMsg))
} else {
logger.Infof(ctx, "skipping item %q (external_id=%s): no content or URL", item.Title, item.ExternalID)
result.Skipped++
}
return
}
isUpdate, err := s.ingestItem(ctx, ds, item, tagIDs)
if err != nil {
var dupErr *types.DuplicateKnowledgeError
switch {
case errors.As(err, &dupErr):
// Duplicate file/URL is not a failure — count as skipped.
logger.Infof(ctx, "item %q (external_id=%s) already exists, skipping", item.Title, item.ExternalID)
result.Skipped++
case item.Metadata["embedded_image"] == "true":
// An image extracted from a document for OCR is a best-effort
// enrichment, not the document itself. If the KB cannot ingest it
// (VLM/object-storage not configured for images, or a transient error),
// skip it rather than failing the whole sync: the doc body already
// synced, and the image stays in SubtreeKeep for a later retry once the
// KB is configured.
logger.Infof(ctx, "skipping embedded image %q (external_id=%s), not ingested: %v",
item.Title, item.ExternalID, err)
result.Skipped++
default:
logger.Warnf(ctx, "failed to ingest item %q (external_id=%s): %v", item.Title, item.ExternalID, err)
result.Failed++
recordSyncError(result, types.SyncItemError{
Title: item.Title,
Code: "ingest_failed",
Message: "Ingest failed; see server logs",
})
}
} else if isUpdate {
result.Updated++
} else {
result.Created++
}
}
// streamStartCursor decides which cursor a streaming fetch should resume from.
// A user-triggered full sync on its first attempt drops the cursor so every
// item is re-fetched; a retried full sync (attempt > 0) and every incremental
// sync resume from the last persisted checkpoint so a timed-out run converges
// instead of restarting from scratch.
func streamStartCursor(ds *types.DataSource, forceFull bool, attempt int) (*types.SyncCursor, error) {
if forceFull && attempt == 0 {
return nil, nil
}
return ds.ParseSyncCursor()
}
// streamSyncHandler adapts a streaming fetch to the knowledge-base ingest path.
// Emit ingests each item as it arrives (bounding memory) and Checkpoint persists
// the connector cursor plus live progress counts at page boundaries.
type streamSyncHandler struct {
svc *DataSourceService
ds *types.DataSource
tagIDs []string
result *types.SyncResult
syncLog *types.SyncLog
}
// Emit ingests one streamed item. A canceled context aborts the stream so the
// connector stops fetching; per-item ingest failures are recorded in result and
// do NOT abort (matching the batch loop, which never fails the whole sync for
// one bad document).
func (h *streamSyncHandler) Emit(ctx context.Context, item types.FetchedItem) error {
if err := ctx.Err(); err != nil {
return err
}
h.result.Total++
h.svc.applyFetchedItem(withKBActivitySuppressed(ctx), h.ds, &item, h.tagIDs, h.result)
return nil
}
// Checkpoint persists the connector cursor onto the data source and mirrors the
// running counts into the sync log so progress survives a crash and the UI can
// reflect a long sync mid-flight instead of jumping from 0 to done.
func (h *streamSyncHandler) Checkpoint(ctx context.Context, cursor *types.SyncCursor) error {
if cursor == nil {
return nil
}
cursorJSON, err := cursor.ToJSON()
if err != nil {
return err
}
h.ds.LastSyncCursor = cursorJSON
if err := h.svc.dsRepo.UpdateSyncState(ctx, h.ds); err != nil {
return err
}
// Best-effort live progress; a failure here must not abort the sync.
h.syncLog.ItemsTotal = h.result.Total
h.syncLog.ItemsCreated = h.result.Created
h.syncLog.ItemsUpdated = h.result.Updated
h.syncLog.ItemsDeleted = h.result.Deleted
h.syncLog.ItemsSkipped = h.result.Skipped
h.syncLog.ItemsFailed = h.result.Failed
if err := h.svc.syncLogRepo.UpdateResult(ctx, h.syncLog); err != nil {
logger.Warnf(ctx, "failed to persist sync log progress at checkpoint: %v", err)
}
return nil
}
// processSyncStreaming runs a sync through a StreamingConnector, ingesting each
// item as it arrives and checkpointing progress so the run is memory-bounded and
// resumable after a timeout.
func (s *DataSourceService) processSyncStreaming(
ctx context.Context, sc datasource.StreamingConnector,
ds *types.DataSource, syncLog *types.SyncLog,
config *types.DataSourceConfig, payload types.DataSourceSyncPayload, wasPaused bool,
) error {
// Tenant + auto-tag setup must precede fetching because the stream ingests
// each item on the fly.
ctx = context.WithValue(ctx, types.TenantIDContextKey, ds.TenantID)
tenant, err := s.tenantRepo.GetTenantByID(ctx, ds.TenantID)
if err != nil {
logger.Errorf(ctx, "failed to get tenant info: %v", err)
s.updateSyncRunResult(ctx, ds, syncLog, &types.SyncResult{}, nil,
types.SyncLogStatusFailed, fmt.Sprintf("Failed to get tenant info: %v", err), wasPaused)
return err
}
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant)
autoTagIDs := s.resolveAutoTagIDs(ctx, ds)
forceFull := payload.ForceFull || ds.SyncMode == types.SyncModeFull
attempt, _ := asynq.GetRetryCount(ctx)
startCursor, err := streamStartCursor(ds, forceFull, attempt)
if err != nil {
logger.Errorf(ctx, "failed to parse sync cursor: %v", err)
s.updateSyncRunResult(ctx, ds, syncLog, &types.SyncResult{}, nil,
types.SyncLogStatusFailed, fmt.Sprintf("Invalid cursor: %v", err), wasPaused)
return err
}
result := &types.SyncResult{}
handler := &streamSyncHandler{svc: s, ds: ds, tagIDs: autoTagIDs, result: result, syncLog: syncLog}
nextCursor, fetchErr := sc.FetchStream(ctx, config, startCursor, handler)
if fetchErr != nil {
// Progress so far is already checkpointed onto ds.LastSyncCursor; leave
// it in place so the Asynq retry resumes from there. Persist counts.
logger.Errorf(ctx, "streaming fetch failed: %v", fetchErr)
resultJSON, _ := result.ToJSON()
s.updateSyncRunResult(ctx, ds, syncLog, result, resultJSON,
types.SyncLogStatusFailed, fmt.Sprintf("Fetch failed: %v", fetchErr), wasPaused)
return fetchErr
}
resultJSON, _ := result.ToJSON()
if err := allFetchedItemsFailedError(result); err != nil {
logger.Errorf(ctx, "streaming sync failed while processing fetched items: %v", err)
s.updateSyncRunResult(ctx, ds, syncLog, result, resultJSON, types.SyncLogStatusFailed, err.Error(), wasPaused)
return err
}
// Persist the final cursor for the next incremental sync.
if nextCursor != nil {
if cursorJSON, cerr := nextCursor.ToJSON(); cerr == nil {
ds.LastSyncCursor = cursorJSON
}
}
ds.LastSyncAt = timePtr(time.Now().UTC())
// Surface per-document failures as a partial sync (not silent success), so
// the sync-log drawer's failure detail explains which docs didn't make it —
// the visibility gap behind "status normal but not everything syncs"
// (Tencent/WeKnora#2136). Fetch failures abort the stream before the failed
// page is checkpointed, so the next run retries them; deletion failures are
// past the cursor and only retry on a full sync in the normal case (see
// applyFetchedItem).
status := types.SyncLogStatusSuccess
errMsg := ""
if result.Failed > 0 {
status = types.SyncLogStatusPartial
errMsg = fmt.Sprintf("%d document(s) failed to sync", result.Failed)
if result.DeletionFailed > 0 {
errMsg += fmt.Sprintf("; %d deletion failure(s) will only retry on the next full sync", result.DeletionFailed)
}
}
s.updateSyncRunResult(ctx, ds, syncLog, result, resultJSON, status, errMsg, wasPaused)
logger.Infof(ctx, "streaming sync completed: ds=%s created=%d updated=%d deleted=%d skipped=%d failed=%d",
payload.DataSourceID, result.Created, result.Updated, result.Deleted, result.Skipped, result.Failed)
return nil
}
func (s *DataSourceService) updateSyncRunResult(
ctx context.Context,
ds *types.DataSource,
syncLog *types.SyncLog,
result *types.SyncResult,
resultJSON types.JSON,
status string,
errorMessage string,
wasPaused bool,
) {
syncLog.ItemsTotal = result.Total
syncLog.ItemsCreated = result.Created
syncLog.ItemsUpdated = result.Updated
syncLog.ItemsDeleted = result.Deleted
syncLog.ItemsSkipped = result.Skipped
syncLog.ItemsFailed = result.Failed
syncLog.Status = status
syncLog.FinishedAt = timePtr(time.Now().UTC())
syncLog.ErrorMessage = errorMessage
syncLog.Result = resultJSON
if err := s.syncLogRepo.UpdateResult(ctx, syncLog); err != nil {
logger.Errorf(ctx, "failed to update sync log: %v", err)
}
if status == types.SyncLogStatusFailed {
if !wasPaused {
ds.Status = types.DataSourceStatusError
}
} else if wasPaused {
ds.Status = types.DataSourceStatusPaused
} else {
ds.Status = types.DataSourceStatusActive
}
ds.ErrorMessage = errorMessage
ds.LastSyncResult = resultJSON
if err := s.dsRepo.UpdateSyncState(ctx, ds); err != nil {
logger.Errorf(ctx, "failed to update data source: %v", err)
}
action := types.AuditActionDataSourceSyncCompleted
outcome := types.AuditOutcomeSuccess
if status == types.SyncLogStatusFailed {
action = types.AuditActionDataSourceSyncFailed
outcome = types.AuditOutcomeFailed
} else if status == types.SyncLogStatusPartial {
outcome = types.AuditOutcomePartial
}
recordKBActivity(ctx, s.audit, ds.TenantID, ds.KnowledgeBaseID, action,
"data_source", ds.ID, outcome,
map[string]any{
"name": ds.Name, "type": ds.Type, "sync_log_id": syncLog.ID,
"total": result.Total, "created": result.Created, "updated": result.Updated,
"deleted": result.Deleted, "skipped": result.Skipped, "failed": result.Failed,
})
}
func allFetchedItemsFailedError(result *types.SyncResult) error {
if result == nil || result.Total == 0 {
return nil
}
if result.Failed != result.Total || result.Created != 0 || result.Updated != 0 ||
result.Deleted != 0 || result.Skipped != 0 {
return nil
}
detail := ""
if len(result.Errors) > 0 {
detail = result.Errors[0].Display()
const maxDetailLen = 500
if len(detail) > maxDetailLen {
detail = detail[:maxDetailLen] + "..."
}
}
if detail == "" {
return fmt.Errorf("all fetched items failed during sync (%d/%d)", result.Failed, result.Total)
}
return fmt.Errorf("all fetched items failed during sync (%d/%d): %s", result.Failed, result.Total, detail)
}
// ValidateCredentials tests connectivity using raw credentials without persisting anything.
func (s *DataSourceService) ValidateCredentials(ctx context.Context, connectorType string, credentials map[string]interface{}) error {
connector, err := s.connectorRegistry.Get(connectorType)
if err != nil {
return err
}
config := &types.DataSourceConfig{
Type: connectorType,
Credentials: credentials,
}
if err := connector.Validate(ctx, config); err != nil {
return err
}
return nil
}
// Helper functions
func (s *DataSourceService) validateDataSourceConfig(ctx context.Context, ds *types.DataSource) error {
connector, err := s.connectorRegistry.Get(ds.Type)
if err != nil {
return err
}
config, err := ds.ParseConfig()
if err != nil {
return datasource.ErrInvalidConfig
}
return connector.Validate(ctx, config)
}
// ingestItem writes a single FetchedItem into the knowledge base.
// If a knowledge item with the same external_id already exists, it is deleted first (update = delete + re-create).
//
// Routing logic:
// - Has Content bytes → CreateKnowledgeFromFile (走完整的文档解析 pipeline)
// - Has URL only → CreateKnowledgeFromURL (让 WeKnora 下载并解析)
//
// Returns (isUpdate, error) — isUpdate is true when an existing item was replaced.
func (s *DataSourceService) ingestItem(ctx context.Context, ds *types.DataSource, item *types.FetchedItem, tagIDs []string) (bool, error) {
// Channel decides the knowledge "source" label shown in the UI. Prefer the
// connector-supplied metadata["channel"] (e.g. Feishu Drive sets it to
// "feishu" so Drive docs share the wiki's "飞书" label instead of showing
// "unknown" for the raw ds.Type "feishu_drive"). Fall back to ds.Type so
// connectors that don't set metadata.channel still get a meaningful label.
channel := ds.Type // e.g. "feishu", "notion"
if item.Metadata != nil {
if mc, ok := item.Metadata["channel"]; ok && mc != "" {
channel = mc
}
}
metadata := map[string]string{
"external_id": item.ExternalID,
"source_resource_id": item.SourceResourceID,
"datasource_id": ds.ID,
}
for k, v := range item.Metadata {
metadata[k] = v
}
// Check if a knowledge item with this external_id already exists → delete it first (update)
isUpdate := false
if item.ExternalID != "" {
repo := s.knowledgeService.GetRepository()
// Scope the lookup to items owned by this data source so identical
// external IDs from two data sources cannot collide or overwrite each
// other during updates.
existing, err := repo.FindByDataSourceExternalID(ctx, ds.TenantID, ds.KnowledgeBaseID, ds.ID, item.ExternalID)
if err != nil {
logger.Warnf(ctx, "failed to check existing knowledge for external_id=%s: %v", item.ExternalID, err)
// Non-fatal: proceed with creation (may produce duplicate)
} else if existing != nil {
logger.Infof(ctx, "found existing knowledge %s for external_id=%s, deleting for update", existing.ID, item.ExternalID)
if err := s.knowledgeService.DeleteKnowledge(ctx, existing.ID); err != nil {
logger.Warnf(ctx, "failed to delete existing knowledge %s: %v", existing.ID, err)
} else {
if herr := repo.HardDeleteKnowledge(ctx, ds.TenantID, existing.ID); herr != nil {
logger.Warnf(ctx, "failed to hard-delete replaced knowledge %s: %v", existing.ID, herr)
}
isUpdate = true
}
}
}
// Case 1: content already fetched → build a FileHeader from bytes and call CreateKnowledgeFromFile
if len(item.Content) > 0 {
fh, err := bytesToFileHeader(item.Content, item.FileName)
if err != nil {
return isUpdate, fmt.Errorf("build file header: %w", err)
}
if _, err := s.knowledgeService.CreateKnowledgeFromFile(
ctx,
ds.KnowledgeBaseID,
fh,
metadata,
nil, // use KB default for multimodal
item.FileName, // customFileName — must include extension for file-type validation
tagIDs, // auto-tag from data source
channel,
nil,
); err != nil {
var dupErr *types.DuplicateKnowledgeError
if errors.As(err, &dupErr) && dupIsSameNode(dupErr, item) {
// Identical content is already present in the KB under THIS node's
// own external_id, so the parent effectively exists — reconcile the
// subtree so children removed from the doc do not linger.
s.sweepStaleSubtree(ctx, ds, item)
}
return isUpdate, err
}
s.sweepStaleSubtree(ctx, ds, item)
return isUpdate, nil
}
// Case 2: only a remote URL — let WeKnora handle downloading and parsing
if item.URL != "" {
created, err := s.knowledgeService.CreateKnowledgeFromURL(
ctx,
ds.KnowledgeBaseID,
item.URL,
item.FileName,
"", // auto-detect file type
nil, // use KB default for multimodal
item.Title,
tagIDs, // auto-tag from data source
channel,
nil,
)
if err != nil {
var dupErr *types.DuplicateKnowledgeError
if errors.As(err, &dupErr) && dupIsSameNode(dupErr, item) {
// Identical content is already present in the KB under THIS node's
// own external_id, so the parent effectively exists — reconcile the
// subtree so children removed from the doc do not linger.
s.sweepStaleSubtree(ctx, ds, item)
}
return isUpdate, err
}
// URL-created knowledge has no metadata, so a later deletion could
// never find it. Attach the datasource keys on fresh creation only;
// the duplicate path reuses an existing row that must not be re-tagged.
if created != nil {
metadataBytes, mErr := json.Marshal(metadata)
if mErr != nil {
return isUpdate, fmt.Errorf("marshal datasource metadata: %w", mErr)
}
created.Metadata = types.JSON(metadataBytes)
if uErr := s.knowledgeService.GetRepository().UpdateKnowledge(ctx, created); uErr != nil {
return isUpdate, fmt.Errorf("attach datasource metadata: %w", uErr)
}
}
s.sweepStaleSubtree(ctx, ds, item)
return isUpdate, nil
}
return isUpdate, fmt.Errorf("item has neither content nor URL")
}
// dupIsSameNode reports whether a duplicate-content error means the parent still
// exists in the KB *under this item's own external_id* — i.e. a content-dedup hit
// against this same node, so reconciling its subtree is safe. File deduplication
// keys on file_hash plus file_type (CheckKnowledgeExists), so an updated node whose rebuilt body
// happens to hash-collide with a DIFFERENT knowledge item (another node, or a
// manually-uploaded file with no external_id) would otherwise sweep this node's
// children even though its own parent row was just deleted for the update and
// never recreated — deleting those children with no parent to replace them. In
// that case the matched row's external_id differs (or is absent), so we skip the
// sweep and leave the children intact.
func dupIsSameNode(dupErr *types.DuplicateKnowledgeError, item *types.FetchedItem) bool {
return dupErr != nil && dupErr.Knowledge != nil &&
dupErr.Knowledge.GetMetadata()["external_id"] == item.ExternalID
}
// sweepStaleSubtree deletes STALE sub-items of item — knowledge whose external_id
// is prefixed with "<item.ExternalID>#" (e.g. attachment children of a docx node)
// that is NOT listed in item.SubtreeKeep, i.e. no longer present in the source.
//
// It runs only AFTER the parent item exists in the KB (freshly (re)created, or
// confirmed present via a duplicate-hash error), so a genuinely failed parent
// write never destroys existing children. Children still present in the source
// are preserved via SubtreeKeep even when they could not be re-ingested this
// cycle (e.g. a transient attachment download failure), so a still-present
// attachment never loses its previously-synced good copy. The "<id>#" prefix
// never matches the parent's own "<id>" external_id, so the parent is never
// self-swept.
func (s *DataSourceService) sweepStaleSubtree(ctx context.Context, ds *types.DataSource, item *types.FetchedItem) {
if !item.ReplacesSubtree || item.ExternalID == "" {
return
}
repo := s.knowledgeService.GetRepository()
children, err := repo.FindByMetadataKeyPrefix(ctx, ds.TenantID, ds.KnowledgeBaseID, "external_id", types.SubtreeChildPrefix(item.ExternalID))
if err != nil {
logger.Warnf(ctx, "failed to list subtree of external_id=%s: %v", item.ExternalID, err)
return
}
if len(children) == 0 {
return
}
ids := make([]string, 0, len(children))
for _, child := range children {
// Scope to this data source so identical external_id prefixes from
// another connector in the same KB cannot be swept.
if child.GetMetadata()["datasource_id"] != ds.ID {
continue
}
// A child still present in the source is preserved even if it could not be
// re-ingested this sync; only children that vanished from the source are
// stale and swept. Every child here was selected by the external_id-prefix
// query, so its external_id is guaranteed present and readable (a malformed
// row could not have matched the SQL predicate), and GetMetadata resolves
// it identically to the keep-set entries the connector built. SubtreeKeep
// holds one entry per still-present sub-item of this node (a small set), so
// a linear scan is cheaper than materializing a lookup map.
if slices.Contains(item.SubtreeKeep, child.GetMetadata()["external_id"]) {
continue
}
ids = append(ids, child.ID)
}
if len(ids) == 0 {
return
}
// Batch the deletion so a node whose attachment set shrank from N pays one
// round of the delete fan-out rather than N sequential ones.
if derr := s.knowledgeService.DeleteKnowledgeList(ctx, ids); derr != nil {
logger.Warnf(ctx, "failed to delete %d stale sub-item(s) of external_id=%s: %v",
len(ids), item.ExternalID, derr)
} else if herr := repo.HardDeleteKnowledgeList(ctx, ds.TenantID, ids); herr != nil {
logger.Warnf(ctx, "failed to hard-delete %d stale sub-item(s) of external_id=%s: %v",
len(ids), item.ExternalID, herr)
}
}
// bytesToFileHeader wraps a []byte into a *multipart.FileHeader so it can be
// consumed by KnowledgeService.CreateKnowledgeFromFile.
func bytesToFileHeader(data []byte, filename string) (*multipart.FileHeader, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Create a form file part
partHeader := make(textproto.MIMEHeader)
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
partHeader.Set("Content-Type", "application/octet-stream")
part, err := writer.CreatePart(partHeader)
if err != nil {
return nil, fmt.Errorf("create multipart part: %w", err)
}
if _, err := part.Write(data); err != nil {
return nil, fmt.Errorf("write data to part: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close multipart writer: %w", err)
}
// Parse the multipart data to get a FileHeader
reader := multipart.NewReader(&buf, writer.Boundary())
form, err := reader.ReadForm(int64(len(data)) + 1024)
if err != nil {
return nil, fmt.Errorf("read multipart form: %w", err)
}
files := form.File["file"]
if len(files) == 0 {
return nil, fmt.Errorf("no file in multipart form")
}
return files[0], nil
}
func timePtr(t time.Time) *time.Time {
utc := t.UTC()
return &utc
}