diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 8ecde0bf..68ec46e9 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -306,6 +306,12 @@ public static class CultDocumentMessagePackSerialization private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record) { + if (record.RawBytes is { } raw) + { + writer.WriteRaw(new ReadOnlySpan(raw)); + return; + } + writer.WriteArrayHeader(record.Variant == null ? PersistedRecordFieldCount : VariantRecordFieldCount); writer.Write(record.Key); writer.Write(record.SchemaId); @@ -389,6 +395,7 @@ public static class CultDocumentMessagePackSerialization public static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader) { + var start = reader.Position; var fieldCount = reader.ReadArrayHeader(); var record = new CultPersistedRecord(); @@ -429,6 +436,7 @@ public static class CultDocumentMessagePackSerialization } } + record.RawBytes = reader.Sequence.Slice(start, reader.Position).ToArray(); return record; } diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 03fffb62..761200d0 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -47,10 +47,14 @@ visible, to the committing flow or to observers, until the store has accepted it; a failed commit changes nothing on disk or in memory. A batch may carry conditions: per-record version, or the whole store unchanged -since it was last loaded. A record's version is the SHA-256 of its persisted -encoding, the array `[key, schemaId, storedAt, payload(, variant)]` a directory -page holds; it names the schema id and storedAt as well as the bytes. storedAt is -metadata and decides no condition. Conditions are evaluated +since it was last loaded. A record's version is the SHA-256 of the exact bytes the +record is stored as: in a single file the record's own slice of the file, the array +`[key, schemaId, storedAt, payload(, variant)]` exactly as the file holds it, in +whatever legal MessagePack encoding its writer chose (never a re-encoding of the +decoded record); in a directory store the page the manifest indexes. A record being +written has the version of the bytes it is written as. It names the schema id and +storedAt as well as the payload. storedAt is metadata and decides no condition. +Conditions are evaluated under the store's exclusive lock against what is durably on disk. A failed condition is a lost race, not an error: the commit reports it and writes nothing. @@ -59,9 +63,9 @@ nothing. the store's exclusive lock the writer reads what is durably in the store now, applies only what it staged (writes and removals) and, for a commit, its batch, and replaces the store with the result. Every other record, and every catalog -entry the result references, is copied forward as stored: key, schema id, -storedAt, payload and variant, and a catalog entry's exact bytes, unknown -fields included. No type interprets a copied record: a write may hash another +entry the result references, is copied forward as stored: a record's exact bytes +and a catalog entry's exact bytes, unknown fields included, so a copied record +keeps its version. No type interprets a copied record: a write may hash another record's bytes to compare versions, and reads its header (key, schema id, variant slot), but never decodes its payload. A record written over one the store holds gets a storedAt later than the one it diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index a15a1c84..1f509ae1 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -134,13 +134,24 @@ namespace GameCult.Caching public sealed class CultPersistedRecord { - public string Key { get; set; } = string.Empty; - public string SchemaId { get; set; } = string.Empty; - public string StoredAt { get; set; } = string.Empty; - public byte[] Payload { get; set; } = Array.Empty(); + private string _key = string.Empty; + private string _schemaId = string.Empty; + private string _storedAt = string.Empty; + private byte[] _payload = Array.Empty(); + private CultVariantDelta? _variant; + + public string Key { get => _key; set { _key = value; RawBytes = null; } } + public string SchemaId { get => _schemaId; set { _schemaId = value; RawBytes = null; } } + public string StoredAt { get => _storedAt; set { _storedAt = value; RawBytes = null; } } + public byte[] Payload { get => _payload; set { _payload = value; RawBytes = null; } } // Non-null for a variant record (slot 4): its base and overrides. Its payload is empty. - public CultVariantDelta? Variant { get; set; } + public CultVariantDelta? Variant { get => _variant; set { _variant = value; RawBytes = null; } } + + // The exact bytes this record was read from, as a store file held them. A record is written as the bytes it was read from + // while no field has been assigned since, so a copied record stays byte-identical and its version (the SHA-256 of its + // stored bytes) is the one the file's bytes name, whatever encoding their writer chose. Assigning any field drops them. + internal byte[]? RawBytes { get; set; } } // One record a write puts in a single-file store: encoded, with the catalog entry that publishes the id it is stored under and