From 135d79e9ce64ee63401d4aaf9c486fb93e70b3af Mon Sep 17 00:00:00 2001 From: Gerald Unterrainer Date: Mon, 28 Jun 2021 13:17:10 +0200 Subject: [PATCH] add DoubleBufferedFile --- pom.xml | 2 +- .../commons/jreutils/DateUtils.java | 5 + .../commons/jreutils/DoubleBufferedFile.java | 225 ++++++++++++++++++ .../jreutils/DoubleBufferedFileTests.java | 110 +++++++++ test1.txt | 1 + 5 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/main/java/info/unterrainer/commons/jreutils/DoubleBufferedFile.java create mode 100644 src/test/java/info/unterrainer/commons/jreutils/DoubleBufferedFileTests.java create mode 100644 test1.txt diff --git a/pom.xml b/pom.xml index af01991..22b05e6 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ 4.0.0 jre-utils - 0.1.10 + 0.1.11 JreUtils jar diff --git a/src/main/java/info/unterrainer/commons/jreutils/DateUtils.java b/src/main/java/info/unterrainer/commons/jreutils/DateUtils.java index 00b1c51..765dd7d 100644 --- a/src/main/java/info/unterrainer/commons/jreutils/DateUtils.java +++ b/src/main/java/info/unterrainer/commons/jreutils/DateUtils.java @@ -1,5 +1,6 @@ package info.unterrainer.commons.jreutils; +import java.nio.file.attribute.FileTime; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; @@ -27,6 +28,10 @@ public static LocalDateTime epochToUtcLocalDateTime(final Long epoch) { return Instant.ofEpochMilli(epoch).atZone(ZoneId.of("UTC")).toLocalDateTime(); } + public static LocalDateTime fileTimeToUtcLocalDateTime(final FileTime time) { + return time.toInstant().atZone(ZoneId.of("UTC")).toLocalDateTime(); + } + public static int getWeekOf(final LocalDateTime dateTime) { return dateTime.get(WeekFields.ISO.weekOfWeekBasedYear()); } diff --git a/src/main/java/info/unterrainer/commons/jreutils/DoubleBufferedFile.java b/src/main/java/info/unterrainer/commons/jreutils/DoubleBufferedFile.java new file mode 100644 index 0000000..a710aab --- /dev/null +++ b/src/main/java/info/unterrainer/commons/jreutils/DoubleBufferedFile.java @@ -0,0 +1,225 @@ +package info.unterrainer.commons.jreutils; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.LocalDateTime; +import java.util.Objects; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * Allows to access (read/write) a 'doubleBuffered file', meaning that there are + * two files which get written to alternately.
+ * The files are named pathToFile/file1.fileExtension and + * pathToFile/file2.fileExtension + *

+ * This allows for some fault-tolerance when it comes to corrupted files, since + * you always have, at least, the other, albeit older file at your disposal. + *

+ * You create this with a path to a virtual file that should not exist, since it + * misses the numbers.
+ * Use the methods to retrieve a write-handle to the correct (older) file and a + * read-handle as well (newer). + *

+ * Throws an IOException if something happens it cannot handle any longer (both + * files are locked for write-access and you're requesting write-access for + * example). + */ +@Accessors(fluent = true) +public class DoubleBufferedFile { + + @FunctionalInterface + public interface ConsumerWithIoException { + /** + * Performs this operation on the given argument. + * + * @param t the input argument + * @throws IOException if one occurs + */ + void accept(T t) throws IOException; + + /** + * Returns a composed {@code Consumer} that performs, in sequence, this + * operation followed by the {@code after} operation. If performing either + * operation throws an exception, it is relayed to the caller of the composed + * operation. If performing this operation throws an exception, the + * {@code after} operation will not be performed. + * + * @param after the operation to perform after this operation + * @return a composed {@code Consumer} that performs in sequence this operation + * followed by the {@code after} operation + * @throws NullPointerException if {@code after} is null + * @throws IOException if one occurs + */ + default ConsumerWithIoException andThen(final ConsumerWithIoException after) throws IOException { + Objects.requireNonNull(after); + return (final T t) -> { + accept(t); + after.accept(t); + }; + } + } + + @Data + class DoubleBufferedFileData { + private final Path path; + private boolean exists; + private LocalDateTime modified; + private boolean readable; + private boolean writable; + + DoubleBufferedFileData(final Path path) { + super(); + this.path = path; + probe(); + } + + void delete() throws IOException { + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) + Files.delete(path); + } + + void probe() { + exists = Files.exists(path, LinkOption.NOFOLLOW_LINKS); + + writable = Files.isWritable(path); + readable = Files.isReadable(path); + + modified = null; + if (exists) + try { + modified = DateUtils.fileTimeToUtcLocalDateTime( + Files.readAttributes(path, BasicFileAttributes.class).lastModifiedTime()); + } catch (IOException e) { + modified = null; + readable = false; + writable = false; + } + } + + DoubleBufferedFileData withCheckedWrite() throws IOException { + if (!writable) + throw new IOException(String.format("There is no write-access for the given path [%s].", path)); + return this; + } + + DoubleBufferedFileData withCheckedRead() throws IOException { + if (!readable) + throw new IOException(String.format("There is no read-access for the given path [%s].", path)); + return this; + } + + BufferedWriter getBufferedWriter() throws IOException { + return Files.newBufferedWriter(path, Charset.forName("UTF-8")); + } + } + + protected DoubleBufferedFileData path1; + protected DoubleBufferedFileData path2; + + public DoubleBufferedFile(final Path pathWithoutNumber, final String fileExtension) { + path1 = new DoubleBufferedFileData(Path.of(pathWithoutNumber.toString() + "1." + fileExtension)); + path2 = new DoubleBufferedFileData(Path.of(pathWithoutNumber.toString() + "2." + fileExtension)); + } + + public void delete() throws IOException { + path1.delete(); + path2.delete(); + } + + public LocalDateTime getNewestModifiedTime() { + if (path1.modified() == null && path2.modified() == null) + return null; + if (path1.modified() == null) + return path2.modified(); + if (path2.modified() == null) + return path1.modified(); + + if (path1.modified().compareTo(path2.modified()) > 0) + return path1.modified(); + return path2.modified(); + } + + public LocalDateTime getOldestModifiedTime() { + if (path1.modified() == null && path2.modified() == null) + return null; + if (path1.modified() == null) + return path2.modified(); + if (path2.modified() == null) + return path1.modified(); + + if (path1.modified().compareTo(path2.modified()) <= 0) + return path1.modified(); + return path2.modified(); + } + + private DoubleBufferedFileData getOldestForWriteAccess() throws IOException { + path1.probe(); + path2.probe(); + if (!path1.exists() && !path2.exists()) + return path1; + if (!path1.exists()) + return path1; + if (!path2.exists()) + return path2; + + if (!path1.writable() && !path2.writable()) + throw new IOException("Both files are locked for write-access."); + if (!path1.writable()) + throw new IOException("File1 is locked for write-access."); + if (!path2.writable()) + throw new IOException("File2 is locked for write-access."); + + if (path1.modified() == null || path2.modified() == null) + throw new IOException("Could not read the modified-date from one of the files."); + if (path1.modified().compareTo(path2.modified()) <= 0) + return path1; + return path2; + } + + private DoubleBufferedFileData getNewestForReadAccess() throws IOException { + path1.probe(); + path2.probe(); + if (!path1.exists() && !path2.exists()) + throw new IOException("There is no file to read from, because both files are missing."); + if (!path1.exists()) + return path2.withCheckedRead(); + if (!path2.exists()) + return path1.withCheckedRead(); + + if (!path1.readable() && !path2.readable()) + throw new IOException("Both files are locked for read-access."); + if (!path1.readable()) + throw new IOException("File1 is locked for read-access."); + if (!path2.readable()) + throw new IOException("File2 is locked for read-access."); + + if (path1.modified() == null || path2.modified() == null) + throw new IOException("Could not read the modified-date from one of the files."); + if (path1.modified().compareTo(path2.modified()) > 0) + return path1; + return path2; + } + + public void write(final ConsumerWithIoException writeContentDelegate) throws IOException { + DoubleBufferedFileData p = getOldestForWriteAccess(); + if (p.exists()) + Files.delete(p.path()); + + try (BufferedWriter writer = p.getBufferedWriter()) { + writeContentDelegate.accept(writer); + } + p.probe(); + } + + public String read() throws IOException { + DoubleBufferedFileData p = getNewestForReadAccess(); + return Files.readString(p.path()); + } +} diff --git a/src/test/java/info/unterrainer/commons/jreutils/DoubleBufferedFileTests.java b/src/test/java/info/unterrainer/commons/jreutils/DoubleBufferedFileTests.java new file mode 100644 index 0000000..e9a018a --- /dev/null +++ b/src/test/java/info/unterrainer/commons/jreutils/DoubleBufferedFileTests.java @@ -0,0 +1,110 @@ +package info.unterrainer.commons.jreutils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; + +public class DoubleBufferedFileTests { + + @Test + public void readFromPreparedFileWorks() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("test"), "txt"); + assertThat(dbf.read()).isEqualTo("test"); + } + + @Test + public void readThrowsExceptionWhenNoFilePresent() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt"); + try { + dbf.delete(); + assertThatIOException().isThrownBy(() -> dbf.read()); + } finally { + dbf.delete(); + } + } + + @Test + public void readAfterWriteReturnsSameValue() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt"); + try { + dbf.write(w -> w.write("test")); + assertThat(dbf.read()).isEqualTo("test"); + } finally { + dbf.delete(); + } + } + + @Test + public void readAfterWriteAfterWriteReturnsNewValue() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt"); + try { + dbf.write(w -> w.write("test_old")); + dbf.write(w -> w.write("test_new")); + assertThat(dbf.read()).isEqualTo("test_new"); + } finally { + dbf.delete(); + } + } + + @Test + public void modifiedNewIsNullWithoutAnyValueWritten() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt"); + LocalDateTime newest = dbf.getNewestModifiedTime(); + assertThat(newest).isNull(); + } + + @Test + public void modifiedOldIsNullWithoutAnyValueWritten() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt"); + LocalDateTime oldest = dbf.getOldestModifiedTime(); + assertThat(oldest).isNull(); + } + + @Test + public void modifiedNewAndOldAreEqualWithOneValueWritten() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt"); + try { + dbf.write(w -> w.write("test_old")); + LocalDateTime newest = dbf.getNewestModifiedTime(); + LocalDateTime oldest = dbf.getOldestModifiedTime(); + assertThat(newest).isEqualTo(oldest); + } finally { + dbf.delete(); + } + } + + @Test + public void modifiedNewAndOldDifferWithTwoValuesWritten() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt"); + try { + dbf.write(w -> w.write("test_old")); + dbf.write(w -> w.write("test_new")); + LocalDateTime newest = dbf.getNewestModifiedTime(); + LocalDateTime oldest = dbf.getOldestModifiedTime(); + assertThat(newest).isAfter(oldest); + } finally { + dbf.delete(); + } + } + + @Test + public void modifiedAfterThreeWritesReturnsNewestValue() throws IOException { + DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt"); + try { + dbf.write(w -> w.write("test_oldest")); + LocalDateTime oldest = dbf.getNewestModifiedTime(); + dbf.write(w -> w.write("test_older")); + LocalDateTime older = dbf.getNewestModifiedTime(); + dbf.write(w -> w.write("test_new")); + assertThat(dbf.getNewestModifiedTime()).isAfter(older); + assertThat(dbf.getNewestModifiedTime()).isAfter(oldest); + } finally { + dbf.delete(); + } + } +} diff --git a/test1.txt b/test1.txt new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/test1.txt @@ -0,0 +1 @@ +test \ No newline at end of file