Skip to content

Commit 8e6bcbf

Browse files
authored
Fix SpillablePartialFileHandle read overflow when written more than 2GB (#15327)
Fixes #15325 ### Description This is a bug where the length of the `SpillablePartialFileHandle` (actually written) was larger than Int.MaxValue, and we overflowed while reading, due to casting we were doing too early. I refactored it a bit to be able to test the functionality. I believe this to be small/targeted enough that I don't see a need to re-run performance runs when we have spilled partial file handles. ### Checklists Documentation - [ ] Updated for new or modified user-facing features or behaviors - [x] No user-facing change Testing - [x] Added or modified tests to cover new code paths - [ ] Covered by existing tests (Please provide the names of the existing tests in the PR description.) - [ ] Not required Performance - [ ] Tests ran and results are added in the PR description - [ ] Issue filed with a link in the PR description - [x] Not required Signed-off-by: Alessandro Bellina <abellina@nvidia.com>
1 parent 8e360c1 commit 8e6bcbf

2 files changed

Lines changed: 98 additions & 5 deletions

File tree

sql-plugin/src/main/scala/com/nvidia/spark/rapids/spill/SpillablePartialFileHandle.scala

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ object PartialFileStorageMode extends Enumeration {
5959
* @param memoryThreshold Host memory usage threshold for buffer expansion decisions
6060
* @param priority Spill priority for memory-based mode
6161
* @param syncWrites Whether to force outstanding writes to disk
62+
* @param bufferedOutputStreamFactory Creates buffered streams for file writes
6263
* @param capacityHintProvider Optional function that provides capacity hints based on
6364
* current bytes written and required capacity. When provided,
6465
* buffer expansion will use this hint instead of simple doubling.
@@ -73,6 +74,7 @@ class SpillablePartialFileHandle private (
7374
memoryThreshold: Double,
7475
priority: Long,
7576
syncWrites: Boolean,
77+
bufferedOutputStreamFactory: FileOutputStream => BufferedOutputStream,
7678
capacityHintProvider: Option[(Long, Long) => Long])
7779
extends HostSpillableHandle[ai.rapids.cudf.HostMemoryBuffer] with Logging {
7880

@@ -407,7 +409,8 @@ class SpillablePartialFileHandle private (
407409
return -1 // EOF
408410
}
409411

410-
val actualLength = math.min(length, (totalBytesWritten - readPosition).toInt)
412+
val actualLength = SpillablePartialFileHandle.boundedReadLengthAsInt(
413+
length, totalBytesWritten - readPosition)
411414

412415
def readFromFile(): Int = {
413416
ensureFileInputStreamOpen()
@@ -475,7 +478,8 @@ class SpillablePartialFileHandle private (
475478
return -1
476479
}
477480

478-
val actualLength = math.min(length, (totalBytesWritten - position).toInt)
481+
val actualLength = SpillablePartialFileHandle.boundedReadLengthAsInt(
482+
length, totalBytesWritten - position)
479483
if (actualLength <= 0) {
480484
return -1
481485
}
@@ -698,7 +702,7 @@ class SpillablePartialFileHandle private (
698702
if (fileOutputStream.isEmpty) {
699703
val fos = new FileOutputStream(file, true) // append mode
700704
fileOutputStream = Some(fos)
701-
bufferedOutputStream = Some(new BufferedOutputStream(fos, 64 * 1024))
705+
bufferedOutputStream = Some(bufferedOutputStreamFactory(fos))
702706
}
703707
}
704708

@@ -800,6 +804,14 @@ class SpillablePartialFileHandle private (
800804

801805
object SpillablePartialFileHandle extends Logging {
802806

807+
private val DEFAULT_FILE_BUFFER_SIZE = 64 * 1024
808+
809+
private[spill] def boundedReadLengthAsInt(requestedLength: Int, remainingBytes: Long): Int = {
810+
require(requestedLength >= 0, s"requestedLength must be non-negative: $requestedLength")
811+
require(remainingBytes >= 0, s"remainingBytes must be non-negative: $remainingBytes")
812+
math.min(requestedLength.toLong, remainingBytes).toInt
813+
}
814+
803815
/**
804816
* Create a file-only handle.
805817
* Data is written directly to disk without using host memory.
@@ -817,6 +829,24 @@ object SpillablePartialFileHandle extends Logging {
817829
memoryThreshold = 0.0,
818830
priority = Long.MinValue,
819831
syncWrites = syncWrites,
832+
bufferedOutputStreamFactory = new BufferedOutputStream(_, DEFAULT_FILE_BUFFER_SIZE),
833+
capacityHintProvider = None)
834+
}
835+
836+
private[spill] def createFileOnly(
837+
file: File,
838+
syncWrites: Boolean,
839+
bufferedOutputStreamFactory: FileOutputStream => BufferedOutputStream):
840+
SpillablePartialFileHandle = {
841+
new SpillablePartialFileHandle(
842+
storageMode = PartialFileStorageMode.FILE_ONLY,
843+
file = file,
844+
initialCapacity = 0L,
845+
maxBufferSize = 0L,
846+
memoryThreshold = 0.0,
847+
priority = Long.MinValue,
848+
syncWrites = syncWrites,
849+
bufferedOutputStreamFactory = bufferedOutputStreamFactory,
820850
capacityHintProvider = None)
821851
}
822852

@@ -857,6 +887,7 @@ object SpillablePartialFileHandle extends Logging {
857887
memoryThreshold = memoryThreshold,
858888
priority = priority,
859889
syncWrites = syncWrites,
890+
bufferedOutputStreamFactory = new BufferedOutputStream(_, DEFAULT_FILE_BUFFER_SIZE),
860891
capacityHintProvider = capacityHintProvider)
861892
}
862893
}

tests/src/test/scala/com/nvidia/spark/rapids/spill/SpillablePartialFileHandleSuite.scala

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@
1616

1717
package com.nvidia.spark.rapids.spill
1818

19-
import java.io.File
19+
import java.io.{BufferedOutputStream, File, FileOutputStream}
2020
import java.util.Arrays
2121

2222
import com.nvidia.spark.rapids.Arm.withResource
2323
import com.nvidia.spark.rapids.RapidsConf
2424
import org.scalatest.BeforeAndAfterEach
2525
import org.scalatest.funsuite.AnyFunSuite
26+
import org.scalatestplus.mockito.MockitoSugar
2627

27-
class SpillablePartialFileHandleSuite extends AnyFunSuite with BeforeAndAfterEach {
28+
class SpillablePartialFileHandleSuite
29+
extends AnyFunSuite with BeforeAndAfterEach with MockitoSugar {
2830

2931
// Use 1GB max buffer size for tests to avoid memory issues on test machines
3032
private val testMaxBufferSize = 1L * 1024 * 1024 * 1024
@@ -286,6 +288,37 @@ class SpillablePartialFileHandleSuite extends AnyFunSuite with BeforeAndAfterEac
286288
}
287289
}
288290

291+
test("FILE_ONLY mode: read with logical size larger than Int.MaxValue") {
292+
val testData = "dummy shuffle bytes".getBytes("UTF-8")
293+
val tempFile = createTempFileWithData("test-file-only-large-logical-", testData)
294+
295+
withLargeLogicalFileOnlyHandle(tempFile) { handle =>
296+
val readBuffer = new Array[Byte](testData.length)
297+
assert(handle.read(readBuffer, 0, readBuffer.length) == readBuffer.length)
298+
assert(readBuffer.sameElements(testData))
299+
}
300+
}
301+
302+
test("FILE_ONLY mode: readAt with logical size larger than Int.MaxValue") {
303+
val testData = "dummy shuffle bytes".getBytes("UTF-8")
304+
val tempFile = createTempFileWithData("test-file-only-large-logical-random-", testData)
305+
306+
withLargeLogicalFileOnlyHandle(tempFile) { handle =>
307+
val readBuffer = new Array[Byte](testData.length)
308+
assert(handle.readAt(0, readBuffer, 0, readBuffer.length) == readBuffer.length)
309+
assert(readBuffer.sameElements(testData))
310+
}
311+
}
312+
313+
test("boundedReadLength requires non-negative inputs") {
314+
assertThrows[IllegalArgumentException] {
315+
SpillablePartialFileHandle.boundedReadLengthAsInt(-1, 1L)
316+
}
317+
assertThrows[IllegalArgumentException] {
318+
SpillablePartialFileHandle.boundedReadLengthAsInt(1, -1L)
319+
}
320+
}
321+
289322
test("Error handling: write after finish should fail") {
290323
val tempFile = File.createTempFile("test-error-", ".tmp")
291324

@@ -572,5 +605,34 @@ class SpillablePartialFileHandleSuite extends AnyFunSuite with BeforeAndAfterEac
572605
assert(handle.isSpilled)
573606
}
574607
}
608+
609+
private def withLargeLogicalFileOnlyHandle(tempFile: File)(
610+
testBody: SpillablePartialFileHandle => Unit): Unit = {
611+
val outputStream = mock[BufferedOutputStream]
612+
withResource(SpillablePartialFileHandle.createFileOnly(
613+
file = tempFile,
614+
syncWrites = false,
615+
bufferedOutputStreamFactory = _ => outputStream)) { handle =>
616+
val ignored = new Array[Byte](1)
617+
val expectedLogicalSize = Int.MaxValue.toLong + 4096L
618+
handle.write(ignored, 0, Int.MaxValue)
619+
handle.write(ignored, 0, 4096)
620+
handle.finishWrite()
621+
assert(handle.getTotalBytesWritten == expectedLogicalSize)
622+
testBody(handle)
623+
}
624+
}
625+
626+
private def createTempFileWithData(prefix: String, data: Array[Byte]): File = {
627+
val tempFile = File.createTempFile(prefix, ".tmp")
628+
val out = new FileOutputStream(tempFile)
629+
try {
630+
out.write(data)
631+
} finally {
632+
out.close()
633+
}
634+
tempFile
635+
}
636+
575637
}
576638

0 commit comments

Comments
 (0)