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 39d6c640b UNOMI-1002: Stop serializing public requests in 
compatibility mode (#915)
39d6c640b is described below

commit 39d6c640bd2730072270dc8f6ab3f29b1f99743d
Author: Serge Huber <[email protected]>
AuthorDate: Tue Oct 13 07:41:53 2026 +0200

    UNOMI-1002: Stop serializing public requests in compatibility mode (#915)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../apache/unomi/api/tenants/TenantService.java    |  19 +-
 .../asciidoc/migrations/v2-compatibility-mode.adoc |   3 +
 .../AuthenticationFilterBlankPasswordTest.java     |  24 ++-
 .../services/impl/tenants/TenantServiceImpl.java   |  76 ++++++-
 .../impl/tenants/TenantServiceImplTest.java        | 228 +++++++++++++++++++++
 5 files changed, 340 insertions(+), 10 deletions(-)

diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java 
b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
index dff0128b2..136e80e17 100644
--- a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
@@ -55,17 +55,14 @@ public interface TenantService {
      */
     ApiKeyCreationResult generateApiKey(String tenantId, Long validityPeriod);
 
-    /**
-     * Retrieves a tenant by its ID.
-     *
-     * @param tenantId the ID of the tenant to retrieve
-     * @return the Tenant object if found, null otherwise
-     */
     /**
      * Returns the tenant with the given identifier, and creates it when it 
does not exist yet.
      * <p>
-     * Two callers that race on the same identifier both get the tenant, and 
only one creation
-     * happens.
+     * Two callers on the same node that race on the same identifier both get 
the tenant, and only
+     * one creation happens.
+     * <p>
+     * The result may be cached for a short time and shared between callers: 
do not modify it, and
+     * use {@link #getTenant(String)} when the current state of the tenant 
matters.
      *
      * @param tenantId   the identifier of the tenant
      * @param properties the properties to set when the tenant has to be 
created, may be {@code null}
@@ -73,6 +70,12 @@ public interface TenantService {
      */
     Tenant getOrCreateTenant(String tenantId, Map<String, Object> properties);
 
+    /**
+     * Retrieves a tenant by its ID.
+     *
+     * @param tenantId the ID of the tenant to retrieve
+     * @return the Tenant object if found, null otherwise
+     */
     Tenant getTenant(String tenantId);
 
     /**
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc 
b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
index dfded3512..ea53c442a 100644
--- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -29,6 +29,9 @@ When single-tenant compatibility mode is enabled:
 - **Protected events** (like `login`, `updateProperties`) require IP + 
X-Unomi-Peer (like V2)
 - **Private endpoints** require system administrator authentication (like V2)
 - **Tenant `default`** is used for every operation. The mode cannot be pointed 
at another tenant.
+  The tenant is created on a public request if it is missing. Each node then 
keeps it in memory for
+  about 30 seconds, so public requests do not wait on each other or re-read it 
from the database
+  every time. A change made on another node is picked up after about that time.
 - **No authentication** is required for non-protected events (like V2)
 
 This allows V2 clients to work with Unomi V3 immediately after migration, 
giving you time to gradually update client applications to use the new V3 
authentication model.
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
index 1b4baac8e..fa707202a 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
@@ -59,6 +59,7 @@ class AuthenticationFilterBlankPasswordTest {
 
     private RestAuthenticationConfig restAuthenticationConfig;
     private TenantService tenantService;
+    private SecurityService securityService;
     private JAASAuthenticationFilter jaasAuthenticationFilter;
     private AuthenticationFilter filter;
 
@@ -66,6 +67,7 @@ class AuthenticationFilterBlankPasswordTest {
     void setUp() {
         restAuthenticationConfig = mock(RestAuthenticationConfig.class);
         tenantService = mock(TenantService.class);
+        securityService = mock(SecurityService.class);
         // Stubbed so the tests below can assert the credential never reached 
JAAS. Every refusal
         // path in the filter answers 401, so the status alone cannot tell 
"refused for a blank
         // password" apart from "JAAS rejected it" — only this can.
@@ -73,7 +75,7 @@ class AuthenticationFilterBlankPasswordTest {
         filter = new AuthenticationFilter(
                 restAuthenticationConfig,
                 tenantService,
-                mock(SecurityService.class),
+                securityService,
                 mock(ExecutionContextManager.class),
                 jaasAuthenticationFilter);
     }
@@ -216,6 +218,26 @@ class AuthenticationFilterBlankPasswordTest {
         verify(tenantService).getOrCreateTenant(eq("default"), any());
     }
 
+    /**
+     * Compatibility mode always uses tenant {@code default}. A client must 
not be able to steer
+     * which tenant is created via request headers (UNOMI-1002).
+     */
+    @Test
+    void 
filterAlwaysUsesDefaultTenantOnPublicPathInSingleTenantCompatibilityMode() 
throws IOException {
+        
when(restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()).thenReturn(true);
+        when(restAuthenticationConfig.getPublicPathPatterns())
+                .thenReturn(Collections.singletonList(Pattern.compile("POST 
context\\.json")));
+        ContainerRequestContext requestContext = request("context.json", null);
+        
when(requestContext.getHeaderString("X-Unomi-Tenant-Id")).thenReturn("attacker-tenant");
+        
when(requestContext.getHeaderString("X-Unomi-Api-Key")).thenReturn("some-key");
+
+        filter.filter(requestContext);
+
+        verify(tenantService).getOrCreateTenant(eq("default"), any());
+        verify(tenantService, 
never()).getOrCreateTenant(eq("attacker-tenant"), any());
+        verify(securityService).createSubject(eq("default"), eq(false));
+    }
+
     private void assertUnauthorizedWithoutReachingJaas(ContainerRequestContext 
requestContext) throws IOException {
         ArgumentCaptor<Response> aborted = 
ArgumentCaptor.forClass(Response.class);
         verify(requestContext).abortWith(aborted.capture());
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
index c2c6e474d..cc2951703 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
@@ -29,7 +29,11 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.LongSupplier;
 
 /**
  * Default implementation of {@link TenantService}.
@@ -40,6 +44,23 @@ public class TenantServiceImpl implements TenantService {
     private static final String TENANT_ID_PATTERN = 
"^[a-zA-Z0-9][a-zA-Z0-9-_]*[a-zA-Z0-9]$";
 
     private final List<TenantLifecycleListener> lifecycleListeners = new 
CopyOnWriteArrayList<>();
+    /**
+     * How long {@link #getOrCreateTenant} trusts a cached tenant before it 
reads persistence again.
+     * The cache is local to this node, so a change made on another node, or 
directly in the search
+     * engine, goes unnoticed here for about this long, and longer while a 
reload is still running.
+     */
+    static final long GET_OR_CREATE_CACHE_TTL_NANOS = 
TimeUnit.SECONDS.toNanos(30);
+
+    /**
+     * Hot-path cache for {@link #getOrCreateTenant}. Compatibility-mode 
public requests call that
+     * method on every context/eventcollector hit; without a cache they 
serialized on a method lock
+     * and re-read the tenant from persistence each time (UNOMI-1002).
+     */
+    private final ConcurrentHashMap<String, CachedTenant> getOrCreateCache = 
new ConcurrentHashMap<>();
+    /** Serializes load/create so concurrent first requests on this node 
create the tenant once. */
+    private final ReentrantLock getOrCreateLock = new ReentrantLock();
+    /** Replaced in tests to make cache entries expire without waiting. */
+    LongSupplier nanoClock = System::nanoTime;
     private PersistenceService persistenceService;
     private ExecutionContextManager executionContextManager;
     private SecretHashService secretHashService;
@@ -114,7 +135,35 @@ public class TenantServiceImpl implements TenantService {
     }
 
     @Override
-    public synchronized Tenant getOrCreateTenant(String tenantId, Map<String, 
Object> properties) {
+    public Tenant getOrCreateTenant(String tenantId, Map<String, Object> 
properties) {
+        if (tenantId == null) {
+            throw new IllegalArgumentException("Tenant ID cannot be null or 
empty");
+        }
+        CachedTenant cached = getOrCreateCache.get(tenantId);
+        if (isFresh(cached)) {
+            return cached.tenant;
+        }
+        if (cached == null) {
+            getOrCreateLock.lock();
+        } else if (!getOrCreateLock.tryLock()) {
+            // Another thread is already refreshing. Keep answering from the 
expired entry rather
+            // than queue every request behind that read.
+            return cached.tenant;
+        }
+        try {
+            cached = getOrCreateCache.get(tenantId);
+            if (isFresh(cached)) {
+                return cached.tenant;
+            }
+            Tenant tenant = loadOrCreateTenant(tenantId, properties);
+            getOrCreateCache.put(tenantId, new CachedTenant(tenant, 
nanoClock.getAsLong()));
+            return tenant;
+        } finally {
+            getOrCreateLock.unlock();
+        }
+    }
+
+    private Tenant loadOrCreateTenant(String tenantId, Map<String, Object> 
properties) {
         Tenant tenant = getTenant(tenantId);
         if (tenant != null) {
             return tenant;
@@ -132,6 +181,26 @@ public class TenantServiceImpl implements TenantService {
         }
     }
 
+    private boolean isFresh(CachedTenant cached) {
+        return cached != null && nanoClock.getAsLong() - cached.loadedAtNanos 
< GET_OR_CREATE_CACHE_TTL_NANOS;
+    }
+
+    private void invalidateGetOrCreateCache(String tenantId) {
+        if (tenantId != null) {
+            getOrCreateCache.remove(tenantId);
+        }
+    }
+
+    private static final class CachedTenant {
+        private final Tenant tenant;
+        private final long loadedAtNanos;
+
+        private CachedTenant(Tenant tenant, long loadedAtNanos) {
+            this.tenant = tenant;
+            this.loadedAtNanos = loadedAtNanos;
+        }
+    }
+
     @Override
     public Tenant createTenant(String requestedId, Map<String, Object> 
properties) {
         validateTenantId(requestedId);
@@ -198,6 +267,7 @@ public class TenantServiceImpl implements TenantService {
                 tenant.getApiKeys().removeIf(existingKey -> 
existingKey.getKeyType() == keyType);
                 tenant.getApiKeys().add(apiKey);
                 persistenceService.save(tenant);
+                invalidateGetOrCreateCache(tenantId);
             }
 
             return new ApiKeyCreationResult(apiKey, plainTextKey);
@@ -217,6 +287,9 @@ public class TenantServiceImpl implements TenantService {
     @Override
     public void saveTenant(Tenant tenant) {
         executionContextManager.executeAsSystem(() -> 
persistenceService.save(tenant));
+        if (tenant != null) {
+            invalidateGetOrCreateCache(tenant.getItemId());
+        }
     }
 
     @Override
@@ -236,6 +309,7 @@ public class TenantServiceImpl implements TenantService {
             }
             persistenceService.remove(tenantId, Tenant.class);
         });
+        invalidateGetOrCreateCache(tenantId);
     }
 
     @Override
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
index aad74bebf..12ec5b3b0 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
@@ -29,20 +29,38 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.LockSupport;
+import java.util.function.Consumer;
 import java.util.function.Supplier;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -61,9 +79,12 @@ public class TenantServiceImplTest {
 
     private TenantServiceImpl tenantService;
 
+    private final AtomicLong clock = new AtomicLong();
+
     @BeforeEach
     public void setUp() {
         tenantService = new TenantServiceImpl();
+        tenantService.nanoClock = clock::get;
         tenantService.setPersistenceService(persistenceService);
         tenantService.setExecutionContextManager(executionContextManager);
         tenantService.setSecretHashService(secretHashService);
@@ -82,6 +103,213 @@ public class TenantServiceImplTest {
         // without depending on the real hash implementation.
         when(secretHashService.verify(anyString(), anyString()))
                 .thenAnswer(invocation -> 
Objects.equals(invocation.getArgument(0), invocation.getArgument(1)));
+        when(secretHashService.hash(anyString())).thenAnswer(invocation -> 
invocation.getArgument(0));
+    }
+
+    @Test
+    public void getOrCreateTenant_readsPersistenceOnceAfterCacheWarm() {
+        Tenant existing = new Tenant();
+        existing.setItemId("default");
+        when(persistenceService.load("default", 
Tenant.class)).thenReturn(existing);
+
+        assertSame(existing, tenantService.getOrCreateTenant("default", 
Collections.emptyMap()));
+        assertSame(existing, tenantService.getOrCreateTenant("default", 
Collections.emptyMap()));
+        assertSame(existing, tenantService.getOrCreateTenant("default", 
Collections.emptyMap()));
+
+        verify(persistenceService, times(1)).load("default", Tenant.class);
+    }
+
+    @Test
+    public void getOrCreateTenant_reloadsAfterSave() {
+        Tenant first = new Tenant();
+        first.setItemId("default");
+        Map<String, Object> firstProps = new HashMap<>();
+        firstProps.put("name", "first");
+        first.setProperties(firstProps);
+
+        Tenant second = new Tenant();
+        second.setItemId("default");
+        Map<String, Object> secondProps = new HashMap<>();
+        secondProps.put("name", "second");
+        second.setProperties(secondProps);
+
+        when(persistenceService.load("default", 
Tenant.class)).thenReturn(first, second);
+
+        assertEquals("first", tenantService.getOrCreateTenant("default", 
Collections.emptyMap())
+                .getProperties().get("name"));
+
+        tenantService.saveTenant(second);
+        assertEquals("second", tenantService.getOrCreateTenant("default", 
Collections.emptyMap())
+                .getProperties().get("name"));
+
+        verify(persistenceService, times(2)).load("default", Tenant.class);
+    }
+
+    @Test
+    public void getOrCreateTenant_reloadsAfterDelete() {
+        Tenant existing = defaultTenant();
+        AtomicReference<Tenant> store = inMemoryTenantStore(existing, tenant 
-> { });
+
+        assertSame(existing, getOrCreateDefaultTenant());
+
+        tenantService.deleteTenant("default");
+        assertNull(store.get());
+
+        Tenant afterDelete = getOrCreateDefaultTenant();
+        assertNotSame(existing, afterDelete);
+        assertSame(store.get(), afterDelete);
+    }
+
+    @Test
+    public void getOrCreateTenant_reloadsAfterApiKeyGeneration() {
+        inMemoryTenantStore(defaultTenant(), tenant -> { });
+
+        getOrCreateDefaultTenant();
+        tenantService.generateApiKeyWithType("default", 
ApiKey.ApiKeyType.PUBLIC, null);
+        getOrCreateDefaultTenant();
+
+        // One read per getOrCreateTenant call, plus the one 
generateApiKeyWithType does itself.
+        verify(persistenceService, times(3)).load("default", Tenant.class);
+    }
+
+    /**
+     * Nothing tells this node about a delete done on another one, so the 
entry has to expire by
+     * itself for the tenant to be created again.
+     */
+    @Test
+    public void 
getOrCreateTenant_recreatesTenantDeletedOnAnotherNodeOnceEntryExpires() {
+        Tenant existing = defaultTenant();
+        AtomicReference<Tenant> store = inMemoryTenantStore(existing, tenant 
-> { });
+        assertSame(existing, getOrCreateDefaultTenant());
+
+        store.set(null);
+        assertSame(existing, getOrCreateDefaultTenant());
+
+        clock.addAndGet(TenantServiceImpl.GET_OR_CREATE_CACHE_TTL_NANOS);
+        Tenant recreated = getOrCreateDefaultTenant();
+        assertNotSame(existing, recreated);
+        assertSame(store.get(), recreated);
+    }
+
+    @Test
+    public void 
getOrCreateTenant_answersFromExpiredEntryWhileAnotherThreadRefreshes() throws 
Exception {
+        Tenant existing = defaultTenant();
+        CountDownLatch refreshStarted = new CountDownLatch(1);
+        CountDownLatch releaseRefresh = new CountDownLatch(1);
+        AtomicInteger loads = new AtomicInteger();
+        when(persistenceService.load("default", 
Tenant.class)).thenAnswer(invocation -> {
+            if (loads.incrementAndGet() > 1) {
+                refreshStarted.countDown();
+                releaseRefresh.await();
+            }
+            return existing;
+        });
+        getOrCreateDefaultTenant();
+        clock.addAndGet(TenantServiceImpl.GET_OR_CREATE_CACHE_TTL_NANOS);
+
+        ExecutorService pool = Executors.newSingleThreadExecutor();
+        try {
+            Future<Tenant> refresh = 
pool.submit(this::getOrCreateDefaultTenant);
+            assertTrue(refreshStarted.await(10, TimeUnit.SECONDS));
+
+            assertSame(existing, 
assertTimeoutPreemptively(Duration.ofSeconds(10), 
this::getOrCreateDefaultTenant));
+
+            releaseRefresh.countDown();
+            assertSame(existing, refresh.get(10, TimeUnit.SECONDS));
+        } finally {
+            releaseRefresh.countDown();
+            pool.shutdownNow();
+        }
+    }
+
+    @Test
+    public void getOrCreateTenant_concurrentFirstRequestsCreateOnce() throws 
Exception {
+        AtomicInteger creations = new AtomicInteger();
+        inMemoryTenantStore(null, tenant -> {
+            // Only createTenant's own save carries no API key yet. Hold it 
open so that callers
+            // which are not kept out by the lock would all get past the 
existence check.
+            if (tenant.getApiKeys() == null || tenant.getApiKeys().isEmpty()) {
+                creations.incrementAndGet();
+                LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(50));
+            }
+        });
+
+        int threads = 20;
+        ExecutorService pool = Executors.newFixedThreadPool(threads);
+        try {
+            CountDownLatch start = new CountDownLatch(1);
+            List<Future<Tenant>> futures = new ArrayList<>();
+            for (int i = 0; i < threads; i++) {
+                futures.add(pool.submit(() -> {
+                    start.await();
+                    return getOrCreateDefaultTenant();
+                }));
+            }
+            start.countDown();
+
+            for (Future<Tenant> future : futures) {
+                assertEquals("default", future.get(10, 
TimeUnit.SECONDS).getItemId());
+            }
+        } finally {
+            pool.shutdownNow();
+        }
+        assertEquals(1, creations.get(), "Exactly one tenant must be created 
under concurrent first requests");
+    }
+
+    @Test
+    public void 
getOrCreateTenant_returnsTenantCreatedByAnotherNodeDuringCreation() {
+        Tenant createdElsewhere = defaultTenant();
+        // Missing on the first read, there by the time createTenant checks 
for a duplicate.
+        when(persistenceService.load("default", 
Tenant.class)).thenReturn(null, createdElsewhere);
+
+        assertSame(createdElsewhere, getOrCreateDefaultTenant());
+
+        verify(persistenceService, never()).save(any(Tenant.class));
+    }
+
+    @Test
+    public void getOrCreateTenant_doesNotCacheFailure() {
+        Tenant existing = defaultTenant();
+        when(persistenceService.load("default", Tenant.class))
+                .thenThrow(new IllegalStateException("search engine 
unavailable"))
+                .thenReturn(existing);
+
+        assertThrows(IllegalStateException.class, 
this::getOrCreateDefaultTenant);
+        // Retried from another thread, which a lock left held by the failed 
call would block.
+        assertSame(existing, assertTimeoutPreemptively(Duration.ofSeconds(10), 
this::getOrCreateDefaultTenant));
+    }
+
+    @Test
+    public void getOrCreateTenant_rejectsNullTenantId() {
+        assertThrows(IllegalArgumentException.class,
+                () -> tenantService.getOrCreateTenant(null, 
Collections.emptyMap()));
+    }
+
+    private static Tenant defaultTenant() {
+        Tenant tenant = new Tenant();
+        tenant.setItemId("default");
+        return tenant;
+    }
+
+    private Tenant getOrCreateDefaultTenant() {
+        return tenantService.getOrCreateTenant("default", 
Collections.singletonMap("name", "default"));
+    }
+
+    /** Backs tenant {@code default} with a single in-memory slot; {@code 
onSave} sees each tenant before it is stored. */
+    private AtomicReference<Tenant> inMemoryTenantStore(Tenant initial, 
Consumer<Tenant> onSave) {
+        AtomicReference<Tenant> store = new AtomicReference<>(initial);
+        when(persistenceService.load(eq("default"), 
eq(Tenant.class))).thenAnswer(invocation -> store.get());
+        when(persistenceService.save(any(Tenant.class))).thenAnswer(invocation 
-> {
+            Tenant tenant = invocation.getArgument(0);
+            onSave.accept(tenant);
+            store.set(tenant);
+            return true;
+        });
+        doAnswer(invocation -> {
+            store.set(null);
+            return null;
+        }).when(persistenceService).remove("default", Tenant.class);
+        return store;
     }
 
     @Test

Reply via email to