TimeoutLimits.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 java.io.Serializable;
import java.util.Objects;

import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.parser.ParseContext;

/**
 * Configuration for the task timeout system.
 * <p>
 * <ul>
 *   <li>{@code totalTaskTimeoutMillis} ��� bounds entire task wall-clock time, including
 *       any embedded documents it recurses into
 *       (default: 3,600,000 ms = 1 hour)</li>
 *   <li>{@code progressTimeoutMillis} ��� bounds time since the last progress update;
 *       catches infinite loops and hung processes (default: 120,000 ms = 2 minutes)</li>
 *   <li>{@code throwOnDeadline} ��� whether reaching the total timeout mid-parse throws
 *       (via {@link org.apache.tika.exception.EmbeddedLimitReachedException}) instead of
 *       skipping remaining embedded documents and returning content extracted so far
 *       (default: {@code false})</li>
 * </ul>
 * <p>
 * The first two compose with any per-parser timeout via {@link ParseTimeout#budgetFor(long)}: a
 * parser's own timeout is honored, but no operation gets more than what remains of
 * {@code totalTaskTimeoutMillis}. A bounded external call reports its own progress, so a
 * legitimately long call (e.g. a multi-minute external process) doesn't need
 * {@code progressTimeoutMillis} raised to accommodate it ��� see
 * {@link org.apache.tika.utils.ProcessUtils#execute}.
 * <p>
 * Example configuration:
 * <pre>
 * {
 *   "parse-context": {
 *     "timeout-limits": {
 *       "totalTaskTimeoutMillis": 3600000,
 *       "progressTimeoutMillis": 60000
 *     }
 *   }
 * }
 * </pre>
 *
 * @since Apache Tika 4.0
 */
@TikaComponent(spi = false)
public class TimeoutLimits implements Serializable, Initializable {

    private static final long serialVersionUID = 2L;

    public static final long DEFAULT_TOTAL_TASK_TIMEOUT_MILLIS = 3_600_000L;
    public static final long DEFAULT_PROGRESS_TIMEOUT_MILLIS = 120_000L;

    private long totalTaskTimeoutMillis = DEFAULT_TOTAL_TASK_TIMEOUT_MILLIS;
    private long progressTimeoutMillis = DEFAULT_PROGRESS_TIMEOUT_MILLIS;
    private boolean throwOnDeadline = false;

    /**
     * No-arg constructor for Jackson deserialization.
     */
    public TimeoutLimits() {
    }

    /**
     * Constructor with both timeout parameters.
     *
     * @param totalTaskTimeoutMillis maximum wall-clock time for a task
     * @param progressTimeoutMillis  maximum time between progress updates
     */
    public TimeoutLimits(long totalTaskTimeoutMillis, long progressTimeoutMillis) {
        setTotalTaskTimeoutMillis(totalTaskTimeoutMillis);
        setProgressTimeoutMillis(progressTimeoutMillis);
    }

    // Jackson invokes setters, so this turns a bad value into a config-load failure.
    private static long checkNonNegative(long millis, String name) {
        if (millis < 0) {
            throw new IllegalArgumentException(name + " must be >= 0, was " + millis +
                    "; use Long.MAX_VALUE for unbounded");
        }
        return millis;
    }

    /**
     * Gets the maximum wall-clock time in milliseconds for a parse task.
     *
     * @return total task timeout in milliseconds
     */
    public long getTotalTaskTimeoutMillis() {
        return totalTaskTimeoutMillis;
    }

    /**
     * Sets the maximum wall-clock time in milliseconds for a parse task.
     *
     * @param totalTaskTimeoutMillis total task timeout in milliseconds
     */
    public void setTotalTaskTimeoutMillis(long totalTaskTimeoutMillis) {
        this.totalTaskTimeoutMillis =
                checkNonNegative(totalTaskTimeoutMillis, "totalTaskTimeoutMillis");
    }

    /**
     * Gets the maximum time in milliseconds between progress updates before
     * the task is considered stalled.
     *
     * @return progress timeout in milliseconds
     */
    public long getProgressTimeoutMillis() {
        return progressTimeoutMillis;
    }

    /**
     * Sets the maximum time in milliseconds between progress updates before
     * the task is considered stalled.
     *
     * @param progressTimeoutMillis progress timeout in milliseconds
     */
    public void setProgressTimeoutMillis(long progressTimeoutMillis) {
        this.progressTimeoutMillis =
                checkNonNegative(progressTimeoutMillis, "progressTimeoutMillis");
    }

    /**
     * Cross-field check, so it cannot live in the setters (Jackson calls them in JSON
     * field order). Fails config load rather than every task at runtime; ParseTimeout.start
     * repeats the check as the backstop for programmatic construction.
     */
    @Override
    public void initialize() throws TikaConfigException {
        if (progressTimeoutMillis == 0 && totalTaskTimeoutMillis > 0) {
            throw new TikaConfigException("progressTimeoutMillis of 0 with a positive "
                    + "totalTaskTimeoutMillis (" + totalTaskTimeoutMillis
                    + ") would kill every task immediately; use a positive progress timeout");
        }
    }

    /**
     * Whether to throw when the task's total timeout is exhausted mid-parse, instead of
     * skipping remaining embedded documents and returning content extracted so far.
     * Default: {@code false}.
     */
    public boolean isThrowOnDeadline() {
        return throwOnDeadline;
    }

    public void setThrowOnDeadline(boolean throwOnDeadline) {
        this.throwOnDeadline = throwOnDeadline;
    }

    /**
     * Returns this instance if both timeouts are within {@code maxMillis}, otherwise a
     * copy with each offending timeout reduced to {@code maxMillis}. Used at trust
     * boundaries to cap request-supplied limits at an operator-set maximum.
     */
    public TimeoutLimits clampedTo(long maxMillis) {
        if (totalTaskTimeoutMillis <= maxMillis && progressTimeoutMillis <= maxMillis) {
            return this;
        }
        TimeoutLimits clamped = new TimeoutLimits(
                Math.min(totalTaskTimeoutMillis, maxMillis),
                Math.min(progressTimeoutMillis, maxMillis));
        clamped.throwOnDeadline = throwOnDeadline;
        return clamped;
    }

    /**
     * Helper method to get TimeoutLimits from ParseContext with defaults.
     *
     * @param context the ParseContext (may be null)
     * @return the TimeoutLimits from context, or a new instance with defaults if not found
     */
    public static TimeoutLimits get(ParseContext context) {
        if (context == null) {
            return new TimeoutLimits();
        }
        TimeoutLimits limits = context.get(TimeoutLimits.class);
        return limits != null ? limits : new TimeoutLimits();
    }

    @Override
    public String toString() {
        return "TimeoutLimits{" +
                "totalTaskTimeoutMillis=" + totalTaskTimeoutMillis +
                ", progressTimeoutMillis=" + progressTimeoutMillis +
                ", throwOnDeadline=" + throwOnDeadline +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        TimeoutLimits that = (TimeoutLimits) o;
        return totalTaskTimeoutMillis == that.totalTaskTimeoutMillis &&
                progressTimeoutMillis == that.progressTimeoutMillis &&
                throwOnDeadline == that.throwOnDeadline;
    }

    @Override
    public int hashCode() {
        return Objects.hash(totalTaskTimeoutMillis, progressTimeoutMillis, throwOnDeadline);
    }
}