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

ndipiazza pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 1c85fc34d TIKA-4576 add fetcher config store interface (#2460)
1c85fc34d is described below

commit 1c85fc34dc6718dd872fb0beb5cca1eba0c5e559
Author: Nicholas DiPiazza <[email protected]>
AuthorDate: Wed Dec 17 09:17:50 2025 -0600

    TIKA-4576 add fetcher config store interface (#2460)
    
    * TIKA-4576
    Create pluggable storage interface for Fetcher components with in-memory 
implementationi
    
    * TIKA-4576
    Create pluggable storage interface for Fetcher components with in-memory 
implementation
    
    * Address PR review comments
    
    - Return immutable copy in InMemoryConfigStore.keySet() to prevent 
ConcurrentModificationException
    - Add thread-safety documentation to ConfigStore interface
    - Add null handling documentation to ConfigStore methods
    - Add performance note about keySet() being inexpensive
    - Replace System.out.println with SLF4J logger in LoggingConfigStore
    - Add test for LoggingConfigStore to verify custom implementations work 
correctly
    
    * TIKA-4578 - Add profiles to enable Docker build for AWS, Azure, and 
Docker Hub
    
    ---------
    
    Co-authored-by: Nicholas DiPiazza <[email protected]>
---
 .../apache/tika/pipes/grpc/TikaGrpcServerImpl.java |  26 +++--
 .../tika/pipes/core/AbstractComponentManager.java  |  40 +++++--
 .../apache/tika/pipes/core/config/ConfigStore.java |  79 ++++++++++++++
 .../pipes/core/config/InMemoryConfigStore.java     |  56 ++++++++++
 .../tika/pipes/core/emitter/EmitterManager.java    |  26 +++++
 .../tika/pipes/core/fetcher/FetcherManager.java    |  26 +++++
 .../tika/pipes/core/config/ConfigStoreTest.java    | 115 +++++++++++++++++++++
 .../tika/pipes/core/config/LoggingConfigStore.java |  80 ++++++++++++++
 8 files changed, 429 insertions(+), 19 deletions(-)

diff --git 
a/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java 
b/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
index 2aedbfb94..c7e268c1d 100644
--- a/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
+++ b/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
@@ -204,17 +204,27 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
             // Check if fetcher already exists, if so, we need to update it
             if 
(fetcherManager.getSupported().contains(request.getFetcherId())) {
                 LOG.info("Updating existing fetcher: {}", 
request.getFetcherId());
-                // We can't update directly, so we need to work around this by 
using reflection
-                // or just accept that updates aren't supported in the new 
system
-                // For now, let's just replace it in the internal map using 
reflection
+                // We can't update directly through the API, so we use 
reflection to update
+                // the configStore's internal map and clear the componentCache
                 try {
-                    java.lang.reflect.Field configsField = 
fetcherManager.getClass().getSuperclass().getDeclaredField("componentConfigs");
-                    configsField.setAccessible(true);
-                    @SuppressWarnings("unchecked") java.util.Map<String, 
ExtensionConfig> configs = (java.util.Map<String, ExtensionConfig>) 
configsField.get(fetcherManager);
-                    configs.put(config.id(), config);
+                    // Get the AbstractComponentManager superclass
+                    Class<?> superClass = 
fetcherManager.getClass().getSuperclass();
+                    
+                    // Access the configStore field
+                    java.lang.reflect.Field configStoreField = 
superClass.getDeclaredField("configStore");
+                    configStoreField.setAccessible(true);
+                    Object configStore = configStoreField.get(fetcherManager);
+                    
+                    // For InMemoryConfigStore, access its internal map to 
replace the config
+                    java.lang.reflect.Field storeField = 
configStore.getClass().getDeclaredField("store");
+                    storeField.setAccessible(true);
+                    @SuppressWarnings("unchecked") 
+                    java.util.Map<String, ExtensionConfig> store = 
+                        (java.util.Map<String, ExtensionConfig>) 
storeField.get(configStore);
+                    store.put(config.id(), config);
 
                     // Also clear the cache so it gets re-instantiated
-                    java.lang.reflect.Field cacheField = 
fetcherManager.getClass().getSuperclass().getDeclaredField("componentCache");
+                    java.lang.reflect.Field cacheField = 
superClass.getDeclaredField("componentCache");
                     cacheField.setAccessible(true);
                     @SuppressWarnings("unchecked") java.util.Map<String, 
Fetcher> cache = (java.util.Map<String, Fetcher>) 
cacheField.get(fetcherManager);
                     cache.remove(config.id());
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/AbstractComponentManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/AbstractComponentManager.java
index 02c77d4d2..059cfc839 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/AbstractComponentManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/AbstractComponentManager.java
@@ -33,6 +33,8 @@ import org.slf4j.LoggerFactory;
 import org.apache.tika.config.loader.TikaObjectMapperFactory;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.pipes.core.config.ConfigStore;
+import org.apache.tika.pipes.core.config.InMemoryConfigStore;
 import org.apache.tika.plugins.ExtensionConfig;
 import org.apache.tika.plugins.TikaExtension;
 import org.apache.tika.plugins.TikaExtensionFactory;
@@ -50,15 +52,23 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
     private static final Logger LOG = 
LoggerFactory.getLogger(AbstractComponentManager.class);
 
     protected final PluginManager pluginManager;
-    private final Map<String, ExtensionConfig> componentConfigs = new 
ConcurrentHashMap<>();
+    private final ConfigStore configStore;
     private final Map<String, T> componentCache = new ConcurrentHashMap<>();
     private final boolean allowRuntimeModifications;
 
     protected AbstractComponentManager(PluginManager pluginManager,
                                       Map<String, ExtensionConfig> 
componentConfigs,
                                       boolean allowRuntimeModifications) {
+        this(pluginManager, componentConfigs, allowRuntimeModifications, new 
InMemoryConfigStore());
+    }
+
+    protected AbstractComponentManager(PluginManager pluginManager,
+                                      Map<String, ExtensionConfig> 
componentConfigs,
+                                      boolean allowRuntimeModifications,
+                                      ConfigStore configStore) {
         this.pluginManager = pluginManager;
-        this.componentConfigs.putAll(componentConfigs);
+        this.configStore = configStore;
+        componentConfigs.forEach(configStore::put);
         this.allowRuntimeModifications = allowRuntimeModifications;
     }
 
@@ -67,6 +77,14 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      */
     protected abstract String getConfigKey();
 
+    /**
+     * Returns the config store used by this manager.
+     * Useful for subclasses that need direct access to the store.
+     */
+    protected ConfigStore getConfigStore() {
+        return configStore;
+    }
+
     /**
      * Returns the factory class for this component type.
      */
@@ -194,11 +212,11 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
         }
 
         // Check if config exists
-        ExtensionConfig config = componentConfigs.get(id);
+        ExtensionConfig config = configStore.get(id);
         if (config == null) {
             throw createNotFoundException(
                     "Can't find " + getComponentName() + " for id=" + id +
-                    ". Available: " + componentConfigs.keySet());
+                    ". Available: " + configStore.keySet());
         }
 
         // Synchronized block to ensure only one thread builds the component
@@ -267,7 +285,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
         String typeName = config.name();
 
         // Check for duplicate ID
-        if (componentConfigs.containsKey(componentId)) {
+        if (configStore.containsKey(componentId)) {
             throw new TikaConfigException(getComponentName().substring(0, 
1).toUpperCase(Locale.ROOT) +
                     getComponentName().substring(1) + " with id '" + 
componentId + "' already exists");
         }
@@ -281,7 +299,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
         }
 
         // Store config without instantiating
-        componentConfigs.put(componentId, config);
+        configStore.put(componentId, config);
         LOG.debug("Saved {} config: id={}, type={}", getComponentName(), 
componentId, typeName);
     }
 
@@ -289,7 +307,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      * Returns the set of supported component IDs.
      */
     public Set<String> getSupported() {
-        return componentConfigs.keySet();
+        return configStore.keySet();
     }
 
     /**
@@ -299,15 +317,15 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      * @return the single configured component
      */
     public T getComponent() throws IOException, TikaException {
-        if (componentConfigs.size() != 1) {
+        if (configStore.size() != 1) {
             throw new IllegalArgumentException(
                     "No-arg get" + getComponentName().substring(0, 
1).toUpperCase(Locale.ROOT) +
                     getComponentName().substring(1) + "() requires exactly 1 
configured " +
-                    getComponentName() + ". Found: " + componentConfigs.size() 
+
-                    " (" + componentConfigs.keySet() + ")");
+                    getComponentName() + ". Found: " + configStore.size() +
+                    " (" + configStore.keySet() + ")");
         }
         // Get the single component id and use getComponent(id) for lazy 
loading
-        String componentId = componentConfigs.keySet().iterator().next();
+        String componentId = configStore.keySet().iterator().next();
         return getComponent(componentId);
     }
 }
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigStore.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigStore.java
new file mode 100644
index 000000000..2f6c4c164
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigStore.java
@@ -0,0 +1,79 @@
+/*
+ * 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.tika.pipes.core.config;
+
+import java.util.Set;
+
+import org.apache.tika.plugins.ExtensionConfig;
+
+/**
+ * Interface for storing and retrieving component configurations.
+ * Implementations can provide different storage backends (in-memory, 
database, distributed cache, etc.).
+ * <p>
+ * <b>Thread-safety:</b> Implementations of this interface may or may not be 
thread-safe.
+ * If an implementation is thread-safe, it should be clearly documented as 
such.
+ * Callers must not assume thread-safety unless it is explicitly documented by 
the implementation.
+ * The default in-memory implementation ({@code InMemoryConfigStore}) is 
thread-safe.
+ * <p>
+ * <b>Performance considerations:</b> The {@link #keySet()} method should be 
an inexpensive operation
+ * as it may be called in error message generation and other scenarios where 
performance matters.
+ */
+public interface ConfigStore {
+
+    /**
+     * Stores a configuration.
+     *
+     * @param id the configuration ID (must not be null)
+     * @param config the configuration to store (must not be null)
+     * @throws NullPointerException if id or config is null
+     */
+    void put(String id, ExtensionConfig config);
+
+    /**
+     * Retrieves a configuration by ID.
+     *
+     * @param id the configuration ID (must not be null)
+     * @return the configuration, or null if not found
+     * @throws NullPointerException if id is null
+     */
+    ExtensionConfig get(String id);
+
+    /**
+     * Checks if a configuration exists.
+     *
+     * @param id the configuration ID (must not be null)
+     * @return true if the configuration exists
+     * @throws NullPointerException if id is null
+     */
+    boolean containsKey(String id);
+
+    /**
+     * Returns all configuration IDs.
+     * Implementations should return an immutable snapshot to avoid
+     * ConcurrentModificationException during iteration.
+     *
+     * @return an immutable set of all configuration IDs
+     */
+    Set<String> keySet();
+
+    /**
+     * Returns the number of stored configurations.
+     *
+     * @return the number of configurations
+     */
+    int size();
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/InMemoryConfigStore.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/InMemoryConfigStore.java
new file mode 100644
index 000000000..e746eb4c7
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/InMemoryConfigStore.java
@@ -0,0 +1,56 @@
+/*
+ * 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.tika.pipes.core.config;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.tika.plugins.ExtensionConfig;
+
+/**
+ * Default in-memory implementation of {@link ConfigStore} using a {@link 
ConcurrentHashMap}.
+ * Thread-safe and suitable for single-instance deployments.
+ */
+public class InMemoryConfigStore implements ConfigStore {
+
+    private final ConcurrentHashMap<String, ExtensionConfig> store = new 
ConcurrentHashMap<>();
+
+    @Override
+    public void put(String id, ExtensionConfig config) {
+        store.put(id, config);
+    }
+
+    @Override
+    public ExtensionConfig get(String id) {
+        return store.get(id);
+    }
+
+    @Override
+    public boolean containsKey(String id) {
+        return store.containsKey(id);
+    }
+
+    @Override
+    public Set<String> keySet() {
+        return Set.copyOf(store.keySet());
+    }
+
+    @Override
+    public int size() {
+        return store.size();
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitterManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitterManager.java
index 101de53d6..05551c112 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitterManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitterManager.java
@@ -67,12 +67,32 @@ public class EmitterManager extends 
AbstractComponentManager<Emitter, EmitterFac
     public static EmitterManager load(PluginManager pluginManager, 
TikaJsonConfig tikaJsonConfig,
                                      boolean allowRuntimeModifications)
             throws IOException, TikaConfigException {
+        return load(pluginManager, tikaJsonConfig, allowRuntimeModifications, 
null);
+    }
+
+    /**
+     * Loads an EmitterManager with optional support for runtime modifications 
and a custom config store.
+     *
+     * @param pluginManager the plugin manager
+     * @param tikaJsonConfig the configuration
+     * @param allowRuntimeModifications if true, allows calling {@link 
#saveEmitter(ExtensionConfig)}
+     *                                  to add emitters at runtime
+     * @param configStore custom config store implementation, or null to use 
default in-memory store
+     * @return an EmitterManager
+     */
+    public static EmitterManager load(PluginManager pluginManager, 
TikaJsonConfig tikaJsonConfig,
+                                     boolean allowRuntimeModifications,
+                                     
org.apache.tika.pipes.core.config.ConfigStore configStore)
+            throws IOException, TikaConfigException {
         EmitterManager manager = new EmitterManager(pluginManager, 
allowRuntimeModifications);
         JsonNode emittersNode = tikaJsonConfig.getRootNode().get(CONFIG_KEY);
 
         // Validate configuration and collect emitter configs without 
instantiating
         Map<String, ExtensionConfig> configs = 
manager.validateAndCollectConfigs(pluginManager, emittersNode);
 
+        if (configStore != null) {
+            return new EmitterManager(pluginManager, configs, 
allowRuntimeModifications, configStore);
+        }
         return new EmitterManager(pluginManager, configs, 
allowRuntimeModifications);
     }
 
@@ -85,6 +105,12 @@ public class EmitterManager extends 
AbstractComponentManager<Emitter, EmitterFac
         super(pluginManager, emitterConfigs, allowRuntimeModifications);
     }
 
+    private EmitterManager(PluginManager pluginManager, Map<String, 
ExtensionConfig> emitterConfigs,
+                          boolean allowRuntimeModifications,
+                          org.apache.tika.pipes.core.config.ConfigStore 
configStore) {
+        super(pluginManager, emitterConfigs, allowRuntimeModifications, 
configStore);
+    }
+
     @Override
     protected String getConfigKey() {
         return CONFIG_KEY;
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/FetcherManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/FetcherManager.java
index af43914ac..c9ed4c3bf 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/FetcherManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/FetcherManager.java
@@ -66,12 +66,32 @@ public class FetcherManager extends 
AbstractComponentManager<Fetcher, FetcherFac
     public static FetcherManager load(PluginManager pluginManager, 
TikaJsonConfig tikaJsonConfig,
                                      boolean allowRuntimeModifications)
             throws TikaConfigException, IOException {
+        return load(pluginManager, tikaJsonConfig, allowRuntimeModifications, 
null);
+    }
+
+    /**
+     * Loads a FetcherManager with optional support for runtime modifications 
and a custom config store.
+     *
+     * @param pluginManager the plugin manager
+     * @param tikaJsonConfig the configuration
+     * @param allowRuntimeModifications if true, allows calling {@link 
#saveFetcher(ExtensionConfig)}
+     *                                  to add fetchers at runtime
+     * @param configStore custom config store implementation, or null to use 
default in-memory store
+     * @return a FetcherManager
+     */
+    public static FetcherManager load(PluginManager pluginManager, 
TikaJsonConfig tikaJsonConfig,
+                                     boolean allowRuntimeModifications,
+                                     
org.apache.tika.pipes.core.config.ConfigStore configStore)
+            throws TikaConfigException, IOException {
         FetcherManager manager = new FetcherManager(pluginManager, 
allowRuntimeModifications);
         JsonNode fetchersNode = tikaJsonConfig.getRootNode().get(CONFIG_KEY);
 
         // Validate configuration and collect fetcher configs without 
instantiating
         Map<String, ExtensionConfig> configs = 
manager.validateAndCollectConfigs(pluginManager, fetchersNode);
 
+        if (configStore != null) {
+            return new FetcherManager(pluginManager, configs, 
allowRuntimeModifications, configStore);
+        }
         return new FetcherManager(pluginManager, configs, 
allowRuntimeModifications);
     }
 
@@ -84,6 +104,12 @@ public class FetcherManager extends 
AbstractComponentManager<Fetcher, FetcherFac
         super(pluginManager, fetcherConfigs, allowRuntimeModifications);
     }
 
+    private FetcherManager(PluginManager pluginManager, Map<String, 
ExtensionConfig> fetcherConfigs,
+                          boolean allowRuntimeModifications,
+                          org.apache.tika.pipes.core.config.ConfigStore 
configStore) {
+        super(pluginManager, fetcherConfigs, allowRuntimeModifications, 
configStore);
+    }
+
     @Override
     protected String getConfigKey() {
         return CONFIG_KEY;
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigStoreTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigStoreTest.java
new file mode 100644
index 000000000..dbffab352
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigStoreTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.tika.pipes.core.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.plugins.ExtensionConfig;
+
+public class ConfigStoreTest {
+
+    @Test
+    public void testInMemoryConfigStore() {
+        ConfigStore store = new InMemoryConfigStore();
+        
+        ExtensionConfig config1 = new ExtensionConfig("id1", "type1", 
"{\"key\":\"value\"}");
+        ExtensionConfig config2 = new ExtensionConfig("id2", "type2", 
"{\"key2\":\"value2\"}");
+        
+        // Test put and get
+        store.put("id1", config1);
+        store.put("id2", config2);
+        
+        assertNotNull(store.get("id1"));
+        assertEquals("id1", store.get("id1").id());
+        assertEquals("type1", store.get("id1").name());
+        
+        assertNotNull(store.get("id2"));
+        assertEquals("id2", store.get("id2").id());
+        
+        // Test containsKey
+        assertTrue(store.containsKey("id1"));
+        assertTrue(store.containsKey("id2"));
+        assertFalse(store.containsKey("id3"));
+        
+        // Test size
+        assertEquals(2, store.size());
+        
+        // Test keySet
+        assertEquals(2, store.keySet().size());
+        assertTrue(store.keySet().contains("id1"));
+        assertTrue(store.keySet().contains("id2"));
+        
+        // Test get non-existent
+        assertNull(store.get("nonexistent"));
+    }
+    
+    @Test
+    public void testConfigStoreThreadSafety() throws InterruptedException {
+        ConfigStore store = new InMemoryConfigStore();
+        int numThreads = 10;
+        int numOperationsPerThread = 100;
+        
+        Thread[] threads = new Thread[numThreads];
+        for (int i = 0; i < numThreads; i++) {
+            final int threadId = i;
+            threads[i] = new Thread(() -> {
+                for (int j = 0; j < numOperationsPerThread; j++) {
+                    String id = "thread" + threadId + "_config" + j;
+                    ExtensionConfig config = new ExtensionConfig(id, "type", 
"{}");
+                    store.put(id, config);
+                    assertNotNull(store.get(id));
+                }
+            });
+            threads[i].start();
+        }
+        
+        for (Thread thread : threads) {
+            thread.join();
+        }
+        
+        assertEquals(numThreads * numOperationsPerThread, store.size());
+    }
+    
+    @Test
+    public void testLoggingConfigStore() {
+        ConfigStore store = new LoggingConfigStore();
+        
+        ExtensionConfig config1 = new ExtensionConfig("id1", "type1", 
"{\"key\":\"value\"}");
+        ExtensionConfig config2 = new ExtensionConfig("id2", "type2", 
"{\"key2\":\"value2\"}");
+        
+        store.put("id1", config1);
+        store.put("id2", config2);
+        
+        assertNotNull(store.get("id1"));
+        assertEquals("id1", store.get("id1").id());
+        
+        assertTrue(store.containsKey("id1"));
+        assertFalse(store.containsKey("id3"));
+        
+        assertEquals(2, store.size());
+        assertEquals(2, store.keySet().size());
+        assertTrue(store.keySet().contains("id1"));
+        
+        assertNull(store.get("nonexistent"));
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/LoggingConfigStore.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/LoggingConfigStore.java
new file mode 100644
index 000000000..fdf6a4190
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/LoggingConfigStore.java
@@ -0,0 +1,80 @@
+/*
+ * 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.tika.pipes.core.config;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.tika.plugins.ExtensionConfig;
+
+/**
+ * Example custom ConfigStore implementation for demonstration purposes.
+ * This implementation logs all operations and could be extended to add
+ * persistence, caching, or other custom behavior.
+ * Thread-safe through synchronized access to the underlying map.
+ */
+public class LoggingConfigStore implements ConfigStore {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(LoggingConfigStore.class);
+    private final Map<String, ExtensionConfig> store = new HashMap<>();
+    
+    @Override
+    public void put(String id, ExtensionConfig config) {
+        LOG.debug("ConfigStore: Storing config with id={}", id);
+        synchronized (store) {
+            store.put(id, config);
+        }
+    }
+
+    @Override
+    public ExtensionConfig get(String id) {
+        synchronized (store) {
+            ExtensionConfig config = store.get(id);
+            if (config != null) {
+                LOG.debug("ConfigStore: Retrieved config with id={}", id);
+            } else {
+                LOG.debug("ConfigStore: Config not found for id={}", id);
+            }
+            return config;
+        }
+    }
+
+    @Override
+    public boolean containsKey(String id) {
+        synchronized (store) {
+            return store.containsKey(id);
+        }
+    }
+
+    @Override
+    public Set<String> keySet() {
+        synchronized (store) {
+            return Set.copyOf(store.keySet());
+        }
+    }
+
+    @Override
+    public int size() {
+        synchronized (store) {
+            return store.size();
+        }
+    }
+}

Reply via email to