{"id":6161,"date":"2026-10-24T16:33:59","date_gmt":"2026-10-24T16:33:59","guid":{"rendered":"https:\/\/dalelane.co.uk\/blog\/?p=6161"},"modified":"2026-10-24T16:33:59","modified_gmt":"2026-10-24T16:33:59","slug":"how-flinks-kafka-connector-commits-offsets-to-kafka","status":"publish","type":"post","link":"https:\/\/dalelane.co.uk\/blog\/?p=6161","title":{"rendered":"How Flink&#8217;s Kafka connector commits offsets to Kafka"},"content":{"rendered":"<p><strong>An unnecessarily deep dive into how Flink commits Kafka consumer offsets.<\/strong> <\/p>\n<p>A <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/293\">pull request of mine<\/a> was merged into <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\">this week<\/a>. A ridiculous number of sketches and diagrams went into the commits to get the idea straight in my head, so in this post I tidied them up a bit to share. <\/p>\n<h2>How consumer offsets are used by Flink<\/h2>\n<p>If you run a Flink job that reads from Kafka, you&#8217;ve probably seen its consumer group in your Kafka tooling like any other Kafka consumer: committed offsets for each partition, and a lag figure showing how far behind the log end the job is. <\/p>\n<p>Flink doesn&#8217;t need to depend on those committed offsets for its own work. The unit of work for a Flink source is a &#8220;split&#8221;. For Kafka, a split is a partition and the offset to read from (and for a bounded split, the offset to stop reading at). This is stored in Flink state. Each source reader holds a split for every partition it reads, and each split records the next Kafka offset to read. <\/p>\n<p>When Flink takes a checkpoint, each split is saved in the checkpoint, and contains those offsets. If the job fails, or you restart it from a savepoint, the source picks up from the offsets in the checkpoint. That&#8217;s how Flink can offer exactly-once processing: the offsets and the rest of the job&#8217;s state are captured together, in the same consistent snapshot.<\/p>\n<p>Typically, the Kafka source reader commits the checkpointed offsets to Kafka once a checkpoint has completed. Although that is not necessary for the Flink job, it&#8217;s useful for lag dashboards and alerting, in the same way as any other Kafka consumer, and helps to <a href=\"https:\/\/dalelane.co.uk\/blog\/?p=5906\">understand how the job is doing<\/a>. <\/p>\n<h2>Lag that wasn&#8217;t going away<\/h2>\n<p><a href=\"https:\/\/issues.apache.org\/jira\/browse\/FLINK-33484\">FLINK-33484<\/a> reported a problem with a Flink job consuming a transactional topic with <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">isolation.level=read_committed<\/code>. The job had read everything on the topic, but the reported lag according to Kafka was stuck at 1, not 0. <\/p>\n<p>A plain Kafka Java consumer reading the same data from the same topic reported no lag. Flink wasn&#8217;t committing the right offset.<\/p>\n<p>Every time a record is handed on to the rest of the job, <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaRecordEmitter.java#L54\">the split&#8217;s position was updated<\/a>:<\/p>\n<pre style=\"white-space: pre !important; overflow: scroll; background-color: #FFFFC0; color: #770000; padding: 6px; max-height: 350px; border: thin #808080 solid; font-weight: 800; \">splitState.setCurrentOffset(consumerRecord.offset() + 1);<\/pre>\n<p>The next offset to read is the one after the last record. <\/p>\n<p>That&#8217;s the right thing to do for a non-transactional, uncompacted topic, where every offset holds a record. <\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2a-1.jpg\"\/><\/p>\n<p>It&#8217;s not always the right thing to do.<\/p>\n<p>When a Kafka producer commits or aborts a transaction, the broker writes a transaction marker into each partition the transaction touched. That marker takes up an offset in the log like any other entry.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2a-2.jpg\"\/><\/p>\n<p>This is handled by the Kafka consumer. Transaction markers aren&#8217;t given to the application. <\/p>\n<p>And if the application only asks for committed records (<code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">read_committed<\/code>), records from aborted transactions are skipped in the same way. The consumer reads past them, and its position moves forward, but no record comes out for them.<\/p>\n<p>Imagine a Kafka producer writes a single record in a transaction and commits it:<\/p>\n<table border=1 cellpadding=10>\n<tr>\n<td>offset 0<\/td>\n<td>record<\/td>\n<td>delivered to Flink<\/td>\n<\/tr>\n<tr>\n<td>offset 1<\/td>\n<td>commit marker<\/td>\n<td>read by the consumer<br \/>not delivered to Flink<\/td>\n<\/tr>\n<\/table>\n<p>The topic partition&#8217;s log end offset (and last stable offset) is <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">2<\/code> because that&#8217;s where the next entry will be written.<\/p>\n<p>Flink emitted the record at offset 0, and set the split&#8217;s current offset to 1 (<em>because it added 1 to the offset it&#8217;s emitted<\/em>).<\/p>\n<p>The Kafka consumer has actually read past the transaction marker, so its position is 2.<\/p>\n<p>But at the next checkpoint, Flink committed an offset of <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">1<\/code>. <\/p>\n<p>Any Kafka tooling set up to monitor Kafka consumer progress compared that with a log end of 2, and <strong>reported a lag of 1<\/strong>.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-1a.jpg\"\/><\/p>\n<p>On a busy topic, you&#8217;d barely notice. The next record arrives, gets emitted, and the committed offset jumps over the marker. The committed offset was always slightly behind, but only until new data arrives.<\/p>\n<p>On a quiet or bursty topic, &#8220;until new data arrives&#8221; can be a long time. The job was fully caught up, but every checkpoint committed the same stale offset, and Kafka monitoring reported a lag that didn&#8217;t exist. <\/p>\n<p>That was <a href=\"https:\/\/issues.apache.org\/jira\/browse\/FLINK-33484\">FLINK-33484<\/a> that I picked up a little while ago.<\/p>\n<p><!--more--><\/p>\n<h2>My first fix<\/h2>\n<p>How many &#8220;invisible&#8221; entries are there after a record, that Flink doesn&#8217;t see? <\/p>\n<p>For a plain topic with no transactions, maybe none.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2a-1.jpg\"\/><\/p>\n<p>For a topic with a single transaction, maybe one.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2a-2.jpg\"\/><\/p>\n<p>For a topic where a producer has aborted its most recent transaction, maybe four. <\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2a-3.jpg\"\/><\/p>\n<p>For a topic where multiple transactional producers are sending events at the same time, maybe two.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2a-4.jpg\"\/><\/p>\n<p>You get the idea. <\/p>\n<p>The point is, Flink&#8217;s <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaRecordEmitter.java\">KafkaRecordEmitter<\/a> that was <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaRecordEmitter.java#L54\">adding 1<\/a> can&#8217;t actually know how many invisible entries follow a record. Only the Kafka consumer knows that.<\/p>\n<p>A Kafka consumer does know where it is, though. <\/p>\n<p><code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">consumer.position(tp)<\/code> <a href=\"https:\/\/javadoc.io\/doc\/org.apache.kafka\/kafka-clients\/latest\/org\/apache\/kafka\/clients\/consumer\/KafkaConsumer.html#position(org.apache.kafka.common.TopicPartition)\">returns the offset of the next entry<\/a> it will read, and that accounts for any markers and aborted records it skipped. <\/p>\n<p>But I couldn&#8217;t just commit the consumer&#8217;s position. The consumer fetches batches of records from Kafka, ahead of what Flink has processed so far. Records can be sitting in the fetcher&#8217;s queue, waiting to be emitted. When Flink takes a checkpoint, the checkpoint doesn&#8217;t include the records that Flink hasn&#8217;t processed yet. The offset committed to Kafka shouldn&#8217;t either.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2b.jpg\"\/><\/p>\n<p>For <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/commit\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\">my first fix attempt<\/a> I only used the consumer&#8217;s position when I thought it was safe. My changes were in <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java\">KafkaPartitionSplitReader<\/a>, the class that owns the <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">KafkaConsumer<\/code> and runs on the split fetcher thread:<\/p>\n<ol>\n<li>At the <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L154\">end of each <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">fetch()<\/code><\/a>, I <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L306-L314\">recorded the offset of the last record handed to the reader for each partition<\/a>.<\/li>\n<li><a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L275-L279\">When a checkpoint completes<\/a> and the offsets are about to be committed, <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">reconcileOffsetsToCommit<\/code> <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L325\">checks each partition<\/a>. If <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L334-L340\">the checkpointed offset is exactly the last fetched offset + 1<\/a>, then the checkpoint includes every record the consumer has delivered. Anything between that and the consumer&#8217;s position must be entries that were never going to be delivered. In that case, it&#8217;s safe to <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L339-L348\">advance the offset to the consumer position<\/a>.<\/li>\n<\/ol>\n<p>That <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">lastFetchedOffset + 1<\/code> check was the main idea, to stop the committed offset skipping over records that have been fetched but not yet checkpointed.<\/p>\n<blockquote><p>if checkpoint offset == last fetched offset + 1<br \/>\nthen commit the consumer position<\/p><\/blockquote>\n<p>If we&#8217;re caught up&#8230;<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2c-1.jpg\"\/><\/p>\n<p><code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">1 == 0 + 1<\/code><\/p>\n<p>Nothing is waiting in the queue, so commit the consumer position: <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">2<\/code><\/p>\n<p>If there are still fetched records in the queue&#8230;<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-2c-2.jpg\"\/><\/p>\n<p><code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">2 != 2 + 1<\/code><\/p>\n<p>Record 2 isn&#8217;t in the checkpoint, so commit the checkpoint&#8217;s offset: <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">2<\/code><\/p>\n<p>It also <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L331-L333\">skips partitions no longer assigned to the consumer, and bounded splits that are still running<\/a>.<\/p>\n<p>This fix worked for <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/commit\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8#diff-912e48531349dc6ecdd8a8a2c25c5b65d4f3511bdd028a0e1cdb5605350759f3R276-R609\">a variety of scenarios that I came up with<\/a>, such as single-record transactions, multi-record transactions, committed transactions followed by aborted transactions, several transactions in a row, and interleaved transactions from two producers. <\/p>\n<p>Part of the motivation for this approach was in deciding what to commit as late as possible, at the time of committing the offset. At first, I did this by <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L339\">asking the consumer for its position<\/a> at commit time. But <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/9b4bc677dea4f56c94693d724480361d6c743537\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L133-L134\">recording it during the fetch<\/a> avoided an extra call to the consumer that could block. Following <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/293#discussion_r3842335257\">a review discussion on my approach<\/a>, I added another test scenario showing what would happen on <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/commit\/9e524f851a03b6c47fde001a637201d036dabe87#diff-912e48531349dc6ecdd8a8a2c25c5b65d4f3511bdd028a0e1cdb5605350759f3R460-R501\">an idle topic where no more records come after a transaction marker<\/a>. <\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-3.jpg\"\/><\/p>\n<table border=1 cellpadding=5>\n<tr>\n<th>poll<\/th>\n<th>records returned<\/th>\n<th>emitter runs<\/th>\n<th>split&#8217;s offset<\/th>\n<th>consumer position<\/th>\n<\/tr>\n<tr>\n<td>poll 1<\/td>\n<td>record 0<\/td>\n<td>yes<\/td>\n<td>1<\/td>\n<td>1<\/td>\n<\/tr>\n<tr>\n<td>poll 2<\/td>\n<td>record 1<\/td>\n<td>yes<\/td>\n<td>2<\/td>\n<td>2<\/td>\n<\/tr>\n<tr>\n<td>poll 3<\/td>\n<td>none (marker)<\/td>\n<td>no<\/td>\n<td>2<\/td>\n<td>3<\/td>\n<\/tr>\n<\/table>\n<p>If you decide what to commit as records are emitted, then because poll 3 has no records, the emitter is never called and the offset stays at 2. The commit will lag until more data arrives. <\/p>\n<p>By deciding when the commit happens, it can use the position from the most recent poll, whether or not it returned any records. The committed offset can be 3, with no lag.<\/p>\n<p>At that point, I thought I&#8217;d fixed it. But <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/293\">reviews<\/a> showed that I&#8217;d missed a few related bugs. <\/p>\n<h2>Flink and Kafka disagreeing about the committed offset<\/h2>\n<p>Flink has its own metrics, including <a href=\"https:\/\/nightlies.apache.org\/flink\/flink-docs-release-2.2\/docs\/connectors\/datastream\/kafka\/#monitoring\">metrics from the Kafka connector<\/a>. That has a <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">committedOffset<\/code> metric with the <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/2d8ea909127312d6b6404e7f9f18979faf9a9ed5\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/metrics\/KafkaSourceReaderMetrics.java#L76\">last successfully committed offset to Kafka<\/a> for each partition. <\/p>\n<p>A reviewer pointed out that <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/293#discussion_r3882403570\">my fix made Flink contradict itself<\/a>. The commit callback <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaSourceReader.java#L148\">ignored the offsets passed to it<\/a>, and <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaSourceReader.java#L164-L167\">recorded the offsets that had been requested<\/a> instead. That had always been fine, because the two were always the same. Once my split reader started <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L278\">changing the offsets on their way to Kafka<\/a>, they weren&#8217;t the same. Kafka would say the committed offset was 2 while Flink&#8217;s own metric said 1.<\/p>\n<p>I had another go, and updated the fix so the <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/commit\/14b8282c6415caf6ec62ea2859c2d0c81a1ac7d0\">callback records the offsets that were actually committed<\/a>, so Flink&#8217;s metrics and Kafka committed offsets now agree.<\/p>\n<h2>Partitions that never deliver a record<\/h2>\n<p>A reviewer <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/293#discussion_r3882469868\">pointed out another gap<\/a>. My reconcile check <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/15fe5e2d9e04e5967c700ad5c146fb97cdc626d8\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L334-L335\">depended on a last-fetched offset<\/a>. But what about partitions where nothing has ever been delivered?<\/p>\n<p>For example, a partition holding only an aborted transaction (aborted records at offsets 0 to 5, the abort marker at 6), read by a split whose starting offset is 0, such as a job started with <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">OffsetsInitializer.latest()<\/code> before anything was written to the partition:<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-5.jpg\"\/><\/p>\n<p>In this case, the Kafka source reader is fully caught up on the topic, but in every checkpoint Flink commits offset 0, so from Kafka&#8217;s perspective the consumer has a lag of 7 until a Kafka producer writes a committed record to move it on.<\/p>\n<p>That wasn&#8217;t one of the scenarios I&#8217;d come up with, but it was a worse version of the original bug than <a href=\"https:\/\/issues.apache.org\/jira\/browse\/FLINK-33484\">FLINK-33484<\/a> reported. <\/p>\n<p>The reader is fully caught up, but every checkpoint commits 0, and monitoring would report a lag of 7 until someone writes a committed record.<\/p>\n<p>I <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/commit\/e6a615f31b76264e6054dc8ddd67ae6686c4a954\">tweaked my check<\/a> so that <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/e6a615f31b76264e6054dc8ddd67ae6686c4a954\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L354-L355\">a partition with no fetched records can still be advanced to the consumer position<\/a>. If nothing has been fetched, nothing can be waiting un-checkpointed in the queue. <\/p>\n<h2>The placeholder offset<\/h2>\n<p>That last example assumed the split started at a real offset. A split using the default <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">OffsetsInitializer.earliest()<\/code> doesn&#8217;t have a real offset.<\/p>\n<p><code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">EARLIEST_OFFSET<\/code> is a <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/split\/KafkaPartitionSplit.java#L40-L41\">constant representing a split starting from the earliest offset<\/a> on a partition. It&#8217;s a placeholder, represented by <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">-2<\/code>. <\/p>\n<p>A review <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/293#discussion_r4155593985\">pointed out that I hadn&#8217;t thought of that<\/a>. The <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/split\/KafkaPartitionSplitState.java#L34\">split keeps that placeholder<\/a> until the <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaRecordEmitter.java#L54\">first record is emitted<\/a>. This meant Flink <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/e6a615f31b76264e6054dc8ddd67ae6686c4a954\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaSourceReader.java#L111-L117\">wouldn&#8217;t commit any offset<\/a> for this partition until a record is emitted. It was another type of &#8220;no record delivered&#8221; scenario.<\/p>\n<p>My <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/compare\/a94226763cf24fca816c095b503ec2679f417b7f..9164260b9a74ac537ecab97ea5308a8aa395e628#diff-5c32cb307371c71d9767f475f9bea58b2edff26ef26a317f7df33b005a62280bR137-R140\">next attempt<\/a> recognised these negative offset placeholders so that I could <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaSourceReader.java#L137-L141\">track those partitions-without-offsets<\/a> alongside the offsets. The split reader could then safely <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L495-L510\">commit the consumer position for them<\/a>, because it couldn&#8217;t have fetched any records that the checkpoint didn&#8217;t include. <\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-5.jpg\"\/><\/p>\n<p>Before:<\/p>\n<ul>\n<li><strong>Split state<\/strong>\n<ul>\n<li>offset = EARLIEST_OFFSET<br \/>placeholder (-2) until record is emitted<\/li>\n<\/ul>\n<\/li>\n<li><strong>snapshotState<\/strong>\n<ul>\n<li>negative offset<br \/>so the split is skipped<\/li>\n<\/ul>\n<\/li>\n<li>no records fetched &#8211; nothing to commit<\/li>\n<li><strong>Kafka<\/strong>\n<ul>\n<li>no offsets are committed for this partition<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<p>Now:<\/p>\n<ul>\n<li><strong>Split state<\/strong>\n<ul>\n<li>offset = EARLIEST_OFFSET<br \/>placeholder (-2) until record is emitted<\/li>\n<\/ul>\n<\/li>\n<li><strong>snapshotState<\/strong>\n<ul>\n<li>records the partition as a partition without an offset<\/li>\n<\/ul>\n<\/li>\n<li>no records fetched &#8211; commit the consumer position<\/li>\n<li><strong>Kafka<\/strong>\n<ul>\n<li>committed offset = 7<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<h2>Moving backwards<\/h2>\n<p><a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/293#discussion_r4155597664\">Another review comment<\/a> and another problem scenario spotted. <\/p>\n<p>Consider a topic with a transactional record at offset 0 with a commit marker at offset 1.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-7a-1.jpg\"\/><\/p>\n<p>The reader emits the record at offset 0<\/p>\n<p>The consumer moves to position 2, past the transaction marker.<\/p>\n<p>No offsets are committed to Kafka yet.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-7a-2a.jpg\"\/><\/p>\n<p>Checkpoint 1 snapshots offset 1.<\/p>\n<p>Reconciliation advances it.<\/p>\n<p>Offset <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">2<\/code> is committed to Kafka.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-7a-3b.jpg\"\/><\/p>\n<p>Checkpoint 2 snapshots offset 1 (again).<\/p>\n<p>The offset committed to Kafka is still <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">2<\/code><\/p>\n<p>The partition was idle, but before the checkpoint completes&#8230; <\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/scenario-7a-4a.jpg\"\/><\/p>\n<p>A new record arrives at offset 2.<\/p>\n<p>The record is fetched. <\/p>\n<p>The raw checkpointed offset, <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">1<\/code>, would be committed to Kafka<\/p>\n<p>The offset committed to Kafka went backwards, from 2 to 1. This is the sort of thing that if it happened for real you&#8217;d hope would trigger some alert somewhere because that&#8217;s a weird sign. <\/p>\n<p>The split reader now <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/9164260b9a74ac537ecab97ea5308a8aa395e628\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L375-L381\">stores the offset it last committed for each assigned partition<\/a>, so it can <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blame\/9164260b9a74ac537ecab97ea5308a8aa395e628\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L511-L518\">leave a partition out of a commit<\/a> rather than commit a lower offset. <\/p>\n<h2>How it works now<\/h2>\n<p>Those are the kinds of things that I changed along the way &#8211; there were others, but let me skip to the end and describe where I ended up.  <\/p>\n<p>This is <strong>what happens to a committed offset<\/strong> now, from fetch to Kafka.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/images.dalelane.co.uk\/2026-10-24-flinkkafka\/diagram.png\"\/><\/p>\n<h3>While fetching<\/h3>\n<p>On the split fetcher thread, <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">KafkaPartitionSplitReader.fetch()<\/code> <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L170\">polls the consumer<\/a>. <\/p>\n<p>For each partition it records:<\/p>\n<ul>\n<li><code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">lastKnownPositions<\/code> : <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L185-L189\">the consumer&#8217;s position at the end of the fetch<\/a>, which accounts for markers and aborted records it has read past<\/li>\n<li><code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">lastFetchedOffsets<\/code> : <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L433-L441\">offset of the last record it handed to the reader<\/a><\/li>\n<\/ul>\n<p><code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">KafkaRecordEmitter<\/code> sets each split&#8217;s current offset <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaRecordEmitter.java#L54\">to the offset after the record it emits<\/a>.<\/p>\n<h3>When Flink takes a checkpoint<\/h3>\n<p><code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">KafkaSourceReader.snapshotState<\/code> <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaSourceReader.java#L117-L147\">collects each split&#8217;s current offset to commit<\/a>. <\/p>\n<p>Splits that are still at a starting placeholder such as <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">EARLIEST_OFFSET<\/code> have no real offset yet, so their partitions are now recorded separately for that checkpoint.<\/p>\n<h3>When a checkpoint completes<\/h3>\n<p>The reader <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/fetcher\/KafkaSourceFetcherManager.java#L101-L121\">enqueues a commit task on the split fetcher<\/a>, with the checkpoint&#8217;s offsets and its partitions with no offset. <\/p>\n<p>The commit task runs on the fetcher thread (the only thread that touches the consumer) and <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L363-L383\">reconciles them before committing<\/a>:<\/p>\n<ul>\n<li>For a partition with a checkpointed offset, if the checkpoint covers every record fetched so far (or none have been fetched) and the consumer position is further ahead, the offset is advanced to the consumer position<\/li>\n<li>For a partition with no offset, the consumer position is committed, as long as no records have been fetched for it<\/li>\n<li>Partitions that are no longer assigned, and bounded splits that are still running, aren&#8217;t moved to the consumer position<\/li>\n<li>Any partition whose offset would be lower than the one last committed is left out, so while a partition stays assigned to a reader, its committed offset doesn&#8217;t move backwards. The offsets being committed are recorded as soon as the commit is sent.<\/li>\n<\/ul>\n<p>If nothing is left to commit, the <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L370-L374\">callback is called without a round trip to Kafka<\/a>.<\/p>\n<h3>When a bounded split finishes<\/h3>\n<p>It&#8217;s <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaSourceReader.java#L92-L115\">committed with the next checkpoint<\/a>. I changed this to use its stopping offset (when that&#8217;s a concrete offset) instead of the offset after its last emitted record, unless the split never left its starting placeholder.<\/p>\n<h3>When the Kafka commit completes<\/h3>\n<p>The <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaSourceReader.java#L175-L209\">callback records Flink&#8217;s <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">committedOffset<\/code> metric<\/a> from the offsets Kafka actually received, so Flink&#8217;s metrics and Kafka&#8217;s tooling agree.<\/p>\n<h3>If partitions are unassigned<\/h3>\n<p>Their <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/blob\/8ddcc0901850bb69baaf4bf6801632947985cb7c\/flink-connector-kafka\/src\/main\/java\/org\/apache\/flink\/connector\/kafka\/source\/reader\/KafkaPartitionSplitReader.java#L662-L674\">tracking state is removed<\/a>.<\/p>\n<h3>Phew<\/h3>\n<p>None of my changes altered Flink&#8217;s processing guarantees. I didn&#8217;t change what goes into a checkpoint. This has all been about the offsets committed to Kafka (and Flink&#8217;s <code style=\"background-color: #FFFFC0; color: #770000; padding: 4px; font-weight: 600; font-size: 0.9em;\">committedOffset<\/code> metric so they are consistent). <\/p>\n<p>When the checkpoint covers everything the consumer has delivered, the committed offsets for unbounded splits now match the consumer&#8217;s position as of its most recent poll. <\/p>\n<p>I hope.<\/p>\n<h2>Summary<\/h2>\n<p>I thought I was just picking up a simple off-by-one fix for Flink jobs on transactional topics. I think I even described this as &#8220;a quick fix&#8221; when I started. <\/p>\n<p>A series of reviews raised questions that were each a variation on the same theme: what if the partition has delivered nothing at all? What if the split hasn&#8217;t got a real offset yet? What if a record arrives between the checkpoint and the commit? What if the split is bounded? <\/p>\n<p>The idea at the core of the fix didn&#8217;t change much from my first commit, but pretty much everything around it did. \ud83d\ude42 <\/p>\n<h3>This isn&#8217;t finished!<\/h3>\n<p>I&#8217;m not claiming that committed offsets are now right in every case. My <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/293\">pull request<\/a> was about which offset gets committed when a commit happens. It doesn&#8217;t change what triggers a commit, or what happens if a commit is never triggered.<\/p>\n<p>There are other aspects to consider. For example, <a href=\"https:\/\/issues.apache.org\/jira\/browse\/FLINK-36532\">FLINK-36532<\/a> is looking at stopping a job with a savepoint. The savepoint holds the source&#8217;s offsets, but they never reach Kafka. The <a href=\"https:\/\/github.com\/apache\/flink-connector-kafka\/pull\/333\">pull request opened for it<\/a> explains that the cause is the asynchronous design described above. When a checkpoint completes, the commit is queued as a task on the split fetcher. Stop-with-savepoint closes the reader straight after the savepoint completes, and the fetcher shuts down without running the queued task. The consumer group is left at an earlier checkpoint&#8217;s offsets, or with no committed offsets at all if no earlier commit ran.<\/p>\n<p>The committed offset is a side channel. It&#8217;s best effort, it&#8217;s never allowed to fail the job, and Flink&#8217;s processing guarantees don&#8217;t cover it. This means no single piece of code is responsible for getting it right. Each step in a reader&#8217;s life (a checkpoint completing, a split finishing, a reader closing, a job restarting) decides for itself what reaches Kafka. I&#8217;ve described a few of those steps here. And I&#8217;ve shown how easy it was for me to miss steps! <\/p>\n<h3>Thanks<\/h3>\n<p>Huge thanks to <a href=\"https:\/\/github.com\/Savonitar\">Aleksandr<\/a>, <a href=\"https:\/\/github.com\/MartijnVisser\">Martijn<\/a>, <a href=\"https:\/\/github.com\/Efrat19\">Efrat<\/a>, <a href=\"https:\/\/github.com\/AHeise\">Arvid<\/a> for their kind patience with me on this pull request &#8211; it was their reviews that found most of what I&#8217;ve written about here, and I learned a lot about the internals of Flink&#8217;s Kafka connector from the process.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>An unnecessarily deep dive into how Flink commits Kafka consumer offsets. A pull request of mine was merged into this week. A ridiculous number of sketches and diagrams went into the commits to get the idea straight in my head, so in this post I tidied them up a bit to share. How consumer offsets [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[615,593,610,584],"class_list":["post-6161","post","type-post","status-publish","format-standard","hentry","category-code","tag-apacheflink","tag-apachekafka","tag-flink","tag-kafka"],"_links":{"self":[{"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/posts\/6161","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=6161"}],"version-history":[{"count":17,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/posts\/6161\/revisions"}],"predecessor-version":[{"id":6178,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/posts\/6161\/revisions\/6178"}],"wp:attachment":[{"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=6161"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=6161"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=6161"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}