Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
Release 4.1.0 - unreleased

* The JPEG, TIFF and WebP parsers no longer spool in-memory input to a temp
file to read metadata, and the OpenDocument parser no longer spools each
inline picture before detecting it: both now rewind the stream (governed
by the CacheMemoryBudget when one is set) instead of calling getFile().
On a 20k-file corpus sample these sites accounted for ~70% of the temp
bytes 4.x wrote, the dominant driver of 4.x's batch slowdown vs 3.x on
spinning disks. ImageMetadataExtractor gains InputStream overloads of
parseJpeg/parseTiff/parseWebP; ImageXmp.extractJpeg/extractWebp now take
an InputStream. POIFSContainerDetector opens in-memory OLE2 objects from
memory (under the CacheMemoryBudget, falling back to the file path for
anything POI's stream loader rejects) instead of spooling every embedded
OLE2 object to read its entry names, and the digest of translated
embedded streams (DigestHelper) buffers the translated bytes in memory
up to the budget before spilling. PDFParser's incremental-update xref
scan reads in-memory input from memory instead of spooling it. One
visible metadata change: embedded (in-memory) JPEG/TIFF/WebP images no
longer carry metadata-extractor's file-system tags (img:File Name,
img:File Size, img:File Modified Date), which described the temp file,
not the image; real files keep them (TIKA-4835).

* tika-server and tika-async-cli now start from a config that contains
// or /* */ comments, as the configuration docs have always said they
may. The main loader accepted them; the steps that re-read the user's
Expand Down
26 changes: 19 additions & 7 deletions tika-core/src/main/java/org/apache/tika/digest/DigestHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

import org.apache.tika.extractor.DefaultEmbeddedStreamTranslator;
import org.apache.tika.extractor.EmbeddedStreamTranslator;
Expand Down Expand Up @@ -59,6 +57,9 @@ public class DigestHelper {
* @param context parse context (should contain DigesterFactory, may contain SkipContainerDocumentDigest marker)
* @throws IOException if an I/O error occurs
*/
// Same per-object threshold as StreamCache when no budget is in the context.
private static final long DEFAULT_TRANSLATED_MEMORY_THRESHOLD = 1024 * 1024;

public static void maybeDigest(TikaInputStream tis,
Metadata metadata,
ParseContext context) throws IOException {
Expand All @@ -84,16 +85,27 @@ public static void maybeDigest(TikaInputStream tis,
// The translator consumes `tis` (e.g. OLE2), so enableRewind() before and rewind()
// after -- otherwise the caller would see an exhausted stream.
if (EMBEDDED_STREAM_TRANSLATOR.shouldTranslate(tis, metadata)) {
tis.enableRewind(context.get(CacheMemoryBudget.class));
CacheMemoryBudget budget = context.get(CacheMemoryBudget.class);
tis.enableRewind(budget);
// Translated size is unknown up front (translation may inflate), so the sink
// starts at the source length / per-object default and grows from the budget.
long initial = DEFAULT_TRANSLATED_MEMORY_THRESHOLD;
if (tis.hasLength() && tis.getLength() > initial) {
initial = tis.getLength();
}
TranslatedBytes translated = null;
try (TemporaryResources tmp = new TemporaryResources()) {
Path tmpBytes = tmp.createTempFile();
try (OutputStream os = Files.newOutputStream(tmpBytes)) {
translated = new TranslatedBytes(tmp, budget, initial);
try (OutputStream os = translated) {
EMBEDDED_STREAM_TRANSLATOR.translate(tis, metadata, os);
}
try (TikaInputStream translated = TikaInputStream.get(tmpBytes)) {
digester.digest(translated, metadata, context);
try (TikaInputStream translatedStream = translated.toTikaInputStream()) {
digester.digest(translatedStream, metadata, context);
}
} finally {
if (translated != null) {
translated.release();
}
tis.rewind();
}
} else {
Expand Down
124 changes: 124 additions & 0 deletions tika-core/src/main/java/org/apache/tika/digest/TranslatedBytes.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* 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.digest;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream;

import org.apache.tika.io.CacheMemoryBudget;
import org.apache.tika.io.TemporaryResources;
import org.apache.tika.io.TikaInputStream;

/**
* Sink for a translated embedded stream: bytes stay in memory while the shared
* {@link CacheMemoryBudget} (or the per-object default without one) allows, and spill to a
* temp file owned by {@code tmp} past that, so the common small object is digested without
* touching disk. Translation can inflate (compressed OLE payloads), so the reservation grows
* on demand rather than being fixed to the source length.
*/
class TranslatedBytes extends OutputStream {

private static final long GROW_CHUNK = 1024 * 1024;

private final TemporaryResources tmp;
private final CacheMemoryBudget budget;
private long threshold;
private long reserved;
private UnsynchronizedByteArrayOutputStream memory =
UnsynchronizedByteArrayOutputStream.builder().get();
private long size;
private Path spillFile;
private OutputStream spill;

/**
* @param budget shared budget, or null for a fixed {@code initialThreshold}
* @param initialThreshold bytes allowed in memory before asking the budget for more
*/
TranslatedBytes(TemporaryResources tmp, CacheMemoryBudget budget, long initialThreshold) {
this.tmp = tmp;
this.budget = budget;
this.threshold = initialThreshold;
if (budget != null) {
reserved = budget.tryReserve(initialThreshold) > 0 ? initialThreshold : 0;
threshold = reserved;
}
}

@Override
public void write(int b) throws IOException {
write(new byte[]{(byte) b}, 0, 1);
}

@Override
public void write(byte[] b, int off, int len) throws IOException {
if (spill == null && size + len > threshold && !grow(size + len)) {
spillFile = tmp.createTempFile();
spill = Files.newOutputStream(spillFile);
memory.writeTo(spill);
memory = null;
}
if (spill != null) {
spill.write(b, off, len);
} else {
memory.write(b, off, len);
}
size += len;
}

// Extends the in-memory allowance from the budget in whole chunks; false => spill.
private boolean grow(long needed) {
if (budget == null) {
return false;
}
while (threshold < needed) {
if (budget.tryReserve(GROW_CHUNK) == 0) {
return false;
}
reserved += GROW_CHUNK;
threshold += GROW_CHUNK;
}
return true;
}

@Override
public void close() throws IOException {
if (spill != null) {
spill.close();
}
}

/** Returns the reservation to the budget; call once the digest is done with the bytes. */
void release() {
if (budget != null && reserved > 0) {
budget.release(reserved);
reserved = 0;
}
}

boolean isInMemory() {
return spill == null;
}

/** The translated content; the caller closes it. */
TikaInputStream toTikaInputStream() throws IOException {
return spill == null ? TikaInputStream.get(memory.toByteArray()) : TikaInputStream.get(spillFile);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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.digest;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import org.apache.tika.io.CacheMemoryBudget;
import org.apache.tika.io.TemporaryResources;
import org.apache.tika.io.TikaInputStream;

public class TranslatedBytesTest {

@TempDir
Path tempDir;

@Test
public void testStaysInMemoryUnderThreshold() throws Exception {
byte[] data = new byte[1000];
for (int i = 0; i < data.length; i++) {
data[i] = (byte) i;
}
try (TemporaryResources tmp = new TemporaryResources()) {
tmp.setTemporaryFileDirectory(tempDir);
TranslatedBytes sink = new TranslatedBytes(tmp, null, 1000);
sink.write(data, 0, 600);
sink.write(data, 600, 400);
sink.close();
assertTrue(sink.isInMemory());
try (TikaInputStream tis = sink.toTikaInputStream()) {
assertArrayEquals(data, tis.readAllBytes());
}
try (Stream<Path> files = Files.list(tempDir)) {
assertEquals(0, files.count());
}
}
}

@Test
public void testSpillsPastThreshold() throws Exception {
byte[] data = new byte[5000];
for (int i = 0; i < data.length; i++) {
data[i] = (byte) (i * 7);
}
try (TemporaryResources tmp = new TemporaryResources()) {
tmp.setTemporaryFileDirectory(tempDir);
TranslatedBytes sink = new TranslatedBytes(tmp, null, 1000);
sink.write(data, 0, 800); // in memory
sink.write(data, 800, 4200); // crosses the threshold: memory flushed to the file
sink.close();
assertFalse(sink.isInMemory());
try (Stream<Path> files = Files.list(tempDir)) {
assertEquals(1, files.count());
}
try (TikaInputStream tis = sink.toTikaInputStream()) {
assertArrayEquals(data, tis.readAllBytes());
}
}
// the temp file belongs to tmp and is gone once it closes
try (Stream<Path> files = Files.list(tempDir)) {
assertEquals(0, files.count());
}
}

@Test
public void testGrowsFromBudgetInsteadOfSpilling() throws Exception {
byte[] data = new byte[3 * 1024 * 1024];
for (int i = 0; i < data.length; i++) {
data[i] = (byte) (i * 13);
}
CacheMemoryBudget budget = new CacheMemoryBudget(64L * 1024 * 1024);
try (TemporaryResources tmp = new TemporaryResources()) {
tmp.setTemporaryFileDirectory(tempDir);
TranslatedBytes sink = new TranslatedBytes(tmp, budget, 1024 * 1024);
sink.write(data, 0, data.length); // 3x the initial threshold
sink.close();
assertTrue(sink.isInMemory(), "should have grown its reservation, not spilled");
try (TikaInputStream tis = sink.toTikaInputStream()) {
assertArrayEquals(data, tis.readAllBytes());
}
sink.release();
}
// a budget too small to grow into => spill
CacheMemoryBudget tiny = new CacheMemoryBudget(1024 * 1024 + 1);
try (TemporaryResources tmp = new TemporaryResources()) {
tmp.setTemporaryFileDirectory(tempDir);
TranslatedBytes sink = new TranslatedBytes(tmp, tiny, 1024 * 1024);
sink.write(data, 0, data.length);
sink.close();
assertFalse(sink.isInMemory());
sink.release();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ public void testRegularImages() throws Exception {

//need flexibility for if tesseract is installed or not
//TODO -- fix this test. It is too fragile.
assertTrue(meta_jpg.names().length >= 52 && meta_jpg.names().length <= 60);
// in-memory embedded images no longer carry metadata-extractor's temp-file
// name/size/date tags (TIKA-4835), hence the lower bound
assertTrue(meta_jpg.names().length >= 49 && meta_jpg.names().length <= 60);
assertTrue(meta_jpg_exif.names().length >= 100 && meta_jpg_exif.names().length <= 130);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ public void parseJpeg(File file) throws IOException, SAXException, TikaException
}
}

public void parseJpeg(InputStream stream) throws IOException, SAXException, TikaException {
try {
com.drew.metadata.Metadata jpegMetadata =
JpegMetadataReader.readMetadata(stream, JPEG_READERS_NO_XMP);
handle(jpegMetadata);
} catch (JpegProcessingException | MetadataException e) {
throw new TikaException("Can't read JPEG metadata", e);
}
}

public void parseTiff(File file) throws IOException, SAXException, TikaException {
try {
com.drew.metadata.Metadata tiffMetadata = TiffMetadataReader.readMetadata(file);
Expand All @@ -154,14 +164,26 @@ public void parseTiff(File file) throws IOException, SAXException, TikaException
}
}

public void parseTiff(InputStream stream) throws IOException, SAXException, TikaException {
try {
com.drew.metadata.Metadata tiffMetadata = TiffMetadataReader.readMetadata(stream);
handle(tiffMetadata);
} catch (MetadataException | TiffProcessingException e) {
throw new TikaException("Can't read TIFF metadata", e);
}
}

public void parseWebP(File file) throws IOException, TikaException {
try {
handle(WebpMetadataReader.readMetadata(file));
} catch (RiffProcessingException | MetadataException e) {
throw new TikaException("Can't process Riff data", e);
}
}

public void parseWebP(InputStream stream) throws IOException, TikaException {
try {
com.drew.metadata.Metadata webPMetadata = new com.drew.metadata.Metadata();
webPMetadata = WebpMetadataReader.readMetadata(file);
handle(webPMetadata);
} catch (IOException e) {
throw e;
handle(WebpMetadataReader.readMetadata(stream));
} catch (RiffProcessingException | MetadataException e) {
throw new TikaException("Can't process Riff data", e);
}
Expand Down
Loading
Loading