-
Notifications
You must be signed in to change notification settings - Fork 85
fix: release blob file handles on resolve error paths #810
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e3f12cc
cb514ba
31aeebc
5cc6d4e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
| } finally { | ||
| for (BlobFile blob : blobs) { | ||
| CloseableUtil.closeQuietly(blob); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The blob-release loop is identical in |
||
| 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); | ||
| } | ||
|
|
||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The two read-failure tests (this one and line 187 in 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 |
||
| resolver.assertReleased() | ||
| } finally resolver.close() | ||
| } | ||
|
|
||
| @Test | ||
| def singleReadFailureReleasesHandle(): Unit = withSource() { (uri, refs) => | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test doesn't actually pin the The case that would show the new code's value (takeBlobs returning more handles than requested, where the old code closed only |
||
| 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() | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolvecallsblobs.get(0).read()with no null check, unlikeresolveBatch, which throws a descriptive IOException for a null blob.takeBlobsreturns null for a null-descriptor row, soresolvethrows a bare NPE whereresolveBatchreports column/dataset context. This isn't a leak (the finally'scloseQuietly(null)is a no-op) and the behavior is pre-existing; alsoresolve/resolveIfNeededhave no production caller today, only tests.Since this PR already rewrote
resolve's body, adding a null guard matchingresolveBatchwould make the two paths report errors consistently. Optional, can be deferred.