voonhous commented on code in PR #19658:
URL: https://github.com/apache/hudi/pull/19658#discussion_r3830459618
##########
hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java:
##########
@@ -471,17 +480,39 @@ private Option<String>
getOffsetsByTimestamp(KafkaConsumer consumer, List<Partit
.map(x -> new
TopicPartition(x.topic(), x.partition()))
.collect(Collectors.toMap(Function.identity(), x -> timestamp));
- Map<TopicPartition, Long> earliestOffsets =
consumer.beginningOffsets(topicPartitions);
Map<TopicPartition, OffsetAndTimestamp> offsetAndTimestamp =
consumer.offsetsForTimes(topicPartitionsTimestamp);
+ // Fetch end offsets lazily: they are only needed when at least one
partition has no record
+ // at/after the requested timestamp. In the common case where every
partition resolves,
+ // this saves one round-trip to the broker on every sync.
+ Map<TopicPartition, Long> endOffsets = null;
+ // Track partitions with no offset at/after the requested timestamp so we
can surface them
+ // as a WARN. Without this, callers whose messages use a pre-0.10.0 format
(which always
+ // returns null here) would silently skip to the tip of every partition
with no signal.
+ Map<TopicPartition, Long> fallbackToEndOffsets = new HashMap<>();
StringBuilder sb = new StringBuilder(topicName);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> map :
offsetAndTimestamp.entrySet()) {
if (map.getValue() != null) {
sb.append(",").append(map.getKey().partition()).append(":").append(map.getValue().offset());
} else {
-
sb.append(",").append(map.getKey().partition()).append(":").append(earliestOffsets.get(map.getKey()));
+ // No record in this partition has a timestamp >= the requested one.
Fall back to the
+ // end offset (partition tip) rather than 0/earliest to avoid
re-consuming the whole
+ // partition when the user has asked for a timestamp-based checkpoint.
+ if (endOffsets == null) {
+ endOffsets = consumer.endOffsets(topicPartitions);
+ }
Review Comment:
**Blocker, and this one is on me.** My nit asking for a lazy fetch
contradicted my own "do not swap those two lines" note in the same review. The
lazy form is the unsafe one.
`offsetsForTimes` now runs at :483, `endOffsets` at :502. A record appended
to a fallback partition between the two is dropped permanently: it did not
exist when `offsetsForTimes` ran, and the later tip is already past it. The
eager order is at-least-once. This is not a one-RPC window either -- both calls
retry through `HoodieRetryingKafkaConsumer` (`retry.max_count=4`,
`max_interval_ms=2000`).
The saving was not real anyway: this path only runs on the first batch after
an explicit `--checkpoint <ts>`, and `endOffsets` already fires at :344 and
:446.
```suggestion
Map<TopicPartition, Long> endOffsets =
consumer.endOffsets(topicPartitions);
Map<TopicPartition, OffsetAndTimestamp> offsetAndTimestamp =
consumer.offsetsForTimes(topicPartitionsTimestamp);
// Track partitions with no offset at/after the requested timestamp so
we can surface them
// as a WARN. Without this, callers whose messages use a pre-0.10.0
format (which always
// returns null here) would silently skip to the tip of every partition
with no signal.
Map<TopicPartition, Long> fallbackToEndOffsets = new HashMap<>();
StringBuilder sb = new StringBuilder(topicName);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> map :
offsetAndTimestamp.entrySet()) {
if (map.getValue() != null) {
sb.append(",").append(map.getKey().partition()).append(":").append(map.getValue().offset());
} else {
// No record in this partition has a timestamp >= the requested one.
Fall back to the
// end offset (partition tip) rather than 0/earliest to avoid
re-consuming the whole
// partition. The tip is read BEFORE offsetsForTimes above on
purpose: a record landing
// between the two calls must be ingested, not skipped.
```
Keep it a separate call from `toOffsets` at :344 -- sharing one map there
would drop records for the opposite reason.
##########
hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java:
##########
@@ -471,17 +480,39 @@ private Option<String>
getOffsetsByTimestamp(KafkaConsumer consumer, List<Partit
.map(x -> new
TopicPartition(x.topic(), x.partition()))
.collect(Collectors.toMap(Function.identity(), x -> timestamp));
- Map<TopicPartition, Long> earliestOffsets =
consumer.beginningOffsets(topicPartitions);
Map<TopicPartition, OffsetAndTimestamp> offsetAndTimestamp =
consumer.offsetsForTimes(topicPartitionsTimestamp);
+ // Fetch end offsets lazily: they are only needed when at least one
partition has no record
+ // at/after the requested timestamp. In the common case where every
partition resolves,
+ // this saves one round-trip to the broker on every sync.
+ Map<TopicPartition, Long> endOffsets = null;
+ // Track partitions with no offset at/after the requested timestamp so we
can surface them
+ // as a WARN. Without this, callers whose messages use a pre-0.10.0 format
(which always
+ // returns null here) would silently skip to the tip of every partition
with no signal.
+ Map<TopicPartition, Long> fallbackToEndOffsets = new HashMap<>();
StringBuilder sb = new StringBuilder(topicName);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> map :
offsetAndTimestamp.entrySet()) {
if (map.getValue() != null) {
sb.append(",").append(map.getKey().partition()).append(":").append(map.getValue().offset());
} else {
-
sb.append(",").append(map.getKey().partition()).append(":").append(earliestOffsets.get(map.getKey()));
+ // No record in this partition has a timestamp >= the requested one.
Fall back to the
+ // end offset (partition tip) rather than 0/earliest to avoid
re-consuming the whole
+ // partition when the user has asked for a timestamp-based checkpoint.
+ if (endOffsets == null) {
+ endOffsets = consumer.endOffsets(topicPartitions);
+ }
+ Long endOffset = endOffsets.get(map.getKey());
+ fallbackToEndOffsets.put(map.getKey(), endOffset);
+
sb.append(",").append(map.getKey().partition()).append(":").append(endOffset);
}
}
+ if (!fallbackToEndOffsets.isEmpty()) {
Review Comment:
**Blocker (coverage).** Nothing pins this WARN or the null-matrix behind it.
`getOffsetsByTimestamp` builds its consumer inside `getNextOffsetRanges`, so
the new tests can only reach it through a real broker.
This file already has the pattern, from HUDI-8955 (`6edf2094c4de`): the
sibling handler was made `@VisibleForTesting` and is driven with
`mock(KafkaConsumer.class)` over all-null / some-null / none-null via
`resolveEarliestOffsetsWithRetentionTestArgs`
(TestKafkaOffsetGen.java:650-676). `@VisibleForTesting` is already imported and
used at :524 and :647.
Please mark `getOffsetsByTimestamp` `@VisibleForTesting` and add the
equivalent `@ParameterizedTest`.
To be explicit so you do not chase it: the pre-0.10.0 case cannot be
reproduced on a broker here (`cp-kafka:7.7.1` is KRaft). A mock is the only way
to cover it.
##########
hudi-utilities/src/main/java/org/apache/hudi/utilities/config/KafkaSourceConfig.java:
##########
@@ -58,6 +58,10 @@ public class KafkaSourceConfig extends HoodieConfig {
+ ". Default type is " + KAFKA_CHECKPOINT_TYPE_STRING + ". "
+ "For type " + KAFKA_CHECKPOINT_TYPE_STRING + ", checkpoint should
be provided as: topicName,0:offset0,1:offset1,2:offset2. "
+ "For type " + KAFKA_CHECKPOINT_TYPE_TIMESTAMP + ", checkpoint
should be provided as long value of desired timestamp. "
+ + "If a partition has no record with a timestamp at or after the
checkpoint (either the partition is empty, "
+ + "all records predate the checkpoint, or messages use a pre-0.10.0
format that has no timestamp), that "
+ + "partition resumes from its end offset (the tip) rather than from
the beginning, so no already-published "
+ + "data is re-consumed. "
Review Comment:
Doc nit: this states only the upside. The user-visible half is the other one
-- records already sitting in that partition that predate the checkpoint are
never ingested at all. On a bootstrap sync nothing was consumed yet, so "no
already-published data is re-consumed" does not describe what the operator will
see.
Also, `beginningOffsets` returns the log *start* offset, which is 0 only for
an untrimmed partition; "the beginning" is fine but "offset 0" elsewhere in the
description is imprecise.
```suggestion
+ "partition resumes from its end offset (the tip) rather than
from the beginning; records already in "
+ "that partition that predate the checkpoint are skipped and will
not be ingested. "
```
##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -3334,30 +3338,133 @@ public void testJsonKafkaDFSSourceWithOffsets() throws
Exception {
@Test
public void testKafkaTimestampType() throws Exception {
- topicName = "topic" + testNum;
+ // Timestamp-based Kafka checkpoints have two distinct fallback behaviors
we need to cover:
+ // (1) Checkpoint captured BEFORE records are produced: every record has
ts >= checkpoint,
+ // so `offsetsForTimes` returns concrete offsets and ingestion
consumes all of them.
+ // (2) Checkpoint captured AFTER records are produced: no record has ts
>= checkpoint, so
+ // `offsetsForTimes` returns null for every partition and we fall
back to the end offset
+ // of each partition. Nothing should be ingested, and a subsequent
batch produced *after*
+ // the checkpoint should be picked up on the next sync — this proves
that the fallback
+ // stored a usable checkpoint at the partition tip (not offset 0,
which would replay the
+ // original records).
kafkaCheckpointType = "timestamp";
- prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName);
- prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName);
- String tableBasePath = basePath + "/test_json_kafka_table" + testNum;
+
+ // ---- Case 1: checkpoint captured BEFORE producing records ----
+ String topicName1 = "topic" + testNum;
+ topicName = topicName1;
Review Comment:
Nit: `topicName` is the inherited static field
(HoodieDeltaStreamerTestBase.java:142) and nothing here reads it --
`prepareJsonKafkaDFSFiles`, `prepareJsonKafkaDFSSource` and the new helper all
take an explicit topic parameter, and `@BeforeEach` resets it at :151.
Drop this assignment, the one at :3374, and the `topicName1` local.
##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -3334,30 +3338,133 @@ public void testJsonKafkaDFSSourceWithOffsets() throws
Exception {
@Test
public void testKafkaTimestampType() throws Exception {
- topicName = "topic" + testNum;
+ // Timestamp-based Kafka checkpoints have two distinct fallback behaviors
we need to cover:
+ // (1) Checkpoint captured BEFORE records are produced: every record has
ts >= checkpoint,
+ // so `offsetsForTimes` returns concrete offsets and ingestion
consumes all of them.
+ // (2) Checkpoint captured AFTER records are produced: no record has ts
>= checkpoint, so
+ // `offsetsForTimes` returns null for every partition and we fall
back to the end offset
+ // of each partition. Nothing should be ingested, and a subsequent
batch produced *after*
+ // the checkpoint should be picked up on the next sync — this proves
that the fallback
+ // stored a usable checkpoint at the partition tip (not offset 0,
which would replay the
+ // original records).
kafkaCheckpointType = "timestamp";
- prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName);
- prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName);
- String tableBasePath = basePath + "/test_json_kafka_table" + testNum;
+
+ // ---- Case 1: checkpoint captured BEFORE producing records ----
+ String topicName1 = "topic" + testNum;
+ topicName = topicName1;
+ long checkpointBeforeProduction = System.currentTimeMillis();
+ prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName1);
+ prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName1);
+ String tableBasePath1 = basePath + "/test_json_kafka_table" + testNum;
HoodieDeltaStreamer deltaStreamer = new HoodieDeltaStreamer(
- TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ TestHelpers.makeConfig(tableBasePath1, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
true, 100000, false, null,
- null, "timestamp", String.valueOf(System.currentTimeMillis())),
jsc);
+ null, "timestamp", String.valueOf(checkpointBeforeProduction)),
jsc);
deltaStreamer.sync();
- assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath, sqlContext);
+ assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath1, sqlContext);
+ deltaStreamer.shutdownGracefully();
- prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, false, topicName);
+ // ---- Case 2: checkpoint captured AFTER producing records ----
+ // First batch predates the checkpoint => fallback path returns end
offsets (partition tips).
+ // Nothing should be ingested in the first sync; a second batch produced
after the checkpoint
+ // should be fully consumed on the follow-up sync (which reuses the
checkpoint stored by the
+ // first sync). This asserts we resumed at the tip, not at offset 0.
+ String topicName2 = "topic_after_" + testNum;
+ topicName = topicName2;
+ prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName2);
+ // Small pause so the timestamp is guaranteed to be after the last
produced record's ts.
+ Thread.sleep(10);
+ long checkpointAfterProduction = System.currentTimeMillis();
+ prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName2);
+ String tableBasePath2 = basePath + "/test_json_kafka_table_after_" +
testNum;
deltaStreamer = new HoodieDeltaStreamer(
- TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ TestHelpers.makeConfig(tableBasePath2, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
+ true, 100000, false, null, null,
+ "timestamp", String.valueOf(checkpointAfterProduction)), jsc);
+ deltaStreamer.sync();
Review Comment:
Nit: this instance is synced then overwritten at :3392 without
`shutdownGracefully()`, so its write client is never closed. Same at :3421.
Inconsistent with :3366, :3401 and :3444 in this same method.
All five syncs here are one-shot, so `syncOnce(cfg)`
(HoodieDeltaStreamerTestBase.java:861) covers it -- it does new/sync/shutdown
and drops the local variable. Precedent in this file at :3309.
##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -3334,30 +3338,133 @@ public void testJsonKafkaDFSSourceWithOffsets() throws
Exception {
@Test
public void testKafkaTimestampType() throws Exception {
- topicName = "topic" + testNum;
+ // Timestamp-based Kafka checkpoints have two distinct fallback behaviors
we need to cover:
+ // (1) Checkpoint captured BEFORE records are produced: every record has
ts >= checkpoint,
+ // so `offsetsForTimes` returns concrete offsets and ingestion
consumes all of them.
+ // (2) Checkpoint captured AFTER records are produced: no record has ts
>= checkpoint, so
+ // `offsetsForTimes` returns null for every partition and we fall
back to the end offset
+ // of each partition. Nothing should be ingested, and a subsequent
batch produced *after*
+ // the checkpoint should be picked up on the next sync — this proves
that the fallback
+ // stored a usable checkpoint at the partition tip (not offset 0,
which would replay the
+ // original records).
kafkaCheckpointType = "timestamp";
- prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName);
- prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName);
- String tableBasePath = basePath + "/test_json_kafka_table" + testNum;
+
+ // ---- Case 1: checkpoint captured BEFORE producing records ----
+ String topicName1 = "topic" + testNum;
+ topicName = topicName1;
+ long checkpointBeforeProduction = System.currentTimeMillis();
+ prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName1);
+ prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName1);
+ String tableBasePath1 = basePath + "/test_json_kafka_table" + testNum;
HoodieDeltaStreamer deltaStreamer = new HoodieDeltaStreamer(
- TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ TestHelpers.makeConfig(tableBasePath1, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
true, 100000, false, null,
- null, "timestamp", String.valueOf(System.currentTimeMillis())),
jsc);
+ null, "timestamp", String.valueOf(checkpointBeforeProduction)),
jsc);
deltaStreamer.sync();
- assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath, sqlContext);
+ assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath1, sqlContext);
+ deltaStreamer.shutdownGracefully();
- prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, false, topicName);
+ // ---- Case 2: checkpoint captured AFTER producing records ----
+ // First batch predates the checkpoint => fallback path returns end
offsets (partition tips).
+ // Nothing should be ingested in the first sync; a second batch produced
after the checkpoint
+ // should be fully consumed on the follow-up sync (which reuses the
checkpoint stored by the
+ // first sync). This asserts we resumed at the tip, not at offset 0.
+ String topicName2 = "topic_after_" + testNum;
+ topicName = topicName2;
+ prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName2);
+ // Small pause so the timestamp is guaranteed to be after the last
produced record's ts.
+ Thread.sleep(10);
+ long checkpointAfterProduction = System.currentTimeMillis();
+ prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName2);
+ String tableBasePath2 = basePath + "/test_json_kafka_table_after_" +
testNum;
deltaStreamer = new HoodieDeltaStreamer(
- TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ TestHelpers.makeConfig(tableBasePath2, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
+ true, 100000, false, null, null,
+ "timestamp", String.valueOf(checkpointAfterProduction)), jsc);
+ deltaStreamer.sync();
+ assertRecordCount(0, tableBasePath2, sqlContext);
+
+ // Produce a fresh batch strictly after the checkpoint and sync again with
no --checkpoint
+ // override, so the streamer picks up from the offsets we stored in the
first sync.
+ prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, false, topicName2);
+ deltaStreamer = new HoodieDeltaStreamer(
+ TestHelpers.makeConfig(tableBasePath2, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
+ true, 100000, false, null, null,
+ "timestamp", null), jsc);
+ deltaStreamer.sync();
+ // Only the second batch should be ingested; the first batch (which
predates the checkpoint)
+ // stays skipped, confirming the fallback resumed at the partition tip.
+ assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath2, sqlContext);
+ deltaStreamer.shutdownGracefully();
Review Comment:
Optional, but strictly better than what is here. This second sync exists
only to prove the fallback checkpoint was persisted at the tip -- and the
discriminating assertion already fired at :3387 (`assertRecordCount(0, ...)`,
which would be 5 under the old code).
The repo asserts the stored kafka checkpoint directly in one line:
`TestHelpers.assertCommitMetadata` (HoodieDeltaStreamerTestBase.java:737), used
for exactly this shape at :988 -- `assertCommitMetadata(topicName +
",0:500,1:500", tableBasePath, 1)`.
Swapping both second syncs for that takes this method from 5 syncs to 2 and
asserts the exact checkpoint string instead of inferring it from a record count.
##########
hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java:
##########
@@ -471,17 +480,39 @@ private Option<String>
getOffsetsByTimestamp(KafkaConsumer consumer, List<Partit
.map(x -> new
TopicPartition(x.topic(), x.partition()))
.collect(Collectors.toMap(Function.identity(), x -> timestamp));
- Map<TopicPartition, Long> earliestOffsets =
consumer.beginningOffsets(topicPartitions);
Map<TopicPartition, OffsetAndTimestamp> offsetAndTimestamp =
consumer.offsetsForTimes(topicPartitionsTimestamp);
+ // Fetch end offsets lazily: they are only needed when at least one
partition has no record
+ // at/after the requested timestamp. In the common case where every
partition resolves,
+ // this saves one round-trip to the broker on every sync.
+ Map<TopicPartition, Long> endOffsets = null;
+ // Track partitions with no offset at/after the requested timestamp so we
can surface them
+ // as a WARN. Without this, callers whose messages use a pre-0.10.0 format
(which always
+ // returns null here) would silently skip to the tip of every partition
with no signal.
+ Map<TopicPartition, Long> fallbackToEndOffsets = new HashMap<>();
StringBuilder sb = new StringBuilder(topicName);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> map :
offsetAndTimestamp.entrySet()) {
if (map.getValue() != null) {
sb.append(",").append(map.getKey().partition()).append(":").append(map.getValue().offset());
} else {
-
sb.append(",").append(map.getKey().partition()).append(":").append(earliestOffsets.get(map.getKey()));
+ // No record in this partition has a timestamp >= the requested one.
Fall back to the
+ // end offset (partition tip) rather than 0/earliest to avoid
re-consuming the whole
+ // partition when the user has asked for a timestamp-based checkpoint.
+ if (endOffsets == null) {
+ endOffsets = consumer.endOffsets(topicPartitions);
+ }
+ Long endOffset = endOffsets.get(map.getKey());
+ fallbackToEndOffsets.put(map.getKey(), endOffset);
+
sb.append(",").append(map.getKey().partition()).append(":").append(endOffset);
}
}
+ if (!fallbackToEndOffsets.isEmpty()) {
+ log.warn("No offset was found at/after timestamp {} for partitions {};
falling back to their "
+ + "end offsets. This can happen when all records in the
partition predate the requested "
+ + "timestamp, when the partition is empty, or when messages use
a pre-0.10.0 format "
+ + "without a timestamp. Fallback offsets: {}",
+ timestamp, fallbackToEndOffsets.keySet(), fallbackToEndOffsets);
Review Comment:
Nit, optional: this prints `fallbackToEndOffsets.keySet()` and then the
whole map, so the partition list appears twice -- `Map.toString()` already
contains the keys. The sibling handler at :556 logs it once.
Drop the `keySet()` argument and its `{}`, keeping only `Fallback offsets:
{}`.
##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -3334,30 +3338,133 @@ public void testJsonKafkaDFSSourceWithOffsets() throws
Exception {
@Test
public void testKafkaTimestampType() throws Exception {
- topicName = "topic" + testNum;
+ // Timestamp-based Kafka checkpoints have two distinct fallback behaviors
we need to cover:
+ // (1) Checkpoint captured BEFORE records are produced: every record has
ts >= checkpoint,
+ // so `offsetsForTimes` returns concrete offsets and ingestion
consumes all of them.
+ // (2) Checkpoint captured AFTER records are produced: no record has ts
>= checkpoint, so
+ // `offsetsForTimes` returns null for every partition and we fall
back to the end offset
+ // of each partition. Nothing should be ingested, and a subsequent
batch produced *after*
+ // the checkpoint should be picked up on the next sync — this proves
that the fallback
+ // stored a usable checkpoint at the partition tip (not offset 0,
which would replay the
+ // original records).
kafkaCheckpointType = "timestamp";
- prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName);
- prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName);
- String tableBasePath = basePath + "/test_json_kafka_table" + testNum;
+
+ // ---- Case 1: checkpoint captured BEFORE producing records ----
+ String topicName1 = "topic" + testNum;
+ topicName = topicName1;
+ long checkpointBeforeProduction = System.currentTimeMillis();
+ prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName1);
+ prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName1);
+ String tableBasePath1 = basePath + "/test_json_kafka_table" + testNum;
HoodieDeltaStreamer deltaStreamer = new HoodieDeltaStreamer(
- TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ TestHelpers.makeConfig(tableBasePath1, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
true, 100000, false, null,
- null, "timestamp", String.valueOf(System.currentTimeMillis())),
jsc);
+ null, "timestamp", String.valueOf(checkpointBeforeProduction)),
jsc);
deltaStreamer.sync();
- assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath, sqlContext);
+ assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath1, sqlContext);
+ deltaStreamer.shutdownGracefully();
- prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, false, topicName);
+ // ---- Case 2: checkpoint captured AFTER producing records ----
+ // First batch predates the checkpoint => fallback path returns end
offsets (partition tips).
+ // Nothing should be ingested in the first sync; a second batch produced
after the checkpoint
+ // should be fully consumed on the follow-up sync (which reuses the
checkpoint stored by the
+ // first sync). This asserts we resumed at the tip, not at offset 0.
+ String topicName2 = "topic_after_" + testNum;
+ topicName = topicName2;
+ prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, topicName2);
+ // Small pause so the timestamp is guaranteed to be after the last
produced record's ts.
+ Thread.sleep(10);
+ long checkpointAfterProduction = System.currentTimeMillis();
+ prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName2);
+ String tableBasePath2 = basePath + "/test_json_kafka_table_after_" +
testNum;
deltaStreamer = new HoodieDeltaStreamer(
- TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ TestHelpers.makeConfig(tableBasePath2, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
+ true, 100000, false, null, null,
+ "timestamp", String.valueOf(checkpointAfterProduction)), jsc);
+ deltaStreamer.sync();
+ assertRecordCount(0, tableBasePath2, sqlContext);
+
+ // Produce a fresh batch strictly after the checkpoint and sync again with
no --checkpoint
+ // override, so the streamer picks up from the offsets we stored in the
first sync.
+ prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, false, topicName2);
+ deltaStreamer = new HoodieDeltaStreamer(
+ TestHelpers.makeConfig(tableBasePath2, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
+ true, 100000, false, null, null,
+ "timestamp", null), jsc);
+ deltaStreamer.sync();
+ // Only the second batch should be ingested; the first batch (which
predates the checkpoint)
+ // stays skipped, confirming the fallback resumed at the partition tip.
+ assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath2, sqlContext);
+ deltaStreamer.shutdownGracefully();
+
+ // ---- Case 3: checkpoint captured between two partition-targeted batches
----
+ // Partition 0 receives its records BEFORE the checkpoint, partition 1
receives its records
+ // AFTER. Only partition 1 should be consumed on the first sync (partition
0 falls back to
+ // its end offset). A follow-up sync that adds fresh records to partition
0 should then
+ // ingest exactly those new records, proving partition 0's checkpoint was
stored at its tip
+ // rather than at offset 0.
+ String topicName3 = "topic_partial_" + testNum;
+ topicName = topicName3;
+ int numPartitions = 2;
+ int recordsPerPartition = JSON_KAFKA_NUM_RECORDS / numPartitions;
+ testUtils.createTopic(topicName3, numPartitions);
+ sendJsonMessagesToPartition(topicName3, 0, recordsPerPartition);
+ Thread.sleep(10);
+ long checkpointBetweenPartitions = System.currentTimeMillis();
+ Thread.sleep(10);
+ sendJsonMessagesToPartition(topicName3, 1, recordsPerPartition);
+ prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest",
topicName3);
+ String tableBasePath3 = basePath + "/test_json_kafka_table_partial_" +
testNum;
+ deltaStreamer = new HoodieDeltaStreamer(
+ TestHelpers.makeConfig(tableBasePath3, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
+ Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
+ true, 100000, false, null, null,
+ "timestamp", String.valueOf(checkpointBetweenPartitions)), jsc);
+ deltaStreamer.sync();
+ // Partition 0 predates the checkpoint => fallback to end offset (0
records). Partition 1
+ // was produced after the checkpoint => all recordsPerPartition records
ingested.
+ assertRecordCount(recordsPerPartition, tableBasePath3, sqlContext);
+
+ // Fresh records to partition 0 (produced after the earlier checkpoint)
should now be picked
+ // up. If we had incorrectly fallen back to offset 0 for partition 0
above, we'd have already
+ // consumed its earlier records and the assertion above would have failed
at 2 * recordsPerPartition.
+ sendJsonMessagesToPartition(topicName3, 0, recordsPerPartition);
+ deltaStreamer = new HoodieDeltaStreamer(
+ TestHelpers.makeConfig(tableBasePath3, WriteOperationType.UPSERT,
JsonKafkaSource.class.getName(),
Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
true, 100000, false, null, null,
- "timestamp", String.valueOf(System.currentTimeMillis())), jsc);
+ "timestamp", null), jsc);
deltaStreamer.sync();
- assertRecordCount(JSON_KAFKA_NUM_RECORDS * 2, tableBasePath, sqlContext);
+ // Total: partition 1's original batch + partition 0's fresh batch.
Partition 0's original,
+ // pre-checkpoint batch stays skipped.
+ assertRecordCount(recordsPerPartition * 2L, tableBasePath3, sqlContext);
deltaStreamer.shutdownGracefully();
Review Comment:
Cleanliness, but a real one. Case 3 exercises the same production branch as
`testGetNextOffsetRangesFromTimestampCheckpointTypeWithPartialOffsets`, but
pays two full Spark syncs plus a third topic and table for it.
It is also the weaker of the two: `JSON_KAFKA_NUM_RECORDS = 5`
(HoodieDeltaStreamerTestBase.java:123), so `recordsPerPartition = 5 / 2 = 2`.
Case 3 discriminates on 2 vs 4 records where the unit test does 500 in one
direct call, and the integer division silently drops a record.
Please drop case 3 (:3403-3444), `sendJsonMessagesToPartition`, and the four
Kafka producer imports. Cases 1-2 plus the unit test already cover every branch.
##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestKafkaOffsetGen.java:
##########
@@ -186,6 +190,97 @@ public void
testGetNextOffsetRangesFromTimestampCheckpointType() {
assertEquals(500, nextOffsetRanges[0].untilOffset());
}
+ /**
+ * When the requested timestamp is later than every record in the topic,
+ * {@link org.apache.kafka.clients.consumer.KafkaConsumer#offsetsForTimes}
returns {@code null}
+ * for every partition. In that case we must fall back to the partition's
end offset (its tip),
+ * not to offset 0 / earliest — otherwise the entire partition would be
replayed even though the
+ * user asked for a strictly later checkpoint.
+ */
+ @Test
+ public void
testGetNextOffsetRangesFromTimestampCheckpointTypeWithNoOffsetsAfterTimestamp()
throws Exception {
+ HoodieTestDataGenerator dataGenerator = new HoodieTestDataGenerator();
+ testUtils.createTopic(testTopicName, 1);
+ testUtils.sendMessages(testTopicName,
Helpers.jsonifyRecords(dataGenerator.generateInserts("000", 1000)));
+ // Ensure the checkpoint we pass is strictly after every published
record's timestamp.
+ Thread.sleep(10);
+ long checkpointAfterProduction = System.currentTimeMillis();
+
+ KafkaOffsetGen kafkaOffsetGen = new
KafkaOffsetGen(getConsumerConfigs("latest", KAFKA_CHECKPOINT_TYPE_TIMESTAMP));
+
+ OffsetRange[] nextOffsetRanges = kafkaOffsetGen.getNextOffsetRanges(
+ Option.of(new
StreamerCheckpointV2(String.valueOf(checkpointAfterProduction))), 500, metrics);
+ assertEquals(1, nextOffsetRanges.length);
+ // Fallback to end offset (the tip): from == until == 1000, nothing to
consume.
+ assertEquals(1000, nextOffsetRanges[0].fromOffset());
+ assertEquals(1000, nextOffsetRanges[0].untilOffset());
+ }
+
+ /**
+ * Mixed case: some partitions have records at/after the requested timestamp
and some don't.
+ * Only the partitions with no matching record should fall back to their end
offset; partitions
+ * that do have matching records should still resume at the offset returned
by
+ * {@link org.apache.kafka.clients.consumer.KafkaConsumer#offsetsForTimes}.
This is the user-visible
+ * bug the fallback change is targeting.
+ */
+ @Test
+ public void
testGetNextOffsetRangesFromTimestampCheckpointTypeWithPartialOffsets() throws
Exception {
+ testUtils.createTopic(testTopicName, 2);
+ int recordsPerPartition = 500;
+
+ // Publish `recordsPerPartition` records to partition 0 first, then take a
checkpoint after
+ // them. Any record produced later goes to partition 1 and is guaranteed
to have a timestamp
+ // strictly greater than the checkpoint.
+ sendMessagesToPartition(testTopicName, 0, recordsPerPartition);
+ Thread.sleep(10);
+ long checkpointBetweenBatches = System.currentTimeMillis();
+ Thread.sleep(10);
+ sendMessagesToPartition(testTopicName, 1, recordsPerPartition);
+
+ KafkaOffsetGen kafkaOffsetGen = new
KafkaOffsetGen(getConsumerConfigs("latest", KAFKA_CHECKPOINT_TYPE_TIMESTAMP));
+
+ OffsetRange[] nextOffsetRanges = kafkaOffsetGen.getNextOffsetRanges(
+ Option.of(new
StreamerCheckpointV2(String.valueOf(checkpointBetweenBatches))),
recordsPerPartition, metrics);
+
+ // computeOffsetRanges may split a single partition into multiple
sub-ranges when
+ // eventsPerPartition < partition size, so group by partition and verify
the aggregate.
+ Map<Integer, List<OffsetRange>> byPartition =
Arrays.stream(nextOffsetRanges)
+ .collect(Collectors.groupingBy(OffsetRange::partition));
+ assertEquals(2, byPartition.size(), "expected ranges for exactly 2
partitions");
+
+ // Partition 0: all records predate the checkpoint => fromOffset ==
untilOffset == 500
+ List<OffsetRange> p0Ranges = byPartition.get(0);
+ assertEquals(recordsPerPartition, p0Ranges.get(0).fromOffset(),
+ "partition 0 should start at the end offset (tip)");
+ assertEquals(recordsPerPartition, p0Ranges.get(p0Ranges.size() -
1).untilOffset(),
+ "partition 0 should end at the end offset (nothing to consume)");
+
+ // Partition 1: records were produced after the checkpoint => consume from
offset 0 to 500
+ List<OffsetRange> p1Ranges = byPartition.get(1);
+ assertEquals(0, p1Ranges.get(0).fromOffset(),
+ "partition 1 should start from offset 0");
+ assertEquals(recordsPerPartition, p1Ranges.get(p1Ranges.size() -
1).untilOffset(),
+ "partition 1 should consume all records");
Review Comment:
Worth one more line. This asserts only `first.fromOffset()` and
`last.untilOffset()` per partition. With `numEvents = 500` and `minPartitions =
2`, partition 1 is deliberately split into sub-ranges -- and sub-range
accounting is the most-regressed code in this file: HUDI-7450 (#10768),
HUDI-7506 (#10869), HUDI-7511 (#10875), HUDI-7153 (#10205).
As written, overlapping or gapped middle sub-ranges would still pass. Add
`assertEquals(recordsPerPartition,
KafkaOffsetGen.CheckpointUtils.totalNewMessages(nextOffsetRanges));` and assert
p1's ranges are contiguous (`ranges[i].untilOffset() ==
ranges[i+1].fromOffset()`). Cheap, and it closes that exact bug class.
--
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]