This is an automated email from the ASF dual-hosted git repository.
lucasbru pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/trunk by this push:
new 3e0787427b0 KAFKA-19871 - Add partition support to TestRecord (#22612)
3e0787427b0 is described below
commit 3e0787427b0500f216b306b6dc7927194bfa22f7
Author: Sebastien Viale <[email protected]>
AuthorDate: Tue Jun 23 16:05:46 2026 +0200
KAFKA-19871 - Add partition support to TestRecord (#22612)
**Motivation**
As part of [KIP-1238: Multi-partition support in
TopologyTestDriver.](https://cwiki.apache.org/confluence/display/KAFKA/KIP-1238%3A+Multipartition+for+TopologyTestDriver+in+Kafka+Streams)
TestRecord needs to carry partition information so that records produced
and consumed by TopologyTestDriver can expose the partition they belong
to. This is a prerequisite for supporting multi-partition testing with
TopologyTestDriver.
**Changes**
This PR updates TestRecord by:
- adding a partition field;
- adding constructors that allow specifying the partition explicitly;
- preserving existing constructors, which continue to default the
partition to -1 (unspecified);
- including the partition in equals(), hashCode(), and toString();
- adding equalsIgnorePartition() for tests that do not care about the
partition.
**Compatibility**
Existing TestRecord constructors remain unchanged and continue to assign
partition = -1.
Since no component currently sets a partition value, records created
through existing APIs continue to carry partition = -1, preserving the
behavior of existing tests.
This PR is intentionally limited to introducing partition support in
TestRecord. Follow-up PRs will add multi-partition support to
TopologyTestDriver.
Reviewers: Matthias J. Sax <[email protected]>, Lucas Brutschy
<[email protected]>
Co-authored-by: Marie-Laure Momplot <[email protected]>
Co-authored-by: Julien Brunet <[email protected]>
Co-authored-by: Adam Souquieres <[email protected]>
---
.../org/apache/kafka/streams/test/TestRecord.java | 144 ++++++++++++++++-----
.../apache/kafka/streams/test/TestRecordTest.java | 128 +++++++++++++++++-
2 files changed, 238 insertions(+), 34 deletions(-)
diff --git
a/streams/test-utils/src/main/java/org/apache/kafka/streams/test/TestRecord.java
b/streams/test-utils/src/main/java/org/apache/kafka/streams/test/TestRecord.java
index eb724a08e16..1a6570a71e0 100644
---
a/streams/test-utils/src/main/java/org/apache/kafka/streams/test/TestRecord.java
+++
b/streams/test-utils/src/main/java/org/apache/kafka/streams/test/TestRecord.java
@@ -33,37 +33,62 @@ import java.util.StringJoiner;
* {@link TestInputTopic} will auto advance it's time when the record is piped.
*/
public class TestRecord<K, V> {
+ private static final int NO_PARTITION = -1;
private final Headers headers;
private final K key;
private final V value;
private final Instant recordTime;
-
- public boolean equalsIgnorePartition(final TestRecord<? extends K, ? super
V> o) {
- return false;
- }
+ /**
+ * The partition this record is assigned to.
+ * A value of {@code -1} is a sentinel meaning "no explicit partition set"
and is used
+ * only on <em>input</em> records created without an explicit partition
argument.
+ * Output records read from {@link
org.apache.kafka.streams.TestOutputTopic#readRecordsToList()}
+ * always carry the real resolved partition ({@code >= 0}).
+ */
+ private final int partition;
/**
- * Creates a record.
+ * Creates a record with an explicit partition.
*
* @param key The key that will be included in the record
* @param value The value of the record
- * @param headers the record headers that will be included in the record
- * @param recordTime The timestamp of the record.
+ * @param headers The record headers that will be included in the record
+ * @param recordTime The timestamp of the record
+ * @param partition The partition this record is assigned to
*/
- public TestRecord(final K key, final V value, final Headers headers, final
Instant recordTime) {
+ public TestRecord(final K key, final V value, final Headers headers, final
Instant recordTime, final int partition) {
this.key = key;
this.value = value;
this.recordTime = recordTime;
this.headers = new RecordHeaders(headers);
+ if (partition < NO_PARTITION) {
+ throw new IllegalArgumentException(
+ String.format("Invalid partition: %d. Partition number should
always be non-negative or %d.", partition, NO_PARTITION));
+ }
+ this.partition = partition;
}
/**
* Creates a record.
+ * Partition defaults to {@code -1} (no explicit partition set).
*
* @param key The key that will be included in the record
* @param value The value of the record
- * @param headers the record headers that will be included in the record
- * @param timestampMs The timestamp of the record, in milliseconds since
the beginning of the epoch.
+ * @param headers The record headers that will be included in the record
+ * @param recordTime The timestamp of the record
+ */
+ public TestRecord(final K key, final V value, final Headers headers, final
Instant recordTime) {
+ this(key, value, headers, recordTime, NO_PARTITION);
+ }
+
+ /**
+ * Creates a record.
+ * Partition defaults to {@code -1} (no explicit partition set).
+ *
+ * @param key The key that will be included in the record
+ * @param value The value of the record
+ * @param headers The record headers that will be included in the record
+ * @param timestampMs The timestamp of the record, in milliseconds since
the beginning of the epoch
*/
public TestRecord(final K key, final V value, final Headers headers, final
Long timestampMs) {
if (timestampMs != null) {
@@ -78,35 +103,36 @@ public class TestRecord<K, V> {
this.key = key;
this.value = value;
this.headers = new RecordHeaders(headers);
+ this.partition = NO_PARTITION;
}
/**
* Creates a record.
+ * Partition defaults to {@code -1} (no explicit partition set).
*
* @param key The key of the record
* @param value The value of the record
- * @param recordTime The timestamp of the record as Instant.
+ * @param recordTime The timestamp of the record as Instant
*/
public TestRecord(final K key, final V value, final Instant recordTime) {
- this(key, value, null, recordTime);
+ this(key, value, null, recordTime, NO_PARTITION);
}
/**
* Creates a record.
+ * Partition defaults to {@code -1} (no explicit partition set).
*
* @param key The key of the record
* @param value The value of the record
* @param headers The record headers that will be included in the record
*/
public TestRecord(final K key, final V value, final Headers headers) {
- this.key = key;
- this.value = value;
- this.headers = new RecordHeaders(headers);
- this.recordTime = null;
+ this(key, value, headers, (Instant) null, NO_PARTITION);
}
-
+
/**
* Creates a record.
+ * Partition defaults to {@code -1} (no explicit partition set).
*
* @param key The key of the record
* @param value The value of the record
@@ -116,10 +142,12 @@ public class TestRecord<K, V> {
this.value = value;
this.headers = new RecordHeaders();
this.recordTime = null;
+ this.partition = NO_PARTITION;
}
/**
* Create a record with {@code null} key.
+ * Partition defaults to {@code -1} (no explicit partition set).
*
* @param value The value of the record
*/
@@ -129,19 +157,27 @@ public class TestRecord<K, V> {
/**
* Create a {@code TestRecord} from a {@link ConsumerRecord}.
+ * The partition is taken from {@link ConsumerRecord#partition()}.
*
- * @param record The v
+ * @param record The consumer record
*/
public TestRecord(final ConsumerRecord<K, V> record) {
Objects.requireNonNull(record);
this.key = record.key();
this.value = record.value();
this.headers = record.headers();
- this.recordTime = Instant.ofEpochMilli(record.timestamp());
+ this.recordTime = record.timestamp() < 0 ? null :
Instant.ofEpochMilli(record.timestamp());
+ final int partition = record.partition();
+ if (partition < 0) {
+ throw new IllegalArgumentException(
+ String.format("Invalid partition: %d. Partition number
should always be non-negative.", partition));
+ }
+ this.partition = partition;
}
/**
* Create a {@code TestRecord} from a {@link ProducerRecord}.
+ * If the producer record carries an explicit partition it is used;
otherwise defaults to {@code -1}.
*
* @param record The record contents
*/
@@ -150,7 +186,18 @@ public class TestRecord<K, V> {
this.key = record.key();
this.value = record.value();
this.headers = record.headers();
- this.recordTime = Instant.ofEpochMilli(record.timestamp());
+ final Long timestamp = record.timestamp();
+ if (timestamp != null && timestamp < 0) {
+ throw new IllegalArgumentException(
+ String.format("Invalid timestamp: %d. Timestamp should always
be non-negative or null.", timestamp));
+ }
+ this.recordTime = timestamp == null ? null :
Instant.ofEpochMilli(timestamp);
+ final Integer partition = record.partition();
+ if (partition != null && partition < 0) {
+ throw new IllegalArgumentException(
+ String.format("Invalid partition: %d. Partition number should
always be non-negative or null.", partition));
+ }
+ this.partition = partition != null ? partition : NO_PARTITION;
}
/**
@@ -181,6 +228,13 @@ public class TestRecord<K, V> {
return this.recordTime == null ? null : this.recordTime.toEpochMilli();
}
+ /**
+ * @return the partition number, or {@code -1} if no partition was
explicitly set
+ */
+ public int partition() {
+ return partition;
+ }
+
/**
* @return The headers.
*/
@@ -209,17 +263,48 @@ public class TestRecord<K, V> {
return recordTime;
}
+ /**
+ * @return the partition number, or {@code -1} if no partition was
explicitly set
+ */
+ public int getPartition() {
+ return partition;
+ }
+
+ /**
+ * Compares this record to {@code otherRecord} without considering the
{@code partition} field.
+ *
+ * <p>Use this in tests that do not care about which partition a record
was routed to:
+ * <pre>{@code
+ * assertTrue(expected.equalsIgnorePartition(actual));
+ * }</pre>
+ *
+ * @param otherRecord the record to compare against; {@code null} returns
{@code false}
+ * @return {@code true} if all fields except {@code partition} are equal
+ */
+ public boolean equalsIgnorePartition(final TestRecord<K, V> otherRecord) {
+ return otherRecord != null && (this == otherRecord ||
equalsFields(otherRecord));
+ }
+
+ private boolean equalsFields(final TestRecord<K, V> otherRecord) {
+ return Objects.equals(headers, otherRecord.headers)
+ && Objects.equals(key, otherRecord.key)
+ && Objects.equals(value, otherRecord.value)
+ && Objects.equals(recordTime, otherRecord.recordTime);
+ }
+
@Override
public String toString() {
return new StringJoiner(", ", TestRecord.class.getSimpleName() + "[",
"]")
- .add("key=" + key)
- .add("value=" + value)
- .add("headers=" + headers)
- .add("recordTime=" + recordTime)
- .toString();
+ .add("key=" + key)
+ .add("value=" + value)
+ .add("headers=" + headers)
+ .add("recordTime=" + recordTime)
+ .add("partition=" + partition)
+ .toString();
}
@Override
+ @SuppressWarnings("unchecked")
public boolean equals(final Object o) {
if (this == o) {
return true;
@@ -227,15 +312,12 @@ public class TestRecord<K, V> {
if (o == null || getClass() != o.getClass()) {
return false;
}
- final TestRecord<?, ?> that = (TestRecord<?, ?>) o;
- return Objects.equals(headers, that.headers) &&
- Objects.equals(key, that.key) &&
- Objects.equals(value, that.value) &&
- Objects.equals(recordTime, that.recordTime);
+ final TestRecord<K, V> that = (TestRecord<K, V>) o;
+ return equalsFields(that) && partition == that.partition;
}
@Override
public int hashCode() {
- return Objects.hash(headers, key, value, recordTime);
+ return Objects.hash(headers, key, value, recordTime, partition);
}
}
diff --git
a/streams/test-utils/src/test/java/org/apache/kafka/streams/test/TestRecordTest.java
b/streams/test-utils/src/test/java/org/apache/kafka/streams/test/TestRecordTest.java
index ad3b1a2a20a..3bde0342fa4 100644
---
a/streams/test-utils/src/test/java/org/apache/kafka/streams/test/TestRecordTest.java
+++
b/streams/test-utils/src/test/java/org/apache/kafka/streams/test/TestRecordTest.java
@@ -34,8 +34,11 @@ import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.hasProperty;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class TestRecordTest {
private final String key = "testKey";
@@ -147,7 +150,7 @@ public class TestRecordTest {
assertThat(testRecord.toString(), equalTo("TestRecord[key=testKey,
value=1, "
+ "headers=RecordHeaders(headers = [RecordHeader(key = foo,
value = [118, 97, 108, 117, 101]), "
+ "RecordHeader(key = bar, value = null), RecordHeader(key =
\"A\\u00ea\\u00f1\\u00fcC\", value = [118, 97, 108, 117, 101])], isReadOnly =
false), "
- + "recordTime=2019-06-01T10:00:00Z]"));
+ + "recordTime=2019-06-01T10:00:00Z, partition=-1]"));
}
@Test
@@ -156,17 +159,136 @@ public class TestRecordTest {
final ConsumerRecord<String, Integer> consumerRecord = new
ConsumerRecord<>(topicName, 1, 0, recordMs,
TimestampType.CREATE_TIME, 0, 0, key, value, headers,
Optional.empty());
final TestRecord<String, Integer> testRecord = new
TestRecord<>(consumerRecord);
- final TestRecord<String, Integer> expectedRecord = new
TestRecord<>(key, value, headers, recordTime);
+ final TestRecord<String, Integer> expectedRecord = new
TestRecord<>(key, value, headers, recordTime, 1);
assertEquals(expectedRecord, testRecord);
}
+ @Test
+ public void testConsumerRecordWithNegativePartition() {
+ final String topicName = "topic";
+ final ConsumerRecord<String, Integer> consumerRecord = new
ConsumerRecord<>(topicName, -1, 0, recordMs,
+ TimestampType.CREATE_TIME, 0, 0, key, value, headers,
Optional.empty());
+ final IllegalArgumentException exception =
assertThrows(IllegalArgumentException.class, () -> new
TestRecord<>(consumerRecord));
+ assertEquals("Invalid partition: -1. Partition number should always be
non-negative.",
+ exception.getMessage());
+ }
+
+ @Test
+ public void testConsumerRecordWithNoTimestamp() {
+ final String topicName = "topic";
+ final ConsumerRecord<String, String> record = new ConsumerRecord<>(
+ topicName, 0, 0L, ConsumerRecord.NO_TIMESTAMP,
TimestampType.NO_TIMESTAMP_TYPE,
+ 0, 0, "key", "value", new RecordHeaders(), Optional.empty()
+ );
+ final TestRecord<String, String> testRecord = new TestRecord<>(record);
+ assertNull(testRecord.timestamp());
+ }
+
@Test
public void testProducerRecord() {
final String topicName = "topic";
final ProducerRecord<String, Integer> producerRecord =
new ProducerRecord<>(topicName, 1, recordMs, key, value, headers);
final TestRecord<String, Integer> testRecord = new
TestRecord<>(producerRecord);
- final TestRecord<String, Integer> expectedRecord = new
TestRecord<>(key, value, headers, recordTime);
+ final TestRecord<String, Integer> expectedRecord = new
TestRecord<>(key, value, headers, recordTime, 1);
assertEquals(expectedRecord, testRecord);
}
+
+ @Test
+ public void testProducerRecordWithNullTimestamp() {
+ final String topicName = "topic";
+ final ProducerRecord<String, String> record = new ProducerRecord<>(
+ topicName, null, null, "key", "value", new RecordHeaders()
+ );
+ final TestRecord<String, String> testRecord = new TestRecord<>(record);
+ assertNull(testRecord.timestamp());
+ }
+
+ @Test
+ public void testProducerRecordWithoutPartition() {
+ final String topicName = "topic";
+ final ProducerRecord<String, Integer> producerRecord =
+ new ProducerRecord<>(topicName, null, recordMs, key, value,
headers);
+ final TestRecord<String, Integer> testRecord = new
TestRecord<>(producerRecord);
+ assertEquals(-1, testRecord.partition());
+ }
+
+ @Test
+ public void testPartitionDefaultsToUnset() {
+ // Records built without an explicit partition, default to -1
+ assertEquals(-1, new TestRecord<>(key, value, headers,
recordTime).partition());
+ assertEquals(-1, new TestRecord<>(key, value, headers,
recordMs).partition());
+ assertEquals(-1, new TestRecord<>(key, value, headers).partition());
+ assertEquals(-1, new TestRecord<>(key, value).partition());
+ assertEquals(-1, new TestRecord<>(value).partition());
+ }
+
+ @Test
+ public void testExplicitPartitionConstructor() {
+ // Records built with an explicit partition.
+ final TestRecord<String, Integer> testRecord = new TestRecord<>(key,
value, headers, recordTime, 3);
+ assertEquals(3, testRecord.partition());
+ }
+
+ @Test
+ public void testExplicitNoPartitionConstructor() {
+ // Records built with the NO_PARTITION sentinel.
+ final TestRecord<String, Integer> testRecord =
+ new TestRecord<>(key, value, headers, recordTime, -1);
+ assertEquals(-1, testRecord.partition());
+ }
+
+ @Test
+ public void testInvalidNegativePartitionConstructor() {
+ final IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> new TestRecord<>(key, value, headers, recordTime, -2)
+ );
+ assertEquals(
+ "Invalid partition: -2. Partition number should always be
non-negative or -1.",
+ exception.getMessage()
+ );
+ }
+
+ @Test
+ public void testEqualsConsidersPartition() {
+ // equals()/hashCode() take the partition into account.
+ final TestRecord<String, Integer> record1 = new TestRecord<>(key,
value, headers, recordTime, 0);
+ final TestRecord<String, Integer> record2 = new TestRecord<>(key,
value, headers, recordTime, 1);
+ assertNotEquals(record1, record2);
+
+ final TestRecord<String, Integer> record1Again = new TestRecord<>(key,
value, headers, recordTime, 0);
+ assertEquals(record1, record1Again);
+ assertEquals(record1.hashCode(), record1Again.hashCode());
+
+ // an unset (default) partition differs from an explicit one
+ assertNotEquals(new TestRecord<>(key, value, headers, recordTime),
record1);
+ }
+
+ @Test
+ public void testEqualsIgnorePartition() {
+ // equalsIgnorePartition() matches on every field except the partition.
+ final TestRecord<String, Integer> record1 = new TestRecord<>(key,
value, headers, recordTime, 0);
+ final TestRecord<String, Integer> record2 = new TestRecord<>(key,
value, headers, recordTime, 1);
+ assertNotEquals(record1, record2);
+ assertTrue(record1.equalsIgnorePartition(record2));
+ assertTrue(record2.equalsIgnorePartition(record1));
+
+ // a genuine field mismatch is still detected
+ assertFalse(record1.equalsIgnorePartition(new TestRecord<>("other",
value, headers, recordTime, 0)));
+ assertFalse(record1.equalsIgnorePartition(new TestRecord<>(key, 2,
headers, recordTime, 0)));
+
+ // reflexive / null guards
+ assertTrue(record1.equalsIgnorePartition(record1));
+ assertFalse(record1.equalsIgnorePartition(null));
+ }
+
+ @Test
+ public void testToStringIncludesPartitionWhenSet() {
+ final TestRecord<String, Integer> testRecord = new TestRecord<>(key,
value, headers, recordTime, 2);
+ assertThat(testRecord.toString(), equalTo("TestRecord[key=testKey,
value=1, "
+ + "headers=RecordHeaders(headers = [RecordHeader(key = foo, value
= [118, 97, 108, 117, 101]), "
+ + "RecordHeader(key = bar, value = null), RecordHeader(key =
\"A\\u00ea\\u00f1\\u00fcC\", value = [118, 97, 108, 117, 101])], isReadOnly =
false), "
+ + "recordTime=2019-06-01T10:00:00Z, partition=2]"));
+ }
}