nsivabalan commented on PR #20273:
URL: https://github.com/apache/hudi/pull/20273#issuecomment-6157370012
Thanks @ad1happy2go, nice debugging on this one. The core fix looks right to
me. Treating "first instant on the active timeline" as the archival boundary
breaks as soon as a rollback or clean outlives the commits around it, and the
archiver archives those on a separate schedule. Moving
`BaseHoodieTableFileIndex#isBeforeTimelineStarts` and the
`CompletionTimeQueryViewV2` cursor to the write timeline fixes the reported bug.
Most of the remaining diff is about archival beyond savepoint:
`activeSavepointedInstants`, the new `isArchived` boundary, the per-instant
`splitInstantByActiveness`, and the savepoint tests. I dug into that feature,
and I don't think we should keep extending read paths for it.
The hole it leaves after a savepointed commit breaks much more than
incremental reads. `getFirstNonSavepointCommit()` only skips a savepointed
commit when the timeline it's given contains the SAVEPOINT instant. The file
system view (`AbstractTableFileSystemView#refreshTimeline` →
`getWriteTimeline()`), the cleaner and the index all pass timelines without it.
So a commit in the hole counts as uncommitted.
I reproduced this on master with a Spark functional test on COW. All four
combinations behave the same: table version 6 and 9, each with the BLOOM and
SIMPLE index.
- C1 inserts into partition 1 and is savepointed.
- C2 inserts 10 records into partition 2.
- Four more commits into partition 3 archive C2, while C1 stays active.
| Step | Expected | Actual |
|---|---|---|
| Snapshot read | 60 rows | **50**: C2's rows are missing |
| Upsert of C2's keys | update C2's file group | **new file group** in
partition 2 |
| Delete savepoint C1, archive past it | 100 rows / 100 keys | **110 rows /
100 keys**: 10 duplicates |
That's the same root cause as #11405, which has been open since 2024. With
the LSM timeline we're moving away from a separate archived timeline anyway, so
I've opened #20310 to remove support for `hoodie.archive.beyond.savepoint`. It
deprecates the config, fails fast when it's set to true, and replaces the
config-based restore guard with a check on the table itself.
Given that, could we narrow this PR to the stale rollback/clean fix?
- Keep the write-timeline boundary in `isBeforeTimelineStarts` and the
`CompletionTimeQueryViewV2` cursor, plus the stale-rollback tests
(`testIncrementalStartBeforeFirstWriteIsArchivedDespiteOlderNonWriteInstant`,
`testIncrementalStartIsArchivedWithoutActiveWriteInstant`,
`testArchivedInstantsAfterStaleNonWriteInstant`, and the Spark functional test).
- Drop the savepoint-hole handling: `activeSavepointedInstants`, the
`isArchived` / `isCompleted` split, and the `splitInstantByActiveness` rewrite
with its sorting.
That also settles the open Flink comment about
`QueryContext#getMaxCompletionTime`. That issue only exists because archived
and active instants can now interleave, which can't happen once the feature is
gone.
<details><summary>Repro test (TestArchiveBeyondSavepointReads,
hudi-spark)</summary>
```java
package org.apache.hudi.functional;
import org.apache.hudi.client.SparkRDDWriteClient;
import org.apache.hudi.common.config.HoodieMetadataConfig;
import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy;
import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.model.HoodieTableType;
import org.apache.hudi.common.table.HoodieTableVersion;
import org.apache.hudi.common.table.timeline.HoodieTimeline;
import org.apache.hudi.config.HoodieArchivalConfig;
import org.apache.hudi.config.HoodieCleanConfig;
import org.apache.hudi.config.HoodieIndexConfig;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.index.HoodieIndex;
import org.apache.hudi.testutils.HoodieClientTestBase;
import org.apache.spark.sql.Dataset;
import org.apache.spark.sql.Row;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import static
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
import static
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
import static
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_THIRD_PARTITION_PATH;
import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Repro: with hoodie.archive.beyond.savepoint=true, commits archived after
a savepointed commit leave a hole in the
* active timeline. File slices written only by commits in the hole are
treated as uncommitted by the file system view.
*/
@Tag("functional")
public class TestArchiveBeyondSavepointReads extends HoodieClientTestBase {
private static final int NUM_RECORDS = 10;
private static final String REPORT_FILE =
"target/archive-beyond-savepoint-repro.txt";
@BeforeEach
public void setUp() throws Exception {
initPath();
initSparkContexts();
initTestDataGenerator();
initHoodieStorage();
initTimelineService();
}
private static Stream<Arguments> tableVersionAndIndex() {
return Stream.of(
Arguments.of(HoodieTableVersion.SIX, HoodieIndex.IndexType.BLOOM),
Arguments.of(HoodieTableVersion.SIX, HoodieIndex.IndexType.SIMPLE),
Arguments.of(HoodieTableVersion.NINE, HoodieIndex.IndexType.BLOOM),
Arguments.of(HoodieTableVersion.NINE, HoodieIndex.IndexType.SIMPLE));
}
/**
* C1 inserts into partition 1 and is savepointed. C2 inserts into
partition 2, a file group no later commit touches.
* C3..C6 insert into partition 3, which archives C2 while C1 stays
active. Then:
* 1. a snapshot read should still return C2's rows;
* 2. an upsert of C2's keys should update them in place, not insert them
again;
* 3. once the savepoint is deleted and C1 is archived too, the table
should not contain duplicate keys.
*/
@ParameterizedTest
@MethodSource("tableVersionAndIndex")
void testHoleCommitsAfterSavepoint(HoodieTableVersion tableVersion,
HoodieIndex.IndexType indexType) throws Exception {
HoodieWriteConfig writeConfig = getWriteConfigAndInitTable(tableVersion,
indexType);
List<String> observations = new ArrayList<>();
try (SparkRDDWriteClient client = getHoodieWriteClient(writeConfig)) {
// given: savepointed C1, hole commit C2, and enough later commits to
archive C2
String c1 = insert(client, DEFAULT_FIRST_PARTITION_PATH);
client.savepoint(c1, "user", "savepoint C1");
List<HoodieRecord> holeRecords = new ArrayList<>();
String c2 = insert(client, DEFAULT_SECOND_PARTITION_PATH, holeRecords);
for (int i = 0; i < 4; i++) {
insert(client, DEFAULT_THIRD_PARTITION_PATH);
}
HoodieTimeline activeTimeline = metaClient.reloadActiveTimeline();
assertTrue(activeTimeline.containsInstant(c1), "precondition:
savepointed C1 stays active");
assertFalse(activeTimeline.containsInstant(c2), "precondition: C2 is
archived, leaving a hole after C1");
// when/then 1: snapshot read
long snapshotCount = snapshotRead().count();
observations.add("snapshot rows after archival (expected 60): " +
snapshotCount);
// when/then 2: upsert C2's keys
String newCommitTime = client.startCommit();
List<HoodieRecord> updates = dataGen.generateUpdates(newCommitTime,
holeRecords);
client.commit(newCommitTime, client.upsert(jsc.parallelize(updates,
1), newCommitTime));
Dataset<Row> afterUpsert = snapshotRead();
long upsertRows = afterUpsert.count();
long upsertKeys =
afterUpsert.select("_hoodie_record_key").distinct().count();
long holeFileGroupsInStorage =
countBaseFileGroups(DEFAULT_SECOND_PARTITION_PATH);
observations.add("after upsert of C2 keys: rows=" + upsertRows + "
distinctKeys=" + upsertKeys
+ " fileGroupsInPartition2=" + holeFileGroupsInStorage + "
(expected 60/60/1)");
// when/then 3: delete the savepoint and archive C1, so the hole closes
client.deleteSavepoint(c1);
for (int i = 0; i < 4; i++) {
insert(client, DEFAULT_THIRD_PARTITION_PATH);
}
assertFalse(metaClient.reloadActiveTimeline().containsInstant(c1),
"precondition: C1 archived after savepoint delete");
Dataset<Row> afterHoleCloses = snapshotRead();
long finalRows = afterHoleCloses.count();
long finalKeys =
afterHoleCloses.select("_hoodie_record_key").distinct().count();
observations.add("after savepoint delete and archival: rows=" +
finalRows + " distinctKeys=" + finalKeys
+ " (expected " + (60 + 4 * NUM_RECORDS) + "/" + (60 + 4 *
NUM_RECORDS) + ")");
report(tableVersion, indexType, observations);
long expectedFinal = 60 + 4 * NUM_RECORDS;
assertAll(
() -> assertEquals(60, snapshotCount, "snapshot read drops rows of
the archived hole commit"),
() -> assertEquals(60, upsertRows, "rows after upserting the hole
commit's keys"),
() -> assertEquals(60, upsertKeys, "distinct keys after upserting
the hole commit's keys"),
() -> assertEquals(1, holeFileGroupsInStorage, "upsert should
update the existing file group, not create a new one"),
() -> assertEquals(expectedFinal, finalRows, "rows once the hole
closes"),
() -> assertEquals(finalRows, finalKeys, "duplicate record keys
once the hole closes"));
}
}
private HoodieWriteConfig getWriteConfigAndInitTable(HoodieTableVersion
tableVersion, HoodieIndex.IndexType indexType) throws IOException {
HoodieWriteConfig writeConfig =
getConfigBuilder(HoodieFailedWritesCleaningPolicy.EAGER)
.withAutoUpgradeVersion(false)
.withWriteTableVersion(tableVersion.versionCode())
.withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(indexType).build())
.withCleanConfig(HoodieCleanConfig.newBuilder().retainCommits(1).build())
.withArchivalConfig(HoodieArchivalConfig.newBuilder()
.archiveCommitsWith(2, 3)
.withArchiveBeyondSavepoint(true)
.build())
.withMetadataConfig(HoodieMetadataConfig.newBuilder()
.enable(true)
.withMaxNumDeltaCommitsBeforeCompaction(1)
.build())
.build();
initMetaClient(HoodieTableType.COPY_ON_WRITE, writeConfig.getProps());
return writeConfig;
}
private String insert(SparkRDDWriteClient client, String partition) {
return insert(client, partition, new ArrayList<>());
}
private String insert(SparkRDDWriteClient client, String partition,
List<HoodieRecord> insertedRecords) {
String instantTime = client.startCommit();
List<HoodieRecord> records =
dataGen.generateInsertsForPartition(instantTime, NUM_RECORDS, partition);
insertedRecords.addAll(records);
List<org.apache.hudi.client.WriteStatus> statuses =
client.insert(jsc.parallelize(records, 1), instantTime).collect();
assertNoWriteErrors(statuses);
client.commit(instantTime, jsc.parallelize(statuses, 1));
return instantTime;
}
private Dataset<Row> snapshotRead() {
return sparkSession.read().format("hudi").load(basePath);
}
private long countBaseFileGroups(String partition) throws IOException {
try (Stream<java.nio.file.Path> files = Files.list(Paths.get(basePath,
partition))) {
return files.map(path -> path.getFileName().toString())
.filter(name -> name.endsWith(".parquet"))
.map(name -> name.substring(0, name.indexOf('_')))
.distinct()
.count();
}
}
private static void report(HoodieTableVersion tableVersion,
HoodieIndex.IndexType indexType, List<String> observations) throws IOException {
StringBuilder sb = new StringBuilder("== table version " +
tableVersion.versionCode() + ", index " + indexType + "\n");
observations.forEach(line -> sb.append(" ").append(line).append('\n'));
Files.write(Paths.get(REPORT_FILE),
sb.toString().getBytes(StandardCharsets.UTF_8),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
}
```
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]