5.1 Stateful Operations
Module Overview
Goal: Explain why state exists in stream processing and how to use stateful operations.
What You'll Learn:
- What makes an operation "stateful"
- Aggregations: count, reduce, aggregate
- GroupBy and GroupByKey
- Why repartitioning happens (conceptually)
- When to use stateful operations
- State vs stateless trade-offs
What's NOT Covered Here:
- Window types (covered in Module 5.2)
- State store internals (covered in Module 6.1)
- Joins (covered in Module 8.1)
- Windowed aggregations (covered in Module 5.3)
Key Insight: Stateful operations require remembering information across multiple records to compute results.
1. What is Stateful Processing?
Definition
A stateful operation needs to remember information from previous records to compute results.
Stateful:
Record 1 → [Process + Store] → State updated
Record 2 → [Process + Store] → State updated (uses Record 1's info)
Record 3 → [Process + Store] → State updated (uses Record 1 + 2's info)
Each record depends on previous records
State maintained in memory/disk
Real-World Analogy: Coffee Shop Register
Imagine you're a barista at a coffee shop with a customer loyalty program:
Stateless Processing = Each Order Processed Independently
Customer Alice orders coffee → Charge $4.50 → Done
Customer Bob orders latte → Charge $5.00 → Done
Customer Alice orders muffin → Charge $3.00 → Done
Each transaction is independent.
You don't remember who ordered what before.
Stateful Processing = Tracking Customer Points
Customer Alice orders coffee ($4.50):
├─ Look up Alice's current points: 10
├─ Add 4 points (1 point per dollar)
└─ Update Alice's state: 14 points
Customer Bob orders latte ($5.00):
├─ Look up Bob's current points: 0
├─ Add 5 points
└─ Update Bob's state: 5 points
Customer Alice orders muffin ($3.00):
├─ Look up Alice's current points: 14
├─ Add 3 points
└─ Update Alice's state: 17 points
You MUST remember each customer's running points total.
Each new order updates their existing state.
The Key Difference:
- Stateless: Process order → Charge → Forget
- Stateful: Process order → Look up state → Update state → Store state
In Kafka Streams, state is stored in state stores (like a database for your streaming app), allowing you to remember information across records.
Spreadsheet Analogy
Think of state like a spreadsheet:
┌────────────┬────────────┬─────────────────┐
│ Customer │ Total │ Orders Count │
│ (Key) │ Spent │ │
├────────────┼────────────┼─────────────────┤
│ Alice │ $7.50 │ 2 │ ← State for Alice
│ Bob │ $5.00 │ 1 │ ← State for Bob
│ Charlie │ $12.00 │ 3 │ ← State for Charlie
└────────────┴────────────┴─────────────────┘
When a new event arrives (Alice orders $3 coffee):
├─ Find Alice's row (key = Alice)
├─ Read current values: $7.50, 2 orders
├─ Update: $7.50 + $3 = $10.50, 2 + 1 = 3
└─ Write back to state store
Each key (customer) has its own row (state).
Stateful operations = reading and writing to this spreadsheet.
Stateful vs Stateless
STATELESS:
├─ filter(value > 100)
├─ mapValues(v -> v.toUpperCase())
└─ Each record processed independently
STATEFUL:
├─ count() → needs to remember previous count
├─ sum() → needs to remember running total
├─ join() → needs to keep records for matching
└─ Requires state storage
Why State is Needed
Question: "How many clicks per user?"
Record 1: user=alice, event=click
Record 2: user=bob, event=click
Record 3: user=alice, event=click
Answer: alice=2, bob=1
To compute this, we must REMEMBER:
├─ How many clicks alice had before (1)
├─ How many clicks bob had before (1)
└─ Update counts as new records arrive
This requires STATE
2. The Aggregation Pattern
Core Concept
Aggregation follows this pattern:
1. Group records by key
2. Apply aggregation function
3. Maintain state (running result)
4. Emit updated result
Example: Word Count
Input Stream:
["hello", "world", "hello", "kafka", "hello"]
Grouped by word:
├─ "hello" → ["hello", "hello", "hello"]
├─ "world" → ["world"]
└─ "kafka" → ["kafka"]
Count per group:
├─ "hello" → 3
├─ "world" → 1
└─ "kafka" → 1
Result: KTable with current counts
3. GroupBy and GroupByKey
groupByKey: Group with Existing Key
KStream<String, Long> input = builder.stream("user-clicks");
// Group by existing key (user ID)
KGroupedStream<String, Long> grouped = input.groupByKey();
Use when: Key already represents what you want to group by.
groupBy: Group with New Key
KStream<String, Order> orders = builder.stream("orders");
// Group by product (not original key)
KGroupedStream<String, Order> byProduct = orders.groupBy(
(orderId, order) -> order.getProductId(),
Grouped.with(Serdes.String(), orderSerde)
);
Use when: Need to group by a field other than the key.
groupBy Triggers Repartitioning
IMPORTANT:
groupBy creates a NEW key
└─ Data must be repartitioned by new key
└─ Writes to internal repartition topic
└─ Reads back in new partition order
└─ Expensive operation
groupByKey keeps existing key
└─ No repartitioning needed
└─ Faster
Prefer groupByKey when possible
Repartitioning covered in detail in Module 7.1.
4. count: Count Records per Key
Syntax
KTable<K, Long> count()
What it does: Count records per key.
Example: Count Events per User
KStream<String, ClickEvent> clicks = builder.stream("clicks");
KTable<String, Long> clickCounts = clicks
.groupByKey()
.count();
// Input:
// user1 → click
// user2 → click
// user1 → click
// user1 → click
// Output (KTable):
// user1 → 3
// user2 → 1
count Creates a KTable
count() returns KTable<K, Long>
Why KTable?
├─ Represents current count per key
├─ Updates as new records arrive
└─ Latest count always available
user1: 0 → 1 → 2 → 3 (as events arrive)
Writing Count Results
// Write to changelog topic
clickCounts.toStream().to("click-counts");
// Or materialize for queries (Module 11.1)
clickCounts.toStream().to("click-counts",
Produced.with(Serdes.String(), Serdes.Long()));
5. reduce: Custom Aggregation Logic
Syntax
KTable<K, V> reduce(Reducer<V> reducer)
What it does: Combine values using custom logic.
Example: Sum Values
KStream<String, Integer> numbers = builder.stream("numbers");
KTable<String, Integer> sums = numbers
.groupByKey()
.reduce((value1, value2) -> value1 + value2);
// Input:
// keyA → 5
// keyA → 3
// keyA → 7
// Output (KTable):
// keyA → 15 (5 + 3 + 7)
Example: Keep Latest Value
KTable<String, Order> latestOrder = orders
.groupByKey()
.reduce((oldOrder, newOrder) -> newOrder); // Keep new
// Equivalent to building a KTable directly
Example: Concatenate Strings
KTable<String, String> concatenated = stream
.groupByKey()
.reduce((str1, str2) -> str1 + "," + str2);
// Input:
// user1 → "a"
// user1 → "b"
// user1 → "c"
// Output:
// user1 → "a,b,c"
6. aggregate: Most Flexible Aggregation
Syntax
KTable<K, VR> aggregate(
Initializer<VR> initializer,
Aggregator<K, V, VR> aggregator
)
What it does: Accumulate values into custom result type.
Example: Count with Custom Type
KStream<String, Order> orders = builder.stream("orders");
KTable<String, OrderStats> stats = orders
.groupByKey()
.aggregate(
// Initializer: create initial value
() -> new OrderStats(0, 0.0),
// Aggregator: update stats with each order
(userId, order, stats) -> {
stats.incrementCount();
stats.addTotal(order.getAmount());
return stats;
},
// Materialized: specify serdes and store name
Materialized.with(Serdes.String(), orderStatsSerde)
);
// Input:
// user1 → Order(amount: 100)
// user1 → Order(amount: 50)
// Output:
// user1 → OrderStats(count: 2, total: 150)
Example: Collect Values into List
KTable<String, List<String>> collected = stream
.groupByKey()
.aggregate(
ArrayList::new, // Initial empty list
(key, value, list) -> {
list.add(value);
return list;
},
Materialized.with(Serdes.String(), listSerde)
);
// Input:
// user1 → "event1"
// user1 → "event2"
// user1 → "event3"
// Output:
// user1 → ["event1", "event2", "event3"]
Why Use aggregate?
Use aggregate when:
✅ Result type differs from input type
✅ Need to maintain complex state
✅ Accumulating into collections
✅ Computing multiple metrics simultaneously
Use reduce when:
✅ Result type same as input type
✅ Simple combination logic
✅ Cleaner syntax
7. Materialized: Naming and Configuring State Stores
What is Materialized?
Controls how state is stored:
Materialized.<String, Long, KeyValueStore<Bytes, byte[]>>as("click-counts-store")
.withKeySerde(Serdes.String())
.withValueSerde(Serdes.Long())
.withCachingEnabled()
.withLoggingEnabled();
Covered in detail in Module 6.1. For now, basic usage:
KTable<String, Long> counts = stream
.groupByKey()
.count(Materialized.as("my-count-store"));
8. Common Aggregation Patterns
Pattern 1: Count Events per Key
KStream<String, Event> events = builder.stream("events");
KTable<String, Long> counts = events
.groupByKey()
.count();
Use case: Count clicks per user, requests per IP, etc.
Pattern 2: Sum Values per Key
KStream<String, Integer> sales = builder.stream("sales");
KTable<String, Integer> totalSales = sales
.groupByKey()
.reduce((sum1, sum2) -> sum1 + sum2);
Use case: Total sales per product, total bytes per connection.
Pattern 3: Complex Aggregation
KStream<String, Transaction> transactions = builder.stream("transactions");
KTable<String, AccountSummary> summary = transactions
.groupByKey()
.aggregate(
AccountSummary::new,
(accountId, txn, summary) -> {
summary.addTransaction(txn);
summary.updateBalance(txn.getAmount());
summary.recordTimestamp(txn.getTime());
return summary;
},
Materialized.with(Serdes.String(), accountSummarySerde)
);
Use case: Account summaries, session analytics.
Pattern 4: Group by Different Key
KStream<String, Order> orders = builder.stream("orders");
// Group by product instead of order ID
KTable<String, Long> ordersByProduct = orders
.groupBy((orderId, order) -> order.getProductId())
.count();
// Output: count of orders per product
Use case: Group by any field (not just key).
9. State Storage
Where is State Stored?
Aggregation state is stored in:
1. LOCAL STATE STORE (RocksDB or in-memory)
└─ On disk at each Streams instance
2. CHANGELOG TOPIC (Kafka)
└─ Backup in Kafka for fault tolerance
Both automatically managed by Kafka Streams
Covered in detail in Module 6.1.
State Store Implications
STATEFUL OPERATIONS:
1. Use Disk/Memory
└─ RocksDB stores state on disk
2. Slower than Stateless
└─ Read/write to state store
3. Require State Restore
└─ On restart, state rebuilt from changelog
4. Limit Scalability
└─ State tied to partitions
But necessary for aggregations!
10. Performance Characteristics
Aggregation Overhead
Typical latency per record:
Stateless operation: < 1 ms
Stateful operation: 1-10 ms (depends on state size)
Throughput:
Stateless: 1M+ records/sec
Stateful: 100K-500K records/sec
Trade latency for capability
Optimization Tips
1. Use RocksDB (default)
└─ Handles large state efficiently
2. Enable caching
└─ Reduces changelog writes
3. Tune commit interval
└─ Balance durability vs performance
4. Use windowing for bounded state
└─ Covered in Module 5.2
11. Handling Updates and Deletes
Updates
// KTable automatically handles updates
KTable<String, Long> counts = stream.groupByKey().count();
// New record for existing key → updates count
// user1: 1 → 2 → 3 → 4 (as events arrive)
Deletes (Tombstones)
// Null values delete state
KStream<String, String> stream = builder.stream("input");
stream
.groupByKey()
.reduce((v1, v2) -> v2) // Latest value
.toStream()
.filter((k, v) -> v != null) // Filter out deletes
.to("output");
12. Common Pitfalls
Pitfall 1: Using groupBy Unnecessarily
// Bad (unnecessary repartition)
stream.groupBy((k, v) -> k).count()
// Good (no repartition)
stream.groupByKey().count()
Pitfall 2: Large State Per Key
// Bad: unbounded state growth
stream
.groupByKey()
.aggregate(
ArrayList::new,
(k, v, list) -> {
list.add(v); // List grows forever!
return list;
}
);
// Good: use windows (Module 5.2) or bounded collections
Pitfall 3: Wrong Serde for Aggregation Result
// Bad: default serde may not match result type
stream.groupByKey().count().to("output");
// Crashes if default value serde isn't Long
// Good: specify serde
stream.groupByKey()
.count()
.toStream()
.to("output", Produced.with(Serdes.String(), Serdes.Long()));
13. Testing Stateful Operations
Using TopologyTestDriver
// Test aggregation
TopologyTestDriver testDriver = new TopologyTestDriver(topology, props);
TestInputTopic<String, String> input =
testDriver.createInputTopic("input", stringSerializer, stringSerializer);
TestOutputTopic<String, Long> output =
testDriver.createOutputTopic("output", stringDeserializer, longDeserializer);
// Send test data
input.pipeInput("user1", "click");
input.pipeInput("user1", "click");
input.pipeInput("user2", "click");
// Verify results
assertEquals(2L, output.readKeyValue().value); // user1: 2
assertEquals(1L, output.readKeyValue().value); // user2: 1
Testing covered in Module 11.1.
14. Mental Model Summary
Stateful Operations
AGGREGATIONS:
count()
└─ Count records per key
└─ Returns KTable<K, Long>
reduce(reducer)
└─ Combine values (same type)
└─ Returns KTable<K, V>
aggregate(init, aggregator)
└─ Accumulate into custom type
└─ Returns KTable<K, VR>
When to Use Each
Use count() when:
✅ Simple counting
✅ No custom logic needed
Use reduce() when:
✅ Result type = input type
✅ Simple combination (sum, concat, etc.)
Use aggregate() when:
✅ Result type ≠ input type
✅ Complex accumulation logic
✅ Maintaining multiple metrics
The State Lifecycle
1. Group by key
└─ Determine which records belong together
2. Initialize state
└─ Create initial value per key
3. Process each record
└─ Update state with aggregation logic
4. Persist state
└─ Write to local store + changelog
5. Emit results
└─ KTable represents current state
15. Questions You Should Be Able to Answer
After this module, you should be able to answer:
✅ What makes an operation "stateful"?
✅ What's the difference between groupByKey() and groupBy()?
✅ When should I use count(), reduce(), or aggregate()?
✅ What is a KGroupedStream?
✅ Why does groupBy() trigger repartitioning?
✅ Where is aggregation state stored?
✅ What are the performance implications of stateful operations?
16. What's Next?
Now that you understand stateful operations, the next modules will teach you time-based processing:
- Module 5.2: Windowing concepts — time-based grouping
- Module 5.3: Windowed aggregations — combining state with windows
- Module 6.1: State stores — how state is physically stored
- Module 8.1: Joins — another form of stateful processing
Summary
You learned:
- ✅ Stateful operations require maintaining state across multiple records
- ✅ groupByKey() groups by existing key, groupBy() groups by new key (triggers repartition)
- ✅ count() counts records per key
- ✅ reduce() combines values with custom logic (same type)
- ✅ aggregate() accumulates into custom result type
- ✅ State stored in local state store (RocksDB) + changelog topic (Kafka)
- ✅ Stateful operations are slower but necessary for aggregations
- ✅ Results are KTable (current state per key)
Next up: Module 5.2 — Windowing Concepts & Types — Learn how to bound state with time windows.