Joel Robin created SPARK-59631:
----------------------------------

             Summary: Persisted V1 time-travel relations are recached after 
table writes
                 Key: SPARK-59631
                 URL: https://issues.apache.org/jira/browse/SPARK-59631
             Project: Spark
          Issue Type: Bug
          Components: SQL
    Affects Versions: 4.2.0, 4.1.0
            Reporter: Joel Robin


h2. Description

A persisted DataFrame reading a fixed table version through the V1 data source 
path is unnecessarily recached after a later write advances the live table.

The pinned snapshot is immutable, but recaching re-executes its entire plan. 
This can repeat expensive or non-idempotent UDFs even though the DataFrame 
remains registered as cached.

h2. Reproduction

Tested with unpatched Apache Spark 4.2.0, Scala 2.13, Java 17, and Delta Lake 
master commit {{052429d500f64f7b3d482f28092b2eb066d20cdb}}. Delta V2 mode is 
disabled to exercise the affected V1 relation path.

Compile and run the following standalone application with the local Spark and 
Delta classpath:

{code:scala}
import java.nio.file.Files
import java.util.UUID

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions.{col, udf}
import org.apache.spark.storage.StorageLevel

object DeltaTimeTravelCacheRepro {
  def main(args: Array[String]): Unit = {
    val root = Files.createTempDirectory("delta-time-travel-cache-repro-")
    val table = "tt_cache_" + UUID.randomUUID().toString.replace("-", "")

    val spark = SparkSession.builder()
      .appName("DeltaTimeTravelCacheRepro")
      .master("local[2]")
      .config("spark.ui.enabled", "false")
      .config("spark.sql.shuffle.partitions", "2")
      .config("spark.sql.warehouse.dir", 
root.resolve("warehouse").toUri.toString)
      .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
      .config(
        "spark.sql.catalog.spark_catalog",
        "org.apache.spark.sql.delta.catalog.DeltaCatalog")
      .config("spark.databricks.delta.v2.enableMode", "NONE")
      .getOrCreate()

    var cached: Option[org.apache.spark.sql.DataFrame] = None
    try {
      spark.range(10).write.format("delta").saveAsTable(table)

      val udfCalls = spark.sparkContext.longAccumulator("udf-calls")
      val expensiveUdf = udf { id: Long =>
        udfCalls.add(1L)
        s"$id-${UUID.randomUUID()}"
      }.asNondeterministic()

      val pinned = spark.read
        .option("versionAsOf", 0)
        .table(table)
        .withColumn("token", expensiveUdf(col("id")))
        .persist()
      cached = Some(pinned)

      // Materialize the cache before advancing the live table.
      val before = pinned.orderBy("id").select("token").collect().toSeq
      val callsBefore = udfCalls.value

      // This creates version 1. The pinned DataFrame still reads version 0.
      spark.range(10, 20)
        .write
        .format("delta")
        .mode("append")
        .saveAsTable(table)

      val after = pinned.orderBy("id").select("token").collect().toSeq
      val callsAfter = udfCalls.value
      val pinnedCount = pinned.count()
      val liveCount = spark.table(table).count()

      println(s"is_cached=${pinned.storageLevel != StorageLevel.NONE}")
      println(s"udf_calls=$callsBefore->$callsAfter")
      println(s"tokens_same=${before == after}")
      println(s"pinned_count=$pinnedCount")
      println(s"live_count=$liveCount")

      assert(callsBefore == 10)
      assert(callsAfter == callsBefore)
      assert(after == before)
      assert(pinnedCount == 10)
      assert(liveCount == 20)
    } finally {
      cached.foreach(_.unpersist(blocking = true))
      spark.sql(s"DROP TABLE IF EXISTS $table")
      spark.stop()
    }
  }
}
{code}

The nondeterministic Scala UDF is a stand-in for the reported pandas UDF that 
performs LLM calls. It isolates the cache invalidation behavior without 
requiring Python or Arrow.

h2. Actual Result

On unpatched Spark 4.2.0:

{noformat}
is_cached=true
udf_calls=10->20
tokens_same=false
pinned_count=10
live_count=20
{noformat}

The second action recomputes the cached plan and invokes the UDF again. The 
cache entry remains registered, which explains why the DataFrame still reports 
as cached while its materialized buffers have been discarded.

h2. Expected Result

{noformat}
is_cached=true
udf_calls=10->10
tokens_same=true
pinned_count=10
live_count=20
{noformat}

A write that advances the live table should refresh live-table cache entries 
but should not invalidate a cache entry for an immutable, version-pinned 
snapshot. Explicit operations such as {{REFRESH TABLE}}, {{refreshByPath}}, or 
uncache should retain their existing ability to invalidate pinned snapshots.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to