Viktor Klimin created KAFKA-21243:
-------------------------------------

             Summary: Producer: concurrent first sends can wait an extra 
retry.backoff.ms for a redundant metadata refresh
                 Key: KAFKA-21243
                 URL: https://issues.apache.org/jira/browse/KAFKA-21243
             Project: Kafka
          Issue Type: Bug
          Components: producer 
    Affects Versions: 3.9.2, 4.4.1
            Reporter: Viktor Klimin


h3. Issue

We run a producer with metadata.max.idle.ms at its default (5 min) and 
retry.backoff.ms=750. When a topic has been idle longer than 
metadata.max.idle.ms and several threads call send() at about the same time, 
some of them occasionally block for roughly 900 ms inside send(). That is about 
one retry.backoff.ms, even though the brokers are healthy and the first 
metadata response comes back in a couple of milliseconds. In our case two 
threads that called send() within ~2 ms of each other were both stuck for ~897 
ms and were released together.
h3. Description
 # In KafkaProducer.{*}waitOnMetadata{*}() the thread first takes a snapshot 
with metadata.fetch(), then calls metadata.requestUpdateForTopic(topic). These 
are two separate lock acquisitions. If the sender thread applies the first 
metadata response in between: ProducerMetadata.update() removes the topic from 
newTopics, bumps updateVersion (V -> V+1), and sets lastRefreshMs = now.
 # The waiting thread still has the old snapshot (no partitions for the topic), 
so it does not take the fast path.
 # It calls {*}requestUpdateForTopic{*}(topic). The topic is no longer in 
newTopics, so this goes to {*}requestUpdate{*}(false). That only sets 
needFullUpdate and returns V+1.
 # {*}awaitUpdate{*}(V+1) now needs a second metadata response, but a new 
refresh is not allowed until *lastRefreshMs + retry.backoff.ms* (with jitter), 
so the thread sits there for ~{*}0.6-0.9 s at 750{*} ms.

The data the thread needs is already in the metadata at step 2, so the wait is 
redundant. When the second response arrives, all the threads that hit this 
window wake up together.
If requestUpdateForTopic() happens before the response is applied, the topic is 
still in newTopics, requestUpdateForNewTopics() is used (no backoff), and the 
thread is released by the first response. So only the interleaving decides.
h3. Reproduction

The race is also reproducible with a real KafkaProducer.

Test setup: one producer, metadata.max.idle.ms=5000, metadata.max.age.ms=1000, 
retry.backoff.ms=750, brokers ~35 ms away. Every cycle: wait until the topic is 
evicted from the producer metadata, send from one "trigger" thread, then send 
from 100 other threads at randomized instants spread over a ~20 ms window 
around the time the first metadata response is expected. Plain simultaneous 
start does not hit it, because all threads then arrive before the first 
response is applied; the arrivals have to be spread around the moment the 
response is applied.

Since the window is the time between {*}fetch{*}() and 
{*}requestUpdateForTopic{*}(), the example below shows the same.
{code:java}
public class MetadataRaceProbe {
    public static void main(String[] a) throws Exception {
        String topic = "probe";
        int probes = 100;
        long spreadMs = 20, pauseMs = 7_000, stallMs = 400;
        long estimateMs = 40;                // initial guess of the metadata 
round trip
 
        Properties p = new Properties();
        p.put("bootstrap.servers", "broker:9092");
        p.put("key.serializer", ByteArraySerializer.class.getName());
        p.put("value.serializer", ByteArraySerializer.class.getName());
        p.put("metadata.max.idle.ms", "5000");     // minimum Kafka allows
        p.put("metadata.max.age.ms", "1000");      // refresh often so eviction 
happens inside the pause
        p.put("retry.backoff.ms", "750");
        // + security settings if needed
 
        KafkaProducer<byte[], byte[]> producer = new KafkaProducer<>(p);
        ExecutorService pool = Executors.newFixedThreadPool(probes + 1);
        byte[] payload = "x".getBytes();
 
        for (int cycle = 0; cycle < 85; cycle++) {
            long t0 = System.nanoTime() + 100_000_000L;          // common 
start, 100 ms ahead
            long est = estimateMs * 1_000_000L, spread = spreadMs * 1_000_000L;
 
            Future<Long> trigger = pool.submit(timedSend(producer, topic, 
payload, t0));
            List<Future<Long>> fs = new ArrayList<>();
            long slot = spread / probes;
            for (int i = 0; i < probes; i++) {
                long offset = -spread / 2 + i * slot + 
ThreadLocalRandom.current().nextLong(slot);
                fs.add(pool.submit(timedSend(producer, topic, payload, t0 + 
Math.max(0, est + offset))));
            }
 
            long triggerMs = trigger.get();
            List<Long> stalls = new ArrayList<>();
            for (Future<Long> f : fs) { long ms = f.get(); if (ms >= stallMs) 
stalls.add(ms); }
 
            boolean evicted = triggerMs >= 5;            // trigger paid a 
metadata round trip
            System.out.printf("cycle=%d evicted=%b trigger=%dms 
stalledProbes=%d %s%n",
                    cycle, evicted, triggerMs, stalls.size(), stalls);
 
            if (cycle > 0 && triggerMs >= 5 && triggerMs < stallMs)
                estimateMs = (estimateMs * 3 + triggerMs) / 4;   // follow the 
real RTT
            Thread.sleep(pauseMs);                           // > idle + age, 
so the topic gets evicted
        }
        pool.shutdownNow();
        producer.close();
    }

    static Callable<Long> timedSend(KafkaProducer<byte[], byte[]> producer, 
String topic, byte[] payload, long targetNanos) {
        return () -> {
            waitUntil(targetNanos);
            long t = System.nanoTime();
            Future<RecordMetadata> ack = producer.send(new 
ProducerRecord<>(topic, payload));
            long sendMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t);
            ack.get();
            return sendMs;
        };
    }


    static void waitUntil(long target) {
        long left;
        while ((left = target - System.nanoTime()) > 0) {
            if (left > 200_000) LockSupport.parkNanos(left - 150_000);
            else Thread.onSpinWait();
        }
    }
}{code}
h3. Expected behavior

A thread whose snapshot is older than an already-applied metadata update should 
not schedule a new refresh. It should just re-read the metadata, find the 
topic, and carry on.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to