Files
Yingfeng 706e0d2d06 Refactor harness framework (#16271)
### What problem does this PR solve?

- Tools management
- Pregel engine wrapper for better usage
- UT race
- Coding style

### Type of change

- [x] Refactoring
2026-06-23 20:18:04 +08:00

570 lines
15 KiB
Go

package runnable
import (
"context"
"fmt"
"reflect"
)
// Runnable is the base interface for all runnable components.
// A Runnable represents a unit of computation that can be invoked.
type Runnable[Input, Output any] interface {
// Invoke executes the runnable synchronously.
Invoke(ctx context.Context, input Input) (Output, error)
// Batch executes the runnable on multiple inputs.
Batch(ctx context.Context, inputs []Input) ([]Output, []error)
// Stream returns a stream of outputs.
Stream(ctx context.Context, input Input) <-chan Output
// GetSchema returns the input/output schema.
GetSchema() *RunnableSchema
}
// RunnableSchema describes the schema of a runnable.
type RunnableSchema struct {
InputType string
OutputType string
Name string
Description string
}
// RunnableFunc wraps a function as a Runnable.
type RunnableFunc[Input, Output any] struct {
fn func(context.Context, Input) (Output, error)
schema *RunnableSchema
batchFn func(context.Context, []Input) ([]Output, []error)
streamFn func(context.Context, Input) <-chan Output
}
// NewRunnableFunc creates a new Runnable from a function.
func NewRunnableFunc[Input, Output any](
fn func(context.Context, Input) (Output, error),
opts ...RunnableOption[Input, Output],
) Runnable[Input, Output] {
r := &RunnableFunc[Input, Output]{
fn: fn,
schema: &RunnableSchema{
InputType: fmt.Sprintf("%T", *new(Input)),
OutputType: fmt.Sprintf("%T", *new(Output)),
},
}
for _, opt := range opts {
opt(r)
}
return r
}
// Invoke executes the runnable.
func (r *RunnableFunc[Input, Output]) Invoke(ctx context.Context, input Input) (Output, error) {
return r.fn(ctx, input)
}
// Batch executes the runnable on multiple inputs.
func (r *RunnableFunc[Input, Output]) Batch(ctx context.Context, inputs []Input) ([]Output, []error) {
if r.batchFn != nil {
return r.batchFn(ctx, inputs)
}
// Default: execute sequentially
outputs := make([]Output, len(inputs))
errs := make([]error, len(inputs))
for i, input := range inputs {
outputs[i], errs[i] = r.Invoke(ctx, input)
}
return outputs, errs
}
// Stream returns a stream of outputs.
func (r *RunnableFunc[Input, Output]) Stream(ctx context.Context, input Input) <-chan Output {
if r.streamFn != nil {
return r.streamFn(ctx, input)
}
// Default: single output
ch := make(chan Output, 1)
go func() {
defer close(ch)
output, err := r.Invoke(ctx, input)
if err == nil {
ch <- output
}
}()
return ch
}
// GetSchema returns the schema.
func (r *RunnableFunc[Input, Output]) GetSchema() *RunnableSchema {
return r.schema
}
// RunnableOption configures a Runnable.
type RunnableOption[Input, Output any] func(*RunnableFunc[Input, Output])
// WithName sets the name of the runnable.
func WithName[Input, Output any](name string) RunnableOption[Input, Output] {
return func(r *RunnableFunc[Input, Output]) {
r.schema.Name = name
}
}
// WithDescription sets the description of the runnable.
func WithDescription[Input, Output any](desc string) RunnableOption[Input, Output] {
return func(r *RunnableFunc[Input, Output]) {
r.schema.Description = desc
}
}
// WithBatchFn sets the batch function.
func WithBatchFn[Input, Output any](
fn func(context.Context, []Input) ([]Output, []error),
) RunnableOption[Input, Output] {
return func(r *RunnableFunc[Input, Output]) {
r.batchFn = fn
}
}
// WithStreamFn sets the stream function.
func WithStreamFn[Input, Output any](
fn func(context.Context, Input) <-chan Output,
) RunnableOption[Input, Output] {
return func(r *RunnableFunc[Input, Output]) {
r.streamFn = fn
}
}
// RunnableSeq chains multiple runnables together in sequence.
// The output of one runnable is the input to the next.
type RunnableSeq struct {
runnables []Runnable[any, any]
schema *RunnableSchema
}
// NewRunnableSeq creates a new sequence of runnables.
func NewRunnableSeq(runnables ...Runnable[any, any]) (*RunnableSeq, error) {
if len(runnables) == 0 {
return nil, &RunnableError{Message: "at least one runnable is required"}
}
// Verify type compatibility (simplified check)
for i := 1; i < len(runnables); i++ {
prevSchema := runnables[i-1].GetSchema()
currSchema := runnables[i].GetSchema()
if prevSchema.OutputType != currSchema.InputType {
return nil, &RunnableError{
Message: fmt.Sprintf("type mismatch: %s -> %s", prevSchema.OutputType, currSchema.InputType),
}
}
}
return &RunnableSeq{
runnables: runnables,
schema: &RunnableSchema{
InputType: runnables[0].GetSchema().InputType,
OutputType: runnables[len(runnables)-1].GetSchema().OutputType,
Name: "sequence",
},
}, nil
}
// Invoke executes all runnables in sequence.
func (s *RunnableSeq) Invoke(ctx context.Context, input interface{}) (interface{}, error) {
var current interface{} = input
var err error
for _, r := range s.runnables {
current, err = r.Invoke(ctx, current)
if err != nil {
return nil, err
}
}
return current, nil
}
// Batch executes the sequence on multiple inputs.
func (s *RunnableSeq) Batch(ctx context.Context, inputs []interface{}) ([]interface{}, []error) {
outputs := make([]interface{}, len(inputs))
errs := make([]error, len(inputs))
for i, input := range inputs {
outputs[i], errs[i] = s.Invoke(ctx, input)
}
return outputs, errs
}
// Stream returns a stream of outputs.
// On error, sends a StreamError value instead of silently dropping.
// Callers can type-assert: if se, ok := val.(StreamError); ok { /* handle err */ }
func (s *RunnableSeq) Stream(ctx context.Context, input interface{}) <-chan interface{} {
ch := make(chan interface{}, 1)
go func() {
defer close(ch)
output, err := s.Invoke(ctx, input)
if err != nil {
ch <- StreamError{Err: err}
return
}
ch <- output
}()
return ch
}
// GetSchema returns the schema.
func (s *RunnableSeq) GetSchema() *RunnableSchema {
return s.schema
}
// RunnableParallel executes multiple runnables in parallel.
type RunnableParallel struct {
runnables map[string]Runnable[any, any]
schema *RunnableSchema
}
// NewRunnableParallel creates a new parallel runnable.
func NewRunnableParallel(runnables map[string]Runnable[any, any]) *RunnableParallel {
return &RunnableParallel{
runnables: runnables,
schema: &RunnableSchema{
Name: "parallel",
InputType: "map",
OutputType: "map",
},
}
}
// Invoke executes all runnables in parallel with the same input.
func (p *RunnableParallel) Invoke(ctx context.Context, input interface{}) (interface{}, error) {
type result struct {
name string
value interface{}
err error
}
resultCh := make(chan result, len(p.runnables))
for name, r := range p.runnables {
go func(n string, rn Runnable[any, any]) {
value, err := rn.Invoke(ctx, input)
resultCh <- result{name: n, value: value, err: err}
}(name, r)
}
outputs := make(map[string]interface{})
for range p.runnables {
res := <-resultCh
if res.err != nil {
return nil, res.err
}
outputs[res.name] = res.value
}
return outputs, nil
}
// Batch executes the parallel runnable.
func (p *RunnableParallel) Batch(ctx context.Context, inputs []interface{}) ([]interface{}, []error) {
outputs := make([]interface{}, len(inputs))
errs := make([]error, len(inputs))
for i, input := range inputs {
outputs[i], errs[i] = p.Invoke(ctx, input)
}
return outputs, errs
}
// Stream returns a stream of outputs.
// On error, sends a StreamError value instead of silently dropping.
func (p *RunnableParallel) Stream(ctx context.Context, input interface{}) <-chan interface{} {
ch := make(chan interface{}, 1)
go func() {
defer close(ch)
output, err := p.Invoke(ctx, input)
if err != nil {
ch <- StreamError{Err: err}
return
}
ch <- output
}()
return ch
}
// GetSchema returns the schema.
func (p *RunnableParallel) GetSchema() *RunnableSchema {
return p.schema
}
// RunnableMap transforms the input before passing to the underlying runnable.
type RunnableMap struct {
inputFn func(context.Context, interface{}) (interface{}, error)
outputFn func(context.Context, interface{}) (interface{}, error)
base Runnable[any, any]
schema *RunnableSchema
}
// NewRunnableMap creates a new runnable with input/output transformation.
func NewRunnableMap(
base Runnable[any, any],
inputFn func(context.Context, interface{}) (interface{}, error),
outputFn func(context.Context, interface{}) (interface{}, error),
) Runnable[any, any] {
return &RunnableMap{
base: base,
inputFn: inputFn,
outputFn: outputFn,
schema: &RunnableSchema{
Name: "map",
},
}
}
// Invoke executes the runnable with transformations.
func (m *RunnableMap) Invoke(ctx context.Context, input interface{}) (interface{}, error) {
if m.inputFn != nil {
transformed, err := m.inputFn(ctx, input)
if err != nil {
return nil, err
}
input = transformed
}
output, err := m.base.Invoke(ctx, input)
if err != nil {
return nil, err
}
if m.outputFn != nil {
transformed, err := m.outputFn(ctx, output)
if err != nil {
return nil, err
}
output = transformed
}
return output, nil
}
// Batch executes the mapped runnable.
func (m *RunnableMap) Batch(ctx context.Context, inputs []interface{}) ([]interface{}, []error) {
outputs := make([]interface{}, len(inputs))
errs := make([]error, len(inputs))
for i, input := range inputs {
outputs[i], errs[i] = m.Invoke(ctx, input)
}
return outputs, errs
}
// Stream returns a stream of outputs.
// On error, sends a StreamError value instead of silently dropping.
func (m *RunnableMap) Stream(ctx context.Context, input interface{}) <-chan interface{} {
ch := make(chan interface{}, 1)
go func() {
defer close(ch)
output, err := m.Invoke(ctx, input)
if err != nil {
ch <- StreamError{Err: err}
return
}
ch <- output
}()
return ch
}
// GetSchema returns the schema.
func (m *RunnableMap) GetSchema() *RunnableSchema {
return m.schema
}
// CoerceToRunnable converts various types to a Runnable.
// Supported types: Runnable, func(context.Context, T) (U, error), func(T) U
func CoerceToRunnable(value interface{}) (Runnable[any, any], error) {
switch v := value.(type) {
case Runnable[any, any]:
return v, nil
default:
// Check if it's a function
valType := reflect.TypeOf(value)
if valType == nil {
return nil, &RunnableError{
Message: "cannot coerce nil to Runnable",
}
}
if valType.Kind() == reflect.Func {
// Try to wrap it as a RunnableFunc
return coerceFuncToRunnable(value, valType)
}
return nil, &RunnableError{
Message: fmt.Sprintf("cannot coerce %T to Runnable", value),
}
}
}
// coerceFuncToRunnable coerces a function to a Runnable.
func coerceFuncToRunnable(fn interface{}, fnType reflect.Type) (Runnable[any, any], error) {
// Check function signature
numIn := fnType.NumIn()
numOut := fnType.NumOut()
// Supported signatures:
// 1. func(context.Context, T) (U, error)
// 2. func(T) U
// 3. func(T) (U, error)
// 4. func() U
// 5. func() (U, error)
// We'll create a wrapper that adapts the function to Runnable[any, any]
wrapper := func(ctx context.Context, input interface{}) (interface{}, error) {
// Prepare arguments
args := make([]reflect.Value, 0, numIn)
argIndex := 0
// Check if first argument is context.Context
if numIn > 0 && fnType.In(0).AssignableTo(reflect.TypeOf((*context.Context)(nil)).Elem()) {
args = append(args, reflect.ValueOf(ctx))
argIndex++
}
// Add input argument if needed
if argIndex < numIn {
inputVal := reflect.ValueOf(input)
paramType := fnType.In(argIndex)
// Try to convert input to expected type
if input != nil && inputVal.Type().AssignableTo(paramType) {
args = append(args, inputVal)
} else if input != nil && inputVal.Type().ConvertibleTo(paramType) {
args = append(args, inputVal.Convert(paramType))
} else {
// Use zero value
args = append(args, reflect.Zero(paramType))
}
argIndex++
}
// Fill remaining parameters with zero values
for ; argIndex < numIn; argIndex++ {
args = append(args, reflect.Zero(fnType.In(argIndex)))
}
// Call function
results := reflect.ValueOf(fn).Call(args)
// Process results
if numOut == 0 {
return nil, nil
} else if numOut == 1 {
// Single return value
result := results[0].Interface()
// Check if it's an error
if err, ok := result.(error); ok {
return nil, err
}
return result, nil
} else if numOut == 2 {
// Two return values: result, error
result := results[0].Interface()
errVal := results[1].Interface()
if errVal != nil {
if err, ok := errVal.(error); ok {
return result, err
}
return nil, fmt.Errorf("expected error, got %T", errVal)
}
return result, nil
}
return nil, &RunnableError{
Message: fmt.Sprintf("unsupported number of return values: %d", numOut),
}
}
return NewRunnableFunc(wrapper), nil
}
// StreamError wraps an error for stream channels.
// Stream implementations send this instead of silently dropping errors.
// Callers type-assert: if se, ok := val.(runnable.StreamError); ok { handle(se.Err) }.
type StreamError struct {
Err error
}
func (e StreamError) Error() string {
if e.Err == nil {
return "<nil error>"
}
return e.Err.Error()
}
// RunnableError represents a runnable-related error.
type RunnableError struct {
Message string
Code string
}
func (e *RunnableError) Error() string {
if e.Code != "" {
return e.Code + ": " + e.Message
}
return e.Message
}
// InvokeCompat provides a compatibility layer for invoke with different input types.
func InvokeCompat(ctx context.Context, r Runnable[any, any], input interface{}) (interface{}, error) {
return r.Invoke(ctx, input)
}
// RunnableBuilder provides a fluent interface for building runnables.
type RunnableBuilder struct {
runnable Runnable[any, any]
}
// NewRunnableBuilder creates a new runnable builder.
func NewRunnableBuilder(runnable Runnable[any, any]) *RunnableBuilder {
return &RunnableBuilder{runnable: runnable}
}
// Then chains another runnable after this one.
func (b *RunnableBuilder) Then(next Runnable[any, any]) (*RunnableBuilder, error) {
seq, err := NewRunnableSeq(b.runnable, next)
if err != nil {
return nil, err
}
return &RunnableBuilder{runnable: seq}, nil
}
// Map applies input/output transformations.
func (b *RunnableBuilder) Map(
inputFn func(context.Context, interface{}) (interface{}, error),
outputFn func(context.Context, interface{}) (interface{}, error),
) *RunnableBuilder {
b.runnable = NewRunnableMap(b.runnable, inputFn, outputFn)
return b
}
// Build returns the final runnable.
func (b *RunnableBuilder) Build() Runnable[any, any] {
return b.runnable
}
// Pipe chains runnables in a more ergonomic way.
func Pipe(runnables ...Runnable[any, any]) (Runnable[any, any], error) {
return NewRunnableSeq(runnables...)
}
// MapValue transforms the input value.
func MapValue(
fn func(interface{}) interface{},
) func(context.Context, interface{}) (interface{}, error) {
return func(_ context.Context, v interface{}) (interface{}, error) {
return fn(v), nil
}
}