Align Go ingestion boundaries with Python (#16647)

Moves doc_id blob resolution into Parser, tightens chunker/tokenizer to
Python output_format semantics, updates extractor list handling, and
fixes real-template integration tests.
This commit is contained in:
Zhichang Yu
2026-07-05 20:43:52 +08:00
committed by GitHub
parent 0fcfb38365
commit 014c3f634f
119 changed files with 18083 additions and 4712 deletions

View File

@@ -79,21 +79,32 @@ var (
)
// SetDefaultFactory installs the production ComponentFactory. The
// component package calls this in its init() with `component.New`.
// Calling SetDefaultFactory more than once with a non-nil factory is
// a no-op after the first call (the first wins) so concurrent
// registration is safe. Passing nil clears the factory — tests use
// this to assert "no factory registered" error paths.
// component package calls this in its init() via
// installDefaultRegistryFactory (see below). After Phase 0, the
// "first writer wins" guard is REMOVED: SetDefaultFactory now ALWAYS
// replaces the active default, regardless of whether one is already
// installed. This preserves the existing test-override pattern where
// tests save the previous factory, install a stub, and restore on
// t.Cleanup. Passing nil clears the factory — tests use this to
// assert "no factory registered" error paths.
//
// Two-layer model:
//
// - Production: installDefaultRegistryFactory installs a closure
// that calls runtime.DefaultRegistry.Lookup on every invocation.
// It captures DefaultRegistry by reference, so even if
// installDefaultRegistryFactory runs before all init()
// registrations complete, the factory is correct at every
// subsequent lookup (the registry is read lazily, not captured
// at install time).
// - Override: tests call SetDefaultFactory(stub) directly to stub
// the default factory. t.Cleanup restores the production factory
// by calling installDefaultRegistryFactory again (or by saving
// the previous value and re-injecting it).
func SetDefaultFactory(f ComponentFactory) {
factoryMu.Lock()
defer factoryMu.Unlock()
if f == nil {
defaultFactory = nil
return
}
if defaultFactory == nil {
defaultFactory = f
}
defaultFactory = f
}
// DefaultFactory returns the registered ComponentFactory, or nil if
@@ -106,6 +117,36 @@ func DefaultFactory() ComponentFactory {
return defaultFactory
}
// InstallDefaultRegistryFactory installs the production
// ComponentFactory: a closure that resolves component names via
// runtime.DefaultRegistry.Lookup at every invocation. The closure
// captures DefaultRegistry by reference (the variable, not the
// concrete registry), so lookup always reads the current state of
// the singleton even if a test later swaps it out via SetDefaultFactory.
//
// This is the helper the component package's init() calls (from
// internal/agent/component/runtime_wire.go). Production callers
// should prefer InstallDefaultRegistryFactory over SetDefaultFactory
// directly so the wiring stays in one place — if the resolution
// strategy changes (e.g. switch to a per-call registry handle),
// only this function changes.
//
// Note: this is EXPORTED (unlike the helper sketched in plan §4
// Phase 0 task 3) because the call site lives in a different package
// (internal/agent/component) and Go's visibility rules don't allow
// access to unexported names across package boundaries. The "test
// override layer" still owns SetDefaultFactory — tests that want to
// stub the factory call SetDefaultFactory directly, not this helper.
func InstallDefaultRegistryFactory() {
SetDefaultFactory(func(name string, params map[string]any) (Component, error) {
f, _, _, ok := DefaultRegistry.Lookup(name)
if !ok {
return nil, fmt.Errorf("runtime: unknown component %q", name)
}
return f(name, params)
})
}
// ResetDefaultFactoryForTesting clears the registered factory.
// Test-only helper for code paths that want to assert behaviour
// when no factory is installed. Not safe under concurrent use with

View File

@@ -0,0 +1,163 @@
//
// 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.
//
// Cross-cutting helpers that replace Python's `rag/flow/base.py:ProcessBase`
// wrapper (lines 33-63). Three call-site concerns are extracted into plain
// higher-order functions:
//
// (a) timeout enforcement -> WithTimeout
// (b) progress callback fan-out -> TrackProgress
// (c) elapsed-time accounting -> TrackElapsed
//
// These live in `runtime` (rather than as a `Component` interface method or
// a base type) because they are call-site concerns, not extension points.
// Both `internal/ingestion/pipeline` and `internal/agent/canvas` compose
// them at the DAG-node / goroutine boundary.
//
// LOSSY MAPPING (plan §8 R1):
//
// Python `ProcessBase._invoke` is wrapped by BOTH `asyncio.wait_for` AND
// the `@timeout` decorator — a dual-layer timeout to catch different
// failure modes. Go's `context.WithTimeout` collapses this into a single
// layer; `WithTimeout` covers the outer one (asyncio.wait_for equivalent).
// The inner `@timeout` decorator has no Go equivalent and is not
// replicated here. If a future requirement needs the inner layer,
// `WithTimeout` can be nested at the call site.
package runtime
import (
"context"
"errors"
"fmt"
"time"
)
// ProgressCallback receives progress notifications from TrackProgress.
// The numeric progress values follow the convention used by the Python
// pipeline canvas callback:
//
// progress=0 before fn runs (component just started)
// progress=1 on success (component finished cleanly)
// progress=-1 on failure (component errored; message is the error)
//
// Concrete sinks (Redis log writer, in-memory test recorder) implement
// this signature. nil is a valid value: TrackProgress treats a nil cb as
// "no observer" and simply runs fn.
type ProgressCallback func(progress int, message string)
// TrackProgress wraps fn with progress notifications. The callback is
// invoked at most twice per call (once at start, once at end).
//
// On success: cb(1, "<compName> Done") and nil error.
// On failure: cb(-1, "<compName>: <err>") and the original error.
//
// A nil callback is permitted: fn runs to completion and its return
// value (including error) is passed through untouched.
func TrackProgress(compName string, cb ProgressCallback, fn func() error) error {
if cb != nil {
cb(0, compName+" Started")
}
err := fn()
if cb == nil {
return err
}
if err != nil {
cb(-1, fmt.Sprintf("%s: %s", compName, err.Error()))
return err
}
cb(1, compName+" Done")
return nil
}
// WithTimeout runs fn under a derived context that cancels either when
// d elapses or when the parent ctx is cancelled (whichever happens
// first). fn receives the child context so it can honor cancellation at
// its own yield points.
//
// On timeout: returns context.DeadlineExceeded (matching Python's
// asyncio.TimeoutError semantics).
// On parent cancellation: returns the parent ctx's error (typically
// context.Canceled).
// On fn completion within d: returns fn's error (may be nil).
//
// NOTES:
//
// - This function implements ONLY the outer timeout layer that
// Python `ProcessBase` enforces via `asyncio.wait_for`. The inner
// `@timeout` decorator is not replicated in Go (see plan §8 R1).
// - fn MUST NOT retain or use the ctx past return; once fn returns
// the child context's cancel func is invoked by WithTimeout.
func WithTimeout(ctx context.Context, d time.Duration, fn func(ctx context.Context) error) error {
childCtx, cancel := context.WithTimeout(ctx, d)
defer cancel()
if err := fn(childCtx); err != nil {
// If fn honored cancellation, prefer the ctx error so callers
// see a uniform "timed out" / "canceled" signal regardless of
// whether fn propagated the error or replaced it.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return err
}
if cerr := childCtx.Err(); cerr != nil {
return cerr
}
return err
}
// fn returned nil — but the deadline may have elapsed between
// fn's last yield point and return. Surface that as
// DeadlineExceeded so the caller sees a consistent timeout
// signal rather than a false "success".
if cerr := childCtx.Err(); cerr != nil {
return cerr
}
return nil
}
// TrackElapsed records the wall-clock duration of fn and stamps the
// output map with two synthetic keys mirroring Python `ProcessBase`
// (base.py:42, 58):
//
// "_created_time" RFC3339Nano-formatted timestamp taken BEFORE fn runs.
// "_elapsed_time" float64 seconds (with sub-second precision) that
// fn took to complete, in [0, +∞).
//
// Any keys already present in fn's result map are preserved verbatim;
// the two synthetic keys are added only if absent (fn-supplied values
// win on conflict — fn is the authoritative source of business data).
// This matches the Python ProcessBase convention: a component that
// computes its own elapsed time is trusted over the helper's stopwatch.
//
// On error: the returned map is nil and the error is propagated
// untouched. The "name" parameter is recorded in the error message
// when err is non-nil so log readers can attribute the elapsed
// accounting failure to a specific component.
func TrackElapsed(name string, fn func() (map[string]any, error)) (map[string]any, error) {
start := time.Now()
out, err := fn()
elapsed := time.Since(start)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
if out == nil {
out = make(map[string]any)
}
if _, ok := out["_created_time"]; !ok {
out["_created_time"] = start.UTC().Format(time.RFC3339Nano)
}
if _, ok := out["_elapsed_time"]; !ok {
out["_elapsed_time"] = elapsed.Seconds()
}
return out, nil
}

View File

@@ -0,0 +1,393 @@
//
// 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 runtime
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
)
// recordingCallback is a thread-safe ProgressCallback recorder used by
// the TrackProgress tests. progress/message pairs are appended in
// invocation order so tests can assert the exact call sequence.
type recordingCallback struct {
mu sync.Mutex
calls []recordedCall
started bool
}
type recordedCall struct {
progress int
message string
}
func (r *recordingCallback) callback(progress int, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, recordedCall{progress: progress, message: message})
}
func (r *recordingCallback) callsCopy() []recordedCall {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedCall, len(r.calls))
copy(out, r.calls)
return out
}
// --- TrackProgress ---
func TestTrackProgress_Success(t *testing.T) {
rec := &recordingCallback{}
err := TrackProgress("Parser", rec.callback, func() error {
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
calls := rec.callsCopy()
if len(calls) != 2 {
t.Fatalf("expected 2 callback invocations, got %d: %+v", len(calls), calls)
}
if calls[0].progress != 0 || calls[0].message != "Parser Started" {
t.Errorf("first call = %+v, want progress=0 message=%q", calls[0], "Parser Started")
}
if calls[1].progress != 1 || calls[1].message != "Parser Done" {
t.Errorf("second call = %+v, want progress=1 message=%q", calls[1], "Parser Done")
}
}
func TestTrackProgress_Failure(t *testing.T) {
rec := &recordingCallback{}
wantErr := errors.New("boom")
err := TrackProgress("Tokenizer", rec.callback, func() error {
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
calls := rec.callsCopy()
if len(calls) != 2 {
t.Fatalf("expected 2 callback invocations, got %d", len(calls))
}
if calls[0].progress != 0 || calls[0].message != "Tokenizer Started" {
t.Errorf("first call = %+v, want progress=0 message=%q", calls[0], "Tokenizer Started")
}
if calls[1].progress != -1 {
t.Errorf("second call progress = %d, want -1", calls[1].progress)
}
if !strings.Contains(calls[1].message, "Tokenizer") || !strings.Contains(calls[1].message, "boom") {
t.Errorf("second call message = %q, want it to contain both %q and %q", calls[1].message, "Tokenizer", "boom")
}
}
func TestTrackProgress_NilCallback(t *testing.T) {
// Must not panic with a nil callback; must still pass fn's result through.
called := false
if err := TrackProgress("File", nil, func() error {
called = true
return nil
}); err != nil {
t.Fatalf("unexpected error from nil-cb success path: %v", err)
}
if !called {
t.Fatal("fn was not invoked")
}
wantErr := errors.New("nil-cb err")
got := TrackProgress("File", nil, func() error {
return wantErr
})
if !errors.Is(got, wantErr) {
t.Fatalf("nil-cb failure path: got %v, want %v", got, wantErr)
}
}
// TestTrackProgress_PassesThroughReturnValue covers the documented contract
// that the error returned to the caller is fn's error verbatim (wrapped
// only by the message-formatting for the callback, not for the return).
func TestTrackProgress_PassesThroughReturnValue(t *testing.T) {
rec := &recordingCallback{}
// nil path
if err := TrackProgress("Foo", rec.callback, func() error { return nil }); err != nil {
t.Fatalf("nil error not propagated as nil: %v", err)
}
// err path — exact identity preserved
want := errors.New("exact")
got := TrackProgress("Foo", rec.callback, func() error { return want })
if got != want {
t.Fatalf("err not propagated by identity: got %v (%T), want %v (%T)", got, got, want, want)
}
// cb saw the failure with progress=-1
var last recordedCall
for _, c := range rec.callsCopy() {
last = c
}
if last.progress != -1 {
t.Errorf("final cb call progress = %d, want -1", last.progress)
}
}
// --- WithTimeout ---
func TestWithTimeout_Success(t *testing.T) {
ctx := context.Background()
err := WithTimeout(ctx, 50*time.Millisecond, func(ctx context.Context) error {
// simulate fast work
time.Sleep(5 * time.Millisecond)
return nil
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
func TestWithTimeout_Timeout(t *testing.T) {
ctx := context.Background()
start := time.Now()
err := WithTimeout(ctx, 20*time.Millisecond, func(ctx context.Context) error {
// sleep long enough to outlast the timeout; honor ctx so the
// test doesn't have to wait the full duration.
select {
case <-time.After(500 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
})
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
}
if elapsed > 250*time.Millisecond {
t.Errorf("WithTimeout waited too long after deadline (%s) — fn should have observed ctx.Done() quickly", elapsed)
}
}
func TestWithTimeout_ParentCancellation(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
observed := make(chan error, 1)
start := time.Now()
err := WithTimeout(parent, 5*time.Second, func(ctx context.Context) error {
select {
case <-ctx.Done():
observed <- ctx.Err()
return ctx.Err()
case <-time.After(2 * time.Second):
observed <- nil
return nil
}
})
elapsed := time.Since(start)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
select {
case inner := <-observed:
if !errors.Is(inner, context.Canceled) {
t.Errorf("fn observed ctx.Err() = %v, want context.Canceled", inner)
}
case <-time.After(time.Second):
t.Fatal("fn never observed ctx.Done()")
}
if elapsed > 250*time.Millisecond {
t.Errorf("WithTimeout took %s after parent cancel — expected fast exit", elapsed)
}
}
// TestWithTimeout_PassesContextToFn verifies the ctx fn receives is a
// CHILD of the parent (not the parent itself). The child should carry
// the parent's Values but have its own Done channel tied to the
// timeout deadline. We probe captured properties from inside fn
// (NOT after WithTimeout returns) because WithTimeout's deferred
// cancel() will mark the child ctx as canceled once it returns —
// which is the documented contract of context.WithTimeout, not a
// helper bug.
func TestWithTimeout_PassesContextToFn(t *testing.T) {
type ctxKey struct{}
parent := context.WithValue(context.Background(), ctxKey{}, "v")
type captured struct {
ctx context.Context
errInFlight error
hasDeadline bool
deadline time.Time
}
var cap captured
err := WithTimeout(parent, 100*time.Millisecond, func(ctx context.Context) error {
cap.ctx = ctx
cap.errInFlight = ctx.Err()
cap.deadline, cap.hasDeadline = ctx.Deadline()
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cap.ctx == nil {
t.Fatal("fn did not receive a context")
}
if cap.ctx == parent {
t.Fatal("fn received the parent ctx directly — expected a derived child ctx")
}
if v, _ := cap.ctx.Value(ctxKey{}).(string); v != "v" {
t.Errorf("child ctx did not carry parent's Value(): got %q, want %q", v, "v")
}
if cap.errInFlight != nil {
t.Errorf("child ctx should not be done while fn is still running successfully, got Err=%v", cap.errInFlight)
}
if !cap.hasDeadline {
t.Error("child ctx has no Deadline — expected one from WithTimeout")
}
if !time.Now().Before(cap.deadline) {
t.Errorf("child ctx deadline %v is in the past", cap.deadline)
}
}
// --- TrackElapsed ---
func TestTrackElapsed_AddsCreatedAndElapsedFields(t *testing.T) {
got, err := TrackElapsed("Parser", func() (map[string]any, error) {
time.Sleep(5 * time.Millisecond)
return map[string]any{"chunks": 3}, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := got["_created_time"]; !ok {
t.Fatal("result missing _created_time")
}
ct, ok := got["_created_time"].(string)
if !ok || ct == "" {
t.Fatalf("_created_time = %v (type %T), want non-empty string", got["_created_time"], got["_created_time"])
}
if _, err := time.Parse(time.RFC3339Nano, ct); err != nil {
t.Errorf("_created_time %q is not RFC3339Nano: %v", ct, err)
}
elapsed, ok := got["_elapsed_time"].(float64)
if !ok {
t.Fatalf("_elapsed_time = %v (type %T), want float64", got["_elapsed_time"], got["_elapsed_time"])
}
if elapsed < 0 {
t.Errorf("_elapsed_time = %f, want >= 0", elapsed)
}
// We slept 5ms; elapsed should be in a reasonable range (loose bound
// to keep the test stable on noisy CI runners).
if elapsed < 0.001 {
t.Errorf("_elapsed_time = %f, expected >= ~0.005 after 5ms sleep", elapsed)
}
}
func TestTrackElapsed_PreservesExistingKeys(t *testing.T) {
in := map[string]any{"x": 1, "name": "kept"}
got, err := TrackElapsed("Tokenizer", func() (map[string]any, error) {
return in, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["x"] != 1 {
t.Errorf("existing key x = %v, want 1", got["x"])
}
if got["name"] != "kept" {
t.Errorf("existing key name = %v, want %q", got["name"], "kept")
}
if _, ok := got["_created_time"]; !ok {
t.Error("missing _created_time")
}
if _, ok := got["_elapsed_time"]; !ok {
t.Error("missing _elapsed_time")
}
}
func TestTrackElapsed_PropagatesError(t *testing.T) {
want := errors.New("downstream boom")
got, err := TrackElapsed("Extractor", func() (map[string]any, error) {
return map[string]any{"partial": true}, want
})
if !errors.Is(err, want) {
t.Fatalf("err = %v, want wraps %v", err, want)
}
if got != nil {
t.Errorf("result map = %+v, want nil when fn errors", got)
}
// name parameter captured in the error message (documented
// in the TrackElapsed package doc: on error, `name` is
// recorded in the error message so log readers can attribute
// the failure to a specific component).
if !strings.Contains(err.Error(), "Extractor") {
t.Errorf("err message %q should mention the component name %q", err.Error(), "Extractor")
}
}
// TestTrackElapsed_NameParameterRecorded verifies that `name` appears
// somewhere observable — we chose to surface it in the error message
// on failure (see TrackElapsed doc).
func TestTrackElapsed_NameParameterRecorded(t *testing.T) {
// On failure path: name is in the error message.
_, err := TrackElapsed("MyComp", func() (map[string]any, error) {
return nil, errors.New("nope")
})
if err == nil || !strings.Contains(err.Error(), "MyComp") {
t.Fatalf("name not recorded on error path: err=%v", err)
}
// On success path: name is not part of the output map (per the
// chosen design — name appears in error messages only). We
// document this here so future maintainers don't expect it in
// the success map.
out, err := TrackElapsed("MyComp", func() (map[string]any, error) {
return map[string]any{}, nil
})
if err != nil {
t.Fatalf("unexpected error on success path: %v", err)
}
for k := range out {
if strings.Contains(k, "MyComp") {
t.Errorf("success-path map contains key %q referencing component name; name should appear in error messages only", k)
}
}
}
// TestTrackElapsed_NilMapFromFn covers the edge case where fn returns
// (nil, nil) — TrackElapsed must still populate the bookkeeping keys
// without panicking on the nil-map write.
func TestTrackElapsed_NilMapFromFn(t *testing.T) {
got, err := TrackElapsed("X", func() (map[string]any, error) {
return nil, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := got["_created_time"]; !ok {
t.Error("missing _created_time after nil-map input")
}
if _, ok := got["_elapsed_time"]; !ok {
t.Error("missing _elapsed_time after nil-map input")
}
}

View File

@@ -0,0 +1,206 @@
//
// 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.
//
// runtime — category-aware component registry.
//
// Phase 0 of plan port-rag-flow-pipeline-to-go.md lifts the component
// registry out of internal/agent/component into the runtime package
// so the ingestion pipeline (Phase 2) can register under
// CategoryIngestion without depending on the agent canvas. The legacy
// component.Register / component.New / component.RegisteredNames become
// thin adapters that delegate here.
//
// The single source of truth is DefaultRegistry. The component package's
// internal `registry` map has been removed — keeping two maps in sync
// during the transition would cause RegisteredNames() to return a
// partial set (the internal map only sees legacy Register calls; the
// new map only sees RegisterWithMeta calls). See plan §5.
package runtime
import (
"fmt"
"sort"
"strings"
"sync"
)
// Category tags each registered component with its domain so the UI can
// filter and the runtime can audit cross-domain wiring.
type Category string
const (
CategoryAgent Category = "agent"
CategoryIngestion Category = "ingestion"
CategoryShared Category = "shared"
)
// Metadata is the static component descriptor exposed to the API.
// Components MUST provide this at registration time so the API can serve
// a complete component catalog (name, category, inputs, outputs) without
// having to instantiate the component.
//
// The plan §4 Phase 0 task 1 contract says Register REJECTS empty
// metadata. The "is empty" check is implemented in Register as
// "Version == \"\" AND Inputs == nil AND Outputs == nil"; this
// three-way check lets a caller fill in any single field as
// evidence of intent. The Version field is the canonical marker
// (plan §4 Phase 0 task 2 uses `Metadata{Version: "legacy"}` for
// the legacy-adapter shim), but a component that wants to skip
// the version stamp but still record inputs/outputs is also
// allowed.
type Metadata struct {
Version string // contract version; required for ingestion, "legacy" for the legacy adapter
Inputs map[string]string // input key → human-readable description
Outputs map[string]string // output key → human-readable description
}
// entry is one slot in the registry.
type entry struct {
factory ComponentFactory
category Category
metadata Metadata
}
// Registry is the process-wide collection of named ComponentFactories,
// tagged with Category so callers can enumerate by domain. Each registration
// also carries static Metadata (Inputs/Outputs) consumed by the API layer.
type Registry interface {
Register(name string, category Category, factory ComponentFactory, metadata Metadata) error
Lookup(name string) (ComponentFactory, Category, Metadata, bool)
NamesByCategory(category Category) []string
Names() []string
}
// memoryRegistry is the production Registry. It is concurrency-safe; init()
// race-to-register is acceptable for `Register` — the duplicate-registration
// check rejects the second writer, so the FIRST successful registration
// for a given name wins. (This is independent of the `SetDefaultFactory`
// "first-wins" guard, which is REMOVED in Phase 0 task 3; see
// "two-layer model" comment there for the distinction.)
//
// Lookup is **case-insensitive**: keys are lowercased on Register and Lookup.
// This matches internal/agent/component/registry.go:28, 43, which lowercases
// at both ends. A case-sensitive implementation would silently fail canvas
// build with "unknown component" errors when an existing init() registers
// "ExampleComponent" but the canvas looks up "examplecomponent" (or vice
// versa).
type memoryRegistry struct {
mu sync.RWMutex
entries map[string]entry
}
// Register enrolls a ComponentFactory under name (case-insensitive).
// Returns an error on empty name, empty metadata, or duplicate key —
// callers who want the legacy panic-on-duplicate semantics at init()
// time should wrap the call with MustRegister.
//
// "Empty metadata" (plan §4 Phase 0 task 1) means all three of
// Version, Inputs, Outputs are unset. The Version field is the
// canonical marker — ingestion components MUST supply a real
// version string; the legacy agent-component adapter MUST supply
// "legacy"; the catalog endpoint only serves components whose
// metadata passes this check.
func (r *memoryRegistry) Register(name string, category Category, factory ComponentFactory, metadata Metadata) error {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
return fmt.Errorf("runtime: Register called with empty name")
}
if metadata.Version == "" && metadata.Inputs == nil && metadata.Outputs == nil {
return fmt.Errorf("runtime: %q registered with empty metadata (Version, Inputs, Outputs all unset; "+
"see plan §4 Phase 0 task 1 — ingestion components MUST supply a version string, "+
"legacy agent-component adapter MUST supply {Version: \"legacy\"})", name)
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.entries[key]; exists {
return fmt.Errorf("runtime: %q already registered", name)
}
r.entries[key] = entry{factory: factory, category: category, metadata: metadata}
return nil
}
// Lookup resolves a name (case-insensitive) to its factory + category +
// metadata. Returns ok=false on miss.
func (r *memoryRegistry) Lookup(name string) (ComponentFactory, Category, Metadata, bool) {
key := strings.ToLower(strings.TrimSpace(name))
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.entries[key]
if !ok {
return nil, "", Metadata{}, false
}
return e.factory, e.category, e.metadata, true
}
// NamesByCategory returns the sorted list of names registered under the
// given category. Sorted output keeps UI listings and error messages
// stable.
func (r *memoryRegistry) NamesByCategory(category Category) []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.entries))
for n, e := range r.entries {
if e.category == category {
out = append(out, n)
}
}
sort.Strings(out)
return out
}
// Names returns the sorted list of all registered names.
func (r *memoryRegistry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.entries))
for n := range r.entries {
out = append(out, n)
}
sort.Strings(out)
return out
}
// NewMemoryRegistry constructs an empty in-memory Registry. Tests use
// this to spin up isolated registries; production code uses
// DefaultRegistry.
func NewMemoryRegistry() Registry {
return &memoryRegistry{entries: make(map[string]entry)}
}
// DefaultRegistry is the process-wide singleton. Each component package's
// init() registers its factories here. Lookup is lazy: even if a canvas
// build occurs before every package's init() has run, lookups see all
// completed registrations because the registry is read at call time, not
// at SetDefaultFactory time.
var DefaultRegistry Registry = NewMemoryRegistry()
// MustRegister wraps Register and panics on error. Init()-time callers
// that want the legacy "panic on duplicate" behaviour can use this
// instead of Register + manual error check.
func MustRegister(name string, category Category, factory ComponentFactory, metadata Metadata) {
if err := DefaultRegistry.Register(name, category, factory, metadata); err != nil {
panic(err)
}
}
// RegisterWithMeta is a thin convenience around DefaultRegistry.Register
// for callers that want explicit (name, category, factory, metadata) at
// the call site without going through MustRegister. It returns the same
// error as Register; callers that want panic-on-error should use
// MustRegister instead.
func RegisterWithMeta(name string, category Category, factory ComponentFactory, metadata Metadata) error {
return DefaultRegistry.Register(name, category, factory, metadata)
}

View File

@@ -0,0 +1,357 @@
//
// 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 runtime
import (
"context"
"errors"
"fmt"
"sort"
"sync"
"testing"
)
// stubComponent is a minimal Component impl used as the factory's
// return value in tests. It echoes the params back into the output map
// so a test can assert what the factory actually received.
type stubComponent struct {
name string
params map[string]any
}
func (s *stubComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
return map[string]any{"name": s.name, "params": s.params}, nil
}
func stubFactory(name string, params map[string]any) (Component, error) {
return &stubComponent{name: name, params: params}, nil
}
// errFactory returns a fixed error so a test can assert that factory
// errors propagate via Lookup.
func errFactory(err error) ComponentFactory {
return func(name string, params map[string]any) (Component, error) {
return nil, err
}
}
func TestRegistry_RegisterAndLookup_HappyPath(t *testing.T) {
r := NewMemoryRegistry()
meta := Metadata{
Inputs: map[string]string{"x": "input x"},
Outputs: map[string]string{"y": "output y"},
}
if err := r.Register("Foo", CategoryAgent, stubFactory, meta); err != nil {
t.Fatalf("Register(Foo) returned error: %v", err)
}
f, cat, gotMeta, ok := r.Lookup("Foo")
if !ok {
t.Fatalf("Lookup(Foo) missed")
}
if cat != CategoryAgent {
t.Errorf("Lookup category = %q, want %q", cat, CategoryAgent)
}
if gotMeta.Inputs["x"] != "input x" || gotMeta.Outputs["y"] != "output y" {
t.Errorf("Lookup metadata lost: got %+v", gotMeta)
}
c, err := f("Foo", map[string]any{"k": "v"})
if err != nil {
t.Fatalf("factory returned error: %v", err)
}
if _, ok := c.(*stubComponent); !ok {
t.Errorf("factory returned wrong type %T", c)
}
}
func TestRegistry_Lookup_CaseInsensitive(t *testing.T) {
r := NewMemoryRegistry()
if err := r.Register("ExampleComponent", CategoryShared, stubFactory, Metadata{Version: "legacy"}); err != nil {
t.Fatalf("Register: %v", err)
}
for _, variant := range []string{"ExampleComponent", "examplecomponent", "EXAMPLECOMPONENT", " examplecomponent "} {
if _, _, _, ok := r.Lookup(variant); !ok {
t.Errorf("Lookup(%q) missed; case-insensitive lookup must succeed for all variants", variant)
}
}
}
func TestRegistry_Register_DuplicateReturnsError(t *testing.T) {
r := NewMemoryRegistry()
if err := r.Register("Dup", CategoryAgent, stubFactory, Metadata{Version: "legacy"}); err != nil {
t.Fatalf("first Register(Dup) returned error: %v", err)
}
err := r.Register("Dup", CategoryIngestion, stubFactory, Metadata{Version: "legacy"})
if err == nil {
t.Fatalf("second Register(Dup) succeeded; expected duplicate-key error")
}
// Duplicate detection is also case-insensitive.
err2 := r.Register("DUP", CategoryIngestion, stubFactory, Metadata{Version: "legacy"})
if err2 == nil {
t.Fatalf("Register(DUP) succeeded; duplicate detection must be case-insensitive")
}
}
func TestRegistry_Register_EmptyNameReturnsError(t *testing.T) {
r := NewMemoryRegistry()
if err := r.Register("", CategoryAgent, stubFactory, Metadata{Version: "legacy"}); err == nil {
t.Fatalf("Register(\"\") succeeded; expected empty-name error")
}
if err := r.Register(" ", CategoryAgent, stubFactory, Metadata{Version: "legacy"}); err == nil {
t.Fatalf("Register(\" \") succeeded; expected empty-name error after trim")
}
}
// TestRegistry_Register_EmptyMetadataReturnsError verifies the plan
// §4 Phase 0 task 1 contract: Register rejects empty metadata
// (Version, Inputs, Outputs all unset). Ingestion components MUST
// supply a Version string; the legacy adapter shim stamps
// {Version: "legacy"}; a single-field fill is also allowed (e.g.,
// only Inputs, or only Version).
func TestRegistry_Register_EmptyMetadataReturnsError(t *testing.T) {
r := NewMemoryRegistry()
// All three fields unset → empty-metadata error.
err := r.Register("EmptyMeta", CategoryIngestion, stubFactory, Metadata{})
if err == nil {
t.Fatalf("Register with empty metadata succeeded; expected empty-metadata error")
}
// Verify it was not actually registered (re-register with valid
// metadata should succeed).
if err := r.Register("EmptyMeta", CategoryIngestion, stubFactory, Metadata{Version: "1.0.0"}); err != nil {
t.Fatalf("Register with valid metadata after empty-metadata rejection failed: %v", err)
}
}
// TestRegistry_Register_AcceptsPartialMetadata verifies the
// three-way empty check: filling ANY one of Version / Inputs /
// Outputs is enough to register successfully. This accommodates
// the migration path where a component has only inputs but no
// outputs yet, or where a shim stamps {Version: "legacy"}.
func TestRegistry_Register_AcceptsPartialMetadata(t *testing.T) {
cases := []struct {
name string
meta Metadata
}{
{"OnlyVersion", Metadata{Version: "1.0.0"}},
{"OnlyInputs", Metadata{Inputs: map[string]string{"x": "x"}}},
{"OnlyOutputs", Metadata{Outputs: map[string]string{"y": "y"}}},
{"LegacyAdapter", Metadata{Version: "legacy"}},
{"Full", Metadata{Version: "1.0.0", Inputs: map[string]string{"x": "x"}, Outputs: map[string]string{"y": "y"}}},
}
for i, c := range cases {
r := NewMemoryRegistry()
name := c.name
if err := r.Register(name, CategoryIngestion, stubFactory, c.meta); err != nil {
t.Errorf("case %d (%s): Register returned error: %v", i, c.name, err)
}
}
}
func TestRegistry_MustRegister_PanicsOnDuplicate(t *testing.T) {
// MustRegister operates on DefaultRegistry. Save and restore so this
// test does not pollute the global.
saved := DefaultRegistry
defer func() { DefaultRegistry = saved }()
DefaultRegistry = NewMemoryRegistry()
MustRegister("Panic", CategoryAgent, stubFactory, Metadata{Version: "legacy"})
defer func() {
if r := recover(); r == nil {
t.Errorf("MustRegister on duplicate did not panic")
}
}()
MustRegister("Panic", CategoryAgent, stubFactory, Metadata{Version: "legacy"})
}
func TestRegistry_Lookup_MissReturnsFalse(t *testing.T) {
r := NewMemoryRegistry()
f, cat, meta, ok := r.Lookup("NotThere")
if ok {
t.Errorf("Lookup on empty registry returned ok=true (f=%v cat=%q meta=%+v)", f, cat, meta)
}
if f != nil {
t.Errorf("Lookup miss: factory should be nil, got %v", f)
}
if cat != "" {
t.Errorf("Lookup miss: category should be empty, got %q", cat)
}
if meta.Inputs != nil || meta.Outputs != nil {
t.Errorf("Lookup miss: metadata should be zero-value, got %+v", meta)
}
}
func TestRegistry_NamesByCategory_FiltersCorrectly(t *testing.T) {
r := NewMemoryRegistry()
if err := r.Register("AgentComp", CategoryAgent, stubFactory, Metadata{Version: "legacy"}); err != nil {
t.Fatalf("Register AgentComp: %v", err)
}
if err := r.Register("IngestComp", CategoryIngestion, stubFactory, Metadata{Version: "legacy"}); err != nil {
t.Fatalf("Register IngestComp: %v", err)
}
if err := r.Register("SharedComp", CategoryShared, stubFactory, Metadata{Version: "legacy"}); err != nil {
t.Fatalf("Register SharedComp: %v", err)
}
gotAgent := r.NamesByCategory(CategoryAgent)
wantAgent := []string{"agentcomp"}
if !equalSlices(gotAgent, wantAgent) {
t.Errorf("NamesByCategory(CategoryAgent) = %v, want %v", gotAgent, wantAgent)
}
gotIngest := r.NamesByCategory(CategoryIngestion)
wantIngest := []string{"ingestcomp"}
if !equalSlices(gotIngest, wantIngest) {
t.Errorf("NamesByCategory(CategoryIngestion) = %v, want %v", gotIngest, wantIngest)
}
gotShared := r.NamesByCategory(CategoryShared)
wantShared := []string{"sharedcomp"}
if !equalSlices(gotShared, wantShared) {
t.Errorf("NamesByCategory(CategoryShared) = %v, want %v", gotShared, wantShared)
}
gotUnknown := r.NamesByCategory(Category("nonexistent"))
if len(gotUnknown) != 0 {
t.Errorf("NamesByCategory(unknown) = %v, want empty", gotUnknown)
}
}
func TestRegistry_Names_ReturnsAllSorted(t *testing.T) {
r := NewMemoryRegistry()
for _, n := range []string{"Charlie", "alpha", "BRAVO"} {
if err := r.Register(n, CategoryAgent, stubFactory, Metadata{Version: "legacy"}); err != nil {
t.Fatalf("Register %s: %v", n, err)
}
}
got := r.Names()
// Keys are normalized to lowercase at registration time, so the
// returned list is ["alpha", "bravo", "charlie"] (sorted).
want := []string{"alpha", "bravo", "charlie"}
if !equalSlices(got, want) {
t.Errorf("Names() = %v, want %v", got, want)
}
}
func TestRegistry_NamesByCategory_ReturnsSorted(t *testing.T) {
r := NewMemoryRegistry()
for _, n := range []string{"Zulu", "alpha", "mike", "BRAVO"} {
if err := r.Register(n, CategoryIngestion, stubFactory, Metadata{Version: "legacy"}); err != nil {
t.Fatalf("Register %s: %v", n, err)
}
}
got := r.NamesByCategory(CategoryIngestion)
want := []string{"alpha", "bravo", "mike", "zulu"}
if !equalSlices(got, want) {
t.Errorf("NamesByCategory(Ingestion) = %v, want %v", got, want)
}
}
func TestRegistry_ThreadSafe(t *testing.T) {
// Concurrency smoke test: N goroutines each register a distinct
// name; after all join, Names() must contain every one. A
// non-thread-safe map would lose entries or panic under -race.
saved := DefaultRegistry
defer func() { DefaultRegistry = saved }()
r := NewMemoryRegistry()
DefaultRegistry = r
const N = 64
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
i := i
go func() {
defer wg.Done()
name := fmt.Sprintf("comp-%03d", i)
if err := r.Register(name, CategoryIngestion, stubFactory, Metadata{Version: "legacy"}); err != nil {
t.Errorf("Register(%s) returned error: %v", name, err)
}
}()
}
wg.Wait()
got := r.NamesByCategory(CategoryIngestion)
if len(got) != N {
t.Errorf("NamesByCategory(Ingestion) returned %d names; expected %d", len(got), N)
}
// And a parallel Lookup burst against the same registry must
// observe all N entries.
var lookupWg sync.WaitGroup
for i := 0; i < N; i++ {
i := i
lookupWg.Add(1)
go func() {
defer lookupWg.Done()
name := fmt.Sprintf("comp-%03d", i)
if _, _, _, ok := r.Lookup(name); !ok {
t.Errorf("Lookup(%s) missed after concurrent registration", name)
}
}()
}
lookupWg.Wait()
}
func TestRegistry_FactoryErrorPropagates(t *testing.T) {
// A factory that returns an error must propagate that error through
// the Lookup → invoke path. This is not directly tested by the
// plan checklist but it confirms the factory closure contract.
r := NewMemoryRegistry()
wantErr := errors.New("boom")
if err := r.Register("Bad", CategoryAgent, errFactory(wantErr), Metadata{Version: "legacy"}); err != nil {
t.Fatalf("Register: %v", err)
}
f, _, _, ok := r.Lookup("Bad")
if !ok {
t.Fatalf("Lookup missed")
}
_, err := f("Bad", nil)
if !errors.Is(err, wantErr) {
t.Errorf("factory error not propagated: got %v, want %v", err, wantErr)
}
}
func TestDefaultRegistry_Present(t *testing.T) {
if DefaultRegistry == nil {
t.Fatal("DefaultRegistry is nil")
}
// Names() must work without panicking even on the empty default.
if got := DefaultRegistry.Names(); got == nil {
t.Errorf("Names() returned nil; want non-nil slice")
}
}
// equalSlices compares two []string for unordered (no — wait, we DO want
// order-sensitive comparison; Names() and NamesByCategory() guarantee
// sorted output).
func equalSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// extraSort helper kept here in case a future test wants sorted
// comparison without imposing a specific ordering.
var _ = sort.Strings