diff --git a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/IRouterCamelContext.java b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/IRouterCamelContext.java index c6835f143e..63d0b82c12 100644 --- a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/IRouterCamelContext.java +++ b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/IRouterCamelContext.java @@ -33,7 +33,8 @@ *

Typical usage: *

* * @since 1.0 @@ -41,36 +42,41 @@ public interface IRouterCamelContext { /** - * Stops and removes an existing Camel route by id, if it is currently registered in the context. + * Stops and removes the Camel route of a tenant's import/export configuration, if it is currently + * registered in the context. A configuration of the same id in another tenant keeps its route. * - * @param routeId Camel route identifier (usually aligned with import/export configuration id) + * @param tenantId tenant owning the configuration + * @param configId identifier of the import/export configuration whose route is removed * @param fireEvent when {@code true}, signals that router lifecycle events may be emitted; the concrete * implementation defines whether events are fired (reserved hook for observability) * @throws Exception if Camel fails to remove the route definition */ - void killExistingRoute(String routeId, boolean fireEvent) throws Exception; + void killExistingRoute(String tenantId, String configId, boolean fireEvent) throws Exception; /** - * Refreshes the profile import reader route for the given configuration: removes any existing route with the - * same id, loads the {@link org.apache.unomi.router.api.ImportConfiguration}, and—for recurrent configs— - * registers a new route built from current settings. + * Refreshes the profile import reader route for the given configuration: removes the existing route of that + * tenant's configuration, loads the {@link org.apache.unomi.router.api.ImportConfiguration}, and—for recurrent + * configs—registers a new route built from current settings. Must be called in the tenant's execution context. * + * @param tenantId tenant owning the configuration * @param configId identifier of the import configuration whose reader route should be updated * @param fireEvent when {@code true}, signals that router lifecycle events may be emitted after the update * @throws Exception if route removal or registration fails */ - void updateProfileImportReaderRoute(String configId, boolean fireEvent) throws Exception; + void updateProfileImportReaderRoute(String tenantId, String configId, boolean fireEvent) throws Exception; /** - * Refreshes the profile export reader (collect) route for the given configuration: removes any existing route - * with the same id, loads the {@link org.apache.unomi.router.api.ExportConfiguration}, and—for recurrent - * configs—registers a new collect route built from current settings. + * Refreshes the profile export reader (collect) route for the given configuration: removes the existing route + * of that tenant's configuration, loads the {@link org.apache.unomi.router.api.ExportConfiguration}, and—for + * recurrent configs—registers a new collect route built from current settings. Must be called in the tenant's + * execution context. * + * @param tenantId tenant owning the configuration * @param configId identifier of the export configuration whose reader route should be updated * @param fireEvent when {@code true}, signals that router lifecycle events may be emitted after the update * @throws Exception if route removal or registration fails */ - void updateProfileExportReaderRoute(String configId, boolean fireEvent) throws Exception; + void updateProfileExportReaderRoute(String tenantId, String configId, boolean fireEvent) throws Exception; /** * Enables or disables Camel route tracing on the underlying {@code CamelContext} for debugging (message flow, diff --git a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/RouteIds.java b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/RouteIds.java new file mode 100644 index 0000000000..c9105606fd --- /dev/null +++ b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/RouteIds.java @@ -0,0 +1,65 @@ +/* + * 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.unomi.router.api; + +import org.apache.unomi.api.ExecutionContext; + +import java.util.Objects; + +/** + * Identifiers of the Camel routes built from recurrent import/export configurations. + *

A configuration identifier is only unique within its tenant, while Camel route identifiers are + * global, so a route is identified by {@code :}. Tenant identifiers cannot + * contain a colon, which keeps the configuration identifier recoverable whatever it contains.

+ */ +public final class RouteIds { + + private static final char SEPARATOR = ':'; + + private RouteIds() { + } + + /** + * Returns the identifier of the route of a configuration. + * + * @param tenantId the tenant owning the configuration, the system tenant when {@code null} + * @param configId the configuration identifier + * @return the Camel route identifier + * @throws IllegalArgumentException if the tenant identifier contains a colon, which would make the + * configuration identifier impossible to recover from the route identifier + */ + public static String of(String tenantId, String configId) { + Objects.requireNonNull(configId, "configId"); + String owner = tenantId != null ? tenantId : ExecutionContext.SYSTEM_TENANT; + if (owner.indexOf(SEPARATOR) >= 0) { + throw new IllegalArgumentException("Tenant identifier must not contain '" + SEPARATOR + "': " + owner); + } + return owner + SEPARATOR + configId; + } + + /** + * Returns the identifier of the configuration a route was built from. + * + * @param routeId a Camel route identifier + * @return the configuration identifier, or the route identifier itself for a route that is not + * built from a configuration + */ + public static String configId(String routeId) { + int separator = routeId.indexOf(SEPARATOR); + return separator < 0 ? routeId : routeId.substring(separator + 1); + } +} 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 5911de36b5..4f4c18c937 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 @@ -18,7 +18,6 @@ import org.apache.camel.CamelContext; import org.apache.camel.Exchange; -import org.apache.camel.Route; import org.apache.camel.component.jackson.JacksonDataFormat; import org.apache.camel.core.osgi.OsgiDefaultCamelContext; import org.apache.camel.management.event.ExchangeCompletedEvent; @@ -26,16 +25,20 @@ import org.apache.camel.management.event.ExchangeSentEvent; import org.apache.camel.model.RouteDefinition; import org.apache.camel.support.EventNotifierSupport; +import org.apache.unomi.api.Item; import org.apache.unomi.api.services.ConfigSharingService; import org.apache.unomi.api.services.ExecutionContextManager; import org.apache.unomi.api.services.ProfileService; import org.apache.unomi.api.services.SchedulerService; import org.apache.unomi.api.tasks.ScheduledTask; +import org.apache.unomi.api.tenants.Tenant; +import org.apache.unomi.api.tenants.TenantService; import org.apache.unomi.api.security.SecurityService; import org.apache.unomi.persistence.spi.PersistenceService; import org.apache.unomi.router.api.ExportConfiguration; import org.apache.unomi.router.api.IRouterCamelContext; import org.apache.unomi.router.api.ImportConfiguration; +import org.apache.unomi.router.api.RouteIds; import org.apache.unomi.router.api.RouterConstants; import org.apache.unomi.router.api.services.ImportExportConfigurationService; import org.apache.unomi.router.api.services.ProfileExportService; @@ -51,6 +54,7 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; /** * The main Camel context manager for the Unomi Router component. @@ -95,6 +99,20 @@ public class RouterCamelContext implements IRouterCamelContext { private ConfigSharingService configSharingService; private ExecutionContextManager contextManager; private SecurityService securityService; + private TenantService tenantService; + + /** + * Tenants whose recurring routes could not be built at startup; the refresh timer retries them. + * The sets and the flag are filled at startup and emptied by the refresh timer; the two retry times + * below are only read and written by the refresh timer, which runs one tick at a time. + */ + private final Set importTenantsToRetry = ConcurrentHashMap.newKeySet(); + private final Set exportTenantsToRetry = ConcurrentHashMap.newKeySet(); + private volatile boolean tenantListingToRetry; + private final Set tenantsWithLoggedLoadFailure = ConcurrentHashMap.newKeySet(); + static final long MAX_STARTUP_RETRY_DELAY_MS = 60_000; + private long startupRetryDelay; + private long nextStartupRetryTime; private SchedulerService schedulerService; private ScheduledTask scheduledTask; @@ -127,6 +145,10 @@ public void setContextManager(ExecutionContextManager contextManager) { this.contextManager = contextManager; } + public void setTenantService(TenantService tenantService) { + this.tenantService = tenantService; + } + /** {@inheritDoc} */ @Override public void setTracing(boolean tracing) { @@ -147,7 +169,20 @@ public void init() throws Exception { configSharingService.setProperty(RouterConstants.CONFIG_EXPORT_BASE_DIRS, permittedExportBaseDirs); configSharingService.setProperty(RouterConstants.KEY_HISTORY_SIZE, execHistorySize); - initCamel(); + try { + initCamel(); + } catch (Exception e) { + // Do not leave a context behind that nothing will ever stop. + if (camelContext != null) { + try { + camelContext.stop(); + } catch (Exception stopFailure) { + e.addSuppressed(stopFailure); + } + camelContext = null; + } + throw e; + } initTimers(); LOGGER.info("Camel Context initialized successfully."); @@ -159,7 +194,12 @@ public void destroy() throws Exception { } //This is to shutdown Camel context //(will stop all routes/components/endpoints etc and clear internal state/cache) - this.camelContext.stop(); + if (camelContext == null) { + // init() failed before the context could be started + LOGGER.info("Camel context for profile import was not started, nothing to shut down."); + return; + } + camelContext.stop(); LOGGER.info("Camel context for profile import is shutdown."); } @@ -167,93 +207,124 @@ private void initTimers() { TimerTask task = new TimerTask() { @Override public void run() { - try { - Map> tenantsImportConfigsToRefresh = importConfigurationService.consumeConfigsToBeRefresh(); - - for (Map.Entry> tenantImportConfigsToRefresh : tenantsImportConfigsToRefresh.entrySet()) { - String tenantId = tenantImportConfigsToRefresh.getKey(); - contextManager.executeAsTenant(tenantId, () -> { - try { - for (Map.Entry importConfigToRefresh : tenantImportConfigsToRefresh.getValue().entrySet()) { - String configId = importConfigToRefresh.getKey(); - RouterConstants.CONFIG_CAMEL_REFRESH refreshType = importConfigToRefresh.getValue(); - RetryKey retryKey = new RetryKey("import", tenantId, configId); - try { - 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) { - 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) { - LOGGER.error("Unexpected error while refreshing import/export camel routes for tenant {}", tenantId, e); - } - return null; - }); - } + refreshRoutes(); + } + }; + scheduledTask = schedulerService.createRecurringTask("camel-route-refresh", configsRefreshInterval, TimeUnit.MILLISECONDS, task, false); + } + + /** + * One tick of the refresh timer: retries what could not be started at startup, then rebuilds or removes + * the routes of the configurations that were saved, deleted or re-queued since the previous tick. + */ + void refreshRoutes() { + try { + retryStartupLoadsWhenDue(System.currentTimeMillis()); + } catch (Exception e) { + // must not keep the refreshes below from being processed + LOGGER.warn("Unexpected error while retrying to start the recurring routes that could not be started at startup", e); + } + try { + refreshRoutes("import", importConfigurationService, this::updateProfileImportReaderRoute, this::markImportRouteCreationFailed); + refreshRoutes("export", exportConfigurationService, this::updateProfileExportReaderRoute, this::markExportRouteCreationFailed); + } catch (Exception e) { + LOGGER.error("Unexpected error while refreshing import/export camel routes", e); + } + } - Map> tenantsExportConfigsToRefresh = exportConfigurationService.consumeConfigsToBeRefresh(); - for (Map.Entry> tenantExportConfigsToRefresh : tenantsExportConfigsToRefresh.entrySet()) { - String tenantId = tenantExportConfigsToRefresh.getKey(); - contextManager.executeAsTenant(tenantId, () -> { - try { - for (Map.Entry exportConfigToRefresh : tenantExportConfigsToRefresh.getValue().entrySet()) { - String configId = exportConfigToRefresh.getKey(); - RouterConstants.CONFIG_CAMEL_REFRESH refreshType = exportConfigToRefresh.getValue(); - RetryKey retryKey = new RetryKey("export", tenantId, configId); - try { - 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) { - 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) { - LOGGER.error("Unexpected error while refreshing import/export camel routes for tenant {}", tenantId, e); + /** Rebuilds or removes the routes of the import or of the export configurations queued for a refresh. */ + private void refreshRoutes(String direction, ImportExportConfigurationService service, RouteUpdater routeUpdater, + Consumer routeCreationFailureMarker) { + Map> tenantsConfigsToRefresh = service.consumeConfigsToBeRefresh(); + for (Map.Entry> tenantConfigsToRefresh : tenantsConfigsToRefresh.entrySet()) { + String tenantId = tenantConfigsToRefresh.getKey(); + try { + contextManager.executeAsTenant(tenantId, () -> { + for (Map.Entry configToRefresh : tenantConfigsToRefresh.getValue().entrySet()) { + String configId = configToRefresh.getKey(); + RouterConstants.CONFIG_CAMEL_REFRESH refreshType = configToRefresh.getValue(); + RetryKey retryKey = new RetryKey(direction, tenantId, configId); + try { + if (refreshType.equals(RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED)) { + routeUpdater.update(tenantId, configId, true); + routeCreationRetryCount.remove(retryKey); + } else if (refreshType.equals(RouterConstants.CONFIG_CAMEL_REFRESH.REMOVED)) { + killExistingRoute(tenantId, configId, true); + routeCreationRetryCount.remove(retryKey); } - return null; - }); + } catch (Exception e) { + retryRefreshOrGiveUp(service, retryKey, refreshType, routeCreationFailureMarker, e, true); + } } + }); + } catch (Exception e) { + // The tenant could not be switched to: none of its refreshes ran. They were already taken off + // the queue, so they are counted and queued again like any other failed refresh, and the + // other tenants are still refreshed. + LOGGER.error("Could not switch to tenant {} to refresh its {} camel routes", tenantId, direction, e); + for (Map.Entry configToRefresh : tenantConfigsToRefresh.getValue().entrySet()) { + // no failure status can be recorded on a configuration of a tenant that cannot be switched to + // the stack trace was logged once above, for the tenant + retryRefreshOrGiveUp(service, new RetryKey(direction, tenantId, configToRefresh.getKey()), configToRefresh.getValue(), + null, e, false); + } + } + } + } + + /** + * Queues a failed refresh again until it has failed {@link #MAX_ROUTE_CREATION_RETRIES} times, then gives up + * and records the failure on the configuration. Never throws: the refreshes that follow must still run. + */ + private void retryRefreshOrGiveUp(ImportExportConfigurationService service, RetryKey retryKey, + RouterConstants.CONFIG_CAMEL_REFRESH refreshType, Consumer routeCreationFailureMarker, + Exception failure, boolean logStackTrace) { + String configId = retryKey.configId(); + try { + int attempt = routeCreationRetryCount.merge(retryKey, 1, Integer::sum); + if (attempt <= MAX_ROUTE_CREATION_RETRIES) { + if (logStackTrace) { + LOGGER.error("Refreshing({}) camel route {} failed (attempt {}/{}) — will retry on next tick", + refreshType, configId, attempt, MAX_ROUTE_CREATION_RETRIES, failure); + } else { + LOGGER.error("Refreshing({}) camel route {} failed (attempt {}/{}) — will retry on next tick: {}", + refreshType, configId, attempt, MAX_ROUTE_CREATION_RETRIES, failure.toString()); + } + service.requeueForRefresh(retryKey.tenantId(), configId, refreshType); + } else { + if (logStackTrace) { + LOGGER.error("Refreshing({}) camel route {} failed after {} attempts — giving up", + refreshType, configId, MAX_ROUTE_CREATION_RETRIES, failure); + } else { + LOGGER.error("Refreshing({}) camel route {} failed after {} attempts — giving up: {}", + refreshType, configId, MAX_ROUTE_CREATION_RETRIES, failure.toString()); + } + routeCreationRetryCount.remove(retryKey); + try { + // drop what the last failed attempt may have left in the context + killExistingRoute(retryKey.tenantId(), configId, false); } catch (Exception e) { - LOGGER.error("Unexpected error while refreshing import/export camel routes", e); + // must not keep the failure from being recorded on the configuration below + LOGGER.warn("Could not remove what is left of {} camel route {} of tenant {}", retryKey.direction(), configId, + retryKey.tenantId(), e); + } + if (routeCreationFailureMarker != null) { + routeCreationFailureMarker.accept(configId); } } - }; - scheduledTask = schedulerService.createRecurringTask("camel-route-refresh", configsRefreshInterval, TimeUnit.MILLISECONDS, task, false); + } catch (Exception e) { + LOGGER.error("Could not record the failed refresh({}) of {} camel route {} of tenant {}", refreshType, retryKey.direction(), + configId, retryKey.tenantId(), e); + } + } + + /** Creates the Camel context the routes are added to; a seam for running the route setup outside OSGi. */ + CamelContext createCamelContext() { + return new OsgiDefaultCamelContext(bundleContext); } - private void initCamel() throws Exception { - camelContext = new OsgiDefaultCamelContext(bundleContext); + void initCamel() throws Exception { + camelContext = createCamelContext(); // Setup listener, we might want to improve this to know exactly what is running at a given time and expose an API to query this information camelContext.getManagementStrategy().addEventNotifier(new EventNotifierSupport() { @@ -281,18 +352,6 @@ public boolean isEnabled(EventObject event) { //--IMPORT ROUTES - //Source - ProfileImportFromSourceRouteBuilder builderReader = new ProfileImportFromSourceRouteBuilder(kafkaProps, configType); - builderReader.setProfileService(profileService); - builderReader.setImportConfigurationService(importConfigurationService); - builderReader.setJacksonDataFormat(jacksonDataFormat); - builderReader.setAllowedEndpoints(allowedEndpoints); - builderReader.setPermittedImportBaseDirs(permittedImportBaseDirs); - builderReader.setContext(camelContext); - builderReader.setExecutionContextManager(contextManager); - builderReader.setSecurityService(securityService); - camelContext.addRoutes(builderReader); - //One shot import route ProfileImportOneShotRouteBuilder builderOneShot = new ProfileImportOneShotRouteBuilder(kafkaProps, configType); builderOneShot.setProfileService(profileService); @@ -312,18 +371,6 @@ public boolean isEnabled(EventObject event) { //--EXPORT ROUTES - //Profiles collect - ProfileExportCollectRouteBuilder profileExportCollectRouteBuilder = new ProfileExportCollectRouteBuilder(kafkaProps, configType); - profileExportCollectRouteBuilder.setExportConfigurationList(exportConfigurationService.getAll()); - profileExportCollectRouteBuilder.setExportConfigurationService(exportConfigurationService); - profileExportCollectRouteBuilder.setPersistenceService(persistenceService); - profileExportCollectRouteBuilder.setAllowedEndpoints(allowedEndpoints); - profileExportCollectRouteBuilder.setPermittedExportBaseDirs(permittedExportBaseDirs); - profileExportCollectRouteBuilder.setJacksonDataFormat(jacksonDataFormat); - profileExportCollectRouteBuilder.setContext(camelContext); - profileExportCollectRouteBuilder.setExecutionContextManager(contextManager); - camelContext.addRoutes(profileExportCollectRouteBuilder); - //Write to destination ProfileExportProducerRouteBuilder profileExportProducerRouteBuilder = new ProfileExportProducerRouteBuilder(kafkaProps, configType); profileExportProducerRouteBuilder.setProfileService(profileService); @@ -335,6 +382,49 @@ public boolean isEnabled(EventObject event) { camelContext.addRoutes(profileExportProducerRouteBuilder); camelContext.start(); + + startRecurrentRoutes(); + } + + /** + * Starts the routes of the recurrent import/export configurations of every real tenant plus the system + * tenant. {@link ImportExportConfigurationService#getAll()} is context-scoped, so a single call under the + * system context at startup would miss recurring configs of migrated tenants. + *

Each route is added on its own, once the context runs: a configuration whose route cannot be built + * is handed to the refresh timer, with its retries and failure status, and does not keep the routes of + * the other configurations, or of the other tenants, from starting. A tenant whose configurations cannot + * be loaded is retried by the refresh timer too.

+ */ + private void startRecurrentRoutes() { + // Startup has no request subject; nest tenant switches under system like other router paths. + contextManager.executeAsSystem(() -> { + // Listed once for the import and the export routes, so that both start for the same tenants. + for (String tenantId : startupTenantIds()) { + startRecurrentRoutes(tenantId, importConfigurationService, importTenantsToRetry, this::addImportReaderRoute); + startRecurrentRoutes(tenantId, exportConfigurationService, exportTenantsToRetry, this::addExportReaderRoute); + } + }); + } + + private void startRecurrentRoutes(String tenantId, ImportExportConfigurationService service, + Set tenantsToRetry, RouteAdder routeAdder) { + try { + contextManager.executeAsTenant(tenantId, () -> { + for (T config : service.getAll()) { + try { + routeAdder.add(config); + } catch (Exception e) { + LOGGER.error("Could not start the camel route of configuration {} of tenant {}, the route refresh " + + "will retry it", config.getItemId(), tenantId, e); + service.requeueForRefresh(tenantId, config.getItemId(), RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED); + } + } + }); + } catch (Exception e) { + LOGGER.error("Could not start the recurring routes of tenant {}; this will be retried", tenantId, e); + tenantsToRetry.add(tenantId); + tenantsWithLoggedLoadFailure.add(tenantId); + } } /** Persists a visible failure status on the configuration once route creation has exhausted all retries, instead of only logging it. */ @@ -357,27 +447,31 @@ private void markExportRouteCreationFailed(String configId) { /** {@inheritDoc} */ @Override - public void killExistingRoute(String routeId, boolean fireEvent) throws Exception { - //Active routes - Route route = camelContext.getRoute(routeId); - if (route != null) { - RouteDefinition routeDefinition = camelContext.getRouteDefinition(routeId); - if (routeDefinition != null) { - camelContext.removeRouteDefinition(routeDefinition); - } + public void killExistingRoute(String tenantId, String configId, boolean fireEvent) throws Exception { + String routeId = RouteIds.of(tenantId, configId); + // The definition is removed whether or not it has a running route: a route that failed to start + // leaves its definition behind, which must not survive the configuration or pile up over retries. + RouteDefinition routeDefinition = camelContext.getRouteDefinition(routeId); + if (routeDefinition != null) { + camelContext.removeRouteDefinition(routeDefinition); } } /** {@inheritDoc} */ @Override - public void updateProfileImportReaderRoute(String configId, boolean fireEvent) throws Exception { - killExistingRoute(configId, false); + public void updateProfileImportReaderRoute(String tenantId, String configId, boolean fireEvent) throws Exception { + killExistingRoute(tenantId, configId, false); ImportConfiguration importConfiguration = importConfigurationService.load(configId); if (importConfiguration == null) { throw new IllegalStateException("Cannot update profile import reader route, config: " + configId + " not found — will be retried"); } + addImportReaderRoute(importConfiguration); + } + + /** Adds the route of a recurrent import configuration to the running context; other configurations get none. */ + private void addImportReaderRoute(ImportConfiguration importConfiguration) throws Exception { if (RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT.equals(importConfiguration.getConfigType())) { ProfileImportFromSourceRouteBuilder builder = new ProfileImportFromSourceRouteBuilder(kafkaProps, configType); builder.setImportConfigurationList(Arrays.asList(importConfiguration)); @@ -395,14 +489,19 @@ public void updateProfileImportReaderRoute(String configId, boolean fireEvent) t /** {@inheritDoc} */ @Override - public void updateProfileExportReaderRoute(String configId, boolean fireEvent) throws Exception { - killExistingRoute(configId, false); + public void updateProfileExportReaderRoute(String tenantId, String configId, boolean fireEvent) throws Exception { + killExistingRoute(tenantId, configId, false); ExportConfiguration exportConfiguration = exportConfigurationService.load(configId); if (exportConfiguration == null) { throw new IllegalStateException("Cannot update profile export reader route, config: " + configId + " not found — will be retried"); } + addExportReaderRoute(exportConfiguration); + } + + /** Adds the collect route of a recurrent export configuration to the running context; other configurations get none. */ + private void addExportReaderRoute(ExportConfiguration exportConfiguration) throws Exception { if (RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT.equals(exportConfiguration.getConfigType())) { ProfileExportCollectRouteBuilder profileExportCollectRouteBuilder = new ProfileExportCollectRouteBuilder(kafkaProps, configType); profileExportCollectRouteBuilder.setExportConfigurationList(Collections.singletonList(exportConfiguration)); @@ -507,4 +606,108 @@ public void setPermittedImportBaseDirs(String permittedImportBaseDirs) { public void setPermittedExportBaseDirs(String permittedExportBaseDirs) { this.permittedExportBaseDirs = permittedExportBaseDirs; } + + /** Every tenant plus the system tenant, or the system tenant alone until tenants can be listed. */ + private Set startupTenantIds() { + Set tenantIds = new LinkedHashSet<>(); + try { + for (Tenant tenant : tenantService.getAllTenants()) { + tenantIds.add(tenant.getItemId()); + } + } catch (Exception e) { + LOGGER.error("Could not list tenants, only the recurring routes of the system tenant are started; " + + "the load will be retried", e); + tenantListingToRetry = true; + } + tenantIds.add(TenantService.SYSTEM_TENANT); + return tenantIds; + } + + /** + * Called on every tick of the refresh timer: retries what could not be started at startup, waiting + * twice as long after each unsuccessful attempt, up to {@link #MAX_STARTUP_RETRY_DELAY_MS}. + * + * @param now the current time in milliseconds + */ + void retryStartupLoadsWhenDue(long now) { + if (now < nextStartupRetryTime) { + return; + } + if (retryStartupLoads()) { + startupRetryDelay = 0; + } else { + startupRetryDelay = Math.min(Math.max(configsRefreshInterval, startupRetryDelay * 2), MAX_STARTUP_RETRY_DELAY_MS); + LOGGER.warn("Not all recurring routes could be started yet (tenants still to list: {}, import tenants to load: {}, " + + "export tenants to load: {}), next attempt in {} ms", + tenantListingToRetry, importTenantsToRetry, exportTenantsToRetry, startupRetryDelay); + } + nextStartupRetryTime = now + startupRetryDelay; + } + + /** + * Builds the recurring routes that could not be built at startup because tenants could not be listed or + * a tenant's configurations could not be loaded. + * + * @return {@code true} when nothing is left to retry + */ + boolean retryStartupLoads() { + if (nothingToRetry()) { + return true; + } + contextManager.executeAsSystem(() -> { + if (tenantListingToRetry) { + try { + // the system tenant was loaded at startup, the others were not known + for (Tenant tenant : tenantService.getAllTenants()) { + importTenantsToRetry.add(tenant.getItemId()); + exportTenantsToRetry.add(tenant.getItemId()); + } + tenantListingToRetry = false; + } catch (Exception e) { + // the stack trace was logged at startup + LOGGER.warn("Still cannot list tenants to start their recurring routes: {}", e.toString()); + } + } + retryStartupLoad(importTenantsToRetry, importConfigurationService); + retryStartupLoad(exportTenantsToRetry, exportConfigurationService); + }); + return nothingToRetry(); + } + + private boolean nothingToRetry() { + return !tenantListingToRetry && importTenantsToRetry.isEmpty() && exportTenantsToRetry.isEmpty(); + } + + /** + * Hands the configurations of the tenants that can now be loaded to the refresh the timer runs next, + * which builds their routes with its usual retries and failure status. + */ + private void retryStartupLoad(Set tenantsToRetry, ImportExportConfigurationService service) { + for (String tenantId : tenantsToRetry) { + try { + List configs = contextManager.executeAsTenant(tenantId, service::getAll); + for (T config : configs) { + service.requeueForRefresh(tenantId, config.getItemId(), RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED); + } + tenantsToRetry.remove(tenantId); + tenantsWithLoggedLoadFailure.remove(tenantId); + LOGGER.info("Router configurations of tenant {} loaded, its recurring routes are being started", tenantId); + } catch (Exception e) { + if (tenantsWithLoggedLoadFailure.add(tenantId)) { + LOGGER.warn("Still cannot load router configurations for tenant {}", tenantId, e); + } else { + // the stack trace was logged with the first failure + LOGGER.warn("Still cannot load router configurations for tenant {}: {}", tenantId, e.toString()); + } + } + } + } + + private interface RouteUpdater { + void update(String tenantId, String configId, boolean fireEvent) throws Exception; + } + + private interface RouteAdder { + void add(T config) throws Exception; + } } diff --git a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/processor/ExportRouteCompletionProcessor.java b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/processor/ExportRouteCompletionProcessor.java index a45a524e08..199ac69f29 100644 --- a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/processor/ExportRouteCompletionProcessor.java +++ b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/processor/ExportRouteCompletionProcessor.java @@ -22,6 +22,7 @@ import org.apache.unomi.router.api.RouterConstants; import org.apache.unomi.router.api.RouterUtils; import org.apache.unomi.router.api.services.ImportExportConfigurationService; +import org.apache.unomi.router.api.RouteIds; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -72,9 +73,9 @@ public class ExportRouteCompletionProcessor implements Processor { @Override public void process(Exchange exchange) throws Exception { // We load the conf from ES because we are going to increment the execution number - ExportConfiguration exportConfiguration = exportConfigurationService.load(exchange.getFromRouteId()); + ExportConfiguration exportConfiguration = exportConfigurationService.load(RouteIds.configId(exchange.getFromRouteId())); if (exportConfiguration == null) { - LOGGER.warn("Unable to complete export, config cannot not found: {}", exchange.getFromRouteId()); + LOGGER.error("Unable to complete export, config cannot be found: {}. Its status is left as it is.", exchange.getFromRouteId()); return; } diff --git a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/processor/ImportRouteCompletionProcessor.java b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/processor/ImportRouteCompletionProcessor.java index 25784d6c52..0bc58c08eb 100644 --- a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/processor/ImportRouteCompletionProcessor.java +++ b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/processor/ImportRouteCompletionProcessor.java @@ -76,9 +76,13 @@ public void process(Exchange exchange) throws Exception { if (importConfigOneShot != null) { importConfigId = importConfigOneShot.getItemId(); } else { - importConfigId = exchange.getFromRouteId(); + importConfigId = RouteIds.configId(exchange.getFromRouteId()); } ImportConfiguration importConfiguration = importConfigurationService.load(importConfigId); + if (importConfiguration == null) { + LOGGER.error("Unable to complete import, config cannot be found: {}. Its status is left as it is.", importConfigId); + return; + } long successCount = 0; long failureCount = 0; long ignoreCount = 0; diff --git a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java index bd652f3afb..f7a28bbc1f 100644 --- a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java +++ b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java @@ -23,6 +23,7 @@ import org.apache.unomi.persistence.spi.PersistenceService; import org.apache.unomi.router.api.EndpointValidator; import org.apache.unomi.router.api.ExportConfiguration; +import org.apache.unomi.router.api.RouteIds; import org.apache.unomi.router.api.RouterConstants; import org.apache.unomi.router.api.services.ImportExportConfigurationService; import org.apache.unomi.router.core.bean.CollectProfileBean; @@ -116,7 +117,7 @@ public void configure() throws Exception { timerString += "&delay=" + (String) exportConfiguration.getProperties().get("delay"); } ProcessorDefinition prDef = from(timerString) - .routeId(exportConfiguration.getItemId())// This allow identification of the route for manual start/stop + .routeId(RouteIds.of(exportConfiguration.getTenantId(), exportConfiguration.getItemId()))// This allow identification of the route for manual start/stop .autoStartup(exportConfiguration.isActive()) .setHeader(RouterConstants.HEADER_TENANT_ID, constant(exportConfiguration.getTenantId())) .bean(collectProfileBean, "extractProfileBySegment(" + exportConfiguration.getProperties().get("segment") + "," + exportConfiguration.getTenantId() + ")") 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 f9919a670d..e4f3e4e888 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 @@ -27,6 +27,7 @@ import org.apache.unomi.api.services.ExecutionContextManager; import org.apache.unomi.router.api.EndpointValidator; import org.apache.unomi.router.api.ImportConfiguration; +import org.apache.unomi.router.api.RouteIds; import org.apache.unomi.router.api.RouterConstants; import org.apache.unomi.router.api.services.ImportExportConfigurationService; import org.apache.unomi.router.api.exceptions.BadProfileDataFormatException; @@ -149,7 +150,7 @@ public void configure() throws Exception { recordEndpointOutcome(importConfiguration, importConfigurationService, refusal, executionContextManager); if (refusal == null) { ProcessorDefinition prDef = from(endpoint) - .routeId(importConfiguration.getItemId())// This allow identification of the route for manual start/stop + .routeId(RouteIds.of(importConfiguration.getTenantId(), importConfiguration.getItemId()))// This allow identification of the route for manual start/stop .autoStartup(importConfiguration.isActive())// Auto-start if the import configuration is set active .shutdownRunningTask(ShutdownRunningTask.CompleteAllTasks) .onCompletion() diff --git a/extensions/router/router-core/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/extensions/router/router-core/src/main/resources/OSGI-INF/blueprint/blueprint.xml index e40ce79455..7cc53a8f89 100644 --- a/extensions/router/router-core/src/main/resources/OSGI-INF/blueprint/blueprint.xml +++ b/extensions/router/router-core/src/main/resources/OSGI-INF/blueprint/blueprint.xml @@ -133,6 +133,7 @@ + diff --git a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/context/RouterCamelContextTenantLoadTest.java b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/context/RouterCamelContextTenantLoadTest.java new file mode 100644 index 0000000000..dcc8b0b191 --- /dev/null +++ b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/context/RouterCamelContextTenantLoadTest.java @@ -0,0 +1,512 @@ +/* + * 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.unomi.router.core.context; + +import org.apache.camel.CamelContext; +import org.apache.camel.component.jackson.JacksonDataFormat; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.unomi.api.ExecutionContext; +import org.apache.unomi.api.Item; +import org.apache.unomi.api.services.ExecutionContextManager; +import org.apache.unomi.api.tenants.Tenant; +import org.apache.unomi.api.tenants.TenantService; +import org.apache.unomi.router.api.ExportConfiguration; +import org.apache.unomi.router.api.ImportConfiguration; +import org.apache.unomi.router.api.ProfileToImport; +import org.apache.unomi.router.api.RouterConstants; +import org.apache.unomi.router.api.services.ImportExportConfigurationService; +import org.apache.unomi.router.core.processor.ExportRouteCompletionProcessor; +import org.apache.unomi.router.core.processor.ImportConfigByFileNameProcessor; +import org.apache.unomi.router.core.processor.ImportRouteCompletionProcessor; +import org.apache.unomi.router.core.processor.UnomiStorageProcessor; +import org.apache.unomi.router.core.route.RouterTestFixtures; +import org.apache.unomi.router.api.RouteIds; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * UNOMI-1000: router startup must load recurring configs for every tenant. + */ +public class RouterCamelContextTenantLoadTest { + + private static final Map NO_KAFKA = new HashMap<>(); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private final Map> configsByTenant = new HashMap<>(); + private final Map> exportConfigsByTenant = new HashMap<>(); + private final List visitedTenants = new ArrayList<>(); + private String currentTenant; + private String failingTenant; + private boolean tenantListingFails; + + private DefaultCamelContext camelContext; + private TenantScopedService importService; + private TenantScopedService exportService; + private String unswitchableTenant; + + @After + public void tearDown() throws Exception { + if (camelContext != null) { + camelContext.stop(); + } + } + + /** + * The startup path itself: after a restart, the routes of every tenant's recurrent configurations + * must exist without anyone saving the configurations again. + */ + @Test + public void startupBuildsTheRecurrentRoutesOfEveryTenant() throws Exception { + RouterCamelContext router = startRouter(); + + for (String tenantId : Arrays.asList("tenant-a", "tenant-b", TenantService.SYSTEM_TENANT)) { + assertRoutesBuilt(tenantId); + } + assertNull("tenant context must be restored after startup", currentTenant); + + // Removing one tenant's route must leave the other tenant's route of the same configuration id. + router.killExistingRoute("tenant-a", "crm-import", false); + assertNull(camelContext.getRouteDefinition(RouteIds.of("tenant-a", "crm-import"))); + assertNotNull("tenant-b keeps its route", camelContext.getRouteDefinition(RouteIds.of("tenant-b", "crm-import"))); + } + + @Test + public void tenantWhoseLoadFailsAtStartupDoesNotBlockTheOthersAndIsRetried() throws Exception { + failingTenant = "tenant-a"; + + RouterCamelContext router = startRouter(); + + assertRoutesBuilt("tenant-b"); + assertNull("tenant-a cannot have a route yet", camelContext.getRouteDefinition(RouteIds.of("tenant-a", "crm-import"))); + + assertFalse("tenant-a still cannot be loaded", router.retryStartupLoads()); + router.refreshRoutes(); + assertNull(camelContext.getRouteDefinition(RouteIds.of("tenant-a", "crm-import"))); + + failingTenant = null; + assertTrue("nothing is left to retry", router.retryStartupLoads()); + router.refreshRoutes(); + assertRoutesBuilt("tenant-a"); + assertRoutesBuilt("tenant-b"); + } + + @Test + public void refreshTimerRetriesWhatStartupCouldNotLoad() throws Exception { + failingTenant = "tenant-a"; + RouterCamelContext router = startRouter(); + failingTenant = null; + + // first tick retries the load and hands the configurations to the refresh of the same tick + router.refreshRoutes(); + + assertRoutesBuilt("tenant-a"); + } + + @Test + public void startupRetriesBackOffUntilTheTenantCanBeLoaded() throws Exception { + failingTenant = "tenant-a"; + RouterCamelContext router = startRouter(); + router.setConfigsRefreshInterval(1000); + + router.retryStartupLoadsWhenDue(0); + assertEquals("the first tick retries", 1, retriesOf("tenant-a")); + router.retryStartupLoadsWhenDue(999); + assertEquals("nothing is retried before the delay has passed", 1, retriesOf("tenant-a")); + router.retryStartupLoadsWhenDue(1000); + assertEquals("retried once the delay has passed", 2, retriesOf("tenant-a")); + router.retryStartupLoadsWhenDue(2999); + assertEquals("the delay doubles after an unsuccessful attempt", 2, retriesOf("tenant-a")); + router.retryStartupLoadsWhenDue(3000); + assertEquals(3, retriesOf("tenant-a")); + + // keep failing until the delay stops growing + long now = 3000; + for (int attempt = 0; attempt < 10; attempt++) { + now += RouterCamelContext.MAX_STARTUP_RETRY_DELAY_MS; + router.retryStartupLoadsWhenDue(now); + } + int retries = retriesOf("tenant-a"); + router.retryStartupLoadsWhenDue(now + RouterCamelContext.MAX_STARTUP_RETRY_DELAY_MS - 1); + assertEquals("the delay is capped", retries, retriesOf("tenant-a")); + + failingTenant = null; + router.retryStartupLoadsWhenDue(now + RouterCamelContext.MAX_STARTUP_RETRY_DELAY_MS); + router.refreshRoutes(); + assertRoutesBuilt("tenant-a"); + + retries = retriesOf("tenant-a"); + router.retryStartupLoadsWhenDue(now + 10 * RouterCamelContext.MAX_STARTUP_RETRY_DELAY_MS); + assertEquals("nothing is retried once every tenant is started", retries, retriesOf("tenant-a")); + } + + @Test + public void startupRetryKeepsARefreshAlreadyQueuedForAConfiguration() throws Exception { + failingTenant = "tenant-a"; + RouterCamelContext router = startRouter(); + failingTenant = null; + // the configuration is deleted while its tenant is still waiting for the startup retry + importService.requeueForRefresh("tenant-a", "crm-import", RouterConstants.CONFIG_CAMEL_REFRESH.REMOVED); + + router.refreshRoutes(); + + assertEquals("the queued removal must win over the startup retry", + RouterConstants.CONFIG_CAMEL_REFRESH.REMOVED, importService.lastConsumed.get("tenant-a").get("crm-import")); + assertNull(camelContext.getRouteDefinition(RouteIds.of("tenant-a", "crm-import"))); + assertNotNull(camelContext.getRouteDefinition(RouteIds.of("tenant-a", "crm-export"))); + } + + @Test + public void tenantThatCannotBeSwitchedToDoesNotKeepTheOthersFromBeingRefreshed() throws Exception { + RouterCamelContext router = startRouter(); + router.killExistingRoute("tenant-b", "crm-import", false); + router.killExistingRoute("tenant-b", "crm-export", false); + // tenant-a is refreshed first and cannot be switched to, as a tenant that was just deleted + for (TenantScopedService service : Arrays.asList(importService, exportService)) { + String configId = service == importService ? "crm-import" : "crm-export"; + service.requeueForRefresh("tenant-a", configId, RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED); + service.requeueForRefresh("tenant-b", configId, RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED); + } + unswitchableTenant = "tenant-a"; + + router.refreshRoutes(); + + assertRoutesBuilt("tenant-b"); + } + + @Test + public void configurationWhoseRouteCannotBeBuiltDoesNotKeepTheOthersFromStarting() throws Exception { + // an export without a period cannot be turned into a timer route + ExportConfiguration broken = recurrentExport("broken-export", "tenant-a", new File(exportRoot(), "tenant-a")); + broken.getProperties().remove("period"); + // active, so that the route is started, which is when its timer endpoint is found to be unusable + broken.setActive(true); + exportConfigsByTenant.computeIfAbsent("tenant-a", k -> new ArrayList<>()).add(broken); + + RouterCamelContext router = startRouter(); + + for (String tenantId : Arrays.asList("tenant-a", "tenant-b", TenantService.SYSTEM_TENANT)) { + assertRoutesBuilt(tenantId); + } + assertNull(camelContext.getRoute(RouteIds.of("tenant-a", "broken-export"))); + + // the refresh retries it, then gives up and records the failure on the configuration + for (int tick = 0; tick < 10; tick++) { + router.refreshRoutes(); + } + assertEquals(RouterConstants.CONFIG_STATUS_ROUTE_CREATION_FAILED, broken.getStatus()); + assertTrue("a configuration that was given up on is not retried forever", exportService.toRefresh.isEmpty()); + assertEquals("failed attempts must not leave route definitions behind", 0, camelContext.getRouteDefinitions().stream() + .filter(route -> RouteIds.of("tenant-a", "broken-export").equals(route.getId())).count()); + assertRoutesBuilt("tenant-a"); + } + + @Test + public void refreshesOfATenantThatCannotBeSwitchedToAreRetriedOnTheNextTicks() throws Exception { + RouterCamelContext router = startRouter(); + router.killExistingRoute("tenant-a", "crm-import", false); + importService.requeueForRefresh("tenant-a", "crm-import", RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED); + unswitchableTenant = "tenant-a"; + + router.refreshRoutes(); + assertNull(camelContext.getRouteDefinition(RouteIds.of("tenant-a", "crm-import"))); + + unswitchableTenant = null; + router.refreshRoutes(); + assertNotNull("the refresh that could not run must not be lost", + camelContext.getRouteDefinition(RouteIds.of("tenant-a", "crm-import"))); + } + + @Test + public void refreshesOfATenantThatCanNeverBeSwitchedToAreGivenUpOn() throws Exception { + RouterCamelContext router = startRouter(); + importService.requeueForRefresh("tenant-a", "crm-import", RouterConstants.CONFIG_CAMEL_REFRESH.UPDATED); + unswitchableTenant = "tenant-a"; + + for (int tick = 0; tick < 10; tick++) { + router.refreshRoutes(); + } + + assertTrue("a tenant that is gone must not be retried on every tick forever", importService.toRefresh.isEmpty()); + } + + @Test + public void shutdownOfARouterThatNeverStartedDoesNotFail() throws Exception { + RouterCamelContext neverStarted = new RouterCamelContext(); + + neverStarted.destroy(); + } + + @Test + public void tenantListingFailureAtStartupIsRetried() throws Exception { + tenantListingFails = true; + + RouterCamelContext router = startRouter(); + + assertNull("no tenant is known yet", camelContext.getRouteDefinition(RouteIds.of("tenant-a", "crm-import"))); + + assertFalse("tenants still cannot be listed", router.retryStartupLoads()); + + tenantListingFails = false; + assertTrue("nothing is left to retry", router.retryStartupLoads()); + router.refreshRoutes(); + assertRoutesBuilt("tenant-a"); + assertRoutesBuilt("tenant-b"); + } + + @Test + public void nothingIsReloadedOnceEveryTenantIsStarted() throws Exception { + RouterCamelContext router = startRouter(); + visitedTenants.clear(); + + router.refreshRoutes(); + + assertTrue("the refresh timer must not reload tenants on every tick", visitedTenants.isEmpty()); + } + + /** + * Starts a router over two tenants and the system tenant, which all own a recurrent import and a recurrent + * export under the same configuration ids: ids are only unique within a tenant. Configurations a test put + * in place beforehand are kept. + */ + private RouterCamelContext startRouter() throws Exception { + File importRoot = new File(tmp.getRoot(), "permitted-import"); + File exportRoot = exportRoot(); + for (String tenantId : Arrays.asList("tenant-a", "tenant-b", TenantService.SYSTEM_TENANT)) { + configsByTenant.computeIfAbsent(tenantId, k -> new ArrayList<>()) + .add(recurrentImport("crm-import", tenantId, new File(importRoot, tenantId))); + exportConfigsByTenant.computeIfAbsent(tenantId, k -> new ArrayList<>()) + .add(recurrentExport("crm-export", tenantId, new File(exportRoot, tenantId))); + } + + camelContext = new DefaultCamelContext(); + RouterCamelContext router = new RouterCamelContext() { + @Override + CamelContext createCamelContext() { + return camelContext; + } + }; + router.setContextManager(new TenantTrackingContextManager()); + router.setTenantService(tenantService("tenant-a", "tenant-b")); + importService = new TenantScopedService<>(configsByTenant); + router.setImportConfigurationService(importService); + exportService = new TenantScopedService<>(exportConfigsByTenant); + router.setExportConfigurationService(exportService); + router.setProfileService(RouterTestFixtures.noOpProfileService()); + router.setKafkaProps(NO_KAFKA); + router.setConfigType(RouterConstants.CONFIG_TYPE_NOBROKER); + router.setJacksonDataFormat(new JacksonDataFormat(ProfileToImport.class)); + router.setAllowedEndpoints("file"); + router.setPermittedImportBaseDirs(importRoot.getAbsolutePath()); + router.setPermittedExportBaseDirs(exportRoot.getAbsolutePath()); + router.setUnomiStorageProcessor(new UnomiStorageProcessor()); + router.setImportRouteCompletionProcessor(new ImportRouteCompletionProcessor()); + router.setExportRouteCompletionProcessor(new ExportRouteCompletionProcessor()); + router.setImportConfigByFileNameProcessor(new ImportConfigByFileNameProcessor()); + router.initCamel(); + return router; + } + + /** How many times the startup retry tried to load the tenant's import configurations. */ + private int retriesOf(String tenantId) { + return importService.loadAttempts.getOrDefault(tenantId, 0) - 1; + } + + private File exportRoot() { + return new File(tmp.getRoot(), "permitted-export"); + } + + private void assertRoutesBuilt(String tenantId) { + for (String configId : Arrays.asList("crm-import", "crm-export")) { + assertNotNull("the route of " + configId + " must be built for " + tenantId, + camelContext.getRouteDefinition(RouteIds.of(tenantId, configId))); + } + } + + private static ImportConfiguration recurrentImport(String id, String tenantId, File directory) { + assertTrue(directory.isDirectory() || directory.mkdirs()); + ImportConfiguration configuration = config(id, tenantId); + configuration.setConfigType(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT); + // inactive: the route is built but does not start polling during the test + configuration.setActive(false); + configuration.getProperties().put("source", "file://" + directory.getAbsolutePath() + "?fileName=profiles.csv"); + configuration.getProperties().put("mapping", Collections.singletonMap("0", 0)); + return configuration; + } + + private static ExportConfiguration recurrentExport(String id, String tenantId, File directory) { + assertTrue(directory.isDirectory() || directory.mkdirs()); + ExportConfiguration configuration = new ExportConfiguration(); + configuration.setItemId(id); + configuration.setTenantId(tenantId); + configuration.setConfigType(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT); + configuration.setActive(false); + configuration.getProperties().put("destination", "file://" + directory.getAbsolutePath() + "?fileName=profiles.csv"); + configuration.getProperties().put("mapping", Collections.singletonMap("0", "firstName")); + configuration.getProperties().put("segment", "exportSegment"); + configuration.getProperties().put("period", "1m"); + return configuration; + } + + private static ImportConfiguration config(String id, String tenantId) { + ImportConfiguration config = new ImportConfiguration(); + config.setItemId(id); + config.setTenantId(tenantId); + return config; + } + + private TenantService tenantService(String... tenantIds) { + return (TenantService) Proxy.newProxyInstance( + TenantService.class.getClassLoader(), + new Class[]{TenantService.class}, + (proxy, method, args) -> { + if ("getAllTenants".equals(method.getName())) { + if (tenantListingFails) { + throw new IllegalStateException("persistence unavailable"); + } + List tenants = new ArrayList<>(); + for (String tenantId : tenantIds) { + Tenant tenant = new Tenant(); + tenant.setItemId(tenantId); + tenants.add(tenant); + } + return tenants; + } + throw new UnsupportedOperationException(method.getName()); + }); + } + + /** Returns only the configurations of the tenant the context manager is currently switched to. */ + private class TenantScopedService implements ImportExportConfigurationService { + private final Map> configs; + private final Map> toRefresh = new LinkedHashMap<>(); + private Map> lastConsumed = new LinkedHashMap<>(); + /** Calls to getAll() per tenant, the startup load included. */ + private final Map loadAttempts = new HashMap<>(); + + TenantScopedService(Map> configs) { + this.configs = configs; + } + + @Override + public List getAll() { + loadAttempts.merge(String.valueOf(currentTenant), 1, Integer::sum); + if (currentTenant != null && currentTenant.equals(failingTenant)) { + throw new IllegalStateException("cannot load configurations of " + currentTenant); + } + return configs.getOrDefault(currentTenant, Collections.emptyList()); + } + + @Override + public T load(String configId) { + return configs.getOrDefault(currentTenant, Collections.emptyList()).stream() + .filter(config -> configId.equals(((Item) config).getItemId())).findFirst().orElse(null); + } + + @Override + public T save(T configuration, boolean updateRunningRoute) { + return configuration; + } + + @Override + public void delete(String configId) { + } + + @Override + public Map> consumeConfigsToBeRefresh() { + lastConsumed = new LinkedHashMap<>(toRefresh); + toRefresh.clear(); + return lastConsumed; + } + + @Override + public void requeueForRefresh(String tenantId, String configId, + RouterConstants.CONFIG_CAMEL_REFRESH refreshType) { + // like the real service: a refresh already queued for the configuration is kept + toRefresh.computeIfAbsent(tenantId, k -> new HashMap<>()).putIfAbsent(configId, refreshType); + } + } + + private class TenantTrackingContextManager implements ExecutionContextManager { + @Override + public ExecutionContext getCurrentContext() { + return null; + } + + @Override + public void setCurrentContext(ExecutionContext context) { + } + + @Override + public T executeAsSystem(Supplier operation) { + return operation.get(); + } + + @Override + public void executeAsSystem(Runnable operation) { + operation.run(); + } + + @Override + public T executeAsTenant(String tenantId, Supplier operation) { + if (tenantId.equals(unswitchableTenant)) { + throw new IllegalStateException("cannot switch to " + tenantId); + } + visitedTenants.add(tenantId); + String previous = currentTenant; + currentTenant = tenantId; + try { + return operation.get(); + } finally { + currentTenant = previous; + } + } + + @Override + public void executeAsTenant(String tenantId, Runnable operation) { + executeAsTenant(tenantId, () -> { + operation.run(); + return null; + }); + } + + @Override + public ExecutionContext createContext(String tenantId) { + return null; + } + } +} diff --git a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/processor/RouteCompletionProcessorTest.java b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/processor/RouteCompletionProcessorTest.java new file mode 100644 index 0000000000..d4ceeb358f --- /dev/null +++ b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/processor/RouteCompletionProcessorTest.java @@ -0,0 +1,155 @@ +/* + * 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.unomi.router.core.processor; + +import org.apache.camel.Exchange; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.impl.DefaultExchange; +import org.apache.unomi.router.api.ExportConfiguration; +import org.apache.unomi.router.api.ImportConfiguration; +import org.apache.unomi.router.api.ProfileToImport; +import org.apache.unomi.router.api.RouterConstants; +import org.apache.unomi.router.api.services.ImportExportConfigurationService; +import org.apache.unomi.router.api.RouteIds; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +/** + * The completion processors find the configuration to update from the route the exchange comes from, + * whose id carries the tenant as well as the configuration id. + */ +public class RouteCompletionProcessorTest { + + @Test + public void importCompletionUpdatesTheConfigurationOfTheRoute() throws Exception { + ImportConfiguration configuration = new ImportConfiguration(); + configuration.setItemId("crm:import"); + configuration.setTenantId("tenant-a"); + StoredConfigurations store = new StoredConfigurations<>(); + store.byId.put("crm:import", configuration); + ImportRouteCompletionProcessor processor = new ImportRouteCompletionProcessor(); + processor.setImportConfigurationService(store); + processor.setExecutionsHistorySize(5); + + Exchange exchange = exchangeFromRoute(RouteIds.of("tenant-a", "crm:import")); + exchange.setProperty("CamelSplitSize", 1); + exchange.getIn().setBody(new ArrayList<>(Collections.singletonList(new ProfileToImport()))); + processor.process(exchange); + + assertSame("the configuration of the route must be the one saved", configuration, store.lastSaved); + assertEquals(RouterConstants.CONFIG_STATUS_COMPLETE_SUCCESS, configuration.getStatus()); + } + + @Test + public void exportCompletionUpdatesTheConfigurationOfTheRoute() throws Exception { + ExportConfiguration configuration = new ExportConfiguration(); + configuration.setItemId("crm:export"); + configuration.setTenantId("tenant-a"); + StoredConfigurations store = new StoredConfigurations<>(); + store.byId.put("crm:export", configuration); + ExportRouteCompletionProcessor processor = new ExportRouteCompletionProcessor(); + processor.setExportConfigurationService(store); + processor.setExecutionsHistorySize(5); + + Exchange exchange = exchangeFromRoute(RouteIds.of("tenant-a", "crm:export")); + exchange.setProperty("CamelSplitSize", 1); + processor.process(exchange); + + assertSame("the configuration of the route must be the one saved", configuration, store.lastSaved); + assertEquals(RouterConstants.CONFIG_STATUS_COMPLETE_SUCCESS, configuration.getStatus()); + } + + @Test + public void importCompletionOfAConfigurationThatIsGoneSavesNothing() throws Exception { + StoredConfigurations store = new StoredConfigurations<>(); + ImportRouteCompletionProcessor processor = new ImportRouteCompletionProcessor(); + processor.setImportConfigurationService(store); + + Exchange exchange = exchangeFromRoute(RouteIds.of("tenant-a", "deleted-import")); + exchange.setProperty("CamelSplitSize", 1); + exchange.getIn().setBody(new ArrayList<>(Collections.singletonList(new ProfileToImport()))); + processor.process(exchange); + + assertNull(store.lastSaved); + } + + @Test + public void exportCompletionOfAConfigurationThatIsGoneSavesNothing() throws Exception { + StoredConfigurations store = new StoredConfigurations<>(); + ExportRouteCompletionProcessor processor = new ExportRouteCompletionProcessor(); + processor.setExportConfigurationService(store); + + Exchange exchange = exchangeFromRoute(RouteIds.of("tenant-a", "deleted-export")); + exchange.setProperty("CamelSplitSize", 1); + processor.process(exchange); + + assertNull(store.lastSaved); + } + + private static Exchange exchangeFromRoute(String routeId) { + Exchange exchange = new DefaultExchange(new DefaultCamelContext()); + exchange.setFromRouteId(routeId); + exchange.setProperty("CamelCreatedTimestamp", new Date()); + return exchange; + } + + /** Hands out the configurations it holds by configuration id, as the real service does within a tenant. */ + private static final class StoredConfigurations implements ImportExportConfigurationService { + + private final Map byId = new HashMap<>(); + private T lastSaved; + + @Override + public List getAll() { + return new ArrayList<>(byId.values()); + } + + @Override + public T load(String configId) { + return byId.get(configId); + } + + @Override + public T save(T configuration, boolean updateRunningRoute) { + lastSaved = configuration; + return configuration; + } + + @Override + public void delete(String configId) { + } + + @Override + public Map> consumeConfigsToBeRefresh() { + return Collections.emptyMap(); + } + + @Override + public void requeueForRefresh(String tenantId, String configId, RouterConstants.CONFIG_CAMEL_REFRESH refreshType) { + } + } +} diff --git a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java index 8d328f04d8..cee4c10473 100644 --- a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java +++ b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java @@ -20,6 +20,7 @@ import org.apache.camel.impl.DefaultCamelContext; import org.apache.unomi.router.api.ExportConfiguration; import org.apache.unomi.router.api.ImportConfiguration; +import org.apache.unomi.router.api.RouteIds; import org.apache.unomi.router.api.ProfileToImport; import org.apache.unomi.router.api.RouterConstants; import org.apache.unomi.router.api.services.ImportExportConfigurationService; @@ -44,6 +45,7 @@ import static org.apache.unomi.router.core.route.RouterTestFixtures.recurrentExport; import static org.apache.unomi.router.core.route.RouterTestFixtures.recurrentImport; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -461,7 +463,7 @@ public void importRouteIsRefusedWhenSourceIsTheSharedBaseOrAnotherTenantsDirecto assertRouteRefused("shared-base", "the shared base directory is not a tenant directory"); assertRouteRefused("other-tenant", "acme may not read beta's directory"); assertRouteBuilt("own-tenant", "acme may read its own directory"); - assertRouteBuilt("beta-tenant", "beta may read its own directory"); + assertRouteBuilt("beta", "beta-tenant", "beta may read its own directory"); } @Test @@ -767,20 +769,26 @@ public void importRouteIsRefusedWhenTheTenantIdNamesNoDirectoryOfItsOwn() throws // Fixtures // --------------------------------------------------------------------------------------------- - private void assertRouteBuilt(String routeId, String why) { - assertNotNull("no route was built for configuration '" + routeId + "', although " + why, - camelContext.getRouteDefinition(routeId)); + private void assertRouteBuilt(String configId, String why) { + assertRouteBuilt(TENANT, configId, why); + } + + private void assertRouteBuilt(String tenantId, String configId, String why) { + assertNotNull("no route was built for configuration '" + configId + "' of " + tenantId + ", although " + why, + camelContext.getRouteDefinition(RouteIds.of(tenantId, configId))); } /** The endpoint URI the route was actually built on, which is not the one that was configured. */ - private String builtEndpointUri(String routeId) { - assertRouteBuilt(routeId, "there is no endpoint to look at otherwise"); - return camelContext.getRouteDefinition(routeId).getInputs().get(0).getUri(); + private String builtEndpointUri(String configId) { + assertRouteBuilt(configId, "there is no endpoint to look at otherwise"); + return camelContext.getRouteDefinition(RouteIds.of(TENANT, configId)).getInputs().get(0).getUri(); } - private void assertRouteRefused(String routeId, String why) { - assertNull("a route was built for configuration '" + routeId + "', although " + why, - camelContext.getRouteDefinition(routeId)); + /** No tenant may have got a route out of the configuration: route ids carry the tenant. */ + private void assertRouteRefused(String configId, String why) { + assertFalse("a route was built for configuration '" + configId + "', although " + why, + camelContext.getRouteDefinitions().stream() + .anyMatch(route -> configId.equals(RouteIds.configId(route.getId())))); } private void addImportRoutes(ImportConfiguration... configurations) throws Exception { diff --git a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RefusedConfigurationStatusTest.java b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RefusedConfigurationStatusTest.java index 8185ea88b1..fd6f574975 100644 --- a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RefusedConfigurationStatusTest.java +++ b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RefusedConfigurationStatusTest.java @@ -21,6 +21,7 @@ import org.apache.unomi.api.services.ExecutionContextManager; import org.apache.unomi.router.api.ExportConfiguration; import org.apache.unomi.router.api.ImportConfiguration; +import org.apache.unomi.router.api.RouteIds; import org.apache.unomi.router.api.ProfileToImport; import org.apache.unomi.router.api.RouterConstants; import org.apache.unomi.router.api.services.ImportExportConfigurationService; @@ -117,7 +118,7 @@ public void aRefusedImportConfigurationIsRecordedAsFailed() throws Exception { addImportRoutes(configuration); - assertNull("no route should have been built", camelContext.getRouteDefinition("out-of-bounds")); + assertNull("no route should have been built", camelContext.getRouteDefinition(RouteIds.of(TENANT, "out-of-bounds"))); assertEquals("the configuration should be recorded as failed", RouterConstants.CONFIG_STATUS_INVALID_ENDPOINT, configuration.getStatus()); assertTrue("the configuration should have been saved so the failure is visible", @@ -130,7 +131,7 @@ public void aRefusedExportConfigurationIsRecordedAsFailed() throws Exception { addExportRoutes(configuration); - assertNull("no route should have been built", camelContext.getRouteDefinition("out-of-bounds")); + assertNull("no route should have been built", camelContext.getRouteDefinition(RouteIds.of(TENANT, "out-of-bounds"))); assertEquals("the configuration should be recorded as failed", RouterConstants.CONFIG_STATUS_INVALID_ENDPOINT, configuration.getStatus()); assertTrue("the configuration should have been saved so the failure is visible", @@ -178,9 +179,9 @@ public void aContextThatCannotBeSwitchedDoesNotCostTheBatchItsOtherRoutes() thro addImportRoutes(noSystemSubject, recurrentImport(fileUri(arbitraryDir, "?fileName=profiles.csv")), inBoundsImport("in-bounds")); - assertNull("the refused configuration still gets no route", camelContext.getRouteDefinition("out-of-bounds")); + assertNull("the refused configuration still gets no route", camelContext.getRouteDefinition(RouteIds.of(TENANT, "out-of-bounds"))); assertNotNull("failing to switch context must not cost the other configurations their routes", - camelContext.getRouteDefinition("in-bounds")); + camelContext.getRouteDefinition(RouteIds.of(TENANT, "in-bounds"))); } @Test @@ -190,9 +191,9 @@ public void aStoreThatCannotRecordTheRefusalDoesNotCostTheBatchItsOtherRoutes() addImportRoutes(recurrentImport(fileUri(arbitraryDir, "?fileName=profiles.csv")), inBoundsImport("in-bounds")); - assertNull("the refused configuration still gets no route", camelContext.getRouteDefinition("out-of-bounds")); + assertNull("the refused configuration still gets no route", camelContext.getRouteDefinition(RouteIds.of(TENANT, "out-of-bounds"))); assertNotNull("failing to record the refusal must not cost the other configurations their routes", - camelContext.getRouteDefinition("in-bounds")); + camelContext.getRouteDefinition(RouteIds.of(TENANT, "in-bounds"))); } @Test diff --git a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RouteIdsTest.java b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RouteIdsTest.java new file mode 100644 index 0000000000..bcd7ae9b9a --- /dev/null +++ b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RouteIdsTest.java @@ -0,0 +1,58 @@ +/* + * 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.unomi.router.core.route; + +import org.apache.unomi.router.api.RouteIds; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; + +public class RouteIdsTest { + + @Test + public void sameConfigurationIdInTwoTenantsGetsTwoRouteIds() { + assertNotEquals(RouteIds.of("tenant-a", "crm-import"), RouteIds.of("tenant-b", "crm-import")); + } + + @Test + public void configurationIdIsRecoveredWhateverItContains() { + assertEquals("crm-import", RouteIds.configId(RouteIds.of("tenant-a", "crm-import"))); + assertEquals("a:b:c", RouteIds.configId(RouteIds.of("tenant_a", "a:b:c"))); + } + + @Test + public void configurationWithoutTenantBelongsToTheSystemTenant() { + assertEquals(RouteIds.of("system", "crm-import"), RouteIds.of(null, "crm-import")); + } + + @Test + public void tenantIdThatWouldMakeTheRouteIdAmbiguousIsRefused() { + assertThrows(IllegalArgumentException.class, () -> RouteIds.of("tenant:a", "crm-import")); + } + + @Test + public void routeIdNeedsAConfigurationId() { + assertThrows(NullPointerException.class, () -> RouteIds.of("tenant-a", null)); + } + + @Test + public void routeNotBuiltFromAConfigurationKeepsItsId() { + assertEquals("ONE_SHOT_ROUTE", RouteIds.configId("ONE_SHOT_ROUTE")); + } +} diff --git a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RouterTestFixtures.java b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RouterTestFixtures.java index ff1ca1b8d8..af8e0a9646 100644 --- a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RouterTestFixtures.java +++ b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RouterTestFixtures.java @@ -31,7 +31,7 @@ * place so that the tests of route construction and of the recorded status cannot come to disagree * about it. */ -final class RouterTestFixtures { +public final class RouterTestFixtures { static final String TENANT = "acme"; @@ -70,7 +70,7 @@ static ExportConfiguration recurrentExport(String itemId, String destination) { * The route builders ask the profile service for the profile property types while they build. * Nothing in these tests depends on what it answers. */ - static ProfileService noOpProfileService() { + public static ProfileService noOpProfileService() { return (ProfileService) Proxy.newProxyInstance( ProfileService.class.getClassLoader(), new Class[]{ProfileService.class}, diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java b/itests/src/test/java/org/apache/unomi/itests/AllITs.java index 76c1ad0542..5e07fde443 100644 --- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java @@ -94,6 +94,7 @@ OtherCommandsIT.class, LegacyQueryBuilderMappingIT.class, TenantIT.class, + BackgroundTenantTasksIT.class, SchedulerIT.class, EventsCollectorIT.class, RolloverIT.class, diff --git a/itests/src/test/java/org/apache/unomi/itests/BackgroundTenantTasksIT.java b/itests/src/test/java/org/apache/unomi/itests/BackgroundTenantTasksIT.java new file mode 100644 index 0000000000..ccf0a8728b --- /dev/null +++ b/itests/src/test/java/org/apache/unomi/itests/BackgroundTenantTasksIT.java @@ -0,0 +1,225 @@ +/* + * 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.unomi.itests; + +import org.apache.unomi.api.Event; +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.rules.RuleStatistics; +import org.apache.unomi.api.segments.Segment; +import org.apache.unomi.api.services.EventService; +import org.apache.unomi.api.tenants.Tenant; +import org.apache.unomi.router.api.ImportConfiguration; +import org.apache.unomi.router.api.RouterConstants; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerSuite; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collections; +import java.util.Date; +import java.util.Objects; + +/** + * UNOMI-1000: background work must reach every tenant, not only the system tenant. The test tenant the + * suite runs in is a real tenant, so these tests fail when a task only sees {@code tenantId=system}. + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerSuite.class) +public class BackgroundTenantTasksIT extends BaseIT { + + private static final Logger LOGGER = LoggerFactory.getLogger(BackgroundTenantTasksIT.class); + + private static final String RULE_ID = "unomi1000-statistics-rule"; + private static final String EVENT_TYPE = "unomi1000StatisticsEvent"; + private static final String SCOPE = "test-scope"; + private static final String SEGMENT_ID = "unomi1000-system-inactive-segment"; + private static final String IMPORT_CONFIG_ID = "unomi1000-shared-import"; + private static final String OTHER_TENANT_ID = "unomi1000-router-tenant"; + + /** + * The statistics of a tenant's rule are only persisted when the periodic statistics refresh runs + * under that tenant. + */ + @Test + public void ruleStatisticsOfATenantRuleArePersisted() throws InterruptedException { + String profileId = "unomi1000-statistics-profile"; + Profile profile = new Profile(profileId); + profileService.save(profile); + + Condition condition = new Condition(definitionsService.getConditionType("eventTypeCondition")); + condition.setParameter("eventTypeId", EVENT_TYPE); + Action action = new Action(definitionsService.getActionType("setPropertyAction")); + action.setParameter("propertyName", "unomi1000Statistics"); + action.setParameter("propertyValue", "fired"); + Rule rule = new Rule(new Metadata(SCOPE, RULE_ID, RULE_ID, "Counts its executions for UNOMI-1000")); + rule.setCondition(condition); + rule.setActions(Collections.singletonList(action)); + try { + createAndWaitForRule(rule); + rulesService.refreshRules(); + + // Fire the rule; its execution is first counted in memory. A rule only applies to events of its scope. + keepTrying("The rule did not fire", + () -> { + Event event = new Event(EVENT_TYPE, null, profile, SCOPE, null, profile, new Date()); + event.setPersistent(false); + Assert.assertNotEquals(EventService.ERROR, eventService.send(event)); + return rulesService.getRuleStatistics(RULE_ID); + }, + counted -> counted != null && counted.getLocalExecutionCount() + counted.getExecutionCount() > 0, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // The rule statistics refresh runs every 10 seconds by default. Wait until it has stored the count + // and nothing is left to flush, so that no later refresh stores statistics for the removed rule. + RuleStatistics statistics = keepTrying("Statistics of a tenant rule were not persisted for the tenant", + () -> persistenceService.load(RULE_ID, RuleStatistics.class), + stored -> stored != null && stored.getExecutionCount() > 0 + && rulesService.getRuleStatistics(RULE_ID).getLocalExecutionCount() == 0, + 1000, 60); + Assert.assertEquals("Statistics must be stored in the tenant owning the rule", TEST_TENANT_ID, statistics.getTenantId()); + } finally { + rulesService.removeRule(RULE_ID); + persistenceService.remove(RULE_ID, RuleStatistics.class); + profileService.delete(profileId, false); + waitForNullValue("Rule still present after deletion", () -> rulesService.getRule(RULE_ID), DEFAULT_TRYING_TIMEOUT, + DEFAULT_TRYING_TRIES); + } + } + + /** + * A date-relative segment owned by the system tenant applies to the profiles of every tenant, so the + * recalculation of a tenant has to re-evaluate it for that tenant's profiles. + */ + @Test + public void systemDateRelativeSegmentIsRecalculatedForTenantProfiles() throws InterruptedException { + String profileId = "unomi1000-inactive-profile"; + Profile profile = new Profile(profileId); + LocalDate twoMonthsAgo = LocalDate.now().minusDays(60); + profile.setProperty("lastVisit", Date.from(twoMonthsAgo.atStartOfDay(ZoneId.systemDefault()).toInstant())); + profileService.save(profile); + persistenceService.refreshIndex(Profile.class); + + Condition inactiveForAMonth = new Condition(definitionsService.getConditionType("profilePropertyCondition")); + inactiveForAMonth.setParameter("propertyName", "properties.lastVisit"); + inactiveForAMonth.setParameter("comparisonOperator", "lessThanOrEqualTo"); + inactiveForAMonth.setParameter("propertyValueDateExpr", "now-30d"); + Segment segment = new Segment(new Metadata(SEGMENT_ID)); + segment.setCondition(inactiveForAMonth); + try { + // Defining the segment under the system tenant only evaluates the system tenant's own profiles. + executionContextManager.executeAsSystem(() -> segmentService.setSegmentDefinition(segment)); + keepTrying("The system segment is not visible from the tenant", () -> segmentService.getSegmentDefinition(SEGMENT_ID), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + Assert.assertFalse("The profile was saved directly, it cannot be in the segment yet", + profileService.load(profileId).getSegments().contains(SEGMENT_ID)); + + // What the daily task runs under each tenant; events are disabled to keep the test free of races. + segmentService.recalculatePastEventConditions(false); + persistenceService.refreshIndex(Profile.class); + + keepTrying("The tenant profile did not enter the system tenant's date-relative segment", + () -> profileService.load(profileId), + updated -> updated != null && updated.getSegments().contains(SEGMENT_ID), + 1000, 20); + } finally { + executionContextManager.executeAsSystem(() -> { + segmentService.removeSegmentDefinition(SEGMENT_ID, false); + }); + profileService.delete(profileId, false); + waitForNullValue("Profile still present after deletion", () -> profileService.load(profileId), DEFAULT_TRYING_TIMEOUT, + DEFAULT_TRYING_TRIES); + } + } + + /** + * Configuration ids are only unique within a tenant: two tenants using the same id each get their + * own route, and removing one tenant's configuration leaves the other tenant's route running. + */ + @Test + public void sameImportConfigurationIdInTwoTenantsGetsTwoRoutes() throws InterruptedException { + Tenant otherTenant = tenantService.createTenant(OTHER_TENANT_ID, Collections.emptyMap()); + String otherTenantId = otherTenant.getItemId(); + try { + importConfigurationService.save(recurrentImport(TEST_TENANT_ID), true); + executionContextManager.executeAsTenant(otherTenantId, () -> { + importConfigurationService.save(recurrentImport(otherTenantId), true); + }); + + keepTrying("No route was started for the configuration of the test tenant", + () -> camelRouteExists(TEST_TENANT_ID, IMPORT_CONFIG_ID), exists -> exists, 1000, 30); + keepTrying("No route was started for the configuration of the other tenant, which uses the same id", + () -> camelRouteExists(otherTenantId, IMPORT_CONFIG_ID), exists -> exists, 1000, 30); + + executionContextManager.executeAsTenant(otherTenantId, () -> importConfigurationService.delete(IMPORT_CONFIG_ID)); + + keepTrying("The route of the deleted configuration is still there", + () -> camelRouteExists(otherTenantId, IMPORT_CONFIG_ID), exists -> !exists, 1000, 30); + Assert.assertTrue("Deleting one tenant's configuration must not remove the route of another tenant's configuration", + camelRouteExists(TEST_TENANT_ID, IMPORT_CONFIG_ID)); + } finally { + // Clean up without hiding what the test itself reported. + try { + importConfigurationService.delete(IMPORT_CONFIG_ID); + keepTrying("The route of the test tenant's configuration is still there after its deletion", + () -> camelRouteExists(TEST_TENANT_ID, IMPORT_CONFIG_ID), exists -> !exists, 1000, 30); + } catch (Exception | AssertionError cleanupFailure) { + LOGGER.warn("Could not remove the import configuration of the test tenant", cleanupFailure); + } + try { + executionContextManager.executeAsTenant(otherTenantId, () -> { + if (importConfigurationService.load(IMPORT_CONFIG_ID) != null) { + importConfigurationService.delete(IMPORT_CONFIG_ID); + } + }); + tenantService.deleteTenant(otherTenantId); + } catch (Exception cleanupFailure) { + LOGGER.warn("Could not remove tenant {}", otherTenantId, cleanupFailure); + } + } + } + + /** A recurrent import polling the tenant's own directory for a file that never comes. */ + private ImportConfiguration recurrentImport(String tenantId) { + File tenantImportDirectory = new File("data/tmp/recurrent_import/" + tenantId); + tenantImportDirectory.mkdirs(); + + ImportConfiguration configuration = new ImportConfiguration(); + configuration.setItemId(IMPORT_CONFIG_ID); + configuration.setConfigType(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT); + configuration.setMergingProperty("email"); + configuration.setOverwriteExistingProfiles(true); + configuration.setColumnSeparator(";"); + configuration.setHasHeader(true); + configuration.setHasDeleteColumn(false); + configuration.getProperties().put("mapping", Collections.singletonMap("email", 0)); + configuration.getProperties().put("source", + "file://" + tenantImportDirectory.getAbsolutePath() + "?fileName=unomi1000-never-delivered.csv&move=.done"); + configuration.setActive(true); + return configuration; + } +} diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java index 05d784176f..8484ee4d0e 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java @@ -63,6 +63,7 @@ import org.apache.unomi.rest.authentication.RestAuthenticationConfig; import org.apache.unomi.router.api.ExportConfiguration; import org.apache.unomi.router.api.IRouterCamelContext; +import org.apache.unomi.router.api.RouteIds; import org.apache.unomi.router.api.ImportConfiguration; import org.apache.unomi.router.api.services.ImportExportConfigurationService; import org.apache.unomi.schema.api.SchemaService; @@ -684,6 +685,11 @@ public Option[] config() { // crash recovery repeatedly (mis)reclaims them, logging "Lock verification failed... after // CAS" every checker tick. Widen it for ITs so heartbeats/checker never starve. editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.scheduler.thread.poolSize", "10"), + // The scheduled purge runs seconds after startup for every tenant and would delete the + // years-old profiles, sessions and events the migration ITs restore from snapshots before + // they are checked. Purge ITs call the purge methods directly, so switch the schedule off. + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.profile.purge.inactiveTime", "-1"), + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.monthly.index.purge.existTime", "-1"), // The router's base directories have to be set here rather than in // etc/org.apache.unomi.router.cfg: Karaf's configuration plugin overrides every @@ -1795,10 +1801,12 @@ protected CamelContext getCamelContext() { * Checks if a Camel route with the given route ID exists. * Uses official Camel API: CamelContext.getRoute(String routeId) * - * @param routeId The route ID to check (typically the import configuration itemId) + * @param tenantId The tenant owning the import/export configuration + * @param configId The import/export configuration ID; the route ID is built from both * @return true if the route exists, false otherwise */ - protected boolean camelRouteExists(String routeId) { + protected boolean camelRouteExists(String tenantId, String configId) { + String routeId = RouteIds.of(tenantId, configId); CamelContext camelContext = getCamelContext(); if (camelContext == null) { return false; @@ -1812,10 +1820,12 @@ protected boolean camelRouteExists(String routeId) { * Uses Camel 2.23.1 API directly. * Returns ServiceStatus enum: Started, Stopped, Suspended, etc. * - * @param routeId The route ID to get status for + * @param tenantId The tenant owning the import/export configuration + * @param configId The import/export configuration ID; the route ID is built from both * @return The route status, or null if route doesn't exist or status unavailable */ - protected ServiceStatus getCamelRouteStatus(String routeId) { + protected ServiceStatus getCamelRouteStatus(String tenantId, String configId) { + String routeId = RouteIds.of(tenantId, configId); CamelContext camelContext = getCamelContext(); if (camelContext == null) { return null; @@ -1839,11 +1849,12 @@ protected ServiceStatus getCamelRouteStatus(String routeId) { * Checks if a Camel route is started (running). * Uses official Camel API to check route status. * - * @param routeId The route ID to check + * @param tenantId The tenant owning the import/export configuration + * @param configId The import/export configuration ID; the route ID is built from both * @return true if the route exists and is started, false otherwise */ - protected boolean isCamelRouteStarted(String routeId) { - ServiceStatus status = getCamelRouteStatus(routeId); + protected boolean isCamelRouteStarted(String tenantId, String configId) { + ServiceStatus status = getCamelRouteStatus(tenantId, configId); return status != null && status.isStarted(); } @@ -1851,10 +1862,12 @@ protected boolean isCamelRouteStarted(String routeId) { * Gets detailed information about a Camel route including status, endpoints, and configuration. * Uses Camel 2.23.1 API to inspect route definitions and endpoints. * - * @param routeId The route ID to get information for + * @param tenantId The tenant owning the import/export configuration + * @param configId The import/export configuration ID; the route ID is built from both * @return A string describing the route status, endpoints, and configuration, or error message if route doesn't exist */ - protected String getCamelRouteInfo(String routeId) { + protected String getCamelRouteInfo(String tenantId, String configId) { + String routeId = RouteIds.of(tenantId, configId); CamelContext camelContext = getCamelContext(); if (camelContext == null) { return "CamelContext not available"; @@ -1869,7 +1882,7 @@ protected String getCamelRouteInfo(String routeId) { info.append("Route '").append(routeId).append("': "); // Get route status using official API - ServiceStatus status = getCamelRouteStatus(routeId); + ServiceStatus status = getCamelRouteStatus(tenantId, configId); if (status != null) { info.append("status=").append(status); } else { @@ -1929,22 +1942,24 @@ protected String getCamelRouteInfo(String routeId) { * Waits for a Camel route to be created and started. * This is useful for tests that need to verify the route was created by the timer. * - * @param routeId The route ID to wait for + * @param tenantId The tenant owning the import/export configuration + * @param configId The import/export configuration ID; the route ID is built from both * @param timeoutMs Timeout in milliseconds between retries * @param maxRetries Maximum number of retries * @return true if the route exists and is started, false if timeout * @throws InterruptedException if interrupted */ - protected boolean waitForCamelRouteStarted(String routeId, int timeoutMs, int maxRetries) throws InterruptedException { + protected boolean waitForCamelRouteStarted(String tenantId, String configId, int timeoutMs, int maxRetries) throws InterruptedException { + String routeId = RouteIds.of(tenantId, configId); for (int i = 0; i < maxRetries; i++) { - if (isCamelRouteStarted(routeId)) { - String routeInfo = getCamelRouteInfo(routeId); + if (isCamelRouteStarted(tenantId, configId)) { + String routeInfo = getCamelRouteInfo(tenantId, configId); LOGGER.debug("Camel route '{}' is started. {}", routeId, routeInfo); return true; } Thread.sleep(timeoutMs); } - String routeInfo = getCamelRouteInfo(routeId); + String routeInfo = getCamelRouteInfo(tenantId, configId); LOGGER.warn("Camel route '{}' did not start within timeout. {}", routeId, routeInfo); return false; } @@ -1966,10 +1981,8 @@ protected java.util.Map getAllCamelRoutesWithStatus() { // In Camel 2.23.1, Route has getId() method String routeId = route.getId(); if (routeId != null) { - ServiceStatus status = getCamelRouteStatus(routeId); - if (status != null) { - routes.put(routeId, status); - } + // every route that exists in the context is assumed to be started + routes.put(routeId, ServiceStatus.Started); } } } catch (Exception e) { diff --git a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java index 38d88b17a6..f7a8d8fa0a 100644 --- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java @@ -91,6 +91,7 @@ OtherCommandsIT.class, LegacyQueryBuilderMappingIT.class, TenantIT.class, + BackgroundTenantTasksIT.class, SchedulerIT.class, EventsCollectorIT.class, RolloverIT.class, 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 9e31c291a0..b7e3bed031 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ProfileImportActorsIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ProfileImportActorsIT.java @@ -97,10 +97,10 @@ public void testImportActors() throws InterruptedException { // 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), + () -> isCamelRouteStarted(TEST_TENANT_ID, itemId), started -> started, 1000, 15); - System.out.println("==== Camel Route Status: " + getCamelRouteInfo(itemId) + " ===="); + System.out.println("==== Camel Route Status: " + getCamelRouteInfo(TEST_TENANT_ID, 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. diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileImportSurfersIT.java b/itests/src/test/java/org/apache/unomi/itests/ProfileImportSurfersIT.java index 6495cbe95f..f52c39e830 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ProfileImportSurfersIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ProfileImportSurfersIT.java @@ -98,9 +98,9 @@ public void testImportSurfers() throws InterruptedException { // 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 - boolean routeStarted = waitForCamelRouteStarted(itemId1, 1000, 10); + boolean routeStarted = waitForCamelRouteStarted(TEST_TENANT_ID, itemId1, 1000, 10); if (routeStarted) { - String routeInfo = getCamelRouteInfo(itemId1); + String routeInfo = getCamelRouteInfo(TEST_TENANT_ID, itemId1); LOGGER.info("Camel Route Status: {}", routeInfo); } else { LOGGER.warn("Camel Route '{}' was not started within timeout", itemId1); @@ -166,9 +166,9 @@ public void testImportSurfers() throws InterruptedException { LOGGER.info("ProfileImportSurfersOverwriteIT setup successfully."); // Wait for Camel route to be created and started - boolean routeStarted2 = waitForCamelRouteStarted(itemId2, 1000, 10); + boolean routeStarted2 = waitForCamelRouteStarted(TEST_TENANT_ID, itemId2, 1000, 10); if (routeStarted2) { - String routeInfo = getCamelRouteInfo(itemId2); + String routeInfo = getCamelRouteInfo(TEST_TENANT_ID, itemId2); LOGGER.info("Camel Route Status: {}", routeInfo); } else { LOGGER.warn("Camel Route '{}' was not started within timeout", itemId2); @@ -229,9 +229,9 @@ public void testImportSurfers() throws InterruptedException { LOGGER.info("ProfileImportSurfersDeleteIT setup successfully."); // Wait for Camel route to be created and started - boolean routeStarted3 = waitForCamelRouteStarted(itemId3, 1000, 10); + boolean routeStarted3 = waitForCamelRouteStarted(TEST_TENANT_ID, itemId3, 1000, 10); if (routeStarted3) { - String routeInfo = getCamelRouteInfo(itemId3); + String routeInfo = getCamelRouteInfo(TEST_TENANT_ID, itemId3); LOGGER.info("Camel Route Status: {}", routeInfo); } else { LOGGER.warn("Camel Route '{}' was not started within timeout", itemId3); diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc index e93cd64275..5444d8cf6a 100644 --- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc +++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc @@ -292,6 +292,28 @@ files under `{baseDir}/{tenantId}` and update its `source` or `destination` acco base-directory properties at the directories already in use. The mark clears on its own once the endpoint is accepted again. The "Profile import and export" chapter describes the rule in full. +==== Background tasks and recurrent routes run for every tenant + +The scheduled purge, the daily recalculation of date-relative segments and scoring plans, the rule statistics +refresh and the start of recurrent import and export routes apply to every tenant, the tenant holding the +migrated data included. + +- The first purge after the upgrade applies the configured retention (`org.apache.unomi.profile.purge.*`, + `org.apache.unomi.session.purge.existTime`, `org.apache.unomi.event.purge.existTime`, + `org.apache.unomi.monthly.index.purge.existTime`) to the migrated tenant in one pass, shortly after startup. + Review these values before starting 4.0 if the 3.0 deployment ran with the purge disabled or with a longer + retention. +- A purge or recalculation that fails for one tenant is still run for the others; the task is then reported as + failed, naming the tenants concerned, and the scheduler retries it. +- The Camel route of a recurrent import or export configuration is identified by `{tenantId}:{configId}` + instead of the configuration identifier alone, since configuration identifiers are only unique within a + tenant. Code that looks routes up in the router's Camel context must build the identifier with + `org.apache.unomi.router.api.RouteIds`. The `killExistingRoute`, `updateProfileImportReaderRoute` and + `updateProfileExportReaderRoute` methods of `IRouterCamelContext` take the tenant identifier as their first + parameter. A tenant identifier containing a colon gets no recurrent route: the identifier given to + `org.apache.unomi.migration.tenant.id` must follow the tenant identifier rules (letters, digits, hyphens and + underscores). + ==== Migration Steps 1. **Understand Multi-Tenancy Impact** diff --git a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java index 8cf4441573..3e2e88addc 100644 --- a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java +++ b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java @@ -23,6 +23,7 @@ import org.apache.unomi.api.services.cache.CacheableTypeConfig; import org.apache.unomi.api.services.cache.MultiTypeCacheService; import org.apache.unomi.api.tasks.ScheduledTask; +import org.apache.unomi.api.tasks.TaskExecutor; import org.apache.unomi.api.tenants.AuditService; import org.apache.unomi.api.tenants.Tenant; import org.apache.unomi.api.tenants.TenantService; @@ -551,6 +552,52 @@ protected Set getTenants() { return tenants; } + /** + * Runs an action once under every tenant, system tenant included. A tenant whose run fails is logged + * and does not stop the others; once the thread is interrupted the remaining tenants are skipped. + * + * @param taskName short description of the work, used in log messages + * @param action the work to run under each tenant's execution context + * @return identifiers of the tenants for which the action failed or was skipped, empty when all succeeded + */ + protected List executeForEachTenant(String taskName, Runnable action) { + List failedTenants = new ArrayList<>(); + for (String tenantId : getTenants()) { + if (Thread.currentThread().isInterrupted()) { + logger.warn("Interrupted during {}, skipping tenant {}", taskName, tenantId); + failedTenants.add(tenantId); + continue; + } + try { + contextManager.executeAsTenant(tenantId, action); + } catch (Exception e) { + logger.error("Error during {} for tenant {}", taskName, tenantId, e); + failedTenants.add(tenantId); + } + } + return failedTenants; + } + + /** + * Runs a scheduled task's action once under every tenant and reports the outcome to the scheduler: + * the task completes when every tenant was processed, and fails naming the tenants that were not. + * A failed task is retried by the scheduler for every tenant, so the action must be safe to repeat. + * + * @param taskName short description of the work, used in log messages and in the failure reported + * @param action the work to run under each tenant's execution context + * @param callback the scheduler callback of the running task + * @return {@code true} when the task completed, {@code false} when it was reported as failed + */ + protected boolean executeForEachTenant(String taskName, Runnable action, TaskExecutor.TaskStatusCallback callback) { + List failedTenants = executeForEachTenant(taskName, action); + if (failedTenants.isEmpty()) { + callback.complete(); + return true; + } + callback.fail("The " + taskName + " failed for tenants " + failedTenants); + return false; + } + /** * Loads predefined JSON items from bundle resources for all configured types. * diff --git a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java index ad962c9091..4729badf33 100644 --- a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java +++ b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java @@ -517,4 +517,84 @@ public void testSaveItemAuditsCreateWhenExistingItemHasNoCreateAudit() { verify(auditService).auditCreate(same(fromBundle), eq("system-bundle")); verify(auditService, never()).auditUpdate(any(), anyString()); } + + @Test + public void testExecuteForEachTenantRunsUnderEveryTenantAndSystem() { + List tenantsRun = new ArrayList<>(); + recordTenantsRun(tenantsRun, null); + + List failed = testCachingService.executeForEachTenant("test task", () -> { }); + + assertTrue("no tenant failed", failed.isEmpty()); + assertEquals(new HashSet<>(Arrays.asList(TEST_TENANT, SYSTEM_TENANT)), new HashSet<>(tenantsRun)); + assertEquals("each tenant runs once", 2, tenantsRun.size()); + } + + @Test + public void testExecuteForEachTenantReportsTheFailingTenantAndRunsTheOthers() { + List tenantsRun = new ArrayList<>(); + recordTenantsRun(tenantsRun, TEST_TENANT); + + List failed = testCachingService.executeForEachTenant("test task", () -> { }); + + assertEquals(Collections.singletonList(TEST_TENANT), failed); + assertEquals(Collections.singletonList(SYSTEM_TENANT), tenantsRun); + } + + @Test + public void testExecuteForEachTenantReportsTenantsSkippedByAnInterruption() { + List tenantsRun = new ArrayList<>(); + recordTenantsRun(tenantsRun, null); + + List failed; + Thread.currentThread().interrupt(); + try { + failed = testCachingService.executeForEachTenant("test task", () -> { }); + } finally { + Thread.interrupted(); + } + + assertEquals(new HashSet<>(Arrays.asList(TEST_TENANT, SYSTEM_TENANT)), new HashSet<>(failed)); + assertTrue("no tenant runs once the thread is interrupted", tenantsRun.isEmpty()); + } + + @Test + public void testExecuteForEachTenantCompletesTheTaskWhenEveryTenantRan() { + recordTenantsRun(new ArrayList<>(), null); + org.apache.unomi.api.tasks.TaskExecutor.TaskStatusCallback callback = + mock(org.apache.unomi.api.tasks.TaskExecutor.TaskStatusCallback.class); + + assertTrue(testCachingService.executeForEachTenant("test task", () -> { }, callback)); + + verify(callback).complete(); + verify(callback, never()).fail(anyString()); + } + + @Test + public void testExecuteForEachTenantFailsTheTaskNamingTheTenantsThatFailed() { + recordTenantsRun(new ArrayList<>(), TEST_TENANT); + org.apache.unomi.api.tasks.TaskExecutor.TaskStatusCallback callback = + mock(org.apache.unomi.api.tasks.TaskExecutor.TaskStatusCallback.class); + + assertFalse(testCachingService.executeForEachTenant("test task", () -> { }, callback)); + + ArgumentCaptor failure = ArgumentCaptor.forClass(String.class); + verify(callback).fail(failure.capture()); + verify(callback, never()).complete(); + assertTrue(failure.getValue(), failure.getValue().contains(TEST_TENANT)); + assertFalse(failure.getValue(), failure.getValue().contains(SYSTEM_TENANT)); + } + + /** Records the tenants the action runs under; switching to {@code failingTenant} throws instead. */ + private void recordTenantsRun(List tenantsRun, String failingTenant) { + doAnswer(invocation -> { + String tenantId = invocation.getArgument(0); + if (tenantId.equals(failingTenant)) { + throw new IllegalStateException("simulated failure for " + tenantId); + } + tenantsRun.add(tenantId); + ((Runnable) invocation.getArgument(1)).run(); + return null; + }).when(contextManager).executeAsTenant(anyString(), any(Runnable.class)); + } } diff --git a/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java index 8bb24622a6..0feb57c658 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java @@ -365,17 +365,22 @@ public void execute(ScheduledTask task, TaskExecutor.TaskStatusCallback callback long purgeStartTime = System.currentTimeMillis(); LOGGER.info("Purge: triggered"); - // Profile purge - purgeProfiles(purgeProfileInactiveTime, purgeProfileExistTime); - if (purgeSessionExistTime > 0) { - purgeSessionItems(purgeSessionExistTime); - } - if (purgeEventExistTime > 0) { - purgeEventItems(purgeEventExistTime); + // Run under each tenant so persistence sees that tenant's data + // (system context alone only matches tenantId=system). + boolean purged = executeForEachTenant("purge of profiles, sessions and events", () -> { + purgeProfiles(purgeProfileInactiveTime, purgeProfileExistTime); + if (purgeSessionExistTime > 0) { + purgeSessionItems(purgeSessionExistTime); + } + if (purgeEventExistTime > 0) { + purgeEventItems(purgeEventExistTime); + } + }, callback); + if (purged) { + LOGGER.info("Purge: executed in {} ms", System.currentTimeMillis() - purgeStartTime); + } else { + LOGGER.warn("Purge: failed for some tenants after {} ms", System.currentTimeMillis() - purgeStartTime); } - LOGGER.info("Purge: executed in {} ms", System.currentTimeMillis() - purgeStartTime); - - callback.complete(); } catch (Throwable t) { // During shutdown, services may be unavailable - only log if not shutting down LOGGER.error("Error while purging profiles, sessions, or events", t); diff --git a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java index 8e9264c325..e5dd4f972d 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java @@ -195,7 +195,10 @@ public void postConstruct() { .nonPersistent() .withPeriod(rulesStatisticsRefreshInterval, TimeUnit.MILLISECONDS) .withFixedDelay() - .withSimpleExecutor(() -> contextManager.executeAsSystem(() -> syncRuleStatistics())) + .withSimpleExecutor(() -> contextManager.executeAsSystem(() -> { + // Flush in-memory stats for every tenant; system context alone only persists system. + executeForEachTenant("rule statistics sync", () -> syncRuleStatistics()); + })) .schedule(); LOGGER.info("Rule service initialized."); @@ -764,16 +767,20 @@ public void removeRule(String ruleId) { } private void syncRuleStatistics() { + String currentTenant = contextManager.getCurrentContext().getTenantId(); + + Map tenantStats = getRuleStatisticsForTenant(currentTenant); + if (tenantStats.isEmpty()) { + // nothing to flush or to refresh for this tenant: spare the query, the sync runs for every tenant + return; + } + List allPersistedRuleStatisticsList = persistenceService.getAllItems(RuleStatistics.class); Map allPersistedRuleStatistics = new HashMap<>(); for (RuleStatistics ruleStatistics : allPersistedRuleStatisticsList) { allPersistedRuleStatistics.put(ruleStatistics.getItemId(), ruleStatistics); } - String currentTenant = contextManager.getCurrentContext().getTenantId(); - - Map tenantStats = getRuleStatisticsForTenant(currentTenant); - // Sync tenant statistics for (RuleStatistics ruleStatistics : tenantStats.values()) { boolean mustPersist = false; @@ -821,16 +828,9 @@ private void syncRuleStatistics() { persistenceService.save(ruleStatistics, null, true); } } - - // Also sync system tenant statistics if needed - if (!SYSTEM_TENANT.equals(currentTenant)) { - Map systemStats = getRuleStatisticsForTenant(SYSTEM_TENANT); - for (RuleStatistics ruleStatistics : systemStats.values()) { - if (!tenantStats.containsKey(ruleStatistics.getItemId())) { - tenantStats.put(ruleStatistics.getItemId(), ruleStatistics); - } - } - } + // System-tenant statistics are synced by the system tenant's own run and read through the + // fallback in getRuleStatistics()/getAllRuleStatistics(). Copying them into this tenant's map + // would have this tenant's next sync save them under its own tenantId and drop their counts. } public void bind(ServiceReference serviceReference) { diff --git a/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java index 1ebbc0453c..ed1b71911c 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java @@ -1273,25 +1273,21 @@ public void recalculatePastEventConditions(boolean sendProfileUpdateEvents) { int pastEventSegmentsAndScoringsSize = segmentOrScoringIdsToReevaluate.size(); LOGGER.info("Found {} segments or scoring plans containing pastEventCondition conditions", pastEventSegmentsAndScoringsSize); - // get Segments and Scoring that contains relative date expressions - String currentTenant = contextManager.getCurrentContext().getTenantId(); - Map tenantSegments = cacheService.getTenantCache(currentTenant, Segment.class); - Map tenantScoring = cacheService.getTenantCache(currentTenant, Scoring.class); - - if (tenantSegments != null) { - segmentOrScoringIdsToReevaluate.addAll(tenantSegments.values().stream() - .filter(segment -> segment.getCondition() != null && segment.getCondition().toString().contains("propertyValueDateExpr")) - .map(Item::getItemId) - .collect(Collectors.toList())); - } - - if (tenantScoring != null) { - segmentOrScoringIdsToReevaluate.addAll(tenantScoring.values().stream() - .filter(scoring -> scoring.getElements() != null && !scoring.getElements().isEmpty() && scoring.getElements().stream() - .anyMatch(scoringElement -> scoringElement != null && scoringElement.getCondition() != null && scoringElement.getCondition().toString().contains("propertyValueDateExpr"))) - .map(Item::getItemId) - .collect(Collectors.toList())); - } + // get Segments and Scoring that contains relative date expressions, those inherited from the system + // tenant included: they apply to this tenant's profiles too (see getSegmentsAndScoresForProfile) + Collection tenantSegments = getAllItems(Segment.class, true); + Collection tenantScoring = getAllItems(Scoring.class, true); + + segmentOrScoringIdsToReevaluate.addAll(tenantSegments.stream() + .filter(segment -> segment.getCondition() != null && segment.getCondition().toString().contains("propertyValueDateExpr")) + .map(Item::getItemId) + .collect(Collectors.toList())); + + segmentOrScoringIdsToReevaluate.addAll(tenantScoring.stream() + .filter(scoring -> scoring.getElements() != null && !scoring.getElements().isEmpty() && scoring.getElements().stream() + .anyMatch(scoringElement -> scoringElement != null && scoringElement.getCondition() != null && scoringElement.getCondition().toString().contains("propertyValueDateExpr"))) + .map(Item::getItemId) + .collect(Collectors.toList())); LOGGER.info("Found {} segments or scoring plans containing date relative expressions", segmentOrScoringIdsToReevaluate.size() - pastEventSegmentsAndScoringsSize); // reevaluate segments and scoring. @@ -1554,9 +1550,15 @@ public void execute(ScheduledTask task, TaskExecutor.TaskStatusCallback callback try { long currentTimeMillis = System.currentTimeMillis(); LOGGER.info("Running scheduled task to recalculate segments and scoring that contains date relative conditions..."); - recalculatePastEventConditions(); - LOGGER.info("...Finished recalculate segments and scoring that contains date relative conditions in {}ms. ", System.currentTimeMillis() - currentTimeMillis); - callback.complete(); + // Run under each tenant so rules/segments/profiles of real tenants are seen + // (system context alone only matches tenantId=system). + boolean recalculated = executeForEachTenant("recalculation of date-relative segments and scoring", + () -> recalculatePastEventConditions(), callback); + if (recalculated) { + LOGGER.info("...Finished recalculate segments and scoring that contains date relative conditions in {}ms. ", System.currentTimeMillis() - currentTimeMillis); + } else { + LOGGER.warn("...Failed for some tenants to recalculate segments and scoring that contains date relative conditions after {}ms. ", System.currentTimeMillis() - currentTimeMillis); + } } catch (Throwable t) { LOGGER.error("Error while updating profiles for segments and scoring that contains date relative conditions", t); callback.fail(t.getMessage()); diff --git a/services/src/test/java/org/apache/unomi/services/impl/BackgroundTenantTasksTest.java b/services/src/test/java/org/apache/unomi/services/impl/BackgroundTenantTasksTest.java new file mode 100644 index 0000000000..4870e79d11 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/BackgroundTenantTasksTest.java @@ -0,0 +1,607 @@ +/* + * 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.unomi.services.impl; + +import org.apache.unomi.api.ExecutionContext; +import org.apache.unomi.api.Event; +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.Session; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.rules.RuleStatistics; +import org.apache.unomi.api.segments.Segment; +import org.apache.unomi.api.services.EventService; +import org.apache.unomi.api.services.RulesService; +import org.apache.unomi.api.services.SegmentService; +import org.apache.unomi.api.tasks.ScheduledTask; +import org.apache.unomi.api.tasks.TaskExecutor; +import org.apache.unomi.api.tenants.TenantService; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.services.TestHelper; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.apache.unomi.services.common.security.KarafSecurityService; +import org.apache.unomi.services.impl.cache.MultiTypeCacheServiceImpl; +import org.apache.unomi.services.impl.definitions.DefinitionsServiceImpl; +import org.apache.unomi.services.impl.profiles.ProfileServiceImpl; +import org.apache.unomi.services.impl.rules.RulesServiceImpl; +import org.apache.unomi.services.impl.scheduler.SchedulerServiceImpl; +import org.apache.unomi.services.impl.segments.SegmentServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.osgi.framework.Bundle; + +import java.util.Collections; +import java.util.Date; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * UNOMI-1000: background jobs must visit every tenant (not only {@code system}). + *

+ * These tests fail when purge / segment recalculation / rule-statistics refresh run + * solely under {@code executeAsSystem}, where persistence only ever sees + * {@code tenantId=system}. They also cover what running per tenant adds: + * one tenant's failure is reported without stopping the others, and system-owned + * statistics stay in the system tenant. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class BackgroundTenantTasksTest { + + private static final String TENANT_A = "tenant1"; + private static final String TENANT_B = "tenant2"; + + private TestTenantService tenantService; + private PersistenceService persistenceService; + private DefinitionsServiceImpl definitionsService; + private TestBundleContext bundleContext; + private ExecutionContextManagerImpl executionContextManager; + private MultiTypeCacheServiceImpl multiTypeCacheService; + private KarafSecurityService securityService; + private SchedulerServiceImpl schedulerService; + + @BeforeEach + public void setUp() { + bundleContext = new TestBundleContext(); + Bundle systemBundle = mock(Bundle.class); + when(systemBundle.getBundleContext()).thenReturn(bundleContext); + when(systemBundle.getBundleId()).thenReturn(0L); + when(systemBundle.getSymbolicName()).thenReturn("org.apache.unomi.predefined"); + bundleContext.addBundle(systemBundle); + + tenantService = new TestTenantService(); + TestHelper.setupCommonTestData(tenantService); + + securityService = TestHelper.createSecurityService(); + executionContextManager = TestHelper.createExecutionContextManager(securityService); + multiTypeCacheService = new MultiTypeCacheServiceImpl(); + + ConditionEvaluatorDispatcher dispatcher = TestConditionEvaluators.createDispatcher(); + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, dispatcher); + schedulerService = TestHelper.createSchedulerService( + "background-tenant-tasks-node", persistenceService, executionContextManager, + bundleContext, null, -1, true, true); + + definitionsService = TestHelper.createDefinitionService( + persistenceService, bundleContext, schedulerService, multiTypeCacheService, + executionContextManager, tenantService); + TestHelper.injectDefinitionsServiceIntoDispatcher(dispatcher, definitionsService); + TestConditionEvaluators.getConditionTypes().forEach((k, v) -> definitionsService.setConditionType(v)); + } + + @AfterEach + public void tearDown() throws Exception { + TestHelper.tearDown(schedulerService, multiTypeCacheService, persistenceService, tenantService, + TENANT_A, TENANT_B, TenantService.SYSTEM_TENANT); + TestHelper.cleanupReferences(tenantService, securityService, executionContextManager, + persistenceService, definitionsService, bundleContext, schedulerService, multiTypeCacheService); + } + + @Test + public void profilePurgeTaskPurgesEveryTenantAndRestoresContext() throws Exception { + AtomicReference purgeExecutor = new AtomicReference<>(); + ExecutionContextManagerImpl spiedContext = spy(executionContextManager); + newProfileService(spiedContext, capturingScheduler("profile-purge", purgeExecutor)); + saveOldProfile(TENANT_A, "a-old"); + saveOldProfile(TENANT_B, "b-old"); + + ExecutionContext before = spiedContext.getCurrentContext(); + String beforeTenant = before != null ? before.getTenantId() : null; + + assertNull(runExecutor(purgeExecutor.get()), "purge task should complete"); + persistenceService.refresh(); + + assertNull(loadProfile(TENANT_A, "a-old"), "purge must remove tenant1's old profile"); + assertNull(loadProfile(TENANT_B, "b-old"), "purge must remove tenant2's old profile"); + + ExecutionContext after = spiedContext.getCurrentContext(); + String afterTenant = after != null ? after.getTenantId() : null; + assertEquals(beforeTenant, afterTenant, "tenant context must be restored after the purge task"); + } + + @Test + public void profilePurgeTaskPurgesSessionsAndEventsOfEveryTenant() throws Exception { + AtomicReference purgeExecutor = new AtomicReference<>(); + ProfileServiceImpl profileService = new ProfileServiceImpl(); + profileService.setPurgeSessionExistTime(30); + profileService.setPurgeEventExistTime(30); + initProfileService(profileService, executionContextManager, capturingScheduler("profile-purge", purgeExecutor)); + for (String tenantId : new String[]{TENANT_A, TENANT_B}) { + executionContextManager.executeAsTenant(tenantId, () -> { + persistenceService.save(session("old-session", new Date(0))); + persistenceService.save(session("recent-session", new Date())); + persistenceService.save(event("old-event", new Date(0))); + persistenceService.save(event("recent-event", new Date())); + }); + } + persistenceService.refresh(); + + assertNull(runExecutor(purgeExecutor.get()), "purge task should complete"); + persistenceService.refresh(); + + for (String tenantId : new String[]{TENANT_A, TENANT_B}) { + executionContextManager.executeAsTenant(tenantId, () -> { + assertNull(persistenceService.load("old-session", Session.class), "old session of " + tenantId + " must be purged"); + assertNotNull(persistenceService.load("recent-session", Session.class), "recent session of " + tenantId + " must be kept"); + assertNull(persistenceService.load("old-event", Event.class), "old event of " + tenantId + " must be purged"); + assertNotNull(persistenceService.load("recent-event", Event.class), "recent event of " + tenantId + " must be kept"); + }); + } + } + + @Test + public void profilePurgeFailureInOneTenantIsReportedAndDoesNotStopTheOthers() throws Exception { + AtomicReference purgeExecutor = new AtomicReference<>(); + ExecutionContextManagerImpl spiedContext = spy(executionContextManager); + newProfileService(spiedContext, capturingScheduler("profile-purge", purgeExecutor)); + failTenant(spiedContext, TENANT_A); + saveOldProfile(TENANT_A, "a-old"); + saveOldProfile(TENANT_B, "b-old"); + + ExecutionContext before = spiedContext.getCurrentContext(); + String beforeTenant = before != null ? before.getTenantId() : null; + + String failure = runExecutor(purgeExecutor.get()); + persistenceService.refresh(); + + assertNotNull(failure, "a tenant failure must fail the task instead of being reported as success"); + assertTrue(failure.contains(TENANT_A), "failure must name the tenant: " + failure); + assertFalse(failure.contains(TENANT_B), "failure must not name a tenant that succeeded: " + failure); + assertNotNull(loadProfile(TENANT_A, "a-old"), "the failing tenant keeps its data"); + assertNull(loadProfile(TENANT_B, "b-old"), "the other tenant must still be purged"); + + ExecutionContext after = spiedContext.getCurrentContext(); + String afterTenant = after != null ? after.getTenantId() : null; + assertEquals(beforeTenant, afterTenant, "tenant context must be restored after a tenant failure"); + } + + @Test + public void profilePurgeInterruptedBeforeItsTenantsIsNotReportedAsDone() throws Exception { + AtomicReference purgeExecutor = new AtomicReference<>(); + newProfileService(executionContextManager, capturingScheduler("profile-purge", purgeExecutor)); + saveOldProfile(TENANT_A, "a-old"); + + String failure; + Thread.currentThread().interrupt(); + try { + failure = runExecutor(purgeExecutor.get()); + } finally { + Thread.interrupted(); + } + + assertNotNull(failure, "tenants skipped by an interruption must not be reported as purged"); + for (String tenantId : new String[]{TENANT_A, TENANT_B, TenantService.SYSTEM_TENANT}) { + assertTrue(failure.contains(tenantId), "every skipped tenant must be named: " + failure); + } + assertNotNull(loadProfile(TENANT_A, "a-old"), "an interrupted purge must not have run"); + } + + @Test + public void segmentDateRecalculationTaskRunsUnderEveryTenant() throws Exception { + Set visitedTenants = ConcurrentHashMap.newKeySet(); + ExecutionContextManagerImpl spiedContext = spy(executionContextManager); + trackTenantVisits(spiedContext, visitedTenants); + AtomicReference segmentExecutor = new AtomicReference<>(); + newSegmentService(spiedContext, capturingScheduler("segment-date-recalculation", segmentExecutor)); + // starting the service already visits every tenant to load its cache + visitedTenants.clear(); + + assertNull(runExecutor(segmentExecutor.get()), "segment recalculation should complete"); + + assertEquals(Set.of(TENANT_A, TENANT_B, TenantService.SYSTEM_TENANT), visitedTenants); + } + + @Test + public void segmentDateRecalculationTaskUpdatesProfilesOfEveryTenant() throws Exception { + AtomicReference segmentExecutor = new AtomicReference<>(); + SegmentServiceImpl segmentService = newSegmentService(executionContextManager, + capturingScheduler("segment-date-recalculation", segmentExecutor)); + saveInactiveProfilesAndTheirSegments(segmentService); + assertFalse(loadProfile(TENANT_A, "inactive-profile").getSegments().contains("inactive-" + TENANT_A), + "the profile must not be in the segment before the task runs"); + + assertNull(runExecutor(segmentExecutor.get()), "segment recalculation should complete"); + persistenceService.refresh(); + + assertEquals(Set.of("inactive-" + TENANT_A), loadProfile(TENANT_A, "inactive-profile").getSegments(), + "tenant1's profile must enter tenant1's date-relative segment only"); + assertEquals(Set.of("inactive-" + TENANT_B), loadProfile(TENANT_B, "inactive-profile").getSegments(), + "tenant2's profile must enter tenant2's date-relative segment only"); + } + + @Test + public void segmentDateRecalculationTaskAppliesSystemSegmentsToTenantProfiles() throws Exception { + AtomicReference segmentExecutor = new AtomicReference<>(); + SegmentServiceImpl segmentService = newSegmentService(executionContextManager, + capturingScheduler("segment-date-recalculation", segmentExecutor)); + // a date-relative segment owned by the system tenant applies to the profiles of every tenant + executionContextManager.executeAsSystem(() -> segmentService.setSegmentDefinition(inactiveSinceSegment("inactive-everywhere"))); + executionContextManager.executeAsTenant(TENANT_A, () -> { + Profile inactive = new Profile("inactive-profile"); + inactive.setProperty("lastVisit", new Date(0)); + persistenceService.save(inactive); + Profile active = new Profile("active-profile"); + active.setProperty("lastVisit", new Date()); + persistenceService.save(active); + }); + persistenceService.refresh(); + + assertNull(runExecutor(segmentExecutor.get()), "segment recalculation should complete"); + persistenceService.refresh(); + + assertEquals(Set.of("inactive-everywhere"), loadProfile(TENANT_A, "inactive-profile").getSegments(), + "a tenant profile must enter the system tenant's date-relative segment"); + assertTrue(loadProfile(TENANT_A, "active-profile").getSegments().isEmpty(), + "a profile that does not match must stay out of it"); + } + + @Test + public void segmentDateRecalculationFailureInOneTenantIsReportedAndDoesNotStopTheOthers() throws Exception { + ExecutionContextManagerImpl spiedContext = spy(executionContextManager); + AtomicReference segmentExecutor = new AtomicReference<>(); + SegmentServiceImpl segmentService = newSegmentService(spiedContext, + capturingScheduler("segment-date-recalculation", segmentExecutor)); + saveInactiveProfilesAndTheirSegments(segmentService); + failTenant(spiedContext, TENANT_B); + + String failure = runExecutor(segmentExecutor.get()); + persistenceService.refresh(); + + assertNotNull(failure, "a tenant failure must fail the task instead of being reported as success"); + assertTrue(failure.contains(TENANT_B), "failure must name the tenant: " + failure); + assertFalse(failure.contains(TENANT_A), "failure must not name a tenant that succeeded: " + failure); + assertEquals(Set.of("inactive-" + TENANT_A), loadProfile(TENANT_A, "inactive-profile").getSegments(), + "the other tenant must still be recalculated"); + assertTrue(loadProfile(TENANT_B, "inactive-profile").getSegments().isEmpty(), + "the failing tenant's profiles are left as they were"); + } + + @Test + public void ruleStatisticsRefreshPersistsEachTenantsOwnStatistics() throws Exception { + AtomicReference statsExecutor = new AtomicReference<>(); + RulesServiceImpl rulesService = newRulesService(capturingScheduler("rules-statistics-refresh", statsExecutor)); + saveRuleStatistics(TENANT_A, "a-rule", 10); + // getRuleStatistics() hands out the live in-memory statistics the rule engine increments + RuleStatistics liveStats = executionContextManager.executeAsTenant(TENANT_A, () -> rulesService.getRuleStatistics("a-rule")); + liveStats.setLocalExecutionCount(4); + + assertNull(runExecutor(statsExecutor.get()), "statistics refresh should complete"); + persistenceService.refresh(); + + assertEquals(14L, loadRuleStatistics(TENANT_A, "a-rule").getExecutionCount(), + "tenant1's pending executions must be flushed to tenant1's statistics"); + assertNull(loadRuleStatistics(TENANT_B, "a-rule"), "tenant2 must not receive tenant1's statistics"); + } + + @Test + public void ruleStatisticsRefreshOnlyQueriesPersistenceForTenantsWithStatistics() throws Exception { + AtomicReference statsExecutor = new AtomicReference<>(); + PersistenceService spiedPersistence = spy(persistenceService); + RulesServiceImpl rulesService = newRulesService(capturingScheduler("rules-statistics-refresh", statsExecutor), spiedPersistence); + + clearInvocations(spiedPersistence); + assertNull(runExecutor(statsExecutor.get()), "statistics refresh should complete"); + verify(spiedPersistence, never()).getAllItems(RuleStatistics.class); + + saveRuleStatistics(TENANT_A, "a-rule", 10); + executionContextManager.executeAsTenant(TENANT_A, () -> rulesService.getRuleStatistics("a-rule")); + clearInvocations(spiedPersistence); + assertNull(runExecutor(statsExecutor.get()), "statistics refresh should complete"); + verify(spiedPersistence, times(1)).getAllItems(RuleStatistics.class); + } + + @Test + public void ruleStatisticsRefreshKeepsSystemStatisticsInTheSystemTenant() throws Exception { + AtomicReference statsExecutor = new AtomicReference<>(); + RulesServiceImpl rulesService = newRulesService(capturingScheduler("rules-statistics-refresh", statsExecutor)); + saveRuleStatistics(TenantService.SYSTEM_TENANT, "system-rule", 5); + RuleStatistics liveStats = executionContextManager.executeAsSystem(() -> rulesService.getRuleStatistics("system-rule")); + + // Several ticks: a tenant run must never pick up the system statistics a previous tick exposed to it. + for (int tick = 1; tick <= 3; tick++) { + liveStats.setLocalExecutionCount(liveStats.getLocalExecutionCount() + 1); + assertNull(runExecutor(statsExecutor.get()), "statistics refresh should complete"); + persistenceService.refresh(); + + assertEquals(5L + tick, loadRuleStatistics(TenantService.SYSTEM_TENANT, "system-rule").getExecutionCount(), + "system rule executions must be flushed to the system statistics on tick " + tick); + assertNull(loadRuleStatistics(TENANT_A, "system-rule"), "tenant1 must not get a copy of system statistics"); + assertNull(loadRuleStatistics(TENANT_B, "system-rule"), "tenant2 must not get a copy of system statistics"); + assertEquals(TenantService.SYSTEM_TENANT, liveStats.getTenantId(), "system statistics must stay in the system tenant"); + } + } + + /** + * The tests above rely on the in-memory persistence to keep tenants apart the way the real one does: + * the system context sees the system tenant only, which is why the tasks have to switch tenant. + */ + @Test + public void systemContextPersistenceStillOnlySeesSystemTenant() { + executionContextManager.executeAsTenant(TENANT_A, () -> { + Profile profile = new Profile("tenant-a-only"); + persistenceService.save(profile); + return null; + }); + persistenceService.refresh(); + + executionContextManager.executeAsSystem(() -> { + assertNull(persistenceService.load("tenant-a-only", Profile.class), + "system context must not load another tenant's profile"); + assertEquals(0L, persistenceService.getAllItemsCount(Profile.ITEM_TYPE), + "system context must not count another tenant's profiles"); + return null; + }); + + executionContextManager.executeAsTenant(TENANT_A, () -> { + assertNotNull(persistenceService.load("tenant-a-only", Profile.class)); + return null; + }); + } + + private ProfileServiceImpl newProfileService(ExecutionContextManagerImpl contextManager, SchedulerServiceImpl scheduler) { + ProfileServiceImpl profileService = new ProfileServiceImpl(); + profileService.setPurgeProfileExistTime(30); + return initProfileService(profileService, contextManager, scheduler); + } + + private ProfileServiceImpl initProfileService(ProfileServiceImpl profileService, ExecutionContextManagerImpl contextManager, + SchedulerServiceImpl scheduler) { + profileService.setBundleContext(bundleContext); + profileService.setPersistenceService(persistenceService); + profileService.setDefinitionsService(definitionsService); + profileService.setContextManager(contextManager); + profileService.setSchedulerService(scheduler); + profileService.setCacheService(multiTypeCacheService); + profileService.setTenantService(tenantService); + profileService.setSegmentService(mock(SegmentService.class)); + profileService.setPurgeProfileInterval(1); + profileService.postConstruct(); + return profileService; + } + + private SegmentServiceImpl newSegmentService(ExecutionContextManagerImpl contextManager, SchedulerServiceImpl scheduler) { + RulesService rulesService = mock(RulesService.class); + when(rulesService.getAllRules()).thenReturn(Collections.emptyList()); + + SegmentServiceImpl segmentService = new SegmentServiceImpl(); + segmentService.setBundleContext(bundleContext); + segmentService.setPersistenceService(persistenceService); + segmentService.setDefinitionsService(definitionsService); + segmentService.setContextManager(contextManager); + segmentService.setSchedulerService(scheduler); + segmentService.setCacheService(multiTypeCacheService); + segmentService.setTenantService(tenantService); + segmentService.setRulesService(rulesService); + segmentService.setEventService(mock(EventService.class)); + segmentService.setTracerService(TestHelper.createTracerService()); + segmentService.postConstruct(); + return segmentService; + } + + private RulesServiceImpl newRulesService(SchedulerServiceImpl scheduler) { + return newRulesService(scheduler, persistenceService); + } + + private RulesServiceImpl newRulesService(SchedulerServiceImpl scheduler, PersistenceService persistenceService) { + RulesServiceImpl rulesService = new RulesServiceImpl(); + rulesService.setBundleContext(bundleContext); + rulesService.setPersistenceService(persistenceService); + rulesService.setDefinitionsService(definitionsService); + rulesService.setContextManager(executionContextManager); + rulesService.setSchedulerService(scheduler); + rulesService.setCacheService(multiTypeCacheService); + rulesService.setTenantService(tenantService); + rulesService.setEventService(mock(EventService.class)); + rulesService.setActionExecutorDispatcher(mock(org.apache.unomi.services.actions.ActionExecutorDispatcher.class)); + rulesService.setTracerService(TestHelper.createTracerService()); + rulesService.setRulesRefreshInterval(60000); + rulesService.setRulesStatisticsRefreshInterval(60000); + rulesService.postConstruct(); + return rulesService; + } + + /** + * Gives each tenant a date-relative segment, then a profile matching it. The profile is saved after the + * segment was defined, straight to persistence: only the scheduled recalculation can put it in the + * segment, as when time makes a profile match. + */ + private void saveInactiveProfilesAndTheirSegments(SegmentServiceImpl segmentService) { + for (String tenantId : new String[]{TENANT_A, TENANT_B}) { + executionContextManager.executeAsTenant(tenantId, () -> { + segmentService.setSegmentDefinition(inactiveSinceSegment("inactive-" + tenantId)); + Profile inactive = new Profile("inactive-profile"); + inactive.setProperty("lastVisit", new Date(0)); + persistenceService.save(inactive); + }); + } + persistenceService.refresh(); + } + + /** A segment of the profiles whose last visit is more than 30 days old: its membership changes as time passes. */ + private Segment inactiveSinceSegment(String segmentId) { + Metadata metadata = new Metadata(); + metadata.setId(segmentId); + metadata.setName(segmentId); + metadata.setScope("systemscope"); + metadata.setEnabled(true); + + Condition condition = new Condition(definitionsService.getConditionType("profilePropertyCondition")); + condition.setParameter("propertyName", "properties.lastVisit"); + condition.setParameter("comparisonOperator", "lessThanOrEqualTo"); + condition.setParameter("propertyValueDateExpr", "now-30d"); + + Segment segment = new Segment(); + segment.setItemId(segmentId); + segment.setMetadata(metadata); + segment.setCondition(condition); + return segment; + } + + private static Session session(String sessionId, Date timeStamp) { + Session session = new Session(sessionId, new Profile("profile-of-" + sessionId), timeStamp, "scope"); + // the in-memory persistence purges on the creation date + session.setCreationDate(timeStamp); + return session; + } + + private static Event event(String eventId, Date timeStamp) { + Event event = new Event(); + event.setItemId(eventId); + event.setEventType("view"); + event.setProfileId("profile-of-" + eventId); + event.setTimeStamp(timeStamp); + event.setCreationDate(timeStamp); + return event; + } + + private void saveOldProfile(String tenantId, String profileId) { + executionContextManager.executeAsTenant(tenantId, () -> { + Profile old = new Profile(profileId); + old.setProperty("firstVisit", new Date(0)); + persistenceService.save(old); + }); + persistenceService.refresh(); + } + + private Profile loadProfile(String tenantId, String profileId) { + return executionContextManager.executeAsTenant(tenantId, () -> persistenceService.load(profileId, Profile.class)); + } + + private void saveRuleStatistics(String tenantId, String ruleId, long executionCount) { + executionContextManager.executeAsTenant(tenantId, () -> { + RuleStatistics statistics = new RuleStatistics(ruleId); + statistics.setExecutionCount(executionCount); + persistenceService.save(statistics); + }); + persistenceService.refresh(); + } + + private RuleStatistics loadRuleStatistics(String tenantId, String ruleId) { + return executionContextManager.executeAsTenant(tenantId, () -> persistenceService.load(ruleId, RuleStatistics.class)); + } + + @SuppressWarnings("unchecked") + private static void trackTenantVisits(ExecutionContextManagerImpl spiedContext, Set visitedTenants) { + doAnswer(invocation -> { + visitedTenants.add(invocation.getArgument(0)); + return invocation.callRealMethod(); + }).when(spiedContext).executeAsTenant(any(String.class), any(Supplier.class)); + } + + /** Makes every later switch into the given tenant fail, as if that tenant's work had thrown. */ + @SuppressWarnings("unchecked") + private static void failTenant(ExecutionContextManagerImpl spiedContext, String tenantId) { + doThrow(new IllegalStateException("simulated failure for " + tenantId)) + .when(spiedContext).executeAsTenant(eq(tenantId), any(Supplier.class)); + } + + /** + * Returns a scheduler that hands the executor registered for the given task type to the test instead of + * registering it: only the test runs that task, the scheduler's own threads cannot run it concurrently. + */ + private SchedulerServiceImpl capturingScheduler(String taskType, AtomicReference target) { + SchedulerServiceImpl spiedScheduler = spy(schedulerService); + doAnswer(invocation -> { + TaskExecutor executor = invocation.getArgument(0); + if (taskType.equals(executor.getTaskType())) { + target.set(executor); + return null; + } + return invocation.callRealMethod(); + }).when(spiedScheduler).registerTaskExecutor(any(TaskExecutor.class)); + return spiedScheduler; + } + + /** Runs the executor to its end and returns the failure it reported, or {@code null} when it completed. */ + private String runExecutor(TaskExecutor executor) throws Exception { + assertNotNull(executor, "the task executor must be registered"); + AtomicReference done = new AtomicReference<>(false); + AtomicReference failure = new AtomicReference<>(); + executor.execute(mock(ScheduledTask.class), new TaskExecutor.TaskStatusCallback() { + @Override + public void updateStep(String step, Map details) { + } + + @Override + public void checkpoint(Map checkpointData) { + } + + @Override + public void updateStatusDetails(Map details) { + } + + @Override + public void complete() { + done.set(true); + } + + @Override + public void fail(String error) { + failure.set(error); + done.set(true); + } + }); + assertTrue(done.get(), "task should report its outcome before returning"); + return failure.get(); + } +}