ParseTimeoutTest.java
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.tika.config;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.apache.tika.parser.ParseContext;
public class ParseTimeoutTest {
@Test
public void testTimeoutLimitsRejectNegatives() {
assertThrows(IllegalArgumentException.class, () -> new TimeoutLimits(-1, 1000));
assertThrows(IllegalArgumentException.class, () -> new TimeoutLimits(1000, -1));
assertThrows(IllegalArgumentException.class,
() -> new TimeoutLimits().setTotalTaskTimeoutMillis(-1));
assertThrows(IllegalArgumentException.class,
() -> new TimeoutLimits().setProgressTimeoutMillis(-1));
}
@Test
public void testTimeoutLimitsClampedTo() {
TimeoutLimits within = new TimeoutLimits(1000, 500);
assertSame(within, within.clampedTo(3_600_000L), "within-cap limits pass through");
TimeoutLimits over = new TimeoutLimits(Long.MAX_VALUE, Long.MAX_VALUE);
over.setThrowOnDeadline(true);
TimeoutLimits clamped = over.clampedTo(3_600_000L);
assertEquals(3_600_000L, clamped.getTotalTaskTimeoutMillis());
assertEquals(3_600_000L, clamped.getProgressTimeoutMillis());
assertTrue(clamped.isThrowOnDeadline(), "clamping must preserve throwOnDeadline");
TimeoutLimits mixed = new TimeoutLimits(7_200_000L, 1000);
TimeoutLimits mixedClamped = mixed.clampedTo(3_600_000L);
assertEquals(3_600_000L, mixedClamped.getTotalTaskTimeoutMillis());
assertEquals(1000, mixedClamped.getProgressTimeoutMillis(),
"only the offending timeout is reduced");
}
@Test
public void testInitialElapsedIsNearZero() {
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits());
// Generous upper bound for slack against jitter under a loaded test run -- the
// point is "close to zero right after start", not an exact value.
assertTrue(timeout.elapsedMillis() >= 0);
assertTrue(timeout.elapsedMillis() < 5000);
assertTrue(timeout.millisSinceLastProgress() >= 0);
assertTrue(timeout.millisSinceLastProgress() < 5000);
}
@Test
public void testCheckpointResetsMillisSinceLastProgress() throws Exception {
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits());
Thread.sleep(20);
long beforeCheckpoint = timeout.millisSinceLastProgress();
assertTrue(beforeCheckpoint >= 20);
timeout.checkpoint();
assertTrue(timeout.millisSinceLastProgress() < beforeCheckpoint);
}
@Test
public void testConcurrentCheckpoints() throws Exception {
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits());
int numThreads = 4;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(numThreads);
for (int i = 0; i < numThreads; i++) {
new Thread(() -> {
try {
startLatch.await();
for (int j = 0; j < 100; j++) {
timeout.checkpoint();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneLatch.countDown();
}
}).start();
}
startLatch.countDown();
assertTrue(doneLatch.await(5, TimeUnit.SECONDS));
assertTrue(timeout.millisSinceLastProgress() >= 0);
}
@Test
public void testBudgetForNeverExceedsRequested() {
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits(3_600_000L, 60_000L));
assertEquals(5_000L, timeout.budgetFor(5_000L));
}
@Test
public void testBudgetForClipsToRemaining() {
// total task budget of 1000ms; request far more than that -- must clip to ~1000ms
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits(1_000L, 1_000L));
long budget = timeout.budgetFor(600_000L);
assertTrue(budget <= 1_000L, "budget should be clipped to remaining, was " + budget);
assertTrue(budget >= 0L);
}
@Test
public void testBudgetForNeverGrowsAcrossACompositionChain() {
// A tight parser-level request must not be "rescued" back up to a looser value
// by an outer budget, regardless of order -- min() composes correctly either way.
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits(3_600_000L, 60_000L));
long innerRequested = 500L;
assertEquals(innerRequested, timeout.budgetFor(innerRequested));
}
@Test
public void testRemainingMillisIsNeverNegative() throws Exception {
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits(1L, 1L));
Thread.sleep(20);
assertEquals(0L, timeout.remainingMillis());
assertEquals(0L, timeout.budgetFor(60_000L));
}
@Test
public void testHardDeadlineOverflowGuard() {
// A huge or MAX_VALUE total must not wrap the deadline negative and expire
// the task immediately.
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits(Long.MAX_VALUE, 60_000L));
assertEquals(Long.MAX_VALUE, timeout.getTotalTimeoutMillis());
assertEquals(Long.MAX_VALUE, timeout.remainingMillis());
assertTrue(timeout.budgetFor(60_000L) == 60_000L);
}
@Test
public void testGetOrCreateInstallsAndReusesSameInstance() {
ParseContext context = new ParseContext();
ParseTimeout first = ParseTimeout.getOrCreate(context);
ParseTimeout second = ParseTimeout.getOrCreate(context);
assertNotNull(first);
assertSame(first, second, "getOrCreate must be idempotent per context");
assertSame(first, context.get(ParseTimeout.class));
}
@Test
public void testGetOrCreateUsesTimeoutLimitsFromContext() {
ParseContext context = new ParseContext();
context.set(TimeoutLimits.class, new TimeoutLimits(7_200_000L, 300_000L));
ParseTimeout timeout = ParseTimeout.getOrCreate(context);
assertEquals(300_000L, timeout.getProgressTimeoutMillis());
}
@Test
public void testCheckpointStaticWithNullContext() {
// Should not throw
ParseTimeout.checkpoint(null);
}
@Test
public void testCheckpointStaticWithNoTimeoutInstalled() {
// Should not throw, and should not install one as a side effect
ParseContext context = new ParseContext();
ParseTimeout.checkpoint(context);
assertEquals(null, context.get(ParseTimeout.class));
}
@Test
public void testCheckpointStaticUpdatesInstalledTimeout() throws Exception {
ParseContext context = new ParseContext();
ParseTimeout timeout = ParseTimeout.getOrCreate(context);
Thread.sleep(20);
long beforeCheckpoint = timeout.millisSinceLastProgress();
assertTrue(beforeCheckpoint >= 20);
ParseTimeout.checkpoint(context);
assertTrue(timeout.millisSinceLastProgress() < beforeCheckpoint);
}
// ---- misconfiguration validation (design doc ��9) ----------------------------------
@Test
public void testStartRejectsNegativeTotal() {
assertThrows(IllegalArgumentException.class,
() -> ParseTimeout.start(new TimeoutLimits(-1L, 1000L)));
}
@Test
public void testStartRejectsNegativeProgress() {
assertThrows(IllegalArgumentException.class,
() -> ParseTimeout.start(new TimeoutLimits(1000L, -1L)));
}
@Test
public void testStartRejectsZeroProgressWithPositiveTotal() {
// A stall timeout of 0 with a real total budget would kill every task immediately.
assertThrows(IllegalArgumentException.class,
() -> ParseTimeout.start(new TimeoutLimits(1000L, 0L)));
}
@Test
public void testStartAcceptsZeroTotalAndZeroProgress() {
// A fully-exhausted budget (both zero) is valid and expires immediately.
ParseTimeout.start(new TimeoutLimits(0L, 0L));
}
@Test
public void testStartAllowsZeroAsAnAlreadyExhaustedBudget() {
// Zero is a coherent state (no time left), unlike negative -- must not throw,
// and must behave as immediately exhausted.
ParseTimeout timeout = assertDoesNotThrow(() -> ParseTimeout.start(new TimeoutLimits(0L, 0L)));
assertEquals(0L, timeout.remainingMillis());
}
@Test
public void testBudgetForNonPositiveRequestFallsBackToRemainingInsteadOfZero() {
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits(60_000L, 60_000L));
// A misconfigured 0 or negative per-parser timeout must not silently grant a
// budget of zero (which would make every call fail instantly with no useful
// diagnostic) -- it falls back to whatever remains of the task instead.
assertTrue(timeout.budgetFor(0L) > 0L);
assertTrue(timeout.budgetFor(-100L) > 0L);
}
@Test
public void testBudgetForSubSecondRequestIsStillHonored() {
// The sub-second warning is diagnostic only -- it must not alter the granted budget.
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits(60_000L, 60_000L));
assertEquals(500L, timeout.budgetFor(500L));
}
@Test
public void testBudgetForExceedingTotalStillClipsToRemaining() {
// The "exceeds total" warning is diagnostic only -- min(requested, remaining)
// still applies exactly as it would without the warning.
ParseTimeout timeout = ParseTimeout.start(new TimeoutLimits(1_000L, 1_000L));
long budget = timeout.budgetFor(600_000L);
assertTrue(budget <= 1_000L, "budget should still be clipped to remaining, was " + budget);
}
}