Skip to content

KAFKA-20845: Don't flush empty batch when appending large records in group coordinator - #22969

Open
squah-confluent wants to merge 6 commits into
apache:trunkfrom
confluentinc:squah-fix-empty-batch-flush-on-large-append
Open

KAFKA-20845: Don't flush empty batch when appending large records in group coordinator#22969
squah-confluent wants to merge 6 commits into
apache:trunkfrom
confluentinc:squah-fix-empty-batch-flush-on-large-append

Conversation

@squah-confluent

@squah-confluent squah-confluent commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

When appending records, we create a new batch if no batch exists.
KAFKA-19760 introduced a flush of any existing batch when appending
large records, to maximize our chances of compressing the records under
the max.message.bytes. When these two things happen in the same append
operation, we flush an empty batch.

Flushing an empty batch is written to fail and revert the coordinator
state. This would be harmless, except some group coordinator operations
update the coordinator state directly without replay. Upon appending
their records and triggering the empty batch flush, their state changes
are then reverted while their records are written. The group
coordinator's in-memory state diverges from the on-disk state and
subsequent writes for the group can be invalid for the on-disk state.
eg. we may have a consumer group downgraded to a classic group, followed
by consumer group records which is invalid.

Do not flush empty batches when appending large records.

Reviewers: nileshkumar3 nileshkumar3@gmail.com, Andrew Schofield
aschofield@confluent.io, Dongnuo Lyu dlyu@confluent.io, David Jacot
david.jacot@gmail.com

…group coordinator

When appending records, we create a new batch if no batch exists. We
also flush any existing batch when appending large records to maximize
our chances of compressing the records under the max.message.bytes.
When these two things happen in the same append operation, we flush an
empty batch.

Flushing an empty batch is written to fail and revert the coordinator
state. This would be harmless, except some group coordinator operations
update the coordinator state directly without replay. Upon appending
their records and triggering the empty batch flush, their state changes
are then reverted while their records are written. The group
coordinator's in-memory state diverges from the on-disk state and
subsequent writes for the group can be invalid for the on-disk state.
eg. we may have a consumer group downgraded to a classic group, followed
by consumer group records which is invalid.

Do not flush empty batches when appending large records.
@github-actions github-actions Bot added group-coordinator small Small PRs triage PRs from the community labels Jul 27, 2026
}

@Test
public void testLargeCompressibleRecordSucceeds() throws Exception {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add regression test for a large uncompressible record when the current batch was just allocated and is still empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% on board with this because that's a case where we have already run into a bug. In normal operation there should never be an empty batch lying around. I'm trying to keep KAFKA-20845 scoped to the happy path - the unhappy path is broken for non-replaying operations in a few ways*

* Off the top of my head, these include:

  • A non-replaying operation that throws an exception midway through will leave group coordinator state partially updated with no records written.
  • Same for an exception midway through append(), except we may leave an empty batch lying around. It's wrong to continue using that batch and commit it, which we may currently do (even before this PR).


if (!currentBatch.builder.hasRoomFor(estimatedSizeUpperBound)) {
if (!currentBatch.builder.hasRoomFor(estimatedSizeUpperBound) &&
currentBatch.builder.numRecords() > 0) {

@nileshkumar3 nileshkumar3 Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: consider reordering this condition to currentBatch.builder.numRecords() > 0 && !currentBatch.builder.hasRoomFor(estimatedSizeUpperBound)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! That check is cheaper so it makes sense to do it first.

@github-actions github-actions Bot removed the triage PRs from the community label Jul 29, 2026
@AndrewJSchofield
AndrewJSchofield requested a review from dajac July 31, 2026 08:24
@AndrewJSchofield

Copy link
Copy Markdown
Member

This merits backporting to 4.2 and 4.3 I think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make it on info level?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good idea. I think it should be warn level since it's supposed to be impossible unless we had a previous exception or bug in the append logic.

recordsToAppend
);

if (!currentBatch.builder.hasRoomFor(estimatedSizeUpperBound)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is the only place where downgrade could possibly cause flushCurrentBatch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether we should also change this. The assumption is kind of wrong as in-mem changes could have been made so reverting is not alway correct. Should we just free to batch if the batch is empty?

My concern is that we have many other paths calling flushCurrentBatch() so I am not sure whether gating the flush in append in enough to cover all the possible cases. It is good to have it for sure but it may not be enough.

@squah-confluent @dongnuo123 What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah i think it makes sense. The only concern might be that if we really have another path that can result in the same bug with flushing empty batch, directly freeing the batch might cause divergence between the memory and logs.

eg the scenario we wanted to fix with this if (currentBatch.builder.numRecords() == 0) { branch. If it's a downgrade and the in-mem state has been updated and serializeKey/serializeValue throws, the exception is not caught, no record is written so we could end up in an empty batch with in-mem change. In this case we still need the fail the batch to revert the state.

Also I wonder what if we don't end up with an empty batch because there's already something in the batch when the serialization error happens? Looks like no revert will be triggered and we will have memory changed while no changes in log?

@squah-confluent squah-confluent Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is indeed wrong and needs updating. Both succeeding and reverting an empty batch are incorrect in different ways. Basically we need state changes from non-replaying writes to be applied or reverted atomically with the records. If we have an empty batch with state changes attached it means we have separated them and that's wrong.

Regarding other paths:
There aren't any other happy paths that flush an empty batch. The non-atomic flush later in the method will never do it because hasRoomFor returns true when the batch is empty. Even without that, it's invalid to have a non-replaying non-atomic write anyway, since we can't revert the correct part of the in-memory updates when the write fails partially.

If we start considering unhappy paths, that's a huge can of worms. The only way we can have an empty batch after an append() call is if it threw. Both succeeding and failing an empty batch are incorrect in different situations. If the empty batch is from a previous failed non-replaying write, succeeding the batch would keep its in-memory changes without writing corresponding records. Arguably we should add exception handling to append() to guarantee that it never leaves an empty batch.


tl;dr I'm not keen on this right now because it doesn't really fix the unhappy paths and I want to avoid scope creeping this PR.

To close all the unhappy paths, ideally we would:

  • Add exception handling to append() to never leave empty batches
    • which lets us clean up flushCurrentBatch() to succeed empty batches, ie. handle 0-record batches the same way as 1-record batches.
  • Add exception handling to CoordinatorWriteEvent to revert in-memory changes when an exception is thrown before appending records.

And even then, large non-replaying writes would still be bugged, since the in-memory changes would be attached to any existing batch, but the records would go in the next batch.

(Filed as KAFKA-20912)

}

@Test
public void testLargeCompressibleRecordSucceeds() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of the test does not fully represent the bug in my opinion. Could we come up with a better one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reviewing, renamed it to testLargeCompressibleRecordDoesNotFlushEmptyBatch

@github-actions github-actions Bot removed the small Small PRs label Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants