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

mjsax 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 dee08051945 MINOR: add suppress() header-propagation scenario tests 
(#22955)
dee08051945 is described below

commit dee0805194531a88bedb6974bafcfdc27db4079b
Author: Matthias J. Sax <[email protected]>
AuthorDate: Mon Jul 27 13:25:12 2026 -0700

    MINOR: add suppress() header-propagation scenario tests (#22955)
    
    Part of KIP-1285.
    
    Adds SuppressHeadersScenarioTest, a baseline harness for record-header
    propagation through suppress(). The suppress buffer holds a prior, an
    old and a new value per row, each originating from a different input
    record, so each must be (de)serialized with the headers of the record it
    came from. Header-agnostic serdes discard the headers they are handed,
    so this is invisible from the output topic; the tests therefore observe
    every serde invocation and assert on what the serdes actually saw.
    
    Scenarios: non-windowed and windowed, crossed with eviction triggered by
    the same row versus a different row, plus tombstone handling in both the
    forwarded and the deliberately-dropped case.
    
    All scenarios currently fail and are therefore @Disabled; they encode
    the expected behaviour so a fix can be validated against them, and
    should be enabled by that fix.
    
    Reviewers: TengYao Chi <[email protected]>
---
 .../internals/SuppressHeadersScenarioTest.java     | 610 +++++++++++++++++++++
 1 file changed, 610 insertions(+)

diff --git 
a/streams/src/test/java/org/apache/kafka/streams/kstream/internals/SuppressHeadersScenarioTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/kstream/internals/SuppressHeadersScenarioTest.java
new file mode 100644
index 00000000000..d31d9880622
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/kstream/internals/SuppressHeadersScenarioTest.java
@@ -0,0 +1,610 @@
+/*
+ * 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.kstream.internals;
+
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.serialization.Deserializer;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.serialization.Serializer;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.TestInputTopic;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.TopologyTestDriver;
+import org.apache.kafka.streams.TopologyTestDriverBuilder;
+import org.apache.kafka.streams.kstream.Consumed;
+import org.apache.kafka.streams.kstream.Grouped;
+import org.apache.kafka.streams.kstream.KTable;
+import org.apache.kafka.streams.kstream.Materialized;
+import org.apache.kafka.streams.kstream.Produced;
+import org.apache.kafka.streams.kstream.TimeWindows;
+import org.apache.kafka.streams.kstream.Windowed;
+import org.apache.kafka.streams.state.KeyValueStore;
+import org.apache.kafka.streams.state.WindowStore;
+import org.apache.kafka.streams.test.TestRecord;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Properties;
+
+import static java.time.Duration.ofMillis;
+import static 
org.apache.kafka.streams.kstream.Suppressed.BufferConfig.unbounded;
+import static org.apache.kafka.streams.kstream.Suppressed.untilTimeLimit;
+import static org.apache.kafka.streams.kstream.Suppressed.untilWindowCloses;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Scenario tests for record-header propagation through {@code suppress()}.
+ *
+ * <p>The suppress buffer holds a row per key containing an {@code old} and a 
{@code new} value,
+ * plus a single record context. Each of those parts originates from a
+ * <em>different</em> input record, so each must be serialized and 
deserialized with the headers of
+ * the record it came from. Getting this wrong is invisible with 
header-agnostic serdes such as
+ * {@link Serdes#String()}, because they discard the headers they are handed. 
These tests therefore
+ * use serdes that observe every invocation, and assert on what the serdes 
actually saw rather than
+ * only on what reached the output topic.
+ *
+ * <p>Each input record carries the headers it expects to see preserved, as 
{@code origin=<value>},
+ * so every value should always meet its own marker; see {@link 
#observingSerde}.
+ *
+ * <h3>Invariants asserted</h3>
+ * <ul>
+ *   <li><b>INV-1</b> — every value (de)serialized by the suppress buffer must 
be handed the headers
+ *       belonging to that value, i.e. {@code origin=<value>}.</li>
+ *   <li><b>INV-2</b> — the record emitted on eviction must carry the headers 
belonging to the value
+ *       being emitted.</li>
+ *   <li><b>INV-3</b> — reported, not enforced: which headers the suppress 
buffer hands to the
+ *       <em>key</em> serde. The key is shared by all parts of a row, so there 
is no single "correct"
+ *       answer to pin down yet; the observations are printed so the behaviour 
is visible.</li>
+ * </ul>
+ *
+ * <p>All checks are collected and reported together: a scenario runs to 
completion, prints a full
+ * trace plus a check summary to stdout, and only then makes a single 
assertion. That keeps the whole
+ * picture visible instead of stopping at the first deviation.
+ *
+ * <h3>Scenario coverage</h3>
+ *
+ * The buffer never sees {@code Windowed}: it stores serialized key bytes, and 
windowing reaches
+ * {@code suppress()} only through the time definition and through {@code 
safeToDropTombstones},
+ * neither of which affects header handling. Windowed and non-windowed 
scenarios therefore drive the
+ * same code, and the matrix below is deliberately not filled in exhaustively.
+ *
+ * <p>In particular there is <b>no</b> windowed / 
eviction-triggered-by-a-different-key scenario. That
+ * is omitted on purpose rather than missing: it would drive the same buffer 
path as
+ * {@link #nonWindowedEvictionTriggeredByDifferentKey()} with nothing added. 
The windowed scenarios
+ * that are here earn their place either by recording row-identity semantics 
that are easy to get
+ * wrong, or by being reachable only when windowed.
+ *
+ * <h3>Why the same-row scenarios require the HEADERS store format</h3>
+ *
+ * When the evicting record updates a row that is already buffered, that row 
ends up holding an
+ * {@code old} and a {@code new} value originating from two different records, 
so INV-1 requires two
+ * different sets of headers to be recoverable from one row. The plain format 
has nowhere to put
+ * them: it stores bare values and a single record context, so at most one of 
the two can be right.
+ * INV-1 is therefore unsatisfiable in plain format for these scenarios, no 
matter how
+ * {@code suppress()} is implemented.
+ *
+ * <p>{@link #nonWindowedEvictionTriggeredBySameKey()} and
+ * {@link #windowedEvictionTriggeredBySameKeySameWindow()} consequently set
+ * {@code dsl.store.format=headers}, where each value part carries its own 
headers. Running them in
+ * plain format would assert something impossible and read as a permanent bug.
+ *
+ * <p>Scenarios where the evicted row is <em>not</em> the one being updated 
have only one origin per
+ * row, so they are satisfiable in plain format and deliberately stay there.
+ */
+public class SuppressHeadersScenarioTest {
+
+    private static final String INPUT_TOPIC = "input";
+    private static final String OUTPUT_TOPIC = "output";
+
+    /** Explicit suppress name so the buffer's changelog topic is predictable 
and can be filtered on. */
+    private static final String SUPPRESS_NAME = "suppress-under-test";
+    private static final String SUPPRESS_STORE_TOPIC_PART = SUPPRESS_NAME + 
"-store";
+
+    private static final String ORIGIN_HEADER = "origin";
+
+    /** Input value whose aggregation result is {@code null}, used to stage a 
tombstone downstream. */
+    private static final String DELETE_SENTINEL = "DELETE";
+
+    private final List<Event> events = new ArrayList<>();
+    private final Checks checks = new Checks();
+    private String phase = "init";
+    private String scenario = "";
+    private Properties config;
+
+    @BeforeEach
+    public void setup() {
+        events.clear();
+        checks.clear();
+        config = new Properties();
+        config.setProperty(StreamsConfig.STATE_DIR_CONFIG, 
TestUtils.tempDirectory().getAbsolutePath());
+    }
+
+    @AfterEach
+    public void report() {
+        System.out.println();
+        System.out.println("================ SCENARIO: " + scenario + " 
================");
+        System.out.println(renderTrace());
+        System.out.println("---- checks ----");
+        System.out.println(checks.render());
+        System.out.println("================ END: " + scenario + " 
================");
+        System.out.println();
+    }
+
+    // ------------------------------------------------------------------ 
scenarios
+
+    @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
+    @Test
+    public void nonWindowedEvictionTriggeredByDifferentKey() {
+        scenario = "non-windowed / eviction triggered by a DIFFERENT key";
+        try (final TopologyTestDriver driver = new 
TopologyTestDriverBuilder(nonWindowedTopology()).withConfig(config).build()) {
+            final TestInputTopic<String, String> input =
+                driver.createInputTopic(INPUT_TOPIC, new StringSerializer(), 
new StringSerializer());
+
+            pipe(input, "kA", "v1", 0L);
+            // Advancing stream time past kA's suppress deadline. kA's row is 
untouched by this
+            // record, so it is evicted exactly as it was buffered.
+            pipe(input, "kB", "v2", 120_000L);
+
+            verifyEmitted(driver, OUTPUT_TOPIC, "v1");
+        }
+        finish();
+    }
+
+    /**
+     * Runs in HEADERS store format only; see the note on same-row scenarios 
in the class javadoc.
+     * The row holds {@code new=v2 / old=v1}, each from a different input 
record, which plain values
+     * cannot represent.
+     */
+    @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
+    @Test
+    public void nonWindowedEvictionTriggeredBySameKey() {
+        scenario = "non-windowed / SAME key, row updated then evicted (headers 
format)";
+        config.setProperty(StreamsConfig.DSL_STORE_FORMAT_CONFIG, 
StreamsConfig.DSL_STORE_FORMAT_HEADERS);
+        try (final TopologyTestDriver driver = new 
TopologyTestDriverBuilder(nonWindowedTopology()).withConfig(config).build()) {
+            final TestInputTopic<String, String> input =
+                driver.createInputTopic(INPUT_TOPIC, new StringSerializer(), 
new StringSerializer());
+
+            pipe(input, "kA", "v1", 0L);
+            pipe(input, "kA", "v2", 120_000L);
+
+            verifyEmitted(driver, OUTPUT_TOPIC, "v2");
+        }
+        finish();
+    }
+
+    /**
+     * A delete of a buffered row. {@code untilTimeLimit} reports {@code 
safeToDropTombstones=false},
+     * so the tombstone must reach the output: dropping it would leave 
downstream state undeleted.
+     *
+     * <p>This is also the sharpest header case in this class. There is no 
value left to carry
+     * anything, so the record's headers are all that remains, and they still 
have to be correct for
+     * the key's sake.
+     *
+     * <p>Not a duplicate of {@link #nonWindowedEvictionTriggeredBySameKey()}, 
despite also being a
+     * same-row overwrite: it is the only scenario that fails INV-2. There, 
the new value is the last
+     * part serialized, so the headers left behind on the shared object happen 
to match what is
+     * emitted; with a tombstone only the <em>old</em> value is serialized, 
which makes the leak
+     * visible on the emitted record.
+     */
+    @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
+    @Test
+    public void nonWindowedTombstoneMustBeForwarded() {
+        scenario = "non-windowed / buffered row is DELETED, tombstone must be 
forwarded";
+        try (final TopologyTestDriver driver = new 
TopologyTestDriverBuilder(nonWindowedTopology()).withConfig(config).build()) {
+            final TestInputTopic<String, String> input =
+                driver.createInputTopic(INPUT_TOPIC, new StringSerializer(), 
new StringSerializer());
+
+            pipe(input, "kA", "v1", 0L);
+            // A null value deletes the table row. It lands on the same 
suppress row, so it overwrites
+            // the buffered value and is then evicted as Change(new=null, 
old=v1).
+            pipe(input, "kA", null, 120_000L);
+
+            verifyEmitted(driver, OUTPUT_TOPIC, null);
+        }
+        finish();
+    }
+
+    /**
+     * Drives the same buffer path as {@link 
#nonWindowedEvictionTriggeredByDifferentKey()} — the
+     * buffer never sees {@code Windowed}, only serialized key bytes — and is 
kept for the semantics
+     * it records: a <em>same</em> input key still produces a 
<em>different</em> suppress row once
+     * windowed, so the evicted row is not the one the arriving record updated.
+     */
+    @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
+    @Test
+    public void windowedEvictionTriggeredBySameKeyDifferentWindow() {
+        scenario = "windowed (untilWindowCloses) / SAME key but a DIFFERENT 
window";
+        try (final TopologyTestDriver driver = new 
TopologyTestDriverBuilder(windowedTopology(false)).withConfig(config).build()) {
+            final TestInputTopic<String, String> input =
+                driver.createInputTopic(INPUT_TOPIC, new StringSerializer(), 
new StringSerializer());
+
+            pipe(input, "kA", "v1", 0L);
+            // Same input key, but a far-future window, so this is a different 
suppress row.
+            pipe(input, "kA", "v2", 120_000L);
+
+            verifyEmitted(driver, OUTPUT_TOPIC, "v1");
+        }
+        finish();
+    }
+
+    /**
+     * Drives the same buffer path as {@link 
#nonWindowedEvictionTriggeredBySameKey()}, and is kept
+     * for the semantics it records: same-row eviction <em>is</em> reachable 
while windowed, but only
+     * under {@code untilTimeLimit}. It cannot happen under {@code 
untilWindowCloses}, where being
+     * inside a window excludes being past its close.
+     */
+    @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
+    @Test
+    public void windowedEvictionTriggeredBySameKeySameWindow() {
+        scenario = "windowed (untilTimeLimit) / SAME key and SAME window 
(headers format)";
+        config.setProperty(StreamsConfig.DSL_STORE_FORMAT_CONFIG, 
StreamsConfig.DSL_STORE_FORMAT_HEADERS);
+        try (final TopologyTestDriver driver = new 
TopologyTestDriverBuilder(windowedTopology(true)).withConfig(config).build()) {
+            final TestInputTopic<String, String> input =
+                driver.createInputTopic(INPUT_TOPIC, new StringSerializer(), 
new StringSerializer());
+
+            pipe(input, "kA", "v1", 0L);
+            // A one-hour window with a one-second time limit: both records 
land in the same window,
+            // so this is the same suppress row, updated and then evicted. Not 
reachable with
+            // untilWindowCloses, where being inside a window excludes being 
past its close.
+            pipe(input, "kA", "v2", 5_000L);
+
+            verifyEmitted(driver, OUTPUT_TOPIC, "v2");
+        }
+        finish();
+    }
+
+    /**
+     * The mirror image of {@link #nonWindowedTombstoneMustBeForwarded()}. In 
final-results mode
+     * {@code safeToDropTombstones} is {@code true}, so the tombstone is 
deliberately swallowed: for a
+     * windowed key that never emitted a result there is nothing downstream to 
delete, and an
+     * unnecessary tombstone in the output is undesirable (see {@code 
SuppressedInternal}'s javadoc).
+     *
+     * <p>Pinned deliberately, so the swallow is not later "fixed" as a leak.
+     *
+     * <p>A null value cannot be piped into an aggregation, so the tombstone 
is staged by having the
+     * reducer return {@code null} for the sentinel value {@code DELETE}.
+     */
+    @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
+    @Test
+    public void windowedFinalResultsTombstoneIsDropped() {
+        scenario = "windowed (untilWindowCloses) / tombstone is dropped by 
design";
+        try (final TopologyTestDriver driver = new 
TopologyTestDriverBuilder(windowedTopology(false)).withConfig(config).build()) {
+            final TestInputTopic<String, String> input =
+                driver.createInputTopic(INPUT_TOPIC, new StringSerializer(), 
new StringSerializer());
+
+            pipe(input, "kA", "v1", 0L);
+            // Same window: the aggregate becomes null, so the buffered row 
becomes a tombstone.
+            pipe(input, "kA", DELETE_SENTINEL, 1L);
+            // Advance stream time so kA's window closes and its row is 
evicted.
+            pipe(input, "kB", "v2", 120_000L);
+
+            verifyNothingEmitted(driver, OUTPUT_TOPIC);
+        }
+        finish();
+    }
+
+    // ------------------------------------------------------------------ 
topologies
+
+    private Topology nonWindowedTopology() {
+        final StreamsBuilder builder = new StreamsBuilder();
+
+        builder
+            .table(
+                INPUT_TOPIC,
+                Consumed.with(observingSerde("key"), observingSerde("value")),
+                Materialized.<String, String, KeyValueStore<Bytes, 
byte[]>>as("table-store")
+                    .withKeySerde(observingSerde("key"))
+                    .withValueSerde(observingSerde("value"))
+                    .withCachingDisabled()
+                    .withLoggingDisabled())
+            .suppress(untilTimeLimit(Duration.ofSeconds(1), 
unbounded()).withName(SUPPRESS_NAME))
+            .toStream()
+            .to(OUTPUT_TOPIC, Produced.with(Serdes.String(), Serdes.String()));
+
+        return builder.build();
+    }
+
+    private Topology windowedTopology(final boolean useTimeLimit) {
+        final StreamsBuilder builder = new StreamsBuilder();
+
+        // reduce((old, latest) -> latest) keeps the aggregate value equal to 
the latest input value,
+        // so each aggregate value still identifies the record it came from, 
unlike count().
+        final KTable<Windowed<String>, String> table = builder
+            .stream(INPUT_TOPIC, Consumed.with(observingSerde("key"), 
observingSerde("value")))
+            .groupByKey(Grouped.with(observingSerde("key"), 
observingSerde("value")))
+            .windowedBy(useTimeLimit
+                ? TimeWindows.ofSizeWithNoGrace(Duration.ofHours(1))
+                : TimeWindows.ofSizeAndGrace(ofMillis(2L), ofMillis(1L)))
+            .reduce(
+                (oldValue, latest) -> DELETE_SENTINEL.equals(latest) ? null : 
latest,
+                Materialized.<String, String, WindowStore<Bytes, 
byte[]>>as("windowed-store")
+                    .withKeySerde(observingSerde("key"))
+                    .withValueSerde(observingSerde("value"))
+                    .withCachingDisabled());
+
+        final KTable<Windowed<String>, String> suppressed = useTimeLimit
+            ? table.suppress(untilTimeLimit(Duration.ofSeconds(1), 
unbounded()).withName(SUPPRESS_NAME))
+            : 
table.suppress(untilWindowCloses(unbounded()).withName(SUPPRESS_NAME));
+
+        suppressed
+            .toStream()
+            .map((final Windowed<String> k, final String v) -> new 
KeyValue<>(k.key(), v))
+            .to(OUTPUT_TOPIC, Produced.with(Serdes.String(), Serdes.String()));
+
+        return builder.build();
+    }
+
+    // ------------------------------------------------------------------ 
serdes
+
+    /**
+     * Serde that only observes: it records every invocation and the {@link 
Headers} instance it
+     * was handed, and never modifies them. Assertions are then made against 
what the buffer actually
+     * passed in, on both the serialize and the deserialize side — a serde 
that wrote headers of its
+     * own would overwrite the evidence and hide every serialize-side defect.
+     *
+     * <p>Headers reach the buffer the ordinary way: {@code pipe(...)} puts 
{@code origin=<value>} on
+     * the input record, and the record context carries it into {@code 
suppress()}. Writes to the
+     * upstream table store appear in the trace with <em>empty</em> headers, 
because the DSL does not
+     * propagate record headers into a materialized store. That is expected 
and does not affect these
+     * scenarios: {@code KTableSource} discards the store's headers when it 
builds the {@code Change},
+     * so the buffer never reads that path.
+     *
+     * @param role {@code "key"} or {@code "value"}, recorded with each 
observation so the trace and
+     *             the invariants can tell the two apart
+     */
+    private Serde<String> observingSerde(final String role) {
+        return new Serde<>() {
+            @Override
+            public Serializer<String> serializer() {
+                return new Serializer<>() {
+                    @Override
+                    public byte[] serialize(final String topic, final String 
data) {
+                        record(role, "SER(no-headers-overload)", topic, data, 
null);
+                        return raw(data);
+                    }
+
+                    @Override
+                    public byte[] serialize(final String topic, final Headers 
headers, final String data) {
+                        record(role, "SER", topic, data, headers);
+                        return raw(data);
+                    }
+                };
+            }
+
+            @Override
+            public Deserializer<String> deserializer() {
+                return new Deserializer<>() {
+                    @Override
+                    public String deserialize(final String topic, final byte[] 
data) {
+                        final String value = str(data);
+                        record(role, "DESER(no-headers-overload)", topic, 
value, null);
+                        return value;
+                    }
+
+                    @Override
+                    public String deserialize(final String topic, final 
Headers headers, final byte[] data) {
+                        final String value = str(data);
+                        record(role, "DESER", topic, value, headers);
+                        return value;
+                    }
+                };
+            }
+        };
+    }
+
+    private static byte[] raw(final String s) {
+        return s == null ? null : s.getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static String str(final byte[] b) {
+        return b == null ? null : new String(b, StandardCharsets.UTF_8);
+    }
+
+    // ------------------------------------------------------------------ 
driving and checking
+
+    private void pipe(final TestInputTopic<String, String> topic,
+                      final String key,
+                      final String value,
+                      final long timestamp) {
+        phase = String.format(Locale.ROOT, "%s=%s@%d", key, value, timestamp);
+        topic.pipeInput(new TestRecord<>(key, value, 
expectedHeadersFor(value), timestamp));
+    }
+
+    /**
+     * The origin marker for {@code value}. A tombstone gets its own marker 
rather than none, because
+     * the record's headers still have to travel with the key.
+     */
+    private static String originOf(final String value) {
+        return value == null ? "<tombstone>" : value;
+    }
+
+    /** The headers that belong to {@code value}: what every (de)serialization 
of it should see. */
+    private static Headers expectedHeadersFor(final String value) {
+        return new RecordHeaders().add(ORIGIN_HEADER, 
originOf(value).getBytes(StandardCharsets.UTF_8));
+    }
+
+    private void verifyEmitted(final TopologyTestDriver driver,
+                               final String topic,
+                               final String expectedValue) {
+        final List<TestRecord<String, String>> output = driver
+            .createOutputTopic(topic, new StringDeserializer(), new 
StringDeserializer())
+            .readRecordsToList();
+
+        checks.record("exactly one record is emitted", 1, output.size());
+        if (output.isEmpty()) {
+            return;
+        }
+        final TestRecord<String, String> emitted = output.get(0);
+        checks.record("emitted value", expectedValue, emitted.value());
+        // INV-2: the emitted record carries the headers of the value being 
emitted.
+        checks.record(
+            "INV-2 emitted headers match the emitted value's origin",
+            render(expectedHeadersFor(emitted.value())),
+            render(emitted.headers()));
+    }
+
+    private void verifyNothingEmitted(final TopologyTestDriver driver, final 
String topic) {
+        final List<TestRecord<String, String>> output = driver
+            .createOutputTopic(topic, new StringDeserializer(), new 
StringDeserializer())
+            .readRecordsToList();
+        checks.record("no record is emitted", 0, output.size());
+    }
+
+    /** Applies INV-1 and INV-3 over the captured trace, then makes the single 
assertion. */
+    private void finish() {
+        for (final Event e : events) {
+            if (!e.insideSuppressBuffer()) {
+                continue;
+            }
+            if ("value".equals(e.role) && e.headers != null) {
+                // INV-1: a value must always meet the headers it was written 
with. Null values are
+                // included: a tombstone's headers still have to be correct 
for the key's sake.
+                checks.record(
+                    "INV-1 " + e.op + " of '" + originOf(e.value) + "' during 
[" + e.phase + "]",
+                    render(expectedHeadersFor(e.value)),
+                    e.headers);
+            } else if ("key".equals(e.role)) {
+                checks.observe("INV-3 key " + e.op + " during [" + e.phase + 
"] saw " + e.headers);
+            }
+        }
+        assertTrue(checks.allPassed(), "\n" + renderTrace() + "\n" + 
checks.render());
+    }
+
+    private String renderTrace() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(String.format(Locale.ROOT, "%-22s %-5s %-28s %-8s %s%n",
+            "PHASE", "ROLE", "OP", "VALUE", "HEADERS / topic"));
+        for (final Event e : events) {
+            sb.append(e).append(System.lineSeparator());
+        }
+        return sb.toString();
+    }
+
+    private void record(final String role,
+                        final String op,
+                        final String topic,
+                        final String value,
+                        final Headers headers) {
+        // Headers are mutable and shared, so snapshot them as text 
immediately.
+        events.add(new Event(phase, role, op, topic, value, headers == null ? 
null : render(headers)));
+    }
+
+    private static String render(final Headers headers) {
+        if (headers == null) {
+            return "<null>";
+        }
+        final StringBuilder sb = new StringBuilder("[");
+        boolean first = true;
+        for (final Header h : headers) {
+            if (!first) {
+                sb.append(", ");
+            }
+            first = false;
+            sb.append(h.key()).append('=')
+              .append(h.value() == null ? "<null>" : new String(h.value(), 
StandardCharsets.UTF_8));
+        }
+        return sb.append(']').toString();
+    }
+
+    // ------------------------------------------------------------------ 
small helpers
+
+    private static class Event {
+        private final String phase;
+        private final String role;
+        private final String op;
+        private final String topic;
+        private final String value;
+        private final String headers;
+
+        Event(final String phase, final String role, final String op,
+              final String topic, final String value, final String headers) {
+            this.phase = phase;
+            this.role = role;
+            this.op = op;
+            this.topic = topic;
+            this.value = value;
+            this.headers = headers;
+        }
+
+        /** True for invocations made by the suppress buffer rather than the 
upstream table store. */
+        boolean insideSuppressBuffer() {
+            return topic != null && topic.contains(SUPPRESS_STORE_TOPIC_PART);
+        }
+
+        @Override
+        public String toString() {
+            return String.format(Locale.ROOT, "%-22s %-5s %-28s %-8s %s   
(%s%s)",
+                phase, role, op, value, headers, topic, insideSuppressBuffer() 
? "  <-- SUPPRESS" : "");
+        }
+    }
+
+    private static final class Checks {
+        private final List<String> lines = new ArrayList<>();
+        private int failures;
+
+        void clear() {
+            lines.clear();
+            failures = 0;
+        }
+
+        void record(final String description, final Object expected, final 
Object actual) {
+            final boolean ok = Objects.equals(expected, actual);
+            if (!ok) {
+                failures++;
+            }
+            lines.add(String.format(Locale.ROOT, "[%s] %s%n        expected: 
%s%n        actual:   %s",
+                ok ? "PASS" : "FAIL", description, expected, actual));
+        }
+
+        void observe(final String note) {
+            lines.add("[INFO] " + note);
+        }
+
+        boolean allPassed() {
+            return failures == 0;
+        }
+
+        String render() {
+            final StringBuilder sb = new StringBuilder();
+            for (final String l : lines) {
+                sb.append(l).append(System.lineSeparator());
+            }
+            sb.append(failures == 0
+                ? "ALL CHECKS PASSED"
+                : failures + " CHECK(S) FAILED");
+            return sb.toString();
+        }
+    }
+}

Reply via email to