aliehsaeedii commented on code in PR #21676:
URL: https://github.com/apache/kafka/pull/21676#discussion_r3559843453


##########
clients/src/main/java/org/apache/kafka/clients/producer/ProducerRecord.java:
##########
@@ -81,6 +83,35 @@ public ProducerRecord(String topic, Integer partition, Long 
timestamp, K key, V
         this.value = value;
         this.timestamp = timestamp;
         this.headers = new RecordHeaders(headers);
+        this.rawSerializedHeaders = null;
+    }
+
+    /**
+     * Creates a record carrying pre-serialized header bytes. When this 
constructor is used, the
+     * producer writes {@code rawSerializedHeaders} directly into the record 
batch without
+     * deserializing or re-serializing individual headers. The {@code 
rawSerializedHeaders} must
+     * use the standard Kafka header wire format: {@code 
[count(varint)][header1][header2]...},
+     * or be empty (length 0) for zero headers.
+     *
+     * <p>This is intended for internal use (e.g., Kafka Streams changelog 
writing) where headers
+     * are already available in serialized form and the deserialization 
round-trip should be avoided.
+     */
+    public ProducerRecord(String topic, Integer partition, Long timestamp, K 
key, V value, byte[] rawSerializedHeaders) {

Review Comment:
   Oh, it needs a KIP. Also I don't like to add it for all users honestly. It's 
sort of a foot-gun to be public and also we’d have to push hard to get that. 
Based on my experience, this area is heavily guarded by the code owners. It 
could take a year of effort and still result in no meaningful progress or 
acceptance.
   
   
   Suggested alternative (no public API, likely no KIP): keep the optimization 
but smuggle the bytes through the *existing* constructor via an internal 
carrier:
   - Add an internal `Headers` implementation (e.g. 
`common.header.internals.PreSerializedHeaders`) holding the raw bytes and 
lazily materializing to `RecordHeaders` on any read/iteration.
   - Streams passes it into the existing `ProducerRecord(..., 
Iterable<Header>)` constructor — no new constructor.
   - `ProducerRecord` special-cases it internally into a **package-private** 
field; `KafkaProducer.doSend` (same package) reads a package-private getter and 
routes to the raw append path.
   - Because the carrier materializes on read, `headers()` stays correct and 
interceptors keep working — `doSend` just falls back to the normal `Header[]` 
path if anything already materialized it.
   
   This matches the mechanism's blast radius to the (niche) benefit and stays 
fully reversible.



##########
clients/src/main/java/org/apache/kafka/clients/producer/ProducerRecord.java:
##########
@@ -185,6 +216,14 @@ public Integer partition() {
         return partition;
     }
 
+    /**
+     * @return Pre-serialized header bytes in Kafka wire format, or null if 
headers were
+     *         provided as {@link Headers} objects via the standard 
constructors.
+     */
+    public byte[] rawSerializedHeaders() {

Review Comment:
   This public getter is part of the same public-API expansion. Under the 
internal-carrier approach this becomes a package-private getter read only by 
`KafkaProducer.doSend` (same package), so it never enters the public surface.



##########
clients/src/main/java/org/apache/kafka/common/record/internal/DefaultRecord.java:
##########
@@ -222,6 +222,54 @@ public static int writeTo(DataOutputStream out,
         return ByteUtils.sizeOfVarint(sizeInBytes) + sizeInBytes;
     }
 
+    /**
+     * Write the record to {@code out} using pre-serialized header bytes, 
bypassing per-header
+     * iteration. The {@code rawSerializedHeaders} must use the standard Kafka 
header wire format:
+     * {@code [count(varint)][header1][header2]...}, or be empty (length 0) 
for zero headers.
+     */
+    public static int writeTo(DataOutputStream out,
+                              int offsetDelta,
+                              long timestampDelta,
+                              ByteBuffer key,
+                              ByteBuffer value,
+                              byte[] rawSerializedHeaders) throws IOException {
+        int sizeInBytes = sizeOfBodyInBytes(offsetDelta, timestampDelta, key, 
value, rawSerializedHeaders);
+        ByteUtils.writeVarint(sizeInBytes, out);
+
+        byte attributes = 0;
+        out.write(attributes);
+
+        ByteUtils.writeVarlong(timestampDelta, out);
+        ByteUtils.writeVarint(offsetDelta, out);
+
+        if (key == null) {
+            ByteUtils.writeVarint(-1, out);
+        } else {
+            int keySize = key.remaining();
+            ByteUtils.writeVarint(keySize, out);
+            Utils.writeTo(out, key, keySize);
+        }
+
+        if (value == null) {
+            ByteUtils.writeVarint(-1, out);
+        } else {
+            int valueSize = value.remaining();
+            ByteUtils.writeVarint(valueSize, out);
+            Utils.writeTo(out, value, valueSize);
+        }
+
+        if (rawSerializedHeaders == null)
+            throw new IllegalArgumentException("Raw serialized headers cannot 
be null");
+
+        if (rawSerializedHeaders.length == 0) {
+            ByteUtils.writeVarint(0, out);
+        } else {
+            out.write(rawSerializedHeaders);

Review Comment:
   The raw bytes are written verbatim with no validation. This is correct 
**only** because the KIP-1271 stored-header format is byte-identical to the 
on-wire `DefaultRecord` header format 
(`[count(varint)][keyLen][key][valueLen|-1][value]...`). Worth an explicit 
comment asserting that invariant. If the source of these bytes is ever a public 
API (current constructor), malformed input silently corrupts the log; if it 
comes from a trusted internal carrier instead, the risk is contained to 
Streams-produced bytes.



##########
streams/src/main/java/org/apache/kafka/streams/processor/internals/ProcessorContextImpl.java:
##########
@@ -146,6 +154,96 @@ public void logChange(final String storeName,
             null);
     }
 
+    @Override
+    public void logChange(final String storeName,
+                          final Bytes key,
+                          final byte[] value,
+                          final long timestamp,
+                          final byte[] rawSerializedHeaders,
+                          final Position position) {
+        throwUnsupportedOperationExceptionIfStandby("logChange");
+
+        final TopicPartition changelogPartition = 
stateManager().registeredChangelogPartitionFor(storeName);
+
+        byte[] finalRawHeaders = rawSerializedHeaders;
+        if (consistencyEnabled) {
+            finalRawHeaders = 
mergeVectorClockIntoRawHeaders(rawSerializedHeaders, position);
+        }
+
+        final byte[] keyBytes = 
BYTES_KEY_SERIALIZER.serialize(changelogPartition.topic(), null, key);
+        final ProducerRecord<byte[], byte[]> record = new ProducerRecord<>(
+            changelogPartition.topic(),
+            changelogPartition.partition(),
+            timestamp,
+            keyBytes,
+            value,
+            finalRawHeaders
+        );
+
+        collector.send(key, value, null, null, record);
+    }
+
+    /**
+     * Merge vector clock entries into raw serialized header bytes without 
deserializing
+     * existing headers. The raw format is [count(varint)][entry1][entry2]... 
or empty.
+     */
+    private byte[] mergeVectorClockIntoRawHeaders(final byte[] rawHeaders, 
final Position position) {

Review Comment:
   The vector-clock merge logic looks correct: it reads the leading count 
varint, appends the two serialized VC entries, and rewrites `count + 2` without 
deserializing existing headers. Two notes: (1) this allocates two 
`ByteArrayOutputStream`/`DataOutputStream` per record when consistency is 
enabled — fine, but on the hot path; consider sizing/reusing a buffer. (2) This 
method is needed regardless of the public-vs-internal-carrier decision, so it 
can stay as-is when switching to the internal carrier.



##########
clients/src/main/java/org/apache/kafka/clients/producer/internals/RecordAccumulator.java:
##########
@@ -401,6 +401,74 @@ private RecordAppendResult appendNewBatch(String topic,
         return new RecordAppendResult(future, dq.size() > 1 || batch.isFull(), 
true, batch.estimatedSizeInBytes());
     }
 
+    public RecordAppendResult appendWithRawHeaders(String topic,

Review Comment:
   `appendWithRawHeaders` / `tryAppendRawHeaders` / `appendNewBatchRawHeaders` 
duplicate the existing `append`/`tryAppend`/`appendNewBatch` paths almost 
verbatim, in the producer's hottest code. This is significant maintenance debt 
(the two paths will drift). Consider parameterizing the existing methods over a 
small "record body writer" abstraction (Header[] vs raw bytes) rather than 
forking the whole append flow, so the partitioning/batching/allocation logic 
stays single-sourced.



##########
clients/src/main/java/org/apache/kafka/clients/producer/ProducerRecord.java:
##########
@@ -81,6 +83,35 @@ public ProducerRecord(String topic, Integer partition, Long 
timestamp, K key, V
         this.value = value;
         this.timestamp = timestamp;
         this.headers = new RecordHeaders(headers);
+        this.rawSerializedHeaders = null;
+    }
+
+    /**
+     * Creates a record carrying pre-serialized header bytes. When this 
constructor is used, the
+     * producer writes {@code rawSerializedHeaders} directly into the record 
batch without
+     * deserializing or re-serializing individual headers. The {@code 
rawSerializedHeaders} must
+     * use the standard Kafka header wire format: {@code 
[count(varint)][header1][header2]...},
+     * or be empty (length 0) for zero headers.
+     *
+     * <p>This is intended for internal use (e.g., Kafka Streams changelog 
writing) where headers
+     * are already available in serialized form and the deserialization 
round-trip should be avoided.
+     */
+    public ProducerRecord(String topic, Integer partition, Long timestamp, K 
key, V value, byte[] rawSerializedHeaders) {
+        if (topic == null)
+            throw new IllegalArgumentException("Topic cannot be null.");
+        if (timestamp != null && timestamp < 0)
+            throw new IllegalArgumentException(
+                    String.format("Invalid timestamp: %d. Timestamp should 
always be non-negative or null.", timestamp));
+        if (partition != null && partition < 0)
+            throw new IllegalArgumentException(
+                    String.format("Invalid partition: %d. Partition number 
should always be non-negative or null.", partition));
+        this.topic = topic;
+        this.partition = partition;
+        this.key = key;
+        this.value = value;
+        this.timestamp = timestamp;
+        this.headers = new RecordHeaders();

Review Comment:
   With the raw path, `this.headers` is set to empty while the real headers 
live in `rawSerializedHeaders`, so `record.headers()` no longer reflects the 
record's actual headers. That's a silent trap: `ProducerInterceptor.onSend` 
(which runs before `doSend`) sees empty headers, and any header edits an 
interceptor makes are dropped. `equals`/`hashCode`/`toString` also become 
half-consistent. The internal-carrier approach avoids this because it can 
lazily materialize on read, keeping `headers()` accurate.



##########
clients/src/main/java/org/apache/kafka/clients/producer/KafkaProducer.java:
##########
@@ -1135,18 +1135,25 @@ private Future<RecordMetadata> doSend(ProducerRecord<K, 
V> record, Callback call
             // take into account broker load, the amount of data produced to 
each partition, etc.).
             int partition = partition(record, serializedKey, serializedValue, 
cluster);
 
-            setReadOnly(record.headers());
-            Header[] headers = record.headers().toArray();
-
-            int serializedSize = 
AbstractRecords.estimateSizeInBytesUpperBound(RecordBatch.CURRENT_MAGIC_VALUE,
-                    compression.type(), serializedKey, serializedValue, 
headers);
-            ensureValidRecordSize(serializedSize);
             long timestamp = record.timestamp() == null ? nowMs : 
record.timestamp();
 
-            // Append the record to the accumulator.  Note, that the actual 
partition may be
-            // calculated there and can be accessed via 
appendCallbacks.topicPartition.
-            RecordAccumulator.RecordAppendResult result = 
accumulator.append(record.topic(), partition, timestamp, serializedKey,
-                    serializedValue, headers, appendCallbacks, 
remainingWaitMs, nowMs, cluster);
+            final RecordAccumulator.RecordAppendResult result;
+            byte[] rawHeaders = record.rawSerializedHeaders();

Review Comment:
   Note the interceptor ordering: `this.interceptors.onSend(record)` runs in 
`send(...)` before `doSend(...)`. Since the raw path leaves `record.headers()` 
empty (see `ProducerRecord`), any configured `ProducerInterceptor` sees no 
headers and cannot modify them. Streams' changelog producer typically has no 
interceptors, but this is a behavioral divergence baked into a public type. A 
lazily-materializing internal carrier would let `onSend` observe/modify the 
real headers and only take the fast path when they were never materialized.



##########
streams/src/test/java/org/apache/kafka/test/InternalMockProcessorContext.java:
##########
@@ -477,6 +477,17 @@ public void logChange(final String storeName,
             null);
     }
 
+    @Override
+    public void logChange(final String storeName,
+                          final Bytes key,
+                          final byte[] value,
+                          final long timestamp,
+                          final byte[] rawSerializedHeaders,
+                          final Position position) {
+        // In tests, delegate to the Headers-based overload via the regular 
collector path
+        throw new UnsupportedOperationException("Raw header logChange not 
supported in mock context");

Review Comment:
   This will likely break existing tests. 
`ChangeLoggingTimestampedKeyValueBytesStoreWithHeadersTest` and 
`ChangeLoggingTimestampedWindowBytesStoreWithHeadersTest` drive these stores 
through `InternalMockProcessorContext`, and the stores' non-null-value `put` 
path now routes to this raw `logChange` overload — which throws here. Please 
either implement this overload in the mock (delegate to the `Headers`-based 
path by deserializing the raw bytes) or update those tests. Worth confirming 
the streams test suite is green.



-- 
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]

Reply via email to