This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-management.git
The following commit(s) were added to refs/heads/main by this push:
new 126bcb83 improve lease code (#911)
126bcb83 is described below
commit 126bcb839c124065156acfde86b64bfa73744acb
Author: PJ Fanning <[email protected]>
AuthorDate: Wed Aug 26 09:47:32 2026 +0100
improve lease code (#911)
* improve lease code
* revert m8
* Update LeaseActorSpec.scala
* add heartbeatMaxRetries
* deal with FIXME deal with failure from releasing the the lock
* Create operation-in-progress.backwards.excludes
* Update operation-in-progress.backwards.excludes
* Update operation-in-progress.backwards.excludes
* Update operation-in-progress.backwards.excludes
* Bound acquire conflict retries and pace release retries off the operation
timeout
Motivation:
Two retry loops in LeaseActor could run for longer than the caller waits.
The Granting conflict retry was unbounded and had no deadline, so a lease
version that kept moving on made the actor issue update requests in a tight
loop. The caller's ask expired at lease-operation-timeout while the actor
stayed in Granting, failing every later acquire/release with InvalidRequest.
If the loop eventually won, the lease was granted after the caller had
already
been told it was not. The retry also stored the original version rather than
the one just attempted, which defeated the require that guards against a
conflict response repeating a version already tried.
The Releasing retry was spaced at heartbeat-interval / (retries + 1). With
the
default 12s heartbeat interval that is 3s per retry, roughly 9s in total,
against a 5s lease-operation-timeout ask. The caller therefore saw an ask
timeout and the eventual reply went to dead letters. The retries only
appeared
to work because LeaseActorSpec configures a 25ms heartbeat interval.
Modification:
Give the Granting conflict retry a deadline: once the elapsed time since the
operation started exceeds lease-operation-timeout, reply with a
LeaseTimeoutException and return to Idle instead of retrying. Record the
version being attempted so a subsequent conflict is compared against it.
Pace release retries at lease-operation-timeout / (retries + 1) and stop
once
the remaining budget cannot fit another delay, so the caller is always told
the outcome before its ask expires.
Document in reference.conf that heartbeat-max-retries also bounds release
retries and that the two are spaced against different timeouts.
Two existing tests had the mock return the same resourceVersion for a
successful PUT, which a real API server never does; they now return a fresh
version, which the tightened conflict tracking requires.
Result:
Neither retry loop outlives the caller, and a lease can no longer be granted
after the acquire has been reported as failed.
Tests:
- sbt "lease-kubernetes/test" headerCheckAll - 108 tests pass
- sbt scalafmtCheckAll "+lease-kubernetes/mimaReportBinaryIssues" - pass on
2.13.18, 3.3.8 and 3.8.4
References:
Refs #911
* Separate release retries from heartbeat retries and queue concurrent
acquires
Motivation:
heartbeat-max-retries governed both the heartbeat retry loop and the release
retry loop, even though its name and documentation only describe the former
and the two are now spaced against different timeouts. Nothing rejected a
negative value, and the default of 3 was stated both in reference.conf and
as
a default argument on LeaseActor.props.
An Acquire arriving while an acquire was already in flight was answered with
InvalidRequest. That is better than the silent drop it replaced, but the
common case is the same owner asking for the same lease twice, and both
callers want the same outcome. The spec has carried a TODO about replying to
all such callers since the code was first written.
Modification:
Add a release-max-retries setting, defaulting to 3, and use it for the
Releasing retry loop. heartbeat-max-retries now only bounds heartbeat
retries.
Both are validated as non-negative in KubernetesSettings, and reference.conf
documents what each one spaces its retries against. Drop the duplicated
default from LeaseActor.props so reference.conf is the only source of truth.
Queue an Acquire that arrives during PendingRead or Granting instead of
rejecting it: the extra sender is recorded in the FSM data and every queued
caller gets the same LeaseAcquired, LeaseTaken or failure as the original.
The most recently supplied lease lost callback wins, matching the existing
re-acquire behaviour in Granted. An Acquire during Releasing is
contradictory
rather than duplicate, so it is still answered with InvalidRequest.
LeaseActorSpec now overrides the retry counts rather than the actor itself,
which also stops each no-retry test creating a second, orphaned LeaseActor.
Result:
The two retry loops are configured independently, and concurrent acquires of
the same lease no longer fail one of the callers.
Tests:
- sbt "lease-kubernetes/test" headerCheckAll - 118 tests pass
- sbt scalafmtCheckAll "+lease-kubernetes/mimaReportBinaryIssues" - pass on
2.13.18, 3.3.8 and 3.8.4
References:
Refs #911
* Tidy LeaseActorSpec retry tests and cover repeated acquire conflicts
Motivation:
The retry tests hardcoded the attempt count as a literal 4, which is only
correct while both retry settings default to 3, and repeated the same
"initial attempt + 3 retries" loop and comment in six places. The version
offset used to simulate a conflict was a bare 6 with no explanation. The
test
for the acquire conflict retry sat in the block for a disabled retry setting
that has no bearing on it. Nothing covered a conflict that recurs, which is
the case the version tracking in the retry exists for, or a retry that finds
the lease taken by then.
Modification:
Add failAllHeartbeatAttempts and failAllReleaseAttempts helpers to the Test
trait that derive the number of attempts from the configured retry counts,
and
use them for every such loop. heartBeatFailureNoRetry is now redundant and
is
removed, since the shared helper honours the overridden retry count.
Name the conflict version offset otherClientUpdates and say what it stands
for.
Move the conflict retry test into the acquire conflict retry block, and
assert
that nothing is reported to the caller until the retry has been answered.
Add
a test for a conflict that recurs, asserting the retry uses the version from
the most recent conflict rather than the original, and a test for a retry
that
finds another owner holding the lease.
Result:
The retry tests follow the configured retry counts instead of restating
them,
and the acquire conflict retry is covered beyond the single conflict case.
Tests:
- sbt "lease-kubernetes/test" headerCheckAll - 120 tests pass
- sbt scalafmtCheckAll "+lease-kubernetes/mimaReportBinaryIssues" - pass on
2.13.18, 3.3.8 and 3.8.4
References:
Refs #911
---
.../operation-in-progress.backwards.excludes | 36 +++
lease-kubernetes/src/main/resources/reference.conf | 12 +
.../lease/kubernetes/AbstractKubernetesLease.scala | 3 +-
.../lease/kubernetes/KubernetesSettings.scala | 14 +-
.../coordination/lease/kubernetes/LeaseActor.scala | 215 +++++++++---
.../internal/NativeKubernetesApiImpl.scala | 5 +-
.../lease/kubernetes/KubernetesSettingsSpec.scala | 22 ++
.../lease/kubernetes/LeaseActorSpec.scala | 359 +++++++++++++++++++--
8 files changed, 589 insertions(+), 77 deletions(-)
diff --git
a/lease-kubernetes/src/main/mima-filters/2.0.x.backwards.excludes/operation-in-progress.backwards.excludes
b/lease-kubernetes/src/main/mima-filters/2.0.x.backwards.excludes/operation-in-progress.backwards.excludes
new file mode 100644
index 00000000..94406d10
--- /dev/null
+++
b/lease-kubernetes/src/main/mima-filters/2.0.x.backwards.excludes/operation-in-progress.backwards.excludes
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# OperationInProgress changes
+ProblemFilters.exclude[IncompatibleSignatureProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#GrantedVersion.unapply")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#GrantedVersion.copy")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#GrantedVersion.this")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#GrantedVersion.apply")
+ProblemFilters.exclude[MissingTypesProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor$GrantedVersion$")
+ProblemFilters.exclude[IncompatibleSignatureProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#OperationInProgress.unapply")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#OperationInProgress.apply")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#OperationInProgress.copy")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#OperationInProgress.this")
+ProblemFilters.exclude[MissingTypesProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor$OperationInProgress$")
+
+# PendingReadData changes
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#PendingReadData.copy")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#PendingReadData.this")
+ProblemFilters.exclude[MissingTypesProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor$PendingReadData$")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#PendingReadData.apply")
+ProblemFilters.exclude[IncompatibleSignatureProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#PendingReadData.unapply")
+ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.coordination.lease.kubernetes.LeaseActor#ReplyRequired.alsoReplyTo")
diff --git a/lease-kubernetes/src/main/resources/reference.conf
b/lease-kubernetes/src/main/resources/reference.conf
index dc88efbf..36af9267 100644
--- a/lease-kubernetes/src/main/resources/reference.conf
+++ b/lease-kubernetes/src/main/resources/reference.conf
@@ -37,6 +37,18 @@ pekko.coordination.lease.kubernetes {
# having been updated
heartbeat-timeout = 120s
+ # Number of times to retry a failed heartbeat before giving up the lease.
Retries are spaced
+ # evenly within the heartbeat-interval, so raising this shortens the gap
between attempts
+ # rather than extending how long the lease is held without a successful
heartbeat.
+ # Set to 0 to give up the lease on the first failed heartbeat.
+ heartbeat-max-retries = 3
+
+ # Number of times to retry a failed lease release before reporting the
failure to the caller.
+ # Retries are spaced evenly within the lease-operation-timeout so that the
caller is told the
+ # outcome before its own timeout expires.
+ # Set to 0 to report the failure on the first failed release.
+ release-max-retries = 3
+
# The individual timeout for each HTTP request. Defaults to 2/5 of the
lease-operation-timeout
# Can't be greater than then lease-operation-timeout
api-server-request-timeout = ""
diff --git
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/AbstractKubernetesLease.scala
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/AbstractKubernetesLease.scala
index 419aaae1..2486b5c1 100644
---
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/AbstractKubernetesLease.scala
+++
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/AbstractKubernetesLease.scala
@@ -140,7 +140,8 @@ abstract class AbstractKubernetesLease(system:
ExtendedActorSystem, leaseTaken:
k8sSettings.leaseLabelMaxLength,
k8sSettings.onTruncateAddHashLength)
private val leaseActor = system.systemActorOf(
- LeaseActor.props(k8sApi, settings, leaseName, leaseTaken),
+ LeaseActor.props(k8sApi, settings, leaseName, leaseTaken,
k8sSettings.heartbeatMaxRetries,
+ k8sSettings.releaseMaxRetries),
s"kubernetesLease${AbstractKubernetesLease.leaseCounter.incrementAndGet}")
if (leaseName != settings.leaseName) {
logger.info(
diff --git
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettings.scala
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettings.scala
index b4d23221..351bce90 100644
---
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettings.scala
+++
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettings.scala
@@ -53,6 +53,12 @@ private[pekko] object KubernetesSettings {
apiServerRequestTimeout < leaseTimeoutSettings.operationTimeout,
"'api-server-request-timeout can not be less than
'lease-operation-timeout'")
+ val heartbeatMaxRetries = config.getInt("heartbeat-max-retries")
+ require(heartbeatMaxRetries >= 0, "'heartbeat-max-retries' must not be
negative")
+
+ val releaseMaxRetries = config.getInt("release-max-retries")
+ require(releaseMaxRetries >= 0, "'release-max-retries' must not be
negative")
+
val retryConfPath = "token-rotation-retry"
val tokenRetrySettings = new TokenRetrySettings(
@@ -75,7 +81,9 @@ private[pekko] object KubernetesSettings {
bodyReadTimeout = apiServerRequestTimeout / 2,
tokenRetrySettings = tokenRetrySettings,
leaseLabelMaxLength = config.getInt("lease-name-max-length"),
- onTruncateAddHashLength = config.getInt("on-truncate-add-hash-length"))
+ onTruncateAddHashLength = config.getInt("on-truncate-add-hash-length"),
+ heartbeatMaxRetries = heartbeatMaxRetries,
+ releaseMaxRetries = releaseMaxRetries)
}
}
@@ -111,4 +119,6 @@ private[pekko] class KubernetesSettings(
0.3
),
val leaseLabelMaxLength: Int = 63,
- val onTruncateAddHashLength: Int = 8)
+ val onTruncateAddHashLength: Int = 8,
+ val heartbeatMaxRetries: Int = 3,
+ val releaseMaxRetries: Int = 3)
diff --git
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/LeaseActor.scala
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/LeaseActor.scala
index d50a32b7..1b60251f 100644
---
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/LeaseActor.scala
+++
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/LeaseActor.scala
@@ -48,19 +48,36 @@ private[pekko] object LeaseActor {
sealed trait ReplyRequired {
def replyTo: ActorRef
+
+ /**
+ * Callers that asked to acquire the same lease while this operation was
already in flight.
+ * They get the same response as [[replyTo]] once the operation completes.
+ */
+ def alsoReplyTo: Set[ActorRef]
+
+ def allReplyTo: Set[ActorRef] = alsoReplyTo + replyTo
}
// Awaiting a read to try and get the lease
- case class PendingReadData(replyTo: ActorRef, leaseLostCallback:
Option[Throwable] => Unit)
+ case class PendingReadData(
+ replyTo: ActorRef,
+ leaseLostCallback: Option[Throwable] => Unit,
+ alsoReplyTo: Set[ActorRef] = Set.empty)
extends Data
with ReplyRequired
case class OperationInProgress(
replyTo: ActorRef,
version: String,
leaseLostCallback: Option[Throwable] => Unit,
- operationStartTime: Long = System.nanoTime())
+ operationStartTime: Long = System.nanoTime(),
+ releaseRetries: Int = 0,
+ alsoReplyTo: Set[ActorRef] = Set.empty)
extends Data
with ReplyRequired
- case class GrantedVersion(version: String, leaseLostCallback:
Option[Throwable] => Unit) extends Data
+ case class GrantedVersion(
+ version: String,
+ leaseLostCallback: Option[Throwable] => Unit,
+ heartbeatFailures: Int = 0)
+ extends Data
sealed trait Command
case class Acquire(leaseLostCallback: Option[Throwable] => Unit =
ConstantFun.scalaAnyToUnit) extends Command
@@ -70,6 +87,8 @@ private[pekko] object LeaseActor {
private case class ReadResponse(response: LeaseResource) extends Command
private case class WriteResponse(response: Either[LeaseResource,
LeaseResource]) extends Command
private case object Heartbeat extends Command
+ private case object HeartbeatRetry extends Command
+ private case object ReleaseRetry extends Command
sealed trait Response
case object LeaseAcquired extends Response
@@ -77,8 +96,14 @@ private[pekko] object LeaseActor {
case object LeaseReleased extends Response with DeadLetterSuppression
case class InvalidRequest(reason: String) extends Response with
DeadLetterSuppression
- def props(k8sApi: KubernetesApi, settings: LeaseSettings, leaseName: String,
granted: AtomicBoolean): Props = {
- Props(new LeaseActor(k8sApi, settings, leaseName, granted))
+ def props(
+ k8sApi: KubernetesApi,
+ settings: LeaseSettings,
+ leaseName: String,
+ granted: AtomicBoolean,
+ heartbeatMaxRetries: Int,
+ releaseMaxRetries: Int): Props = {
+ Props(new LeaseActor(k8sApi, settings, leaseName, granted,
heartbeatMaxRetries, releaseMaxRetries))
}
}
@@ -87,8 +112,13 @@ private[pekko] object LeaseActor {
* INTERNAL API
*/
@InternalApi
-private[pekko] class LeaseActor(k8sApi: KubernetesApi, settings:
LeaseSettings, leaseName: String,
- granted: AtomicBoolean)
+private[pekko] class LeaseActor(
+ k8sApi: KubernetesApi,
+ settings: LeaseSettings,
+ leaseName: String,
+ granted: AtomicBoolean,
+ heartbeatMaxRetries: Int,
+ releaseMaxRetries: Int)
extends LoggingFSM[LeaseActor.State, LeaseActor.Data] {
import pekko.pattern.pipe
@@ -113,10 +143,11 @@ private[pekko] class LeaseActor(k8sApi: KubernetesApi,
settings: LeaseSettings,
when(PendingRead) {
// Lock not taken
- case Event(ReadResponse(LeaseResource(None, version, _)),
PendingReadData(who, leaseLost)) =>
- tryGetLease(version, who, leaseLost)
- case Event(ReadResponse(LeaseResource(Some(currentOwner), version, time)),
PendingReadData(who, leaseLost))
- if currentOwner == ownerName =>
+ case Event(ReadResponse(LeaseResource(None, version, _)), prd @
PendingReadData(who, leaseLost, _)) =>
+ tryGetLease(version, who, leaseLost, prd.alsoReplyTo)
+ case Event(
+ ReadResponse(LeaseResource(Some(currentOwner), version, time)),
+ prd @ PendingReadData(who, leaseLost, _)) if currentOwner ==
ownerName =>
// We have the lock from a different incarnation
if (hasLeaseTimedOut(time)) {
log.warning(
@@ -125,26 +156,27 @@ private[pekko] class LeaseActor(k8sApi: KubernetesApi,
settings: LeaseSettings,
leaseName,
ownerName,
time)
- tryGetLease(version, who, leaseLost)
+ tryGetLease(version, who, leaseLost, prd.alsoReplyTo)
} else {
log.warning(
"Lease {} requested by client {} is already owned by client.
Previous lease was not released due to ungraceful shutdown. " +
"Lease is still within timeout so granting immediately",
leaseName,
ownerName)
- who ! LeaseAcquired
+ replyToAll(prd, LeaseAcquired)
goto(Granted).using(GrantedVersion(version, leaseLost))
}
- case Event(ReadResponse(LeaseResource(Some(currentOwner), version, time)),
PendingReadData(who, leaseLost)) =>
+ case Event(ReadResponse(LeaseResource(Some(currentOwner), version, time)),
+ prd @ PendingReadData(who, leaseLost, _)) =>
if (hasLeaseTimedOut(time)) {
log.warning(
"Lease {} has reached TTL. Owner {} has failed to heartbeat, have
they crashed?. Allowing {} to try and take lease",
leaseName,
currentOwner,
ownerName)
- tryGetLease(version, who, leaseLost)
+ tryGetLease(version, who, leaseLost, prd.alsoReplyTo)
} else {
- who ! LeaseTaken
+ replyToAll(prd, LeaseTaken)
// Even though we have a version there is no benefit to storing it as
we can't update a lease that has a client
goto(Idle).using(ReadRequired)
}
@@ -153,58 +185,92 @@ private[pekko] class LeaseActor(k8sApi: KubernetesApi,
settings: LeaseSettings,
when(Granting) {
case Event(
WriteResponse(Right(response)),
- cc @ OperationInProgress(who, oldVersion, leaseLost,
operationStartTime)) =>
+ cc @ OperationInProgress(_, oldVersion, leaseLost,
operationStartTime, _, _)) =>
require(
oldVersion != response.version,
s"Update response from Kubernetes API should not return the same
version: Response: $response. Client: $cc")
val operationDuration = System.nanoTime() - operationStartTime
if (operationDuration >
(settings.timeoutSettings.heartbeatTimeout.toNanos / 2)) {
log.warning("API server took too long to respond to update: {}. ",
operationDuration.nanos.pretty)
- who ! Failure(
- new LeaseTimeoutException(s"API server took too long to respond:
${operationDuration.nanos.pretty}"))
+ replyToAll(cc,
+ Failure(new LeaseTimeoutException(s"API server took too long to
respond: ${operationDuration.nanos.pretty}")))
goto(Idle).using(ReadRequired)
} else {
granted.set(true)
- who ! LeaseAcquired
+ replyToAll(cc, LeaseAcquired)
goto(Granted).using(GrantedVersion(response.version, leaseLost))
}
- case Event(WriteResponse(Left(LeaseResource(None, version, _))),
OperationInProgress(who, oldVersion, _, _)) =>
+ case Event(WriteResponse(Left(LeaseResource(None, version, _))),
+ op @ OperationInProgress(_, oldVersion, _, startTime, _, _)) =>
require(oldVersion != version)
- who ! LeaseAcquired
- // Try again as lock version has moved on but is not taken
- pipe(k8sApi.updateLeaseResource(leaseName, ownerName, version).map(r =>
WriteResponse(r))).to(self)
- stay()
- case Event(WriteResponse(Left(LeaseResource(Some(_), _, _))),
OperationInProgress(who, _, _, _)) =>
+ val operationDuration = (System.nanoTime() - startTime).nanos
+ if (operationDuration > settings.timeoutSettings.operationTimeout) {
+ // The lease version keeps moving on. Give up rather than retrying for
longer than the
+ // caller is prepared to wait, otherwise the lease could be granted
after the caller has
+ // already been told the acquire failed.
+ log.warning(
+ "Failed to acquire lease {} for owner {} after {}: lease version
kept moving on.",
+ leaseName,
+ ownerName,
+ operationDuration.pretty)
+ replyToAll(op,
+ Failure(new LeaseTimeoutException(
+ s"Timed out trying to acquire lease [$leaseName, $ownerName] after
${operationDuration.pretty}")))
+ goto(Idle).using(ReadRequired)
+ } else {
+ // Try again as lock version has moved on but is not taken.
+ // Do not reply yet — wait for the retry to succeed. Record the
version being attempted so
+ // that a subsequent conflict is compared against it rather than
against the original version.
+ pipe(k8sApi.updateLeaseResource(leaseName, ownerName, version).map(r
=> WriteResponse(r))).to(self)
+ stay().using(op.copy(version = version))
+ }
+ case Event(WriteResponse(Left(LeaseResource(Some(_), _, _))), op:
OperationInProgress) =>
// The audacity, someone else has taken the lease :(
- who ! LeaseTaken
+ replyToAll(op, LeaseTaken)
goto(Idle).using(ReadRequired) // can't use version as another owner has
the lock
}
when(Granted) {
- case Event(Heartbeat, GrantedVersion(version, _)) =>
+ case Event(Heartbeat, GrantedVersion(version, _, _)) =>
log.debug("Heartbeat: updating lease time. Version {}", version)
pipe(k8sApi.updateLeaseResource(leaseName, ownerName,
version).map(WriteResponse.apply)).to(self)
stay()
+ case Event(HeartbeatRetry, GrantedVersion(version, _, _)) =>
+ log.debug("Heartbeat retry: updating lease time. Version {}", version)
+ pipe(k8sApi.updateLeaseResource(leaseName, ownerName,
version).map(WriteResponse.apply)).to(self)
+ stay()
case Event(WriteResponse(Right(resource)), gv: GrantedVersion) =>
require(
resource.owner.contains(ownerName),
"response from API server has different owner for success: " +
resource)
log.debug("Heartbeat: lease time updated: Version {}", resource.version)
startSingleTimer("heartbeat", Heartbeat,
settings.timeoutSettings.heartbeatInterval)
- stay().using(gv.copy(version = resource.version))
- case Event(WriteResponse(Left(lr @ _)), GrantedVersion(_, leaseLost)) =>
+ stay().using(gv.copy(version = resource.version, heartbeatFailures = 0))
+ case Event(WriteResponse(Left(lr @ _)), GrantedVersion(_, leaseLost, _)) =>
log.warning("Conflict during heartbeat to lease {}. Lease assumed to be
released.", lr)
granted.set(false)
executeLeaseLockCallback(leaseLost, None)
goto(Idle).using(ReadRequired)
- case Event(Failure(t), GrantedVersion(_, leaseLost)) =>
- // FIXME, retry if timeout far enough off:
https://github.com/lightbend/akka-commercial-addons/issues/501
- log.warning("Failure during heartbeat to lease: [{}]. Lease assumed to
be released.", t.getMessage)
- granted.set(false)
- executeLeaseLockCallback(leaseLost, Some(t))
- goto(Idle).using(ReadRequired)
- case Event(Release(), GrantedVersion(version, leaseLost)) =>
+ case Event(Failure(t), gv @ GrantedVersion(_, leaseLost, failures)) =>
+ if (failures < heartbeatMaxRetries) {
+ log.warning(
+ "Failure during heartbeat to lease: [{}]. Retrying (attempt {}/{}).",
+ t.getMessage,
+ failures + 1,
+ heartbeatMaxRetries)
+ val retryDelay = settings.timeoutSettings.heartbeatInterval /
(heartbeatMaxRetries + 1)
+ startSingleTimer("heartbeat-retry", HeartbeatRetry, retryDelay)
+ stay().using(gv.copy(heartbeatFailures = failures + 1))
+ } else {
+ log.warning(
+ "Failure during heartbeat to lease: [{}]. Retries exhausted. Lease
assumed to be released.",
+ t.getMessage)
+ granted.set(false)
+ executeLeaseLockCallback(leaseLost, Some(t))
+ goto(Idle).using(ReadRequired)
+ }
+ case Event(Release(), GrantedVersion(version, leaseLost, _)) =>
pipe(k8sApi.updateLeaseResource(leaseName, "",
version).map(WriteResponse.apply)).to(self)
goto(Releasing).using(OperationInProgress(sender(), version, leaseLost))
case Event(Acquire(leaseLostCallback), gv: GrantedVersion) =>
@@ -221,33 +287,69 @@ private[pekko] class LeaseActor(k8sApi: KubernetesApi,
settings: LeaseSettings,
}
when(Releasing) {
- // FIXME deal with failure from releasing the the lock, currently handled
in whenUnhandled but could retry to remove:
https://github.com/lightbend/akka-commercial-addons/issues/502
- case Event(WriteResponse(Right(lr)), OperationInProgress(who, _, _, _)) =>
+ case Event(WriteResponse(Right(lr)), OperationInProgress(who, _, _, _, _,
_)) =>
require(lr.owner.isEmpty, "Released lease has unexpected owner: " + lr)
who ! LeaseReleased
goto(Idle).using(LeaseCleared(lr.version))
- case Event(WriteResponse(Left(lr @ LeaseResource(None, _, _))),
OperationInProgress(who, _, _, _)) =>
+ case Event(WriteResponse(Left(lr @ LeaseResource(None, _, _))),
OperationInProgress(who, _, _, _, _, _)) =>
log.warning(
"Release conflict and owner has been removed: {}. Lease will continue
to work but TTL must have been reached to allow another node to remove lease.",
lr)
who ! LeaseReleased
goto(Idle).using(ReadRequired)
- case Event(WriteResponse(Left(lr @ LeaseResource(Some(_), _, _))),
OperationInProgress(who, _, _, _)) =>
+ case Event(WriteResponse(Left(lr @ LeaseResource(Some(_), _, _))),
OperationInProgress(who, _, _, _, _, _)) =>
log.warning(
"Release conflict and owner has changed: {}. Lease will continue to
work but TTL must have been reached to allow another node to update the lease.",
lr)
who ! LeaseReleased
goto(Idle).using(ReadRequired)
+ case Event(Failure(t), op @ OperationInProgress(who, _, _, startTime,
retries, _)) =>
+ // Pace release retries off the lease operation timeout rather than the
heartbeat interval:
+ // the caller is waiting on an ask that uses the operation timeout, so
retries that run past
+ // it would leave the caller with an ask timeout and the reply in dead
letters.
+ val operationTimeout = settings.timeoutSettings.operationTimeout
+ val retryDelay = operationTimeout / (releaseMaxRetries + 1)
+ val elapsed = (System.nanoTime() - startTime).nanos
+ if (retries < releaseMaxRetries && (elapsed + retryDelay) <
operationTimeout) {
+ log.warning(
+ "Failure releasing lease: [{}]. Retrying in {} (attempt {}/{}).",
+ t.getMessage,
+ retryDelay.pretty,
+ retries + 1,
+ releaseMaxRetries)
+ startSingleTimer("release-retry", ReleaseRetry, retryDelay)
+ stay().using(op.copy(releaseRetries = retries + 1))
+ } else {
+ log.warning("Failure releasing lease: [{}]. Retries exhausted.",
t.getMessage)
+ who ! Failure(t)
+ goto(Idle).using(ReadRequired)
+ }
+ case Event(ReleaseRetry, OperationInProgress(_, version, _, _, _, _)) =>
+ log.debug("Release retry: releasing lease. Version {}", version)
+ pipe(k8sApi.updateLeaseResource(leaseName, "",
version).map(WriteResponse.apply)).to(self)
+ stay()
+ case Event(Acquire(_), _) =>
+ // Acquiring while a release of the same lease is in flight is
contradictory, so unlike an
+ // acquire during an in-flight acquire this is rejected rather than
queued.
+ log.info(
+ "Acquire request for owner {} lease {} while a release is in
progress.",
+ ownerName,
+ leaseName)
+ sender() ! InvalidRequest("Tried to acquire a lease while a release is
in progress")
+ stay()
}
whenUnhandled {
- case Event(Acquire(_), data @ _) =>
+ case Event(Acquire(leaseLostCallback), data: ReplyRequired) =>
+ // An acquire for the same lease is already in flight. Queue this caller
rather than
+ // rejecting it: they all want the same outcome and will get the same
response.
log.info(
- "Acquire request for owner {} lease {} while previous acquire/release
still in progress. Current state: {}",
+ "Acquire request for owner {} lease {} while a previous acquire is
still in progress, " +
+ "the caller will get the result of that acquire. Current state: {}",
ownerName,
leaseName,
stateName)
- stay().using(data)
+ stay().using(addAcquirer(data, sender(), leaseLostCallback))
case Event(Release(), data @ _) =>
log.info(
"Release request for owner {} lease {} while previous acquire/release
still in progress. Current state: {}",
@@ -263,24 +365,43 @@ private[pekko] class LeaseActor(k8sApi: KubernetesApi,
settings: LeaseSettings,
leaseName,
t.getMessage,
stateName)
- replyRequired.replyTo ! Failure(t)
+ replyToAll(replyRequired, Failure(t))
goto(Idle).using(ReadRequired)
}
+ private def replyToAll(data: ReplyRequired, response: Any): Unit =
+ data.allReplyTo.foreach(_ ! response)
+
+ /**
+ * Add a caller that asked to acquire the lease while an acquire was already
in flight. The most
+ * recently supplied lease lost callback wins, matching the re-acquire
behaviour in `Granted`.
+ */
+ private def addAcquirer(data: ReplyRequired, who: ActorRef, leaseLost:
Option[Throwable] => Unit): Data =
+ data match {
+ case prd: PendingReadData =>
+ prd.copy(leaseLostCallback = leaseLost, alsoReplyTo = prd.alsoReplyTo
+ who)
+ case op: OperationInProgress =>
+ op.copy(leaseLostCallback = leaseLost, alsoReplyTo = op.alsoReplyTo +
who)
+ }
+
onTransition {
case _ -> Granted =>
startSingleTimer("heartbeat", Heartbeat,
settings.timeoutSettings.heartbeatInterval)
case Granted -> _ =>
cancelTimer("heartbeat")
+ cancelTimer("heartbeat-retry")
granted.set(false)
+ case Releasing -> _ =>
+ cancelTimer("release-retry")
}
private def tryGetLease(
version: String,
reply: ActorRef,
- leaseLost: Option[Throwable] => Unit): FSM.State[LeaseActor.State, Data]
= {
+ leaseLost: Option[Throwable] => Unit,
+ alsoReplyTo: Set[ActorRef]): FSM.State[LeaseActor.State, Data] = {
pipe(k8sApi.updateLeaseResource(leaseName, ownerName, version).map(r =>
WriteResponse(r))).to(self)
- goto(Granting).using(OperationInProgress(reply, version, leaseLost))
+ goto(Granting).using(OperationInProgress(reply, version, leaseLost,
alsoReplyTo = alsoReplyTo))
}
private def hasLeaseTimedOut(leaseTime: Long): Boolean = {
diff --git
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/internal/NativeKubernetesApiImpl.scala
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/internal/NativeKubernetesApiImpl.scala
index 40a47bf7..d6e2a2f7 100644
---
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/internal/NativeKubernetesApiImpl.scala
+++
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/internal/NativeKubernetesApiImpl.scala
@@ -39,6 +39,7 @@ object NativeKubernetesApiImpl {
new DateTimeFormatterBuilder().parseDefaulting(ChronoField.OFFSET_SECONDS,
0).append(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")).optionalStart.appendFraction(
ChronoField.NANO_OF_SECOND, 6, 6,
true).optionalEnd.appendLiteral("Z").toFormatter
+ private val UTC_FORMATTER: DateTimeFormatter =
RFC3339MICRO_FORMATTER.withZone(ZoneId.of("UTC"))
}
/**
@@ -182,7 +183,7 @@ object NativeKubernetesApiImpl {
}
private def currentTimeRFC3339: String = {
- RFC3339MICRO_FORMATTER.withZone(ZoneId.of("UTC")).format(Instant.now())
+ NativeKubernetesApiImpl.UTC_FORMATTER.format(Instant.now())
}
private def toLeaseResource(lcr: NativeLeaseResource) = {
@@ -195,7 +196,7 @@ object NativeKubernetesApiImpl {
case other => Some(other)
}
LeaseResource(owner, lcr.metadata.resourceVersion.get,
- LocalDateTime.parse(lcr.spec.acquireTime, RFC3339MICRO_FORMATTER)
+ LocalDateTime.parse(lcr.spec.acquireTime,
NativeKubernetesApiImpl.RFC3339MICRO_FORMATTER)
.atZone(ZoneId.of("UTC"))
.toInstant
.toEpochMilli)
diff --git
a/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettingsSpec.scala
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettingsSpec.scala
index dbbf9c2f..ae1e585f 100644
---
a/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettingsSpec.scala
+++
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettingsSpec.scala
@@ -59,6 +59,28 @@ class KubernetesSettingsSpec extends AnyWordSpec with
Matchers {
"support on-truncate-add-hash-length override" in {
conf("on-truncate-add-hash-length=12").onTruncateAddHashLength
shouldEqual 12
}
+ "default heartbeat-max-retries to 3" in {
+ conf("").heartbeatMaxRetries shouldEqual 3
+ }
+ "support heartbeat-max-retries override" in {
+ conf("heartbeat-max-retries=5").heartbeatMaxRetries shouldEqual 5
+ }
+ "default release-max-retries to 3" in {
+ conf("").releaseMaxRetries shouldEqual 3
+ }
+ "support release-max-retries override" in {
+ conf("release-max-retries=1").releaseMaxRetries shouldEqual 1
+ }
+ "not allow a negative heartbeat-max-retries" in {
+ intercept[IllegalArgumentException] {
+ conf("heartbeat-max-retries=-1")
+ }.getMessage shouldEqual "requirement failed: 'heartbeat-max-retries'
must not be negative"
+ }
+ "not allow a negative release-max-retries" in {
+ intercept[IllegalArgumentException] {
+ conf("release-max-retries=-1")
+ }.getMessage shouldEqual "requirement failed: 'release-max-retries' must
not be negative"
+ }
"not allow server request timeout greater than operation timeout" in {
intercept[IllegalArgumentException] {
conf("""
diff --git
a/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/LeaseActorSpec.scala
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/LeaseActorSpec.scala
index 02037f74..14e816cc 100644
---
a/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/LeaseActorSpec.scala
+++
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/LeaseActorSpec.scala
@@ -19,9 +19,9 @@ import org.apache.pekko
import pekko.actor.Status.Failure
import pekko.actor.{ ActorRef, ActorSystem }
import pekko.coordination.lease.kubernetes.LeaseActor._
-import pekko.coordination.lease.{ LeaseException, LeaseSettings,
TimeoutSettings }
+import pekko.coordination.lease.{ LeaseException, LeaseSettings,
LeaseTimeoutException, TimeoutSettings }
import pekko.pattern.ask
-import pekko.testkit.{ TestKit, TestProbe }
+import pekko.testkit.{ TestDuration, TestKit, TestProbe }
import pekko.util.{ ConstantFun, Timeout }
import com.typesafe.config.ConfigFactory
import org.scalatest.BeforeAndAfterAll
@@ -68,6 +68,10 @@ class LeaseActorSpec
val leaseName = "sbr"
+ // How far the lease version is moved on by other clients when simulating a
conflict. Any value
+ // greater than zero works: the test only needs a version ahead of the one
the actor sent.
+ val otherClientUpdates = 6
+
"LeaseActor" should {
// TODO what if the same client asks for the lease when granting? respond
to both or ignore?
@@ -174,9 +178,7 @@ class LeaseActorSpec
val k8sApiFailure = new LeaseException("Failed to communicate with API
server")
acquireLease()
underTest ! Release()
- updateProbe.expectMsg(("", currentVersion))
- incrementVersion()
- updateProbe.reply(Failure(k8sApiFailure))
+ failAllReleaseAttempts(k8sApiFailure)
senderProbe.expectMsg(Failure(k8sApiFailure))
}
@@ -214,12 +216,13 @@ class LeaseActorSpec
// Version from the previous lock so can skip the read of the resource
unless the CAS fails
underTest ! LeaseActor.Acquire()
updateProbe.expectMsg((ownerName, currentVersion))
- // Fail due to cas, version has moved on by 6 but no one owns the lock
- val failedVersion = currentVersionCount + 6
+ // Fail due to cas, the version has moved on but no one owns the lock
+ val failedVersion = currentVersionCount + otherClientUpdates
updateProbe.reply(Left(LeaseResource(None, failedVersion.toString,
System.currentTimeMillis())))
- // Try again
+ // Try again, a successful update moves the version on again
updateProbe.expectMsg((ownerName, failedVersion.toString))
- updateProbe.reply(Right(LeaseResource(Some(ownerName),
failedVersion.toString, System.currentTimeMillis())))
+ currentVersionCount = failedVersion + 1
+ updateProbe.reply(Right(LeaseResource(Some(ownerName), currentVersion,
System.currentTimeMillis())))
senderProbe.expectMsg(LeaseAcquired)
}
@@ -250,30 +253,26 @@ class LeaseActorSpec
}
}
- "heartbeat fail should set granted to false" in new Test {
+ "heartbeat fail should set granted to false after retries exhausted" in
new Test {
val k8sApiFailure = new LeaseException("Failed to communicate with API
server")
acquireLease()
expectHeartBeat()
granted.get() shouldEqual true
- updateProbe.expectMsg((ownerName, currentVersion))
- incrementVersion()
- updateProbe.reply(Failure(k8sApiFailure))
+ failAllHeartbeatAttempts(k8sApiFailure)
awaitAssert {
granted.get() shouldEqual false
}
}
- "heartbeat fail should call lease lost callback" in new Test {
+ "heartbeat fail should call lease lost callback after retries exhausted"
in new Test {
val k8sApiFailure = new LeaseException("Failed to communicate with API
server")
@volatile var callbackCalled: Option[Throwable] = None
acquireLease(reason => callbackCalled = reason)
expectHeartBeat()
granted.get() shouldEqual true
- updateProbe.expectMsg((ownerName, currentVersion))
- incrementVersion()
- updateProbe.reply(Failure(k8sApiFailure))
+ failAllHeartbeatAttempts(k8sApiFailure)
awaitAssert {
callbackCalled shouldEqual Some(k8sApiFailure)
}
@@ -293,10 +292,80 @@ class LeaseActorSpec
acquireLease()
}
- "lease acquire in reading state" in new Test {
- // TODO this could accumulate senders and reply to all, atm it'll log
saying
- // previous action hasn't finished
- pending
+ "reply LeaseAcquired to both callers when acquire arrives while read is
pending" in new Test {
+ val secondSender = TestProbe()
+ underTest.tell(LeaseActor.Acquire(), senderProbe.ref)
+ leaseProbe.expectMsg(leaseName)
+
+ // second acquire while first is still pending, only one read/update is
issued for both
+ underTest.tell(LeaseActor.Acquire(), secondSender.ref)
+ leaseProbe.expectNoMessage(100.millis)
+
+ leaseProbe.reply(LeaseResource(None, currentVersion,
System.currentTimeMillis()))
+ updateProbe.expectMsg((ownerName, currentVersion))
+ incrementVersion()
+ updateProbe.reply(Right(LeaseResource(Some(ownerName), currentVersion,
System.currentTimeMillis())))
+
+ senderProbe.expectMsg(LeaseAcquired)
+ secondSender.expectMsg(LeaseAcquired)
+ }
+
+ "reply LeaseAcquired to both callers when acquire arrives while grant is
in progress" in new Test {
+ val secondSender = TestProbe()
+ underTest.tell(LeaseActor.Acquire(), senderProbe.ref)
+ leaseProbe.expectMsg(leaseName)
+ leaseProbe.reply(LeaseResource(None, currentVersion,
System.currentTimeMillis()))
+ updateProbe.expectMsg((ownerName, currentVersion))
+
+ // second acquire while granting, no extra update is issued
+ underTest.tell(LeaseActor.Acquire(), secondSender.ref)
+ updateProbe.expectNoMessage(100.millis)
+
+ incrementVersion()
+ updateProbe.reply(Right(LeaseResource(Some(ownerName), currentVersion,
System.currentTimeMillis())))
+
+ senderProbe.expectMsg(LeaseAcquired)
+ secondSender.expectMsg(LeaseAcquired)
+ }
+
+ "reply LeaseTaken to both callers when the lease turns out to be taken" in
new Test {
+ val secondSender = TestProbe()
+ underTest.tell(LeaseActor.Acquire(), senderProbe.ref)
+ leaseProbe.expectMsg(leaseName)
+ underTest.tell(LeaseActor.Acquire(), secondSender.ref)
+
+ leaseProbe.reply(LeaseResource(Some("someone else"), currentVersion,
System.currentTimeMillis()))
+
+ senderProbe.expectMsg(LeaseTaken)
+ secondSender.expectMsg(LeaseTaken)
+ }
+
+ "reply the failure to both callers when the in progress acquire fails" in
new Test {
+ val k8sApiFailure = new LeaseException("Failed to communicate with API
server")
+ val secondSender = TestProbe()
+ underTest.tell(LeaseActor.Acquire(), senderProbe.ref)
+ leaseProbe.expectMsg(leaseName)
+ underTest.tell(LeaseActor.Acquire(), secondSender.ref)
+
+ leaseProbe.reply(Failure(k8sApiFailure))
+
+ senderProbe.expectMsg(Failure(k8sApiFailure))
+ secondSender.expectMsg(Failure(k8sApiFailure))
+ }
+
+ "reply InvalidRequest when acquire arrives while a release is in progress"
in new Test {
+ val secondSender = TestProbe()
+ acquireLease()
+ underTest ! Release()
+ updateProbe.expectMsg(("", currentVersion))
+
+ underTest.tell(LeaseActor.Acquire(), secondSender.ref)
+ secondSender.expectMsg(InvalidRequest("Tried to acquire a lease while a
release is in progress"))
+
+ // the release itself is unaffected
+ incrementVersion()
+ updateProbe.reply(Right(LeaseResource(None, currentVersion,
System.currentTimeMillis())))
+ senderProbe.expectMsg(LeaseReleased)
}
"return lease taken if conflict when updating lease" in new Test {
@@ -384,10 +453,11 @@ class LeaseActorSpec
trait Test {
val ownerName = "owner1"
+ def timeoutSettings: TimeoutSettings = new TimeoutSettings(25.millis,
250.millis, 1.second)
val leaseSettings: LeaseSettings = new LeaseSettings(
leaseName,
ownerName,
- new TimeoutSettings(25.millis, 250.millis, 1.second),
+ timeoutSettings,
ConfigFactory.empty())
var currentVersionCount = 1
@@ -397,7 +467,11 @@ class LeaseActorSpec
val updateProbe = TestProbe()
val mockKubernetesApi = new MockKubernetesApi(system, leaseProbe.ref,
updateProbe.ref)
val granted = new AtomicBoolean(false)
- val underTest = system.actorOf(LeaseActor.props(mockKubernetesApi,
leaseSettings, leaseSettings.leaseName, granted))
+ def heartbeatMaxRetries: Int = 3
+ def releaseMaxRetries: Int = 3
+ val underTest = system.actorOf(
+ LeaseActor.props(mockKubernetesApi, leaseSettings,
leaseSettings.leaseName, granted, heartbeatMaxRetries,
+ releaseMaxRetries))
val senderProbe = TestProbe()
implicit val sender: ActorRef = senderProbe.ref
@@ -476,13 +550,248 @@ class LeaseActorSpec
}
}
+ /**
+ * Fail the initial heartbeat and every retry of it. A failed heartbeat
does not move the lease
+ * version on, so the same version is expected for each attempt.
+ */
+ def failAllHeartbeatAttempts(failure: Throwable): Unit =
+ for (_ <- 0 to heartbeatMaxRetries) {
+ updateProbe.expectMsg((ownerName, currentVersion))
+ updateProbe.reply(Failure(failure))
+ }
+
+ /** Fail the initial release and every retry of it. */
+ def failAllReleaseAttempts(failure: Throwable): Unit =
+ for (_ <- 0 to releaseMaxRetries) {
+ updateProbe.expectMsg(("", currentVersion))
+ updateProbe.reply(Failure(failure))
+ }
+
def heartBeatFailure(): Unit = {
+ failAllHeartbeatAttempts(new LeaseException("Failed to communicate with
API server"))
+ awaitAssert {
+ granted.get() shouldEqual false
+ }
+ }
+
+ }
+
+ trait NoRetryTest extends Test {
+ override def heartbeatMaxRetries: Int = 0
+ override def releaseMaxRetries: Int = 0
+ }
+
+ "LeaseActor with retries disabled" should {
+
+ "immediately release lease on heartbeat failure" in new NoRetryTest {
+ acquireLease()
+ expectHeartBeat()
+ granted.get() shouldEqual true
+
+ heartBeatFailure()
+ }
+
+ "call lease lost callback immediately on heartbeat failure" in new
NoRetryTest {
+ @volatile var callbackCalled: Option[Throwable] = None
+ acquireLease(reason => callbackCalled = reason)
+ expectHeartBeat()
+ granted.get() shouldEqual true
+
+ failAllHeartbeatAttempts(new LeaseException("Failed to communicate with
API server"))
+ awaitAssert {
+ callbackCalled shouldBe defined
+ }
+ }
+
+ "allow re-acquire after immediate heartbeat failure" in new NoRetryTest {
+ acquireLease()
+ expectHeartBeat()
+ heartBeatFailure()
+ acquireLease()
+ }
+
+ "immediately report release failure with no retries" in new NoRetryTest {
+ val k8sApiFailure = new LeaseException("Failed to communicate with API
server")
+ acquireLease()
+ underTest ! Release()
+ updateProbe.expectMsg(("", currentVersion))
+ updateProbe.reply(Failure(k8sApiFailure))
+ senderProbe.expectMsg(Failure(k8sApiFailure))
+ }
+
+ "allow re-acquire after immediate release failure" in new NoRetryTest {
+ acquireLease()
+ underTest ! Release()
+ updateProbe.expectMsg(("", currentVersion))
+ updateProbe.reply(Failure(new LeaseException("Failed")))
+ senderProbe.expectMsgType[Failure]
+ acquireLease()
+ }
+
+ }
+
+ trait ShortOperationTimeoutTest extends Test {
+ // an operation timeout that is already spent by the time the first
conflict is handled
+ override def timeoutSettings: TimeoutSettings = new
TimeoutSettings(25.millis, 250.millis, 1.nano)
+ }
+
+ trait ReleaseRetryTimingTest extends Test {
+ // heartbeat-interval is deliberately far larger than the operation
timeout: if release retries
+ // were paced off the heartbeat interval they would be 5s apart and the
probe would never see them
+ override def timeoutSettings: TimeoutSettings = new
TimeoutSettings(20.seconds, 60.seconds, 1.second)
+ }
+
+ trait IndependentRetryCountsTest extends Test {
+ override def heartbeatMaxRetries: Int = 0
+ override def releaseMaxRetries: Int = 2
+ }
+
+ "LeaseActor retry settings" should {
+
+ "apply heartbeat-max-retries and release-max-retries independently" in new
IndependentRetryCountsTest {
+ val k8sApiFailure = new LeaseException("Failed to communicate with API
server")
+ acquireLease()
+ expectHeartBeat()
+
+ // heartbeat-max-retries is 0, so the lease is given up on the first
failed heartbeat
updateProbe.expectMsg((ownerName, currentVersion))
- incrementVersion()
- updateProbe.reply(Failure(new LeaseException("Failed to communicate with
API server")))
+ updateProbe.reply(Failure(k8sApiFailure))
awaitAssert {
granted.get() shouldEqual false
}
+
+ // release-max-retries is 2, so the release is attempted three times
before the caller is told
+ acquireLease()
+ underTest ! Release()
+ failAllReleaseAttempts(k8sApiFailure)
+ senderProbe.expectMsg(Failure(k8sApiFailure))
+ }
+
+ }
+
+ "LeaseActor acquire conflict retry" should {
+
+ "reply LeaseAcquired only once the retry succeeds" in new Test {
+ acquireLease()
+ releaseLease()
+
+ // Start acquire, will hit a conflict and retry
+ underTest ! LeaseActor.Acquire()
+ updateProbe.expectMsg((ownerName, currentVersion))
+ // Conflict: version moved on, no owner
+ val conflictVersion = currentVersionCount + otherClientUpdates
+ updateProbe.reply(Left(LeaseResource(None, conflictVersion.toString,
System.currentTimeMillis())))
+ // Nothing is reported to the caller until the retry has been answered
+ senderProbe.expectNoMessage(100.millis)
+ // Retry uses the version from the conflict response, and success moves
the version on again
+ updateProbe.expectMsg((ownerName, conflictVersion.toString))
+ currentVersionCount = conflictVersion + 1
+ updateProbe.reply(Right(LeaseResource(Some(ownerName), currentVersion,
System.currentTimeMillis())))
+ senderProbe.expectMsg(LeaseAcquired)
+ granted.get() shouldEqual true
+ }
+
+ "keep retrying while the version keeps moving on" in new Test {
+ acquireLease()
+ releaseLease()
+
+ underTest ! LeaseActor.Acquire()
+ updateProbe.expectMsg((ownerName, currentVersion))
+ // the version moves on again between the first conflict and the retry
landing
+ val firstConflict = currentVersionCount + otherClientUpdates
+ updateProbe.reply(Left(LeaseResource(None, firstConflict.toString,
System.currentTimeMillis())))
+ updateProbe.expectMsg((ownerName, firstConflict.toString))
+ val secondConflict = firstConflict + otherClientUpdates
+ updateProbe.reply(Left(LeaseResource(None, secondConflict.toString,
System.currentTimeMillis())))
+
+ // the third attempt uses the version from the second conflict, not the
original one
+ updateProbe.expectMsg((ownerName, secondConflict.toString))
+ currentVersionCount = secondConflict + 1
+ updateProbe.reply(Right(LeaseResource(Some(ownerName), currentVersion,
System.currentTimeMillis())))
+ senderProbe.expectMsg(LeaseAcquired)
+ granted.get() shouldEqual true
+ }
+
+ "reply LeaseTaken if another owner has the lease by the time the retry
lands" in new Test {
+ acquireLease()
+ releaseLease()
+
+ underTest ! LeaseActor.Acquire()
+ updateProbe.expectMsg((ownerName, currentVersion))
+ val conflictVersion = currentVersionCount + otherClientUpdates
+ updateProbe.reply(Left(LeaseResource(None, conflictVersion.toString,
System.currentTimeMillis())))
+
+ updateProbe.expectMsg((ownerName, conflictVersion.toString))
+ updateProbe.reply(
+ Left(LeaseResource(Some("i got there first"), (conflictVersion +
1).toString, System.currentTimeMillis())))
+ senderProbe.expectMsg(LeaseTaken)
+ granted.get() shouldEqual false
+ }
+
+ "give up and fail the caller once the lease operation timeout is spent" in
new ShortOperationTimeoutTest {
+ underTest ! LeaseActor.Acquire()
+ leaseProbe.expectMsg(leaseName)
+ leaseProbe.reply(LeaseResource(None, currentVersion,
System.currentTimeMillis()))
+ updateProbe.expectMsg((ownerName, currentVersion))
+ incrementVersion()
+ // version has moved on but the lease is not taken, so this would
normally be retried
+ updateProbe.reply(Left(LeaseResource(None, currentVersion,
System.currentTimeMillis())))
+
+ senderProbe.expectMsgType[Failure].cause shouldBe
a[LeaseTimeoutException]
+ // no further retry is issued, the lease is not granted behind the
caller's back
+ updateProbe.expectNoMessage(200.millis)
+ granted.get() shouldEqual false
+ }
+
+ "be able to acquire again after giving up on conflict retries" in new
ShortOperationTimeoutTest {
+ underTest ! LeaseActor.Acquire()
+ leaseProbe.expectMsg(leaseName)
+ leaseProbe.reply(LeaseResource(None, currentVersion,
System.currentTimeMillis()))
+ updateProbe.expectMsg((ownerName, currentVersion))
+ incrementVersion()
+ updateProbe.reply(Left(LeaseResource(None, currentVersion,
System.currentTimeMillis())))
+ senderProbe.expectMsgType[Failure].cause shouldBe
a[LeaseTimeoutException]
+
+ acquireLease()
+ }
+
+ }
+
+ "LeaseActor release retry" should {
+
+ "pace retries off the lease operation timeout, not the heartbeat interval"
in new ReleaseRetryTimingTest {
+ val k8sApiFailure = new LeaseException("Failed to communicate with API
server")
+ acquireLease()
+ val operationTimeout = leaseSettings.timeoutSettings.operationTimeout
+ val start = System.nanoTime()
+ // Paced off the operation timeout these attempts are 250ms apart; paced
off the heartbeat
+ // interval they would be 5s apart and the probe would time out waiting
for them.
+ underTest ! Release()
+ failAllReleaseAttempts(k8sApiFailure)
+ senderProbe.expectMsg(Failure(k8sApiFailure))
+ // the caller is told the outcome before the ask it is waiting on would
have timed out
+ (System.nanoTime() - start).nanos should be < operationTimeout.dilated
+ }
+
+ "retry release on failure and succeed" in new Test {
+ acquireLease()
+ underTest ! Release()
+ // First attempt fails
+ updateProbe.expectMsg(("", currentVersion))
+ updateProbe.reply(Failure(new LeaseException("transient error")))
+ // Retry succeeds
+ updateProbe.expectMsg(("", currentVersion))
+ incrementVersion()
+ updateProbe.reply(Right(LeaseResource(None, currentVersion,
System.currentTimeMillis())))
+ senderProbe.expectMsg(LeaseReleased)
+ }
+
+ "report release failure after retries exhausted" in new Test {
+ val k8sApiFailure = new LeaseException("Failed to communicate with API
server")
+ acquireLease()
+ underTest ! Release()
+ failAllReleaseAttempts(k8sApiFailure)
+ senderProbe.expectMsg(Failure(k8sApiFailure))
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]