Preserve dirty CultCache state during pulls

This commit is contained in:
Meta Vens 2026-07-18 23:19:45 +01:00
commit 49c43baa4c
15 changed files with 117 additions and 5 deletions

View file

@ -14,7 +14,7 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>© Meta Vens and contributors</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<Version>1.0.17</Version>
<Version>1.0.18</Version>
<!-- Symbols/debugging -->
<EmbedUntrackedSources>true</EmbedUntrackedSources>

View file

@ -75,6 +75,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
foreach (var pair in loaded)
{
if (_dirtyKeys.ContainsKey(pair.Key) || _deletedKeys.ContainsKey(pair.Key))
continue;
if (Entries.TryGetValue(pair.Key, out var existing) &&
string.Equals(existing.StoredAt, pair.Value.StoredAt, StringComparison.Ordinal) &&
string.Equals(existing.Descriptor.SchemaId, pair.Value.Descriptor.SchemaId, StringComparison.Ordinal))
@ -91,14 +94,15 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
foreach (var removedKey in Entries.Keys.Where(key => !loaded.ContainsKey(key)).ToArray())
{
if (_dirtyKeys.ContainsKey(removedKey) || _deletedKeys.ContainsKey(removedKey))
continue;
if (Entries.TryRemove(removedKey, out var removed))
EntryDeleted.OnNext(removed);
}
SetLastSchemaMigrationReports(reports);
_dirtyKeys.Clear();
_deletedKeys.Clear();
IsDirty = false;
IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty;
}
/// <inheritdoc />

View file

@ -2092,6 +2092,13 @@ namespace GameCult.Caching
return;
}
// A single-file snapshot cannot distinguish local dirty keys from clean keys.
// Pulling while local mutations are staged would let an older disk snapshot
// erase those mutations before the next flush. Flush first; clean readers can
// still poll external snapshots incrementally.
if (IsDirty)
return;
CultPersistedStoreSnapshot snapshot;
try
{

View file

@ -107,6 +107,53 @@ namespace GameCult.Caching.Tests
}
}
[Test]
public async Task SingleFileMessagePackBackingStore_PullAll_DoesNotEraseUnflushedLocalMutations()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack");
try
{
using (var seed = await CultCacheMessagePack.OpenAsync(filePath))
{
await seed.UpsertAsync(new NamedTestEntry
{
Name = "persisted",
Value = "before-local-write"
});
await seed.FlushAsync();
}
CultRecordHandle<NamedTestEntry> localHandle;
using (var cache = await CultCacheMessagePack.OpenAsync(filePath))
{
localHandle = await cache.UpsertAsync(new NamedTestEntry
{
Name = "local",
Value = "must-survive-pull"
});
Assert.That(cache.IsDirty, Is.True);
await cache.PullAllBackingStoresAsync();
Assert.That(cache.Get<NamedTestEntry>(localHandle.Key)?.Value,
Is.EqualTo("must-survive-pull"));
Assert.That(cache.IsDirty, Is.True,
"pulling a clean disk snapshot must not pardon staged local mutations");
await cache.FlushAsync();
}
using var reopened = await CultCacheMessagePack.OpenAsync(filePath);
Assert.That(reopened.Get<NamedTestEntry>(localHandle.Key)?.Value,
Is.EqualTo("must-survive-pull"));
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Test]
public async Task CultCache_DirtyState_Tracks_Mutations_And_ExplicitFlush()
{
@ -425,6 +472,60 @@ namespace GameCult.Caching.Tests
}
}
[Test]
public async Task DirectoryMessagePackBackingStore_PullAll_PreservesUnflushedLocalKeys()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
try
{
using (var seed = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true }))
{
await seed.UpsertAsync(new NamedTestEntry
{
Name = "persisted",
Value = "before-local-write"
});
await seed.FlushAsync();
}
CultRecordHandle<NamedTestEntry> localHandle;
using (var cache = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true }))
{
localHandle = await cache.UpsertAsync(new NamedTestEntry
{
Name = "local",
Value = "must-survive-pull"
});
await cache.PullAllBackingStoresAsync();
Assert.That(cache.Get<NamedTestEntry>(localHandle.Key)?.Value,
Is.EqualTo("must-survive-pull"));
Assert.That(cache.IsDirty, Is.True);
await cache.FlushAsync();
}
using var reopened = await CultCacheMessagePack.OpenAsync(
filePath,
new CultCacheOpenOptions { UseDirectoryStore = true });
Assert.That(reopened.Get<NamedTestEntry>(localHandle.Key)?.Value,
Is.EqualTo("must-survive-pull"));
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
if (Directory.Exists(recordsPath))
Directory.Delete(recordsPath, recursive: true);
}
}
[Test]
public async Task DirectoryMessagePackBackingStore_Remains_Dirty_When_Deletion_Fails()
{

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.cultlib",
"displayName": "CultLib",
"version": "1.0.17",
"version": "1.0.18",
"unity": "2021.3",
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
"license": "MIT",