KafkaIQ

11.1 Interactive Queries & Testing

Module Overview

Goal: Master interactive queries and testing of Kafka Streams applications.

What You'll Learn:

  • Interactive queries (IQ) for querying state stores
  • Key-value queries
  • Range queries
  • Windowed queries
  • Distributed queries across instances
  • TopologyTestDriver for unit testing
  • Integration testing strategies

What's NOT Covered Here:

  • State store internals (covered in Module 6.1)
  • Windowing concepts (covered in Module 5.2)
  • Monitoring metrics (covered in Module 12.1)

Key Insight: Interactive queries expose the state of your Streams application as a queryable database — testing ensures correctness before production.


1. What are Interactive Queries?

Definition

Interactive Queries (IQ) = querying state stores in real-time

Purpose:
├─ Expose aggregated data (counts, sums, etc.)
├─ Serve as read-only query layer
├─ Avoid external database for simple lookups
└─ Low-latency reads from local state

NOT for:
├─ Writing to state (read-only)
├─ Ad-hoc analytics (use ksqlDB or external DB)
└─ Complex joins (limited query capabilities)

Use Cases

1. Real-time dashboards
   └─ Query aggregated metrics (counts, sums)

2. Enrichment lookups
   └─ Query KTable state for enrichment

3. Materialized views
   └─ Query processed/aggregated data

4. Health checks
   └─ Verify application state

2. Enabling Interactive Queries

Materialized State Store

// State store MUST be materialized for queries
KTable<String, Long> counts = stream
    .groupByKey()
    .count(Materialized.as("user-counts"));  // Named store

// Without Materialized.as(), store not queryable

Store Name

Store name = identifier for queries

Rules:
├─ Must be unique per topology
├─ Use descriptive names
└─ Used to locate store across instances

Example:
└─ "user-counts", "windowed-aggregates", "customer-profiles"

3. Querying Local State

Get Store Instance

KafkaStreams streams = new KafkaStreams(topology, props);
streams.start();

// Wait for streams to be ready
while (streams.state() != State.RUNNING) {
    Thread.sleep(100);
}

// Get read-only state store
ReadOnlyKeyValueStore<String, Long> store = 
    streams.store(StoreQueryParameters.fromNameAndType(
        "user-counts",                    // Store name
        QueryableStoreTypes.keyValueStore()  // Store type
    ));

Key-Value Query

// Query by key
Long count = store.get("user123");

if (count != null) {
    System.out.println("User123 count: " + count);
} else {
    System.out.println("User123 not found");
}

Range Query

// Query all keys
KeyValueIterator<String, Long> all = store.all();

while (all.hasNext()) {
    KeyValue<String, Long> kv = all.next();
    System.out.println(kv.key + " -> " + kv.value);
}
all.close();  // Always close iterator

// Query range of keys
KeyValueIterator<String, Long> range = store.range("user100", "user200");

while (range.hasNext()) {
    KeyValue<String, Long> kv = range.next();
    System.out.println(kv.key + " -> " + kv.value);
}
range.close();

Approximate Count

// Get approximate number of entries
long approxCount = store.approximateNumEntries();
System.out.println("Approximate entries: " + approxCount);

4. Querying Windowed State

Windowed Store

// Create windowed aggregation
TimeWindowedKStream<String, String> windowed = stream
    .groupByKey()
    .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)));

KTable<Windowed<String>, Long> counts = windowed.count(
    Materialized.as("windowed-counts")
);

Get Windowed Store

ReadOnlyWindowStore<String, Long> windowStore = 
    streams.store(StoreQueryParameters.fromNameAndType(
        "windowed-counts",
        QueryableStoreTypes.windowStore()
    ));

Query Specific Window

// Query specific key and time range
Instant now = Instant.now();
Instant from = now.minus(Duration.ofMinutes(10));
Instant to = now;

WindowStoreIterator<Long> results = windowStore.fetch(
    "user123",  // Key
    from,       // From time
    to          // To time
);

while (results.hasNext()) {
    KeyValue<Long, Long> kv = results.next();
    Instant windowStart = Instant.ofEpochMilli(kv.key);
    Long count = kv.value;
    System.out.println("Window " + windowStart + ": " + count);
}
results.close();

Query All Windows for Key

// Fetch all windows for a key (not time-bounded)
WindowStoreIterator<Long> allWindows = windowStore.fetch(
    "user123",
    Instant.EPOCH,
    Instant.now()
);

while (allWindows.hasNext()) {
    KeyValue<Long, Long> kv = allWindows.next();
    System.out.println("Timestamp: " + kv.key + ", Count: " + kv.value);
}
allWindows.close();

Query Multiple Keys

// Fetch all windows for key range
KeyValueIterator<Windowed<String>, Long> multiKey = windowStore.fetchAll(
    Instant.now().minus(Duration.ofHours(1)),
    Instant.now()
);

while (multiKey.hasNext()) {
    KeyValue<Windowed<String>, Long> kv = multiKey.next();
    System.out.println("Key: " + kv.key.key() + 
        ", Window: " + kv.key.window().start() + 
        ", Count: " + kv.value);
}
multiKey.close();

5. Distributed Queries

The Challenge

Problem:
├─ State partitioned across multiple instances
├─ Instance A has keys [a-m]
├─ Instance B has keys [n-z]
└─ Query for "user_xyz" may be on different instance

Solution:
├─ Metadata API: Find which instance has key
├─ HTTP/gRPC: Query remote instance
└─ Aggregate results

Metadata API

// Find which instance has a key
StreamsMetadata metadata = streams.metadataForKey(
    "user-counts",        // Store name
    "user123",            // Key
    Serdes.String().serializer()  // Key serializer
);

if (metadata != null) {
    System.out.println("Key is on host: " + metadata.host());
    System.out.println("Port: " + metadata.port());
    System.out.println("State store: " + metadata.stateStoreNames());
}

REST API for Remote Queries

// 1. Expose REST endpoint in each instance
@GetMapping("/counts/{key}")
public ResponseEntity<Long> getCount(@PathVariable String key) {
    // Check if key is local
    StreamsMetadata metadata = streams.metadataForKey(
        "user-counts",
        key,
        Serdes.String().serializer()
    );

    // If local, query locally
    if (metadata.host().equals(localHost)) {
        ReadOnlyKeyValueStore<String, Long> store = streams.store(...);
        Long count = store.get(key);
        return ResponseEntity.ok(count);
    } else {
        // Forward to remote instance
        String remoteUrl = "http://" + metadata.host() + ":" + metadata.port() + "/counts/" + key;
        RestTemplate restTemplate = new RestTemplate();
        Long remoteCount = restTemplate.getForObject(remoteUrl, Long.class);
        return ResponseEntity.ok(remoteCount);
    }
}

Query All Instances

// Get metadata for all instances
Collection<StreamsMetadata> allMetadata = streams.allMetadataForStore("user-counts");

List<Long> allCounts = new ArrayList<>();

for (StreamsMetadata metadata : allMetadata) {
    String url = "http://" + metadata.host() + ":" + metadata.port() + "/counts/all";
    RestTemplate restTemplate = new RestTemplate();
    List<Long> counts = restTemplate.getForObject(url, List.class);
    allCounts.addAll(counts);
}

// Aggregate results
long total = allCounts.stream().mapToLong(Long::longValue).sum();

6. Query Limitations

What You CAN Do

✅ Query by key
✅ Range queries
✅ Windowed queries (time-based)
✅ Iterate all keys
✅ Count approximate entries

What You CANNOT Do

❌ Complex queries (JOINs, GROUP BY, etc.)
❌ Secondary indexes
❌ Full-text search
❌ Aggregations beyond what's materialized
❌ Transactions (read-only)

For advanced queries: Use ksqlDB or external database

7. Testing with TopologyTestDriver

What is TopologyTestDriver?

TopologyTestDriver = lightweight, in-memory test harness

Purpose:
├─ Unit test Kafka Streams topologies
├─ No real Kafka cluster needed
├─ Fast, deterministic tests
└─ Test processing logic in isolation

NOT for:
├─ Integration testing (use real Kafka)
├─ Performance testing
└─ Multi-instance behavior

Basic Setup

@Test
public void testTopology() {
    // Create topology
    StreamsBuilder builder = new StreamsBuilder();
    KStream<String, String> input = builder.stream("input-topic");
    input.mapValues(v -> v.toUpperCase()).to("output-topic");
    Topology topology = builder.build();

    // Test properties
    Properties props = new Properties();
    props.put(StreamsConfig.APPLICATION_ID_CONFIG, "test-app");
    props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "dummy:9092");

    // Create test driver
    TopologyTestDriver testDriver = new TopologyTestDriver(topology, props);

    // Test (see below)

    // Clean up
    testDriver.close();
}

Sending Test Input

// Create input topic
TestInputTopic<String, String> inputTopic = testDriver.createInputTopic(
    "input-topic",
    Serdes.String().serializer(),
    Serdes.String().serializer()
);

// Send records
inputTopic.pipeInput("key1", "hello");
inputTopic.pipeInput("key2", "world");

// With timestamp
inputTopic.pipeInput("key3", "test", Instant.now().toEpochMilli());

Reading Test Output

// Create output topic
TestOutputTopic<String, String> outputTopic = testDriver.createOutputTopic(
    "output-topic",
    Serdes.String().deserializer(),
    Serdes.String().deserializer()
);

// Read single record
KeyValue<String, String> result = outputTopic.readKeyValue();
assertEquals("key1", result.key);
assertEquals("HELLO", result.value);

// Read all records
List<KeyValue<String, String>> allResults = outputTopic.readKeyValuesToList();
assertEquals(2, allResults.size());

// Check if empty
assertTrue(outputTopic.isEmpty());

Testing Stateful Operations

@Test
public void testCount() {
    // Topology: count by key
    StreamsBuilder builder = new StreamsBuilder();
    KStream<String, String> input = builder.stream("input");
    input.groupByKey()
         .count(Materialized.as("counts"))
         .toStream()
         .to("output");

    TopologyTestDriver testDriver = new TopologyTestDriver(builder.build(), props);

    TestInputTopic<String, String> inputTopic = ...;
    TestOutputTopic<String, Long> outputTopic = ...;

    // Send records
    inputTopic.pipeInput("user1", "event1");
    inputTopic.pipeInput("user1", "event2");
    inputTopic.pipeInput("user2", "event1");

    // Verify output
    assertEquals(1L, outputTopic.readValue());  // user1 -> 1
    assertEquals(2L, outputTopic.readValue());  // user1 -> 2
    assertEquals(1L, outputTopic.readValue());  // user2 -> 1
}

Querying State Stores in Tests

@Test
public void testStateStore() {
    // Build topology
    StreamsBuilder builder = new StreamsBuilder();
    KStream<String, String> input = builder.stream("input");
    input.groupByKey().count(Materialized.as("counts"));

    TopologyTestDriver testDriver = new TopologyTestDriver(builder.build(), props);

    // Send records
    TestInputTopic<String, String> inputTopic = ...;
    inputTopic.pipeInput("user1", "event");

    // Query state store
    KeyValueStore<String, Long> store = testDriver.getKeyValueStore("counts");
    Long count = store.get("user1");
    assertEquals(1L, count);
}

Testing Time-Based Operations

@Test
public void testWindowing() {
    // Topology: 5-minute tumbling window
    StreamsBuilder builder = new StreamsBuilder();
    KStream<String, String> input = builder.stream("input");
    input.groupByKey()
         .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
         .count()
         .toStream()
         .to("output");

    TopologyTestDriver testDriver = new TopologyTestDriver(builder.build(), props);

    TestInputTopic<String, String> inputTopic = ...;

    // Send with specific timestamps
    Instant time1 = Instant.parse("2023-01-01T10:00:00Z");
    Instant time2 = Instant.parse("2023-01-01T10:02:00Z");  // Same window
    Instant time3 = Instant.parse("2023-01-01T10:06:00Z");  // Different window

    inputTopic.pipeInput("user1", "event1", time1.toEpochMilli());
    inputTopic.pipeInput("user1", "event2", time2.toEpochMilli());
    inputTopic.pipeInput("user1", "event3", time3.toEpochMilli());

    // Verify: 2 records in first window, 1 in second
    TestOutputTopic<Windowed<String>, Long> outputTopic = ...;
    assertEquals(1L, outputTopic.readValue());  // First event
    assertEquals(2L, outputTopic.readValue());  // Second event (same window)
    assertEquals(1L, outputTopic.readValue());  // Third event (new window)
}

8. Integration Testing

With Embedded Kafka

@Test
public void testIntegration() {
    // Use Testcontainers or embedded Kafka
    KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.4.0")
    );
    kafka.start();

    // Configure Kafka Streams
    Properties props = new Properties();
    props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
    props.put(StreamsConfig.APPLICATION_ID_CONFIG, "integration-test");

    // Build and start streams
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();

    // Produce input
    Producer<String, String> producer = new KafkaProducer<>(...);
    producer.send(new ProducerRecord<>("input-topic", "key", "value"));

    // Consume output
    Consumer<String, String> consumer = new KafkaConsumer<>(...);
    consumer.subscribe(Collections.singletonList("output-topic"));
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(10));

    // Verify
    assertEquals(1, records.count());

    // Clean up
    streams.close();
    kafka.stop();
}

9. Best Practices

1. Name Stores Explicitly

// Good: Named stores
.count(Materialized.as("user-counts"));

// Bad: Auto-generated names (not queryable reliably)
.count();

2. Wait for RUNNING State

// Wait before querying
while (streams.state() != State.RUNNING) {
    Thread.sleep(100);
}

// Or use state listener
streams.setStateListener((newState, oldState) -> {
    if (newState == State.RUNNING) {
        // Now safe to query
    }
});

3. Handle Null Results

// Always check for null
Long count = store.get("user123");
if (count == null) {
    count = 0L;  // Default value
}

4. Close Iterators

// Always close iterators
KeyValueIterator<String, Long> iterator = store.all();
try {
    while (iterator.hasNext()) {
        // Process
    }
} finally {
    iterator.close();  // Prevent resource leak
}

5. Use TopologyTestDriver for Unit Tests

// Fast, deterministic tests
// No Kafka cluster needed
TopologyTestDriver testDriver = new TopologyTestDriver(topology, props);
// Test logic
testDriver.close();

6. Test Edge Cases

// Test empty input
inputTopic.pipeInput(null, null);

// Test late-arriving data
inputTopic.pipeInput("key", "value", pastTimestamp);

// Test out-of-order data
inputTopic.pipeInput("key", "value2", time1);
inputTopic.pipeInput("key", "value1", time2);  // time2 > time1

10. Interactive Query Patterns

Pattern 1: Health Check Endpoint

@GetMapping("/health")
public ResponseEntity<String> health() {
    if (streams.state() == State.RUNNING) {
        return ResponseEntity.ok("Healthy");
    } else {
        return ResponseEntity.status(503).body("Not ready");
    }
}

Pattern 2: Aggregated Query

@GetMapping("/total-count")
public ResponseEntity<Long> totalCount() {
    ReadOnlyKeyValueStore<String, Long> store = streams.store(...);
    KeyValueIterator<String, Long> all = store.all();

    long total = 0;
    while (all.hasNext()) {
        total += all.next().value;
    }
    all.close();

    return ResponseEntity.ok(total);
}

Pattern 3: Top-N Query

@GetMapping("/top-users")
public ResponseEntity<List<UserCount>> topUsers(@RequestParam int n) {
    ReadOnlyKeyValueStore<String, Long> store = streams.store(...);
    KeyValueIterator<String, Long> all = store.all();

    PriorityQueue<UserCount> topN = new PriorityQueue<>(n, 
        Comparator.comparing(UserCount::count).reversed());

    while (all.hasNext()) {
        KeyValue<String, Long> kv = all.next();
        topN.add(new UserCount(kv.key, kv.value));
        if (topN.size() > n) {
            topN.poll();
        }
    }
    all.close();

    return ResponseEntity.ok(new ArrayList<>(topN));
}

Pattern 4: Caching Query Results

// Cache query results to reduce load
Cache<String, Long> cache = Caffeine.newBuilder()
    .expireAfterWrite(Duration.ofSeconds(10))
    .build();

@GetMapping("/counts/{key}")
public ResponseEntity<Long> getCount(@PathVariable String key) {
    return ResponseEntity.ok(cache.get(key, k -> {
        ReadOnlyKeyValueStore<String, Long> store = streams.store(...);
        return store.get(k);
    }));
}

11. Mental Model Summary

Interactive Queries

┌──────────────────────────────────────────────────┐
│  Kafka Streams Application                       │
│                                                  │
│  ┌────────────────┐      ┌────────────────┐     │
│  │  Processing    │─────→│  State Store   │     │
│  │  Topology      │      │  (Materialized)│     │
│  └────────────────┘      └────────┬───────┘     │
│                                   │             │
│                          ┌────────▼───────┐     │
│                          │ Interactive    │     │
│                          │ Query API      │     │
│                          └────────┬───────┘     │
└──────────────────────────────────┬──────────────┘
                                   │
                          ┌────────▼───────┐
                          │ REST/gRPC      │
                          │ Endpoint       │
                          └────────────────┘

State is queryable in real-time

Distributed Queries

┌─────────────────────────────────────────────┐
│  Client                                     │
│  └─ Query for "user123"                     │
└───────────────┬─────────────────────────────┘
                ↓
┌───────────────▼─────────────────────────────┐
│  Instance A (has keys a-m)                  │
│  ├─ Check metadata: "user123" on Instance B │
│  └─ Forward query to Instance B             │
└───────────────┬─────────────────────────────┘
                ↓
┌───────────────▼─────────────────────────────┐
│  Instance B (has keys n-z)                  │
│  ├─ Query local state store                 │
│  └─ Return result                           │
└─────────────────────────────────────────────┘

Metadata API finds correct instance

12. Questions You Should Be Able to Answer

After this module, you should be able to answer:

✅ What are interactive queries?
✅ How do I enable a state store for querying?
✅ How do I query a key-value store?
✅ How do I query windowed state?
✅ How do I query state across multiple instances?
✅ What is TopologyTestDriver?
✅ How do I unit test stateful operations?
✅ What are the limitations of interactive queries?


13. What's Next?

Now that you understand interactive queries and testing, the next module will cover:

  • Module 12.1: Monitoring & operating — metrics, dashboards, operational best practices
  • Module 13.1: Advanced configuration — performance tuning and tool selection

Summary

You learned:

  • ✅ Interactive queries expose state stores as queryable databases
  • ✅ Use Materialized.as("store-name") to enable querying
  • ✅ Query key-value stores with get(), range(), all()
  • ✅ Query windowed stores with fetch() and time ranges
  • ✅ Distributed queries use metadata API to find key location
  • ✅ TopologyTestDriver for fast, in-memory unit tests (no Kafka needed)
  • ✅ Test stateful operations by querying stores in tests
  • ✅ Always close iterators to prevent resource leaks
  • ✅ Wait for RUNNING state before querying
  • ✅ IQ limitations: No complex queries, JOINs, or secondary indexes

Next up: Module 12.1 — Monitoring & Operating — Learn how to monitor, debug, and operate Kafka Streams applications in production.