sh4.tech

Note 4 min read Kafka · ClickHouse · validation

1.8 million messages, 200 million rows: checking Kafka against ClickHouse

Our players send one Kafka message per upload, about 112 rows each. How I counted a day of messages from offsets alone, compared them with the rows ClickHouse really inserted, and got 13,912,374 against 13,912,522.

When it happened
Published
Measured
, production, read-only: Kafka offsets and ClickHouse system.part_log
Stack
Kafka (3 brokers, KRaft), confluent-kafka for Python, ClickHouse, Apache NiFi
Also in
Português

One message, a hundred rows

Our screens send proof-of-play in batches. Every upload from a player becomes one Kafka message with all the lines of that upload, separated by newlines. In a small sample I read on October 5, the median message of the main audit topic had 12.7 KB and 112 lines. That is why about 200 million rows a day travel in less than 2 million messages.

We designed this pipeline together in the team; I prototyped and tested the approach, and a teammate wrote and shipped the final consumer. The check in this note is mine. I wanted two answers that the dashboards didn't give. How many rows does a message really carry? And does what goes into Kafka come out in ClickHouse, row by row?

Counting messages without consuming

Offsets already have the answer. Ask for the offset of the first message after "24 hours ago", ask for the current end of each partition, and subtract. It reads metadata only, no message, no commit, so it doesn't disturb the real consumers:

messages in the last 24 h, from offsets
import time
from confluent_kafka import Consumer, TopicPartition

c = Consumer({
    "bootstrap.servers": BROKERS,
    "group.id": "readonly-count-tmp",     # throwaway, never subscribes
    "enable.auto.commit": False,
})

def messages_last_24h(topic: str) -> int:
    parts = c.list_topics(topic, timeout=10).topics[topic].partitions
    since = int((time.time() - 86_400) * 1000)
    start = c.offsets_for_times([TopicPartition(topic, p, since) for p in parts], timeout=10)
    total = 0
    for tp in start:
        _low, high = c.get_watermark_offsets(TopicPartition(topic, tp.partition), timeout=10)
        total += high - (tp.offset if tp.offset >= 0 else high)   # -1 = nothing newer
    return total

To see the size of a message I read one message per partition, 19 in total, with assign() in a throwaway group and no commit, and I only measured bytes and counted newlines. Nothing of the content was printed or saved. It's a small sample, so the 112 is an estimate, and I say it like that.

Counting rows on the other side

On the ClickHouse side count() on the table is the wrong tool. Our audit tables are ReplacingMergeTree, so count() changes when parts merge, and on a table with a TTL old rows also leave. What I want is what was inserted, and system.part_log records each new part with its row count at the moment of the insert:

rows inserted in the last 24 h
SELECT table, sum(rows) AS rows_inserted
FROM system.part_log
WHERE event_type = 'NewPart'               -- inserts only; merges are MergeParts
  AND database = 'audit'
  AND table LIKE 'play_log%'
  AND event_time >= now() - INTERVAL 24 HOUR
GROUP BY table;

The numbers

For telemetry, where one message is one row, the comparison is direct: 13,912,374 messages in the presence topics against 13,912,522 rows in the ClickHouse table, in the same 24 hours, from 13,835 screens. A difference of 148 rows, 0.001%. My reading is that the two windows can't start in the exact same millisecond (I think that's all it is), and I didn't chase it further.

Proof-of-play had 1,782,599 messages in the main topic in 24 hours, and part_log showed 193,924,874 rows inserted in the main play tables plus 5,380,579 in the smaller environment, about 199.3 million. Multiply 1.78 million messages by ~112 rows and you land close to that, which is as much as a sample of six messages can tell.

All the main topics together had about 22.5 million messages that day, and that one is a floor, since retention cuts part of the shortest topics. Retention is set per type of data: 3 hours for telemetry that only passes through, 3 days for proof-of-play. The 3 days matter. Once the importer stopped for about 39 hours and the messages just waited in the topic until it came back.

Why I check this way

A lag graph tells if consumers follow the offsets. Offsets against part_log tell if the data arrived, and in what quantity. I had used the same part_log idea in August to check a summary table against its source, and I keep going back to it because it is cheap, it reads metadata, and it gives a number that someone else can repeat.

If you take one thingCompare what entered the topic with what the database says it inserted, for the same window. Two independent counts that agree to 0.001% convince people faster than any dashboard.

Filed under the problem it solves

Luciano Serra
Luciano Serra Founder-engineer · co-founder & CTO, built a 15,000-screen network from zero, 2015–2024 · São Paulo, remote

I design production systems from zero and I take over the ones already running. Either way they have to hold up when the business grows. I co-founded a digital out-of-home company and built its technology for ten years; my partners raised the money and ran sales.

Your business is growing faster than your systems? Let's talk →