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:
- 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'
);
- Enable NBCC (see the concurrency config under Environment Description).
- Normal write (lands in bucket A):
INSERT INTO test_mor VALUES (1, 1000, 'a');
- 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.
- 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');
- 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.
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:
INSERT INTO test_mor VALUES (1, 1000, 'a');(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 theLAZYpolicy this failed log is not rolled back immediately and remains in the bucket B directory.SELECT * FROM test_mor;→ onlyid=1is returned; the entireid=2row 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
HoodieFileGroupfile-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.buildFileGroupsgroups byHoodieLogFile::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):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) >= t2holds (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.
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
filterUncommittedFilesingetLatestFileSlices(AbstractTableFileSystemView.java:750-767) runs aftergetAllFileSlices; 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.