mjsax commented on code in PR #22961:
URL: https://github.com/apache/kafka/pull/22961#discussion_r3663472755


##########
streams/src/main/java/org/apache/kafka/streams/state/internals/RecordConverters.java:
##########
@@ -105,6 +105,40 @@ public static RecordConverter 
rawValueToSessionHeadersValue() {
         return RAW_TO_SESSION_WITH_HEADERS_INSTANCE;
     }
 
+    private static final RecordConverter RAW_LIST_TO_HEADERS_LIST_INSTANCE = 
record -> {
+        // The outer-join ListValueStore changelog stores the whole list blob, 
always in the PLAIN
+        // element format, with the per-element headers parked in a reserved 
record header. Restoring
+        // means re-inlining them. Legacy records written before the headers 
format simply lack that
+        // header, which is the same as "every element has empty headers" — so 
there is one path, not
+        // two. A tombstone (null value) is passed through.
+        if (record.value() == null) {
+            return record;
+        }
+
+        final byte[] convertedValue = 
ListValueStoreUpgradeUtils.joinPlainListBlobWithElementHeaders(
+            record.value(),
+            ListValueStoreUpgradeUtils.elementHeaders(record.headers())
+        );
+
+        return new ConsumerRecord<>(
+            record.topic(),
+            record.partition(),
+            record.offset(),
+            record.timestamp(),
+            record.timestampType(),
+            record.serializedKeySize(),
+            convertedValue.length,
+            record.key(),
+            convertedValue,
+            record.headers(),

Review Comment:
   Do we want to keep the `record.headers()` unmodified? Should we at least 
strip our own header `LIST_VALUE_HEADERS_HEADER_KEY` (would also be a side 
effect inside `istValueStoreUpgradeUtils.elementHeaders(...)` ?
   
   Or maybe it does not matter, as this `ConsumerRecord` should be short lived 
anyway, so we don't really delay freeing up he memory for too long.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ListValueStoreBuilder.java:
##########
@@ -26,15 +26,25 @@
 
 public class ListValueStoreBuilder<K, V> extends AbstractStoreBuilder<K, V, 
KeyValueStore<K, V>> {
     private final KeyValueBytesStoreSupplier storeSupplier;
+    private final boolean headersFormat;
 
     public ListValueStoreBuilder(final KeyValueBytesStoreSupplier 
storeSupplier,
                                  final Serde<K> keySerde,
                                  final Serde<V> valueSerde,
                                  final Time time) {
+        this(storeSupplier, keySerde, valueSerde, time, false);
+    }
+
+    public ListValueStoreBuilder(final KeyValueBytesStoreSupplier 
storeSupplier,
+                                 final Serde<K> keySerde,
+                                 final Serde<V> valueSerde,
+                                 final Time time,
+                                 final boolean headersFormat) {
         super(storeSupplier.name(), keySerde, valueSerde, time);
         Objects.requireNonNull(storeSupplier, "storeSupplier can't be null");
         Objects.requireNonNull(storeSupplier.metricsScope(), "storeSupplier's 
metricsScope can't be null");
         this.storeSupplier = storeSupplier;
+        this.headersFormat = headersFormat;

Review Comment:
   Do we need to pass this boolean explicitly, or should we try to inver from 
the `KeyValueBytesStoreSupplier` (ie, some `instanceof <markerInterface>` check 
-- should we use existing `HeadersBytesStoreSupplier` interface (which 
`RocksDBListValueHeadersBytesStoreSupplier` would implement)? 



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ListValueStoreUpgradeUtils.java:
##########
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.utils.internals.ByteUtils;
+import org.apache.kafka.streams.state.HeadersBytesStore;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helpers for migrating the outer-join {@link ListValueStore} from the 
pre-headers PLAIN element
+ * format to the HEADERS element format (KIP-1271, added for AK 4.4).
+ * <p>
+ * The store persists, per key, a {@link Serdes#ListSerde} blob whose elements 
are single serialized
+ * values. The element encoding differs by {@code dsl.store.format}:
+ * <ul>
+ *   <li>PLAIN:   {@code [leftFlag(1B)][rawValue]} (a {@code 
LeftOrRightValue})</li>
+ *   <li>HEADERS: {@code 
[headersSize(varint)][headersBytes][leftFlag(1B)][rawValue]}
+ *       (an {@code AggregationWithHeaders<LeftOrRightValue>})</li>
+ * </ul>
+ * A PLAIN element becomes a HEADERS element with <em>empty</em> headers 
simply by prepending a single
+ * {@code 0x00} byte (the empty-headers varint) — see {@link 
HeadersBytesStore#convertToHeaderFormat}.
+ * So a whole PLAIN list blob is converted by prepending {@code 0x00} to each 
element and re-serializing
+ * the same {@code ListSerde}.
+ * <p>
+ * The HEADERS element format above is the <em>local, on-disk</em> format 
only. As everywhere else in
+ * KIP-1271, the changelog value must keep the pre-headers format so that 
downgrading — either to an
+ * older version or just by flipping {@code dsl.store.format} back to PLAIN — 
can still decode it. The
+ * {@link #splitHeadersListBlob(byte[]) split} / {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[]) join}
+ * pair moves the per-element headers between the value bytes and a reserved 
record header for that
+ * purpose; {@link #LIST_VALUE_HEADERS_HEADER_KEY} documents the wire encoding.
+ */
+final class ListValueStoreUpgradeUtils {
+
+    /**
+     * Reserved changelog record-header key carrying the per-element headers 
of a HEADERS-format list,
+     * so that the changelog <em>value</em> can stay in the format an old 
PLAIN store understands.
+     * <p>
+     * Its value is the concatenation of the {@code 
[headersSize(varint)][headersBytes]} prefixes that
+     * were stripped off the list elements, in list order. Each chunk carries 
its own length, so the
+     * blob is self-delimiting and no element count is needed. An element with 
no headers contributes
+     * a single {@code 0x00} byte.
+     * <p>
+     * Deliberately namespaced to avoid colliding with user headers that ride 
along on the record.
+     * A record <em>without</em> this header is a legacy PLAIN record: see
+     * {@link #joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     */
+    static final String LIST_VALUE_HEADERS_HEADER_KEY = 
"__kafka_streams_list_value_headers__";
+
+    // The prefix of an element with no headers: headersSize = varint(0), no 
headers bytes.
+    private static final byte[] EMPTY_HEADERS_PREFIX = {(byte) 0};
+
+    // Must match ListValueStore.LIST_SERDE.
+    @SuppressWarnings("unchecked")
+    private static final Serde<List<byte[]>> LIST_SERDE = 
Serdes.ListSerde(ArrayList.class, Serdes.ByteArray());

Review Comment:
   It it must match, why not share it? Make one accessible by the user on just 
use?



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBListValueStoreWithHeaders.java:
##########
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.streams.state.internals.metrics.RocksDBMetricsRecorder;
+
+import org.rocksdb.ColumnFamilyDescriptor;
+import org.rocksdb.ColumnFamilyHandle;
+import org.rocksdb.ColumnFamilyOptions;
+import org.rocksdb.DBOptions;
+import org.rocksdb.RocksDB;
+import org.rocksdb.RocksIterator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * A persistent bytes key-value store for the outer-join {@link 
ListValueStore} in HEADERS mode.
+ * <p>
+ * The store keeps two column families so it can be upgraded in place from a 
pre-headers (PLAIN) store
+ * without corrupting existing data (KIP-1271 dual-column-family pattern, 
mirroring
+ * {@link RocksDBTimestampedStoreWithHeaders}):
+ * <ul>
+ *   <li>DEFAULT: legacy PLAIN list blobs written by the pre-headers 
version;</li>
+ *   <li>{@code listValueWithHeaders}: list blobs whose elements carry inline 
headers.</li>
+ * </ul>
+ * When the DEFAULT column family holds data at open time, a {@link 
DualColumnFamilyAccessor} lifts each
+ * legacy blob to the headers format on read/write via
+ * {@link ListValueStoreUpgradeUtils#convertPlainListBlobToHeadersListBlob} 
and migrates it forward.
+ * Otherwise a {@link RocksDBStore.SingleColumnFamilyAccessor} over the 
headers column family is used.
+ * <p>
+ * This class intentionally does NOT implement {@code HeadersBytesStore}; see
+ * {@link HeadersAwareListValueStore} for why.

Review Comment:
   But should it implement `HeadersAwareListValueStore` instead?



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ListValueStoreUpgradeUtils.java:
##########
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.utils.internals.ByteUtils;
+import org.apache.kafka.streams.state.HeadersBytesStore;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helpers for migrating the outer-join {@link ListValueStore} from the 
pre-headers PLAIN element
+ * format to the HEADERS element format (KIP-1271, added for AK 4.4).
+ * <p>
+ * The store persists, per key, a {@link Serdes#ListSerde} blob whose elements 
are single serialized
+ * values. The element encoding differs by {@code dsl.store.format}:
+ * <ul>
+ *   <li>PLAIN:   {@code [leftFlag(1B)][rawValue]} (a {@code 
LeftOrRightValue})</li>
+ *   <li>HEADERS: {@code 
[headersSize(varint)][headersBytes][leftFlag(1B)][rawValue]}
+ *       (an {@code AggregationWithHeaders<LeftOrRightValue>})</li>
+ * </ul>
+ * A PLAIN element becomes a HEADERS element with <em>empty</em> headers 
simply by prepending a single
+ * {@code 0x00} byte (the empty-headers varint) — see {@link 
HeadersBytesStore#convertToHeaderFormat}.
+ * So a whole PLAIN list blob is converted by prepending {@code 0x00} to each 
element and re-serializing
+ * the same {@code ListSerde}.
+ * <p>
+ * The HEADERS element format above is the <em>local, on-disk</em> format 
only. As everywhere else in
+ * KIP-1271, the changelog value must keep the pre-headers format so that 
downgrading — either to an
+ * older version or just by flipping {@code dsl.store.format} back to PLAIN — 
can still decode it. The
+ * {@link #splitHeadersListBlob(byte[]) split} / {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[]) join}
+ * pair moves the per-element headers between the value bytes and a reserved 
record header for that
+ * purpose; {@link #LIST_VALUE_HEADERS_HEADER_KEY} documents the wire encoding.
+ */
+final class ListValueStoreUpgradeUtils {
+
+    /**
+     * Reserved changelog record-header key carrying the per-element headers 
of a HEADERS-format list,
+     * so that the changelog <em>value</em> can stay in the format an old 
PLAIN store understands.
+     * <p>
+     * Its value is the concatenation of the {@code 
[headersSize(varint)][headersBytes]} prefixes that
+     * were stripped off the list elements, in list order. Each chunk carries 
its own length, so the
+     * blob is self-delimiting and no element count is needed. An element with 
no headers contributes
+     * a single {@code 0x00} byte.
+     * <p>
+     * Deliberately namespaced to avoid colliding with user headers that ride 
along on the record.
+     * A record <em>without</em> this header is a legacy PLAIN record: see
+     * {@link #joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     */
+    static final String LIST_VALUE_HEADERS_HEADER_KEY = 
"__kafka_streams_list_value_headers__";
+
+    // The prefix of an element with no headers: headersSize = varint(0), no 
headers bytes.
+    private static final byte[] EMPTY_HEADERS_PREFIX = {(byte) 0};
+
+    // Must match ListValueStore.LIST_SERDE.
+    @SuppressWarnings("unchecked")
+    private static final Serde<List<byte[]>> LIST_SERDE = 
Serdes.ListSerde(ArrayList.class, Serdes.ByteArray());
+
+    private ListValueStoreUpgradeUtils() {}
+
+    /**
+     * Converts a whole PLAIN list blob into the HEADERS list blob by lifting 
each element to the
+     * empty-headers format. {@code null} (a tombstone / whole-list delete) is 
passed through.
+     */
+    static byte[] convertPlainListBlobToHeadersListBlob(final byte[] 
plainListBlob) {
+        if (plainListBlob == null) {
+            return null;
+        }
+        final List<byte[]> plainElements = 
LIST_SERDE.deserializer().deserialize(null, plainListBlob);
+        final List<byte[]> headersElements = new 
ArrayList<>(plainElements.size());
+        for (final byte[] element : plainElements) {
+            // convertToHeaderFormat(null) returns null, preserving any null 
list members.
+            
headersElements.add(HeadersBytesStore.convertToHeaderFormat(element));
+        }
+        return LIST_SERDE.serializer().serialize(null, headersElements);
+    }
+
+    /**
+     * A HEADERS list blob taken apart for the changelog: the value bytes an 
old PLAIN store can still
+     * read, plus the per-element headers prefixes to park in {@link 
#LIST_VALUE_HEADERS_HEADER_KEY}.
+     */
+    static final class SplitListBlob {
+        final byte[] plainListBlob;
+        final byte[] elementHeaders;
+
+        SplitListBlob(final byte[] plainListBlob, final byte[] elementHeaders) 
{
+            this.plainListBlob = plainListBlob;
+            this.elementHeaders = elementHeaders;
+        }
+    }
+
+    /**
+     * Splits a HEADERS list blob into the PLAIN list blob plus the 
concatenated per-element headers
+     * prefixes. Inverse of {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     * <p>
+     * This is the list-aware counterpart of {@link 
Utils#rawPlainValue(byte[])}: it keeps the changelog
+     * value in the pre-headers format so that an old PLAIN store — or a store 
whose
+     * {@code dsl.store.format} was flipped back to PLAIN — can still decode 
it.
+     *
+     * @param headersListBlob a {@code ListSerde} blob of {@code 
[headersSize][headers][flag][value]}
+     *                        elements, or {@code null} for a whole-list 
tombstone
+     */
+    static SplitListBlob splitHeadersListBlob(final byte[] headersListBlob) {
+        if (headersListBlob == null) {
+            return new SplitListBlob(null, null);
+        }
+        final List<byte[]> headersElements = 
LIST_SERDE.deserializer().deserialize(null, headersListBlob);
+        final List<byte[]> plainElements = new 
ArrayList<>(headersElements.size());
+        final ByteArrayOutputStream elementHeaders = new 
ByteArrayOutputStream();
+
+        for (final byte[] element : headersElements) {
+            if (element == null) {
+                // ListValueStore never appends null, but ListSerde can hold 
nulls, so keep the pair
+                // total: a null element round-trips as null and consumes an 
empty-headers prefix.
+                plainElements.add(null);
+                elementHeaders.write(EMPTY_HEADERS_PREFIX, 0, 
EMPTY_HEADERS_PREFIX.length);
+                continue;
+            }
+            final int prefixLength = headersPrefixLength(element);
+            elementHeaders.write(element, 0, prefixLength);
+            final byte[] plainElement = new byte[element.length - 
prefixLength];
+            System.arraycopy(element, prefixLength, plainElement, 0, 
plainElement.length);
+            plainElements.add(plainElement);
+        }
+
+        return new SplitListBlob(
+            LIST_SERDE.serializer().serialize(null, plainElements),
+            elementHeaders.toByteArray()
+        );
+    }
+
+    /**
+     * Rebuilds a HEADERS list blob by re-inlining each element's headers 
prefix. Inverse of
+     * {@link #splitHeadersListBlob(byte[])}, and the restore-time counterpart 
of the split.
+     *
+     * @param plainListBlob  a {@code ListSerde} blob of {@code [flag][value]} 
elements, or {@code null}
+     * @param elementHeaders the concatenated prefixes written by the split, 
or {@code null}/empty for a
+     *                       legacy record that predates the headers format — 
in which case every element
+     *                       gets empty headers, i.e. exactly
+     *                       {@link 
#convertPlainListBlobToHeadersListBlob(byte[])}
+     */
+    static byte[] joinPlainListBlobWithElementHeaders(final byte[] 
plainListBlob, final byte[] elementHeaders) {
+        if (plainListBlob == null) {
+            return null;
+        }
+        // Every element contributes at least the one-byte headersSize varint, 
so an absent or empty
+        // prefix blob can only mean "legacy record" or "empty list" — both 
are the all-empty case.
+        if (elementHeaders == null || elementHeaders.length == 0) {
+            return convertPlainListBlobToHeadersListBlob(plainListBlob);
+        }
+
+        final List<byte[]> plainElements = 
LIST_SERDE.deserializer().deserialize(null, plainListBlob);
+        final List<byte[]> headersElements = new 
ArrayList<>(plainElements.size());
+        final ByteBuffer prefixes = ByteBuffer.wrap(elementHeaders);
+
+        for (final byte[] plainElement : plainElements) {
+            final byte[] prefix = readNextHeadersPrefix(prefixes);
+            if (plainElement == null) {
+                headersElements.add(null);
+                continue;
+            }
+            final byte[] headersElement = new byte[prefix.length + 
plainElement.length];
+            System.arraycopy(prefix, 0, headersElement, 0, prefix.length);
+            System.arraycopy(plainElement, 0, headersElement, prefix.length, 
plainElement.length);
+            headersElements.add(headersElement);
+        }
+
+        if (prefixes.hasRemaining()) {
+            throw new SerializationException("Invalid list-value headers: " + 
prefixes.remaining()
+                + " trailing bytes after " + plainElements.size() + " list 
elements");
+        }
+        return LIST_SERDE.serializer().serialize(null, headersElements);
+    }
+
+    /**
+     * @return the per-element headers prefixes carried by a changelog record, 
or {@code null} if the
+     *         record has none — i.e. it is a legacy record written before the 
headers format
+     */
+    static byte[] elementHeaders(final Headers headers) {
+        if (headers == null) {

Review Comment:
   I believe this case can never happen? We are reading from the changelog, to 
if there is no headers, we would get empty headers, but never `null`? So not 
sure if we need this check? `headers.lastHeader` should cover the empty case 
correctly anyway?



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ChangeLoggingListValueBytesStoreWithHeaders.java:
##########
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.state.KeyValueStore;
+
+/**
+ * The HEADERS-mode changelog store for the outer-join {@link ListValueStore}.
+ * <p>
+ * The local store holds {@code [headersSize][headers][flag][value]} per list 
element, but that format
+ * must never reach the changelog: the changelog topic is the only durable 
copy of the state, so its
+ * value format is a permanent compatibility contract. If we logged the local 
bytes verbatim, an old
+ * PLAIN reader — after a version downgrade, or simply after flipping {@code 
dsl.store.format} back to
+ * PLAIN — would read each element's leading empty-headers {@code 0x00} as the 
{@code LeftOrRightValue}
+ * flag and silently mistake left values for right ones.
+ * <p>
+ * So this store does what every other KIP-1271 changelog store does (see
+ * {@link ChangeLoggingTimestampedKeyValueBytesStoreWithHeaders}, which logs
+ * {@link Utils#rawPlainValue(byte[])}): it keeps the headers out of the value 
and puts them in a record
+ * header instead. The list makes that a little more involved — one changelog 
record holds the whole
+ * list, so N sets of headers have to share one header field — which is why 
the stripped prefixes are
+ * concatenated into a single self-delimiting blob under
+ * {@link ListValueStoreUpgradeUtils#LIST_VALUE_HEADERS_HEADER_KEY} rather 
than unpacked into individual
+ * {@code RecordHeader}s.
+ * <p>
+ * Implements {@link HeadersAwareListValueStore} purely so {@code 
StateManagerUtil.converterForStore}
+ * selects {@link RecordConverters#rawListValueToHeadersListValue()}, which 
performs the inverse join on
+ * restore.
+ */
+public class ChangeLoggingListValueBytesStoreWithHeaders
+    extends ChangeLoggingListValueBytesStore
+    implements HeadersAwareListValueStore {

Review Comment:
   Why does the changelogger implement this interface? Seems it one layer too 
high? -- If we would disable changelogging we could get the wrong signal (not 
sure if it would break anything, but it seems structurally incorrect -- the 
actual RocksDB store should carry the marker interface IMHO



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ListValueStoreUpgradeUtils.java:
##########
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.utils.internals.ByteUtils;
+import org.apache.kafka.streams.state.HeadersBytesStore;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helpers for migrating the outer-join {@link ListValueStore} from the 
pre-headers PLAIN element
+ * format to the HEADERS element format (KIP-1271, added for AK 4.4).
+ * <p>
+ * The store persists, per key, a {@link Serdes#ListSerde} blob whose elements 
are single serialized
+ * values. The element encoding differs by {@code dsl.store.format}:
+ * <ul>
+ *   <li>PLAIN:   {@code [leftFlag(1B)][rawValue]} (a {@code 
LeftOrRightValue})</li>
+ *   <li>HEADERS: {@code 
[headersSize(varint)][headersBytes][leftFlag(1B)][rawValue]}
+ *       (an {@code AggregationWithHeaders<LeftOrRightValue>})</li>
+ * </ul>
+ * A PLAIN element becomes a HEADERS element with <em>empty</em> headers 
simply by prepending a single
+ * {@code 0x00} byte (the empty-headers varint) — see {@link 
HeadersBytesStore#convertToHeaderFormat}.
+ * So a whole PLAIN list blob is converted by prepending {@code 0x00} to each 
element and re-serializing
+ * the same {@code ListSerde}.
+ * <p>
+ * The HEADERS element format above is the <em>local, on-disk</em> format 
only. As everywhere else in
+ * KIP-1271, the changelog value must keep the pre-headers format so that 
downgrading — either to an
+ * older version or just by flipping {@code dsl.store.format} back to PLAIN — 
can still decode it. The
+ * {@link #splitHeadersListBlob(byte[]) split} / {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[]) join}
+ * pair moves the per-element headers between the value bytes and a reserved 
record header for that
+ * purpose; {@link #LIST_VALUE_HEADERS_HEADER_KEY} documents the wire encoding.
+ */
+final class ListValueStoreUpgradeUtils {
+
+    /**
+     * Reserved changelog record-header key carrying the per-element headers 
of a HEADERS-format list,
+     * so that the changelog <em>value</em> can stay in the format an old 
PLAIN store understands.
+     * <p>
+     * Its value is the concatenation of the {@code 
[headersSize(varint)][headersBytes]} prefixes that
+     * were stripped off the list elements, in list order. Each chunk carries 
its own length, so the
+     * blob is self-delimiting and no element count is needed. An element with 
no headers contributes
+     * a single {@code 0x00} byte.
+     * <p>
+     * Deliberately namespaced to avoid colliding with user headers that ride 
along on the record.
+     * A record <em>without</em> this header is a legacy PLAIN record: see
+     * {@link #joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     */
+    static final String LIST_VALUE_HEADERS_HEADER_KEY = 
"__kafka_streams_list_value_headers__";
+
+    // The prefix of an element with no headers: headersSize = varint(0), no 
headers bytes.
+    private static final byte[] EMPTY_HEADERS_PREFIX = {(byte) 0};
+
+    // Must match ListValueStore.LIST_SERDE.
+    @SuppressWarnings("unchecked")
+    private static final Serde<List<byte[]>> LIST_SERDE = 
Serdes.ListSerde(ArrayList.class, Serdes.ByteArray());
+
+    private ListValueStoreUpgradeUtils() {}
+
+    /**
+     * Converts a whole PLAIN list blob into the HEADERS list blob by lifting 
each element to the
+     * empty-headers format. {@code null} (a tombstone / whole-list delete) is 
passed through.
+     */
+    static byte[] convertPlainListBlobToHeadersListBlob(final byte[] 
plainListBlob) {
+        if (plainListBlob == null) {
+            return null;
+        }
+        final List<byte[]> plainElements = 
LIST_SERDE.deserializer().deserialize(null, plainListBlob);
+        final List<byte[]> headersElements = new 
ArrayList<>(plainElements.size());
+        for (final byte[] element : plainElements) {
+            // convertToHeaderFormat(null) returns null, preserving any null 
list members.
+            
headersElements.add(HeadersBytesStore.convertToHeaderFormat(element));
+        }
+        return LIST_SERDE.serializer().serialize(null, headersElements);
+    }
+
+    /**
+     * A HEADERS list blob taken apart for the changelog: the value bytes an 
old PLAIN store can still
+     * read, plus the per-element headers prefixes to park in {@link 
#LIST_VALUE_HEADERS_HEADER_KEY}.
+     */
+    static final class SplitListBlob {
+        final byte[] plainListBlob;
+        final byte[] elementHeaders;
+
+        SplitListBlob(final byte[] plainListBlob, final byte[] elementHeaders) 
{
+            this.plainListBlob = plainListBlob;
+            this.elementHeaders = elementHeaders;
+        }
+    }
+
+    /**
+     * Splits a HEADERS list blob into the PLAIN list blob plus the 
concatenated per-element headers
+     * prefixes. Inverse of {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     * <p>
+     * This is the list-aware counterpart of {@link 
Utils#rawPlainValue(byte[])}: it keeps the changelog
+     * value in the pre-headers format so that an old PLAIN store — or a store 
whose
+     * {@code dsl.store.format} was flipped back to PLAIN — can still decode 
it.
+     *
+     * @param headersListBlob a {@code ListSerde} blob of {@code 
[headersSize][headers][flag][value]}
+     *                        elements, or {@code null} for a whole-list 
tombstone
+     */
+    static SplitListBlob splitHeadersListBlob(final byte[] headersListBlob) {
+        if (headersListBlob == null) {
+            return new SplitListBlob(null, null);
+        }
+        final List<byte[]> headersElements = 
LIST_SERDE.deserializer().deserialize(null, headersListBlob);
+        final List<byte[]> plainElements = new 
ArrayList<>(headersElements.size());
+        final ByteArrayOutputStream elementHeaders = new 
ByteArrayOutputStream();
+
+        for (final byte[] element : headersElements) {
+            if (element == null) {
+                // ListValueStore never appends null, but ListSerde can hold 
nulls, so keep the pair

Review Comment:
   If `ListValueStore never appends null`, why do we need to handle this case? 
Isn't this just dead code?
   
   We could of course add a guard and `throw new SerializationException(...)`, 
too.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ChangeLoggingListValueBytesStoreWithHeaders.java:
##########
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.state.KeyValueStore;
+
+/**
+ * The HEADERS-mode changelog store for the outer-join {@link ListValueStore}.
+ * <p>
+ * The local store holds {@code [headersSize][headers][flag][value]} per list 
element, but that format
+ * must never reach the changelog: the changelog topic is the only durable 
copy of the state, so its
+ * value format is a permanent compatibility contract. If we logged the local 
bytes verbatim, an old
+ * PLAIN reader — after a version downgrade, or simply after flipping {@code 
dsl.store.format} back to
+ * PLAIN — would read each element's leading empty-headers {@code 0x00} as the 
{@code LeftOrRightValue}
+ * flag and silently mistake left values for right ones.
+ * <p>
+ * So this store does what every other KIP-1271 changelog store does (see
+ * {@link ChangeLoggingTimestampedKeyValueBytesStoreWithHeaders}, which logs
+ * {@link Utils#rawPlainValue(byte[])}): it keeps the headers out of the value 
and puts them in a record
+ * header instead. The list makes that a little more involved — one changelog 
record holds the whole
+ * list, so N sets of headers have to share one header field — which is why 
the stripped prefixes are
+ * concatenated into a single self-delimiting blob under
+ * {@link ListValueStoreUpgradeUtils#LIST_VALUE_HEADERS_HEADER_KEY} rather 
than unpacked into individual
+ * {@code RecordHeader}s.
+ * <p>
+ * Implements {@link HeadersAwareListValueStore} purely so {@code 
StateManagerUtil.converterForStore}
+ * selects {@link RecordConverters#rawListValueToHeadersListValue()}, which 
performs the inverse join on
+ * restore.
+ */
+public class ChangeLoggingListValueBytesStoreWithHeaders
+    extends ChangeLoggingListValueBytesStore
+    implements HeadersAwareListValueStore {
+
+    ChangeLoggingListValueBytesStoreWithHeaders(final KeyValueStore<Bytes, 
byte[]> inner) {
+        super(inner);
+    }
+
+    @Override
+    public void put(final Bytes key, final byte[] value) {
+        wrapped().put(key, value);
+        // As in the parent, a tombstone deletes the whole list, so there is 
nothing to read back and
+        // no per-element headers to carry.
+        if (value == null) {
+            log(key, null, internalContext.recordContext().timestamp(), 
changelogHeaders(null));
+        } else {
+            final ListValueStoreUpgradeUtils.SplitListBlob split =
+                
ListValueStoreUpgradeUtils.splitHeadersListBlob(wrapped().get(key));
+            log(key, split.plainListBlob, 
internalContext.recordContext().timestamp(), 
changelogHeaders(split.elementHeaders));
+        }
+    }
+
+    private Headers changelogHeaders(final byte[] elementHeaders) {
+        // Copy, for two reasons: the live record headers are forwarded 
downstream, so neither our
+        // control header nor the vector clock that 
ProcessorContextImpl#logChange appends to whatever
+        // instance we hand it may leak into them.
+        final Headers headers = new 
RecordHeaders(internalContext.recordContext().headers());

Review Comment:
   Yes, we should not leak, by why not just create an empty header? Why do we 
need to copy the content from the context? -- Also not sure why we need the 
`remove` below? If we have out own copy, why do we need to remove?



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ListValueStoreUpgradeUtils.java:
##########
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.utils.internals.ByteUtils;
+import org.apache.kafka.streams.state.HeadersBytesStore;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helpers for migrating the outer-join {@link ListValueStore} from the 
pre-headers PLAIN element
+ * format to the HEADERS element format (KIP-1271, added for AK 4.4).
+ * <p>
+ * The store persists, per key, a {@link Serdes#ListSerde} blob whose elements 
are single serialized
+ * values. The element encoding differs by {@code dsl.store.format}:
+ * <ul>
+ *   <li>PLAIN:   {@code [leftFlag(1B)][rawValue]} (a {@code 
LeftOrRightValue})</li>
+ *   <li>HEADERS: {@code 
[headersSize(varint)][headersBytes][leftFlag(1B)][rawValue]}
+ *       (an {@code AggregationWithHeaders<LeftOrRightValue>})</li>
+ * </ul>
+ * A PLAIN element becomes a HEADERS element with <em>empty</em> headers 
simply by prepending a single
+ * {@code 0x00} byte (the empty-headers varint) — see {@link 
HeadersBytesStore#convertToHeaderFormat}.
+ * So a whole PLAIN list blob is converted by prepending {@code 0x00} to each 
element and re-serializing
+ * the same {@code ListSerde}.
+ * <p>
+ * The HEADERS element format above is the <em>local, on-disk</em> format 
only. As everywhere else in
+ * KIP-1271, the changelog value must keep the pre-headers format so that 
downgrading — either to an
+ * older version or just by flipping {@code dsl.store.format} back to PLAIN — 
can still decode it. The
+ * {@link #splitHeadersListBlob(byte[]) split} / {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[]) join}
+ * pair moves the per-element headers between the value bytes and a reserved 
record header for that
+ * purpose; {@link #LIST_VALUE_HEADERS_HEADER_KEY} documents the wire encoding.
+ */
+final class ListValueStoreUpgradeUtils {
+
+    /**
+     * Reserved changelog record-header key carrying the per-element headers 
of a HEADERS-format list,
+     * so that the changelog <em>value</em> can stay in the format an old 
PLAIN store understands.
+     * <p>
+     * Its value is the concatenation of the {@code 
[headersSize(varint)][headersBytes]} prefixes that
+     * were stripped off the list elements, in list order. Each chunk carries 
its own length, so the
+     * blob is self-delimiting and no element count is needed. An element with 
no headers contributes
+     * a single {@code 0x00} byte.
+     * <p>
+     * Deliberately namespaced to avoid colliding with user headers that ride 
along on the record.
+     * A record <em>without</em> this header is a legacy PLAIN record: see
+     * {@link #joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     */
+    static final String LIST_VALUE_HEADERS_HEADER_KEY = 
"__kafka_streams_list_value_headers__";
+
+    // The prefix of an element with no headers: headersSize = varint(0), no 
headers bytes.
+    private static final byte[] EMPTY_HEADERS_PREFIX = {(byte) 0};
+
+    // Must match ListValueStore.LIST_SERDE.
+    @SuppressWarnings("unchecked")
+    private static final Serde<List<byte[]>> LIST_SERDE = 
Serdes.ListSerde(ArrayList.class, Serdes.ByteArray());
+
+    private ListValueStoreUpgradeUtils() {}
+
+    /**
+     * Converts a whole PLAIN list blob into the HEADERS list blob by lifting 
each element to the
+     * empty-headers format. {@code null} (a tombstone / whole-list delete) is 
passed through.
+     */
+    static byte[] convertPlainListBlobToHeadersListBlob(final byte[] 
plainListBlob) {
+        if (plainListBlob == null) {
+            return null;
+        }
+        final List<byte[]> plainElements = 
LIST_SERDE.deserializer().deserialize(null, plainListBlob);
+        final List<byte[]> headersElements = new 
ArrayList<>(plainElements.size());
+        for (final byte[] element : plainElements) {
+            // convertToHeaderFormat(null) returns null, preserving any null 
list members.
+            
headersElements.add(HeadersBytesStore.convertToHeaderFormat(element));
+        }
+        return LIST_SERDE.serializer().serialize(null, headersElements);
+    }
+
+    /**
+     * A HEADERS list blob taken apart for the changelog: the value bytes an 
old PLAIN store can still
+     * read, plus the per-element headers prefixes to park in {@link 
#LIST_VALUE_HEADERS_HEADER_KEY}.
+     */
+    static final class SplitListBlob {
+        final byte[] plainListBlob;
+        final byte[] elementHeaders;
+
+        SplitListBlob(final byte[] plainListBlob, final byte[] elementHeaders) 
{
+            this.plainListBlob = plainListBlob;
+            this.elementHeaders = elementHeaders;
+        }
+    }
+
+    /**
+     * Splits a HEADERS list blob into the PLAIN list blob plus the 
concatenated per-element headers
+     * prefixes. Inverse of {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     * <p>
+     * This is the list-aware counterpart of {@link 
Utils#rawPlainValue(byte[])}: it keeps the changelog
+     * value in the pre-headers format so that an old PLAIN store — or a store 
whose
+     * {@code dsl.store.format} was flipped back to PLAIN — can still decode 
it.
+     *
+     * @param headersListBlob a {@code ListSerde} blob of {@code 
[headersSize][headers][flag][value]}
+     *                        elements, or {@code null} for a whole-list 
tombstone
+     */
+    static SplitListBlob splitHeadersListBlob(final byte[] headersListBlob) {
+        if (headersListBlob == null) {
+            return new SplitListBlob(null, null);
+        }
+        final List<byte[]> headersElements = 
LIST_SERDE.deserializer().deserialize(null, headersListBlob);
+        final List<byte[]> plainElements = new 
ArrayList<>(headersElements.size());
+        final ByteArrayOutputStream elementHeaders = new 
ByteArrayOutputStream();
+
+        for (final byte[] element : headersElements) {
+            if (element == null) {
+                // ListValueStore never appends null, but ListSerde can hold 
nulls, so keep the pair
+                // total: a null element round-trips as null and consumes an 
empty-headers prefix.
+                plainElements.add(null);
+                elementHeaders.write(EMPTY_HEADERS_PREFIX, 0, 
EMPTY_HEADERS_PREFIX.length);
+                continue;
+            }
+            final int prefixLength = headersPrefixLength(element);
+            elementHeaders.write(element, 0, prefixLength);
+            final byte[] plainElement = new byte[element.length - 
prefixLength];
+            System.arraycopy(element, prefixLength, plainElement, 0, 
plainElement.length);
+            plainElements.add(plainElement);
+        }
+
+        return new SplitListBlob(
+            LIST_SERDE.serializer().serialize(null, plainElements),
+            elementHeaders.toByteArray()
+        );
+    }
+
+    /**
+     * Rebuilds a HEADERS list blob by re-inlining each element's headers 
prefix. Inverse of
+     * {@link #splitHeadersListBlob(byte[])}, and the restore-time counterpart 
of the split.
+     *
+     * @param plainListBlob  a {@code ListSerde} blob of {@code [flag][value]} 
elements, or {@code null}
+     * @param elementHeaders the concatenated prefixes written by the split, 
or {@code null}/empty for a
+     *                       legacy record that predates the headers format — 
in which case every element
+     *                       gets empty headers, i.e. exactly
+     *                       {@link 
#convertPlainListBlobToHeadersListBlob(byte[])}
+     */
+    static byte[] joinPlainListBlobWithElementHeaders(final byte[] 
plainListBlob, final byte[] elementHeaders) {
+        if (plainListBlob == null) {
+            return null;
+        }
+        // Every element contributes at least the one-byte headersSize varint, 
so an absent or empty
+        // prefix blob can only mean "legacy record" or "empty list" — both 
are the all-empty case.
+        if (elementHeaders == null || elementHeaders.length == 0) {
+            return convertPlainListBlobToHeadersListBlob(plainListBlob);
+        }
+
+        final List<byte[]> plainElements = 
LIST_SERDE.deserializer().deserialize(null, plainListBlob);
+        final List<byte[]> headersElements = new 
ArrayList<>(plainElements.size());
+        final ByteBuffer prefixes = ByteBuffer.wrap(elementHeaders);
+
+        for (final byte[] plainElement : plainElements) {
+            final byte[] prefix = readNextHeadersPrefix(prefixes);
+            if (plainElement == null) {

Review Comment:
   This is the counter-part question: if `ListStore` never puts `null` values 
into the list, we should never get a `plainElement == null` back here?



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBListValueHeadersBytesStoreSupplier.java:
##########
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.state.KeyValueBytesStoreSupplier;
+import org.apache.kafka.streams.state.KeyValueStore;
+
+/**
+ * Supplies the persistent, dual-column-family {@link 
RocksDBListValueStoreWithHeaders} used as the
+ * bytes store for the outer-join {@link ListValueStore} in HEADERS mode.
+ */
+public class RocksDBListValueHeadersBytesStoreSupplier implements 
KeyValueBytesStoreSupplier {

Review Comment:
   Should this interface implement `HeadersBytesStoreSupplier` ?



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/HeadersAwareListValueStore.java:
##########
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.streams.processor.StateStore;
+
+/**
+ * Marker interface for the HEADERS-format outer-join {@link ListValueStore} 
changelog wrapper.
+ * <p>
+ * Used solely by {@code StateManagerUtil.converterForStore} to select the 
list-aware restore
+ * {@link RecordConverters#rawListValueToHeadersListValue() converter}.
+ * <p>
+ * Note: this is intentionally NOT {@link 
org.apache.kafka.streams.state.HeadersBytesStore}. That
+ * interface would make {@code WrappedStateStore.isHeadersAware} true and 
wrongly select
+ * {@code rawValueToHeadersValue()}, which reconstructs a single {@code 
[headers][ts][value]} payload
+ * and would corrupt the multi-element list blob used here.
+ */
+public interface HeadersAwareListValueStore extends StateStore {

Review Comment:
   Does it need to extend `StateStore`? `HeadersBytesStore` doesn't extend 
anything either.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ListValueStoreUpgradeUtils.java:
##########
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.utils.internals.ByteUtils;
+import org.apache.kafka.streams.state.HeadersBytesStore;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helpers for migrating the outer-join {@link ListValueStore} from the 
pre-headers PLAIN element
+ * format to the HEADERS element format (KIP-1271, added for AK 4.4).
+ * <p>
+ * The store persists, per key, a {@link Serdes#ListSerde} blob whose elements 
are single serialized
+ * values. The element encoding differs by {@code dsl.store.format}:
+ * <ul>
+ *   <li>PLAIN:   {@code [leftFlag(1B)][rawValue]} (a {@code 
LeftOrRightValue})</li>
+ *   <li>HEADERS: {@code 
[headersSize(varint)][headersBytes][leftFlag(1B)][rawValue]}
+ *       (an {@code AggregationWithHeaders<LeftOrRightValue>})</li>
+ * </ul>
+ * A PLAIN element becomes a HEADERS element with <em>empty</em> headers 
simply by prepending a single
+ * {@code 0x00} byte (the empty-headers varint) — see {@link 
HeadersBytesStore#convertToHeaderFormat}.
+ * So a whole PLAIN list blob is converted by prepending {@code 0x00} to each 
element and re-serializing
+ * the same {@code ListSerde}.
+ * <p>
+ * The HEADERS element format above is the <em>local, on-disk</em> format 
only. As everywhere else in
+ * KIP-1271, the changelog value must keep the pre-headers format so that 
downgrading — either to an
+ * older version or just by flipping {@code dsl.store.format} back to PLAIN — 
can still decode it. The
+ * {@link #splitHeadersListBlob(byte[]) split} / {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[]) join}
+ * pair moves the per-element headers between the value bytes and a reserved 
record header for that
+ * purpose; {@link #LIST_VALUE_HEADERS_HEADER_KEY} documents the wire encoding.
+ */
+final class ListValueStoreUpgradeUtils {
+
+    /**
+     * Reserved changelog record-header key carrying the per-element headers 
of a HEADERS-format list,
+     * so that the changelog <em>value</em> can stay in the format an old 
PLAIN store understands.
+     * <p>
+     * Its value is the concatenation of the {@code 
[headersSize(varint)][headersBytes]} prefixes that
+     * were stripped off the list elements, in list order. Each chunk carries 
its own length, so the
+     * blob is self-delimiting and no element count is needed. An element with 
no headers contributes
+     * a single {@code 0x00} byte.
+     * <p>
+     * Deliberately namespaced to avoid colliding with user headers that ride 
along on the record.
+     * A record <em>without</em> this header is a legacy PLAIN record: see
+     * {@link #joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     */
+    static final String LIST_VALUE_HEADERS_HEADER_KEY = 
"__kafka_streams_list_value_headers__";

Review Comment:
   Should we use such a long header key? It add quite some overhead per record.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ListValueStoreUpgradeUtils.java:
##########
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.utils.internals.ByteUtils;
+import org.apache.kafka.streams.state.HeadersBytesStore;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helpers for migrating the outer-join {@link ListValueStore} from the 
pre-headers PLAIN element
+ * format to the HEADERS element format (KIP-1271, added for AK 4.4).
+ * <p>
+ * The store persists, per key, a {@link Serdes#ListSerde} blob whose elements 
are single serialized
+ * values. The element encoding differs by {@code dsl.store.format}:
+ * <ul>
+ *   <li>PLAIN:   {@code [leftFlag(1B)][rawValue]} (a {@code 
LeftOrRightValue})</li>
+ *   <li>HEADERS: {@code 
[headersSize(varint)][headersBytes][leftFlag(1B)][rawValue]}
+ *       (an {@code AggregationWithHeaders<LeftOrRightValue>})</li>
+ * </ul>
+ * A PLAIN element becomes a HEADERS element with <em>empty</em> headers 
simply by prepending a single
+ * {@code 0x00} byte (the empty-headers varint) — see {@link 
HeadersBytesStore#convertToHeaderFormat}.
+ * So a whole PLAIN list blob is converted by prepending {@code 0x00} to each 
element and re-serializing
+ * the same {@code ListSerde}.
+ * <p>
+ * The HEADERS element format above is the <em>local, on-disk</em> format 
only. As everywhere else in
+ * KIP-1271, the changelog value must keep the pre-headers format so that 
downgrading — either to an
+ * older version or just by flipping {@code dsl.store.format} back to PLAIN — 
can still decode it. The
+ * {@link #splitHeadersListBlob(byte[]) split} / {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[]) join}
+ * pair moves the per-element headers between the value bytes and a reserved 
record header for that
+ * purpose; {@link #LIST_VALUE_HEADERS_HEADER_KEY} documents the wire encoding.
+ */
+final class ListValueStoreUpgradeUtils {
+
+    /**
+     * Reserved changelog record-header key carrying the per-element headers 
of a HEADERS-format list,
+     * so that the changelog <em>value</em> can stay in the format an old 
PLAIN store understands.
+     * <p>
+     * Its value is the concatenation of the {@code 
[headersSize(varint)][headersBytes]} prefixes that
+     * were stripped off the list elements, in list order. Each chunk carries 
its own length, so the
+     * blob is self-delimiting and no element count is needed. An element with 
no headers contributes
+     * a single {@code 0x00} byte.
+     * <p>
+     * Deliberately namespaced to avoid colliding with user headers that ride 
along on the record.
+     * A record <em>without</em> this header is a legacy PLAIN record: see
+     * {@link #joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     */
+    static final String LIST_VALUE_HEADERS_HEADER_KEY = 
"__kafka_streams_list_value_headers__";
+
+    // The prefix of an element with no headers: headersSize = varint(0), no 
headers bytes.
+    private static final byte[] EMPTY_HEADERS_PREFIX = {(byte) 0};
+
+    // Must match ListValueStore.LIST_SERDE.
+    @SuppressWarnings("unchecked")
+    private static final Serde<List<byte[]>> LIST_SERDE = 
Serdes.ListSerde(ArrayList.class, Serdes.ByteArray());
+
+    private ListValueStoreUpgradeUtils() {}
+
+    /**
+     * Converts a whole PLAIN list blob into the HEADERS list blob by lifting 
each element to the
+     * empty-headers format. {@code null} (a tombstone / whole-list delete) is 
passed through.
+     */
+    static byte[] convertPlainListBlobToHeadersListBlob(final byte[] 
plainListBlob) {
+        if (plainListBlob == null) {
+            return null;
+        }
+        final List<byte[]> plainElements = 
LIST_SERDE.deserializer().deserialize(null, plainListBlob);
+        final List<byte[]> headersElements = new 
ArrayList<>(plainElements.size());
+        for (final byte[] element : plainElements) {
+            // convertToHeaderFormat(null) returns null, preserving any null 
list members.
+            
headersElements.add(HeadersBytesStore.convertToHeaderFormat(element));
+        }
+        return LIST_SERDE.serializer().serialize(null, headersElements);
+    }
+
+    /**
+     * A HEADERS list blob taken apart for the changelog: the value bytes an 
old PLAIN store can still
+     * read, plus the per-element headers prefixes to park in {@link 
#LIST_VALUE_HEADERS_HEADER_KEY}.
+     */
+    static final class SplitListBlob {
+        final byte[] plainListBlob;
+        final byte[] elementHeaders;
+
+        SplitListBlob(final byte[] plainListBlob, final byte[] elementHeaders) 
{
+            this.plainListBlob = plainListBlob;
+            this.elementHeaders = elementHeaders;
+        }
+    }
+
+    /**
+     * Splits a HEADERS list blob into the PLAIN list blob plus the 
concatenated per-element headers
+     * prefixes. Inverse of {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     * <p>
+     * This is the list-aware counterpart of {@link 
Utils#rawPlainValue(byte[])}: it keeps the changelog
+     * value in the pre-headers format so that an old PLAIN store — or a store 
whose
+     * {@code dsl.store.format} was flipped back to PLAIN — can still decode 
it.
+     *
+     * @param headersListBlob a {@code ListSerde} blob of {@code 
[headersSize][headers][flag][value]}
+     *                        elements, or {@code null} for a whole-list 
tombstone
+     */
+    static SplitListBlob splitHeadersListBlob(final byte[] headersListBlob) {
+        if (headersListBlob == null) {
+            return new SplitListBlob(null, null);
+        }
+        final List<byte[]> headersElements = 
LIST_SERDE.deserializer().deserialize(null, headersListBlob);
+        final List<byte[]> plainElements = new 
ArrayList<>(headersElements.size());
+        final ByteArrayOutputStream elementHeaders = new 
ByteArrayOutputStream();
+
+        for (final byte[] element : headersElements) {
+            if (element == null) {
+                // ListValueStore never appends null, but ListSerde can hold 
nulls, so keep the pair
+                // total: a null element round-trips as null and consumes an 
empty-headers prefix.
+                plainElements.add(null);
+                elementHeaders.write(EMPTY_HEADERS_PREFIX, 0, 
EMPTY_HEADERS_PREFIX.length);
+                continue;
+            }
+            final int prefixLength = headersPrefixLength(element);
+            elementHeaders.write(element, 0, prefixLength);
+            final byte[] plainElement = new byte[element.length - 
prefixLength];
+            System.arraycopy(element, prefixLength, plainElement, 0, 
plainElement.length);
+            plainElements.add(plainElement);
+        }
+
+        return new SplitListBlob(
+            LIST_SERDE.serializer().serialize(null, plainElements),
+            elementHeaders.toByteArray()

Review Comment:
   Seems we could put a small optimization: if all headers are actually empty, 
we could just return `null` here throwing away the buffer?



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ListValueStoreUpgradeUtils.java:
##########
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.utils.internals.ByteUtils;
+import org.apache.kafka.streams.state.HeadersBytesStore;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helpers for migrating the outer-join {@link ListValueStore} from the 
pre-headers PLAIN element
+ * format to the HEADERS element format (KIP-1271, added for AK 4.4).
+ * <p>
+ * The store persists, per key, a {@link Serdes#ListSerde} blob whose elements 
are single serialized
+ * values. The element encoding differs by {@code dsl.store.format}:
+ * <ul>
+ *   <li>PLAIN:   {@code [leftFlag(1B)][rawValue]} (a {@code 
LeftOrRightValue})</li>
+ *   <li>HEADERS: {@code 
[headersSize(varint)][headersBytes][leftFlag(1B)][rawValue]}
+ *       (an {@code AggregationWithHeaders<LeftOrRightValue>})</li>
+ * </ul>
+ * A PLAIN element becomes a HEADERS element with <em>empty</em> headers 
simply by prepending a single
+ * {@code 0x00} byte (the empty-headers varint) — see {@link 
HeadersBytesStore#convertToHeaderFormat}.
+ * So a whole PLAIN list blob is converted by prepending {@code 0x00} to each 
element and re-serializing
+ * the same {@code ListSerde}.
+ * <p>
+ * The HEADERS element format above is the <em>local, on-disk</em> format 
only. As everywhere else in
+ * KIP-1271, the changelog value must keep the pre-headers format so that 
downgrading — either to an
+ * older version or just by flipping {@code dsl.store.format} back to PLAIN — 
can still decode it. The
+ * {@link #splitHeadersListBlob(byte[]) split} / {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[]) join}
+ * pair moves the per-element headers between the value bytes and a reserved 
record header for that
+ * purpose; {@link #LIST_VALUE_HEADERS_HEADER_KEY} documents the wire encoding.
+ */
+final class ListValueStoreUpgradeUtils {
+
+    /**
+     * Reserved changelog record-header key carrying the per-element headers 
of a HEADERS-format list,
+     * so that the changelog <em>value</em> can stay in the format an old 
PLAIN store understands.
+     * <p>
+     * Its value is the concatenation of the {@code 
[headersSize(varint)][headersBytes]} prefixes that
+     * were stripped off the list elements, in list order. Each chunk carries 
its own length, so the
+     * blob is self-delimiting and no element count is needed. An element with 
no headers contributes
+     * a single {@code 0x00} byte.
+     * <p>
+     * Deliberately namespaced to avoid colliding with user headers that ride 
along on the record.
+     * A record <em>without</em> this header is a legacy PLAIN record: see
+     * {@link #joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     */
+    static final String LIST_VALUE_HEADERS_HEADER_KEY = 
"__kafka_streams_list_value_headers__";
+
+    // The prefix of an element with no headers: headersSize = varint(0), no 
headers bytes.
+    private static final byte[] EMPTY_HEADERS_PREFIX = {(byte) 0};
+
+    // Must match ListValueStore.LIST_SERDE.
+    @SuppressWarnings("unchecked")
+    private static final Serde<List<byte[]>> LIST_SERDE = 
Serdes.ListSerde(ArrayList.class, Serdes.ByteArray());
+
+    private ListValueStoreUpgradeUtils() {}
+
+    /**
+     * Converts a whole PLAIN list blob into the HEADERS list blob by lifting 
each element to the
+     * empty-headers format. {@code null} (a tombstone / whole-list delete) is 
passed through.
+     */
+    static byte[] convertPlainListBlobToHeadersListBlob(final byte[] 
plainListBlob) {
+        if (plainListBlob == null) {
+            return null;
+        }
+        final List<byte[]> plainElements = 
LIST_SERDE.deserializer().deserialize(null, plainListBlob);
+        final List<byte[]> headersElements = new 
ArrayList<>(plainElements.size());
+        for (final byte[] element : plainElements) {
+            // convertToHeaderFormat(null) returns null, preserving any null 
list members.
+            
headersElements.add(HeadersBytesStore.convertToHeaderFormat(element));
+        }
+        return LIST_SERDE.serializer().serialize(null, headersElements);
+    }
+
+    /**
+     * A HEADERS list blob taken apart for the changelog: the value bytes an 
old PLAIN store can still
+     * read, plus the per-element headers prefixes to park in {@link 
#LIST_VALUE_HEADERS_HEADER_KEY}.
+     */
+    static final class SplitListBlob {
+        final byte[] plainListBlob;
+        final byte[] elementHeaders;
+
+        SplitListBlob(final byte[] plainListBlob, final byte[] elementHeaders) 
{
+            this.plainListBlob = plainListBlob;
+            this.elementHeaders = elementHeaders;
+        }
+    }
+
+    /**
+     * Splits a HEADERS list blob into the PLAIN list blob plus the 
concatenated per-element headers
+     * prefixes. Inverse of {@link 
#joinPlainListBlobWithElementHeaders(byte[], byte[])}.
+     * <p>
+     * This is the list-aware counterpart of {@link 
Utils#rawPlainValue(byte[])}: it keeps the changelog
+     * value in the pre-headers format so that an old PLAIN store — or a store 
whose
+     * {@code dsl.store.format} was flipped back to PLAIN — can still decode 
it.
+     *
+     * @param headersListBlob a {@code ListSerde} blob of {@code 
[headersSize][headers][flag][value]}
+     *                        elements, or {@code null} for a whole-list 
tombstone
+     */
+    static SplitListBlob splitHeadersListBlob(final byte[] headersListBlob) {
+        if (headersListBlob == null) {
+            return new SplitListBlob(null, null);
+        }
+        final List<byte[]> headersElements = 
LIST_SERDE.deserializer().deserialize(null, headersListBlob);
+        final List<byte[]> plainElements = new 
ArrayList<>(headersElements.size());
+        final ByteArrayOutputStream elementHeaders = new 
ByteArrayOutputStream();
+
+        for (final byte[] element : headersElements) {
+            if (element == null) {
+                // ListValueStore never appends null, but ListSerde can hold 
nulls, so keep the pair
+                // total: a null element round-trips as null and consumes an 
empty-headers prefix.
+                plainElements.add(null);
+                elementHeaders.write(EMPTY_HEADERS_PREFIX, 0, 
EMPTY_HEADERS_PREFIX.length);
+                continue;
+            }

Review Comment:
   ```suggestion
               } else {
   ```



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/ChangeLoggingListValueBytesStoreWithHeaders.java:
##########
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.state.KeyValueStore;
+
+/**
+ * The HEADERS-mode changelog store for the outer-join {@link ListValueStore}.
+ * <p>
+ * The local store holds {@code [headersSize][headers][flag][value]} per list 
element, but that format
+ * must never reach the changelog: the changelog topic is the only durable 
copy of the state, so its
+ * value format is a permanent compatibility contract. If we logged the local 
bytes verbatim, an old
+ * PLAIN reader — after a version downgrade, or simply after flipping {@code 
dsl.store.format} back to
+ * PLAIN — would read each element's leading empty-headers {@code 0x00} as the 
{@code LeftOrRightValue}
+ * flag and silently mistake left values for right ones.
+ * <p>
+ * So this store does what every other KIP-1271 changelog store does (see
+ * {@link ChangeLoggingTimestampedKeyValueBytesStoreWithHeaders}, which logs
+ * {@link Utils#rawPlainValue(byte[])}): it keeps the headers out of the value 
and puts them in a record
+ * header instead. The list makes that a little more involved — one changelog 
record holds the whole
+ * list, so N sets of headers have to share one header field — which is why 
the stripped prefixes are
+ * concatenated into a single self-delimiting blob under
+ * {@link ListValueStoreUpgradeUtils#LIST_VALUE_HEADERS_HEADER_KEY} rather 
than unpacked into individual
+ * {@code RecordHeader}s.
+ * <p>
+ * Implements {@link HeadersAwareListValueStore} purely so {@code 
StateManagerUtil.converterForStore}
+ * selects {@link RecordConverters#rawListValueToHeadersListValue()}, which 
performs the inverse join on
+ * restore.
+ */
+public class ChangeLoggingListValueBytesStoreWithHeaders
+    extends ChangeLoggingListValueBytesStore
+    implements HeadersAwareListValueStore {
+
+    ChangeLoggingListValueBytesStoreWithHeaders(final KeyValueStore<Bytes, 
byte[]> inner) {
+        super(inner);
+    }
+
+    @Override
+    public void put(final Bytes key, final byte[] value) {
+        wrapped().put(key, value);
+        // As in the parent, a tombstone deletes the whole list, so there is 
nothing to read back and
+        // no per-element headers to carry.
+        if (value == null) {
+            log(key, null, internalContext.recordContext().timestamp(), 
changelogHeaders(null));

Review Comment:
   Why do we call `changelogHeaders(null)`? Should we not just pass in 
`internalContext.recordContext().headers()` ? -- At least this is what we do on 
the other header store.
   
   Or do we actually need a copy as your comment in `changelogHeaders(...)` 
says, and all other stores have a bug?



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