KAFKA-2334 Guard against non-monotonic offsets in the client#5991
Conversation
This reverts commit 3e75fdf.
hachikuji
left a comment
There was a problem hiding this comment.
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 && |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
nit: typically we prefer a space after if
|
retest this please |
hachikuji
left a comment
There was a problem hiding this comment.
Thanks for the updates. Left a few more comments.
| ISOLATION_LEVEL, | ||
| TOPICS_V4); | ||
|
|
||
| // V5 bump to include new possible error code |
There was a problem hiding this comment.
Let's be specific and say that the new error code is OFFSET_NOT_AVAILABLE
| case None => localReplica.logEndOffset.messageOffset | ||
| } | ||
|
|
||
| // Only actually check the HW if this is a client request (isolation level is null) |
There was a problem hiding this comment.
Should be, if isolationLevel is defined, this is a client request, right?
hachikuji
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Should be LEADER_NOT_AVAILABLE?
|
By the way, I think the new usage of |
| 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 " + |
There was a problem hiding this comment.
Does this exception apply for all cases or only when the offset for latest timestamp is requested?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
I think refusing to return any offset until the HW has caught up is a reasonable solution.
There was a problem hiding this comment.
Hmm seems like this behavior was changed then in 6a56493?
There was a problem hiding this comment.
Indeed! After talking through the various cases with @hachikuji, we decided to only enforce the check on latest timestamp offset requests.
There was a problem hiding this comment.
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
|
@hachikuji 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. |
|
@edenhill what's the reason for the distinction in librdkafka? |
|
@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. |
|
@edenhill what about new unknown error codes (like the one introduce here)? Does librdkafka have some fallback logic for unknown error codes? |
hachikuji
left a comment
There was a problem hiding this comment.
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 " + |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
I think that makes sense, +1
There was a problem hiding this comment.
Ok, I incorporated this logic into the latest commit. I also reorganized the code to make it easier to understand (hopefully)
hachikuji
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
nit: perhaps we could combine these two checks into one filter?
| } | ||
|
|
||
| fetchedOffset.filter(allowed) | ||
| val epochLogString: String = Some(currentLeaderEpoch.orElse(null)) |
There was a problem hiding this comment.
This feels a little like overkill. Using the toString of Option is probably reasonable.
There was a problem hiding this comment.
As an aside, Some(null) should generally be avoided. When working with Option, the expectation is that the value inside it is not null.
There was a problem hiding this comment.
Yea, the trouble here was dealing with a Java Optional (it doesn't like Scala lambdas apparently). I'll think of something better
There was a problem hiding this comment.
There's a Scala/Java 8 integration library that helps with this. We should consider adding they dependency.
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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) { |
| case ListOffsetRequest.EARLIEST_TIMESTAMP => | ||
| getOffsetByTimestamp | ||
| case _ => | ||
| getOffsetByTimestamp match { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 " + |
There was a problem hiding this comment.
Maybe this message should say something about maxTrueHighWatermark?
| Some(timestampAndOffset) | ||
| } else { | ||
| // Either throw or return None depending on epochStartOffset | ||
| if (maxTrueHighWatermark.isDefined) { |
There was a problem hiding this comment.
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 => |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
nit: LSO annoyingly can be mean either "last stable offset" or "log start offset". How about just startOffset instead?
hachikuji
left a comment
There was a problem hiding this comment.
LGTM. Thanks for the patch!
…#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>
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)