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

tballison 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 92884773a2 TIKA-5001: step 1 (#3436)
92884773a2 is described below

commit 92884773a2d9fdd0952f33fab0c4a5390f3a2052
Author: Tim Allison <[email protected]>
AuthorDate: Tue Oct 27 12:08:52 2026 -0400

    TIKA-5001: step 1 (#3436)
---
 docs/modules/ROOT/pages/configuration/index.adoc   |  6 +-
 docs/modules/ROOT/pages/pipes/configuration.adoc   |  2 +-
 docs/modules/ROOT/pages/using-tika/grpc/index.adoc |  3 +-
 .../apache/tika/pipes/grpc/TikaGrpcServerTest.java | 74 ++++++++++++++-----
 .../tika/pipes/core/AbstractComponentManager.java  | 50 ++++++++++---
 .../tika/pipes/core/emitter/EmitterManager.java    |  1 +
 .../tika/pipes/core/fetcher/FetcherManager.java    |  1 +
 .../pipes/core/emitter/EmitterManagerTest.java     | 48 ++++++-------
 .../pipes/core/fetcher/FetcherManagerTest.java     | 82 +++++++++++++++++-----
 9 files changed, 190 insertions(+), 77 deletions(-)

diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index fa363ae605..9012ab191d 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -365,9 +365,9 @@ so a value that itself contains `${env:...}` is not 
expanded a second time. Pres
 at startup with the rest of it; catalog presets and per-request configuration 
(`/config`
 endpoints, pipes tuples) never do: a request cannot read the server's 
environment.
 
-A reference keeps a secret out of tika-config.json, not out of every file: 
with a persistent
-config store (`configStoreType` `file` or Ignite) the resolved fetcher and 
emitter configurations
-are written to that store. The default in-memory store writes nothing.
+A persistent config store (`configStoreType` `file` or Ignite) holds only 
fetchers and emitters
+registered at runtime (gRPC `SaveFetcher`), as the caller sent them. 
Components from the config
+file, with their resolved references, are never written to it.
 
 == Windows file paths
 
diff --git a/docs/modules/ROOT/pages/pipes/configuration.adoc 
b/docs/modules/ROOT/pages/pipes/configuration.adoc
index c16926767b..ce63831dc0 100644
--- a/docs/modules/ROOT/pages/pipes/configuration.adoc
+++ b/docs/modules/ROOT/pages/pipes/configuration.adoc
@@ -256,7 +256,7 @@ For multi-host pipelines (e.g., shared-server clusters) you 
can store fetcher/em
 
 |`configStoreType`
 |`"memory"`
-|Backend for storing fetcher/emitter configurations. `"memory"` (default) is 
in-process; `"ignite"` uses Apache Ignite for shared state across nodes.
+|Backend for storing fetcher/emitter configurations registered at runtime; 
those in the config file are never written to it. `"memory"` (default) is 
in-process; `"file"` persists to a JSON file; `"ignite"` uses Apache Ignite for 
shared state across nodes.
 
 |`configStoreParams`
 |`"{}"`
diff --git a/docs/modules/ROOT/pages/using-tika/grpc/index.adoc 
b/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
index ca0927e892..0642be675f 100644
--- a/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/grpc/index.adoc
@@ -119,7 +119,8 @@ it is off by default. When `false`, a request carrying 
either field is rejected
 configuration back (`GetFetcher`, `ListFetchers`, `GetPipesIterator`). Off by 
default for two
 reasons: mutations change what the server can reach for all subsequent 
requests (for example, a
 fetcher that escapes a configured base path), and the stored configs returned 
by the read RPCs may
-contain passwords, access keys and tokens. When `false`, the mutating RPCs are 
rejected with
+contain passwords, access keys and tokens. Fetchers defined in the config file 
cannot be
+modified or deleted this way. When `false`, the mutating RPCs are rejected with
 `PERMISSION_DENIED`, and the read RPCs return only component identity — id and 
class — never the
 config.
 |===
diff --git 
a/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java 
b/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java
index 8090113fff..7dcb2d04d5 100644
--- a/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java
+++ b/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java
@@ -17,6 +17,7 @@
 package org.apache.tika.pipes.grpc;
 
 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.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
@@ -67,6 +68,7 @@ import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -218,7 +220,7 @@ public class TikaGrpcServerTest {
         String targetFolder = new File("target").getAbsolutePath();
         // create fetchers
         for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) {
-            String fetcherId = createFetcherId(i);
+            String fetcherId = "saved" + i + ".fs";
             SaveFetcherReply reply = 
blockingStub.saveFetcher(SaveFetcherRequest
                     .newBuilder()
                     .setFetcherId(fetcherId)
@@ -233,7 +235,7 @@ public class TikaGrpcServerTest {
         }
         // update fetchers
         for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) {
-            String fetcherId = createFetcherId(i);
+            String fetcherId = "saved" + i + ".fs";
             SaveFetcherReply reply = 
blockingStub.saveFetcher(SaveFetcherRequest
                     .newBuilder()
                     .setFetcherId(fetcherId)
@@ -256,7 +258,7 @@ public class TikaGrpcServerTest {
 
         // get fetchers
         for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) {
-            String fetcherId = createFetcherId(i);
+            String fetcherId = "saved" + i + ".fs";
             GetFetcherReply getFetcherReply = 
blockingStub.getFetcher(GetFetcherRequest
                     .newBuilder()
                     .setFetcherId(fetcherId)
@@ -267,7 +269,7 @@ public class TikaGrpcServerTest {
 
         // delete fetchers
         for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) {
-            String fetcherId = createFetcherId(i);
+            String fetcherId = "saved" + i + ".fs";
             DeleteFetcherReply deleteFetcherReply = 
blockingStub.deleteFetcher(DeleteFetcherRequest
                     .newBuilder()
                     .setFetcherId(fetcherId)
@@ -638,6 +640,54 @@ public class TikaGrpcServerTest {
         }
     }
 
+    /**
+     * The point of a persistent store: a fetcher saved at runtime reaches the 
forked worker.
+     * Config-file fetchers stay out of the store.
+     */
+    @Test
+    public void testSavedFetcherReachesTheForkThroughFileStore(Resources 
resources,
+            @TempDir Path tmp) throws Exception {
+        Path store = tmp.resolve("config-store.json");
+        ObjectNode root = (ObjectNode) 
OBJECT_MAPPER.readTree(tikaConfigUnlocked.toFile());
+        ObjectNode pipes = (ObjectNode) root.get("pipes");
+        pipes.put("configStoreType", "file");
+        pipes.put("configStoreParams", OBJECT_MAPPER.writeValueAsString(
+                Map.of("path", store.toAbsolutePath().toString())));
+        Path config = tmp.resolve("tika-config-file-store.json");
+        Files.writeString(config, OBJECT_MAPPER.writeValueAsString(root), 
StandardCharsets.UTF_8);
+
+        String serverName = InProcessServerBuilder.generateName();
+        Server server = 
InProcessServerBuilder.forName(serverName).directExecutor()
+                .addService(newService(config)).build().start();
+        resources.register(server, Duration.ofSeconds(10));
+        ManagedChannel channel = 
InProcessChannelBuilder.forName(serverName).directExecutor().build();
+        resources.register(channel, Duration.ofSeconds(10));
+        TikaGrpc.TikaBlockingStub blockingStub = 
TikaGrpc.newBlockingStub(channel);
+
+        Path docs = Files.createDirectories(tmp.resolve("docs"));
+        Files.writeString(docs.resolve("saved.html"),
+                "<html><body>saved-fetcher-marker</body></html>", 
StandardCharsets.UTF_8);
+        blockingStub.saveFetcher(SaveFetcherRequest.newBuilder()
+                .setFetcherId("saved.fs")
+                .setFetcherType("file-system-fetcher")
+                .setFetcherConfigJson(OBJECT_MAPPER.writeValueAsString(
+                        Map.of("basePath", docs.toAbsolutePath().toString())))
+                .build());
+
+        FetchAndParseReply reply = 
blockingStub.fetchAndParse(FetchAndParseRequest.newBuilder()
+                .setFetcherId("saved.fs")
+                .setFetchKey("saved.html")
+                .build());
+        assertEquals(PipesResult.RESULT_STATUS.PARSE_SUCCESS.name(), 
reply.getStatus(),
+                reply.getErrorMessage());
+        assertTrue(reply.getFieldsMap().values().stream()
+                .anyMatch(v -> v.contains("saved-fetcher-marker")));
+
+        String stored = Files.readString(store, StandardCharsets.UTF_8);
+        assertTrue(stored.contains("saved.fs"), stored);
+        assertFalse(stored.contains("nick1.is.cool.super-fs"), stored);
+    }
+
     @Test
     public void testBiStream(Resources resources) throws Exception {
         String serverName = InProcessServerBuilder.generateName();
@@ -659,20 +709,8 @@ public class TikaGrpcServerTest {
         TikaGrpc.TikaBlockingStub blockingStub = 
TikaGrpc.newBlockingStub(channel);
         TikaGrpc.TikaStub tikaStub = TikaGrpc.newStub(channel);
 
-        String fetcherId = createFetcherId(1);
-        String targetFolder = new File("target").getAbsolutePath();
-        SaveFetcherReply reply = blockingStub.saveFetcher(SaveFetcherRequest
-                .newBuilder()
-                .setFetcherId(fetcherId)
-                .setFetcherType("file-system-fetcher")
-                
.setFetcherConfigJson(OBJECT_MAPPER.writeValueAsString(ImmutableMap
-                        .builder()
-                        .put("basePath", targetFolder)
-                        .put("extractFileSystemMetadata", true)
-                        .build()))
-                .build());
-
-        assertEquals(fetcherId, reply.getFetcherId());
+        // from the config file: one saved at runtime is not visible to the 
forked worker
+        String fetcherId = "nick1.is.cool.super-fs";
 
         List<FetchAndParseReply> successes = Collections.synchronizedList(new 
ArrayList<>());
         List<FetchAndParseReply> errors = Collections.synchronizedList(new 
ArrayList<>());
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 e93b49a7c9..a6e5bc1b9e 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
@@ -56,6 +56,10 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
     private static final Logger LOG = 
LoggerFactory.getLogger(AbstractComponentManager.class);
 
     protected final PluginManager pluginManager;
+    // from the config file: never written to the store, so a persistent store 
holds no resolved
+    // ${env:...} secrets, and never replaced or deleted at runtime
+    private final Map<String, ExtensionConfig> configured;
+    // runtime registrations only
     private final ConfigStore configStore;
     private final Map<String, T> componentCache = new ConcurrentHashMap<>();
     private final boolean allowRuntimeModifications;
@@ -77,7 +81,14 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
         } catch (Exception e) {
             throw new RuntimeException("Failed to initialize ConfigStore", e);
         }
-        componentConfigs.forEach(configStore::put);
+        this.configured = Map.copyOf(componentConfigs);
+        for (String id : configured.keySet()) {
+            if (configStore.containsKey(id)) {
+                // most likely a copy an older version persisted; it may hold 
resolved secrets
+                LOG.warn("The config store holds a {} '{}' that the config 
file also defines; the "
+                        + "config file's wins. Remove it from the store.", 
getComponentName(), id);
+            }
+        }
         this.allowRuntimeModifications = allowRuntimeModifications;
     }
 
@@ -243,7 +254,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
         }
 
         // Check if config exists
-        ExtensionConfig config = configStore.get(id);
+        ExtensionConfig config = lookup(id);
         if (config == null) {
             throw createNotFoundException(
                     "Can't find " + getComponentName() + " for id=" + id +
@@ -270,6 +281,19 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
         }
     }
 
+    private ExtensionConfig lookup(String id) {
+        ExtensionConfig config = configured.get(id);
+        return config != null ? config : configStore.get(id);
+    }
+
+    private void requireNotConfigured(String componentId) throws 
TikaConfigException {
+        if (configured.containsKey(componentId)) {
+            throw new TikaConfigException(getComponentName().substring(0, 
1).toUpperCase(Locale.ROOT)
+                    + getComponentName().substring(1) + " '" + componentId
+                    + "' is defined in the server config; change it there");
+        }
+    }
+
     /**
      * Builds a component instance from its configuration.
      */
@@ -291,6 +315,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      * Dynamically adds or updates a component configuration at runtime.
      * The component will not be instantiated until it is first requested via 
{@link #getComponent(String)}.
      * If a component with the same ID already exists, it will be replaced and 
the cached instance cleared.
+     * A component defined in the server config cannot be replaced.
      * <p>
      * This method is only available if the manager was loaded with 
allowRuntimeModifications=true.
      * <p>
@@ -321,6 +346,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
         } catch (IllegalArgumentException e) {
             throw new TikaConfigException(e.getMessage(), e);
         }
+        requireNotConfigured(componentId);
         config = new ExtensionConfig(componentId, config.name(), 
config.json());
         String typeName = config.name();
 
@@ -349,7 +375,8 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      * Clears the cached instance and removes the configuration.
      *
      * @param componentId the component ID to delete
-     * @throws TikaConfigException if runtime modifications are not allowed or 
component not found
+     * @throws TikaConfigException if runtime modifications are not allowed, 
the component is not
+     *         found, or it is defined in the server config
      */
     public synchronized void deleteComponent(String componentId) throws 
TikaConfigException {
         if (!allowRuntimeModifications) {
@@ -370,6 +397,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
         } catch (IllegalArgumentException e) {
             throw new TikaConfigException(e.getMessage(), e);
         }
+        requireNotConfigured(componentId);
 
         if (!configStore.containsKey(componentId)) {
             throw new TikaConfigException(
@@ -390,7 +418,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      * @return the component configuration, or null if not found
      */
     public ExtensionConfig getComponentConfig(String componentId) {
-        return configStore.get(ComponentIds.normalize(componentId));
+        return lookup(ComponentIds.normalize(componentId));
     }
 
     /**
@@ -398,7 +426,7 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      * bind them, so listing them in a "not found" message only advertises 
internals.
      */
     public Set<String> getSupported() {
-        return configStore.keySet().stream()
+        return getAllIds().stream()
                 .filter(id -> !ComponentIds.isSystem(id))
                 .collect(Collectors.toCollection(LinkedHashSet::new));
     }
@@ -408,7 +436,9 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      * anything a caller sees.
      */
     public Set<String> getAllIds() {
-        return configStore.keySet();
+        Set<String> ids = new LinkedHashSet<>(configured.keySet());
+        ids.addAll(configStore.keySet());
+        return ids;
     }
 
     /**
@@ -418,15 +448,15 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
      * @return the single configured component
      */
     public T getComponent() throws IOException, TikaException {
-        if (configStore.size() != 1) {
+        Set<String> ids = getAllIds();
+        if (ids.size() != 1) {
             throw new IllegalArgumentException(
                     "No-arg get" + getComponentName().substring(0, 
1).toUpperCase(Locale.ROOT) +
                     getComponentName().substring(1) + "() requires exactly 1 
configured " +
-                    getComponentName() + ". Found: " + configStore.size() +
-                    " (" + configStore.keySet() + ")");
+                    getComponentName() + ". Found: " + ids.size() + " (" + ids 
+ ")");
         }
         // Get the single component id and use getComponent(id) for lazy 
loading
-        String componentId = configStore.keySet().iterator().next();
+        String componentId = ids.iterator().next();
         return getComponent(componentId);
     }
 }
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 256e35b7c2..360eb9bb7d 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
@@ -161,6 +161,7 @@ public class EmitterManager extends 
AbstractComponentManager<Emitter, EmitterFac
      * The emitter will not be instantiated until it is first requested via 
{@link #getEmitter(String)}.
      * This allows for dynamic configuration without the overhead of immediate 
instantiation.
      * If an emitter with the same ID already exists, it will be replaced and 
the cached instance cleared.
+     * An emitter defined in the server config cannot be replaced.
      * <p>
      * This method is only available if the EmitterManager was loaded with
      * {@link #load(PluginManager, TikaJsonConfig, boolean)} with 
allowRuntimeModifications=true.
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 4f4e9a199e..0880d383ef 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
@@ -160,6 +160,7 @@ public class FetcherManager extends 
AbstractComponentManager<Fetcher, FetcherFac
      * The fetcher will not be instantiated until it is first requested via 
{@link #getFetcher(String)}.
      * This allows for dynamic configuration without the overhead of immediate 
instantiation.
      * If a fetcher with the same ID already exists, it will be replaced and 
the cached instance cleared.
+     * A fetcher defined in the server config cannot be replaced.
      * <p>
      * This method is only available if the FetcherManager was loaded with
      * {@link #load(PluginManager, TikaJsonConfig, boolean)} with 
allowRuntimeModifications=true.
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/emitter/EmitterManagerTest.java
 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/emitter/EmitterManagerTest.java
index 18aa5d2fbf..8d8b438275 100644
--- 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/emitter/EmitterManagerTest.java
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/emitter/EmitterManagerTest.java
@@ -384,33 +384,22 @@ public class EmitterManagerTest {
         TikaPluginManager pluginManager = 
TikaPluginManager.load(tikaJsonConfig);
 
         EmitterManager emitterManager = EmitterManager.load(pluginManager, 
tikaJsonConfig, true);
+        emitterManager.saveEmitter(fileSystemEmitter("fse2", 
tmpDir.resolve("output2")));
+        Emitter originalEmitter = emitterManager.getEmitter("fse2");
 
-        // Update existing emitter with new configuration
-        String newConfigJson = String.format(Locale.ROOT, """
-                {
-                  "basePath": "%s",
-                  "onExists": "REPLACE"
-                }
-                """, JsonConfigHelper.toJsonPath(tmpDir.resolve("output2")));
-        ExtensionConfig updatedConfig = new ExtensionConfig("fse", 
"file-system-emitter", newConfigJson);
-
-        // Get original emitter instance
-        Emitter originalEmitter = emitterManager.getEmitter("fse");
-        assertNotNull(originalEmitter);
-
-        // Update the emitter config
-        emitterManager.saveEmitter(updatedConfig);
-
-        // Should still only have 1 emitter
-        assertEquals(1, emitterManager.getSupported().size());
-        assertTrue(emitterManager.getSupported().contains("fse"));
+        emitterManager.saveEmitter(fileSystemEmitter("fse2", 
tmpDir.resolve("output3")));
 
-        // Getting the emitter again should return a NEW instance (cache 
cleared)
-        Emitter updatedEmitter = emitterManager.getEmitter("fse");
-        assertNotNull(updatedEmitter);
-        
-        // Should be different instance due to re-instantiation
-        assertTrue(originalEmitter != updatedEmitter, "Updated emitter should 
be a new instance");
+        assertEquals(2, emitterManager.getSupported().size());
+        // the cached instance was dropped
+        assertTrue(originalEmitter != emitterManager.getEmitter("fse2"),
+                "Updated emitter should be a new instance");
+
+        // one from the config file is not the caller's to replace or delete
+        Emitter configured = emitterManager.getEmitter("fse");
+        assertThrows(TikaConfigException.class,
+                () -> emitterManager.saveEmitter(fileSystemEmitter("fse", 
tmpDir.resolve("output2"))));
+        assertThrows(TikaConfigException.class, () -> 
emitterManager.deleteEmitter("fse"));
+        assertSame(configured, emitterManager.getEmitter("fse"));
     }
 
     @Test
@@ -531,4 +520,13 @@ public class EmitterManagerTest {
 
         assertTrue(exception.getMessage().contains("Runtime modifications are 
not allowed"));
     }
+
+    private static ExtensionConfig fileSystemEmitter(String id, Path basePath) 
{
+        return new ExtensionConfig(id, "file-system-emitter", 
String.format(Locale.ROOT, """
+                {
+                  "basePath": "%s",
+                  "onExists": "REPLACE"
+                }
+                """, JsonConfigHelper.toJsonPath(basePath)));
+    }
 }
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/fetcher/FetcherManagerTest.java
 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/fetcher/FetcherManagerTest.java
index 595b5667a4..6fcd6391a1 100644
--- 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/fetcher/FetcherManagerTest.java
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/fetcher/FetcherManagerTest.java
@@ -17,6 +17,7 @@
 package org.apache.tika.pipes.core.fetcher;
 
 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.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -28,6 +29,7 @@ import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -42,6 +44,7 @@ import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.pipes.api.fetcher.Fetcher;
 import org.apache.tika.pipes.api.fetcher.FetcherNotFoundException;
 import org.apache.tika.pipes.core.PluginsTestHelper;
+import org.apache.tika.pipes.core.config.FileBasedConfigStore;
 import org.apache.tika.plugins.ExtensionConfig;
 import org.apache.tika.plugins.TikaPluginManager;
 import org.apache.tika.serialization.config.JsonConfigHelper;
@@ -375,30 +378,71 @@ public class FetcherManagerTest {
         TikaPluginManager pluginManager = 
TikaPluginManager.load(tikaJsonConfig);
 
         FetcherManager fetcherManager = FetcherManager.load(pluginManager, 
tikaJsonConfig, true);
+        fetcherManager.saveFetcher(fileSystemFetcher("fsf2", 
tmpDir.resolve("path2")));
+        Fetcher originalFetcher = fetcherManager.getFetcher("fsf2");
 
-        // Update existing fetcher with new configuration
-        String newConfigJson = String.format(Locale.ROOT, """
-                {"basePath": "%s"}
-                """, JsonConfigHelper.toJsonPath(tmpDir.resolve("path2")));
-        ExtensionConfig updatedConfig = new ExtensionConfig("fsf", 
"file-system-fetcher", newConfigJson);
+        fetcherManager.saveFetcher(fileSystemFetcher("fsf2", 
tmpDir.resolve("path3")));
 
-        // Get original fetcher instance
-        Fetcher originalFetcher = fetcherManager.getFetcher("fsf");
-        assertNotNull(originalFetcher);
+        assertEquals(2, fetcherManager.getSupported().size());
+        // the cached instance was dropped
+        assertTrue(originalFetcher != fetcherManager.getFetcher("fsf2"),
+                "Updated fetcher should be a new instance");
+    }
 
-        // Update the fetcher config
-        fetcherManager.saveFetcher(updatedConfig);
+    @Test
+    public void testConfiguredFetcherCannotBeReplacedOrDeleted(@TempDir Path 
tmpDir) throws Exception {
+        Path config = PluginsTestHelper.getFileSystemFetcherConfig(tmpDir);
+        TikaJsonConfig tikaJsonConfig = TikaJsonConfig.load(config);
+        TikaPluginManager pluginManager = 
TikaPluginManager.load(tikaJsonConfig);
 
-        // Should still only have 1 fetcher
-        assertEquals(1, fetcherManager.getSupported().size());
-        assertTrue(fetcherManager.getSupported().contains("fsf"));
+        FetcherManager fetcherManager = FetcherManager.load(pluginManager, 
tikaJsonConfig, true);
+        Fetcher configured = fetcherManager.getFetcher("fsf");
 
-        // Getting the fetcher again should return a NEW instance (cache 
cleared)
-        Fetcher updatedFetcher = fetcherManager.getFetcher("fsf");
-        assertNotNull(updatedFetcher);
-        
-        // Should be different instance due to re-instantiation
-        assertTrue(originalFetcher != updatedFetcher, "Updated fetcher should 
be a new instance");
+        TikaConfigException e = assertThrows(TikaConfigException.class,
+                () -> fetcherManager.saveFetcher(fileSystemFetcher("fsf", 
tmpDir.resolve("path2"))));
+        assertTrue(e.getMessage().contains("defined in the server config"), 
e.getMessage());
+        assertThrows(TikaConfigException.class, () -> 
fetcherManager.deleteFetcher("fsf"));
+        assertSame(configured, fetcherManager.getFetcher("fsf"));
+    }
+
+    @Test
+    public void testConfiguredFetcherIsNotPersisted(@TempDir Path tmpDir) 
throws Exception {
+        Path config = PluginsTestHelper.getFileSystemFetcherConfig(tmpDir);
+        TikaJsonConfig tikaJsonConfig = TikaJsonConfig.load(config);
+        TikaPluginManager pluginManager = 
TikaPluginManager.load(tikaJsonConfig);
+        Path storeFile = tmpDir.resolve("config-store.json");
+
+        FetcherManager fetcherManager = FetcherManager.load(pluginManager, 
tikaJsonConfig, true,
+                new FileBasedConfigStore(storeFile));
+        fetcherManager.saveFetcher(fileSystemFetcher("fsf2", 
tmpDir.resolve("path2")));
+
+        assertEquals(Set.of("fsf", "fsf2"), fetcherManager.getSupported());
+        assertEquals(Set.of("fsf2"), new 
FileBasedConfigStore(storeFile).keySet());
+    }
+
+    @Test
+    public void testOldStoreCopyOfConfiguredFetcherIsIgnored(@TempDir Path 
tmpDir) throws Exception {
+        Path config = PluginsTestHelper.getFileSystemFetcherConfig(tmpDir);
+        TikaJsonConfig tikaJsonConfig = TikaJsonConfig.load(config);
+        TikaPluginManager pluginManager = 
TikaPluginManager.load(tikaJsonConfig);
+        Path storeFile = tmpDir.resolve("config-store.json");
+        // what an older version left behind: its copy of the configured 
fetcher
+        FileBasedConfigStore legacy = new FileBasedConfigStore(storeFile);
+        legacy.init();
+        legacy.put("fsf", fileSystemFetcher("fsf", tmpDir.resolve("stale")));
+
+        FetcherManager fetcherManager = FetcherManager.load(pluginManager, 
tikaJsonConfig, true,
+                new FileBasedConfigStore(storeFile));
+
+        assertFalse(fetcherManager.getConfig("fsf").json().contains("stale"));
+        // left for the operator to remove
+        assertEquals(Set.of("fsf"), new 
FileBasedConfigStore(storeFile).keySet());
+    }
+
+    private static ExtensionConfig fileSystemFetcher(String id, Path basePath) 
{
+        return new ExtensionConfig(id, "file-system-fetcher", 
String.format(Locale.ROOT, """
+                {"basePath": "%s"}
+                """, JsonConfigHelper.toJsonPath(basePath)));
     }
 
     @Test

Reply via email to