hudi-agent commented on code in PR #19737:
URL: https://github.com/apache/hudi/pull/19737#discussion_r3859746320
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/blob/BlobTestHelpers.scala:
##########
@@ -71,6 +75,37 @@ object BlobTestHelpers {
).as(name, blobMetadata)
}
+ /**
+ * Exposes the local filesystem under the given non-local schemes, so a test
can tell apart storage
+ * resolved from a path and storage resolved from a default file:/// URI
without a remote object
+ * store.
+ *
+ * Hadoop caches filesystems statically, keyed by scheme and authority
rather than by
+ * configuration, so a borrowed impl would outlive the test for the whole
fork and the next test to
+ * use that scheme would silently read local files. Both the configuration
and the cache are put
+ * back afterwards.
+ */
+ def withBorrowedSchemes[T](hadoopConf: Configuration, authorities:
Seq[String], schemes: String*)(body: => T): T = {
Review Comment:
🤖 nit: `authorities` is `Seq[String]` while `schemes` is `String*` — a
caller has to wrap one but not the other, which is an inconsistency that only
shows up when you try to call it. Could you make both consistent, e.g.
`schemes: Seq[String]` (and move `body` to a separate parameter list), or flip
`authorities` to varargs if the single-authority case is the common one?
<sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag
quality.</i></sub>
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/blob/TestBatchedBlobReader.scala:
##########
@@ -454,4 +461,147 @@ class TestBatchedBlobReader extends HoodieClientTestBase {
assertTrue(thrown.getCause.isInstanceOf[IllegalArgumentException])
assertTrue(thrown.getCause.getMessage.contains("Overlapping blob ranges
detected"))
}
+
+ /**
+ * Blob references are absolute paths carried in row data, so the filesystem
a partition must read
+ * is not known until the rows arrive. These tests put the referenced files
behind an object-store
+ * scheme, which is where storage resolved from a default {@code file:///}
URI fails with
+ * {@code IllegalArgumentException: Wrong FS: s3a://..., expected: file:///}.
+ */
+ @Test
+ def testRangedReadOfAReferenceOnANonLocalScheme(): Unit = {
+ val localPath = createTestFile(tempDir, "ranged-non-local.bin", 10000)
+ val inputDF = sparkSession.createDataFrame(Seq(
+ (onScheme("s3a", localPath), 0L, 100L),
+ (onScheme("s3a", localPath), 100L, 100L)
+ )).toDF("external_path", "offset", "length")
+ .withColumn("data", blobStructCol("data", col("external_path"),
col("offset"), col("length")))
+ .select("offset", "data")
+ .coalesce(1)
+
+ val results = BatchedBlobReader.readBatched(inputDF,
nonLocalSchemeStorageConf).orderBy("offset").collect()
+
+ assertEquals(2, results.length)
+ results.zipWithIndex.foreach { case (row, i) =>
+ val data = row.getAs[Array[Byte]]("data")
+ assertEquals(100, data.length)
+ assertBytesContent(data, expectedOffset = i * 100)
+ }
+ }
+
+ @Test
+ def testWholeFileReadOfAReferenceOnANonLocalScheme(): Unit = {
+ val localPath = createTestFile(tempDir, "whole-non-local.bin", 512)
+ val inputDF = sparkSession.createDataFrame(Seq(Tuple1(onScheme("gs",
localPath))))
+ .toDF("external_path")
+ .withColumn("data", wholeFileBlobStructCol("data", col("external_path")))
+ .select("data")
+ .coalesce(1)
+
+ val results = BatchedBlobReader.readBatched(inputDF,
nonLocalSchemeStorageConf).collect()
+
+ assertEquals(1, results.length)
+ val data = results(0).getAs[Array[Byte]]("data")
+ assertEquals(512, data.length)
+ assertBytesContent(data)
+ }
+
+ /**
+ * One partition referencing two buckets on the same scheme. Hadoop keys its
filesystem cache by
+ * scheme and authority and FileSystem.checkPath validates both, so a reader
that resolved storage
+ * once would serve the second bucket through the first bucket's handle and
fail with
+ * Wrong FS: s3a://bucket-b/..., expected: s3a://bucket-a.
+ */
+ @Test
+ def testReferencesInTwoBucketsInOnePartition(): Unit = {
+ val firstPath = createTestFile(tempDir, "two-bucket-first.bin", 1000)
+ val secondPath = createTestFile(tempDir, "two-bucket-second.bin", 1000)
+ val inputDF = sparkSession.createDataFrame(Seq(
+ ("a", s"s3a://bucket-a$firstPath", 0L, 100L),
+ ("b", s"s3a://bucket-b$secondPath", 0L, 100L)
+ )).toDF("bucket", "external_path", "offset", "length")
+ .withColumn("data", blobStructCol("data", col("external_path"),
col("offset"), col("length")))
+ .select("bucket", "data")
+ .coalesce(1)
+
+ val results = BatchedBlobReader.readBatched(inputDF,
nonLocalSchemeStorageConf).orderBy("bucket").collect()
+
+ assertEquals(2, results.length)
+ assertEquals(Seq("a", "b"), results.map(_.getAs[String]("bucket")).toSeq)
+ results.foreach { row =>
+ val data = row.getAs[Array[Byte]]("data")
+ assertEquals(100, data.length)
+ assertBytesContent(data)
+ }
+ }
+
+ /**
+ * Every storage the reader resolves is closed. HoodieHadoopStorage.close is
a no-op because it
+ * does not own the cached Hadoop filesystem, but hoodie.storage.class is
pluggable and another
+ * implementation may hold resources, so the reader must not leak the
handles it creates.
+ */
+ @Test
+ def testResolvedStorageIsClosed(): Unit = {
+ val localPath = createTestFile(tempDir, "counted-close.bin", 1000)
+ val conf = nonLocalSchemeStorageConf
+ conf.set(HoodieStorageConfig.HOODIE_STORAGE_CLASS.key,
classOf[CountingHoodieStorage].getName)
+
+ val inputDF = sparkSession.createDataFrame(Seq(
+ (onScheme("s3a", localPath), 0L, 100L),
+ (onScheme("s3a", localPath), 500L, 100L)
+ )).toDF("external_path", "offset", "length")
+ .withColumn("data", blobStructCol("data", col("external_path"),
col("offset"), col("length")))
+ .select("offset", "data")
+ .coalesce(1)
+
+ CountingHoodieStorage.reset()
+ val results = BatchedBlobReader.readBatched(inputDF, conf).collect()
+ assertEquals(2, results.length)
+
+ val constructed = CountingHoodieStorage.constructed.get()
+ assertTrue(constructed > 0, "the reader should have resolved storage at
least once")
+ assertEquals(constructed, CountingHoodieStorage.closed.get(),
+ s"every resolved storage must be closed, constructed $constructed")
+ }
+
+ /**
+ * Harness sanity, so none of the tests above can pass for the wrong reason:
the borrowed schemes
+ * must reach the local file the test wrote, and must not be silently
rewritten to {@code file}.
+ */
+ @Test
+ def testBorrowedSchemesReachLocalFiles(): Unit = {
+ val localPath = createTestFile(tempDir, "harness-sanity.bin", 256)
+ val conf = nonLocalSchemeStorageConf
+ Seq("s3a", "gs").foreach { scheme =>
+ val path = new StoragePath(onScheme(scheme, localPath))
+ val storage = HoodieStorageUtils.getStorage(path, conf)
+ try {
+ assertEquals(scheme, storage.getScheme)
+ assertEquals(256, storage.getPathInfo(path).getLength)
+ } finally {
+ storage.close()
+ }
+ }
+ val localStorage = HoodieTestUtils.getLocalStorage(conf)
+ try {
+ assertEquals("file", localStorage.getScheme)
+ } finally {
+ localStorage.close()
+ }
+ }
+
+ private def onScheme(scheme: String, localPath: String): String =
s"$scheme://test-bucket$localPath"
+
+ /**
+ * The table's storage configuration with the local filesystem also exposed
under two object-store
+ * schemes. Neither scheme has a real implementation on this module's test
classpath, so nothing
+ * else claims them.
+ */
+ private def nonLocalSchemeStorageConf: StorageConfiguration[_] = {
Review Comment:
🤖 nit: in Scala, a parameterless `def` without `()` signals a stable,
side-effect-free property — calling it twice is expected to be the same as
calling it once. This method allocates a fresh `Configuration` each time, so it
should carry `()` (i.e. `def nonLocalSchemeStorageConf():
StorageConfiguration[_]`) to signal that to callers.
<sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag
quality.</i></sub>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]