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 585abb8432 fix: preserve pipelines on extension outages (#5181)
585abb8432 is described below

commit 585abb843251262f8814eb3176699c8762d08176
Author: Jacqueline Höllig <[email protected]>
AuthorDate: Mon Nov 2 19:15:32 2026 +0100

    fix: preserve pipelines on extension outages (#5181)
    
    Co-authored-by: Sven Oehler <[email protected]>
---
 .../model/connect/adapter/PipelineUpdateInfo.java  |   9 ++
 .../CustomTransformOutputSchemaGenerator.java      |  44 +++++--
 .../output/OutputSchemaUnavailableException.java   |  36 ++++++
 .../update/DataStreamUpdateManagement.java         |   2 +-
 .../pipeline/update/PipelineUpdateCoordinator.java | 102 ++++++++++++++-
 .../CustomTransformOutputSchemaGeneratorTest.java  | 101 +++++++++++++++
 .../update/DataStreamUpdateManagementTest.java     |  24 +++-
 .../update/PipelineUpdateCoordinatorTest.java      |  59 +++++++++
 .../streampipes/rest/impl/PipelineResource.java    |   8 ++
 ui/deployment/i18n/de.json                         |   4 +-
 ui/deployment/i18n/en.json                         |   4 +-
 ui/deployment/i18n/pl.json                         |   4 +-
 .../src/lib/model/gen/streampipes-model.ts         |   2 +
 ...adapter-started-update-migration.component.html |  28 ++++-
 .../components/pipeline/pipeline.component.spec.ts |  64 ++++++++++
 .../components/pipeline/pipeline.component.ts      | 137 ++++++++++++++++-----
 .../app/editor/services/pipeline-style.service.ts  |  26 ++++
 .../editor/services/pipeline-validation.service.ts |  25 +++-
 18 files changed, 619 insertions(+), 60 deletions(-)

diff --git 
a/streampipes-model/src/main/java/org/apache/streampipes/model/connect/adapter/PipelineUpdateInfo.java
 
b/streampipes-model/src/main/java/org/apache/streampipes/model/connect/adapter/PipelineUpdateInfo.java
index 8ee7184720..d77bd240ba 100644
--- 
a/streampipes-model/src/main/java/org/apache/streampipes/model/connect/adapter/PipelineUpdateInfo.java
+++ 
b/streampipes-model/src/main/java/org/apache/streampipes/model/connect/adapter/PipelineUpdateInfo.java
@@ -33,6 +33,7 @@ public class PipelineUpdateInfo {
   private String pipelineName;
   private boolean canAutoMigrate;
   private String migrationInfo;
+  private boolean validationUnavailable;
   private Map<String, List<PipelineElementValidationInfo>> validationInfos;
   private List<ChartSchemaUpdateInfo> chartSchemaUpdateInfos;
 
@@ -73,6 +74,14 @@ public class PipelineUpdateInfo {
     this.migrationInfo = migrationInfo;
   }
 
+  public boolean isValidationUnavailable() {
+    return validationUnavailable;
+  }
+
+  public void setValidationUnavailable(boolean validationUnavailable) {
+    this.validationUnavailable = validationUnavailable;
+  }
+
   public Map<String, List<PipelineElementValidationInfo>> getValidationInfos() 
{
     return validationInfos;
   }
diff --git 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/matching/output/CustomTransformOutputSchemaGenerator.java
 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/matching/output/CustomTransformOutputSchemaGenerator.java
index c9ee72c879..cc0a04812c 100644
--- 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/matching/output/CustomTransformOutputSchemaGenerator.java
+++ 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/matching/output/CustomTransformOutputSchemaGenerator.java
@@ -17,6 +17,7 @@
  */
 package org.apache.streampipes.manager.matching.output;
 
+import 
org.apache.streampipes.commons.exceptions.NoServiceEndpointsAvailableException;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceOperationResult;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestTargets;
@@ -79,8 +80,14 @@ public class CustomTransformOutputSchemaGenerator extends 
OutputSchemaGenerator<
   }
 
   private EventSchema makeRequest() {
+    String httpRequestBody;
+    try {
+      httpRequestBody = makeRequestBody();
+    } catch (IOException e) {
+      throw new IllegalStateException("Could not create the output schema 
request", e);
+    }
+
     try {
-      String httpRequestBody = makeRequestBody();
       var service = new 
ExtensionsServiceEndpointGenerator(serviceDiscovery).selectService(
           dataProcessorInvocation.getAppId(),
           SpServiceUrlProvider.DATA_PROCESSOR,
@@ -93,9 +100,8 @@ public class CustomTransformOutputSchemaGenerator extends 
OutputSchemaGenerator<
       );
       var response = 
requestManager.request(ExtensionServiceRequests.outputSchema(requestTarget, 
httpRequestBody));
       return handleResponse(response);
-    } catch (Exception e) {
-      e.printStackTrace();
-      return new EventSchema();
+    } catch (NoServiceEndpointsAvailableException | IOException e) {
+      throw new 
OutputSchemaUnavailableException(dataProcessorInvocation.getAppId(), e);
     }
   }
 
@@ -105,15 +111,33 @@ public class CustomTransformOutputSchemaGenerator extends 
OutputSchemaGenerator<
     return 
JacksonSerializer.getObjectMapper().writeValueAsString(requestInvocation);
   }
 
-  private EventSchema handleResponse(ExtensionServiceOperationResult response) 
throws JsonSyntaxException, IOException {
+  private EventSchema handleResponse(ExtensionServiceOperationResult response) 
{
+    if (response == null) {
+      throw new IllegalStateException("The extension returned no output schema 
response");
+    }
+
     if (!response.isSuccess()) {
-      throw new IOException("Could not compute output schema, status code: " + 
response.statusCode());
+      var cause = new IOException("Could not compute output schema, status 
code: " + response.statusCode());
+      if (isUnavailableStatus(response.statusCode())) {
+        throw new 
OutputSchemaUnavailableException(dataProcessorInvocation.getAppId(), cause);
+      }
+      throw new IllegalStateException(cause);
     }
 
-    String resp = response.responseBody();
+    try {
+      var eventSchema = JacksonSerializer
+          .getObjectMapper()
+          .readValue(response.responseBody(), EventSchema.class);
+      if (eventSchema == null) {
+        throw new IllegalStateException("The extension returned no output 
schema");
+      }
+      return eventSchema;
+    } catch (IOException | JsonSyntaxException e) {
+      throw new IllegalStateException("The extension returned an invalid 
output schema", e);
+    }
+  }
 
-    return JacksonSerializer
-        .getObjectMapper()
-        .readValue(resp, EventSchema.class);
+  private boolean isUnavailableStatus(int statusCode) {
+    return statusCode == 502 || statusCode == 503 || statusCode == 504;
   }
 }
diff --git 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/matching/output/OutputSchemaUnavailableException.java
 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/matching/output/OutputSchemaUnavailableException.java
new file mode 100644
index 0000000000..498d08ca8c
--- /dev/null
+++ 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/matching/output/OutputSchemaUnavailableException.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.streampipes.manager.matching.output;
+
+/**
+ * Signals that a custom output schema could not be determined from its 
extension service.
+ */
+public class OutputSchemaUnavailableException extends RuntimeException {
+
+  private final String appId;
+
+  public OutputSchemaUnavailableException(String appId, Throwable cause) {
+    super("Could not compute the output schema for pipeline element " + appId, 
cause);
+    this.appId = appId;
+  }
+
+  public String getAppId() {
+    return appId;
+  }
+}
diff --git 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/pipeline/update/DataStreamUpdateManagement.java
 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/pipeline/update/DataStreamUpdateManagement.java
index 5de6578114..6ca7f87e06 100644
--- 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/pipeline/update/DataStreamUpdateManagement.java
+++ 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/pipeline/update/DataStreamUpdateManagement.java
@@ -48,8 +48,8 @@ public class DataStreamUpdateManagement {
     boolean schemaChanged = EventSchemaChangeDetector.hasChanged(
         previous == null ? null : previous.getEventSchema(), 
dataStream.getEventSchema());
     dataStreamResourceManager.update(dataStream);
-    pipelineUpdateCoordinator.updatePipelines(dataStream);
     if (schemaChanged) {
+      pipelineUpdateCoordinator.updatePipelines(dataStream);
       eventPublisher.publishEvent(new 
DataStreamSchemaUpdatedEvent(dataStream.getElementId(), dataStream.getName()));
     }
   }
diff --git 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/pipeline/update/PipelineUpdateCoordinator.java
 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/pipeline/update/PipelineUpdateCoordinator.java
index 7405727c07..6dadd74ab4 100644
--- 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/pipeline/update/PipelineUpdateCoordinator.java
+++ 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/pipeline/update/PipelineUpdateCoordinator.java
@@ -22,12 +22,15 @@ import 
org.apache.streampipes.commons.prometheus.pipelines.PipelinesStats;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
 import org.apache.streampipes.manager.execution.PipelineExecutor;
 import org.apache.streampipes.manager.matching.PipelineVerificationHandlerV2;
+import 
org.apache.streampipes.manager.matching.output.OutputSchemaUnavailableException;
 import 
org.apache.streampipes.manager.matching.v2.pipeline.MeasurementChangeValidationStep;
 import org.apache.streampipes.manager.pipeline.PipelineElementUserCleaner;
 import org.apache.streampipes.manager.pipeline.PipelineManager;
 import org.apache.streampipes.model.SpDataStream;
 import org.apache.streampipes.model.base.NamedStreamPipesEntity;
 import org.apache.streampipes.model.connect.adapter.PipelineUpdateInfo;
+import org.apache.streampipes.model.graph.DataProcessorInvocation;
+import org.apache.streampipes.model.graph.DataSinkInvocation;
 import org.apache.streampipes.model.message.PipelineModificationMessage;
 import org.apache.streampipes.model.pipeline.Pipeline;
 import org.apache.streampipes.model.pipeline.PipelineElementValidationInfo;
@@ -43,12 +46,14 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Stream;
 
 public class PipelineUpdateCoordinator {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(PipelineUpdateCoordinator.class);
   private static final PipelinesStats PIPELINES_STATS = new PipelinesStats();
+  private static final String DEFERRED_VALIDATION_NOTIFICATION_PREFIX = 
"Validation deferred: ";
 
   private final ExtensionServiceRequestManager requestManager;
   private final ChartSchemaUpdateCoordinator chartSchemaUpdateCoordinator;
@@ -90,12 +95,10 @@ public class PipelineUpdateCoordinator {
 
     affectedPipelines.forEach(pipeline -> {
       var shouldRestartPipeline = pipeline.isRunning();
-      if (shouldRestartPipeline) {
-        new PipelineExecutor(pipeline, requestManager, 
resourceManager).stopPipeline(true);
-      }
-
+      var pipelineStopped = new AtomicBoolean();
       var storedPipeline = 
pipelineManager.getPipeline(pipeline.getPipelineId());
-      var updatedPipeline = updatePipeline(storedPipeline, affectedElementId, 
updatedStreamName, updatedEventSchema);
+      var updatedPipeline = updatePipeline(
+          copyPipeline(storedPipeline), affectedElementId, updatedStreamName, 
updatedEventSchema);
 
       try {
         var verificationHandler = new 
PipelineVerificationHandlerV2(resourceManager.getServiceDiscovery(), 
updatedPipeline, requestManager);
@@ -104,6 +107,8 @@ public class PipelineUpdateCoordinator {
         var modifiedPipeline = 
verificationHandler.makeModifiedPipeline(modificationMessage).pipeline();
         var canAutoMigrate = canAutoMigrate(modificationMessage);
 
+        removeDeferredValidationNotifications(modifiedPipeline);
+
         if (!canAutoMigrate) {
           modifiedPipeline.setHealthStatus(
               requiresMeasurementUpdate(modificationMessage)
@@ -119,6 +124,12 @@ public class PipelineUpdateCoordinator {
           modifiedPipeline.setValid(false);
         }
 
+        if (shouldRestartPipeline) {
+          new PipelineExecutor(pipeline, requestManager, 
resourceManager).stopPipeline(true);
+          pipelineStopped.set(true);
+          modifiedPipeline.setRunning(false);
+          
modifiedPipeline.setRev(pipelineManager.getPipeline(pipeline.getPipelineId()).getRev());
+        }
         PipelineElementUserCleaner.clearCorrespondingUsers(modifiedPipeline);
         pipelineStorage.updateElement(modifiedPipeline);
 
@@ -127,7 +138,18 @@ public class PipelineUpdateCoordinator {
               pipelineManager.getPipeline(pipeline.getPipelineId()), 
requestManager, resourceManager
           ).startPipeline();
         }
+      } catch (OutputSchemaUnavailableException e) {
+        try {
+          deferPipelineUpdate(storedPipeline, notificationType, e);
+        } catch (Exception notificationException) {
+          LOG.warn("Could not record the deferred pipeline update for {}", 
storedPipeline.getName(), notificationException);
+        }
+        LOG.warn("Skipped pipeline update for {} because the output schema for 
{} is unavailable",
+            storedPipeline.getName(), e.getAppId(), e);
       } catch (Exception e) {
+        if (shouldRestartPipeline && !pipelineStopped.get()) {
+          new PipelineExecutor(pipeline, requestManager, 
resourceManager).stopPipeline(true);
+        }
         LOG.error("Could not update pipeline {}", updatedPipeline.getName(), 
e);
       }
     });
@@ -140,7 +162,8 @@ public class PipelineUpdateCoordinator {
     var pipelineUpdateInfos = new ArrayList<PipelineUpdateInfo>();
 
     affectedPipelines.forEach(pipeline -> {
-      var updatedPipeline = updatePipeline(pipeline, affectedElementId, 
updatedStreamName, updatedEventSchema);
+      var updatedPipeline = updatePipeline(
+          copyPipeline(pipeline), affectedElementId, updatedStreamName, 
updatedEventSchema);
       try {
         var verificationHandler = new 
PipelineVerificationHandlerV2(resourceManager.getServiceDiscovery(), 
updatedPipeline, requestManager);
         var modificationMessage = verificationHandler.verifyPipeline();
@@ -148,6 +171,8 @@ public class PipelineUpdateCoordinator {
         updateInfo.setChartSchemaUpdateInfos(
             chartSchemaUpdateCoordinator.checkChartMigrations(updatedPipeline, 
updatedEventSchema));
         pipelineUpdateInfos.add(updateInfo);
+      } catch (OutputSchemaUnavailableException e) {
+        pipelineUpdateInfos.add(makeUnavailableUpdateInfo(pipeline, e));
       } catch (Exception e) {
         throw new RuntimeException(e);
       }
@@ -156,6 +181,71 @@ public class PipelineUpdateCoordinator {
     return pipelineUpdateInfos;
   }
 
+  private Pipeline copyPipeline(Pipeline pipeline) {
+    var copy = pipeline.clone();
+    copy.setRunning(pipeline.isRunning());
+    copy.setRestartOnSystemReboot(pipeline.isRestartOnSystemReboot());
+    copy.setStartedAt(pipeline.getStartedAt());
+    copy.setPublicElement(pipeline.isPublicElement());
+    copy.setLabels(new ArrayList<>(pipeline.getLabels()));
+    copy.setLastMigratedAt(pipeline.getLastMigratedAt());
+    copy.setPipelineNotifications(new 
ArrayList<>(pipeline.getPipelineNotifications()));
+    
copy.setStreams(pipeline.getStreams().stream().map(SpDataStream::new).toList());
+    
copy.setSepas(pipeline.getSepas().stream().map(this::copyProcessor).toList());
+    
copy.setActions(pipeline.getActions().stream().map(this::copySink).toList());
+    return copy;
+  }
+
+  private DataProcessorInvocation copyProcessor(DataProcessorInvocation 
processor) {
+    var copy = new DataProcessorInvocation(processor);
+    copy.setSelectedServiceId(processor.getSelectedServiceId());
+    return copy;
+  }
+
+  private DataSinkInvocation copySink(DataSinkInvocation sink) {
+    var copy = new DataSinkInvocation(sink);
+    copy.setSelectedServiceId(sink.getSelectedServiceId());
+    return copy;
+  }
+
+  private PipelineUpdateInfo makeUnavailableUpdateInfo(Pipeline pipeline,
+                                                       
OutputSchemaUnavailableException exception) {
+    var updateInfo = new PipelineUpdateInfo();
+    updateInfo.setPipelineId(pipeline.getPipelineId());
+    updateInfo.setPipelineName(pipeline.getName());
+    updateInfo.setCanAutoMigrate(false);
+    updateInfo.setValidationUnavailable(true);
+    updateInfo.setMigrationInfo(exception.getMessage());
+    return updateInfo;
+  }
+
+  private void deferPipelineUpdate(Pipeline pipeline,
+                                   String notificationType,
+                                   OutputSchemaUnavailableException exception) 
{
+    var notification = deferredValidationNotification(notificationType, 
exception.getAppId());
+    var notifications = new ArrayList<>(pipeline.getPipelineNotifications());
+    if (notifications.contains(notification)) {
+      return;
+    }
+
+    notifications.add(notification);
+    pipeline.setPipelineNotifications(notifications);
+    pipelineStorage.updateElement(pipeline);
+  }
+
+  private void removeDeferredValidationNotifications(Pipeline pipeline) {
+    
pipeline.setPipelineNotifications(pipeline.getPipelineNotifications().stream()
+        .filter(notification -> 
!notification.startsWith(DEFERRED_VALIDATION_NOTIFICATION_PREFIX))
+        .toList());
+  }
+
+  private String deferredValidationNotification(String notificationType,
+                                                String appId) {
+    return DEFERRED_VALIDATION_NOTIFICATION_PREFIX
+        + "%s modification could not be validated because pipeline element %s 
is unavailable".formatted(
+            notificationType, appId);
+  }
+
   private Pipeline updatePipeline(Pipeline pipeline,
                                   String affectedElementId,
                                   String updatedStreamName,
diff --git 
a/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/matching/output/CustomTransformOutputSchemaGeneratorTest.java
 
b/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/matching/output/CustomTransformOutputSchemaGeneratorTest.java
index a9986ab55c..0c256a82c7 100644
--- 
a/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/matching/output/CustomTransformOutputSchemaGeneratorTest.java
+++ 
b/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/matching/output/CustomTransformOutputSchemaGeneratorTest.java
@@ -18,7 +18,9 @@
 
 package org.apache.streampipes.manager.matching.output;
 
+import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceOperationResult;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
+import 
org.apache.streampipes.model.extensions.svcdiscovery.SpServiceRegistration;
 import org.apache.streampipes.model.graph.DataProcessorInvocation;
 import org.apache.streampipes.model.output.CustomTransformOutputStrategy;
 import org.apache.streampipes.model.staticproperty.SecretStaticProperty;
@@ -29,12 +31,19 @@ import 
org.apache.streampipes.user.management.encryption.SecretEncryptionManager
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 class CustomTransformOutputSchemaGeneratorTest {
 
@@ -65,6 +74,98 @@ class CustomTransformOutputSchemaGeneratorTest {
     assertTrue(secretProperty.getEncrypted());
   }
 
+  @Test
+  void throwsWhenTheExtensionCannotComputeTheOutputSchema() {
+    var invocation = invocation();
+    var generator = new CustomTransformOutputSchemaGenerator(
+        mock(ISpServiceDiscovery.class),
+        new CustomTransformOutputStrategy(),
+        invocation,
+        mock(ExtensionServiceRequestManager.class)
+    );
+
+    var exception = assertThrows(OutputSchemaUnavailableException.class,
+        () -> generator.buildFromOneStream(null));
+
+    assertEquals(invocation.getAppId(), exception.getAppId());
+  }
+
+  @Test
+  void throwsWhenTheExtensionReturnsAnErrorResponse() throws IOException {
+    var invocation = invocation();
+    var serviceDiscovery = mock(ISpServiceDiscovery.class);
+    var requestManager = mock(ExtensionServiceRequestManager.class);
+    when(serviceDiscovery.getService(anyString(), eq(true), 
anyList())).thenReturn(List.of(service()));
+    when(requestManager.request(any())).thenReturn(new 
ExtensionServiceOperationResult(500, null));
+    var generator = new CustomTransformOutputSchemaGenerator(
+        serviceDiscovery,
+        new CustomTransformOutputStrategy(),
+        invocation,
+        requestManager
+    );
+
+    var exception = assertThrows(IllegalStateException.class,
+        () -> generator.buildFromOneStream(null));
+
+    assertTrue(exception.getMessage().contains("status code: 500"));
+  }
+
+  @Test
+  void treatsGatewayUnavailableResponsesAsOutputSchemaUnavailable() throws 
IOException {
+    var invocation = invocation();
+    var serviceDiscovery = mock(ISpServiceDiscovery.class);
+    var requestManager = mock(ExtensionServiceRequestManager.class);
+    when(serviceDiscovery.getService(anyString(), eq(true), 
anyList())).thenReturn(List.of(service()));
+    when(requestManager.request(any())).thenReturn(new 
ExtensionServiceOperationResult(503, null));
+    var generator = new CustomTransformOutputSchemaGenerator(
+        serviceDiscovery,
+        new CustomTransformOutputStrategy(),
+        invocation,
+        requestManager
+    );
+
+    var exception = assertThrows(OutputSchemaUnavailableException.class,
+        () -> generator.buildFromOneStream(null));
+
+    assertEquals(invocation.getAppId(), exception.getAppId());
+  }
+
+  @Test
+  void throwsWhenTheExtensionReturnsAnInvalidSchema() throws IOException {
+    var invocation = invocation();
+    var serviceDiscovery = mock(ISpServiceDiscovery.class);
+    var requestManager = mock(ExtensionServiceRequestManager.class);
+    when(serviceDiscovery.getService(anyString(), eq(true), 
anyList())).thenReturn(List.of(service()));
+    when(requestManager.request(any())).thenReturn(new 
ExtensionServiceOperationResult(
+        200, "not-an-event-schema".getBytes(StandardCharsets.UTF_8)));
+    var generator = new CustomTransformOutputSchemaGenerator(
+        serviceDiscovery,
+        new CustomTransformOutputStrategy(),
+        invocation,
+        requestManager
+    );
+
+    var exception = assertThrows(IllegalStateException.class,
+        () -> generator.buildFromOneStream(null));
+
+    assertTrue(exception.getMessage().contains("invalid output schema"));
+  }
+
+  private DataProcessorInvocation invocation() {
+    var invocation = new DataProcessorInvocation();
+    invocation.setAppId("org.apache.streampipes.test.processor");
+    invocation.setStaticProperties(List.of());
+    return invocation;
+  }
+
+  private SpServiceRegistration service() {
+    var service = new SpServiceRegistration();
+    service.setHost("localhost");
+    service.setPort(8080);
+    service.setSvcId("test-service");
+    return service;
+  }
+
   private SecretStaticProperty makeSecretProperty(String value,
                                                   boolean encrypted) {
     var secretProperty = new SecretStaticProperty("secret", "Secret", "");
diff --git 
a/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/pipeline/update/DataStreamUpdateManagementTest.java
 
b/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/pipeline/update/DataStreamUpdateManagementTest.java
index 97e16c7f0e..61bede7e8a 100644
--- 
a/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/pipeline/update/DataStreamUpdateManagementTest.java
+++ 
b/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/pipeline/update/DataStreamUpdateManagementTest.java
@@ -61,7 +61,7 @@ class DataStreamUpdateManagementTest {
   }
 
   @Test
-  void persistsStreamRenameWithoutSchemaEvent() {
+  void persistsStreamRenameWithoutUpdatingPipelinesOrPublishingSchemaEvent() {
     var previous = stream();
     var updated = new SpDataStream(previous);
     updated.setName("Renamed stream");
@@ -76,8 +76,25 @@ class DataStreamUpdateManagementTest {
       new DataStreamUpdateManagement(null, resources, 
publisher).updateDataStream(updated);
 
       verify(streams).update(updated);
-      verify(coordinators.constructed().getFirst()).updatePipelines(updated);
-      verifyNoInteractions(publisher);
+      verifyNoInteractions(publisher, coordinators.constructed().getFirst());
+    }
+  }
+
+  @Test
+  void 
persistsUnchangedStreamWithoutUpdatingPipelinesOrPublishingSchemaEvent() {
+    var previous = stream();
+    var updated = new SpDataStream(previous);
+    var resources = mock(SpResourceManager.class);
+    var streams = mock(DataStreamResourceManager.class);
+    var publisher = mock(ApplicationEventPublisher.class);
+    when(resources.manageDataStreams()).thenReturn(streams);
+    when(streams.find("stream-1")).thenReturn(previous);
+
+    try (var coordinators = mockConstruction(PipelineUpdateCoordinator.class)) 
{
+      new DataStreamUpdateManagement(null, resources, 
publisher).updateDataStream(updated);
+
+      verify(streams).update(updated);
+      verifyNoInteractions(publisher, coordinators.constructed().getFirst());
     }
   }
 
@@ -110,6 +127,7 @@ class DataStreamUpdateManagementTest {
     var stream = new SpDataStream();
     stream.setElementId("stream-1");
     stream.setName("Stream");
+    stream.getEventSchema().addEventProperty(new 
EventPropertyPrimitive("xs:string", "value", null, null));
     return stream;
   }
 }
diff --git 
a/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/pipeline/update/PipelineUpdateCoordinatorTest.java
 
b/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/pipeline/update/PipelineUpdateCoordinatorTest.java
index 7927b61ebf..69753f5aff 100644
--- 
a/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/pipeline/update/PipelineUpdateCoordinatorTest.java
+++ 
b/streampipes-pipeline-management/src/test/java/org/apache/streampipes/manager/pipeline/update/PipelineUpdateCoordinatorTest.java
@@ -22,6 +22,7 @@ import org.apache.streampipes.audit.api.AuditService;
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
 import org.apache.streampipes.manager.execution.PipelineExecutor;
 import org.apache.streampipes.manager.matching.PipelineVerificationHandlerV2;
+import 
org.apache.streampipes.manager.matching.output.OutputSchemaUnavailableException;
 import 
org.apache.streampipes.manager.matching.v2.pipeline.MeasurementChangeValidationStep;
 import org.apache.streampipes.model.SpDataStream;
 import org.apache.streampipes.model.connect.adapter.PipelineUpdateInfo;
@@ -190,6 +191,38 @@ class PipelineUpdateCoordinatorTest {
     }
   }
 
+  @Test
+  void 
updatePipelines_ShouldDeferPipelineUpdateWhenOutputSchemaIsUnavailable() {
+    var requestManager = mock(ExtensionServiceRequestManager.class);
+    var resourceManager = mock(SpResourceManager.class);
+    var pipelineStorage = mock(IPipelineStorage.class);
+    var coordinator = makeCoordinator(requestManager, resourceManager, 
pipelineStorage);
+    var dataStream = makeDataStream("stream-1", "Updated stream");
+    var storedPipeline = makePipeline("pipeline-1", "Pipeline", true, 
"stream-1", "Old stream");
+    when(pipelineStorage.findAll()).thenReturn(List.of(storedPipeline));
+    
when(pipelineStorage.getElementById("pipeline-1")).thenReturn(storedPipeline);
+
+    try (MockedConstruction<PipelineVerificationHandlerV2> 
verificationHandlerConstruction =
+             mockConstruction(PipelineVerificationHandlerV2.class, (mock, 
context) ->
+                 when(mock.verifyPipeline()).thenThrow(new 
OutputSchemaUnavailableException("math-expression",
+                     new RuntimeException("Service unavailable"))));
+         MockedConstruction<PipelineExecutor> executorConstruction =
+             mockConstruction(PipelineExecutor.class)) {
+
+      coordinator.updatePipelines(dataStream);
+
+      assertEquals(1, verificationHandlerConstruction.constructed().size());
+      assertEquals(0, executorConstruction.constructed().size());
+      verify(pipelineStorage).updateElement(storedPipeline);
+      assertTrue(storedPipeline.isRunning());
+      assertTrue(storedPipeline.isValid());
+      assertEquals("Old stream", 
storedPipeline.getStreams().getFirst().getName());
+      assertEquals(List.of(
+          "Validation deferred: Data stream modification could not be 
validated because pipeline element math-expression is unavailable"
+      ), storedPipeline.getPipelineNotifications());
+    }
+  }
+
   @Test
   void checkPipelineMigrations_ShouldUseUpdatedDataStreamValues() {
     var requestManager = mock(ExtensionServiceRequestManager.class);
@@ -262,6 +295,32 @@ class PipelineUpdateCoordinatorTest {
     }
   }
 
+  @Test
+  void 
checkPipelineMigrations_ShouldReportUnavailableOutputSchemaWithoutMutatingThePipeline()
 {
+    var requestManager = mock(ExtensionServiceRequestManager.class);
+    var resourceManager = mock(SpResourceManager.class);
+    var pipelineStorage = mock(IPipelineStorage.class);
+    var coordinator = makeCoordinator(requestManager, resourceManager, 
pipelineStorage);
+    var dataStream = makeDataStream("stream-1", "Updated stream");
+    var pipeline = makePipeline("pipeline-1", "Pipeline", true, "stream-1", 
"Old stream");
+    when(pipelineStorage.findAll()).thenReturn(List.of(pipeline));
+
+    try (MockedConstruction<PipelineVerificationHandlerV2> 
verificationHandlerConstruction =
+             mockConstruction(PipelineVerificationHandlerV2.class, (mock, 
context) ->
+                 when(mock.verifyPipeline()).thenThrow(new 
OutputSchemaUnavailableException("math-expression",
+                     new RuntimeException("Service unavailable"))))) {
+
+      var result = coordinator.checkPipelineMigrations(dataStream);
+
+      assertEquals(1, verificationHandlerConstruction.constructed().size());
+      assertEquals(1, result.size());
+      assertTrue(result.getFirst().isValidationUnavailable());
+      assertFalse(result.getFirst().isCanAutoMigrate());
+      
assertTrue(result.getFirst().getMigrationInfo().contains("math-expression"));
+      assertEquals("Old stream", pipeline.getStreams().getFirst().getName());
+    }
+  }
+
   @Test
   void 
checkPipelineMigrations_ShouldDisableAutoMigrationForCriticalMeasurementFieldChange()
 {
     var requestManager = mock(ExtensionServiceRequestManager.class);
diff --git 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/PipelineResource.java
 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/PipelineResource.java
index d4513a9ad4..bd114638a9 100644
--- 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/PipelineResource.java
+++ 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/PipelineResource.java
@@ -24,6 +24,7 @@ import 
org.apache.streampipes.commons.exceptions.NoSuitableSepasAvailableExcepti
 import 
org.apache.streampipes.manager.api.extensions.ExtensionServiceRequestManager;
 import org.apache.streampipes.manager.execution.status.PipelineStatusManager;
 import org.apache.streampipes.manager.matching.PipelineVerificationHandlerV2;
+import 
org.apache.streampipes.manager.matching.output.OutputSchemaUnavailableException;
 import org.apache.streampipes.manager.pipeline.PipelineEditChanges;
 import org.apache.streampipes.manager.pipeline.PipelineManager;
 import 
org.apache.streampipes.manager.pipeline.compact.CompactPipelineManagement;
@@ -286,6 +287,13 @@ public class PipelineResource extends 
AbstractAuthGuardedRestResource {
   public ResponseEntity<?> validatePipeline(@RequestBody Pipeline pipeline) {
     try {
       return ok(new 
PipelineVerificationHandlerV2(resourceManager.getServiceDiscovery(), pipeline, 
requestManager).verifyPipeline());
+    } catch (OutputSchemaUnavailableException e) {
+      LOG.warn("Could not validate pipeline because the output schema for {} 
is unavailable", e.getAppId(), e);
+      return serverError(new Notification(
+          "Extension unavailable",
+          "Pipeline validation requires an extension that is currently 
unavailable.",
+          e.getAppId()
+      ));
     } catch (JsonSyntaxException e) {
       return badRequest(new Notification(NotificationType.UNKNOWN_ERROR, 
e.getMessage()));
     } catch (Exception e) {
diff --git a/ui/deployment/i18n/de.json b/ui/deployment/i18n/de.json
index 466d1b4cdf..8adad43d32 100644
--- a/ui/deployment/i18n/de.json
+++ b/ui/deployment/i18n/de.json
@@ -138,7 +138,6 @@
   "Assign additional labels to better discover your assets": "Weisen Sie 
zusätzliche Labels zu, um Ihre Assets besser zu finden",
   "Attach adapters, dashboards, or pipelines in the asset editor to surface 
them here.": "Fügen Sie Adapter, Dashboards oder Pipelines im Asset-Editor 
hinzu, um sie hier anzuzeigen.",
   "Audit Log": "Audit-Protokoll",
-  "Audit events could not be downloaded. Try again.": "Audit-Ereignisse 
konnten nicht heruntergeladen werden. Versuche es erneut.",
   "Audit events could not be loaded. Check the filters and storage 
availability, then retry.": "Audit-Ereignisse konnten nicht geladen werden. 
Prüfe die Filter und die Speicherverfügbarkeit und versuche es erneut.",
   "Audit pages": "Audit-Seiten",
   "Audit recording is disabled. Enable auditing in the Core deployment 
configuration.": "Die Audit-Aufzeichnung ist deaktiviert. Aktiviere sie in der 
Bereitstellungskonfiguration des Core.",
@@ -889,7 +888,6 @@
   "Please wait while the pipeline is stopping": "Bitte warten Sie, während die 
Pipeline gestoppt wird",
   "Port": "Port",
   "Possible placeholders:": "Mögliche Platzhalter:",
-  "Preparing download...": "Download wird vorbereitet...",
   "Preparing pipeline editor...": "Pipeline-Editor vorbereiten...",
   "Press 'Next' to start the process.": "Auf \"Weiter\" drücken, um den 
Vorgang zu starten.",
   "Press Next to start the process.": "Klicken Sie auf Weiter, um den Vorgang 
zu starten.",
@@ -1260,6 +1258,7 @@
   "This operation cannot be undone.": "Dieser Vorgang kann nicht rückgängig 
gemacht werden.",
   "This operation cannot be undone. Please ensure that the data provider is 
not used in a datalake retention.": "Dieser Vorgang kann nicht rückgängig 
gemacht werden. Bitte stellen Sie sicher, dass der Provider in keiner 
Speicherrichtlinie verwendet wird.",
   "This pipeline cannot be updated because at least one data lake measurement 
field changed its datatype.": "Diese Pipeline kann nicht aktualisiert werden, 
da sich der Datentyp mindestens eines Data-Lake-Messfelds geändert hat.",
+  "This pipeline could not be revalidated because a required extension is 
unavailable. Its configuration will remain unchanged.": "Diese Pipeline konnte 
nicht erneut validiert werden, weil eine erforderliche Erweiterung nicht 
verfügbar ist. Ihre Konfiguration bleibt unverändert.",
   "This pipeline is still running.": "Diese Pipeline ist noch in Betrieb.",
   "This pipeline update requires manual review.": "Diese 
Pipeline-Aktualisierung erfordert eine manuelle Prüfung.",
   "This pipeline will be automatically migrated.": "Diese Pipeline wird 
automatisch migriert.",
@@ -1415,7 +1414,6 @@
   "You need to delete those pipelines first before deleting the adapter.": 
"Sie müssen diese Pipelines zuerst löschen, bevor Sie den Adapter löschen 
können.",
   "Your Assets were successfully added.": "Assets erfolgreich hinzugefügt.",
   "Your Assets were successfully deleted.": "Assets erfolgreich gelöscht.",
-  "Your audit log is being downloaded.": "Dein Audit-Protokoll wird 
heruntergeladen.",
   "Your new API key has been created. Please copy the key now - you won't be 
able to see the key again.": "Ihr neuer API-Schlüssel wurde erstellt. Bitte 
kopieren Sie den Schlüssel jetzt - Sie werden ihn nicht mehr sehen können.",
   "Your new data stream is now available in the pipeline editor.": "Ihr neuer 
Datenstrom ist nun im Pipeline-Editor verfügbar.",
   "Your password successfully changed.": "Ihr Passwort wurde erfolgreich 
geändert.",
diff --git a/ui/deployment/i18n/en.json b/ui/deployment/i18n/en.json
index 4a078bc89e..96d6c8b72e 100644
--- a/ui/deployment/i18n/en.json
+++ b/ui/deployment/i18n/en.json
@@ -138,7 +138,6 @@
   "Assign additional labels to better discover your assets": null,
   "Attach adapters, dashboards, or pipelines in the asset editor to surface 
them here.": null,
   "Audit Log": "Audit Log",
-  "Audit events could not be downloaded. Try again.": null,
   "Audit events could not be loaded. Check the filters and storage 
availability, then retry.": "Audit events could not be loaded. Check the 
filters and storage availability, then retry.",
   "Audit pages": "Audit pages",
   "Audit recording is disabled. Enable auditing in the Core deployment 
configuration.": "Audit recording is disabled. Enable auditing in the Core 
deployment configuration.",
@@ -889,7 +888,6 @@
   "Please wait while the pipeline is stopping": null,
   "Port": null,
   "Possible placeholders:": null,
-  "Preparing download...": null,
   "Preparing pipeline editor...": null,
   "Press 'Next' to start the process.": null,
   "Press Next to start the process.": null,
@@ -1260,6 +1258,7 @@
   "This operation cannot be undone.": null,
   "This operation cannot be undone. Please ensure that the data provider is 
not used in a datalake retention.": null,
   "This pipeline cannot be updated because at least one data lake measurement 
field changed its datatype.": null,
+  "This pipeline could not be revalidated because a required extension is 
unavailable. Its configuration will remain unchanged.": null,
   "This pipeline is still running.": null,
   "This pipeline update requires manual review.": null,
   "This pipeline will be automatically migrated.": null,
@@ -1415,7 +1414,6 @@
   "You need to delete those pipelines first before deleting the adapter.": 
null,
   "Your Assets were successfully added.": null,
   "Your Assets were successfully deleted.": null,
-  "Your audit log is being downloaded.": null,
   "Your new API key has been created. Please copy the key now - you won't be 
able to see the key again.": null,
   "Your new data stream is now available in the pipeline editor.": null,
   "Your password successfully changed.": null,
diff --git a/ui/deployment/i18n/pl.json b/ui/deployment/i18n/pl.json
index 4ff0f740a8..7829c5162d 100644
--- a/ui/deployment/i18n/pl.json
+++ b/ui/deployment/i18n/pl.json
@@ -138,7 +138,6 @@
   "Assign additional labels to better discover your assets": "Przypisz 
dodatkowe etykiety, aby łatwiej odnajdywać zasoby",
   "Attach adapters, dashboards, or pipelines in the asset editor to surface 
them here.": "Dołącz adaptery, pulpity lub strumienie w edytorze zasobu, aby 
wyświetlały się tutaj.",
   "Audit Log": "Dziennik audytu",
-  "Audit events could not be downloaded. Try again.": "Nie udało się pobrać 
zdarzeń audytu. Spróbuj ponownie.",
   "Audit events could not be loaded. Check the filters and storage 
availability, then retry.": "Nie udało się wczytać zdarzeń audytu. Sprawdź 
filtry i dostępność magazynu, a następnie spróbuj ponownie.",
   "Audit pages": "Strony audytu",
   "Audit recording is disabled. Enable auditing in the Core deployment 
configuration.": "Rejestrowanie audytu jest wyłączone. Włącz je w konfiguracji 
wdrożenia Core.",
@@ -889,7 +888,6 @@
   "Please wait while the pipeline is stopping": "Proszę czekać, trwa 
zatrzymywanie strumienia",
   "Port": "Port",
   "Possible placeholders:": "Możliwe zastępniki:",
-  "Preparing download...": "Przygotowywanie pobierania...",
   "Preparing pipeline editor...": "Przygotowywanie edytora strumieni...",
   "Press 'Next' to start the process.": "Naciśnij „Dalej”, aby rozpocząć 
proces.",
   "Press Next to start the process.": "Kliknij Dalej, aby rozpocząć proces.",
@@ -1260,6 +1258,7 @@
   "This operation cannot be undone.": "Tego działania nie można cofnąć.",
   "This operation cannot be undone. Please ensure that the data provider is 
not used in a datalake retention.": "Tego działania nie można cofnąć. Upewnij 
się, że dostawca nie jest używany w retencji Data Lake.",
   "This pipeline cannot be updated because at least one data lake measurement 
field changed its datatype.": "Nie można zaktualizować tego strumienia, 
ponieważ co najmniej jedno pole miary Data Lake zmieniło typ danych.",
+  "This pipeline could not be revalidated because a required extension is 
unavailable. Its configuration will remain unchanged.": "Nie można było 
ponownie zweryfikować tego strumienia, ponieważ wymagana wtyczka jest 
niedostępna. Jego konfiguracja pozostanie bez zmian.",
   "This pipeline is still running.": "Ten strumień nadal działa.",
   "This pipeline update requires manual review.": "Ta aktualizacja strumienia 
wymaga ręcznego sprawdzenia.",
   "This pipeline will be automatically migrated.": "Ten strumień zostanie 
automatycznie zmigrowany.",
@@ -1415,7 +1414,6 @@
   "You need to delete those pipelines first before deleting the adapter.": 
"Przed usunięciem adaptera najpierw usuń te strumienie.",
   "Your Assets were successfully added.": "Twoje zasoby zostały pomyślnie 
dodane.",
   "Your Assets were successfully deleted.": "Twoje zasoby zostały pomyślnie 
usunięte.",
-  "Your audit log is being downloaded.": "Twój dziennik audytu jest 
pobierany.",
   "Your new API key has been created. Please copy the key now - you won't be 
able to see the key again.": "Twój nowy klucz API został utworzony. Skopiuj go 
teraz - później nie będzie już widoczny.",
   "Your new data stream is now available in the pipeline editor.": "Twój nowy 
strumień danych jest teraz dostępny w edytorze strumieńów.",
   "Your password successfully changed.": "Twoje hasło zostało pomyślnie 
zmienione.",
diff --git 
a/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model.ts
 
b/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model.ts
index 92cf0d4c2e..85075aed61 100644
--- 
a/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model.ts
+++ 
b/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model.ts
@@ -3316,6 +3316,7 @@ export class PipelineUpdateInfo {
     pipelineId: string;
     pipelineName: string;
     validationInfos: { [index: string]: PipelineElementValidationInfo[] };
+    validationUnavailable: boolean;
 
     static fromData(
         data: PipelineUpdateInfo,
@@ -3335,6 +3336,7 @@ export class PipelineUpdateInfo {
         instance.validationInfos = __getCopyObjectFn(
             __getCopyArrayFn(PipelineElementValidationInfo.fromData),
         )(data.validationInfos);
+        instance.validationUnavailable = data.validationUnavailable;
         return instance;
     }
 }
diff --git 
a/ui/src/app/connect/dialog/adapter-started/adapter-started-update-migration/adapter-started-update-migration.component.html
 
b/ui/src/app/connect/dialog/adapter-started/adapter-started-update-migration/adapter-started-update-migration.component.html
index 4775d0f3db..b13f513426 100644
--- 
a/ui/src/app/connect/dialog/adapter-started/adapter-started-update-migration/adapter-started-update-migration.component.html
+++ 
b/ui/src/app/connect/dialog/adapter-started/adapter-started-update-migration/adapter-started-update-migration.component.html
@@ -61,7 +61,33 @@
                             </div>
                         </div>
                     }
-                    @if (!updateInfo.canAutoMigrate) {
+                    @if (updateInfo.validationUnavailable) {
+                        <div
+                            class="pipeline-status"
+                            fxLayoutAlign="start center"
+                            
data-cy="adapter-pipeline-validation-unavailable-warning"
+                        >
+                            <mat-icon class="status-icon status-icon-warning">
+                                warning
+                            </mat-icon>
+                            <div>
+                                <div class="pipeline-title">
+                                    {{ 'Pipeline' | translate }}
+                                    <b>{{ updateInfo.pipelineName }}</b>
+                                </div>
+                                <div class="pipeline-description">
+                                    {{
+                                        'This pipeline could not be 
revalidated because a required extension is unavailable. Its configuration will 
remain unchanged.'
+                                            | translate
+                                    }}
+                                </div>
+                            </div>
+                        </div>
+                    }
+                    @if (
+                        !updateInfo.canAutoMigrate &&
+                        !updateInfo.validationUnavailable
+                    ) {
                         <div
                             class="pipeline-status"
                             fxLayoutAlign="start center"
diff --git a/ui/src/app/editor/components/pipeline/pipeline.component.spec.ts 
b/ui/src/app/editor/components/pipeline/pipeline.component.spec.ts
index 92f5db4f00..9107ab64f6 100644
--- a/ui/src/app/editor/components/pipeline/pipeline.component.spec.ts
+++ b/ui/src/app/editor/components/pipeline/pipeline.component.spec.ts
@@ -17,6 +17,7 @@
  */
 
 import { TestBed } from '@angular/core/testing';
+import { HttpErrorResponse } from '@angular/common/http';
 import { MatDialog } from '@angular/material/dialog';
 import { DialogService, KeyboardShortcutService } from 
'@streampipes/shared-ui';
 import { DataProcessorInvocation } from '@streampipes/platform-services';
@@ -30,6 +31,7 @@ import { EditorService } from '../../services/editor.service';
 import { PipelineStyleService } from '../../services/pipeline-style.service';
 import { PipelineValidationService } from 
'../../services/pipeline-validation.service';
 import { IdGeneratorService } from 
'../../../core-services/id-generator/id-generator.service';
+import { PipelineElementConfigurationStatus } from '../../model/editor.model';
 
 describe('Pipeline palette drops', () => {
     const create = vi.fn();
@@ -146,4 +148,66 @@ describe('Pipeline palette drops', () => {
         });
         expect(create).not.toHaveBeenCalled();
     });
+
+    it('marks an unavailable extension and its connections as invalid', () => {
+        const { component } = setup();
+        const unavailableExtension = {
+            type: 'sepa',
+            payload: Object.assign(new DataProcessorInvocation(), {
+                appId: 
'org.apache.streampipes.processors.enricher.jvm.processor.expression',
+                dom: 'math-expression',
+                name: 'Math Expression',
+            }),
+            settings: {
+                completed: 1,
+            },
+        } as PipelineComponent['rawPipelineModel'][number];
+        component.rawPipelineModel = [unavailableExtension];
+
+        const pipelineStyleService = component[
+            'pipelineStyleService'
+        ] as unknown as {
+            updatePeConfigurationStatus: ReturnType<typeof vi.fn>;
+            updateConnectedConnectorStylesAsError: ReturnType<typeof vi.fn>;
+        };
+        pipelineStyleService.updatePeConfigurationStatus = vi.fn();
+        pipelineStyleService.updateConnectedConnectorStylesAsError = vi.fn();
+        const validationService = component[
+            'pipelineValidationService'
+        ] as unknown as {
+            reportRemoteValidationError: ReturnType<typeof vi.fn>;
+        };
+        validationService.reportRemoteValidationError = vi.fn();
+
+        component['handlePipelineValidationFailure'](
+            new HttpErrorResponse({
+                error: {
+                    title: 'Extension unavailable',
+                    description:
+                        'Pipeline validation requires an extension that is 
currently unavailable.',
+                    additionalInformation:
+                        
'org.apache.streampipes.processors.enricher.jvm.processor.expression',
+                },
+            }),
+        );
+
+        expect(
+            pipelineStyleService.updatePeConfigurationStatus,
+        ).toHaveBeenCalledWith(
+            unavailableExtension,
+            PipelineElementConfigurationStatus.INCOMPLETE,
+        );
+        expect(
+            pipelineStyleService.updateConnectedConnectorStylesAsError,
+        ).toHaveBeenCalledWith('math-expression');
+        expect(
+            validationService.reportRemoteValidationError,
+        ).toHaveBeenCalledWith(
+            'Extension unavailable',
+            'Pipeline validation requires an extension that is currently 
unavailable. (Math Expression 
(org.apache.streampipes.processors.enricher.jvm.processor.expression))',
+        );
+        expect(
+            component.JsplumbBridge.repaintEverything,
+        ).toHaveBeenCalledOnce();
+    });
 });
diff --git a/ui/src/app/editor/components/pipeline/pipeline.component.ts 
b/ui/src/app/editor/components/pipeline/pipeline.component.ts
index 2ee290631e..ffb9b41285 100644
--- a/ui/src/app/editor/components/pipeline/pipeline.component.ts
+++ b/ui/src/app/editor/components/pipeline/pipeline.component.ts
@@ -56,7 +56,6 @@ import {
 import { ObjectProvider } from '../../services/object-provider.service';
 import { CustomizeComponent } from 
'../../dialog/customize/customize.component';
 import {
-    ConfirmDialogComponent,
     DialogService,
     KeyboardShortcutService,
     PanelType,
@@ -64,7 +63,6 @@ import {
 } from '@streampipes/shared-ui';
 import { EditorService } from '../../services/editor.service';
 import { MatchingErrorComponent } from 
'../../dialog/matching-error/matching-error.component';
-import { MatDialog } from '@angular/material/dialog';
 import { JsplumbFactoryService } from '../../services/jsplumb-factory.service';
 import {
     EVENT_CONNECTION,
@@ -75,6 +73,7 @@ import {
 } from '@jsplumb/browser-ui';
 import { PipelineStyleService } from '../../services/pipeline-style.service';
 import { IdGeneratorService } from 
'../../../core-services/id-generator/id-generator.service';
+import { HttpErrorResponse } from '@angular/common/http';
 import { NgStyle } from '@angular/common';
 import { StyleDirective } from '@ngbracket/ngx-layout/extended';
 import { DroppedPipelineElementComponent } from 
'./dropped-pipeline-element/dropped-pipeline-element.component';
@@ -112,7 +111,6 @@ export class PipelineComponent implements OnInit, OnDestroy 
{
     private pipelineStyleService = inject(PipelineStyleService);
     private pipelineValidationService = inject(PipelineValidationService);
     private dialogService = inject(DialogService);
-    private dialog = inject(MatDialog);
     private ngZone = inject(NgZone);
     private shortcutService = inject(KeyboardShortcutService);
 
@@ -154,6 +152,10 @@ export class PipelineComponent implements OnInit, 
OnDestroy {
 
     shouldOpenCustomizeSettings = false;
     private shortcutReg: ShortcutRegistration;
+    private unavailablePipelineElementStatuses = new Map<
+        PipelineElementConfig,
+        PipelineElementConfigurationStatus
+    >();
 
     constructor() {
         this.currentPipelineModel = new Pipeline();
@@ -485,10 +487,7 @@ export class PipelineComponent implements OnInit, 
OnDestroy {
                         },
                         status => {
                             pe.settings.loadingStatus = false;
-                            this.showErrorDialog(
-                                status.error.title,
-                                status.error.description,
-                            );
+                            this.handlePipelineValidationFailure(status);
                         },
                     );
             }
@@ -514,6 +513,7 @@ export class PipelineComponent implements OnInit, OnDestroy 
{
     }
 
     modifyPipeline(pm: PipelineModificationMessage) {
+        this.restoreUnavailablePipelineElements();
         if (pm.pipelineModifications) {
             pm.pipelineModifications.forEach(modification => {
                 const id = modification.domId;
@@ -579,15 +579,90 @@ export class PipelineComponent implements OnInit, 
OnDestroy {
         return custom;
     }
 
-    showErrorDialog(title: string, description: string) {
-        this.dialog.open(ConfirmDialogComponent, {
-            width: '500px',
-            data: {
-                title: title,
-                subtitle: description,
-                confirmTitle: 'Ok',
+    private handlePipelineValidationFailure(status: HttpErrorResponse): void {
+        const error = status.error as Notification;
+        const title = error?.title ?? 'Pipeline validation failed';
+        const description = error?.description ?? status.message;
+        const extensionId = error?.additionalInformation;
+        const unavailableExtensions =
+            this.getUnavailableExtensions(extensionId);
+
+        if (title === 'Extension unavailable' && unavailableExtensions.length) 
{
+            unavailableExtensions.forEach(pipelineElement => {
+                if (
+                    !this.unavailablePipelineElementStatuses.has(
+                        pipelineElement,
+                    )
+                ) {
+                    this.unavailablePipelineElementStatuses.set(
+                        pipelineElement,
+                        pipelineElement.settings.completed,
+                    );
+                }
+                this.pipelineStyleService.updatePeConfigurationStatus(
+                    pipelineElement,
+                    PipelineElementConfigurationStatus.INCOMPLETE,
+                );
+                
this.pipelineStyleService.updateConnectedConnectorStylesAsError(
+                    pipelineElement.payload.dom,
+                );
+            });
+            this.JsplumbBridge.repaintEverything();
+        }
+
+        this.pipelineValid = false;
+        this.pipelineValidationService.reportRemoteValidationError(
+            title,
+            this.getUnavailableExtensionDescription(
+                description,
+                extensionId,
+                unavailableExtensions,
+            ),
+        );
+    }
+
+    private getUnavailableExtensions(
+        extensionId?: string,
+    ): PipelineElementConfig[] {
+        if (!extensionId) {
+            return [];
+        }
+
+        return this.rawPipelineModel.filter(
+            pipelineElement => pipelineElement.payload.appId === extensionId,
+        );
+    }
+
+    private getUnavailableExtensionDescription(
+        description: string,
+        extensionId: string | undefined,
+        unavailableExtensions: PipelineElementConfig[],
+    ): string {
+        if (!extensionId) {
+            return description;
+        }
+
+        const extensionNames = unavailableExtensions.map(
+            pipelineElement => pipelineElement.payload.name,
+        );
+        const unavailableExtension =
+            extensionNames.length > 0
+                ? `${extensionNames.join(', ')} (${extensionId})`
+                : extensionId;
+
+        return `${description} (${unavailableExtension})`;
+    }
+
+    private restoreUnavailablePipelineElements(): void {
+        this.unavailablePipelineElementStatuses.forEach(
+            (configurationStatus, pipelineElement) => {
+                this.pipelineStyleService.updatePeConfigurationStatus(
+                    pipelineElement,
+                    configurationStatus,
+                );
             },
-        });
+        );
+        this.unavailablePipelineElementStatuses.clear();
     }
 
     showMatchingErrorDialog(notifications: Notification[]) {
@@ -620,15 +695,20 @@ export class PipelineComponent implements OnInit, 
OnDestroy {
                 );
                 this.objectProvider
                     .updatePipeline(this.currentPipelineModel)
-                    .subscribe(pm => {
-                        this.modifyPipeline(pm);
-                        this.triggerPipelineCacheUpdateEmitter.emit();
-                        this.announceConfiguredElement(pipelineElementConfig);
-                        if (this.previewModeActive) {
-                            this.deletePreviewEmitter.emit(true);
-                        }
-                        this.validatePipeline(pm);
-                    });
+                    .subscribe(
+                        pm => {
+                            this.modifyPipeline(pm);
+                            this.triggerPipelineCacheUpdateEmitter.emit();
+                            this.announceConfiguredElement(
+                                pipelineElementConfig,
+                            );
+                            if (this.previewModeActive) {
+                                this.deletePreviewEmitter.emit(true);
+                            }
+                            this.validatePipeline(pm);
+                        },
+                        status => this.handlePipelineValidationFailure(status),
+                    );
             } else {
                 this.validatePipeline();
             }
@@ -643,9 +723,8 @@ export class PipelineComponent implements OnInit, OnDestroy 
{
         this.currentPipelineModel = this.objectProvider.makePipeline(
             this.rawPipelineModel,
         );
-        this.objectProvider
-            .updatePipeline(this.currentPipelineModel)
-            .subscribe(pm => {
+        
this.objectProvider.updatePipeline(this.currentPipelineModel).subscribe(
+            pm => {
                 this.modifyPipeline(pm);
                 this.pipelineValid =
                     this.pipelineValidationService.isValidPipeline(
@@ -653,7 +732,9 @@ export class PipelineComponent implements OnInit, OnDestroy 
{
                         this.readonly,
                         pm,
                     );
-            });
+            },
+            status => this.handlePipelineValidationFailure(status),
+        );
     }
 
     openTopicsDialog(pipelineElementConfig: PipelineElementConfig) {
diff --git a/ui/src/app/editor/services/pipeline-style.service.ts 
b/ui/src/app/editor/services/pipeline-style.service.ts
index 71da6b8344..df157cdce3 100644
--- a/ui/src/app/editor/services/pipeline-style.service.ts
+++ b/ui/src/app/editor/services/pipeline-style.service.ts
@@ -69,6 +69,32 @@ export class PipelineStyleService {
         }
     }
 
+    updateConnectedConnectorStylesAsError(pipelineElementDomId: string) {
+        const jsplumbBridge =
+            this.jsplumbFactoryService.getJsplumbBridge(false);
+        const pipelineElement = this.byId(pipelineElementDomId);
+        const sourceConnections = jsplumbBridge.getConnections({
+            source: pipelineElement,
+        });
+        const targetConnections = jsplumbBridge.getConnections({
+            target: pipelineElement,
+        });
+        const connections = [
+            ...(Array.isArray(sourceConnections)
+                ? sourceConnections
+                : Object.values(sourceConnections)),
+            ...(Array.isArray(targetConnections)
+                ? targetConnections
+                : Object.values(targetConnections)),
+        ];
+        const connectorStyle =
+            this.jsPlumbConfigService.getConnectorStyleError();
+
+        connections.forEach(connection => {
+            connection.setPaintStyle(connectorStyle);
+        });
+    }
+
     getConnectorStyleConfig(status: EdgeValidationStatus) {
         if (status.validationStatusType === 'COMPLETE') {
             return this.jsPlumbConfigService.getConnectorStyleSuccess();
diff --git a/ui/src/app/editor/services/pipeline-validation.service.ts 
b/ui/src/app/editor/services/pipeline-validation.service.ts
index f6eb6c97b3..98123a16bd 100644
--- a/ui/src/app/editor/services/pipeline-validation.service.ts
+++ b/ui/src/app/editor/services/pipeline-validation.service.ts
@@ -36,8 +36,9 @@ import { Connection } from '@jsplumb/browser-ui';
 export class PipelineValidationService {
     private jsplumbFactoryService = inject(JsplumbFactoryService);
 
-    errorMessages: any = [];
+    errorMessages: UserErrorMessage[] = [];
     pipelineValid = false;
+    private remoteValidationError?: UserErrorMessage;
 
     availableErrorMessages: UserErrorMessage[] = [
         new UserErrorMessage(
@@ -71,6 +72,9 @@ export class PipelineValidationService {
         previewConfig: boolean,
         pm?: PipelineModificationMessage,
     ) {
+        if (pm) {
+            this.remoteValidationError = undefined;
+        }
         const jsplumbBridge =
             this.jsplumbFactoryService.getJsplumbBridge(previewConfig);
         const streamInAssembly = this.isStreamInAssembly(rawPipelineModel);
@@ -112,17 +116,34 @@ export class PipelineValidationService {
             this.errorMessages.push(this.availableErrorMessages[5]);
         }
 
+        if (this.remoteValidationError) {
+            this.errorMessages.push(this.remoteValidationError);
+        }
+
         this.pipelineValid =
             streamInAssembly &&
             actionInAssembly &&
             allElementsConnected &&
             onlyOnePipelineCreated &&
             allElementsConfigured &&
-            (pm ? pm.pipelineValid : true);
+            (pm ? pm.pipelineValid : true) &&
+            !this.remoteValidationError;
 
         return this.pipelineValid;
     }
 
+    reportRemoteValidationError(title: string, content: string): void {
+        const previousRemoteValidationError = this.remoteValidationError;
+        this.remoteValidationError = new UserErrorMessage(title, content);
+        this.errorMessages = [
+            ...this.errorMessages.filter(
+                error => error !== previousRemoteValidationError,
+            ),
+            this.remoteValidationError,
+        ];
+        this.pipelineValid = false;
+    }
+
     isEmptyPipeline(rawPipelineModel) {
         return (
             !this.isActionInAssembly(rawPipelineModel) &&

Reply via email to