blob: f07715b4c23636737b7d9a31e7ede5d311f5941a [file] [log] [blame]
// Copyright (C) 2014 The Android Open Source Project
//
// Licensed 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 com.google.gerrit.server.config;
import static java.time.ZoneId.systemDefault;
import static java.util.Objects.requireNonNull;
import com.google.auto.value.AutoValue;
import com.google.auto.value.extension.memoized.Memoized;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.lib.Config;
/**
* This class reads a schedule for running a periodic background job from a Git config.
*
* <p>A schedule configuration consists of two parameters:
*
* <ul>
* <li>{@code interval}: Interval for running the periodic background job. The interval must be
* larger than zero. The following suffixes are supported to define the time unit for the
* interval:
* <ul>
* <li>{@code s}, {@code sec}, {@code second}, {@code seconds}
* <li>{@code m}, {@code min}, {@code minute}, {@code minutes}
* <li>{@code h}, {@code hr}, {@code hour}, {@code hours}
* <li>{@code d}, {@code day}, {@code days}
* <li>{@code w}, {@code week}, {@code weeks} ({@code 1 week} is treated as {@code 7 days})
* <li>{@code mon}, {@code month}, {@code months} ({@code 1 month} is treated as {@code 30
* days})
* <li>{@code y}, {@code year}, {@code years} ({@code 1 year} is treated as {@code 365
* days})
* </ul>
* <li>{@code startTime}: The start time defines the first execution of the periodic background
* job. If the configured {@code interval} is shorter than {@code startTime - now} the start
* time will be preponed by the maximum integral multiple of {@code interval} so that the
* start time is still in the future. {@code startTime} must have one of the following
* formats:
* <ul>
* <li>{@code <day of week> <hours>:<minutes>}
* <li>{@code <hours>:<minutes>}
* </ul>
* The placeholders can have the following values:
* <ul>
* <li>{@code <day of week>}: {@code Mon}, {@code Tue}, {@code Wed}, {@code Thu}, {@code
* Fri}, {@code Sat}, {@code Sun}
* <li>{@code <hours>}: {@code 00}-{@code 23}
* <li>{@code <minutes>}: {@code 00}-{@code 59}
* </ul>
* The timezone cannot be specified but is always the system default time-zone.
* </ul>
*
* <p>The section and the subsection from which the {@code interval} and {@code startTime}
* parameters are read can be configured.
*
* <p>Examples for a schedule configuration:
*
* <ul>
* <li>
* <pre>
* foo.startTime = Fri 10:30
* foo.interval = 2 day
* </pre>
* Assuming that the server is started on {@code Mon 7:00} then {@code startTime - now} is
* {@code 4 days 3:30 hours}. This is larger than the interval hence the start time is
* preponed by the maximum integral multiple of the interval so that start time is still in
* the future, i.e. preponed by 4 days. This yields a start time of {@code Mon 10:30}, next
* executions are {@code Wed 10:30}, {@code Fri 10:30}. etc.
* <li>
* <pre>
* foo.startTime = 06:00
* foo.interval = 1 day
* </pre>
* Assuming that the server is started on {@code Mon 7:00} then this yields the first run on
* next Tuesday at 6:00 and a repetition interval of 1 day.
* </ul>
*/
@AutoValue
public abstract class ScheduleConfig {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@VisibleForTesting static final String KEY_INTERVAL = "interval";
@VisibleForTesting static final String KEY_STARTTIME = "startTime";
private static final long MISSING_CONFIG = -1L;
private static final long INVALID_CONFIG = -2L;
public static Optional<Schedule> createSchedule(Config config, String section) {
return builder(config, section).buildSchedule();
}
public static ScheduleConfig.Builder builder(Config config, String section) {
return new AutoValue_ScheduleConfig.Builder()
.setNow(computeNow())
.setKeyInterval(KEY_INTERVAL)
.setKeyStartTime(KEY_STARTTIME)
.setConfig(config)
.setSection(section);
}
abstract Config config();
abstract String section();
@Nullable
abstract String subsection();
abstract String keyInterval();
abstract String keyStartTime();
abstract ZonedDateTime now();
@Memoized
public Optional<Schedule> schedule() {
long interval = computeInterval(config(), section(), subsection(), keyInterval());
long initialDelay;
if (interval > 0) {
initialDelay =
computeInitialDelay(config(), section(), subsection(), keyStartTime(), now(), interval);
} else {
initialDelay = interval;
}
if (isInvalidOrMissing(interval, initialDelay)) {
return Optional.empty();
}
return Optional.of(Schedule.create(interval, initialDelay));
}
private boolean isInvalidOrMissing(long interval, long initialDelay) {
String key = section() + (subsection() != null ? "." + subsection() : "");
if (interval == MISSING_CONFIG && initialDelay == MISSING_CONFIG) {
logger.atInfo().log("No schedule configuration for \"%s\".", key);
return true;
}
if (interval == MISSING_CONFIG) {
logger.atSevere().log(
"Incomplete schedule configuration for \"%s\" is ignored. Missing value for \"%s\".",
key, key + "." + keyInterval());
return true;
}
if (initialDelay == MISSING_CONFIG) {
logger.atSevere().log(
"Incomplete schedule configuration for \"%s\" is ignored. Missing value for \"%s\".",
key, key + "." + keyStartTime());
return true;
}
if (interval != INVALID_CONFIG && interval <= 0) {
logger.atSevere().log("Invalid interval value \"%d\" for \"%s\": must be > 0", interval, key);
interval = INVALID_CONFIG;
}
if (initialDelay != INVALID_CONFIG && initialDelay < 0) {
logger.atSevere().log(
"Invalid initial delay value \"%d\" for \"%s\": must be >= 0", initialDelay, key);
initialDelay = INVALID_CONFIG;
}
if (interval == INVALID_CONFIG || initialDelay == INVALID_CONFIG) {
logger.atSevere().log("Invalid schedule configuration for \"%s\" is ignored. ", key);
return true;
}
return false;
}
@Override
public final String toString() {
StringBuilder b = new StringBuilder();
b.append(formatValue(keyInterval()));
b.append(", ");
b.append(formatValue(keyStartTime()));
return b.toString();
}
private String formatValue(String key) {
StringBuilder b = new StringBuilder();
b.append(section());
if (subsection() != null) {
b.append(".");
b.append(subsection());
}
b.append(".");
b.append(key);
String value = config().getString(section(), subsection(), key);
if (value != null) {
b.append(" = ");
b.append(value);
} else {
b.append(": NA");
}
return b.toString();
}
private static long computeInterval(
Config rc, String section, String subsection, String keyInterval) {
try {
return ConfigUtil.getTimeUnit(
rc, section, subsection, keyInterval, MISSING_CONFIG, TimeUnit.MILLISECONDS);
} catch (IllegalArgumentException e) {
// We only need to log the exception message; it already includes the
// section.subsection.key and bad value.
logger.atSevere().log("%s", e.getMessage());
return INVALID_CONFIG;
}
}
private static long computeInitialDelay(
Config rc,
String section,
String subsection,
String keyStartTime,
ZonedDateTime now,
long interval) {
String start = rc.getString(section, subsection, keyStartTime);
if (start == null) {
return MISSING_CONFIG;
}
return computeInitialDelay(interval, start, now);
}
private static long computeInitialDelay(long interval, String start) {
return computeInitialDelay(interval, start, computeNow());
}
private static long computeInitialDelay(long interval, String start, ZonedDateTime now) {
requireNonNull(start);
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("[E ]HH:mm").withLocale(Locale.US);
LocalTime firstStartTime = LocalTime.parse(start, formatter);
ZonedDateTime startTime = now.with(firstStartTime);
try {
DayOfWeek dayOfWeek = formatter.parse(start, DayOfWeek::from);
startTime = startTime.with(dayOfWeek);
} catch (DateTimeParseException ignored) {
// Day of week is an optional parameter.
}
startTime = startTime.truncatedTo(ChronoUnit.MINUTES);
long delay = Duration.between(now, startTime).toMillis() % interval;
if (delay <= 0) {
delay += interval;
}
return delay;
} catch (DateTimeParseException e) {
logger.atSevere().log("Invalid start time: %s", e.getMessage());
return INVALID_CONFIG;
}
}
private static ZonedDateTime computeNow() {
return ZonedDateTime.now(systemDefault());
}
@AutoValue.Builder
public abstract static class Builder {
public abstract Builder setConfig(Config config);
public abstract Builder setSection(String section);
public abstract Builder setSubsection(@Nullable String subsection);
public abstract Builder setKeyInterval(String keyInterval);
public abstract Builder setKeyStartTime(String keyStartTime);
@VisibleForTesting
abstract Builder setNow(ZonedDateTime now);
abstract ScheduleConfig build();
public Optional<Schedule> buildSchedule() {
return build().schedule();
}
}
@AutoValue
public abstract static class Schedule {
/** Number of milliseconds between events. */
public abstract long interval();
/**
* Milliseconds between constructor invocation and first event time.
*
* <p>If there is any lag between the constructor invocation and queuing the object into an
* executor the event will run later, as there is no method to adjust for the scheduling delay.
*/
public abstract long initialDelay();
/**
* Creates a schedule.
*
* <p>{@link ScheduleConfig} defines details about which values are valid for the {@code
* interval} and {@code startTime} parameters.
*
* @param interval the interval in milliseconds
* @param startTime the start time as "{@code <day of week> <hours>:<minutes>}" or "{@code
* <hours>:<minutes>}"
* @return the schedule
* @throws IllegalArgumentException if any of the parameters is invalid
*/
public static Schedule createOrFail(long interval, String startTime) {
return create(interval, startTime).orElseThrow(IllegalArgumentException::new);
}
/**
* Creates a schedule.
*
* <p>{@link ScheduleConfig} defines details about which values are valid for the {@code
* interval} and {@code startTime} parameters.
*
* @param interval the interval in milliseconds
* @param startTime the start time as "{@code <day of week> <hours>:<minutes>}" or "{@code
* <hours>:<minutes>}"
* @return the schedule or {@link Optional#empty()} if any of the parameters is invalid
*/
public static Optional<Schedule> create(long interval, String startTime) {
long initialDelay = computeInitialDelay(interval, startTime);
if (interval <= 0 || initialDelay < 0) {
return Optional.empty();
}
return Optional.of(create(interval, initialDelay));
}
static Schedule create(long interval, long initialDelay) {
return new AutoValue_ScheduleConfig_Schedule(interval, initialDelay);
}
}
}