Skip to content

feat: improve data-builder performance and resilience#25

Merged
ankurs merged 7 commits into
mainfrom
feat/improvements
Mar 23, 2026
Merged

feat: improve data-builder performance and resilience#25
ankurs merged 7 commits into
mainfrom
feat/improvements

Conversation

@ankurs
Copy link
Copy Markdown
Member

@ankurs ankurs commented Mar 22, 2026

Summary

  • Reflection caching: Cache reflect.ValueOf(builder) in the builder struct at registration time, avoiding repeated reflection on every Run()/RunParallel()
  • Context cancellation: Check ctx.Err() before each execution stage so cancelled plans short-circuit instead of running all remaining stages
  • Error aggregation: Use errors.Join(errs...) instead of returning only the first error, preserving all failure information from parallel builders

Test plan

  • Existing tests pass (make test -race), including extensive dependency, plan, and builder tests
  • Lint clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Aggregate and return all collected errors (instead of only the first).
    • Early-abort operations when context cancellation is detected.
    • Reject nil / typed-nil builder inputs and avoid wrapping single errors so original error identities are preserved.
  • Tests

    • Added unit tests covering nil-builder rejection, context cancellation handling, and error-joining behavior.

- Cache reflect.ValueOf(builder) at registration time to avoid
  repeated reflection on every Run().
- Check context cancellation before each execution stage so
  cancelled plans short-circuit instead of running all stages.
- Aggregate multiple builder errors with errors.Join instead of
  returning only the first error.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 22, 2026

Warning

Rate limit exceeded

@ankurs has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 14 minutes and 50 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c819dae2-6ee6-4a40-ba27-7bce0a19256a

📥 Commits

Reviewing files that changed from the base of the PR and between a6e8665 and d77e35a.

📒 Files selected for processing (1)
  • plan.go
📝 Walkthrough

Walkthrough

Replaced stored exported function with a cached reflect.Value for builders, added explicit rejection of typed-nil builders, centralized error-joining behavior, changed plan to return joined errors, and added tests for nil-builder rejection, context cancellation, and joinErrors behavior.

Changes

Cohort / File(s) Summary
Builder reflection & validation
databuilder.go
Replaced Builder any with fnValue reflect.Value, detect and reject typed-nil builders via reflect.ValueOf(builder).IsNil(), derive function name/Type from fnValue, and removed redundant getFuncName.
Plan execution & error aggregation
plan.go
Use cached fnValue when calling builders, introduce joinErrors(errs []error) error to return nil/single/joined errors appropriately, add context cancellation early-abort inside ordered builder loop, and return joined errors instead of only the first.
Tests
databuilder_test.go
Added TestTypedNilBuilderRejected, TestContextCancellation, and TestJoinErrors to verify typed-nil rejection, context-cancel behavior, and joinErrors semantics.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I cached my function, no more reflection chase,
I sniffed out nils in their tiniest place.
I gathered errors in a tidy, joined stack,
And stopped on the breeze when context turned back.
Hooray — a hop for cleaner code, from your rabbit with grace.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: performance improvements via reflection caching and resilience enhancements through context cancellation and error aggregation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/improvements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the execution runtime of databuilder plans by reducing per-builder reflection overhead, short-circuiting plan execution on context cancellation, and aggregating errors from builder execution (especially in parallel stages).

Changes:

  • Cache each builder’s reflect.Value at registration time and reuse it during execution.
  • Stop executing additional plan stages when ctx is canceled.
  • Aggregate errors using errors.Join instead of returning only the first encountered error.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
plan.go Uses cached reflection value during execution, adds ctx cancellation checks per stage, and joins errors instead of returning the first.
databuilder.go Extends builder to store a cached reflect.Value and initializes it during builder registration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread plan.go Outdated
Comment thread plan.go
Comment thread plan.go Outdated
Comment thread databuilder.go Outdated
… ctx cancel

- Remove unused Builder field from builder struct since fnValue
  replaced all usages
- Join accumulated errors with ctx.Err() on context cancellation
  so prior stage failures are not lost
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
plan.go (1)

169-173: ⚠️ Potential issue | 🟠 Major

Aggregate o.err instead of returning the first one.

Line 172 still short-circuits on the first internal failure, so parallel panics and ErrWTF cases remain lossy and order-dependent even though builder-returned errors are now joined.

🧩 Proposed fix
 	for o := range outChan {
 		if o.err != nil {
-			// error occured, return it back and stop processing
-			return o.err
+			errs = append(errs, o.err)
+			continue
 		}
 		outputs := o.outputs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plan.go` around lines 169 - 173, The loop over outChan currently returns the
first o.err, which short-circuits and loses other errors; instead, collect all
non-nil o.err values as you drain outChan (e.g., append to a slice of error or
use errors.Join) and continue processing every item; after the for-range
completes, if you have any collected errors return errors.Join(collected...) (or
a wrapped multi-error), otherwise return nil — apply this change to the loop
that reads from outChan and any related logic handling ErrWTF to ensure all
builder errors are aggregated instead of returning immediately.
databuilder.go (1)

137-149: ⚠️ Potential issue | 🔴 Critical

Reject typed-nil builder funcs — check value in IsValidBuilder.

A typed-nil func stored in any passes the type checks in IsValidBuilder but reflect.ValueOf(bldr).Pointer() in getFuncName panics on a nil value. The safest fix is to check fnValue.IsNil() immediately after calling reflect.ValueOf(bldr) in getBuilder and return ErrInvalidBuilder if true. This also eliminates redundant reflection calls.

Proposed fix
 func getBuilder(bldr any) (*builder, error) {
 	if err := IsValidBuilder(bldr); err != nil {
 		return nil, err
 	}
 
-	t := reflect.TypeOf(bldr)
+	fnValue := reflect.ValueOf(bldr)
+	if fnValue.IsNil() {
+		return nil, ErrInvalidBuilder
+	}
+
+	t := fnValue.Type()
 	out := getStructName(t.Out(0))
-	name := getFuncName(bldr)
+	name := runtime.FuncForPC(fnValue.Pointer()).Name()
 
 	b := &builder{
 		Out:     out,
-		fnValue: reflect.ValueOf(bldr),
+		fnValue: fnValue,
 		Name:    name,
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@databuilder.go` around lines 137 - 149, getBuilder currently calls
IsValidBuilder but then uses reflect.ValueOf(bldr) later which can be a typed
nil and cause getFuncName to panic; after creating fnValue :=
reflect.ValueOf(bldr) inside getBuilder, immediately check fnValue.IsNil() and
return ErrInvalidBuilder if true, and then reuse fnValue (and fnValue.Type() /
fnValue.Type().Out(0) or pass fnValue to getFuncName) instead of re-calling
reflect.TypeOf or reflect.ValueOf to eliminate redundant reflection and prevent
the nil-pointer panic; update references to use the builder.fnValue you create.
🧹 Nitpick comments (1)
plan.go (1)

186-187: Keep single-error paths unwrapped.

errors.Join wraps even one error. That changes the concrete error returned by Run/RunParallel, so direct checks like err == context.Canceled or sentinel equality stop working in the single-failure case.

♻️ Proposed fix
-	if len(errs) > 0 {
-		return errors.Join(errs...)
-	}
-	return nil
+	switch len(errs) {
+	case 0:
+		return nil
+	case 1:
+		return errs[0]
+	default:
+		return errors.Join(errs...)
+	}
-		if ctx.Err() != nil {
-			return errors.Join(append(errs, ctx.Err())...)
+		if err := ctx.Err(); err != nil {
+			if len(errs) == 0 {
+				return err
+			}
+			return errors.Join(append(errs, err)...)
 		}

Also applies to: 206-208, 214-215

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plan.go` around lines 186 - 187, The code currently always uses errors.Join
on the errs slice (e.g., in the return inside Run/RunParallel), which wraps even
a single error and breaks sentinel equality checks; change each occurrence so
that if len(errs) == 1 you return errs[0] directly, otherwise return
errors.Join(errs...), updating the branches where errors are aggregated (the
spots shown around the errors.Join calls and the other occurrences referenced)
so single-error paths are not wrapped.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@databuilder.go`:
- Around line 137-149: getBuilder currently calls IsValidBuilder but then uses
reflect.ValueOf(bldr) later which can be a typed nil and cause getFuncName to
panic; after creating fnValue := reflect.ValueOf(bldr) inside getBuilder,
immediately check fnValue.IsNil() and return ErrInvalidBuilder if true, and then
reuse fnValue (and fnValue.Type() / fnValue.Type().Out(0) or pass fnValue to
getFuncName) instead of re-calling reflect.TypeOf or reflect.ValueOf to
eliminate redundant reflection and prevent the nil-pointer panic; update
references to use the builder.fnValue you create.

In `@plan.go`:
- Around line 169-173: The loop over outChan currently returns the first o.err,
which short-circuits and loses other errors; instead, collect all non-nil o.err
values as you drain outChan (e.g., append to a slice of error or use
errors.Join) and continue processing every item; after the for-range completes,
if you have any collected errors return errors.Join(collected...) (or a wrapped
multi-error), otherwise return nil — apply this change to the loop that reads
from outChan and any related logic handling ErrWTF to ensure all builder errors
are aggregated instead of returning immediately.

---

Nitpick comments:
In `@plan.go`:
- Around line 186-187: The code currently always uses errors.Join on the errs
slice (e.g., in the return inside Run/RunParallel), which wraps even a single
error and breaks sentinel equality checks; change each occurrence so that if
len(errs) == 1 you return errs[0] directly, otherwise return
errors.Join(errs...), updating the branches where errors are aggregated (the
spots shown around the errors.Join calls and the other occurrences referenced)
so single-error paths are not wrapped.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e6e5caa9-c6f6-4658-8eea-c50be183515f

📥 Commits

Reviewing files that changed from the base of the PR and between f538299 and 1eb759e.

📒 Files selected for processing (2)
  • databuilder.go
  • plan.go

ankurs added 2 commits March 22, 2026 23:51
…ping

- Guard against typed-nil func in getBuilder to prevent panic in
  reflect.ValueOf().Pointer(). Reuse fnValue to eliminate redundant
  reflection calls.
- Remove unused getFuncName (inlined into getBuilder).
- Add joinErrors helper that returns single errors unwrapped to
  preserve sentinel equality (e.g. err == context.Canceled).
  errors.Join is only used for 2+ errors.
- TestTypedNilBuilderRejected: typed-nil func returns ErrInvalidBuilder
- TestContextCancellation: cancelled context short-circuits and returns
  context.Canceled (verifies sentinel equality is preserved)
- TestJoinErrorsSingle: single error returned unwrapped, nil returns nil,
  multiple errors joined and individually matchable via errors.Is
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread databuilder.go
Comment thread databuilder_test.go Outdated
- Add nil check in IsValidBuilder to prevent reflect.TypeOf(nil) panic
  when called via Plan.Replace or directly
- Rename TestJoinErrorsSingle to TestJoinErrors to reflect full coverage
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

databuilder.go:99

  • IsValidBuilder still returns nil for a typed-nil function value (e.g. var f func(...) ...; IsValidBuilder(f)), even though AddBuilders/getBuilder will reject it via fnValue.IsNil(). Since IsValidBuilder is public API documented as validating builders, it should also detect and reject typed-nil funcs (e.g., check reflect.ValueOf(builder).IsNil() once t.Kind()==reflect.Func).
// IsValidBuilder checks if the given function is valid or not
func IsValidBuilder(builder any) error {
	if builder == nil {
		return ErrInvalidBuilder
	}
	t := reflect.TypeOf(builder)
	if t.Kind() != reflect.Func {
		// Input can only be a function
		return ErrInvalidBuilderKind
	}
	if t.NumOut() != 2 {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread databuilder_test.go
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
plan.go (1)

168-186: ⚠️ Potential issue | 🟠 Major

o.err still bypasses the new aggregation path.

Line 172 returns on the first internal worker error, so panics and ErrWTF from the rest of the same parallel batch are still dropped. That undercuts the new “preserve parallel failures” behavior.

Suggested fix
 	errs := make([]error, 0)
 	for o := range outChan {
 		if o.err != nil {
-			// error occured, return it back and stop processing
-			return o.err
+			errs = append(errs, o.err)
+			continue
 		}
 		outputs := o.outputs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plan.go` around lines 168 - 186, The loop currently returns immediately on
any internal worker error (o.err), which bypasses aggregation; change the
handling in the outChan loop to treat o.err like other per-item errors: append
o.err to the errs slice (or wrap it consistently with joinErrors/ErrWTF
semantics), skip processing outputs for that item, and continue iterating so all
parallel failures are collected; after the loop keep the final return as return
joinErrors(errs). Ensure you update the block that references o.err and do not
short-circuit the function there.
🧹 Nitpick comments (1)
databuilder_test.go (1)

95-99: Use an identity assertion for the single-sentinel case.

assert.Equal only checks deep equality here. A fresh errors.New(ErrWTF.Error()) would still pass, so this does not actually prove joinErrors returned the original error unchanged.

Suggested fix
-	assert.Equal(t, sentinel, err, "single error should be returned as-is, not wrapped")
+	assert.Same(t, sentinel, err, "single error should be returned as-is, not wrapped")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@databuilder_test.go` around lines 95 - 99, The test currently uses
assert.Equal to check the single-sentinel case which only checks deep equality;
change this to an identity assertion so we verify joinErrors returns the exact
sentinel object. Replace the assert.Equal call in the test for joinErrors with
an identity check such as assert.Same(t, sentinel, err, "single error should be
returned as-is, not wrapped") (or require.Same if you prefer a hard fail),
referencing the sentinel variable ErrWTF and the joinErrors result.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@plan.go`:
- Around line 217-225: The loop currently appends ctx.Err() unconditionally
which can duplicate sentinel errors like context.Canceled and force joinErrors
to return a composite error; change the check so if ctx.Err() != nil you first
detect whether that exact error value is already present in errs (e.g., compare
to each element) and if so return joinErrors(errs) immediately, otherwise return
joinErrors(append(errs, ctx.Err())). Update the block around ctx.Err() handling
(where ctx.Err() is checked before calling doWorkAndGetResult) so you avoid
re-adding the same sentinel error to errs and preserve direct err ==
context.Canceled behavior.

---

Outside diff comments:
In `@plan.go`:
- Around line 168-186: The loop currently returns immediately on any internal
worker error (o.err), which bypasses aggregation; change the handling in the
outChan loop to treat o.err like other per-item errors: append o.err to the errs
slice (or wrap it consistently with joinErrors/ErrWTF semantics), skip
processing outputs for that item, and continue iterating so all parallel
failures are collected; after the loop keep the final return as return
joinErrors(errs). Ensure you update the block that references o.err and do not
short-circuit the function there.

---

Nitpick comments:
In `@databuilder_test.go`:
- Around line 95-99: The test currently uses assert.Equal to check the
single-sentinel case which only checks deep equality; change this to an identity
assertion so we verify joinErrors returns the exact sentinel object. Replace the
assert.Equal call in the test for joinErrors with an identity check such as
assert.Same(t, sentinel, err, "single error should be returned as-is, not
wrapped") (or require.Same if you prefer a hard fail), referencing the sentinel
variable ErrWTF and the joinErrors result.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa05e7e9-a4d7-423d-8b3d-4955d37c0d0f

📥 Commits

Reviewing files that changed from the base of the PR and between 1eb759e and a6e8665.

📒 Files selected for processing (3)
  • databuilder.go
  • databuilder_test.go
  • plan.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • databuilder.go

Comment thread plan.go
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@ankurs ankurs merged commit 8d530ea into main Mar 23, 2026
9 checks passed
@ankurs ankurs deleted the feat/improvements branch March 23, 2026 00:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants