Ctrl K
ComunidadGuías…

internal/ingestion/pipeline/pipeline.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//
//  Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
//

package pipeline

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"ragflow/internal/agent/canvas"
	_ "ragflow/internal/agent/component"
	"ragflow/internal/agent/runtime"
	"ragflow/internal/common"
	kvrocks "ragflow/internal/engine/kvrocks"
	"ragflow/internal/ingestion/component/globals"
	"ragflow/internal/ingestion/component/schema"
	"ragflow/internal/ingestion/mediacache"
	"ragflow/internal/parser/media"
	"ragflow/internal/utility"

	"go.uber.org/zap"
)

var _ mediacache.Backend = (*kvrocks.Client)(nil)

// Pipeline is a compiled ingestion canvas plus task-scoped metadata.
type Pipeline struct {
	taskID     string
	documentID string // owning document; numeric progress is mirrored back to
	// the document table for existing status projections.
	canvas  *canvas.Canvas
	tracker *canvas.RunTracker       // optional injected; nil -> resolve at Run
	kv      *kvrocks.Client          // optional; nil -> no tracker fallback (unit/test runs)
	media   media.Cache              // optional injected run media cache
	factory runtime.ComponentFactory // optional instance-scoped component factory
	sink    ProgressSink             // optional progress sink; nil -> drop events (DB-independent)
}

// PipelineOption mutates a Pipeline before Run. Used to inject test doubles
// (in-memory store / miniredis tracker) or dedicated Redis pools.
type PipelineOption func(*Pipeline)

// WithRunTracker injects a RunTracker for progress mirroring (terminal
// run-status marks). When unset and no Kvrocks client is configured, Run does
// not mirror progress.
func WithRunTracker(t *canvas.RunTracker) PipelineOption {
	return func(p *Pipeline) { p.tracker = t }
}

// WithKV injects the Kvrocks client used to resolve a RunTracker when none is
// injected. It replaces the previous global kvrocks.Get() lookup, so the
// pipeline no longer reads a process-wide singleton (arch_test.go guards that
// rule). A nil client disables the RunTracker fallback.
func WithKV(kv *kvrocks.Client) PipelineOption {
	return func(p *Pipeline) { p.kv = kv }
}

// WithDocumentID binds the pipeline's owning document so numeric progress can
// be mirrored back into the document table.
// Pass the empty string to disable the mirror (e.g. headless/test runs where
// the document row is not materialized).
func WithDocumentID(docID string) PipelineOption {
	return func(p *Pipeline) { p.documentID = docID }
}

// ProgressEvent is a structured component lifecycle event emitted by the
// pipeline to a ProgressSink. The pipeline fills the task/document/component
// identity and phase/status message; the sink caches the denominator (total)
// from OnComponentTotal and needs no canvas knowledge.
type ProgressEvent struct {
	TaskID     string
	DocumentID string
	Component  string
	Message    string
	Phase      int
}

// ProgressSink receives pipeline progress for durable persistence. It is the
// single channel through which the pipeline reports component lifecycle
// events and the component-total denominator; the pipeline itself never
// touches the DAO layer. Implementations live in the orchestration layer
// (internal/ingestion/service). A nil sink is valid: events are dropped and
// the pipeline stays DB-independent (unit tests, headless runs).
type ProgressSink interface {
	OnComponentTotal(ctx context.Context, taskID string, total int)
	OnComponentProgress(ctx context.Context, ev ProgressEvent)
}

// WithProgressSink injects a sink that receives component progress events
// and the component-total denominator. When unset, the pipeline drops
// progress events and stays DB-independent.
func WithProgressSink(s ProgressSink) PipelineOption {
	return func(p *Pipeline) { p.sink = s }
}

// NewPipelineFromDSL compiles the canonical ingestion canvas DSL.
// It accepts either the inner canvas DSL or the template wrapper whose
// top-level `dsl` field carries that canvas.
func NewPipelineFromDSL(dsl []byte, taskID string, opts ...PipelineOption) (*Pipeline, error) {
	taskID = strings.TrimSpace(taskID)
	if taskID == "" {
		return nil, errors.New("pipeline: task id is required")
	}
	// UnwrapCanvasDSL is the single source of truth for stripping the
	// optional {"dsl": {...}} canvas envelope; it also reports a nil/unparseable
	// DSL.
	canvasDSL, err := UnwrapCanvasDSL(dsl)
	if err != nil {
		return nil, err
	}
	cnv, err := canvas.DecodeFromDSL(canvasDSL)
	if err != nil {
		return nil, fmt.Errorf("pipeline: decode canvas DSL: %w", err)
	}
	if err := ValidatePipeline(cnv); err != nil {
		return nil, err
	}
	p := &Pipeline{
		taskID: taskID,
		canvas: cnv,
	}
	for _, o := range opts {
		o(p)
	}
	return p, nil
}

// ValidatePipeline enforces ingestion pipeline constraints.
// Specifically, at most one Extractor component is permitted in the graph.
func ValidatePipeline(cnv *canvas.Canvas) error {
	if cnv == nil {
		return nil
	}
	extractorCount := 0
	for id, comp := range cnv.Components {
		if common.IsExtractorComponent(id, comp.Obj.ComponentName) {
			extractorCount++
		}
	}
	if extractorCount > 1 {
		return fmt.Errorf("pipeline validation error: at most 1 Extractor component is allowed, found %d", extractorCount)
	}
	return nil
}

// WithComponentFactory installs an instance-scoped factory override for this
// pipeline. It is used during canvas compilation so one pipeline run can
// construct task-specific component instances without mutating the process-wide
// runtime default factory.
func (p *Pipeline) WithComponentFactory(factory runtime.ComponentFactory) *Pipeline {
	if p != nil {
		p.factory = factory
	}
	return p
}

func mergeInto(dst, src map[string]any) map[string]any {
	if src == nil {
		return dst
	}
	if dst == nil {
		dst = make(map[string]any, len(src))
	}
	for k, v := range src {
		dst[k] = v
	}
	return dst
}

func cloneMapOrEmpty(m map[string]any) map[string]any {
	if m == nil {
		return map[string]any{}
	}
	out := make(map[string]any, len(m))
	for k, v := range m {
		out[k] = v
	}
	return out
}

// defaultTrackerTTL is the expiry applied to the RunTracker run-status hash.
// It matches the model-result cache TTL; media has its own one-day TTL.
var defaultTrackerTTL = 7 * 24 * time.Hour

// Run executes the full ingestion graph described by the canonical DSL.
// There is no pipeline-layer partial resume entry point: execution always
// starts from the graph entry and component-level replay decisions belong to
// the components themselves.
func (p *Pipeline) Run(ctx context.Context, inputs map[string]any, overrideParams map[string]any) (map[string]any, error) {
	if p == nil {
		return nil, fmt.Errorf("pipeline: Run on nil pipeline")
	}
	if p.canvas == nil {
		return nil, fmt.Errorf("pipeline: canvas is nil")
	}
	if runtime.DefaultFactory() == nil {
		runtime.InstallDefaultRegistryFactory()
	}
	if runtime.DefaultFactory() == nil {
		return nil, fmt.Errorf("pipeline: Run: runtime default component factory is not installed")
	}

	compileCtx := ctx
	if p.factory != nil {
		compileCtx = canvas.WithComponentFactory(compileCtx, p.factory)
	}

	// Resolve the run tracker for progress mirroring (terminal
	// MarkSucceeded/MarkFailed/MarkCancelled marks). Resume-from-checkpoint is
	// intentionally not supported: the pipeline always runs the full graph
	// from the entry node, and component-level replay decisions (e.g. serving
	// prior LLM/embedding results from the run cache) belong to the
	// components themselves.
	tracker := p.resolveTracker()

	var compileOpts []canvas.CompileOption
	// Run-level setups (keyed by cpnID) override the DSL-baked component
	// setups at compile time (higher priority; see canvas.WithOverrideParams).
	if overrideParams != nil {
		compileOpts = append(compileOpts, canvas.WithOverrideParams(overrideParams))
	}
	compiled, err := canvas.Compile(compileCtx, p.canvas, compileOpts...)
	if err != nil {
		return nil, fmt.Errorf("pipeline: Run: compile agent: %w", err)
	}

	// Record the component count as the authoritative denominator for
	// progress percentage. Best-effort: a DB failure (or headless run
	// with no DB) must not abort the pipeline — progress is observability.
	if p.sink != nil {
		p.sink.OnComponentTotal(ctx, p.taskID, len(p.canvas.Components))
	}

	runState := canvas.NewCanvasState("", p.taskID)
	runCtx := canvas.WithState(ctx, runState)
	// Framework-level progress fan-out: the canvas framework
	// (realComponentBody) pulls this callback from ctx via
	// runtime.ProgressCallbackFromContext and records every component
	// start/done/fail event as an ingestion_task_log row. The callback
	// is nil when the DB is not initialized (unit tests, headless
	// runs), in which case TrackProgress is a no-op — progress is an
	// observability concern, not a data dependency.
	runCtx = runtime.WithProgressCallback(runCtx, p.componentProgressCallback(ctx))
	runCtx = runtime.WithProgressMessageCallback(runCtx, p.componentProgressMessageCallback(ctx))
	runCtx = runtime.WithProgressFractionCallback(runCtx, p.componentFractionCallback(ctx))

	current := cloneMapOrEmpty(inputs)

	// Seed the workflow-wide Globals bag with the run-level metadata
	// (name, tenant_id, kb_id, model_id, doc_id, ...) once, from the
	// pipeline run inputs. Downstream components read these from ctx
	// instead of relying on every node re-emitting them. The File
	// component re-publishes `name` (and storage refs) as it derives
	// them mid-run.
	globals.SeedIngestionGlobals(runCtx, current)
	// Expose the task id on the run context so every component can register the
	// run-cache keys it writes into the task manifest (chunkcache). Without
	// this the manifest is never populated and PurgeTask on success becomes a
	// no-op, leaving orphaned cache entries until TTL expiry.
	globals.SetTaskID(runCtx, p.taskID)
	mediaCache := p.media
	if mediaCache == nil && p.kv != nil {
		mediaCache = mediacache.New(p.kv, p.taskID)
	}
	if mediaCache != nil {
		runCtx = media.WithCache(runCtx, mediaCache)
	}

	return p.runPlain(runCtx, current, compiled, tracker, runState)
}

// resolveTracker resolves the RunTracker used for progress mirroring. When an
// instance-scoped tracker was injected it wins; otherwise one is created on
// the global Kvrocks client when available.
func (p *Pipeline) resolveTracker() *canvas.RunTracker {
	if p.tracker != nil {
		return p.tracker
	}
	if p.kv != nil {
		return canvas.NewRunTrackerWithClient(p.kv.GetClient(), defaultTrackerTTL)
	}
	return nil
}

// runPlain executes the full ingestion graph with a single Invoke and records
// the terminal run status via the sink/RunTracker when available.
func (p *Pipeline) runPlain(runCtx context.Context, current map[string]any, compiled *canvas.CompiledCanvas, tracker *canvas.RunTracker, runState *canvas.CanvasState) (map[string]any, error) {
	// Terminal tracker writes must survive a run ctx that gets cancelled
	// (the common failure/cancel path). Derive a fresh detached ctx per
	// terminal branch, bounded so a hung Redis cannot stall — mirrors
	// markStopped/markFailed in ingestion_service.go.
	detached := func() (context.Context, context.CancelFunc) {
		return context.WithTimeout(context.WithoutCancel(runCtx), 5*time.Second)
	}

	out, err := compiled.Workflow.Invoke(runCtx, current)
	if err != nil {
		if errors.Is(runCtx.Err(), context.Canceled) || errors.Is(runCtx.Err(), context.DeadlineExceeded) {
			if tracker != nil {
				stateCtx, cancel := detached()
				utility.BestEffort(fmt.Sprintf("MarkCancelled for %s", p.taskID), func() error { return tracker.MarkCancelled(stateCtx, p.taskID) })
				cancel()
			}
			return current, fmt.Errorf("pipeline: run cancelled: %w", runCtx.Err())
		}
		if tracker != nil {
			stateCtx, cancel := detached()
			utility.BestEffort(fmt.Sprintf("MarkFailed for %s", p.taskID), func() error { return tracker.MarkFailed(stateCtx, p.taskID, err.Error()) })
			cancel()
		}
		return current, fmt.Errorf("pipeline: run canvas workflow: %w", err)
	}
	if tracker != nil {
		stateCtx, cancel := detached()
		utility.BestEffort(fmt.Sprintf("MarkSucceeded for %s", p.taskID), func() error { return tracker.MarkSucceeded(stateCtx, p.taskID) })
		cancel()
	}
	return finalizeResult(current, out, runState), nil
}

// finalizeResult merges the graph output into the input map and attaches the
// canvas state snapshot as the success payload.
func finalizeResult(current, out map[string]any, runState *canvas.CanvasState) map[string]any {
	if out == nil {
		current["state"] = runState.Snapshot()
		return current
	}
	merged := mergeInto(current, out)
	merged["state"] = runState.Snapshot()
	return merged
}

// componentProgressCallback returns a runtime.ProgressCallback that forwards
// every component lifecycle event (start/done/fail) to the pipeline's
// ProgressSink. The sink owns all persistence; this callback only shapes the
// event - deriving the message string the frontend expects - so the pipeline
// never touches the DAO layer. Returns nil when no sink is attached, leaving
// TrackProgress a no-op and the pipeline DB-independent (unit tests, headless
// runs).
func (p *Pipeline) componentProgressCallback(ctx context.Context) runtime.ProgressCallback {
	if p.sink == nil {
		return nil
	}
	return func(ev runtime.ProgressEvent) {
		componentName := ev.Component
		if comp, ok := p.canvas.Components[ev.Component]; ok && comp.Obj.ComponentName != "" {
			componentName = comp.Obj.ComponentName
		}
		var msg string
		switch ev.Phase {
		case runtime.PhaseEnter:
			msg = componentName + " Started"
		case runtime.PhaseExit:
			msg = componentName + " Done"
		case runtime.PhaseError:
			msg = formatComponentFailure(componentName, ev.Err)
		}
		// Surface every component lifecycle event as a structured log line so
		// a component failure (e.g. an LLM/client error) is captured in
		// ingestor_server.log even if the wrapped error never reaches the
		// higher-level "Task ... failed" branch.
		switch ev.Phase {
		case runtime.PhaseError:
			if errors.Is(ev.Err, context.Canceled) {
				// A user cancel is a normal control path; the authoritative
				// "Task ... cancelled" line is logged by the service layer, so
				// keep this at debug to avoid duplicate noise.
				common.Debug("component progress: canceled",
					zap.String("component", ev.Component),
					zap.String("task_id", p.taskID),
					zap.String("document_id", p.documentID))
			} else if ev.Err != nil {
				common.Error("component progress: error", ev.Err,
					zap.String("component", ev.Component),
					zap.String("task_id", p.taskID),
					zap.String("document_id", p.documentID))
			} else {
				common.Info("component progress: error",
					zap.String("component", ev.Component),
					zap.String("task_id", p.taskID),
					zap.String("document_id", p.documentID))
			}
		default:
			// Keep the message constant: msg may carry component names or
			// error-derived text, and a newline in it could forge a log record
			// (CWE-117). Pass msg and the phase as structured fields instead.
			common.Info("component progress",
				zap.String("message", msg),
				zap.Int("phase", int(ev.Phase)),
				zap.String("component", ev.Component),
				zap.String("task_id", p.taskID),
				zap.String("document_id", p.documentID))
		}
		sinkCtx, cancel := progressSinkContext(ctx)
		defer cancel()
		p.sink.OnComponentProgress(sinkCtx, ProgressEvent{
			TaskID:     p.taskID,
			DocumentID: p.documentID,
			Component:  ev.Component,
			Message:    msg,
			Phase:      int(ev.Phase),
		})
	}
}

func formatComponentFailure(componentName string, err error) string {
	if err == nil {
		return componentName + " Error"
	}
	if errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), context.DeadlineExceeded.Error()) {
		switch componentName {
		case schema.ComponentNameExtractor:
			return "Extractor: Document extraction timed out while waiting for the configured LLM. Check that the model service is reachable, then retry."
		case schema.ComponentNameCompiler:
			return "Compiler: Document compilation timed out. Check that the configured model service is reachable and responsive, then retry."
		}
	}

	message := err.Error()
	prefix := componentName + ":"
	for componentName != "" && strings.HasPrefix(strings.ToLower(message), strings.ToLower(prefix)) {
		message = strings.TrimSpace(message[len(prefix):])
	}
	if componentName == "" {
		return message
	}
	return componentName + ": " + message
}

type detailedProgressSink interface {
	OnComponentMessage(ctx context.Context, taskID, documentID, component, message string)
}

// fractionProgressSink is the optional interface through which the pipeline
// forwards in-flight component fractions (pages parsed, chunks embedded) to
// the sink's progress tracker. Mirrors detailedProgressSink: a sink that does
// not implement it simply receives no fraction channel.
type fractionProgressSink interface {
	OnComponentFraction(ctx context.Context, component string, fraction float64)
}

// componentFractionCallback forwards fraction reports to the sink. The sink
// only mutates in-memory state here (its flusher owns persistence), so the
// run context is passed through without the WithoutCancel fallback that the
// I/O-bound callbacks need.
func (p *Pipeline) componentFractionCallback(ctx context.Context) runtime.ProgressFractionCallback {
	sink, ok := p.sink.(fractionProgressSink)
	if !ok {
		return nil
	}
	return func(component string, fraction float64) {
		sink.OnComponentFraction(ctx, component, fraction)
	}
}

func (p *Pipeline) componentProgressMessageCallback(ctx context.Context) runtime.ProgressMessageCallback {
	sink, ok := p.sink.(detailedProgressSink)
	if !ok {
		return nil
	}
	return func(component, message string) {
		common.Info("component progress detail",
			zap.String("component", component),
			zap.String("task_id", p.taskID),
			zap.String("document_id", p.documentID),
			zap.String("message", message))
		sinkCtx, cancel := progressSinkContext(ctx)
		defer cancel()
		sink.OnComponentMessage(sinkCtx, p.taskID, p.documentID, component, message)
	}
}

func progressSinkContext(ctx context.Context) (context.Context, context.CancelFunc) {
	if ctx.Err() == nil {
		return context.WithCancel(ctx)
	}
	return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
}