Skip to content

KAFKA-2334 Guard against non-monotonic offsets in the client#5991

Merged
hachikuji merged 24 commits into
apache:trunkfrom
mumrah:KAFKA-2334
Dec 14, 2018
Merged

KAFKA-2334 Guard against non-monotonic offsets in the client#5991
hachikuji merged 24 commits into
apache:trunkfrom
mumrah:KAFKA-2334

Conversation

@mumrah

@mumrah mumrah commented Dec 3, 2018

Copy link
Copy Markdown
Member

After a recent leader election, the leaders high-water mark might lag behind the offset at the beginning of the new epoch (as well as the previous leader's HW). This can lead to offsets going backwards from a client perspective, which is confusing and leads to strange behavior in some clients.

This change causes Partition#fetchOffsetForTimestamp to throw an exception to indicate the offsets are not yet available from the leader. For new clients, a new OFFSET_NOT_AVAILABLE error is added. For existing clients, a LEADER_NOT_AVAILABLE is thrown.

This is an implementation of KIP-207

Committer Checklist (excluded from commit message)

  • Verify design and implementation
  • Verify test coverage and CI build status
  • Verify documentation (including upgrade notes)

@hachikuji hachikuji self-assigned this Dec 3, 2018

@hachikuji hachikuji left a comment

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.

Thanks for the patch. Left a few comments.

// Only actually check the HW if this is a client request and _not_ while unclean leader
// election is enabled
val shouldCheckHW = {
!logManager.currentDefaultConfig.uncleanLeaderElectionEnable &&

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 think we'd want to do this validation even if unclean leader election is enabled. The point that the KIP was making is that high watermark monotonicity can be violated if unclean leader election is enabled. But we'd still want to minimize such violations to cases when an unclean leader election has actually happened.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for the clarification, I misunderstood the KIP. I'll take this check out.

currentLeaderEpoch: Optional[Integer],
fetchOnlyFromLeader: Boolean): Option[TimestampAndOffset] = inReadLock(leaderIsrUpdateLock) {
fetchOnlyFromLeader: Boolean,
isFromClient: Boolean): Option[TimestampAndOffset] = inReadLock(leaderIsrUpdateLock) {

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 think we may be able to drop this argument. When the isolation level is read_uncommitted, then we are limited by the high watermark. For followers, isolation level will be None.

leaderEpochStartOffsetOpt.isDefined
}

if(shouldCheckHW) {

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.

nit: typically we prefer a space after if

Comment thread core/src/main/scala/kafka/server/KafkaApis.scala
Comment thread core/src/main/scala/kafka/server/ReplicaFetcherThread.scala Outdated
@mumrah

mumrah commented Dec 6, 2018

Copy link
Copy Markdown
Member Author

retest this please

@hachikuji hachikuji left a comment

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.

Thanks for the updates. Left a few more comments.

Comment thread core/src/main/scala/kafka/api/ApiVersion.scala Outdated
Comment thread core/src/main/scala/kafka/cluster/Partition.scala Outdated
Comment thread core/src/main/scala/kafka/server/KafkaApis.scala Outdated
ISOLATION_LEVEL,
TOPICS_V4);

// V5 bump to include new possible error code

@cmccabe cmccabe Dec 7, 2018

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.

Let's be specific and say that the new error code is OFFSET_NOT_AVAILABLE

Comment thread core/src/main/scala/kafka/api/ApiVersion.scala
case None => localReplica.logEndOffset.messageOffset
}

// Only actually check the HW if this is a client request (isolation level is null)

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.

Should be, if isolationLevel is defined, this is a client request, right?

@hachikuji hachikuji left a comment

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.

Left one minor comment. Other than that, looks good.

// Fail with LEADER_NOT_AVAILABLE
time.sleep(retryBackoffMs);
client.prepareResponse(listOffsetRequestMatcher(ListOffsetRequest.LATEST_TIMESTAMP),
listOffsetResponse(Errors.OFFSET_NOT_AVAILABLE, 1L, 5L), false);

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.

Should be LEADER_NOT_AVAILABLE?

@hachikuji

Copy link
Copy Markdown
Contributor

By the way, I think the new usage of LEADER_NOT_AVAILABLE works with the error handling in old versions of the java client. Might be a good idea to make sure this won't cause problems for any of the other popular clients. Such as librdkafka. cc @edenhill

if (isolationLevel.isDefined && leaderEpochStartOffsetOpt.isDefined) {
// Wait until the HW has caught up with the start offset from this epoch
if (leaderEpochStartOffsetOpt.get > localReplica.highWatermark.messageOffset) {
throw Errors.OFFSET_NOT_AVAILABLE.exception(s"Failed to fetch offsets for " +

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.

Does this exception apply for all cases or only when the offset for latest timestamp is requested?

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.

That's a good question. Presumably it is possible for a timestamp query to find an offset between the log end offset and the high watermark as well. I guess the point is that we cannot trust the high watermark upper bound until we can ensure that it cannot have gone backwards. Maybe the only offset you can safely query without this validation is the earliest?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think refusing to return any offset until the HW has caught up is a reasonable solution.

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.

Hmm seems like this behavior was changed then in 6a56493?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Indeed! After talking through the various cases with @hachikuji, we decided to only enforce the check on latest timestamp offset requests.

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.

Thanks! Would be good to document the change in the KIP, given the following is now out-dated:

The KIP-207 behavior applies to all ListOffsetsRequests, whether they are for the latest offset, the earliest offset, or a time-based offset

@edenhill

edenhill commented Dec 8, 2018

Copy link
Copy Markdown
Contributor

@hachikuji
In librdkafka LEADER_NOT_AVAILABLE will trigger a metadata-refresh but propagate the error to the application rather than retry,

NOT_LEADER_FOR_PARTITION on the other hand will trigger metadata-refresh and retry.

So from my point of view the latter would be preferred, but I don't think this matters much in practice.

@ijuma

ijuma commented Dec 8, 2018

Copy link
Copy Markdown
Member

@edenhill what's the reason for the distinction in librdkafka?

@edenhill

edenhill commented Dec 9, 2018

Copy link
Copy Markdown
Contributor

@ijuma No reason other than historical, i.e., old code. We have a backlog item to update to latest protocol request versions and update error handling accordingly.

@mumrah

mumrah commented Dec 10, 2018

Copy link
Copy Markdown
Member Author

@edenhill what about new unknown error codes (like the one introduce here)? Does librdkafka have some fallback logic for unknown error codes?

@hachikuji hachikuji left a comment

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.

Thanks, I think we're almost there. Just one more comment.

// Throw if the HW has not caught up with the start offset from this epoch
leaderEpochStartOffsetOpt
.filter(leo => leo > localReplica.highWatermark.messageOffset)
.map(leo => Errors.OFFSET_NOT_AVAILABLE.exception(s"Failed to fetch offsets for " +

@hachikuji hachikuji Dec 11, 2018

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 change this map to foreach and throw from here?

If we are only checking this for the latest case, I guess we could move this into the if block below. However, I am considering whether we should check this for general timestamp queries. The problem is that we may be excluding some committed data from a query if we filter only based on the local high watermark. This might cause a timestamp which was visible to disappear temporarily after a leader change.

So we could change the code slightly below. If fetchOffsetByTimestamp returns an offset that is larger than lastFetchableOffset and leaderEpochStartOffsetOpt is defined, then instead of returning None, we could raise OFFSET_NOT_AVAILABLE. Would that make sense?

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 think that makes sense, +1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ok, I incorporated this logic into the latest commit. I also reorganized the code to make it easier to understand (hopefully)

@hachikuji hachikuji left a comment

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.

Thanks, the updated logic looks good. I left a few small stylistic suggestions to consider.

timestamp == ListOffsetRequest.EARLIEST_TIMESTAMP || timestampOffset.offset < lastFetchableOffset
val invalidEpochStartOffset: Option[Long] = leaderEpochStartOffsetOpt
.filter(_ => isolationLevel.isDefined)
.filter(leo => leo > localReplica.highWatermark.messageOffset)

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.

nit: perhaps we could combine these two checks into one filter?

}

fetchedOffset.filter(allowed)
val epochLogString: String = Some(currentLeaderEpoch.orElse(null))

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.

This feels a little like overkill. Using the toString of Option is probably reasonable.

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.

As an aside, Some(null) should generally be avoided. When working with Option, the expectation is that the value inside it is not null.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yea, the trouble here was dealing with a Java Optional (it doesn't like Scala lambdas apparently). I'll think of something better

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.

There's a Scala/Java 8 integration library that helps with this. We should consider adding they dependency.

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.

There's a Scala/Java 8 integration library that helps with this. We should consider adding they dependency.


import scala.collection.JavaConverters._
import scala.collection.Map
import scala.collection.{JavaConverters, Map}

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.

nit: seems we didn't need this?

timestamp match {
case ListOffsetRequest.LATEST_TIMESTAMP =>
// Special case, if asking for "latest" and HW is lagging behind LEO, throw an error
if(invalidEpochStartOffset.isDefined) {

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.

nit: use a match?

case ListOffsetRequest.EARLIEST_TIMESTAMP =>
getOffsetByTimestamp
case _ =>
getOffsetByTimestamp match {

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.

nit: seems we could do a flatMap since we aren't doing anything in the None case.

} else {
def allowed(timestampOffset: TimestampAndOffset): Boolean =
timestamp == ListOffsetRequest.EARLIEST_TIMESTAMP || timestampOffset.offset < lastFetchableOffset
val invalidEpochStartOffset: Option[Long] = leaderEpochStartOffsetOpt

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've been trying to come up with an alternative name. Basically we're in a situation where the local high watermark is not trusted and this is an upper bound on the true high watermark. Maybe maxTrueHighWatermark?

} else {
// Either throw or return None depending on epochStartOffset
if (maxTrueHighWatermark.isDefined) {
throw Errors.OFFSET_NOT_AVAILABLE.exception(s"Failed to fetch offsets for " +

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.

Maybe this message should say something about maxTrueHighWatermark?

Some(timestampAndOffset)
} else {
// Either throw or return None depending on epochStartOffset
if (maxTrueHighWatermark.isDefined) {

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 think we could technically do this as a map, but I'll leave it up to you if that seems obscure.

case ListOffsetRequest.EARLIEST_TIMESTAMP =>
getOffsetByTimestamp
case _ =>
getOffsetByTimestamp.flatMap(timestampAndOffset =>

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.

nit: this is usually preferred

getOffsetByTimestamp.flatMap { timestampAndOffset =>

}

// Or if we request the earliest timestamp, we skip the check
offsetAndTimestamp = partition.fetchOffsetForTimestamp(
timestamp = ListOffsetRequest.EARLIEST_TIMESTAMP,
isolationLevel = None,

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.

We should provide an isolation level for this case?

// Try to get offsets as a client
partition.fetchOffsetForTimestamp(
timestamp = ListOffsetRequest.LATEST_TIMESTAMP,
isolationLevel = Some(IsolationLevel.READ_COMMITTED),

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.

Doesn't make a difference for this test case, but maybe we can use READ_UNCOMMITTED so it does not cause any confusion about the expectation in regard to the last stable offset.

fetchOnlyFromLeader = true
)
assertTrue(offsetAndTimestamp.isDefined)
assertEquals(offsetAndTimestamp.get.offset, 5)

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.

nit: the expected value should be the first argument. There are a few of these.


try {
// Try to get offsets as a client
partition.fetchOffsetForTimestamp(

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.

nit: there's a bit of boilerplate here that we could probably factor out into an inner def. Like maybe

def fetchOffsetForTimestamp(timestamp, isolation): Either[OffsetAndTimestamp, Errors]

Or sth like that.

// Only consider throwing an error if we get a client request (isolationLevel is defined) and the start offset
// is lagging behind the high watermark
val maybeOffsetsError: Option[ApiException] = leaderEpochStartOffsetOpt
.filter(epochLSO => isolationLevel.isDefined && epochLSO > localReplica.highWatermark.messageOffset)

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.

nit: LSO annoyingly can be mean either "last stable offset" or "log start offset". How about just startOffset instead?

@hachikuji hachikuji left a comment

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.

LGTM. Thanks for the patch!

@hachikuji
hachikuji merged commit 1522929 into apache:trunk Dec 14, 2018
pengxiaolong pushed a commit to pengxiaolong/kafka that referenced this pull request Jun 14, 2019
…#5991)

After a recent leader election, the leaders high-water mark might lag behind the offset at the beginning of the new epoch (as well as the previous leader's HW). This can lead to offsets going backwards from a client perspective, which is confusing and leads to strange behavior in some clients.

This change causes Partition#fetchOffsetForTimestamp to throw an exception to indicate the offsets are not yet available from the leader. For new clients, a new OFFSET_NOT_AVAILABLE error is added. For existing clients, a LEADER_NOT_AVAILABLE is thrown.

This is an implementation of [KIP-207](https://cwiki.apache.org/confluence/display/KAFKA/KIP-207%3A+Offsets+returned+by+ListOffsetsResponse+should+be+monotonically+increasing+even+during+a+partition+leader+change).

Reviewers: Colin Patrick McCabe <colin@cmccabe.xyz>, Dhruvil Shah <dhruvil@confluent.io>, Jason Gustafson <jason@confluent.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants