mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-03 22:30:31 +08:00
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:
@@ -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
|
||||
|
||||
163
internal/agent/runtime/helpers.go
Normal file
163
internal/agent/runtime/helpers.go
Normal 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
|
||||
}
|
||||
393
internal/agent/runtime/helpers_test.go
Normal file
393
internal/agent/runtime/helpers_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
206
internal/agent/runtime/registry.go
Normal file
206
internal/agent/runtime/registry.go
Normal 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)
|
||||
}
|
||||
357
internal/agent/runtime/registry_test.go
Normal file
357
internal/agent/runtime/registry_test.go
Normal 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
|
||||
Reference in New Issue
Block a user