fix(manifest): verify cached ManifestFile content before reusing it - #3991
ArulJerald wants to merge 1 commit into
Conversation
d5dfb69 to
3f53a70
Compare
|
Out of curiosity, do you know how this happens? The manifests should be immutable by definition: https://iceberg.apache.org/spec/#manifests |
|
@Fokko you are right manifest files is immutable. Why we have to cache manifest files: to avoid re-reading (re-parsing) the same object multiple times. Example: The catch: |
|
Thanks, @ArulJerald, for explaining the caching mechanism to me, and that we always have a cache miss of the manifest-list after a write. Let me elaborate on the question. Since the files are immutable, it should never happen that a specific path returns something different. If that would be the case, then the manifest file has been mutated, which voilates the constraint in the spec. |
|
Maybe we should take this dicussion to the issue level. |
|
I'm inclined to agree with Fokko that this isn't an issue. If it is, I don't think this would be the solution to the issue anyways. I would think the solution would be to some kind of |
| cached = self._cache.get(manifest_path) | ||
| # ManifestFile equality compares manifest_path only, so fall back to the | ||
| # full record to guard against a path collision with different content. | ||
| if cached is not None and cached._data == manifest_file._data: |
There was a problem hiding this comment.
This seems like a particular expensive computation for cache misses.
|
@Fokko to clarify — I'm not claiming a manifest mutates in place; that would indeed violate the spec, and nothing here contradicts it. The scenario is a process-wide cache with no table/catalog scoping: two unrelated ManifestFile objects (e.g. from different tables, or even two unrelated test fixtures that happen to reuse the same placeholder path) hitting the same cache key. Nothing was mutated — the cache just has no way to tell "same manifest, reused correctly" apart from "different manifest, coincidentally same path string," because the key is only ever a bare path. @rambleraptor your point on the cost is fair, and thinking about it more, scoping the cache key by catalog, table (or table UUID/location) is a better fix than the content-comparison approach in this PR — it prevents the collision structurally instead of detecting it after the fact, and it doesn't add cost to the common hit path. I'll rework the PR to key on (table_identifier, manifest_path) instead and drop the equality check unless there's a reason to keep it as a cheap defensive assertion. Let me know if that direction sounds right before I push the change. |
|
I think that's the better path, but I'm not convinced this is fully necessary. Why don't you push up that change and everyone can discuss it? |
|
@rambleraptor - Sure ! that sounds make sense! |
|
Rewrote this per the discussion — the cache key is now (table_uuid, manifest_path) instead of a content comparison, and the equality-check has been removed entirely. @rambleraptor this directly addresses the cost concern: a genuine cache hit is back to a plain O(1) tuple lookup, no comparison against the cached record. I benchmarked it against an append on a table with ~2000 snapshots — identical number of metadata rebuilds before and after (22 vs 22), so this adds no measurable overhead on the write path. @Fokko to clarify the framing — I'm not claiming a manifest mutates in place; that would violate the spec and nothing here contradicts it. The actual scenario is a process-wide cache with no attribution to a table: two unrelated ManifestFile reads (different tables, or even two unrelated test fixtures reusing a placeholder path) landing on the same cache key. Nothing was mutated; the cache just had no way to tell "same manifest, correctly reused" apart from "different manifest, coincidentally same path," because the key was a bare path with no owner attached. Also: a caller that doesn't supply a table_uuid (e.g. any external code still on the old two-arg Snapshot.manifests(io) signature) now bypasses the cache entirely rather than falling into a shared unattributed bucket — so there's no remaining pathway for two tables to share an entry, attributed or not. One caveat worth flagging: table-uuid is optional in format v1, so a legacy v1 table without one gets a fresh UUID generated on every metadata parse (via default_factory) rather than None — it's never unattributed, just unstable across reloads. That means caching doesn't carry over across a refresh()/reload for that specific table, but it can never collide with another table's entries either way. Correctness-safe, just a smaller caching win for that edge case. |
…rison Closes apache#3978. Per feedback from Fokko and rambleraptor, a manifest_path collision across genuinely different manifests should not be detected and repaired after the fact -- it should be structurally impossible. Key the process-wide cache on (table_uuid, manifest_path) instead of path alone: table_uuid is stable for a table's lifetime and unique across tables, so two tables can never share an entry regardless of what their manifest paths look like, and a genuine cache hit costs a plain O(1) lookup instead of a full record comparison. A caller that omits table_uuid (e.g. any external code still on the pre-fix Snapshot.manifests(io) signature) bypasses the cache entirely rather than falling into a shared unattributed bucket, so there is no remaining path for two tables to share an
e681fb0 to
7a51aea
Compare
The process-wide
ManifestFilecache was keyed onmanifest_pathalone, andManifestFileequality compares only that path, so the first read of a path won and any later read of the same path returned the earlier object. Two tables referencing one path therefore shared a singleManifestFile, and since its partition summaries, counts and sequence numbers drive scan pruning, a mismatched entry changed which files a scan considered.Closes #3978
Rationale for this change
The cache exists to avoid retaining duplicate objects when consecutive snapshots reference the same manifests, which is a memory optimisation worth keeping. The problem is only that a path match was treated as proof the content matched.
get_or_cachenow compares the full record before reusing a cached instance — falling back toRecord's structural equality, sinceManifestFile.__eq__is intentionally path-only — and replaces the entry when the content differs. Genuine reuse still hits the cache; only a real collision takes the slow path.The added comparison costs up to roughly 16% of what constructing the record already costs on the same path (measured at 20 partition summaries: 3.3 µs against 20.8 µs), and is negligible for unpartitioned manifests. That seemed a fair price for not serving stale pruning metadata.
Are these changes tested?
Yes.
test_manifest_cache_detects_path_collision_with_different_contentintests/utils/test_manifest.pycaches oneManifestFile, then callsget_or_cachewith a second sharing itsmanifest_pathbut carrying a differentadded_snapshot_idandexisting_files_count, and asserts the second call returns the second object's content rather than the stale first. It then confirms a third call with content identical to what is cached still returns the cached instance, so the deduplication fast path is covered too.The test fails without this change and passes with it. Full file: 45 passed.
Are there any user-facing changes?
No. Scans that previously read mismatched manifest metadata for a colliding path now read the correct metadata; there is no API change.