This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new c309b2a3df TIKA-4901: don't inflate embedded doc twice for digesting
(#3191)
c309b2a3df is described below
commit c309b2a3dfa1323188eddd53adb4632791537883
Author: Tim Allison <[email protected]>
AuthorDate: Thu Sep 17 19:58:04 2026 -0400
TIKA-4901: don't inflate embedded doc twice for digesting (#3191)
---
CHANGES.txt | 3 +
.../apache/tika/digest/InputStreamDigester.java | 3 +
.../java/org/apache/tika/io/ReopenableSource.java | 58 ++++++++--
.../java/org/apache/tika/io/TikaInputSource.java | 16 +++
.../java/org/apache/tika/io/TikaInputStream.java | 17 +++
.../tika/digest/InputStreamDigesterTest.java | 39 +++++++
.../org/apache/tika/io/ReopenableSourceTest.java | 123 +++++++++++++++++++++
7 files changed, 252 insertions(+), 7 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index e72d60114c..010d7fe8b5 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,8 @@
Release 4.1.0 - unreleased
+ * Digesting an embedded document no longer makes its source hand out the
+ bytes twice. (TIKA-4901).
+
* Enable audio/video chunking with ffmpeg and inference (TIKA-4900).
* PDF incremental-update scanning reads the file in blocks instead of one
diff --git
a/tika-core/src/main/java/org/apache/tika/digest/InputStreamDigester.java
b/tika-core/src/main/java/org/apache/tika/digest/InputStreamDigester.java
index ec46af2839..4773a74040 100644
--- a/tika-core/src/main/java/org/apache/tika/digest/InputStreamDigester.java
+++ b/tika-core/src/main/java/org/apache/tika/digest/InputStreamDigester.java
@@ -121,6 +121,9 @@ public class InputStreamDigester implements Digester {
CacheMemoryBudget budget =
(parseContext == null) ? null :
parseContext.get(CacheMemoryBudget.class);
tis.enableRewind(budget);
+ // The parse re-reads what we are about to read. Where re-opening the
source means
+ // inflating an embedded entry again, hold it in memory instead; a
no-op for a file.
+ tis.tryRetainInMemory();
MessageDigest messageDigest = newMessageDigest();
byte[] buffer = new byte[8192];
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 61c8dcf181..ea6df9397e 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
@@ -17,6 +17,7 @@
package org.apache.tika.io;
import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -81,11 +82,28 @@ class ReopenableSource extends InputStream implements
TikaInputSource {
private void ensureOpen() throws IOException {
if (currentStream == null) {
- currentStream = new BufferedInputStream(
- spilledPath != null ? Files.newInputStream(spilledPath) :
opener.get());
+ currentStream = openAt(position);
}
}
+ /**
+ * A fresh stream positioned at {@code at}. Served from the retained
content when there is
+ * some: re-opening this source can mean inflating a zip entry again,
which the page cache
+ * does nothing for.
+ */
+ private InputStream openAt(long at) throws IOException {
+ if (retainedBuffer != null && spilledPath == null) {
+ int from = (int) Math.min(at, retainedLength);
+ return new ByteArrayInputStream(retainedBuffer, from,
retainedLength - from);
+ }
+ InputStream in = new BufferedInputStream(
+ spilledPath != null ? Files.newInputStream(spilledPath) :
opener.get());
+ if (at > 0) {
+ IOUtils.skipFully(in, at);
+ }
+ return in;
+ }
+
@Override
public int read() throws IOException {
ensureOpen();
@@ -128,11 +146,7 @@ class ReopenableSource extends InputStream implements
TikaInputSource {
if (currentStream != null) {
currentStream.close();
}
- currentStream = new BufferedInputStream(
- spilledPath != null ? Files.newInputStream(spilledPath) :
opener.get());
- if (newPosition > 0) {
- IOUtils.skipFully(currentStream, newPosition);
- }
+ currentStream = openAt(newPosition);
this.position = newPosition;
}
@@ -190,6 +204,36 @@ class ReopenableSource extends InputStream implements
TikaInputSource {
}
}
+ /**
+ * Drains into memory up front so the caller's read, and every later one,
is served from
+ * there. Only worth it when the content certainly fits: a doomed attempt
reads as far as
+ * the budget allows and throws it away, and {@link
#getSeekableByteChannel()} would spill
+ * instead, which is what TIKA-4835 removed.
+ */
+ @Override
+ public boolean tryRetainInMemory() throws IOException {
+ if (retainedBuffer != null) {
+ return true;
+ }
+ if (spilledPath != null || closed || length < 0 || length >
MAX_ARRAY_SIZE) {
+ return false;
+ }
+ if (length > IN_MEMORY_FLOOR) {
+ if (budget == null ||
+ budget.getMaxBytes() - budget.getReservedBytes() < length
- IN_MEMORY_FLOOR) {
+ return false;
+ }
+ }
+ if (!tryBufferInMemory()) {
+ return false;
+ }
+ if (currentStream != null) {
+ currentStream.close();
+ currentStream = openAt(position);
+ }
+ return true;
+ }
+
@Override
public SeekableByteChannel getSeekableByteChannel() throws IOException {
if (retainedBuffer != null) {
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 380db7a638..ca70497b2a 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
@@ -104,4 +104,20 @@ interface TikaInputSource extends Closeable {
* @throws IOException if the source is partially read and cannot be
rewound
*/
SeekableByteChannel getSeekableByteChannel() throws IOException;
+
+ /**
+ * Asks the source to hold its full content in memory, so that later reads
-- sequential
+ * or via {@link #getSeekableByteChannel()} -- are served from there
instead of going
+ * back to the underlying source. Re-opening is cheap for a file (the page
cache serves
+ * it) but not for a zip entry, where it means inflating the entry again.
+ * <p>
+ * Advisory and best effort: a source that would have to spill to disk, or
that cannot
+ * tell whether the content fits, returns false and keeps its current
behaviour. Callers
+ * must work the same either way. Does not change this source's read
position.
+ *
+ * @return true if the full content is now held in memory
+ */
+ default boolean tryRetainInMemory() throws IOException {
+ return false;
+ }
}
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 398bf53778..65ad794795 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
@@ -593,6 +593,23 @@ public class TikaInputStream extends TaggedInputStream {
return source.getSeekableByteChannel();
}
+ /**
+ * Asks the underlying source to hold its full content in memory, so a
later rewind and
+ * re-read does not go back to the source. Worth calling before a pass
that will be
+ * followed by another (digest then parse) when re-opening is expensive --
a zip entry
+ * has to be inflated again, where a file is served by the page cache.
+ * <p>
+ * Advisory: sources that would have to spill, or cannot tell whether the
content fits,
+ * return false and behave as before. Does not change the read position.
+ *
+ * @return true if the full content is now held in memory
+ * @see TikaInputSource#tryRetainInMemory()
+ */
+ public boolean tryRetainInMemory() throws IOException {
+ TikaInputSource source = inputSource();
+ return source != null && source.tryRetainInMemory();
+ }
+
/**
* Zero-copy, read-only view of the content behind a channel from
* {@link #getSeekableByteChannel()}, or {@code null} when that content is
on disk. Lets a
diff --git
a/tika-core/src/test/java/org/apache/tika/digest/InputStreamDigesterTest.java
b/tika-core/src/test/java/org/apache/tika/digest/InputStreamDigesterTest.java
index be20831746..1d0b5d0d9b 100644
---
a/tika-core/src/test/java/org/apache/tika/digest/InputStreamDigesterTest.java
+++
b/tika-core/src/test/java/org/apache/tika/digest/InputStreamDigesterTest.java
@@ -16,14 +16,22 @@
*/
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.assertNotNull;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.Test;
+import org.apache.tika.io.TemporaryResources;
import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.Property;
import org.apache.tika.parser.ParseContext;
@@ -43,4 +51,35 @@ public class InputStreamDigesterTest {
assertNotNull(metadata.get(key), "trusted digest write must survive
the reserved-key guard");
assertNotNull(Property.get(key), "digest key must be registered as a
Property");
}
+
+ /**
+ * Digesting a re-openable source used to read it once for the hash and
leave the parse to
+ * read it again. For an embedded entry that second read means inflating
it again, so the
+ * digest now asks the source to hold the content.
+ */
+ @Test
+ public void testDigestOfAReopenableSourceDoesNotForceASecondOpen() throws
Exception {
+ byte[] content = new byte[64 * 1024];
+ for (int i = 0; i < content.length; i++) {
+ content[i] = (byte) (i * 31 + 7);
+ }
+ AtomicInteger opens = new AtomicInteger();
+ Metadata metadata = new Metadata();
+ metadata.set(HttpHeaders.CONTENT_LENGTH,
Integer.toString(content.length));
+ InputStreamDigester digester = new InputStreamDigester("SHA256",
"tk:digest:SHA256",
+ bytes -> Base64.getEncoder().encodeToString(bytes));
+ try (TemporaryResources tmp = new TemporaryResources()) {
+ java.util.function.Supplier<InputStream> raw = () -> {
+ opens.incrementAndGet();
+ return new ByteArrayInputStream(content);
+ };
+ try (TikaInputStream tis = TikaInputStream.get(raw::get, tmp,
metadata)) {
+ digester.digest(tis, metadata, new ParseContext());
+ assertNotNull(metadata.get("tk:digest:SHA256"));
+ // the parse that follows the digest
+ assertArrayEquals(content, IOUtils.toByteArray(tis));
+ }
+ }
+ assertEquals(1, opens.get(), "digest plus parse must open the source
once");
+ }
}
diff --git
a/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
b/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
index 35ab3027be..49bdc99443 100644
--- a/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
+++ b/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
@@ -62,6 +62,129 @@ public class ReopenableSourceTest {
};
}
+ private static byte[] readFully(InputStream in) throws IOException {
+ java.io.ByteArrayOutputStream out = new
java.io.ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ out.write(buf, 0, n);
+ }
+ return out.toByteArray();
+ }
+
+ @Test
+ public void testRetainInMemoryServesSequentialReads() throws Exception {
+ byte[] data = data(64 * 1024);
+ AtomicInteger opens = new AtomicInteger();
+ ReopenableSource source =
+ new ReopenableSource(countingOpener(data, opens), tmp,
data.length, ".bin");
+ try {
+ assertTrue(source.tryRetainInMemory());
+ assertEquals(1, opens.get());
+ assertArrayEquals(data, readFully(source));
+ source.seekTo(0);
+ assertArrayEquals(data, readFully(source));
+ assertEquals(1, opens.get(), "reads after retention must not
re-open the source");
+ assertFalse(source.hasPath(), "retention must not spill");
+ } finally {
+ source.close();
+ }
+ }
+
+ @Test
+ public void testRetainInMemoryIsIdempotent() throws Exception {
+ byte[] data = data(8192);
+ AtomicInteger opens = new AtomicInteger();
+ ReopenableSource source =
+ new ReopenableSource(countingOpener(data, opens), tmp,
data.length, ".bin");
+ try {
+ assertTrue(source.tryRetainInMemory());
+ assertTrue(source.tryRetainInMemory());
+ assertEquals(1, opens.get());
+ } finally {
+ source.close();
+ }
+ }
+
+ @Test
+ public void testRetainInMemoryRefusedWhenLengthUnknown() throws Exception {
+ byte[] data = data(8192);
+ AtomicInteger opens = new AtomicInteger();
+ ReopenableSource source = new ReopenableSource(countingOpener(data,
opens), tmp, -1, ".bin");
+ try {
+ assertFalse(source.tryRetainInMemory());
+ assertEquals(0, opens.get());
+ assertArrayEquals(data, readFully(source));
+ } finally {
+ source.close();
+ }
+ }
+
+ @Test
+ public void testRetainInMemoryRefusedOverFloorWithNoBudget() throws
Exception {
+ byte[] data = data(FLOOR + 4096);
+ AtomicInteger opens = new AtomicInteger();
+ ReopenableSource source =
+ new ReopenableSource(countingOpener(data, opens), tmp,
data.length, ".bin");
+ try {
+ assertFalse(source.tryRetainInMemory());
+ assertEquals(0, opens.get(), "a doomed attempt must not read the
source");
+ assertFalse(source.hasPath(), "a refusal must not spill");
+ assertArrayEquals(data, readFully(source));
+ } finally {
+ source.close();
+ }
+ }
+
+ @Test
+ public void testRetainInMemoryOverFloorWithBudget() throws Exception {
+ byte[] data = data(FLOOR + 4096);
+ AtomicInteger opens = new AtomicInteger();
+ ReopenableSource source =
+ new ReopenableSource(countingOpener(data, opens), tmp,
data.length, ".bin");
+ try {
+ source.enableRewind(new CacheMemoryBudget(8 * FLOOR));
+ assertTrue(source.tryRetainInMemory());
+ assertArrayEquals(data, readFully(source));
+ assertEquals(1, opens.get());
+ } finally {
+ source.close();
+ }
+ }
+
+ @Test
+ public void testRetainInMemoryRefusedWhenBudgetTooSmall() throws Exception
{
+ byte[] data = data(4 * FLOOR);
+ AtomicInteger opens = new AtomicInteger();
+ ReopenableSource source =
+ new ReopenableSource(countingOpener(data, opens), tmp,
data.length, ".bin");
+ try {
+ source.enableRewind(new CacheMemoryBudget(4096));
+ assertFalse(source.tryRetainInMemory());
+ assertEquals(0, opens.get());
+ assertFalse(source.hasPath());
+ assertArrayEquals(data, readFully(source));
+ } finally {
+ source.close();
+ }
+ }
+
+ @Test
+ public void testGetPathAfterRetainUsesRetainedBytes() throws Exception {
+ byte[] data = data(32 * 1024);
+ AtomicInteger opens = new AtomicInteger();
+ ReopenableSource source =
+ new ReopenableSource(countingOpener(data, opens), tmp,
data.length, ".bin");
+ try {
+ assertTrue(source.tryRetainInMemory());
+ Path path = source.getPath(null);
+ assertArrayEquals(data, java.nio.file.Files.readAllBytes(path));
+ assertEquals(1, opens.get());
+ } finally {
+ source.close();
+ }
+ }
+
private static byte[] readFully(SeekableByteChannel channel) throws
IOException {
ByteBuffer buf = ByteBuffer.allocate((int) channel.size());
while (buf.hasRemaining() && channel.read(buf) != -1) {