This is an automated email from the ASF dual-hosted git repository.

dominikriemer pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to refs/heads/dev by this push:
     new e88e420a66 feat: Make startup recovery more robust (#5168)
e88e420a66 is described below

commit e88e420a6662b84e34c31f2b24b65604095d4b31
Author: Dominik Riemer <[email protected]>
AuthorDate: Sun Nov 1 22:23:04 2026 +0100

    feat: Make startup recovery more robust (#5168)
---
 docker-compose.yml                                 |   2 +
 installer/compose/docker-compose.kafka.yml         |   2 +
 installer/compose/docker-compose.minimal.yml       |   2 +
 installer/compose/docker-compose.yml               |   2 +
 .../streampipes/client/http/HttpRequest.java       |   9 +-
 .../apache/streampipes/commons/constants/Envs.java |   2 +
 .../commons/environment/Environment.java           |  10 +
 .../management/init/RunningInstances.java          |  10 +
 .../function/StreamPipesFunctionHandler.java       |  22 +-
 .../health/monitoring/AdapterHealthCheck.java      |  18 ++
 .../health/monitoring/ExtensionHealthCheck.java    |  55 ++++-
 .../ExtensionInstanceAvailabilityCheck.java        |  66 ++++--
 .../health/monitoring/PipelineHealthCheck.java     |  20 +-
 .../health/monitoring/PipelineRecoveryBackoff.java |  71 ++++---
 ...ata.java => ServiceAvailabilityGeneration.java} |  27 ++-
 .../health/monitoring/ServiceHealthCheck.java      |   4 +
 .../monitoring/ServiceRegistrationManager.java     |   8 +
 .../HealthCheckData.java => ShutdownSignal.java}   |  31 ++-
 .../monitoring/StaleRegistrationCleanup.java       | 105 ++++++++++
 .../health/monitoring/model/HealthCheckData.java   |  16 +-
 .../health/monitoring/utils/HealthCheckUtils.java  |   7 +
 .../health/monitoring/AdapterHealthCheckTest.java  |  45 +++++
 .../monitoring/ExtensionHealthCheckTest.java       | 129 ++++++++++++
 .../ExtensionInstanceAvailabilityCheckTest.java    | 123 ++++++++++++
 .../health/monitoring/PipelineHealthCheckTest.java |  44 +++-
 .../monitoring/PipelineRecoveryBackoffTest.java    |  24 ++-
 .../monitoring/ServiceRegistrationManagerTest.java |  74 +++++++
 .../monitoring/StaleRegistrationCleanupTest.java   | 128 ++++++++++++
 .../impl/admin/ServiceRegistrationResource.java    |   6 +
 .../streampipes/service/core/PostStartupTask.java  |  66 ++++--
 .../service/core/StreamPipesCoreApplication.java   | 146 ++++++++++----
 .../extensions/ExtensionServiceShutdownHook.java   |  22 +-
 .../ExtensionsServiceShutdownHandler.java          | 173 +++++++++++++++-
 .../StreamPipesExtensionsServiceBase.java          |  30 ++-
 .../ExtensionsServiceShutdownHandlerTest.java      | 221 +++++++++++++++++++++
 35 files changed, 1580 insertions(+), 140 deletions(-)

diff --git a/docker-compose.yml b/docker-compose.yml
index 0ada05cfe9..d8aec467e5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,6 +34,7 @@ services:
       context: ./streampipes-service-core
       dockerfile: Dockerfile
     image: streampipes_backend:release-validation
+    stop_grace_period: 60s
     environment:
       - SP_INITIAL_SERVICE_USER_SECRET=${SP_SERVICE_SECRET:?Run the Compose 
configure helper first}
       - SP_CLIENT_SECRET=${SP_SERVICE_SECRET:?Run the Compose configure helper 
first}
@@ -75,6 +76,7 @@ services:
       context: ./streampipes-extensions/streampipes-extensions-all-iiot
       dockerfile: Dockerfile
     image: streampipes_pipeline-elements-all-iiot:release-validation
+    stop_grace_period: 60s
     environment:
       - SP_CLIENT_SECRET=${SP_SERVICE_SECRET:?Run the Compose configure helper 
first}
     logging: *default-logging
diff --git a/installer/compose/docker-compose.kafka.yml 
b/installer/compose/docker-compose.kafka.yml
index 446ead976e..8cb6bfc0dd 100644
--- a/installer/compose/docker-compose.kafka.yml
+++ b/installer/compose/docker-compose.kafka.yml
@@ -41,6 +41,7 @@ services:
       - backend:/root/.streampipes
     logging: *default-logging
     restart: unless-stopped
+    stop_grace_period: 60s
     networks:
       spnet:
 
@@ -128,6 +129,7 @@ services:
       - SP_ENCRYPTION_PASSCODE=${SP_ENCRYPTION_PASSCODE:?Run the Compose 
configure helper first}
     logging: *default-logging
     restart: unless-stopped
+    stop_grace_period: 60s
     networks:
       spnet:
 
diff --git a/installer/compose/docker-compose.minimal.yml 
b/installer/compose/docker-compose.minimal.yml
index 4ae3260fbe..89d106f822 100644
--- a/installer/compose/docker-compose.minimal.yml
+++ b/installer/compose/docker-compose.minimal.yml
@@ -41,6 +41,7 @@ services:
       - backend:/root/.streampipes
     logging: *default-logging
     restart: unless-stopped
+    stop_grace_period: 60s
     networks:
       spnet:
 
@@ -107,6 +108,7 @@ services:
       - SP_ENCRYPTION_PASSCODE=${SP_ENCRYPTION_PASSCODE:?Run the Compose 
configure helper first}
     logging: *default-logging
     restart: unless-stopped
+    stop_grace_period: 60s
     networks:
       spnet:
 
diff --git a/installer/compose/docker-compose.yml 
b/installer/compose/docker-compose.yml
index 3bcd728f85..12d68a2b30 100644
--- a/installer/compose/docker-compose.yml
+++ b/installer/compose/docker-compose.yml
@@ -41,6 +41,7 @@ services:
       - backend:/root/.streampipes
     logging: *default-logging
     restart: unless-stopped
+    stop_grace_period: 60s
     networks:
       spnet:
 
@@ -108,6 +109,7 @@ services:
       - SP_ENCRYPTION_PASSCODE=${SP_ENCRYPTION_PASSCODE:?Run the Compose 
configure helper first}
     logging: *default-logging
     restart: unless-stopped
+    stop_grace_period: 60s
     networks:
       spnet:
 
diff --git 
a/streampipes-client/src/main/java/org/apache/streampipes/client/http/HttpRequest.java
 
b/streampipes-client/src/main/java/org/apache/streampipes/client/http/HttpRequest.java
index 69e324c5b6..1ebdcce84f 100644
--- 
a/streampipes-client/src/main/java/org/apache/streampipes/client/http/HttpRequest.java
+++ 
b/streampipes-client/src/main/java/org/apache/streampipes/client/http/HttpRequest.java
@@ -49,6 +49,13 @@ import java.util.List;
 
 public abstract class HttpRequest<K, V, T> {
 
+  /**
+   * Bounds the connection attempt so that a core whose address no longer 
refuses connections
+   * cannot block the caller indefinitely, for example during an extension 
service's shutdown.
+   * Responses are not bounded, since some requests legitimately take long.
+   */
+  private static final int CONNECT_TIMEOUT_MS = 10_000;
+
   private final StreamPipesClientConfig clientConfig;
   private final ClientConnectionUrlResolver connectionConfig;
   private final StreamPipesApiPath apiPath;
@@ -98,7 +105,7 @@ public abstract class HttpRequest<K, V, T> {
   }
 
   public T executeRequest() {
-    Request request = makeRequest(serializer);
+    Request request = 
makeRequest(serializer).connectTimeout(CONNECT_TIMEOUT_MS);
     try {
       HttpResponse response = request.execute().returnResponse();
       StatusLine status = response.getStatusLine();
diff --git 
a/streampipes-commons/src/main/java/org/apache/streampipes/commons/constants/Envs.java
 
b/streampipes-commons/src/main/java/org/apache/streampipes/commons/constants/Envs.java
index 491a2146f5..fa214d0cda 100644
--- 
a/streampipes-commons/src/main/java/org/apache/streampipes/commons/constants/Envs.java
+++ 
b/streampipes-commons/src/main/java/org/apache/streampipes/commons/constants/Envs.java
@@ -103,6 +103,8 @@ public enum Envs {
 
   
SP_INITIAL_WAIT_BEFORE_INSTALLATION_MS("SP_INITIAL_WAIT_BEFORE_INSTALLATION_MS",
 "5000"),
 
+  SP_SHUTDOWN_WAIT_FOR_EXTENSIONS_MS("SP_SHUTDOWN_WAIT_FOR_EXTENSIONS_MS", 
"5000"),
+
   // Broker defaults
 
   SP_KAFKA_HOST("SP_KAFKA_HOST", "kafka"),
diff --git 
a/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/Environment.java
 
b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/Environment.java
index a49129b62d..4fc1b5ead0 100644
--- 
a/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/Environment.java
+++ 
b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/Environment.java
@@ -150,6 +150,16 @@ public interface Environment {
 
   IntEnvironmentVariable getInitialWaitTimeBeforeInstallationInMillis();
 
+  /**
+   * How long the core keeps serving requests during its shutdown so that 
extension
+   * services stopping at the same time can persist their function state. The 
wait ends
+   * early once every extension service has deregistered. Keep it below the 
container
+   * stop grace period.
+   */
+  default IntEnvironmentVariable getShutdownWaitForExtensionsInMillis() {
+    return new IntEnvironmentVariable(Envs.SP_SHUTDOWN_WAIT_FOR_EXTENSIONS_MS);
+  }
+
   // Broker defaults
   StringEnvironmentVariable getKafkaHost();
 
diff --git 
a/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/init/RunningInstances.java
 
b/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/init/RunningInstances.java
index 712d1de23e..ae4e85e59f 100644
--- 
a/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/init/RunningInstances.java
+++ 
b/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/init/RunningInstances.java
@@ -67,6 +67,16 @@ public enum RunningInstances {
     return runningInstances.size();
   }
 
+  /**
+   * Returns a snapshot of the keys under which runtimes are registered. These 
are the
+   * ids accepted by {@link #getInvocation(String)} and {@link 
#remove(String)}, as
+   * opposed to {@link #getRunningInstanceIds()} which returns the element ids 
of the
+   * descriptions.
+   */
+  public Set<String> getRunningInstanceKeys() {
+    return Set.copyOf(runningInstances.keySet());
+  }
+
   public Set<String> getRunningInstanceIds() {
     return runningInstances
         .entrySet()
diff --git 
a/streampipes-extensions-management/src/main/java/org/apache/streampipes/service/extensions/function/StreamPipesFunctionHandler.java
 
b/streampipes-extensions-management/src/main/java/org/apache/streampipes/service/extensions/function/StreamPipesFunctionHandler.java
index 8dd336d00e..4a0745363c 100644
--- 
a/streampipes-extensions-management/src/main/java/org/apache/streampipes/service/extensions/function/StreamPipesFunctionHandler.java
+++ 
b/streampipes-extensions-management/src/main/java/org/apache/streampipes/service/extensions/function/StreamPipesFunctionHandler.java
@@ -28,6 +28,9 @@ import 
org.apache.streampipes.model.function.FunctionShutdownResult;
 import org.apache.streampipes.model.function.FunctionsShutdownResponse;
 import org.apache.streampipes.sdk.builder.stream.DataStreamConfiguration;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -39,6 +42,8 @@ public enum StreamPipesFunctionHandler {
 
   INSTANCE;
 
+  private static final Logger LOG = 
LoggerFactory.getLogger(StreamPipesFunctionHandler.class);
+
   private final Map<String, IStreamPipesFunctionDeclarer> runningInstances;
 
   StreamPipesFunctionHandler() {
@@ -95,11 +100,24 @@ public enum StreamPipesFunctionHandler {
     });
   }
 
+  /**
+   * Discards every function and collects its registered state. A function 
whose stop hook
+   * fails is logged and skipped; the other functions are still discarded and 
their state is
+   * still returned, since this is the only path that persists it on shutdown.
+   */
   public FunctionsShutdownResponse shutdownFunctionsAndGetState() {
     var shutdownResults = new ArrayList<FunctionShutdownResult>();
     this.runningInstances.forEach((key, value) -> {
-      value.discardRuntime();
-      shutdownResults.add(new FunctionShutdownResult(key, 
value.getRegisteredStatePayload().orElse(null)));
+      try {
+        value.discardRuntime();
+      } catch (RuntimeException e) {
+        LOG.warn("Function {} threw an exception while stopping, its state is 
persisted anyway", key, e);
+      }
+      try {
+        shutdownResults.add(new FunctionShutdownResult(key, 
value.getRegisteredStatePayload().orElse(null)));
+      } catch (RuntimeException e) {
+        LOG.warn("Could not read the state of function {}", key, e);
+      }
     });
     this.runningInstances.clear();
 
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/AdapterHealthCheck.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/AdapterHealthCheck.java
index aedb4b34be..fba77973c4 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/AdapterHealthCheck.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/AdapterHealthCheck.java
@@ -173,6 +173,7 @@ public class AdapterHealthCheck implements HealthCheck {
         .stream()
         .filter(Objects::nonNull)
         .filter(a -> a.getElementId() != null)
+        .filter(a -> !isOnUnreachableService(a))
         .filter(a -> !adapterInstanceStates.containsKey(a.getElementId()))
         .toList();
   }
@@ -182,8 +183,25 @@ public class AdapterHealthCheck implements HealthCheck {
     return adapterInstanceStates == null ? Map.of() : adapterInstanceStates;
   }
 
+  /**
+   * The adapter's service is registered but did not answer. The adapter may 
still run
+   * there, so recovering it on another service could start a duplicate.
+   */
+  private boolean isOnUnreachableService(AdapterDescription adapter) {
+    var serviceId = adapter.getSelectedServiceId();
+    var unreachable = serviceId != null && 
healthCheckData.unreachableServiceIds().contains(serviceId);
+    if (unreachable) {
+      LOG.debug("Adapter {} runs on unreachable service {}, deferring 
recovery", adapter.getElementId(), serviceId);
+    }
+    return unreachable;
+  }
+
   public void recoverAdapters(List<AdapterDescription> adaptersToRecover) {
     for (AdapterDescription adapterDescription : adaptersToRecover) {
+      if (ShutdownSignal.INSTANCE.isShutdownRequested()) {
+        LOG.debug("Shutdown in progress, skipping adapter recovery");
+        return;
+      }
       // Invoke all adapters that were running when the adapter container was 
stopped
       try {
         if (adapterDescription.isRunning()) {
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheck.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheck.java
index 02a8724f2c..28b8a592f0 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheck.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheck.java
@@ -18,6 +18,7 @@
 
 package org.apache.streampipes.health.monitoring;
 
+import org.apache.streampipes.commons.environment.Environments;
 import org.apache.streampipes.health.monitoring.model.HealthCheckData;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
 import org.apache.streampipes.model.health.ExtensionInstanceHealth;
@@ -30,7 +31,11 @@ import org.slf4j.LoggerFactory;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
 
 public class ExtensionHealthCheck implements Runnable {
 
@@ -42,6 +47,7 @@ public class ExtensionHealthCheck implements Runnable {
   private final SpResourceManager resourceManager;
   private final List<HealthCheck> registeredHealthChecks;
   private final PipelineRecoveryBackoff pipelineRecoveryBackoff;
+  private final Map<String, Integer> consecutiveUnknownTicks = new HashMap<>();
 
   public ExtensionHealthCheck(ResourceProvider resourceProvider,
                               IExtensionsServiceStorage 
extensionsServiceStorage,
@@ -74,24 +80,42 @@ public class ExtensionHealthCheck implements Runnable {
 
   @Override
   public void run() {
+    if (ShutdownSignal.INSTANCE.isShutdownRequested()) {
+      LOG.debug("Shutdown in progress, skipping extension health check");
+      return;
+    }
     try {
       var activeResources = resourceProvider.loadActiveResources();
       var activeCoreInstances = 
resourceProvider.loadActiveInstances(activeResources);
 
       var activeExtensionInstances = new HashMap<String, 
ExtensionInstanceHealth>();
+      var unreachableServiceIds = new HashSet<String>();
+      consecutiveUnknownTicks.keySet().retainAll(activeCoreInstances.keySet());
       activeCoreInstances.keySet().forEach(serviceId -> {
-        activeExtensionInstances.put(
-            serviceId,
-            new ExtensionInstanceAvailabilityCheck(
-                extensionsServiceStorage, serviceId, extensionRequestManager, 
resourceManager
-            ).checkRunningInstances());
+        var health = checkInstances(serviceId);
+        if (health.isPresent()) {
+          consecutiveUnknownTicks.remove(serviceId);
+          activeExtensionInstances.put(serviceId, health.get());
+        } else {
+          int unknownTicks = consecutiveUnknownTicks.merge(serviceId, 1, 
Integer::sum);
+          if (unknownTicks >= maxUnknownTicks()) {
+            // The service answers its health probe but not the instance 
request, or the
+            // service health check lags behind. Do not defer recovery forever.
+            LOG.warn("Extension service {} did not answer {} instance health 
requests in a row, "
+                + "treating its instances as not running", serviceId, 
unknownTicks);
+            activeExtensionInstances.put(serviceId, new 
ExtensionInstanceHealth(Map.of(), Set.of()));
+          } else {
+            unreachableServiceIds.add(serviceId);
+          }
+        }
       });
 
       var healthCheckData = new HealthCheckData(
           resourceProvider,
           activeResources,
           activeCoreInstances,
-          activeExtensionInstances
+          activeExtensionInstances,
+          unreachableServiceIds
       );
       var healthChecks = getHealthChecks(healthCheckData);
       runHealthChecks(healthChecks);
@@ -100,8 +124,27 @@ public class ExtensionHealthCheck implements Runnable {
     }
   }
 
+  protected Optional<ExtensionInstanceHealth> checkInstances(String serviceId) 
{
+    return new ExtensionInstanceAvailabilityCheck(
+        extensionsServiceStorage, serviceId, extensionRequestManager, 
resourceManager
+    ).checkReachableRunningInstances();
+  }
+
+  /**
+   * How many consecutive ticks a registered service may leave its instance 
state unknown
+   * before it is treated as not running anything. Aligned with the number of 
failed probes
+   * after which the service health check declares a service unhealthy.
+   */
+  protected int maxUnknownTicks() {
+    return Math.max(1, 
Environments.getEnvironment().getServiceHealthFailureThreshold().getValueOrDefault());
+  }
+
   private void runHealthChecks(List<HealthCheck> healthChecks) {
     healthChecks.forEach(healthCheck -> {
+      if (ShutdownSignal.INSTANCE.isShutdownRequested()) {
+        LOG.debug("Shutdown in progress, skipping health check {}", 
healthCheck.getClass().getSimpleName());
+        return;
+      }
       try {
         healthCheck.runCheck();
       } catch (Exception e) {
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionInstanceAvailabilityCheck.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionInstanceAvailabilityCheck.java
index 69124efd72..c446b19d6a 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionInstanceAvailabilityCheck.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionInstanceAvailabilityCheck.java
@@ -22,6 +22,7 @@ import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestTarg
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestTargets;
 import org.apache.streampipes.manager.api.extensions.ExtensionServiceRequests;
 import 
org.apache.streampipes.model.extensions.svcdiscovery.SpServiceRegistration;
+import org.apache.streampipes.model.extensions.svcdiscovery.SpServiceStatus;
 import org.apache.streampipes.model.health.ExtensionInstanceHealth;
 import org.apache.streampipes.resource.management.SpResourceManager;
 import org.apache.streampipes.serializers.json.JacksonSerializer;
@@ -33,6 +34,7 @@ import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 public class ExtensionInstanceAvailabilityCheck {
@@ -54,31 +56,61 @@ public class ExtensionInstanceAvailabilityCheck {
     this.resourceManager = resourceManager;
   }
 
+  /**
+   * Returns the instances the service reports, or an empty health if the 
service is no
+   * longer registered. An unreachable service is reported as empty too; 
callers that need
+   * to distinguish the two use {@link #checkReachableRunningInstances()}.
+   */
   public ExtensionInstanceHealth checkRunningInstances() {
-    try {
-      var service = extensionsServiceStorage.findAll().stream()
-          .filter(svc -> svc.getSvcId().equals(serviceId))
-          .findFirst();
+    return 
checkReachableRunningInstances().orElseGet(ExtensionInstanceAvailabilityCheck::noInstances);
+  }
 
-      if (service.isEmpty()) {
-        return new ExtensionInstanceHealth(Map.of(), Set.of());
-      } else {
-        var response = extensionRequestManager.request(
-            ExtensionServiceRequests
-                .extensionInstanceHealth(makeRequestTarget(service.get()), 
resourceManager)
-        );
-        if (response.statusCode() != 200) {
-          return new ExtensionInstanceHealth(Map.of(), Set.of());
-        }
-        return deserialize(response.responseBody());
-      }
+  /**
+   * Returns the instances the service reports, an empty health if the service 
is no longer
+   * registered or already marked {@link SpServiceStatus#UNHEALTHY}, and 
{@link Optional#empty()}
+   * if a service that is not known to be unhealthy did not answer. In the 
last case the state
+   * of its instances is unknown and must not be treated as "not running", 
otherwise instances
+   * still running there would be restored a second time elsewhere. Once the 
service health
+   * check has declared the service unhealthy after repeated failed probes, 
its instances are
+   * considered gone so that recovery does not wait for the registration to be 
removed.
+   */
+  public Optional<ExtensionInstanceHealth> checkReachableRunningInstances() {
+    var service = extensionsServiceStorage.findAll().stream()
+        .filter(svc -> svc.getSvcId().equals(serviceId))
+        .findFirst();
 
+    if (service.isEmpty()) {
+      return Optional.of(noInstances());
+    }
+    try {
+      var response = extensionRequestManager.request(
+          ExtensionServiceRequests
+              .extensionInstanceHealth(makeRequestTarget(service.get()), 
resourceManager)
+      );
+      if (response.statusCode() != 200) {
+        LOG.warn("Extension service {} answered the instance health request 
with status {}",
+            serviceId, response.statusCode());
+        return unanswered(service.get());
+      }
+      return Optional.of(deserialize(response.responseBody()));
     } catch (IOException e) {
       LOG.error("Extension service {} is unavailable", serviceId);
-      return new ExtensionInstanceHealth(Map.of(), Set.of());
+      return unanswered(service.get());
     }
   }
 
+  private Optional<ExtensionInstanceHealth> unanswered(SpServiceRegistration 
service) {
+    if (service.getStatus() == SpServiceStatus.UNHEALTHY) {
+      LOG.debug("Extension service {} is marked unhealthy, treating its 
instances as not running", serviceId);
+      return Optional.of(noInstances());
+    }
+    return Optional.empty();
+  }
+
+  private static ExtensionInstanceHealth noInstances() {
+    return new ExtensionInstanceHealth(Map.of(), Set.of());
+  }
+
   private ExtensionInstanceHealth deserialize(String json) throws 
JsonProcessingException {
     return JacksonSerializer.getObjectMapper().readValue(json, 
ExtensionInstanceHealth.class);
   }
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineHealthCheck.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineHealthCheck.java
index 18ae3ada08..579661c7d5 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineHealthCheck.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineHealthCheck.java
@@ -115,7 +115,11 @@ public class PipelineHealthCheck implements HealthCheck {
 
   private void checkAndRestorePipelineElements() {
     recoveryBackoff.retainOnly(getActiveRecoveryKeys());
-    healthCheckData.activeResources().runningPipelines().forEach(pipeline -> {
+    for (Pipeline pipeline : 
healthCheckData.activeResources().runningPipelines()) {
+      if (ShutdownSignal.INSTANCE.isShutdownRequested()) {
+        LOG.debug("Shutdown in progress, skipping pipeline recovery");
+        return;
+      }
       List<String> missingInstances = new ArrayList<>();
       List<String> recoveredInstances = new ArrayList<>();
       List<String> pipelineNotifications = new ArrayList<>();
@@ -128,7 +132,12 @@ public class PipelineHealthCheck implements HealthCheck {
         String instanceId = 
HealthCheckUtils.extractInstanceId(pipelineElement);
         if (isNowhereRunning(instanceId)) {
           missingInstances.add(instanceId);
-          if (recoveryBackoff.isAttemptDue(pipeline.getPipelineId(), 
instanceId)) {
+          if (isOnUnreachableService(pipelineElement)) {
+            // The service is registered but did not answer: the element may 
well still be
+            // running there, so restoring it elsewhere could start a 
duplicate.
+            
HealthCheckUtils.addUnreachableServiceNotification(pipelineNotifications, 
pipelineElement);
+          } else if (!ShutdownSignal.INSTANCE.isShutdownRequested()
+              && recoveryBackoff.isAttemptDue(pipeline.getPipelineId(), 
instanceId)) {
             boolean success = restorePipelineElement(pipelineElement, 
pipeline.getPipelineId());
             if (!success) {
               var state = 
recoveryBackoff.recordFailure(pipeline.getPipelineId(), instanceId);
@@ -174,7 +183,7 @@ public class PipelineHealthCheck implements HealthCheck {
         
pipelinesStats.updatePipelineHealthState(currentPipeline.getElementId(), 
currentPipeline.getName(),
             currentPipeline.getHealthStatus().toString());
       }
-    });
+    }
     int healthNum = pipelinesStats.getRunningPipelines() - 
pipelinesStats.getFailedPipelines()
         - pipelinesStats.getAttentionRequiredPipelines();
     pipelinesStats.setHealthyPipelines(healthNum);
@@ -242,6 +251,11 @@ public class PipelineHealthCheck implements HealthCheck {
     }
   }
 
+  private boolean isOnUnreachableService(InvocableStreamPipesEntity 
pipelineElement) {
+    var serviceId = pipelineElement.getSelectedServiceId();
+    return serviceId != null && 
healthCheckData.unreachableServiceIds().contains(serviceId);
+  }
+
   private boolean isNowhereRunning(String instanceId) {
     return (healthCheckData.activeExtensionInstances().entrySet().stream()
         .noneMatch(entry -> 
entry.getValue().runningPipelineElementInstanceIds().contains(instanceId)));
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoff.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoff.java
index b2181752fc..1559fb3d54 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoff.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoff.java
@@ -23,22 +23,31 @@ import java.time.Instant;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.LongSupplier;
+
+/**
+ * Exponential backoff for recovery attempts of individual instances, keyed by 
pipeline and
+ * instance id. The delay starts at the health check interval and doubles up 
to ten minutes.
+ * An attempt becomes due early when an extension service becomes available, 
see
+ * {@link ServiceAvailabilityGeneration}. Public so that registered {@link 
HealthCheck}
+ * implementations can apply the same policy to the resources they recover.
+ */
+public final class PipelineRecoveryBackoff {
 
-final class PipelineRecoveryBackoff {
-
-  static final Duration DEFAULT_INITIAL_DELAY = Duration.ofSeconds(30);
-  static final Duration DEFAULT_MAX_DELAY = Duration.ofMinutes(10);
+  public static final Duration DEFAULT_INITIAL_DELAY = Duration.ofSeconds(30);
+  public static final Duration DEFAULT_MAX_DELAY = Duration.ofMinutes(10);
 
   private final Clock clock;
   private final Duration initialDelay;
   private final Duration maxDelay;
+  private final LongSupplier availabilityGeneration;
   private final Map<RecoveryKey, RecoveryState> recoveryStates = new 
ConcurrentHashMap<>();
 
-  PipelineRecoveryBackoff() {
+  public PipelineRecoveryBackoff() {
     this(Clock.systemUTC(), DEFAULT_INITIAL_DELAY, DEFAULT_MAX_DELAY);
   }
 
-  PipelineRecoveryBackoff(Duration healthCheckInterval) {
+  public PipelineRecoveryBackoff(Duration healthCheckInterval) {
     this(
         Clock.systemUTC(),
         healthCheckInterval,
@@ -46,42 +55,57 @@ final class PipelineRecoveryBackoff {
     );
   }
 
-  PipelineRecoveryBackoff(Clock clock,
-                          Duration initialDelay,
-                          Duration maxDelay) {
+  public PipelineRecoveryBackoff(Clock clock,
+                                 Duration initialDelay,
+                                 Duration maxDelay) {
+    this(clock, initialDelay, maxDelay, 
ServiceAvailabilityGeneration.INSTANCE::current);
+  }
+
+  public PipelineRecoveryBackoff(Clock clock,
+                                 Duration initialDelay,
+                                 Duration maxDelay,
+                                 LongSupplier availabilityGeneration) {
     this.clock = clock;
     this.initialDelay = initialDelay;
     this.maxDelay = maxDelay;
+    this.availabilityGeneration = availabilityGeneration;
   }
 
-  boolean isAttemptDue(String pipelineId,
-                       String instanceId) {
+  /**
+   * An attempt is due when no failure is recorded, when the backoff delay has 
elapsed,
+   * or when an extension service became available since the last failure.
+   */
+  public boolean isAttemptDue(String pipelineId,
+                              String instanceId) {
     var state = recoveryStates.get(new RecoveryKey(pipelineId, instanceId));
-    return state == null || !clock.instant().isBefore(state.nextAttemptAt());
+    return state == null
+        || state.availabilityGeneration() != availabilityGeneration.getAsLong()
+        || !clock.instant().isBefore(state.nextAttemptAt());
   }
 
-  RecoveryState recordFailure(String pipelineId,
-                              String instanceId) {
+  public RecoveryState recordFailure(String pipelineId,
+                                     String instanceId) {
     var key = new RecoveryKey(pipelineId, instanceId);
     return recoveryStates.compute(key, (ignored, previousState) -> {
       int failedAttempts = previousState == null ? 1 : 
previousState.failedAttempts() + 1;
       Duration delay = calculateDelay(failedAttempts);
-      return new RecoveryState(failedAttempts, delay, 
clock.instant().plus(delay));
+      return new RecoveryState(failedAttempts, delay, 
clock.instant().plus(delay),
+          availabilityGeneration.getAsLong());
     });
   }
 
-  int reset(String pipelineId,
-            String instanceId) {
+  public int reset(String pipelineId,
+                   String instanceId) {
     var previousState = recoveryStates.remove(new RecoveryKey(pipelineId, 
instanceId));
     return previousState == null ? 0 : previousState.failedAttempts();
   }
 
-  void retainOnly(Set<RecoveryKey> activeInstances) {
+  public void retainOnly(Set<RecoveryKey> activeInstances) {
     recoveryStates.keySet().retainAll(activeInstances);
   }
 
-  RecoveryState getState(String pipelineId,
-                         String instanceId) {
+  public RecoveryState getState(String pipelineId,
+                                String instanceId) {
     return recoveryStates.get(new RecoveryKey(pipelineId, instanceId));
   }
 
@@ -97,11 +121,12 @@ final class PipelineRecoveryBackoff {
     return first.compareTo(second) >= 0 ? first : second;
   }
 
-  record RecoveryKey(String pipelineId, String instanceId) {
+  public record RecoveryKey(String pipelineId, String instanceId) {
   }
 
-  record RecoveryState(int failedAttempts,
+  public record RecoveryState(int failedAttempts,
                        Duration delay,
-                       Instant nextAttemptAt) {
+                       Instant nextAttemptAt,
+                       long availabilityGeneration) {
   }
 }
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceAvailabilityGeneration.java
similarity index 53%
copy from 
streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
copy to 
streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceAvailabilityGeneration.java
index 6a0744d10f..3b965f2a9a 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceAvailabilityGeneration.java
@@ -16,15 +16,26 @@
  *
  */
 
-package org.apache.streampipes.health.monitoring.model;
+package org.apache.streampipes.health.monitoring;
 
-import org.apache.streampipes.health.monitoring.ResourceProvider;
-import org.apache.streampipes.model.health.ExtensionInstanceHealth;
+import java.util.concurrent.atomic.AtomicLong;
 
-import java.util.Map;
+/**
+ * Counts the times an extension service became available (registered and 
healthy).
+ * {@link PipelineRecoveryBackoff} compares the generation recorded at the 
last failed
+ * recovery attempt with the current one, so a service that comes back makes 
pending
+ * recoveries eligible immediately instead of waiting out the exponential 
backoff.
+ */
+public enum ServiceAvailabilityGeneration {
+  INSTANCE;
+
+  private final AtomicLong generation = new AtomicLong();
+
+  public void serviceBecameAvailable() {
+    generation.incrementAndGet();
+  }
 
-public record HealthCheckData(ResourceProvider resourceProvider,
-                              ActiveResources activeResources,
-                              Map<String, ActiveCoreInstances> 
activeCoreInstances,
-                              Map<String, ExtensionInstanceHealth> 
activeExtensionInstances) {
+  public long current() {
+    return generation.get();
+  }
 }
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceHealthCheck.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceHealthCheck.java
index aba373ec3c..7542489f8b 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceHealthCheck.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceHealthCheck.java
@@ -85,6 +85,10 @@ public class ServiceHealthCheck implements Runnable {
 
   @Override
   public void run() {
+    if (ShutdownSignal.INSTANCE.isShutdownRequested()) {
+      LOG.debug("Shutdown in progress, skipping service health check");
+      return;
+    }
     try {
       Environment env = Environments.getEnvironment();
 
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceRegistrationManager.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceRegistrationManager.java
index 43c1d571fe..de69749fd0 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceRegistrationManager.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ServiceRegistrationManager.java
@@ -60,6 +60,7 @@ public class ServiceRegistrationManager {
     serviceRegistration.setStatus(status);
     storage.updateElement(serviceRegistration);
     logService(serviceRegistration);
+    notifyIfAvailable(status);
   }
 
   public void addService(SpServiceRegistration serviceRegistration, 
SpServiceStatus status) {
@@ -72,6 +73,13 @@ public class ServiceRegistrationManager {
       storage.persist(serviceRegistration);
     }
     logService(serviceRegistration);
+    notifyIfAvailable(status);
+  }
+
+  private void notifyIfAvailable(SpServiceStatus status) {
+    if (status == SpServiceStatus.HEALTHY) {
+      ServiceAvailabilityGeneration.INSTANCE.serviceBecameAvailable();
+    }
   }
 
   public List<SpServiceRegistration> getAllServices() {
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ShutdownSignal.java
similarity index 55%
copy from 
streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
copy to 
streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ShutdownSignal.java
index 6a0744d10f..3c3979e74a 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ShutdownSignal.java
@@ -16,15 +16,30 @@
  *
  */
 
-package org.apache.streampipes.health.monitoring.model;
+package org.apache.streampipes.health.monitoring;
 
-import org.apache.streampipes.health.monitoring.ResourceProvider;
-import org.apache.streampipes.model.health.ExtensionInstanceHealth;
+/**
+ * Process-wide flag raised by the core when it starts shutting down. Health 
checks
+ * and recovery tasks consult it so that they do not restore or re-invoke 
pipeline
+ * elements while the service is going away.
+ */
+public enum ShutdownSignal {
+  INSTANCE;
+
+  private volatile boolean shutdownRequested = false;
+
+  public void requestShutdown() {
+    this.shutdownRequested = true;
+  }
 
-import java.util.Map;
+  public boolean isShutdownRequested() {
+    return shutdownRequested;
+  }
 
-public record HealthCheckData(ResourceProvider resourceProvider,
-                              ActiveResources activeResources,
-                              Map<String, ActiveCoreInstances> 
activeCoreInstances,
-                              Map<String, ExtensionInstanceHealth> 
activeExtensionInstances) {
+  /**
+   * Clears the signal. Intended for tests that share the singleton.
+   */
+  public void reset() {
+    this.shutdownRequested = false;
+  }
 }
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/StaleRegistrationCleanup.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/StaleRegistrationCleanup.java
new file mode 100644
index 0000000000..f23cf0bc5a
--- /dev/null
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/StaleRegistrationCleanup.java
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ *
+ */
+
+package org.apache.streampipes.health.monitoring;
+
+import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
+import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestTargets;
+import org.apache.streampipes.manager.api.extensions.ExtensionServiceRequests;
+import 
org.apache.streampipes.model.extensions.svcdiscovery.SpServiceRegistration;
+import org.apache.streampipes.resource.management.SpResourceManager;
+import org.apache.streampipes.storage.api.system.IExtensionsServiceStorage;
+
+import org.apache.http.HttpStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Removes registrations left behind by a predecessor of a newly registered 
extension service.
+ *
+ * <p>A restarted extension service registers under a new id but keeps its 
group, host and
+ * port. A registration of the same group at the same address that was never 
deregistered
+ * usually belongs to a predecessor that is gone. Left in place, it counts as 
an unreachable
+ * service whose instances are unknown, and recovery of the pipeline elements 
that ran there
+ * waits until the service health check removes it. Several live replicas may 
share one
+ * address as well, for example behind a Kubernetes service name, so every 
candidate is asked
+ * for its own health first: the per-service health endpoint answers only for 
its own id, and
+ * the NATS request topic is per id, so a live replica keeps its registration 
and only a
+ * registration nobody answers for is removed.
+ */
+public class StaleRegistrationCleanup {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(StaleRegistrationCleanup.class);
+
+  private final IExtensionsServiceStorage storage;
+  private final ExtensionServiceRequestManager requestManager;
+  private final SpResourceManager resourceManager;
+
+  public StaleRegistrationCleanup(IExtensionsServiceStorage storage,
+                                  ExtensionServiceRequestManager 
requestManager,
+                                  SpResourceManager resourceManager) {
+    this.storage = storage;
+    this.requestManager = requestManager;
+    this.resourceManager = resourceManager;
+  }
+
+  /**
+   * Probes every other registration of the same group at the same address and 
removes the
+   * ones that do not answer for their own id. Never throws; failures are 
logged.
+   */
+  public void removePredecessorsOf(SpServiceRegistration registration) {
+    try {
+      findCandidates(registration).forEach(candidate -> {
+        if (isAlive(candidate)) {
+          LOG.info("Service {} at {}:{} is alive next to new registration {}, 
keeping it",
+              candidate.getSvcId(), candidate.getHost(), candidate.getPort(), 
registration.getSvcId());
+        } else {
+          LOG.info("Removing stale registration {} of service {} at {}:{}, 
replaced by {}",
+              candidate.getSvcId(), candidate.getSvcGroup(), 
candidate.getHost(), candidate.getPort(),
+              registration.getSvcId());
+          storage.deleteElement(candidate);
+        }
+      });
+    } catch (RuntimeException e) {
+      LOG.warn("Could not clean up stale registrations for service {}", 
registration.getSvcId(), e);
+    }
+  }
+
+  List<SpServiceRegistration> findCandidates(SpServiceRegistration 
registration) {
+    return storage.findAll().stream()
+        .filter(other -> !Objects.equals(other.getSvcId(), 
registration.getSvcId()))
+        .filter(other -> Objects.equals(other.getSvcGroup(), 
registration.getSvcGroup()))
+        .filter(other -> Objects.equals(other.getHost(), 
registration.getHost()))
+        .filter(other -> other.getPort() == registration.getPort())
+        .toList();
+  }
+
+  private boolean isAlive(SpServiceRegistration candidate) {
+    try {
+      var target = ExtensionServiceRequestTargets.serviceHealth(candidate, 
candidate.getHealthCheckPath());
+      var response = 
requestManager.request(ExtensionServiceRequests.serviceHealth(target, 
resourceManager));
+      return response.statusCode() == HttpStatus.SC_OK;
+    } catch (IOException | RuntimeException e) {
+      return false;
+    }
+  }
+}
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
index 6a0744d10f..54c6ce00a6 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
@@ -22,9 +22,23 @@ import 
org.apache.streampipes.health.monitoring.ResourceProvider;
 import org.apache.streampipes.model.health.ExtensionInstanceHealth;
 
 import java.util.Map;
+import java.util.Set;
 
+/**
+ * @param activeExtensionInstances instance health per reachable extension 
service
+ * @param unreachableServiceIds registered services that did not answer the 
health request;
+ *                              the state of their instances is unknown, not 
empty
+ */
 public record HealthCheckData(ResourceProvider resourceProvider,
                               ActiveResources activeResources,
                               Map<String, ActiveCoreInstances> 
activeCoreInstances,
-                              Map<String, ExtensionInstanceHealth> 
activeExtensionInstances) {
+                              Map<String, ExtensionInstanceHealth> 
activeExtensionInstances,
+                              Set<String> unreachableServiceIds) {
+
+  public HealthCheckData(ResourceProvider resourceProvider,
+                         ActiveResources activeResources,
+                         Map<String, ActiveCoreInstances> activeCoreInstances,
+                         Map<String, ExtensionInstanceHealth> 
activeExtensionInstances) {
+    this(resourceProvider, activeResources, activeCoreInstances, 
activeExtensionInstances, Set.of());
+  }
 }
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/utils/HealthCheckUtils.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/utils/HealthCheckUtils.java
index ee7e79043c..0160c4f466 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/utils/HealthCheckUtils.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/utils/HealthCheckUtils.java
@@ -45,6 +45,13 @@ public class HealthCheckUtils {
         + "' is not available. The next automatic recovery attempt is 
pending.");
   }
 
+  public static void addUnreachableServiceNotification(List<String> 
pipelineNotifications,
+                                                       
InvocableStreamPipesEntity pipelineElement) {
+    pipelineNotifications.add(getCurrentDatetime() + "Pipeline element '" + 
pipelineElement.getName()
+        + "' runs on extension service '" + 
pipelineElement.getSelectedServiceId()
+        + "' which is currently unreachable. Recovery waits until the service 
answers again or is considered gone.");
+  }
+
   private static String getCurrentDatetime() {
     DateTimeFormatter dtf = DateTimeFormatter.ofPattern("uuuu/MM/dd HH:mm:ss");
     LocalDateTime now = LocalDateTime.now();
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/AdapterHealthCheckTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/AdapterHealthCheckTest.java
index 47207d027d..40628749b8 100644
--- 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/AdapterHealthCheckTest.java
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/AdapterHealthCheckTest.java
@@ -30,6 +30,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class AdapterHealthCheckTest {
@@ -62,6 +63,50 @@ public class AdapterHealthCheckTest {
     assertTrue(adaptersToRecover.isEmpty());
   }
 
+  @Test
+  public void adapterOnUnreachableServiceIsNotRecovered() {
+    var adapterDescription = new AdapterDescription();
+    adapterDescription.setElementId("adapter-id");
+    adapterDescription.setRunning(true);
+    adapterDescription.setSelectedServiceId("unreachable-service");
+    var activeResources = new ActiveResources(
+        List.of(),
+        List.of(),
+        List.of(adapterDescription),
+        List.of(adapterDescription)
+    );
+    var healthCheckData = new HealthCheckData(
+        null,
+        activeResources,
+        Map.of(),
+        Map.of(),
+        Set.of("unreachable-service")
+    );
+
+    var adaptersToRecover = new 
AdapterHealthCheck(healthCheckData).getAdaptersToRecover();
+
+    assertTrue(adaptersToRecover.isEmpty());
+  }
+
+  @Test
+  public void adapterOnRemovedServiceIsRecovered() {
+    var adapterDescription = new AdapterDescription();
+    adapterDescription.setElementId("adapter-id");
+    adapterDescription.setRunning(true);
+    adapterDescription.setSelectedServiceId("removed-service");
+    var activeResources = new ActiveResources(
+        List.of(),
+        List.of(),
+        List.of(adapterDescription),
+        List.of(adapterDescription)
+    );
+    var healthCheckData = new HealthCheckData(null, activeResources, Map.of(), 
Map.of(), Set.of());
+
+    var adaptersToRecover = new 
AdapterHealthCheck(healthCheckData).getAdaptersToRecover();
+
+    assertEquals(1, adaptersToRecover.size());
+  }
+
   @Test
   public void runCheckDoesNotRestartStartingAdapters() {
     var adapterHealthCheck = new TestAdapterHealthCheck(
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheckTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheckTest.java
new file mode 100644
index 0000000000..dc0b359e6b
--- /dev/null
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheckTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.
+ *
+ */
+
+package org.apache.streampipes.health.monitoring;
+
+import org.apache.streampipes.health.monitoring.model.ActiveCoreInstances;
+import org.apache.streampipes.health.monitoring.model.ActiveResources;
+import org.apache.streampipes.health.monitoring.model.HealthCheckData;
+import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
+import org.apache.streampipes.resource.management.SpResourceManager;
+import org.apache.streampipes.storage.api.system.IExtensionsServiceStorage;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ExtensionHealthCheckTest {
+
+  private ResourceProvider resourceProvider;
+  private ExtensionHealthCheck check;
+
+  @BeforeEach
+  void setUp() {
+    ShutdownSignal.INSTANCE.reset();
+    resourceProvider = mock(ResourceProvider.class);
+    when(resourceProvider.loadActiveResources())
+        .thenReturn(new ActiveResources(List.of(), List.of(), List.of(), 
List.of()));
+    when(resourceProvider.loadActiveInstances(any())).thenReturn(Map.of());
+    check = new ExtensionHealthCheck(
+        resourceProvider,
+        mock(IExtensionsServiceStorage.class),
+        mock(ExtensionServiceRequestManager.class),
+        mock(SpResourceManager.class),
+        List.of(),
+        Duration.ofSeconds(30)
+    );
+  }
+
+  @AfterEach
+  void tearDown() {
+    ShutdownSignal.INSTANCE.reset();
+  }
+
+  @Test
+  void runsWhileServiceIsUp() {
+    check.run();
+
+    verify(resourceProvider).loadActiveResources();
+  }
+
+  @Test
+  void serviceStaysUnknownForLimitedTicksThenCountsAsDown() {
+    when(resourceProvider.loadActiveInstances(any()))
+        .thenReturn(Map.of("svc", new ActiveCoreInstances(List.of(), 
Map.of())));
+    var observed = new AtomicReference<HealthCheckData>();
+    var neverAnswering = new ExtensionHealthCheck(
+        resourceProvider,
+        mock(IExtensionsServiceStorage.class),
+        mock(ExtensionServiceRequestManager.class),
+        mock(SpResourceManager.class),
+        List.of(),
+        Duration.ofSeconds(30)
+    ) {
+      @Override
+      protected 
Optional<org.apache.streampipes.model.health.ExtensionInstanceHealth> 
checkInstances(String id) {
+        return Optional.empty();
+      }
+
+      @Override
+      protected int maxUnknownTicks() {
+        return 2;
+      }
+
+      @Override
+      protected List<HealthCheck> getBuiltInHealthChecks(HealthCheckData 
healthCheckData) {
+        observed.set(healthCheckData);
+        return List.of();
+      }
+    };
+
+    neverAnswering.run();
+    assertTrue(observed.get().unreachableServiceIds().contains("svc"));
+    assertFalse(observed.get().activeExtensionInstances().containsKey("svc"));
+
+    neverAnswering.run();
+    assertFalse(observed.get().unreachableServiceIds().contains("svc"));
+    assertTrue(observed.get().activeExtensionInstances().containsKey("svc"));
+    assertEquals(0, 
observed.get().activeExtensionInstances().get("svc").runningPipelineElementInstanceIds().size());
+  }
+
+  @Test
+  void skipsCheckOnceShutdownIsRequested() {
+    ShutdownSignal.INSTANCE.requestShutdown();
+
+    check.run();
+
+    verify(resourceProvider, never()).loadActiveResources();
+  }
+}
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ExtensionInstanceAvailabilityCheckTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ExtensionInstanceAvailabilityCheckTest.java
new file mode 100644
index 0000000000..0edae65cf4
--- /dev/null
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ExtensionInstanceAvailabilityCheckTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.
+ *
+ */
+
+package org.apache.streampipes.health.monitoring;
+
+import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceOperationResult;
+import org.apache.streampipes.manager.api.extensions.ExtensionServiceRequest;
+import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
+import org.apache.streampipes.manager.api.extensions.ExtensionServiceRequests;
+import 
org.apache.streampipes.model.extensions.svcdiscovery.SpServiceRegistration;
+import org.apache.streampipes.model.extensions.svcdiscovery.SpServiceStatus;
+import org.apache.streampipes.resource.management.SpResourceManager;
+import org.apache.streampipes.storage.api.system.IExtensionsServiceStorage;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+class ExtensionInstanceAvailabilityCheckTest {
+
+  private static final String SERVICE_ID = "svc-1";
+
+  private IExtensionsServiceStorage storage;
+  private ExtensionServiceRequestManager requests;
+  private MockedStatic<ExtensionServiceRequests> requestFactory;
+  private SpServiceRegistration service;
+  private ExtensionInstanceAvailabilityCheck check;
+
+  @BeforeEach
+  void setUp() {
+    storage = mock(IExtensionsServiceStorage.class);
+    requests = mock(ExtensionServiceRequestManager.class);
+    service = new SpServiceRegistration();
+    service.setSvcId(SERVICE_ID);
+    service.setStatus(SpServiceStatus.HEALTHY);
+    when(storage.findAll()).thenReturn(List.of(service));
+    requestFactory = mockStatic(ExtensionServiceRequests.class);
+    requestFactory.when(() -> 
ExtensionServiceRequests.extensionInstanceHealth(any(), any()))
+        .thenReturn(mock(ExtensionServiceRequest.class));
+    check = new ExtensionInstanceAvailabilityCheck(storage, SERVICE_ID, 
requests, mock(SpResourceManager.class));
+  }
+
+  @AfterEach
+  void tearDown() {
+    requestFactory.close();
+  }
+
+  @Test
+  void reachableServiceReportsItsInstances() throws IOException {
+    var body = 
"{\"adapterInstanceStates\":{},\"runningPipelineElementInstanceIds\":[\"instance-a\"]}";
+    when(requests.request(any()))
+        .thenReturn(new ExtensionServiceOperationResult(200, 
body.getBytes(StandardCharsets.UTF_8)));
+
+    var health = check.checkReachableRunningInstances();
+
+    assertTrue(health.isPresent());
+    assertEquals(Set.of("instance-a"), 
health.get().runningPipelineElementInstanceIds());
+  }
+
+  @Test
+  void healthyServiceThatDoesNotAnswerIsUnknown() throws IOException {
+    when(requests.request(any())).thenThrow(new IOException("no responders"));
+
+    assertTrue(check.checkReachableRunningInstances().isEmpty());
+    
assertTrue(check.checkRunningInstances().runningPipelineElementInstanceIds().isEmpty());
+  }
+
+  @Test
+  void healthyServiceAnsweringWithErrorIsUnknown() throws IOException {
+    when(requests.request(any())).thenReturn(new 
ExtensionServiceOperationResult(500, null));
+
+    assertTrue(check.checkReachableRunningInstances().isEmpty());
+  }
+
+  @Test
+  void unhealthyServiceThatDoesNotAnswerHasNoInstances() throws IOException {
+    service.setStatus(SpServiceStatus.UNHEALTHY);
+    when(requests.request(any())).thenThrow(new IOException("no responders"));
+
+    var health = check.checkReachableRunningInstances();
+
+    assertTrue(health.isPresent());
+    assertTrue(health.get().runningPipelineElementInstanceIds().isEmpty());
+  }
+
+  @Test
+  void unregisteredServiceHasNoInstances() throws IOException {
+    when(storage.findAll()).thenReturn(List.of());
+
+    var health = check.checkReachableRunningInstances();
+
+    assertTrue(health.isPresent());
+    assertTrue(health.get().runningPipelineElementInstanceIds().isEmpty());
+  }
+}
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineHealthCheckTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineHealthCheckTest.java
index 8c83031102..04c8f5a10f 100644
--- 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineHealthCheckTest.java
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineHealthCheckTest.java
@@ -27,6 +27,7 @@ import org.apache.streampipes.model.pipeline.Pipeline;
 import org.apache.streampipes.model.pipeline.PipelineHealthStatus;
 import org.apache.streampipes.storage.api.pipeline.IPipelineStorage;
 
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 
 import java.time.Clock;
@@ -46,6 +47,38 @@ import static org.mockito.Mockito.when;
 
 public class PipelineHealthCheckTest {
 
+  @AfterEach
+  public void tearDown() {
+    ShutdownSignal.INSTANCE.reset();
+  }
+
+  @Test
+  public void elementOnUnreachableServiceIsNotRestoredElsewhere() {
+    var processor = processor("missing", "Missing processor");
+    processor.setSelectedServiceId("unreachable-service");
+    var pipeline = pipeline(processor);
+    var healthCheck = healthCheck(pipeline, Map.of("missing", true), new 
PipelineRecoveryBackoff(), Set.of(),
+        Set.of("unreachable-service"));
+
+    healthCheck.runCheck();
+
+    assertEquals(0, healthCheck.restoreAttempts("missing"));
+    assertEquals(PipelineHealthStatus.FAILURE, pipeline.getHealthStatus());
+    assertTrue(pipeline.getPipelineNotifications().get(0).contains("currently 
unreachable"));
+  }
+
+  @Test
+  public void noRecoveryOnceShutdownIsRequested() {
+    var pipeline = pipeline(processor("missing", "Missing processor"));
+    var healthCheck = healthCheck(pipeline, Map.of("missing", true));
+    ShutdownSignal.INSTANCE.requestShutdown();
+
+    healthCheck.runCheck();
+
+    assertEquals(0, healthCheck.restoreAttempts("missing"));
+    assertEquals(PipelineHealthStatus.OK, pipeline.getHealthStatus());
+  }
+
   @Test
   public void retriesRestorationBeyondPreviousLimit() {
     var pipeline = pipeline(processor("missing", "Missing processor"));
@@ -160,6 +193,14 @@ public class PipelineHealthCheckTest {
                                               Map<String, Boolean> 
restorationResults,
                                               PipelineRecoveryBackoff 
recoveryBackoff,
                                               Set<String> runningInstanceIds) {
+    return healthCheck(pipeline, restorationResults, recoveryBackoff, 
runningInstanceIds, Set.of());
+  }
+
+  private TestPipelineHealthCheck healthCheck(Pipeline pipeline,
+                                              Map<String, Boolean> 
restorationResults,
+                                              PipelineRecoveryBackoff 
recoveryBackoff,
+                                              Set<String> runningInstanceIds,
+                                              Set<String> 
unreachableServiceIds) {
     IPipelineStorage pipelineStorage = mock(IPipelineStorage.class);
     
when(pipelineStorage.getElementById(pipeline.getPipelineId())).thenReturn(pipeline);
     when(pipelineStorage.updateElement(any())).thenAnswer(invocation -> 
invocation.getArgument(0));
@@ -175,7 +216,8 @@ public class PipelineHealthCheckTest {
         resourceProvider,
         activeResources,
         Map.of(),
-        Map.of("service-id", new ExtensionInstanceHealth(Map.of(), 
runningInstanceIds))
+        Map.of("service-id", new ExtensionInstanceHealth(Map.of(), 
runningInstanceIds)),
+        unreachableServiceIds
     );
 
     return new TestPipelineHealthCheck(
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoffTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoffTest.java
index 8bbb32f2be..bfdd118158 100644
--- 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoffTest.java
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoffTest.java
@@ -24,6 +24,7 @@ import java.time.Duration;
 import java.time.Instant;
 import java.time.ZoneOffset;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -76,6 +77,26 @@ public class PipelineRecoveryBackoffTest {
     assertTrue(recoveryBackoff.isAttemptDue("pipeline", "instance"));
   }
 
+  @Test
+  public void serviceBecomingAvailableAllowsImmediateRecoveryAttempt() {
+    var generation = new AtomicLong();
+    var recoveryBackoff = new PipelineRecoveryBackoff(
+        Clock.fixed(Instant.EPOCH, ZoneOffset.UTC),
+        PipelineRecoveryBackoff.DEFAULT_INITIAL_DELAY,
+        PipelineRecoveryBackoff.DEFAULT_MAX_DELAY,
+        generation::get
+    );
+    recoveryBackoff.recordFailure("pipeline", "instance");
+    assertFalse(recoveryBackoff.isAttemptDue("pipeline", "instance"));
+
+    generation.incrementAndGet();
+
+    assertTrue(recoveryBackoff.isAttemptDue("pipeline", "instance"));
+    // A further failure after the service came back is subject to the backoff 
again.
+    recoveryBackoff.recordFailure("pipeline", "instance");
+    assertFalse(recoveryBackoff.isAttemptDue("pipeline", "instance"));
+  }
+
   @Test
   public void inactiveInstancesAreRemoved() {
     var recoveryBackoff = recoveryBackoff();
@@ -94,7 +115,8 @@ public class PipelineRecoveryBackoffTest {
     return new PipelineRecoveryBackoff(
         Clock.fixed(Instant.EPOCH, ZoneOffset.UTC),
         PipelineRecoveryBackoff.DEFAULT_INITIAL_DELAY,
-        PipelineRecoveryBackoff.DEFAULT_MAX_DELAY
+        PipelineRecoveryBackoff.DEFAULT_MAX_DELAY,
+        () -> 0L
     );
   }
 }
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ServiceRegistrationManagerTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ServiceRegistrationManagerTest.java
new file mode 100644
index 0000000000..0c05636289
--- /dev/null
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/ServiceRegistrationManagerTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ *
+ */
+
+package org.apache.streampipes.health.monitoring;
+
+import 
org.apache.streampipes.model.extensions.svcdiscovery.SpServiceRegistration;
+import org.apache.streampipes.model.extensions.svcdiscovery.SpServiceStatus;
+import org.apache.streampipes.storage.api.system.IExtensionsServiceStorage;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ServiceRegistrationManagerTest {
+
+  @Test
+  void newRegistrationIsPersistedWithoutTouchingOtherRegistrations() {
+    var storage = mock(IExtensionsServiceStorage.class);
+    var sameAddress = registration("iiot-old", "iiot", "extensions", 8090);
+    when(storage.findAll()).thenReturn(List.of(sameAddress));
+    var fresh = registration("iiot-new", "iiot", "extensions", 8090);
+
+    new ServiceRegistrationManager(storage).addService(fresh, 
SpServiceStatus.REGISTERED);
+
+    // Stale predecessors are removed by StaleRegistrationCleanup after 
probing them, not here.
+    verify(storage, never()).deleteElement(any());
+    verify(storage).persist(fresh);
+  }
+
+  @Test
+  void reRegistrationOfKnownServiceDoesNotRemoveAnything() {
+    var storage = mock(IExtensionsServiceStorage.class);
+    var existing = registration("iiot-1", "iiot", "extensions", 8090);
+    existing.setRev("3-abc");
+    when(storage.getElementById("iiot-1")).thenReturn(existing);
+    var refreshed = registration("iiot-1", "iiot", "extensions", 8090);
+
+    new ServiceRegistrationManager(storage).addService(refreshed, 
SpServiceStatus.REGISTERED);
+
+    verify(storage, never()).deleteElement(any());
+    verify(storage).updateElement(refreshed);
+  }
+
+  private SpServiceRegistration registration(String svcId, String group, 
String host, int port) {
+    var registration = new SpServiceRegistration();
+    registration.setSvcId(svcId);
+    registration.setSvcGroup(group);
+    registration.setHost(host);
+    registration.setPort(port);
+    registration.setStatus(SpServiceStatus.HEALTHY);
+    return registration;
+  }
+}
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/StaleRegistrationCleanupTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/StaleRegistrationCleanupTest.java
new file mode 100644
index 0000000000..4405f33ad5
--- /dev/null
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/StaleRegistrationCleanupTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.
+ *
+ */
+
+package org.apache.streampipes.health.monitoring;
+
+import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceOperationResult;
+import org.apache.streampipes.manager.api.extensions.ExtensionServiceRequest;
+import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
+import org.apache.streampipes.manager.api.extensions.ExtensionServiceRequests;
+import 
org.apache.streampipes.model.extensions.svcdiscovery.SpServiceRegistration;
+import org.apache.streampipes.model.extensions.svcdiscovery.SpServiceStatus;
+import org.apache.streampipes.resource.management.SpResourceManager;
+import org.apache.streampipes.storage.api.system.IExtensionsServiceStorage;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class StaleRegistrationCleanupTest {
+
+  private IExtensionsServiceStorage storage;
+  private ExtensionServiceRequestManager requests;
+  private MockedStatic<ExtensionServiceRequests> requestFactory;
+  private StaleRegistrationCleanup cleanup;
+
+  @BeforeEach
+  void setUp() {
+    storage = mock(IExtensionsServiceStorage.class);
+    requests = mock(ExtensionServiceRequestManager.class);
+    requestFactory = mockStatic(ExtensionServiceRequests.class);
+    requestFactory.when(() -> ExtensionServiceRequests.serviceHealth(any(), 
any()))
+        .thenReturn(mock(ExtensionServiceRequest.class));
+    cleanup = new StaleRegistrationCleanup(storage, requests, 
mock(SpResourceManager.class));
+  }
+
+  @AfterEach
+  void tearDown() {
+    requestFactory.close();
+  }
+
+  @Test
+  void removesPredecessorThatNobodyAnswersFor() throws IOException {
+    var stale = registration("iiot-old", "iiot", "extensions", 8090);
+    var fresh = registration("iiot-new", "iiot", "extensions", 8090);
+    when(storage.findAll()).thenReturn(List.of(stale, fresh));
+    when(requests.request(any())).thenThrow(new IOException("connection 
refused"));
+
+    cleanup.removePredecessorsOf(fresh);
+
+    verify(storage).deleteElement(stale);
+  }
+
+  @Test
+  void removesPredecessorWhoseAddressIsNowServedByAnotherId() throws 
IOException {
+    var stale = registration("iiot-old", "iiot", "extensions", 8090);
+    var fresh = registration("iiot-new", "iiot", "extensions", 8090);
+    when(storage.findAll()).thenReturn(List.of(stale, fresh));
+    when(requests.request(any())).thenReturn(new 
ExtensionServiceOperationResult(404, null));
+
+    cleanup.removePredecessorsOf(fresh);
+
+    verify(storage).deleteElement(stale);
+  }
+
+  @Test
+  void keepsLiveReplicaAtSameAddress() throws IOException {
+    var replica = registration("iiot-a", "iiot", "extensions", 8090);
+    var fresh = registration("iiot-b", "iiot", "extensions", 8090);
+    when(storage.findAll()).thenReturn(List.of(replica, fresh));
+    when(requests.request(any())).thenReturn(new 
ExtensionServiceOperationResult(200, null));
+
+    cleanup.removePredecessorsOf(fresh);
+
+    verify(storage, never()).deleteElement(any());
+  }
+
+  @Test
+  void onlyProbesRegistrationsOfSameGroupAndAddress() throws IOException {
+    var otherPort = registration("iiot-other", "iiot", "extensions", 8091);
+    var otherGroup = registration("ai-old", "ai", "extensions", 8090);
+    var otherHost = registration("iiot-remote", "iiot", "node-2", 8090);
+    var fresh = registration("iiot-new", "iiot", "extensions", 8090);
+    when(storage.findAll()).thenReturn(List.of(otherPort, otherGroup, 
otherHost, fresh));
+
+    cleanup.removePredecessorsOf(fresh);
+
+    verify(requests, times(0)).request(any());
+    verify(storage, never()).deleteElement(any());
+  }
+
+  private SpServiceRegistration registration(String svcId, String group, 
String host, int port) {
+    var registration = new SpServiceRegistration();
+    registration.setSvcId(svcId);
+    registration.setSvcGroup(group);
+    registration.setHost(host);
+    registration.setPort(port);
+    registration.setHealthCheckPath("svchealth/" + svcId);
+    registration.setStatus(SpServiceStatus.HEALTHY);
+    return registration;
+  }
+}
diff --git 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/admin/ServiceRegistrationResource.java
 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/admin/ServiceRegistrationResource.java
index 5de6bd0f41..b1d35f244c 100644
--- 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/admin/ServiceRegistrationResource.java
+++ 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/admin/ServiceRegistrationResource.java
@@ -21,6 +21,7 @@ import 
org.apache.streampipes.health.monitoring.ExtensionInstanceDetailsProvider
 import 
org.apache.streampipes.health.monitoring.ExtensionInstanceRemovalService;
 import org.apache.streampipes.health.monitoring.ResourceProvider;
 import org.apache.streampipes.health.monitoring.ServiceRegistrationManager;
+import org.apache.streampipes.health.monitoring.StaleRegistrationCleanup;
 import 
org.apache.streampipes.health.monitoring.model.RunningExtensionInstances;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
 import 
org.apache.streampipes.model.extensions.svcdiscovery.SpServiceRegistration;
@@ -46,6 +47,7 @@ import org.springframework.web.bind.annotation.RestController;
 
 import java.io.IOException;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
 
 @RestController
 @RequestMapping("/api/v2/extensions-services")
@@ -117,6 +119,10 @@ public class ServiceRegistrationResource extends 
AbstractAuthGuardedRestResource
   public ResponseEntity<Void> registerService(@RequestBody 
SpServiceRegistration serviceRegistration) {
     new 
ServiceRegistrationManager(extensionsServiceStorage).addService(serviceRegistration,
                                                                         
SpServiceStatus.REGISTERED);
+    // Probing predecessors may wait for connection timeouts; do not delay the 
registration.
+    CompletableFuture.runAsync(() -> new StaleRegistrationCleanup(
+        extensionsServiceStorage, extensionServiceRequestManager, 
resourceManager
+    ).removePredecessorsOf(serviceRegistration));
     return ok();
   }
 
diff --git 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/PostStartupTask.java
 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/PostStartupTask.java
index ecddb6f161..2fff6ac78f 100644
--- 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/PostStartupTask.java
+++ 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/PostStartupTask.java
@@ -19,6 +19,7 @@
 package org.apache.streampipes.service.core;
 
 import org.apache.streampipes.audit.events.adapter.AdapterAuditRecorder;
+import org.apache.streampipes.commons.environment.Environments;
 import org.apache.streampipes.commons.prometheus.adapter.AdapterMetricsManager;
 import 
org.apache.streampipes.connect.management.management.AdapterMasterManagement;
 import 
org.apache.streampipes.connect.management.management.WorkerAdministrationManagement;
@@ -28,6 +29,7 @@ import org.apache.streampipes.health.monitoring.HealthCheck;
 import org.apache.streampipes.health.monitoring.PostStartupRecovery;
 import org.apache.streampipes.health.monitoring.ResourceProvider;
 import org.apache.streampipes.health.monitoring.ServiceHealthCheck;
+import org.apache.streampipes.health.monitoring.ShutdownSignal;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
 import org.apache.streampipes.manager.execution.PipelineExecutor;
 import org.apache.streampipes.model.extensions.svcdiscovery.SpServiceTagPrefix;
@@ -108,12 +110,42 @@ public class PostStartupTask implements Runnable {
 
   @Override
   public void run() {
-    new ServiceHealthCheck(extensionsServiceStorage, 
extensionServiceRequestManager, resourceManager).run();
+    if (isShuttingDown()) {
+      return;
+    }
+    probeRegisteredServices();
     performAdapterAssetUpdate();
     startAllPreviouslyStoppedPipelines();
     runHealthCheckOnce();
   }
 
+  /**
+   * Probes the registered services as often as the service health check needs 
consecutive
+   * failures before it declares a service unhealthy. Registrations left 
behind by a crashed
+   * run are therefore unhealthy when the health check below runs, and the 
pipeline elements
+   * that ran there are restored right away instead of after several scheduled 
ticks. Live
+   * services answer every probe, so for them this is cheap.
+   */
+  private void probeRegisteredServices() {
+    var serviceHealthCheck = new ServiceHealthCheck(
+        extensionsServiceStorage, extensionServiceRequestManager, 
resourceManager);
+    int probes = Math.max(1, 
Environments.getEnvironment().getServiceHealthFailureThreshold().getValueOrDefault());
+    for (int i = 0; i < probes; i++) {
+      if (isShuttingDown()) {
+        return;
+      }
+      serviceHealthCheck.run();
+      if (i < probes - 1) {
+        try {
+          TimeUnit.SECONDS.sleep(1);
+        } catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+          return;
+        }
+      }
+    }
+  }
+
   private void performAdapterAssetUpdate() {
     var installedAppIds = extensionsServiceStorage
         .findAll()
@@ -129,20 +161,20 @@ public class PostStartupTask implements Runnable {
     postStartupRecovery.checkAndRestore(0);
   }
 
+  /**
+   * Pipelines that are marked as running are deliberately not started here. 
They usually
+   * still run on the extension services, which the core did not stop when it 
shut down. A
+   * fresh start would allocate endpoints again and, with several services 
offering the same
+   * element, could start a second instance next to the running one. The 
health check that
+   * follows asks every service which instances it runs and restores only the 
missing ones.
+   *
+   * <p>Pipelines flagged with {@code restartOnSystemReboot} were stopped by 
an older core
+   * during its shutdown and are not running anywhere, so starting them is 
safe.
+   */
   private void startAllPreviouslyStoppedPipelines() {
     var allPipelines = pipelineStorage.findAll();
-    LOG.info("Checking for orphaned pipelines...");
-    List<Pipeline> orphanedPipelines = allPipelines
-        .stream()
-        .filter(Pipeline::isRunning)
-        .toList();
-
-    LOG.info("Found {} orphaned pipelines", orphanedPipelines.size());
-
-    orphanedPipelines.forEach(pipeline -> {
-      LOG.info("Restoring orphaned pipeline {}", pipeline.getName());
-      startPipeline(pipeline, false);
-    });
+    LOG.info("Found {} running pipelines which will be reconciled by the 
health check",
+        allPipelines.stream().filter(Pipeline::isRunning).count());
 
     List<Pipeline> pipelinesToRestart = allPipelines
         .stream()
@@ -160,6 +192,10 @@ public class PostStartupTask implements Runnable {
   }
 
   private void startPipeline(Pipeline pipeline, boolean restartOnReboot) {
+    if (isShuttingDown()) {
+      LOG.info("Shutdown in progress, not restoring pipeline {}", 
pipeline.getName());
+      return;
+    }
     PipelineOperationStatus status = new PipelineExecutor(pipeline, 
extensionServiceRequestManager, resourceManager)
         .startPipeline();
     if (status.isSuccess()) {
@@ -205,6 +241,10 @@ public class PostStartupTask implements Runnable {
     }
   }
 
+  private boolean isShuttingDown() {
+    return ShutdownSignal.INSTANCE.isShutdownRequested();
+  }
+
   private IPipelineStorage getPipelineStorage() {
     return resourceManager.managePipelines().getDb();
   }
diff --git 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/StreamPipesCoreApplication.java
 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/StreamPipesCoreApplication.java
index 7ce3b9658c..6edc4ab024 100644
--- 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/StreamPipesCoreApplication.java
+++ 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/StreamPipesCoreApplication.java
@@ -29,16 +29,15 @@ import 
org.apache.streampipes.health.monitoring.ExtensionHealthCheck;
 import org.apache.streampipes.health.monitoring.HealthCheck;
 import org.apache.streampipes.health.monitoring.ResourceProvider;
 import org.apache.streampipes.health.monitoring.ServiceHealthCheck;
+import org.apache.streampipes.health.monitoring.ShutdownSignal;
 import org.apache.streampipes.loadbalance.LoadManager;
 import 
org.apache.streampipes.loadbalance.service.ExtensionsServiceReportExecutor;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestTargets;
 import org.apache.streampipes.manager.api.extensions.ExtensionServiceRequests;
-import org.apache.streampipes.manager.function.FunctionManager;
 import org.apache.streampipes.manager.health.CoreInitialInstallationProgress;
 import org.apache.streampipes.manager.health.CoreServiceStatusManager;
 import org.apache.streampipes.manager.pipeline.ExtensionsServiceLogExecutor;
-import org.apache.streampipes.manager.pipeline.PipelineManager;
 import org.apache.streampipes.manager.setup.AutoInstallation;
 import org.apache.streampipes.manager.setup.InstallationConfiguration;
 import org.apache.streampipes.manager.setup.StreamPipesEnvChecker;
@@ -49,8 +48,6 @@ import 
org.apache.streampipes.messaging.mqtt.SpMqttProtocolFactory;
 import org.apache.streampipes.messaging.nats.SpNatsProtocolFactory;
 import org.apache.streampipes.messaging.pulsar.SpPulsarProtocolFactory;
 import org.apache.streampipes.model.configuration.SpCoreConfigurationStatus;
-import org.apache.streampipes.model.pipeline.Pipeline;
-import org.apache.streampipes.model.pipeline.PipelineOperationStatus;
 import org.apache.streampipes.resource.management.SpResourceManager;
 import 
org.apache.streampipes.resource.management.permission.SpPermissionEvaluator;
 import org.apache.streampipes.service.base.BaseNetworkingConfig;
@@ -62,7 +59,6 @@ import 
org.apache.streampipes.service.core.migrations.Migration;
 import org.apache.streampipes.service.core.migrations.MigrationsHandler;
 import org.apache.streampipes.service.core.storage.StorageApiConfiguration;
 import org.apache.streampipes.storage.api.connect.IAdapterStorage;
-import org.apache.streampipes.storage.api.function.IFunctionStateStorage;
 import 
org.apache.streampipes.storage.api.pipeline.ICompactPipelineTemplateStorage;
 import org.apache.streampipes.storage.api.pipeline.IPipelineStorage;
 import org.apache.streampipes.storage.api.system.ICertificateStorage;
@@ -80,6 +76,8 @@ import 
org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.context.annotation.ComponentScan;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
+import org.springframework.context.event.ContextClosedEvent;
+import org.springframework.context.event.EventListener;
 import org.springframework.scheduling.annotation.EnableScheduling;
 
 import jakarta.annotation.PostConstruct;
@@ -89,9 +87,12 @@ import java.io.IOException;
 import java.net.UnknownHostException;
 import java.time.Duration;
 import java.util.List;
+import java.util.Objects;
 import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Supplier;
+import java.util.stream.Stream;
 
 @Configuration
 @EnableAutoConfiguration
@@ -108,8 +109,8 @@ public class StreamPipesCoreApplication extends 
StreamPipesServiceBase {
   private static final Logger LOG =
       
LoggerFactory.getLogger(StreamPipesCoreApplication.class.getCanonicalName());
 
-  @Autowired
-  private IFunctionStateStorage functionStateStorage;
+  private static final Duration EXECUTOR_SHUTDOWN_TIMEOUT = 
Duration.ofSeconds(5);
+  private static final Duration EXTENSION_DEREGISTRATION_POLL_INTERVAL = 
Duration.ofMillis(250);
 
   @Autowired
   protected ICertificateStorage certificateStorage;
@@ -142,6 +143,10 @@ public class StreamPipesCoreApplication extends 
StreamPipesServiceBase {
   @Qualifier("adapterDescriptionStorage")
   private IAdapterStorage adapterDescriptionStorage;
 
+  private ScheduledExecutorService postStartupExecutor;
+  private ScheduledExecutorService healthCheckExecutor;
+  private ScheduledExecutorService logCheckExecutor;
+
   public static void main(String[] args) {
     StreamPipesCoreApplication application = new StreamPipesCoreApplication();
     application.initialize(() -> List.of(
@@ -178,8 +183,9 @@ public class StreamPipesCoreApplication extends 
StreamPipesServiceBase {
 
   @PostConstruct
   public void init() {
-    var executorService = Executors.newSingleThreadScheduledExecutor();
-    var logCheckExecutorService = Executors.newSingleThreadScheduledExecutor();
+    ShutdownSignal.INSTANCE.reset();
+    this.postStartupExecutor = Executors.newSingleThreadScheduledExecutor();
+    this.logCheckExecutor = Executors.newSingleThreadScheduledExecutor();
 
     new 
StreamPipesEnvChecker(resourceManager.getCoreConfigurationStorage()).updateEnvironmentVariables();
     new CouchDbViewGenerator().createGenericDatabaseIfNotExists();
@@ -221,7 +227,7 @@ public class StreamPipesCoreApplication extends 
StreamPipesServiceBase {
         env.getHealthCheckIntervalInMillis().getValueOrDefault()
     );
 
-    executorService.schedule(new PostStartupTask(
+    postStartupExecutor.schedule(new PostStartupTask(
             getPipelineStorage(),
             adapterDescriptionStorage,
             extensionsServiceStorage,
@@ -259,7 +265,7 @@ public class StreamPipesCoreApplication extends 
StreamPipesServiceBase {
 
     var logFetchInterval = 
env.getLogFetchIntervalInMillis().getValueOrDefault();
     LOG.info("Extensions logs will be fetched every {} seconds", 
TimeUnit.MILLISECONDS.toSeconds(logFetchInterval));
-    logCheckExecutorService.scheduleAtFixedRate(new 
ExtensionsServiceLogExecutor(
+    logCheckExecutor.scheduleAtFixedRate(new ExtensionsServiceLogExecutor(
           extensionServiceRequestManager, resourceManager
         ),
         logFetchInterval, logFetchInterval,
@@ -267,11 +273,11 @@ public class StreamPipesCoreApplication extends 
StreamPipesServiceBase {
   }
 
   private void scheduleHealthChecks(Duration healthCheckInterval, 
List<Runnable> checks) {
-    var healthCheckExecutorService = 
Executors.newSingleThreadScheduledExecutor();
+    this.healthCheckExecutor = Executors.newSingleThreadScheduledExecutor();
     checks.forEach(check -> {
       LOG.info("Health check {} configured with a delay of {} seconds between 
runs", check.getClass().getSimpleName(),
           healthCheckInterval.toSeconds());
-      healthCheckExecutorService.scheduleWithFixedDelay(check, 
healthCheckInterval.toMillis(),
+      healthCheckExecutor.scheduleWithFixedDelay(check, 
healthCheckInterval.toMillis(),
           healthCheckInterval.toMillis(),
           TimeUnit.MILLISECONDS);
     });
@@ -307,39 +313,103 @@ public class StreamPipesCoreApplication extends 
StreamPipesServiceBase {
     }
   }
 
-  @PreDestroy
-  public void onExit() {
+  /**
+   * Runs at the very beginning of the context shutdown, before the web server 
and the
+   * beans are torn down. Pipelines and adapters are intentionally left 
running on the
+   * extension services: their state in storage stays "running", so a 
restarted core
+   * re-attaches to them through {@link PostStartupTask} and the health 
checks, and a
+   * core-only restart causes no processing gap. Extension services clean up 
their own
+   * instances when they shut down.
+   *
+   * <p>While this listener runs, the web server still serves requests. The 
core uses that
+   * window to let extension services that are stopping at the same time 
persist their
+   * function state, see {@link #waitForExtensionServicesToDeregister()}.
+   */
+  @EventListener(ContextClosedEvent.class)
+  public void onShutdownRequested() {
     LOG.info("Shutting down StreamPipes...");
-    LOG.info("Flagging currently running pipelines for restart...");
-    List<Pipeline> pipelinesToStop =
-        getAllPipelines().stream().filter(Pipeline::isRunning).toList();
-
-    LOG.info("Found {} running pipelines which will be stopped...", 
pipelinesToStop.size());
-
-    pipelinesToStop.forEach(pipeline -> {
-      pipeline.setRestartOnSystemReboot(true);
-      resourceManager.managePipelines().getDb().updateElement(pipeline);
-    });
+    ShutdownSignal.INSTANCE.requestShutdown();
+    LOG.info("Health checks and recovery tasks are disabled, running pipelines 
are left untouched");
+    stopExecutors(Stream.of(postStartupExecutor, healthCheckExecutor, 
logCheckExecutor)
+        .filter(Objects::nonNull)
+        .toList());
+    waitForExtensionServicesToDeregister();
+  }
 
-    LOG.info("Gracefully stopping all running pipelines...");
-    var pipelineManager = new PipelineManager(resourceManager);
-    List<PipelineOperationStatus> status = 
pipelineManager.stopAllPipelines(true, extensionServiceRequestManager);
-    status.forEach(s -> {
-      if (s.isSuccess()) {
-        LOG.info("Pipeline {} successfully stopped", s.getPipelineName());
-      } else {
-        LOG.error("Pipeline {} could not be stopped", s.getPipelineName());
+  /**
+   * Extension services persist their function state through the core's REST 
API and
+   * deregister afterwards. Waiting until all of them are gone, bounded by
+   * {@code SP_SHUTDOWN_WAIT_FOR_EXTENSIONS_MS}, keeps that state from being 
lost when the
+   * whole stack stops at once. A core-only restart pays the full timeout, 
since the
+   * extension services stay registered. This is a stopgap until functions 
checkpoint
+   * their state periodically.
+   */
+  private void waitForExtensionServicesToDeregister() {
+    var timeout = Duration.ofMillis(
+        
Environments.getEnvironment().getShutdownWaitForExtensionsInMillis().getValueOrDefault());
+    if (timeout.isZero() || timeout.isNegative()) {
+      return;
+    }
+    var deadline = System.nanoTime() + timeout.toNanos();
+    var registered = countRegisteredExtensionServices();
+    if (registered == 0) {
+      return;
+    }
+    LOG.info("Waiting up to {} ms for {} extension services to persist their 
state and deregister...",
+        timeout.toMillis(), registered);
+    while (registered > 0 && System.nanoTime() < deadline) {
+      try {
+        
TimeUnit.MILLISECONDS.sleep(EXTENSION_DEREGISTRATION_POLL_INTERVAL.toMillis());
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        return;
       }
-    });
+      registered = countRegisteredExtensionServices();
+    }
+    if (registered > 0) {
+      LOG.info("{} extension services are still registered, continuing 
shutdown", registered);
+    } else {
+      LOG.info("All extension services deregistered");
+    }
+  }
 
-    new FunctionManager(extensionServiceRequestManager, resourceManager, 
extensionsServiceStorage)
-        .stopAllFunctionsAndPersistState(functionStateStorage);
+  private long countRegisteredExtensionServices() {
+    try {
+      return extensionsServiceStorage.findAll().size();
+    } catch (RuntimeException e) {
+      LOG.warn("Could not read registered extension services during shutdown: 
{}", e.getMessage());
+      return 0;
+    }
+  }
 
+  @PreDestroy
+  public void onExit() {
     LOG.info("Thanks for using Apache StreamPipes - see you next time!");
   }
 
-  private List<Pipeline> getAllPipelines() {
-    return getPipelineStorage().findAll();
+  /**
+   * Interrupts the executors and waits for running tasks to finish, within 
one shared budget
+   * of {@link #EXECUTOR_SHUTDOWN_TIMEOUT}. A health check that is already 
past its shutdown
+   * guard may be in the middle of an HTTP request, which does not react to 
the interrupt, so
+   * the wait keeps it from touching beans that are being destroyed. The 
checks consult
+   * {@link ShutdownSignal} before every recovery side effect. Idle executors 
terminate at once.
+   */
+  private void stopExecutors(List<ScheduledExecutorService> executors) {
+    var deadline = System.nanoTime() + EXECUTOR_SHUTDOWN_TIMEOUT.toNanos();
+    executors.forEach(ScheduledExecutorService::shutdownNow);
+    for (var executor : executors) {
+      var remaining = deadline - System.nanoTime();
+      try {
+        if (remaining <= 0 || !executor.awaitTermination(remaining, 
TimeUnit.NANOSECONDS)) {
+          LOG.warn("A scheduled task did not finish within {} seconds, 
continuing shutdown",
+              EXECUTOR_SHUTDOWN_TIMEOUT.toSeconds());
+          return;
+        }
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        return;
+      }
+    }
   }
 
   private IPipelineStorage getPipelineStorage() {
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
 
b/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/ExtensionServiceShutdownHook.java
similarity index 57%
copy from 
streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
copy to 
streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/ExtensionServiceShutdownHook.java
index 6a0744d10f..794865eb7e 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/model/HealthCheckData.java
+++ 
b/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/ExtensionServiceShutdownHook.java
@@ -16,15 +16,19 @@
  *
  */
 
-package org.apache.streampipes.health.monitoring.model;
+package org.apache.streampipes.service.extensions;
 
-import org.apache.streampipes.health.monitoring.ResourceProvider;
-import org.apache.streampipes.model.health.ExtensionInstanceHealth;
-
-import java.util.Map;
+/**
+ * Cleanup that an extension service runs when it shuts down, in addition to 
stopping
+ * the pipeline elements, adapters and functions it knows about. Register 
hooks by
+ * overriding {@link 
StreamPipesExtensionsServiceBase#getAdditionalShutdownHooks()}.
+ *
+ * <p>Hooks run after the web server and the broker receiver are closed, so 
they must
+ * not rely on requests from the core. Exceptions are logged and do not 
prevent the
+ * remaining hooks from running.
+ */
+@FunctionalInterface
+public interface ExtensionServiceShutdownHook {
 
-public record HealthCheckData(ResourceProvider resourceProvider,
-                              ActiveResources activeResources,
-                              Map<String, ActiveCoreInstances> 
activeCoreInstances,
-                              Map<String, ExtensionInstanceHealth> 
activeExtensionInstances) {
+  void onShutdown();
 }
diff --git 
a/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/ExtensionsServiceShutdownHandler.java
 
b/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/ExtensionsServiceShutdownHandler.java
index 8a294525ef..e96f3a9b32 100644
--- 
a/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/ExtensionsServiceShutdownHandler.java
+++ 
b/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/ExtensionsServiceShutdownHandler.java
@@ -15,30 +15,189 @@
  * limitations under the License.
  *
  */
+
 package org.apache.streampipes.service.extensions;
 
+import org.apache.streampipes.client.StreamPipesClient;
+import org.apache.streampipes.commons.exceptions.SpHttpErrorStatusCode;
+import org.apache.streampipes.commons.exceptions.connect.AdapterException;
+import org.apache.streampipes.extensions.api.pe.runtime.IStreamPipesRuntime;
+import 
org.apache.streampipes.extensions.management.client.StreamPipesClientResolver;
+import 
org.apache.streampipes.extensions.management.connect.AdapterTransitionRegistry;
+import 
org.apache.streampipes.extensions.management.connect.AdapterWorkerManagement;
+import org.apache.streampipes.extensions.management.init.DeclarersSingleton;
+import 
org.apache.streampipes.extensions.management.init.RunningAdapterInstances;
 import org.apache.streampipes.extensions.management.init.RunningInstances;
+import org.apache.streampipes.model.connect.adapter.AdapterDescription;
+import org.apache.streampipes.model.function.FunctionShutdownResult;
+import org.apache.streampipes.model.function.FunctionsShutdownResponse;
+import 
org.apache.streampipes.service.extensions.function.StreamPipesFunctionHandler;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * Stops everything this extension service is running when the service shuts 
down.
+ *
+ * <p>By the time the shutdown handler runs, the service no longer accepts 
requests from
+ * the core, so it cannot wait for detach requests. Instead, pipeline 
elements, adapters
+ * and functions are stopped locally. Nothing is reported back to the core on 
purpose:
+ * pipelines and adapters remain marked as running in storage, and the core's 
health
+ * checks re-attach them as soon as this service is registered again. Function 
state is
+ * the one thing that is persisted, since it would otherwise be lost. It is 
persisted first,
+ * because the core only keeps serving requests for a short while after it 
received its own
+ * shutdown signal, and stopping adapters and pipeline elements can take 
seconds. Persisting
+ * requires the core's function state endpoints ({@code 
SP_FUNCTION_STATE_ENDPOINTS_ENABLED}).
+ *
+ * <p>Services that run instances outside the registries known here, register 
an
+ * {@link ExtensionServiceShutdownHook} to stop them.
+ */
 public class ExtensionsServiceShutdownHandler {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(ExtensionsServiceShutdownHandler.class);
 
+  private final RunningInstances runningInstances;
+  private final AdapterWorkerManagement adapterManagement;
+  private final Supplier<FunctionsShutdownResponse> functionShutdown;
+  private final Supplier<StreamPipesClient> clientSupplier;
+  private final List<ExtensionServiceShutdownHook> additionalHooks;
+
+  public ExtensionsServiceShutdownHandler() {
+    this(List.of());
+  }
+
+  public ExtensionsServiceShutdownHandler(List<ExtensionServiceShutdownHook> 
additionalHooks) {
+    this(
+        RunningInstances.INSTANCE,
+        new AdapterWorkerManagement(
+            RunningAdapterInstances.INSTANCE,
+            DeclarersSingleton.getInstance(),
+            AdapterTransitionRegistry.INSTANCE
+        ),
+        StreamPipesFunctionHandler.INSTANCE::shutdownFunctionsAndGetState,
+        () -> new StreamPipesClientResolver().makeStreamPipesClientInstance(),
+        additionalHooks
+    );
+  }
+
+  ExtensionsServiceShutdownHandler(RunningInstances runningInstances,
+                                   AdapterWorkerManagement adapterManagement,
+                                   Supplier<FunctionsShutdownResponse> 
functionShutdown,
+                                   Supplier<StreamPipesClient> clientSupplier,
+                                   List<ExtensionServiceShutdownHook> 
additionalHooks) {
+    this.runningInstances = runningInstances;
+    this.adapterManagement = adapterManagement;
+    this.functionShutdown = functionShutdown;
+    this.clientSupplier = clientSupplier;
+    this.additionalHooks = List.copyOf(additionalHooks);
+  }
 
   public void onShutdown() {
     LOG.info("Shutting down StreamPipes extensions service...");
-    int runningInstancesCount = 
RunningInstances.INSTANCE.getRunningInstancesCount();
+    stopFunctions();
+    stopPipelineElements();
+    stopAdapters();
+    runAdditionalHooks();
+  }
+
+  private void runAdditionalHooks() {
+    additionalHooks.forEach(hook -> {
+      try {
+        hook.onShutdown();
+      } catch (RuntimeException e) {
+        LOG.warn("Shutdown hook {} threw an exception", 
hook.getClass().getName(), e);
+      }
+    });
+  }
 
-    while (runningInstancesCount > 0) {
-      LOG.info("Waiting for {} running pipeline elements to be stopped...", 
runningInstancesCount);
+  private void stopPipelineElements() {
+    var instanceKeys = runningInstances.getRunningInstanceKeys();
+    LOG.info("Stopping {} running pipeline elements...", instanceKeys.size());
+    instanceKeys.forEach(this::stopPipelineElement);
+  }
+
+  private void stopPipelineElement(String instanceKey) {
+    IStreamPipesRuntime<?, ?> runtime = 
runningInstances.getInvocation(instanceKey);
+    try {
+      if (runtime != null) {
+        var response = runtime.onRuntimeDetached(instanceKey);
+        if (response != null && !response.isSuccess()) {
+          LOG.warn("Pipeline element {} did not stop cleanly: {}", 
instanceKey, response.getOptionalMessage());
+        }
+      }
+    } catch (RuntimeException e) {
+      LOG.warn("Pipeline element {} threw an exception while stopping", 
instanceKey, e);
+    } finally {
+      runningInstances.remove(instanceKey);
+    }
+  }
+
+  private void stopAdapters() {
+    List<AdapterDescription> adapters = 
List.copyOf(adapterManagement.getAllRunningAdapterInstances());
+    LOG.info("Stopping {} running adapters...", adapters.size());
+    adapters.forEach(adapter -> {
       try {
-        Thread.sleep(1000);
-      } catch (InterruptedException e) {
-        LOG.error("Could not pause current thread...");
+        adapterManagement.stopAdapter(adapter);
+      } catch (AdapterException | RuntimeException e) {
+        LOG.warn("Adapter {} threw an exception while stopping", 
adapter.getElementId(), e);
+      }
+    });
+  }
+
+  private void stopFunctions() {
+    FunctionsShutdownResponse response;
+    try {
+      response = functionShutdown.get();
+    } catch (RuntimeException e) {
+      LOG.warn("Functions threw an exception while stopping", e);
+      return;
+    }
+    var statefulFunctions = response.getFunctions().stream()
+        .filter(result -> result.getState() != null)
+        .toList();
+    if (statefulFunctions.isEmpty()) {
+      return;
+    }
+    LOG.info("Persisting state of {} functions...", statefulFunctions.size());
+    StreamPipesClient client;
+    try {
+      client = clientSupplier.get();
+    } catch (RuntimeException e) {
+      LOG.warn("Could not persist function state, the core might already be 
down: {}", e.getMessage());
+      return;
+    }
+    for (int i = 0; i < statefulFunctions.size(); i++) {
+      if (!persistFunctionState(client, statefulFunctions.get(i))) {
+        LOG.warn("The core is unreachable, skipping state persistence of {} 
remaining functions",
+            statefulFunctions.size() - i - 1);
+        return;
+      }
+    }
+  }
+
+  /**
+   * @return false if the core could not be reached at all, so that the caller 
does not wait
+   *         for a connection timeout per remaining function
+   */
+  private boolean persistFunctionState(StreamPipesClient client, 
FunctionShutdownResult result) {
+    try {
+      client.adminApi().persistFunctionState(result.getFunctionId(), 
result.getState());
+      return true;
+    } catch (SpHttpErrorStatusCode e) {
+      if (e.getHttpStatusCode() != null && e.getHttpStatusCode() == 404) {
+        LOG.warn("Could not persist state of function {}: the core's function 
state endpoints are disabled "
+            + "(set SP_FUNCTION_STATE_ENDPOINTS_ENABLED=true)", 
result.getFunctionId());
+      } else {
+        LOG.warn("Could not persist state of function {}: core answered with 
status {}",
+            result.getFunctionId(), e.getHttpStatusCode());
       }
-      runningInstancesCount = 
RunningInstances.INSTANCE.getRunningInstancesCount();
+      return true;
+    } catch (RuntimeException e) {
+      LOG.warn("Could not persist state of function {}: {}", 
result.getFunctionId(), e.getMessage());
+      return false;
     }
   }
 }
diff --git 
a/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/StreamPipesExtensionsServiceBase.java
 
b/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/StreamPipesExtensionsServiceBase.java
index 049b0a639c..43b89eeaf5 100644
--- 
a/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/StreamPipesExtensionsServiceBase.java
+++ 
b/streampipes-service-extensions/src/main/java/org/apache/streampipes/service/extensions/StreamPipesExtensionsServiceBase.java
@@ -253,13 +253,31 @@ public abstract class StreamPipesExtensionsServiceBase 
extends StreamPipesServic
     client.adminApi().registerServiceConfiguration(serviceConfiguration);
   }
 
+  /**
+   * The web server is already stopped when this runs, and the broker receiver 
is closed
+   * first, so no further requests from the core can arrive. Running pipeline 
elements,
+   * adapters and functions are therefore stopped locally. The core keeps 
their state as
+   * running and re-attaches once this service registers again.
+   */
   @PreDestroy
   public void onExit() {
     if (extensionBrokerRequestReceiver != null) {
       extensionBrokerRequestReceiver.stop();
     }
-    new ExtensionsServiceShutdownHandler().onShutdown();
-    deregisterService(DeclarersSingleton.getInstance().getServiceId());
+    List<ExtensionServiceShutdownHook> additionalHooks;
+    try {
+      additionalHooks = getAdditionalShutdownHooks();
+    } catch (RuntimeException e) {
+      LOG.warn("Could not collect additional shutdown hooks, continuing 
without them", e);
+      additionalHooks = List.of();
+    }
+    new ExtensionsServiceShutdownHandler(additionalHooks).onShutdown();
+    ServiceLoadDataReportGenerator.getInstance().shutdown();
+    try {
+      deregisterService(DeclarersSingleton.getInstance().getServiceId());
+    } catch (RuntimeException e) {
+      LOG.warn("Could not deregister service at core (core might already be 
down): {}", e.getMessage());
+    }
   }
 
   public String serviceId() {
@@ -270,6 +288,14 @@ public abstract class StreamPipesExtensionsServiceBase 
extends StreamPipesServic
     return List.of();
   }
 
+  /**
+   * Override to stop instances this service runs outside the standard 
pipeline element,
+   * adapter and function registries. Hooks run after those registries are 
cleaned up.
+   */
+  protected List<ExtensionServiceShutdownHook> getAdditionalShutdownHooks() {
+    return List.of();
+  }
+
   public abstract SpServiceDefinition provideServiceDefinition();
 
 }
diff --git 
a/streampipes-service-extensions/src/test/java/org/apache/streampipes/service/extensions/ExtensionsServiceShutdownHandlerTest.java
 
b/streampipes-service-extensions/src/test/java/org/apache/streampipes/service/extensions/ExtensionsServiceShutdownHandlerTest.java
new file mode 100644
index 0000000000..6aa950f02a
--- /dev/null
+++ 
b/streampipes-service-extensions/src/test/java/org/apache/streampipes/service/extensions/ExtensionsServiceShutdownHandlerTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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.
+ *
+ */
+
+package org.apache.streampipes.service.extensions;
+
+import org.apache.streampipes.client.StreamPipesClient;
+import org.apache.streampipes.commons.exceptions.SpHttpErrorStatusCode;
+import org.apache.streampipes.commons.exceptions.connect.AdapterException;
+import org.apache.streampipes.extensions.api.pe.runtime.IStreamPipesRuntime;
+import 
org.apache.streampipes.extensions.management.connect.AdapterWorkerManagement;
+import org.apache.streampipes.extensions.management.init.RunningInstances;
+import org.apache.streampipes.model.Response;
+import org.apache.streampipes.model.connect.adapter.AdapterDescription;
+import org.apache.streampipes.model.function.FunctionShutdownResult;
+import org.apache.streampipes.model.function.FunctionsShutdownResponse;
+import org.apache.streampipes.model.graph.DataProcessorInvocation;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ExtensionsServiceShutdownHandlerTest {
+
+  private static final String HEALTHY_INSTANCE = "healthy-instance";
+  private static final String FAILING_INSTANCE = "failing-instance";
+
+  private final RunningInstances runningInstances = RunningInstances.INSTANCE;
+  private AdapterWorkerManagement adapterManagement;
+  private StreamPipesClient client;
+  private Supplier<FunctionsShutdownResponse> functionShutdown;
+
+  @BeforeEach
+  void setUp() {
+    clearRunningInstances();
+    adapterManagement = mock(AdapterWorkerManagement.class);
+    
when(adapterManagement.getAllRunningAdapterInstances()).thenReturn(List.of());
+    client = mock(StreamPipesClient.class, RETURNS_DEEP_STUBS);
+    functionShutdown = FunctionsShutdownResponse::new;
+  }
+
+  @AfterEach
+  void tearDown() {
+    clearRunningInstances();
+  }
+
+  @Test
+  void detachesEveryPipelineElementEvenIfOneFails() {
+    var healthy = mock(IStreamPipesRuntime.class);
+    when(healthy.onRuntimeDetached(HEALTHY_INSTANCE)).thenReturn(new 
Response(HEALTHY_INSTANCE, true));
+    var failing = mock(IStreamPipesRuntime.class);
+    when(failing.onRuntimeDetached(FAILING_INSTANCE)).thenThrow(new 
IllegalStateException("boom"));
+    runningInstances.add(FAILING_INSTANCE, invocation(FAILING_INSTANCE), 
failing);
+    runningInstances.add(HEALTHY_INSTANCE, invocation(HEALTHY_INSTANCE), 
healthy);
+
+    handler().onShutdown();
+
+    verify(healthy).onRuntimeDetached(HEALTHY_INSTANCE);
+    verify(failing).onRuntimeDetached(FAILING_INSTANCE);
+    assertEquals(0, runningInstances.getRunningInstancesCount());
+  }
+
+  @Test
+  void stopsEveryAdapterEvenIfOneFails() throws AdapterException {
+    var failingAdapter = adapter("failing-adapter");
+    var healthyAdapter = adapter("healthy-adapter");
+    
when(adapterManagement.getAllRunningAdapterInstances()).thenReturn(List.of(failingAdapter,
 healthyAdapter));
+    doThrow(new 
AdapterException("boom")).when(adapterManagement).stopAdapter(failingAdapter);
+
+    handler().onShutdown();
+
+    verify(adapterManagement).stopAdapter(failingAdapter);
+    verify(adapterManagement).stopAdapter(healthyAdapter);
+  }
+
+  @Test
+  void persistsStateOfStatefulFunctionsOnly() {
+    var state = Map.<String, Object>of("counter", 42);
+    functionShutdown = () -> new FunctionsShutdownResponse(List.of(
+        new FunctionShutdownResult("stateful", state),
+        new FunctionShutdownResult("stateless", null)
+    ));
+
+    handler().onShutdown();
+
+    verify(client.adminApi()).persistFunctionState("stateful", state);
+    verify(client.adminApi(), never()).persistFunctionState("stateless", null);
+  }
+
+  @Test
+  void unreachableCoreStopsPersistenceEarlyButNotLocalCleanup() {
+    var runtime = mock(IStreamPipesRuntime.class);
+    when(runtime.onRuntimeDetached(HEALTHY_INSTANCE)).thenReturn(new 
Response(HEALTHY_INSTANCE, true));
+    runningInstances.add(HEALTHY_INSTANCE, invocation(HEALTHY_INSTANCE), 
runtime);
+    var adapter = adapter("adapter");
+    
when(adapterManagement.getAllRunningAdapterInstances()).thenReturn(List.of(adapter));
+    functionShutdown = () -> new FunctionsShutdownResponse(List.of(
+        new FunctionShutdownResult("first", Map.of("counter", 1)),
+        new FunctionShutdownResult("second", Map.of("counter", 2))
+    ));
+    var admin = client.adminApi();
+    doThrow(new IllegalStateException("connection 
refused")).when(admin).persistFunctionState(any(), any());
+
+    assertDoesNotThrow(() -> handler().onShutdown());
+
+    verify(admin, times(1)).persistFunctionState(any(), any());
+    verify(runtime).onRuntimeDetached(HEALTHY_INSTANCE);
+    assertEquals(0, runningInstances.getRunningInstancesCount());
+  }
+
+  @Test
+  void endpointErrorsDoNotStopPersistenceOfOtherFunctions() {
+    functionShutdown = () -> new FunctionsShutdownResponse(List.of(
+        new FunctionShutdownResult("first", Map.of("counter", 1)),
+        new FunctionShutdownResult("second", Map.of("counter", 2))
+    ));
+    var admin = client.adminApi();
+    doThrow(new SpHttpErrorStatusCode("404", 
404)).when(admin).persistFunctionState(any(), any());
+
+    handler().onShutdown();
+
+    verify(admin, times(2)).persistFunctionState(any(), any());
+  }
+
+  @Test
+  void toleratesMissingClient() {
+    functionShutdown = () -> new FunctionsShutdownResponse(List.of(
+        new FunctionShutdownResult("stateful", Map.of("counter", 1))
+    ));
+    var withoutClient = new ExtensionsServiceShutdownHandler(runningInstances, 
adapterManagement, functionShutdown,
+        () -> {
+          throw new IllegalStateException("no client");
+        }, List.of());
+
+    assertDoesNotThrow(withoutClient::onShutdown);
+  }
+
+  @Test
+  void persistsFunctionStateBeforeStoppingPipelineElements() {
+    var runtime = mock(IStreamPipesRuntime.class);
+    when(runtime.onRuntimeDetached(HEALTHY_INSTANCE)).thenReturn(new 
Response(HEALTHY_INSTANCE, true));
+    runningInstances.add(HEALTHY_INSTANCE, invocation(HEALTHY_INSTANCE), 
runtime);
+    var state = Map.<String, Object>of("counter", 7);
+    functionShutdown = () -> new FunctionsShutdownResponse(List.of(new 
FunctionShutdownResult("stateful", state)));
+    var admin = client.adminApi();
+
+    handler().onShutdown();
+
+    var order = inOrder(admin, runtime);
+    order.verify(admin).persistFunctionState("stateful", state);
+    order.verify(runtime).onRuntimeDetached(HEALTHY_INSTANCE);
+  }
+
+  @Test
+  void runsEveryAdditionalHookEvenIfOneFails() {
+    var failing = mock(ExtensionServiceShutdownHook.class);
+    doThrow(new IllegalStateException("boom")).when(failing).onShutdown();
+    var healthy = mock(ExtensionServiceShutdownHook.class);
+
+    handler(List.of(failing, healthy)).onShutdown();
+
+    verify(failing).onShutdown();
+    verify(healthy).onShutdown();
+  }
+
+  private ExtensionsServiceShutdownHandler handler() {
+    return handler(List.of());
+  }
+
+  private ExtensionsServiceShutdownHandler 
handler(List<ExtensionServiceShutdownHook> hooks) {
+    return new ExtensionsServiceShutdownHandler(runningInstances, 
adapterManagement, functionShutdown, () -> client,
+        hooks);
+  }
+
+  private DataProcessorInvocation invocation(String elementId) {
+    var invocation = new DataProcessorInvocation();
+    invocation.setElementId(elementId);
+    invocation.setAppId("app-" + elementId);
+    return invocation;
+  }
+
+  private AdapterDescription adapter(String elementId) {
+    var description = new AdapterDescription();
+    description.setElementId(elementId);
+    return description;
+  }
+
+  private void clearRunningInstances() {
+    
runningInstances.getRunningInstanceKeys().forEach(runningInstances::remove);
+  }
+}

Reply via email to