| // Copyright 2018 The Bazel Authors. All rights reserved. |
| // |
| // 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.devtools.build.lib.util; |
| |
| import static com.google.common.base.Preconditions.checkArgument; |
| import static com.google.common.base.Preconditions.checkNotNull; |
| import static com.google.common.base.Preconditions.checkState; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Strings; |
| import com.google.common.io.CountingOutputStream; |
| import com.google.common.net.InetAddresses; |
| import java.io.File; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.io.OutputStreamWriter; |
| import java.lang.management.ManagementFactory; |
| import java.nio.file.DirectoryStream; |
| import java.nio.file.Files; |
| import java.nio.file.LinkOption; |
| import java.nio.file.Path; |
| import java.text.ParseException; |
| import java.text.ParsePosition; |
| import java.text.SimpleDateFormat; |
| import java.time.Clock; |
| import java.time.Instant; |
| import java.time.ZoneId; |
| import java.util.Date; |
| import java.util.Optional; |
| import java.util.PriorityQueue; |
| import java.util.TimeZone; |
| import java.util.function.Function; |
| import java.util.logging.ErrorManager; |
| import java.util.logging.Formatter; |
| import java.util.logging.Handler; |
| import java.util.logging.Level; |
| import java.util.logging.LogManager; |
| import java.util.logging.LogRecord; |
| import javax.annotation.Nullable; |
| import javax.annotation.concurrent.GuardedBy; |
| |
| /** |
| * A simple file-based logging handler that provides an API for getting the current log file and |
| * (optionally) in addition creates a short symlink to the current log file. |
| * |
| * <p>The log file path is concatenated from 4 elements: the prefix (a fixed string, typically a |
| * directory); the pattern (allowing some % variable substitutions); the timestamp; and the |
| * extension. |
| * |
| * <p>The handler can be configured from the JVM command line: <code> |
| * -Djava.util.logging.config.file=/foo/bar/javalog.properties |
| * </code> where the javalog.properties file might contain something like <code> |
| * handlers=com.google.devtools.build.lib.util.SimpleLogHandler |
| * com.google.devtools.build.lib.util.SimpleLogHandler.level=INFO |
| * com.google.devtools.build.lib.util.SimpleLogHandler.prefix=/foo/bar/logs/java.log |
| * com.google.devtools.build.lib.util.SimpleLogHandler.rotate_limit_bytes=1048576 |
| * com.google.devtools.build.lib.util.SimpleLogHandler.total_limit_bytes=10485760 |
| * com.google.devtools.build.lib.util.SimpleLogHandler.formatter=com.google.devtools.build.lib.util.SingleLineFormatter |
| * </code> |
| * |
| * <p>The handler is thread-safe. IO operations ({@link #publish}, {@link #flush}, {@link #close}) |
| * and {@link #getCurrentLogFilePath} block other access to the handler until completed. |
| */ |
| public final class SimpleLogHandler extends Handler { |
| /** Max number of bytes to write before rotating the log. */ |
| private final int rotateLimitBytes; |
| /** Max number of bytes in all logs to keep before deleting oldest ones. */ |
| private final int totalLimitBytes; |
| /** Log file extension; the current process ID by default. */ |
| private final String extension; |
| /** True if the log file extension is not the process ID. */ |
| private final boolean isStaticExtension; |
| /** |
| * Absolute path to symbolic link to current log file, or {@code Optional#empty()} if the link |
| * should not be created. |
| */ |
| private final Optional<Path> symlinkPath; |
| /** Absolute path to common base name of log files. */ |
| @VisibleForTesting final Path baseFilePath; |
| /** Log file currently in use. */ |
| @GuardedBy("this") |
| private final Output output = new Output(); |
| |
| private static final String DEFAULT_PREFIX_STRING = "java.log"; |
| private static final String DEFAULT_BASE_FILE_NAME_PATTERN = ".%h.%u.log.java."; |
| /** Source for timestamps in filenames; non-static for testing. */ |
| private final Clock clock; |
| |
| @VisibleForTesting static final String DEFAULT_TIMESTAMP_FORMAT = "yyyyMMdd-HHmmss."; |
| /** |
| * Timestamp format for log filenames; non-static because {@link SimpleDateFormat} is not |
| * thread-safe. |
| */ |
| @GuardedBy("this") |
| private final SimpleDateFormat timestampFormat = new SimpleDateFormat(DEFAULT_TIMESTAMP_FORMAT); |
| |
| /** |
| * A {@link} LogHandlerQuerier for working with {@code SimpleLogHandler} instances. |
| * |
| * <p>This querier is intended for situations where the logging handler is configured on the JVM |
| * command line to be {@link SimpleLogHandler}, but where the code which needs to query the |
| * handler does not know the handler's class or cannot import it. The command line then should in |
| * addition specify {@code |
| * -Dcom.google.devtools.build.lib.util.LogHandlerQuerier.class=com.google.devtools.build.lib.util.SimpleLogHandler$HandlerQuerier} |
| * and an instance of {@link SimpleLogHandler.HandlerQuerier} class can then be obtained from |
| * {@code LogHandlerQuerier.getInstance()}. |
| */ |
| public static final class HandlerQuerier extends LogHandlerQuerier { |
| @Override |
| protected boolean canQuery(Handler handler) { |
| return handler instanceof SimpleLogHandler; |
| } |
| |
| @Override |
| protected Optional<Path> getLogHandlerFilePath(Handler handler) { |
| return ((SimpleLogHandler) handler).getCurrentLogFilePath(); |
| } |
| } |
| |
| /** Creates a new {@link Builder}. */ |
| public static Builder builder() { |
| return new Builder(); |
| } |
| |
| /** |
| * Builder class for {@link SimpleLogHandler}. |
| * |
| * <p>All setters are optional; if unset, values from the JVM logging configuration or (if those |
| * too are unset) reasonable fallback values will be used. See individual setter documentation. |
| */ |
| public static final class Builder { |
| private String prefix; |
| private String pattern; |
| private String extension; |
| private String symlinkName; |
| private Boolean createSymlink; |
| private Integer rotateLimitBytes; |
| private Integer totalLimitBytes; |
| private Level logLevel; |
| private Formatter formatter; |
| private Clock clock; |
| |
| public Builder setPrefix(String prefix) { |
| this.prefix = prefix; |
| return this; |
| } |
| |
| /** |
| * Sets the pattern for the log file name. The pattern may contain the following variables: |
| * |
| * <ul> |
| * <li><code>%u</code> will be expanded to the username |
| * <li><code>%h</code> will be expanded to the hostname |
| * <li><code>%%</code> will be expanded to % |
| * </ul> |
| * |
| * <p>The log file name will be constructed by appending the expanded pattern to the prefix and |
| * then by appending a timestamp and the extension. |
| * |
| * <p>If unset, the value of "pattern" from the JVM logging configuration for {@link |
| * SimpleLogHandler} will be used; and if that's unset, {@link #DEFAULT_BASE_FILE_NAME_PATTERN} |
| * will be used. |
| * |
| * @param pattern the pattern string, possibly containing <code>%u</code>, <code>%h</code>, |
| * <code>%%</code> variables as above |
| * @return this {@code Builder} object |
| */ |
| public Builder setPattern(String pattern) { |
| this.pattern = pattern; |
| return this; |
| } |
| |
| /** |
| * Sets the log file extension. |
| * |
| * <p>If unset, the value of "extension" from the JVM logging configuration for {@link |
| * SimpleLogHandler} will be used; and if that's unset, the process ID will be used. |
| * |
| * @param extension log file extension |
| * @return this {@code Builder} object |
| */ |
| public Builder setExtension(String extension) { |
| this.extension = extension; |
| return this; |
| } |
| |
| /** |
| * Sets the log file symlink filename. |
| * |
| * <p>If unset, the value of "symlink" from the JVM logging configuration for {@link |
| * SimpleLogHandler} will be used; and if that's unset, the prefix will be used. |
| * |
| * @param symlink either symlink filename without a directory part, or an absolute path whose |
| * directory part matches the prefix |
| * @return this {@code Builder} object |
| */ |
| public Builder setSymlinkName(String symlinkName) { |
| this.symlinkName = symlinkName; |
| return this; |
| } |
| |
| /** |
| * Sets whether symlinks to the log file should be created. |
| * |
| * <p>If unset, the value of "create_symlink" from the JVM logging configuration for {@link |
| * SimpleLogHandler} will be used; and if that's unset, the default behavior will depend on the |
| * platform: false on Windows (because by default, only administrator accounts can create |
| * symbolic links there) and true on other platforms. |
| * |
| * @return this {@code Builder} object |
| */ |
| public Builder setCreateSymlink(boolean createSymlink) { |
| this.createSymlink = Boolean.valueOf(createSymlink); |
| return this; |
| } |
| |
| /** |
| * Sets the log file size limit; if unset or 0, log size is unlimited. |
| * |
| * <p>If unset, the value of "rotate_limit_bytes" from the JVM logging configuration for {@link |
| * SimpleLogHandler} will be used; and if that's unset, the log fie size is unlimited. |
| * |
| * @param rotateLimitBytes maximum log file size in bytes; must be >= 0; 0 means unlimited |
| * @return this {@code Builder} object |
| */ |
| public Builder setRotateLimitBytes(int rotateLimitBytes) { |
| this.rotateLimitBytes = Integer.valueOf(rotateLimitBytes); |
| return this; |
| } |
| |
| /** |
| * Sets the total rotateLimitBytes for log files. |
| * |
| * <p>If set, when opening a new handler or rotating a log file, the handler will scan for all |
| * log files with names matching the expected prefix, pattern, timestamp format, and extension, |
| * and delete the oldest ones to keep the total size under rotateLimitBytes. |
| * |
| * <p>If unset, the value of "total_limit_bytes" from the JVM logging configuration for {@link |
| * SimpleLogHandler} will be used; and if that's unset, the total log size is unlimited. |
| * |
| * @param totalLimitBytes maximum total log file size in bytes; must be >= 0; 0 means unlimited |
| * @return this {@code Builder} object |
| */ |
| public Builder setTotalLimitBytes(int totalLimitBytes) { |
| this.totalLimitBytes = Integer.valueOf(totalLimitBytes); |
| return this; |
| } |
| |
| /** |
| * Sets the minimum level at which to log records. |
| * |
| * <p>If unset, the level named by the "level" field in the JVM logging configuration for {@link |
| * SimpleLogHandler} will be used; and if that's unset, all records are logged. |
| * |
| * @param logLevel minimum log level |
| * @return this {@code Builder} object |
| */ |
| public Builder setLogLevel(Level logLevel) { |
| this.logLevel = logLevel; |
| return this; |
| } |
| |
| /** |
| * Sets the log formatter. |
| * |
| * <p>If unset, the class named by the "formatter" field in the JVM logging configuration for |
| * {@link SimpleLogHandler} will be used; and if that's unset, {@link SingleLineFormatter} will |
| * be used. |
| * |
| * @param formatter log formatter |
| * @return this {@code Builder} object |
| */ |
| public Builder setFormatter(Formatter formatter) { |
| this.formatter = formatter; |
| return this; |
| } |
| |
| /** |
| * Sets the time source for timestamps in log filenames. |
| * |
| * <p>Intended for testing. If unset, the system clock in the system timezone will be used. |
| * |
| * @param clock time source for timestamps |
| * @return this {@code Builder} object |
| */ |
| @VisibleForTesting |
| Builder setClockForTesting(Clock clock) { |
| this.clock = clock; |
| return this; |
| } |
| |
| /** Builds a {@link SimpleLogHandler} instance. */ |
| public SimpleLogHandler build() { |
| return new SimpleLogHandler( |
| prefix, |
| pattern, |
| extension, |
| symlinkName, |
| createSymlink, |
| rotateLimitBytes, |
| totalLimitBytes, |
| logLevel, |
| formatter, |
| clock); |
| } |
| } |
| |
| /** |
| * Constructs a log handler with all state taken from the JVM logging configuration or (as |
| * fallback) the defaults; see {@link SimpleLogHandler.Builder} documentation. |
| * |
| * @throws IllegalArgumentException if invalid JVM logging configuration values are encountered; |
| * see {@link SimpleLogHandler.Builder} documentation |
| */ |
| public SimpleLogHandler() { |
| this(null, null, null, null, null, null, null, null, null, null); |
| } |
| |
| /** |
| * Constructs a log handler, falling back to the JVM logging configuration or (as last fallback) |
| * the defaults for those arguments which are null; see {@link SimpleLogHandler.Builder} |
| * documentation. |
| * |
| * @throws IllegalArgumentException if invalid non-null arguments or configured values are |
| * encountered; see {@link SimpleLogHandler.Builder} documentation |
| */ |
| private SimpleLogHandler( |
| @Nullable String prefix, |
| @Nullable String pattern, |
| @Nullable String extension, |
| @Nullable String symlinkName, |
| @Nullable Boolean createSymlink, |
| @Nullable Integer rotateLimitBytes, |
| @Nullable Integer totalLimit, |
| @Nullable Level logLevel, |
| @Nullable Formatter formatter, |
| @Nullable Clock clock) { |
| this.baseFilePath = |
| getBaseFilePath( |
| getConfiguredStringProperty(prefix, "prefix", DEFAULT_PREFIX_STRING), |
| getConfiguredStringProperty(pattern, "pattern", DEFAULT_BASE_FILE_NAME_PATTERN)); |
| |
| String configuredSymlinkName = |
| getConfiguredStringProperty( |
| symlinkName, |
| "symlink", |
| getConfiguredStringProperty(prefix, "prefix", DEFAULT_PREFIX_STRING)); |
| boolean configuredCreateSymlink = |
| getConfiguredBooleanProperty( |
| createSymlink, "create_symlink", OS.getCurrent() != OS.WINDOWS); |
| this.symlinkPath = |
| configuredCreateSymlink |
| ? Optional.of( |
| getSymlinkAbsolutePath(this.baseFilePath.getParent(), configuredSymlinkName)) |
| : Optional.empty(); |
| this.extension = getConfiguredStringProperty(extension, "extension", getPidString()); |
| this.isStaticExtension = (getConfiguredStringProperty(extension, "extension", null) != null); |
| this.rotateLimitBytes = getConfiguredIntProperty(rotateLimitBytes, "rotate_limit_bytes", 0); |
| checkArgument(this.rotateLimitBytes >= 0, "File size limits cannot be negative"); |
| this.totalLimitBytes = getConfiguredIntProperty(totalLimit, "total_limit_bytes", 0); |
| checkArgument(this.totalLimitBytes >= 0, "File size limits cannot be negative"); |
| setLevel(getConfiguredLevelProperty(logLevel, "level", Level.ALL)); |
| setFormatter(getConfiguredFormatterProperty(formatter, "formatter", new SingleLineFormatter())); |
| if (clock != null) { |
| this.clock = clock; |
| this.timestampFormat.setTimeZone(TimeZone.getTimeZone(clock.getZone())); |
| } else { |
| this.clock = Clock.system(ZoneId.systemDefault()); |
| } |
| } |
| |
| /** |
| * Returns the absolute path of the current log file if a log file is open or {@code |
| * Optional#empty()} otherwise. |
| * |
| * <p>Since the log file is opened lazily, this method is expected to return {@code |
| * Optional#empty()} if no record has yet been published. |
| */ |
| public synchronized Optional<Path> getCurrentLogFilePath() { |
| return output.isOpen() ? Optional.of(output.getPath()) : Optional.empty(); |
| } |
| |
| /** |
| * Returns the expected absolute path for the symbolic link to the current log file, or {@code |
| * Optional#empty()} if not used. |
| */ |
| public Optional<Path> getSymbolicLinkPath() { |
| return symlinkPath; |
| } |
| |
| @Override |
| public boolean isLoggable(LogRecord record) { |
| return record != null && super.isLoggable(record); |
| } |
| |
| @Override |
| public synchronized void publish(LogRecord record) { |
| if (!isLoggable(record)) { |
| // Silently ignore null or filtered records, matching FileHandler behavior. |
| return; |
| } |
| |
| try { |
| String message = getFormatter().format(record); |
| openOutputIfNeeded(); |
| output.write(message); |
| } catch (Exception e) { |
| reportError(null, e, ErrorManager.WRITE_FAILURE); |
| // Failing to log is non-fatal. Continue to try to rotate the log if necessary, which may fix |
| // the underlying IO problem with the file. |
| } |
| |
| try { |
| if (rotateLimitBytes > 0) { |
| output.closeIfByteCountAtleast(rotateLimitBytes); |
| openOutputIfNeeded(); |
| } |
| } catch (IOException e) { |
| reportError("Failed to rotate log file", e, ErrorManager.GENERIC_FAILURE); |
| } |
| } |
| |
| @Override |
| public synchronized void flush() { |
| if (output.isOpen()) { |
| try { |
| output.flush(); |
| } catch (IOException e) { |
| reportError(null, e, ErrorManager.FLUSH_FAILURE); |
| } |
| } |
| } |
| |
| @Override |
| public synchronized void close() { |
| if (output.isOpen()) { |
| try { |
| output.write(getFormatter().getTail(this)); |
| } catch (IOException e) { |
| reportError("Failed to write log tail", e, ErrorManager.WRITE_FAILURE); |
| } |
| |
| try { |
| output.close(); |
| } catch (IOException e) { |
| reportError(null, e, ErrorManager.CLOSE_FAILURE); |
| } |
| } |
| } |
| |
| /** |
| * Checks if a value is null, and if it is, falls back to the JVM logging configuration, and if |
| * that too is missing, to a provided fallback value. |
| * |
| * @param builderValue possibly null value provided by the caller, e.g. from {@link |
| * SimpleLogHandler.Builder} |
| * @param configuredName field name in the JVM logging configuration for {@link SimpleLogHandler} |
| * @param parse parser for the string value from the JVM logging configuration |
| * @param fallbackValue fallback to use if the {@code builderValue} is null and no value is |
| * configured in the JVM logging configuration |
| * @param <T> value type |
| */ |
| private static @Nullable <T> T getConfiguredProperty( |
| @Nullable T builderValue, |
| String configuredName, |
| Function<String, T> parse, |
| @Nullable T fallbackValue) { |
| if (builderValue != null) { |
| return builderValue; |
| } |
| |
| String configuredValue = |
| LogManager.getLogManager() |
| .getProperty(SimpleLogHandler.class.getName() + "." + configuredName); |
| if (configuredValue != null) { |
| return parse.apply(configuredValue); |
| } |
| return fallbackValue; |
| } |
| |
| /** Matches java.logging.* configuration behavior; configured strings are trimmed. */ |
| private static String getConfiguredStringProperty( |
| String builderValue, String configuredName, String fallbackValue) { |
| return getConfiguredProperty(builderValue, configuredName, val -> val.trim(), fallbackValue); |
| } |
| |
| /** |
| * Matches java.logging.* configuration behavior; "true" and "1" are true, "false" and "0" are |
| * false. |
| * |
| * @throws IllegalArgumentException if the configured boolean property cannot be parsed |
| */ |
| private static boolean getConfiguredBooleanProperty( |
| Boolean builderValue, String configuredName, boolean fallbackValue) { |
| Boolean value = |
| getConfiguredProperty( |
| builderValue, |
| configuredName, |
| val -> { |
| val = val.trim().toLowerCase(); |
| if ("true".equals(val) || "1".equals(val)) { |
| return true; |
| } else if ("false".equals(val) || "0".equals(val)) { |
| return false; |
| } else if (val.length() == 0) { |
| return null; |
| } |
| throw new IllegalArgumentException("Cannot parse boolean property value"); |
| }, |
| null); |
| return value != null ? value.booleanValue() : fallbackValue; |
| } |
| |
| /** |
| * Empty configured values are ignored and the fallback is used instead. |
| * |
| * @throws NumberFormatException if the configured formatter value is non-numeric |
| */ |
| private static int getConfiguredIntProperty( |
| Integer builderValue, String configuredName, int fallbackValue) { |
| Integer value = |
| getConfiguredProperty( |
| builderValue, |
| configuredName, |
| val -> { |
| val = val.trim(); |
| return val.length() > 0 ? Integer.parseInt(val) : null; |
| }, |
| null); |
| return value != null ? value.intValue() : fallbackValue; |
| } |
| |
| /** |
| * Empty configured values are ignored and the fallback is used instead. |
| * |
| * @throws IllegalArgumentException if the configured level name cannot be parsed |
| */ |
| private static Level getConfiguredLevelProperty( |
| Level builderValue, String configuredName, Level fallbackValue) { |
| Level value = |
| getConfiguredProperty( |
| builderValue, |
| configuredName, |
| val -> { |
| val = val.trim(); |
| return val.length() > 0 ? Level.parse(val) : null; |
| }, |
| null); |
| return value != null ? value : fallbackValue; |
| } |
| |
| /** |
| * Empty configured values are ignored and the fallback is used instead. |
| * |
| * @throws IllegalArgumentException if a formatter object cannot be instantiated from the |
| * configured class name |
| */ |
| private static Formatter getConfiguredFormatterProperty( |
| Formatter builderValue, String configuredName, Formatter fallbackValue) { |
| return getConfiguredProperty( |
| builderValue, |
| configuredName, |
| val -> { |
| val = val.trim(); |
| if (val.length() > 0) { |
| try { |
| return (Formatter) |
| ClassLoader.getSystemClassLoader() |
| .loadClass(val) |
| .getDeclaredConstructor() |
| .newInstance(); |
| } catch (ReflectiveOperationException e) { |
| throw new IllegalArgumentException(e); |
| } |
| } else { |
| return fallbackValue; |
| } |
| }, |
| fallbackValue); |
| } |
| |
| @VisibleForTesting |
| static String getPidString() { |
| long pid; |
| try { |
| // TODO(b/78168359): Replace with ProcessHandle.current().pid() in Java 9 |
| pid = Long.parseLong(ManagementFactory.getRuntimeMXBean().getName().split("@", -1)[0]); |
| } catch (NumberFormatException e) { |
| // getRuntimeMXBean().getName() output is implementation-specific, may be unparseable. |
| pid = 0; |
| } |
| return Long.toString(pid); |
| } |
| |
| @VisibleForTesting |
| static String getLocalHostnameFirstComponent() { |
| String name = NetUtil.getCachedShortHostName(); |
| if (!InetAddresses.isInetAddress(name)) { |
| // Keep only the first component of the name. |
| int firstDot = name.indexOf('.'); |
| if (firstDot >= 0) { |
| name = name.substring(0, firstDot); |
| } |
| } |
| return name.toLowerCase(); |
| } |
| |
| /** |
| * Creates the log file absolute base path according to the given pattern. |
| * |
| * @param prefix non-null string to prepend to the base path |
| * @param pattern non-null string which may include the following variables: %h will be expanded |
| * to the hostname; %u will be expanded to the username; %% will be expanded to % |
| * @throws IllegalArgumentException if an unknown variable is encountered in the pattern |
| */ |
| private static Path getBaseFilePath(String prefix, String pattern) { |
| checkNotNull(prefix, "prefix"); |
| checkNotNull(pattern, "pattern"); |
| |
| StringBuilder sb = new StringBuilder(100); // Typical name is < 100 bytes |
| boolean inVar = false; |
| String username = System.getProperty("user.name"); |
| |
| if (Strings.isNullOrEmpty(username)) { |
| username = "unknown_user"; |
| } |
| |
| sb.append(prefix); |
| |
| for (int i = 0; i < pattern.length(); ++i) { |
| char c = pattern.charAt(i); |
| if (inVar) { |
| inVar = false; |
| switch (c) { |
| case '%': |
| sb.append('%'); |
| break; |
| case 'h': |
| sb.append(getLocalHostnameFirstComponent()); |
| break; |
| case 'u': |
| sb.append(username); |
| break; |
| default: |
| throw new IllegalArgumentException("Unknown variable " + c + " in " + pattern); |
| } |
| } else { |
| if (c == '%') { |
| inVar = true; |
| } else { |
| sb.append(c); |
| } |
| } |
| } |
| |
| return new File(sb.toString()).getAbsoluteFile().toPath(); |
| } |
| |
| /** |
| * Returns the absolute path for a symlink in the specified directory. |
| * |
| * @throws IllegalArgumentException if the symlink includes a directory component which doesn't |
| * equal {@code logDir} |
| */ |
| private static Path getSymlinkAbsolutePath(Path logDir, String symlink) { |
| checkNotNull(symlink); |
| checkArgument(symlink.length() > 0); |
| File symlinkFile = new File(symlink); |
| if (!symlinkFile.isAbsolute()) { |
| symlinkFile = new File(logDir + File.separator + symlink); |
| } |
| checkArgument( |
| symlinkFile.toPath().getParent().equals(logDir), |
| "symlink is not a top-level file in logDir"); |
| return symlinkFile.toPath(); |
| } |
| |
| private static final class Output { |
| /** Log file currently in use. */ |
| private @Nullable File file; |
| /** Output stream for {@link #file} which counts the number of bytes written. */ |
| private @Nullable CountingOutputStream stream; |
| /** Writer for {@link #stream}. */ |
| private @Nullable OutputStreamWriter writer; |
| |
| public boolean isOpen() { |
| return writer != null; |
| } |
| |
| /** |
| * Opens the specified file in append mode, first closing the current file if needed. |
| * |
| * @throws IOException if the file could not be opened |
| */ |
| public void open(String path) throws IOException { |
| try { |
| close(); |
| file = new File(path); |
| stream = new CountingOutputStream(new FileOutputStream(file, true)); |
| writer = new OutputStreamWriter(stream, UTF_8); |
| } catch (IOException e) { |
| close(); |
| throw e; |
| } |
| } |
| |
| /** |
| * Returns the currently open file's path. |
| * |
| * @throws NullPointerException if not open |
| */ |
| public Path getPath() { |
| return file.toPath(); |
| } |
| |
| /** |
| * Writes the string to the current file in UTF-8 encoding. |
| * |
| * @throws NullPointerException if not open |
| * @throws IOException if an underlying IO operation failed |
| */ |
| public void write(String string) throws IOException { |
| writer.write(string); |
| } |
| |
| /** |
| * Flushes the current file. |
| * |
| * @throws NullPointerException if not open |
| * @throws IOException if an underlying IO operation failed |
| */ |
| public void flush() throws IOException { |
| writer.flush(); |
| } |
| |
| /** |
| * Closes the current file if it is open. |
| * |
| * @throws IOException if an underlying IO operation failed |
| */ |
| public void close() throws IOException { |
| try { |
| if (isOpen()) { |
| writer.close(); |
| } |
| } finally { |
| writer = null; |
| stream = null; |
| file = null; |
| } |
| } |
| |
| /** |
| * Closes the current file unless the number of bytes written to it was under the specified |
| * limit. |
| * |
| * @throws NullPointerException if not open |
| * @throws IOException if an underlying IO operation failed |
| */ |
| public void closeIfByteCountAtleast(int limit) throws IOException { |
| if (stream.getCount() < limit && stream.getCount() + 8192L >= limit) { |
| // The writer and its internal encoder buffer output before writing to the output stream. |
| // The default size of the encoder's buffer is 8192 bytes. To count the bytes in the output |
| // stream accurately, we have to flush. But flushing unnecessarily harms performance; let's |
| // flush only when it matters - per record and within expected buffer size from the limit. |
| flush(); |
| } |
| if (stream.getCount() >= limit) { |
| close(); |
| } |
| } |
| } |
| |
| /** |
| * Opens a new log file if one is not open, updating the symbolic link and deleting old logs if |
| * needed. |
| */ |
| @GuardedBy("this") |
| private void openOutputIfNeeded() { |
| if (!output.isOpen()) { |
| // Ensure the log file's directory exists. |
| checkState(baseFilePath.isAbsolute()); |
| baseFilePath.getParent().toFile().mkdirs(); |
| |
| try { |
| output.open( |
| baseFilePath + timestampFormat.format(Date.from(Instant.now(clock))) + extension); |
| output.write(getFormatter().getHead(this)); |
| } catch (IOException e) { |
| try { |
| output.close(); |
| } catch (IOException eClose) { |
| // Already handling a prior IO failure. |
| } |
| reportError("Failed to open log file", e, ErrorManager.OPEN_FAILURE); |
| return; |
| } |
| |
| if (totalLimitBytes > 0) { |
| deleteOldLogs(); |
| } |
| |
| // Try to create relative symlink from currentLogFile to baseFile, but don't treat a failure |
| // as fatal. |
| if (symlinkPath.isPresent()) { |
| try { |
| checkState(symlinkPath.get().getParent().equals(output.getPath().getParent())); |
| if (Files.exists(symlinkPath.get(), LinkOption.NOFOLLOW_LINKS)) { |
| Files.delete(symlinkPath.get()); |
| } |
| Files.createSymbolicLink(symlinkPath.get(), output.getPath().getFileName()); |
| } catch (IOException e) { |
| reportError( |
| "Failed to create symbolic link to log file", e, ErrorManager.GENERIC_FAILURE); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Parses the absolute path of a logfile (e.g from a previous run of the program) and extracts the |
| * timestamp. |
| * |
| * @throws ParseException if the path does not match the expected prefix, resolved pattern, |
| * timestamp format, or extension |
| */ |
| @GuardedBy("this") |
| private Date parseLogFileTimestamp(Path path) throws ParseException { |
| String pathString = path.toString(); |
| if (!pathString.startsWith(baseFilePath.toString())) { |
| throw new ParseException("Wrong prefix or pattern", 0); |
| } |
| ParsePosition parsePosition = new ParsePosition(baseFilePath.toString().length()); |
| Date timestamp = timestampFormat.parse(pathString, parsePosition); |
| if (timestamp == null) { |
| throw new ParseException("Wrong timestamp format", parsePosition.getErrorIndex()); |
| } |
| if (isStaticExtension) { |
| if (!pathString.substring(parsePosition.getIndex()).equals(extension)) { |
| throw new ParseException("Wrong file extension", parsePosition.getIndex()); |
| } |
| } else { |
| try { |
| Long.parseLong(pathString.substring(parsePosition.getIndex())); |
| } catch (NumberFormatException e) { |
| throw new ParseException("File extension is not a numeric PID", parsePosition.getIndex()); |
| } |
| } |
| return timestamp; |
| } |
| |
| /** |
| * File path ordered by timestamp. |
| */ |
| private static final class PathByTimestamp implements Comparable<PathByTimestamp> { |
| private final Path path; |
| private final Date timestamp; |
| private final long size; |
| |
| PathByTimestamp(Path path, Date timestamp, long size) { |
| this.path = path; |
| this.timestamp = timestamp; |
| this.size = size; |
| } |
| |
| Path getPath() { |
| return path; |
| } |
| |
| long getSize() { |
| return size; |
| } |
| |
| @Override |
| public int compareTo(PathByTimestamp rhs) { |
| return this.timestamp.compareTo(rhs.timestamp); |
| } |
| } |
| |
| /** |
| * Deletes the oldest log files matching the expected prefix, pattern, timestamp format, and |
| * extension, to keep the total size under {@link #totalLimitBytes} (if set to non-0). |
| * |
| * <p>Each log file's timestamp is determined only from the filename. The current log file will |
| * not be deleted. |
| */ |
| @GuardedBy("this") |
| private void deleteOldLogs() { |
| checkState(baseFilePath.isAbsolute()); |
| PriorityQueue<PathByTimestamp> queue = new PriorityQueue<>(); |
| long totalSize = 0; |
| try (DirectoryStream<Path> dirStream = Files.newDirectoryStream(baseFilePath.getParent())) { |
| for (Path path : dirStream) { |
| try { |
| Date timestamp = parseLogFileTimestamp(path); |
| long size = Files.size(path); |
| totalSize += size; |
| if (!output.getPath().equals(path)) { |
| queue.add(new PathByTimestamp(path, timestamp, size)); |
| } |
| } catch (ParseException e) { |
| // Ignore files which don't look like our logs. |
| } |
| } |
| |
| if (totalLimitBytes > 0) { |
| while (totalSize > totalLimitBytes && !queue.isEmpty()) { |
| PathByTimestamp entry = queue.poll(); |
| Files.delete(entry.getPath()); |
| totalSize -= entry.getSize(); |
| } |
| } |
| } catch (IOException e) { |
| reportError("Failed to clean up old log files", e, ErrorManager.GENERIC_FAILURE); |
| } |
| } |
| } |