Files
Enterprise-Knowledge-Base/internal/application/service/datasource_service_test.go
T
nullkey c695125dce fix(feishu): resilient large-wiki sync — retry, streaming resume, failure visibility
Large Feishu wikis stopped syncing after the first batch (Tencent/WeKnora#2136:
5000+ docs, only ~150 ingested; #1262: a child-list failure aborted the run).
Three compounding causes, fixed in layers:

1. Rate-limit / transient failures had no retry. Feishu's wiki/drive/export
   APIs are aggressively rate limited; a thousand-doc sync issues tens of
   thousands of calls, and a single 429/5xx burst failed whole swathes of docs.
   Add exponential backoff honouring Retry-After to doRequest (JSON) and
   downloadRawBytes (files): 429 up to 3x, 5xx once, transport errors back off.

2. The batch path fetched the entire tree into memory before ingesting, so a
   sync that hit the 2h task timeout lost all progress and restarted from
   scratch, and a transient per-document export failure advanced the cursor and
   skipped that doc permanently. Add an optional StreamingConnector interface,
   implemented for Feishu: FetchStream interleaves fetch -> ingest -> checkpoint,
   bounding memory to one item and persisting a per-node edit-time cursor at
   page boundaries. The service dispatches to it via type assertion; connectors
   without it fall back to FetchAll / FetchIncremental unchanged (non-breaking).
   A resumed/incremental run skips nodes already recorded at their current edit
   time (converges), a failed fetch does not advance the cursor (retried next
   run instead of dropped), and partial listing failures emit error items and
   carry prior edit times forward instead of aborting the sync.

3. Failures and skips were invisible. Unsupported node types (mindnote/slides/…)
   were skipped with no item, error or log ("13 docs synced only 3"). FetchAll
   now tallies discovered/fetched/failed/skipped per resource with a per-type
   skip breakdown and a progress line every 100 nodes. The backend already
   persisted per-item errors in SyncLog.result.errors but the frontend type
   omitted the field; declare `result` on SyncLog and render up to 50 failed
   documents in the sync-log drawer (#1262). i18n for zh-CN/en-US/ko-KR/ru-RU.
   result.errors is a jsonb column shipped in every list response, so it is
   capped at 100 samples server-side (recordSyncError); the exact failure total
   comes from the bounded items_failed counter, which the drawer uses for its
   count and "N more" line so a sync that fails thousands of docs stays bounded.
   Each sample is a structured, localisable SyncItemError instead of a raw
   "title: ...api error: status=500 body={json}" dump that repeated the title
   and leaked status/body/log_id. The connector (feishuFailure) classifies the
   error into a stable i18n code (+ the feishu code as a param); the service
   builds {title, code, params, message}; the drawer localises via vue-i18n to
   the viewer's language (zh/en/ko/ru), falling back to message. A custom
   UnmarshalJSON keeps old string-form logs readable. The banner is localised
   from items_failed too. Raw errors stay in the server logs. Follows the
   two-tier norm of Airbyte/Fivetran/Onyx: human, actionable, localised
   summaries in the UI; raw detail in logs. Transient errors say "will retry"
   (the cursor is retained); auth/permission errors point at the fix instead.

Tests: client_retry_test (backoff), tally_test (accounting), connector_stream /
datasource_stream (streaming dispatch + checkpoint), connector_convergence
(timeout + transient-failure resume converges to full coverage; proven to catch
#2136 via bug injection), datasource_result_cap (error-sample cap + structured
localisable SyncItemError, no raw body), connector_error_reason (error→i18n-code
classification), sync_item_error (legacy-string + structured back-compat) — all
in the normal suite. connector_realapi / connector_seed are an opt-in real-Feishu
harness gated by the feishu_integration build tag AND env vars, never run in CI.
2026-07-21 16:14:22 +08:00

190 lines
6.2 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/hibiken/asynq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProcessSyncCancelsWhenKnowledgeBaseDeleted(t *testing.T) {
ds := &types.DataSource{
ID: "ds-1",
TenantID: 1,
KnowledgeBaseID: "kb-deleted",
Type: types.ConnectorTypeRSS,
Status: types.DataSourceStatusActive,
}
dsRepo := newKBDeleteDSRepo("kb-deleted", ds)
syncLog := &types.SyncLog{
ID: "log-1",
DataSourceID: ds.ID,
TenantID: ds.TenantID,
Status: types.SyncLogStatusRunning,
StartedAt: time.Now().UTC(),
}
syncLogRepo := &processSyncSyncLogRepo{logs: map[string]*types.SyncLog{syncLog.ID: syncLog}}
svc := &DataSourceService{
dsRepo: dsRepo,
syncLogRepo: syncLogRepo,
kbService: &processSyncKBService{getErr: apprepo.ErrKnowledgeBaseNotFound},
}
payload, err := json.Marshal(types.DataSourceSyncPayload{
DataSourceID: ds.ID,
TenantID: ds.TenantID,
SyncLogID: syncLog.ID,
})
require.NoError(t, err)
err = svc.ProcessSync(context.Background(), asynq.NewTask(types.TypeDataSourceSync, payload))
require.NoError(t, err)
updated := syncLogRepo.logs[syncLog.ID]
require.NotNil(t, updated)
assert.Equal(t, types.SyncLogStatusCanceled, updated.Status)
assert.Equal(t, "knowledge base has been deleted", updated.ErrorMessage)
require.NotNil(t, updated.FinishedAt)
}
type processSyncKBService struct {
getErr error
}
func (s *processSyncKBService) CreateKnowledgeBase(context.Context, *types.KnowledgeBase) (*types.KnowledgeBase, error) {
return nil, nil
}
func (s *processSyncKBService) GetKnowledgeBaseByID(context.Context, string) (*types.KnowledgeBase, error) {
return nil, s.getErr
}
func (s *processSyncKBService) GetKnowledgeBaseByIDOnly(context.Context, string) (*types.KnowledgeBase, error) {
return nil, s.getErr
}
func (s *processSyncKBService) GetKnowledgeBasesByIDsOnly(context.Context, []string) ([]*types.KnowledgeBase, error) {
return nil, nil
}
func (s *processSyncKBService) FillKnowledgeBaseCounts(context.Context, *types.KnowledgeBase) error {
return nil
}
func (s *processSyncKBService) ListKnowledgeBases(context.Context) ([]*types.KnowledgeBase, error) {
return nil, nil
}
func (s *processSyncKBService) ListKnowledgeBasesByTenantID(context.Context, uint64) ([]*types.KnowledgeBase, error) {
return nil, nil
}
func (s *processSyncKBService) UpdateKnowledgeBase(
context.Context, string, string, string, *types.KnowledgeBaseConfig,
) (*types.KnowledgeBase, error) {
return nil, nil
}
func (s *processSyncKBService) DeleteKnowledgeBase(context.Context, string) error { return nil }
func (s *processSyncKBService) TogglePinKnowledgeBase(context.Context, string) (*types.KnowledgeBase, error) {
return nil, nil
}
func (s *processSyncKBService) HybridSearch(context.Context, string, types.SearchParams) ([]*types.SearchResult, error) {
return nil, nil
}
func (s *processSyncKBService) GetQueryEmbedding(context.Context, string, string) ([]float32, error) {
return nil, nil
}
func (s *processSyncKBService) ResolveEmbeddingModelKeys(context.Context, []*types.KnowledgeBase) map[string]string {
return nil
}
func (s *processSyncKBService) CopyKnowledgeBase(context.Context, string, string) (*types.KnowledgeBase, *types.KnowledgeBase, error) {
return nil, nil, nil
}
func (s *processSyncKBService) DuplicateKnowledgeBase(context.Context, string) (*types.KnowledgeBase, error) {
return nil, nil
}
func (s *processSyncKBService) GetRepository() interfaces.KnowledgeBaseRepository { return nil }
func (s *processSyncKBService) ProcessKBDelete(context.Context, *asynq.Task) error {
return nil
}
var _ interfaces.KnowledgeBaseService = (*processSyncKBService)(nil)
type processSyncSyncLogRepo struct {
logs map[string]*types.SyncLog
}
func (r *processSyncSyncLogRepo) Create(_ context.Context, log *types.SyncLog) error {
r.logs[log.ID] = log
return nil
}
func (r *processSyncSyncLogRepo) FindByID(_ context.Context, id string) (*types.SyncLog, error) {
log, ok := r.logs[id]
if !ok {
return nil, errors.New("sync log not found")
}
return log, nil
}
func (r *processSyncSyncLogRepo) FindByDataSource(context.Context, string, int, int) ([]*types.SyncLog, error) {
return nil, nil
}
func (r *processSyncSyncLogRepo) FindLatest(context.Context, string) (*types.SyncLog, error) {
return nil, nil
}
func (r *processSyncSyncLogRepo) HasRunningSync(context.Context, string) (bool, error) {
return false, nil
}
func (r *processSyncSyncLogRepo) Update(_ context.Context, log *types.SyncLog) error {
r.logs[log.ID] = log
return nil
}
func (r *processSyncSyncLogRepo) UpdateResult(_ context.Context, log *types.SyncLog) error {
return r.Update(context.Background(), log)
}
func (r *processSyncSyncLogRepo) CancelPendingByDataSource(context.Context, string) error {
return nil
}
func (r *processSyncSyncLogRepo) CleanupOldLogs(context.Context, int) error { return nil }
func TestAllFetchedItemsFailedError(t *testing.T) {
err := allFetchedItemsFailedError(&types.SyncResult{
Total: 2,
Failed: 2,
Errors: []types.SyncItemError{{Message: "doc one: export failed"}},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "all fetched items failed during sync (2/2)")
assert.Contains(t, err.Error(), "doc one: export failed")
}
func TestAllFetchedItemsFailedErrorIgnoresPartialFailure(t *testing.T) {
err := allFetchedItemsFailedError(&types.SyncResult{
Total: 3,
Created: 1,
Failed: 2,
})
require.NoError(t, err)
}
func TestAllFetchedItemsFailedErrorIgnoresSkippedItems(t *testing.T) {
err := allFetchedItemsFailedError(&types.SyncResult{
Total: 3,
Skipped: 3,
})
require.NoError(t, err)
}
func TestAllFetchedItemsFailedErrorTruncatesLongDetail(t *testing.T) {
err := allFetchedItemsFailedError(&types.SyncResult{
Total: 1,
Failed: 1,
Errors: []types.SyncItemError{{Message: strings.Repeat("x", 600)}},
})
require.Error(t, err)
assert.LessOrEqual(t, len(err.Error()), 560)
assert.Contains(t, err.Error(), "...")
}