This is an automated email from the ASF dual-hosted git repository. tballison pushed a commit to branch TIKA-4868-performance-improvements in repository https://gitbox.apache.org/repos/asf/tika.git
commit a864bc897e203f2c94dd814db7ab2c058e9dfc19 Author: tallison <[email protected]> AuthorDate: Wed Sep 2 09:38:01 2026 -0400 TIKA-4868 fixes/reviewer feedback --- CHANGES.txt | 17 +- tika-app/src/main/resources/log4j2.xml | 2 + .../java/org/apache/tika/config/ParseTimeout.java | 2 +- .../apache/tika/config/TransientParseState.java | 26 +++ .../java/org/apache/tika/detect/MagicDetector.java | 18 +- .../java/org/apache/tika/io/ByteArraySource.java | 5 + .../java/org/apache/tika/io/CachingSource.java | 9 + .../main/java/org/apache/tika/io/FileSource.java | 5 + .../java/org/apache/tika/io/ReopenableSource.java | 9 + .../java/org/apache/tika/io/TikaInputSource.java | 6 + .../java/org/apache/tika/io/TikaInputStream.java | 46 +++-- .../main/java/org/apache/tika/mime/MimeTypes.java | 5 +- .../main/java/org/apache/tika/mime/Patterns.java | 22 ++- .../org/apache/tika/parser/CompositeParser.java | 20 +- .../java/org/apache/tika/parser/ParseRecord.java | 3 +- .../apache/tika/sax/FastMarkdownTextRenderer.java | 9 +- .../apache/tika/sax/ToMarkdownContentHandler.java | 3 +- .../org/apache/tika/io/DeclaredLengthTest.java | 111 ++++++++++++ .../org/apache/tika/mime/MimeDetectionTest.java | 14 ++ .../java/org/apache/tika/mime/PatternsTest.java | 16 ++ .../tika/sax/FastMarkdownTextRendererTest.java | 201 +++++++++++++++++++++ .../tika/sax/ToMarkdownContentHandlerTest.java | 18 ++ .../tika/parser/microsoft/WordExtractor.java | 4 - .../org/apache/tika/parser/csv/CSVSniffer.java | 12 +- .../apache/tika/pipes/core/ContentBytesConfig.java | 5 +- .../tika/pipes/core/async/AsyncProcessor.java | 6 + .../tika/pipes/core/emitter/EmitDataImpl.java | 20 ++ .../apache/tika/pipes/core/server/EmitHandler.java | 7 +- .../serdes/ParseContextSerializer.java | 8 + .../TestParseContextSerialization.java | 15 ++ .../tika/server/core/resource/TikaResource.java | 6 +- .../tika-server-core/src/main/resources/log4j2.xml | 2 + .../src/main/resources/log4j2.xml | 2 + 33 files changed, 591 insertions(+), 63 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index f125119319..f0a5cf99d3 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -33,8 +33,9 @@ Release 4.1.0 - unreleased (4.1.0-dev ran it twice) to 52us per document; eml requests ~-45%, ppt ~-35% in tika-server. MagicDetector also precomputes a 256-entry first-byte table per pattern (mask and case fold become one array - load per scanned position). Adds an opt-in RESOURCE_TIMING log on - org.apache.tika.pipes.timing.resource (TIKA-4868). + load per scanned position). Adds a RESOURCE_TIMING log on + org.apache.tika.pipes.timing.resource, silenced by default in the + shipped log4j2 configs (TIKA-4868). * WordExtractor (.doc) cleans each character run in one pass instead of four chained replace/replaceAll copies, and tests paragraph blankness @@ -77,15 +78,19 @@ Release 4.1.0 - unreleased * Pipes workers no longer stall between pre-parse and parse waiting for the client to acknowledge the intermediate-result frame; the - ACK round trip now overlaps the parse. Adds opt-in per-request - timing logs on org.apache.tika.pipes.timing.* (TIKA-4868). + ACK round trip now overlaps the parse. Adds per-request timing logs + on org.apache.tika.pipes.timing.*, silenced by default in the shipped + log4j2 configs; raise that logger to info to enable (TIKA-4868). * DefaultDetector honors CONTENT_TYPE_USER_OVERRIDE and CONTENT_TYPE_PARSER_OVERRIDE before running magic detection, matching CompositeDetector's contract. Removes the second full magic scan every - pipes parse paid per document. Compat note: with a user override set, + pipes parse paid per document. Compat note: with either override set, DefaultDetector no longer lets a more specific magic result overrule - the override (TIKA-4868). + it; parts whose parser declares a type from container headers (e.g. + inline text/* mail parts) now report the declared type, and + CONTENT_TYPE_MAGIC_DETECTED is not recorded when an override short + circuits detection (TIKA-4868). * Markdown output is ~4x faster on large documents: ToMarkdownContentHandler now buffers the commonmark renderer's diff --git a/tika-app/src/main/resources/log4j2.xml b/tika-app/src/main/resources/log4j2.xml index c88e66e99e..d22d891023 100644 --- a/tika-app/src/main/resources/log4j2.xml +++ b/tika-app/src/main/resources/log4j2.xml @@ -25,6 +25,8 @@ </Console> </Appenders> <Loggers> + <!-- opt-in per-request timing lines (TIKA-4868): raise to info to enable --> + <Logger name="org.apache.tika.pipes.timing" level="warn"/> <Root level="info"> <AppenderRef ref="Console"/> </Root> diff --git a/tika-core/src/main/java/org/apache/tika/config/ParseTimeout.java b/tika-core/src/main/java/org/apache/tika/config/ParseTimeout.java index 4f9d385907..df95726f46 100644 --- a/tika-core/src/main/java/org/apache/tika/config/ParseTimeout.java +++ b/tika-core/src/main/java/org/apache/tika/config/ParseTimeout.java @@ -49,7 +49,7 @@ import org.apache.tika.parser.ParseContext; * * @since Apache Tika 4.0 */ -public class ParseTimeout { +public class ParseTimeout implements TransientParseState { private static final Logger LOG = LoggerFactory.getLogger(ParseTimeout.class); diff --git a/tika-core/src/main/java/org/apache/tika/config/TransientParseState.java b/tika-core/src/main/java/org/apache/tika/config/TransientParseState.java new file mode 100644 index 0000000000..cf3d0ef3f2 --- /dev/null +++ b/tika-core/src/main/java/org/apache/tika/config/TransientParseState.java @@ -0,0 +1,26 @@ +/* + * 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.config; + +/** + * Marker for per-parse runtime state that parsers store in a + * {@link org.apache.tika.parser.ParseContext} (e.g. {@code ParseRecord}, + * {@code ParseTimeout}). Such entries are never configuration: serializers + * skip them instead of failing on an unregistered component. + */ +public interface TransientParseState { +} diff --git a/tika-core/src/main/java/org/apache/tika/detect/MagicDetector.java b/tika-core/src/main/java/org/apache/tika/detect/MagicDetector.java index d9d6b2ad2d..2af36ee54f 100644 --- a/tika-core/src/main/java/org/apache/tika/detect/MagicDetector.java +++ b/tika-core/src/main/java/org/apache/tika/detect/MagicDetector.java @@ -459,15 +459,6 @@ public class MagicDetector implements Detector { } } - /** - * Core matching logic that checks if the pattern matches anywhere in the buffer - * within the specified offset range. - * - * @param buffer the byte array to search in - * @param startOffset the first position in the buffer to start matching (inclusive) - * @param endOffset the last position in the buffer to start matching (inclusive) - * @return true if a match is found, false otherwise - */ // Which raw first bytes can begin a match; encodes mask[0] and the case fold so the // scan loop is a single table load. Built lazily, idempotent under racing builds. private transient volatile boolean[] firstByteMatches; @@ -485,6 +476,15 @@ public class MagicDetector implements Detector { return table; } + /** + * Core matching logic that checks if the pattern matches anywhere in the buffer + * within the specified offset range. + * + * @param buffer the byte array to search in + * @param startOffset the first position in the buffer to start matching (inclusive) + * @param endOffset the last position in the buffer to start matching (inclusive) + * @return true if a match is found, false otherwise + */ private boolean matchesBuffer(byte[] buffer, int startOffset, int endOffset) { if (this.isRegex) { int bufferLen = Math.min(buffer.length - startOffset, length + (endOffset - startOffset)); diff --git a/tika-core/src/main/java/org/apache/tika/io/ByteArraySource.java b/tika-core/src/main/java/org/apache/tika/io/ByteArraySource.java index d5eee0b07d..76a3842ce7 100644 --- a/tika-core/src/main/java/org/apache/tika/io/ByteArraySource.java +++ b/tika-core/src/main/java/org/apache/tika/io/ByteArraySource.java @@ -123,6 +123,11 @@ class ByteArraySource extends InputStream implements TikaInputSource { return spilledPath; } + @Override + public boolean hasReliableLength() { + return true; + } + @Override public long getLength() { return length; diff --git a/tika-core/src/main/java/org/apache/tika/io/CachingSource.java b/tika-core/src/main/java/org/apache/tika/io/CachingSource.java index 8b424696c7..47255ccfa4 100644 --- a/tika-core/src/main/java/org/apache/tika/io/CachingSource.java +++ b/tika-core/src/main/java/org/apache/tika/io/CachingSource.java @@ -49,6 +49,8 @@ class CachingSource extends InputStream implements TikaInputSource { // temp-file suffix for threshold spills, which precede any getPath(suffix) call private final String suffix; private long length; + // getLength() is a declared hint until a drain/spill measures it + private boolean lengthMeasured; // Passthrough mode: just a BufferedInputStream private BufferedInputStream passthroughStream; @@ -224,6 +226,7 @@ class CachingSource extends InputStream implements TikaInputSource { // Record the drained length like getPath() does (feeds SecureContentHandler's // zip-bomb ratio) length = channel.size(); + lengthMeasured = true; if (metadata != null && StringUtils.isBlank(metadata.get(HttpHeaders.CONTENT_LENGTH))) { metadata.set(HttpHeaders.CONTENT_LENGTH, Long.toString(length)); @@ -309,6 +312,7 @@ class CachingSource extends InputStream implements TikaInputSource { // The spooled size is ground truth, even when it is 0 and a // Content-Length claimed otherwise length = Files.size(spilledPath); + lengthMeasured = true; // Update metadata if not already set if (metadata != null && @@ -326,6 +330,11 @@ class CachingSource extends InputStream implements TikaInputSource { return length; } + @Override + public boolean hasReliableLength() { + return lengthMeasured; + } + // seekTo() reopens fileStream, so the registered resource must close whichever // handle is current rather than the one open at registration time. private void closeFileStream() throws IOException { diff --git a/tika-core/src/main/java/org/apache/tika/io/FileSource.java b/tika-core/src/main/java/org/apache/tika/io/FileSource.java index 257cbacbc3..a57abf7ba9 100644 --- a/tika-core/src/main/java/org/apache/tika/io/FileSource.java +++ b/tika-core/src/main/java/org/apache/tika/io/FileSource.java @@ -115,6 +115,11 @@ class FileSource extends InputStream implements TikaInputSource { return path; } + @Override + public boolean hasReliableLength() { + return true; + } + @Override public long getLength() { return length; diff --git a/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java b/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java index 332e56eba3..8cbaa66881 100644 --- a/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java +++ b/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java @@ -53,6 +53,8 @@ class ReopenableSource extends InputStream implements TikaInputSource { private final TemporaryResources tmp; private final String suffix; private long length; + // getLength() is a declared hint until a drain/spill measures it + private boolean lengthMeasured; private InputStream currentStream; // lazily opened private long position; @@ -160,6 +162,7 @@ class ReopenableSource extends InputStream implements TikaInputSource { spilledPath = p; // The spooled size is ground truth, even over a lying declared length length = Files.size(p); + lengthMeasured = true; } return spilledPath; } @@ -169,6 +172,11 @@ class ReopenableSource extends InputStream implements TikaInputSource { return length; } + @Override + public boolean hasReliableLength() { + return lengthMeasured; + } + @Override public void enableRewind(CacheMemoryBudget budget) throws IOException { if (position != 0) { @@ -291,6 +299,7 @@ class ReopenableSource extends InputStream implements TikaInputSource { retainedReservation = reservedHere; // The full read is ground truth, even over a lying declared length length = total; + lengthMeasured = true; return true; } diff --git a/tika-core/src/main/java/org/apache/tika/io/TikaInputSource.java b/tika-core/src/main/java/org/apache/tika/io/TikaInputSource.java index 9e7b4a8d30..9f3dc96146 100644 --- a/tika-core/src/main/java/org/apache/tika/io/TikaInputSource.java +++ b/tika-core/src/main/java/org/apache/tika/io/TikaInputSource.java @@ -63,6 +63,12 @@ interface TikaInputSource extends Closeable { */ long getLength(); + /** + * True when {@link #getLength()} is ground truth (file size, byte array, + * fully-drained cache) rather than a caller-declared hint, which may lie. + */ + boolean hasReliableLength(); + /** * Enables full rewind capability. * <p> diff --git a/tika-core/src/main/java/org/apache/tika/io/TikaInputStream.java b/tika-core/src/main/java/org/apache/tika/io/TikaInputStream.java index b58cd548db..ae62397b9e 100644 --- a/tika-core/src/main/java/org/apache/tika/io/TikaInputStream.java +++ b/tika-core/src/main/java/org/apache/tika/io/TikaInputStream.java @@ -100,7 +100,8 @@ public class TikaInputStream extends TaggedInputStream { return (TikaInputStream) stream; } String ext = getExtension(metadata); - TikaInputSource inputSource = new CachingSource(stream, tmp, -1, metadata, ext); + TikaInputSource inputSource = + new CachingSource(stream, tmp, declaredLength(metadata), metadata, ext); return new TikaInputStream(inputSource, tmp, ext); } @@ -123,18 +124,8 @@ public class TikaInputStream extends TaggedInputStream { throw new NullPointerException("The opener must not be null"); } String ext = getExtension(metadata); - long length = -1; - if (metadata != null) { - String cl = metadata.get(HttpHeaders.CONTENT_LENGTH); - if (cl != null) { - try { - length = Long.parseLong(cl); - } catch (NumberFormatException e) { - length = -1; - } - } - } - TikaInputSource inputSource = new ReopenableSource(opener, tmp, length, ext); + TikaInputSource inputSource = + new ReopenableSource(opener, tmp, declaredLength(metadata), ext); return new TikaInputStream(inputSource, tmp, ext); } @@ -284,6 +275,21 @@ public class TikaInputStream extends TaggedInputStream { return tis; } + private static long declaredLength(Metadata metadata) { + if (metadata == null) { + return -1; + } + String cl = metadata.get(HttpHeaders.CONTENT_LENGTH); + if (cl == null) { + return -1; + } + try { + return Long.parseLong(cl); + } catch (NumberFormatException e) { + return -1; + } + } + private static String getExtension(Metadata metadata) { if (metadata == null) { return StringUtils.EMPTY; @@ -440,6 +446,20 @@ public class TikaInputStream extends TaggedInputStream { return source != null && source.getLength() != -1; } + /** + * True when {@link #getLength()} would return a measured, ground-truth length + * (file, byte array, fully-drained cache, explicit override) without forcing a + * spool. False when the only length available is a caller-declared hint + * (Content-Length metadata, HTTP header, archive central directory), which may lie. + */ + public boolean hasReliableLength() { + if (overrideLength >= 0) { + return true; + } + TikaInputSource source = inputSource(); + return source != null && source.hasReliableLength() && source.getLength() != -1; + } + /** * The stream length. For a stream-backed instance with no declared length this * spools the entire remaining stream to a temporary file to measure it. diff --git a/tika-core/src/main/java/org/apache/tika/mime/MimeTypes.java b/tika-core/src/main/java/org/apache/tika/mime/MimeTypes.java index eb0657c3a3..d2e9f082ff 100644 --- a/tika-core/src/main/java/org/apache/tika/mime/MimeTypes.java +++ b/tika-core/src/main/java/org/apache/tika/mime/MimeTypes.java @@ -542,8 +542,9 @@ public final class MimeTypes implements Detector, Serializable { // Get type based on magic prefix if (tis != null) { int toRead = getMinLength(); - // hasLength() is non-forcing; getLength() would only spool when unknown - if (tis.hasLength()) { + // Only a measured length may shrink the read: a lying declared + // Content-Length would silently truncate the magic prefix. + if (tis.hasReliableLength()) { long known = tis.getLength() - tis.getPosition(); if (known >= 0 && known < toRead) { toRead = (int) known; diff --git a/tika-core/src/main/java/org/apache/tika/mime/Patterns.java b/tika-core/src/main/java/org/apache/tika/mime/Patterns.java index 8cc8ce9728..f5f8c234e0 100644 --- a/tika-core/src/main/java/org/apache/tika/mime/Patterns.java +++ b/tika-core/src/main/java/org/apache/tika/mime/Patterns.java @@ -54,9 +54,10 @@ class Patterns implements Serializable { /** * Compiled forms of {@link #globs}' keys. Matching recompiled every glob regex * per lookup before; for names that miss the name/extension indexes that was - * a Pattern.compile per glob per call. + * a Pattern.compile per glob per call. Transient with a lazy rebuild so a + * serialized form from a build without this field still deserializes. */ - private final Map<String, Pattern> compiledGlobs = new HashMap<>(); + private transient Map<String, Pattern> compiledGlobs = new HashMap<>(); private int minExtensionLength = Integer.MAX_VALUE; private int maxExtensionLength = 0; @@ -120,11 +121,23 @@ class Patterns implements Serializable { } } + private Map<String, Pattern> compiledGlobs() { + Map<String, Pattern> compiled = compiledGlobs; + if (compiled == null) { + compiled = new HashMap<>(); + for (String glob : globs.keySet()) { + compiled.put(glob, Pattern.compile(glob)); + } + compiledGlobs = compiled; + } + return compiled; + } + private void addGlob(String glob, MimeType type) throws MimeTypeException { MimeType previous = globs.get(glob); if (previous == null || registry.isSpecializationOf(previous.getType(), type.getType())) { globs.put(glob, type); - compiledGlobs.put(glob, Pattern.compile(glob)); + compiledGlobs().put(glob, Pattern.compile(glob)); } else if (previous == type || registry.isSpecializationOf(type.getType(), previous.getType())) { // do nothing @@ -167,8 +180,9 @@ class Patterns implements Serializable { } // And finally, try complex regexp matching + Map<String, Pattern> compiled = compiledGlobs(); for (Map.Entry<String, MimeType> entry : globs.entrySet()) { - Pattern glob = compiledGlobs.get(entry.getKey()); + Pattern glob = compiled.get(entry.getKey()); boolean matched = glob != null ? glob.matcher(name).matches() : name.matches(entry.getKey()); if (matched) { diff --git a/tika-core/src/main/java/org/apache/tika/parser/CompositeParser.java b/tika-core/src/main/java/org/apache/tika/parser/CompositeParser.java index 4e97d96fc0..7d6a46e0b3 100644 --- a/tika-core/src/main/java/org/apache/tika/parser/CompositeParser.java +++ b/tika-core/src/main/java/org/apache/tika/parser/CompositeParser.java @@ -31,6 +31,7 @@ import org.xml.sax.ContentHandler; import org.xml.sax.SAXException; import org.apache.tika.config.ParseTimeout; +import org.apache.tika.config.TransientParseState; import org.apache.tika.exception.EmbeddedLimitReachedException; import org.apache.tika.exception.TikaException; import org.apache.tika.exception.WriteLimitReachedException; @@ -247,16 +248,19 @@ public class CompositeParser implements Parser { } /** - * Per-parse cache of built parser maps, stored in the ParseContext so every - * embedded document in a parse reuses the container's map instead of - * rebuilding it (a full walk of every parser's supported types). Keyed by - * parser instance because nested composites share one context. The map is - * built once per (parser, context); a context entry that would change a - * parser's supported types mid-parse is not picked up until the next parse. + * Cache of built parser maps, stored in the ParseContext so every embedded + * document in a parse reuses the container's map instead of rebuilding it + * (a full walk of every parser's supported types). Keyed by parser instance + * because nested composites share one context. The map is built once per + * (parser, context) and lives as long as the context: a context entry that + * would change a parser's supported types is not picked up mid-parse, nor on + * later parses that reuse the same ParseContext instance. */ - private static final class ParserMapCache { + private static final class ParserMapCache implements TransientParseState { + // synchronized: contexts are occasionally shared across threads, and a + // concurrent put into a bare IdentityHashMap can corrupt it private final Map<CompositeParser, Map<MediaType, Parser>> maps = - new IdentityHashMap<>(); + Collections.synchronizedMap(new IdentityHashMap<>()); } private Map<MediaType, Parser> getParsersCached(ParseContext context) { diff --git a/tika-core/src/main/java/org/apache/tika/parser/ParseRecord.java b/tika-core/src/main/java/org/apache/tika/parser/ParseRecord.java index 88100d532e..84ce139b2d 100644 --- a/tika-core/src/main/java/org/apache/tika/parser/ParseRecord.java +++ b/tika-core/src/main/java/org/apache/tika/parser/ParseRecord.java @@ -23,6 +23,7 @@ import java.util.Set; import org.apache.tika.config.EmbeddedLimits; import org.apache.tika.config.TimeoutLimits; +import org.apache.tika.config.TransientParseState; import org.apache.tika.metadata.Metadata; /** @@ -34,7 +35,7 @@ import org.apache.tika.metadata.Metadata; * which can be configured via {@link #setMaxEmbeddedDepth(int)} and * {@link #setMaxEmbeddedCount(int)}. */ -public class ParseRecord { +public class ParseRecord implements TransientParseState { //hard limits so that specially crafted files //don't cause an OOM diff --git a/tika-core/src/main/java/org/apache/tika/sax/FastMarkdownTextRenderer.java b/tika-core/src/main/java/org/apache/tika/sax/FastMarkdownTextRenderer.java index ad68db7261..e821ff35c9 100644 --- a/tika-core/src/main/java/org/apache/tika/sax/FastMarkdownTextRenderer.java +++ b/tika-core/src/main/java/org/apache/tika/sax/FastMarkdownTextRenderer.java @@ -40,10 +40,11 @@ import org.commonmark.text.AsciiMatcher; * {@code raw()} call. * <p> * The escaping semantics replicate {@code CoreMarkdownNodeRenderer#visit(Text)} for - * commonmark 0.27.x exactly: the line-start disambiguation cases, the heading escape - * variant, the {@code !}-before-link case, and the {@code \n} numeric reference. The - * differential test in {@code ToMarkdownContentHandlerTest} and the corpus differential - * guard the equivalence. + * the commonmark version pinned in tika-parent exactly: the line-start disambiguation + * cases, the heading escape variant, the {@code !}-before-link case, and the + * {@code \n} numeric reference. {@code FastMarkdownTextRendererTest} renders ASTs + * through this renderer and the stock one and requires byte-identical output; a + * commonmark upgrade that changes escaping fails there and must be re-synced here. * <p> * Text inside a table cell renders through the stock per-char path: the tables extension * pushes a raw-escape for {@code |} onto the writer there, and hand-emitted escapes would diff --git a/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java b/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java index a6614bffae..6a958b1a7d 100644 --- a/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java +++ b/tika-core/src/main/java/org/apache/tika/sax/ToMarkdownContentHandler.java @@ -90,7 +90,8 @@ public class ToMarkdownContentHandler extends DefaultHandler { private final Writer writer; private final MarkdownRenderer renderer = MarkdownRenderer.builder().extensions(EXTENSIONS) - // registered first, so it wins Text rendering from the core renderer + // registered before the core fallback, which is the only other + // renderer claiming Text, so first-wins registration picks this one .nodeRendererFactory(FastMarkdownTextRenderer.FACTORY).build(); private final Document document = new Document(); diff --git a/tika-core/src/test/java/org/apache/tika/io/DeclaredLengthTest.java b/tika-core/src/test/java/org/apache/tika/io/DeclaredLengthTest.java new file mode 100644 index 0000000000..1a9a6f5c01 --- /dev/null +++ b/tika-core/src/test/java/org/apache/tika/io/DeclaredLengthTest.java @@ -0,0 +1,111 @@ +/* + * 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.io; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.apache.tika.metadata.HttpHeaders; +import org.apache.tika.metadata.Metadata; + +/** + * Declared-vs-measured length semantics of {@link TikaInputStream}: a + * Content-Length hint is served without a spool but is never "reliable"; + * a measured length (byte array, file, spool) is. + */ +public class DeclaredLengthTest { + + @TempDir + Path tmpDir; + + @Test + public void testStreamHonorsDeclaredLength() throws Exception { + byte[] data = "0123456789".getBytes(UTF_8); + Metadata metadata = new Metadata(); + metadata.set(HttpHeaders.CONTENT_LENGTH, "7"); + try (TikaInputStream tis = TikaInputStream.get( + new ByteArrayInputStream(data), new TemporaryResources(), metadata)) { + assertTrue(tis.hasLength()); + assertFalse(tis.hasReliableLength(), "declared length is a hint, not ground truth"); + // declared value served without forcing a spool + assertEquals(7, tis.getLength()); + assertFalse(tis.hasFile()); + } + } + + @Test + public void testSpoolMeasuresOverDeclaredLength() throws Exception { + byte[] data = "0123456789".getBytes(UTF_8); + Metadata metadata = new Metadata(); + metadata.set(HttpHeaders.CONTENT_LENGTH, "7"); + try (TikaInputStream tis = TikaInputStream.get( + new ByteArrayInputStream(data), new TemporaryResources(), metadata)) { + tis.getPath(); + assertTrue(tis.hasReliableLength()); + assertEquals(data.length, tis.getLength(), "spooled size wins over the lying hint"); + } + } + + @Test + public void testStreamWithNoDeclaredLengthStillSpoolsToMeasure() throws Exception { + byte[] data = "0123456789".getBytes(UTF_8); + try (TikaInputStream tis = TikaInputStream.get( + new ByteArrayInputStream(data), new TemporaryResources(), new Metadata())) { + assertFalse(tis.hasLength()); + assertEquals(data.length, tis.getLength()); + assertTrue(tis.hasReliableLength()); + } + } + + @Test + public void testByteArrayAndFileAreReliable() throws Exception { + try (TikaInputStream tis = TikaInputStream.get("abc".getBytes(UTF_8))) { + assertTrue(tis.hasReliableLength()); + assertEquals(3, tis.getLength()); + } + Path f = tmpDir.resolve("len.bin"); + java.nio.file.Files.write(f, "abcd".getBytes(UTF_8)); + try (TikaInputStream tis = TikaInputStream.get(f)) { + assertTrue(tis.hasReliableLength()); + assertEquals(4, tis.getLength()); + } + } + + @Test + public void testReopenableDeclaredLengthNotReliableUntilSpool() throws Exception { + byte[] data = "0123456789".getBytes(UTF_8); + Metadata metadata = new Metadata(); + metadata.set(HttpHeaders.CONTENT_LENGTH, "3"); + try (TikaInputStream tis = TikaInputStream.get( + () -> new ByteArrayInputStream(data), new TemporaryResources(), metadata)) { + assertTrue(tis.hasLength()); + assertFalse(tis.hasReliableLength()); + assertEquals(3, tis.getLength()); + tis.getPath(); + assertTrue(tis.hasReliableLength()); + assertEquals(data.length, tis.getLength()); + } + } +} diff --git a/tika-core/src/test/java/org/apache/tika/mime/MimeDetectionTest.java b/tika-core/src/test/java/org/apache/tika/mime/MimeDetectionTest.java index 5748f185c1..a3794f3184 100644 --- a/tika-core/src/test/java/org/apache/tika/mime/MimeDetectionTest.java +++ b/tika-core/src/test/java/org/apache/tika/mime/MimeDetectionTest.java @@ -306,4 +306,18 @@ public class MimeDetectionTest { public void testPNGWithSomeEmlHeaders() throws IOException { testFile("image/png", "test-pngNotEml.bin"); } + + @Test + public void testLyingDeclaredLengthDoesNotTruncateMagic() throws Exception { + byte[] pdf = "%PDF-1.4\nsome pdf content".getBytes(UTF_8); + Metadata metadata = new Metadata(); + // an under-declared Content-Length must not shrink the magic read + metadata.set(HttpHeaders.CONTENT_LENGTH, "2"); + try (TikaInputStream tis = TikaInputStream.get( + new java.io.ByteArrayInputStream(pdf), + new org.apache.tika.io.TemporaryResources(), metadata)) { + assertEquals(MediaType.application("pdf"), + MIME_TYPES.detect(tis, metadata, new ParseContext())); + } + } } diff --git a/tika-core/src/test/java/org/apache/tika/mime/PatternsTest.java b/tika-core/src/test/java/org/apache/tika/mime/PatternsTest.java index 25721b15fd..9d3c7f3aa9 100644 --- a/tika-core/src/test/java/org/apache/tika/mime/PatternsTest.java +++ b/tika-core/src/test/java/org/apache/tika/mime/PatternsTest.java @@ -97,4 +97,20 @@ public class PatternsTest { assertTrue(extensions.contains(".jpeg")); } + + @Test + public void testSerializationRoundTrip() throws Exception { + patterns.add("*ile*.txt", text); + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + try (java.io.ObjectOutputStream oos = new java.io.ObjectOutputStream(bos)) { + oos.writeObject(patterns); + } + Patterns copy; + try (java.io.ObjectInputStream ois = new java.io.ObjectInputStream( + new java.io.ByteArrayInputStream(bos.toByteArray()))) { + copy = (Patterns) ois.readObject(); + } + // compiledGlobs is transient; the glob path must rebuild it lazily + assertEquals(text, copy.matches("file7.txt")); + } } diff --git a/tika-core/src/test/java/org/apache/tika/sax/FastMarkdownTextRendererTest.java b/tika-core/src/test/java/org/apache/tika/sax/FastMarkdownTextRendererTest.java new file mode 100644 index 0000000000..9122e84269 --- /dev/null +++ b/tika-core/src/test/java/org/apache/tika/sax/FastMarkdownTextRendererTest.java @@ -0,0 +1,201 @@ +/* + * 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.sax; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +import org.commonmark.Extension; +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.node.Document; +import org.commonmark.node.Heading; +import org.commonmark.node.Link; +import org.commonmark.node.Node; +import org.commonmark.node.Paragraph; +import org.commonmark.node.Text; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.markdown.MarkdownRenderer; +import org.junit.jupiter.api.Test; + +/** + * Differential guard for {@link FastMarkdownTextRenderer}: every AST must render + * byte-identically through the fast renderer and through commonmark's stock + * renderer. If a commonmark upgrade changes {@code CoreMarkdownNodeRenderer}'s + * escaping, these tests fail and the fast renderer must be re-synced. + */ +public class FastMarkdownTextRendererTest { + + private static final List<Extension> EXTENSIONS = + Arrays.asList(TablesExtension.create(), StrikethroughExtension.create()); + + private static final MarkdownRenderer FAST = MarkdownRenderer.builder() + .extensions(EXTENSIONS) + .nodeRendererFactory(FastMarkdownTextRenderer.FACTORY).build(); + + private static final MarkdownRenderer STOCK = + MarkdownRenderer.builder().extensions(EXTENSIONS).build(); + + private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); + + private static void assertSameRendering(Node document, String label) { + assertEquals(STOCK.render(document), FAST.render(document), label); + } + + private static void assertSameRendering(String markdown) { + assertSameRendering(PARSER.parse(markdown), "diverged on: " + markdown); + } + + private static void assertSameRendering(Node document) { + assertSameRendering(document, "diverged on hand-built AST"); + } + + private static Node paragraphs(String... literals) { + Document doc = new Document(); + Paragraph p = new Paragraph(); + for (String literal : literals) { + p.appendChild(new Text(literal)); + } + doc.appendChild(p); + return doc; + } + + @Test + public void testLineStartEscapes() { + // Literals whose first char would parse as block structure at line start + for (String literal : new String[]{ + "- not a list", "-not a list", "-", "+ plus", "* star", + "# not a heading", "#hash", "#", + "= not setext", "=", + "1. not ordered", "12. also not", "123456789. limit", "1) paren", + "1234567890. ten digits is not a marker", "12x. no", + "> not a quote", + " leading space", "\tleading tab", " two spaces"}) { + assertSameRendering(paragraphs(literal), "diverged on literal: " + literal); + } + } + + @Test + public void testSecondTextNodeNotAtLineStart() { + // The line-start branches must not fire mid-line + assertSameRendering(paragraphs("before ", "- mid", "# mid", "= mid", "12. mid")); + } + + @Test + public void testEscapableCharacters() { + for (String literal : new String[]{ + "a[b]c", "a<b>c", "a`b`c", "a*b*c", "a_b_c", "a&b;c", "a\\b", + "pipe | in text", "tilde ~~x~~", "", "plain text, no specials.", + "text\nwith\nnewlines", "\n", "trailing newline\n", "*", "\\"}) { + assertSameRendering(paragraphs(literal), "diverged on literal: " + literal); + } + } + + @Test + public void testBangBeforeLink() { + Document doc = new Document(); + Paragraph p = new Paragraph(); + p.appendChild(new Text("see!")); + Link link = new Link("http://example.com", null); + link.appendChild(new Text("here")); + p.appendChild(link); + doc.appendChild(p); + assertSameRendering(doc, "bang before link"); + + // bang NOT followed by a link needs no escape + assertSameRendering(paragraphs("no link here!")); + } + + @Test + public void testHeadingEscapeSet() { + for (String literal : new String[]{"plain", "with # hash", "with\nnewline", "a`b"}) { + Document doc = new Document(); + Heading h = new Heading(); + h.setLevel(2); + h.appendChild(new Text(literal)); + doc.appendChild(h); + assertSameRendering(doc, "heading literal: " + literal); + } + } + + @Test + public void testParsedDocuments() { + // Round-trips through the parser: escaped specials come back as raw Text + for (String markdown : new String[]{ + "\\- not a list\n", + "\\# not a heading\n", + "12\\. not ordered\n", + "para one\n\npara two with \\*stars\\* and \\[brackets\\]\n", + "**bold** and _em_ and ~~strike~~\n", + "a paragraph\nwith a soft break\n", + "| a | b\\|c |\n|---|---|\n| d | *e* |\n", + "# heading `code` and *em*\n", + "> quoted \\> text\n", + "[link](http://example.com \"ti\\\"tle\") and \n"}) { + assertSameRendering(markdown); + } + } + + @Test + public void testRandomizedDifferential() { + char[] alphabet = ("abc XYZ \t\n-#=!*_[]<>&`\\|~.)0129" + + "é中😀").toCharArray(); + for (long seed = 0; seed < 50; seed++) { + Random random = new Random(seed); + StringBuilder sb = new StringBuilder(); + int len = 1 + random.nextInt(80); + for (int i = 0; i < len; i++) { + sb.append(alphabet[random.nextInt(alphabet.length)]); + } + String literal = sb.toString(); + // the alphabet splits astral chars into chars; skip broken pairs + if (!isWellFormed(literal)) { + continue; + } + assertSameRendering(paragraphs(literal), "seed " + seed + " literal: " + literal); + } + } + + private static boolean isWellFormed(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isHighSurrogate(c)) { + if (i + 1 >= s.length() || !Character.isLowSurrogate(s.charAt(i + 1))) { + return false; + } + i++; + } else if (Character.isLowSurrogate(c)) { + return false; + } + } + return true; + } + + @Test + public void testTableCellKeepsStockPath() { + // '|' inside a cell must come out escaped once, not twice + Node doc = PARSER.parse("| a\\|b | *c* |\n|---|---|\n| \\|start | end\\| |\n"); + String stock = STOCK.render(doc); + String fast = FAST.render(doc); + assertEquals(stock, fast, "table cell rendering"); + assertTrue(fast.contains("a\\|b"), "single-escaped pipe expected: " + fast); + } +} diff --git a/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java b/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java index 26c8a3d6e5..2b060e504d 100644 --- a/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java +++ b/tika-core/src/test/java/org/apache/tika/sax/ToMarkdownContentHandlerTest.java @@ -1520,4 +1520,22 @@ public class ToMarkdownContentHandlerTest { handler.writePartialContentIfUnfinished(); assertEquals(afterEnd, writer.toString(), "writePartial after endDocument must be a no-op"); } + + @Test + public void testRenderBufferBoundaries() throws Exception { + // Spans sized around the internal 8 KB render buffer: exact fit, one-over, + // buffer-sized single write (direct path), and a multi-drain run. + for (int size : new int[]{8191, 8192, 8193, 16384, 40000}) { + StringWriter writer = new StringWriter(); + ToMarkdownContentHandler handler = new ToMarkdownContentHandler(writer); + String run = "a".repeat(size); + handler.startDocument(); + startElement(handler, "p"); + chars(handler, run); + endElement(handler, "p"); + handler.endDocument(); + assertTrue(writer.toString().contains(run), + "clean " + size + "-char run must survive buffering intact"); + } + } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java index 25ad90d49f..18efa9bcd2 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java @@ -106,10 +106,6 @@ public class WordExtractor extends AbstractPOIFSExtractor { return count; } - /** - * Given a style name, return what tag should be used, and - * what style should be applied to it. - */ // matches the old regex [\r\n\s]+ ( \s == [ \t\n\x0B\f\r] ), without the // full replaceAll copy it used to make per paragraph private static boolean isBlankParagraph(String text) { diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/csv/CSVSniffer.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/csv/CSVSniffer.java index 164eacfd15..0ef0cd53cf 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/csv/CSVSniffer.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/csv/CSVSniffer.java @@ -21,6 +21,7 @@ import java.io.EOFException; import java.io.IOException; import java.io.Reader; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -58,13 +59,18 @@ class CSVSniffer { } // Every snifflet examines the same window, so read it once into a buffer // instead of once per delimiter through a mark/reset + pushback stack. - char[] buf = new char[markLimit]; + // Grown on demand: markLimit is user-configurable and most inputs are + // far smaller, so an eager char[markLimit] would be waste per parse. + char[] buf = new char[Math.min(markLimit, 8192)]; reader.mark(markLimit); int len = 0; try { while (len < markLimit) { - int n = reader.read(buf, len, markLimit - len); - if (n == -1) { + if (len == buf.length) { + buf = Arrays.copyOf(buf, (int) Math.min((long) buf.length * 2, markLimit)); + } + int n = reader.read(buf, len, buf.length - len); + if (n <= 0) { break; } len += n; diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ContentBytesConfig.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ContentBytesConfig.java index 5d00bbd6ff..a8f8d2c5e3 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ContentBytesConfig.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ContentBytesConfig.java @@ -25,7 +25,10 @@ import org.apache.tika.annotation.TikaComponent; * {@code TIKA_CONTENT} into raw UTF-8 bytes on the {@code EmitData}, which travel * as binary over the IPC instead of a Smile-encoded string. A caller who sets this * must read {@code EmitData.getContentBytes()}; the metadata no longer carries the - * content. tika-server sets it for the raw-output endpoints. + * content. tika-server sets it for the raw-output endpoints. Results routed to a + * regular Emitter get the content restored into the metadata first + * ({@code EmitDataImpl#restoreContentFromBytes()}), so the move only sticks for + * consumers on the passback path. */ @TikaComponent(name = "content-bytes-config") public class ContentBytesConfig implements Serializable { diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java index 70849fa8c7..1a6b472bbc 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java @@ -54,6 +54,7 @@ import org.apache.tika.pipes.core.PipesResults; import org.apache.tika.pipes.core.RestartReason; import org.apache.tika.pipes.core.ServerManager; import org.apache.tika.pipes.core.SharedServerManager; +import org.apache.tika.pipes.core.emitter.EmitDataImpl; import org.apache.tika.pipes.core.emitter.EmitterManager; import org.apache.tika.pipes.core.reporter.ReporterManager; import org.apache.tika.plugins.TikaPluginManager; @@ -484,6 +485,11 @@ public class AsyncProcessor implements Closeable { long offerStart = System.currentTimeMillis(); if (shouldEmit(result)) { + if (result.emitData() instanceof EmitDataImpl emitDataImpl) { + // a client-side Emitter reads only the metadata list; + // undo any content-bytes move so content is not lost + emitDataImpl.restoreContentFromBytes(); + } LOG.trace("adding result to emitter queue: " + result.emitData()); boolean offered = emitDataTupleQueue.offer( new EmitDataPair(t.getEmitKey().getEmitterId(), result.emitData()), MAX_OFFER_WAIT_MS, diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java index ebe53de666..d7683b5e76 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java @@ -16,9 +16,11 @@ */ package org.apache.tika.pipes.core.emitter; +import java.nio.charset.StandardCharsets; import java.util.List; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.api.emitter.EmitData; import org.apache.tika.utils.StringUtils; @@ -66,6 +68,24 @@ public class EmitDataImpl implements EmitData { this.contentBytes = contentBytes; } + /** + * Inverse of the content-bytes move: puts the content back in + * {@code TIKA_CONTENT} for consumers that only read the metadata list + * (e.g. a regular Emitter). No-op when there are no content bytes or + * the metadata already carries content. + */ + public void restoreContentFromBytes() { + if (contentBytes == null || metadataList == null || metadataList.isEmpty()) { + return; + } + Metadata m = metadataList.get(0); + if (m.get(TikaCoreProperties.TIKA_CONTENT) == null) { + m.set(TikaCoreProperties.TIKA_CONTENT, + new String(contentBytes, StandardCharsets.UTF_8)); + } + contentBytes = null; + } + public long getEstimatedSizeBytes() { long sz = estimateSizeInBytes(getEmitKey(), getMetadataList(), containerStackTrace); if (contentBytes != null) { diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/EmitHandler.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/EmitHandler.java index 57bac19e86..7a73feb4a7 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/EmitHandler.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/EmitHandler.java @@ -172,11 +172,12 @@ class EmitHandler { return; } Metadata m = metadataList.get(0); - String content = m.get(TikaCoreProperties.TIKA_CONTENT); - if (content == null) { + String[] content = m.getValues(TikaCoreProperties.TIKA_CONTENT); + // remove() drops every value; only move when that loses nothing + if (content.length != 1) { return; } - emitData.setContentBytes(content.getBytes(StandardCharsets.UTF_8)); + emitData.setContentBytes(content[0].getBytes(StandardCharsets.UTF_8)); m.remove(TikaCoreProperties.TIKA_CONTENT); } diff --git a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextSerializer.java b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextSerializer.java index 592aaed460..658dcaaf51 100644 --- a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextSerializer.java +++ b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextSerializer.java @@ -29,6 +29,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializerProvider; import org.apache.tika.config.JsonConfig; +import org.apache.tika.config.TransientParseState; import org.apache.tika.config.loader.TikaObjectMapperFactory; import org.apache.tika.parser.ParseContext; import org.apache.tika.serialization.ComponentNameResolver; @@ -79,6 +80,13 @@ public class ParseContextSerializer extends JsonSerializer<ParseContext> { continue; } + // Per-parse runtime state (ParseRecord, ParseTimeout, parser-map cache) + // is never configuration; a context that has been through a parse must + // still serialize. + if (value instanceof TransientParseState) { + continue; + } + // Find the friendly component name — all serializable components must be registered String keyName = ComponentNameResolver.getFriendlyName(value.getClass()); if (keyName == null) { diff --git a/tika-serialization/src/test/java/org/apache/tika/serialization/TestParseContextSerialization.java b/tika-serialization/src/test/java/org/apache/tika/serialization/TestParseContextSerialization.java index d93e5924b1..5a5526b941 100644 --- a/tika-serialization/src/test/java/org/apache/tika/serialization/TestParseContextSerialization.java +++ b/tika-serialization/src/test/java/org/apache/tika/serialization/TestParseContextSerialization.java @@ -591,4 +591,19 @@ public class TestParseContextSerialization { "the message must name the offending key: " + e.getMessage()); } + + @Test + public void testTransientParseStateSkipped() throws Exception { + // A context that has been through a parse carries per-parse runtime state + // (ParseRecord, ParseTimeout); serialization must skip it, not throw. + ParseContext pc = new ParseContext(); + pc.set(org.apache.tika.parser.ParseRecord.class, + org.apache.tika.parser.ParseRecord.newInstance(pc)); + org.apache.tika.config.ParseTimeout.getOrCreate(pc); + + String json = serializeParseContext(pc); + ObjectMapper mapper = createMapper(); + JsonNode root = mapper.readTree(json); + assertEquals(0, root.size(), "transient parse state must not serialize: " + json); + } } 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 ab48d46e85..b955bde571 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 @@ -697,13 +697,13 @@ public class TikaResource { // ==================== Internal methods ==================== - /** - * Produces raw streaming output (text, html, xml, md) using pipes-based parsing. - */ /** Per-request resource-layer latency; joins the pipes lines by adjacency at c=1. */ private static final org.slf4j.Logger TIMING_LOG = org.slf4j.LoggerFactory.getLogger("org.apache.tika.pipes.timing.resource"); + /** + * Produces raw streaming output (text, html, xml, md) using pipes-based parsing. + */ private Response produceRawOutput(TikaInputStream tis, Metadata metadata, MultivaluedMap<String, String> httpHeaders, String handlerTypeName) throws IOException { diff --git a/tika-server/tika-server-core/src/main/resources/log4j2.xml b/tika-server/tika-server-core/src/main/resources/log4j2.xml index c88e66e99e..d22d891023 100644 --- a/tika-server/tika-server-core/src/main/resources/log4j2.xml +++ b/tika-server/tika-server-core/src/main/resources/log4j2.xml @@ -25,6 +25,8 @@ </Console> </Appenders> <Loggers> + <!-- opt-in per-request timing lines (TIKA-4868): raise to info to enable --> + <Logger name="org.apache.tika.pipes.timing" level="warn"/> <Root level="info"> <AppenderRef ref="Console"/> </Root> diff --git a/tika-server/tika-server-standard/src/main/resources/log4j2.xml b/tika-server/tika-server-standard/src/main/resources/log4j2.xml index c88e66e99e..d22d891023 100644 --- a/tika-server/tika-server-standard/src/main/resources/log4j2.xml +++ b/tika-server/tika-server-standard/src/main/resources/log4j2.xml @@ -25,6 +25,8 @@ </Console> </Appenders> <Loggers> + <!-- opt-in per-request timing lines (TIKA-4868): raise to info to enable --> + <Logger name="org.apache.tika.pipes.timing" level="warn"/> <Root level="info"> <AppenderRef ref="Console"/> </Root>
