C2a: mark the header by content, refuse an element twice in a list, route CultMesh single-file through the id rule
A store is written v3 (v5 for a directory) only when a record in it holds a non-empty element id. CultElementIds.Plan reports that as IdPlan.HoldsIds; the admission stamps it on the stored record and every store reads it. The descriptor's CarriesElementIds is deleted. A file already marked on disk stays marked. CultPersistedStoreSnapshot.FormatFor is the one header decision. Plan refuses one element object sitting twice in a list at write, naming the record and list, instead of writing both ids and refusing the store at load. CultMesh single-file write mints, refuses and marks as a cache write does; the read applies the load-time plan, so a write and a load refuse the same things. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e85b201bd2
commit
490f7b811f
7 changed files with 326 additions and 41 deletions
|
|
@ -288,7 +288,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
|
|||
|
||||
// A manifest already marked stays marked: pages it names may hold ids this cache cannot read.
|
||||
var carriesIds = string.Equals(currentManifest.FormatVersion, IndexedFormatVersionWithIds, StringComparison.Ordinal) ||
|
||||
keysToWrite.Any(key => Entries.TryGetValue(key, out var written) && written.Descriptor.CarriesElementIds);
|
||||
keysToWrite.Any(key => Entries.TryGetValue(key, out var written) && written.HoldsIds);
|
||||
WriteManifest(targetCatalog, currentIndex.Values
|
||||
.OrderBy(record => record.Key, StringComparer.Ordinal)
|
||||
.ToArray(), carriesIds);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,13 @@ namespace GameCult.Caching
|
|||
public const string FormatV3 = "cultcache.store.v3";
|
||||
|
||||
public string FormatVersion { get; set; } = FormatV1;
|
||||
|
||||
// The one header decision. v3 when the store holds an element id: a reader older than ids refuses the header, because it
|
||||
// would skip the id slots and rewrite the elements without them. Otherwise v2 exactly when it holds a variant, and a store
|
||||
// with neither stays byte-identical v1. A file already marked stays marked; the writer that knows that says so.
|
||||
internal static string FormatFor(bool holdsIds, bool holdsVariants) =>
|
||||
holdsIds ? FormatV3 : holdsVariants ? FormatV2 : FormatV1;
|
||||
|
||||
public CultSchemaCatalogEntry[] SchemaCatalog { get; set; } = Array.Empty<CultSchemaCatalogEntry>();
|
||||
public CultPersistedRecord[] Records { get; set; } = Array.Empty<CultPersistedRecord>();
|
||||
}
|
||||
|
|
@ -211,9 +218,6 @@ namespace GameCult.Caching
|
|||
internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; }
|
||||
internal IReadOnlyList<CultDocumentRegistry.PersistedMember> RichMembers { get; }
|
||||
|
||||
// True when the type reaches an object list: what it writes can hold element ids, so a store holding it carries the id marker.
|
||||
public bool CarriesElementIds { get; internal set; }
|
||||
|
||||
// The public read surface (CultNet selection cut 1, section 6): declared addressing, and the
|
||||
// three ways to read a value through it. GameCult.Networking has no InternalsVisibleTo here on
|
||||
// purpose - this is the only door, so nothing outside the cache decides what a member means.
|
||||
|
|
@ -383,8 +387,12 @@ namespace GameCult.Caching
|
|||
// True while this record holds element ids minted when it loaded, that no write has persisted (see MintElementIds).
|
||||
internal bool IdsInMemoryOnly { get; set; }
|
||||
|
||||
internal CultStoredDocument Resolved(object document, CultVariantDelta delta, bool idsInMemoryOnly) =>
|
||||
new(Key, StoredAt, Descriptor, delta, document) { IdsInMemoryOnly = idsInMemoryOnly };
|
||||
// True when some element this record persists holds a non-empty id: decided where ids are planned (an admission), and the
|
||||
// only thing a store reads to mark its header. A variant holds ids when an override value does; its base's ids are the base's.
|
||||
public bool HoldsIds { get; internal set; }
|
||||
|
||||
internal CultStoredDocument Resolved(object document, CultVariantDelta delta, bool idsInMemoryOnly, bool holdsIds) =>
|
||||
new(Key, StoredAt, Descriptor, delta, document) { IdsInMemoryOnly = idsInMemoryOnly, HoldsIds = holdsIds };
|
||||
}
|
||||
|
||||
public sealed class CultDocumentRegistry
|
||||
|
|
@ -678,10 +686,7 @@ namespace GameCult.Caching
|
|||
nameMember?.GetterNullable,
|
||||
indexAccessors,
|
||||
descriptorMembers,
|
||||
members)
|
||||
{
|
||||
CarriesElementIds = ids.CarriesIds
|
||||
};
|
||||
members);
|
||||
}
|
||||
|
||||
private static string BuildSemanticFingerprint(
|
||||
|
|
@ -2379,6 +2384,8 @@ namespace GameCult.Caching
|
|||
.Where(stored => stored.Variant == null)
|
||||
.Select(stored => (Stored: stored, Plan: CultElementIds.Plan(stored.Document, stored.Key.Value, deterministic: source != null)))
|
||||
.ToArray();
|
||||
foreach (var (stored, idPlan) in idPlans)
|
||||
stored.HoldsIds = idPlan.HoldsIds;
|
||||
VariantPlan plan;
|
||||
CultCommitOutcome outcome;
|
||||
try
|
||||
|
|
@ -2388,6 +2395,10 @@ namespace GameCult.Caching
|
|||
// The one resolution: a variant admitted, a base admitted or evicted, all resolved here against the post-batch
|
||||
// set, before land and before memory. land receives the admitted records with variants resolved.
|
||||
plan = Resolve(admitted, evicted, source);
|
||||
// A variant's own record is resolved into a copy; the record a loading store keeps is the original.
|
||||
var resolvedByKey = plan.Admitted.ToDictionary(resolved => resolved.Key.Value, StringComparer.Ordinal);
|
||||
foreach (var stored in admitted.Where(stored => stored.Variant != null))
|
||||
stored.HoldsIds = resolvedByKey[stored.Key.Value].HoldsIds;
|
||||
outcome = land(home, plan.Admitted);
|
||||
}
|
||||
catch
|
||||
|
|
@ -2665,6 +2676,7 @@ namespace GameCult.Caching
|
|||
var kept = new List<KeyValuePair<int, byte[]>>();
|
||||
var overrides = new List<CultVariantOverride>(delta.Overrides.Count);
|
||||
var minted = false;
|
||||
var holdsIds = false;
|
||||
var seen = new HashSet<int>();
|
||||
foreach (var entry in delta.Overrides)
|
||||
{
|
||||
|
|
@ -2697,6 +2709,7 @@ namespace GameCult.Caching
|
|||
// decoded copy, and the delta this hold lands carries them.
|
||||
var bytes = entry.Value;
|
||||
var idPlan = CultElementIds.Plan(value, key + "." + slot.ToString(CultureInfo.InvariantCulture), deterministic: source != null);
|
||||
holdsIds |= idPlan.HoldsIds;
|
||||
if (idPlan.Count > 0)
|
||||
{
|
||||
idPlan.Apply();
|
||||
|
|
@ -2729,8 +2742,8 @@ namespace GameCult.Caching
|
|||
|
||||
stack.RemoveAt(stack.Count - 1);
|
||||
return done[key] = isVariant
|
||||
? variant.Resolved(document, minted ? new CultVariantDelta(delta.BaseKey, overrides) : delta, variant.IdsInMemoryOnly || (minted && source != null))
|
||||
: new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) { Flattens = true };
|
||||
? variant.Resolved(document, minted ? new CultVariantDelta(delta.BaseKey, overrides) : delta, variant.IdsInMemoryOnly || (minted && source != null), holdsIds)
|
||||
: new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) { Flattens = true, HoldsIds = variant.HoldsIds };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3224,7 +3237,7 @@ namespace GameCult.Caching
|
|||
WriteSnapshot(
|
||||
Entries.Values.Select(entry => ToPersistedRecord(entry, SerializePayload)),
|
||||
Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()),
|
||||
Entries.Values.Any(entry => entry.Descriptor.CarriesElementIds));
|
||||
Entries.Values.Any(entry => entry.HoldsIds));
|
||||
Cache?.IdsPersisted(Entries.Keys);
|
||||
}
|
||||
|
||||
|
|
@ -3268,11 +3281,12 @@ namespace GameCult.Caching
|
|||
records[entry.Key.Value] = ToPersistedRecord(entry, SerializePayload);
|
||||
|
||||
// A file already marked stays marked: it may hold records this cache cannot read, and a rewrite must not shed the marker.
|
||||
var carriesIds = request.Upserts.Any(entry => entry.Descriptor.CarriesElementIds) ||
|
||||
var replaced = request.Upserts.Concat(request.Deletes).Select(entry => entry.Key.Value).ToHashSet(StringComparer.Ordinal);
|
||||
var holdsIds = request.Upserts.Any(entry => entry.HoldsIds) ||
|
||||
(ontoDisk
|
||||
? string.Equals(disk.FormatVersion, CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal)
|
||||
: Entries.Values.Any(entry => entry.Descriptor.CarriesElementIds));
|
||||
WriteSnapshot(records.Values, catalog.Concat(request.Upserts.Select(entry => entry.Descriptor.ToCatalogEntry())), carriesIds);
|
||||
: Entries.Values.Any(entry => entry.HoldsIds && !replaced.Contains(entry.Key.Value)));
|
||||
WriteSnapshot(records.Values, catalog.Concat(request.Upserts.Select(entry => entry.Descriptor.ToCatalogEntry())), holdsIds);
|
||||
if (!ontoDisk)
|
||||
Cache?.IdsPersisted(records.Keys);
|
||||
foreach (var entry in request.Deletes)
|
||||
|
|
@ -3327,20 +3341,13 @@ namespace GameCult.Caching
|
|||
}
|
||||
}
|
||||
|
||||
private void WriteSnapshot(IEnumerable<CultPersistedRecord> records, IEnumerable<CultSchemaCatalogEntry> catalog, bool carriesElementIds)
|
||||
private void WriteSnapshot(IEnumerable<CultPersistedRecord> records, IEnumerable<CultSchemaCatalogEntry> catalog, bool holdsElementIds)
|
||||
{
|
||||
var ordered = records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray();
|
||||
var used = new HashSet<string>(ordered.Select(record => record.SchemaId), StringComparer.Ordinal);
|
||||
var snapshot = new CultPersistedStoreSnapshot
|
||||
{
|
||||
// v3 when the store can hold element ids: a reader older than ids refuses the header, because it would skip the id
|
||||
// slots and rewrite the elements without them. Otherwise v2 exactly when the store holds a variant, and a store
|
||||
// with neither stays byte-identical v1.
|
||||
FormatVersion = carriesElementIds
|
||||
? CultPersistedStoreSnapshot.FormatV3
|
||||
: ordered.Any(record => record.Variant != null)
|
||||
? CultPersistedStoreSnapshot.FormatV2
|
||||
: CultPersistedStoreSnapshot.FormatV1,
|
||||
FormatVersion = CultPersistedStoreSnapshot.FormatFor(holdsElementIds, ordered.Any(record => record.Variant != null)),
|
||||
SchemaCatalog = catalog
|
||||
.GroupBy(entry => entry.SchemaId, StringComparer.Ordinal)
|
||||
.Select(group => group.First())
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ namespace GameCult.Caching
|
|||
|
||||
public int Count => _fills.Count;
|
||||
|
||||
// True when some element under the root holds a non-empty id once the plan is applied: one already there, or one this
|
||||
// plan fills. It decides whether the store that writes the root carries the v3 header.
|
||||
public bool HoldsIds { get; internal set; }
|
||||
|
||||
internal void Add(object item, Shape shape, string? before, string id) => _fills.Add((item, shape, before, id));
|
||||
|
||||
public void Apply()
|
||||
|
|
@ -95,13 +99,9 @@ namespace GameCult.Caching
|
|||
internal sealed class Survey
|
||||
{
|
||||
public List<string> Problems { get; } = new();
|
||||
|
||||
// True when the type reaches an object list: what it writes can hold element ids.
|
||||
public bool CarriesIds { get; set; }
|
||||
}
|
||||
|
||||
// Every reason this document type cannot be registered under the id rule, one per offending concrete element type,
|
||||
// and whether it reaches any element list at all.
|
||||
// Every reason this document type cannot be registered under the id rule, one per offending concrete element type.
|
||||
internal static Survey Inspect(Type documentType)
|
||||
{
|
||||
var survey = new Survey();
|
||||
|
|
@ -134,7 +134,6 @@ namespace GameCult.Caching
|
|||
{
|
||||
if (IsObjectType(element))
|
||||
{
|
||||
survey.CarriesIds = true;
|
||||
foreach (var concrete in ConcreteTypes(element))
|
||||
{
|
||||
var problem = IdProblem(concrete);
|
||||
|
|
@ -209,7 +208,8 @@ namespace GameCult.Caching
|
|||
|
||||
// Decides every unset element id under root, and refuses what the rule refuses, without changing anything: a random id
|
||||
// that is not 12 lowercase hex characters, a duplicate id in one list, a derived id whose source is null or empty.
|
||||
// rootPath (the record key) seeds deterministic minting and names the record in a refusal.
|
||||
// rootPath (the record key) seeds deterministic minting and names the record in a refusal. An element object that sits
|
||||
// twice in one list is refused too: planning would give it two ids and it would then be a duplicate on load.
|
||||
internal static IdPlan Plan(object? root, string rootPath, bool deterministic)
|
||||
{
|
||||
var plan = new IdPlan();
|
||||
|
|
@ -241,13 +241,19 @@ namespace GameCult.Caching
|
|||
{
|
||||
var items = list.Cast<object?>().ToArray();
|
||||
var taken = new HashSet<string>(StringComparer.Ordinal);
|
||||
var objects = new HashSet<object>(ReferenceComparer.Instance);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item == null || !IsObjectType(item.GetType()) || ShapeOf(item.GetType()) is not { GetId: { } getId } shape)
|
||||
continue;
|
||||
if (!objects.Add(item))
|
||||
throw new CultElementIdException(
|
||||
$"Record {rootPath}: the same {item.GetType().Name} object appears twice in the list at {path}; an element is one object with one id.",
|
||||
rootPath, path);
|
||||
var id = getId(item);
|
||||
if (string.IsNullOrEmpty(id))
|
||||
continue;
|
||||
plan.HoldsIds = true;
|
||||
if (shape.Derive == null && !IsRandomId(id!))
|
||||
throw new CultElementIdException(
|
||||
$"Record {rootPath}: element id '{id}' in the list at {path} is not 12 lowercase hex characters.",
|
||||
|
|
@ -279,6 +285,7 @@ namespace GameCult.Caching
|
|||
else
|
||||
id = Mint(itemPath, deterministic, rootPath, taken);
|
||||
plan.Add(item, itemShape, get(item), id);
|
||||
plan.HoldsIds = true;
|
||||
}
|
||||
|
||||
Visit(item, itemPath);
|
||||
|
|
@ -290,6 +297,15 @@ namespace GameCult.Caching
|
|||
rootPath, path, id);
|
||||
}
|
||||
|
||||
private sealed class ReferenceComparer : IEqualityComparer<object>
|
||||
{
|
||||
public static readonly ReferenceComparer Instance = new();
|
||||
|
||||
bool IEqualityComparer<object>.Equals(object? left, object? right) => ReferenceEquals(left, right);
|
||||
|
||||
int IEqualityComparer<object>.GetHashCode(object value) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(value);
|
||||
}
|
||||
|
||||
private static bool IsRandomId(string id)
|
||||
{
|
||||
if (id.Length != IdBytes * 2)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
<InternalsVisibleTo Include="GameCult.Mesh" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -98,12 +98,25 @@ namespace GameCult.Mesh
|
|||
|
||||
var resolvedRegistry = registry ?? CultDocumentRegistry.Shared;
|
||||
var descriptor = resolvedRegistry.GetRequired<TDocument>();
|
||||
WriteSingleFileDocumentPayload(
|
||||
path,
|
||||
key,
|
||||
descriptor.ToCatalogEntry(),
|
||||
storedAt,
|
||||
CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)));
|
||||
// Element ids are decided as a cache write decides them, before anything is written: an unset id is minted, a
|
||||
// refused list refuses the write, and the file is marked when it holds an id. A failed write gives the ids back.
|
||||
var ids = CultElementIds.Plan(document, key.Value, deterministic: false);
|
||||
ids.Apply();
|
||||
try
|
||||
{
|
||||
WriteSingleFileDocumentPayload(
|
||||
path,
|
||||
key,
|
||||
descriptor.ToCatalogEntry(),
|
||||
storedAt,
|
||||
CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)),
|
||||
ids.HoldsIds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ids.Undo();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -118,7 +131,10 @@ namespace GameCult.Mesh
|
|||
var resolvedRegistry = registry ?? CultDocumentRegistry.Shared;
|
||||
var descriptor = resolvedRegistry.GetRequired<TDocument>();
|
||||
var payload = ReadSingleFileDocumentPayload(path, key, descriptor.SchemaId);
|
||||
return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), payload);
|
||||
var document = (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), payload);
|
||||
// A load refuses what a write refuses, and mints the ids a cache load of the same file would mint.
|
||||
CultElementIds.Plan(document, key.Value, deterministic: true).Apply();
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -132,7 +148,7 @@ namespace GameCult.Mesh
|
|||
byte[] payload)
|
||||
{
|
||||
if (schema == null) throw new ArgumentNullException(nameof(schema));
|
||||
WriteSingleFileDocumentPayload(path, key, schema.ToCatalogEntry(payload), storedAt, payload);
|
||||
WriteSingleFileDocumentPayload(path, key, schema.ToCatalogEntry(payload), storedAt, payload, holdsIds: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -214,7 +230,8 @@ namespace GameCult.Mesh
|
|||
CultRecordKey key,
|
||||
CultSchemaCatalogEntry catalogEntry,
|
||||
string? storedAt,
|
||||
byte[] payload)
|
||||
byte[] payload,
|
||||
bool holdsIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path));
|
||||
if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry));
|
||||
|
|
@ -222,8 +239,11 @@ namespace GameCult.Mesh
|
|||
throw new ArgumentException("Catalog entry must include a schema id.", nameof(catalogEntry));
|
||||
|
||||
payload ??= Array.Empty<byte>();
|
||||
// A raw payload is opaque: whether it holds element ids is the typed caller's to say. A file already marked stays
|
||||
// marked, as it does in a cache's own store.
|
||||
var snapshot = new CultPersistedStoreSnapshot
|
||||
{
|
||||
FormatVersion = CultPersistedStoreSnapshot.FormatFor(holdsIds || IsMarkedOnDisk(path), holdsVariants: false),
|
||||
SchemaCatalog = new[] { catalogEntry },
|
||||
Records = new[]
|
||||
{
|
||||
|
|
@ -244,6 +264,20 @@ namespace GameCult.Mesh
|
|||
WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
}
|
||||
|
||||
private static bool IsMarkedOnDisk(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new MessagePackReader(File.ReadAllBytes(path));
|
||||
reader.ReadArrayHeader();
|
||||
return string.Equals(reader.ReadString(), CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or MessagePackSerializationException or EndOfStreamException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
|
|
|
|||
|
|
@ -647,6 +647,110 @@ namespace GameCult.Caching.Tests
|
|||
Assert.That(cache.Get<IdDeck>(new CultRecordKey("plain")), Is.Null);
|
||||
}
|
||||
|
||||
// ---- C2a fix batch 3: mark by content, one object twice ----
|
||||
|
||||
private static IdDeck EmptyDeck(string name) => new() { Name = name };
|
||||
|
||||
[Test]
|
||||
public void ASingleFileStoreIsMarkedByTheIdsItHoldsNotByTheTypesItCouldHold()
|
||||
{
|
||||
var empty = PathOf("empty-deck.cc");
|
||||
using (var cache = Open(empty))
|
||||
cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")));
|
||||
Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v1"), "a deck with no elements holds no id");
|
||||
|
||||
var flushed = PathOf("flushed.cc");
|
||||
using (var cache = Open(flushed))
|
||||
{
|
||||
cache.UpsertAsync(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")).GetAwaiter().GetResult();
|
||||
cache.FlushAllBackingStores();
|
||||
}
|
||||
|
||||
Assert.That(HeaderOf(flushed), Is.EqualTo("cultcache.store.v1"), "the whole-store flush decides by content too");
|
||||
|
||||
using (var cache = Open(flushed))
|
||||
{
|
||||
cache.UpsertAsync(typeof(IdDeck), Deck("d"), new CultRecordKey("d")).GetAwaiter().GetResult();
|
||||
cache.FlushAllBackingStores();
|
||||
}
|
||||
|
||||
Assert.That(HeaderOf(flushed), Is.EqualTo("cultcache.store.v3"), "one element with an id marks the flush");
|
||||
|
||||
var marked = PathOf("marked.cc");
|
||||
using (var cache = Open(marked))
|
||||
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
||||
using (var cache = Open(marked))
|
||||
{
|
||||
Assert.That(cache.Commit(batch =>
|
||||
{
|
||||
batch.Expect(new CultRecordKey("e"), null);
|
||||
batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e"));
|
||||
}), Is.True);
|
||||
}
|
||||
|
||||
Assert.That(HeaderOf(marked), Is.EqualTo("cultcache.store.v3"), "a file already marked on disk stays marked");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADirectoryStoreIsMarkedByTheIdsItWritesAndAnEmptyDeckDoesNotMarkIt()
|
||||
{
|
||||
var empty = PathOf("empty-dir.cc");
|
||||
using (var cache = OpenWith(empty, true, typeof(IdDeck)))
|
||||
cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")));
|
||||
Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
|
||||
|
||||
using (var cache = OpenWith(empty, true, typeof(IdDeck)))
|
||||
cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")));
|
||||
Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AVariantIsMarkedByItsOverrideIdsAndOtherwiseIsV2()
|
||||
{
|
||||
var plain = PathOf("variant-plain.cc");
|
||||
using (var cache = Open(plain))
|
||||
{
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
||||
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v") });
|
||||
});
|
||||
}
|
||||
|
||||
Assert.That(HeaderOf(plain), Is.EqualTo("cultcache.store.v2"));
|
||||
|
||||
var withIds = PathOf("variant-ids.cc");
|
||||
using (var cache = Open(withIds))
|
||||
{
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
|
||||
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
|
||||
new[] { cache.Override<IdDeck>(nameof(IdDeck.Reels), new List<IdReel> { new() { Label = "r" } }) });
|
||||
});
|
||||
}
|
||||
|
||||
Assert.That(HeaderOf(withIds), Is.EqualTo("cultcache.store.v3"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnElementObjectTwiceInOneListIsRefusedAtWriteAndNothingIsPersisted()
|
||||
{
|
||||
var path = PathOf("shared.cc");
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
var shared = new IdMark { Text = "s" };
|
||||
var deck = new IdDeck { Name = "shared", Reels = { new IdReel { Label = "r", Marks = { shared, shared } } } };
|
||||
var refusal = Assert.Throws<CultElementIdException>(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("shared"))))!;
|
||||
Assert.That(refusal.Message, Does.Contain("shared").And.Contain("twice"));
|
||||
Assert.That(refusal.RecordKey, Is.EqualTo("shared"));
|
||||
Assert.That(shared.Id, Is.Empty, "the refusal minted nothing");
|
||||
Assert.That(cache.Get<IdDeck>(new CultRecordKey("shared")), Is.Null);
|
||||
}
|
||||
|
||||
Assert.That(File.Exists(path), Is.False, "nothing was persisted");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADerivedIdWhoseSourceIsEmptyIsRefusedAtWriteNamingTheSourceMember()
|
||||
{
|
||||
|
|
|
|||
123
tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs
Normal file
123
tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace GameCult.Mesh.Tests;
|
||||
|
||||
// C2a: a single-file document written or read through CultMesh is judged as a cache judges it: ids minted, a list that
|
||||
// cannot hold ids refused, the header decided by what the file holds.
|
||||
public sealed class CultMeshSingleFileElementIdTests
|
||||
{
|
||||
private const string HexA = "aaaaaaaaaaaa";
|
||||
private static readonly CultRecordKey Key = new("publication");
|
||||
|
||||
private string _root = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "cultmesh-single-file-ids", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
||||
}
|
||||
|
||||
private static CultMeshBodyPublicationDocument Publication(params CultMeshBodyDescriptor[] representations) => new()
|
||||
{
|
||||
BodyId = "aetheria:entities",
|
||||
ProducerId = "aetheria",
|
||||
SchemaId = "eve.entity_soa.v1",
|
||||
Representations = representations
|
||||
};
|
||||
|
||||
private static string HeaderOf(string path) =>
|
||||
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion;
|
||||
|
||||
private static CultMeshBodyDescriptor Descriptor(string id = "") => new() { Id = id, BodyId = "body" };
|
||||
|
||||
private string Path_(string name) => Path.Combine(_root, name);
|
||||
|
||||
[Test]
|
||||
public void TypedWriteMintsTheElementIdsAndMarksTheFileThatHoldsThem()
|
||||
{
|
||||
var path = Path_("ids.cc");
|
||||
var publication = Publication(Descriptor(), Descriptor());
|
||||
|
||||
CultMesh.WriteSingleFileDocument(path, Key, publication);
|
||||
|
||||
publication.Representations.Select(entry => entry.Id).Should().OnlyContain(id => id.Length == 12).And.OnlyHaveUniqueItems();
|
||||
HeaderOf(path).Should().Be("cultcache.store.v3");
|
||||
CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(path, Key).Representations.Select(entry => entry.Id)
|
||||
.Should().Equal(publication.Representations.Select(entry => entry.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TypedWriteOfADocumentHoldingNoIdStaysV1AndAMarkedFileStaysMarked()
|
||||
{
|
||||
var path = Path_("none.cc");
|
||||
CultMesh.WriteSingleFileDocument(path, Key, Publication());
|
||||
HeaderOf(path).Should().Be("cultcache.store.v1");
|
||||
|
||||
CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor()));
|
||||
HeaderOf(path).Should().Be("cultcache.store.v3");
|
||||
CultMesh.WriteSingleFileDocument(path, Key, Publication());
|
||||
HeaderOf(path).Should().Be("cultcache.store.v3", "a file already marked on disk stays marked");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TypedWriteRefusesADuplicateIdAndAnElementObjectTwiceAndLeavesTheFileAlone()
|
||||
{
|
||||
var path = Path_("refused.cc");
|
||||
CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor()));
|
||||
var before = File.ReadAllBytes(path);
|
||||
|
||||
var duplicate = Publication(Descriptor(HexA), Descriptor(HexA));
|
||||
var refusal = Assert.Throws<CultElementIdException>(() => CultMesh.WriteSingleFileDocument(path, Key, duplicate))!;
|
||||
refusal.ElementId.Should().Be(HexA);
|
||||
|
||||
var once = Descriptor();
|
||||
var twice = Publication(once, once);
|
||||
Assert.Throws<CultElementIdException>(() => CultMesh.WriteSingleFileDocument(path, Key, twice))!.Message.Should().Contain("twice");
|
||||
once.Id.Should().BeEmpty("a refusal mints nothing");
|
||||
|
||||
File.ReadAllBytes(path).Should().Equal(before);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TypedReadRefusesWhatALoadRefusesAndMintsWhatALoadMints()
|
||||
{
|
||||
var path = Path_("disk.cc");
|
||||
CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor()));
|
||||
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
||||
|
||||
var duplicate = Publication(Descriptor(HexA), Descriptor(HexA));
|
||||
snapshot.Records[0].Payload = MessagePackSerializer.Serialize(duplicate);
|
||||
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
var refusal = Assert.Throws<CultElementIdException>(() => CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(path, Key))!;
|
||||
refusal.ElementId.Should().Be(HexA);
|
||||
|
||||
// A payload written before ids: two reads, and a cache load, agree on the ids minted.
|
||||
snapshot.Records[0].Payload = MessagePackSerializer.Serialize(Publication(Descriptor(), Descriptor()));
|
||||
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
var first = CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(path, Key).Representations.Select(entry => entry.Id).ToArray();
|
||||
first.Should().OnlyContain(id => id.Length == 12);
|
||||
CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(path, Key).Representations.Select(entry => entry.Id).Should().Equal(first);
|
||||
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions
|
||||
{
|
||||
Registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultMeshBodyPublicationDocument) }),
|
||||
ReadOnly = true
|
||||
});
|
||||
cache.Get<CultMeshBodyPublicationDocument>(Key)!.Representations.Select(entry => entry.Id).Should().Equal(first);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue