Files
ragflow/internal/ingestion/task/pipeline_executor_test.go

567 lines
18 KiB
Go
Raw Normal View History

package task
import (
"context"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"ragflow/internal/agent/runtime"
"ragflow/internal/dao"
"ragflow/internal/entity"
pipelinepkg "ragflow/internal/ingestion/pipeline"
refactor(ingestion/task): extract index-doc mapping into task/indexdoc package (#17749) ## Summary Extract the pipeline-output → search-engine index document mapping helpers out of the `task` package into a dedicated, dependency-light leaf package `internal/ingestion/task/indexdoc`. These functions are pure transforms (they only depend on `common`/`utility`) and are not task-orchestration concerns: - `NormalizeChunks`, `DeepCopyChunks` (was unexported `deepCopyChunks`), `toChunkMaps` → `indexdoc/normalize.go` - `ProcessChunksForPipeline`, `RenameTextToContentWithWeight`, `GetEmbeddingTokenConsumption`, `cleanupConsumedChunkFields`, `mergeChunkMetadata`, `processChunkPositions`, `AggregateTableDocMetadata`, `resolveTableColumnConfig` → `indexdoc/process.go` - `AddPositions` → `indexdoc/position.go` - `EmbeddingTokenConsumptionKey` constant → `indexdoc/constants.go` (task/constants.go keeps only `GRAPH_RAPTOR_FAKE_DOC_ID`) Call sites in `pipeline_executor.go` and `golden_compare.go` now reference the `indexdoc` package; package-task tests qualify the moved symbols. ## Why The `task` package had grown into a "orchestration + pure mapping + debug" mix. Splitting the pure mapping helpers into a leaf package sharpens package boundaries, removes a misleading top-level `ingestion/chunk` candidate (there are already `parser/chunk` and `service/chunk`), and lets the golden tool / future reuse pull in the mapping logic without dragging in `task`'s `dao`/`engine`/`service` dependency graph (Go subpackage import does not pull in the parent). ## Test plan - `build.sh --test ./internal/ingestion/task/...` — **green** (task 4.7s, indexdoc 0.007s), matching the pre-change baseline. - `gofmt` clean; `build.sh` builds both `ragflow-cli` and `ragflow_server` successfully. - Integration/E2E tiers are delegated to CI (need real MySQL/MinIO/ES services). Note: `pipeline_e2e_test.go` has a **pre-existing** compile error (`server.ElasticsearchConfig` / `server.InfinityConfig` are now defined under `internal/server/config/`, not re-exported by `internal/server`). This is unrelated to this change — the diff to that file is only the added `indexdoc` import and the qualified `EmbeddingTokenConsumptionKey` reference.
2026-08-04 10:05:27 +08:00
indexdoc "ragflow/internal/ingestion/task/indexdoc"
)
// =============================================================================
// Test helpers
// =============================================================================
func strPtr(s string) *string { return &s }
// TestMarkCompiledProductsHidden verifies the pipeline caller hides
// per-document compiled knowledge products (compile_kwd present) as
// available_int=0 while leaving ordinary source chunks searchable
// (available_int=1, the index default). Merged dataset-level products are written
// by the consumer and never reach this path, so they are never double-marked.
func TestMarkCompiledProductsHidden(t *testing.T) {
chunks := []map[string]any{
{"id": "src-1", "content_with_weight": "ordinary source chunk"},
{"id": "struct-1", "compile_kwd": "structure", "content_with_weight": "entity A"},
{"id": "wiki-1", "compile_kwd": "artifact_page", "content_with_weight": "page X"},
{"id": "src-2", "content_with_weight": "another source chunk"},
}
markCompiledProductsHidden(chunks)
if v, ok := chunks[0]["available_int"]; ok {
t.Fatalf("ordinary source chunk should keep default available_int, got %v", v)
}
if chunks[1]["available_int"] != 0 {
t.Fatalf("compiled structure chunk should be available_int=0, got %v", chunks[1]["available_int"])
}
if chunks[2]["available_int"] != 0 {
t.Fatalf("compiled wiki chunk should be available_int=0, got %v", chunks[2]["available_int"])
}
if v, ok := chunks[3]["available_int"]; ok {
t.Fatalf("source chunk without compile_kwd should keep default available_int, got %v", v)
}
}
func makeTaskCtx() *TaskContext {
return &TaskContext{
IngestionTask: &entity.IngestionTask{
ID: "task-1",
DocumentID: "doc-1",
},
Doc: entity.Document{
ID: "doc-1",
KbID: "kb-1",
Name: strPtr("test-doc.pdf"),
Suffix: ".pdf",
Type: "pdf",
},
KB: entity.Knowledgebase{
ID: "kb-1",
TenantID: "tenant-1",
EmbdID: "embd-1",
},
Tenant: entity.Tenant{
ID: "tenant-1",
},
}
}
func setupPipelineExecutorTestDB(t *testing.T) func() {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&entity.UserCanvas{}, &entity.PipelineOperationLog{}); err != nil {
t.Fatalf("auto-migrate sqlite: %v", err)
}
origDB := dao.DB
dao.DB = db
return func() { dao.DB = origDB }
}
func mustNewPipelineExecutor(t *testing.T, taskCtx *TaskContext, canvasID string, docBulkSize int) *PipelineExecutor {
t.Helper()
svc, err := NewPipelineExecutor(taskCtx, canvasID, docBulkSize)
if err != nil {
t.Fatalf("NewPipelineExecutor: %v", err)
}
return svc
}
// =============================================================================
// NewPipelineExecutor — constructor
// =============================================================================
func TestNewPipelineExecutor_Basic(t *testing.T) {
svc, err := NewPipelineExecutor(makeTaskCtx(), "flow-1", 0)
if err != nil {
t.Fatalf("NewPipelineExecutor: %v", err)
}
if svc == nil {
t.Fatal("NewPipelineExecutor returned nil")
}
if svc.taskCtx == nil {
t.Error("taskCtx should not be nil")
}
if svc.indexWriter == nil || svc.logCreateFunc == nil || svc.loadDSLFunc == nil || svc.runPipelineFunc == nil {
t.Fatal("expected production dependencies to be fully initialized")
}
}
func TestNewPipelineExecutor_RejectsNilTaskContext(t *testing.T) {
_, err := NewPipelineExecutor(nil, "flow-1", 0)
if err == nil {
t.Fatal("expected error for nil task context")
}
}
func TestNewPipelineExecutor_RejectsEmptyCanvasID(t *testing.T) {
_, err := NewPipelineExecutor(makeTaskCtx(), "", 0)
if err == nil {
t.Fatal("expected error for empty canvas id")
}
}
func TestNewPipelineExecutor_RejectsIncompleteTaskContext(t *testing.T) {
tests := []struct {
name string
mutate func(*TaskContext)
}{
{name: "missing doc id", mutate: func(ctx *TaskContext) { ctx.Doc.ID = "" }},
{name: "missing kb id", mutate: func(ctx *TaskContext) { ctx.Doc.KbID = "" }},
{name: "missing doc name", mutate: func(ctx *TaskContext) { ctx.Doc.Name = nil }},
{name: "missing tenant id", mutate: func(ctx *TaskContext) { ctx.Tenant.ID = "" }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := makeTaskCtx()
tt.mutate(ctx)
_, err := NewPipelineExecutor(ctx, "flow-1", 0)
if err == nil {
t.Fatal("expected validation error")
}
})
}
}
Feat(ingestion): add canvas pipeline debug (dry-run) mode with View result log (#17538) ## Summary Adds a side-effect-free DataFlow canvas **debug (dry-run) mode** plus a **debug run log with a "View result" panel**, so a canvas can be executed synchronously and inspected end-to-end (per-component progress and parsed chunks) without persisting anything. ### Dry-run execution (inline parsed chunks) - `task/debug.go`: `NewDebugTaskContext` builds an in-memory `TaskContext` with **`KB.ID == ""` — the single debug signal used across the ingestion pipeline**. A canvas debug run has no knowledgebase, and production ingestion always supplies one, so `kb_id == ""` occurs ONLY in debug mode. Components gate their own side effects on this signal without any dedicated debug vocabulary (the former `CANVAS_DEBUG_DOC_ID` marker constant is removed). - `task/pipeline_executor.go`: `validateTaskContext` no longer requires a KB when `KB.ID == ""` (debug); debug runs return `collectDebugOutput` (chunks) instead of a no-op; uploaded bytes are delivered as `inputs['binary']` for doc-less runs; `injectDebugPageCap` caps the parser to the first pages for a fast preview via the production `override_params` channel (Parser cpnID + family). - `component/tokenizer.go`: `shouldHaveEmbedding` skips embedding when `kb_id == ""` — the embedder is configured on the knowledgebase, so a debug run has nothing to resolve against and stays side-effect free. - `chunker/register.go`: chunk images are uploaded to MinIO only when a KB is present (persist run). **In debug mode the raw image bytes are intentionally dropped (`delete(ck, "image")`)** — the debug preview does not render chunk images, and dropping the bytes keeps them out of memory and out of the Redis-stored debug log. This is a deliberate trade-off, not an oversight. - `component/file.go`: pass through in-memory binary bytes, skipping `doc_id` -> storage resolution. - `handler/agent.go` + `agent_webhook.go`: detect `dataflow_canvas` and run a sync debug returning chunks inline on the existing chat/completions endpoint; reject DataFlow canvases from webhooks (fixes the previously dead `== "DataFlow"` check; mirrors Python `agent_api.py`). - `parser_dispatch.go`: export `ParserFileFamily` for the executor's page-cap injection. ### Debug run log + "View result" Mirrors Python's debug-log contract so the front-end can replay each component's progress and parsed output: - `task/debug_log_sink.go`: a `DebugLogSink` records every component's lifecycle into a `[{component_id, trace}]` array (each trace entry carries `message`, `progress`, `timestamp`, `elapsed_time`). `Flush` appends a terminal `END` marker whose first trace message is non-empty so the front-end detects completion. On failure the END marker is prefixed `[ERROR]` yet still carries the run, so the failure timeline renders instead of being stuck empty. Timestamps and `elapsed_time` are in seconds (matching the rest of the app). - `task/debug_result_dsl.go`: `BuildDebugResultDSL` builds the `dsl` the END marker carries — the Go analogue of Python's `Graph.__str__` + END-marker `dsl` in `rag/flow/pipeline.py`. It combines the static DSL structure (component_name / downstream / params / graph.nodes) with the run output map (`output["state"][<id>]`) to emit, per component, `obj.params.outputs[<format>].value` (chunks / text / json / html / markdown) — the exact keys the front-end `dataflow-result` page reads to render each step's parsed chunks. Raw embedding vectors (including the dimension-scoped `q_<dim>_vec` keys) are stripped so the stored log stays Python-scale. - `task/pipeline_executor.go`: after the run, attach the built `dsl` to the END marker via the `ResultSink` capability. - `handler/agent.go`: `runCanvasPipelineDebug` generates a stable `message_id` up-front and always flushes the log (success or failure); `respondWithDebugResult` returns `message_id` in **both** the success and the error envelope so the front-end can poll the log. The debug-log endpoint `GET /agents/:id/logs/:message_id` serves the array. - `web/src/pages/agent/hooks/use-run-dataflow.ts`: on a run failure, also surface `message_id` via `setMessageId` so the log sheet renders the failure timeline (the `[ERROR]` END marker is already written). Guarded by `if (msgId)`, so it is a safe no-op when the back-end does not return an id. ## Behavioral notes - Debug parses only the first pages (`debugPageCapPages`) for a fast preview; an explicit `pages` cap already present in the ParserConfig is respected. - Debug mode does not keep chunk images (see above) and does not compute embeddings — it exercises parse + chunk only. ## Test plan - Go: `debug_test.go`, `debug_log_sink_test.go` (trace pairing, END marker, `[ERROR]` prefix, fractional-second timestamp/elapsed_time, size caps, and a real-pipeline test asserting the END-marker `dsl` carries non-empty per-component `params.outputs` with chunks), `debug_result_dsl_test.go` (flat and real nested `output["state"]` shapes, vector stripping, format priority), `debug_pages_integration_test.go`, `pipeline_executor_persist_test.go`, `handler/agent_pipeline_debug_test.go`, `handler/agent_logs_test.go` (incl. `TestRunCanvasPipelineDebug_ErrorStillExposesMessageID` / `TestRespondWithDebugResult_ErrorCarriesMessageID` locking `message_id` on failure), plus updates to `agent_test.go` / `agent_webhook_test.go` / `chunker/image_upload_test.go` / `tokenizer*.go`. - `go build ./...` and `./build.sh --test` for affected packages. 🤖 Generated with [CodeBuddy Code](https://cnb.cool/codebuddy) --------- Co-authored-by: CodeBuddy Code <noreply@tencent.com>
2026-07-31 13:10:08 +08:00
// TestNewPipelineExecutor_AcceptsDebugTaskContext verifies the canvas-debug
// (dry-run) contract: a TaskContext with an empty KB.ID is valid because debug
// mode carries no knowledgebase. KB.ID == "" never occurs in production
// ingestion, which always supplies a KB.
func TestNewPipelineExecutor_AcceptsDebugTaskContext(t *testing.T) {
ctx := makeTaskCtx()
ctx.KB = entity.Knowledgebase{ID: ""}
ctx.Doc.KbID = ""
if _, err := NewPipelineExecutor(ctx, "flow-1", 0); err != nil {
t.Fatalf("debug TaskContext rejected: %v", err)
}
}
func TestNewPipelineExecutor_DocBulkSize(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 128)
if svc.docBulkSize != 128 {
t.Errorf("docBulkSize = %d, want 128", svc.docBulkSize)
}
}
func TestNewPipelineExecutor_CanvasID(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "my-flow-id", 0)
if svc.canvasID != "my-flow-id" {
t.Errorf("canvasID = %q, want %q", svc.canvasID, "my-flow-id")
}
}
func TestKB_Doc_Tenant_Accessors(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0)
if svc.KB().ID != "kb-1" {
t.Errorf("KB().ID = %q, want \"kb-1\"", svc.KB().ID)
}
if svc.Doc().ID != "doc-1" {
t.Errorf("Doc().ID = %q, want \"doc-1\"", svc.Doc().ID)
}
if svc.Tenant().ID != "tenant-1" {
t.Errorf("Tenant().ID = %q, want \"tenant-1\"", svc.Tenant().ID)
}
}
// =============================================================================
// processChunks
// =============================================================================
func TestPipelineExecutor_ProcessChunks_WrapsProcessChunksForPipeline(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0)
chunks := []map[string]any{{"text": "hello world"}}
refactor(ingestion/task): extract index-doc mapping into task/indexdoc package (#17749) ## Summary Extract the pipeline-output → search-engine index document mapping helpers out of the `task` package into a dedicated, dependency-light leaf package `internal/ingestion/task/indexdoc`. These functions are pure transforms (they only depend on `common`/`utility`) and are not task-orchestration concerns: - `NormalizeChunks`, `DeepCopyChunks` (was unexported `deepCopyChunks`), `toChunkMaps` → `indexdoc/normalize.go` - `ProcessChunksForPipeline`, `RenameTextToContentWithWeight`, `GetEmbeddingTokenConsumption`, `cleanupConsumedChunkFields`, `mergeChunkMetadata`, `processChunkPositions`, `AggregateTableDocMetadata`, `resolveTableColumnConfig` → `indexdoc/process.go` - `AddPositions` → `indexdoc/position.go` - `EmbeddingTokenConsumptionKey` constant → `indexdoc/constants.go` (task/constants.go keeps only `GRAPH_RAPTOR_FAKE_DOC_ID`) Call sites in `pipeline_executor.go` and `golden_compare.go` now reference the `indexdoc` package; package-task tests qualify the moved symbols. ## Why The `task` package had grown into a "orchestration + pure mapping + debug" mix. Splitting the pure mapping helpers into a leaf package sharpens package boundaries, removes a misleading top-level `ingestion/chunk` candidate (there are already `parser/chunk` and `service/chunk`), and lets the golden tool / future reuse pull in the mapping logic without dragging in `task`'s `dao`/`engine`/`service` dependency graph (Go subpackage import does not pull in the parent). ## Test plan - `build.sh --test ./internal/ingestion/task/...` — **green** (task 4.7s, indexdoc 0.007s), matching the pre-change baseline. - `gofmt` clean; `build.sh` builds both `ragflow-cli` and `ragflow_server` successfully. - Integration/E2E tiers are delegated to CI (need real MySQL/MinIO/ES services). Note: `pipeline_e2e_test.go` has a **pre-existing** compile error (`server.ElasticsearchConfig` / `server.InfinityConfig` are now defined under `internal/server/config/`, not re-exported by `internal/server`). This is unrelated to this change — the diff to that file is only the added `indexdoc` import and the qualified `EmbeddingTokenConsumptionKey` reference.
2026-08-04 10:05:27 +08:00
meta, err := indexdoc.ProcessChunksForPipeline(chunks, svc.taskCtx.Doc.ID, svc.taskCtx.Doc.KbID, *svc.taskCtx.Doc.Name, time.Now())
if err != nil {
t.Fatalf("ProcessChunksForPipeline: %v", err)
}
// Verify the wrapper method works correctly and chunks are processed
if chunks[0]["doc_id"] != "doc-1" {
t.Errorf("doc_id = %q, want \"doc-1\"", chunks[0]["doc_id"])
}
if meta != nil {
// No need to verify the detailed content of meta as ProcessChunksForPipeline already has comprehensive tests
}
}
// =============================================================================
// insertChunks
// =============================================================================
func TestInsertChunks_EmptyChunks(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).WithInsertFunc(
func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
return nil, nil
},
)
ctx := t.Context()
err := svc.indexWriter.Write(ctx, nil)
if err != nil {
t.Errorf("expected no error for nil chunks, got %v", err)
}
}
func TestInsertChunks_BaseNameAndDatasetID(t *testing.T) {
var capturedBaseName, capturedDatasetID string
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).WithInsertFunc(
func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
capturedBaseName = baseName
capturedDatasetID = datasetID
return nil, nil
},
)
ctx := t.Context()
chunks := []map[string]any{{"text": "hello"}}
err := svc.indexWriter.Write(ctx, chunks)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedBaseName != "ragflow_tenant-1" {
t.Errorf("baseName = %q, want \"ragflow_tenant-1\"", capturedBaseName)
}
if capturedDatasetID != "kb-1" {
t.Errorf("datasetID = %q, want \"kb-1\"", capturedDatasetID)
}
}
func TestRecordPipelineLog(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).WithLogCreateFunc(
func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { return nil },
)
ctx := t.Context()
svc.recordPipelineLog(ctx, dao.DB, "doc-1", `{"components": {}}`, "done")
}
func TestRecordPipelineLog_InvalidJSONFallback(t *testing.T) {
var captured *entity.PipelineOperationLog
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).WithLogCreateFunc(
func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error {
captured = log
return nil
},
)
ctx := t.Context()
svc.recordPipelineLog(ctx, dao.DB, "doc-1", "not-valid-json", "done")
if captured == nil {
t.Fatal("logCreateFunc was not called")
}
raw, ok := captured.DSL["raw"].(string)
if !ok || raw != "not-valid-json" {
t.Fatalf("DSL = %v, want {\"raw\": \"not-valid-json\"}", captured.DSL)
}
}
func TestRecordPipelineLog_ValidJSONParsed(t *testing.T) {
var captured *entity.PipelineOperationLog
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).WithLogCreateFunc(
func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error {
captured = log
return nil
},
)
ctx := t.Context()
svc.recordPipelineLog(ctx, dao.DB, "doc-1", `{"components": {"a": {"obj": {"component_name": "Parser", "params": {}}}}}`, "done")
if captured == nil {
t.Fatal("logCreateFunc was not called")
}
if captured.DSL["raw"] != nil {
t.Fatalf("DSL should be parsed JSON, not fallback raw; got %v", captured.DSL)
}
}
// =============================================================================
// updateDocumentMetadata
// =============================================================================
func TestRunPipeline_NilOutput(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0)
ctx := t.Context()
_, err := svc.processOutput(ctx, nil, time.Now())
if err != nil {
t.Errorf("expected nil error for nil output, got %v", err)
}
}
func TestRunPipeline_EmptyOutput(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).WithLogCreateFunc(
func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { return nil },
)
ctx := t.Context()
_, err := svc.processOutput(ctx, map[string]any{}, time.Now())
if err != nil {
t.Errorf("expected nil error for empty output, got %v", err)
}
}
func TestRunPipeline_NormalizedEmpty(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).WithLogCreateFunc(
func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { return nil },
)
ctx := t.Context()
_, err := svc.processOutput(ctx, map[string]any{"markdown": ""}, time.Now())
if err != nil {
t.Errorf("expected nil error for empty normalized output, got %v", err)
}
}
func TestRunPipeline_FullFlow(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).
WithInsertFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
return nil, nil
}).
WithLogCreateFunc(func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { return nil })
output := map[string]any{
"chunks": []map[string]any{
{"text": "hello"},
{"text": "world"},
},
}
ctx := t.Context()
_, err := svc.processOutput(ctx, output, time.Now())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestRunPipeline_AlreadyHasVectors(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).
WithInsertFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
return nil, nil
}).
WithLogCreateFunc(func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { return nil })
output := map[string]any{
"chunks": []map[string]any{
{"text": "hello", "q_768_vec": []float64{0.1, 0.2}},
},
}
ctx := t.Context()
_, err := svc.processOutput(ctx, output, time.Now())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestRunPipeline_ContextCanceled(t *testing.T) {
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := svc.processOutput(ctx, map[string]any{
"chunks": []map[string]any{{"text": "hello"}},
}, time.Now())
if err == nil {
t.Error("expected context canceled error")
}
}
func TestPipelineExecutor_Run_MainFlowWithStubs(t *testing.T) {
logged := false
inserted := false
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0).
WithLoadDSLFunc(func(ctx context.Context, canvasID string) (string, string, error) {
return `{"nodes":[{"id":"n1"}],"edges":[]}`, "flow-corrected", nil
}).
WithRunPipelineFunc(func(ctx context.Context, dsl string) (map[string]any, string, error) {
return map[string]any{
"chunks": []map[string]any{
{"text": "hello world"},
},
}, dsl, nil
}).
WithInsertFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
inserted = true
return nil, nil
}).
WithLogCreateFunc(func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error {
logged = true
if log.PipelineID == nil || *log.PipelineID != "flow-corrected" {
t.Fatalf("PipelineID = %v, want flow-corrected", log.PipelineID)
}
return nil
})
_, err := svc.Execute(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !inserted {
t.Fatal("expected insertChunks to be called")
}
if !logged {
t.Fatal("expected pipeline log to be created")
}
}
// TestPipelineExecutor_Execute_PropagatesContext verifies the ctx passed to
// Execute is the ctx received by runPipelineFunc - the task context must flow
// through to the pipeline run.
func TestPipelineExecutor_Execute_PropagatesContext(t *testing.T) {
type ctxKey string
const key ctxKey = "trace"
taskCtx := makeTaskCtx()
ctx := t.Context()
taskCtx.Ctx = context.WithValue(ctx, key, "task-ctx")
svc := mustNewPipelineExecutor(t, taskCtx, "flow-1", 0).
WithLoadDSLFunc(func(ctx context.Context, canvasID string) (string, string, error) {
return `{"nodes":[{"id":"n1"}],"edges":[]}`, canvasID, nil
}).
WithRunPipelineFunc(func(runCtx context.Context, dsl string) (map[string]any, string, error) {
if got := runCtx.Value(key); got != "task-ctx" {
t.Fatalf("runCtx value = %v, want task-ctx", got)
}
return map[string]any{"chunks": []map[string]any{{"text": "hello world"}}}, dsl, nil
}).
WithInsertFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
return nil, nil
}).
WithLogCreateFunc(func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { return nil })
if _, err := svc.Execute(taskCtx.Ctx); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// =============================================================================
// Stub implementations for testing
// =============================================================================
// recordingProgressSink captures progress events for asserting the executor
// forwards its sink through runPipelineWithDSL into the pipeline.
type recordingProgressSink struct {
mu sync.Mutex
total int
totalSet bool
events []pipelinepkg.ProgressEvent
}
func (r *recordingProgressSink) OnComponentTotal(ctx context.Context, taskID string, total int) {
r.mu.Lock()
defer r.mu.Unlock()
r.total = total
r.totalSet = true
}
func (r *recordingProgressSink) OnComponentProgress(ctx context.Context, ev pipelinepkg.ProgressEvent) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, ev)
}
type sinkPassthroughStage struct{}
func (sinkPassthroughStage) Invoke(_ context.Context, _ *gorm.DB, inputs map[string]any) (map[string]any, error) {
return inputs, nil
}
// TestPipelineExecutorRunPipelineWithDSLForwardsSink verifies the sink set via
// WithProgressSink is threaded through runPipelineWithDSL into the pipeline,
// which reports the component total and lifecycle events back to the sink.
func TestPipelineExecutorRunPipelineWithDSLForwardsSink(t *testing.T) {
const nameA = "task.SinkPassthroughA"
runtime.MustRegister(nameA, runtime.CategoryIngestion,
func(_ string, _ map[string]any) (runtime.Component, error) { return sinkPassthroughStage{}, nil },
runtime.Metadata{Version: "1.0.0"})
sink := &recordingProgressSink{}
svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 0)
svc.WithProgressSink(sink)
dsl := `{"dsl":{"components":{"begin":{"obj":{"component_name":"Begin","params":{}},"downstream":["a"]},"a":{"obj":{"component_name":"` + nameA + `","params":{}},"upstream":["begin"]}},"path":["begin","a"],"graph":{"nodes":[]}}}`
ctx := t.Context()
if _, _, err := svc.runPipelineWithDSL(ctx, dsl); err != nil {
t.Fatalf("runPipelineWithDSL: %v", err)
}
sink.mu.Lock()
defer sink.mu.Unlock()
if !sink.totalSet || sink.total != 2 {
t.Fatalf("OnComponentTotal = (%d, set=%v), want 2", sink.total, sink.totalSet)
}
if len(sink.events) == 0 {
t.Fatal("expected progress events forwarded to sink, got none")
}
for _, ev := range sink.events {
if ev.TaskID != "task-1" {
t.Fatalf("event TaskID = %q, want task-1", ev.TaskID)
}
if ev.DocumentID != "doc-1" {
t.Fatalf("event DocumentID = %q, want doc-1", ev.DocumentID)
}
}
}
func TestCountDistinctChunkIDs(t *testing.T) {
// Empty list
if n := countDistinctChunkIDs(nil); n != 0 {
t.Fatalf("nil chunks: got %d, want 0", n)
}
// All unique
chunks := []map[string]any{
{"id": "a"},
{"id": "b"},
{"id": "c"},
}
if n := countDistinctChunkIDs(chunks); n != 3 {
t.Fatalf("all unique: got %d, want 3", n)
}
// Duplicates present — this is the key case
chunks = []map[string]any{
{"id": "x"},
{"id": "y"},
{"id": "x"}, // duplicate of [0]
{"id": "z"},
{"id": "y"}, // duplicate of [1]
}
if n := countDistinctChunkIDs(chunks); n != 3 {
t.Fatalf("with duplicates: got %d, want 3", n)
}
// Missing id fields are skipped
chunks = []map[string]any{
{"id": "one"},
{"text": "no id"},
{"id": "two"},
}
if n := countDistinctChunkIDs(chunks); n != 2 {
t.Fatalf("mixed present/absent ids: got %d, want 2", n)
}
}