Skip to content

fix(spark): read INLINE blobs as CONTENT on internal write-side Lance…#19236

Merged
voonhous merged 12 commits into
apache:masterfrom
voonhous:fix-inline-blob-compaction-data-loss
Jul 11, 2026
Merged

fix(spark): read INLINE blobs as CONTENT on internal write-side Lance…#19236
voonhous merged 12 commits into
apache:masterfrom
voonhous:fix-inline-blob-compaction-data-loss

Conversation

@voonhous

@voonhous voonhous commented Jul 9, 2026

Copy link
Copy Markdown
Member

Describe the issue this Pull Request addresses

Closes #19232.

On Lance tables with INLINE blobs, compaction/clustering/upsert-merge read base files through SparkReaderContextFactory -> SparkLanceReaderBase, which honors hoodie.read.blob.inline.mode. The factory never set it, so the DESCRIPTOR default applied and rewrites persisted INLINE blobs with data=null, silently losing the bytes of every carried-over row (all rows, in the clustering case).

Summary and Changelog

  • Pin hoodie.read.blob.inline.mode=CONTENT in the conf SparkReaderContextFactory broadcasts. The factory only serves internal write-side/index reads; user queries build their own conf, so their DESCRIPTOR/CONTENT choice is unaffected.
  • Add a per-row guard in HoodieSparkLanceWriter rejecting the descriptor-leak shape {type=INLINE, data=null, reference!=null} so any future leak fails loudly instead of dropping bytes. {INLINE, null, null} stays writable.
  • Fix a stale comment in SparkLanceReaderBase claiming CONTENT is the config default (it is DESCRIPTOR).
  • Un-mask testBlobInlineCompactionRoundTrip (force one file group so untouched rows actually go through the rewrite); add testBlobInlineClusteringRoundTrip and writer-guard tests.

Known follow-up (not in this PR): Flink's HoodieRowDataLanceReader calls readAll without FileReadOptions, so it neither pins CONTENT nor honors the config.

Impact

Fixes silent data loss on internal rewrites of Lance INLINE blobs. No public API or config default changes.

Risk Level

low. Regression tests cover compaction, clustering, and the writer guard.

Documentation Update

none

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

… reads

MOR compaction, clustering, and upsert merge read Lance base files through
SparkReaderContextFactory -> SparkFileFormatInternalRowReaderContext ->
SparkLanceReaderBase, which resolves hoodie.read.blob.inline.mode from the
broadcast Hadoop conf. The factory never set it, so the DESCRIPTOR default
applied and rewrites persisted INLINE blobs with null data, silently losing
the bytes of every carried-over row (all rows, in the clustering case).

- Pin hoodie.read.blob.inline.mode=CONTENT in the conf that
  SparkReaderContextFactory broadcasts; the factory only serves internal
  write-side and index reads, never user-facing queries, which build their
  own conf from session options.
- Add a per-row guard in HoodieSparkLanceWriter that rejects the
  descriptor-leak shape {type=INLINE, data=null, reference!=null} so any
  future leak fails loudly instead of dropping bytes; {INLINE, null, null}
  stays writable.
- Fix the stale comment in SparkLanceReaderBase claiming CONTENT is the
  config default (it is DESCRIPTOR).
- Un-mask testBlobInlineCompactionRoundTrip by forcing all rows into one
  file group so untouched rows actually go through the compaction rewrite,
  and correct its doc about which reader compaction uses.
- Add testBlobInlineClusteringRoundTrip and writer-guard tests.

Fixes apache#19232
@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Jul 9, 2026
…d_blob

Assert payload.data under CONTENT mode before calling read_blob() in the
compaction and clustering round-trip tests. On a regression the failure now
reads as explicit data loss (data is null after the rewrite) instead of
read_blob()'s misleading DESCRIPTOR-mode IllegalStateException, which made
apache#19232 look like an error by design. Also drop a stale comment crediting
HoodieSparkLanceReader's CONTENT pin for compaction correctness.
@voonhous

voonhous commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Our original test did not capture this error because:

  1. The topology never forced the dangerous path. With default shuffle parallelism, the bulk_insert scattered ids 0..5 across multiple file groups. The upsert of ids 0..2 only produced log files in their file groups. Compaction only rewrites file groups that have log files, so the untouched ids 3..5 sat in log-free file groups that compaction never touched. Their bytes "survived" because their base file was never rewritten at all under compaction. The test even asserted that a deltacommit existed and that some file slice had log files, but never that an untouched row and a log file shared a filegroup, which is the only combination that exercises the carry-over rewrite.
  2. Even the rewritten rows didn't test the base-file read. Ids 0..2 got their new bytes from the log records during the merge, not from reading the base file. So whatever blob mode the base-file read used (the actual bug) was invisible: the buggy read's output for those rows was discarded in favor of the log records anyway.

TLDR: New tests forces 3..5 into the same filegroup that needed to be re-read and rewritten into new base file under compaction but were never present in the log files, triggering the data loss error which we are fixing.

The un-masking of apache#19232 relies on coalesce(1) plus shuffle parallelism 1
forcing all rows into one file group, but nothing asserted it. Pin the
invariant before the anyHadLogs scan so drifting back to multiple file
groups (log-free groups compaction never rewrites) fails loudly instead
of silently re-masking the regression.
@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

voonhous added 5 commits July 10, 2026 16:09
BlobDescriptorTransform never emits OUT_OF_LINE; it synthesizes the
reference sub-struct while preserving type=INLINE. The old wording
described the exact wrong mental model that hid apache#19232.
Assert hoodie.read.blob.inline.mode=CONTENT on the captured broadcast
Configuration so dropping the pin in SparkReaderContextFactory fails a
fast unit test instead of only the Lance functional suite.
BlobDescriptorTransform and the HoodieSparkLanceWriter guard each
re-derived the BLOB struct ordinals, arities and INLINE token. Move them
into a package-private BlobStructLayout holder (arities sourced from
HoodieSchema.Blob) so the two decoders cannot drift apart.
The deltaCommits assertion message still credited the pin to
HoodieSparkLanceReader; it lives in SparkReaderContextFactory. The test
docstring understated HoodieSparkLanceReader's callers (bloom-index
lookups and legacy HoodieWriteMergeHandle merges also use it), and the
pin comment inside that reader still claimed compaction/merge/log-replay
which now go through SparkLanceReaderBase.
After the SparkReaderContextFactory pin, internal rewrites cannot
produce the descriptor shape; the realistic trigger is a user reading a
blob table at the DESCRIPTOR default and writing the rows back (INSERT
INTO ... SELECT). Stop attributing the leak solely to internal rewrites
so that user knows to set hoodie.read.blob.inline.mode=CONTENT on their
read.
@voonhous

Copy link
Copy Markdown
Member Author

@wombatu-kun Comments addressed, can you PTAL?

@voonhous
voonhous requested a review from wombatu-kun July 10, 2026 09:06
voonhous added 2 commits July 10, 2026 22:09
…ob inline clustering test

Address review on testBlobInlineClusteringRoundTrip. getLastClusteringInstant.isPresent
is satisfied by a REQUESTED/INFLIGHT replacecommit (getTimelineOfActions filters by action
only), so assert the instant isCompleted. Also snapshot the first commit's base files and
assert the post-clustering set is disjoint, proving the rewrite ran rather than the byte
assertions reading the untouched originals (the masking mode of apache#19232).
The latestBaseFileNames helper in testBlobInlineClusteringRoundTrip
referenced engineContext without binding it, causing a Scala compile
error. Define it locally via HoodieLocalEngineContext(mc.getStorageConf),
matching the three other FileSystemViewManager call sites in this file.
@github-actions github-actions Bot added size:L PR with lines of changes in (300, 1000] and removed size:M PR with lines of changes in (100, 300] labels Jul 10, 2026
// only come back if HoodieSparkLanceReader's CONTENT pin held during the compactor's
// base-file read — otherwise untouched ids 3..5 would have been rewritten with null
// `data` and CONTENT-mode read would surface that.
// Plain projection under CONTENT first: on a regression this fails with an explicit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This describes a failure mode the PR itself makes unreachable. A DESCRIPTOR-leaked row is {INLINE, data=null, reference!=null}, and every Lance write goes through HoodieSparkLanceWriter, whose new validateBlobRow rejects exactly that shape. If the CONTENT pin regresses, the inline compaction aborts inside the rewrite with a HoodieException and writeDataframe fails before this CONTENT projection ever runs.

The projection still earns its place, but against a different shape: {INLINE, null, null}, which the guard deliberately allows. Suggest rewording to that, and pointing at TestSparkReaderContextFactory as what actually pins the config. The same comment is duplicated on testBlobInlineClusteringRoundTrip.

@voonhous voonhous Jul 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right -- with validateBlobRow in the writer, a DESCRIPTOR leak now aborts the rewrite itself, so this projection can never see {INLINE, null, populated reference}. Reworded both comments (compaction + clustering) to say what the projection actually backstops -- the {INLINE, null, null} shape the guard deliberately allows -- and pointed at TestSparkReaderContextFactory for the CONTENT pin.

* asserts a replacecommit actually completed, and verifies every row's bytes survived.
*/
@Test
def testBlobInlineClusteringRoundTrip(): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SparkReaderContextFactory and HoodieSparkLanceReader both name "merge" as a path the CONTENT pin protects, but no blob test exercises it. Every blob test writes via bulk_insert or insert; the only upsert is the MOR one in testBlobInlineCompactionRoundTrip, which reaches the reader through HoodieCompactor. A CoW upsert that rewrites an existing base file through FileGroupReaderBasedMergeHandle is never covered.

That path is not just a third caller of the same code: it resolves the factory via getReaderContextFactoryForWrite, which branches to AvroReaderContextFactory for AVRO record types, unlike the getReaderContextFactory used by compaction and clustering. A COPY_ON_WRITE round-trip that bulk-inserts INLINE blobs and then upserts a subset would close it.

@voonhous voonhous Jul 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added testBlobInlineCowUpsertMergeRoundTrip: CoW bulk_insert of INLINE blobs into a single file group, then upsert of a subset.

  • Pins that the merge actually ran: two commits, no deltacommits, post-upsert base file disjoint from the pre-upsert one.
  • Checks touched rows carry the new bytes and untouched rows keep the originals, with the same DESCRIPTOR/CONTENT checks as the compaction and clustering tests.

The class's write helper uses DefaultSparkRecordMerger, so this exercises the SPARK branch of getReaderContextFactoryForWrite (SparkReaderContextFactory), i.e. the default datasource path.

voonhous added 2 commits July 11, 2026 00:19
…ally backstop

The old comments described catching a persisted DESCRIPTOR leak
({INLINE, null data, populated reference}), but validateBlobRow in
HoodieSparkLanceWriter now rejects that shape inside the rewrite, so the
test would fail at the compaction/clustering write before the projection
runs. Reword both sites (compaction + clustering) to the shape the guard
deliberately allows, {INLINE, null, null}, and point at
TestSparkReaderContextFactory as what pins the CONTENT config.
Compaction and clustering resolve their reader via getReaderContextFactory,
but a CoW upsert rewrites the base file through
FileGroupReaderBasedMergeHandle, which resolves it via
getReaderContextFactoryForWrite -- a separate path that branches on the
record merger type. No blob test exercised it. Add a CoW round-trip that
bulk-inserts INLINE blobs into a single file group, upserts a subset,
proves the merge rewrote the base file (disjoint base file names, no
deltacommits), and verifies touched rows carry new bytes while untouched
rows retain the originals. Hoist latestBaseFileNames out of the
clustering test for reuse.
@voonhous
voonhous force-pushed the fix-inline-blob-compaction-data-loss branch from fa45454 to 37ae5f5 Compare July 10, 2026 16:31
@voonhous
voonhous merged commit 120d73f into apache:master Jul 11, 2026
73 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] MOR compaction drops INLINE blob bytes of untouched rows in rewritten Lance file groups

3 participants