RollingFileOutput.java

package io.jstach.rainbowgum.rolling;

import java.io.File;
import java.net.URI;
import java.nio.file.Path;
import java.util.function.Consumer;
import java.util.function.Supplier;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.LogConfig;
import io.jstach.rainbowgum.LogMetrics;
import io.jstach.rainbowgum.LogProperties;
import io.jstach.rainbowgum.LogProvider;
import io.jstach.rainbowgum.LogProviderRef;
import io.jstach.rainbowgum.annotation.LogConfigurable;
import io.jstach.rainbowgum.annotation.LogConfigurable.ConvertParameter;
import io.jstach.rainbowgum.annotation.LogConfigurable.DefaultParameter;
import io.jstach.rainbowgum.file.DataSize;
import io.jstach.rainbowgum.file.FileOutput;
import io.jstach.rainbowgum.file.FileOutputBuilder;

/**
 * A {@link FileOutput} that rolls (renames the active file to a numbered archive and
 * starts a fresh one) once it grows past {@code maxFileSize}, retaining at most
 * {@code maxHistory} archives.
 * <p>
 * Deliberately size (not calendar/date) triggered and only supports a <code>%i</code>
 * (rotation index) token in {@link #DEFAULT_FILE_NAME_PATTERN} - see
 * {@code doc/overview.html}'s "Rolling Files" section for why: reopen-on-external-signal
 * (e.g. via <code>logrotate</code>) remains the recommended approach for anything beyond
 * "keep a small/desktop app from filling its disk", and that simpler need does not
 * benefit from Logback-style calendar based archive naming.
 * <p>
 * Registered under the {@value #ROLLING_SCHEME} URI scheme (see
 * {@code RollingConfigurator}), e.g. {@code rolling:///var/log/app.log?maxFileSize=...}.
 * Its properties are <code>logging.appender.{name}.output.rolling.</code> followed by a
 * {@link RollingFileOutputBuilder} property name. {@code fileName}, {@code append},
 * {@code prudent} and {@code bufferSize} mean the same as for {@link FileOutput} and are
 * passed to the underlying file output this wraps. The {@code append} setting applies
 * when the output is first opened. Replacement outputs always append so recovery
 * preserves any active contents left behind by a failed rotation.
 * <p>
 * Failed automatic rotation attempts record {@link LogMetrics#ROLL_FAIL_METRIC} and an
 * error alert identifying the active file. The event that triggered the failed rotation
 * is not retried and also counts toward {@link LogMetrics#EVENTS_FAILED_METRIC}. The next
 * write attempts to recover the output. Archive changes already completed before a
 * failure are not rolled back. An explicit {@link #reopen()} after external rotation
 * instead uses {@link LogMetrics#REOPEN_FAIL_METRIC}.
 * <p>
 * Only one process should rotate these files. Prudent mode locks individual writes, but
 * does not coordinate archive rotation between processes.
 */
public interface RollingFileOutput extends FileOutput {

	/**
	 * URI scheme for rolling file outputs.
	 */
	static final String ROLLING_SCHEME = "rolling";

	/**
	 * Default buffer size, the same as {@link FileOutput#DEFAULT_BUFFER_SIZE}.
	 */
	static final DataSize DEFAULT_BUFFER_SIZE = FileOutput.DEFAULT_BUFFER_SIZE;

	/**
	 * Default max file size before a roll is triggered: 10MB, matching Logback and Spring
	 * Boot's own default.
	 */
	static final DataSize DEFAULT_MAX_FILE_SIZE = DataSize.ofMegabytes(10);

	/**
	 * Default number of archives to retain - 7, matching Logback/Spring Boot's own
	 * default.
	 */
	static final int DEFAULT_MAX_HISTORY = 7;

	/**
	 * Default total archive size cap. {@link DataSize#ZERO} means unlimited.
	 */
	static final DataSize DEFAULT_TOTAL_SIZE_CAP = DataSize.ZERO;

	/**
	 * Default for whether archive pruning ({@code maxHistory}/{@code totalSizeCap}) also
	 * runs once at {@link #start(LogConfig)}, not just after each roll.
	 */
	static final boolean DEFAULT_CLEAN_HISTORY_ON_START = false;

	/**
	 * Default {@code fileNamePattern} - the literal text appended directly after the
	 * active file's own path (unlike Logback, never containing the base file name itself)
	 * with <code>%i</code> substituted for the rotation index, e.g. <code>app.log</code>
	 * with this pattern archives to <code>app.log.1</code>, <code>app.log.2</code>, etc.
	 * A pattern ending in <code>.gz</code> gzip compresses archives on rotation.
	 */
	static final String DEFAULT_FILE_NAME_PATTERN = ".%i";

	/**
	 * Creates a rolling file output provider from a URI reference - the entry point used
	 * by the {@value #ROLLING_SCHEME} scheme registration.
	 * @param ref uri reference, may have a query string of builder properties (same
	 * convention as {@link FileOutput#of(LogProviderRef)}).
	 * @return provider.
	 */
	public static LogProvider<io.jstach.rainbowgum.LogOutput> of(LogProviderRef ref) {
		return (name, config) -> provide(ref, name, config);
	}

	/**
	 * Creates a rolling file output provider from a lambda builder and uses the config
	 * properties from the returned log provider - mirrors
	 * {@link FileOutput#of(Consumer)}.
	 * @param consumer builder lambda.
	 * @return provider.
	 */
	public static LogProvider<RollingFileOutput> of(Consumer<RollingFileOutputBuilder> consumer) {
		return (name, config) -> {
			var builder = new RollingFileOutputBuilder(name);
			consumer.accept(builder);
			builder.fromProperties(config.properties());
			return builder.build().provide(name, config);
		};
	}

	private static FileOutput provide(LogProviderRef ref, String name, LogConfig config) {
		var b = new RollingFileOutputBuilder(name);
		var uri = ref.uri();
		var properties = config.properties();
		LogProperties combined;
		if (uri.getQuery() != null) {
			combined = LogProperties.of(uri, b.propertyPrefix(), properties, ref.keyOrNull());
			String s = uri.toString();
			int index = s.indexOf('?');
			uri = URI.create(s.substring(0, index));
		}
		else {
			combined = properties;
		}
		b.uri(uri);
		b.fromProperties(combined);
		return b.build().provide(name, config);
	}

	/**
	 * Creates a rolling file output provider.
	 * @param name name of output, not file name.
	 * @param uri file uri, set by the appender from its <code>output.uri</code>.
	 * @param fileName file name.
	 * @param append whether or not to append to an existing file when first opened.
	 * @param prudent logback prudent mode where files are locked on each write.
	 * @param bufferSize buffer size, in
	 * {@link io.jstach.rainbowgum.file.DataSize#parse(String)} format when set by
	 * property, e.g. {@code 8KB}. Zero means unbuffered.
	 * @param maxFileSize max file size before a roll is triggered, in
	 * {@link io.jstach.rainbowgum.file.DataSize#parse(String)} format when set by
	 * property, e.g. {@code 10MB}.
	 * @param maxHistory number of archives to retain.
	 * @param totalSizeCap total archive size cap; zero means unlimited.
	 * @param cleanHistoryOnStart whether pruning also runs once at start, not just after
	 * each roll.
	 * @param fileNamePattern archive naming pattern; must contain <code>%i</code> and
	 * must not contain <code>%d</code> (date based rotation is not supported).
	 * @return rolling file output provider.
	 */
	@LogConfigurable(name = "RollingFileOutputBuilder", prefix = LogProperties.OUTPUT_PREFIX + "rolling.")
	public static LogProvider<RollingFileOutput> of(@LogConfigurable.KeyParameter String name,
			@LogConfigurable.PassThroughParameter @Nullable URI uri, @Nullable String fileName,
			@Nullable Boolean append, @Nullable Boolean prudent,
			@ConvertParameter("parseDataSize") @DefaultParameter("DEFAULT_BUFFER_SIZE") DataSize bufferSize,
			@ConvertParameter("parseDataSize") @DefaultParameter("DEFAULT_MAX_FILE_SIZE") DataSize maxFileSize,
			@DefaultParameter("DEFAULT_MAX_HISTORY") Integer maxHistory,
			@ConvertParameter("parseDataSize") @DefaultParameter("DEFAULT_TOTAL_SIZE_CAP") DataSize totalSizeCap,
			@DefaultParameter("DEFAULT_CLEAN_HISTORY_ON_START") Boolean cleanHistoryOnStart,
			@DefaultParameter("DEFAULT_FILE_NAME_PATTERN") String fileNamePattern) {
		var parsedPattern = RollingPolicy.ParsedPattern.parse(fileNamePattern);
		File file;
		if (fileName != null) {
			file = new File(fileName);
		}
		else if (uri != null) {
			file = new File(uri.getPath());
		}
		else {
			throw new IllegalArgumentException("fileName and uri cannot both be unset.");
		}
		Path activeFile = file.toPath().toAbsolutePath();
		String fileNameForDelegate = file.getPath();
		long maxFileSize_ = maxFileSize.toBytes();
		int maxHistory_ = maxHistory;
		long totalSizeCap_ = totalSizeCap.toBytes();
		boolean cleanHistoryOnStart_ = cleanHistoryOnStart;
		return (n, config) -> {
			var fileBuilder = new FileOutputBuilder(n).fileName(fileNameForDelegate)
				.append(append == null ? true : append)
				.prudent(prudent)
				.bufferSize(bufferSize);
			var initialOutput = fileBuilder.build().provide(n, config);
			// append=false applies only to initial opening. Recovery must preserve any
			// active contents left behind by a failed rotation.
			var replacementProvider = fileBuilder.append(true).build();
			Supplier<FileOutput> supplier = () -> replacementProvider.provide(n, config);
			return new DefaultRollingFileOutput(activeFile, parsedPattern, maxFileSize_, maxHistory_, totalSizeCap_,
					cleanHistoryOnStart_, supplier, initialOutput, config);
		};
	}

	/**
	 * Converts a {@code maxFileSize} or {@code totalSizeCap} property value.
	 * @param value property value.
	 * @return data size.
	 * @see DataSize#parse(String)
	 */
	static DataSize parseDataSize(String value) {
		return DataSize.parse(value);
	}

}