This is an automated email from the ASF dual-hosted git repository.

anishshri-db pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 3bf13f1fecde [SPARK-57269][SS] Enforce read-only access in the 
StateDateSource/StateMetadataSource
3bf13f1fecde is described below

commit 3bf13f1fecdebeaba58cee1f2432a1e8c4ce2bb4
Author: liviazhu <[email protected]>
AuthorDate: Fri Jul 24 11:26:47 2026 -0700

    [SPARK-57269][SS] Enforce read-only access in the 
StateDateSource/StateMetadataSource
    
    ### What changes were proposed in this pull request?
    
    Keep the state data source read path from writing to the checkpoint, so 
reads work on
    read-only storage.
    
    - `StatePartitionReader` / `StatePartitionAllColumnFamiliesReader`: use 
`getReadStore` +
      `release()` instead of `getStore` + `abort()`.
    - `StreamStreamJoinStatePartitionReader`: build 
`SymmetricHashJoinStateManager` with
      `readOnly = true`. The handler exposes a writable `stateStore` (guarded 
by `require(!readOnly)`)
      and a mode-aware `readStateStore`; reads use `readStateStore`, 
`abortIfNeeded` calls `release()`.
    - `HDFSBackedStateStoreProvider`: defer `baseDir` mkdirs to the first 
write; guard `release()`;
      reject `readOnly = true` in `replayStateFromSnapshot` (use 
`replayReadStateFromSnapshot`).
    - `ReadStateStore.registerColFamily`: read-safe column-family registration 
(in-memory id +
      encoders, no forced snapshot), split from the writable 
`StateStore.createColFamilyIfAbsent`.
    
    Tests use `WriteProtectedLocalFileSystem` / 
`WriteProtectedAbstractFileSystem` (covering the
    `FileSystem` and `FileContext` write paths) via 
`WriteProtectedCheckpointTestMixin`, which
    auto-protects every `withTempDir`; `testStream` / `withWritableCheckpoint` 
lift protection for
    real writes. Mixed into `StateDataSourceTestBase` and 
`OperatorStateMetadataSuite`.
    
    ### Why are the changes needed?
    
    Reads previously issued writes (mkdirs, changelog files) to the checkpoint, 
failing on read-only
    storage.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    `StateDataSource{Read,ChangeDataRead,TransformWithState}Suite`,
    `StatePartitionAllColumnFamilies{Reader,Writer}Suite`, 
`StateDataSourceNoEmptyDirCreationSuite`,
    `OfflineStateRepartitionIntegrationSuite`, `OperatorStateMetadataSuite`, 
`RocksDBStateStoreSuite`,
    `StateStoreSuite`, `StreamingJoinV4Suite`. Added framework-sanity and 
read-only enforcement
    tests; verified the framework catches an injected `mkdir` on the read path.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, with Claude (Claude Code).
    
    Closes #56332 from 
liviazhu/liviazhu-db/SPARK-57225-state-datasource-read-only.
    
    Authored-by: liviazhu <[email protected]>
    Signed-off-by: Anish Shrigondekar <[email protected]>
    (cherry picked from commit 3c2f9ef1cf40de7d037a3c6776e06272a01c5e1d)
    Signed-off-by: Anish Shrigondekar <[email protected]>
---
 .../v2/state/StatePartitionReader.scala            |  27 +-
 .../StreamStreamJoinStatePartitionReader.scala     |   7 +-
 .../join/SymmetricHashJoinStateManager.scala       | 248 +++++++++++----
 .../state/HDFSBackedStateStoreProvider.scala       |  38 ++-
 .../sql/execution/streaming/state/RocksDB.scala    |  10 +-
 .../state/RocksDBStateStoreProvider.scala          |  27 +-
 .../sql/execution/streaming/state/StateStore.scala |  32 ++
 .../state/StateDataSourceChangeDataReadSuite.scala |  42 ++-
 .../v2/state/StateDataSourceReadSuite.scala        | 135 ++++++--
 .../v2/state/StateDataSourceTestBase.scala         |   9 +-
 .../StateDataSourceTransformWithStateSuite.scala   |  78 ++++-
 .../v2/state/WriteProtectedLocalFileSystem.scala   | 348 +++++++++++++++++++++
 .../OfflineStateRepartitionIntegrationSuite.scala  |   6 +-
 .../state/OperatorStateMetadataSuite.scala         |  60 ++--
 ...tatePartitionAllColumnFamiliesWriterSuite.scala |  41 +--
 .../streaming/state/StateStoreSuite.scala          |  25 ++
 16 files changed, 965 insertions(+), 168 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/state/StatePartitionReader.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/state/StatePartitionReader.scala
index 2f7d9ab2d8c7..268d03c6b805 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/state/StatePartitionReader.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/state/StatePartitionReader.scala
@@ -97,6 +97,11 @@ class StatePartitionReaderFactory(
 /**
  * An implementation of [[PartitionReader]] for State data source. This is 
used to support
  * general read from a state store instance, rather than specific to the 
operator.
+ *
+ * NOTE: The state data source is strictly read-only. Any new reader added 
here must open the
+ * state store in read-only mode (via [[StateStoreProvider.getReadStore]] or
+ * [[SupportsFineGrainedReplay.replayReadStateFromSnapshot]]) so the read path 
never writes to
+ * the checkpoint and does not require write access to it.
  */
 abstract class StatePartitionReaderBase(
     storeConf: StateStoreConf,
@@ -167,7 +172,9 @@ abstract class StatePartitionReaderBase(
       useMultipleValuesPerKey = useMultipleValuesPerKey, 
stateSchemaProviderOpt)
 
     if (useColFamilies) {
-      val store = provider.getStore(
+      // Register the column family on a read-only store so the read path does 
not write
+      // to the checkpoint (column-family registration is an in-memory 
operation).
+      val readStore = provider.getReadStore(
         partition.sourceOptions.batchId + 1,
         getEndStoreUniqueId)
       require(stateStoreColFamilySchemaOpt.isDefined)
@@ -176,14 +183,14 @@ abstract class StatePartitionReaderBase(
         StateStoreColumnFamilySchemaUtils.isTestingInternalColFamily(
           stateStoreColFamilySchema.colFamilyName)
       require(stateStoreColFamilySchema.keyStateEncoderSpec.isDefined)
-      store.createColFamilyIfAbsent(
+      readStore.registerColFamily(
         stateStoreColFamilySchema.colFamilyName,
         stateStoreColFamilySchema.keySchema,
         stateStoreColFamilySchema.valueSchema,
         stateStoreColFamilySchema.keyStateEncoderSpec.get,
         useMultipleValuesPerKey = useMultipleValuesPerKey,
         isInternal = isInternal)
-      store.abort()
+      readStore.release()
     }
     provider
   }
@@ -364,7 +371,7 @@ class StatePartitionAllColumnFamiliesReader(
 
   private def checkAllColFamiliesExist(
       colFamilyNames: List[String],
-      stateStore: StateStore
+      stateStore: ReadStateStore
     ): Unit = {
     // Filter out DEFAULT column family from validation for two reasons:
     // 1. Some operators (e.g., stream-stream join v3) don't include DEFAULT 
in their schema
@@ -385,13 +392,11 @@ class StatePartitionAllColumnFamiliesReader(
         s"Column families in state store but not in metadata: 
${expectedCFs.diff(actualCFs)}")
   }
 
-  // Use a single store instance for both registering column families and 
iteration.
-  // We cannot abort and then get a read store because abort() invalidates the 
loaded version,
-  // causing getReadStore() to reload from checkpoint and clear the column 
family registrations.
-  private lazy val store: StateStore = {
+  // Use a single read-mode store instance for both registering column 
families and iteration.
+  private lazy val store: ReadStateStore = {
     assert(getStartStoreUniqueId == getEndStoreUniqueId,
       "Start and end store unique IDs must be the same when reading all column 
families")
-    val stateStore = provider.getStore(
+    val stateStore = provider.getReadStore(
       partition.sourceOptions.batchId + 1,
       getStartStoreUniqueId
     )
@@ -408,7 +413,7 @@ class StatePartitionAllColumnFamiliesReader(
             val useMultipleValuesPerKey = 
isMultiValuedCF(cfSchema.colFamilyName)
             require(cfSchema.keyStateEncoderSpec.isDefined,
               s"keyStateEncoderSpec must be defined for column family 
${cfSchema.colFamilyName}")
-            stateStore.createColFamilyIfAbsent(
+            stateStore.registerColFamily(
               cfSchema.colFamilyName,
               cfSchema.keySchema,
               cfSchema.valueSchema,
@@ -444,7 +449,7 @@ class StatePartitionAllColumnFamiliesReader(
   }
 
   override def close(): Unit = {
-    store.abort()
+    store.release()
     super.close()
   }
 }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/state/StreamStreamJoinStatePartitionReader.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/state/StreamStreamJoinStatePartitionReader.scala
index 471ebc6fa8a6..9ed1460e2dd3 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/state/StreamStreamJoinStatePartitionReader.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/state/StreamStreamJoinStatePartitionReader.scala
@@ -50,6 +50,10 @@ class StreamStreamJoinStatePartitionReaderFactory(
 /**
  * An implementation of [[PartitionReader]] for State Store data source, 
specifically to read
  * the partition for the state from stream-stream join.
+ *
+ * NOTE: The state data source is strictly read-only. Any new reader added 
here must open the
+ * state store in read-only mode. For the join reader, this means constructing 
the
+ * [[SymmetricHashJoinStateManager]] with readOnly = true.
  */
 class StreamStreamJoinStatePartitionReader(
     storeConf: StateStoreConf,
@@ -184,7 +188,8 @@ class StreamStreamJoinStatePartitionReader(
             startKeyWithIndexToValueStateStoreCkptId = 
startKeyWithIndexToValueStateStoreCkptId,
             endKeyToNumValuesStateStoreCkptId = 
endKeyToNumValuesStateStoreCkptId,
             endKeyWithIndexToValueStateStoreCkptId = 
endKeyWithIndexToValueStateStoreCkptId)),
-        joinStoreGenerator = new JoinStateManagerStoreGenerator()
+        joinStoreGenerator = new JoinStateManagerStoreGenerator(),
+        readOnly = true
       )
     }
 
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/join/SymmetricHashJoinStateManager.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/join/SymmetricHashJoinStateManager.scala
index ea8fc861b104..97878b33c778 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/join/SymmetricHashJoinStateManager.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/join/SymmetricHashJoinStateManager.scala
@@ -32,7 +32,7 @@ import 
org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
 import org.apache.spark.sql.execution.metric.SQLMetric
 import 
org.apache.spark.sql.execution.streaming.operators.stateful.{StatefulOperatorStateInfo,
 StatefulOpStateStoreCheckpointInfo, WatermarkSupport}
 import 
org.apache.spark.sql.execution.streaming.operators.stateful.join.StreamingSymmetricHashJoinHelper._
-import 
org.apache.spark.sql.execution.streaming.state.{DropLastNFieldsStatePartitionKeyExtractor,
 KeyStateEncoderSpec, NoopStatePartitionKeyExtractor, 
NoPrefixKeyStateEncoderSpec, RangeScanBoundaryUtils, 
StatePartitionKeyExtractor, StateSchemaBroadcast, StateStore, 
StateStoreCheckpointInfo, StateStoreColFamilySchema, StateStoreConf, 
StateStoreErrors, StateStoreId, StateStoreMetrics, StateStoreProvider, 
StateStoreProviderId, SupportsFineGrainedReplay, 
TimestampAsPostfixKeyStateEncoderSpec [...]
+import 
org.apache.spark.sql.execution.streaming.state.{DropLastNFieldsStatePartitionKeyExtractor,
 KeyStateEncoderSpec, NoopStatePartitionKeyExtractor, 
NoPrefixKeyStateEncoderSpec, RangeScanBoundaryUtils, ReadStateStore, 
StatePartitionKeyExtractor, StateSchemaBroadcast, StateStore, 
StateStoreCheckpointInfo, StateStoreColFamilySchema, StateStoreConf, 
StateStoreErrors, StateStoreId, StateStoreMetrics, StateStoreProvider, 
StateStoreProviderId, SupportsFineGrainedReplay, TimestampAsPostfixKey [...]
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.{BooleanType, DataType, LongType, NullType, 
StructField, StructType}
 import org.apache.spark.util.NextIterator
@@ -239,7 +239,8 @@ class SymmetricHashJoinStateManagerV4(
     useStateStoreCoordinator: Boolean = true,
     snapshotOptions: Option[SnapshotOptions] = None,
     joinStoreGenerator: JoinStateManagerStoreGenerator,
-    joinKeyOrdinalForWatermark: Option[Int] = None)
+    joinKeyOrdinalForWatermark: Option[Int] = None,
+    readOnly: Boolean = false)
   extends SymmetricHashJoinStateManager with SupportsEvictByTimestamp with 
Logging {
 
   // TODO: [SPARK-55729] Once the new state manager is integrated to 
stream-stream join operator,
@@ -319,23 +320,69 @@ class SymmetricHashJoinStateManagerV4(
 
   private var stateStoreProvider: StateStoreProvider = _
 
+  private var _stateStore: StateStore = _
+  private var _readStateStore: ReadStateStore = _
+
+  /**
+   * Writable store. Access fails fast when in read-only mode.
+   */
+  private def stateStore: StateStore = {
+    require(!readOnly,
+      "Writable stateStore is not available on a read-only V4 join state 
manager")
+    _stateStore
+  }
+
+  /** Read view of the loaded store. Safe to use in either mode. */
+  private def readStateStore: ReadStateStore =
+    if (readOnly) _readStateStore else _stateStore
+
+  /**
+   * Register a column family on the loaded store. In read-only mode this is 
the read-safe
+   * in-memory registration ([[ReadStateStore.registerColFamily]]); in write 
mode it is the
+   * writable [[StateStore.createColFamilyIfAbsent]] (which forces a snapshot 
for a new family).
+   */
+  private def registerColFamily(
+      colFamilyName: String,
+      keySchema: StructType,
+      valueSchema: StructType,
+      keyStateEncoderSpec: KeyStateEncoderSpec,
+      useMultipleValuesPerKey: Boolean = false,
+      isInternal: Boolean = false): Unit = {
+    if (readOnly) {
+      readStateStore.registerColFamily(colFamilyName, keySchema, valueSchema,
+        keyStateEncoderSpec, useMultipleValuesPerKey, isInternal)
+    } else {
+      stateStore.createColFamilyIfAbsent(colFamilyName, keySchema, valueSchema,
+        keyStateEncoderSpec, useMultipleValuesPerKey, isInternal)
+    }
+  }
+
   // We will use the dummy schema for the default CF since we will register CF 
separately.
-  private val stateStore = getStateStore(
+  initializeStateStore(
     dummySchema, dummySchema, useVirtualColumnFamilies = true,
     NoPrefixKeyStateEncoderSpec(dummySchema), useMultipleValuesPerKey = false
   )
 
-  private def getStateStore(
+  /**
+   * Load the state store and populate `_stateStore` (when !readOnly) or 
`_readStateStore`
+   * (when readOnly). Subclasses invoke this for the side effect during 
construction;
+   * afterwards, the [[stateStore]] / [[readStateStore]] accessors return the 
right view.
+   */
+  private def initializeStateStore(
       keySchema: StructType,
       valueSchema: StructType,
       useVirtualColumnFamilies: Boolean,
       keyStateEncoderSpec: KeyStateEncoderSpec,
-      useMultipleValuesPerKey: Boolean): StateStore = {
+      useMultipleValuesPerKey: Boolean): Unit = {
     val storeName = StateStoreId.DEFAULT_STORE_NAME
     val storeProviderId = StateStoreProviderId(stateInfo.get, partitionId, 
storeName)
-    val store = if (useStateStoreCoordinator) {
+    val handle: ReadStateStore = if (useStateStoreCoordinator) {
       assert(handlerSnapshotOptions.isEmpty, "Should not use state store 
coordinator " +
         "when reading state as data source.")
+      // JoinStateManagerStoreGenerator only exposes a writable getStore. 
Read-only mode is
+      // only ever used with useStateStoreCoordinator=false (data source 
reads).
+      require(!readOnly,
+        "readOnly mode is not supported with useStateStoreCoordinator=true")
       joinStoreGenerator.getStore(
         storeProviderId, keySchema, valueSchema, keyStateEncoderSpec,
         stateInfo.get.storeVersion, stateStoreCkptId, None, 
useVirtualColumnFamilies,
@@ -348,24 +395,43 @@ class SymmetricHashJoinStateManagerV4(
         storeConf, hadoopConf, useMultipleValuesPerKey = 
useMultipleValuesPerKey,
         stateSchemaProvider = None)
       if (handlerSnapshotOptions.isDefined) {
+        // Fine-grained snapshot replay (state data source with 
snapshotStartBatchId).
         if (!stateStoreProvider.isInstanceOf[SupportsFineGrainedReplay]) {
           throw 
StateStoreErrors.stateStoreProviderDoesNotSupportFineGrainedReplay(
             stateStoreProvider.getClass.toString)
         }
         val opts = handlerSnapshotOptions.get
-        stateStoreProvider.asInstanceOf[SupportsFineGrainedReplay]
-          .replayStateFromSnapshot(
+        val replayer = 
stateStoreProvider.asInstanceOf[SupportsFineGrainedReplay]
+        if (readOnly) {
+          replayer.replayReadStateFromSnapshot(
+            opts.snapshotVersion,
+            opts.endVersion,
+            opts.startStateStoreCkptId,
+            opts.endStateStoreCkptId)
+        } else {
+          replayer.replayStateFromSnapshot(
             opts.snapshotVersion,
             opts.endVersion,
-            readOnly = true,
+            readOnly = false,
             opts.startStateStoreCkptId,
             opts.endStateStoreCkptId)
+        }
       } else {
-        stateStoreProvider.getStore(stateInfo.get.storeVersion, 
stateStoreCkptId)
+        // No snapshot options: open the store at its committed version 
normally.
+        if (readOnly) {
+          stateStoreProvider.getReadStore(stateInfo.get.storeVersion, 
stateStoreCkptId)
+        } else {
+          stateStoreProvider.getStore(stateInfo.get.storeVersion, 
stateStoreCkptId)
+        }
       }
     }
-    logInfo(log"Loaded store ${MDC(STATE_STORE_ID, store.id)}")
-    store
+    if (readOnly) {
+      _readStateStore = handle
+      logInfo(log"Loaded read-only store ${MDC(STATE_STORE_ID, handle.id)}")
+    } else {
+      _stateStore = handle.asInstanceOf[StateStore]
+      logInfo(log"Loaded store ${MDC(STATE_STORE_ID, handle.id)}")
+    }
   }
 
   private val keyWithTsToValues = new KeyWithTsToValuesStore
@@ -577,7 +643,9 @@ class SymmetricHashJoinStateManagerV4(
   }
 
   override def abortIfNeeded(): Unit = {
-    if (!stateStore.hasCommitted) {
+    if (readOnly) {
+      readStateStore.release()
+    } else if (!stateStore.hasCommitted) {
       logInfo(log"Aborted store ${MDC(STATE_STORE_ID, stateStore.id)}")
       stateStore.abort()
     }
@@ -626,8 +694,7 @@ class SymmetricHashJoinStateManagerV4(
     private val attachTimestampProjection: UnsafeProjection =
       TimestampKeyStateEncoder.getAttachTimestampProjection(keySchema)
 
-    // Create the specific column family in the store for this join side's 
KeyWithTsToValuesStore.
-    stateStore.createColFamilyIfAbsent(
+    registerColFamily(
       colFamilyName,
       keySchema,
       valueRowConverter.valueAttributes.toStructType,
@@ -659,7 +726,7 @@ class SymmetricHashJoinStateManagerV4(
     }
 
     def get(key: UnsafeRow, timestamp: Long): Iterator[ValueAndMatchPair] = {
-      stateStore.valuesIterator(createKeyRow(key, timestamp), 
colFamilyName).map { valueRow =>
+      readStateStore.valuesIterator(createKeyRow(key, timestamp), 
colFamilyName).map { valueRow =>
         valueRowConverter.convertValue(valueRow)
       }
     }
@@ -700,9 +767,9 @@ class SymmetricHashJoinStateManagerV4(
           val startKey = createKeyRow(key, minTs).copy()
           // rangeScanWithMultiValues endKey is exclusive, so use maxTs + 1
           val endKey = Some(createKeyRow(key, maxTs + 1))
-          stateStore.rangeScanWithMultiValues(Some(startKey), endKey, 
colFamilyName)
+          readStateStore.rangeScanWithMultiValues(Some(startKey), endKey, 
colFamilyName)
         } else {
-          stateStore.prefixScanWithMultiValues(key, colFamilyName)
+          readStateStore.prefixScanWithMultiValues(key, colFamilyName)
         }
 
         private var currentTs = -1L
@@ -763,7 +830,7 @@ class SymmetricHashJoinStateManagerV4(
     }
 
     def iterator(): Iterator[KeyAndTsToValuePair] = {
-      val iter = stateStore.iteratorWithMultiValues(colFamilyName)
+      val iter = readStateStore.iteratorWithMultiValues(colFamilyName)
       val reusableKeyAndTsToValuePair = KeyAndTsToValuePair()
       iter.map { kv =>
         val keyRow = detachTimestampProjection(kv.key)
@@ -800,9 +867,8 @@ class SymmetricHashJoinStateManagerV4(
     private val attachTimestampProjection: UnsafeProjection =
       TimestampKeyStateEncoder.getAttachTimestampProjection(keySchema)
 
-    // Create the specific column family in the store for this join side's 
TsWithKeyStore.
     // Mark as internal so that numKeys counts only primary data, not the 
secondary index.
-    stateStore.createColFamilyIfAbsent(
+    registerColFamily(
       colFamilyName,
       keySchema,
       valueStructType,
@@ -881,7 +947,8 @@ class SymmetricHashJoinStateManagerV4(
       } else {
         None
       }
-      val evictIterator = stateStore.rangeScanWithMultiValues(startKeyRow, 
endKeyRow, colFamilyName)
+      val evictIterator =
+        readStateStore.rangeScanWithMultiValues(startKeyRow, endKeyRow, 
colFamilyName)
       new NextIterator[EvictedKeysResult]() {
         var currentKeyRow: UnsafeRow = null
         var currentEventTime: Long = -1L
@@ -1025,7 +1092,8 @@ abstract class SymmetricHashJoinStateManagerBase(
     skippedNullValueCount: Option[SQLMetric] = None,
     useStateStoreCoordinator: Boolean = true,
     snapshotOptions: Option[SnapshotOptions] = None,
-    joinStoreGenerator: JoinStateManagerStoreGenerator)
+    joinStoreGenerator: JoinStateManagerStoreGenerator,
+    readOnly: Boolean = false)
   extends SymmetricHashJoinStateManager
   with SupportsEvictByCondition
   with SupportsIndexedKeys
@@ -1522,8 +1590,43 @@ abstract class SymmetricHashJoinStateManagerBase(
       handlerSnapshotOptions: Option[HandlerSnapshotOptions] = None) extends 
Logging {
     private var stateStoreProvider: StateStoreProvider = _
 
-    /** StateStore that the subclasses of this class is going to operate on */
-    protected def stateStore: StateStore
+    // Exactly one is populated by initializeStateStore -- readOnly decides 
which.
+    private var _stateStore: StateStore = _
+    private var _readStateStore: ReadStateStore = _
+
+    /**
+     * Writable store. Access fails fast when in read-only mode.
+     */
+    protected def stateStore: StateStore = {
+      require(!readOnly,
+        "Writable stateStore is not available on a read-only state store 
handler")
+      _stateStore
+    }
+
+    /** Read view of the loaded store. Safe to use in either mode. */
+    protected def readStateStore: ReadStateStore =
+      if (readOnly) _readStateStore else _stateStore
+
+    /**
+     * Register a column family on the loaded store. In read-only mode this is 
the read-safe
+     * in-memory registration ([[ReadStateStore.registerColFamily]]); in write 
mode it is the
+     * writable [[StateStore.createColFamilyIfAbsent]] (which forces a 
snapshot for a new family).
+     */
+    protected def registerColFamily(
+        colFamilyName: String,
+        keySchema: StructType,
+        valueSchema: StructType,
+        keyStateEncoderSpec: KeyStateEncoderSpec,
+        useMultipleValuesPerKey: Boolean = false,
+        isInternal: Boolean = false): Unit = {
+      if (readOnly) {
+        readStateStore.registerColFamily(colFamilyName, keySchema, valueSchema,
+          keyStateEncoderSpec, useMultipleValuesPerKey, isInternal)
+      } else {
+        stateStore.createColFamilyIfAbsent(colFamilyName, keySchema, 
valueSchema,
+          keyStateEncoderSpec, useMultipleValuesPerKey, isInternal)
+      }
+    }
 
     def commit(): Unit = {
       stateStore.commit()
@@ -1531,7 +1634,9 @@ abstract class SymmetricHashJoinStateManagerBase(
     }
 
     def abortIfNeeded(): Unit = {
-      if (!stateStore.hasCommitted) {
+      if (readOnly) {
+        readStateStore.release()
+      } else if (!stateStore.hasCommitted) {
         logInfo(log"Aborted store ${MDC(STATE_STORE_ID, stateStore.id)}")
         stateStore.abort()
       }
@@ -1544,24 +1649,33 @@ abstract class SymmetricHashJoinStateManagerBase(
 
     def metrics: StateStoreMetrics = stateStore.metrics
 
-    def getLatestCheckpointInfo(): StateStoreCheckpointInfo = {
+    def getLatestCheckpointInfo(): StateStoreCheckpointInfo =
       stateStore.getStateStoreCheckpointInfo()
-    }
 
-    /** Get the StateStore with the given schema */
-    protected def getStateStore(
+    /**
+     * Load the state store for this handler and populate `_stateStore` (when 
!readOnly)
+     * or `_readStateStore` (when readOnly). Subclasses invoke this for the 
side effect
+     * during construction; afterwards, the [[stateStore]] / 
[[readStateStore]] accessors
+     * return the right view based on mode.
+     */
+    protected def initializeStateStore(
         keySchema: StructType,
         valueSchema: StructType,
-        useVirtualColumnFamilies: Boolean): StateStore = {
+        useVirtualColumnFamilies: Boolean): Unit = {
       val storeName = if (useVirtualColumnFamilies) {
         StateStoreId.DEFAULT_STORE_NAME
       } else {
         getStateStoreName(joinSide, stateStoreType)
       }
       val storeProviderId = StateStoreProviderId(stateInfo.get, partitionId, 
storeName)
-      val store = if (useStateStoreCoordinator) {
+      val handle: ReadStateStore = if (useStateStoreCoordinator) {
         assert(handlerSnapshotOptions.isEmpty, "Should not use state store 
coordinator " +
           "when reading state as data source.")
+        // JoinStateManagerStoreGenerator only exposes a writable getStore. 
Read-only
+        // mode is only ever used with useStateStoreCoordinator=false (data 
source reads),
+        // so this branch must not be reached with readOnly=true.
+        require(!readOnly,
+          "readOnly mode is not supported with useStateStoreCoordinator=true")
         joinStoreGenerator.getStore(
           storeProviderId, keySchema, valueSchema, 
NoPrefixKeyStateEncoderSpec(keySchema),
           stateInfo.get.storeVersion, stateStoreCkptId, None, 
useVirtualColumnFamilies,
@@ -1573,24 +1687,44 @@ abstract class SymmetricHashJoinStateManagerBase(
           useColumnFamilies = useVirtualColumnFamilies, storeConf, hadoopConf,
           useMultipleValuesPerKey = false, stateSchemaProvider = None)
         if (handlerSnapshotOptions.isDefined) {
+          // Fine-grained snapshot replay (state data source with 
snapshotStartBatchId).
           if (!stateStoreProvider.isInstanceOf[SupportsFineGrainedReplay]) {
             throw 
StateStoreErrors.stateStoreProviderDoesNotSupportFineGrainedReplay(
               stateStoreProvider.getClass.toString)
           }
           val opts = handlerSnapshotOptions.get
-          stateStoreProvider.asInstanceOf[SupportsFineGrainedReplay]
-            .replayStateFromSnapshot(
+          val replayer = 
stateStoreProvider.asInstanceOf[SupportsFineGrainedReplay]
+          if (readOnly) {
+            replayer.replayReadStateFromSnapshot(
               opts.snapshotVersion,
               opts.endVersion,
-              readOnly = true,
               opts.startStateStoreCkptId,
               opts.endStateStoreCkptId)
+          } else {
+            replayer.replayStateFromSnapshot(
+              opts.snapshotVersion,
+              opts.endVersion,
+              readOnly = false,
+              opts.startStateStoreCkptId,
+              opts.endStateStoreCkptId)
+          }
         } else {
-          stateStoreProvider.getStore(stateInfo.get.storeVersion, 
stateStoreCkptId)
+          // No snapshot options: open the store at its committed version 
normally.
+          // readOnly mode must not write to the checkpoint path; use the 
read-only store.
+          if (readOnly) {
+            stateStoreProvider.getReadStore(stateInfo.get.storeVersion, 
stateStoreCkptId)
+          } else {
+            stateStoreProvider.getStore(stateInfo.get.storeVersion, 
stateStoreCkptId)
+          }
         }
       }
-      logInfo(log"Loaded store ${MDC(STATE_STORE_ID, store.id)}")
-      store
+      if (readOnly) {
+        _readStateStore = handle
+        logInfo(log"Loaded read-only store ${MDC(STATE_STORE_ID, handle.id)}")
+      } else {
+        _stateStore = handle.asInstanceOf[StateStore]
+        logInfo(log"Loaded store ${MDC(STATE_STORE_ID, handle.id)}")
+      }
     }
   }
 
@@ -1617,8 +1751,7 @@ abstract class SymmetricHashJoinStateManagerBase(
     private val longValueSchema = new StructType().add("value", "long")
     private val longToUnsafeRow = UnsafeProjection.create(longValueSchema)
     private val valueRow = longToUnsafeRow(new 
SpecificInternalRow(longValueSchema))
-    protected val stateStore: StateStore =
-      getStateStore(keySchema, longValueSchema, useVirtualColumnFamilies)
+    initializeStateStore(keySchema, longValueSchema, useVirtualColumnFamilies)
 
     // Set up virtual column family name in the store if it is being used
     private val colFamilyName = if (useVirtualColumnFamilies) {
@@ -1629,7 +1762,7 @@ abstract class SymmetricHashJoinStateManagerBase(
 
     // Create the specific column family in the store for this join side's 
KeyToNumValuesStore
     if (useVirtualColumnFamilies) {
-      stateStore.createColFamilyIfAbsent(
+      registerColFamily(
         colFamilyName,
         keySchema,
         longValueSchema,
@@ -1640,7 +1773,7 @@ abstract class SymmetricHashJoinStateManagerBase(
 
     /** Get the number of values the key has */
     def get(key: UnsafeRow): Long = {
-      val longValueRow = stateStore.get(key, colFamilyName)
+      val longValueRow = readStateStore.get(key, colFamilyName)
       if (longValueRow != null) longValueRow.getLong(0) else 0L
     }
 
@@ -1657,7 +1790,7 @@ abstract class SymmetricHashJoinStateManagerBase(
 
     def iterator: Iterator[KeyAndNumValues] = {
       val keyAndNumValues = new KeyAndNumValues()
-      stateStore.iterator(colFamilyName).map { pair =>
+      readStateStore.iterator(colFamilyName).map { pair =>
         keyAndNumValues.withNew(pair.key, pair.value.getLong(0))
       }
     }
@@ -1727,7 +1860,7 @@ abstract class SymmetricHashJoinStateManagerBase(
     private val valueRowConverter = StreamingSymmetricHashJoinValueRowConverter
       .create(inputValueAttributes, stateFormatVersion)
 
-    protected val stateStore = getStateStore(keyWithIndexSchema,
+    initializeStateStore(keyWithIndexSchema,
       valueRowConverter.valueAttributes.toStructType, useVirtualColumnFamilies)
 
     // Set up virtual column family name in the store if it is being used
@@ -1739,7 +1872,7 @@ abstract class SymmetricHashJoinStateManagerBase(
 
     // Create the specific column family in the store for this join side's 
KeyWithIndexToValueStore
     if (useVirtualColumnFamilies) {
-      stateStore.createColFamilyIfAbsent(
+      registerColFamily(
         colFamilyName,
         keyWithIndexSchema,
         valueRowConverter.valueAttributes.toStructType,
@@ -1749,7 +1882,7 @@ abstract class SymmetricHashJoinStateManagerBase(
 
     def get(key: UnsafeRow, valueIndex: Long): ValueAndMatchPair = {
       valueRowConverter.convertValue(
-        stateStore.get(keyWithIndexRow(key, valueIndex), colFamilyName)
+        readStateStore.get(keyWithIndexRow(key, valueIndex), colFamilyName)
       )
     }
 
@@ -1767,7 +1900,7 @@ abstract class SymmetricHashJoinStateManagerBase(
           while (hasMoreValues) {
             val keyWithIndex = keyWithIndexRow(key, index)
             val valuePair =
-              valueRowConverter.convertValue(stateStore.get(keyWithIndex, 
colFamilyName))
+              valueRowConverter.convertValue(readStateStore.get(keyWithIndex, 
colFamilyName))
             if (valuePair == null) {
               handleNullValuePair(key, index, numValues)
               skippedNullValueCount.foreach(_ += 1L)
@@ -1813,7 +1946,7 @@ abstract class SymmetricHashJoinStateManagerBase(
 
     def iterator: Iterator[KeyWithIndexAndValue] = {
       val keyWithIndexAndValue = new KeyWithIndexAndValue()
-      stateStore.iterator(colFamilyName).map { pair =>
+      readStateStore.iterator(colFamilyName).map { pair =>
         val valuePair = valueRowConverter.convertValue(pair.value)
         keyWithIndexAndValue.withNew(
           keyRowGenerator(pair.key), 
pair.key.getLong(indexOrdinalInKeyWithIndexRow), valuePair)
@@ -1851,12 +1984,13 @@ class SymmetricHashJoinStateManagerV1(
     skippedNullValueCount: Option[SQLMetric] = None,
     useStateStoreCoordinator: Boolean = true,
     snapshotOptions: Option[SnapshotOptions] = None,
-    joinStoreGenerator: JoinStateManagerStoreGenerator)
+    joinStoreGenerator: JoinStateManagerStoreGenerator,
+    readOnly: Boolean = false)
   extends SymmetricHashJoinStateManagerBase(
     joinSide, inputValueAttributes, joinKeys, stateInfo, storeConf, hadoopConf,
     partitionId, keyToNumValuesStateStoreCkptId, 
keyWithIndexToValueStateStoreCkptId,
     stateFormatVersion, skippedNullValueCount, useStateStoreCoordinator, 
snapshotOptions,
-    joinStoreGenerator) {
+    joinStoreGenerator, readOnly) {
 
   /** Commit all the changes to all the state stores */
   override def commit(): Unit = {
@@ -1931,12 +2065,13 @@ class SymmetricHashJoinStateManagerV2(
     skippedNullValueCount: Option[SQLMetric] = None,
     useStateStoreCoordinator: Boolean = true,
     snapshotOptions: Option[SnapshotOptions] = None,
-    joinStoreGenerator: JoinStateManagerStoreGenerator)
+    joinStoreGenerator: JoinStateManagerStoreGenerator,
+    readOnly: Boolean = false)
   extends SymmetricHashJoinStateManagerBase(
     joinSide, inputValueAttributes, joinKeys, stateInfo, storeConf, hadoopConf,
     partitionId, keyToNumValuesStateStoreCkptId, 
keyWithIndexToValueStateStoreCkptId,
     stateFormatVersion, skippedNullValueCount, useStateStoreCoordinator, 
snapshotOptions,
-    joinStoreGenerator) {
+    joinStoreGenerator, readOnly) {
 
   /** Commit all the changes to the state store */
   override def commit(): Unit = {
@@ -2043,27 +2178,28 @@ object SymmetricHashJoinStateManager {
       useStateStoreCoordinator: Boolean = true,
       snapshotOptions: Option[SnapshotOptions] = None,
       joinStoreGenerator: JoinStateManagerStoreGenerator,
-      joinKeyOrdinalForWatermark: Option[Int] = None): 
SymmetricHashJoinStateManager = {
+      joinKeyOrdinalForWatermark: Option[Int] = None,
+      readOnly: Boolean = false): SymmetricHashJoinStateManager = {
     if (stateFormatVersion == 4) {
       new SymmetricHashJoinStateManagerV4(
         joinSide, inputValueAttributes, joinKeys, stateInfo, storeConf, 
hadoopConf,
         partitionId, keyToNumValuesStateStoreCkptId, 
keyWithIndexToValueStateStoreCkptId,
         stateFormatVersion, skippedNullValueCount, useStateStoreCoordinator, 
snapshotOptions,
-        joinStoreGenerator, joinKeyOrdinalForWatermark
+        joinStoreGenerator, joinKeyOrdinalForWatermark, readOnly
       )
     } else if (stateFormatVersion == 3) {
       new SymmetricHashJoinStateManagerV2(
         joinSide, inputValueAttributes, joinKeys, stateInfo, storeConf, 
hadoopConf,
         partitionId, keyToNumValuesStateStoreCkptId, 
keyWithIndexToValueStateStoreCkptId,
         stateFormatVersion, skippedNullValueCount, useStateStoreCoordinator, 
snapshotOptions,
-        joinStoreGenerator
+        joinStoreGenerator, readOnly
       )
     } else {
       new SymmetricHashJoinStateManagerV1(
         joinSide, inputValueAttributes, joinKeys, stateInfo, storeConf, 
hadoopConf,
         partitionId, keyToNumValuesStateStoreCkptId, 
keyWithIndexToValueStateStoreCkptId,
         stateFormatVersion, skippedNullValueCount, useStateStoreCoordinator, 
snapshotOptions,
-        joinStoreGenerator
+        joinStoreGenerator, readOnly
       )
     }
   }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/HDFSBackedStateStoreProvider.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/HDFSBackedStateStoreProvider.scala
index 2562f1ff3304..e64e790bd21f 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/HDFSBackedStateStoreProvider.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/HDFSBackedStateStoreProvider.scala
@@ -149,7 +149,10 @@ private[sql] class HDFSBackedStateStoreProvider extends 
StateStoreProvider with
     private val newVersion = version + 1
     @volatile private var state: STATE = UPDATING
     private val finalDeltaFile: Path = deltaFile(newVersion)
-    private lazy val deltaFileStream = fm.createAtomic(finalDeltaFile, 
overwriteIfPossible = true)
+    private lazy val deltaFileStream = {
+      createBaseDirIfNotExists()
+      fm.createAtomic(finalDeltaFile, overwriteIfPossible = true)
+    }
     private lazy val compressedStream = compressStream(deltaFileStream)
 
     override def id: StateStoreId = 
HDFSBackedStateStoreProvider.this.stateStoreId
@@ -241,6 +244,12 @@ private[sql] class HDFSBackedStateStoreProvider extends 
StateStoreProvider with
       }
     }
 
+    override def release(): Unit = {
+      verify(state == UPDATING || state == RELEASED,
+        s"Cannot release after state $state")
+      state = RELEASED
+    }
+
     /** Abort all the updates made on this store. This store will not be 
usable any more. */
     override def abort(): Unit = {
       // This if statement is to ensure that files are deleted only once: if 
either commit or abort
@@ -469,7 +478,8 @@ private[sql] class HDFSBackedStateStoreProvider extends 
StateStoreProvider with
 
     this.numColsPrefixKey = getNumColsPrefixKey(keyStateEncoderSpec)
 
-    fm.mkdirs(baseDir)
+    // baseDir is created lazily on the first write -- see 
createBaseDirIfNotExists().
+    // This keeps read-only callers from issuing an mkdirs on the checkpoint 
path.
   }
 
   override def stateStoreId: StateStoreId = stateStoreId_
@@ -525,6 +535,23 @@ private[sql] class HDFSBackedStateStoreProvider extends 
StateStoreProvider with
   private lazy val loadedMaps = new util.TreeMap[Long, 
HDFSBackedStateStoreMap](
     Ordering[Long].reverse)
   private lazy val baseDir = stateStoreId.storeCheckpointLocation()
+
+  // baseDir is created lazily on the first write, so read-only entry points 
never trigger a
+  // mkdirs on the checkpoint path. compareAndSet ensures only one task issues 
the mkdirs even
+  // if several tasks for this partition race here; the flag is reset on 
failure so a transient
+  // mkdirs error does not permanently skip creation.
+  private val baseDirChecked = new AtomicBoolean(false)
+  private def createBaseDirIfNotExists(): Unit = {
+    if (baseDirChecked.compareAndSet(false, true)) {
+      try {
+        if (!fm.exists(baseDir)) fm.mkdirs(baseDir)
+      } catch {
+        case e: Throwable =>
+          baseDirChecked.set(false)
+          throw e
+      }
+    }
+  }
   // Visible to state pkg for testing.
   private[state] lazy val fm = {
     val mgr = CheckpointFileManager.create(baseDir, hadoopConf)
@@ -890,6 +917,7 @@ private[sql] class HDFSBackedStateStoreProvider extends 
StateStoreProvider with
     var rawOutput: CancellableFSDataOutputStream = null
     var output: DataOutputStream = null
     try {
+      createBaseDirIfNotExists()
       rawOutput = fm.createAtomic(targetFile, overwriteIfPossible = true)
       output = compressStream(rawOutput)
       // Entry iterator doesn't do verification, we will do it ourselves
@@ -1232,6 +1260,12 @@ private[sql] class HDFSBackedStateStoreProvider extends 
StateStoreProvider with
         "HDFSBackedStateStoreProvider does not support checkpointFormatVersion 
> 1 " +
         "but a state store checkpointID is passed in")
     }
+    // The trait's return type is StateStore (writable), so we cannot return
+    // HDFSBackedReadStateStore here. Callers that need a read-only store must 
call
+    // replayReadStateFromSnapshot instead.
+    require(!readOnly,
+      "HDFSBackedStateStoreProvider.replayStateFromSnapshot cannot return a 
read-only " +
+        "store; use replayReadStateFromSnapshot when readOnly = true.")
     val newMap = replayLoadedMapFromSnapshot(snapshotVersion, endVersion)
     logInfo(log"Retrieved snapshot at version " +
       log"${MDC(LogKeys.STATE_STORE_VERSION, snapshotVersion)} and apply delta 
files to version " +
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDB.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDB.scala
index bd479ffc6d2a..8b23c9628450 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDB.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDB.scala
@@ -309,13 +309,19 @@ class RocksDB(
    *
    * @param colFamilyName - column family name
    * @param isInternal - whether the column family is for internal use or not
+   * @param readOnly - when true (read-only callers), do not force a snapshot 
for a newly created
+   *                   family; read stores never commit, so registering a 
family carries no write
+   *                   intent.
    * @return - virtual column family id
    */
-  def createColFamilyIfAbsent(colFamilyName: String, isInternal: Boolean): 
Short = {
+  def createColFamilyIfAbsent(
+      colFamilyName: String,
+      isInternal: Boolean,
+      readOnly: Boolean = false): Short = {
     if (!checkColFamilyExists(colFamilyName)) {
       val newColumnFamilyId = maxColumnFamilyId.incrementAndGet().toShort
       addToColFamilyMaps(colFamilyName, newColumnFamilyId, isInternal)
-      shouldForceSnapshot.set(true)
+      if (!readOnly) shouldForceSnapshot.set(true)
       newColumnFamilyId
     } else {
       colFamilyNameToInfoMap.get(colFamilyName).cfId
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDBStateStoreProvider.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDBStateStoreProvider.scala
index c181130eec94..52e7ce296567 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDBStateStoreProvider.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDBStateStoreProvider.scala
@@ -201,6 +201,20 @@ private[sql] class RocksDBStateStoreProvider
       }
     }
 
+    // Read-only registration: sets up the virtual column-family id and the 
key/value encoders
+    // needed to decode this family. Purely in-memory -- it does not write to 
the checkpoint and
+    // carries no write intent (no forced snapshot). Safe to call on a 
read-only store.
+    override def registerColFamily(
+        colFamilyName: String,
+        keySchema: StructType,
+        valueSchema: StructType,
+        keyStateEncoderSpec: KeyStateEncoderSpec,
+        useMultipleValuesPerKey: Boolean = false,
+        isInternal: Boolean = false): Unit = {
+      registerColFamilyInternal(colFamilyName, keySchema, valueSchema, 
keyStateEncoderSpec,
+        useMultipleValuesPerKey, isInternal)
+    }
+
     override def createColFamilyIfAbsent(
         colFamilyName: String,
         keySchema: StructType,
@@ -208,9 +222,20 @@ private[sql] class RocksDBStateStoreProvider
         keyStateEncoderSpec: KeyStateEncoderSpec,
         useMultipleValuesPerKey: Boolean = false,
         isInternal: Boolean = false): Unit = {
+      registerColFamilyInternal(colFamilyName, keySchema, valueSchema, 
keyStateEncoderSpec,
+        useMultipleValuesPerKey, isInternal)
+    }
+
+    private def registerColFamilyInternal(
+        colFamilyName: String,
+        keySchema: StructType,
+        valueSchema: StructType,
+        keyStateEncoderSpec: KeyStateEncoderSpec,
+        useMultipleValuesPerKey: Boolean,
+        isInternal: Boolean): Unit = {
       validateAndTransitionState(UPDATE)
       verifyColFamilyCreationOrDeletion("create_col_family", colFamilyName, 
isInternal)
-      val cfId = rocksDB.createColFamilyIfAbsent(colFamilyName, isInternal)
+      val cfId = rocksDB.createColFamilyIfAbsent(colFamilyName, isInternal, 
readOnly)
       val dataEncoderCacheKey = StateRowEncoderCacheKey(
         queryRunId = StateStoreProvider.getRunId(hadoopConf),
         operatorId = stateStoreId.operatorId,
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/StateStore.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/StateStore.scala
index ad067b8edcc3..961d2f963ce8 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/StateStore.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/StateStore.scala
@@ -270,6 +270,22 @@ trait ReadStateStore {
    * @return Set of all column family names
    */
   def allColumnFamilyNames: Set[String]
+
+  /**
+   * Read-only counterpart of [[StateStore.createColFamilyIfAbsent]]: a purely 
in-memory
+   * registration of the column family's id and encoders so reads can decode 
it. Must not write
+   * to the checkpoint or carry write intent (e.g. forcing a snapshot).
+   */
+  def registerColFamily(
+      colFamilyName: String,
+      keySchema: StructType,
+      valueSchema: StructType,
+      keyStateEncoderSpec: KeyStateEncoderSpec,
+      useMultipleValuesPerKey: Boolean = false,
+      isInternal: Boolean = false): Unit = {
+    throw new UnsupportedOperationException(
+      "registerColFamily is not supported by this ReadStateStore")
+  }
 }
 
 /**
@@ -474,6 +490,22 @@ class WrappedReadStateStore(store: StateStore) extends 
ReadStateStore {
       colFamilyName: String): StateStoreIterator[UnsafeRowPair] = {
     store.iteratorWithMultiValues(colFamilyName)
   }
+
+  // Registering a column family is a purely in-memory operation (virtual 
column family id and
+  // key/value encoders); it does not touch the native store or write to the 
checkpoint. Reads
+  // cannot decode a column family that has not been registered, so this is 
delegated to the
+  // underlying store via its read-safe registerColFamily rather than rejected.
+  override def registerColFamily(
+      colFamilyName: String,
+      keySchema: StructType,
+      valueSchema: StructType,
+      keyStateEncoderSpec: KeyStateEncoderSpec,
+      useMultipleValuesPerKey: Boolean = false,
+      isInternal: Boolean = false): Unit = {
+    store.registerColFamily(
+      colFamilyName, keySchema, valueSchema, keyStateEncoderSpec,
+      useMultipleValuesPerKey, isInternal)
+  }
 }
 
 /**
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceChangeDataReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceChangeDataReadSuite.scala
index 4e9f6cca2ffc..bb4b00e74d7a 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceChangeDataReadSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceChangeDataReadSuite.scala
@@ -233,17 +233,20 @@ abstract class StateDataSourceChangeDataReaderSuite 
extends StateDataSourceTestB
 
   test("ERROR: mixed checkpoint format versions not supported") {
     withTempDir { tempDir =>
-      val commitLog = new CommitLog(spark,
-        new File(tempDir.getAbsolutePath, "commits").getAbsolutePath)
-
-      // Start version: treated as v1 (no operator unique ids)
-      val startMetadata = CommitMetadata(0)
-      assert(commitLog.add(0, startMetadata))
-
-      // End version: treated as v2 (operator 0 has unique ids)
-      val endMetadata = CommitMetadataV2(0,
-        Some(Map[Long, Array[Array[String]]](0L -> Array(Array("uid")))))
-      assert(commitLog.add(1, endMetadata))
+      // Test setup writes commit log entries.
+      withWritableCheckpoint {
+        val commitLog = new CommitLog(spark,
+          new File(tempDir.getAbsolutePath, "commits").getAbsolutePath)
+
+        // Start version: treated as v1 (no operator unique ids)
+        val startMetadata = CommitMetadata(0)
+        assert(commitLog.add(0, startMetadata))
+
+        // End version: treated as v2 (operator 0 has unique ids)
+        val endMetadata = CommitMetadataV2(0,
+          Some(Map[Long, Array[Array[String]]](0L -> Array(Array("uid")))))
+        assert(commitLog.add(1, endMetadata))
+      }
 
       val exc = 
intercept[StateDataSourceMixedCheckpointFormatVersionsNotSupported] {
         spark.read.format("statestore")
@@ -417,6 +420,23 @@ abstract class StateDataSourceChangeDataReaderSuite 
extends StateDataSourceTestB
     }
   }
 
+  test("read flatMapGroupsWithState state change feed") {
+    withTempDir { tempDir =>
+      runFlatMapGroupsWithStateQuery(tempDir.getAbsolutePath)
+      val df = spark.read.format("statestore")
+        .option(StateSourceOptions.PATH, tempDir.getAbsolutePath)
+        .option(StateSourceOptions.READ_CHANGE_FEED, value = true)
+        .option(StateSourceOptions.CHANGE_START_BATCH_ID, 0)
+        .option(StateSourceOptions.CHANGE_END_BATCH_ID, 1)
+        .load()
+      assert(df.collect().nonEmpty,
+        "expected flatMapGroupsWithState state change feed to return change 
records")
+      val keys = df.selectExpr("key.value AS 
k").collect().map(_.getString(0)).toSet
+      assert(keys == Set("hello", "world", "scala"),
+        s"unexpected change-feed keys: $keys")
+    }
+  }
+
   test("read stream-stream join state change feed") {
     withTempDir { tempDir =>
       val inputData = MemoryStream[(Int, Long)]
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceReadSuite.scala
index 4a2a454077a7..a81c586af433 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceReadSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceReadSuite.scala
@@ -65,13 +65,15 @@ class StateDataSourceNegativeTestSuite extends 
StateDataSourceTestBase {
     withTempDir { tempDir =>
       runLargeDataStreamingAggregationQuery(tempDir.getAbsolutePath)
 
-      val offsetLog = new OffsetSeqLog(spark,
-        new File(tempDir.getAbsolutePath, "offsets").getAbsolutePath)
-      val commitLog = new CommitLog(spark,
-        new File(tempDir.getAbsolutePath, "commits").getAbsolutePath)
-
-      offsetLog.purgeAfter(0)
-      commitLog.purgeAfter(-1)
+      // Purge logs to create the error condition. This writes to checkpoint 
files.
+      withWritableCheckpoint {
+        val offsetLog = new OffsetSeqLog(spark,
+          new File(tempDir.getAbsolutePath, "offsets").getAbsolutePath)
+        val commitLog = new CommitLog(spark,
+          new File(tempDir.getAbsolutePath, "commits").getAbsolutePath)
+        offsetLog.purgeAfter(0)
+        commitLog.purgeAfter(-1)
+      }
 
       intercept[StataDataSourceCommittedBatchUnavailable] {
         spark.read.format("statestore").load(tempDir.getAbsolutePath)
@@ -477,6 +479,63 @@ class HDFSBackedStateDataSourceReadSuite extends 
StateDataSourceReadSuite {
     testSnapshotOnJoinState("hdfs", 1)
     testSnapshotOnJoinState("hdfs", 2)
   }
+
+  test("read-only checkpoint: HDFS-backed join snapshot replay honors 
readOnly") {
+    // Use the pre-generated golden join1 state so this test does not race the 
maintenance
+    // thread. Copy it into a withTempDir (auto-protected) so any unintended 
write on the
+    // read path is caught by WriteProtectedLocalFileSystem.
+    val resourceUri = this.getClass.getResource(
+      "/structured-streaming/checkpoint-version-4.0.0/hdfs/join1/").toURI
+    withTempDir { tempDir =>
+      withWritableCheckpoint {
+        Utils.copyDirectory(new File(resourceUri), tempDir)
+      }
+      val df = spark.read
+        .format("statestore")
+        .option(StateSourceOptions.PATH, tempDir.getAbsolutePath)
+        .option(StateSourceOptions.JOIN_SIDE, "left")
+        .option(StateSourceOptions.SNAPSHOT_START_BATCH_ID, 1)
+        .option(StateSourceOptions.SNAPSHOT_PARTITION_ID, 2)
+        .load()
+      assert(df.collect().nonEmpty)
+    }
+  }
+
+  // HDFSBackedStateStoreProvider.init() defers baseDir mkdirs to the first 
write
+  // (createBaseDirIfNotExists). Pin this behavior: deleting a per-store 
baseDir under a
+  // partition lets partition discovery and schema discovery succeed 
(PARTITION_ID_TO_CHECK_SCHEMA=0
+  // is intact, all partition dirs still exist) but exposes the task-side 
init() in a way
+  // that previously would have triggered mkdirs -- and now must NOT.
+  test("read-only checkpoint: HDFS provider does not mkdirs baseDir on read 
path") {
+    withTempDir { tempDir =>
+      runStreamStreamJoinQuery(tempDir.getAbsolutePath)
+
+      val namedStoreDir = new java.io.File(tempDir, 
"state/0/2/left-keyToNumValues")
+      assert(namedStoreDir.exists(),
+        "Expected state/0/2/left-keyToNumValues to exist after running the 
join query")
+      Utils.deleteRecursively(namedStoreDir)
+      assert(!namedStoreDir.exists())
+
+      val exc = intercept[SparkException] {
+        spark.read
+          .format("statestore")
+          .option(StateSourceOptions.PATH, tempDir.getAbsolutePath)
+          .option(StateSourceOptions.JOIN_SIDE, "left")
+          .load()
+          .collect()
+      }
+      // Read fails with a data-load error from the missing state files -- NOT 
by
+      // attempting to (re)create the directory.
+      checkError(
+        exception = exc,
+        condition = 
"CANNOT_LOAD_STATE_STORE.CANNOT_READ_DELTA_FILE_NOT_EXISTS",
+        sqlState = Some("58030"),
+        parameters = Map(
+          "clazz" -> 
("HDFSStateStoreProvider\\[.*storeName=left-keyToNumValues.*\\]"),
+          "fileToRead" -> ".*state/0/2/left-keyToNumValues/1\\.delta"),
+        matchPVals = true)
+    }
+  }
 }
 
 class RocksDBStateDataSourceReadSuite extends StateDataSourceReadSuite {
@@ -717,6 +776,19 @@ abstract class StateDataSourceReadSuite extends 
StateDataSourceTestBase with Ass
   protected val keySchema: StructType = StateStoreTestsHelper.keySchema
   protected val valueSchema: StructType = StateStoreTestsHelper.valueSchema
 
+  test("framework sanity: write to protected path inside withTempDir is 
rejected") {
+    val e = intercept[Exception] {
+      withTempDir { tempDir =>
+        // No withWritableCheckpoint / testStream: tempDir is protected.
+        val out = new java.io.File(tempDir, "forbidden.txt")
+        val fs = new org.apache.hadoop.fs.Path(out.getAbsolutePath)
+          .getFileSystem(spark.sessionState.newHadoopConf())
+        fs.create(new org.apache.hadoop.fs.Path(out.getAbsolutePath)).close()
+      }
+    }
+    assertWriteProtectionFailure(e)
+  }
+
   protected def newStateStoreProvider(): StateStoreProvider
 
   /**
@@ -1164,15 +1236,12 @@ abstract class StateDataSourceReadSuite extends 
StateDataSourceTestBase with Ass
         .groupBy("value")
         .count()
 
-      stream.addData(1 to 10000: _*)
-
-      val query = df.writeStream.format("noop")
-        .option("checkpointLocation", tempDir.getAbsolutePath)
-        .outputMode(OutputMode.Update())
-        .start()
-
-      query.processAllAvailable()
-      query.stop()
+      testStream(df, OutputMode.Update())(
+        StartStream(checkpointLocation = tempDir.getAbsolutePath),
+        AddData(stream, 1 to 10000: _*),
+        ProcessAllAvailable(),
+        StopStream
+      )
 
       val stateReadDf = spark.read
         .format("statestore")
@@ -1267,12 +1336,15 @@ abstract class StateDataSourceReadSuite extends 
StateDataSourceTestBase with Ass
 
   protected def testSnapshotNotFound(): Unit = {
     withTempDir { tempDir =>
-      val provider = getNewStateStoreProvider(tempDir.getAbsolutePath)
-      for (i <- 1 to 4) {
-        val store = provider.getStore(i - 1)
-        put(store, "a", i, i)
-        store.commit()
-        provider.doMaintenance() // create a snapshot every other delta file
+      val provider = withWritableCheckpoint {
+        val p = getNewStateStoreProvider(tempDir.getAbsolutePath)
+        for (i <- 1 to 4) {
+          val store = p.getStore(i - 1)
+          put(store, "a", i, i)
+          store.commit()
+          p.doMaintenance() // create a snapshot every other delta file
+        }
+        p
       }
 
       val exc = intercept[SparkException] {
@@ -1286,16 +1358,19 @@ abstract class StateDataSourceReadSuite extends 
StateDataSourceTestBase with Ass
   protected def testGetReadStoreWithStartVersion(): Unit = {
     withTempDir { tempDir =>
       val versionToCkptId = scala.collection.mutable.Map[Long, 
Option[String]]()
-      val provider = getNewStateStoreProvider(tempDir.getAbsolutePath)
-      for (i <- 1 to 4) {
-        val store = provider.getStore(i - 1, versionToCkptId.getOrElse(i - 1, 
None))
-        put(store, "a", i, i)
-        store.commit()
+      val provider = withWritableCheckpoint {
+        val p = getNewStateStoreProvider(tempDir.getAbsolutePath)
+        for (i <- 1 to 4) {
+          val store = p.getStore(i - 1, versionToCkptId.getOrElse(i - 1, None))
+          put(store, "a", i, i)
+          store.commit()
 
-        val ssInfo = store.getStateStoreCheckpointInfo()
-        versionToCkptId(ssInfo.batchVersion) = ssInfo.stateStoreCkptId
+          val ssInfo = store.getStateStoreCheckpointInfo()
+          versionToCkptId(ssInfo.batchVersion) = ssInfo.stateStoreCkptId
 
-        provider.doMaintenance()
+          p.doMaintenance()
+        }
+        p
       }
 
       val result =
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceTestBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceTestBase.scala
index aed484470e47..c5262833b1eb 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceTestBase.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceTestBase.scala
@@ -29,7 +29,10 @@ import org.apache.spark.sql.streaming._
 import org.apache.spark.sql.streaming.util.{ColumnFamilyMetadata, 
StreamManualClock}
 import org.apache.spark.sql.types.{BooleanType, IntegerType, LongType, 
NullType, StringType, StructField, StructType, TimestampType}
 
-trait StateDataSourceTestBase extends StreamTest with StateStoreMetricsTest {
+trait StateDataSourceTestBase
+  extends StreamTest
+  with WriteProtectedCheckpointTestMixin
+  with StateStoreMetricsTest {
   import testImplicits._
 
   override def beforeEach(): Unit = {
@@ -39,8 +42,10 @@ trait StateDataSourceTestBase extends StreamTest with 
StateStoreMetricsTest {
 
   override def afterEach(): Unit = {
     // Stop maintenance tasks because they may access already deleted 
checkpoint.
-    StateStore.stop()
+    // The WriteProtectedCheckpointTestMixin's afterEach (called via super) 
clears
+    // WriteProtectedPaths first, so teardown writes are not falsely flagged.
     super.afterEach()
+    StateStore.stop()
   }
 
   protected def runCompositeKeyStreamingAggregationQuery(checkpointRoot: 
String): Unit = {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceTransformWithStateSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceTransformWithStateSuite.scala
index 41f66807bef3..78e394b7cf75 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceTransformWithStateSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/StateDataSourceTransformWithStateSuite.scala
@@ -28,7 +28,7 @@ import 
org.apache.spark.sql.execution.streaming.runtime.{MemoryStream, Streaming
 import 
org.apache.spark.sql.execution.streaming.state.{AlsoTestWithEncodingTypes, 
AlsoTestWithRocksDBFeatures, EnableStateStoreRowChecksum, RocksDBFileManager, 
RocksDBStateStoreProvider, TestClass}
 import org.apache.spark.sql.functions.{col, explode, timestamp_seconds}
 import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.streaming.{InputMapRow, ListState, MapInputEvent, 
MapOutputEvent, MapStateTTLProcessor, MaxEventTimeStatefulProcessor, 
OutputMode, RunningCountStatefulProcessor, 
RunningCountStatefulProcessorWithProcTimeTimerUpdates, StatefulProcessor, 
StateStoreMetricsTest, TestMapStateProcessor, TimeMode, TimerValues, 
TransformWithStateSuiteUtils, Trigger, TTLConfig, ValueState}
+import org.apache.spark.sql.streaming.{InputMapRow, ListState, MapInputEvent, 
MapOutputEvent, MapStateTTLProcessor, MaxEventTimeStatefulProcessor, 
OutputMode, RunningCountStatefulProcessor, 
RunningCountStatefulProcessorWithProcTimeTimerUpdates, StatefulProcessor, 
TestMapStateProcessor, TimeMode, TimerValues, TransformWithStateSuiteUtils, 
Trigger, TTLConfig, ValueState}
 import org.apache.spark.sql.streaming.util.StreamManualClock
 import org.apache.spark.tags.{ExtendedSQLTest, SlowSQLTest}
 import org.apache.spark.util.Utils
@@ -128,7 +128,7 @@ class SessionGroupsStatefulProcessorWithTTL extends
  * Test suite to verify integration of state data source reader with the 
transformWithState operator
  */
 @SlowSQLTest
-class StateDataSourceTransformWithStateSuite extends StateStoreMetricsTest
+class StateDataSourceTransformWithStateSuite extends StateDataSourceTestBase
   with AlsoTestWithEncodingTypes with AlsoTestWithRocksDBFeatures {
 
   import testImplicits._
@@ -1134,12 +1134,15 @@ class StateDataSourceTransformWithStateSuite extends 
StateStoreMetricsTest
       } else {
         dfsRootDir.getAbsolutePath + "/3.changelog"
       }
-      Utils.deleteRecursively(new File(changelogFilePath))
-
-      // Write the retained entry back to the changelog
-      val changelogWriter = fileManager.getChangeLogWriter(3, false, 
checkpointUniqueId, lineage)
-      changelogWriter.put(retainEntry._2, retainEntry._3)
-      changelogWriter.commit()
+      withWritableCheckpoint {
+        Utils.deleteRecursively(new File(changelogFilePath))
+
+        // Write the retained entry back to the changelog
+        val changelogWriter = fileManager.getChangeLogWriter(
+          3, false, checkpointUniqueId, lineage)
+        changelogWriter.put(retainEntry._2, retainEntry._3)
+        changelogWriter.commit()
+      }
 
       // Ensure that we have only one entry in the changelog for version 3
       // For this test - key 9 is retained and key 12 is deleted
@@ -1199,6 +1202,65 @@ class StateDataSourceTransformWithStateSuite extends 
StateStoreMetricsTest
       }
     }
   }
+
+  /**
+   * Recursively removes write permission from a directory tree, simulating a
+   * read-only filesystem (e.g. a read-only cloud object store with only READ 
permission).
+   */
+  private def makeReadOnly(dir: java.io.File): Unit = {
+    if (dir.isDirectory) {
+      dir.listFiles().foreach(makeReadOnly)
+    }
+    dir.setWritable(false)
+  }
+
+  /** Recursively restores write permission to a directory tree. */
+  private def makeWritable(dir: java.io.File): Unit = {
+    dir.setWritable(true)
+    if (dir.isDirectory) {
+      dir.listFiles().foreach(makeWritable)
+    }
+  }
+
+  test("SPARK-57269: TWS state data source read succeeds on read-only 
checkpoint") {
+    withTempDir { tempDir =>
+      withSQLConf(SQLConf.STATE_STORE_PROVIDER_CLASS.key ->
+        classOf[RocksDBStateStoreProvider].getName,
+        SQLConf.SHUFFLE_PARTITIONS.key ->
+          TransformWithStateSuiteUtils.NUM_SHUFFLE_PARTITIONS.toString) {
+        val checkpointPath = tempDir.getAbsolutePath
+        val inputData = MemoryStream[String]
+        val result = inputData.toDS()
+          .groupByKey(x => x)
+          .transformWithState(new RunningCountStatefulProcessor(),
+            TimeMode.None(),
+            OutputMode.Update())
+
+        testStream(result, OutputMode.Update())(
+          StartStream(checkpointLocation = checkpointPath),
+          AddData(inputData, "a"),
+          AddData(inputData, "b"),
+          ProcessAllAvailable(),
+          StopStream
+        )
+
+        val stateDir = new java.io.File(tempDir, "state")
+        assert(stateDir.exists(), "State directory should exist after running 
the query")
+        makeReadOnly(stateDir)
+
+        try {
+          val df = spark.read
+            .format("statestore")
+            .option(StateSourceOptions.PATH, checkpointPath)
+            .option(StateSourceOptions.STATE_VAR_NAME, "countState")
+            .load()
+          assert(df.collect().length > 0)
+        } finally {
+          makeWritable(stateDir)
+        }
+      }
+    }
+  }
 }
 
 @ExtendedSQLTest
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/WriteProtectedLocalFileSystem.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/WriteProtectedLocalFileSystem.scala
new file mode 100644
index 000000000000..2b712d14af70
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/state/WriteProtectedLocalFileSystem.scala
@@ -0,0 +1,348 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.sql.execution.datasources.v2.state
+
+import java.net.URI
+import java.nio.file.AccessDeniedException
+import java.util
+import java.util.concurrent.atomic.AtomicReference
+
+import scala.jdk.CollectionConverters._
+
+import org.apache.hadoop.conf.Configuration
+import org.apache.hadoop.fs.{CreateFlag, DelegateToFileSystem, 
FSDataOutputStream, FsServerDefaults, Options, Path}
+import org.apache.hadoop.fs.local.LocalConfigKeys
+import org.apache.hadoop.fs.permission.FsPermission
+import org.apache.hadoop.util.Progressable
+import org.scalatest.BeforeAndAfterEach
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.streaming.StreamTest
+
+/**
+ * Write protection for the Hadoop FileSystem API (`fs.file.impl`).
+ *
+ * Extends [[org.apache.spark.DebugFilesystem]] so it replaces DebugFilesystem
+ * without losing open-connection tracking. Every write entry point is 
overridden to deny
+ * the operation when the target path is protected; reads are left untouched.
+ */
+class WriteProtectedLocalFileSystem extends org.apache.spark.DebugFilesystem {
+
+  override def create(
+      f: Path,
+      permission: FsPermission,
+      overwrite: Boolean,
+      bufferSize: Int,
+      replication: Short,
+      blockSize: Long,
+      progress: Progressable): FSDataOutputStream = {
+    if (WriteProtectedPaths.isProtected(f)) WriteProtectedPaths.deny("create", 
f)
+    super.create(f, permission, overwrite, bufferSize, replication, blockSize, 
progress)
+  }
+
+  override def create(
+      f: Path,
+      permission: FsPermission,
+      flags: util.EnumSet[CreateFlag],
+      bufferSize: Int,
+      replication: Short,
+      blockSize: Long,
+      progress: Progressable,
+      checksumOpt: Options.ChecksumOpt): FSDataOutputStream = {
+    if (WriteProtectedPaths.isProtected(f)) WriteProtectedPaths.deny("create", 
f)
+    super.create(
+      f, permission, flags, bufferSize, replication, blockSize, progress, 
checksumOpt)
+  }
+
+  override def createNonRecursive(
+      f: Path,
+      permission: FsPermission,
+      flags: util.EnumSet[CreateFlag],
+      bufferSize: Int,
+      replication: Short,
+      blockSize: Long,
+      progress: Progressable): FSDataOutputStream = {
+    if (WriteProtectedPaths.isProtected(f)) 
WriteProtectedPaths.deny("createNonRecursive", f)
+    super.createNonRecursive(f, permission, flags, bufferSize, replication, 
blockSize, progress)
+  }
+
+  override def append(f: Path, bufferSize: Int, progress: Progressable): 
FSDataOutputStream = {
+    if (WriteProtectedPaths.isProtected(f)) WriteProtectedPaths.deny("append", 
f)
+    super.append(f, bufferSize, progress)
+  }
+
+  override def rename(src: Path, dst: Path): Boolean = {
+    // A rename out of a protected path is also a write to that path -- check 
both ends.
+    if (WriteProtectedPaths.isProtected(src)) WriteProtectedPaths.deny("rename 
from", src)
+    if (WriteProtectedPaths.isProtected(dst)) WriteProtectedPaths.deny("rename 
to", dst)
+    super.rename(src, dst)
+  }
+
+  override def delete(f: Path, recursive: Boolean): Boolean = {
+    if (WriteProtectedPaths.isProtected(f)) WriteProtectedPaths.deny("delete", 
f)
+    super.delete(f, recursive)
+  }
+
+  override def mkdirs(f: Path, permission: FsPermission): Boolean = {
+    if (WriteProtectedPaths.isProtected(f)) WriteProtectedPaths.deny("mkdirs", 
f)
+    super.mkdirs(f, permission)
+  }
+
+  override def truncate(f: Path, newLength: Long): Boolean = {
+    if (WriteProtectedPaths.isProtected(f)) 
WriteProtectedPaths.deny("truncate", f)
+    super.truncate(f, newLength)
+  }
+
+  override def setTimes(f: Path, mtime: Long, atime: Long): Unit = {
+    if (WriteProtectedPaths.isProtected(f)) 
WriteProtectedPaths.deny("setTimes", f)
+    super.setTimes(f, mtime, atime)
+  }
+}
+
+/**
+ * Write protection for the Hadoop FileContext/AbstractFileSystem API
+ * (`fs.AbstractFileSystem.file.impl`).
+ *
+ * Needed because FileContextBasedCheckpointFileManager goes through 
FileContext, which
+ * bypasses the FileSystem-level write methods overridden in 
[[WriteProtectedLocalFileSystem]].
+ */
+class WriteProtectedAbstractFileSystem(uri: URI, conf: Configuration)
+    extends DelegateToFileSystem(
+      uri,
+      new WriteProtectedLocalFileSystem,
+      conf,
+      "file",
+      false) {
+
+  override def getUriDefaultPort(): Int = -1
+  override def getServerDefaults(): FsServerDefaults = 
LocalConfigKeys.getServerDefaults
+  override def isValidName(src: String): Boolean = true
+
+  override def createInternal(
+      f: Path,
+      flag: util.EnumSet[CreateFlag],
+      absolutePermission: FsPermission,
+      bufferSize: Int,
+      replication: Short,
+      blockSize: Long,
+      progress: Progressable,
+      checksumOpt: Options.ChecksumOpt,
+      createParent: Boolean): FSDataOutputStream = {
+    if (WriteProtectedPaths.isProtected(f)) WriteProtectedPaths.deny("create", 
f)
+    super.createInternal(
+      f, flag, absolutePermission, bufferSize, replication, blockSize,
+      progress, checksumOpt, createParent)
+  }
+
+  override def mkdir(
+      dir: Path, permission: FsPermission, createParent: Boolean): Unit = {
+    if (WriteProtectedPaths.isProtected(dir)) 
WriteProtectedPaths.deny("mkdir", dir)
+    super.mkdir(dir, permission, createParent)
+  }
+
+  override def delete(f: Path, recursive: Boolean): Boolean = {
+    if (WriteProtectedPaths.isProtected(f)) WriteProtectedPaths.deny("delete", 
f)
+    super.delete(f, recursive)
+  }
+
+  override def renameInternal(src: Path, dst: Path): Unit = {
+    // A rename out of a protected path is also a write to that path -- check 
both ends.
+    if (WriteProtectedPaths.isProtected(src)) WriteProtectedPaths.deny("rename 
from", src)
+    if (WriteProtectedPaths.isProtected(dst)) WriteProtectedPaths.deny("rename 
to", dst)
+    super.renameInternal(src, dst)
+  }
+
+  override def truncate(f: Path, newLength: Long): Boolean = {
+    if (WriteProtectedPaths.isProtected(f)) 
WriteProtectedPaths.deny("truncate", f)
+    super.truncate(f, newLength)
+  }
+
+  override def setTimes(f: Path, mtime: Long, atime: Long): Unit = {
+    if (WriteProtectedPaths.isProtected(f)) 
WriteProtectedPaths.deny("setTimes", f)
+    super.setTimes(f, mtime, atime)
+  }
+}
+
+/**
+ * Shared state for write-protected paths. Used by both 
[[WriteProtectedLocalFileSystem]]
+ * (FileSystem API) and [[WriteProtectedAbstractFileSystem]] (FileContext API).
+ *
+ * State is JVM-global: parallel suites in the same JVM would interfere. Tests 
in this
+ * package run serially via [[StateDataSourceTestBase]], which clears state in 
`afterEach`.
+ *
+ * Path normalization: prefixes are stored with a trailing "/", and matches use
+ * `pathStr.startsWith(prefix)`. Operations on the protected directory itself
+ * (without trailing "/") are NOT matched -- only operations on its 
descendants.
+ */
+object WriteProtectedPaths {
+  // Single AtomicReference so snapshot/restore are atomic; readers do one 
volatile load.
+  private val protectedPrefixes =
+    new AtomicReference[Set[String]](Set.empty[String])
+
+  private def normalize(path: String): String =
+    if (path.endsWith("/")) path else path + "/"
+
+  private[state] def deny(op: String, p: Path): Nothing = {
+    throw new AccessDeniedException(
+      s"Write-protected path: $op attempted on path: $p. " +
+      "This path is protected to simulate a read-only cloud object store 
checkpoint. " +
+      "If you are seeing this in a test, it means the data source code path " +
+      "is attempting a write that would fail on a read-only cloud object store 
path.")
+  }
+
+  def protectPath(path: String): Unit = {
+    val normalized = normalize(path)
+    protectedPrefixes.updateAndGet(_ + normalized)
+  }
+
+  def unprotectPath(path: String): Unit = {
+    val normalized = normalize(path)
+    protectedPrefixes.updateAndGet(_ - normalized)
+  }
+
+  def isProtected(p: Path): Boolean = {
+    val current = protectedPrefixes.get()
+    if (current.isEmpty) return false
+    val pathStr = p.toUri.getPath
+    current.exists(pathStr.startsWith)
+  }
+
+  def clearAll(): Unit = protectedPrefixes.set(Set.empty)
+
+  /** Snapshot current prefixes and atomically clear -- single store update. */
+  def snapshotAndClear(): java.util.Set[String] =
+    new util.HashSet(protectedPrefixes.getAndSet(Set.empty).asJava)
+
+  /**
+   * Re-add the snapshot's entries. Non-destructive: prefixes added 
concurrently by other
+   * code while the snapshot was inactive are preserved.
+   */
+  def restore(snapshot: java.util.Set[String]): Unit = {
+    val toAdd = snapshot.asScala.toSet
+    protectedPrefixes.updateAndGet(_ ++ toAdd)
+  }
+}
+
+/**
+ * Mix-in that installs [[WriteProtectedLocalFileSystem]] and auto-protects 
every
+ * [[withTempDir]] from writes. Tests that perform writes outside of 
[[testStream]]
+ * (e.g. provider API calls, log purging, direct file manipulation) must wrap 
them in
+ * [[withWritableCheckpoint]] to temporarily suspend protection.
+ *
+ * Mix this into any test base whose reads should never write to the 
checkpoint path
+ * (state data source, ...). The framework is JVM-global and assumes a single 
suite runs
+ * at a time within a JVM.
+ */
+trait WriteProtectedCheckpointTestMixin extends StreamTest with 
BeforeAndAfterEach {
+
+  // Set the write-protected filesystem via sparkConf so it is applied before 
the
+  // SparkContext is created. Setting it later (e.g., on the session) is too 
late --
+  // the Hadoop FileSystem cache may already hold a DebugFilesystem from 
startup.
+  override protected def sparkConf: SparkConf = {
+    super.sparkConf
+      .set("spark.hadoop.fs.file.impl",
+        classOf[WriteProtectedLocalFileSystem].getCanonicalName)
+      .set("spark.hadoop.fs.file.impl.disable.cache", "true")
+      .set("spark.hadoop.fs.AbstractFileSystem.file.impl",
+        classOf[WriteProtectedAbstractFileSystem].getCanonicalName)
+      .set("spark.hadoop.fs.AbstractFileSystem.file.impl.disable.cache", 
"true")
+  }
+
+  override def afterEach(): Unit = {
+    // Clear protections so teardown writes (state-store maintenance, temp-dir 
cleanup)
+    // are not falsely flagged.
+    WriteProtectedPaths.clearAll()
+    super.afterEach()
+  }
+
+  /**
+   * Override withTempDir to automatically protect the directory from writes.
+   * [[testStream]] temporarily suspends protections for streaming query 
writes.
+   * Everything else (data source reads) is blocked from writing.
+   */
+  override def withTempDir(f: java.io.File => Unit): Unit = {
+    super.withTempDir { dir =>
+      // Protect both absolute and canonical paths since data sources resolve 
paths
+      // via makeQualified which may canonicalize.
+      WriteProtectedPaths.protectPath(dir.getAbsolutePath)
+      WriteProtectedPaths.protectPath(dir.getCanonicalPath)
+      try {
+        f(dir)
+      } finally {
+        WriteProtectedPaths.unprotectPath(dir.getAbsolutePath)
+        WriteProtectedPaths.unprotectPath(dir.getCanonicalPath)
+      }
+    }
+  }
+
+  /**
+   * Suspends write protection for a block. Use this for test setup code that 
writes
+   * to the checkpoint outside of [[testStream]] (e.g. provider API calls, log 
purging,
+   * writeStream.start(), legacy behavior tests).
+   */
+  protected def withWritableCheckpoint[T](body: => T): T = {
+    val saved = WriteProtectedPaths.snapshotAndClear()
+    try {
+      body
+    } finally {
+      WriteProtectedPaths.restore(saved)
+    }
+  }
+
+  /**
+   * Assert that a write-protection-related exception exists in the cause 
chain.
+   *
+   * Accepted shapes:
+   *   - AccessDeniedException: thrown directly by 
[[WriteProtectedLocalFileSystem]] /
+   *     [[WriteProtectedAbstractFileSystem]].
+   *   - "FAILED_TO_GET_CHANGELOG_WRITER" error class (CANNOT_LOAD_STATE_STORE 
in
+   *     [[org.apache.spark.sql.errors.QueryExecutionErrors]]): RocksDB wraps 
the
+   *     underlying AccessDeniedException when changelog-writer creation fails.
+   */
+  protected def assertWriteProtectionFailure(e: Throwable): Unit = {
+    def matches(t: Throwable): Boolean = {
+      val msg = Option(t.getMessage).getOrElse("")
+      t.isInstanceOf[java.nio.file.AccessDeniedException] ||
+        msg.contains("Write-protected") ||
+        msg.contains("FAILED_TO_GET_CHANGELOG_WRITER")
+    }
+    val found = Iterator.iterate[Throwable](e)(_.getCause).takeWhile(_ != 
null).exists(matches)
+    assert(found,
+      s"Expected write-protection failure in cause chain, got: ${e.getClass}: 
${e.getMessage}")
+  }
+
+  /**
+   * Override testStream to temporarily suspend all protections during 
streaming query
+   * execution. Streaming queries write checkpoints, state, and metadata. 
After the
+   * query completes, protections are restored so subsequent reads are checked.
+   */
+  override def testStream(
+      _stream: org.apache.spark.sql.Dataset[_],
+      outputMode: org.apache.spark.sql.streaming.OutputMode =
+        org.apache.spark.sql.streaming.OutputMode.Append,
+      extraOptions: Map[String, String] = Map.empty,
+      sink: org.apache.spark.sql.execution.streaming.sources.MemorySink =
+        new org.apache.spark.sql.execution.streaming.sources.MemorySink())(
+      actions: StreamAction*): Unit = {
+    val saved = WriteProtectedPaths.snapshotAndClear()
+    try {
+      super.testStream(_stream, outputMode, extraOptions, sink)(actions: _*)
+    } finally {
+      WriteProtectedPaths.restore(saved)
+    }
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/OfflineStateRepartitionIntegrationSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/OfflineStateRepartitionIntegrationSuite.scala
index caf60d51ed62..26e94102a5c0 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/OfflineStateRepartitionIntegrationSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/OfflineStateRepartitionIntegrationSuite.scala
@@ -148,8 +148,10 @@ abstract class OfflineStateRepartitionIntegrationSuiteBase 
extends StateDataSour
       }
 
       // Step 3: Run repartition
-      spark.streamingCheckpointManager.repartition(
-        checkpointDir.getAbsolutePath, newPartitions)
+      withWritableCheckpoint {
+        spark.streamingCheckpointManager.repartition(
+          checkpointDir.getAbsolutePath, newPartitions)
+      }
 
       val repartitionBatchId = lastBatchId + 1
       val hadoopConf = spark.sessionState.newHadoopConf()
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/OperatorStateMetadataSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/OperatorStateMetadataSuite.scala
index 5fa8d86c21af..d7ae82e97e04 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/OperatorStateMetadataSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/OperatorStateMetadataSuite.scala
@@ -21,7 +21,7 @@ import org.apache.hadoop.fs.Path
 
 import org.apache.spark.SparkRuntimeException
 import org.apache.spark.sql.{Column, Row}
-import 
org.apache.spark.sql.execution.datasources.v2.state.{StateDataSourceUnspecifiedRequiredOption,
 StateSourceOptions}
+import 
org.apache.spark.sql.execution.datasources.v2.state.{StateDataSourceUnspecifiedRequiredOption,
 StateSourceOptions, WriteProtectedCheckpointTestMixin}
 import 
org.apache.spark.sql.execution.streaming.checkpointing.{CheckpointFileManager, 
OffsetSeq, OffsetSeqLog}
 import org.apache.spark.sql.execution.streaming.runtime.{LongOffset, 
MemoryStream}
 import 
org.apache.spark.sql.execution.streaming.runtime.StreamingCheckpointConstants.DIR_NAME_OFFSETS
@@ -30,7 +30,7 @@ import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.streaming.{OutputMode, 
RunningCountStatefulProcessor, StreamTest, TimeMode}
 import org.apache.spark.sql.streaming.OutputMode.{Complete, Update}
 
-class OperatorStateMetadataSuite extends StreamTest {
+class OperatorStateMetadataSuite extends StreamTest with 
WriteProtectedCheckpointTestMixin {
   import testImplicits._
 
   private lazy val hadoopConf = spark.sessionState.newHadoopConf()
@@ -70,15 +70,19 @@ class OperatorStateMetadataSuite extends StreamTest {
 
   test("Serialize and deserialize stateful operator metadata") {
     withTempDir { checkpointDir =>
-      val offsetLog = new OffsetSeqLog(spark,
-        new Path(checkpointDir.toString, DIR_NAME_OFFSETS).toString)
-      val batch0 = OffsetSeq.fill(LongOffset(0), LongOffset(1), LongOffset(2))
-      offsetLog.add(0, batch0)
-      val statePath = new Path(checkpointDir.toString, "state/0")
-      val stateStoreInfo = (1 to 4).map(i => StateStoreMetadataV1(s"store$i", 
1, 200))
-      val operatorInfo = OperatorInfoV1(1, "Join")
-      val operatorMetadata = OperatorStateMetadataV1(operatorInfo, 
stateStoreInfo.toArray)
-      new OperatorStateMetadataV1Writer(statePath, 
hadoopConf).write(operatorMetadata)
+      // Test setup writes the offset log and operator metadata directly to 
the checkpoint.
+      val operatorMetadata = withWritableCheckpoint {
+        val offsetLog = new OffsetSeqLog(spark,
+          new Path(checkpointDir.toString, DIR_NAME_OFFSETS).toString)
+        val batch0 = OffsetSeq.fill(LongOffset(0), LongOffset(1), 
LongOffset(2))
+        offsetLog.add(0, batch0)
+        val statePath = new Path(checkpointDir.toString, "state/0")
+        val stateStoreInfo = (1 to 4).map(i => 
StateStoreMetadataV1(s"store$i", 1, 200))
+        val operatorInfo = OperatorInfoV1(1, "Join")
+        val metadata = OperatorStateMetadataV1(operatorInfo, 
stateStoreInfo.toArray)
+        new OperatorStateMetadataV1Writer(statePath, 
hadoopConf).write(metadata)
+        metadata
+      }
       checkOperatorStateMetadata(checkpointDir.toString, 0, operatorMetadata)
       val df = spark.read.format("state-metadata").load(checkpointDir.toString)
       // Commit log is empty, there is no available batch id.
@@ -413,10 +417,12 @@ class OperatorStateMetadataSuite extends StreamTest {
         StopStream
       )
 
-      // Delete operator metadata path
-      val metadataPath = new Path(checkpointDir.toString, 
s"state/0/_metadata/metadata")
-      val fm = CheckpointFileManager.create(new 
Path(checkpointDir.getCanonicalPath), hadoopConf)
-      fm.delete(metadataPath)
+      // Delete operator metadata path (test setup mutates the checkpoint).
+      withWritableCheckpoint {
+        val metadataPath = new Path(checkpointDir.toString, 
s"state/0/_metadata/metadata")
+        val fm = CheckpointFileManager.create(new 
Path(checkpointDir.getCanonicalPath), hadoopConf)
+        fm.delete(metadataPath)
+      }
 
       // Restart the query
       testStream(aggregated, Complete)(
@@ -484,10 +490,13 @@ class OperatorStateMetadataSuite extends StreamTest {
         ProcessAllAvailable(),
         StopStream)
 
-      // Delete the state directory to simulate deleted state files
-      val stateDir = new Path(checkpointDir.toString, "state")
-      val fileManager = CheckpointFileManager.create(stateDir, hadoopConf)
-      fileManager.delete(stateDir)
+      // Delete the state directory to simulate deleted state files (test 
setup mutates
+      // the checkpoint).
+      withWritableCheckpoint {
+        val stateDir = new Path(checkpointDir.toString, "state")
+        val fileManager = CheckpointFileManager.create(stateDir, hadoopConf)
+        fileManager.delete(stateDir)
+      }
 
       // Restart the query - should fail with empty state directory error
       testStream(stream.dropDuplicates())(
@@ -520,11 +529,14 @@ class OperatorStateMetadataSuite extends StreamTest {
         ProcessAllAvailable(),
         StopStream)
 
-      // Delete the state directory if it exists (it shouldn't for stateless 
query)
-      val stateDir = new Path(checkpointDir.toString, "state")
-      val fileManager = CheckpointFileManager.create(stateDir, hadoopConf)
-      if (fileManager.exists(stateDir)) {
-        fileManager.delete(stateDir)
+      // Delete the state directory if it exists (it shouldn't for stateless 
query).
+      // Wrapped so the test-setup delete bypasses write protection.
+      withWritableCheckpoint {
+        val stateDir = new Path(checkpointDir.toString, "state")
+        val fileManager = CheckpointFileManager.create(stateDir, hadoopConf)
+        if (fileManager.exists(stateDir)) {
+          fileManager.delete(stateDir)
+        }
       }
 
       // Restart with a stateful operator added - should fail
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/StatePartitionAllColumnFamiliesWriterSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/StatePartitionAllColumnFamiliesWriterSuite.scala
index 22d0af0a77fd..66617a4d0908 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/StatePartitionAllColumnFamiliesWriterSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/StatePartitionAllColumnFamiliesWriterSuite.scala
@@ -83,24 +83,29 @@ class StatePartitionAllColumnFamiliesWriterSuite extends 
StateDataSourceTestBase
     val lastBatch = targetCheckpointMetadata.commitLog.getLatestBatchId().get
     val targetOffsetSeq = targetCheckpointMetadata.offsetLog.get(lastBatch).get
     val writeBatchId = lastBatch + 1
-    targetCheckpointMetadata.offsetLog.add(writeBatchId, targetOffsetSeq)
-
-    val rewriter = new StateRewriter(
-      spark,
-      readBatchId,
-      writeBatchId,
-      targetCpLocation,
-      hadoopConf,
-      readResolvedCheckpointLocation = Some(sourceCpLocation),
-      transformFunc = None,
-      writeCheckpointMetadata = Some(targetCheckpointMetadata)
-    )
-    val checkpointInfos = rewriter.run()
-
-    // Commit to commitLog with checkpoint IDs
-    val latestCommit = targetCheckpointMetadata.commitLog.get(lastBatch).get
-    val commitMetadata = latestCommit.withStateUniqueIds(checkpointInfos)
-    targetCheckpointMetadata.commitLog.add(writeBatchId, commitMetadata)
+    // Wrap the offset/commit-log writes and StateRewriter.run() (which writes 
state files
+    // and metadata into the target checkpoint) so they bypass write 
protection on the
+    // target temp dir. Subsequent state-data-source reads stay protected.
+    withWritableCheckpoint {
+      targetCheckpointMetadata.offsetLog.add(writeBatchId, targetOffsetSeq)
+
+      val rewriter = new StateRewriter(
+        spark,
+        readBatchId,
+        writeBatchId,
+        targetCpLocation,
+        hadoopConf,
+        readResolvedCheckpointLocation = Some(sourceCpLocation),
+        transformFunc = None,
+        writeCheckpointMetadata = Some(targetCheckpointMetadata)
+      )
+      val checkpointInfos = rewriter.run()
+
+      // Commit to commitLog with checkpoint IDs
+      val latestCommit = targetCheckpointMetadata.commitLog.get(lastBatch).get
+      val commitMetadata = latestCommit.withStateUniqueIds(checkpointInfos)
+      targetCheckpointMetadata.commitLog.add(writeBatchId, commitMetadata)
+    }
     val versionToCheck = writeBatchId + 1
 
     storeToColumnFamilies.foreach { case (storeName, columnFamilies) =>
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/StateStoreSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/StateStoreSuite.scala
index 65009a2c1c6b..b3d85c855e2f 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/StateStoreSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/state/StateStoreSuite.scala
@@ -1707,6 +1707,31 @@ class StateStoreSuite extends 
StateStoreSuiteBase[HDFSBackedStateStoreProvider]
     filePath.createNewFile()
   }
 
+  // The trait return type of replayStateFromSnapshot is the writable 
StateStore. On
+  // HDFS, the writable and read-only stores are separate classes, so a 
readOnly=true
+  // call cannot honor the flag through this method. The provider rejects it 
and directs
+  // callers to replayReadStateFromSnapshot. (RocksDB's StateStore class is 
unified and
+  // respects the flag internally, so RocksDB still implements both paths via 
this entry
+  // point.)
+  test("replayStateFromSnapshot rejects readOnly=true (use 
replayReadStateFromSnapshot)") {
+    tryWithProviderResource(newStoreProvider(opId = Random.nextInt(), 
partition = 0,
+        minDeltasForSnapshot = 1)) { provider =>
+      val s1 = provider.getStore(0)
+      put(s1, "a", 1, 1)
+      s1.commit()
+      val s2 = provider.getStore(1)
+      put(s2, "a", 2, 2)
+      s2.commit()
+      provider.doMaintenance()
+
+      val ex = intercept[IllegalArgumentException] {
+        provider.asInstanceOf[SupportsFineGrainedReplay]
+          .replayStateFromSnapshot(1, 2, readOnly = true)
+      }
+      assert(ex.getMessage.contains("replayReadStateFromSnapshot"))
+    }
+  }
+
   override protected def testQuietly(name: String)(f: => Unit): Unit = {
     // Use the implementation from StateStoreSuiteBase.
     // There is another in QueryTest. Doing this to avoid conflict error.


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


Reply via email to