RollingPolicy.java

package io.jstach.rainbowgum.rolling;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;

/**
 * The rotation algorithm itself, independent of {@link io.jstach.rainbowgum.LogOutput} so
 * it can be unit tested directly against a temp directory without going through a full
 * appender/config pipeline. Deliberately size (not calendar/date) triggered - see
 * {@link ParsedPattern#parse(String)} - so numbered suffixes (<code>app.log.1</code>,
 * <code>app.log.2</code>, ...) are the only naming scheme needed, matching classic
 * <code>logrotate</code> numbering rather than Logback's combined <code>%d{...}.%i</code>
 * scheme.
 */
final class RollingPolicy {

	private RollingPolicy() {
	}

	/**
	 * A parsed {@code fileNamePattern} - the literal text before and after the
	 * <code>%i</code> token.
	 *
	 * @param prefix literal text before <code>%i</code>.
	 * @param suffix literal text after <code>%i</code>, e.g. <code>.gz</code> or empty.
	 * @param gzip whether {@code suffix} ends in <code>.gz</code>, meaning archives
	 * should be gzip compressed on rotation rather than just moved.
	 */
	record ParsedPattern(String prefix, String suffix, boolean gzip) {

		/**
		 * Parses a {@code fileNamePattern}. The pattern is always relative to (appended
		 * directly after) the active file's own path - unlike Logback's
		 * {@code fileNamePattern}, it never contains the base file name itself.
		 * @param pattern pattern, must contain exactly one <code>%i</code> token and no
		 * <code>%d</code> token.
		 * @return parsed pattern.
		 * @throws IllegalArgumentException if the pattern contains a <code>%d</code>
		 * (date based) token - not supported, only size based (<code>%i</code>) rotation
		 * is - or does not contain <code>%i</code> at all.
		 */
		static ParsedPattern parse(String pattern) {
			if (pattern.contains("%d")) {
				throw new IllegalArgumentException(
						"fileNamePattern does not support %d (date based) tokens, only %i (rotation index): "
								+ pattern);
			}
			int i = pattern.indexOf("%i");
			if (i < 0) {
				throw new IllegalArgumentException("fileNamePattern must contain %i (rotation index): " + pattern);
			}
			if (pattern.indexOf("%i", i + 2) >= 0) {
				throw new IllegalArgumentException("fileNamePattern must contain only one %i token: " + pattern);
			}
			String prefix = pattern.substring(0, i);
			String suffix = pattern.substring(i + 2);
			boolean gzip = suffix.endsWith(".gz");
			return new ParsedPattern(prefix, suffix, gzip);
		}

		Path archivePath(Path activeFile, int index) {
			return Path.of(activeFile + prefix + index + suffix);
		}

		private Pattern indexRegex(Path activeFile) {
			return Pattern.compile(Pattern.quote(activeFile.getFileName() + prefix) + "(\\d+)" + Pattern.quote(suffix));
		}

	}

	/**
	 * Rolls the active file: shifts existing numbered archives up by one, drops anything
	 * beyond {@code maxHistory}, moves (optionally gzip compressing) the active file into
	 * archive slot 1, then enforces {@code totalSizeCap}. Does not recreate the active
	 * file - the caller is expected to reopen it.
	 * @param activeFile the file being actively written to.
	 * @param pattern parsed {@code fileNamePattern}.
	 * @param maxHistory maximum number of archives to retain; {@code 0} keeps none (the
	 * active file's contents are simply discarded on roll).
	 * @param totalSizeCap maximum combined size, in bytes, of all archives; {@code <= 0}
	 * means unlimited.
	 */
	static void roll(Path activeFile, ParsedPattern pattern, int maxHistory, long totalSizeCap) {
		try {
			if (maxHistory > 0) {
				Files.deleteIfExists(pattern.archivePath(activeFile, maxHistory));
			}
			for (int n = maxHistory - 1; n >= 1; n--) {
				Path from = pattern.archivePath(activeFile, n);
				if (Files.exists(from)) {
					Files.move(from, pattern.archivePath(activeFile, n + 1), StandardCopyOption.REPLACE_EXISTING);
				}
			}
			if (maxHistory >= 1 && Files.exists(activeFile)) {
				Path target = pattern.archivePath(activeFile, 1);
				if (pattern.gzip()) {
					gzip(activeFile, target);
					Files.delete(activeFile);
				}
				else {
					Files.move(activeFile, target, StandardCopyOption.REPLACE_EXISTING);
				}
			}
			else {
				Files.deleteIfExists(activeFile);
			}
			enforceTotalSizeCap(activeFile, pattern, maxHistory, totalSizeCap);
		}
		catch (IOException e) {
			throw new UncheckedIOException(e);
		}
	}

	/**
	 * Prunes archives without rolling the active file - for {@code cleanHistoryOnStart}:
	 * drops any leftover archive whose index exceeds the <em>current</em>
	 * {@code maxHistory} (e.g. after lowering the setting) and then enforces
	 * {@code totalSizeCap}, same as after a normal {@link #roll}.
	 * @param activeFile the file that will be actively written to.
	 * @param pattern parsed {@code fileNamePattern}.
	 * @param maxHistory maximum number of archives to retain.
	 * @param totalSizeCap maximum combined size, in bytes, of all archives; {@code <= 0}
	 * means unlimited.
	 */
	static void cleanHistory(Path activeFile, ParsedPattern pattern, int maxHistory, long totalSizeCap) {
		try {
			for (int index : existingArchiveIndexes(activeFile, pattern)) {
				if (index > maxHistory) {
					Files.deleteIfExists(pattern.archivePath(activeFile, index));
				}
			}
			enforceTotalSizeCap(activeFile, pattern, maxHistory, totalSizeCap);
		}
		catch (IOException e) {
			throw new UncheckedIOException(e);
		}
	}

	private static void enforceTotalSizeCap(Path activeFile, ParsedPattern pattern, int maxHistory, long totalSizeCap)
			throws IOException {
		if (totalSizeCap <= 0) {
			return;
		}
		long total = 0;
		boolean evict = false;
		for (int n = 1; n <= maxHistory; n++) {
			Path p = pattern.archivePath(activeFile, n);
			if (!Files.exists(p)) {
				continue;
			}
			if (evict) {
				Files.delete(p);
				continue;
			}
			total += Files.size(p);
			if (total > totalSizeCap) {
				Files.delete(p);
				evict = true;
			}
		}
	}

	/*
	 * Bounded only by whatever archives actually exist on disk - lists the parent
	 * directory and matches file names against the pattern's prefix/suffix rather than
	 * guessing an upper bound to scan, so a maxHistory lowered across restarts (or a
	 * totalSizeCap that evicted files out of order) is handled correctly either way.
	 */
	private static List<Integer> existingArchiveIndexes(Path activeFile, ParsedPattern pattern) throws IOException {
		Path parent = activeFile.toAbsolutePath().getParent();
		if (parent == null || !Files.isDirectory(parent)) {
			return List.of();
		}
		var regex = pattern.indexRegex(activeFile.toAbsolutePath());
		List<Integer> indexes = new ArrayList<>();
		try (DirectoryStream<Path> stream = Files.newDirectoryStream(parent)) {
			for (Path candidate : stream) {
				var matcher = regex.matcher(Objects.requireNonNull(candidate.getFileName()).toString());
				if (matcher.matches()) {
					indexes.add(Integer.parseInt(Objects.requireNonNull(matcher.group(1))));
				}
			}
		}
		return indexes;
	}

	private static void gzip(Path source, Path target) throws IOException {
		Path directory = Objects.requireNonNull(target.getParent());
		Path fileName = Objects.requireNonNull(target.getFileName());
		Path temporary = Files.createTempFile(directory, fileName.toString(), ".tmp");
		try {
			try (var in = Files.newInputStream(source);
					var out = new GZIPOutputStream(Files.newOutputStream(temporary))) {
				in.transferTo(out);
			}
			Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
		}
		catch (IOException | RuntimeException e) {
			try {
				Files.deleteIfExists(temporary);
			}
			catch (IOException cleanupFailure) {
				e.addSuppressed(cleanupFailure);
			}
			throw e;
		}
	}

}