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 cc9fd84d49 TIKA-4908: close archive entry streams so their cache
budget is released (#3199)
cc9fd84d49 is described below
commit cc9fd84d49a27afc38b24953518ab0ed5b73bc11
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 18 16:00:58 2026 -0400
TIKA-4908: close archive entry streams so their cache budget is released
(#3199)
---
CHANGES.txt | 3 +
.../java/org/apache/tika/io/CachingSource.java | 5 +
.../java/org/apache/tika/io/ReopenableSource.java | 12 +-
.../java/org/apache/tika/io/TikaInputStream.java | 6 +
.../tika/io/TikaInputStreamOwnershipTest.java | 160 +++++++++++++++++++++
.../org/apache/tika/parser/pkg/PackageParser.java | 5 +-
.../org/apache/tika/parser/pkg/SevenZParser.java | 5 +-
.../java/org/apache/tika/parser/pkg/ZipParser.java | 3 +-
.../tika/parser/pkg/PackageEmbeddedBudgetTest.java | 87 +++++++++++
9 files changed, 278 insertions(+), 8 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 3336a59aac..26446a0a4a 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,8 @@
Release 4.1.0 - unreleased
+ * Fix a cache-budget leak: tar, 7z and streamed zip entries never released
+ their in-memory cache's reservation (TIKA-4908).
+
* Mp3Parser no longer writes the literal "null" (a missing album) or the
duration into the body; an untagged MP3 has an empty body, and a TEXT
inference binding no longer embeds it (TIKA-4907).
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 47255ccfa4..d77b86779b 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
@@ -204,6 +204,11 @@ class CachingSource extends InputStream implements
TikaInputSource {
// Switch to caching mode
StreamCache cache = new StreamCache(tmp, suffix, budget);
+ if (tmp != null) {
+ // tmp owns the cache: a scope that disposes tmp without closing
this stream still
+ // frees the buffer and its budget reservation. The wrapped stream
is not tmp's to close.
+ tmp.addResource(cache);
+ }
cachingStream = new CachingInputStream(passthroughStream, cache);
passthroughStream = null;
}
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 ea6df9397e..8ce3d278e1 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
@@ -255,8 +255,13 @@ class ReopenableSource extends InputStream implements
TikaInputSource {
});
}
+ private void dropRetained() {
+ retainedBuffer = null;
+ maybeReleaseRetained();
+ }
+
private void maybeReleaseRetained() {
- if (closed && channelPins == 0 && retainedReservation > 0) {
+ if ((closed || retainedBuffer == null) && channelPins == 0 &&
retainedReservation > 0) {
budget.release(retainedReservation);
retainedReservation = 0;
}
@@ -337,6 +342,11 @@ class ReopenableSource extends InputStream implements
TikaInputSource {
retainedBuffer = data;
retainedLength = total;
retainedReservation = reservedHere;
+ if (tmp != null && reservedHere > 0) {
+ // tmp owns the retention: disposing tmp without closing this
source still releases
+ // the reservation (once no handed-out channel pins the array)
+ tmp.addResource(this::dropRetained);
+ }
// The full read is ground truth, even over a lying declared length
length = total;
lengthMeasured = true;
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 65ad794795..09e6148f60 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
@@ -85,6 +85,12 @@ public class TikaInputStream extends TaggedInputStream {
/**
* Returns the backing TikaInputSource, or null if using protected
constructor.
+ * <p>
+ * {@code tmp} owns what this call creates, never {@code stream} itself: a
cache built for
+ * rewind (its buffer, {@link CacheMemoryBudget} reservation and spill
file) is registered
+ * with {@code tmp}, so disposing {@code tmp} frees it even if the
returned stream is never
+ * closed, while the caller's stream stays open. If {@code stream} is
already a
+ * TikaInputStream it is returned as-is.
*/
private TikaInputSource inputSource() {
return in instanceof TikaInputSource ? (TikaInputSource) in : null;
diff --git
a/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamOwnershipTest.java
b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamOwnershipTest.java
new file mode 100644
index 0000000000..4953a370c3
--- /dev/null
+++
b/tika-core/src/test/java/org/apache/tika/io/TikaInputStreamOwnershipTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.channels.SeekableByteChannel;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * {@code TikaInputStream.get(stream, tmp, ...)}: tmp owns the cache the call
creates and never
+ * the caller's stream. A scope that disposes tmp without closing the stream
must still release
+ * the cache's budget reservation; the caller's stream, or a TikaInputStream
passed through
+ * get() as-is, must never be closed on their behalf.
+ */
+public class TikaInputStreamOwnershipTest {
+
+ private static final int BIG = 3 * 1024 * 1024; // past StreamCache's 1 MB
threshold and
+ // ReopenableSource's
in-memory floor
+
+ private static byte[] data(int size) {
+ byte[] data = new byte[size];
+ for (int i = 0; i < size; i++) {
+ data[i] = (byte) (i * 31 + 7);
+ }
+ return data;
+ }
+
+ private static class CloseCountingInputStream extends ByteArrayInputStream
{
+ int closes = 0;
+
+ CloseCountingInputStream(byte[] buf) {
+ super(buf);
+ }
+
+ @Override
+ public void close() throws IOException {
+ closes++;
+ super.close();
+ }
+ }
+
+ private static CacheMemoryBudget budget() {
+ return new CacheMemoryBudget(64L * 1024 * 1024);
+ }
+
+ @Test
+ public void tmpOwnsTheCacheNotTheCallersStream() throws Exception {
+ CacheMemoryBudget budget = budget();
+ CloseCountingInputStream raw = new CloseCountingInputStream(data(BIG));
+ TemporaryResources tmp = new TemporaryResources();
+ TikaInputStream tis = TikaInputStream.get(raw, tmp, new Metadata());
+ tis.enableRewind(budget);
+ assertEquals(BIG, IOUtils.toByteArray(tis).length);
+ assertTrue(budget.getReservedBytes() > 0, "a cache past the threshold
reserves budget");
+
+ tmp.close(); // the TikaInputStream itself is never closed
+
+ assertEquals(0, budget.getReservedBytes(),
+ "disposing tmp must release the cache it created and its
reservation");
+ assertEquals(0, raw.closes, "the caller's stream is not tmp's to
close");
+ }
+
+ @Test
+ public void callersTikaInputStreamIsNotRegistered() throws Exception {
+ byte[] data = data(64);
+ TikaInputStream outer = TikaInputStream.get(data);
+ TemporaryResources tmp = new TemporaryResources();
+ TikaInputStream same = TikaInputStream.get(outer, tmp, new Metadata());
+ assertSame(outer, same, "an existing TikaInputStream is returned
as-is");
+
+ tmp.close();
+
+ assertEquals(data.length, IOUtils.toByteArray(outer).length,
+ "closing the scope's tmp must not close a caller-supplied
stream");
+ outer.close();
+ }
+
+ @Test
+ public void closingBothWaysReleasesOnce() throws Exception {
+ CacheMemoryBudget budget = budget();
+ TemporaryResources tmp = new TemporaryResources();
+ TikaInputStream tis =
+ TikaInputStream.get(new ByteArrayInputStream(data(BIG)), tmp,
new Metadata());
+ tis.enableRewind(budget);
+ IOUtils.toByteArray(tis);
+ assertTrue(budget.getReservedBytes() > 0);
+
+ tis.close();
+ tmp.close();
+ tis.close();
+
+ assertEquals(0, budget.getReservedBytes(), "released exactly once,
never negative");
+ }
+
+ private static TikaInputStream reopenable(byte[] data, TemporaryResources
tmp) {
+ Metadata metadata = new Metadata();
+ metadata.set(HttpHeaders.CONTENT_LENGTH,
Integer.toString(data.length));
+ return TikaInputStream.get(() -> new ByteArrayInputStream(data), tmp,
metadata);
+ }
+
+ @Test
+ public void tmpOwnsAReopenableSourcesRetention() throws Exception {
+ CacheMemoryBudget budget = budget();
+ TemporaryResources tmp = new TemporaryResources();
+ TikaInputStream tis = reopenable(data(BIG), tmp);
+ tis.enableRewind(budget);
+ try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+ assertEquals(BIG, channel.size());
+ }
+ assertTrue(budget.getReservedBytes() > 0, "retention past the floor
reserves budget");
+
+ tmp.close(); // the TikaInputStream itself is never closed
+
+ assertEquals(0, budget.getReservedBytes(),
+ "disposing tmp must release the retained buffer's
reservation");
+ }
+
+ @Test
+ public void aPinnedChannelOutlivesTmpAndReleasesOnClose() throws Exception
{
+ CacheMemoryBudget budget = budget();
+ TemporaryResources tmp = new TemporaryResources();
+ TikaInputStream tis = reopenable(data(BIG), tmp);
+ tis.enableRewind(budget);
+ SeekableByteChannel channel = tis.getSeekableByteChannel();
+ long reserved = budget.getReservedBytes();
+ assertTrue(reserved > 0);
+
+ tmp.close();
+
+ assertEquals(reserved, budget.getReservedBytes(),
+ "a handed-out channel still pins the array; nothing is
released under it");
+ assertEquals(BIG, channel.size());
+ channel.close();
+ assertEquals(0, budget.getReservedBytes(), "the last pin releases the
reservation");
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/PackageParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/PackageParser.java
index cba7fc9d22..1bc5b317b7 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/PackageParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/PackageParser.java
@@ -176,8 +176,9 @@ public class PackageParser extends AbstractArchiveParser {
if (extractor.shouldParseEmbedded(entrydata, context)) {
TemporaryResources tmp = new TemporaryResources();
- try {
- TikaInputStream tis = TikaInputStream.get(archive, tmp,
entrydata);
+ // close the stream, not only the temp dir: an in-memory cache
is not owned by
+ // tmp, and its budget reservation is released only on close
+ try (TikaInputStream tis = TikaInputStream.get(archive, tmp,
entrydata)) {
extractor.parseEmbedded(tis, xhtml, entrydata, context,
true);
} finally {
tmp.dispose();
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/SevenZParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/SevenZParser.java
index 1bf93fb580..b0c97c1973 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/SevenZParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/SevenZParser.java
@@ -182,9 +182,8 @@ public class SevenZParser extends AbstractArchiveParser {
if (extractor.shouldParseEmbedded(entrydata, context)) {
TemporaryResources tmp = new TemporaryResources();
- try {
- TikaInputStream tis = TikaInputStream.get(
- new SevenZEntryInputStream(sevenZFile), tmp,
entrydata);
+ try (TikaInputStream tis = TikaInputStream.get(
+ new SevenZEntryInputStream(sevenZFile), tmp, entrydata)) {
extractor.parseEmbedded(tis, xhtml, entrydata, context, true);
} finally {
tmp.dispose();
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
index 405bb314bb..c21ece862b 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
@@ -548,8 +548,7 @@ public class ZipParser extends AbstractArchiveParser {
if (extractor.shouldParseEmbedded(entryMetadata, context)) {
TemporaryResources tmp = new TemporaryResources();
- try {
- TikaInputStream tis = TikaInputStream.get(zis, tmp,
entryMetadata);
+ try (TikaInputStream tis = TikaInputStream.get(zis, tmp,
entryMetadata)) {
extractor.parseEmbedded(tis, xhtml, entryMetadata, context,
true);
} catch (UnsupportedZipFeatureException e) {
EmbeddedDocumentUtil.recordEmbeddedStreamException(e,
parentMetadata, context);
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/PackageEmbeddedBudgetTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/PackageEmbeddedBudgetTest.java
new file mode 100644
index 0000000000..e73e9b1fd4
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/test/java/org/apache/tika/parser/pkg/PackageEmbeddedBudgetTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.parser.pkg;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.List;
+
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.digest.DigesterFactory;
+import org.apache.tika.digest.InputStreamDigester;
+import org.apache.tika.io.CacheMemoryBudget;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * An archive entry's in-memory cache reserves against the shared {@link
CacheMemoryBudget}
+ * when it is digested (rewind is enabled, the cache grows past the 1 MB
threshold). That
+ * reservation is released only when the entry's TikaInputStream is closed;
disposing the
+ * TemporaryResources alone does not reach it. Leaked reservations pin the
budget at its
+ * maximum after a few thousand documents, after which every cache spills to
disk.
+ */
+public class PackageEmbeddedBudgetTest extends AbstractPkgTest {
+
+ @TempDir
+ Path tmp;
+
+ /** Three entries over the 1 MB StreamCache threshold, so each one
reserves budget. */
+ private Path buildTar() throws Exception {
+ Path tar = tmp.resolve("entries.tar");
+ try (TarArchiveOutputStream out =
+ new TarArchiveOutputStream(Files.newOutputStream(tar))) {
+ for (int i = 0; i < 3; i++) {
+ byte[] data = new byte[2 * 1024 * 1024];
+ for (int j = 0; j < data.length; j++) {
+ data[j] = (byte) ('a' + (j + i) % 26);
+ }
+ TarArchiveEntry entry = new TarArchiveEntry("entry-" + i +
".txt");
+ entry.setSize(data.length);
+ out.putArchiveEntry(entry);
+ out.write(data);
+ out.closeArchiveEntry();
+ }
+ }
+ return tar;
+ }
+
+ @Test
+ public void budgetIsReleasedAfterEachEntry() throws Exception {
+ CacheMemoryBudget budget = new CacheMemoryBudget(64L * 1024 * 1024);
+ ParseContext context = new ParseContext();
+ context.set(DigesterFactory.class, () -> new
InputStreamDigester("MD5", "tk:digest:MD5",
+ bytes -> Base64.getEncoder().encodeToString(bytes)));
+ context.set(CacheMemoryBudget.class, budget);
+
+ List<Metadata> metadataList = getRecursiveMetadata(buildTar(),
context, false);
+
+ assertEquals(4, metadataList.size(), "container plus three entries");
+ for (int i = 1; i < metadataList.size(); i++) {
+ assertNotNull(metadataList.get(i).get("tk:digest:MD5"), "entry " +
i + " digested");
+ }
+ assertEquals(0, budget.getReservedBytes(),
+ "every entry's in-memory cache must return its reservation on
close");
+ }
+}