Repository navigation
Conversation
…batches Arrow 18.3.0's ZstdCompressionCodec allocates compressBound(n) + 8 bytes, points zstd 8 bytes in past the length prefix, but still passes compressBound(n) + 8 as the capacity. zstd fills the destination up to the capacity it is given while entropy-coding a block that turns out not to compress, so the attempt can write up to 8 bytes past the buffer. The allocator's power-of-two rounding hides it except where the request lands within 8 bytes below a power of two, and without NMT the overrun is silent. Use a subclass whose doCompress passes compressBound(n), as the upstream fix (apache/arrow-java#1119, Arrow 20.0.0) does, and test the codec through an allocator that places a sentinel guard behind every buffer. Closes apache#7305
comphead
left a comment
There was a problem hiding this comment.
Thanks, the diagnosis checks out. I ran the codec's capacity math in a standalone Java program against zstd-jni 1.5.2-5, 1.5.5-4, 1.5.6-9, 1.5.7-9 and 1.5.7-23, using this suite's inputs and a guard right after the compressBound(n) + 8 buffer. On every version, 298 of the 1600 inputs wrote past the buffer with Arrow's capacity and none did with compressBound(n). So the "Arrow's zstd codec writes past" test should hold on every Spark profile. This was a local probe, not a run of the suite.
Two points outside the diff:
CometInMemoryCacheSuitestill constructs Arrow'snew ZstdCompressionCodec(1)directly in the empty-batch test, inFailAfterCompressionCodecand in two other write-path tests. Those now exercise a codec the write path no longer uses.new CometZstdCompressionCodec(1)would keep them on the shipped one.- Spark master has the same exposure.
ArrowCachedBatchSerializerandArrowCompressionUtilsconstruct Arrow'sZstdCompressionCodecon Arrow 19.0.0, which does not have the upstream fix. It may be worth a SPARK JIRA.
| import com.github.luben.zstd.Zstd | ||
|
|
||
| /** | ||
| * Checks that the zstd codec never writes past the buffer it allocates for its output. |
There was a problem hiding this comment.
The overrun is explained in full in the CometZstdCompressionCodec scaladoc, again here, and a third time on exposedLengths. A one-line pointer to the codec's scaladoc would keep a single copy.
There was a problem hiding this comment.
Done, the suite header now just points to the CometZstdCompressionCodec scaladoc, and exposedLengths is gone.
| * hides the overrun. 437 bytes, the size in #7305, is one of them: compressBound(437) + 8 is | ||
| * 509, allocated as 512. | ||
| */ | ||
| private val exposedLengths: Seq[Int] = (1 to 8192).filter { n => |
There was a problem hiding this comment.
GuardedAllocator cuts every slice to the exact requested size, so the guard catches the overrun at any length, not only where power-of-two rounding leaves less than 8 bytes of slack. In the same local probe, Arrow's capacity overran on 19177 of 49550 inputs at the other lengths below 1024. A plain range such as 400 to 600 would drive both tests and drop the rounding math and its doc comment.
There was a problem hiding this comment.
Good point. Replaced it with 400 to 600 and dropped the rounding math and its doc comment. The Arrow test still sees overruns with the guard.
| withAllocators { (root, guarded) => | ||
| val codec = new CometZstdCompressionCodec(1) | ||
| var raw = 0 | ||
| var packed = 0 |
There was a problem hiding this comment.
packed is counted but never read. Adding a few compressible inputs to this loop and asserting packed should be > 0 would cover the compressed outcome under the guard and let the next test go. Otherwise it can be dropped.
There was a problem hiding this comment.
Added compressible inputs to that loop and it now asserts packed > 0 alongside raw > 0.
| } | ||
| } | ||
|
|
||
| test("Comet's zstd codec round-trips compressible and empty input") { |
There was a problem hiding this comment.
This mostly repeats existing coverage. Empty input never reaches doCompress, because AbstractCompressionCodec.compress returns early when writerIndex() is 0. The compressed round trip through the config is covered by "Comet in-memory cache round-trips under every compression codec" in CometInMemoryCacheSuite, which now goes through this codec. The only new check is the guard on a compressed outcome, which fits in the previous test.
There was a problem hiding this comment.
Agreed, removed. The compressed outcome under the guard is covered by the previous test now, and the config round trip is in CometInMemoryCacheSuite.
| } | ||
| } | ||
|
|
||
| test("the guard notices a write past the buffer") { |
There was a problem hiding this comment.
The first test already shows that the guard detects an overrun, since it asserts overruns > 0 through the same GuardedAllocator. Once Arrow is bumped, that test, the codec and this suite go together, so this test never adds coverage beyond it.
There was a problem hiding this comment.
Agreed, removed this one too. The first test already shows the guard detects an overrun.
|
Thanks for the review. I pushed 2c30c57 with the suite changes above. On Spark having the same exposure: agreed, I read the 19.0.0 bytecode and |
Which issue does this PR close?
Closes #7305.
Rationale for this change
An executor JVM with Native Memory Tracking enabled aborted with
NMT corruption: ... footer canary broken (buffer overflow?)while materializing Comet's in-memory cache with the defaultzstdcodec. The same job passes withlz4.The cause is in Arrow 18.3.0's
ZstdCompressionCodec.doCompress. It allocatescompressBound(n) + 8bytes, points zstd 8 bytes in (past the uncompressed-length prefix), but still tells zstd the capacity iscompressBound(n) + 8. zstd guarantees that it never writes past the capacity it is given, not that it never writes pastcompressBound: while it entropy-codes the literals of a block that turns out not to compress, it fills the destination up to that capacity before falling back to a stored block. The attempt can therefore write up to 8 bytes past the buffer.Two things make this rare. The allocator rounds requests up to a power of two, so the overrun lands in slack unless
compressBound(n) + 8is within 8 bytes below a power of two (n = 432..440 for 512, and so on; the block in #7305 was 437 bytes). And without NMT the extra bytes land in the allocator's padding or in whatever sits next, with no error at all; with NMT the footer canary sits right after the buffer and the JVM aborts when the discarded output is freed, which is why the crashed block had a zstd frame but no length prefix.A standalone program that feeds Arrow's codec 437-byte inputs reproduces the exact abort in about a second under
-XX:NativeMemoryTracking=detail; with the corrected capacity it runs clean. Arrow fixed this in apache/arrow-java#1119 (reported as apache/arrow-java#1116) for Arrow 20.0.0, which has not been released.What changes are included in this PR?
CometZstdCompressionCodec, a subclass of Arrow's codec whosedoCompresspassescompressBound(n)as the capacity, as the upstream fix does. Decompression is inherited unchanged.CachedBatchIpc.compressionCodecbuilds that subclass forzstd. It is the only place Comet compresses with Arrow's zstd codec.The subclass can be removed once Comet moves to an Arrow release that contains the upstream fix.
How are these changes tested?
New
CometZstdCompressionCodecSuite. It compresses through aBufferAllocatorthat hands the codec exactly the capacity it requests and keeps a sentinel-filled guard region behind every buffer, so an overrun shows up as a changed sentinel rather than as silent corruption. The inputs are the ones that provoke the bug: nearly incompressible bytes with scattered repeated values, in every length up to 8192 whose output buffer lands within 8 bytes below a power of two. The suite shows Arrow's codec overruns on those inputs, that Comet's codec does not, that both the stored-raw and the compressed outcomes round-trip, and that the guard itself detects a one-byte overrun.Also verified with the standalone reproducer under
-XX:NativeMemoryTracking=detail: Arrow's codec aborts the JVM, the fixed codec does not.