Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,18 @@ public BlobReferenceResolver(Map<String, BlobSourceContext> sourceContexts) {
* @throws IOException if reading the blob fails
*/
public byte[] resolve(BlobReference ref) throws IOException {
Dataset dataset = getOrOpenDataset(ref.getDatasetUri());
List<Long> rowAddresses = new ArrayList<>(1);
rowAddresses.add(ref.getRowAddress());
List<BlobFile> blobs = dataset.takeBlobs(rowAddresses, ref.getColumnName());
if (blobs.isEmpty()) {
return new byte[0];
}
try (BlobFile blob = blobs.get(0)) {
return blob.read();
List<BlobFile> blobs = takeBlobs(ref.getDatasetUri(), rowAddresses, ref.getColumnName());
try {
if (blobs.isEmpty()) {
return new byte[0];
}
return blobs.get(0).read();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve calls blobs.get(0).read() with no null check, unlike resolveBatch, which throws a descriptive IOException for a null blob. takeBlobs returns null for a null-descriptor row, so resolve throws a bare NPE where resolveBatch reports column/dataset context. This isn't a leak (the finally's closeQuietly(null) is a no-op) and the behavior is pre-existing; also resolve/resolveIfNeeded have no production caller today, only tests.

Since this PR already rewrote resolve's body, adding a null guard matching resolveBatch would make the two paths report errors consistently. Optional, can be deferred.

} finally {
for (BlobFile blob : blobs) {
CloseableUtil.closeQuietly(blob);
}
}
}

Expand Down Expand Up @@ -133,41 +136,50 @@ public Map<Integer, byte[]> resolveBatch(List<Integer> indices, List<BlobReferen
// Resolve each group with a single takeBlobs() call over its distinct addresses, then fan the
// bytes back out to every vector index that referenced that address.
for (Group group : groups.values()) {
Dataset dataset = getOrOpenDataset(group.datasetUri);
List<Long> addresses = group.distinctAddresses; // requested order
List<BlobFile> blobs = dataset.takeBlobs(addresses, group.columnName);

// takeBlobs must return exactly one BlobFile per requested address, in order. A mismatch
// means the selection hit deleted/null-descriptor rows, in which case positional mapping
// would skew and silently write the wrong bytes into the target table — fail loudly instead.
if (blobs.size() != addresses.size()) {
throw new IOException(
String.format(
"takeBlobs returned %d blobs for %d requested addresses (column=%s, dataset=%s); "
+ "cannot map results to rows",
blobs.size(), addresses.size(), group.columnName, group.datasetUri));
}

for (int i = 0; i < addresses.size(); i++) {
BlobFile blob = blobs.get(i);
if (blob == null) {
List<BlobFile> blobs = takeBlobs(group.datasetUri, addresses, group.columnName);

// Every handle takeBlobs returned is released in the finally below, including on the two
// throws: BlobFile wraps a native handle with no cleaner, so an abandoned one is only
// reclaimed when the JVM exits, and Spark retries the task in the same JVM.
try {
// takeBlobs must return exactly one BlobFile per requested address, in order. A mismatch
// means the selection hit deleted/null-descriptor rows, in which case positional mapping
// would skew and silently write the wrong bytes into the target table — fail loudly.
if (blobs.size() != addresses.size()) {
throw new IOException(
String.format(
"takeBlobs returned a null blob for address %d (column=%s, dataset=%s)",
addresses.get(i), group.columnName, group.datasetUri));
"takeBlobs returned %d blobs for %d requested addresses (column=%s, dataset=%s); "
+ "cannot map results to rows",
blobs.size(), addresses.size(), group.columnName, group.datasetUri));
}
byte[] data;
try (BlobFile b = blob) {
data = b.read();

for (int i = 0; i < addresses.size(); i++) {
BlobFile blob = blobs.get(i);
if (blob == null) {
throw new IOException(
String.format(
"takeBlobs returned a null blob for address %d (column=%s, dataset=%s)",
addresses.get(i), group.columnName, group.datasetUri));
}
byte[] data = blob.read();
for (int vectorIndex : group.indicesByAddress.get(addresses.get(i))) {
resolved.put(vectorIndex, data);
}
}
for (int vectorIndex : group.indicesByAddress.get(addresses.get(i))) {
resolved.put(vectorIndex, data);
} finally {
for (BlobFile blob : blobs) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blob-release loop is identical in resolve (line 88) and here in resolveBatch. A future change to release semantics has to touch both, which is easy to miss. Since the PR already extracted a takeBlobs method, extracting a closeAll(List<BlobFile>) helper alongside it would read more cleanly. Pure cleanup, can be deferred.

CloseableUtil.closeQuietly(blob);
}
}
}
return resolved;
}

List<BlobFile> takeBlobs(String datasetUri, List<Long> addresses, String columnName) {
return getOrOpenDataset(datasetUri).takeBlobs(addresses, columnName);
}

private Dataset getOrOpenDataset(String datasetUri) {
return datasetCache.computeIfAbsent(datasetUri, this::openDataset);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/*
* Licensed 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.lance.spark.utils

import org.apache.arrow.memory.RootAllocator
import org.apache.arrow.vector.{LargeVarBinaryVector, VectorSchemaRoot}
import org.apache.arrow.vector.complex.StructVector
import org.apache.arrow.vector.types.pojo.{ArrowType, Field, FieldType, Schema}
import org.junit.jupiter.api.Assertions._
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.lance.{BlobFile, Dataset}
import org.lance.spark.write.SingleBatchArrowReader

import java.io.IOException
import java.nio.file.{Files, Path, Paths}
import java.util.{Arrays, Collections, List => JList}

import scala.collection.JavaConverters._

class BlobReferenceResolverTest {
@TempDir var tempDir: Path = _

private val data = Array[Byte](1, 2, 3)

// JNI take_rust_field clears this field when close releases the native owner.
private def handle(blob: BlobFile): Long = {
val field = classOf[BlobFile].getDeclaredField("nativeBlobHandle")
field.setAccessible(true)
field.getLong(blob)
}

private class RecordingResolver(afterTake: JList[BlobFile] => Unit)
extends BlobReferenceResolver {
var acquired: Seq[BlobFile] = Seq.empty

override def takeBlobs(
uri: String,
addresses: JList[java.lang.Long],
column: String): JList[BlobFile] = {
val blobs = super.takeBlobs(uri, addresses, column)
acquired = blobs.asScala.filter(_ != null).toVector
assertTrue(acquired.nonEmpty)
acquired.foreach(blob => assertNotEquals(0L, handle(blob)))
afterTake(blobs)
blobs
}

def assertReleased(): Unit =
acquired.foreach(blob => assertEquals(0L, handle(blob), "native blob handle leaked"))

override def close(): Unit = {
acquired.filter(blob => handle(blob) != 0L).foreach(_.close())
super.close()
}
}

private def withSource(nullable: Boolean = false)(
body: (String, JList[BlobReference]) => Unit): Unit = {
val uri = tempDir.resolve("source.lance").toString
val field = new Field(
"data",
new FieldType(
true,
ArrowType.Struct.INSTANCE,
null,
Collections.singletonMap(
BlobUtils.ARROW_EXTENSION_NAME_KEY,
BlobUtils.ARROW_EXTENSION_BLOB_V2)),
Arrays.asList(
Field.nullable("data", ArrowType.LargeBinary.INSTANCE),
Field.nullable("uri", ArrowType.Utf8.INSTANCE)))
val allocator = new RootAllocator()
val root = VectorSchemaRoot.create(new Schema(Collections.singletonList(field)), allocator)
val reader = new SingleBatchArrowReader(allocator, root)
try {
root.allocateNew()
val vector = root.getVector(0).asInstanceOf[StructVector]
val bytes = vector.getChild("data").asInstanceOf[LargeVarBinaryVector]
(0 until 3).foreach { i =>
if (nullable && i == 1) {
vector.setNull(i)
bytes.setNull(i)
} else {
vector.setIndexDefined(i)
bytes.setSafe(i, data)
}
}
root.setRowCount(3)
val dataset = Dataset.write()
.allocator(allocator)
.reader(reader)
.uri(uri)
.dataStorageVersion("2.2")
.execute()
try {
assertEquals(1, dataset.getFragments.size())
val fragmentId = dataset.getFragments.get(0).getId.toLong
val refs = (0 until 3)
.map(i => new BlobReference(uri, "data", (fragmentId << 32) | i.toLong))
.asJava
body(uri, refs)
} finally dataset.close()
} finally {
reader.close()
root.close()
allocator.close()
}
}

private def indices(refs: JList[BlobReference]): JList[Integer] =
(0 until refs.size()).map(Integer.valueOf).asJava

private def deleteDataFiles(uri: String): Unit = {
val paths = Files.walk(Paths.get(uri).resolve("data"))
try {
val files = paths.iterator().asScala.filter(Files.isRegularFile(_)).toVector
assertFalse(files.isEmpty)
files.foreach(Files.delete)
} finally paths.close()
}

@Test
def batchSuccessReleasesAllHandles(): Unit = withSource() { (_, refs) =>
val resolver = new RecordingResolver(_ => ())
try {
val result = resolver.resolveBatch(indices(refs), refs)
assertEquals(3, result.size())
result.values().asScala.foreach(bytes => assertArrayEquals(data, bytes))
resolver.assertReleased()
} finally resolver.close()
}

@Test
def nullBlobReleasesVisitedAndUnvisitedHandles(): Unit = withSource(nullable = true) {
(_, refs) =>
val resolver = new RecordingResolver(blobs => assertNull(blobs.get(1)))
try {
val error = assertThrows(
classOf[IOException],
() => resolver.resolveBatch(indices(refs), refs))
assertTrue(error.getMessage.contains("takeBlobs returned a null blob"))
resolver.assertReleased()
} finally resolver.close()
}

@Test
def countMismatchReleasesReturnedHandles(): Unit = withSource() { (_, refs) =>
val resolver = new RecordingResolver(blobs => blobs.remove(blobs.size() - 1).close())
try {
val error = assertThrows(
classOf[IOException],
() => resolver.resolveBatch(indices(refs), refs))
assertTrue(error.getMessage.contains("takeBlobs returned 2 blobs for 3 requested addresses"))
resolver.assertReleased()
} finally resolver.close()
}

@Test
def batchReadFailureReleasesAllHandles(): Unit = withSource() { (uri, refs) =>
val resolver = new RecordingResolver(_ => deleteDataFiles(uri))
try {
val error = assertThrows(
classOf[IOException],
() => resolver.resolveBatch(indices(refs), refs))
assertTrue(error.getMessage.contains("Not found"), error.getMessage)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two read-failure tests (this one and line 187 in singleReadFailureReleasesHandle) force read() to fail by deleting the data files, then assert getMessage.contains("Not found"). That leans on two lance-core behaviors: the read must fail after takeBlobs returns (which assumes Lance reads lazily and holds no fd / no mmap at take time), and the error text stays exactly "Not found". A lance-core message change, or a platform where the file is opened at take time, turns these red.

The direction is fail-fast (the test breaks loudly, it won't miss a real leak), so it's not a merge blocker. Asserting only the IOException type rather than the literal text would be more robust.

resolver.assertReleased()
} finally resolver.close()
}

@Test
def singleReadFailureReleasesHandle(): Unit = withSource() { (uri, refs) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't actually pin the resolve change. resolve requests a single address, so takeBlobs returns one blob, and the old try (BlobFile blob = blobs.get(0)) closed it just as well when read() threw: revert the try/finally to the old form and this test still passes. For a single-address request the old and new code are equivalent, so resolve's change is close to behavior-neutral.

The case that would show the new code's value (takeBlobs returning more handles than requested, where the old code closed only blobs.get(0) and leaked the rest) can't be constructed from a single-address request. So this is really a smoke test for resolve; the release semantics are pinned by the resolveBatch tests. If resolve were broken, this test wouldn't catch it.

val resolver = new RecordingResolver(_ => deleteDataFiles(uri))
try {
val error = assertThrows(classOf[IOException], () => resolver.resolve(refs.get(0)))
assertTrue(error.getMessage.contains("Not found"), error.getMessage)
resolver.assertReleased()
} finally resolver.close()
}
}
Loading