dongjoon-hyun commented on code in PR #59922:
URL: https://github.com/apache/spark/pull/59922#discussion_r4416019839


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala:
##########
@@ -85,4 +93,75 @@ class ArrowCompressionUtilsSuite extends SparkFunSuite {
     }
     assert(e.getMessage.contains("Unsupported Arrow compression codec: 
snappy"))
   }
+
+  test("SPARK-60707: zstd compression does not write past the end of its 
output buffer") {
+    val guardSize = 64
+    val guardByte = 0x5A.toByte
+    // Pads each output buffer with sentinel bytes so that an overrun is 
detectable.
+    var requestedSize = 0L
+    val guardedAllocator = new RootAllocator(Long.MaxValue) {
+      override def buffer(size: Long): ArrowBuf = {
+        requestedSize = size
+        val buf = super.buffer(size + guardSize)
+        buf.setBytes(size, Array.fill(guardSize)(guardByte))
+        buf
+      }
+    }
+    val inputAllocator =
+      ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName, 
0, Long.MaxValue)
+    val codec = new RawBoundedZstdCompressionCodec
+    val random = new Random(0)
+    try {
+      // Random bytes do not compress, which is what makes zstd fill the whole 
destination.
+      Seq(1, 7, 100, 280, 500, 4096, 65536, 131072, 262144).foreach { n =>
+        val input = inputAllocator.buffer(n)
+        val out = try {
+          input.setBytes(0, random.nextBytes(n))
+          input.writerIndex(n)
+          codec.compressRaw(guardedAllocator, input)
+        } finally {
+          input.close()
+        }
+        try {
+          val guard = new Array[Byte](guardSize)
+          out.getBytes(requestedSize, guard)
+          assert(guard.forall(_ == guardByte),
+            s"zstd wrote past the end of a $requestedSize byte output buffer 
(input size $n)")
+        } finally {
+          out.close()
+        }
+      }
+    } finally {
+      guardedAllocator.close()
+      inputAllocator.close()
+    }
+  }
+
+  test("SPARK-60707: zstd codec round-trips compressible and incompressible 
buffers") {
+    val allocator =
+      ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName, 
0, Long.MaxValue)
+    val codec = ArrowCompressionUtils.createCompressionCodec("zstd", 3)
+    val random = new Random(1)
+    try {
+      Seq(1, 100, 4096, 100000).foreach { n =>
+        val compressible = Array.tabulate[Byte](n)(i => (i % 7).toByte)
+        val incompressible = random.nextBytes(n)

Review Comment:
   **2. [Low] The incompressible inputs never reach zstd decompression**
   
   For random bytes, zstd's output is at least n + 9 bytes (frame header plus a 
raw block header), so `AbstractCompressionCodec.compress` drops it and stores 
the input raw behind a `-1` prefix, and `decompress` then just slices it. The 
1-byte compressible input takes the same path. These are the frame sizes for 
this test's inputs:
   
   | Input | n | zstd frame (bytes) | Stored as |
   |---|---|---|---|
   | compressible | 1 | 10 | raw |
   | compressible | 100, 4096, 100000 | 23, 24, 27 | zstd |
   | incompressible | 1, 100, 4096, 100000 | 10, 109, 4106, 100012 | raw |
   
   So only three cases decode output from `BoundedZstdCompressionCodec`, and 
the incompressible half checks Arrow's raw fallback. Could the test assert the 
length prefix before decompressing, for example `n` for the compressible inputs 
of 100 bytes or more and `-1` for the rest, so that each case shows which path 
it takes?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala:
##########
@@ -85,4 +93,75 @@ class ArrowCompressionUtilsSuite extends SparkFunSuite {
     }
     assert(e.getMessage.contains("Unsupported Arrow compression codec: 
snappy"))
   }
+
+  test("SPARK-60707: zstd compression does not write past the end of its 
output buffer") {
+    val guardSize = 64
+    val guardByte = 0x5A.toByte
+    // Pads each output buffer with sentinel bytes so that an overrun is 
detectable.
+    var requestedSize = 0L
+    val guardedAllocator = new RootAllocator(Long.MaxValue) {
+      override def buffer(size: Long): ArrowBuf = {
+        requestedSize = size
+        val buf = super.buffer(size + guardSize)
+        buf.setBytes(size, Array.fill(guardSize)(guardByte))
+        buf
+      }
+    }
+    val inputAllocator =
+      ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName, 
0, Long.MaxValue)
+    val codec = new RawBoundedZstdCompressionCodec
+    val random = new Random(0)
+    try {
+      // Random bytes do not compress, which is what makes zstd fill the whole 
destination.
+      Seq(1, 7, 100, 280, 500, 4096, 65536, 131072, 262144).foreach { n =>

Review Comment:
   **1. [Medium] The guard test catches the original bug only at n = 500, and 
only with this seed**
   
   The guard itself works: with Arrow's stock `ZstdCompressionCodec`, this test 
fails at n = 500 as the PR description says. But that is the only size where it 
fails. I replayed these inputs with zstd-jni 1.5.7-23 alone 
(`Zstd.compressUnsafe` into native memory with a 64-byte guard, no Arrow or 
Spark code), once with Arrow 19's capacity (`compressBound(n) + 8`, starting 8 
bytes in) and once with this PR's (`compressBound(n)`):
   
   | n | Stock: bytes written past the buffer | This PR |
   |---|---|---|
   | 1, 7, 100, 280 | 0 | 0 |
   | 500 | 8 | 0 |
   | 4096, 65536, 131072, 262144 | 0 | 0 |
   
   The comment on line 115 does not describe why n = 500 fails. Random bytes do 
not make zstd fill its destination: they go out as raw blocks, and for every 
other size zstd stops 51 to 1005 bytes before the end of the buffer. zstd 
reaches the end only when it tries to Huffman-code the literals and then gives 
up, and it tries that only when the most common byte value occurs more than `(n 
>> 7) + 4` times. The 500 bytes drawn here happen to contain one value 8 times, 
against a limit of 7.
   
   So the test depends on the seed and on the order of the sizes. With the same 
size list, 782 of the seeds 0 to 999 never write past the buffer with the stock 
codec. With seed 0, reversing the list or adding a 1000-byte case before 500 
also hides the overrun. A later change to either would leave this test passing 
against the old `doCompress`.
   
   Could the test try many seeds at n = 500, or assert as a control that the 
stock codec does reach the guard for the chosen input? With uniform bytes, 
about 20% of seeds overrun at n = 500; with about 2% of the bytes forced to 0, 
about 92% do. The stock overrun is at most 8 bytes, so it stays inside the 
64-byte guard. Such a control would also start failing once Arrow 20.0.0 fixes 
the codec, which is when this class can be removed.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ArrowCachedBatchSerializer.scala:
##########
@@ -267,18 +266,7 @@ private object ArrowCachedBatchSerializer {
   def createCompressionCodec(
       codecName: String,
       compressionLevel: Int): CompressionCodec = {
-    codecName.toLowerCase match {
-      case "none" => NoCompressionCodec.INSTANCE
-      // The codec instance must be constructed directly so that 
compressionLevel is honored:
-      // CompressionCodec.Factory.createCodec(codecType) ignores the level and 
builds a codec at
-      // the default level. The level only matters on the write side; the read 
side looks up the
-      // codec by the type recorded in the IPC message.
-      case "zstd" => new ZstdCompressionCodec(compressionLevel)
-      case "lz4" => new Lz4CompressionCodec()
-      case other =>
-        throw SparkException.internalError(
-          s"Unsupported Arrow compression codec: $other. Supported values: 
none, zstd, lz4")
-    }
+    ArrowCompressionUtils.createCompressionCodec(codecName.toLowerCase, 
compressionLevel)

Review Comment:
   **4. [Nit] `ArrowCachedBatchSerializer.createCompressionCodec` is now a 
pass-through**
   
   Both callers (lines 996 and 1103) pass `conf.arrowCompressionCodec`, which 
SQLConf already lowercases with `Locale.ROOT` and checks against `none`, `zstd` 
and `lz4`, so `toLowerCase` and the `caselocale` suppression around it do 
nothing. The other three write paths call 
`ArrowCompressionUtils.createCompressionCodec` directly. The delegation also 
turns `ArrowCachedBatchSerializerSuite`'s "zstd compression level is honored by 
createCompressionCodec" into a copy of `ArrowCompressionUtilsSuite`'s 
SPARK-57383 test: same data, same levels, same assertion.
   
   Could both callers use `ArrowCompressionUtils.createCompressionCodec` and 
that test go? The two belong together: while the wrapper stays, that test is 
the only check that it passes the level through. `CompressionCodec` in the 
import on line 28 and `VectorUnloader` in the suite would then be unused.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala:
##########
@@ -85,4 +93,75 @@ class ArrowCompressionUtilsSuite extends SparkFunSuite {
     }
     assert(e.getMessage.contains("Unsupported Arrow compression codec: 
snappy"))
   }
+
+  test("SPARK-60707: zstd compression does not write past the end of its 
output buffer") {
+    val guardSize = 64
+    val guardByte = 0x5A.toByte
+    // Pads each output buffer with sentinel bytes so that an overrun is 
detectable.
+    var requestedSize = 0L
+    val guardedAllocator = new RootAllocator(Long.MaxValue) {
+      override def buffer(size: Long): ArrowBuf = {
+        requestedSize = size
+        val buf = super.buffer(size + guardSize)
+        buf.setBytes(size, Array.fill(guardSize)(guardByte))
+        buf
+      }
+    }
+    val inputAllocator =
+      ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName, 
0, Long.MaxValue)
+    val codec = new RawBoundedZstdCompressionCodec
+    val random = new Random(0)
+    try {
+      // Random bytes do not compress, which is what makes zstd fill the whole 
destination.
+      Seq(1, 7, 100, 280, 500, 4096, 65536, 131072, 262144).foreach { n =>
+        val input = inputAllocator.buffer(n)
+        val out = try {
+          input.setBytes(0, random.nextBytes(n))
+          input.writerIndex(n)
+          codec.compressRaw(guardedAllocator, input)
+        } finally {
+          input.close()
+        }
+        try {
+          val guard = new Array[Byte](guardSize)
+          out.getBytes(requestedSize, guard)
+          assert(guard.forall(_ == guardByte),
+            s"zstd wrote past the end of a $requestedSize byte output buffer 
(input size $n)")
+        } finally {
+          out.close()
+        }
+      }
+    } finally {
+      guardedAllocator.close()
+      inputAllocator.close()
+    }
+  }
+
+  test("SPARK-60707: zstd codec round-trips compressible and incompressible 
buffers") {
+    val allocator =
+      ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName, 
0, Long.MaxValue)
+    val codec = ArrowCompressionUtils.createCompressionCodec("zstd", 3)
+    val random = new Random(1)
+    try {
+      Seq(1, 100, 4096, 100000).foreach { n =>
+        val compressible = Array.tabulate[Byte](n)(i => (i % 7).toByte)
+        val incompressible = random.nextBytes(n)
+        Seq(compressible, incompressible).foreach { expected =>
+          val input = allocator.buffer(n)
+          input.setBytes(0, expected)
+          input.writerIndex(n)
+          val decompressed = codec.decompress(allocator, 
codec.compress(allocator, input))
+          try {
+            val actual = new Array[Byte](n)
+            decompressed.getBytes(0, actual)
+            assert(actual.sameElements(expected))
+          } finally {
+            decompressed.close()
+          }
+        }
+      }
+    } finally {
+      allocator.close()

Review Comment:
   **5. [Nit] A failure inside the round-trip loop is reported as a memory 
leak**
   
   `AbstractCompressionCodec.compress` closes its input, and `decompress` its 
compressed buffer, only on success. If either throws, that buffer stays 
allocated, so `allocator.close()` here throws `IllegalStateException` ("Memory 
was leaked by query ...") and the zstd error is lost. For example, a 
`doCompress` that sets `writerIndex(bytesWritten)` without the 8-byte prefix 
keeps a truncated frame for the 100-byte compressible case, `doDecompress` 
fails with "Src size is incorrect", and the test reports the leak instead. 
`Utils.tryWithSafeFinally { ... } { allocator.close() }` would keep the first 
error.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtils.scala:
##########
@@ -40,11 +42,41 @@ private[sql] object ArrowCompressionUtils {
       zstdCompressionLevel: Int): CompressionCodec = {
     codecName match {
       case "none" => NoCompressionCodec.INSTANCE
-      case "zstd" => new ZstdCompressionCodec(zstdCompressionLevel)
+      case "zstd" => new BoundedZstdCompressionCodec(zstdCompressionLevel)

Review Comment:
   **3. [Low] The scaladoc of `createCompressionCodec` explains only the level, 
not the bounded codec**
   
   With the copy in `ArrowCachedBatchSerializer` gone, the scaladoc above is 
the only place that says why the codecs are constructed directly, and it gives 
only the level: `createCodec(codecType)` drops it. Arrow 19 also has 
`CompressionCodec.Factory.INSTANCE.createCodec(codecType, compressionLevel)`, 
which keeps the level but returns the stock codec (`CommonsCompressionFactory` 
calls `new ZstdCompressionCodec(int)`). Switching this line to that overload 
would pass the level tests and bring the overrun back. The `isInstanceOf` check 
suggested in [this 
comment](https://github.com/apache/spark/pull/59922#discussion_r4414638639) 
would catch that in tests, but the scaladoc would still suggest that only the 
level matters. Could it add a sentence such as:
   
   ```scala
      * For zstd it must also be a [[BoundedZstdCompressionCodec]], which 
neither factory overload
      * returns, not even `createCodec(codecType, compressionLevel)`, which 
does keep the level.
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtils.scala:
##########
@@ -40,11 +42,41 @@ private[sql] object ArrowCompressionUtils {
       zstdCompressionLevel: Int): CompressionCodec = {
     codecName match {
       case "none" => NoCompressionCodec.INSTANCE
-      case "zstd" => new ZstdCompressionCodec(zstdCompressionLevel)
+      case "zstd" => new BoundedZstdCompressionCodec(zstdCompressionLevel)
       case "lz4" => new Lz4CompressionCodec()
       case other =>
         throw SparkException.internalError(
           s"Unsupported Arrow compression codec: $other. Supported values: 
none, zstd, lz4")
     }
   }
 }
+
+/**
+ * A [[ZstdCompressionCodec]] that never lets zstd write past its output 
buffer. Arrow 18.3 and

Review Comment:
   **6. [Nit] The bug is older than Arrow 18.3**
   
   The same `dstSize` is already passed in the first zstd codec (ARROW-12110, 
Arrow 4.0.0), and in every release tag I checked up to 19.0.0. 18.3 and 19.0 
are the versions Spark ships with this write path, so nothing is wrong for 
Spark, but "Arrow 18.3 and 19.0 pass ..." reads as if earlier releases were 
fine. Maybe "arrow-java up to 19.0 passes ..."? The PR description has the same 
wording.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to