Skip to content

[BUG] MOR + BUCKET + NBCC: when a bucket's earliest write is an uncommitted (file-written-but-not-committed) failed write, the entire bucket's later committed data is silently dropped from snapshot queries #19774

Description

@zhaoyudi-creator

Bug Description

What happened:
With MOR + BUCKET index + NBCC, if the first write into a bucket (FileGroup) is a failed write whose "file has already been produced but the commit did not finish", then all subsequently committed data in that bucket is entirely filtered out of snapshot queries — the query does not fail, it silently returns an incomplete result.

The BUCKET index acts as an amplifier here: it forces the failed write and the later successful writes to share the same FileGroup, so a single failed write drags down all later data in the whole bucket.
What you expected:

Steps to reproduce:
Steps to reproduce the behavior:

  1. Create the table:
    CREATE TABLE test_mor (
      id BIGINT, ts BIGINT, v STRING
    ) USING hudi
    TBLPROPERTIES (
      primaryKey = 'id',
      preCombineField = 'ts',
      type = 'mor',
      hoodie.index.type = 'BUCKET',
      hoodie.bucket.index.num.buckets = '2',
      hoodie.metadata.enable = 'false'
    );
  2. Enable NBCC (see the concurrency config under Environment Description).
  3. Normal write (lands in bucket A): INSERT INTO test_mor VALUES (1, 1000, 'a');
  4. Produce one failed write (lands in bucket B, id=2): write (2, 1002, 'b') so that its log file is already produced but the corresponding deltacommit did not finish (the instant stays inflight/requested with no completed marker). For example, kill the job before the commit finishes; under the LAZY policy this failed log is not rolled back immediately and remains in the bucket B directory.
  5. Two more normal writes to the same bucket B:
    INSERT INTO test_mor VALUES (2, 1003, 'b');
    INSERT INTO test_mor VALUES (2, 1004, 'b');
  6. Run SELECT * FROM test_mor; → only id=1 is returned; the entire id=2 row is missing.

Environment

Hudi version: 1.1.1
Query engine: (Spark/Flink/Trino etc)
Relevant configs:

Logs and Stack Trace

Additional context

Root cause (in hudi-common's HoodieFileGroup file-slice attribution logic; line numbers are from the 1.1 branch and may differ slightly in the community release):

(1) Bucketing puts the failed write and later successful writes into the same FileGroup.
With the BUCKET index, all logs in a bucket share the same fileId (the bucket number). AbstractTableFileSystemView.buildFileGroups groups by HoodieLogFile::getFileId (AbstractTableFileSystemView.java:287), so bucket B's failed-write log (t2) and later successful-write logs (t3, t4) all fall into one FileGroup.

(2) With no base file, the earliest log's deltacommit becomes the slice base instant — without checking whether it is committed.
Logs are processed in ascending deltacommit order. HoodieFileGroup.getBaseInstantTime (HoodieFileGroup.java:192-194):

if (fileSlices.isEmpty()) {
  // no base file in the file group, use the log file delta commit time.
  return logFile.getDeltaCommitTime();   // uses the earliest log's deltacommit directly, no completed check
}

Bucket B's earliest log is exactly the failed t2 (instant time t2 < t3 < t4), so the slice's base instant is anchored on the uncommitted t2.

(3) The successful t3, t4 are routed by completion time into the same t2 slice.
completion(t3) >= t2 holds (HoodieFileGroup.java:196-201), so t3 and t4 are attached to the slice whose base instant = t2. Bucket B ends up with a single slice anchored on the uncommitted t2.

(4) getAllFileSlices filters out that whole slice.

// HoodieFileGroup.java:224-230  isFileSliceCommitted
return timeline.containsOrBeforeTimelineStarts(slice.getBaseInstantTime()); // baseInstant = t2

containsOrBeforeTimelineStarts(t2) = containsInstant(t2) || isBeforeTimelineStarts(t2) (BaseHoodieTimeline.java:480-482). t2 never completed → both are false → returns false. getAllFileSlices (HoodieFileGroup.java:249-251) therefore discards the entire slice, including the already-committed t3/t4 data attached under it.

(5) filterUncommittedFiles cannot recover it.
The filterUncommittedFiles in getLatestFileSlices (AbstractTableFileSystemView.java:750-767) runs after getAllFileSlices; the slice is already gone by then. It also reuses the original base instant when constructing the new slice, so even if it ran it would not change the anchor.

Trigger condition (key): the earliest log in a bucket comes from an uncommitted failed write — it seizes the slice's base-instant anchor. If any successfully committed log/base file exists in the bucket before the failed write, the anchor lands on a committed instant and the failed file is only dropped at file granularity by filterUncommittedFiles, without taking down the whole slice.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type:bugBug reports and fixes

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions