Skip to content

Commit 612fb42

Browse files
authored
TIKA-4558 -- add lazy loading to FetcherManager (#2426)
1 parent 2df3069 commit 612fb42

16 files changed

Lines changed: 1608 additions & 97 deletions

File tree

tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,13 @@ public enum RESULT_STATUS {
7777
// Emit failure
7878
EMIT_EXCEPTION(CATEGORY.APPLICATION_ERROR),
7979

80+
// Emitter failures
81+
EMITTER_INITIALIZATION_EXCEPTION(CATEGORY.APPLICATION_ERROR),
82+
EMITTER_NOT_FOUND(CATEGORY.APPLICATION_ERROR),
83+
8084
// Other errors
8185
INTERRUPTED_EXCEPTION(CATEGORY.APPLICATION_ERROR),
82-
FETCHER_NOT_FOUND(CATEGORY.APPLICATION_ERROR),
83-
EMITTER_NOT_FOUND(CATEGORY.APPLICATION_ERROR);
86+
FETCHER_NOT_FOUND(CATEGORY.APPLICATION_ERROR);
8487

8588

8689
private final CATEGORY category;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.pipes.api.emitter;
18+
19+
import org.apache.tika.exception.TikaException;
20+
21+
/**
22+
* Exception thrown when a requested emitter configuration does not exist.
23+
*/
24+
public class EmitterNotFoundException extends TikaException {
25+
26+
public EmitterNotFoundException(String msg) {
27+
super(msg);
28+
}
29+
30+
public EmitterNotFoundException(String msg, Throwable cause) {
31+
super(msg, cause);
32+
}
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.pipes.api.fetcher;
18+
19+
import org.apache.tika.exception.TikaException;
20+
21+
/**
22+
* Exception thrown when a requested fetcher configuration does not exist.
23+
*/
24+
public class FetcherNotFoundException extends TikaException {
25+
26+
public FetcherNotFoundException(String msg) {
27+
super(msg);
28+
}
29+
30+
public FetcherNotFoundException(String msg, Throwable cause) {
31+
super(msg, cause);
32+
}
33+
}
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.pipes.core;
18+
19+
import java.io.IOException;
20+
import java.util.HashMap;
21+
import java.util.Iterator;
22+
import java.util.Locale;
23+
import java.util.Map;
24+
import java.util.Set;
25+
import java.util.concurrent.ConcurrentHashMap;
26+
27+
import com.fasterxml.jackson.core.JsonProcessingException;
28+
import com.fasterxml.jackson.databind.JsonNode;
29+
import org.pf4j.PluginManager;
30+
import org.slf4j.Logger;
31+
import org.slf4j.LoggerFactory;
32+
33+
import org.apache.tika.config.loader.PolymorphicObjectMapperFactory;
34+
import org.apache.tika.exception.TikaConfigException;
35+
import org.apache.tika.exception.TikaException;
36+
import org.apache.tika.plugins.ExtensionConfig;
37+
import org.apache.tika.plugins.TikaExtension;
38+
import org.apache.tika.plugins.TikaExtensionFactory;
39+
40+
/**
41+
* Abstract base class for managing Tika components (Fetchers, Emitters, etc.).
42+
* Provides lazy instantiation, early validation, and optional runtime modifications.
43+
*
44+
* @param <T> the component type (e.g., Fetcher, Emitter)
45+
* @param <F> the factory type for creating components
46+
*/
47+
public abstract class AbstractComponentManager<T extends TikaExtension,
48+
F extends TikaExtensionFactory<T>> {
49+
50+
private static final Logger LOG = LoggerFactory.getLogger(AbstractComponentManager.class);
51+
52+
protected final PluginManager pluginManager;
53+
private final Map<String, ExtensionConfig> componentConfigs = new ConcurrentHashMap<>();
54+
private final Map<String, T> componentCache = new ConcurrentHashMap<>();
55+
private final boolean allowRuntimeModifications;
56+
57+
protected AbstractComponentManager(PluginManager pluginManager,
58+
Map<String, ExtensionConfig> componentConfigs,
59+
boolean allowRuntimeModifications) {
60+
this.pluginManager = pluginManager;
61+
this.componentConfigs.putAll(componentConfigs);
62+
this.allowRuntimeModifications = allowRuntimeModifications;
63+
}
64+
65+
/**
66+
* Returns the JSON configuration key for this component type (e.g., "fetchers", "emitters").
67+
*/
68+
protected abstract String getConfigKey();
69+
70+
/**
71+
* Returns the factory class for this component type.
72+
*/
73+
protected abstract Class<F> getFactoryClass();
74+
75+
/**
76+
* Returns the component name for error messages (e.g., "fetcher", "emitter").
77+
*/
78+
protected abstract String getComponentName();
79+
80+
/**
81+
* Creates a not-found exception for this component type.
82+
*/
83+
protected abstract TikaException createNotFoundException(String message);
84+
85+
/**
86+
* Validates the configuration and collects component configs without instantiating.
87+
*/
88+
protected Map<String, ExtensionConfig> validateAndCollectConfigs(
89+
PluginManager pluginManager, JsonNode configNode) throws TikaConfigException, IOException {
90+
91+
Map<String, F> factories = getFactories(pluginManager);
92+
Map<String, ExtensionConfig> configs = new HashMap<>();
93+
94+
if (configNode != null && !configNode.isNull()) {
95+
// Outer loop: iterate over type names
96+
Iterator<Map.Entry<String, JsonNode>> typeFields = configNode.fields();
97+
while (typeFields.hasNext()) {
98+
Map.Entry<String, JsonNode> typeEntry = typeFields.next();
99+
String typeName = typeEntry.getKey();
100+
JsonNode instancesNode = typeEntry.getValue();
101+
102+
// Validate that factory exists
103+
F factory = factories.get(typeName);
104+
if (factory == null) {
105+
throw new TikaConfigException(
106+
"Unknown " + getComponentName() + " type: " + typeName +
107+
". Available: " + factories.keySet());
108+
}
109+
110+
// Inner loop: iterate over instances of this type
111+
Iterator<Map.Entry<String, JsonNode>> instanceFields = instancesNode.fields();
112+
while (instanceFields.hasNext()) {
113+
Map.Entry<String, JsonNode> instanceEntry = instanceFields.next();
114+
String instanceId = instanceEntry.getKey();
115+
JsonNode config = instanceEntry.getValue();
116+
117+
if (configs.containsKey(instanceId)) {
118+
throw new TikaConfigException("Duplicate " + getComponentName() +
119+
" id: " + instanceId);
120+
}
121+
122+
configs.put(instanceId, new ExtensionConfig(instanceId, typeName,
123+
toJsonString(config)));
124+
}
125+
}
126+
}
127+
128+
return configs;
129+
}
130+
131+
protected Map<String, F> getFactories(PluginManager pluginManager) throws TikaConfigException {
132+
if (pluginManager.getStartedPlugins().isEmpty()) {
133+
pluginManager.loadPlugins();
134+
pluginManager.startPlugins();
135+
}
136+
137+
Map<String, F> factories = new HashMap<>();
138+
for (F factory : pluginManager.getExtensions(getFactoryClass())) {
139+
String name = factory.getName();
140+
ClassLoader cl = factory.getClass().getClassLoader();
141+
boolean isFromPlugin = cl instanceof org.pf4j.PluginClassLoader;
142+
143+
F existing = factories.get(name);
144+
if (existing != null) {
145+
boolean existingIsFromPlugin = existing.getClass().getClassLoader()
146+
instanceof org.pf4j.PluginClassLoader;
147+
if (isFromPlugin && !existingIsFromPlugin) {
148+
// Replace classpath version with plugin version
149+
factories.put(name, factory);
150+
}
151+
// Otherwise skip duplicate (keep existing)
152+
continue;
153+
}
154+
factories.put(name, factory);
155+
}
156+
return factories;
157+
}
158+
159+
private static String toJsonString(final JsonNode node) throws TikaConfigException {
160+
try {
161+
return PolymorphicObjectMapperFactory.getMapper().writeValueAsString(node);
162+
} catch (JsonProcessingException e) {
163+
throw new TikaConfigException("Failed to serialize config to JSON string", e);
164+
}
165+
}
166+
167+
/**
168+
* Gets a component by ID, lazily instantiating it if needed.
169+
*/
170+
public T getComponent(String id) throws IOException, TikaException {
171+
// Check cache first (fast path, no synchronization)
172+
T component = componentCache.get(id);
173+
if (component != null) {
174+
return component;
175+
}
176+
177+
// Check if config exists
178+
ExtensionConfig config = componentConfigs.get(id);
179+
if (config == null) {
180+
throw createNotFoundException(
181+
"Can't find " + getComponentName() + " for id=" + id +
182+
". Available: " + componentConfigs.keySet());
183+
}
184+
185+
// Synchronized block to ensure only one thread builds the component
186+
synchronized (this) {
187+
// Double-check in case another thread built it while we were waiting
188+
component = componentCache.get(id);
189+
if (component != null) {
190+
return component;
191+
}
192+
193+
// Build the component
194+
try {
195+
component = buildComponent(config);
196+
componentCache.put(id, component);
197+
LOG.debug("Lazily instantiated {}: {}", getComponentName(), id);
198+
return component;
199+
} catch (TikaConfigException e) {
200+
throw new IOException("Failed to build " + getComponentName() + ": " + id, e);
201+
}
202+
}
203+
}
204+
205+
/**
206+
* Builds a component instance from its configuration.
207+
*/
208+
private T buildComponent(ExtensionConfig config) throws TikaConfigException, IOException {
209+
Map<String, F> factories = getFactories(pluginManager);
210+
F factory = factories.get(config.name());
211+
212+
if (factory == null) {
213+
// This shouldn't happen since we validated in load(), but check anyway
214+
throw new TikaConfigException(
215+
"Unknown " + getComponentName() + " type: " + config.name() +
216+
". Available: " + factories.keySet());
217+
}
218+
219+
return factory.buildExtension(config);
220+
}
221+
222+
/**
223+
* Dynamically adds a component configuration at runtime.
224+
* The component will not be instantiated until it is first requested via {@link #getComponent(String)}.
225+
* <p>
226+
* This method is only available if the manager was loaded with allowRuntimeModifications=true.
227+
* <p>
228+
* Only authorized/authenticated users should be allowed to modify components. BE CAREFUL.
229+
*
230+
* @param config the extension configuration for the component
231+
* @throws TikaConfigException if the component type is unknown, if a component with the same ID already exists,
232+
* or if runtime modifications are not allowed
233+
* @throws IOException if there is an error accessing the plugin manager
234+
*/
235+
public synchronized void saveComponent(ExtensionConfig config) throws TikaConfigException, IOException {
236+
if (!allowRuntimeModifications) {
237+
throw new TikaConfigException(
238+
"Runtime modifications are not allowed. " + getClass().getSimpleName() +
239+
" must be loaded with allowRuntimeModifications=true to use save" +
240+
getComponentName().substring(0, 1).toUpperCase(Locale.ROOT) + getComponentName().substring(1) + "()");
241+
}
242+
243+
if (config == null) {
244+
throw new IllegalArgumentException("ExtensionConfig cannot be null");
245+
}
246+
247+
String componentId = config.id();
248+
String typeName = config.name();
249+
250+
// Check for duplicate ID
251+
if (componentConfigs.containsKey(componentId)) {
252+
throw new TikaConfigException(getComponentName().substring(0, 1).toUpperCase(Locale.ROOT) +
253+
getComponentName().substring(1) + " with id '" + componentId + "' already exists");
254+
}
255+
256+
// Validate that factory exists for this type
257+
Map<String, F> factories = getFactories(pluginManager);
258+
if (!factories.containsKey(typeName)) {
259+
throw new TikaConfigException(
260+
"Unknown " + getComponentName() + " type: " + typeName +
261+
". Available: " + factories.keySet());
262+
}
263+
264+
// Store config without instantiating
265+
componentConfigs.put(componentId, config);
266+
LOG.debug("Saved {} config: id={}, type={}", getComponentName(), componentId, typeName);
267+
}
268+
269+
/**
270+
* Returns the set of supported component IDs.
271+
*/
272+
public Set<String> getSupported() {
273+
return componentConfigs.keySet();
274+
}
275+
276+
/**
277+
* Convenience method that returns a component if only one component
278+
* is configured. If 0 or > 1 components are configured, this throws an IllegalArgumentException.
279+
*
280+
* @return the single configured component
281+
*/
282+
public T getComponent() throws IOException, TikaException {
283+
if (componentConfigs.size() != 1) {
284+
throw new IllegalArgumentException(
285+
"No-arg get" + getComponentName().substring(0, 1).toUpperCase(Locale.ROOT) +
286+
getComponentName().substring(1) + "() requires exactly 1 configured " +
287+
getComponentName() + ". Found: " + componentConfigs.size() +
288+
" (" + componentConfigs.keySet() + ")");
289+
}
290+
// Get the single component id and use getComponent(id) for lazy loading
291+
String componentId = componentConfigs.keySet().iterator().next();
292+
return getComponent(componentId);
293+
}
294+
}

0 commit comments

Comments
 (0)