perf(table): index partition specs by ID - #1910
Conversation
laskoviymishka
left a comment
There was a problem hiding this comment.
Nice work here. The O(n)→O(1) indexing lines up cleanly with the existing snapshotIndex pattern, and the copy-on-write handling across clone/Build/AddPartitionSpec is careful. The benchmark coverage across spec counts is a good touch too.
I'd hold this before merging though.
The main thing is the linear-scan fallback in partitionSpecIndexPosition. The comment says it covers in-package fixtures that "replaced the slice", but it only detects whole-slice replacement. An element-wise mutation on the same backing array (specs[1] = X) misses the map, partitionSpecIndexNeedsRebuild reports the index is current, and we return "not found" for a spec that's actually there. No production path hits that today since builders always mutate through AddPartitionSpec/RemovePartitionSpecs, but the comment promises coverage the code doesn't deliver, and a silent false miss in the fallback is the kind of thing that bites later. I'd either tighten the comment or fall through to the scan on a miss (details inline).
Second, the two concurrent tests don't exercise the copy-on-write path they look like they're guarding. Every goroutine only reads an index that's built once and never mutated, so -race would pass even with the shared flag removed. The scenario worth covering is a reader on already-built metadata racing a builder that mutates and triggers the clone.
Things I'd like to settle before merge:
- Make the fallback comment match what the code detects, or cover element-wise mutation (and add a test that pins it either way)
- Rework one concurrent test to actually trigger copy-on-write under
-race, or document that it only checks post-init read safety - Confirm the index still wins at realistic (single-digit) spec counts, or gate it behind a length threshold
- Clean up the dead nil-guard in
buildCommonMetadataand the duplicated guard block inclone()
Once those are settled, happy to take another pass and approve.
|
|
||
| func buildPartitionSpecIndex(specs []iceberg.PartitionSpec) *partitionSpecIndexData { | ||
| positions := make(map[int]int, len(specs)) | ||
| for i, spec := range specs { |
There was a problem hiding this comment.
Small thing while we're here: for i, spec := range specs copies each PartitionSpec by value and then calls spec.ID() twice, while the fallback scan below uses for i := range specs + specs[i].ID(). Worth matching that here: id := specs[i].ID() once, index off i.
| return 0, false | ||
| } | ||
|
|
||
| if !partitionSpecIndexNeedsRebuild(index, specs) { |
There was a problem hiding this comment.
The comment above says the linear-scan fallback covers in-package fixtures that "replaced the slice", but it only actually covers whole-slice replacement. An element-wise mutation on the same backing array slips through and returns a silent false miss.
Start from specs [A(0), B(1)], build the index, then do specs[1] = C(5) in place: a lookup for id 5 misses the map, partitionSpecIndexNeedsRebuild returns false (both len and &specs[0] are unchanged), and we return (0, false), claiming C isn't there when it's sitting at position 1.
No production path mutates a spec slice element-wise like that today since builders always go through AddPartitionSpec/RemovePartitionSpecs, so this isn't live. But the comment promises more than the code delivers. I'd either tighten the comment to say we only detect whole-slice replacement, or fall through to the linear scan on a miss when the index looks current, though that second option turns every genuine miss into an O(n) scan, which partly defeats the miss fast-path the benchmark is measuring. A test doing specs[1] = X would pin whichever behavior we pick. wdyt?
| lastAddedPartitionID: clonePtr(b.lastAddedPartitionID), | ||
| lastAddedSortOrderID: clonePtr(b.lastAddedSortOrderID), | ||
| } | ||
| if b.partitionSpecIndex != nil { |
There was a problem hiding this comment.
These are two separate if b.partitionSpecIndex != nil blocks with nothing mutating the field between them, so the second guard is dead: the split just makes a reader double-check that no reassignment sneaks in. I'd fold them into one block (build the clone and set b.partitionSpecIndex.shared = true together). The snapshotIndex clone right below has the same shape, so worth aligning both while we're here.
|
|
||
| func (b *MetadataBuilder) buildCommonMetadata() (*commonMetadata, error) { | ||
| b.ensurePartitionSpecIndex() | ||
| if b.partitionSpecIndex != nil { |
There was a problem hiding this comment.
ensurePartitionSpecIndex() calls buildPartitionSpecIndex, which always returns a non-nil &partitionSpecIndexData{}, so b.partitionSpecIndex is guaranteed non-nil right after it returns and this guard can't fail. I'd drop the check and set shared = true unconditionally, since a nil-guard that never fires makes the real nil invariants harder to trust.
| return &s, nil | ||
| } | ||
| index := b.partitionSpecIndex | ||
| if partitionSpecIndexNeedsRebuild(index, b.specs) { |
There was a problem hiding this comment.
When the index is stale this builds a fresh one into a local and throws it away, so N lookups against a stale builder each pay the full O(n) rebuild instead of paying it once. The same discard-the-rebuild pattern is in PartitionSpec and PartitionSpecByID.
It's deliberate: TestMetadataBuilderPartitionSpecIndexFallsBackAfterSliceReplacement asserts assert.Same on the original index, so persisting would break that invariant. But GetSpecByID sits on hot paths (SetDefaultSpecID, the AddPartitionSpec dup check), so I'd either call ensurePartitionSpecIndex() to persist and relax that test, or drop a comment saying we intentionally don't persist on the fixture-replaced path. wdyt?
| var partitionSpecLookupBenchmarkSink int | ||
|
|
||
| func BenchmarkPartitionSpecByID(b *testing.B) { | ||
| for _, specCount := range []int{4, 32, 256, 2_048} { |
There was a problem hiding this comment.
The benchmark bottoms out at 4 specs and tops out at 2,048, but real tables rarely get past single-digit spec counts: the spec list only grows on intentional partition evolution, not per write. At N of 1-4 a map lookup with its hash and allocation can lose to a plain slice scan, so the interesting crossover is exactly the regime this skips.
I'd add N=1 and N=4 cases and report whether the index actually wins there. If it doesn't, it'd be worth gating the index behind a len(specs) threshold so small tables keep the cheaper scan, since the machinery here (pointer-identity check, COW flag, rebuild fallback) is a fair bit of surface to carry if the payoff only shows up at spec counts tables don't reach.
| assert.Equal(t, 1, got.ID()) | ||
| } | ||
|
|
||
| func TestCommonMetadataPartitionSpecLookupsConcurrent(t *testing.T) { |
There was a problem hiding this comment.
These two concurrent tests don't actually exercise the copy-on-write path they look like they're guarding.
Every goroutine only reads from an index that's built once and never mutated: the positions map is written before any goroutine starts and shared/firstSpec are never touched, so the Go memory model already makes these reads safe. A -race run here would pass even if we deleted the shared flag and the whole clone-on-write dance.
The race that matters is a builder marking its index shared in Build(), then AddPartitionSpec triggering ensurePartitionSpecIndexMutable (which clones) while another goroutine reads the metadata built before the mutation. I'd rework one of these to spin up a reader on the built commonMetadata and concurrently call builder.AddPartitionSpec on the builder that produced it, which is what proves the isolation holds under -race. If the intent is only "concurrent reads after init are safe," a comment saying so would keep the next reader from trusting it for more than it tests.
9fae637 to
6ff3e0a
Compare
Summary
PartitionSpecByID, defaultPartitionSpec, and builderGetSpecByID.Benchmark
Command:
go test ./table -run ^$ -bench ^(BenchmarkPartitionSpecByID|BenchmarkMetadataBuilderGetSpecByID)$ -benchmem -benchtime=200ms -count=5Median of 5 runs on Apple M1 Pro:
The metadata path still clones the returned spec. Builder misses still format the existing error. The lookup itself is now constant time.
Checks
go test ./table -count=1 -timeout=5mgo test ./table -race -run ^(TestCommonMetadataPartitionSpecIndex|TestParsedMetadataBuildsPartitionSpecIndex|TestMetadataBuilderPartitionSpecIndex|TestMetadataBuilderRemoveUnknownPartitionSpec|TestCommonMetadataPartitionSpecLookupsConcurrent|TestMetadataBuilderPartitionSpecLookupsConcurrent)$ -count=1go test ./... -run ^$ -count=1go vet ./table