This is an automated email from the ASF dual-hosted git repository.

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 1c573f84f6e0 fix(utilities): use endOffsets when no offset is greater 
than the checkpoint timestamp (#19658)
1c573f84f6e0 is described below

commit 1c573f84f6e08d3942ad0dc1fe47cc6f1ea0eff7
Author: wangxianghu <[email protected]>
AuthorDate: Tue Aug 25 20:02:29 2026 +0400

    fix(utilities): use endOffsets when no offset is greater than the 
checkpoint timestamp (#19658)
    
    * fix(utilities) Use endOffsets when there is no offsets greater than 
timestamp checkpoint
    
    * address comments
---
 .../hudi/utilities/config/KafkaSourceConfig.java   |   4 +
 .../utilities/sources/helpers/KafkaOffsetGen.java  |  40 ++++-
 .../deltastreamer/TestHoodieDeltaStreamer.java     |  68 +++++---
 .../sources/helpers/TestKafkaOffsetGen.java        | 177 +++++++++++++++++++++
 4 files changed, 264 insertions(+), 25 deletions(-)

diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/KafkaSourceConfig.java
 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/KafkaSourceConfig.java
index e0e06549ac02..d7b1dae8a663 100644
--- 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/KafkaSourceConfig.java
+++ 
b/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; records already in "
+          + "that partition that predate the checkpoint are skipped and will 
not be ingested. "
           + "For type " + KAFKA_CHECKPOINT_TYPE_SINGLE_OFFSET + ", we assume 
that topic consists of a single partition, "
           + "so checkpoint should be provided as long value of desired 
offset.");
 
diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java
 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java
index 941858dc8552..aa76ac80842a 100644
--- 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java
+++ 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java
@@ -346,7 +346,7 @@ public class KafkaOffsetGen {
     }
     return CheckpointUtils.computeOffsetRanges(fromOffsets, toOffsets, 
numEvents, minPartitions);
   }
-  
+
   /**
    * Fetch partition infos for given topic.
    *
@@ -459,29 +459,59 @@ public class KafkaOffsetGen {
    * 1. input: timestamp, etc.
    * 2. output: 
topicName,partition_num_0:100,partition_num_1:101,partition_num_2:102.
    *
+   * <p>For each partition, {@link KafkaConsumer#offsetsForTimes} returns the 
offset of the first
+   * record whose timestamp is >= {@code timestamp}. If a partition has no 
such record (either
+   * because all records predate {@code timestamp}, the partition is empty, or 
the messages use a
+   * pre-0.10.0 format that has no timestamp), {@code offsetsForTimes} returns 
{@code null} for
+   * that partition. In that case, we fall back to the partition's end offset 
rather than the
+   * beginning offset: since no record satisfies the requested timestamp, the 
correct starting
+   * point is the tip of the partition. Falling back to the beginning offset 
would re-consume the
+   * entire partition, which contradicts the semantics of a timestamp-based 
checkpoint.
+   *
    * @param consumer
    * @param topicName
    * @param timestamp
    * @return
    */
-  private Option<String> getOffsetsByTimestamp(KafkaConsumer consumer, 
List<PartitionInfo> partitionInfoList, Set<TopicPartition> topicPartitions,
-                                               String topicName, Long 
timestamp) {
+  @VisibleForTesting
+  Option<String> getOffsetsByTimestamp(KafkaConsumer consumer, 
List<PartitionInfo> partitionInfoList, Set<TopicPartition> topicPartitions,
+                                       String topicName, Long timestamp) {
 
     Map<TopicPartition, Long> topicPartitionsTimestamp = 
partitionInfoList.stream()
                                                     .map(x -> new 
TopicPartition(x.topic(), x.partition()))
                                                     
.collect(Collectors.toMap(Function.identity(), x -> timestamp));
 
-    Map<TopicPartition, Long> earliestOffsets = 
consumer.beginningOffsets(topicPartitions);
+    // Fetch end offsets BEFORE offsetsForTimes to close the window where a 
record appended
+    // between the two calls would be permanently skipped. With this order, if 
offsetsForTimes
+    // returns null for a partition, we fall back to the end offset captured 
at T1; any record
+    // written after T1 will be picked up in the next batch (at-least-once 
semantics).
+    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 {
-        
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 captured before offsetsForTimes to guarantee 
at-least-once: any record
+        // written after we snapshot endOffsets will be re-consumed next 
batch, never skipped.
+        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);
+    }
     return Option.of(sb.toString());
   }
 
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
index 16aeb35971e5..81c8560e61d9 100644
--- 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
@@ -3334,28 +3334,56 @@ public class TestHoodieDeltaStreamer extends 
HoodieDeltaStreamerTestBase {
 
   @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;
-    HoodieDeltaStreamer deltaStreamer = new HoodieDeltaStreamer(
-        TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT, 
JsonKafkaSource.class.getName(),
-            Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
-            true, 100000, false, null,
-            null, "timestamp", String.valueOf(System.currentTimeMillis())), 
jsc);
-    deltaStreamer.sync();
-    assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath, sqlContext);
 
-    prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, false, topicName);
-    deltaStreamer = new HoodieDeltaStreamer(
-        TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT, 
JsonKafkaSource.class.getName(),
-            Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
-            true, 100000, false, null, null,
-            "timestamp", String.valueOf(System.currentTimeMillis())), jsc);
-    deltaStreamer.sync();
-    assertRecordCount(JSON_KAFKA_NUM_RECORDS * 2, tableBasePath, sqlContext);
-    deltaStreamer.shutdownGracefully();
+    // ---- Case 1: checkpoint captured BEFORE producing records ----
+    long checkpointBeforeProduction = System.currentTimeMillis();
+    prepareJsonKafkaDFSFiles(JSON_KAFKA_NUM_RECORDS, true, "topic" + testNum);
+    prepareJsonKafkaDFSSource(PROPS_FILENAME_TEST_JSON_KAFKA, "earliest", 
"topic" + testNum);
+    String tableBasePath1 = basePath + "/test_json_kafka_table" + testNum;
+    syncOnce(TestHelpers.makeConfig(tableBasePath1, WriteOperationType.UPSERT, 
JsonKafkaSource.class.getName(),
+        Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
+        true, 100000, false, null,
+        null, "timestamp", String.valueOf(checkpointBeforeProduction)));
+    assertRecordCount(JSON_KAFKA_NUM_RECORDS, tableBasePath1, sqlContext);
+
+    // ---- 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;
+    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;
+    syncOnce(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)));
+    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);
+    syncOnce(TestHelpers.makeConfig(tableBasePath2, WriteOperationType.UPSERT, 
JsonKafkaSource.class.getName(),
+        Collections.emptyList(), PROPS_FILENAME_TEST_JSON_KAFKA, false,
+        true, 100000, false, null, null,
+        "timestamp", null));
+    // 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);
   }
 
   @Disabled("HUDI-6609")
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestKafkaOffsetGen.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestKafkaOffsetGen.java
index e630d6213974..7b261a44ddf7 100644
--- 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestKafkaOffsetGen.java
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestKafkaOffsetGen.java
@@ -37,12 +37,17 @@ import org.apache.kafka.clients.admin.DescribeConfigsResult;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.apache.kafka.clients.consumer.KafkaConsumer;
 import org.apache.kafka.clients.consumer.OffsetAndTimestamp;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.clients.producer.ProducerRecord;
 import org.apache.kafka.common.KafkaException;
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.PartitionInfo;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.config.ConfigResource;
 import org.apache.kafka.common.config.TopicConfig;
 import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
 import org.apache.spark.streaming.kafka010.OffsetRange;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
@@ -186,6 +191,103 @@ public class TestKafkaOffsetGen {
     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");
+    assertEquals(recordsPerPartition, 
KafkaOffsetGen.CheckpointUtils.totalNewMessages(nextOffsetRanges),
+        "total new messages should equal recordsPerPartition");
+    for (int i = 0; i < p1Ranges.size() - 1; i++) {
+      assertEquals(p1Ranges.get(i).untilOffset(), p1Ranges.get(i + 
1).fromOffset(),
+          "partition 1 sub-ranges should be contiguous");
+    }
+  }
+
+  /**
+   * Publish {@code count} simple string records to a specific partition of 
{@code topic}. Used to
+   * simulate a mixed "some partitions have records after ts, some don't" 
state that the default
+   * partitioner-based {@link KafkaTestUtils#sendMessages} cannot 
deterministically produce.
+   */
+  private void sendMessagesToPartition(String topic, int partition, int count) 
{
+    Properties producerProps = new Properties();
+    producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 
testUtils.brokerAddress());
+    producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName());
+    producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName());
+    try (KafkaProducer<String, String> producer = new 
KafkaProducer<>(producerProps)) {
+      for (int i = 0; i < count; i++) {
+        producer.send(new ProducerRecord<>(topic, partition, null, "msg-" + 
partition + "-" + i));
+      }
+      producer.flush();
+    }
+  }
+
   @Test
   public void testGetNextOffsetRangesFromSingleOffsetCheckpoint() {
     HoodieTestDataGenerator dataGenerator = new HoodieTestDataGenerator();
@@ -590,6 +692,81 @@ public class TestKafkaOffsetGen {
     }
   }
 
+  static Stream<Arguments> getOffsetsByTimestampArgs() {
+    long ts = System.currentTimeMillis();
+    String topicName = "kafka-topic-" + UUID.randomUUID();
+    List<TopicPartition> topicPartitions =
+        Arrays.asList(new TopicPartition(topicName, 0), new 
TopicPartition(topicName, 1));
+
+    // end offsets used as fallback when offsetsForTimes returns null
+    Map<TopicPartition, Long> endOffsets = new HashMap<>();
+    endOffsets.put(topicPartitions.get(0), 50L);
+    endOffsets.put(topicPartitions.get(1), 80L);
+
+    // none-null: every partition resolves via offsetsForTimes
+    Map<TopicPartition, OffsetAndTimestamp> allResolved = new HashMap<>();
+    allResolved.put(topicPartitions.get(0), new OffsetAndTimestamp(20, ts));
+    allResolved.put(topicPartitions.get(1), new OffsetAndTimestamp(35, ts));
+    String expectedAllResolved = String.format("%s,0:20,1:35", topicName);
+
+    // some-null: one partition resolves, one falls back to end offset
+    Map<TopicPartition, OffsetAndTimestamp> someNull = new HashMap<>();
+    someNull.put(topicPartitions.get(0), new OffsetAndTimestamp(20, ts));
+    someNull.put(topicPartitions.get(1), null);
+    String expectedSomeNull = String.format("%s,0:20,1:80", topicName);
+
+    // all-null: every partition falls back to end offset (pre-0.10.0 format / 
empty partitions)
+    Map<TopicPartition, OffsetAndTimestamp> allNull = new HashMap<>();
+    allNull.put(topicPartitions.get(0), null);
+    allNull.put(topicPartitions.get(1), null);
+    String expectedAllNull = String.format("%s,0:50,1:80", topicName);
+
+    return Stream.of(
+        Arguments.of(topicName, topicPartitions, endOffsets, allResolved, 
expectedAllResolved),
+        Arguments.of(topicName, topicPartitions, endOffsets, someNull, 
expectedSomeNull),
+        Arguments.of(topicName, topicPartitions, endOffsets, allNull, 
expectedAllNull)
+    );
+  }
+
+  @ParameterizedTest
+  @MethodSource("getOffsetsByTimestampArgs")
+  void testGetOffsetsByTimestamp(
+      String topicName,
+      List<TopicPartition> topicPartitions,
+      Map<TopicPartition, Long> endOffsets,
+      Map<TopicPartition, OffsetAndTimestamp> offsetAndTimestamp,
+      String expectedCheckpoint) {
+    long timestamp = System.currentTimeMillis();
+
+    KafkaConsumer mockConsumer = mock(KafkaConsumer.class);
+    List<PartitionInfo> partitionInfoList = topicPartitions.stream()
+        .map(tp -> new org.apache.kafka.common.PartitionInfo(tp.topic(), 
tp.partition(), null, null, null))
+        .collect(Collectors.toList());
+
+    Map<TopicPartition, Long> topicPartitionsTimestamp = new HashMap<>();
+    topicPartitions.forEach(tp -> topicPartitionsTimestamp.put(tp, timestamp));
+
+    when(mockConsumer.endOffsets(new 
HashSet<>(topicPartitions))).thenReturn(endOffsets);
+    
when(mockConsumer.offsetsForTimes(topicPartitionsTimestamp)).thenReturn(offsetAndTimestamp);
+
+    TypedProperties consumerConfigs = getConsumerConfigs(topicName, 
"earliest", "string");
+    KafkaOffsetGen kafkaOffsetGen = new KafkaOffsetGen(consumerConfigs);
+
+    Option<String> result = kafkaOffsetGen.getOffsetsByTimestamp(
+        mockConsumer, partitionInfoList, new HashSet<>(topicPartitions), 
topicName, timestamp);
+
+    assertTrue(result.isPresent());
+    // Parse both strings into offset maps for order-independent comparison
+    assertEquals(
+        KafkaOffsetGen.CheckpointUtils.strToOffsets(expectedCheckpoint),
+        KafkaOffsetGen.CheckpointUtils.strToOffsets(result.get()));
+
+    // endOffsets must be called BEFORE offsetsForTimes (verified via call 
order)
+    org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(mockConsumer);
+    inOrder.verify(mockConsumer).endOffsets(new HashSet<>(topicPartitions));
+    inOrder.verify(mockConsumer).offsetsForTimes(topicPartitionsTimestamp);
+  }
+
   void mockDescribeTopicConfigs(MockedStatic<AdminClient> staticMock, Map 
kafkaParams, Config topicConfig) {
     mockDescribeTopicConfigs(staticMock, kafkaParams, topicConfig, 
testTopicName);
   }

Reply via email to