perf(table): deduplicate conflict-validation manifest reads - #1904
Conversation
laskoviymishka
left a comment
There was a problem hiding this comment.
The caching design is solid where it counts: the per-descriptor inheritance still runs on every logical read, so the cached-bytes layer is behavior-neutral and I don't see any way it changes what the validators decide. The commit-only-on-EOF idea for not caching early exits is a nice touch too.
I'd hold this before merging though. My main concern is the memory trade-off, which the PR doesn't call out: the old snap.entries path streamed and GC'd one manifest at a time, so peak manifest bytes on the heap was O(one file). This version keeps every fully-read manifest in conflictManifestIO.files for the life of the context, with no bound or eviction, so peak becomes O(all manifests read during the attempt). validateDataFilesExist walks every data manifest of the head snapshot, and under the concurrent-commit load this is meant to help, that's the exact case where the footprint grows most. I'd either document that bound and argue it's acceptable, or add a max-bytes fallback to uncached reads.
The other thing I'd want settled is that the "each manifest opened once" benefit only actually holds for the full-read path. validateDataFilesExist early-exits as soon as it finds the referenced files, which abandons the iterator so that manifest is never cached, and the next validator re-opens it. The benchmark loops a single validator, so it only measures the clean case; the realistic RowDelta composition (validateDataFilesExist then validateAddedDataFilesMatchingFilter) is neither benchmarked nor tested.
A few things I'd want before merge:
- Bound or explicitly document the cache's memory growth.
- Pass
c.fs(not the recording IO) tosnap.ManifestsinmanifestsFor; the manifest-list bytes are cached but never reused. - Firm up the "record only on EOF" invariant so it doesn't depend on
Readreturning(0, io.EOF)separately, and drop theerrors.Isfor a plain==while there. - Add a test and sub-benchmark for the mixed-validator / early-exit path so the opened-once claim is covered.
Once those are addressed, happy to take another pass.
| // descriptor apply its own inheritance metadata. | ||
| type conflictManifestIO struct { | ||
| base iceio.IO | ||
| files map[string][]byte |
There was a problem hiding this comment.
This flips the peak memory profile and the PR doesn't call it out. The old snap.entries path streamed and GC'd one manifest at a time, so peak manifest bytes on the heap was O(one file). Here every fully-read manifest stays in files for the life of the context, with no bound or eviction, so peak becomes O(all manifests read during the attempt).
validateDataFilesExist walks every data manifest of the head snapshot, and under the concurrent-commit load this optimization is meant to help, that's exactly the case where the footprint grows most: 1000 manifests at 100KB is 100MB held per attempt, times N in-flight attempts.
I'd either document that bound and argue it's acceptable (the manifests were going to be read anyway; only the simultaneity is new), or add a max-bytes guard that falls back to uncached reads past a threshold. wdyt?
| type conflictManifestIO struct { | ||
| base iceio.IO | ||
| files map[string][]byte | ||
| } |
There was a problem hiding this comment.
There are no compile-time guards that these satisfy the IO/File interfaces, so if iceio.File ever grows a method the break surfaces at the distant Open call site rather than here.
var (
_ iceio.IO = (*conflictManifestIO)(nil)
_ iceio.File = (*conflictManifestFile)(nil)
_ iceio.File = (*conflictManifestRecordingFile)(nil)
)| func (c *conflictManifestIO) Remove(name string) error { | ||
| delete(c.files, name) | ||
|
|
||
| return c.base.Remove(name) |
There was a problem hiding this comment.
A read-through cache that forwards Remove to the backing store can delete a committed manifest, which is supposed to be immutable. No caller hits it today (only Open is used), but it's a sharp edge the moment this IO gets wired into anything with a cleanup path.
I'd drop the c.base.Remove forwarding and return an unsupported error instead. Thoughts?
| if f.cacheable && n > 0 { | ||
| f.data = append(f.data, p[:n]...) | ||
| } | ||
| if f.cacheable && errors.Is(err, io.EOF) { |
There was a problem hiding this comment.
The "record only on EOF so early exits don't cache" contract is derived from EOF-signal timing, not enforced. The io.Reader contract explicitly permits returning (n, io.EOF) in one call, and if the backing reader ever does that (or drains the file before the decoder yields entries), complete flips true and an abandoned iteration still caches the bytes.
TestConflictValidationDoesNotCacheEarlyManifestExit only passes because local-FS Read happens to return (n, nil) then (0, io.EOF) separately, so the test proves less than it claims. A different FS, buffer size, or OS could silently break the stated invariant. I'd tie completeness to a known size from Stat (or make the decision explicit) rather than to when EOF arrives.
Separately, since the reader contract forbids wrapping io.EOF, errors.Is here is just a slower err == io.EOF on a per-Read hot path. wdyt?
| return cached.manifests, cached.err | ||
| } | ||
|
|
||
| manifests, err := snap.Manifests(c.manifestReadIO()) |
There was a problem hiding this comment.
The manifest-list bytes recorded here are never reused. manifestsFor caches the parsed []iceberg.ManifestFile and short-circuits on it, so every later call returns from the parse cache without re-reading the path, so the raw list bytes just sit in files for the life of the context.
I'd pass c.fs here instead of c.manifestReadIO(). Only the data/delete manifest Avro files get read by multiple validators and actually benefit from the byte cache; the manifest list is read exactly once per snapshot.
| if _, ok := needed[path]; ok { | ||
| delete(needed, path) | ||
| if len(needed) == 0 { | ||
| return nil |
There was a problem hiding this comment.
This early return abandons the manifest iteration, so mf.Entries closes with complete=false and the manifest never lands in the cache. validateDataFilesExist usually runs first in a RowDelta and hits this exit in the first manifest with a small needed set, then validateAddedDataFilesMatchingFilter re-opens the same manifest from the backing store.
So the "each manifest opened once" benefit only holds on full-read paths, not this common one. I'd either read the rest of the current manifest to EOF before returning, or note the limitation in the comment. wdyt?
| // within that attempt share the context and its cache. | ||
| ctx := &conflictContext{fs: baseContext.fs, concurrent: baseContext.concurrent} | ||
| for range validatorCount { | ||
| if err := ctx.forEachAddedEntry(iceberg.ManifestContentData, visit); err != nil { |
There was a problem hiding this comment.
The benchmark loops the same validator, so it only measures the full-read path where the cache pays off cleanly; that's where the 16->2 opens number comes from. The realistic RowDelta shape (validateDataFilesExist with an early exit, then validateAddedDataFilesMatchingFilter) is exactly the case where a manifest gets re-opened, and it's neither benchmarked nor tested.
A sub-benchmark for that composition, plus a test that runs validateDataFilesExist then validateAddedDataFilesMatchingFilter on one context and asserts data manifests open at most once, would keep the opened-once claim honest. wdyt?
07962c3 to
f2624ee
Compare
|
@fallintoplace conflicts need to be resolved |
f2624ee to
d920b2c
Compare
What changed
Why
A commit can run more than one conflict validator against the same
conflictContext. Each pass used to reopen the same manifest list and manifest file, which added extra object-store reads.The cache only lives for one validation attempt. Manifest entry decoding still runs with each descriptor, so inheritance behavior stays unchanged.
Benchmark
Run on an Apple M1 Pro with:
go test ./table -run '^$' -bench '^BenchmarkConflictValidationSharedManifestReads/(entries=(1000|10000))/(validators=(2|8))$' -benchmem -benchtime=1s -count=5These are medians over five runs. Before is
upstream/mainatc9813324. The backend metrics are per commit attempt. Local wall time is mostly Avro decoding and is more noisy.Testing
go test ./table -count=1go test ./table -race -count=1go vet ./tablego test ./... -run '^$'