C# store: a write at a link is refused before the lock and replace; a live directory link says directory
Some checks failed
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-12 22:01:37 +00:00
commit 2bb1b05ca3
4 changed files with 147 additions and 23 deletions

View file

@ -5132,7 +5132,7 @@ namespace GameCult.Caching
var attributes = FileInfo.Attributes;
if ((int)attributes == -1)
return null;
if ((attributes & FileAttributes.Directory) != 0 && (attributes & FileAttributes.ReparsePoint) == 0)
if ((attributes & FileAttributes.Directory) != 0 && Directory.Exists(FileInfo.FullName))
throw new IOException("The store path is a directory, not a store file.");
try
{
@ -5162,9 +5162,19 @@ namespace GameCult.Caching
return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0;
}
// A write never goes through a link at the store path: replacing it would silently detach the store from its link. Every
// write takes the lock first, so this is the one place a link is refused, before the lock file or anything else is made.
private void RefuseLinkedStore()
{
if (PathIsALink())
throw new CultStoreLinkedException(
$"The store path {FileInfo.FullName} is a link; a write is refused so it does not replace the link. Configure the link's target as the store path to write to it.");
}
// The lock is a sidecar opened exclusively, which excludes other handles in this process and in others alike.
private FileStream? AcquireLock(bool wait)
{
RefuseLinkedStore();
Directory.CreateDirectory(FileInfo.DirectoryName!);
var lockPath = FileInfo.FullName + ".lock";
var started = Stopwatch.StartNew();

View file

@ -0,0 +1,17 @@
using System.IO;
namespace GameCult.Caching
{
/// <summary>
/// A write was attempted at a single-file store path that is a link (a symbolic link to a file or a directory, a Windows
/// junction or any other reparse-point link). The write is refused before the lock, the staging file or the replace, so the
/// link and what it points at are untouched. Configure the link's target as the store path to write to it; reads through a
/// live link are unchanged. <see cref="IOException.Message"/> names the path.
/// </summary>
public sealed class CultStoreLinkedException : IOException
{
public CultStoreLinkedException(string message) : base(message)
{
}
}
}

View file

@ -266,7 +266,7 @@ namespace GameCult.Caching.Tests
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
CreateDanglingDirectoryLink(path, Path.Combine(_directory, "unmounted"));
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf<IOException>());
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf<CultStoreLinkedException>());
Assert.That(IsALink(path), "the link was replaced");
Assert.That(Directory.Exists(Path.Combine(_directory, "unmounted")), Is.False, "a write went through the link");
}
@ -292,11 +292,66 @@ namespace GameCult.Caching.Tests
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
File.Delete(target);
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf<IOException>());
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf<CultStoreLinkedException>());
Assert.That(new FileInfo(path).LinkTarget, Is.Not.Null, "the link was replaced");
Assert.That(File.Exists(target), Is.False, "a write went through the link");
}
// A write at a store path that is a link is refused with a typed error before the lock or the replace: the link, its target
// and the directory beside them are as they were.
[Test]
public void ACacheCommitAtALiveLinkIsRefusedAndNothingChanges()
{
var target = Path.Combine(_directory, "target.cc");
var link = Path.Combine(_directory, "link.cc");
using (var seed = CultCacheMessagePack.Create(target, new CultCacheOpenOptions { Registry = Registry }))
seed.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "a" }, new CultRecordKey("a")));
CreateFileLinkOrIgnore(link, target);
var before = File.ReadAllBytes(target);
using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false });
var refusal = Assert.Throws<CultStoreLinkedException>(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "b" }, new CultRecordKey("b"))))!;
Assert.That(refusal.Message, Does.Contain(link));
Assert.That(IsALink(link), "the link was replaced");
Assert.That(File.ReadAllBytes(target), Is.EqualTo(before));
Assert.That(File.Exists(link + ".lock"), Is.False, "the refused write took the lock");
Assert.That(Directory.GetFileSystemEntries(_directory).Where(entry => entry.EndsWith(".tmp")), Is.Empty);
}
[Test]
public void AFlushAtALiveLinkIsRefusedAndNothingChanges()
{
var target = Path.Combine(_directory, "target.cc");
var link = Path.Combine(_directory, "link.cc");
using (var seed = CultCacheMessagePack.Create(target, new CultCacheOpenOptions { Registry = Registry }))
seed.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "a" }, new CultRecordKey("a")));
CreateFileLinkOrIgnore(link, target);
var before = File.ReadAllBytes(target);
using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false });
cache.UpsertAsync(typeof(IdDeck), new IdDeck { Name = "b" }, new CultRecordKey("b")).GetAwaiter().GetResult();
Assert.Throws<CultStoreLinkedException>(() => cache.FlushAllBackingStores());
Assert.That(IsALink(link), "the link was replaced");
Assert.That(File.ReadAllBytes(target), Is.EqualTo(before));
Assert.That(File.Exists(link + ".lock"), Is.False);
}
[Test]
public void AnOpenAtALiveLinkReadsTheTargetsRecords()
{
var target = Path.Combine(_directory, "target.cc");
var link = Path.Combine(_directory, "link.cc");
using (var seed = CultCacheMessagePack.Create(target, new CultCacheOpenOptions { Registry = Registry }))
seed.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "a" }, new CultRecordKey("a")));
CreateFileLinkOrIgnore(link, target);
using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false });
Assert.That(cache.Get<IdDeck>(new CultRecordKey("a"))?.Name, Is.EqualTo("a"));
}
// A directory at the path is something at the path: it is an I/O error on open and on commit, never an empty store, and
// it is left as it was.
[Test]

View file

@ -434,32 +434,74 @@ public sealed class CultMeshSingleFileStoreTests
TempFiles().Should().BeEmpty();
}
// The store's replace decides what a write leaves where a live link was, and a Mesh write leaves what a cache commit leaves:
// one replace, whatever the platform does with a link.
[Test]
public void AMeshWriteOverALiveLinkLeavesWhatACacheCommitLeaves()
// A write at a store path that is a link is refused before the lock, the staging file and the replace: the link and its
// target stay as they were, and nothing is left beside them.
private void AssertRefusedAndUntouched(string link, string target, byte[] targetBefore, Action write)
{
string Outcome(string name, Action<string> write)
var refusal = Assert.Throws<CultStoreLinkedException>(() => write())!;
refusal.Message.Should().Contain(link);
IsALink(link).Should().BeTrue("the link was replaced");
File.ReadAllBytes(target).Should().Equal(targetBefore);
File.Exists(link + ".lock").Should().BeFalse("the refused write took the lock");
TempFiles().Should().BeEmpty();
}
[Test]
public void AMeshWriteAtALiveLinkIsRefusedAndNothingChanges()
{
var target = Path.Combine(_root, "target.cc");
var link = Path.Combine(_root, "link.cc");
CultMesh.WriteSingleFileDocument(target, Key, Publication("first"));
CreateLiveLinkOrIgnore(link, target);
var before = File.ReadAllBytes(target);
AssertRefusedAndUntouched(link, target, before, () => CultMesh.WriteSingleFileDocument(link, Key, Publication("second")));
AssertRefusedAndUntouched(link, target, before, () => CultMesh.WriteSingleFileDocumentPayload(link, Key, Raw, null, EmptyArray));
}
[Test]
public void AReadThroughALiveLinkStillWorks()
{
var target = Path.Combine(_root, "target.cc");
var link = Path.Combine(_root, "link.cc");
CultMesh.WriteSingleFileDocument(target, Key, Publication("first"));
CreateLiveLinkOrIgnore(link, target);
CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(link, Key).ProducerId.Should().Be("first");
RecordsSeenByACache(link).Should().Be(1);
}
// A link to a directory that is there is a directory, not an unreachable store; a write at it is a link refusal.
[Test]
public void ALiveDirectoryLinkIsADirectoryErrorOnReadAndALinkRefusalOnWrite()
{
var directory = Path.Combine(_root, "real-directory");
var link = Path.Combine(_root, "directory-link.cc");
Directory.CreateDirectory(directory);
CreateDirectoryLink(link, directory);
Assert.Catch<IOException>(() => CultMesh.ReadSingleFileDocumentPayload(link, Key, "raw:schema"))!.Message.Should().Contain("directory");
Assert.Catch<IOException>(() => RecordsSeenByACache(link))!.Message.Should().Contain("directory");
Assert.Throws<CultStoreLinkedException>(() => CultMesh.WriteSingleFileDocument(link, Key, Publication()));
Directory.GetFileSystemEntries(directory).Should().BeEmpty();
IsALink(link).Should().BeTrue();
}
private static void CreateDirectoryLink(string path, string target)
{
if (!OperatingSystem.IsWindows())
{
var target = Path.Combine(_root, name + "-target.cc");
var link = Path.Combine(_root, name + "-link.cc");
CultMesh.WriteSingleFileDocument(target, Key, Publication("first"));
CreateLiveLinkOrIgnore(link, target);
var before = File.ReadAllBytes(target);
write(link);
CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(link, Key).ProducerId.Should().Be("second", name);
return $"linkKept={IsALink(link)} targetChanged={!File.ReadAllBytes(target).SequenceEqual(before)}";
Directory.CreateSymbolicLink(path, target);
return;
}
var viaMesh = Outcome("mesh", link => CultMesh.WriteSingleFileDocument(link, Key, Publication("second")));
var viaCache = Outcome("cache", link =>
var mklink = Process.Start(new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{path}\" \"{target}\"")
{
using var cache = CultCacheMessagePack.Create(link, ReadOnlyOpen);
cache.Commit(batch => batch.Upsert(typeof(CultMeshBodyPublicationDocument), Publication("second"), Key));
});
viaMesh.Should().Be(viaCache);
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
})!;
mklink.WaitForExit();
Assert.That(mklink.ExitCode, Is.Zero, "mklink /J failed");
}
}