diff --git a/README.md b/README.md
index c52dddf..d33dd11 100755
--- a/README.md
+++ b/README.md
@@ -13,6 +13,54 @@
import "github.com/go-coldbrew/data-builder"
```
+Package databuilder compiles a set of builder functions into an execution plan with automatic dependency resolution, then runs them sequentially or in parallel.
+
+### Builder functions
+
+A builder is a plain Go function whose signature encodes its inputs and output as types:
+
+```
+func(ctx context.Context, in1 StructA, in2 StructB) (StructC, error)
+```
+
+Rules enforced by [IsValidBuilder](<#IsValidBuilder>):
+
+- The first parameter must be context.Context.
+- All remaining parameters must be concrete struct values \(no pointers, no variadics, no primitives\).
+- The function must return exactly two values: a concrete struct and an error.
+- Two registered builders cannot produce the same output struct.
+- A builder cannot take its own output type as input.
+
+Types are identified by their fully qualified "pkgpath.TypeName", so the dependency graph is built entirely from ordinary Go type information.
+
+### Typical flow
+
+1. Build a [DataBuilder](<#DataBuilder>) with [New](<#New>).
+2. Register builder functions with \[DataBuilder.AddBuilders\].
+3. Call \[DataBuilder.Compile\] with zero\-valued instances of the structs the caller will supply at runtime. Compile topologically sorts the builders into stages, returning a [Plan](<#Plan>).
+4. Run the plan with \[Plan.Run\] \(sequential\) or \[Plan.RunParallel\] \(bounded worker pool\). Both return a [Result](<#Result>).
+5. Read typed outputs from the result with [Result.Get](<#Result.Get>) or [GetFromResult](<#GetFromResult>) from inside a builder.
+
+A compiled [Plan](<#Plan>) is side\-effect free and safe to reuse across goroutines. \[Plan.Replace\] can swap a builder for a compatible one without recompiling, as long as the replacement's inputs are a subset of the original's.
+
+### Parallelism
+
+\[Plan.RunParallel\] runs all builders in the same stage of the DAG concurrently, bounded by a caller\-supplied worker count. A panic or error from any builder is surfaced back to the caller; subsequent stages do not start. Use [MaxPlanParallelism](<#MaxPlanParallelism>) to size the worker pool to the widest stage.
+
+### Performance
+
+Function\-name \(runtime.FuncForPC\) and struct\-name \(reflect.Type\) resolutions are cached in process\-global sync.Maps. Keys are stable for the life of the program, so the caches never evict. Hot\-path effects \(benchstat, count=6\):
+
+- Result.Get: \~4x faster single\-threaded, \~11x faster under parallel load, zero allocations on hit.
+- AddBuilders \(warm cache\): \~40% faster, \~60% fewer allocations.
+- Per\-resolution hits: \~10\-15 ns/op, zero allocations.
+
+Benchmarks live in benchmarks\_test.go; run \`make bench\` to measure on your hardware.
+
+### Visualization
+
+[BuildGraph](<#BuildGraph>) renders the compiled plan to a graphviz file in any format graphviz supports \(png, svg, dot, ...\). Graphviz must be installed on the system.
+
## Index
- [Constants](<#constants>)
@@ -78,7 +126,7 @@ var ErrWTF = errors.New("what a terrible failure: this is likely a bug in depend
```
-## func [AddResultToCtx]()
+## func AddResultToCtx
```go
func AddResultToCtx(ctx context.Context, r Result) context.Context
@@ -89,7 +137,7 @@ AddResultToCtx adds the given result object to context
this function should ideally only be used in your tests and/or for debugging modification made to Result obj will NOT persist
-## func [BuildGraph]()
+## func BuildGraph
```go
func BuildGraph(executionPlan Plan, format, file string) error
@@ -98,7 +146,7 @@ func BuildGraph(executionPlan Plan, format, file string) error
BuildGraph helps understand the execution plan, it renders the plan in the given format please note we depend on graphviz, please ensure you have graphviz installed
-## func [GetFromResult]()
+## func GetFromResult
```go
func GetFromResult(ctx context.Context, obj any) any
@@ -109,7 +157,7 @@ GetFromResult allows builders to access data built by other builders
this function enables optional access to data, your code should not rely on values being present, if you have explicit dependency please add them to your function parameters
-## func [IsValidBuilder]()
+## func IsValidBuilder
```go
func IsValidBuilder(builder any) error
@@ -118,7 +166,7 @@ func IsValidBuilder(builder any) error
IsValidBuilder checks if the given function is valid or not
-## func [MaxPlanParallelism]()
+## func MaxPlanParallelism
```go
func MaxPlanParallelism(pl Plan) (uint, error)
@@ -129,7 +177,7 @@ MaxPlanParallelism return the maximum number of buildes that can be exsecuted pa
this number does not take into account if the builder are cpu intensive or netwrok intensive it may not be benificial to run builders at max parallelism if they are cpu intensive
-## type [DataBuilder]()
+## type DataBuilder
DataBuilder is the interface for DataBuilder
@@ -293,7 +341,7 @@ welcome to singapore
-### func [New]()
+### func New
```go
func New() DataBuilder
@@ -302,7 +350,7 @@ func New() DataBuilder
New Creates a new DataBuilder
-## type [Plan]()
+## type Plan
Plan is the interface that wraps execution of Plans created by DataBuilder.Compile method.
@@ -367,7 +415,7 @@ true
-## type [Result]()
+## type Result
Result is the result of the Plan.Run method
@@ -376,7 +424,7 @@ type Result map[string]any
```
-### func [GetResultFromCtx]()
+### func GetResultFromCtx
```go
func GetResultFromCtx(ctx context.Context) Result
@@ -387,7 +435,7 @@ GetResultFromCtx gives access to result object at this point in execution
this function should ideally only be used in your tests and/or for debugging modification made to Result obj may or may not persist
-### func \(Result\) [Get]()
+### func \(Result\) Get
```go
func (r Result) Get(obj any) any
diff --git a/benchmarks_test.go b/benchmarks_test.go
new file mode 100644
index 0000000..79700a2
--- /dev/null
+++ b/benchmarks_test.go
@@ -0,0 +1,251 @@
+package databuilder
+
+import (
+ "context"
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// Quiet benchmark-only builder variants. The production fixtures in
+// common_test.go call fmt.Println and dominate end-to-end timings, hiding
+// the effect we want to measure.
+
+type benchStructIn struct{ Value string }
+type benchStructA struct{ Value string }
+type benchStructB struct{ Value string }
+type benchStructC struct{ Value string }
+type benchStructD struct{ Value string }
+
+func benchFuncA(_ context.Context, s benchStructIn) (benchStructA, error) {
+ return benchStructA{Value: strings.ReplaceAll(s.Value, "-", "_")}, nil
+}
+
+func benchFuncB(_ context.Context, s benchStructA) (benchStructB, error) {
+ return benchStructB{Value: s.Value + "B"}, nil
+}
+
+func benchFuncC(_ context.Context, s benchStructA) (benchStructC, error) {
+ return benchStructC{Value: s.Value + "C"}, nil
+}
+
+func benchFuncD(_ context.Context, _ benchStructB, _ benchStructC) (benchStructD, error) {
+ return benchStructD{Value: "D"}, nil
+}
+
+// uncachedStructName reproduces the pre-caching implementation for apples-to-apples
+// comparison in the micro-benchmarks.
+func uncachedStructName(t reflect.Type) string {
+ return t.PkgPath() + "." + t.Name()
+}
+
+// --- struct name resolution ---
+
+func BenchmarkGetStructName_Uncached(b *testing.B) {
+ t := reflect.TypeOf(benchStructA{})
+ b.ReportAllocs()
+ b.ResetTimer()
+ var got string
+ for i := 0; i < b.N; i++ {
+ got = uncachedStructName(t)
+ }
+ runtime.KeepAlive(got)
+}
+
+func BenchmarkCachedStructName_Hit(b *testing.B) {
+ t := reflect.TypeOf(benchStructA{})
+ _ = cachedStructName(t)
+ b.ReportAllocs()
+ b.ResetTimer()
+ var got string
+ for i := 0; i < b.N; i++ {
+ got = cachedStructName(t)
+ }
+ runtime.KeepAlive(got)
+}
+
+func BenchmarkCachedStructName_MixedHit(b *testing.B) {
+ types := []reflect.Type{
+ reflect.TypeOf(benchStructIn{}),
+ reflect.TypeOf(benchStructA{}),
+ reflect.TypeOf(benchStructB{}),
+ reflect.TypeOf(benchStructC{}),
+ reflect.TypeOf(benchStructD{}),
+ }
+ for _, t := range types {
+ _ = cachedStructName(t)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ var got string
+ for i := 0; i < b.N; i++ {
+ got = cachedStructName(types[i%len(types)])
+ }
+ runtime.KeepAlive(got)
+}
+
+// --- function PC resolution ---
+
+func BenchmarkFuncForPC_Uncached(b *testing.B) {
+ pc := reflect.ValueOf(benchFuncA).Pointer()
+ b.ReportAllocs()
+ b.ResetTimer()
+ var got string
+ for i := 0; i < b.N; i++ {
+ got = runtime.FuncForPC(pc).Name()
+ }
+ runtime.KeepAlive(got)
+}
+
+func BenchmarkResolveFuncName_Hit(b *testing.B) {
+ pc := reflect.ValueOf(benchFuncA).Pointer()
+ _ = resolveFuncName(pc)
+ b.ReportAllocs()
+ b.ResetTimer()
+ var got string
+ for i := 0; i < b.N; i++ {
+ got = resolveFuncName(pc)
+ }
+ runtime.KeepAlive(got)
+}
+
+func BenchmarkResolveFuncName_MixedHit(b *testing.B) {
+ pcs := []uintptr{
+ reflect.ValueOf(benchFuncA).Pointer(),
+ reflect.ValueOf(benchFuncB).Pointer(),
+ reflect.ValueOf(benchFuncC).Pointer(),
+ reflect.ValueOf(benchFuncD).Pointer(),
+ }
+ for _, pc := range pcs {
+ _ = resolveFuncName(pc)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ var got string
+ for i := 0; i < b.N; i++ {
+ got = resolveFuncName(pcs[i%len(pcs)])
+ }
+ runtime.KeepAlive(got)
+}
+
+// --- registration ---
+
+func BenchmarkAddBuilders(b *testing.B) {
+ // Pin cache state to "warm" so this benchmark measures steady-state
+ // registration and doesn't drift based on prior benchmark ordering.
+ resetCachesForTest()
+ warm := New()
+ if err := warm.AddBuilders(benchFuncA, benchFuncB, benchFuncC, benchFuncD); err != nil {
+ b.Fatal(err)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ d := New()
+ if err := d.AddBuilders(benchFuncA, benchFuncB, benchFuncC, benchFuncD); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// BenchmarkAddBuilders_ColdCache exercises the worst-case path where the
+// caches are purged before every iteration. Not realistic, but it pins the
+// ceiling of how much the caches can help registration.
+func BenchmarkAddBuilders_ColdCache(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ b.StopTimer()
+ resetCachesForTest()
+ b.StartTimer()
+ d := New()
+ if err := d.AddBuilders(benchFuncA, benchFuncB, benchFuncC, benchFuncD); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// --- compile ---
+
+func BenchmarkCompile(b *testing.B) {
+ d := New()
+ if err := d.AddBuilders(benchFuncA, benchFuncB, benchFuncC, benchFuncD); err != nil {
+ b.Fatal(err)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if _, err := d.Compile(benchStructIn{}); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// --- end-to-end execution ---
+
+func newBenchPlan(b *testing.B) Plan {
+ b.Helper()
+ d := New()
+ if err := d.AddBuilders(benchFuncA, benchFuncB, benchFuncC, benchFuncD); err != nil {
+ b.Fatal(err)
+ }
+ plan, err := d.Compile(benchStructIn{})
+ if err != nil {
+ b.Fatal(err)
+ }
+ return plan
+}
+
+func benchRunParallel(b *testing.B, workers uint) {
+ plan := newBenchPlan(b)
+ ctx := context.Background()
+ in := benchStructIn{Value: "hello-world"}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if _, err := plan.RunParallel(ctx, workers, in); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkRunParallel_Workers1(b *testing.B) { benchRunParallel(b, 1) }
+func BenchmarkRunParallel_Workers4(b *testing.B) { benchRunParallel(b, 4) }
+func BenchmarkRunParallel_Workers8(b *testing.B) { benchRunParallel(b, 8) }
+
+// --- Result.Get ---
+
+func BenchmarkResultGet(b *testing.B) {
+ plan := newBenchPlan(b)
+ result, err := plan.RunParallel(context.Background(), 4, benchStructIn{Value: "x"})
+ if err != nil {
+ b.Fatal(err)
+ }
+ key := benchStructC{}
+ b.ReportAllocs()
+ b.ResetTimer()
+ var got any
+ for i := 0; i < b.N; i++ {
+ got = result.Get(key)
+ }
+ runtime.KeepAlive(got)
+}
+
+func BenchmarkResultGet_Parallel(b *testing.B) {
+ plan := newBenchPlan(b)
+ result, err := plan.RunParallel(context.Background(), 4, benchStructIn{Value: "x"})
+ if err != nil {
+ b.Fatal(err)
+ }
+ key := benchStructC{}
+ b.ReportAllocs()
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ var got any
+ for pb.Next() {
+ got = result.Get(key)
+ }
+ runtime.KeepAlive(got)
+ })
+}
diff --git a/cache.go b/cache.go
new file mode 100644
index 0000000..3b61d98
--- /dev/null
+++ b/cache.go
@@ -0,0 +1,33 @@
+package databuilder
+
+import (
+ "reflect"
+ "runtime"
+ "sync"
+)
+
+// Keys (reflect.Type identity, function PC) are stable for the lifetime of
+// the process, so these caches never need eviction and are bounded by the
+// number of distinct types and builder functions ever observed.
+var (
+ structNameCache sync.Map // reflect.Type -> string
+ funcNameCache sync.Map // uintptr -> string
+)
+
+func cachedStructName(t reflect.Type) string {
+ if v, ok := structNameCache.Load(t); ok {
+ return v.(string)
+ }
+ name := t.PkgPath() + "." + t.Name()
+ actual, _ := structNameCache.LoadOrStore(t, name)
+ return actual.(string)
+}
+
+func resolveFuncName(pc uintptr) string {
+ if v, ok := funcNameCache.Load(pc); ok {
+ return v.(string)
+ }
+ name := runtime.FuncForPC(pc).Name()
+ actual, _ := funcNameCache.LoadOrStore(pc, name)
+ return actual.(string)
+}
diff --git a/cache_test.go b/cache_test.go
new file mode 100644
index 0000000..0151fae
--- /dev/null
+++ b/cache_test.go
@@ -0,0 +1,15 @@
+package databuilder
+
+// resetCachesForTest clears both resolution caches in place. It is safe only
+// when no other goroutines are reading or writing the caches (i.e. from
+// tests/benchmarks that are not running alongside live callers).
+func resetCachesForTest() {
+ structNameCache.Range(func(key, _ any) bool {
+ structNameCache.Delete(key)
+ return true
+ })
+ funcNameCache.Range(func(key, _ any) bool {
+ funcNameCache.Delete(key)
+ return true
+ })
+}
diff --git a/databuilder.go b/databuilder.go
index da121b6..664b5c8 100644
--- a/databuilder.go
+++ b/databuilder.go
@@ -3,8 +3,6 @@ package databuilder
import (
"context"
"reflect"
- "runtime"
-
)
/*
@@ -71,7 +69,7 @@ func (d *db) add(bldr any) error {
}
func (d *db) Compile(init ...any) (Plan, error) {
- initialialData := make([]string, 0, len(init))
+ initialData := make([]string, 0, len(init))
for _, inter := range init {
if inter == nil {
continue
@@ -80,14 +78,14 @@ func (d *db) Compile(init ...any) (Plan, error) {
if t.Kind() != reflect.Struct {
return nil, ErrInvalidBuilderInput
}
- initialialData = append(initialialData, getStructName(t))
+ initialData = append(initialData, cachedStructName(t))
}
- order, err := resolveDependencies(d.builders, initialialData...)
+ order, err := resolveDependencies(d.builders, initialData...)
if err != nil {
return nil, err
}
- return newPlan(order, initialialData)
+ return newPlan(order, initialData)
}
// IsValidBuilder checks if the given function is valid or not
@@ -133,7 +131,7 @@ func IsValidBuilder(builder any) error {
// checks for vardic functions as well
return ErrInvalidBuilderInput
}
- if getStructName(t.In(i)) == getStructName(t.Out(0)) {
+ if cachedStructName(t.In(i)) == cachedStructName(t.Out(0)) {
return ErrSameInputAsOutput
}
}
@@ -155,8 +153,8 @@ func getBuilder(bldr any) (*builder, error) {
}
t := fnValue.Type()
- out := getStructName(t.Out(0))
- name := runtime.FuncForPC(fnValue.Pointer()).Name()
+ out := cachedStructName(t.Out(0))
+ name := resolveFuncName(fnValue.Pointer())
b := &builder{
Out: out,
@@ -165,15 +163,11 @@ func getBuilder(bldr any) (*builder, error) {
}
// first in context.Context so we start from second
for i := 1; i < t.NumIn(); i++ {
- b.In = append(b.In, getStructName(t.In(i)))
+ b.In = append(b.In, cachedStructName(t.In(i)))
}
return b, nil
}
-func getStructName(t reflect.Type) string {
- return t.PkgPath() + "." + t.Name()
-}
-
// New Creates a new DataBuilder
func New() DataBuilder {
return &db{}
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..76233cf
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,69 @@
+// Package databuilder compiles a set of builder functions into an execution
+// plan with automatic dependency resolution, then runs them sequentially or
+// in parallel.
+//
+// # Builder functions
+//
+// A builder is a plain Go function whose signature encodes its inputs and
+// output as types:
+//
+// func(ctx context.Context, in1 StructA, in2 StructB) (StructC, error)
+//
+// Rules enforced by [IsValidBuilder]:
+//
+// - The first parameter must be context.Context.
+// - All remaining parameters must be concrete struct values (no pointers,
+// no variadics, no primitives).
+// - The function must return exactly two values: a concrete struct and an
+// error.
+// - Two registered builders cannot produce the same output struct.
+// - A builder cannot take its own output type as input.
+//
+// Types are identified by their fully qualified "pkgpath.TypeName", so the
+// dependency graph is built entirely from ordinary Go type information.
+//
+// # Typical flow
+//
+// 1. Build a [DataBuilder] with [New].
+// 2. Register builder functions with [DataBuilder.AddBuilders].
+// 3. Call [DataBuilder.Compile] with zero-valued instances of the structs
+// the caller will supply at runtime. Compile topologically sorts the
+// builders into stages, returning a [Plan].
+// 4. Run the plan with [Plan.Run] (sequential) or [Plan.RunParallel]
+// (bounded worker pool). Both return a [Result].
+// 5. Read typed outputs from the result with [Result.Get] or
+// [GetFromResult] from inside a builder.
+//
+// A compiled [Plan] is side-effect free and safe to reuse across goroutines.
+// [Plan.Replace] can swap a builder for a compatible one without recompiling,
+// as long as the replacement's inputs are a subset of the original's.
+//
+// # Parallelism
+//
+// [Plan.RunParallel] runs all builders in the same stage of the DAG
+// concurrently, bounded by a caller-supplied worker count. A panic or error
+// from any builder is surfaced back to the caller; subsequent stages do not
+// start. Use [MaxPlanParallelism] to size the worker pool to the widest
+// stage.
+//
+// # Performance
+//
+// Function-name (runtime.FuncForPC) and struct-name (reflect.Type)
+// resolutions are cached in process-global sync.Maps. Keys are stable for
+// the life of the program, so the caches never evict. Hot-path effects
+// (benchstat, count=6):
+//
+// - Result.Get: ~4x faster single-threaded, ~11x faster under parallel
+// load, zero allocations on hit.
+// - AddBuilders (warm cache): ~40% faster, ~60% fewer allocations.
+// - Per-resolution hits: ~10-15 ns/op, zero allocations.
+//
+// Benchmarks live in benchmarks_test.go; run `make bench` to measure on your
+// hardware.
+//
+// # Visualization
+//
+// [BuildGraph] renders the compiled plan to a graphviz file in any format
+// graphviz supports (png, svg, dot, ...). Graphviz must be installed on the
+// system.
+package databuilder
diff --git a/go.mod b/go.mod
index d03c7c3..3a3defa 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/go-coldbrew/data-builder
-go 1.25.8
+go 1.25.9
require (
github.com/go-coldbrew/tracing v0.1.0
diff --git a/plan.go b/plan.go
index 8eb112d..1b557b1 100644
--- a/plan.go
+++ b/plan.go
@@ -81,7 +81,7 @@ func (p *plan) RunParallel(ctx context.Context, workers uint, initData ...any) (
if t.Kind() != reflect.Struct {
return nil, ErrInvalidBuilderInput
}
- name := getStructName(t)
+ name := cachedStructName(t)
if initialData.Has(name) {
return nil, ErrMultipleInitialData
}
@@ -194,7 +194,7 @@ func doWorkAndGetResult(ctx context.Context, builders []*builder, dataMap map[st
continue
}
// add result
- name := getStructName(outputs[0].Type())
+ name := cachedStructName(outputs[0].Type())
dataMap[name] = outputs[0].Interface()
}
return joinErrors(errs)
@@ -252,7 +252,7 @@ func (r Result) Get(obj any) any {
if t.Kind() != reflect.Struct {
return nil
}
- name := getStructName(t)
+ name := cachedStructName(t)
if value, ok := r[name]; ok {
return value
}