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 3d8ae24c better handling of resources when responses are handled (#905)
3d8ae24c is described below

commit 3d8ae24c88bd8d6d2232037678b5f7b15fee7ad6
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Aug 4 18:44:07 2026 +0100

    better handling of resources when responses are handled (#905)
---
 .../discovery/consul/ConsulServiceDiscovery.scala  | 17 +++++++----
 .../internal/AbstractKubernetesApiImpl.scala       | 30 ++++++++++++++------
 .../internal/HttpContactPointBootstrap.scala       | 33 ++++++++++++++++------
 .../management/internal/HealthChecksImpl.scala     | 15 ++++------
 .../kubernetes/KubernetesApiImpl.scala             | 32 ++++++++++++++-------
 5 files changed, 85 insertions(+), 42 deletions(-)

diff --git 
a/discovery-consul/src/main/scala/org/apache/pekko/discovery/consul/ConsulServiceDiscovery.scala
 
b/discovery-consul/src/main/scala/org/apache/pekko/discovery/consul/ConsulServiceDiscovery.scala
index a9294d9d..0cb26734 100644
--- 
a/discovery-consul/src/main/scala/org/apache/pekko/discovery/consul/ConsulServiceDiscovery.scala
+++ 
b/discovery-consul/src/main/scala/org/apache/pekko/discovery/consul/ConsulServiceDiscovery.scala
@@ -20,7 +20,6 @@ import pekko.annotation.ApiMayChange
 import pekko.discovery.ServiceDiscovery.{ Resolved, ResolvedTarget }
 import pekko.discovery.consul.ConsulServiceDiscovery._
 import pekko.discovery.{ Lookup, ServiceDiscovery }
-import pekko.pattern.after
 import org.kiwiproject.consul.Consul
 import org.kiwiproject.consul.async.ConsulResponseCallback
 import org.kiwiproject.consul.model.ConsulResponse
@@ -45,11 +44,17 @@ class ConsulServiceDiscovery(system: ActorSystem) extends 
ServiceDiscovery {
 
   override def lookup(lookup: Lookup, resolveTimeout: FiniteDuration): 
Future[Resolved] = {
     implicit val ec: ExecutionContext = system.dispatcher
-    Future.firstCompletedOf(
-      Seq(
-        after(resolveTimeout, using = system.scheduler)(
-          Future.failed(new TimeoutException(s"Lookup for [$lookup] timed-out, 
within [$resolveTimeout]!"))),
-        lookupInConsul(lookup.serviceName)))
+    // Use a Promise-based pattern instead of Future.firstCompletedOf to avoid 
leaking
+    // the underlying Consul HTTP connections when the timeout fires first.
+    val promise = Promise[Resolved]()
+    val timeoutCancellable = system.scheduler.scheduleOnce(resolveTimeout) {
+      promise.tryFailure(new TimeoutException(s"Lookup for [$lookup] 
timed-out, within [$resolveTimeout]!"))
+    }
+    lookupInConsul(lookup.serviceName).onComplete { result =>
+      timeoutCancellable.cancel()
+      promise.tryComplete(result)
+    }
+    promise.future
   }
 
   private def lookupInConsul(name: String)(implicit executionContext: 
ExecutionContext): Future[Resolved] = {
diff --git 
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/internal/AbstractKubernetesApiImpl.scala
 
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/internal/AbstractKubernetesApiImpl.scala
index 49fb33ab..9cb40e1f 100644
--- 
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/internal/AbstractKubernetesApiImpl.scala
+++ 
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/internal/AbstractKubernetesApiImpl.scala
@@ -24,7 +24,7 @@ import pekko.http.scaladsl.model._
 import pekko.http.scaladsl.model.headers.{ Authorization, OAuth2BearerToken }
 import pekko.http.scaladsl.unmarshalling.Unmarshal
 import pekko.http.scaladsl.{ ConnectionContext, Http, HttpExt, 
HttpsConnectionContext }
-import pekko.pattern.{ after, RetrySupport }
+import pekko.pattern.RetrySupport
 import pekko.pki.kubernetes.PemManagersProvider
 import pekko.stream.scaladsl.{ FileIO, Keep, Sink }
 import pekko.util.ByteString
@@ -32,7 +32,7 @@ import pekko.util.ByteString
 import java.nio.file.{ Files, Paths }
 import javax.net.ssl.SSLContext
 import scala.collection.immutable
-import scala.concurrent.{ ExecutionContext, Future }
+import scala.concurrent.{ ExecutionContext, Future, Promise }
 import scala.util.control.NonFatal
 
 /**
@@ -177,13 +177,25 @@ import scala.util.control.NonFatal
       settings.tokenRetrySettings.randomFactor
     )
 
-    // make sure we always consume response body (in case of timeout)
-    val strictResponse = response.flatMap(_.toStrict(settings.bodyReadTimeout))
-
-    val timeout = after(settings.apiServerRequestTimeout, using = 
system.scheduler)(
-      Future.failed(new LeaseTimeoutException(s"$timeoutMsg. Is the API server 
up?")))
-
-    Future.firstCompletedOf(Seq(strictResponse, timeout))
+    // Use a Promise-based pattern instead of Future.firstCompletedOf so that 
when the
+    // timeout fires first, we properly discard the in-flight response entity 
to release
+    // the HTTP connection back to the pool.
+    val promise = Promise[HttpResponse]()
+    val timeoutCancellable = 
system.scheduler.scheduleOnce(settings.apiServerRequestTimeout) {
+      promise.tryFailure(new LeaseTimeoutException(s"$timeoutMsg. Is the API 
server up?"))
+    }
+    response.onComplete {
+      case scala.util.Success(resp) =>
+        timeoutCancellable.cancel()
+        if (!promise.trySuccess(resp)) {
+          // Timeout already fired — discard the response to release the 
connection
+          
resp.discardEntityBytes()(pekko.stream.Materializer.matFromSystem(system))
+        }
+      case scala.util.Failure(ex) =>
+        timeoutCancellable.cancel()
+        promise.tryFailure(ex)
+    }(system.dispatcher)
+    promise.future.flatMap(_.toStrict(settings.bodyReadTimeout))
   }
 
   protected def readConfigVarFromFilesystem(path: String, name: String): 
Future[Option[String]] = {
diff --git 
a/management-cluster-bootstrap/src/main/scala/org/apache/pekko/management/cluster/bootstrap/internal/HttpContactPointBootstrap.scala
 
b/management-cluster-bootstrap/src/main/scala/org/apache/pekko/management/cluster/bootstrap/internal/HttpContactPointBootstrap.scala
index 85d483ab..195c97d6 100644
--- 
a/management-cluster-bootstrap/src/main/scala/org/apache/pekko/management/cluster/bootstrap/internal/HttpContactPointBootstrap.scala
+++ 
b/management-cluster-bootstrap/src/main/scala/org/apache/pekko/management/cluster/bootstrap/internal/HttpContactPointBootstrap.scala
@@ -18,7 +18,7 @@ import java.security.{ KeyStore, SecureRandom }
 import java.util.concurrent.ThreadLocalRandom
 import java.util.concurrent.TimeoutException
 import javax.net.ssl.{ KeyManager, KeyManagerFactory, SSLContext, TrustManager 
}
-import scala.concurrent.Future
+import scala.concurrent.{ Future, Promise }
 import scala.concurrent.duration._
 
 import org.apache.pekko
@@ -44,7 +44,6 @@ import pekko.http.scaladsl.unmarshalling.Unmarshal
 import pekko.management.cluster.bootstrap.ClusterBootstrapSettings
 import 
pekko.management.cluster.bootstrap.contactpoint.HttpBootstrapJsonProtocol.SeedNodes
 import pekko.management.cluster.bootstrap.contactpoint.{ 
ClusterBootstrapRequests, HttpBootstrapJsonProtocol }
-import pekko.pattern.after
 import pekko.pattern.pipe
 import pekko.pki.kubernetes.PemManagersProvider
 
@@ -133,7 +132,6 @@ private[bootstrap] class HttpContactPointBootstrap(
 
   private val probeInterval = settings.contactPoint.probeInterval
   private val probeRequest = 
ClusterBootstrapRequests.bootstrapSeedNodes(baseUri)
-  private val replyTimeout = Future.failed(new TimeoutException(s"Probing 
timeout of [$baseUri]"))
 
   /**
    * If probing keeps failing until the deadline triggers, we notify the 
parent,
@@ -150,15 +148,34 @@ private[bootstrap] class HttpContactPointBootstrap(
   override def receive = {
     case ProbeTick =>
       log.debug("Probing [{}] for seed nodes...", probeRequest.uri)
-      val reply = if (probeRequest.uri.scheme == "https" && 
useCustomSslContext) {
+      val response = if (probeRequest.uri.scheme == "https" && 
useCustomSslContext) {
         http.singleRequest(probeRequest, settings = 
connectionPoolWithoutRetries,
           connectionContext = clientSslContext)
       } else {
         http.singleRequest(probeRequest, settings = 
connectionPoolWithoutRetries)
-      }.flatMap(handleResponse)
-
-      val afterTimeout = after(settings.contactPoint.probingFailureTimeout, 
context.system.scheduler)(replyTimeout)
-      Future.firstCompletedOf(List(reply, afterTimeout)).pipeTo(self)
+      }
+      // Use a Promise-based pattern instead of Future.firstCompletedOf so 
that when the
+      // timeout fires first, we properly discard the in-flight response 
entity to release
+      // the HTTP connection back to the pool.
+      val promise = Promise[SeedNodes]()
+      val timeoutCancellable = 
context.system.scheduler.scheduleOnce(settings.contactPoint.probingFailureTimeout)
 {
+        promise.tryFailure(new TimeoutException(s"Probing timeout of 
[$baseUri]"))
+      }
+      response.flatMap(handleResponse).onComplete {
+        case scala.util.Success(seedNodes) =>
+          timeoutCancellable.cancel()
+          promise.trySuccess(seedNodes)
+        case scala.util.Failure(ex) =>
+          timeoutCancellable.cancel()
+          promise.tryFailure(ex)
+      }(context.dispatcher)
+      // If timeout fires first, discard the in-flight response to release the 
connection
+      promise.future.failed.foreach { _ =>
+        response.foreach { resp =>
+          
resp.discardEntityBytes()(pekko.stream.Materializer.matFromSystem(context.system))
+        }(context.dispatcher)
+      }(context.dispatcher)
+      promise.future.pipeTo(self)
 
     case Status.Failure(cause) =>
       log.warning("Probing [{}] failed due to: {}", probeRequest.uri, 
cause.getMessage)
diff --git 
a/management/src/main/scala/org/apache/pekko/management/internal/HealthChecksImpl.scala
 
b/management/src/main/scala/org/apache/pekko/management/internal/HealthChecksImpl.scala
index 5b8bb081..b6e0f8a3 100644
--- 
a/management/src/main/scala/org/apache/pekko/management/internal/HealthChecksImpl.scala
+++ 
b/management/src/main/scala/org/apache/pekko/management/internal/HealthChecksImpl.scala
@@ -203,19 +203,16 @@ final private[pekko] class HealthChecksImpl(system: 
ExtendedActorSystem, setting
   }
 
   private def check(checks: immutable.Seq[HealthCheck]): Future[Either[String, 
Unit]] = {
-    val timeout = pekko.pattern.after(settings.checkTimeout, system.scheduler)(
-      Future.failed(new RuntimeException) // will be enriched with which check 
timed out below
-    )
-
     val spawnedChecks: Seq[Future[Either[String, Unit]]] = checks.map { check 
=>
       val checkName = check.getClass.getName
+      // Create a per-check timeout so each check gets its own timer,
+      // avoiding the shared timeout that leaks scheduler resources.
+      val timeout = pekko.pattern.after(settings.checkTimeout, 
system.scheduler)(
+        Future.failed(CheckTimeoutException(s"Check [$checkName] timed out 
after ${settings.checkTimeout}"))
+      )
       Future.firstCompletedOf(
         Seq(
-          timeout.recoverWith {
-            case _: Throwable =>
-              Future.failed(
-                CheckTimeoutException(s"Check [$checkName] timed out after 
${settings.checkTimeout}"))
-          },
+          timeout,
           runCheck(check)
             .map {
               case true  => Right(())
diff --git 
a/rolling-update-kubernetes/src/main/scala/org/apache/pekko/rollingupdate/kubernetes/KubernetesApiImpl.scala
 
b/rolling-update-kubernetes/src/main/scala/org/apache/pekko/rollingupdate/kubernetes/KubernetesApiImpl.scala
index 188f74fd..2a60d99a 100644
--- 
a/rolling-update-kubernetes/src/main/scala/org/apache/pekko/rollingupdate/kubernetes/KubernetesApiImpl.scala
+++ 
b/rolling-update-kubernetes/src/main/scala/org/apache/pekko/rollingupdate/kubernetes/KubernetesApiImpl.scala
@@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets
 import scala.collection.immutable
 import scala.concurrent.ExecutionContext
 import scala.concurrent.Future
+import scala.concurrent.Promise
 import scala.util.control.NonFatal
 
 import org.apache.pekko
@@ -41,7 +42,7 @@ import pekko.http.scaladsl.model._
 import pekko.http.scaladsl.model.headers.Authorization
 import pekko.http.scaladsl.model.headers.OAuth2BearerToken
 import pekko.http.scaladsl.unmarshalling.Unmarshal
-import pekko.pattern.after
+
 import pekko.pki.kubernetes.PemManagersProvider
 import pekko.util.ByteString
 
@@ -274,13 +275,25 @@ PUTs must contain resourceVersions. Response:
       }
     }
 
-    // make sure we always consume response body (in case of timeout)
-    val strictResponse = response.flatMap(_.toStrict(settings.bodyReadTimeout))
-
-    val timeout = after(settings.apiServiceRequestTimeout, using = 
system.scheduler)(
-      Future.failed(new PodCostTimeoutException(s"$timeoutMsg. Is the API 
server up?")))
-
-    Future.firstCompletedOf(Seq(strictResponse, timeout))
+    // Use a Promise-based pattern instead of Future.firstCompletedOf so that 
when the
+    // timeout fires first, we properly discard the in-flight response entity 
to release
+    // the HTTP connection back to the pool.
+    val promise = Promise[HttpResponse]()
+    val timeoutCancellable = 
system.scheduler.scheduleOnce(settings.apiServiceRequestTimeout) {
+      promise.tryFailure(new PodCostTimeoutException(s"$timeoutMsg. Is the API 
server up?"))
+    }
+    response.onComplete {
+      case scala.util.Success(resp) =>
+        timeoutCancellable.cancel()
+        if (!promise.trySuccess(resp)) {
+          // Timeout already fired — discard the response to release the 
connection
+          
resp.discardEntityBytes()(pekko.stream.Materializer.matFromSystem(system))
+        }
+      case scala.util.Failure(ex) =>
+        timeoutCancellable.cancel()
+        promise.tryFailure(ex)
+    }(system.dispatcher)
+    promise.future.flatMap(_.toStrict(settings.bodyReadTimeout))
   }
 
   private def toPodCostResource(cr: PodCostCustomResource) = {
@@ -305,8 +318,7 @@ PUTs must contain resourceVersions. Response:
           Unmarshal(responseEntity).to[PodCostCustomResource].map(cr => 
Some(toPodCostResource(cr)))
         case StatusCodes.Conflict =>
           log.debug("creation of PodCost resource failed as already exists. 
Will attempt to read again")
-          entity.discardBytes()
-          // someone else has created it
+          // response entity already consumed by toStrict above; someone else 
has created it
           Future.successful(None)
         case StatusCodes.Unauthorized =>
           handleUnauthorized(response)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to