Repository navigation
Make YAML persistence atomic #298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1c4e846
422927a
4190e34
dec3058
fc4ec79
456d53e
5886bf1
75955cf
8762a6e
0d02255
99ec2b8
0b8d1f1
a2b187a
b16defd
7b77c6d
de3397a
1fd4a62
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,312 @@ | ||
| package com.bencodez.advancedcore.api.misc.files; | ||
|
|
||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.nio.ByteBuffer; | ||
| import java.nio.channels.FileChannel; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.nio.file.AtomicMoveNotSupportedException; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.nio.file.StandardCopyOption; | ||
| import java.nio.file.StandardOpenOption; | ||
| import java.nio.file.attribute.AclFileAttributeView; | ||
| import java.nio.file.attribute.PosixFileAttributeView; | ||
| import java.nio.file.attribute.PosixFileAttributes; | ||
| import java.nio.file.attribute.UserPrincipal; | ||
| import java.util.HashSet; | ||
| import java.util.Set; | ||
| import java.util.UUID; | ||
|
|
||
| import org.bukkit.configuration.file.FileConfiguration; | ||
|
|
||
| /** | ||
| * Writes YAML data to a sibling temporary file before committing it to the | ||
| * destination. Serialization always completes before the existing file can be | ||
| * modified. Existing files whose security metadata cannot be recreated safely | ||
| * are committed through a preserved hard-link inode so the live path always | ||
| * points at a complete old or new file. | ||
| */ | ||
| public final class AtomicYamlWriter { | ||
|
|
||
| private static final int MAX_SYMLINK_DEPTH = 32; | ||
| private static final String PRESERVED_SUFFIX = ".advancedcore-preserved-inode"; | ||
|
|
||
| private AtomicYamlWriter() { | ||
| } | ||
|
|
||
| public static void save(File file, FileConfiguration data) throws IOException { | ||
| if (file == null) { | ||
| throw new IllegalArgumentException("file cannot be null"); | ||
| } | ||
| if (data == null) { | ||
| throw new IllegalArgumentException("data cannot be null"); | ||
| } | ||
|
|
||
| Path requestedTarget = file.toPath().toAbsolutePath(); | ||
| Path requestedParent = requestedTarget.getParent(); | ||
| if (requestedParent != null) { | ||
| Files.createDirectories(requestedParent); | ||
| } | ||
|
|
||
| Path target = resolveWriteTarget(requestedTarget); | ||
| Path parent = target.getParent(); | ||
| if (parent == null) { | ||
| parent = Path.of(".").toAbsolutePath(); | ||
| } | ||
| Files.createDirectories(parent); | ||
|
|
||
| Path preserved = preservedPath(target); | ||
| recoverPreservedInode(target, preserved); | ||
|
|
||
| Path temp; | ||
| try { | ||
| temp = Files.createTempFile(parent, "." + target.getFileName() + ".", ".tmp"); | ||
| } catch (IOException siblingCreationFailure) { | ||
| if (Files.exists(target) && Files.isWritable(target)) { | ||
| saveSerializedInPlace(target, data, siblingCreationFailure); | ||
| return; | ||
| } | ||
| throw siblingCreationFailure; | ||
| } | ||
|
|
||
| boolean committed = false; | ||
| try { | ||
| data.save(temp.toFile()); | ||
| if (prepareReplacementMetadata(target, temp)) { | ||
| moveReplacement(temp, target); | ||
| } else { | ||
| commitThroughPreservedInode(temp, target, preserved); | ||
| } | ||
| committed = true; | ||
| } finally { | ||
| if (!committed) { | ||
| Files.deleteIfExists(temp); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| static Path resolveWriteTarget(Path requestedTarget) throws IOException { | ||
| return resolveWriteTarget(requestedTarget.toAbsolutePath(), new HashSet<>(), 0); | ||
| } | ||
|
|
||
| private static Path resolveWriteTarget(Path current, Set<Path> visited, int depth) throws IOException { | ||
| if (depth > MAX_SYMLINK_DEPTH || !visited.add(current)) { | ||
| throw new IOException("Cyclic or excessively deep symbolic link while resolving " + current); | ||
| } | ||
|
|
||
| // If the complete path exists, let the filesystem resolve every component. | ||
| // This is important for paths such as dirlink/../target.yml: lexical | ||
| // normalization before following dirlink can select a different file. | ||
| if (Files.exists(current)) { | ||
| return current.toRealPath(); | ||
| } | ||
|
|
||
| // Files.exists follows links and therefore reports false for both a broken | ||
| // final link and a symlink cycle. Inspect the directory entry itself before | ||
| // recursing, while keeping a visited/depth guard for cycles. | ||
| if (Files.isSymbolicLink(current)) { | ||
| Path link = Files.readSymbolicLink(current); | ||
| Path linkedTarget = link.isAbsolute() ? link : current.getParent().resolve(link); | ||
| return resolveWriteTarget(linkedTarget, visited, depth + 1); | ||
| } | ||
|
|
||
| Path parent = current.getParent(); | ||
| if (parent != null && Files.exists(parent)) { | ||
| return parent.toRealPath().resolve(current.getFileName()); | ||
| } | ||
| return current; | ||
| } | ||
|
|
||
| private static boolean prepareReplacementMetadata(Path target, Path temp) throws IOException { | ||
| if (!Files.exists(target)) { | ||
| preserveNormalCreationPermissions(target, temp); | ||
| return true; | ||
|
BenCodez marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| // Java's POSIX view exposes owner/group/mode, but not Linux extended POSIX | ||
| // ACL entries. Use the preserved-inode commit path so those ACLs and other | ||
| // inode metadata survive without truncating the live destination. | ||
| PosixFileAttributeView targetPosix = Files.getFileAttributeView(target, PosixFileAttributeView.class); | ||
| if (targetPosix != null) { | ||
| applyPosixMetadataBestEffort(target, temp); | ||
| return false; | ||
| } | ||
|
|
||
| AclFileAttributeView targetAcl = Files.getFileAttributeView(target, AclFileAttributeView.class); | ||
| AclFileAttributeView tempAcl = Files.getFileAttributeView(temp, AclFileAttributeView.class); | ||
| if (targetAcl != null && tempAcl != null) { | ||
| try { | ||
| tempAcl.setAcl(targetAcl.getAcl()); | ||
| UserPrincipal targetOwner = targetAcl.getOwner(); | ||
| if (!targetOwner.equals(tempAcl.getOwner())) { | ||
| tempAcl.setOwner(targetOwner); | ||
| } | ||
| return true; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On an ACL-capable non-POSIX filesystem such as NTFS, this successful branch copies only the ACL and owner and then replaces the destination inode. Existing DOS attributes such as hidden/system/archive and user-defined attributes or alternate data streams are consequently discarded after the first save, whereas the previous in-place writer retained them. Preserve the remaining supported attribute views or use the metadata-bearing inode commit path for these destinations. Useful? React with 👍 / 👎. |
||
| } catch (IOException | SecurityException e) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| private static void preserveNormalCreationPermissions(Path target, Path temp) throws IOException { | ||
| PosixFileAttributeView tempPosix = Files.getFileAttributeView(temp, PosixFileAttributeView.class); | ||
| if (tempPosix == null) { | ||
| return; | ||
| } | ||
|
|
||
| Path parent = target.getParent(); | ||
| if (parent == null) { | ||
| return; | ||
| } | ||
| Path probe = parent.resolve("." + target.getFileName() + "." + UUID.randomUUID() + ".mode-probe"); | ||
| try { | ||
| Files.createFile(probe); | ||
| Set<java.nio.file.attribute.PosixFilePermission> permissions = Files | ||
| .readAttributes(probe, PosixFileAttributes.class).permissions(); | ||
| tempPosix.setPermissions(permissions); | ||
| } finally { | ||
| Files.deleteIfExists(probe); | ||
| } | ||
| } | ||
|
|
||
| private static void applyPosixMetadataBestEffort(Path target, Path temp) { | ||
| try { | ||
| PosixFileAttributes attributes = Files.readAttributes(target, PosixFileAttributes.class); | ||
| PosixFileAttributeView tempPosix = Files.getFileAttributeView(temp, PosixFileAttributeView.class); | ||
| if (tempPosix == null) { | ||
| return; | ||
| } | ||
| try { | ||
| tempPosix.setPermissions(attributes.permissions()); | ||
| } catch (IOException | SecurityException ignored) { | ||
| } | ||
| try { | ||
| tempPosix.setGroup(attributes.group()); | ||
| } catch (IOException | SecurityException ignored) { | ||
| } | ||
| try { | ||
| if (!attributes.owner().equals(tempPosix.getOwner())) { | ||
| tempPosix.setOwner(attributes.owner()); | ||
| } | ||
| } catch (IOException | SecurityException ignored) { | ||
| } | ||
| } catch (IOException | SecurityException ignored) { | ||
| } | ||
| } | ||
|
|
||
| private static Path preservedPath(Path target) { | ||
| return target.getParent().resolve("." + target.getFileName() + PRESERVED_SUFFIX); | ||
| } | ||
|
|
||
| private static void recoverPreservedInode(Path target, Path preserved) throws IOException { | ||
| if (!Files.exists(preserved)) { | ||
| return; | ||
| } | ||
| if (Files.isSymbolicLink(preserved)) { | ||
| throw new IOException("Refusing to follow preserved-inode symlink: " + preserved); | ||
| } | ||
| if (!Files.exists(target)) { | ||
| moveReplacement(preserved, target); | ||
| return; | ||
| } | ||
| if (Files.isSameFile(target, preserved)) { | ||
| Files.delete(preserved); | ||
| return; | ||
| } | ||
|
|
||
| copyCompleteContents(target, preserved); | ||
| moveReplacement(preserved, target); | ||
| } | ||
|
|
||
| private static void commitThroughPreservedInode(Path temp, Path target, Path preserved) throws IOException { | ||
| Files.deleteIfExists(preserved); | ||
| Files.createLink(preserved, target); | ||
|
Comment on lines
+225
to
+226
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two threads or server processes save the same resolved YAML concurrently, both transactions use this single fixed preserved-inode path, so one can delete or relink the other transaction's backup while its subsequent copy and move operations are still running. The interleaved operations can then copy into the wrong inode, lose the destination's metadata, throw after partially completing a save, or produce inconsistent contents; use destination-scoped cross-process synchronization or transaction-unique sidecars with ownership validation. Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — this is valid and is not fixed on current head There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an existing YAML resides on a POSIX-view filesystem that does not support hard links, such as some network or virtual mounts, every save reaches this call and fails even though creating the sibling temporary file and replacing the destination may be supported. Because Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — this is valid and is not fixed on current head |
||
| boolean livePathReplaced = false; | ||
| try { | ||
| // The fully serialized temp becomes the live path first. If any later copy | ||
| // into the preserved inode fails, the live path still contains a complete | ||
| // new YAML rather than a truncated file. | ||
| moveReplacement(temp, target); | ||
| livePathReplaced = true; | ||
|
|
||
| // Refill the preserved original inode while it is off-path, then atomically | ||
| // restore that inode to the live path so POSIX ACLs/ownership/xattrs survive. | ||
| copyCompleteContents(target, preserved); | ||
| moveReplacement(preserved, target); | ||
| } catch (IOException | RuntimeException e) { | ||
| if (!livePathReplaced) { | ||
| Files.deleteIfExists(preserved); | ||
| } | ||
| // Once the live path has been replaced, leave the preserved inode in place | ||
| // on failure. A later save will repair it from the complete live file before | ||
| // attempting another commit. | ||
| throw e; | ||
| } | ||
| } | ||
|
|
||
| private static void saveSerializedInPlace(Path target, FileConfiguration data, IOException siblingCreationFailure) | ||
| throws IOException { | ||
| byte[] replacement = data.saveToString().getBytes(StandardCharsets.UTF_8); | ||
| byte[] original = null; | ||
| try { | ||
| original = Files.readAllBytes(target); | ||
| } catch (IOException | SecurityException ignored) { | ||
| // The legacy save path required only write access. Preserve that compatibility | ||
| // even when this process cannot read the old file for rollback. | ||
| } | ||
|
|
||
| try { | ||
| writeCompleteBytes(target, replacement); | ||
| } catch (IOException writeFailure) { | ||
| writeFailure.addSuppressed(siblingCreationFailure); | ||
| if (original != null) { | ||
| try { | ||
| writeCompleteBytes(target, original); | ||
| } catch (IOException rollbackFailure) { | ||
| writeFailure.addSuppressed(rollbackFailure); | ||
| } | ||
| } | ||
| throw writeFailure; | ||
| } | ||
| } | ||
|
|
||
| private static void writeCompleteBytes(Path target, byte[] bytes) throws IOException { | ||
| try (FileChannel output = FileChannel.open(target, StandardOpenOption.WRITE)) { | ||
| ByteBuffer buffer = ByteBuffer.wrap(bytes); | ||
| output.position(0L); | ||
| while (buffer.hasRemaining()) { | ||
| output.write(buffer); | ||
| } | ||
| output.truncate(output.position()); | ||
| output.force(true); | ||
| } | ||
| } | ||
|
|
||
| private static void copyCompleteContents(Path source, Path destination) throws IOException { | ||
| try (FileChannel input = FileChannel.open(source, StandardOpenOption.READ); | ||
| FileChannel output = FileChannel.open(destination, StandardOpenOption.WRITE)) { | ||
| ByteBuffer buffer = ByteBuffer.allocateDirect(64 * 1024); | ||
| output.position(0L); | ||
| while (input.read(buffer) != -1) { | ||
| buffer.flip(); | ||
| while (buffer.hasRemaining()) { | ||
| output.write(buffer); | ||
| } | ||
| buffer.clear(); | ||
| } | ||
| output.truncate(output.position()); | ||
| output.force(true); | ||
| } | ||
| } | ||
|
|
||
| private static void moveReplacement(Path source, Path target) throws IOException { | ||
| try { | ||
| Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); | ||
| } catch (AtomicMoveNotSupportedException e) { | ||
| Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the host loses power after
moveReplacement(temp, target)but beforecopyCompleteContentsforces the preserved inode,data.save(...)has only closed the temporary file and has not forced its contents to stable storage. The rename can therefore survive while some YAML data blocks do not, leaving the live path empty or partial; on the next save,recoverPreservedInodecopies that damaged path over the known-good preserved inode. Force the serialized temporary file before the first move, and durably sync the containing directory where supported.Useful? React with 👍 / 👎.