Core: Prevent stale CachingCatalog entries after commit - #17388
Conversation
280d84a to
270fd09
Compare
d5e60b9 to
70d751b
Compare
70d751b to
101c58a
Compare
bcebfcd to
4900433
Compare
Generated-by: Codex Co-authored-by: Humzah Kiani <humzah.kiani@affirm.com> Co-authored-by: Codex (GPT-5) <noreply@openai.com>
4900433 to
06f9a4a
Compare
|
This PR Description is not human friendly, I would strongly recommend summarizing it down to a paragraph or two to explain the problem and what you are doing to solve it. |
|
So the underlying issue here is that the CachingCatalog isn't actually racing, we are intentionally serving bounded-stale results in order to avoid a serializability problem. The basic problem is this:
So the CachingCatalog exists (partly) to avoid this scenario. By guaranteeing that for a certain amount of time (the cache TTL) we ignore changes made by other threads, we avoid ending up with the same table read at multiple points in time within a single query. The guarantee here is bounded staleness, not freshness, and that's a deliberate trade, not a bug. This is a structural problem in Spark that folks are still trying to fix even now. Because of this, I don't think we can "fix" this the way the PR proposes. Evicting or reconciling the entry on commit reintroduces exactly the two-snapshots-in-one-plan failure the cache exists to suppress; it isn't a narrower or safer variant of invalidate-on-commit. (Retaining the committing instance doesn't help here either, since callers re-read currentSnapshot() on every load.) For users who understand the risk and want always-fresh reads, they can disable the cache (cache-enabled=false) and have the catalog reload on every access. Notably, the Iceberg source itself and the incremental-query tests already set cache-enabled=false for this reason. |
I think this is totally a client's responsibility and client's choice. When to refresh "X" completely depends on the transaction semantics provided by the client. Say if client does "Read Committed", then they can refresh any time they want as long as they read the committed snapshots. And if the semantics is "Snapshot Read", then the snapshot "X" needs to remain unchanged for the duration of the transaction. To force a refresh on server side using a TTL seems wrong to me. |
Summary
BaseTablecache entry after a successful commit: retain the committing table, but remove a different table loaded while the write was in flightCommitStateUnknownExceptionChange size
+224/-14, net+210) inCachingCatalog+351/-7, net+344) inTestCachingCatalogRoot cause and fix
The stale-read sequence in #17338 is:
After a successful commit, this revision performs a same-key
compute:Caffeine orders this remapping with loads of the same key. If a pre-commit load is already in flight, the remapping waits for it to publish and then removes its result. If the remapping runs first, a later load begins after the delegate commit has completed and reads committed state.
The callback only compares object references and returns the same value or
null; it does not access or mutate another cache entry. Metadata-table invalidation runs after the callback. This avoids the nested cache-update pattern involved in #3791 and #3801.If reconciliation retains the exact committing table, its cached metadata tables also remain valid because they share that table's operations. If reconciliation removes an absent or different origin entry, metadata tables are evicted because they may be backed by stale replacement operations. Caffeine dispatches any removal-listener cascade after the underlying map remapping returns, so it is not nested in the mapping callback.
Retaining the committing table preserves identity for clients such as Spark's relation cache. Unconditional invalidation caused
TestCherrypickSnapshotProcedure.testCherrypickSnapshotRefreshesRelationCacheto keep serving the pre-cherry-pick cached result.Metadata-table publication
CachingCatalogfirst loads a raw metadata table, then separately loads the origin table and reconstructs the metadata table with the origin'sTableOperations. That reconstruction occurs after the original cache load. This revision publishes the reconstructed metadata table through a same-keycomputeonly if:The callback's only lookup is
getIfPresentQuietlyon the origin key. It may returnnullto remove its own metadata-table entry; that removal has causeEXPLICIT, while the removal listener only cascades onEXPIRED. It deliberately does not record a cache access, load, invalidate, or otherwise mutate another cache key.Scope
This focused PR installs commit tracking only when the loaded table's concrete class is exactly
BaseTable. Other table implementations keep their existing caching behavior and are outside this PR's commit-aware guarantee.Draft follow-up #17401 adds validated operations replacement for specialized table implementations, including
RESTTable, and defines the unsupported-subtype policy. Keeping that API decision separate lets maintainers review this correctness mechanism without simultaneously accepting a new public SPI.If a commit throws
CommitStateUnknownException, the commit may have landed. The wrapper therefore invalidates local state and best-effort invalidates the wrapped catalog before rethrowing the original exception. Failures from either invalidation are attached as suppressed rather than masking the commit-state exception. A local reconciliation failure after a successful commit is logged rather than changing a durable success into an apparent commit failure.Runtime behavior and tradeoffs
BaseTablethat successfully commits remains cached, preserving its identityoperations()on trackedBaseTableinstances returns the commit-tracking wrapper rather than the delegate operations' concrete runtime classRelated work
Validation
./gradlew :iceberg-core:test --tests org.apache.iceberg.hadoop.TestCachingCatalog./gradlew :iceberg-core:spotlessApplyCommitStateUnknownException, including failure of wrapped-catalog invalidationPart of #17338
AI Disclosure