Skip to content

perf(manifest): remove reflection from partition summary updates - #1895

Open
fallintoplace wants to merge 2 commits into
apache:mainfrom
fallintoplace:perf/manifest-typed-partition-stats
Open

perf(manifest): remove reflection from partition summary updates#1895
fallintoplace wants to merge 2 commits into
apache:mainfrom
fallintoplace:perf/manifest-typed-partition-stats

Conversation

@fallintoplace

@fallintoplace fallintoplace commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What changed

partitionFieldStats.update used reflection for every non-null partition value. This runs once for every partition field in every manifest entry.

This PR:

  • stores a typed converter when each field stat is created
  • handles scalar, logical, literal, UUID, binary, and decimal values without reflection
  • keeps the defensive copy for binary bounds
  • keeps the existing error for unsupported values

Benchmark

Existing BenchmarkManifestWriterPartitionSummaries, 10,000 entries, Apple M1 Pro, Go 1.26.3:

  • allocations: 140,485 -> 120,485, about 14% fewer
  • allocated bytes: about 22.4 MB -> 22.3 MB
  • time: about 38.7 ms -> 38.0 ms

Tests

  • go test . -count=1
  • go test -race . -count=1
  • go test ./... -run ^$ -count=1
  • tests for all non-integration packages
  • go vet ./...

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice cleanup: choosing the converter once per field instead of reflecting on every row is the right call, and it's good that the manifest bytes come out identical.

I'd hold this before merging though. My main concern is behavioral parity: the typed converters don't quite reproduce the old reflect path's accept/reject set, and there's no test pinning that they should.

The clearest case is convertPartitionBool, the one converter that skipped the partitionLiteralValue fallback, so a BoolLiteral input now errors where the old CanConvert path silently accepted it. The mirror image is the shared unwrap helper, which now accepts aboveMaxLiteral/belowMinLiteral sentinels that reflection previously rejected. Neither is likely in normal use, but both are undocumented and untested, and since the tests are all happy-path nothing here would flag a regression.

A few things I'd want settled before merge:

  • restore the partitionLiteralValue fallback in convertPartitionBool (plus a BoolLiteral test row)
  • decide whether the sentinel literals should be accepted or rejected, and pin it with a test
  • add rejection-path coverage, one update(struct{}{}) -> error per converter, plus a couple of overflow cases to lock in the current wrapping

The UUID < 16 check, the ns->us timestamp test row, and the numeric-converter duplication are all smaller and non-blocking; I left notes inline. One thing worth a follow-up rather than this PR: the refactor makes newPartitionFieldStat's switch look exhaustive, but TimestampNsType/TimestampTzNsType still fall through to the error branch (pre-existing, not introduced here).

Once the parity items land, happy to take another pass and approve.

Comment thread manifest.go
return nil, false
}

return literal.Any(), true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Flip side of the bool case below: this now accepts things the old path rejected. aboveMaxLiteral/belowMinLiteral are structs that implement Literal, so old reflect.CanConvert returned false and update errored. Here partitionLiteralValue unwraps them via .Any() to their clamped boundary value, the recursion converts it, and we silently record a boundary min/max instead of erroring.

In practice partition maps come from real data values so a sentinel shouldn't show up, but "shouldn't" isn't "can't", and right now nothing documents or tests the new accept-set. I'd either guard the default arm (bail if the unwrapped value is itself a Literal) or add a comment plus a case pinning the intended behavior. wdyt?

Comment thread manifest.go Outdated
return nil
}

func newPartitionFieldStats[T LiteralType](convert partitionValueConverter[T]) fieldStats {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

newPartitionFieldStats differs from the pre-existing newPartitionFieldStat by a single trailing s, with totally different signatures and roles. In a file this size that's easy to misread, and the plural s collides with the Go convention where plural reads as a slice/collection. I'd rename the new one to something that signals a generic factory, e.g. makePartitionFieldStats[T]. Non-blocking.

Comment thread manifest.go
}
}

func convertPartitionBool(value any) (bool, bool) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the one converter without the type-switch + partitionLiteralValue fallback that all the others have, and I think it's a real behavior change. BoolLiteral is type BoolLiteral bool, so the old reflect path accepted it via CanConvert, but value.(bool) here rejects it and update returns an error where it used to succeed.

I'd mirror the other converters: switch on value with a default arm that recurses through partitionLiteralValue. A {typ: PrimitiveTypes.Bool, value: BoolLiteral(true), want: NewLiteral(true)} row would pin it, and would've caught this. wdyt?

Comment thread manifest.go
return converted, ok
}

func convertPartitionInt32(value any) (int32, bool) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The four numeric converters (Int32/Int64/Float32/Float64) are structurally identical, same 16 source cases and fallback, differing only in the cast target, so adding a source type means editing four places with nothing enforcing consistency. Generics can't parameterize a type switch, but a shared numericAny(any) (int64, float64, bool) normalizer plus per-type casts would collapse most of it without bringing reflection back. Not a blocker, just flagging while it's fresh.

Comment thread manifest.go Outdated
case [16]byte:
return uuid.UUID(value), true
case []byte:
if len(value) < len(uuid.UUID{}) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Behavior-preserving (the old reflect path truncated the same way), but now that the length check is explicit I'd make it != len(uuid.UUID{}). A 20-byte slice is almost certainly a caller bug, and < 16 silently keeps the first 16 rather than rejecting it. wdyt?

Comment thread manifest.go Outdated
switch value := value.(type) {
case Decimal:
return value, true
case DecimalLiteral:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

DecimalLiteral would reach case Decimal: in one hop through partitionLiteralValue anyway, so this explicit arm is an optimization, but no other converter special-cases its Literal subtype, so a reader will wonder why decimal is different and int32 isn't. I'd either drop the arm for consistency or add a one-line comment explaining it's there on purpose.

Comment thread manifest_test.go
}

func TestPartitionFieldStatsAcceptsConvertibleValues(t *testing.T) {
tests := []struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every case here is a happy path, but the rejection path is really the half of this change that matters: it decides which inputs are no longer accepted. Both the bool regression and the sentinel change I flagged in manifest.go would surface immediately with a single rejection case per converter.

I'd add at least one stats.update(struct{}{}) (or a plainly-wrong type) asserting a non-nil error per converter. While we're here, a couple of overflow cases (int64 past MaxInt32, float64 past MaxFloat32 -> +Inf) would lock in the current wrapping so a later range check can't silently change it.

Comment thread manifest_test.go Outdated
{name: "date from int32", typ: PrimitiveTypes.Date, value: int32(10), want: NewLiteral(Date(10))},
{name: "time from duration", typ: PrimitiveTypes.Time, value: time.Duration(11), want: NewLiteral(Time(11))},
{name: "timestamp from int64", typ: PrimitiveTypes.Timestamp, value: int64(12), want: NewLiteral(Timestamp(12))},
{name: "timestamp with timezone from nanoseconds", typ: PrimitiveTypes.TimestampTz, value: TimestampNano(13), want: NewLiteral(Timestamp(13))},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not a regression, the old path bit-cast the same way, but this row quietly codifies nanoseconds landing on a microsecond stat with no unit conversion. 13 hides it; a real TimestampNano (~1.7e18) would produce a us bound somewhere around year 55000.

I'd either add a comment that this is a deliberate raw-integer passthrough, or drop the row so we're not documenting it as intended behavior. wdyt?

@fallintoplace
fallintoplace force-pushed the perf/manifest-typed-partition-stats branch from bd3ca1c to d99bd73 Compare August 27, 2026 20:28
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