Skip to content

KAFKA-15213: provide the exact offset to QuorumController.replay#13643

Merged
cmccabe merged 12 commits into
apache:trunkfrom
cmccabe:txn1
Jul 28, 2023
Merged

KAFKA-15213: provide the exact offset to QuorumController.replay#13643
cmccabe merged 12 commits into
apache:trunkfrom
cmccabe:txn1

Conversation

@cmccabe

@cmccabe cmccabe commented Apr 25, 2023

Copy link
Copy Markdown
Contributor

Provide the exact record offset to QuorumController.replay() in all cases. There are several situations
where this is useful, such as logging, implementing metadata transactions, or handling broker
registration records.

In the case where the QC is inactive, and simply replaying records, it is easy to compute the exact
record offset from the batch base offset and the record index.

The active QC case is more difficult. Technically, when we submit records to the Raft layer, it can
choose a batch end offset later than the one we expect, if someone else is also adding records.
While the QC is the only entity submitting data records, control records may be added at any time.
In the current implementation, these are really only used for leadership elections. However, this
could change with the addition of quorum reconfiguration or similar features.

Therefore, this PR allows the QC to tell the Raft layer that a record append should fail if it
would have resulted in a batch end offset other than what was expected. This in turn will trigger a
controller failover. In the future, if automatically added control records become more common, we
may wish to have a more sophisticated system than this simple optimistic concurrency mechanism. But
for now, this will allow us to rely on the offset as correct.

In order that the active QC can learn what offset to start writing at, the PR also adds a new
endOffset parameter to handleLeaderChange. Since the Raft layer only invokes handleLeaderChange on
the active once it has replayed the log, this information should always be up-to-date in that
context.

At the Raft level, this PR adds a new exception, UnexpectedEndOffsetException. This gets thrown
when we request an end offset that doesn't match the one the Raft layer would have given us.
Although this exception should cause a failover, it should not be considered a fault. This
complicated the exception handling a bit and motivated splitting more of it out into the new
EventHandlerExceptionInfo class. This will also let us unit test things like slf4j log messages a
bit better.

Provide the exact record offset to QuorumController.replay() in all cases. There are several situations
where this is useful, such as logging, implementing metadata transactions, or handling broker
registration records.

In the case where the QC is inactive, and simply replaying records, it is easy to compute the exact
record offset from the batch base offset and the record index.

The active QC case is more difficult. Technically, when we submit records to the Raft layer, it can
choose a batch end offset later than the one we expect, if someone else is also adding records.
While the QC is the only entity submitting data records, control records may be added at any time.
In the current implementation, these are really only used for leadership elections. However, this
could change with the addition of quorum reconfiguration or similar features.

Therefore, this PR allows the QC to tell the Raft layer that a record append should fail if it
would have resulted in a batch end offset other than what was expected. This in turn will trigger a
controller failover. In the future, if automatically added control records become more common, we
may wish to have a more sophisticated system than this simple optimistic concurrency mechanism. But
for now, this will allow us to rely on the offset as correct.

In order that the active QC can learn what offset to start writing at, the PR also adds a new
endOffset parameter to handleLeaderChange. Since the Raft layer only invokes handleLeaderChange on
the active once it has replayed the log, this information should always be up-to-date in that
context.

At the Raft level, this PR adds a new exception, UnexpectedEndOffsetException. This gets thrown
when we request an end offset that doesn't match the one the Raft layer would have given us.
Although this exception should cause a failover, it should not be considered a fault. This
complicated the exception handling a bit and motivated splitting more of it out into the new
EventHandlerExceptionInfo class. This will also let us unit test things like slf4j log messages a
bit better.

@mumrah mumrah left a comment

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.

Thanks @cmccabe, left some questions and minor nits. I think the overall approach is fine.

Regarding the failure case where the end offset of a batch is not equal to what the controller expected, will this only happen if a raft election occurred that was not initiated by a resignation? What are the circumstances when this can happen? Raft timeouts?

IIUC, when an election happens in the middle of an atomic batch, the batch will be lost anyways. The node will finish writing the batch to the local log at epoch N, then process the new leader at N+1, and then it will truncate its own log once it fetches from the new leader at N+1 and sees the start offset for the epoch is less than its own end offset. Is that about right?

Comment thread metadata/src/main/java/org/apache/kafka/controller/QuorumController.java Outdated
Comment thread metadata/src/main/java/org/apache/kafka/controller/QuorumController.java Outdated
Comment thread metadata/src/main/java/org/apache/kafka/controller/QuorumController.java Outdated

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.

After an election, is the endOffset report by raft both the last written and last committed offset?

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 endOffset comes directly from the log and is as described... the end offset (exclusive).

Thinking about it more, I don't think I need to assume that it's committed, so I won't.

But it is used to calculate the next offset that the active controller should try to write to.

Comment thread raft/src/main/java/org/apache/kafka/raft/RaftClient.java Outdated
@cmccabe

cmccabe commented Jul 14, 2023

Copy link
Copy Markdown
Contributor Author

Regarding the failure case where the end offset of a batch is not equal to what the controller expected, will this only happen if a raft election occurred that was not initiated by a resignation? What are the circumstances when this can happen? Raft timeouts?

IIUC, when an election happens in the middle of an atomic batch, the batch will be lost anyways. The node will finish writing the batch to the local log at epoch N, then process the new leader at N+1, and then it will truncate its own log once it fetches from the new leader at N+1 and sees the start offset for the epoch is less than its own end offset. Is that about right?

There's been some discussion of adding more Raft internal control records. One example is if we wanted to implement a dynamic change-of-quorum mechanism. There would probably be internal Raft records associated with that. It's not clear whether change-of-quorum events would also always involve a leader change -- I think in some cases they would not.

Like I said earlier, if we end up adding more background raft messages, we might introduce some mechanism for the active controller to get an "offset lock" so it can get an offset, replay the records, and then try to commit them under that lock. That would minimize failovers caused by these background messages. But since they don't exist today, we can avoid that for today.

@mumrah mumrah left a comment

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.

Thanks for the explanation. As you mentioned earlier we are basically doing optimistic concurrency control by doing the atomic append, then checking the resulting state (i.e., offset). With transactions we'll have the ability to roll back metadata state to a particular offset with the abort marker record.

If a non-transactional write from the controller happens and the expected end offset check fails, the controller will resign. I believe that batch will still be committed and processed by the quorum, right?. I think this is correct, but I just want to make sure this scenario is well understood.

@jsancio jsancio left a comment

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.

@cmccabe This doesn't look like a MINOR change since you are changing the interface of a few raft types. Do you mind creating a Jira that explains the problem you are trying to solve?

Comment on lines +84 to +86
* @param endOffset the current log end offset (exclusive). This is useful for nodes that
* are claiming leadership, because it lets them know what log offset they
* should attempt to write to next.

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.

This is only possible in the leader. This is not well defined on any replica outside of the leader. What problem are you trying so solve?

KRaft guarantees that the committed offset is the LEO when notifying the leader that is now the leader of the partition. For all other replicas leadership change and lost of leadership is done immediately.

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.

What problem are you trying so solve?

We have to know the offset of records that we apply. But we apply records before we submit them to Raft.

* uncommitted entries after observing an epoch change.
*
* @param epoch the current leader epoch
* @param requiredEndOffset if this is set, it is the offset we must use as the end offset (inclusive).

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.

This is the same base or starting offset. The RaftClient does expose LEO but it does expose a base offset. I think we should do something like this:

     * @param expectedBaseOffset if this is set, it matches the base offset that KRaft will use for the {@code records}.

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.

ok. we can use requiredBaseOffset.

Comment on lines +94 to +99
public long append(
int epoch,
List<T> records,
OptionalLong requiredEndOffset,
boolean isAtomic
) {

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.

Please document this method.

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.

ok

Comment on lines +112 to +113
long endOffset = nextOffset + records.size() - 1;
requiredEndOffset.ifPresent(r -> {

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.

If the user of RaftClient provides the "expected base offset" this becomes expectedBaseOffset.ifPresent(baseOffset -> if (baseOffset == nextOffset) { ... } );

@jsancio jsancio left a comment

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.

@cmccabe Why do you need this change now? Since KRaft is a single writer (The active controller) and the active controller is guarantee to have see all of the records before becoming leader, don't you always know what is going to be the next offset?

@cmccabe

cmccabe commented Jul 18, 2023

Copy link
Copy Markdown
Contributor Author

@cmccabe This doesn't look like a MINOR change since you are changing the interface of a few raft types. Do you mind creating a Jira that explains the problem you are trying to solve?

OK. @jsancio , I filed https://issues.apache.org/jira/browse/KAFKA-15213

@cmccabe cmccabe changed the title MINOR: provide the exact offset to QuorumController.replay KAFKA-15213: provide the exact offset to QuorumController.replay Jul 18, 2023
@cmccabe

cmccabe commented Jul 18, 2023

Copy link
Copy Markdown
Contributor Author

@cmccabe Why do you need this change now? Since KRaft is a single writer (The active controller) and the active controller is guarantee to have see all of the records before becoming leader, don't you always know what is going to be the next offset?

It is possible for Raft itself to insert control records. So it is not enough to rely on guessing the next offset. We have to know for sure.

@jsancio

jsancio commented Jul 18, 2023

Copy link
Copy Markdown
Member

@cmccabe Why do you need this change now? Since KRaft is a single writer (The active controller) and the active controller is guarantee to have see all of the records before becoming leader, don't you always know what is going to be the next offset?

It is possible for Raft itself to insert control records. So it is not enough to rely on guessing the next offset. We have to know for sure.

It is not possible in the current implementation. The active controller will lose leadership if KRaft inserts a control record. Have you seen this in practice?

@cmccabe

cmccabe commented Jul 18, 2023

Copy link
Copy Markdown
Contributor Author

It is not possible in the current implementation. The active controller will lose leadership if KRaft inserts a control record. Have you seen this in practice?

The implementation could change. If we're going to rely on the offsets for correctness, we need guarantees.

@jsancio

jsancio commented Jul 20, 2023

Copy link
Copy Markdown
Member

@cmccabe @mumrah and I discuss this PR offline. We agreed to add long RaftClient::logEndOffset() method that would return

  1. The BatchAccumulator's baseOffset when the replica is a leader or
  2. The log end offset when the replica is not a leader.

This is instead of the sending the LEO in the RaftClient.Listener::handleLeaderChange callback.

@cmccabe

cmccabe commented Jul 24, 2023

Copy link
Copy Markdown
Contributor Author

Thanks, @jsancio . I updated the PR with the results of our discussion. One small change I made is that I think RaftClient#logEndOffset can just unconditionally return the log end offset, as the name implies. We only need to call it when becoming active anyway.

@jsancio jsancio left a comment

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.

Thanks for the changes @cmccabe

Comment on lines +758 to +760
raftClient.scheduleAtomicAppend(controllerEpoch,
OptionalLong.of(prevEndOffset + 1),
records);

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.

This indentation doesn't look right. We indent 4 spaces in this case.

Comment on lines +2398 to +2400
public long logEndOffset() {
return log.endOffset().offset;
}

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.

This is not correct in all cases. The leader can have records in the base accumulator that have not been sent to the log. I think you want something along these lines:

    public long logEndOffset() {
        return quorum.maybeLeaderState()
            .map(leader -> leader.accumulator().nextOffset())
            .orElse(log.endOffset().offset);
    }

Then we can add this method to BatchAccumulator:

    public long nextOffset() {
        appendLock.lock();
        try {
            return nextOffset;
        finally {
            appendLock.unlock();
        }
    }

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.

Can we also add tests for this new functionality?

@cmccabe cmccabe Jul 25, 2023

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.

This is not correct in all cases. The leader can have records in the base accumulator that have not been sent to the log.

Hmm... the method name is "logEndOffset." So it should just return the log end offset, right? Returning something else would be misleading.

In any case, we only need this method when the leader becomes active. We will not use it after that.

@cmccabe cmccabe Jul 25, 2023

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.

Can we also add tests for this new functionality?

Yes, good point. I will add a test for the requiredEndOffset parameter and the logEndOffset method.

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.

Hmm... the method name is "logEndOffset." So it should just return the log end offset, right? Returning something else would be misleading.

@cmccabe, I don't follow this comment. When the client calls KafkaRaftClient::schedule{Atomic}Append the KafkaRaftClient compare the provided offset with the nextOffset stored in the BatchAccumulator. If we want this method to succeed in most cases KafkaRaftClient::logEndOffset should return that offset, BatchAccumulator::nextOffset and not the log end offset.

Maybe logEndOffset is not a great name. I am okay renaming this to KafkaRaftClient::endOffset() but I am open to suggestions.


appendLock.lock();
try {
long endOffset = nextOffset + records.size() - 1;

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 think must readers will assume that this "end offset" is exclusive. I think this offset is inclusive. We normally use lastOffset for this kind of offset.

            long lastOffset = nextOffset + records.size() - 1;


time.sleep(15);
assertEquals(baseOffset, acc.append(leaderEpoch, singletonList("a")));
assertEquals(baseOffset, acc.append(leaderEpoch, singletonList("a"), OptionalLong.empty(), false));

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.

We need tests for the new functionality added to the BatchAccumulator.


assertThrows(RecordBatchTooLargeException.class, () -> context.client.scheduleAtomicAppend(epoch, batchToLarge));
assertThrows(RecordBatchTooLargeException.class,
() -> context.client.scheduleAtomicAppend(epoch, OptionalLong.empty(), batchToLarge));

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.

We need tests for the new functionality added to KafkaRaftClient. That is both the new method logEndOffset and the changes to scheduleAtomicAppend.

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 couldn't find a test for the new KafkaRaftClient::scheduleAtomicAppend.

@@ -172,15 +173,17 @@ default void beginShutdown() {}
* uncommitted entries after observing an epoch change.

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.

It's probably worth adding a sentence or two about the new optimistic concurrency.

Comment on lines -1179 to +1176
updateWriteOffset(lastCommittedOffset);
updateWriteOffset(newLastWriteOffset);

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.

Previously, we would update lastCommittedOffset as we got the handleCommit callback from our RaftClient. Since we process Raft events sequentially (and they are delivered sequentially from a single thread), we always process any commit callbacks before a leader change. Which means this offset is valid with respect to the end offset when the leadership changed.

Now, while we're processing a leader change, we ask RaftClient for its end offset. Is there any possibility that commits could be made that would make this end offset greater than we expect? Basically, can we be sure that the end offset doesn't change between the time Raft becomes the leader and this event is processed?

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.

Well, there is not really any difference between the previous iterations of this PR and the current one in this regard. If some other component that isn't the controller is adding messages, our supplied requiredBaseOffset may be invalid. It is only a snapshot of the offset at a point in time, after all. Which is why we check requiredBaseOffset in atomicAppend.

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.

Understood, I just wanted to make sure I understood the behavior here regarding the new RaftClient API. It sounds like it's fine for us as long as we're single writer.

@mumrah mumrah left a comment

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.

LGTM

@jsancio jsancio left a comment

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.

Thanks for the changes @cmccabe .

Comment on lines +2398 to +2400
public long logEndOffset() {
return log.endOffset().offset;
}

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.

Hmm... the method name is "logEndOffset." So it should just return the log end offset, right? Returning something else would be misleading.

@cmccabe, I don't follow this comment. When the client calls KafkaRaftClient::schedule{Atomic}Append the KafkaRaftClient compare the provided offset with the nextOffset stored in the BatchAccumulator. If we want this method to succeed in most cases KafkaRaftClient::logEndOffset should return that offset, BatchAccumulator::nextOffset and not the log end offset.

Maybe logEndOffset is not a great name. I am okay renaming this to KafkaRaftClient::endOffset() but I am open to suggestions.

* @throws UnexpectedBaseOffsetException the requested base offset could not be obtained.
*/
long scheduleAtomicAppend(int epoch, List<T> records);
long scheduleAtomicAppend(int epoch, OptionalLong requiredBaseOffset, List<T> records);

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.

What's the argument/reason for adding this functionality to scheduleAtomicAppend and not scheduleAppend?

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 controller doesn't use scheduleAppend. We only use scheduleAtomicAppend.


assertThrows(RecordBatchTooLargeException.class, () -> context.client.scheduleAtomicAppend(epoch, batchToLarge));
assertThrows(RecordBatchTooLargeException.class,
() -> context.client.scheduleAtomicAppend(epoch, OptionalLong.empty(), batchToLarge));

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 couldn't find a test for the new KafkaRaftClient::scheduleAtomicAppend.

@cmccabe

cmccabe commented Jul 27, 2023

Copy link
Copy Markdown
Contributor Author

@cmccabe, I don't follow this comment. When the client calls KafkaRaftClient::schedule{Atomic}Append the KafkaRaftClient compare the provided offset with the nextOffset stored in the BatchAccumulator. If we want this method to succeed in most cases KafkaRaftClient::logEndOffset should return that offset, BatchAccumulator::nextOffset and not the log end offset.

There can't be anything in the accumulator when we become active, because we are not adding things to the accumulator when we are inactive. Therefore all we need to know is the end of the log.

After transitioning from inactive to active, the controller tracks its own offset and will not invoke this method. So assuming leadership never moves, the method would be invoked only once ever.

@cmccabe

cmccabe commented Jul 27, 2023

Copy link
Copy Markdown
Contributor Author

I couldn't find a test for the new KafkaRaftClient::scheduleAtomicAppend.

I added KafkaRaftClientTest.testAppendWithRequiredBaseOffset.

@cmccabe
cmccabe merged commit 10bcd4f into apache:trunk Jul 28, 2023
@cmccabe
cmccabe deleted the txn1 branch July 28, 2023 00:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants