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();
+ }
+ }
+}