spark-test commented on code in PR #59890:
URL: https://github.com/apache/spark/pull/59890#discussion_r4462516730
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/CacheManager.scala:
##########
@@ -634,26 +636,45 @@ class CacheManager extends Logging with
AdaptiveSparkPlanHelper {
fs: FileSystem,
includeTimeTravel: Boolean): Unit = {
val qualifiedPath = fs.makeQualified(resourcePath)
+ // recacheByCondition eagerly filters all entries before rebuilding any of
them, so an
+ // index shared across entries needs refreshing only once during this
call. Use object
+ // identity: equal root paths can have independent file-listing caches.
+ val refreshedIndexes = Collections.newSetFromMap(
+ new IdentityHashMap[FileIndex, java.lang.Boolean]())
recacheByCondition(
spark,
- _.plan.exists(lookupAndRefresh(_, fs, qualifiedPath, includeTimeTravel)))
+ cached => {
+ var refreshed = false
+ // lookupAndRefresh has side effects: exists would stop after the
first matching read.
+ // Refresh matching indexes in the operator tree before rebuilding a
multi-read cache.
+ // As before, this traversal does not enter plans held by subquery
expressions.
+ cached.plan.foreach { plan =>
Review Comment:
**Non-blocking (P2):** `TreeNode.foreach` follows only `children`, so this
walk never enters plans held by subquery expressions. In the normalized cache
key, `IN`, `EXISTS`, scalar, and lateral subquery plans stay inside
expressions, so their V1 `HadoopFsRelation` and V2 `FileTable` reads never
reach `lookupAndRefresh`: their indexes are not refreshed, and they cannot
select the entry for re-caching. The rebuild does not make up for it.
`tryRefreshPlan` and `V2TableRefreshUtil.refresh` reload only catalog-backed V2
tables, so these reads keep their existing index objects and file listings.
For example, with the default V1 parquet path:
```scala
spark.sql("SELECT * FROM parquet.`/d/a` WHERE id IN (SELECT id FROM
parquet.`/d/b`)").cache()
```
Add files under `/d/b` outside Spark's writer, as
`appendParquetWithoutRefresh` does, and call
`spark.catalog.refreshByPath("/d")`. The `/d/a` index is refreshed and the
entry is rebuilt, but the subquery keeps its old `/d/b` listing, so the cached
query silently misses the new rows. If files under `/d/b` were deleted instead,
reading the rebuilt cache fails with `FAILED_READ_FILE.FILE_NOT_EXIST`.
`refreshByPath("/d/b")`, or an append to `/d/b` through
`InsertIntoHadoopFsRelationCommand`, does not re-cache the entry at all. The
same two reads placed in a union are now refreshed correctly.
These reads hit the same stale-listing failure that this PR fixes for union
and join branches, and `refreshByPath` and `REFRESH` are documented to refresh
all cached data for any Dataset that contains the path. The previous round
offered documenting the narrower scope as one option, but documenting it still
leaves these cached queries returning stale rows.
**Recommended change:** In recacheByPath, walk each cached plan with
QueryPlan.foreachWithSubqueries instead of TreeNode.foreach. Keep the same
lookupAndRefresh call, the shared per-call refreshedIndexes set, and the
OR-accumulated match flag, and update the traversal comment so it no longer
says subquery plans are skipped. Add refreshByPath cache tests for file reads
inside subquery expressions, and update the PR description's scope statement to
match.
**Why this works:** foreachWithSubqueries applies the same function to each
node, then to the plans held by that node's subquery expressions, recursing
into their nodes. Every V1 HadoopFsRelation and V2 FileTable read in a cached
plan therefore reaches lookupAndRefresh. Each distinct index is still refreshed
once per call through refreshedIndexes, and a match inside a subquery selects
its entry exactly as an operator-tree match does. recacheByCondition rebuilds
selected entries around the same index objects, so the rebuilt plans read
refreshed listings in every branch and every subquery.
**Scope:** Extend the existing path-refresh traversal in CacheManager to
subquery plans, and add refreshByPath cache tests for reads held by subquery
expressions.
**Compatibility:** Entries with no matching read anywhere in the plan are
not rebuilt, unrelated indexes are never refreshed, each distinct index is
listed at most once per call, and time-travel relations stay excluded from
write-driven refresh.
**Risks:** After refreshByPath, REFRESH, or a V1 write under a path, cached
entries whose only read under that path sits inside a subquery expression are
now rebuilt. That is the intended correction, but it adds rebuild and
re-materialization work for those entries, and only reverting the traversal
change rolls it back. Every recacheByPath call, including the one after each V1
write, also walks the subquery plans of every cached entry. The added driver
work is one pattern match per subquery node, and only indexes under the path
are listed.
**Constraints:** Keep one per-call identity set shared by the operator tree
and subquery plans, so an index referenced in both is refreshed once per call.
Apply the time-travel exclusions to subquery-held reads exactly as to
operator-tree reads: includeTimeTravel, FileIndex.isTimeTravel, and the V2
relation's timeTravelSpec.
**Exclusions:**
- The children-only matching used by uncaching a table or view and by
re-caching by plan or by table name stays as it is: those operations refresh no
file listing and are separate from path-based refresh.
**Success:** After refreshByPath on a parent path, a cached query whose IN,
EXISTS, scalar, or lateral subquery reads files under that path through its own
index returns rows from the newly added files, including when an outer read is
also under the path. A cached entry whose only read under the refreshed or
written path sits inside a subquery expression is rebuilt and returns fresh
rows. Each distinct index is still refreshed at most once per call, and both
entry selection for operator-tree reads and the time-travel exclusions behave
as before.
**Verification:**
- **Regression:** Cache a query whose outer read and IN-subquery read are
separate reads under one parent directory, add files under both directories
outside Spark's writer, refresh the parent, and assert that the cached query
returns the complete fresh result; the same case must fail when the traversal
visits only operator children.
- **Regression:** Cache a query whose only read under the refreshed path is
inside a subquery expression, add files there outside Spark's writer, refresh
that path, and assert that the cached query returns fresh rows.
- **Behavior:** Run the subquery cases with both V1 and V2 file sources and
assert the analyzed relation kinds, and use a refresh-counting index to assert
that an index referenced both in the operator tree and in a subquery is
refreshed exactly once per call.
- **Compatibility:** Existing path-refresh coverage keeps passing:
multi-read refresh in the operator tree, partial matches, deduplication of
shared and of distinct equal indexes within one call, and exclusion of
time-travel relations from write-driven refresh.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/CacheManager.scala:
##########
@@ -672,19 +694,21 @@ class CacheManager extends Logging with
AdaptiveSparkPlanHelper {
/**
* Refresh the given [[FileIndex]] if one of its root paths is a
subdirectory of
* `qualifiedPath` and either `includeTimeTravel` is true or the index is
not an immutable
- * time-travel snapshot.
- * @return whether the [[FileIndex]] was refreshed.
+ * time-travel snapshot. Refresh each index at most once per recacheByPath
call.
+ * @return whether the [[FileIndex]] matches, including one already
refreshed during this call.
*/
private def refreshFileIndexIfNecessary(
fileIndex: FileIndex,
fs: FileSystem,
qualifiedPath: Path,
- includeTimeTravel: Boolean): Boolean = {
+ includeTimeTravel: Boolean,
+ refreshedIndexes: JSet[FileIndex]): Boolean = {
val needToRefresh = (includeTimeTravel || !fileIndex.isTimeTravel) &&
fileIndex.rootPaths
.map(_.makeQualified(fs.getUri, fs.getWorkingDirectory))
.exists(isSubDir(qualifiedPath, _))
- if (needToRefresh) fileIndex.refresh()
+ if (needToRefresh && refreshedIndexes.add(fileIndex)) fileIndex.refresh()
Review Comment:
**Nit (P3):** `needToRefresh` is computed before the set is consulted, so
every later occurrence of an index that this call has already refreshed
re-qualifies all of its root paths and walks `isSubDir` before `add()` returns
`false` and the refresh is skipped. An index enters `refreshedIndexes` only
after it matched, and `qualifiedPath`, `fs`, and `includeTimeTravel` are fixed
for the call, so membership already answers the question.
Within one plan this is new work. `df.union(df)`, or a self-join over one
read, now matches the same index once per occurrence, where `exists` stopped
after the first. Every other cached entry that shares the index repeats the
match as well. The cost is driver-side `Path` work. It is negligible for
single-path reads, but it grows with the root-path count of globbed or
multi-path reads. Results and refresh counts are unaffected.
Checking the set first gives the same results and matches the intent of the
comment below:
```scala
refreshedIndexes.contains(fileIndex) || {
val needToRefresh = (includeTimeTravel || !fileIndex.isTimeTravel) &&
fileIndex.rootPaths
.map(_.makeQualified(fs.getUri, fs.getWorkingDirectory))
.exists(isSubDir(qualifiedPath, _))
if (needToRefresh) {
refreshedIndexes.add(fileIndex)
fileIndex.refresh()
}
needToRefresh
}
```
The V2 `timeTravelSpec` guard in `lookupAndRefresh` still runs before this
check, and the built-in indexes keep the same `rootPaths` and `isTimeTravel`
across `refresh()`.
**Verification:**
- **Compatibility:** Existing path-refresh coverage keeps the same refresh
counts and results for an index repeated within one cached plan, distinct
indexes with equal root paths, an index shared by several cached entries, a
partially matching plan with an unrelated read, and time-travel relations
excluded from write-driven refresh.
- **Inspection:** The refreshed-index membership check runs before root-path
qualification and the isSubDir walk, so a repeated occurrence of an already
refreshed index performs no Path work.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]