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

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


The following commit(s) were added to refs/heads/master by this push:
     new 360a4d86c UNOMI-968: Pluggable persistence IT harness for ES, OS, and 
other backends (#833)
360a4d86c is described below

commit 360a4d86ce1ba8cd1f5ab042f1ef0c26433973af
Author: Serge Huber <[email protected]>
AuthorDate: Tue Jul 21 11:47:11 2026 +0200

    UNOMI-968: Pluggable persistence IT harness for ES, OS, and other backends 
(#833)
    
    Merge pluggeable persistence integration tests modifications
---
 itests/README.md                                   |  96 ++++++-
 itests/pom.xml                                     |  32 +++
 .../test/java/org/apache/unomi/itests/AllITs.java  |   4 +-
 .../test/java/org/apache/unomi/itests/BaseIT.java  | 309 +++++++++++----------
 .../{AllITs.java => CorePersistenceITs.java}       |  11 +-
 .../org/apache/unomi/itests/HealthCheckIT.java     |  51 +++-
 .../java/org/apache/unomi/itests/JSONSchemaIT.java |  11 +-
 .../org/apache/unomi/itests/ProfileServiceIT.java  |   6 +-
 .../itests/ProfileServiceWithoutOverwriteIT.java   |   3 +-
 .../org/apache/unomi/itests/ProgressListener.java  | 123 ++++----
 .../java/org/apache/unomi/itests/RolloverIT.java   |  42 ++-
 .../org/apache/unomi/itests/TestTimingCache.java   | 143 ++++++++--
 .../apache/unomi/itests/TestTimingCacheTest.java   | 123 ++++++++
 .../migration/Migrate16xToCurrentVersionIT.java    |  45 +--
 .../itests/persistence/ElasticsearchITBackend.java | 117 ++++++++
 .../itests/persistence/OpenSearchITBackend.java    | 130 +++++++++
 .../itests/persistence/PersistenceITBackend.java   |  92 ++++++
 .../persistence/PersistenceITBackendResolver.java  | 110 ++++++++
 .../persistence/PersistenceITCapabilities.java     | 204 ++++++++++++++
 .../unomi/itests/persistence/SearchBackendIT.java  |  29 ++
 ...e.unomi.itests.persistence.PersistenceITBackend |  19 ++
 21 files changed, 1425 insertions(+), 275 deletions(-)

diff --git a/itests/README.md b/itests/README.md
index ebb3d9708..0d587d2b4 100644
--- a/itests/README.md
+++ b/itests/README.md
@@ -32,7 +32,9 @@ and data migration.
   `docker-maven-plugin`).
 - **Maven Failsafe** runs a single entry point — `AllITs` — which aggregates 
all test
   classes. Each test class extends `BaseIT`, which handles Karaf startup, OSGi 
service
-  injection, and common test utilities.
+  injection, and common test utilities. Persistence is selected via
+  `unomi.persistence.provider` (Elasticsearch / OpenSearch today; see
+  [Pluggable persistence providers](#pluggable-persistence-providers)).
 
 A full IT run typically takes 20–30 minutes. The Karaf instance is created 
fresh for each
 run under `itests/target/exam/` with a UUID directory name.
@@ -284,6 +286,98 @@ See the [Maven Failsafe plugin 
docs](https://maven.apache.org/surefire/maven-fai
 
 ---
 
+## Pluggable persistence providers
+
+Unomi’s runtime persistence is an OSGi capability 
(`unomi.persistence;provider:=…`).
+The IT harness follows the same idea: `BaseIT` resolves a test-only
+`PersistenceITBackend` instead of hard-coding Elasticsearch / OpenSearch.
+
+### Provider selection
+
+| Property | Role |
+|----------|------|
+| `unomi.persistence.provider` | Preferred provider id (`elasticsearch`, 
`opensearch`, or an extension id) |
+| `unomi.search.engine` | **Deprecated alias** — used when 
`unomi.persistence.provider` is unset |
+| `unomi.persistence.it.backend` | Optional FQCN of a `PersistenceITBackend` 
implementation |
+
+Default CI cells set both `unomi.persistence.provider` and the deprecated
+`unomi.search.engine` so existing `--use-opensearch` / scripts keep working.
+
+### Progress / ETA (local timing cache)
+
+`ProgressSuite` + `ProgressListener` write a best-effort per-test duration 
cache under
+the `itests` module directory (survives `mvn clean`):
+
+`.test-timing-cache-<provider>.properties`
+
+One file per persistence provider (`elasticsearch`, `opensearch`, 
`postgresql`, …)
+so ETAs are not mixed across backends. On later runs the listener sums 
remaining
+historical times and scales them by how fast/slow the current run is vs history
+(clamped). Safe to delete; missing/unwritable cache falls back to in-run 
averages.
+
+### Built-in backends
+
+| Id | Class | Suite for CI |
+|----|-------|--------------|
+| `elasticsearch` | `ElasticsearchITBackend` | `AllITs` (default) |
+| `opensearch` | `OpenSearchITBackend` | `AllITs` with `-Duse.opensearch=true` 
|
+
+### Search-only vs core behavioural tests
+
+`AllITs` and `CorePersistenceITs` share the **same** test membership for 
maximum coverage.
+Tests that need HTTP admin / snapshot / rollover APIs stay in the suite and use
+`Assume` + `PersistenceITCapabilities` so unsupported backends **skip** 
(reported as
+skipped, not green false-pass):
+
+| Class | Gate |
+|-------|------|
+| `Migrate16xToCurrentVersionIT` | `snapshotRestoreMigration()` |
+| `RolloverIT` | `indexRolloverApi().isPresent()` + `httpAdminApi()`; switch 
on `LIFECYCLE` / `STATE_MANAGEMENT` |
+| `HealthCheckIT` | Always asserts `karaf` / `unomi` / `persistence`; optional 
`providerNamedHealthProbe` / `clusterHealthProbe` |
+
+`HealthCheckIT` is not search-only: it runs on every provider.
+
+Elasticsearch / OpenSearch CI continues to use `AllITs`. Portable / non-search 
cells
+(PostgreSQL, …) should run `CorePersistenceITs` (identical membership, clearer 
name).
+
+```bash
+mvn clean install -P integration-tests 
-Dit.test=org.apache.unomi.itests.CorePersistenceITs
+```
+
+Capability flags gate remaining special-cases
+(e.g. `snapshotRestoreMigration`, `flattenedRangeQueryResult`, 
`indexRolloverApi`)
+so providers skip what they cannot support instead of checking product names.
+
+### Adding a third-party backend
+
+1. Implement `org.apache.unomi.itests.persistence.PersistenceITBackend`
+   (feature options, distribution name for `unomi:setup`, ConfigAdmin PID,
+   capabilities, await-ready / HTTP helpers as needed).
+2. Put the implementation on the Failsafe test classpath.
+3. Register it either:
+   - in 
`META-INF/services/org.apache.unomi.itests.persistence.PersistenceITBackend`, or
+   - via `-Dunomi.persistence.it.backend=com.example.MyPersistenceITBackend`
+4. Select it with `-Dunomi.persistence.provider=<your-id>`.
+5. Run `CorePersistenceITs` (same membership as `AllITs`). Incomplete SPI
+   implementations may still fail behavioural tests — unsupported HTTP-admin
+   features are skipped via `Assume`, not removed from the suite.
+
+`unomi-itests` publishes a **test-jar** (`mvn -Pintegration-tests -pl itests 
install`) so external modules can
+depend on `BaseIT`, `CorePersistenceITs`, and `PersistenceITBackend` without 
forking
+sources. Optionally implement `prepareBeforeUnomiSetup` to patch ConfigAdmin 
(e.g. JDBC
+DataSource) after `UnomiManagementService` is up and before `unomi:setup`.
+
+Example Failsafe exclude when running `AllITs` against a non-search provider:
+
+```xml
+<excludedGroups>org.apache.unomi.itests.persistence.SearchBackendIT</excludedGroups>
+```
+
+(Note: JUnit 4 category filtering applies to classes Failsafe launches 
directly;
+prefer `CorePersistenceITs` when using the suite entry point.)
+
+---
+
 ## Debugging Integration Tests
 
 ### Attaching a remote debugger to the test JVM
diff --git a/itests/pom.xml b/itests/pom.xml
index d28e1d2a6..026a1798f 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -28,6 +28,7 @@
     <description>Apache Unomi Context Server integration tests</description>
 
     <properties>
+        <unomi.persistence.provider>elasticsearch</unomi.persistence.provider>
         <unomi.search.engine>elasticsearch</unomi.search.engine>
         <use.opensearch>false</use.opensearch>
         <docker.container.name>itests-opensearch</docker.container.name>
@@ -277,6 +278,33 @@
                     </systemPropertyVariables>
                 </configuration>
             </plugin>
+            <!-- Publish test classes so out-of-tree PersistenceService 
providers can implement
+                 PersistenceITBackend and reuse CorePersistenceITs / BaseIT 
(UNOMI-968). -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-jar-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>test-jar</id>
+                        <goals>
+                            <goal>test-jar</goal>
+                        </goals>
+                        <configuration>
+                            <!-- Include IT classes and test resources 
(fixtures used by BaseIT.config). -->
+                            <includes>
+                                <include>org/apache/unomi/itests/**</include>
+                                <include>**/*.cfg</include>
+                                <include>**/*.csv</include>
+                                <include>**/*.json</include>
+                                <include>**/*.groovy</include>
+                                <include>migration/**</include>
+                                <include>schemas/**</include>
+                                <include>META-INF/**</include>
+                            </includes>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
         </plugins>
     </build>
 
@@ -306,6 +334,8 @@
                             </includes>
                             <systemPropertyVariables>
                                 <my.system.property>foo</my.system.property>
+                                
<unomi.persistence.provider>elasticsearch</unomi.persistence.provider>
+                                <!-- Deprecated alias; keep for older scripts 
-->
                                 
<unomi.search.engine>elasticsearch</unomi.search.engine>
                                 
<elasticsearch.port>${elasticsearch.port}</elasticsearch.port>
                                 <it.karaf.heap>${karaf.heap}</it.karaf.heap>
@@ -432,6 +462,8 @@
                             </includes>
                             <systemPropertyVariables>
                                 <my.system.property>foo</my.system.property>
+                                
<unomi.persistence.provider>opensearch</unomi.persistence.provider>
+                                <!-- Deprecated alias; keep for older scripts 
-->
                                 
<unomi.search.engine>opensearch</unomi.search.engine>
                                 
<org.apache.unomi.opensearch.addresses>localhost:${opensearch.port}</org.apache.unomi.opensearch.addresses>
                                 
<org.ops4j.pax.logging.DefaultServiceLog.level>INFO</org.ops4j.pax.logging.DefaultServiceLog.level>
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 8617b1dcb..41351e5b0 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -24,7 +24,9 @@ import org.junit.runner.RunWith;
 import org.junit.runners.Suite.SuiteClasses;
 
 /**
- * Defines suite of test classes to run.
+ * Defines suite of test classes to run (Elasticsearch / OpenSearch CI 
default).
+ * Same membership as {@link CorePersistenceITs}; use capabilities + {@code 
Assume} for
+ * provider-specific skips rather than maintaining a smaller suite.
  *
  * @author Sergiy Shyrkov
  */
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 8587f4f8a..5ec6385ba 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
@@ -22,8 +22,6 @@ import org.apache.camel.CamelContext;
 import org.apache.camel.Route;
 import org.apache.camel.ServiceStatus;
 import org.apache.commons.io.IOUtils;
-import org.apache.http.auth.AuthScope;
-import org.apache.http.auth.UsernamePasswordCredentials;
 import org.apache.http.HttpEntity;
 import org.apache.http.client.config.RequestConfig;
 import org.apache.http.client.methods.*;
@@ -54,6 +52,9 @@ import org.apache.unomi.api.tenants.Tenant;
 import org.apache.unomi.api.tenants.TenantService;
 import org.apache.unomi.api.utils.ConditionBuilder;
 import org.apache.unomi.groovy.actions.services.GroovyActionsService;
+import org.apache.unomi.itests.persistence.PersistenceITBackend;
+import org.apache.unomi.itests.persistence.PersistenceITBackendResolver;
+import org.apache.unomi.itests.persistence.PersistenceITCapabilities;
 import org.apache.unomi.itests.tools.LogChecker;
 import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
 import org.apache.unomi.lifecycle.BundleWatcher;
@@ -98,9 +99,11 @@ import javax.net.ssl.X509TrustManager;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.URL;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
 import java.security.KeyManagementException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
@@ -135,16 +138,23 @@ public abstract class BaseIT extends KarafTestSupport {
     protected static final int DEFAULT_TRYING_TRIES = 10;
     protected static final int DEFAULT_SHOULDBETRUE_TRIES = 5;
 
-    protected static final String SEARCH_ENGINE_PROPERTY = 
"unomi.search.engine";
+    /** @deprecated use {@link 
PersistenceITBackendResolver#PROVIDER_PROPERTY}. */
+    @Deprecated
+    protected static final String SEARCH_ENGINE_PROPERTY = 
PersistenceITBackendResolver.SEARCH_ENGINE_PROPERTY;
     protected static final String SEARCH_ENGINE_HTTPREQUEST_LOG_LEVEL = 
"unomi.search.engine.httprequest.log.level";
-    protected static final String SEARCH_ENGINE_ELASTICSEARCH = 
"elasticsearch";
-    protected static final String SEARCH_ENGINE_OPENSEARCH = "opensearch";
+    protected static final String SEARCH_ENGINE_ELASTICSEARCH = 
PersistenceITBackendResolver.PROVIDER_ELASTICSEARCH;
+    protected static final String SEARCH_ENGINE_OPENSEARCH = 
PersistenceITBackendResolver.PROVIDER_OPENSEARCH;
+    protected static final String PERSISTENCE_PROVIDER_PROPERTY = 
PersistenceITBackendResolver.PROVIDER_PROPERTY;
     protected static final String RESOLVER_DEBUG_PROPERTY = 
"it.unomi.resolver.debug";
     protected static final String ENABLE_LOG_CHECKING_PROPERTY = 
"it.unomi.log.checking.enabled";
     protected static final String CAMEL_DEBUG_PROPERTY = 
"it.unomi.camel.debug";
 
     protected static boolean unomiStarted = false;
+    /**
+     * Active provider id. Kept for existing tests; prefer {@link 
#getPersistenceBackend()}.
+     */
     protected static String searchEngine = SEARCH_ENGINE_ELASTICSEARCH;
+    private static PersistenceITBackend persistenceBackend;
 
     private static boolean searchEngineConfiguredForTesting = false;
     private static boolean searchEngineHealthVerifiedAfterStartup = false;
@@ -239,37 +249,71 @@ public abstract class BaseIT extends KarafTestSupport {
     }
 
     protected void checkSearchEngine() {
-        searchEngine = System.getProperty(SEARCH_ENGINE_PROPERTY, 
SEARCH_ENGINE_ELASTICSEARCH);
+        resolvePersistenceBackend();
         configureSearchEngineForTesting();
     }
 
+    /**
+     * Resolves (and caches) the active {@link PersistenceITBackend} from 
system properties /
+     * ServiceLoader. Also updates the legacy {@link #searchEngine} field.
+     */
+    protected static PersistenceITBackend resolvePersistenceBackend() {
+        if (persistenceBackend == null) {
+            persistenceBackend = PersistenceITBackendResolver.resolve();
+            searchEngine = persistenceBackend.providerId();
+        }
+        return persistenceBackend;
+    }
+
+    protected static PersistenceITBackend getPersistenceBackend() {
+        return resolvePersistenceBackend();
+    }
+
+    protected PersistenceITCapabilities persistenceCapabilities() {
+        return getPersistenceBackend().capabilities();
+    }
+
+    /** ConfigAdmin PID for the active persistence provider (e.g. 
throwExceptions). */
+    protected String persistenceConfigPid() {
+        return getPersistenceBackend().persistenceConfigPid();
+    }
+
     @Before
     public void waitForStartup() throws InterruptedException {
         // disable retry
         retry = new KarafTestSupport.Retry(false);
 
-        // Check search engine and apply any necessary fixes (e.g., 
default_template deletion)
+        // Resolve provider and apply any necessary backend prep (e.g., 
zero-replica template)
         checkSearchEngine();
+        try {
+            getPersistenceBackend().awaitBackendReady();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalStateException("Persistence backend 
awaitBackendReady failed", e);
+        }
 
         // Start Unomi if not already done
         if (!unomiStarted) {
             // We must check that the Unomi Management Service is up and 
running before launching the
             // command otherwise the start configuration will not be properly 
populated.
             waitForUnomiManagementService();
-            if (SEARCH_ENGINE_ELASTICSEARCH.equals(searchEngine)) {
-                LOGGER.info("Starting Unomi with elasticsearch search 
engine...");
-                System.out.println("==== Starting Unomi with elasticsearch 
search engine...");
-                executeCommand("unomi:setup 
-d=unomi-distribution-elasticsearch -f=true");
-                executeCommand("unomi:start");
-            } else if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)){
-                LOGGER.info("Starting Unomi with opensearch search engine...");
-                System.out.println("==== Starting Unomi with opensearch search 
engine...");
-                executeCommand("unomi:setup -d=unomi-distribution-opensearch 
-f=true");
-                executeCommand("unomi:start");
-            } else {
-                LOGGER.error("Unknown search engine: " + searchEngine);
-                throw new InterruptedException("Unknown search engine: " + 
searchEngine);
+            PersistenceITBackend backend = getPersistenceBackend();
+            try {
+                backend.prepareBeforeUnomiSetup(bundleContext, 
configurationAdmin);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw e;
+            } catch (Exception e) {
+                throw new IllegalStateException("Persistence backend 
prepareBeforeUnomiSetup failed", e);
             }
+            String distribution = backend.distributionFeature();
+            LOGGER.info("Starting Unomi with persistence provider {} 
(distribution {})...", backend.providerId(), distribution);
+            System.out.println("==== Starting Unomi with persistence provider 
" + backend.providerId()
+                    + " (distribution " + distribution + ")...");
+            executeCommand("unomi:setup -d=" + distribution + " -f=true");
+            executeCommand("unomi:start");
             unomiStarted = true;
         }
 
@@ -542,6 +586,43 @@ public abstract class BaseIT extends KarafTestSupport {
         Thread.sleep(1000);
     }
 
+    /**
+     * Resolves a fixture under {@code src/test/resources} for Pax Exam {@code 
replaceConfigurationFile}.
+     * Prefers the local filesystem (Unomi itests module); falls back to the 
classpath so out-of-tree
+     * consumers of the {@code unomi-itests} test-jar can reuse {@link 
#config()} without copying files.
+     */
+    protected static File resolveTestResource(String 
pathUnderSrcTestResources) {
+        File local = new File(pathUnderSrcTestResources);
+        if (local.isFile()) {
+            return local;
+        }
+        String classpathName = pathUnderSrcTestResources;
+        final String prefix = "src/test/resources/";
+        if (classpathName.startsWith(prefix)) {
+            classpathName = classpathName.substring(prefix.length());
+        }
+        URL url = BaseIT.class.getClassLoader().getResource(classpathName);
+        if (url == null) {
+            throw new IllegalStateException("Missing test resource: " + 
pathUnderSrcTestResources
+                    + " (not on filesystem or classpath)");
+        }
+        try {
+            if ("file".equals(url.getProtocol())) {
+                return new File(url.toURI());
+            }
+            String fileName = new File(classpathName).getName();
+            Path tmp = Files.createTempFile("unomi-it-resource-", "-" + 
fileName);
+            try (InputStream in = url.openStream()) {
+                Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
+            }
+            File out = tmp.toFile();
+            out.deleteOnExit();
+            return out;
+        } catch (Exception e) {
+            throw new IllegalStateException("Cannot resolve test resource " + 
pathUnderSrcTestResources, e);
+        }
+    }
+
     @Override
     public MavenArtifactUrlReference getKarafDistribution() {
         return 
maven().groupId("org.apache.unomi").artifactId("unomi").versionAsInProject().type("tar.gz");
@@ -554,120 +635,41 @@ public abstract class BaseIT extends KarafTestSupport {
             System.out.println("==== Configuring container");
         }
 
-        searchEngine = System.getProperty(SEARCH_ENGINE_PROPERTY, 
SEARCH_ENGINE_ELASTICSEARCH);
+        resolvePersistenceBackend();
         if (!configLogged) {
-            LOGGER.info("Search Engine: {}", searchEngine);
-            System.out.println("Search Engine: " + searchEngine);
-        }
-
-        // Define features option based on search engine
-        Option featuresOption;
-        Option distributionOption;
-        if (SEARCH_ENGINE_ELASTICSEARCH.equals(searchEngine)) {
-            featuresOption = features(
-                    
maven().groupId("org.apache.unomi").artifactId("unomi-kar").versionAsInProject().type("xml").classifier("features"),
-                    "unomi-base",
-                    "unomi-startup",
-                    "unomi-elasticsearch-core",
-                    "unomi-persistence-core",
-                    "unomi-services",
-                    "unomi-cxs-privacy-extension-services",
-                    "unomi-plugins-base",
-                    "unomi-plugins-request",
-                    "unomi-plugins-mail",
-                    "unomi-plugins-optimization-test",
-                    "unomi-rest-api",
-                    "unomi-cxs-privacy-extension",
-                    "unomi-elasticsearch-conditions",
-                    "unomi-cxs-lists-extension",
-                    "unomi-cxs-geonames-extension",
-                    "unomi-shell-dev-commands",
-                    "unomi-wab",
-                    "unomi-web-tracker",
-                    "unomi-healthcheck-elasticsearch",
-                    "unomi-router-karaf-feature",
-                    "unomi-groovy-actions",
-                    "unomi-rest-ui",
-                    "cdp-graphql-feature",
-                    "unomi-startup-complete"
-            );
-            distributionOption = features(
-                    
maven().groupId("org.apache.unomi").artifactId("unomi-distribution").versionAsInProject().type("xml").classifier("features"),
-                    "unomi-distribution-elasticsearch-graphql"
-            );
-        } else if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            featuresOption = features(
-                    
maven().groupId("org.apache.unomi").artifactId("unomi-kar").versionAsInProject().type("xml").classifier("features"),
-                    "unomi-base",
-                    "unomi-startup",
-                    "unomi-opensearch-core",
-                    "unomi-persistence-core",
-                    "unomi-services",
-                    "unomi-cxs-privacy-extension-services",
-                    "unomi-plugins-base",
-                    "unomi-plugins-request",
-                    "unomi-plugins-mail",
-                    "unomi-plugins-optimization-test",
-                    "unomi-rest-api",
-                    "unomi-cxs-privacy-extension",
-                    "unomi-opensearch-conditions",
-                    "unomi-cxs-lists-extension",
-                    "unomi-cxs-geonames-extension",
-                    "unomi-shell-dev-commands",
-                    "unomi-wab",
-                    "unomi-web-tracker",
-                    "unomi-healthcheck-opensearch",
-                    "unomi-router-karaf-feature",
-                    "unomi-groovy-actions",
-                    "unomi-rest-ui",
-                    "cdp-graphql-feature",
-                    "unomi-startup-complete"
-            );
-            distributionOption = features(
-                    
maven().groupId("org.apache.unomi").artifactId("unomi-distribution").versionAsInProject().type("xml").classifier("features"),
-                    "unomi-distribution-opensearch-graphql"
-            );
-        } else {
-            throw new IllegalArgumentException("Unknown search engine: " + 
searchEngine);
+            LOGGER.info("Persistence provider: {}", searchEngine);
+            System.out.println("Persistence provider: " + searchEngine);
         }
 
+        PersistenceITBackend backend = getPersistenceBackend();
+        Option[] backendFeatures = backend.featureOptions();
+        Option[] backendConfig = backend.configurationOptions();
+
         Option[] options = new Option[]{
-                replaceConfigurationFile("etc/org.apache.unomi.router.cfg", 
new File("src/test/resources/org.apache.unomi.router.cfg")),
-                replaceConfigurationFile("data/tmp/1-basic-test.csv", new 
File("src/test/resources/1-basic-test.csv")),
-                
replaceConfigurationFile("data/tmp/recurrent_import/2-surfers-test.csv", new 
File("src/test/resources/2-surfers-test.csv")),
-                
replaceConfigurationFile("data/tmp/recurrent_import/3-surfers-overwrite-test.csv",
 new File("src/test/resources/3-surfers-overwrite-test.csv")),
-                
replaceConfigurationFile("data/tmp/recurrent_import/4-surfers-delete-test.csv", 
new File("src/test/resources/4-surfers-delete-test.csv")),
-                
replaceConfigurationFile("data/tmp/recurrent_import/5-ranking-test.csv", new 
File("src/test/resources/5-ranking-test.csv")),
-                
replaceConfigurationFile("data/tmp/recurrent_import/6-actors-test.csv", new 
File("src/test/resources/6-actors-test.csv")),
-                replaceConfigurationFile("data/tmp/testLogin.json", new 
File("src/test/resources/testLogin.json")),
-                replaceConfigurationFile("data/tmp/testCopyProperties.json", 
new File("src/test/resources/testCopyProperties.json")),
-                
replaceConfigurationFile("data/tmp/testCopyPropertiesWithoutSystemTags.json", 
new File("src/test/resources/testCopyPropertiesWithoutSystemTags.json")),
-                
replaceConfigurationFile("data/tmp/testLoginEventCondition.json", new 
File("src/test/resources/testLoginEventCondition.json")),
-                
replaceConfigurationFile("data/tmp/testClickEventCondition.json", new 
File("src/test/resources/testClickEventCondition.json")),
-                replaceConfigurationFile("data/tmp/testRuleGroovyAction.json", 
new File("src/test/resources/testRuleGroovyAction.json")),
-                
replaceConfigurationFile("data/tmp/conditions/testIdsConditionLegacy.json", new 
File("src/test/resources/conditions/testIdsConditionLegacy.json")),
-                
replaceConfigurationFile("data/tmp/conditions/testIdsConditionNew.json", new 
File("src/test/resources/conditions/testIdsConditionNew.json")),
-                
replaceConfigurationFile("data/tmp/conditions/testBooleanConditionLegacy.json", 
new File("src/test/resources/conditions/testBooleanConditionLegacy.json")),
-                
replaceConfigurationFile("data/tmp/conditions/testPropertyConditionLegacy.json",
 new File("src/test/resources/conditions/testPropertyConditionLegacy.json")),
-                
replaceConfigurationFile("data/tmp/groovy/UpdateAddressAction.groovy", new 
File("src/test/resources/groovy/UpdateAddressAction.groovy")),
+                replaceConfigurationFile("etc/org.apache.unomi.router.cfg", 
resolveTestResource("src/test/resources/org.apache.unomi.router.cfg")),
+                replaceConfigurationFile("data/tmp/1-basic-test.csv", 
resolveTestResource("src/test/resources/1-basic-test.csv")),
+                
replaceConfigurationFile("data/tmp/recurrent_import/2-surfers-test.csv", 
resolveTestResource("src/test/resources/2-surfers-test.csv")),
+                
replaceConfigurationFile("data/tmp/recurrent_import/3-surfers-overwrite-test.csv",
 resolveTestResource("src/test/resources/3-surfers-overwrite-test.csv")),
+                
replaceConfigurationFile("data/tmp/recurrent_import/4-surfers-delete-test.csv", 
resolveTestResource("src/test/resources/4-surfers-delete-test.csv")),
+                
replaceConfigurationFile("data/tmp/recurrent_import/5-ranking-test.csv", 
resolveTestResource("src/test/resources/5-ranking-test.csv")),
+                
replaceConfigurationFile("data/tmp/recurrent_import/6-actors-test.csv", 
resolveTestResource("src/test/resources/6-actors-test.csv")),
+                replaceConfigurationFile("data/tmp/testLogin.json", 
resolveTestResource("src/test/resources/testLogin.json")),
+                replaceConfigurationFile("data/tmp/testCopyProperties.json", 
resolveTestResource("src/test/resources/testCopyProperties.json")),
+                
replaceConfigurationFile("data/tmp/testCopyPropertiesWithoutSystemTags.json", 
resolveTestResource("src/test/resources/testCopyPropertiesWithoutSystemTags.json")),
+                
replaceConfigurationFile("data/tmp/testLoginEventCondition.json", 
resolveTestResource("src/test/resources/testLoginEventCondition.json")),
+                
replaceConfigurationFile("data/tmp/testClickEventCondition.json", 
resolveTestResource("src/test/resources/testClickEventCondition.json")),
+                replaceConfigurationFile("data/tmp/testRuleGroovyAction.json", 
resolveTestResource("src/test/resources/testRuleGroovyAction.json")),
+                
replaceConfigurationFile("data/tmp/conditions/testIdsConditionLegacy.json", 
resolveTestResource("src/test/resources/conditions/testIdsConditionLegacy.json")),
+                
replaceConfigurationFile("data/tmp/conditions/testIdsConditionNew.json", 
resolveTestResource("src/test/resources/conditions/testIdsConditionNew.json")),
+                
replaceConfigurationFile("data/tmp/conditions/testBooleanConditionLegacy.json", 
resolveTestResource("src/test/resources/conditions/testBooleanConditionLegacy.json")),
+                
replaceConfigurationFile("data/tmp/conditions/testPropertyConditionLegacy.json",
 
resolveTestResource("src/test/resources/conditions/testPropertyConditionLegacy.json")),
+                
replaceConfigurationFile("data/tmp/groovy/UpdateAddressAction.groovy", 
resolveTestResource("src/test/resources/groovy/UpdateAddressAction.groovy")),
 
                 editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg", 
"log4j2.rootLogger.level", "INFO"),
                 editConfigurationFilePut("etc/org.apache.karaf.features.cfg", 
"serviceRequirements", "disable"),
                 editConfigurationFilePut("etc/system.properties", 
"my.system.property", System.getProperty("my.system.property")),
-                editConfigurationFilePut("etc/system.properties", 
SEARCH_ENGINE_PROPERTY, System.getProperty(SEARCH_ENGINE_PROPERTY, 
SEARCH_ENGINE_ELASTICSEARCH)),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.cluster.name", "contextElasticSearchITests"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.addresses", "localhost:" + getSearchPort()),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.taskWaitingPollingInterval", "50"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.rollover.maxDocs", "300"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.minimalClusterState", "YELLOW"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.cluster.name", "contextElasticSearchITests"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.addresses", "localhost:" + getSearchPort()),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.username", "admin"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.password", "Unomi.1ntegrat10n.Tests"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.sslEnable", "false"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.sslTrustAllCertificates", "true"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.rollover.maxDocs", "300"),
-                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.minimalClusterState", "YELLOW"),
+                editConfigurationFilePut("etc/system.properties", 
SEARCH_ENGINE_PROPERTY, searchEngine),
+                editConfigurationFilePut("etc/system.properties", 
PERSISTENCE_PROVIDER_PROPERTY, searchEngine),
                 editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.migration.tenant.id", TEST_TENANT_ID),
                 // Default scheduler.thread.poolSize (5) is sized for the 
near-instant in-memory unit-test
                 // double, not a real ES/OS backend. Under real refresh/write 
latency, the checker, task
@@ -680,9 +682,6 @@ public abstract class BaseIT extends KarafTestSupport {
                 
systemProperty("org.ops4j.pax.exam.rbc.rmi.port").value("1199"),
                 
systemProperty("org.apache.unomi.healthcheck.enabled").value("true"),
 
-                featuresOption,  // Add the features option
-                distributionOption, // Add the distribution option
-
                 configureConsole().startRemoteShell(),
                 logLevel(LogLevel.INFO),
                 keepRuntimeFolder(),
@@ -691,6 +690,8 @@ public abstract class BaseIT extends KarafTestSupport {
         };
         List<Option> karafOptions = new ArrayList<>();
         karafOptions.addAll(Arrays.asList(options));
+        karafOptions.addAll(Arrays.asList(backendConfig));
+        karafOptions.addAll(Arrays.asList(backendFeatures));
 
         String karafDebug = System.getProperty("it.karaf.debug");
         if (karafDebug != null) {
@@ -1435,6 +1436,10 @@ public abstract class BaseIT extends KarafTestSupport {
         if (searchEngineConfiguredForTesting) {
             return;
         }
+        if (!persistenceCapabilities().httpAdminApi()) {
+            searchEngineConfiguredForTesting = true;
+            return;
+        }
         try (CloseableHttpClient client = createSearchEngineHttpClient()) {
             String baseUrl = getSearchEngineBaseUrl();
             ensureZeroReplicaIndexTemplate(client, baseUrl);
@@ -1464,6 +1469,9 @@ public abstract class BaseIT extends KarafTestSupport {
     }
 
     private void enforceZeroReplicasAndWaitForCluster(String context) {
+        if (!persistenceCapabilities().httpAdminApi()) {
+            return;
+        }
         try (CloseableHttpClient client = createSearchEngineHttpClient()) {
             String baseUrl = getSearchEngineBaseUrl();
             ensureZeroReplicaIndexTemplate(client, baseUrl);
@@ -1517,6 +1525,14 @@ public abstract class BaseIT extends KarafTestSupport {
      * Stage 3 (post-Unomi start): assert cluster health for single-node IT 
(ES and OpenSearch).
      */
     protected void assertClusterHealthy(String context) {
+        if (!persistenceCapabilities().httpAdminApi()) {
+            try {
+                getPersistenceBackend().assertHealthyAfterUnomiStart();
+            } catch (Exception e) {
+                throw new IllegalStateException("Backend health assertion 
failed: " + context, e);
+            }
+            return;
+        }
         try (CloseableHttpClient client = createSearchEngineHttpClient()) {
             String baseUrl = getSearchEngineBaseUrl();
             ensureZeroReplicaIndexTemplate(client, baseUrl);
@@ -1550,36 +1566,31 @@ public abstract class BaseIT extends KarafTestSupport {
     }
 
     protected static String getSearchEngineBaseUrl() {
-        if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            return "http://localhost:"; + getSearchPort();
-        }
-        return "http://localhost:"; + getSearchPort();
+        requireHttpAdminApi("getSearchEngineBaseUrl");
+        return getPersistenceBackend().searchBaseUrl();
     }
 
     protected CloseableHttpClient createSearchEngineHttpClient() throws 
IOException {
-        if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            BasicCredentialsProvider credentialsProvider = new 
BasicCredentialsProvider();
-            credentialsProvider.setCredentials(AuthScope.ANY,
-                    new UsernamePasswordCredentials("admin", 
"Unomi.1ntegrat10n.Tests"));
-            return HttpUtils.initHttpClient(true, credentialsProvider);
-        }
-        return HttpUtils.initHttpClient(true, null);
+        requireHttpAdminApi("createSearchEngineHttpClient");
+        return getPersistenceBackend().createSearchHttpClient();
     }
 
     /**
-     * Gets the appropriate search engine port based on the configured search 
engine.
+     * Gets the appropriate search engine port based on the configured 
persistence provider.
      *
      * @return The port number as a string
      */
     protected static String getSearchPort() {
-        String searchEngine = System.getProperty(SEARCH_ENGINE_PROPERTY, 
SEARCH_ENGINE_ELASTICSEARCH);
-        if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            // For OpenSearch, get the port from the system property set by 
maven-failsafe-plugin
-            return System.getProperty("org.apache.unomi.opensearch.addresses", 
"localhost:9401")
-                    .split(":")[1]; // Extract port number from 
"localhost:9401"
-        } else {
-            // For Elasticsearch, use the default port or system property if 
set
-            return System.getProperty("elasticsearch.port", "9400");
+        requireHttpAdminApi("getSearchPort");
+        return getPersistenceBackend().searchPort();
+    }
+
+    private static void requireHttpAdminApi(String caller) {
+        PersistenceITBackend backend = getPersistenceBackend();
+        if (!backend.capabilities().httpAdminApi()) {
+            throw new UnsupportedOperationException(
+                    caller + " requires httpAdminApi, but provider '" + 
backend.providerId()
+                            + "' does not advertise it (skip via Assume / 
SearchBackendIT / CorePersistenceITs)");
         }
     }
 
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
similarity index 85%
copy from itests/src/test/java/org/apache/unomi/itests/AllITs.java
copy to itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index 8617b1dcb..6cc692a0d 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -24,9 +24,12 @@ import org.junit.runner.RunWith;
 import org.junit.runners.Suite.SuiteClasses;
 
 /**
- * Defines suite of test classes to run.
- *
- * @author Sergiy Shyrkov
+ * Full behavioural IT suite for any {@code PersistenceService} provider.
+ * <p>
+ * Membership matches {@link AllITs}. Tests that need HTTP admin / snapshot / 
rollover
+ * APIs use {@link org.junit.Assume} on {@link 
org.apache.unomi.itests.persistence.PersistenceITCapabilities}
+ * so unsupported backends <em>skip</em> (not fail, not false-pass). Prefer 
this suite for
+ * PostgreSQL, in-memory, JDBC, etc.; Elasticsearch / OpenSearch CI may keep 
using {@link AllITs}.
  */
 @RunWith(ProgressSuite.class)
 @SuiteClasses({
@@ -83,5 +86,5 @@ import org.junit.runners.Suite.SuiteClasses;
         RolloverIT.class,
         HealthCheckIT.class
 })
-public class AllITs {
+public class CorePersistenceITs {
 }
diff --git a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java 
b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
index 9fe11829d..305c2f38e 100644
--- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
@@ -41,7 +41,12 @@ import java.util.concurrent.*;
 import static org.junit.Assert.fail;
 
 /**
- * Health Check Integration Tests
+ * Health Check Integration Tests.
+ * <p>
+ * Always asserts {@code karaf}, {@code unomi}, and {@code persistence} LIVE.
+ * Optional probes ({@code providerNamedHealthProbe}, {@code 
clusterHealthProbe}) are
+ * asserted only when the active {@link 
org.apache.unomi.itests.persistence.PersistenceITCapabilities}
+ * advertise them — so the same class covers Elasticsearch, OpenSearch, 
PostgreSQL, etc.
  */
 @RunWith(PaxExam.class)
 @ExamReactorStrategy(PerSuite.class)
@@ -57,15 +62,10 @@ public class HealthCheckIT extends BaseIT {
     public void testHealthCheck() {
         try {
             List<HealthCheckResponse> response = get(HEALTHCHECK_ENDPOINT, new 
TypeReference<>() {});
-            LOGGER.info("configured search engine: {}", searchEngine);
+            LOGGER.info("configured persistence provider: {}", 
getPersistenceBackend().providerId());
             LOGGER.info("health check response: {}", response);
             Assert.assertNotNull(response);
-            Assert.assertEquals(5, response.size());
-            Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("karaf") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
-            Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals(searchEngine) && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
-            Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("unomi") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
-            Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("persistence") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
-            Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("cluster") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
+            assertHealthCheckLive(response);
         } catch (Exception e) {
             LOGGER.error("Error while executing health check", e);
             fail("Error while executing health check" + e.getMessage());
@@ -88,12 +88,7 @@ public class HealthCheckIT extends BaseIT {
                 }
                 for (Future<List<HealthCheckResponse>> future : futures) {
                     List<HealthCheckResponse> health = future.get(10, 
TimeUnit.SECONDS);
-                    Assert.assertEquals(5, health.size());
-                    Assert.assertTrue(health.stream().anyMatch(r -> 
r.getName().equals("karaf") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
-                    Assert.assertTrue(health.stream().anyMatch(r -> 
r.getName().equals(searchEngine) && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
-                    Assert.assertTrue(health.stream().anyMatch(r -> 
r.getName().equals("unomi") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
-                    Assert.assertTrue(health.stream().anyMatch(r -> 
r.getName().equals("persistence") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
-                    Assert.assertTrue(health.stream().anyMatch(r -> 
r.getName().equals("cluster") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
+                    assertHealthCheckLive(health);
                 }
                 Thread.sleep(10);
             }
@@ -109,6 +104,34 @@ public class HealthCheckIT extends BaseIT {
         }
     }
 
+    private void assertHealthCheckLive(List<HealthCheckResponse> response) {
+        Assert.assertNotNull(response);
+        Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("karaf") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
+        Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("unomi") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
+        Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("persistence") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
+        int expectedMinProbes = 3; // karaf, unomi, persistence
+        boolean named = persistenceCapabilities().providerNamedHealthProbe();
+        boolean cluster = persistenceCapabilities().clusterHealthProbe();
+        if (named) {
+            expectedMinProbes++;
+            String namedProbe = getPersistenceBackend().providerId();
+            Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals(namedProbe) && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
+        }
+        if (cluster) {
+            expectedMinProbes++;
+            Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("cluster") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
+        }
+        // ES/OS advertise both optional probes → keep historical exact size 
(5).
+        // Other backends may expose extra probes; require at least the 
expected set.
+        if (named && cluster) {
+            Assert.assertEquals("Unexpected health probe count: " + response, 
expectedMinProbes, response.size());
+        } else {
+            Assert.assertTrue(
+                    "Expected at least " + expectedMinProbes + " health 
probes, got " + response.size() + ": " + response,
+                    response.size() >= expectedMinProbes);
+        }
+    }
+
     protected <T> T get(final String url, TypeReference<T> typeReference) {
         CloseableHttpResponse response = null;
         try {
diff --git a/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java 
b/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java
index e37c23cc8..e9fcd7c37 100644
--- a/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java
@@ -25,6 +25,7 @@ import org.apache.http.util.EntityUtils;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.Scope;
 import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.itests.persistence.PersistenceITCapabilities;
 import org.apache.unomi.itests.tools.LogChecker;
 import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
 import org.apache.unomi.schema.api.JsonSchemaWrapper;
@@ -377,16 +378,14 @@ public class JSONSchemaIT extends BaseIT {
         // Refresh to ensure event is queryable
         refreshPersistence(Event.class);
         final Condition finalCondition = condition;
-        // For Elasticsearch, range queries on flattened properties should 
return null or empty list
-        // For OpenSearch, they may return results
+        // Providers differ on range queries against flattened properties 
(hits vs empty).
         // We just need to wait for the query to execute (not throw an 
exception)
         refreshPersistence(Event.class);
         org.apache.unomi.api.PartialList<Event> queryResult = 
persistenceService.query(finalCondition, null, Event.class, 0, -1);
-        if ("opensearch".equals(searchEngine)) {
-            assertNotNull("OpenSearch should return results for flattened 
properties", queryResult);
+        if (persistenceCapabilities().flattenedRangeQueryResult() == 
PersistenceITCapabilities.FlattenedRangeQueryResult.HITS) {
+            assertNotNull("provider that returns flattened range hits should 
return results", queryResult);
         } else {
-            // Elasticsearch should return null or empty list for range 
queries on flattened properties
-            assertTrue("Elasticsearch should return null or empty list for 
flattened properties range query",
+            assertTrue("provider that returns empty for flattened range should 
return null or empty list",
                 queryResult == null || queryResult.getList() == null || 
queryResult.getList().isEmpty());
         }
 
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
index dff72a1dc..e256c7004 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
@@ -153,7 +153,7 @@ public class ProfileServiceIT extends BaseIT {
     @Test
     public void testGetProfileWithWrongScrollerIdThrowException() throws 
InterruptedException, IOException {
         boolean throwExceptionCurrent = false;
-        Configuration searchEngineConfiguration = 
configurationAdmin.getConfiguration("org.apache.unomi.persistence." + 
searchEngine);
+        Configuration searchEngineConfiguration = 
configurationAdmin.getConfiguration(persistenceConfigPid());
         if (searchEngineConfiguration != null && 
searchEngineConfiguration.getProperties().get("throwExceptions") != null) {
             try {
                 if 
(searchEngineConfiguration.getProperties().get("throwExceptions") instanceof 
String) {
@@ -167,7 +167,7 @@ public class ProfileServiceIT extends BaseIT {
             }
         }
 
-        updateConfiguration(null, "org.apache.unomi.persistence." + 
searchEngine, "throwExceptions", true);
+        updateConfiguration(null, persistenceConfigPid(), "throwExceptions", 
true);
 
         Query query = new Query();
         query.setLimit(2);
@@ -180,7 +180,7 @@ public class ProfileServiceIT extends BaseIT {
         } catch (RuntimeException ex) {
             // Should get here since this scenario should throw exception
         } finally {
-            updateConfiguration(null, "org.apache.unomi.persistence." + 
searchEngine, "throwExceptions",
+            updateConfiguration(null, persistenceConfigPid(), 
"throwExceptions",
                     throwExceptionCurrent);
         }
     }
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceWithoutOverwriteIT.java
 
b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceWithoutOverwriteIT.java
index 2f94dd198..922b08bf9 100644
--- 
a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceWithoutOverwriteIT.java
+++ 
b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceWithoutOverwriteIT.java
@@ -44,8 +44,7 @@ public class ProfileServiceWithoutOverwriteIT extends BaseIT {
 
     @Configuration
     public Option[] config() {
-
-        searchEngine = System.getProperty(SEARCH_ENGINE_PROPERTY, 
SEARCH_ENGINE_ELASTICSEARCH);
+        resolvePersistenceBackend();
         System.out.println("Search Engine: " + searchEngine);
 
         List<Option> options = new ArrayList<>();
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java 
b/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java
index 78d9653b1..1ecdca270 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java
@@ -16,6 +16,7 @@
  */
 package org.apache.unomi.itests;
 
+import org.apache.unomi.itests.persistence.PersistenceITBackendResolver;
 import org.junit.runner.Description;
 import org.junit.runner.Result;
 import org.junit.runner.notification.Failure;
@@ -32,6 +33,7 @@ import java.util.Map;
 import java.util.PriorityQueue;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -44,7 +46,7 @@ import java.util.concurrent.atomic.AtomicInteger;
  *   <li>ASCII art logo display at test suite startup</li>
  *   <li>Real-time progress bar with percentage completion</li>
  *   <li>Colorized output (when ANSI is supported)</li>
- *   <li>Estimated time remaining calculations</li>
+ *   <li>Estimated time remaining from a per-persistence-provider historical 
timing cache</li>
  *   <li>Test success/failure counters</li>
  *   <li>Top 10 slowest tests tracking and reporting</li>
  *   <li>Motivational quotes displayed at progress milestones</li>
@@ -137,15 +139,24 @@ public class ProgressListener extends RunListener {
     private long startTime = System.currentTimeMillis();
     /** Timestamp when the current individual test started */
     private long startTestTime = System.currentTimeMillis();
+    /**
+     * Set in {@link #testFailure} before {@link #testFinished}; failed tests 
must not update the
+     * timing cache (aborted / assertion failures skew historical ETAs).
+     */
+    private boolean currentTestFailed;
     /** Formatter for human-readable timestamps */
     private static final DateTimeFormatter TIMESTAMP_FORMATTER = 
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
-    /** Search engine under test (e.g. "elasticsearch", "opensearch"); timings 
are cached per engine */
-    private final String searchEngine = 
System.getProperty(BaseIT.SEARCH_ENGINE_PROPERTY, 
BaseIT.SEARCH_ENGINE_ELASTICSEARCH);
-    /** Cached per-test durations loaded from {@link TestTimingCache} */
+    /** Persistence provider under test (e.g. elasticsearch, opensearch, 
postgresql); timings cached per provider */
+    private final String persistenceProvider;
+    /** Cached per-test durations loaded from {@link TestTimingCache} for this 
provider */
     private final Map<String, Long> cachedTimings;
     /** Timing-cache keys for tests not yet completed in this run */
     private final Set<String> remainingTestKeys;
+    /** Durations (ms) of tests completed in this run */
+    private final List<Long> completedDurations = new CopyOnWriteArrayList<>();
+    /** Pairs of [observedMs, cachedMs] for completed tests that had a 
historical entry */
+    private final List<long[]> observedVsCached = new CopyOnWriteArrayList<>();
 
     /**
      * Creates a new ProgressListener instance.
@@ -162,7 +173,8 @@ public class ProgressListener extends RunListener {
         this.completedTests = completedTests;
         this.slowTests = new PriorityQueue<>((t1, t2) -> Long.compare(t1.time, 
t2.time));
         this.ansiSupported = isAnsiSupported();
-        this.cachedTimings = TestTimingCache.load(searchEngine);
+        this.persistenceProvider = 
PersistenceITBackendResolver.resolveProviderId();
+        this.cachedTimings = TestTimingCache.load(persistenceProvider);
         this.remainingTestKeys = ConcurrentHashMap.newKeySet();
         this.remainingTestKeys.addAll(testKeys);
     }
@@ -259,11 +271,12 @@ public class ProgressListener extends RunListener {
         // Print the bottom border
         System.out.println(colorize(bottomBorder, CYAN));
 
-        // Display search engine information once at the start
-        String searchEngine = System.getProperty("unomi.search.engine", 
"elasticsearch");
-        String searchEngineDisplay = capitalizeSearchEngine(searchEngine);
+        // Display persistence provider + historical timing cache info once at 
the start
+        String providerDisplay = capitalizeProvider(persistenceProvider);
         System.out.println();
-        System.out.println(colorize("Using search engine: " + 
searchEngineDisplay, CYAN));
+        System.out.println(colorize("Persistence provider: " + 
providerDisplay, CYAN));
+        System.out.println(colorize("Historical timings: " + 
cachedTimings.size() + "/" + totalTests
+                + " tests cached → " + 
TestTimingCache.cacheFile(persistenceProvider).toAbsolutePath(), CYAN));
         System.out.println();
     }
 
@@ -274,6 +287,7 @@ public class ProgressListener extends RunListener {
      */
     @Override
     public void testStarted(Description description) {
+        currentTestFailed = false;
         startTestTime = System.currentTimeMillis();
         // Print test start boundary with test name
         String testName = extractTestName(description);
@@ -287,7 +301,9 @@ public class ProgressListener extends RunListener {
     }
 
     /**
-     * Called when an individual test finishes successfully. Updates counters 
and displays progress.
+     * Called when an individual test finishes. Updates counters and displays 
progress.
+     * Successful tests are written to {@link TestTimingCache} immediately so 
a killed mid-suite
+     * run still retains timings for every test that completed cleanly.
      *
      * @param description the description of the test that finished
      */
@@ -295,6 +311,9 @@ public class ProgressListener extends RunListener {
     public void testFinished(Description description) {
         long endTestTime = System.currentTimeMillis();
         long testDuration = endTestTime - startTestTime;
+        boolean failed = currentTestFailed;
+        currentTestFailed = false;
+
         completedTests.incrementAndGet();
         successfulTests.incrementAndGet(); // Default to success unless a 
failure is recorded separately.
         slowTests.add(new TestTime(description.getDisplayName(), 
testDuration));
@@ -304,10 +323,18 @@ public class ProgressListener extends RunListener {
         }
         String testKey = TestTimingCache.keyFor(description);
         remainingTestKeys.remove(testKey);
-        // Persist immediately (rather than batching until testRunFinished) so 
a run that gets killed
-        // mid-suite (Ctrl-C, CI timeout, a hung test force-killed) still 
leaves every test that did
-        // complete recorded in the cache for next time.
-        TestTimingCache.save(searchEngine, Collections.singletonMap(testKey, 
testDuration));
+
+        // Persist only successes: failure/abort durations pollute the 
provider cache and ETA scale.
+        // Write after every successful test (not only at suite end) so Ctrl-C 
/ CI kill keeps progress.
+        if (!failed) {
+            completedDurations.add(testDuration);
+            Long historical = cachedTimings.get(testKey);
+            if (historical != null && historical > 0L) {
+                observedVsCached.add(new long[]{testDuration, historical});
+            }
+            TestTimingCache.save(persistenceProvider, 
Collections.singletonMap(testKey, testDuration));
+        }
+
         // Print test end boundary
         String testName = extractTestName(description);
         String durationStr = formatTime(testDuration);
@@ -323,12 +350,25 @@ public class ProgressListener extends RunListener {
     }
 
     /**
-     * Called when a test fails. Updates failure counters and displays the 
failure message.
+     * {@code @Ignore}d tests never call {@link #testFinished}; drop them from 
the remaining set so ETA
+     * does not keep budgeting time for them.
+     */
+    @Override
+    public void testIgnored(Description description) {
+        remainingTestKeys.remove(TestTimingCache.keyFor(description));
+        completedTests.incrementAndGet();
+        displayProgress();
+    }
+
+    /**
+     * Called when a test fails (before {@link #testFinished}). Marks the test 
so its duration is
+     * not written to the timing cache.
      *
      * @param failure the failure information
      */
     @Override
     public void testFailure(Failure failure) {
+        currentTestFailed = true;
         successfulTests.decrementAndGet(); // Remove the previous success 
count for this test.
         failedTests.incrementAndGet();
         String testName = extractTestName(failure.getDescription());
@@ -391,26 +431,25 @@ public class ProgressListener extends RunListener {
     }
 
     /**
-     * Capitalizes the search engine name for display.
-     * Converts "opensearch" to "OpenSearch" and "elasticsearch" to 
"Elasticsearch".
+     * Capitalizes the persistence provider name for display.
      *
-     * @param searchEngine the search engine name (lowercase)
-     * @return the capitalized search engine name
+     * @param provider the provider id (lowercase)
+     * @return a display-friendly name
      */
-    private String capitalizeSearchEngine(String searchEngine) {
-        if (searchEngine == null || searchEngine.isEmpty()) {
-            return searchEngine;
+    private String capitalizeProvider(String provider) {
+        if (provider == null || provider.isEmpty()) {
+            return provider;
         }
-        // Handle special case for "opensearch" -> "OpenSearch"
-        if ("opensearch".equalsIgnoreCase(searchEngine)) {
+        if ("opensearch".equalsIgnoreCase(provider)) {
             return "OpenSearch";
         }
-        // Handle "elasticsearch" -> "Elasticsearch"
-        if ("elasticsearch".equalsIgnoreCase(searchEngine)) {
+        if ("elasticsearch".equalsIgnoreCase(provider)) {
             return "Elasticsearch";
         }
-        // Default: capitalize first letter
-        return searchEngine.substring(0, 1).toUpperCase() + 
searchEngine.substring(1);
+        if ("postgresql".equalsIgnoreCase(provider)) {
+            return "PostgreSQL";
+        }
+        return provider.substring(0, 1).toUpperCase() + provider.substring(1);
     }
 
     /**
@@ -454,32 +493,20 @@ public class ProgressListener extends RunListener {
     }
 
     /**
-     * Estimates the remaining time for the run by summing, for each test that 
has not completed yet,
-     * its historical duration from the search-engine-specific {@link 
TestTimingCache} when one exists,
-     * and falling back to this run's own flat average for any test with no 
cache entry (e.g. the very
-     * first run on a machine, or a newly added test).
+     * Estimates remaining time using the provider-specific {@link 
TestTimingCache}, scaled by how
+     * fast/slow this run has been vs history for tests that already completed 
with a cache hit.
      *
      * @param completed the number of tests completed so far
      * @param elapsedTime the time elapsed since the run started, in 
milliseconds
      * @return the estimated remaining time, in milliseconds
      */
     private long estimateRemainingTime(int completed, long elapsedTime) {
-        // Avoid division by very low completed count; use a floor value
-        int stableCompleted = Math.max(completed, 1);
-        double averageTestTimeMillis = elapsedTime / (double) stableCompleted;
-
-        long estimate = 0;
-        int uncachedRemaining = 0;
-        for (String key : remainingTestKeys) {
-            Long cached = cachedTimings.get(key);
-            if (cached != null) {
-                estimate += cached;
-            } else {
-                uncachedRemaining++;
-            }
-        }
-        estimate += (long) (averageTestTimeMillis * uncachedRemaining);
-        return estimate;
+        return TestTimingCache.estimateRemainingMs(
+                remainingTestKeys,
+                cachedTimings,
+                observedVsCached,
+                completedDurations,
+                elapsedTime);
     }
 
     /**
diff --git a/itests/src/test/java/org/apache/unomi/itests/RolloverIT.java 
b/itests/src/test/java/org/apache/unomi/itests/RolloverIT.java
index 449bfa278..667070dde 100644
--- a/itests/src/test/java/org/apache/unomi/itests/RolloverIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/RolloverIT.java
@@ -18,8 +18,12 @@ package org.apache.unomi.itests;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.unomi.itests.persistence.PersistenceITCapabilities;
+import org.apache.unomi.itests.persistence.SearchBackendIT;
 import org.apache.unomi.shell.migration.utils.HttpUtils;
+import org.junit.Assume;
 import org.junit.Test;
+import org.junit.experimental.categories.Category;
 import org.junit.runner.RunWith;
 import org.ops4j.pax.exam.junit.PaxExam;
 import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
@@ -31,6 +35,7 @@ import java.util.Map;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 /**
  * Verifies that Unomi correctly wires up the event index's rollover lifecycle 
on both Elasticsearch (ILM)
@@ -52,9 +57,14 @@ import static org.junit.Assert.assertTrue;
  * that happens ISM has nothing left to transition to (the rollover policy's 
single state has no further
  * transitions) and marks that now-rolled-over index's management as 
completed/disabled, which would make a
  * hardcoded context-event-000001 check fail even though the rollover 
machinery worked correctly.
+ * <p>
+ * Non-search providers (e.g. PostgreSQL) leave {@link 
PersistenceITCapabilities.IndexRolloverApi#NONE}
+ * so this test is {@link Assume Assumed} skipped (not failed). Kept in {@link 
CorePersistenceITs}
+ * / {@link AllITs} for maximum suite coverage across backends.
  */
 @RunWith(PaxExam.class)
 @ExamReactorStrategy(PerSuite.class)
+@Category(SearchBackendIT.class)
 public class RolloverIT extends BaseIT {
 
     private static final String EVENT_ALIAS = "context-event";
@@ -63,16 +73,36 @@ public class RolloverIT extends BaseIT {
 
     @Test
     public void testEventIndexRolloverIsProperlyConfigured() throws Exception {
+        PersistenceITCapabilities caps = persistenceCapabilities();
+        Assume.assumeTrue(
+                "Index rollover requires an HTTP rollover API (provider="
+                        + getPersistenceBackend().providerId() + ")",
+                caps.indexRolloverApi().isPresent());
+        Assume.assumeTrue(
+                "Index rollover assertions require httpAdminApi (provider="
+                        + getPersistenceBackend().providerId() + ")",
+                caps.httpAdminApi());
+
         try (CloseableHttpClient client = createSearchEngineHttpClient()) {
             String writeIndex = resolveCurrentWriteIndex(client);
             JsonNode indexRoot = getJson(client, "/" + writeIndex + 
"/_settings?flat_settings=true").get(writeIndex);
             assertTrue("Expected the event write index " + writeIndex + " to 
already exist", indexRoot != null);
             JsonNode settings = indexRoot.get("settings");
 
-            if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-                assertOpenSearchRolloverConfigured(client, settings, 
writeIndex);
-            } else {
-                assertElasticsearchRolloverConfigured(client, settings, 
writeIndex);
+            switch (caps.indexRolloverApi()) {
+                case STATE_MANAGEMENT:
+                    assertStateManagementRolloverConfigured(client, settings, 
writeIndex);
+                    break;
+                case LIFECYCLE:
+                    assertLifecycleRolloverConfigured(client, settings, 
writeIndex);
+                    break;
+                case NONE:
+                    fail("unreachable: indexRolloverApi was assumed present");
+                    break;
+                default: {
+                    PersistenceITCapabilities.IndexRolloverApi unexpected = 
caps.indexRolloverApi();
+                    throw new IllegalStateException("Unhandled 
IndexRolloverApi: " + unexpected);
+                }
             }
         }
     }
@@ -94,7 +124,7 @@ public class RolloverIT extends BaseIT {
         throw new AssertionError("Could not find a write index for alias " + 
EVENT_ALIAS);
     }
 
-    private void assertOpenSearchRolloverConfigured(CloseableHttpClient 
client, JsonNode settings, String writeIndex) throws IOException {
+    private void assertStateManagementRolloverConfigured(CloseableHttpClient 
client, JsonNode settings, String writeIndex) throws IOException {
         assertEquals("event index should reference the Unomi rollover policy",
                 POLICY_ID, text(settings, 
"index.plugins.index_state_management.policy_id"));
         assertEquals("event index rollover_alias should be the event write 
alias",
@@ -116,7 +146,7 @@ public class RolloverIT extends BaseIT {
         assertEquals(EXPECTED_MAX_DOCS, 
rolloverAction.get("min_doc_count").asLong());
     }
 
-    private void assertElasticsearchRolloverConfigured(CloseableHttpClient 
client, JsonNode settings, String writeIndex) throws IOException {
+    private void assertLifecycleRolloverConfigured(CloseableHttpClient client, 
JsonNode settings, String writeIndex) throws IOException {
         assertEquals("event index should reference the Unomi rollover policy",
                 POLICY_ID, text(settings, "index.lifecycle.name"));
         assertEquals("event index rollover_alias should be the event write 
alias",
diff --git a/itests/src/test/java/org/apache/unomi/itests/TestTimingCache.java 
b/itests/src/test/java/org/apache/unomi/itests/TestTimingCache.java
index 99e05e802..f6e5b7e55 100644
--- a/itests/src/test/java/org/apache/unomi/itests/TestTimingCache.java
+++ b/itests/src/test/java/org/apache/unomi/itests/TestTimingCache.java
@@ -28,6 +28,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.nio.file.StandardCopyOption;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
@@ -37,13 +38,14 @@ import java.util.Properties;
  * Local, best-effort cache of individual IT execution times, used by {@link 
ProgressListener} to make its
  * estimated-time-remaining calculation more accurate than a single flat 
running average.
  * <p>
- * The cache is a plain properties file kept in the {@code itests} module 
directory (not {@code target/}) so
- * it survives {@code mvn clean}, with one file per search engine since 
Elasticsearch and OpenSearch runs have
- * different timing profiles and shouldn't be averaged together.
+ * The cache is a plain properties file kept under {@code user.dir} (typically 
the {@code itests} module
+ * directory, not {@code target/}) so it survives {@code mvn clean}, with 
<strong>one file per
+ * persistence provider</strong> ({@code elasticsearch}, {@code opensearch}, 
{@code postgresql}, …)
+ * since backends have different timing profiles and must not be averaged 
together.
  * <p>
  * This is a local developer convenience, not build state: all I/O failures 
are swallowed so a missing or
- * unwritable cache (e.g. a read-only or ephemeral CI workspace) never fails 
the IT run - it just falls back
- * to the flat average for every test, matching the prior behavior.
+ * unwritable cache (e.g. a read-only or ephemeral CI workspace) never fails 
the IT run — it just falls back
+ * to the in-run average for every test.
  */
 final class TestTimingCache {
 
@@ -52,6 +54,12 @@ final class TestTimingCache {
     /** Weight given to a freshly observed duration when blending it into the 
persisted average. */
     private static final double SMOOTHING = 0.3;
 
+    /**
+     * Clamp for the live-run vs historical scale factor so a few outliers 
cannot make ETA absurd.
+     */
+    static final double MIN_SCALE = 0.25;
+    static final double MAX_SCALE = 4.0;
+
     private TestTimingCache() {
     }
 
@@ -90,15 +98,15 @@ final class TestTimingCache {
     }
 
     /**
-     * Loads the previously persisted timings for the given search engine.
+     * Loads the previously persisted timings for the given persistence 
provider.
      *
-     * @param searchEngine the search engine the current run targets (e.g. 
"elasticsearch", "opensearch")
+     * @param persistenceProvider provider id (e.g. {@code elasticsearch}, 
{@code opensearch}, {@code postgresql})
      * @return a mutable map of cache key to last known duration in 
milliseconds; empty if no cache
      *         exists yet or it could not be read
      */
-    static Map<String, Long> load(String searchEngine) {
+    static Map<String, Long> load(String persistenceProvider) {
         Map<String, Long> timings = new HashMap<>();
-        Path cacheFile = cacheFile(searchEngine);
+        Path cacheFile = cacheFile(persistenceProvider);
         if (!Files.isReadable(cacheFile)) {
             return timings;
         }
@@ -121,20 +129,20 @@ final class TestTimingCache {
     }
 
     /**
-     * Merges freshly observed durations into the persisted cache for the 
given search engine, smoothing
-     * each updated entry with an exponential moving average so a single 
unusually slow/fast run doesn't
-     * swing future ETAs too far.
+     * Merges freshly observed durations into the persisted cache for the 
given persistence provider,
+     * smoothing each updated entry with an exponential moving average so a 
single unusually slow/fast
+     * run doesn't swing future ETAs too far.
      *
-     * @param searchEngine the search engine the run just executed against
+     * @param persistenceProvider provider id the run just executed against
      * @param observedTimings durations (in milliseconds) observed during the 
run that just finished
      */
-    static void save(String searchEngine, Map<String, Long> observedTimings) {
+    static void save(String persistenceProvider, Map<String, Long> 
observedTimings) {
         if (observedTimings.isEmpty()) {
             return;
         }
-        Path cacheFile = cacheFile(searchEngine);
+        Path cacheFile = cacheFile(persistenceProvider);
         try {
-            Map<String, Long> merged = load(searchEngine);
+            Map<String, Long> merged = load(persistenceProvider);
             for (Map.Entry<String, Long> entry : observedTimings.entrySet()) {
                 Long previous = merged.get(entry.getKey());
                 long updated = previous == null
@@ -148,7 +156,8 @@ final class TestTimingCache {
             Path parent = cacheFile.toAbsolutePath().getParent();
             Path tempFile = Files.createTempFile(parent, "test-timing-cache", 
".tmp");
             try (Writer writer = Files.newBufferedWriter(tempFile, 
StandardCharsets.UTF_8)) {
-                props.store(writer, "Apache Unomi IT test timing cache (local 
dev aid, safe to delete)");
+                props.store(writer, "Apache Unomi IT test timing cache per 
persistence provider "
+                        + "(local dev aid, safe to delete)");
             }
             Files.move(tempFile, cacheFile, 
StandardCopyOption.REPLACE_EXISTING);
         } catch (IOException | RuntimeException e) {
@@ -157,10 +166,102 @@ final class TestTimingCache {
         }
     }
 
-    private static Path cacheFile(String searchEngine) {
-        String normalizedEngine = (searchEngine == null || 
searchEngine.isEmpty())
+    /**
+     * Estimates remaining wall time for unfinished tests.
+     * <p>
+     * For each remaining test with a historical entry, uses that duration 
scaled by how fast/slow
+     * <em>this</em> run has been relative to history (ratio of observed vs 
cached for completed
+     * tests that had a cache hit). Uncached remaining tests use the in-run 
average of completed
+     * durations (or the median of historical values when nothing has 
completed yet).
+     *
+     * @param remainingKeys keys still expected to run
+     * @param cachedTimings historical durations for this persistence provider
+     * @param observedVsCachedCompleted pairs of (observedMs, cachedMs) for 
completed tests that had history
+     * @param completedDurations all completed durations this run (for 
fallback average)
+     * @param elapsedTimeMs wall time since suite start (unused for sum; kept 
for API clarity)
+     * @return estimated remaining milliseconds (never negative)
+     */
+    static long estimateRemainingMs(Collection<String> remainingKeys,
+                                    Map<String, Long> cachedTimings,
+                                    Collection<long[]> 
observedVsCachedCompleted,
+                                    Collection<Long> completedDurations,
+                                    long elapsedTimeMs) {
+        double scale = computeScale(observedVsCachedCompleted);
+        double fallbackAvg = fallbackAverageMs(completedDurations, 
cachedTimings, elapsedTimeMs);
+
+        long estimate = 0L;
+        for (String key : remainingKeys) {
+            Long cached = cachedTimings.get(key);
+            if (cached != null && cached > 0L) {
+                estimate += Math.round(cached * scale);
+            } else {
+                estimate += Math.round(fallbackAvg);
+            }
+        }
+        return Math.max(0L, estimate);
+    }
+
+    /**
+     * How fast/slow this run is vs the historical cache for the same provider.
+     * {@code 1.0} = on pace; {@code >1} = slower than history; {@code <1} = 
faster.
+     */
+    static double computeScale(Collection<long[]> observedVsCachedCompleted) {
+        if (observedVsCachedCompleted == null || 
observedVsCachedCompleted.isEmpty()) {
+            return 1.0;
+        }
+        long observedSum = 0L;
+        long cachedSum = 0L;
+        for (long[] pair : observedVsCachedCompleted) {
+            if (pair == null || pair.length < 2) {
+                continue;
+            }
+            if (pair[0] > 0L && pair[1] > 0L) {
+                observedSum += pair[0];
+                cachedSum += pair[1];
+            }
+        }
+        if (cachedSum <= 0L || observedSum <= 0L) {
+            return 1.0;
+        }
+        double scale = (double) observedSum / (double) cachedSum;
+        if (scale < MIN_SCALE) {
+            return MIN_SCALE;
+        }
+        if (scale > MAX_SCALE) {
+            return MAX_SCALE;
+        }
+        return scale;
+    }
+
+    private static double fallbackAverageMs(Collection<Long> 
completedDurations,
+                                            Map<String, Long> cachedTimings,
+                                            long elapsedTimeMs) {
+        if (completedDurations != null && !completedDurations.isEmpty()) {
+            long sum = 0L;
+            for (Long d : completedDurations) {
+                if (d != null && d > 0L) {
+                    sum += d;
+                }
+            }
+            return sum / (double) completedDurations.size();
+        }
+        if (cachedTimings != null && !cachedTimings.isEmpty()) {
+            long sum = 0L;
+            for (Long d : cachedTimings.values()) {
+                if (d != null && d > 0L) {
+                    sum += d;
+                }
+            }
+            return sum / (double) cachedTimings.size();
+        }
+        // Cold start: tiny placeholder so ETA is non-zero until the first 
test finishes
+        return elapsedTimeMs > 0L ? elapsedTimeMs : 30_000L;
+    }
+
+    static Path cacheFile(String persistenceProvider) {
+        String normalized = (persistenceProvider == null || 
persistenceProvider.isEmpty())
                 ? "unknown"
-                : searchEngine.toLowerCase(Locale.ROOT);
-        return Paths.get(System.getProperty("user.dir", "."), 
".test-timing-cache-" + normalizedEngine + ".properties");
+                : persistenceProvider.toLowerCase(Locale.ROOT);
+        return Paths.get(System.getProperty("user.dir", "."), 
".test-timing-cache-" + normalized + ".properties");
     }
 }
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/TestTimingCacheTest.java 
b/itests/src/test/java/org/apache/unomi/itests/TestTimingCacheTest.java
new file mode 100644
index 000000000..a5030fe19
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/TestTimingCacheTest.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.itests;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Unit tests for {@link TestTimingCache} ETA helpers and per-provider 
isolation.
+ */
+public class TestTimingCacheTest {
+
+    private String previousUserDir;
+    private Path tempDir;
+
+    @Before
+    public void setUp() throws Exception {
+        previousUserDir = System.getProperty("user.dir");
+        tempDir = Files.createTempDirectory("unomi-test-timing-cache");
+        System.setProperty("user.dir", tempDir.toAbsolutePath().toString());
+    }
+
+    @After
+    public void tearDown() {
+        if (previousUserDir != null) {
+            System.setProperty("user.dir", previousUserDir);
+        }
+    }
+
+    @Test
+    public void cacheFilesAreIsolatedPerProvider() {
+        Path es = TestTimingCache.cacheFile("elasticsearch");
+        Path pg = TestTimingCache.cacheFile("postgresql");
+        Assert.assertNotEquals(es, pg);
+        
Assert.assertTrue(es.getFileName().toString().contains("elasticsearch"));
+        Assert.assertTrue(pg.getFileName().toString().contains("postgresql"));
+    }
+
+    @Test
+    public void saveAndLoadRoundTripPerProvider() {
+        Map<String, Long> esTimings = Collections.singletonMap("FooIT#bar", 
1_000L);
+        Map<String, Long> pgTimings = Collections.singletonMap("FooIT#bar", 
5_000L);
+
+        TestTimingCache.save("elasticsearch", esTimings);
+        TestTimingCache.save("postgresql", pgTimings);
+
+        Assert.assertEquals(Long.valueOf(1_000L), 
TestTimingCache.load("elasticsearch").get("FooIT#bar"));
+        Assert.assertEquals(Long.valueOf(5_000L), 
TestTimingCache.load("postgresql").get("FooIT#bar"));
+        Assert.assertTrue(TestTimingCache.load("opensearch").isEmpty());
+    }
+
+    @Test
+    public void computeScaleDefaultsToOneWithoutPairs() {
+        Assert.assertEquals(1.0, 
TestTimingCache.computeScale(Collections.emptyList()), 0.0);
+        Assert.assertEquals(1.0, TestTimingCache.computeScale(null), 0.0);
+    }
+
+    @Test
+    public void computeScaleUsesObservedOverCachedRatioAndClamps() {
+        List<long[]> slower = Collections.singletonList(new long[]{2_000L, 
1_000L});
+        Assert.assertEquals(2.0, TestTimingCache.computeScale(slower), 0.0);
+
+        List<long[]> tooFast = Collections.singletonList(new long[]{10L, 
10_000L});
+        Assert.assertEquals(TestTimingCache.MIN_SCALE, 
TestTimingCache.computeScale(tooFast), 0.0);
+
+        List<long[]> tooSlow = Collections.singletonList(new long[]{50_000L, 
1_000L});
+        Assert.assertEquals(TestTimingCache.MAX_SCALE, 
TestTimingCache.computeScale(tooSlow), 0.0);
+    }
+
+    @Test
+    public void estimateRemainingUsesScaledHistoryAndFallbackAverage() {
+        Map<String, Long> cached = new HashMap<>();
+        cached.put("A#a", 1_000L);
+        cached.put("B#b", 2_000L);
+
+        Set<String> remaining = new HashSet<>(Arrays.asList("A#a", "C#c"));
+        List<long[]> observedVsCached = Collections.singletonList(new 
long[]{1_500L, 1_000L});
+        List<Long> completed = Collections.singletonList(1_500L);
+
+        // scale = 1.5 → A contributes 1500; C uncached → fallback avg 1500
+        long eta = TestTimingCache.estimateRemainingMs(remaining, cached, 
observedVsCached, completed, 0L);
+        Assert.assertEquals(3_000L, eta);
+    }
+
+    @Test
+    public void estimateRemainingUsesHistoricalAverageWhenNothingCompleted() {
+        Map<String, Long> cached = new HashMap<>();
+        cached.put("A#a", 1_000L);
+        cached.put("B#b", 3_000L);
+
+        Set<String> remaining = Collections.singleton("C#c");
+        long eta = TestTimingCache.estimateRemainingMs(
+                remaining, cached, Collections.emptyList(), 
Collections.emptyList(), 0L);
+        // avg of history = 2000
+        Assert.assertEquals(2_000L, eta);
+    }
+}
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate16xToCurrentVersionIT.java
 
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate16xToCurrentVersionIT.java
index 214f71fa8..bd03f8436 100644
--- 
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate16xToCurrentVersionIT.java
+++ 
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate16xToCurrentVersionIT.java
@@ -26,19 +26,23 @@ import org.apache.unomi.api.conditions.ConditionType;
 import org.apache.unomi.api.tenants.Tenant;
 import org.apache.unomi.geonames.services.GeonameEntry;
 import org.apache.unomi.itests.BaseIT;
+import org.apache.unomi.itests.persistence.SearchBackendIT;
 import org.apache.unomi.persistence.spi.aggregate.TermsAggregate;
 import org.apache.unomi.shell.migration.utils.HttpUtils;
 import org.apache.unomi.shell.migration.utils.MigrationUtils;
 import org.junit.After;
 import org.junit.Assert;
+import org.junit.Assume;
 import org.junit.Before;
 import org.junit.Test;
+import org.junit.experimental.categories.Category;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.*;
 
+@Category(SearchBackendIT.class)
 public class Migrate16xToCurrentVersionIT extends BaseIT {
 
     private final static Logger LOGGER = 
LoggerFactory.getLogger(Migrate16xToCurrentVersionIT.class);
@@ -54,7 +58,7 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
 
     // Elasticsearch connection constants
     private static String getEsBaseUrl() {
-        return "http://localhost:"; + getSearchPort();
+        return getSearchEngineBaseUrl();
     }
     private static String getEsSnapshotRepo() {
         return getEsBaseUrl() + "/_snapshot/snapshots_repository/";
@@ -109,8 +113,9 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
         // This is called from BaseIT and will run before any migration setup
         checkSearchEngine();
 
-        if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            System.out.println("Migration from 1.x to 2.x not supported for 
OpenSearch, skipping snapshot restore");
+        if (!persistenceCapabilities().snapshotRestoreMigration()) {
+            System.out.println("snapshotRestoreMigration not supported for 
provider "
+                    + getPersistenceBackend().providerId() + " — starting 
Unomi without legacy snapshot restore");
             super.waitForStartup();
             return;
         }
@@ -119,7 +124,7 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
         LOGGER.info("Restoring snapshot into search engine...");
 
         // Restore snapshot from 1.6.x
-        try (CloseableHttpClient httpClient = HttpUtils.initHttpClient(true, 
null)) {
+        try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
             // Create snapshot repo
             HttpUtils.executePutRequest(httpClient, getEsSnapshotRepo(), 
resourceAsString(RESOURCE_CREATE_SNAPSHOTS_REPO), null);
             // Get snapshot, insure it exists
@@ -199,10 +204,10 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
      */
     @Test
     public void checkMigratedData() throws Exception {
-        if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            System.out.println("Migration from 1.x to 2.x not supported for 
OpenSearch, skipping checks");
-            return;
-        }
+        Assume.assumeTrue(
+                "snapshotRestoreMigration not supported for provider "
+                        + getPersistenceBackend().providerId(),
+                persistenceCapabilities().snapshotRestoreMigration());
         checkMergedProfilesAliases();
         checkProfileInterests();
         checkProfileTotalNbOfVisits();
@@ -654,10 +659,10 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
      * Test that the default tenant was created during migration 
(migrate-3.1.0-10-tenantInitialization)
      */
     private void checkDefaultTenantCreated() throws Exception {
-        if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            System.out.println("Migration from 1.x to 2.x not supported for 
OpenSearch, skipping checks");
-            return;
-        }
+        Assume.assumeTrue(
+                "snapshotRestoreMigration not supported for provider "
+                        + getPersistenceBackend().providerId(),
+                persistenceCapabilities().snapshotRestoreMigration());
 
         // Check that the default tenant index exists
         Assert.assertTrue("Default tenant index should exist", 
MigrationUtils.indexExists(httpClient, getEsBaseUrl(), INDEX_PREFIX_CONTEXT + 
"tenant"));
@@ -692,10 +697,10 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
      * have proper tenant information, audit metadata, and are accessible via 
definitionsService.
      */
     private void checkDefinitionsServiceObjectsAccessible() throws Exception {
-        if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            System.out.println("Migration from 1.x to 2.x not supported for 
OpenSearch, skipping checks");
-            return;
-        }
+        Assume.assumeTrue(
+                "snapshotRestoreMigration not supported for provider "
+                        + getPersistenceBackend().providerId(),
+                persistenceCapabilities().snapshotRestoreMigration());
 
         // Refresh the definitions service cache to ensure migrated items are 
loaded
         // This is necessary because items might be in persistence but not yet 
in cache
@@ -811,10 +816,10 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
      * all condition types that use legacy *ESQueryBuilder syntax to use the 
new generic QueryBuilder syntax.
      */
     private void checkLegacyQueryBuilderMigration() throws Exception {
-        if (SEARCH_ENGINE_OPENSEARCH.equals(searchEngine)) {
-            System.out.println("Migration from 1.x to 2.x not supported for 
OpenSearch, skipping checks");
-            return;
-        }
+        Assume.assumeTrue(
+                "snapshotRestoreMigration not supported for provider "
+                        + getPersistenceBackend().providerId(),
+                persistenceCapabilities().snapshotRestoreMigration());
 
         // Refresh the definitions service cache to ensure migrated items are 
loaded
         definitionsService.refresh();
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/persistence/ElasticsearchITBackend.java
 
b/itests/src/test/java/org/apache/unomi/itests/persistence/ElasticsearchITBackend.java
new file mode 100644
index 000000000..467c9d0fd
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/persistence/ElasticsearchITBackend.java
@@ -0,0 +1,117 @@
+/*
+ * 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.persistence;
+
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.unomi.shell.migration.utils.HttpUtils;
+import org.ops4j.pax.exam.Option;
+
+import java.io.IOException;
+
+import static org.ops4j.pax.exam.CoreOptions.maven;
+import static 
org.ops4j.pax.exam.karaf.options.KarafDistributionOption.editConfigurationFilePut;
+import static 
org.ops4j.pax.exam.karaf.options.KarafDistributionOption.features;
+
+/**
+ * Built-in Elasticsearch backend for Unomi integration tests.
+ */
+public class ElasticsearchITBackend implements PersistenceITBackend {
+
+    @Override
+    public String providerId() {
+        return PersistenceITBackendResolver.PROVIDER_ELASTICSEARCH;
+    }
+
+    @Override
+    public Option[] featureOptions() {
+        return new Option[]{
+                features(
+                        
maven().groupId("org.apache.unomi").artifactId("unomi-kar").versionAsInProject().type("xml").classifier("features"),
+                        "unomi-base",
+                        "unomi-startup",
+                        "unomi-elasticsearch-core",
+                        "unomi-persistence-core",
+                        "unomi-services",
+                        "unomi-cxs-privacy-extension-services",
+                        "unomi-plugins-base",
+                        "unomi-plugins-request",
+                        "unomi-plugins-mail",
+                        "unomi-plugins-optimization-test",
+                        "unomi-rest-api",
+                        "unomi-cxs-privacy-extension",
+                        "unomi-elasticsearch-conditions",
+                        "unomi-cxs-lists-extension",
+                        "unomi-cxs-geonames-extension",
+                        "unomi-shell-dev-commands",
+                        "unomi-wab",
+                        "unomi-web-tracker",
+                        "unomi-healthcheck-elasticsearch",
+                        "unomi-router-karaf-feature",
+                        "unomi-groovy-actions",
+                        "unomi-rest-ui",
+                        "cdp-graphql-feature",
+                        "unomi-startup-complete"
+                ),
+                features(
+                        
maven().groupId("org.apache.unomi").artifactId("unomi-distribution").versionAsInProject().type("xml").classifier("features"),
+                        "unomi-distribution-elasticsearch-graphql"
+                )
+        };
+    }
+
+    @Override
+    public Option[] configurationOptions() {
+        String port = searchPort();
+        return new Option[]{
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.cluster.name", "contextElasticSearchITests"),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.addresses", "localhost:" + port),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.taskWaitingPollingInterval", "50"),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.rollover.maxDocs", "300"),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.elasticsearch.minimalClusterState", "YELLOW"),
+        };
+    }
+
+    @Override
+    public String distributionFeature() {
+        return "unomi-distribution-elasticsearch";
+    }
+
+    @Override
+    public String persistenceConfigPid() {
+        return "org.apache.unomi.persistence.elasticsearch";
+    }
+
+    @Override
+    public PersistenceITCapabilities capabilities() {
+        return PersistenceITCapabilities.elasticsearch();
+    }
+
+    @Override
+    public String searchBaseUrl() {
+        return "http://localhost:"; + searchPort();
+    }
+
+    @Override
+    public String searchPort() {
+        return System.getProperty("elasticsearch.port", "9400");
+    }
+
+    @Override
+    public CloseableHttpClient createSearchHttpClient() throws IOException {
+        return HttpUtils.initHttpClient(true, null);
+    }
+}
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/persistence/OpenSearchITBackend.java
 
b/itests/src/test/java/org/apache/unomi/itests/persistence/OpenSearchITBackend.java
new file mode 100644
index 000000000..04e182264
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/persistence/OpenSearchITBackend.java
@@ -0,0 +1,130 @@
+/*
+ * 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.persistence;
+
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.unomi.shell.migration.utils.HttpUtils;
+import org.ops4j.pax.exam.Option;
+
+import java.io.IOException;
+
+import static org.ops4j.pax.exam.CoreOptions.maven;
+import static 
org.ops4j.pax.exam.karaf.options.KarafDistributionOption.editConfigurationFilePut;
+import static 
org.ops4j.pax.exam.karaf.options.KarafDistributionOption.features;
+
+/**
+ * Built-in OpenSearch backend for Unomi integration tests.
+ */
+public class OpenSearchITBackend implements PersistenceITBackend {
+
+    private static final String OS_USER = "admin";
+    private static final String OS_PASSWORD = "Unomi.1ntegrat10n.Tests";
+
+    @Override
+    public String providerId() {
+        return PersistenceITBackendResolver.PROVIDER_OPENSEARCH;
+    }
+
+    @Override
+    public Option[] featureOptions() {
+        return new Option[]{
+                features(
+                        
maven().groupId("org.apache.unomi").artifactId("unomi-kar").versionAsInProject().type("xml").classifier("features"),
+                        "unomi-base",
+                        "unomi-startup",
+                        "unomi-opensearch-core",
+                        "unomi-persistence-core",
+                        "unomi-services",
+                        "unomi-cxs-privacy-extension-services",
+                        "unomi-plugins-base",
+                        "unomi-plugins-request",
+                        "unomi-plugins-mail",
+                        "unomi-plugins-optimization-test",
+                        "unomi-rest-api",
+                        "unomi-cxs-privacy-extension",
+                        "unomi-opensearch-conditions",
+                        "unomi-cxs-lists-extension",
+                        "unomi-cxs-geonames-extension",
+                        "unomi-shell-dev-commands",
+                        "unomi-wab",
+                        "unomi-web-tracker",
+                        "unomi-healthcheck-opensearch",
+                        "unomi-router-karaf-feature",
+                        "unomi-groovy-actions",
+                        "unomi-rest-ui",
+                        "cdp-graphql-feature",
+                        "unomi-startup-complete"
+                ),
+                features(
+                        
maven().groupId("org.apache.unomi").artifactId("unomi-distribution").versionAsInProject().type("xml").classifier("features"),
+                        "unomi-distribution-opensearch-graphql"
+                )
+        };
+    }
+
+    @Override
+    public Option[] configurationOptions() {
+        String port = searchPort();
+        return new Option[]{
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.cluster.name", "contextElasticSearchITests"),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.addresses", "localhost:" + port),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.username", OS_USER),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.password", OS_PASSWORD),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.sslEnable", "false"),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.sslTrustAllCertificates", "true"),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.rollover.maxDocs", "300"),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.opensearch.minimalClusterState", "YELLOW"),
+        };
+    }
+
+    @Override
+    public String distributionFeature() {
+        return "unomi-distribution-opensearch";
+    }
+
+    @Override
+    public String persistenceConfigPid() {
+        return "org.apache.unomi.persistence.opensearch";
+    }
+
+    @Override
+    public PersistenceITCapabilities capabilities() {
+        return PersistenceITCapabilities.opensearch();
+    }
+
+    @Override
+    public String searchBaseUrl() {
+        return "http://localhost:"; + searchPort();
+    }
+
+    @Override
+    public String searchPort() {
+        return System.getProperty("org.apache.unomi.opensearch.addresses", 
"localhost:9401")
+                .split(":")[1];
+    }
+
+    @Override
+    public CloseableHttpClient createSearchHttpClient() throws IOException {
+        BasicCredentialsProvider credentialsProvider = new 
BasicCredentialsProvider();
+        credentialsProvider.setCredentials(AuthScope.ANY,
+                new UsernamePasswordCredentials(OS_USER, OS_PASSWORD));
+        return HttpUtils.initHttpClient(true, credentialsProvider);
+    }
+}
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITBackend.java
 
b/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITBackend.java
new file mode 100644
index 000000000..50beaa657
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITBackend.java
@@ -0,0 +1,92 @@
+/*
+ * 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.persistence;
+
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.cm.ConfigurationAdmin;
+import org.ops4j.pax.exam.Option;
+
+import java.io.IOException;
+
+/**
+ * Test-only contract for provisioning and awaiting a {@code 
PersistenceService} provider
+ * during Pax Exam integration tests.
+ * <p>
+ * Built-in implementations cover Elasticsearch and OpenSearch. Additional 
providers register
+ * via {@link java.util.ServiceLoader} or {@code 
-Dunomi.persistence.it.backend=<fqcn>}.
+ */
+public interface PersistenceITBackend {
+
+    /** Provider id (e.g. {@code elasticsearch}, {@code opensearch}). */
+    String providerId();
+
+    /**
+     * Karaf feature options for this provider (kar features + distribution 
feature).
+     */
+    Option[] featureOptions();
+
+    /**
+     * Provider-specific {@code etc/custom.system.properties} (and related) 
Pax Exam options.
+     */
+    Option[] configurationOptions();
+
+    /** Distribution feature name passed to {@code unomi:setup -d=…}. */
+    String distributionFeature();
+
+    /**
+     * Wait until the backend is usable before Unomi starts (cluster health, 
JDBC ping, …).
+     * Default is a no-op; search backends typically poll HTTP health from 
{@code BaseIT}.
+     * <p>
+     * Called from the Pax Exam driver / early {@code @Before} path — do not 
assume OSGi
+     * services are available yet. Prefer {@link #prepareBeforeUnomiSetup} for 
ConfigAdmin work.
+     */
+    default void awaitBackendReady() throws Exception {
+    }
+
+    /**
+     * Called in the Karaf JVM after {@code UnomiManagementService} is 
available and before
+     * {@code unomi:setup}. Use for ConfigAdmin DataSource patching, bundle 
restarts, etc.
+     */
+    default void prepareBeforeUnomiSetup(BundleContext bundleContext, 
ConfigurationAdmin configurationAdmin)
+            throws Exception {
+    }
+
+    /** Optional verification after Unomi has started. */
+    default void assertHealthyAfterUnomiStart() throws Exception {
+    }
+
+    /** ConfigAdmin PID for {@code org.apache.unomi.persistence.*} knobs (e.g. 
{@code throwExceptions}). */
+    String persistenceConfigPid();
+
+    PersistenceITCapabilities capabilities();
+
+    /** HTTP base URL when {@link PersistenceITCapabilities#httpAdminApi()} is 
true. */
+    default String searchBaseUrl() {
+        throw new UnsupportedOperationException(providerId() + " does not 
expose an HTTP admin API");
+    }
+
+    /** Search HTTP listen port when {@link 
PersistenceITCapabilities#httpAdminApi()} is true. */
+    default String searchPort() {
+        throw new UnsupportedOperationException(providerId() + " does not 
expose an HTTP admin API");
+    }
+
+    /** HTTP client for admin APIs (may include basic auth). */
+    default CloseableHttpClient createSearchHttpClient() throws IOException {
+        throw new UnsupportedOperationException(providerId() + " does not 
expose an HTTP admin API");
+    }
+}
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITBackendResolver.java
 
b/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITBackendResolver.java
new file mode 100644
index 000000000..5c8466237
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITBackendResolver.java
@@ -0,0 +1,110 @@
+/*
+ * 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.persistence;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.ServiceLoader;
+
+/**
+ * Resolves the active {@link PersistenceITBackend} for the IT harness.
+ * <p>
+ * Resolution order:
+ * <ol>
+ *   <li>{@code 
-Dunomi.persistence.it.backend=<fully.qualified.ClassName>}</li>
+ *   <li>{@link ServiceLoader} implementations matching the provider id</li>
+ *   <li>Built-in Elasticsearch / OpenSearch backends</li>
+ * </ol>
+ * Provider id comes from {@code unomi.persistence.provider}, falling back to 
the
+ * deprecated {@code unomi.search.engine} alias (default {@code 
elasticsearch}).
+ */
+public final class PersistenceITBackendResolver {
+
+    public static final String PROVIDER_PROPERTY = 
"unomi.persistence.provider";
+    /** @deprecated use {@link #PROVIDER_PROPERTY}; kept for existing scripts 
and CI. */
+    @Deprecated
+    public static final String SEARCH_ENGINE_PROPERTY = "unomi.search.engine";
+    public static final String BACKEND_CLASS_PROPERTY = 
"unomi.persistence.it.backend";
+
+    public static final String PROVIDER_ELASTICSEARCH = "elasticsearch";
+    public static final String PROVIDER_OPENSEARCH = "opensearch";
+
+    private PersistenceITBackendResolver() {
+    }
+
+    public static String resolveProviderId() {
+        String provider = System.getProperty(PROVIDER_PROPERTY);
+        if (provider != null && !provider.isBlank()) {
+            return provider.trim();
+        }
+        return System.getProperty(SEARCH_ENGINE_PROPERTY, 
PROVIDER_ELASTICSEARCH).trim();
+    }
+
+    public static PersistenceITBackend resolve() {
+        String explicitClass = System.getProperty(BACKEND_CLASS_PROPERTY);
+        if (explicitClass != null && !explicitClass.isBlank()) {
+            return instantiate(explicitClass.trim());
+        }
+
+        String providerId = resolveProviderId();
+        Map<String, PersistenceITBackend> byId = loadBackendsById();
+        PersistenceITBackend backend = byId.get(providerId);
+        if (backend != null) {
+            return backend;
+        }
+
+        throw new IllegalArgumentException(
+                "No PersistenceITBackend registered for provider '" + 
providerId + "'. "
+                        + "Set -D" + PROVIDER_PROPERTY + "=<id> (or deprecated 
-D" + SEARCH_ENGINE_PROPERTY + "), "
+                        + "register a ServiceLoader implementation, or set -D" 
+ BACKEND_CLASS_PROPERTY + "=<fqcn>. "
+                        + "Known built-in / discovered ids: " + byId.keySet());
+    }
+
+    private static Map<String, PersistenceITBackend> loadBackendsById() {
+        Map<String, PersistenceITBackend> byId = new LinkedHashMap<>();
+        // Built-ins first so ServiceLoader can override the same id if 
desired.
+        register(byId, new ElasticsearchITBackend());
+        register(byId, new OpenSearchITBackend());
+        for (PersistenceITBackend backend : 
ServiceLoader.load(PersistenceITBackend.class)) {
+            register(byId, backend);
+        }
+        return byId;
+    }
+
+    private static void register(Map<String, PersistenceITBackend> byId, 
PersistenceITBackend backend) {
+        byId.put(backend.providerId(), backend);
+    }
+
+    private static PersistenceITBackend instantiate(String fqcn) {
+        try {
+            Class<?> clazz = Class.forName(fqcn);
+            if (!PersistenceITBackend.class.isAssignableFrom(clazz)) {
+                throw new IllegalArgumentException(fqcn + " does not implement 
PersistenceITBackend");
+            }
+            return (PersistenceITBackend) 
clazz.getDeclaredConstructor().newInstance();
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalArgumentException("Failed to instantiate 
PersistenceITBackend " + fqcn, e);
+        }
+    }
+
+    /** Visible for tests / diagnostics. */
+    static List<String> knownProviderIds() {
+        return new ArrayList<>(loadBackendsById().keySet());
+    }
+}
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITCapabilities.java
 
b/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITCapabilities.java
new file mode 100644
index 000000000..707bc4301
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/persistence/PersistenceITCapabilities.java
@@ -0,0 +1,204 @@
+/*
+ * 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.persistence;
+
+/**
+ * Backend-agnostic IT capabilities. Describe <em>what</em> the harness and 
tests may
+ * rely on (HTTP admin surface, rollover API shape, health probes), not which 
vendor
+ * product is installed.
+ * <p>
+ * Built-in Elasticsearch / OpenSearch factories set these to match today’s 
search
+ * engines; JDBC and other providers typically use {@link #none()}.
+ */
+public final class PersistenceITCapabilities {
+
+    /**
+     * HTTP API shape used to inspect or assert index rollover policy wiring.
+     * Named after the protocol, not a vendor.
+     */
+    public enum IndexRolloverApi {
+        /** No rollover HTTP API (typical for SQL / embedded stores). */
+        NONE,
+        /**
+         * Lifecycle-policy HTTP API (paths/settings of the form {@code _ilm},
+         * {@code index.lifecycle.*}).
+         */
+        LIFECYCLE,
+        /**
+         * State-management-policy HTTP API (paths/settings of the form
+         * {@code _plugins/_ism}, {@code index_state_management.*}).
+         */
+        STATE_MANAGEMENT;
+
+        public boolean isPresent() {
+            return this != NONE;
+        }
+    }
+
+    /**
+     * Expected outcome of range queries on flattened / nested document 
properties.
+     */
+    public enum FlattenedRangeQueryResult {
+        /** Query succeeds with null or an empty hit list. */
+        EMPTY,
+        /** Query may return matching documents. */
+        HITS
+    }
+
+    private final boolean httpAdminApi;
+    private final boolean providerNamedHealthProbe;
+    private final boolean clusterHealthProbe;
+    private final IndexRolloverApi indexRolloverApi;
+    private final boolean snapshotRestoreMigration;
+    private final FlattenedRangeQueryResult flattenedRangeQueryResult;
+
+    private PersistenceITCapabilities(Builder builder) {
+        this.httpAdminApi = builder.httpAdminApi;
+        this.providerNamedHealthProbe = builder.providerNamedHealthProbe;
+        this.clusterHealthProbe = builder.clusterHealthProbe;
+        this.indexRolloverApi = builder.indexRolloverApi;
+        this.snapshotRestoreMigration = builder.snapshotRestoreMigration;
+        this.flattenedRangeQueryResult = builder.flattenedRangeQueryResult;
+    }
+
+    /**
+     * Backend exposes HTTP admin APIs for cluster/index/snapshot operations
+     * (typical of search engines). When false, {@code BaseIT} skips HTTP 
health
+     * prep and search helpers refuse to run.
+     */
+    public boolean httpAdminApi() {
+        return httpAdminApi;
+    }
+
+    /**
+     * Health check includes a probe whose name equals the persistence 
provider id.
+     */
+    public boolean providerNamedHealthProbe() {
+        return providerNamedHealthProbe;
+    }
+
+    /**
+     * Health check includes a cluster-level probe (name {@code cluster}).
+     */
+    public boolean clusterHealthProbe() {
+        return clusterHealthProbe;
+    }
+
+    /**
+     * Rollover policy HTTP API shape, or {@link IndexRolloverApi#NONE}.
+     */
+    public IndexRolloverApi indexRolloverApi() {
+        return indexRolloverApi;
+    }
+
+    /**
+     * Backend can exercise legacy snapshot-restore migration fixtures
+     * (search-engine snapshot HTTP + restore into current indices).
+     */
+    public boolean snapshotRestoreMigration() {
+        return snapshotRestoreMigration;
+    }
+
+    /**
+     * How range queries on flattened properties behave for assertions.
+     */
+    public FlattenedRangeQueryResult flattenedRangeQueryResult() {
+        return flattenedRangeQueryResult;
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /** No optional capabilities — JDBC, embedded, or incomplete providers. */
+    public static PersistenceITCapabilities none() {
+        return builder().build();
+    }
+
+    /** @deprecated use {@link #none()} */
+    @Deprecated
+    public static PersistenceITCapabilities nonSearchBackend() {
+        return none();
+    }
+
+    public static PersistenceITCapabilities elasticsearch() {
+        return builder()
+                .httpAdminApi(true)
+                .providerNamedHealthProbe(true)
+                .clusterHealthProbe(true)
+                .indexRolloverApi(IndexRolloverApi.LIFECYCLE)
+                .snapshotRestoreMigration(true)
+                .flattenedRangeQueryResult(FlattenedRangeQueryResult.EMPTY)
+                .build();
+    }
+
+    public static PersistenceITCapabilities opensearch() {
+        return builder()
+                .httpAdminApi(true)
+                .providerNamedHealthProbe(true)
+                .clusterHealthProbe(true)
+                .indexRolloverApi(IndexRolloverApi.STATE_MANAGEMENT)
+                .snapshotRestoreMigration(false)
+                .flattenedRangeQueryResult(FlattenedRangeQueryResult.HITS)
+                .build();
+    }
+
+    public static final class Builder {
+        private boolean httpAdminApi;
+        private boolean providerNamedHealthProbe;
+        private boolean clusterHealthProbe;
+        private IndexRolloverApi indexRolloverApi = IndexRolloverApi.NONE;
+        private boolean snapshotRestoreMigration;
+        private FlattenedRangeQueryResult flattenedRangeQueryResult = 
FlattenedRangeQueryResult.EMPTY;
+
+        public Builder httpAdminApi(boolean httpAdminApi) {
+            this.httpAdminApi = httpAdminApi;
+            return this;
+        }
+
+        public Builder providerNamedHealthProbe(boolean 
providerNamedHealthProbe) {
+            this.providerNamedHealthProbe = providerNamedHealthProbe;
+            return this;
+        }
+
+        public Builder clusterHealthProbe(boolean clusterHealthProbe) {
+            this.clusterHealthProbe = clusterHealthProbe;
+            return this;
+        }
+
+        public Builder indexRolloverApi(IndexRolloverApi indexRolloverApi) {
+            this.indexRolloverApi = indexRolloverApi != null ? 
indexRolloverApi : IndexRolloverApi.NONE;
+            return this;
+        }
+
+        public Builder snapshotRestoreMigration(boolean 
snapshotRestoreMigration) {
+            this.snapshotRestoreMigration = snapshotRestoreMigration;
+            return this;
+        }
+
+        public Builder flattenedRangeQueryResult(FlattenedRangeQueryResult 
flattenedRangeQueryResult) {
+            this.flattenedRangeQueryResult = flattenedRangeQueryResult != null
+                    ? flattenedRangeQueryResult
+                    : FlattenedRangeQueryResult.EMPTY;
+            return this;
+        }
+
+        public PersistenceITCapabilities build() {
+            return new PersistenceITCapabilities(this);
+        }
+    }
+}
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/persistence/SearchBackendIT.java 
b/itests/src/test/java/org/apache/unomi/itests/persistence/SearchBackendIT.java
new file mode 100644
index 000000000..9f8f81820
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/persistence/SearchBackendIT.java
@@ -0,0 +1,29 @@
+/*
+ * 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.persistence;
+
+/**
+ * Marker category for ITs whose <em>primary</em> assertions need HTTP admin 
APIs
+ * (snapshot restore, index rollover policy HTTP). Prefer keeping these 
classes in
+ * {@link org.apache.unomi.itests.CorePersistenceITs} / {@link 
org.apache.unomi.itests.AllITs}
+ * and gating with {@link org.junit.Assume} on {@link 
PersistenceITCapabilities} so unsupported
+ * backends skip rather than losing suite membership.
+ * <p>
+ * Optional Failsafe {@code excludedGroups} may still list this category for 
specialized cells.
+ */
+public interface SearchBackendIT {
+}
diff --git 
a/itests/src/test/resources/META-INF/services/org.apache.unomi.itests.persistence.PersistenceITBackend
 
b/itests/src/test/resources/META-INF/services/org.apache.unomi.itests.persistence.PersistenceITBackend
new file mode 100644
index 000000000..17e9a7ed1
--- /dev/null
+++ 
b/itests/src/test/resources/META-INF/services/org.apache.unomi.itests.persistence.PersistenceITBackend
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+
+org.apache.unomi.itests.persistence.ElasticsearchITBackend
+org.apache.unomi.itests.persistence.OpenSearchITBackend

Reply via email to