Soft flush: pin the host and node passthroughs with a held record (absent writes, readable skips, unreadable refuses); XML-doc the one meaning on the three FlushAsync(bool soft) overloads
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 23:23:31 +00:00
commit 8df0c4b25a
4 changed files with 77 additions and 2 deletions

View file

@ -2547,6 +2547,11 @@ namespace GameCult.Caching
return true;
});
/// <summary>
/// Flushes every dirty backing store. With <paramref name="soft"/> the flush has one meaning, decided by each store's
/// own read: a store with nothing at its path is written; a store that reads (empty included) is skipped and its
/// staged writes stay staged; a store that cannot be read refuses with <see cref="CultStoreUnreadableException"/>.
/// </summary>
public Task FlushAsync(bool soft = false)
{
FlushAllBackingStores(soft);

View file

@ -428,7 +428,10 @@ namespace GameCult.Mesh
}
/// <summary>
/// Flushes the durable cache.
/// Flushes the durable cache. With <paramref name="soft"/> the flush has one meaning, decided by the store's own
/// read and never by what the cache holds in memory: a store with nothing at its path is written; a store that reads
/// (empty included) is skipped and its staged writes stay staged; a store that cannot be read refuses with
/// <see cref="CultStoreUnreadableException"/> and is left untouched.
/// </summary>
public Task FlushAsync(bool soft = false)
{

View file

@ -96,7 +96,10 @@ namespace GameCult.Networking
}
/// <summary>
/// Flushes the durable cache.
/// Flushes the durable cache. With <paramref name="soft"/> the flush has one meaning, decided by the store's own
/// read and never by what the cache holds in memory: a store with nothing at its path is written; a store that reads
/// (empty included) is skipped and its staged writes stay staged; a store that cannot be read refuses with
/// <see cref="CultStoreUnreadableException"/> and is left untouched.
/// </summary>
public Task FlushAsync(bool soft = false)
{

View file

@ -7490,6 +7490,70 @@ namespace GameCult.Networking.Tests
Assert.That(File.ReadAllBytes(cachePath), Is.Not.EqualTo(before), "a plain node flush after a skipped soft flush did not land the staged write");
}
// The cache HOLDS a record in every step: a passthrough that decides soft from the cache's memory instead of
// handing the store the flag fails the absent step (it skips and writes nothing) or the unreadable step (it
// never reaches the reader that refuses).
[Test]
public async Task CultNetLocal_Host_SoftFlush_With_A_Held_Record_Writes_Absent_Skips_Readable_Refuses_Unreadable()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-soft-held-{Guid.NewGuid():N}.msgpack");
CultNetHost? host = null;
try
{
host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions { StartServer = false });
await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "First" });
Assert.That(File.Exists(filePath), Is.False);
await host.FlushAsync(soft: true);
Assert.That(File.Exists(filePath), Is.True, "a soft host flush over an absent store, with a record held, wrote nothing");
var written = File.ReadAllBytes(filePath);
await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "Second" });
await host.FlushAsync(soft: true);
Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(written), "a soft host flush rewrote a readable store");
var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 };
File.WriteAllBytes(filePath, junk);
Assert.That(async () => await host.FlushAsync(soft: true), Throws.InstanceOf<CultStoreUnreadableException>());
Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(junk));
}
finally
{
host?.Dispose();
if (File.Exists(filePath)) File.Delete(filePath);
}
}
[Test]
public async Task CultMeshNode_SoftFlush_With_A_Held_Record_Writes_Absent_Skips_Readable_Refuses_Unreadable()
{
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-soft-held", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(rootPath);
var cachePath = Path.Combine(rootPath, "world.ccmp");
var documents = new CultNetDocumentRegistry()
.Register(CultNetDocumentBinding.ForDocument<MeshQuickstartNote>());
using var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions
{
StartServer = false,
DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "soft-held-local", DocumentRegistry = documents }
});
await node.Database.PutAsync(new CultRecordKey("note:one"), new MeshQuickstartNote { NoteId = "note:one", Body = "one" });
Assert.That(File.Exists(cachePath), Is.False);
await node.FlushAsync(soft: true);
Assert.That(File.Exists(cachePath), Is.True, "a soft node flush over an absent store, with a record held, wrote nothing");
var written = File.ReadAllBytes(cachePath);
await node.Database.PutAsync(new CultRecordKey("note:two"), new MeshQuickstartNote { NoteId = "note:two", Body = "two" });
await node.FlushAsync(soft: true);
Assert.That(File.ReadAllBytes(cachePath), Is.EqualTo(written), "a soft node flush rewrote a readable store");
var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 };
File.WriteAllBytes(cachePath, junk);
Assert.That(async () => await node.FlushAsync(soft: true), Throws.InstanceOf<CultStoreUnreadableException>());
Assert.That(File.ReadAllBytes(cachePath), Is.EqualTo(junk));
}
[Test]
public async Task CultNetLocal_CreateHostAsync_Wires_Durable_Cache_Without_Starting_Server()
{