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

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 5e7f613ae UNOMI-954: Make Camel route refresh resilient to transient 
failures (#779)
5e7f613ae is described below

commit 5e7f613aef2eee2a25e9d373b6b558afd40d3a42
Author: Serge Huber <[email protected]>
AuthorDate: Thu Jun 25 11:22:47 2026 +0200

    UNOMI-954: Make Camel route refresh resilient to transient failures (#779)
---
 .../apache/unomi/router/api/RouterConstants.java   |  1 +
 .../services/ImportExportConfigurationService.java | 11 +++
 .../router/core/context/RouterCamelContext.java    | 83 +++++++++++++++++-----
 .../route/ProfileImportFromSourceRouteBuilder.java | 18 ++++-
 .../services/ExportConfigurationServiceImpl.java   |  6 ++
 .../services/ImportConfigurationServiceImpl.java   |  6 ++
 .../apache/unomi/itests/ProfileImportActorsIT.java | 31 ++++----
 7 files changed, 122 insertions(+), 34 deletions(-)

diff --git 
a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/RouterConstants.java
 
b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/RouterConstants.java
index 0b17be825..5576296d8 100644
--- 
a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/RouterConstants.java
+++ 
b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/RouterConstants.java
@@ -32,6 +32,7 @@ public interface RouterConstants {
     String CONFIG_STATUS_COMPLETE_ERRORS = "ERRORS";
     String CONFIG_STATUS_COMPLETE_SUCCESS = "SUCCESS";
     String CONFIG_STATUS_COMPLETE_WITH_ERRORS = "WITH_ERRORS";
+    String CONFIG_STATUS_ROUTE_CREATION_FAILED = "ROUTE_CREATION_FAILED";
 
     String IMPORT_EXPORT_CONFIG_TYPE_RECURRENT = "recurrent";
     String IMPORT_EXPORT_CONFIG_TYPE_ONESHOT = "oneshot";
diff --git 
a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/services/ImportExportConfigurationService.java
 
b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/services/ImportExportConfigurationService.java
index bb20ba2d0..ba6d321b9 100644
--- 
a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/services/ImportExportConfigurationService.java
+++ 
b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/services/ImportExportConfigurationService.java
@@ -98,4 +98,15 @@ public interface ImportExportConfigurationService<T> {
      * @return map of tenantId to map of configId per operation to be done in 
camel
      */
     Map<String, Map<String, RouterConstants.CONFIG_CAMEL_REFRESH>> 
consumeConfigsToBeRefresh();
+
+    /**
+     * Re-queues a configuration for a Camel route refresh. Used by the timer 
when a route
+     * creation attempt fails, so the config is retried on the next timer tick 
rather than
+     * being permanently dropped by the snap-and-clear in {@link 
#consumeConfigsToBeRefresh()}.
+     *
+     * @param tenantId the tenant that owns the configuration
+     * @param configId the configuration identifier to re-queue
+     * @param refreshType the refresh operation to retry
+     */
+    void requeueForRefresh(String tenantId, String configId, 
RouterConstants.CONFIG_CAMEL_REFRESH refreshType);
 }
diff --git 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
index d8c59857a..090d6927e 100644
--- 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
+++ 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
@@ -49,6 +49,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -98,6 +99,12 @@ public class RouterCamelContext implements 
IRouterCamelContext {
     private ScheduledTask scheduledTask;
 
     private Integer configsRefreshInterval = 1000;
+    private static final int MAX_ROUTE_CREATION_RETRIES = 5;
+    private final Map<RetryKey, Integer> routeCreationRetryCount = new 
ConcurrentHashMap<>();
+
+    /** Identifies a route refresh attempt being retried, scoped by direction 
and tenant so import/export configs and different tenants can never collide. */
+    private record RetryKey(String direction, String tenantId, String 
configId) {
+    }
 
     public void setExecHistorySize(String execHistorySize) {
         this.execHistorySize = execHistorySize;
@@ -163,15 +170,29 @@ public class RouterCamelContext implements 
IRouterCamelContext {
                         contextManager.executeAsTenant(tenantId, () -> {
                             try {
                                 for (Map.Entry<String, 
RouterConstants.CONFIG_CAMEL_REFRESH> importConfigToRefresh : 
tenantImportConfigsToRefresh.getValue().entrySet()) {
+                                    String configId = 
importConfigToRefresh.getKey();
+                                    RouterConstants.CONFIG_CAMEL_REFRESH 
refreshType = importConfigToRefresh.getValue();
+                                    RetryKey retryKey = new RetryKey("import", 
tenantId, configId);
                                     try {
-                                        if 
(importConfigToRefresh.getValue().equals(RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED))
 {
-                                            
updateProfileImportReaderRoute(importConfigToRefresh.getKey(), true);
-                                        } else if 
(importConfigToRefresh.getValue().equals(RouterConstants.CONFIG_CAMEL_REFRESH.REMOVED))
 {
-                                            
killExistingRoute(importConfigToRefresh.getKey(), true);
+                                        if 
(refreshType.equals(RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED)) {
+                                            
updateProfileImportReaderRoute(configId, true);
+                                            
routeCreationRetryCount.remove(retryKey);
+                                        } else if 
(refreshType.equals(RouterConstants.CONFIG_CAMEL_REFRESH.REMOVED)) {
+                                            killExistingRoute(configId, true);
+                                            
routeCreationRetryCount.remove(retryKey);
                                         }
                                     } catch (Exception e) {
-                                        LOGGER.error("Unexpected error while 
refreshing({}) camel route: {}", importConfigToRefresh.getValue(),
-                                                
importConfigToRefresh.getKey(), e);
+                                        int attempt = 
routeCreationRetryCount.merge(retryKey, 1, Integer::sum);
+                                        if (attempt <= 
MAX_ROUTE_CREATION_RETRIES) {
+                                            LOGGER.error("Refreshing({}) camel 
route {} failed (attempt {}/{}) — will retry on next tick",
+                                                    refreshType, configId, 
attempt, MAX_ROUTE_CREATION_RETRIES, e);
+                                            
importConfigurationService.requeueForRefresh(tenantId, configId, refreshType);
+                                        } else {
+                                            LOGGER.error("Refreshing({}) camel 
route {} failed after {} attempts — giving up",
+                                                    refreshType, configId, 
MAX_ROUTE_CREATION_RETRIES, e);
+                                            
routeCreationRetryCount.remove(retryKey);
+                                            
markImportRouteCreationFailed(configId);
+                                        }
                                     }
                                 }
                             } catch (Exception e) {
@@ -187,15 +208,29 @@ public class RouterCamelContext implements 
IRouterCamelContext {
                         contextManager.executeAsTenant(tenantId, () -> {
                             try {
                                 for (Map.Entry<String, 
RouterConstants.CONFIG_CAMEL_REFRESH> exportConfigToRefresh : 
tenantExportConfigsToRefresh.getValue().entrySet()) {
+                                    String configId = 
exportConfigToRefresh.getKey();
+                                    RouterConstants.CONFIG_CAMEL_REFRESH 
refreshType = exportConfigToRefresh.getValue();
+                                    RetryKey retryKey = new RetryKey("export", 
tenantId, configId);
                                     try {
-                                        if 
(exportConfigToRefresh.getValue().equals(RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED))
 {
-                                            
updateProfileExportReaderRoute(exportConfigToRefresh.getKey(), true);
-                                        } else if 
(exportConfigToRefresh.getValue().equals(RouterConstants.CONFIG_CAMEL_REFRESH.REMOVED))
 {
-                                            
killExistingRoute(exportConfigToRefresh.getKey(), true);
+                                        if 
(refreshType.equals(RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED)) {
+                                            
updateProfileExportReaderRoute(configId, true);
+                                            
routeCreationRetryCount.remove(retryKey);
+                                        } else if 
(refreshType.equals(RouterConstants.CONFIG_CAMEL_REFRESH.REMOVED)) {
+                                            killExistingRoute(configId, true);
+                                            
routeCreationRetryCount.remove(retryKey);
                                         }
                                     } catch (Exception e) {
-                                        LOGGER.error("Unexpected error while 
refreshing({}) camel route: {}", exportConfigToRefresh.getValue(),
-                                                
exportConfigToRefresh.getKey(), e);
+                                        int attempt = 
routeCreationRetryCount.merge(retryKey, 1, Integer::sum);
+                                        if (attempt <= 
MAX_ROUTE_CREATION_RETRIES) {
+                                            LOGGER.error("Refreshing({}) camel 
route {} failed (attempt {}/{}) — will retry on next tick",
+                                                    refreshType, configId, 
attempt, MAX_ROUTE_CREATION_RETRIES, e);
+                                            
exportConfigurationService.requeueForRefresh(tenantId, configId, refreshType);
+                                        } else {
+                                            LOGGER.error("Refreshing({}) camel 
route {} failed after {} attempts — giving up",
+                                                    refreshType, configId, 
MAX_ROUTE_CREATION_RETRIES, e);
+                                            
routeCreationRetryCount.remove(retryKey);
+                                            
markExportRouteCreationFailed(configId);
+                                        }
                                     }
                                 }
                             } catch (Exception e) {
@@ -294,6 +329,24 @@ public class RouterCamelContext implements 
IRouterCamelContext {
         camelContext.start();
     }
 
+    /** Persists a visible failure status on the configuration once route 
creation has exhausted all retries, instead of only logging it. */
+    private void markImportRouteCreationFailed(String configId) {
+        ImportConfiguration config = importConfigurationService.load(configId);
+        if (config != null) {
+            
config.setStatus(RouterConstants.CONFIG_STATUS_ROUTE_CREATION_FAILED);
+            importConfigurationService.save(config, false);
+        }
+    }
+
+    /** Persists a visible failure status on the configuration once route 
creation has exhausted all retries, instead of only logging it. */
+    private void markExportRouteCreationFailed(String configId) {
+        ExportConfiguration config = exportConfigurationService.load(configId);
+        if (config != null) {
+            
config.setStatus(RouterConstants.CONFIG_STATUS_ROUTE_CREATION_FAILED);
+            exportConfigurationService.save(config, false);
+        }
+    }
+
     /** {@inheritDoc} */
     @Override
     public void killExistingRoute(String routeId, boolean fireEvent) throws 
Exception {
@@ -314,8 +367,7 @@ public class RouterCamelContext implements 
IRouterCamelContext {
 
         ImportConfiguration importConfiguration = 
importConfigurationService.load(configId);
         if (importConfiguration == null) {
-            LOGGER.warn("Cannot update profile import reader route, config: {} 
not found", configId);
-            return;
+            throw new IllegalStateException("Cannot update profile import 
reader route, config: " + configId + " not found — will be retried");
         }
 
         if 
(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT.equals(importConfiguration.getConfigType()))
 {
@@ -339,8 +391,7 @@ public class RouterCamelContext implements 
IRouterCamelContext {
 
         ExportConfiguration exportConfiguration = 
exportConfigurationService.load(configId);
         if (exportConfiguration == null) {
-            LOGGER.warn("Cannot update profile export reader route, config: {} 
not found", configId);
-            return;
+            throw new IllegalStateException("Cannot update profile export 
reader route, config: " + configId + " not found — will be retried");
         }
 
         if 
(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT.equals(exportConfiguration.getConfigType()))
 {
diff --git 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
index 9a68e3797..845f023c1 100644
--- 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
+++ 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
@@ -137,9 +137,21 @@ public class ProfileImportFromSourceRouteBuilder extends 
RouterAbstractRouteBuil
                 
lineSplitProcessor.setProfilePropertyTypes(profileService.getTargetPropertyTypes("profiles"));
 
                 String endpoint = (String) 
importConfiguration.getProperties().get("source");
-                endpoint += "&moveFailed=.error";
+                if (StringUtils.isBlank(endpoint)) {
+                    LOGGER.error("No source endpoint configured, route {} will 
be skipped.", importConfiguration.getItemId());
+                    continue;
+                }
+                // Poll immediately when the route starts rather than waiting 
the default 1-second initialDelay.
+                endpoint += "&initialDelay=0&moveFailed=.error";
+
+                int schemeSeparatorIndex = endpoint.indexOf(':');
+                if (schemeSeparatorIndex < 0) {
+                    LOGGER.error("Endpoint {} has no scheme, route {} will be 
skipped.", endpoint, importConfiguration.getItemId());
+                    continue;
+                }
+                String endpointScheme = endpoint.substring(0, 
schemeSeparatorIndex);
 
-                if (StringUtils.isNotBlank(endpoint) && 
allowedEndpoints.contains(endpoint.substring(0, endpoint.indexOf(':')))) {
+                if (allowedEndpoints.contains(endpointScheme)) {
                     ProcessorDefinition prDef = from(endpoint)
                             .routeId(importConfiguration.getItemId())// This 
allow identification of the route for manual start/stop
                             .autoStartup(importConfiguration.isActive())// 
Auto-start if the import configuration is set active
@@ -176,7 +188,7 @@ public class ProfileImportFromSourceRouteBuilder extends 
RouterAbstractRouteBuil
                         prDef.to((String) 
getEndpointURI(RouterConstants.DIRECTION_FROM, 
RouterConstants.DIRECT_IMPORT_DEPOSIT_BUFFER));
                     }
                 } else {
-                    LOGGER.error("Endpoint scheme {} is not allowed, route {} 
will be skipped.", endpoint.substring(0, endpoint.indexOf(':')), 
importConfiguration.getItemId());
+                    LOGGER.error("Endpoint scheme {} is not allowed, route {} 
will be skipped.", endpointScheme, importConfiguration.getItemId());
                 }
             }
         }
diff --git 
a/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ExportConfigurationServiceImpl.java
 
b/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ExportConfigurationServiceImpl.java
index 1b7a3f5d2..fbb01bd8f 100644
--- 
a/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ExportConfigurationServiceImpl.java
+++ 
b/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ExportConfigurationServiceImpl.java
@@ -109,4 +109,10 @@ public class ExportConfigurationServiceImpl implements 
ImportExportConfiguration
         camelConfigsToRefresh.clear();
         return result;
     }
+
+    @Override
+    public void requeueForRefresh(String tenantId, String configId, 
RouterConstants.CONFIG_CAMEL_REFRESH refreshType) {
+        camelConfigsToRefresh.computeIfAbsent(tenantId, k -> new 
ConcurrentHashMap<>())
+                .putIfAbsent(configId, refreshType);
+    }
 }
diff --git 
a/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ImportConfigurationServiceImpl.java
 
b/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ImportConfigurationServiceImpl.java
index b599f88bf..c0dfae3b4 100644
--- 
a/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ImportConfigurationServiceImpl.java
+++ 
b/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ImportConfigurationServiceImpl.java
@@ -106,4 +106,10 @@ public class ImportConfigurationServiceImpl implements 
ImportExportConfiguration
         camelConfigsToRefresh.clear();
         return result;
     }
+
+    @Override
+    public void requeueForRefresh(String tenantId, String configId, 
RouterConstants.CONFIG_CAMEL_REFRESH refreshType) {
+        camelConfigsToRefresh.computeIfAbsent(tenantId, k -> new 
ConcurrentHashMap<>())
+                .putIfAbsent(configId, refreshType);
+    }
 }
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/ProfileImportActorsIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ProfileImportActorsIT.java
index 6ae186655..5ae6f595c 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileImportActorsIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileImportActorsIT.java
@@ -94,22 +94,23 @@ public class ProfileImportActorsIT extends BaseIT {
         ImportConfiguration savedImportConfigActors = 
importConfigurationService.save(importConfigActors, true);
         keepTrying("Failed waiting for actors import configuration to be 
saved", () -> importConfigurationService.load(importConfigActors.getItemId()), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
 
-        // Wait for Camel route to be created and started (the timer runs 
every 1 second to process config refreshes)
-        // This gives us visibility into what Camel is doing instead of just 
waiting for results
-        // Using official Camel API: getRouteController().getRouteStatus() and 
Management API for statistics
-        boolean routeStarted = waitForCamelRouteStarted(itemId, 1000, 5);
-        if (routeStarted) {
-            String routeInfo = getCamelRouteInfo(itemId);
-            System.out.println("==== Camel Route Status: " + routeInfo + " 
====");
-        } else {
-            System.out.println("==== Camel Route '" + itemId + "' was not 
started within timeout ====");
-            System.out.println("==== All Camel routes with status: " + 
getAllCamelRoutesWithStatus() + " ====");
-        }
-
-        //Wait for data to be processed
+        // Gate: wait for the Camel route to be created and started before 
polling for results.
+        // The timer fires every ~1 second to pick up config changes, so 15 
retries (15 s) is generous.
+        keepTrying("Camel route '" + itemId + "' did not start — timer may not 
have fired or route creation failed",
+                () -> isCamelRouteStarted(itemId),
+                started -> started,
+                1000, 15);
+        System.out.println("==== Camel Route Status: " + 
getCamelRouteInfo(itemId) + " ====");
+
+        // Wait for data to be processed. Force an ES index refresh before 
each search attempt so
+        // profiles written by UnomiStorageProcessor are visible to the Search 
API immediately.
         keepTrying("Failed waiting for actors initial import to complete",
-                () -> 
profileService.findProfilesByPropertyValue("properties.city", "hollywood", 0, 
10, null), (p) -> p.getTotalSize() == 6,
-                1000, 200);
+                () -> {
+                    persistenceService.refreshIndex(Profile.class);
+                    return 
profileService.findProfilesByPropertyValue("properties.city", "hollywood", 0, 
10, null);
+                },
+                (p) -> p.getTotalSize() == 6,
+                1000, 30);
 
         // Refresh the persistence index to ensure the saved configuration is 
queryable in getAll()
         // This addresses the flakiness where getAll() returns 0 items due to 
index refresh delay

Reply via email to