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 c45f64859d TIKA-4808: tika-server sends only request deltas to the 
pipes worker (#3034)
c45f64859d is described below

commit c45f64859dd335902e733f01b8b249c132c15a7e
Author: Tim Allison <[email protected]>
AuthorDate: Mon Aug 17 08:54:44 2026 -0400

    TIKA-4808: tika-server sends only request deltas to the pipes worker (#3034)
---
 CHANGES.txt                                        |   7 +
 .../server/core/resource/DetectorResource.java     |   4 +-
 .../server/core/resource/MetadataResource.java     |  23 ++-
 .../server/core/resource/PipesParsingHelper.java   |   8 +-
 .../core/resource/RecursiveMetadataResource.java   |  18 +-
 .../tika/server/core/resource/TikaResource.java    | 107 +++++++++--
 .../server/core/resource/UnpackerResource.java     |  27 ++-
 .../org/apache/tika/server/core/CXFTestBase.java   |  43 +++--
 .../core/resource/RequestContextIsolationTest.java | 214 +++++++++++++++++++++
 .../standard/resource/XMPMetadataResource.java     |   8 +-
 10 files changed, 384 insertions(+), 75 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 5ae4bb52ed..382d18f807 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -138,6 +138,13 @@ Release 4.0.0 - ???
    * tika-server: /meta now runs through the same pipes-backed parser as the
      other extraction endpoints (TIKA-4809).
 
+   * tika-server requests now carry only their own parse-context entries to the
+     forked worker, which supplies the config defaults itself. Previously the
+     server sent its config's parse-context along with every request, so the
+     worker -- which clamps request-supplied timeout limits but trusts its own
+     config's -- treated the operator's timeout-limits as caller input and
+     clamped them at pipes.maxTotalTaskTimeoutMillis (TIKA-4808).
+
    * tika-grpc's fetchAndParseServerSideStreaming now completes the call
      after delivering its reply, instead of leaving the client waiting
      for a terminal signal that never came (TIKA-4804).
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/DetectorResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/DetectorResource.java
index 12b52c11c7..e3d2c9588d 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/DetectorResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/DetectorResource.java
@@ -54,8 +54,8 @@ public class DetectorResource {
     @Consumes("*/*")
     @Produces("text/plain")
     public String detect(final InputStream is, @Context HttpHeaders 
httpHeaders, @Context final UriInfo info) {
-        ParseContext parseContext = tikaResource.createParseContext();
-        Metadata met = Metadata.newInstance(parseContext);
+        ParseContext parseContext = tikaResource.createRequestContext();
+        Metadata met = tikaResource.newRequestMetadata();
 
         String filename = 
TikaResource.detectFilename(httpHeaders.getRequestHeaders());
         LOG.debug("Detecting media type for Filename: {}", filename);
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/MetadataResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/MetadataResource.java
index 8af6c93762..4477c3cdda 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/MetadataResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/MetadataResource.java
@@ -63,6 +63,11 @@ public class MetadataResource {
     protected MetadataResource() {
     }
 
+    /** For subclasses in other modules; they need it for request contexts and 
metadata. */
+    protected TikaResource getTikaResource() {
+        return tikaResource;
+    }
+
     protected void setTikaResource(TikaResource tikaResource) {
         this.tikaResource = tikaResource;
     }
@@ -72,10 +77,10 @@ public class MetadataResource {
     @Produces({"application/json", "text/csv"})
     @Path("form")
     public Response getMetadataFromMultipart(Attachment att, @Context UriInfo 
info) throws Exception {
-        ParseContext context = tikaResource.createParseContext();
+        ParseContext context = tikaResource.createRequestContext();
         try (TikaInputStream tis = 
TikaInputStream.get(att.getObject(InputStream.class))) {
             return Response
-                    .ok(parseMetadata(tis, Metadata.newInstance(context), 
att.getHeaders(), context))
+                    .ok(parseMetadata(tis, tikaResource.newRequestMetadata(), 
att.getHeaders(), context))
                     .build();
         }
     }
@@ -93,8 +98,8 @@ public class MetadataResource {
             @Context HttpHeaders httpHeaders) throws Exception {
 
         // Load default context from config, then overlay with request config
-        ParseContext context = tikaResource.createParseContext();
-        Metadata metadata = Metadata.newInstance(context);
+        ParseContext context = tikaResource.createRequestContext();
+        Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = 
tikaResource.setupMultipartConfig(attachments, metadata, context)) {
             TikaResource.logRequest(LOG, "/meta/config", metadata);
             // Null headers: multipart request headers describe the envelope 
and would
@@ -106,8 +111,8 @@ public class MetadataResource {
     @PUT
     @Produces({"application/json", "text/csv"})
     public Response getMetadata(InputStream is, @Context HttpHeaders 
httpHeaders, @Context UriInfo info) throws Exception {
-        ParseContext context = tikaResource.createParseContext();
-        Metadata metadata = Metadata.newInstance(context);
+        ParseContext context = tikaResource.createRequestContext();
+        Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = TikaInputStream.get(is)) {
             return Response
                     .ok(parseMetadata(tis, metadata, 
httpHeaders.getRequestHeaders(), context))
@@ -140,10 +145,10 @@ public class MetadataResource {
     @Path("{field}")
     @Produces({"application/json", "text/csv", "text/plain"})
     public Response getMetadataField(InputStream is, @Context HttpHeaders 
httpHeaders, @Context UriInfo info, @PathParam("field") String field) throws 
Exception {
-        ParseContext context = tikaResource.createParseContext();
+        ParseContext context = tikaResource.createRequestContext();
         Metadata metadata;
         try (TikaInputStream tis = TikaInputStream.get(is)) {
-            metadata = parseMetadata(tis, Metadata.newInstance(context), 
httpHeaders.getRequestHeaders(), context);
+            metadata = parseMetadata(tis, tikaResource.newRequestMetadata(), 
httpHeaders.getRequestHeaders(), context);
         }
 
         String containerException = 
metadata.get(TikaCoreProperties.CONTAINER_EXCEPTION);
@@ -191,7 +196,7 @@ public class MetadataResource {
         TikaResource.logRequest(LOG, "/meta", metadata);
         List<Metadata> metadataList = tikaResource.parseWithPipes(tis, 
metadata, context, ParseMode.RMETA);
         if (metadataList.isEmpty()) {
-            return Metadata.newInstance(context);
+            return tikaResource.newRequestMetadata();
         }
         return metadataList.get(0);
     }
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
index 54fa3da1c2..13a40e06bc 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
@@ -372,12 +372,10 @@ public class PipesParsingHelper {
         LOG.debug("Parse returned empty result, status: {}", result.status());
         String message = result.message();
         if (message != null && !message.isEmpty()) {
-            // Plain ParseContext, not TikaResource.createParseContext() -- 
this class is
+            // Unbounded Metadata: this holds only our own error message, and 
this class is
             // constructed before TikaResource (which takes it as a 
constructor arg), so
-            // depending back on TikaResource here would be circular. Only 
used to build
-            // an error-result Metadata object; no actual parsing happens on 
this path.
-            ParseContext context = new ParseContext();
-            Metadata errorMetadata = Metadata.newInstance(context);
+            // reaching back for the configured write limiter would be 
circular.
+            Metadata errorMetadata = new Metadata();
             errorMetadata.add(TikaCoreProperties.CONTAINER_EXCEPTION, message);
             return Collections.singletonList(errorMetadata);
         }
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
index eb7e7a1455..4bd0b95f21 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
@@ -17,8 +17,6 @@
 package org.apache.tika.server.core.resource;
 
 import static org.apache.tika.server.core.resource.TikaResource.fillMetadata;
-import static 
org.apache.tika.server.core.resource.TikaResource.setupContentHandlerFactory;
-import static 
org.apache.tika.server.core.resource.TikaResource.setupContentHandlerFactoryIfNeeded;
 
 import java.io.InputStream;
 import java.util.List;
@@ -66,12 +64,12 @@ public class RecursiveMetadataResource {
                                                String handlerTypeName)
             throws Exception {
 
-        final ParseContext context = tikaResource.createParseContext();
+        final ParseContext context = tikaResource.createRequestContext();
 
         fillMetadata(null, metadata, httpHeaders);
         TikaResource.logRequest(LOG, "/rmeta", metadata);
 
-        setupContentHandlerFactory(context, handlerTypeName);
+        tikaResource.setupContentHandlerFactory(context, handlerTypeName);
 
         // Filtering is done in child process, no need to filter again
         return tikaResource.parseWithPipes(tis, metadata, context, 
ParseMode.RMETA);
@@ -107,9 +105,8 @@ public class RecursiveMetadataResource {
     @Produces({"application/json"})
     @Path("form{" + HANDLER_TYPE_PARAM + " : (\\w+)?}")
     public Response getMetadataFromMultipart(Attachment att, 
@PathParam(HANDLER_TYPE_PARAM) String handlerTypeName) throws Exception {
-        ParseContext context = tikaResource.createParseContext();
         try (TikaInputStream tis = 
TikaInputStream.get(att.getObject(InputStream.class))) {
-            List<Metadata> metadataList = parseMetadata(tis, 
Metadata.newInstance(context), att.getHeaders(),
+            List<Metadata> metadataList = parseMetadata(tis, 
tikaResource.newRequestMetadata(), att.getHeaders(),
                     handlerTypeName);
             return Response.ok(new MetadataList(metadataList)).build();
         }
@@ -128,8 +125,8 @@ public class RecursiveMetadataResource {
             List<Attachment> attachments,
             @Context HttpHeaders httpHeaders) throws Exception {
 
-        ParseContext context = tikaResource.createParseContext();
-        Metadata metadata = Metadata.newInstance(context);
+        ParseContext context = tikaResource.createRequestContext();
+        Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = 
tikaResource.setupMultipartConfig(attachments, metadata, context)) {
 
             TikaResource.logRequest(LOG, "/rmeta/config", metadata);
@@ -142,7 +139,7 @@ public class RecursiveMetadataResource {
 
     private MetadataList parseMetadataWithContext(TikaInputStream tis, 
Metadata metadata,
                                                   String handlerTypeName, 
ParseContext context) throws Exception {
-        setupContentHandlerFactoryIfNeeded(context, handlerTypeName);
+        tikaResource.setupContentHandlerFactoryIfNeeded(context, 
handlerTypeName);
 
         // Filtering is done in child process, no need to filter again
         List<Metadata> metadataList = tikaResource.parseWithPipes(tis, 
metadata, context, ParseMode.RMETA);
@@ -177,8 +174,7 @@ public class RecursiveMetadataResource {
     @Produces("application/json")
     @Path("{" + HANDLER_TYPE_PARAM + " : (\\w+)?}")
     public Response getMetadata(InputStream is, @Context HttpHeaders 
httpHeaders, @PathParam(HANDLER_TYPE_PARAM) String handlerTypeName) throws 
Exception {
-        ParseContext context = tikaResource.createParseContext();
-        Metadata metadata = Metadata.newInstance(context);
+        Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = TikaInputStream.get(is)) {
             List<Metadata> metadataList = parseMetadata(tis, metadata, 
httpHeaders.getRequestHeaders(),
                     handlerTypeName);
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
index 49feb7d247..c86aa7fef9 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
@@ -52,14 +52,17 @@ import org.slf4j.LoggerFactory;
 
 import org.apache.tika.Tika;
 import org.apache.tika.config.JsonConfig;
+import org.apache.tika.config.OutputLimits;
 import org.apache.tika.config.loader.TikaLoader;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.pipes.api.ParseMode;
+import org.apache.tika.pipes.core.extractor.UnpackConfig;
 import org.apache.tika.sax.BasicContentHandlerFactory;
 import org.apache.tika.sax.ContentHandlerFactory;
 import org.apache.tika.serialization.ParseContextUtils;
@@ -84,6 +87,13 @@ public class TikaResource {
     // Enforced in setupMultipartConfig so every config-consuming endpoint 
honors it.
     private final boolean allowPerRequestConfig;
 
+    // Config-level parse-context defaults, resolved once at startup and kept 
as values rather
+    // than as a shared ParseContext. Requests carry only their own deltas (see
+    // createRequestContext); the forked worker loads these same defaults from 
the same config.
+    private final MetadataWriteLimiterFactory 
configMetadataWriteLimiterFactory;
+    private final OutputLimits configOutputLimits;
+    private final boolean configSuppliesContentHandlerFactory;
+
     /**
      * @param tikaLoader the Tika loader
      * @param serverStatus server status tracker
@@ -96,6 +106,12 @@ public class TikaResource {
         this.serverStatus = serverStatus;
         this.pipesParsingHelper = pipesParsingHelper;
         this.allowPerRequestConfig = allowPerRequestConfig;
+
+        ParseContext configDefaults = loadConfigDefaults();
+        this.configMetadataWriteLimiterFactory = 
configDefaults.get(MetadataWriteLimiterFactory.class);
+        this.configOutputLimits = OutputLimits.get(configDefaults);
+        this.configSuppliesContentHandlerFactory =
+                configDefaults.get(ContentHandlerFactory.class) != null;
     }
 
     /**
@@ -108,12 +124,11 @@ public class TikaResource {
     }
 
     /**
-     * Creates a new ParseContext with defaults loaded from tika-config.
-     * This loads components from "parse-context" such as DigesterFactory and 
MetadataWriteLimiterFactory.
-     *
-     * @return a new ParseContext with defaults applied
+     * Reads the config's {@code parse-context} section. Private and called 
once: the values we
+     * need are cached above, and a request must not carry these defaults (see
+     * {@link #createRequestContext()}).
      */
-    public ParseContext createParseContext() {
+    private ParseContext loadConfigDefaults() {
         try {
             return tikaLoader.loadParseContext();
         } catch (TikaConfigException e) {
@@ -123,6 +138,47 @@ public class TikaResource {
         }
     }
 
+    /**
+     * Creates a ParseContext holding only what this request itself specifies.
+     * <p>
+     * Config-level {@code parse-context} defaults are deliberately absent. 
The forked worker
+     * loads them from the same config and overlays the request on top, so 
sending them is
+     * redundant -- and worse than redundant at the trust boundary: the worker 
clamps
+     * request-supplied timeout limits but trusts its own config's, so a 
default that arrives
+     * as request data gets treated as caller input and clamped.
+     *
+     * @return an empty, request-scoped ParseContext
+     */
+    public ParseContext createRequestContext() {
+        return new ParseContext();
+    }
+
+    /**
+     * Creates request metadata bounded by the config's metadata write limiter.
+     * <p>
+     * The limiter no longer rides in the request context, so it is applied 
here instead. This
+     * metadata holds caller-supplied values (filename, headers), which is 
exactly what the
+     * limiter is meant to bound.
+     */
+    public Metadata newRequestMetadata() {
+        return configMetadataWriteLimiterFactory == null ? new Metadata()
+                : new 
Metadata(configMetadataWriteLimiterFactory.newInstance());
+    }
+
+    /**
+     * A fresh copy of the config's {@code unpack-config}, or null if none is 
declared.
+     * <p>
+     * The unpack path is the one place a config default must still travel: it 
is mutated
+     * per request (zip, suffix strategy, emitter) and so overrides whatever 
the worker would
+     * have loaded. Starting from a default-constructed instance instead would 
silently reset
+     * operator settings the request never touches -- {@code maxUnpackBytes}, 
for one. Re-read
+     * per call because the caller mutates the result; unpack requests are 
heavyweight enough
+     * that the config read does not register.
+     */
+    public UnpackConfig newConfigUnpackConfig() {
+        return loadConfigDefaults().get(UnpackConfig.class);
+    }
+
 
     public TikaLoader getTikaLoader() {
         return tikaLoader;
@@ -361,7 +417,7 @@ public class TikaResource {
      * @param context the ParseContext to configure
      * @param handlerTypeName the handler type name (text, html, xml, ignore), 
may be null for default
      */
-    public static void setupContentHandlerFactory(ParseContext context, String 
handlerTypeName) {
+    public void setupContentHandlerFactory(ParseContext context, String 
handlerTypeName) {
         BasicContentHandlerFactory.HANDLER_TYPE type;
         try {
             type = 
BasicContentHandlerFactory.parseHandlerType(handlerTypeName, 
DEFAULT_HANDLER_TYPE);
@@ -369,8 +425,17 @@ public class TikaResource {
             // The name comes from the URL path, so this is the caller's typo, 
not our failure.
             throw new BadRequestException(e.getMessage());
         }
+        // Request-supplied limits (per-request config) win; the cached config 
defaults are the
+        // fallback. Neither can be left to OutputLimits.get(context) alone: 
it returns plain
+        // defaults when absent, so a deltas-only context would silently drop 
the operator's
+        // configured write limit -- and this factory is the one the worker 
honors.
+        OutputLimits limits = context.get(OutputLimits.class);
+        if (limits == null) {
+            limits = configOutputLimits;
+        }
         context.set(ContentHandlerFactory.class,
-                BasicContentHandlerFactory.newInstance(type, context));
+                new BasicContentHandlerFactory(type, limits.getWriteLimit(),
+                        limits.isThrowOnWriteLimit(), context));
     }
 
     /**
@@ -380,8 +445,12 @@ public class TikaResource {
      * @param context the ParseContext to configure
      * @param handlerTypeName the handler type name
      */
-    public static void setupContentHandlerFactoryIfNeeded(ParseContext 
context, String handlerTypeName) {
-        if (context.get(ContentHandlerFactory.class) == null) {
+    public void setupContentHandlerFactoryIfNeeded(ParseContext context, 
String handlerTypeName) {
+        // A config-declared factory still takes precedence; it is no longer 
visible in the
+        // request context, so leaving the context untouched lets the worker 
resolve it from
+        // the same config.
+        if (context.get(ContentHandlerFactory.class) == null
+                && !configSuppliesContentHandlerFactory) {
             setupContentHandlerFactory(context, handlerTypeName);
         }
     }
@@ -402,8 +471,7 @@ public class TikaResource {
     private Response putRaw(InputStream is, HttpHeaders httpHeaders, String 
handlerTypeName)
             throws IOException {
         try (TikaInputStream tis = TikaInputStream.get(is)) {
-            ParseContext context = createParseContext();
-            return produceRawOutput(tis, Metadata.newInstance(context),
+            return produceRawOutput(tis, newRequestMetadata(),
                     httpHeaders.getRequestHeaders(), handlerTypeName);
         }
     }
@@ -411,8 +479,7 @@ public class TikaResource {
     private Metadata putJson(InputStream is, HttpHeaders httpHeaders, String 
handlerTypeName)
             throws IOException {
         try (TikaInputStream tis = TikaInputStream.get(is)) {
-            ParseContext context = createParseContext();
-            return produceJson(tis, Metadata.newInstance(context),
+            return produceJson(tis, newRequestMetadata(),
                     httpHeaders.getRequestHeaders(), handlerTypeName);
         }
     }
@@ -504,8 +571,8 @@ public class TikaResource {
 
     private Response postConfigured(List<Attachment> attachments, String 
handlerTypeName)
             throws IOException, TikaConfigException {
-        ParseContext context = createParseContext();
-        Metadata metadata = Metadata.newInstance(context);
+        ParseContext context = createRequestContext();
+        Metadata metadata = newRequestMetadata();
         try (TikaInputStream tis = setupMultipartConfig(attachments, metadata, 
context)) {
             return produceRawOutput(tis, metadata, context, handlerTypeName);
         }
@@ -513,8 +580,8 @@ public class TikaResource {
 
     private Metadata postConfiguredJson(List<Attachment> attachments, String 
handlerTypeName)
             throws IOException, TikaConfigException {
-        ParseContext context = createParseContext();
-        Metadata metadata = Metadata.newInstance(context);
+        ParseContext context = createRequestContext();
+        Metadata metadata = newRequestMetadata();
         try (TikaInputStream tis = setupMultipartConfig(attachments, metadata, 
context)) {
             return produceJson(tis, metadata, context, handlerTypeName);
         }
@@ -606,7 +673,7 @@ public class TikaResource {
                                               MultivaluedMap<String, String> 
httpHeaders,
                                               String handlerTypeName) throws 
IOException {
         fillMetadata(null, metadata, httpHeaders);
-        ParseContext context = createParseContext();
+        ParseContext context = createRequestContext();
         setupContentHandlerFactory(context, handlerTypeName);
         return produceRawOutputWithContext(tis, metadata, context, 
handlerTypeName);
     }
@@ -685,7 +752,7 @@ public class TikaResource {
                                   MultivaluedMap<String, String> headers,
                                   String handlerTypeName) throws IOException {
         fillMetadata(null, metadata, headers);
-        ParseContext context = createParseContext();
+        ParseContext context = createRequestContext();
         setupContentHandlerFactory(context, handlerTypeName);
         return produceJsonWithContext(tis, metadata, context, handlerTypeName);
     }
@@ -714,7 +781,7 @@ public class TikaResource {
                 parseWithPipes(tis, metadata, context, ParseMode.CONCATENATE);
 
         if (metadataList.isEmpty()) {
-            return Metadata.newInstance(context);
+            return newRequestMetadata();
         }
         return metadataList.get(0);
     }
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
index b94be06c4f..1180a62b90 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
@@ -40,6 +40,7 @@ import org.slf4j.LoggerFactory;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.core.extractor.UnpackConfig;
 
 /**
  * JAX-RS resource for unpacking embedded documents from container files.
@@ -148,8 +149,8 @@ public class UnpackerResource {
     @PUT
     @Produces("application/zip")
     public Response unpack(InputStream is, @Context HttpHeaders httpHeaders, 
@Context UriInfo info) throws Exception {
-        ParseContext pc = tikaResource.createParseContext();
-        Metadata metadata = Metadata.newInstance(pc);
+        ParseContext pc = tikaResource.createRequestContext();
+        Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = TikaInputStream.get(is)) {
             fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
             TikaResource.logRequest(LOG, "/unpack", metadata);
@@ -171,8 +172,8 @@ public class UnpackerResource {
     @Consumes("multipart/form-data")
     @Produces("application/zip")
     public Response unpackWithConfig(List<Attachment> attachments, @Context 
HttpHeaders httpHeaders, @Context UriInfo info) throws Exception {
-        ParseContext pc = tikaResource.createParseContext();
-        Metadata metadata = Metadata.newInstance(pc);
+        ParseContext pc = tikaResource.createRequestContext();
+        Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = 
tikaResource.setupMultipartConfig(attachments, metadata, pc)) {
             TikaResource.logRequest(LOG, "/unpack", metadata);
             return doUnpack(tis, metadata, pc, false);
@@ -192,8 +193,8 @@ public class UnpackerResource {
     @PUT
     @Produces("application/zip")
     public Response unpackAll(InputStream is, @Context HttpHeaders 
httpHeaders, @Context UriInfo info) throws Exception {
-        ParseContext pc = tikaResource.createParseContext();
-        Metadata metadata = Metadata.newInstance(pc);
+        ParseContext pc = tikaResource.createRequestContext();
+        Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = TikaInputStream.get(is)) {
             fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
             TikaResource.logRequest(LOG, "/unpack/all", metadata);
@@ -215,8 +216,8 @@ public class UnpackerResource {
     @Consumes("multipart/form-data")
     @Produces("application/zip")
     public Response unpackAllWithConfig(List<Attachment> attachments, @Context 
HttpHeaders httpHeaders, @Context UriInfo info) throws Exception {
-        ParseContext pc = tikaResource.createParseContext();
-        Metadata metadata = Metadata.newInstance(pc);
+        ParseContext pc = tikaResource.createRequestContext();
+        Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = 
tikaResource.setupMultipartConfig(attachments, metadata, pc)) {
             TikaResource.logRequest(LOG, "/unpack/all", metadata);
             return doUnpack(tis, metadata, pc, true);
@@ -239,6 +240,16 @@ public class UnpackerResource {
             throw new WebApplicationException("Pipes-based parsing is not 
enabled", Response.Status.SERVICE_UNAVAILABLE);
         }
 
+        // parseUnpack mutates this and so overrides the worker's own config; 
seed it from the
+        // config's unpack-config (a per-request instance) rather than from 
defaults. A config
+        // supplied by the request itself already sits in pc and wins, as 
before.
+        if (pc.get(UnpackConfig.class) == null) {
+            UnpackConfig fromConfig = tikaResource.newConfigUnpackConfig();
+            if (fromConfig != null) {
+                pc.set(UnpackConfig.class, fromConfig);
+            }
+        }
+
         PipesParsingHelper.UnpackResult result = helper.parseUnpack(tis, 
metadata, pc, saveAll);
 
         Path zipFile = result.zipFile();
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
index 79b100fe75..b7a158363f 100644
--- 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
@@ -315,19 +315,7 @@ public abstract class CXFTestBase {
      */
     private Path createDefaultTestConfig(Path tikaConfigPath) throws 
IOException {
         Path pluginsDir = Paths.get("target/plugins").toAbsolutePath();
-
-        // Read tika config to check for metadata-filters
-        String metadataFiltersJson = "";
-        try {
-            ObjectMapper mapper = new ObjectMapper();
-            JsonNode tikaConfig = mapper.readTree(tikaConfigPath.toFile());
-            JsonNode metadataFilters = tikaConfig.get("metadata-filters");
-            if (metadataFilters != null && !metadataFilters.isEmpty()) {
-                metadataFiltersJson = ",\n              \"metadata-filters\": 
" + mapper.writeValueAsString(metadataFilters);
-            }
-        } catch (Exception e) {
-            LOG.debug("Could not read metadata-filters from tika config: {}", 
e.getMessage());
-        }
+        ObjectMapper mapper = new ObjectMapper();
 
         String configJson = String.format(Locale.ROOT, """
             {
@@ -346,12 +334,35 @@ public abstract class CXFTestBase {
                   "progressTimeoutMillis": 60000
                 }
               },
-              "plugin-roots": "%s"%s
+              "plugin-roots": "%s"
+            }
+            """, pluginsDir.toString().replace("\\", "/"));
+
+        com.fasterxml.jackson.databind.node.ObjectNode root =
+                (com.fasterxml.jackson.databind.node.ObjectNode) 
mapper.readTree(configJson);
+
+        // Production hands the forked worker a ConfigMerger'd copy of the 
server's own config,
+        // so the worker resolves the same parse-context and metadata-filters. 
Carry them across
+        // here too: requests ship only their own entries, so anything the 
harness leaves out of
+        // the worker's config is simply absent rather than arriving over the 
wire.
+        try {
+            JsonNode tikaConfig = mapper.readTree(tikaConfigPath.toFile());
+            JsonNode parseContext = tikaConfig.get("parse-context");
+            if (parseContext != null && parseContext.isObject()) {
+                com.fasterxml.jackson.databind.node.ObjectNode target =
+                        (com.fasterxml.jackson.databind.node.ObjectNode) 
root.get("parse-context");
+                parseContext.properties().forEach(e -> target.set(e.getKey(), 
e.getValue()));
             }
-            """, pluginsDir.toString().replace("\\", "/"), 
metadataFiltersJson);
+            JsonNode metadataFilters = tikaConfig.get("metadata-filters");
+            if (metadataFilters != null && !metadataFilters.isEmpty()) {
+                root.set("metadata-filters", metadataFilters);
+            }
+        } catch (Exception e) {
+            LOG.debug("Could not carry config into the worker config: {}", 
e.getMessage());
+        }
 
         Path tempConfig = Files.createTempFile("tika-test-default-config-", 
".json");
-        Files.writeString(tempConfig, configJson);
+        Files.writeString(tempConfig, mapper.writeValueAsString(root));
         return tempConfig;
     }
 
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/RequestContextIsolationTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/RequestContextIsolationTest.java
new file mode 100644
index 0000000000..2c6bb1351a
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/RequestContextIsolationTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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.server.core.resource;
+
+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.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.OutputLimits;
+import org.apache.tika.config.TimeoutLimits;
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.core.extractor.UnpackConfig;
+import org.apache.tika.sax.BasicContentHandlerFactory;
+import org.apache.tika.sax.ContentHandlerFactory;
+import org.apache.tika.server.core.ServerStatus;
+
+/**
+ * A request carries only what the request itself specifies; config-level
+ * {@code parse-context} defaults stay on the server and are re-supplied by 
the forked worker
+ * from the same config.
+ * <p>
+ * This is a correctness boundary, not just wire economy: the worker clamps 
request-supplied
+ * timeout limits and trusts its own config's, so a config default that 
travels as request data
+ * is silently downgraded to caller input. That the clamp then leaves such a 
request alone is
+ * pinned by 
ServerProtocolIOTest#testServerConfigLimitsAreTrustedAndNeverClamped; together 
the
+ * two cover the path end to end.
+ */
+public class RequestContextIsolationTest {
+
+    private static final String CONFIG = """
+            {
+              "parse-context": {
+                "timeout-limits": {"totalTaskTimeoutMillis": 7200000},
+                "output-limits": {"writeLimit": 12345, "throwOnWriteLimit": 
false},
+                "standard-metadata-limiter-factory": {"excludeFields": 
["dropped-field"]}
+              }
+            }
+            """;
+
+    @TempDir
+    Path tmp;
+
+    private TikaResource newTikaResource(String configJson) throws Exception {
+        Path configPath = tmp.resolve("tika-config-" + configJson.hashCode() + 
".json");
+        Files.writeString(configPath, configJson);
+        return new TikaResource(TikaLoader.load(configPath), new 
ServerStatus(), null, true);
+    }
+
+    private TikaLoader newLoader(String configJson) throws Exception {
+        Path configPath = tmp.resolve("loader-config-" + configJson.hashCode() 
+ ".json");
+        Files.writeString(configPath, configJson);
+        return TikaLoader.load(configPath);
+    }
+
+    /**
+     * The core invariant. Asserts against the loader first so the test cannot 
pass vacuously
+     * on a config whose defaults never resolved.
+     */
+    @Test
+    public void configDefaultsDoNotTravelInTheRequestContext() throws 
Exception {
+        ParseContext configDefaults = newLoader(CONFIG).loadParseContext();
+        assertNotNull(configDefaults.get(TimeoutLimits.class),
+                "precondition: config should declare timeout-limits");
+        assertNotNull(configDefaults.get(OutputLimits.class),
+                "precondition: config should declare output-limits");
+
+        ParseContext request = newTikaResource(CONFIG).createRequestContext();
+
+        assertNull(request.get(TimeoutLimits.class),
+                "config timeout limits must not reach the worker as request 
data -- it clamps "
+                        + "request-supplied limits but trusts its own 
config's");
+        assertNull(request.get(OutputLimits.class));
+        assertTrue(request.getContextMap().isEmpty(),
+                "request context should carry only this request's own 
entries");
+    }
+
+    /**
+     * OutputLimits.get() falls back to defaults when absent, so sourcing the 
handler factory's
+     * limits from the now-empty request context would silently swap the 
operator's write limit
+     * for the default -- and this factory is the one the worker honors.
+     */
+    @Test
+    public void configuredWriteLimitStillReachesTheContentHandlerFactory() 
throws Exception {
+        TikaResource tikaResource = newTikaResource(CONFIG);
+        ParseContext request = tikaResource.createRequestContext();
+
+        tikaResource.setupContentHandlerFactory(request, "text");
+
+        BasicContentHandlerFactory chf =
+                (BasicContentHandlerFactory) 
request.get(ContentHandlerFactory.class);
+        assertEquals(12345, chf.getWriteLimit(), "configured writeLimit must 
survive");
+        assertEquals(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, 
chf.getType());
+    }
+
+    /** The write limiter no longer rides in the context, so it must be 
applied to the metadata. */
+    @Test
+    public void configuredMetadataLimiterStillBoundsRequestMetadata() throws 
Exception {
+        Metadata metadata = newTikaResource(CONFIG).newRequestMetadata();
+
+        metadata.set("dropped-field", "value");
+        metadata.set("kept-field", "value");
+
+        assertNull(metadata.get("dropped-field"),
+                "excluded field should be dropped by the configured write 
limiter");
+        assertEquals("value", metadata.get("kept-field"));
+    }
+
+    /** No configured limiter: plain metadata, no NPE on the null-factory 
path. */
+    @Test
+    public void requestMetadataWorksWithoutAConfiguredLimiter() throws 
Exception {
+        Metadata metadata = newTikaResource("{}").newRequestMetadata();
+        metadata.set("kept-field", "value");
+        assertEquals("value", metadata.get("kept-field"));
+    }
+
+    /**
+     * A config-declared handler factory still wins over the endpoint default. 
It is no longer
+     * visible in the request context, so precedence is preserved by leaving 
the context empty
+     * and letting the worker resolve the same factory from the same config.
+     */
+    @Test
+    public void configContentHandlerFactoryStillWinsOverEndpointDefault() 
throws Exception {
+        String withHandler = """
+                {
+                  "parse-context": {
+                    "basic-content-handler-factory": {"type": "HTML"}
+                  }
+                }
+                """;
+        TikaResource tikaResource = newTikaResource(withHandler);
+        ParseContext request = tikaResource.createRequestContext();
+
+        tikaResource.setupContentHandlerFactoryIfNeeded(request, "text");
+
+        assertNull(request.get(ContentHandlerFactory.class),
+                "endpoint default must not override the config-declared 
factory");
+    }
+
+    /**
+     * The unpack path mutates UnpackConfig and so overrides the worker's own, 
which makes it the
+     * one config default that must still travel. Starting from a 
default-constructed instance
+     * would silently reset operator settings the request never touches -- 
here the
+     * {@code maxUnpackBytes} cap.
+     */
+    @Test
+    public void 
configUnpackConfigIsHandedOutPerRequestAndKeepsOperatorValues() throws 
Exception {
+        String withUnpack = """
+                {
+                  "parse-context": {
+                    "unpack-config": {"maxUnpackBytes": 4242, "zeroPadName": 7}
+                  }
+                }
+                """;
+        TikaResource tikaResource = newTikaResource(withUnpack);
+
+        UnpackConfig first = tikaResource.newConfigUnpackConfig();
+        assertNotNull(first, "config-declared unpack-config should be 
available to the request");
+        assertEquals(4242, first.getMaxUnpackBytes());
+        assertEquals(7, first.getZeroPadName());
+
+        // The unpack path mutates what it is given, so each request needs its 
own instance.
+        first.setZipEmbeddedFiles(true);
+        UnpackConfig second = tikaResource.newConfigUnpackConfig();
+        assertNotSame(first, second, "each request must get its own mutable 
copy");
+        assertFalse(second.isZipEmbeddedFiles(),
+                "one request's mutation must not leak into the next");
+        assertEquals(4242, second.getMaxUnpackBytes());
+    }
+
+    /** No config-declared unpack-config: null, and the unpack path falls back 
as before. */
+    @Test
+    public void noConfigUnpackConfigYieldsNull() throws Exception {
+        assertNull(newTikaResource("{}").newConfigUnpackConfig());
+    }
+
+    /** Without a config-declared factory, the endpoint's handler type is 
installed as before. */
+    @Test
+    public void endpointHandlerTypeIsInstalledWhenConfigDeclaresNone() throws 
Exception {
+        TikaResource tikaResource = newTikaResource("{}");
+        ParseContext request = tikaResource.createRequestContext();
+
+        tikaResource.setupContentHandlerFactoryIfNeeded(request, "text");
+
+        BasicContentHandlerFactory chf =
+                (BasicContentHandlerFactory) 
request.get(ContentHandlerFactory.class);
+        assertNotNull(chf);
+        assertEquals(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, 
chf.getType());
+    }
+}
diff --git 
a/tika-server/tika-server-standard/src/main/java/org/apache/tika/server/standard/resource/XMPMetadataResource.java
 
b/tika-server/tika-server-standard/src/main/java/org/apache/tika/server/standard/resource/XMPMetadataResource.java
index 4d7abd492c..9d3b798882 100644
--- 
a/tika-server/tika-server-standard/src/main/java/org/apache/tika/server/standard/resource/XMPMetadataResource.java
+++ 
b/tika-server/tika-server-standard/src/main/java/org/apache/tika/server/standard/resource/XMPMetadataResource.java
@@ -71,10 +71,10 @@ public class XMPMetadataResource extends MetadataResource 
implements TikaResourc
     @Produces({"application/rdf+xml"})
     @Path("form")
     public Response getMetadataFromMultipart(Attachment att, @Context UriInfo 
info) throws Exception {
-        ParseContext context = new ParseContext();
+        ParseContext context = getTikaResource().createRequestContext();
         try (TikaInputStream tis = 
TikaInputStream.get(att.getObject(InputStream.class))) {
             return Response
-                    .ok(parseMetadata(tis, Metadata.newInstance(context), 
att.getHeaders(), context))
+                    .ok(parseMetadata(tis, 
getTikaResource().newRequestMetadata(), att.getHeaders(), context))
                     .build();
         }
     }
@@ -82,8 +82,8 @@ public class XMPMetadataResource extends MetadataResource 
implements TikaResourc
     @PUT
     @Produces({"application/rdf+xml"})
     public Response getMetadata(InputStream is, @Context HttpHeaders 
httpHeaders, @Context UriInfo info) throws Exception {
-        ParseContext context = new ParseContext();
-        Metadata metadata = Metadata.newInstance(context);
+        ParseContext context = getTikaResource().createRequestContext();
+        Metadata metadata = getTikaResource().newRequestMetadata();
         try (TikaInputStream tis = TikaInputStream.get(is)) {
             return Response
                     .ok(parseMetadata(tis, metadata, 
httpHeaders.getRequestHeaders(), context))

Reply via email to