c695125dce
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.
118 lines
4.7 KiB
Go
118 lines
4.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// recordingDSRepo captures UpdateSyncState calls so checkpoint persistence can
|
|
// be asserted without a database.
|
|
type recordingDSRepo struct {
|
|
kbDeleteDSRepo
|
|
updated []*types.DataSource
|
|
}
|
|
|
|
func (r *recordingDSRepo) UpdateSyncState(_ context.Context, ds *types.DataSource) error {
|
|
// Snapshot the fields a checkpoint is expected to persist.
|
|
cp := *ds
|
|
r.updated = append(r.updated, &cp)
|
|
return nil
|
|
}
|
|
|
|
func makeConnectorCursor(t *testing.T, spaceNodeTimes map[string]map[string]string) types.JSON {
|
|
t.Helper()
|
|
inner := map[string]interface{}{"space_node_times": spaceNodeTimes}
|
|
b, err := json.Marshal(&types.SyncCursor{ConnectorCursor: inner})
|
|
require.NoError(t, err)
|
|
return types.JSON(b)
|
|
}
|
|
|
|
// A fresh full sync (ForceFull, first attempt) must ignore any recorded cursor
|
|
// and re-fetch everything. A retry of that same task (attempt > 0) must instead
|
|
// resume from the checkpointed cursor so it converges instead of restarting.
|
|
func TestStreamStartCursor_ForceFullFirstAttemptDropsCursor(t *testing.T) {
|
|
ds := &types.DataSource{
|
|
LastSyncCursor: makeConnectorCursor(t, map[string]map[string]string{"space1": {"nt1": "100"}}),
|
|
}
|
|
|
|
fresh, err := streamStartCursor(ds, true /*forceFull*/, 0 /*attempt*/)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, fresh, "fresh ForceFull must drop the cursor to re-fetch everything")
|
|
|
|
retry, err := streamStartCursor(ds, true /*forceFull*/, 1 /*attempt*/)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, retry, "a retried ForceFull must resume from the checkpoint")
|
|
assert.NotNil(t, retry.ConnectorCursor["space_node_times"])
|
|
}
|
|
|
|
// Incremental sync always resumes from the recorded cursor regardless of attempt.
|
|
func TestStreamStartCursor_IncrementalKeepsCursor(t *testing.T) {
|
|
ds := &types.DataSource{
|
|
LastSyncCursor: makeConnectorCursor(t, map[string]map[string]string{"space1": {"nt1": "100"}}),
|
|
}
|
|
cur, err := streamStartCursor(ds, false /*forceFull*/, 0)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cur)
|
|
assert.NotNil(t, cur.ConnectorCursor["space_node_times"])
|
|
}
|
|
|
|
func newStreamHandler(svc *DataSourceService, ds *types.DataSource, result *types.SyncResult, syncLog *types.SyncLog) *streamSyncHandler {
|
|
return &streamSyncHandler{svc: svc, ds: ds, result: result, syncLog: syncLog}
|
|
}
|
|
|
|
// Emit routes items through the same classification as the batch loop: deleted
|
|
// items are counted, and connector-reported failures (an item carrying only a
|
|
// Metadata["error"]) land in result.Failed with a message — never silently lost.
|
|
func TestStreamHandler_EmitClassifiesDeletedAndFailed(t *testing.T) {
|
|
ds := &types.DataSource{ID: "ds-1", Type: types.ConnectorTypeFeishu, SyncDeletions: true}
|
|
result := &types.SyncResult{}
|
|
h := newStreamHandler(&DataSourceService{}, ds, result, &types.SyncLog{})
|
|
|
|
require.NoError(t, h.Emit(context.Background(), types.FetchedItem{ExternalID: "gone", IsDeleted: true}))
|
|
require.NoError(t, h.Emit(context.Background(), types.FetchedItem{
|
|
ExternalID: "bad", Title: "Broken Doc",
|
|
Metadata: map[string]string{"error": "export failed"},
|
|
}))
|
|
|
|
assert.Equal(t, 1, result.Deleted)
|
|
assert.Equal(t, 1, result.Failed)
|
|
require.Len(t, result.Errors, 1)
|
|
assert.Equal(t, "Broken Doc", result.Errors[0].Title)
|
|
assert.Contains(t, result.Errors[0].Message, "export failed")
|
|
}
|
|
|
|
// A canceled context aborts the stream: Emit returns the context error so the
|
|
// connector stops fetching instead of burning API budget on a doomed run.
|
|
func TestStreamHandler_EmitAbortsOnCanceledContext(t *testing.T) {
|
|
h := newStreamHandler(&DataSourceService{}, &types.DataSource{}, &types.SyncResult{}, &types.SyncLog{})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
err := h.Emit(ctx, types.FetchedItem{ExternalID: "x", Content: []byte("data"), FileName: "x.md"})
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
}
|
|
|
|
// Checkpoint persists the connector cursor onto the data source so a crash
|
|
// after it keeps the progress made so far.
|
|
func TestStreamHandler_CheckpointPersistsCursor(t *testing.T) {
|
|
dsRepo := &recordingDSRepo{}
|
|
svc := &DataSourceService{dsRepo: dsRepo, syncLogRepo: &processSyncSyncLogRepo{logs: map[string]*types.SyncLog{}}}
|
|
ds := &types.DataSource{ID: "ds-1"}
|
|
result := &types.SyncResult{Created: 3}
|
|
syncLog := &types.SyncLog{ID: "log-1"}
|
|
h := newStreamHandler(svc, ds, result, syncLog)
|
|
|
|
cursor := &types.SyncCursor{ConnectorCursor: map[string]interface{}{
|
|
"space_node_times": map[string]map[string]string{"space1": {"nt1": "100"}},
|
|
}}
|
|
require.NoError(t, h.Checkpoint(context.Background(), cursor))
|
|
|
|
require.Len(t, dsRepo.updated, 1)
|
|
assert.NotEmpty(t, dsRepo.updated[0].LastSyncCursor, "checkpoint must persist the cursor JSON")
|
|
}
|