FileOutput.java

package io.jstach.rainbowgum.file;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Paths;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.LogConfig;
import io.jstach.rainbowgum.LogEncoder.BufferHints;
import io.jstach.rainbowgum.LogEvent;
import io.jstach.rainbowgum.LogOutput;
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;

/**
 * An output that is designed for writing to a file.
 */
public interface FileOutput extends LogOutput {

	/**
	 * Default file buffer size: 8KB, based on Logback's default.
	 */
	public static final DataSize DEFAULT_BUFFER_SIZE = DataSize.ofKilobytes(8);

	@Override
	default OutputType type() {
		return OutputType.FILE;
	}

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

	/**
	 * Create output from provider ref. This is mostly an internal call.
	 * @param ref file provider ref.
	 * @return provider.
	 */
	public static LogProvider<LogOutput> of(LogProviderRef ref) {
		return (s, c) -> {
			return provide(ref, s, c);
		};
	}

	private static LogOutput provide(LogProviderRef ref, String name, LogConfig config) {
		FileOutputBuilder b = new FileOutputBuilder(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('?');
			s = s.substring(0, index);
			uri = URI.create(s);
			uri = Paths.get(uri).toUri();
		}
		else {
			combined = properties;
		}
		b.uri(uri);
		b.fromProperties(combined);
		return b.build().provide(name, config);
	}

	/**
	 * Creates a file output provider. Deferred as a <code>LogProvider</code> (rather than
	 * building the file output directly) so that config - in particular the alerts config
	 * offers - is available to pass into the concrete output's constructor instead of
	 * only reaching it later through the output's start call.
	 * @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 existing file.
	 * @param prudent logback prudent mode where files are locked on each write.
	 * @param bufferSize buffer size, in {@link DataSize#parse(String)} format when set by
	 * property, e.g. {@code 8KB}. Zero means unbuffered. Must be at most
	 * {@value Integer#MAX_VALUE} bytes.
	 * @return file output provider whose <code>provide(...)</code> throws
	 * {@link java.io.UncheckedIOException} if the file cannot be opened.
	 */
	@SuppressWarnings("resource")
	@LogConfigurable(name = "FileOutputBuilder", prefix = LogProperties.OUTPUT_PREFIX + "file.")
	public static LogProvider<FileOutput> 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) {
		boolean prudent_ = prudent == null ? false : prudent;
		long bufferBytes = bufferSize.toBytes();
		if (bufferBytes > Integer.MAX_VALUE) {
			throw new IllegalArgumentException("bufferSize is too large: '" + bufferSize + "' (" + bufferBytes
					+ " bytes). Maximum is " + Integer.MAX_VALUE + " bytes.");
		}
		int bufferSize_ = (int) bufferBytes;
		boolean append_ = append == null ? true : append;
		return (n, config) -> {
			IOSupplier<FileOutput> supplier = () -> {
				File file;
				URI uri_ = uri;
				if (fileName != null) {
					file = new File(fileName);
					uri_ = file.toURI();
				}
				else if (uri_ != null) {
					file = new File(uri_);
				}
				else {
					throw new IOException("fileName and uri cannot both be unset.");
				}
				createMissingParentDirectories(file);
				FileOutputStream stream;
				try {
					stream = new FileOutputStream(file, append_);
				}
				catch (FileNotFoundException e) {
					throw new UncheckedIOException(e);
				}
				if (prudent_) {
					return new FileChannelOutput(uri_, stream.getChannel());
				}
				OutputStream s;
				if (bufferSize_ == 0) {
					s = stream;
				}
				else {
					s = new BufferedOutputStream(stream, bufferSize_);
				}
				return new FileOutputStreamOutput(uri_, s);
			};

			return new ReopenableFileOutput(supplier);
		};
	}

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

	/**
	 * Creates the parent directories of a file. If parent directories not specified in
	 * file's path, then nothing is done and this returns gracefully.
	 * @param file file whose parent directories (if any) should be created
	 * @return {@code true} if either no parents were specified, or if all parent
	 * directories were created successfully; {@code false} otherwise
	 */
	private static boolean createMissingParentDirectories(File file) {
		File parent = file.getParentFile();
		if (parent == null) {
			// Parent directory not specified, therefore it's a request to
			// create nothing. Done! ;)
			return true;
		}

		// File.mkdirs() creates the parent directories only if they don't
		// already exist; and it's okay if they do.
		parent.mkdirs();
		return parent.exists();
	}

}

@FunctionalInterface
interface IOSupplier<T> {

	T _get() throws IOException;

	default T get() throws UncheckedIOException {
		try {
			return _get();
		}
		catch (IOException e) {
			throw new UncheckedIOException(e);
		}
	}

}

class ReopenableFileOutput implements ForwardingOutput, FileOutput {

	private volatile FileOutput fileOutput;

	private @Nullable LogConfig config;

	private final IOSupplier<FileOutput> supplier;

	public ReopenableFileOutput(IOSupplier<FileOutput> supplier) {
		super();
		this.supplier = supplier;
		this.fileOutput = supplier.get();
	}

	@Override
	public void start(LogConfig config) {
		this.config = config;
		fileOutput.start(config);
	}

	@Override
	public @Nullable LogOutput delegate() {
		return fileOutput;
	}

	@Override
	public URI uri() throws UnsupportedOperationException {
		return fileOutput.uri();
	}

	@Override
	public OutputType type() {
		return fileOutput.type();
	}

	@Override
	public BufferHints bufferHints() {
		return fileOutput.bufferHints();
	}

	@Override
	public void reopen() {
		this.fileOutput.close();
		this.fileOutput = supplier.get();
		var config = this.config;
		if (config != null) {
			this.fileOutput.start(config);
		}
	}

}

class FileOutputStreamOutput extends LogOutput.AbstractOutputStreamOutput implements FileOutput {

	protected FileOutputStreamOutput(URI uri, OutputStream outputStream) {
		super(uri, outputStream);
	}

}

class FileChannelOutput implements FileOutput {

	protected final URI uri;

	protected final FileChannel channel;

	/*
	 * A log call can still be in flight (e.g. routed through SLF4J from a shutdown hook)
	 * after something outside the normal appender/publisher lifecycle - like Spring
	 * Boot's LoggingSystem.cleanUp() - has already closed this output directly. Once
	 * closed, write/flush become no-ops instead of throwing so that race does not surface
	 * as an uncaught exception on whatever thread is still trying to log. See the
	 * analogous fix for LogOutput.AbstractOutputStreamOutput (issue #323).
	 */
	private final AtomicBoolean closed = new AtomicBoolean();

	public FileChannelOutput(URI uri, FileChannel channel) {
		super();
		this.uri = uri;
		this.channel = channel;
	}

	@Override
	public URI uri() throws UnsupportedOperationException {
		return uri;
	}

	@Override
	public void write(LogEvent event, byte[] bytes, int off, int len, ContentType contentType) {
		write(event, ByteBuffer.wrap(bytes, off, len), contentType);
	}

	@Override
	public void write(LogEvent event, ByteBuffer buffer, ContentType contentType) {
		if (closed.get()) {
			return;
		}
		// Clear any current interrupt (see LOGBACK-875)
		boolean interrupted = Thread.interrupted();
		try {
			FileLock fileLock = null;
			try {
				fileLock = channel.lock();
				long position = channel.position();
				long size = channel.size();
				if (size != position) {
					channel.position(size);
				}
				channel.write(buffer);

			}
			finally {
				if (fileLock != null && fileLock.isValid()) {
					fileLock.release();
				}
			}
		}
		catch (IOException e) {
			throw new UncheckedIOException(e);
		}
		finally {
			if (interrupted) {
				Thread.currentThread().interrupt();
			}
		}
	}

	@Override
	public void close() {
		if (!closed.compareAndSet(false, true)) {
			return;
		}
		try {
			channel.close();
		}
		catch (IOException e) {
			throw new UncheckedIOException(e);
		}
	}

	@Override
	public void flush() {
		if (closed.get()) {
			return;
		}
		try {
			channel.force(false);
		}
		catch (IOException e) {
			throw new UncheckedIOException(e);
		}
	}

	@Override
	public WriteMethod bufferHints() {
		return WriteMethod.BYTE_BUFFER;
	}

}