DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • How AI Is Rewriting Full-Stack Java Systems: Practical Patterns with Spring Boot, Kafka and WebSockets
  • From APIs to Event-Driven Systems: Modern Java Backend Design
  • Java Consumer and Producer Messages Between Kafka Server [Video Tutorials]
  • How To Install CMAK, Apache Kafka, Java 18, and Java 19 [Video Tutorials]

Trending

  • PWA on iOS, Part 2: HTTPS, Manifest and Service Worker
  • Genkit Flows as senro Steps: Extremely Powerful AI Pipelines in Go
  • Why Kotlin or Java Is the Wrong Question For Enterprises
  • When RabbitMQ Accepts a Message but Does Not Route It to a Queue

Draft This article hasn't been published, so readers can't see it.

In moderation This article is waiting for review, so readers can't see it yet.

Deleted This article has been deleted, so readers can't see it.

  1. DZone
  2. Data Engineering
  3. Big Data
  4. Searching Kafka Without an Index: Lessons From Building Message Search for Kafdrop

Searching Kafka Without an Index: Lessons From Building Message Search for Kafdrop

Build bounded Kafka message search into Kafdrop using Java, with timestamp-based scans, memory limits, and safe consumer configuration.

By 
Shreyamsh Kamate
·
Oct. 28, 26 · Analysis
Post on X (opens in a new tab) Share on LinkedIn (opens in a new tab)
123 Views

Join the DZone community Save articles, join the discussion, and download Refcards and Trend Reports with a free account.

Join for free Already a member? Log in

The message left the first service at 2:14 PM and never reached the last one. Between those facts: four pipeline stages, six topics, a pub/sub API moving every record. The engineer on call had Kafdrop open, the offsets looked fine, and nobody could say which stage had eaten the record.

That gap is why we built message search for Kafdrop at a hackathon. The setup was familiar from my day job: a platform moving data between pipeline stages over a pub/sub API, with no way to ask where a message went when something breaks. Kafdrop showed partitions and offsets. It couldn't search inside the messages.

Kafka has no index. That is the whole problem in one sentence. A topic is an append-only log split across partitions, and the consumer API reads by offset: hand it 41,207, and it returns record 41,207. There is no "find the record containing order-88412." To search, you pick a starting point and read forward until you find what you want or stop.

So that is what our feature does, with guardrails that keep the search from hurting the cluster. The interesting part was the caps, the consumer setup, and the SRE agreement.

Why a Full Scan Is Not an Option

The obvious approach is to read the whole topic and grep. Topics hold days of data, so the whole thing doesn't fit in memory. Reading every record on every search hammers the brokers, and during an incident that is the worst time to add load. Most searches end with a few matches, so nearly all of that work is wasted.

The other approach is to mirror every topic into a search engine. That works, and plenty of teams do it. It also means running a second distributed system and explaining index lag during every incident. For a hackathon debugging tool, that was too much machinery.

The Design: Seek, Scan, and Stop

The feature takes four inputs: a topic, a query string, a start time, and two caps.

The start time bounds how far back the search goes. Kafka's offsetsForTimes maps a timestamp to the first offset at or after it per partition, or null when the partition holds nothing that new. The user picks "the last hour" or "since the deploy."

The result cap bounds memory: return the first N matches and stop. Debugging usually needs one record, not ten thousand.

The scan cap bounds broker load: stop after M records even with no match. Every search becomes a fixed amount of work, whatever the user types. A typo costs M reads. The scan can never grow beyond that.

Polls use bounded batches, so the tool never holds more than one page of records at a time. Nothing loads a whole topic.

Safety: Assign, Don't Subscribe

The consumer setup is where a search tool can quietly cause damage. Three decisions were non-negotiable.

First, the search consumer uses assign, not subscribe. Subscribe joins a consumer group, bringing coordination and rebalances. A debugging tool has no business in any of that. Assign hands the consumer a fixed partition list and stays out of group coordination entirely.

Second, the consumer never commits offsets. Auto-commit is off, and the code never calls commit. The default config commits automatically, so this is the one that bites: a search tool that advances a production group's offsets is a horror story, and the fix is one config line.

Third, the scan cap doubles as a safety control: every search reads at most M records, a worst case SRE can read before approving.

The Search Loop

Here is the core in Java. The three constants are the whole design: matches to return, records to read in the worst case, records per poll. The values are illustrative; tune them to your topics.

Java
 
private static final int RESULT_CAP = 25;
private static final int SCAN_CAP = 50_000;
private static final int POLL_BATCH = 500;
 
public List<FoundMessage> search(String topic, String query, long startTimeMs) {
    Properties props = new Properties();
    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokers);
    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
    props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, String.valueOf(POLL_BATCH));
 
    List<FoundMessage> matches = new ArrayList<>();
    try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
        List<TopicPartition> partitions = new ArrayList<>();
        for (PartitionInfo p : consumer.partitionsFor(topic)) {
            partitions.add(new TopicPartition(topic, p.partition()));
        }
        consumer.assign(partitions);
 
        Map<TopicPartition, Long> times = new HashMap<>();
        for (TopicPartition tp : partitions) times.put(tp, startTimeMs);
        for (Map.Entry<TopicPartition, OffsetAndTimestamp> e
                : consumer.offsetsForTimes(times).entrySet()) {
            if (e.getValue() != null) consumer.seek(e.getKey(), e.getValue().offset());
            else consumer.seekToEnd(Collections.singletonList(e.getKey()));
        }
 
        long scanned = 0;
        while (scanned < SCAN_CAP && matches.size() < RESULT_CAP) {
            ConsumerRecords<String, String> batch =
                    consumer.poll(Duration.ofMillis(500));
            if (batch.isEmpty()) break;
            for (ConsumerRecord<String, String> r : batch) {
                if (++scanned > SCAN_CAP) break;
                if (r.value() != null && r.value().contains(query)) {
                    matches.add(new FoundMessage(r.topic(), r.partition(),
                            r.offset(), r.timestamp(), r.value()));
                    if (matches.size() >= RESULT_CAP) break;
                }
            }
        }
    }
    return matches;
}


From the top: the config disables auto-commit and fixes the poll batch size. Assign takes a fixed partition list. offsetsForTimes turns the start time into a starting offset per partition; the null branch seeks to the end for partitions with nothing that new. The loop reads forward and stops at whichever cap hits first. Nothing commits, and try-with-resources closes the consumer.

Matching is a substring check on the record value. Tombstones carry nulls, so the null check is load-bearing.

Shipping It: A Container, Kubernetes, and the SRE Agreement

The app ships as a container image: the search service and its config, nothing else. It runs on Kubernetes as a small deployment with tight resource limits, since the result cap already bounds memory. One replica is plenty.

YAML
 
apiVersion: apps/v1
kind: Deployment
metadata:
  name: kafdrop-search
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kafdrop-search
  template:
    metadata:
      labels:
        app: kafdrop-search
    spec:
      containers:
      - name: search
        image: registry.example.com/kafdrop-search:1.0.0
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        env:
        - name: KAFKA_BROKERS
value: "kafka:9092"


The manifest is deliberately boring: one replica, tight memory limits, brokers passed as config.

The SRE conversation was short, because the design answered their questions first. The tool only reads: assign instead of subscribe, no offset commits, read-only Kafka credentials. Every search is bounded by the scan cap, so the worst case was a number everyone could read.

That agreement mattered more than the code. The caps were the trust.

A Debugging Routine That Works

First, search the topic for the message using something unique: an order ID, a request ID. Found it? Note the offset and timestamp.

Second, check consumer group lag for the downstream stage. If the group consumed past your message's offset, the stage read it, and the problem is in the consumer's logic. If lag is stuck or growing, the consumer is behind, crashed, or wedged on a poison record.

Third, look at the consumer: logs around the message's timestamp, error handling, the dead-letter path.

The order matters. It separates "the message never arrived" from "the message arrived and something went wrong." Before, step one was guesswork. Now it takes minutes.

What We Kept

Kafka gives you ordering and durability, not search. Bolting search onto an append-only log means accepting the scan. Make it bounded and boring: seek to a timestamp, read forward in small batches, stop at the first cap.

Keep the three decisions that made it safe. Assign instead of subscribe, so the tool never joins a consumer group. Never commit offsets, so reading changes nothing. A scan cap that turns every search into a known quantity of broker work. Get those right, and SRE will let you run it against production. Get them wrong, and you have built a very efficient way to cause your next incident.

After the hackathon, the team kept the tool for one job: finding where a message stopped. A search, a lag check, and a look at the consumer, done in minutes. Diagnosis time during incidents fell.

kafka Java (programming language)

Opinions expressed by DZone contributors are their own.

Related

  • How AI Is Rewriting Full-Stack Java Systems: Practical Patterns with Spring Boot, Kafka and WebSockets
  • From APIs to Event-Driven Systems: Modern Java Backend Design
  • Java Consumer and Producer Messages Between Kafka Server [Video Tutorials]
  • How To Install CMAK, Apache Kafka, Java 18, and Java 19 [Video Tutorials]

Partner Resources

×

Comments

Loading comments

The comments didn't load

Please try again in a moment.

Likes

Loading

The next likes didn't load. Please try again.

The likes didn't load

Please try again in a moment.

See who liked this

Log in or join DZone for free to see who liked this article.

Log in Join for free
  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook