Skip to content

fix: release blob file handles on resolve error paths - #810

Open
jackylee-ch wants to merge 3 commits into
lance-format:mainfrom
jackylee-ch:fix/blob-handle-leak-on-error
Open

jackylee-ch wants to merge 3 commits into
lance-format:mainfrom
jackylee-ch:fix/blob-handle-leak-on-error

Conversation

@jackylee-ch

Copy link
Copy Markdown
Contributor

Summary

  • resolveBatch abandoned every BlobFile takeBlobs had returned when the count check or the
    null check threw, and the tail of the list when read() threw. BlobFile wraps a native handle
    with no cleaner, and Spark retries the task in the same JVM. Release all of them in a finally,
    and do the same in resolve

Test plan

  • *Blob*/*Join* suites on Spark 4.1/Scala 2.13 and 3.5/Scala 2.12 (144 each); make lint

🤖 Generated with Claude Code

resolveBatch closed each BlobFile with try-with-resources inside the loop,
so the count-mismatch throw abandoned the whole returned list, the null
check abandoned everything after the current index, and a read() failure
abandoned the tail. BlobFile holds a native handle with no cleaner, so an
abandoned one lives until the JVM exits, and Spark retries the task in the
same JVM by default.

Close the handles once each in a finally instead. resolve() had the same
shape for a multi-element return, so it gets the same treatment.
@github-actions github-actions Bot added the bug Something isn't working label Sep 7, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 7, 2026
Exercise successful reads, count mismatches, null blobs and read failures
with real Lance blob v2 datasets. Capture the returned handles through a
shared package-private takeBlobs helper and verify their native owners
are released before the resolver closes.

The old per-item cleanup fails all three batch error regressions.
Blob/Join suites pass on Spark 3.5/Scala 2.12 and Spark 4.1/Scala 2.13
(149 tests each, no skips); Checkstyle and Spotless pass.
@jackylee-ch

Copy link
Copy Markdown
Contributor Author

cc @LuciferYang

@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 15, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 15, 2026
@LuciferYang

Copy link
Copy Markdown
Collaborator

Missed this ping, will check it out.

@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 17, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gate recommendation: approve.

The merge from main leaves this pull request’s two-file patch unchanged, so the prior assessment still holds: the real-dataset regressions verify native-handle release after successful reads, count mismatches, null results, and read failures, while the narrowly scoped takeBlobs seam leaves production resolution semantics unchanged.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 17, 2026

@LuciferYang LuciferYang left a comment

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 resolveBatch fix looks correct: every handle takeBlobs returned is released once on all error paths (empty, count-mismatch, null-blob, read failure), with no double-close, and LargeBinaryWriter reads the copied bytes so closing before return doesn't affect callers. Nothing blocking.

A few non-blocking notes inline. The main one: singleReadFailureReleasesHandle is effectively a smoke test — the single-address resolve change is behavior-neutral (the old try-with-resources also closed that one blob on a read failure), so the test wouldn't catch a resolve regression; the real release semantics are pinned by the resolveBatch tests. The rest are optional: resolve lacks the null-blob guard resolveBatch has, the read-failure tests couple to lance-core's "Not found" text, and the release loop is duplicated across the two methods.

}

@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.

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.

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants