diff --git a/THIRD-PARTY.txt b/THIRD-PARTY.txt index 1dfebafb3..d65dc091c 100644 --- a/THIRD-PARTY.txt +++ b/THIRD-PARTY.txt @@ -73,6 +73,7 @@ List of third-party dependencies grouped by their license type. * Apache Solr (module: solrj) (org.apache.solr:solr-solrj:10.0.0 - https://solr.apache.org/) * Apache Solr (module: solrj-jetty) (org.apache.solr:solr-solrj-jetty:10.0.0 - https://solr.apache.org/) * Apache Solr (module: solrj-zookeeper) (org.apache.solr:solr-solrj-zookeeper:10.0.0 - https://solr.apache.org/) + * Apache Tika Annotation Processor (org.apache.tika:tika-annotation-processor:4.0.0 - https://tika.apache.org) * Apache Tika Apple parser module (org.apache.tika:tika-parser-apple-module:4.0.0 - https://tika.apache.org/tika-parser-apple-module/) * Apache Tika audiovideo parser module (org.apache.tika:tika-parser-audiovideo-module:4.0.0 - https://tika.apache.org/tika-parser-audiovideo-module/) * Apache Tika cad parser module (org.apache.tika:tika-parser-cad-module:4.0.0 - https://tika.apache.org/tika-parser-cad-module/) @@ -81,10 +82,13 @@ List of third-party dependencies grouped by their license type. * Apache Tika crypto parser module (org.apache.tika:tika-parser-crypto-module:4.0.0 - https://tika.apache.org/tika-parser-crypto-module/) * Apache Tika data URI commons (org.apache.tika:tika-parser-datauri-commons:4.0.0 - https://tika.apache.org/tika-parser-datauri-commons/) * Apache Tika digest commons (org.apache.tika:tika-parser-digest-commons:4.0.0 - https://tika.apache.org/tika-parser-digest-commons/) + * Apache Tika eval core (org.apache.tika:tika-eval-core:4.0.0 - https://tika.apache.org/tika-eval-core/) * Apache Tika font parser module (org.apache.tika:tika-parser-font-module:4.0.0 - https://tika.apache.org/tika-parser-font-module/) * Apache Tika HTML encoding detector (org.apache.tika:tika-encoding-detector-html:4.0.0 - https://tika.apache.org/tika-encoding-detectors/tika-encoding-detector-html/) * Apache Tika html parser module (org.apache.tika:tika-parser-html-module:4.0.0 - https://tika.apache.org/tika-parser-html-module/) * Apache Tika image parser module (org.apache.tika:tika-parser-image-module:4.0.0 - https://tika.apache.org/tika-parser-image-module/) + * Apache Tika langdetect (built-in charsoup) (org.apache.tika:tika-langdetect-charsoup:4.0.0 - https://tika.apache.org/tika-langdetect-charsoup/) + * Apache Tika langdetect (charsoup core — no Tika dependencies) (org.apache.tika:tika-langdetect-charsoup-core:4.0.0 - https://tika.apache.org/tika-langdetect-charsoup-core/) * Apache Tika mail commons (org.apache.tika:tika-parser-mail-commons:4.0.0 - https://tika.apache.org/tika-parser-mail-commons/) * Apache Tika mail parser module (org.apache.tika:tika-parser-mail-module:4.0.0 - https://tika.apache.org/tika-parser-mail-module/) * Apache Tika Microsoft parser module (org.apache.tika:tika-parser-microsoft-module:4.0.0 - https://tika.apache.org/tika-parser-microsoft-module/) @@ -96,6 +100,12 @@ List of third-party dependencies grouped by their license type. * Apache Tika OCR parser module (org.apache.tika:tika-parser-ocr-module:4.0.0 - https://tika.apache.org/tika-parser-ocr-module/) * Apache Tika package parser module (org.apache.tika:tika-parser-pkg-module:4.0.0 - https://tika.apache.org/tika-parser-pkg-module/) * Apache Tika PDF parser module (org.apache.tika:tika-parser-pdf-module:4.0.0 - https://tika.apache.org/tika-parser-pdf-module/) + * Apache Tika pipes api (org.apache.tika:tika-pipes-api:4.0.0 - https://tika.apache.org/) + * Apache Tika pipes core (org.apache.tika:tika-pipes-core:4.0.0 - https://tika.apache.org/) + * Apache Tika Pipes File System (org.apache.tika:tika-pipes-file-system:4.0.0 - https://tika.apache.org/tika-pipes-file-system/) + * Apache Tika pipes fork parser (org.apache.tika:tika-pipes-fork-parser:4.0.0 - https://tika.apache.org/) + * Apache Tika Pipes iterators - base (org.apache.tika:tika-pipes-iterator-commons:4.0.0 - https://tika.apache.org/) + * Apache Tika plugins core (org.apache.tika:tika-plugins-core:4.0.0 - https://tika.apache.org) * Apache Tika serialization (org.apache.tika:tika-serialization:4.0.0 - https://tika.apache.org) * Apache Tika standard parser package (org.apache.tika:tika-parsers-standard-package:4.0.0 - https://tika.apache.org/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/) * Apache Tika text parser module (org.apache.tika:tika-parser-text-module:4.0.0 - https://tika.apache.org/tika-parser-text-module/) @@ -176,7 +186,9 @@ List of third-party dependencies grouped by their license type. * jackson-databind (com.fasterxml.jackson.core:jackson-databind:2.22.0 - https://github.com/FasterXML/jackson) * Jackson dataformat: CBOR (com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.18.8 - https://github.com/FasterXML/jackson-dataformats-binary) * Jackson dataformat: Smile (com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.18.8 - https://github.com/FasterXML/jackson-dataformats-binary) + * Jackson dataformat: Smile (com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.22.1 - https://github.com/FasterXML/jackson-dataformats-binary) * Jackson-dataformat-YAML (com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.8 - https://github.com/FasterXML/jackson-dataformats-text) + * Jackson datatype: JSR310 (com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.22.1 - https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310) * java-libpst (com.pff:java-libpst:0.9.3 - https://github.com/rjohnsondev/java-libpst) * JCL 1.2 implemented over SLF4J (org.slf4j:jcl-over-slf4j:2.0.17 - http://www.slf4j.org) * JCL 1.2 implemented over SLF4J (org.slf4j:jcl-over-slf4j:2.0.18 - http://www.slf4j.org) @@ -257,6 +269,7 @@ List of third-party dependencies grouped by their license type. * parso (com.epam:parso:2.0.14 - https://github.com/epam/parso) * PDFBox JBIG2 ImageIO plugin (org.apache.pdfbox:jbig2-imageio:3.0.5 - https://www.apache.org/jbig2-imageio/) * perfmark:perfmark-api (io.perfmark:perfmark-api:0.27.0 - https://github.com/perfmark/perfmark) + * PF4J (org.pf4j:pf4j:3.15.0 - https://pf4j.org/pf4j) * Playwright - Driver (com.microsoft.playwright:driver:1.63.0 - https://github.com/microsoft/playwright-java/driver) * Playwright - Main Library (com.microsoft.playwright:playwright:1.63.0 - https://github.com/microsoft/playwright-java/playwright) * Playwright - Node.js For All Platforms (com.microsoft.playwright:driver-bundle:1.63.0 - https://github.com/microsoft/playwright-java/driver-bundle) @@ -391,6 +404,7 @@ List of third-party dependencies grouped by their license type. * Animal Sniffer Annotations (org.codehaus.mojo:animal-sniffer-annotations:1.24 - https://www.mojohaus.org/animal-sniffer/animal-sniffer-annotations) * dd-plist (com.googlecode.plist:dd-plist:1.30 - http://www.github.com/3breadt/dd-plist) + * Java SemVer (com.github.zafarkhaja:java-semver:0.10.2 - https://github.com/zafarkhaja/jsemver) * JOpt Simple (net.sf.jopt-simple:jopt-simple:5.0.4 - http://jopt-simple.github.io/jopt-simple) * jsoup Java HTML Parser (org.jsoup:jsoup:1.23.2 - https://jsoup.org/) * JTokkit (com.knuddels:jtokkit:1.1.0 - https://github.com/knuddelsgmbh/jtokkit) diff --git a/archetype/src/main/resources/archetype-resources/pom.xml b/archetype/src/main/resources/archetype-resources/pom.xml index 1a7d48eaa..a37373194 100644 --- a/archetype/src/main/resources/archetype-resources/pom.xml +++ b/archetype/src/main/resources/archetype-resources/pom.xml @@ -88,6 +88,43 @@ under the License. + + + META-INF/extensions.idx + + + META-INF/tika/detectors.idx + + + META-INF/tika/encoding-detectors.idx + + + META-INF/tika/language-detectors.idx + + + META-INF/tika/metadata-filters.idx + + + META-INF/tika/parse-context.idx + + + META-INF/tika/parsers.idx + + + META-INF/tika/renderers.idx + + + META-INF/tika/translators.idx + diff --git a/docs/src/main/asciidoc/configuration.adoc b/docs/src/main/asciidoc/configuration.adoc index 24d2ebf83..14887660c 100644 --- a/docs/src/main/asciidoc/configuration.adoc +++ b/docs/src/main/asciidoc/configuration.adoc @@ -570,6 +570,10 @@ See the link:https://github.com/apache/stormcrawler/tree/main/external/tika[tika | parser.tika.config.file | tika-config.json | Name of the classpath resource holding the Tika configuration (JSON format since Tika 4). | parser.extract.embedded | false | Whether to parse embedded documents. Since Tika 4 embedded documents are no longer parsed unless this is set to `true`. | parser.tika.text.maxlength | -1 | Maximum number of characters of text extracted from a document, -1 (or any negative value) for no limit. When the limit is reached the parse stops, the text and outlinks extracted so far are kept and the metadata `parse.text.trimmed` is set to `true`. +| parser.tika.timeout | -1 | Maximum time in milliseconds a document may take to parse, 0 or less for no limit. When set, the parse runs in a forked JVM (Tika Pipes), one per bolt instance; a parse that exceeds this is killed outright and reported to the status stream as an `ERROR` with the message `parse timeout`. A partial result returned between embedded documents is kept and marked `parse.text.trimmed`. Keep it well below `topology.message.timeout.secs`: the time a tuple waits behind a slow parse or a fork restart counts against that too. `parser.htmlmapper.classname` is ignored in this mode, see the README of the module. +| parser.tika.pipes.jvmargs | -Xmx512m | JVM arguments passed to each forked process under `parser.tika.timeout`. `-Xmx512m` is added when they set no maximum heap (`-Xmx`, `-XX:MaxHeapSize` or `-XX:MaxRAM*`). +| parser.tika.pipes.maxfilesperprocess | - | Restart a forked process after this many documents under `parser.tika.timeout`, to bound slow leaks in parsing libraries. Unset uses Tika's default, 10000. +| parser.tika.pipes.plugins.dir | - | Directory holding Tika Pipes plugin zips under `parser.tika.timeout`, not needed by default. Unset uses Tika's default plugin directory resolution. |=== NOTE: When using the Tika `ParserBolt` alongside `JSoupParserBolt`, set `jsoup.treat.non.html.as.error` to `false` so that non-HTML content is passed through to the Tika parser rather than being treated as an error. diff --git a/external/opensearch/archetype/src/main/resources/archetype-resources/pom.xml b/external/opensearch/archetype/src/main/resources/archetype-resources/pom.xml index 94cefffc6..115554aea 100644 --- a/external/opensearch/archetype/src/main/resources/archetype-resources/pom.xml +++ b/external/opensearch/archetype/src/main/resources/archetype-resources/pom.xml @@ -89,6 +89,43 @@ under the License. + + + META-INF/extensions.idx + + + META-INF/tika/detectors.idx + + + META-INF/tika/encoding-detectors.idx + + + META-INF/tika/language-detectors.idx + + + META-INF/tika/metadata-filters.idx + + + META-INF/tika/parse-context.idx + + + META-INF/tika/parsers.idx + + + META-INF/tika/renderers.idx + + + META-INF/tika/translators.idx + diff --git a/external/solr/archetype/src/main/resources/archetype-resources/pom.xml b/external/solr/archetype/src/main/resources/archetype-resources/pom.xml index 14967e13f..4636f44fc 100644 --- a/external/solr/archetype/src/main/resources/archetype-resources/pom.xml +++ b/external/solr/archetype/src/main/resources/archetype-resources/pom.xml @@ -89,6 +89,43 @@ under the License. + + + META-INF/extensions.idx + + + META-INF/tika/detectors.idx + + + META-INF/tika/encoding-detectors.idx + + + META-INF/tika/language-detectors.idx + + + META-INF/tika/metadata-filters.idx + + + META-INF/tika/parse-context.idx + + + META-INF/tika/parsers.idx + + + META-INF/tika/renderers.idx + + + META-INF/tika/translators.idx + diff --git a/external/tika/README.md b/external/tika/README.md index b04ae1dda..ac7ef37aa 100644 --- a/external/tika/README.md +++ b/external/tika/README.md @@ -39,4 +39,10 @@ Embedded documents are only parsed when `parser.extract.embedded` is set to `tru The length of the text extracted from a document can be limited with `parser.tika.text.maxlength` (number of characters, default `-1`, any negative value means no limit). When the limit is reached the parse stops, the text and outlinks extracted so far are kept and the document is emitted with the metadata `parse.text.trimmed` set to `true`. +The time spent parsing a document can be limited with `parser.tika.timeout` (milliseconds, default `-1`, 0 or less means no limit). When set, the parse runs in a forked JVM via [Tika Pipes](https://tika.apache.org/docs/4.0.x/pipes/index.html), one per bolt instance. ParserBolt starts the fork during `prepare()` using a small test parse. A parse that reaches the hard deadline is reported as `parse timeout`; a partial result returned between embedded documents is kept and marked `parse.text.trimmed`. A forked JVM that dies while parsing, e.g. running out of memory, is reported as an `ERROR` with the message `parse crash` and restarted; the Storm worker is not affected. By default, Tika closes an idle fork after 60 seconds and starts another for the next document. Keep the timeout well below `topology.message.timeout.secs`: the time a tuple waits behind a slow parse or a fork restart counts against that too. + +Each `ParserBolt` instance runs one fork, so a host needs the worker heap plus one fork heap per `ParserBolt` instance on it, and some overhead for each JVM. When several forks share a host, consider `-XX:ActiveProcessorCount` as well as a heap limit. A handful of related keys tune the forked JVMs: `parser.tika.pipes.jvmargs` (`-Xmx512m` is added when they set no maximum heap), `parser.tika.pipes.maxfilesperprocess` (restart a fork after this many documents, to bound slow leaks in parsing libraries; Tika's default is 10000), and `parser.tika.pipes.plugins.dir` (Tika Pipes plugins, not needed by default). Documents over 10MB are handed to the forked JVM through a temporary file in the worker's `java.io.tmpdir`. + +`parser.htmlmapper.classname` is not applied to parses running under `parser.tika.timeout`: an `HtmlMapper` cannot be passed to the forked JVM, which always uses Tika's `DefaultHtmlMapper`. That mapper drops the elements it does not consider safe from the DOM given to the parse filters, so XPath expressions written against the default `IdentityHtmlMapper` output may need adjusting. + Since Tika 4, Tika metadata keys use namespaced names, which surface as renamed `parse.*` keys, e.g. `parse.resourceName` is now `parse.tk:resource-name`. diff --git a/external/tika/pom.xml b/external/tika/pom.xml index c9d8fe4a7..f2feb2b7e 100644 --- a/external/tika/pom.xml +++ b/external/tika/pom.xml @@ -60,6 +60,26 @@ under the License. ${tika.version} + + + org.apache.tika + tika-pipes-fork-parser + ${tika.version} + + + + org.apache.logging.log4j + log4j-core + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + + @@ -93,6 +113,17 @@ under the License. test + + + org.apache.tika + tika-core + ${tika.version} + test-jar + test + + diff --git a/external/tika/src/main/java/org/apache/stormcrawler/tika/ParserBolt.java b/external/tika/src/main/java/org/apache/stormcrawler/tika/ParserBolt.java index 5c96aeb02..450b1748f 100644 --- a/external/tika/src/main/java/org/apache/stormcrawler/tika/ParserBolt.java +++ b/external/tika/src/main/java/org/apache/stormcrawler/tika/ParserBolt.java @@ -21,9 +21,11 @@ import java.io.IOException; import java.io.InputStream; +import java.io.StringReader; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -34,6 +36,8 @@ import java.util.List; import java.util.Map; import java.util.regex.Pattern; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParserFactory; import org.apache.commons.lang3.StringUtils; import org.apache.html.dom.HTMLDocumentImpl; import org.apache.http.HttpHeaders; @@ -61,16 +65,26 @@ import org.apache.stormcrawler.util.MetadataTransfer; import org.apache.stormcrawler.util.URLUtil; import org.apache.tika.Tika; +import org.apache.tika.config.TimeoutLimits; import org.apache.tika.config.loader.TikaLoader; import org.apache.tika.exception.TikaConfigException; +import org.apache.tika.exception.TikaException; import org.apache.tika.exception.WriteLimitReachedException; import org.apache.tika.io.TikaInputStream; import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.EmptyParser; import org.apache.tika.parser.ParseContext; import org.apache.tika.parser.Parser; +import org.apache.tika.parser.html.DefaultHtmlMapper; import org.apache.tika.parser.html.HtmlMapper; import org.apache.tika.parser.html.IdentityHtmlMapper; +import org.apache.tika.pipes.api.ParseMode; +import org.apache.tika.pipes.api.PipesResult; +import org.apache.tika.pipes.core.PipesException; +import org.apache.tika.pipes.fork.PipesForkParser; +import org.apache.tika.pipes.fork.PipesForkParserConfig; +import org.apache.tika.pipes.fork.PipesForkResult; +import org.apache.tika.sax.BasicContentHandlerFactory; import org.apache.tika.sax.BodyContentHandler; import org.apache.tika.sax.Link; import org.apache.tika.sax.LinkContentHandler; @@ -80,6 +94,10 @@ import org.slf4j.LoggerFactory; import org.w3c.dom.DocumentFragment; import org.xml.sax.ContentHandler; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.SAXParseException; +import org.xml.sax.XMLReader; /** Uses Tika to parse the output of a fetch and extract text + metadata. */ public class ParserBolt extends BaseRichBolt { @@ -96,6 +114,55 @@ public class ParserBolt extends BaseRichBolt { */ public static final String TEXT_TRIMMED_KEY = "parse.text.trimmed"; + /** + * Configuration key for the maximum time in milliseconds a document may take to parse, 0 or + * less for no limit. Runs the parse in a forked JVM (Tika Pipes) and kills it outright if + * exceeded. Keep below {@code topology.message.timeout.secs}. + */ + public static final String PARSE_TIMEOUT_PARAM = "parser.tika.timeout"; + + /** + * Directory holding Tika Pipes plugin zips under {@link #PARSE_TIMEOUT_PARAM}, not needed by + * default. Unset uses Tika's default. + */ + public static final String PIPES_PLUGINS_DIR_PARAM = "parser.tika.pipes.plugins.dir"; + + /** + * JVM arguments passed to each forked process under {@link #PARSE_TIMEOUT_PARAM}. The fork gets + * {@code -Xmx512m} when they set no maximum heap. + */ + public static final String PIPES_JVM_ARGS_PARAM = "parser.tika.pipes.jvmargs"; + + /** Heap of a forked process when {@link #PIPES_JVM_ARGS_PARAM} sets none. */ + private static final String PIPES_DEFAULT_HEAP = "-Xmx512m"; + + /** Restart a forked process after this many documents under {@link #PARSE_TIMEOUT_PARAM}. */ + public static final String PIPES_MAX_FILES_PER_PROCESS_PARAM = + "parser.tika.pipes.maxfilesperprocess"; + + /** + * Cap on the characters of text a fork may return when {@link #TEXT_MAX_LENGTH_PARAM} is not + * set. Tika counts text only, the markup around it comes on top. + */ + private static final int PIPES_WRITE_LIMIT_CHARS = 20_000_000; + + private static final SAXParserFactory PIPES_CONTENT_PARSER_FACTORY = + newHardenedSaxParserFactory(); + + private static SAXParserFactory newHardenedSaxParserFactory() { + SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setNamespaceAware(true); + try { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (ParserConfigurationException | SAXException e) { + throw new IllegalStateException( + "Failed to configure the XML parser used to re-read Tika Pipes output", e); + } + return factory; + } + private Tika tika; /** ParseContext configured from the "parse-context" section of the Tika configuration. */ @@ -125,6 +192,17 @@ public class ParserBolt extends BaseRichBolt { private int textMaxLength = -1; + private long parseTimeout = -1; + + /** Runs parses under {@link #PARSE_TIMEOUT_PARAM} in a forked JVM; null otherwise. */ + private PipesForkParser pipesForkParser; + + /** On-disk copy of the Tika configuration, kept alive to merge into the forked JVM's config. */ + private Path resolvedTikaConfigPath; + + /** Whether {@link #resolvedTikaConfigPath} is a temp copy this bolt owns and must delete. */ + private boolean resolvedTikaConfigPathIsTemporary; + @Override public void prepare( @NotNull Map conf, @@ -170,8 +248,17 @@ public void prepare( int maxLength = ConfUtils.getInt(conf, TEXT_MAX_LENGTH_PARAM, -1); textMaxLength = maxLength < 0 ? -1 : maxLength; + parseTimeout = ConfUtils.getLong(conf, PARSE_TIMEOUT_PARAM, -1); + tika = instantiateTika(conf); + if (parseTimeout > 0) { + pipesForkParser = buildPipesForkParser(conf); + startFork(); + } else { + deleteTemporaryTikaConfig(); + } + this.collector = collector; this.eventCounter = @@ -314,8 +401,28 @@ public void execute(Tuple tuple) { String text; boolean textTrimmed = false; try (TikaInputStream tis = TikaInputStream.get(content)) { - tika.getParser().parse(tis, teeHandler, md, parseContext); + if (pipesForkParser != null) { + PipesParseOutcome outcome = parseWithPipes(tis, md, teeHandler, url); + md = outcome.metadata(); + textTrimmed = outcome.trimmed(); + if (textTrimmed) { + LOG.info("Text of {} trimmed", url); + eventCounter.scope("text_trimmed").incrBy(1); + } + } else { + tika.getParser().parse(tis, teeHandler, md, parseContext); + } text = textHandler.toString(); + } catch (ParseTimeoutException e) { + handleException(url, null, metadata, tuple, "parse timeout"); + return; + } catch (ParseCrashException e) { + handleException(url, e, metadata, tuple, "parse crash"); + return; + } catch (ParsePipesInfraException e) { + // infra problem, not a bad document -- affects every parse, distinct status + handleException(url, e, metadata, tuple, "parse pipes error"); + return; } catch (Throwable e) { if (!WriteLimitReachedException.isWriteLimitReached(e)) { handleException(url, e, metadata, tuple, "parse error"); @@ -415,21 +522,21 @@ private Tika instantiateTika(Map conf) { "Tika configuration file " + tikaConfigFile + " not found on classpath"); } LOG.info("Instantiating Tika using custom configuration {}", tikaConfigUrl); - Path configPath = null; - boolean temporary = false; + Path configPath; try { if ("file".equals(tikaConfigUrl.getProtocol())) { configPath = Paths.get(tikaConfigUrl.toURI()); } else { - // TikaLoader can only read configurations from the filesystem: - // copy the resource to a temporary file and delete it as soon - // as the configuration has been loaded + // TikaLoader needs a filesystem path; kept until cleanup() when the forked + // JVMs under parser.tika.timeout need it too, deleteOnExit() as a safety net configPath = Files.createTempFile("tika-config", ".json"); - temporary = true; + configPath.toFile().deleteOnExit(); + resolvedTikaConfigPathIsTemporary = true; try (InputStream is = tikaConfigUrl.openStream()) { Files.copy(is, configPath, StandardCopyOption.REPLACE_EXISTING); } } + resolvedTikaConfigPath = configPath; TikaLoader tikaLoader = TikaLoader.load(configPath, getClass().getClassLoader()); configuredParseContext = tikaLoader.loadParseContext(); Tika tika = new Tika(tikaLoader.loadDetectors(), tikaLoader.loadAutoDetectParser()); @@ -438,15 +545,242 @@ private Tika instantiateTika(Map conf) { } catch (IOException | TikaConfigException | URISyntaxException e) { throw new IllegalStateException( "Failed to instantiate Tika using custom configuration " + tikaConfigUrl, e); - } finally { - if (temporary && configPath != null) { - try { - Files.deleteIfExists(configPath); - } catch (IOException e) { - LOG.warn("Failed to delete temporary Tika configuration {}", configPath, e); + } + } + + /** + * Builds the {@link PipesForkParser} used under {@link #PARSE_TIMEOUT_PARAM}: the fork kills a + * parse that runs past its {@code TimeoutLimits} outright, without relying on cooperative + * interruption. + */ + private PipesForkParser buildPipesForkParser(Map conf) { + PipesForkParserConfig pipesConfig = new PipesForkParserConfig(); + // XML, not HTML: ToXMLContentHandler always self-closes/escapes (safe to re-parse + // locally); ToHTMLContentHandler leaves some elements unclosed per the HTML spec. + pipesConfig.setHandlerType(BasicContentHandlerFactory.HANDLER_TYPE.XML); + // stop the fork where the text would be trimmed anyway + pipesConfig.setWriteLimit(textMaxLength >= 0 ? textMaxLength : PIPES_WRITE_LIMIT_CHARS); + // matches how the direct parse already merges embedded content into one stream + pipesConfig.setParseMode(ParseMode.CONCATENATE); + pipesConfig.setMaxEmbeddedCount(extractEmbedded ? -1 : 0); + pipesConfig.setTimeoutLimits(new TimeoutLimits(parseTimeout, parseTimeout)); + if (resolvedTikaConfigPath != null) { + pipesConfig.setUserConfigPath(resolvedTikaConfigPath); + } + + // execute() parses one document at a time, a second fork per bolt would sit idle + pipesConfig.setNumClients(1); + // same JVM as the Storm worker instead of whatever "java" is on the PATH + pipesConfig + .getPipesConfig() + .setJavaPath(Paths.get(System.getProperty("java.home"), "bin", "java").toString()); + int maxFilesPerProcess = ConfUtils.getInt(conf, PIPES_MAX_FILES_PER_PROCESS_PARAM, -1); + if (maxFilesPerProcess > 0) { + pipesConfig.setMaxFilesPerProcess(maxFilesPerProcess); + } + String pluginsDir = ConfUtils.getString(conf, PIPES_PLUGINS_DIR_PARAM, null); + if (StringUtils.isNotBlank(pluginsDir)) { + pipesConfig.setPluginsDir(Paths.get(pluginsDir)); + } + pipesConfig.setJvmArgs(forkedJvmArgs(conf)); + + // an HtmlMapper can't be passed to the forked JVM, which always uses Tika's default + if (!DefaultHtmlMapper.class.equals(htmlMapperClass)) { + LOG.warn( + "parser.htmlmapper.classname ({}) is ignored under {}: the forked JVM maps" + + " HTML with {}, which drops some elements from the DOM given to" + + " the parse filters", + htmlMapperClass.getName(), + PARSE_TIMEOUT_PARAM, + DefaultHtmlMapper.class.getSimpleName()); + } + + try { + return new PipesForkParser(pipesConfig); + } catch (IOException | TikaConfigException e) { + throw new IllegalStateException( + "Failed to initialise the Tika Pipes fork parser for " + PARSE_TIMEOUT_PARAM, + e); + } + } + + /** + * Returns the JVM arguments of a forked process: the configured ones, plus {@link + * #PIPES_DEFAULT_HEAP} when none of them sets a maximum heap. Without it Tika gives each fork + * 60% of the RAM, whatever the number of forks on the host. + */ + static List forkedJvmArgs(Map conf) { + final List jvmArgs = + new ArrayList<>(ConfUtils.loadListFromConf(PIPES_JVM_ARGS_PARAM, conf)); + if (jvmArgs.stream().noneMatch(ParserBolt::setsHeap)) { + jvmArgs.add(PIPES_DEFAULT_HEAP); + } + return jvmArgs; + } + + private static boolean setsHeap(String jvmArg) { + return jvmArg.startsWith("-Xmx") + || jvmArg.startsWith("-XX:MaxHeapSize") + || jvmArg.startsWith("-XX:MaxRAM"); + } + + /** + * Parses a one-word document so that a fork which cannot start fails the bolt here, instead of + * failing every document with a "parse pipes error" status. The fork starts lazily otherwise. + */ + private void startFork() { + final PipesForkResult result; + try (TikaInputStream tis = + TikaInputStream.get("StormCrawler".getBytes(StandardCharsets.UTF_8))) { + result = pipesForkParser.parse(tis); + } catch (IOException | TikaException | PipesException e) { + throw new IllegalStateException( + "The Tika Pipes fork for " + PARSE_TIMEOUT_PARAM + " did not start", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while starting the Tika Pipes fork for " + PARSE_TIMEOUT_PARAM, e); + } + if (!result.isSuccess()) { + throw new IllegalStateException( + "The Tika Pipes fork for " + + PARSE_TIMEOUT_PARAM + + " failed a test parse: " + + describe(result)); + } + } + + private static String describe(PipesForkResult result) { + return result.getStatus() + + (result.getMessage() != null ? " - " + result.getMessage() : ""); + } + + /** + * The parse under {@link #PARSE_TIMEOUT_PARAM} did not complete within {@link #parseTimeout}. + */ + private static final class ParseTimeoutException extends Exception { + ParseTimeoutException(String message) { + super(message); + } + } + + /** + * The forked JVM crashed (OOM or otherwise) while parsing; it restarts for the next document. + */ + private static final class ParseCrashException extends Exception { + ParseCrashException(String message) { + super(message); + } + } + + /** Tika Pipes itself is misconfigured or unavailable, independently of the document parsed. */ + private static final class ParsePipesInfraException extends Exception { + ParsePipesInfraException(String message, Throwable cause) { + super(message, cause); + } + } + + private record PipesParseOutcome(org.apache.tika.metadata.Metadata metadata, boolean trimmed) {} + + /** + * Parses {@code tis} in a forked JVM, then replays the returned content into {@code handler} as + * if parsed in-process, so outlink/text/DOM handling downstream is unchanged. + */ + private PipesParseOutcome parseWithPipes( + TikaInputStream tis, + org.apache.tika.metadata.Metadata seedMetadata, + ContentHandler handler, + String url) + throws ParseTimeoutException, + ParseCrashException, + ParsePipesInfraException, + IOException, + SAXException { + PipesForkResult result; + try { + result = pipesForkParser.parse(tis, seedMetadata, new ParseContext()); + } catch (TikaException | PipesException e) { + throw new ParsePipesInfraException("Tika Pipes error for " + url, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ParsePipesInfraException( + "Interrupted while waiting for a Tika Pipes parse of " + url, e); + } + + if (result.isProcessCrash()) { + if (result.getStatus() == PipesResult.RESULT_STATUS.TIMEOUT) { + throw new ParseTimeoutException( + "Tika parse of " + url + " exceeded " + parseTimeout + "ms"); + } + throw new ParseCrashException( + "Tika parse of " + url + " crashed the forked process: " + describe(result)); + } + + if (!result.isSuccess()) { + throw new IOException("Tika Pipes parse of " + url + " failed: " + describe(result)); + } + + org.apache.tika.metadata.Metadata resultMetadata = result.getMetadata(); + + if (result.getStatus() == PipesResult.RESULT_STATUS.PARSE_SUCCESS_WITH_EXCEPTION + && !isWriteLimitReached(resultMetadata)) { + // not a text.maxlength trim: match the direct path, which discards the whole + // document rather than keep partial content + throw new IOException( + "Tika Pipes parse of " + + url + + " threw: " + + (resultMetadata != null + ? resultMetadata.get(TikaCoreProperties.CONTAINER_EXCEPTION) + : result.getMessage())); + } + + boolean trimmed = + result.getStatus() == PipesResult.RESULT_STATUS.PARSE_SUCCESS_WITH_EXCEPTION + || result.getStatus() == PipesResult.RESULT_STATUS.PARTIAL_TIMEOUT; + + String xml = result.getContent(); + if (StringUtils.isNotBlank(xml)) { + try { + reparseIntoHandler(xml, handler); + } catch (SAXException e) { + if (WriteLimitReachedException.isWriteLimitReached(e)) { + trimmed = true; + } else if (!(trimmed && e instanceof SAXParseException)) { + throw e; } + // a trimmed fork returns XML cut off mid-document: the handlers keep + // what they got before the cut, as with a trimmed direct parse } } + + org.apache.tika.metadata.Metadata md = + resultMetadata != null ? resultMetadata : seedMetadata; + // the fork returns the content as metadata, it must not end up in parse.* + md.remove(TikaCoreProperties.TIKA_CONTENT.getName()); + md.remove(TikaCoreProperties.TIKA_CONTENT_HANDLER_TYPE.getName()); + return new PipesParseOutcome(md, trimmed); + } + + private static boolean isWriteLimitReached(org.apache.tika.metadata.Metadata metadata) { + return metadata != null + && "true".equalsIgnoreCase(metadata.get(TikaCoreProperties.WRITE_LIMIT_REACHED)); + } + + /** + * Re-parses already-extracted XML into {@code handler}. Produced by {@code + * ToXMLContentHandler}, which always self-closes/escapes, so it's safe to re-read this way. + */ + private static void reparseIntoHandler(String xml, ContentHandler handler) + throws SAXException, IOException { + try { + XMLReader reader = PIPES_CONTENT_PARSER_FACTORY.newSAXParser().getXMLReader(); + reader.setContentHandler(handler); + reader.parse(new InputSource(new StringReader(xml))); + } catch (ParserConfigurationException e) { + throw new IllegalStateException( + "Failed to create the XML parser used to re-read Tika Pipes output", e); + } } /** @@ -551,5 +885,27 @@ public void cleanup() { if (parseFilters != null) { parseFilters.cleanup(); } + if (pipesForkParser != null) { + try { + pipesForkParser.close(); + } catch (IOException e) { + LOG.warn("Failed to close the Tika Pipes fork parser", e); + } + } + deleteTemporaryTikaConfig(); + } + + private void deleteTemporaryTikaConfig() { + if (resolvedTikaConfigPathIsTemporary && resolvedTikaConfigPath != null) { + try { + Files.deleteIfExists(resolvedTikaConfigPath); + } catch (IOException e) { + LOG.warn( + "Failed to delete temporary Tika configuration {}", + resolvedTikaConfigPath, + e); + } + resolvedTikaConfigPathIsTemporary = false; + } } } diff --git a/external/tika/src/test/java/org/apache/stormcrawler/tika/ParserBoltPipesJvmArgsTest.java b/external/tika/src/test/java/org/apache/stormcrawler/tika/ParserBoltPipesJvmArgsTest.java new file mode 100644 index 000000000..c6eeb3f2d --- /dev/null +++ b/external/tika/src/test/java/org/apache/stormcrawler/tika/ParserBoltPipesJvmArgsTest.java @@ -0,0 +1,64 @@ +/* + * 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.stormcrawler.tika; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class ParserBoltPipesJvmArgsTest { + + @Test + void defaultHeapWhenNoArgumentIsSet() { + Assertions.assertEquals(List.of("-Xmx512m"), ParserBolt.forkedJvmArgs(Map.of())); + } + + @Test + void defaultHeapIsAddedToOtherArguments() { + Assertions.assertEquals( + List.of("-Djava.awt.headless=true", "-Xmx512m"), + ParserBolt.forkedJvmArgs( + Map.of(ParserBolt.PIPES_JVM_ARGS_PARAM, "-Djava.awt.headless=true"))); + } + + @Test + void configuredHeapIsKept() { + Assertions.assertEquals( + List.of("-Xmx1g"), + ParserBolt.forkedJvmArgs(Map.of(ParserBolt.PIPES_JVM_ARGS_PARAM, "-Xmx1g"))); + } + + @Test + void configuredMaxHeapSizeIsKept() { + Assertions.assertEquals( + List.of("-XX:MaxHeapSize=2g"), + ParserBolt.forkedJvmArgs( + Map.of(ParserBolt.PIPES_JVM_ARGS_PARAM, "-XX:MaxHeapSize=2g"))); + } + + @Test + void configuredRamPercentageIsKept() { + Assertions.assertEquals( + List.of("-XX:MaxRAMPercentage=25"), + ParserBolt.forkedJvmArgs( + Map.of( + ParserBolt.PIPES_JVM_ARGS_PARAM, + List.of("-XX:MaxRAMPercentage=25")))); + } +} diff --git a/external/tika/src/test/java/org/apache/stormcrawler/tika/ParserBoltPipesTimeoutTest.java b/external/tika/src/test/java/org/apache/stormcrawler/tika/ParserBoltPipesTimeoutTest.java new file mode 100644 index 000000000..0ce52b5b7 --- /dev/null +++ b/external/tika/src/test/java/org/apache/stormcrawler/tika/ParserBoltPipesTimeoutTest.java @@ -0,0 +1,238 @@ +/* + * 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.stormcrawler.tika; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.storm.task.OutputCollector; +import org.apache.stormcrawler.Constants; +import org.apache.stormcrawler.Metadata; +import org.apache.stormcrawler.TestUtil; +import org.apache.stormcrawler.parse.ParsingTester; +import org.apache.stormcrawler.persistence.Status; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** + * Proves parser.tika.timeout (Tika Pipes) kills a stuck parse outright, unlike cooperative + * interruption: MockParser.hang(interruptible=false) never checks Thread.interrupt() and produces + * no SAX events, so a callback-based interrupt check would never even run. + * + *

{@code } content needs an {@code } declaration: MockParser is dispatched via + * {@code application/mock+xml} (registered as root-XML "mock" in tika-core's own + * custom-mimetypes.xml), and root-XML sniffing only refines bytes already magic-classified as + * {@code application/xml}. Without the declaration it falls back to text/plain. + * + * @see #2097 + * @see #2182 + */ +class ParserBoltPipesTimeoutTest extends ParsingTester { + + private static final String XML_DECLARATION = ""; + + @BeforeEach + void setupParserBolt() { + bolt = new ParserBolt(); + setupParserBolt(bolt); + } + + private void prepare(Map extraConf) { + Map conf = new HashMap<>(extraConf); + conf.putIfAbsent(ParserBolt.PARSE_TIMEOUT_PARAM, 5_000L); + conf.putIfAbsent(ParserBolt.PIPES_JVM_ARGS_PARAM, "-Xmx256m"); + bolt.prepare(conf, TestUtil.getMockedTopologyContext(), new OutputCollector(output)); + } + + /** A 2s timeout must stop the bolt well before the hang's own 60s duration elapses. */ + @Test + @Timeout(30) + void parserThatIgnoresInterruptsIsKilledByTimeout() throws IOException { + Map conf = new HashMap<>(); + conf.put(ParserBolt.PARSE_TIMEOUT_PARAM, 2_000L); + prepare(conf); + + String url = "https://example.org/hang.xml"; + byte[] content = + (XML_DECLARATION + "") + .getBytes(StandardCharsets.UTF_8); + + long start = System.currentTimeMillis(); + parse(url, content, new Metadata()); + long elapsed = System.currentTimeMillis() - start; + + Assertions.assertTrue( + elapsed < 20_000, + "the bolt should not have waited anywhere near the hang's" + + " own 60s duration, took " + + elapsed + + "ms"); + + Assertions.assertTrue(output.getEmitted().isEmpty()); + List> status = output.getEmitted(Constants.StatusStreamName); + Assertions.assertEquals(1, status.size()); + Assertions.assertEquals(url, status.get(0).get(0)); + Assertions.assertEquals(Status.ERROR, status.get(0).get(2)); + Metadata md = (Metadata) status.get(0).get(1); + Assertions.assertEquals("parse timeout", md.getFirstValue(Constants.STATUS_ERROR_MESSAGE)); + Assertions.assertEquals(1, output.getAckedTuples().size()); + } + + /** Parses normally within the timeout: text and outlinks are still emitted. */ + @Test + @Timeout(30) + void documentIsParsedUnderTimeout() throws IOException { + prepare(new HashMap<>()); + + String url = "https://example.org/fast.html"; + byte[] content = + ("t

hello world

" + + "next page" + + "") + .getBytes(StandardCharsets.UTF_8); + parse(url, content, new Metadata()); + + Assertions.assertTrue( + output.getEmitted(Constants.StatusStreamName).stream() + .noneMatch(t -> t.get(2) == Status.ERROR)); + List> emitted = output.getEmitted(); + Assertions.assertEquals(1, emitted.size()); + Assertions.assertTrue(emitted.get(0).get(3).toString().contains("hello world")); + Metadata parseMetadata = (Metadata) emitted.get(0).get(2); + Assertions.assertEquals("t", parseMetadata.getFirstValue("parse.dc:title")); + // the fork returns the content as metadata, it must not be copied to parse.* + Assertions.assertNull(parseMetadata.getFirstValue("parse.tk:content")); + Assertions.assertNull(parseMetadata.getFirstValue("parse.tk:content-handler-type")); + + List> discovered = + output.getEmitted(Constants.StatusStreamName).stream() + .filter(t -> t.get(2) == Status.DISCOVERED) + .toList(); + Assertions.assertEquals(1, discovered.size()); + Assertions.assertEquals("http://example.com/next", discovered.get(0).get(0)); + } + + /** + * The fork stops at parser.tika.text.maxlength and returns XML cut off mid-document: the + * document is still emitted, trimmed, instead of failing on the truncated XML. + */ + @Test + @Timeout(30) + void textIsTrimmedUnderTimeout() throws IOException { + Map conf = new HashMap<>(); + conf.put(ParserBolt.TEXT_MAX_LENGTH_PARAM, 5); + prepare(conf); + + String url = "https://example.org/long.html"; + byte[] content = + ("t

hello world

" + + "

more text after the limit

") + .getBytes(StandardCharsets.UTF_8); + parse(url, content, new Metadata()); + + Assertions.assertTrue( + output.getEmitted(Constants.StatusStreamName).stream() + .noneMatch(t -> t.get(2) == Status.ERROR)); + List> emitted = output.getEmitted(); + Assertions.assertEquals(1, emitted.size()); + Assertions.assertFalse(emitted.get(0).get(3).toString().contains("more text")); + Metadata parseMetadata = (Metadata) emitted.get(0).get(2); + Assertions.assertEquals("true", parseMetadata.getFirstValue(ParserBolt.TEXT_TRIMMED_KEY)); + } + + /** A fork which cannot start fails the bolt in prepare, not every document afterwards. */ + @Test + @Timeout(60) + void forkThatCannotStartFailsPrepare() { + Map conf = new HashMap<>(); + conf.put(ParserBolt.PIPES_JVM_ARGS_PARAM, "-XX:NoSuchOption"); + Assertions.assertThrows(IllegalStateException.class, () -> prepare(conf)); + } + + /** A forked JVM dying mid-parse is reported as "parse crash" and the bolt carries on. */ + @Test + @Timeout(60) + void crashedForkIsReportedUnderTimeout() throws IOException { + prepare(new HashMap<>()); + + String url = "https://example.org/crash.xml"; + byte[] content = + (XML_DECLARATION + "").getBytes(StandardCharsets.UTF_8); + parse(url, content, new Metadata()); + + List> status = output.getEmitted(Constants.StatusStreamName); + Assertions.assertEquals(1, status.size()); + Metadata md = (Metadata) status.get(0).get(1); + Assertions.assertEquals("parse crash", md.getFirstValue(Constants.STATUS_ERROR_MESSAGE)); + + // the fork restarts for the next document + parse( + "https://example.org/fast.html", + "

hello again

".getBytes(StandardCharsets.UTF_8), + new Metadata()); + List> emitted = output.getEmitted(); + Assertions.assertEquals(1, emitted.size()); + Assertions.assertTrue(emitted.get(0).get(3).toString().contains("hello again")); + } + + /** A genuine parse failure (not a timeout or a crash) is still reported as "parse error". */ + @Test + @Timeout(30) + void parseErrorIsReportedUnderTimeout() throws IOException { + prepare(new HashMap<>()); + + String url = "https://example.org/broken.xml"; + byte[] content = + (XML_DECLARATION + + "broken on" + + " purpose") + .getBytes(StandardCharsets.UTF_8); + parse(url, content, new Metadata()); + + List> status = output.getEmitted(Constants.StatusStreamName); + Assertions.assertEquals(1, status.size()); + Metadata md = (Metadata) status.get(0).get(1); + Assertions.assertEquals("parse error", md.getFirstValue(Constants.STATUS_ERROR_MESSAGE)); + } + + /** parser.extract.embedded still gates embedded-document parsing under parser.tika.timeout. */ + @Test + @Timeout(30) + void embeddedNotParsedByDefaultUnderTimeout() throws IOException { + Map conf = new HashMap<>(); + conf.put("parser.extract.embedded", false); + prepare(conf); + + parse( + "https://stormcrawler.apache.org/test_recursive_embedded.docx", + "test_recursive_embedded.docx"); + List> outTuples = output.getEmitted(); + Assertions.assertEquals(1, outTuples.size()); + Assertions.assertFalse( + outTuples + .get(0) + .get(3) + .toString() + .contains("Life, Liberty and the pursuit of Happiness"), + "embedded documents should not be parsed when parser.extract.embedded is false"); + } +}