SerializePayload takes the declared type. A call attaches its registry under the store's gate and restores the previous one, so no call leaves a registry on the store. Not yet run; the next commit set is verified on Yggdrasil. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4717 lines
235 KiB
C#
4717 lines
235 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using System.Reflection;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.ExceptionServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using MessagePack;
|
|
using R3;
|
|
|
|
namespace GameCult.Caching
|
|
{
|
|
public sealed class CultSchemaCatalogEntry
|
|
{
|
|
public string SchemaId { get; set; } = string.Empty;
|
|
public string SchemaName { get; set; } = string.Empty;
|
|
public string SchemaVersion { get; set; } = string.Empty;
|
|
public string ContentHash { get; set; } = string.Empty;
|
|
public string CanonicalSchemaJson { get; set; } = string.Empty;
|
|
public string[] CompatibleSchemaIds { get; set; } = Array.Empty<string>();
|
|
|
|
public CultSchemaMemberCatalogEntry[] Members { get; set; } = Array.Empty<CultSchemaMemberCatalogEntry>();
|
|
|
|
// The catalog a write leaves, derived from the records the written store holds and nothing else. For each schema id a record
|
|
// carries, one entry that publishes it: for an id the write stages a record under, the entry the writer registered (or passed)
|
|
// for it, which owns the id; for every other id the arrived (durable) entry that owns it, else the first that lists it as
|
|
// compatible (the store that read it carries its bytes as read). A registered entry never publishes an id nothing staged sits under, so
|
|
// the caller passes registered entries only for the ids it writes. Entries are written as chosen, never merged; a registered
|
|
// descriptor wins over an arrived entry with the same own id. Two arrived entries with the same own id and different schema
|
|
// names, or a record no chosen entry publishes, refuse the write.
|
|
// Entries that tie are taken in one fixed order, so the catalog does not depend on the order they arrive in.
|
|
private static IEnumerable<CultSchemaCatalogEntry> Canonical(IEnumerable<CultSchemaCatalogEntry> entries) => entries
|
|
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
|
|
.ThenBy(entry => entry.ContentHash, StringComparer.Ordinal)
|
|
.ThenBy(entry => string.Join(",", entry.CompatibleSchemaIds), StringComparer.Ordinal);
|
|
|
|
internal static CultSchemaCatalogEntry[] Derive(
|
|
IEnumerable<CultPersistedRecord> records,
|
|
IReadOnlyCollection<CultSchemaCatalogEntry> registered,
|
|
IReadOnlyCollection<CultSchemaCatalogEntry> arrived)
|
|
{
|
|
var chosen = new Dictionary<string, (CultSchemaCatalogEntry Entry, bool Registered)>(StringComparer.Ordinal);
|
|
var carried = records.GroupBy(record => record.SchemaId, StringComparer.Ordinal).OrderBy(group => group.Key, StringComparer.Ordinal).ToArray();
|
|
foreach (var group in carried)
|
|
{
|
|
var schemaId = group.Key;
|
|
var recordKey = group.Min(record => record.Key)!;
|
|
var ownRegisteredAll = Canonical(registered.Where(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal))).ToArray();
|
|
var ownRegistered = ownRegisteredAll.FirstOrDefault();
|
|
var ownArrived = Canonical(arrived.Where(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal))).ToArray();
|
|
// Entries of one tier that share an own id must be one schema; a registered entry settles a disagreement among arrived ones.
|
|
var disagreeing = ownRegisteredAll.Length > 0 ? ownRegisteredAll : ownArrived;
|
|
if (disagreeing.Select(entry => entry.SchemaName).Distinct(StringComparer.Ordinal).Count() > 1)
|
|
{
|
|
var names = disagreeing.Select(entry => entry.SchemaName).Distinct(StringComparer.Ordinal).ToArray();
|
|
throw new CultSchemaConflictException(
|
|
$"Schema id '{schemaId}' is claimed by schemas {string.Join(" and ", names.Select(name => $"'{name}'"))}; record '{recordKey}' cannot be written under it.",
|
|
schemaId, names, recordKey);
|
|
}
|
|
|
|
CultSchemaCatalogEntry? pick;
|
|
bool isRegistered;
|
|
if (ownRegistered != null)
|
|
{
|
|
pick = ownRegistered;
|
|
isRegistered = true;
|
|
}
|
|
else if (ownArrived.Length > 0)
|
|
{
|
|
pick = ownArrived[0];
|
|
isRegistered = false;
|
|
}
|
|
else
|
|
{
|
|
pick = Canonical(arrived.Where(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal))).FirstOrDefault();
|
|
isRegistered = false;
|
|
}
|
|
|
|
if (pick == null)
|
|
continue;
|
|
if (chosen.TryGetValue(pick.SchemaId, out var prior) && prior.Registered && !isRegistered)
|
|
continue;
|
|
chosen[pick.SchemaId] = (pick, isRegistered);
|
|
}
|
|
|
|
foreach (var group in carried)
|
|
{
|
|
if (!chosen.Values.Any(pair => string.Equals(pair.Entry.SchemaId, group.Key, StringComparison.Ordinal) ||
|
|
pair.Entry.CompatibleSchemaIds.Contains(group.Key, StringComparer.Ordinal)))
|
|
{
|
|
var recordKey = group.Min(record => record.Key)!;
|
|
throw new CultSchemaConflictException(
|
|
$"No schema entry publishes schema id '{group.Key}' of record '{recordKey}'.",
|
|
group.Key, chosen.Values.Select(pair => pair.Entry.SchemaName).ToArray(), recordKey);
|
|
}
|
|
}
|
|
|
|
return chosen.Values.Select(pair => pair.Entry).ToArray();
|
|
}
|
|
}
|
|
|
|
public sealed class CultSchemaMemberCatalogEntry
|
|
{
|
|
public int Slot { get; set; }
|
|
|
|
public string MemberName { get; set; } = string.Empty;
|
|
|
|
public string TypeName { get; set; } = string.Empty;
|
|
|
|
public bool IsReference { get; set; }
|
|
|
|
public bool IsMany { get; set; }
|
|
|
|
public string? TargetSchemaName { get; set; }
|
|
|
|
public bool IsName { get; set; }
|
|
|
|
public string? IndexAlias { get; set; }
|
|
}
|
|
|
|
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<byte>();
|
|
|
|
// Non-null for a variant record (slot 4): its base and overrides. Its payload is empty.
|
|
public CultVariantDelta? Variant { 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
|
|
// whether its document holds element ids (null when the writer cannot see, as for a raw payload).
|
|
internal sealed class CultStagedRecord
|
|
{
|
|
internal CultStagedRecord(CultPersistedRecord record, CultSchemaCatalogEntry entry, bool? holdsIds)
|
|
{
|
|
Record = record;
|
|
Entry = entry;
|
|
HoldsIds = holdsIds;
|
|
}
|
|
|
|
internal CultPersistedRecord Record { get; }
|
|
internal CultSchemaCatalogEntry Entry { get; }
|
|
internal bool? HoldsIds { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A record a store carries untouched because no registered type can read it, for example a record of a type this build does
|
|
/// not have, or one renamed under a new schema name without declaring its old id. The store keeps its bytes as the file holds
|
|
/// them, a write of another record keeps it, and a write that would replace or remove it is refused with a
|
|
/// <see cref="CultSchemaConflictException"/>. The cache never holds it. Declaring its schema id on a type claims it at the next load.
|
|
/// A record a type can read (through the catalog's schema name) but no type owns or declares is not foreign: the cache holds it
|
|
/// read-only, and the same refusal applies to a write or removal of it.
|
|
/// </summary>
|
|
public sealed class CultForeignRecord
|
|
{
|
|
internal CultForeignRecord(string key, string schemaId, string schemaName, string storedAt, string storedVersion)
|
|
{
|
|
Key = key;
|
|
SchemaId = schemaId;
|
|
SchemaName = schemaName;
|
|
StoredAt = storedAt;
|
|
StoredVersion = storedVersion;
|
|
}
|
|
|
|
public string Key { get; }
|
|
|
|
public string SchemaId { get; }
|
|
|
|
// The name the store's catalog gives the schema.
|
|
public string SchemaName { get; }
|
|
|
|
public string StoredAt { get; }
|
|
|
|
// The version of the record's stored bytes when its store read it, in that store's spelling, given by the store through
|
|
// CacheBackingStore.Foreign. ExpectUnchanged compares it with the version on disk at the commit.
|
|
internal string StoredVersion { get; }
|
|
}
|
|
|
|
public sealed class CultPersistedStoreSnapshot
|
|
{
|
|
public const string FormatV1 = "cultcache.store.v1";
|
|
public const string FormatV2 = "cultcache.store.v2";
|
|
public const string FormatV3 = "cultcache.store.v3";
|
|
|
|
public string FormatVersion { get; set; } = FormatV1;
|
|
|
|
public CultSchemaCatalogEntry[] SchemaCatalog { get; set; } = Array.Empty<CultSchemaCatalogEntry>();
|
|
public CultPersistedRecord[] Records { get; set; } = Array.Empty<CultPersistedRecord>();
|
|
}
|
|
|
|
public enum CultSchemaMigrationKind
|
|
{
|
|
Exact,
|
|
|
|
CompatibleDrift
|
|
}
|
|
|
|
public sealed class CultSchemaMigrationWarning
|
|
{
|
|
public string Code { get; set; } = string.Empty;
|
|
|
|
public string Message { get; set; } = string.Empty;
|
|
}
|
|
|
|
public sealed class CultSchemaMigrationReport
|
|
{
|
|
public string PersistedSchemaId { get; set; } = string.Empty;
|
|
|
|
public string LocalSchemaId { get; set; } = string.Empty;
|
|
|
|
public string PersistedSchemaName { get; set; } = string.Empty;
|
|
|
|
public string LocalSchemaName { get; set; } = string.Empty;
|
|
|
|
public CultSchemaMigrationKind Kind { get; set; }
|
|
|
|
public int[] IgnoredExtraSlots { get; set; } = Array.Empty<int>();
|
|
|
|
public int[] DefaultedMissingSlots { get; set; } = Array.Empty<int>();
|
|
|
|
public CultSchemaMigrationWarning[] Warnings { get; set; } = Array.Empty<CultSchemaMigrationWarning>();
|
|
}
|
|
|
|
internal sealed class CultSchemaResolutionResult
|
|
{
|
|
public CultSchemaResolutionResult(CultDocumentDescriptor descriptor, CultSchemaMigrationReport report)
|
|
{
|
|
Descriptor = descriptor;
|
|
Report = report;
|
|
}
|
|
|
|
public CultDocumentDescriptor Descriptor { get; }
|
|
|
|
public CultSchemaMigrationReport Report { get; }
|
|
}
|
|
|
|
// A declared member's addressing shape, read-only: what a caller outside GameCult.Caching
|
|
// (the CultNet selection evaluator, chiefly) is allowed to know about a member without
|
|
// reflecting over the document itself. Built from the same declarations CultIndexAttribute
|
|
// and CultReferenceAttribute already recorded; carries no value.
|
|
public readonly struct CultDocumentMemberView
|
|
{
|
|
internal CultDocumentMemberView(
|
|
string memberName,
|
|
int slot,
|
|
string? indexAlias,
|
|
bool isName,
|
|
bool isReference,
|
|
bool isMany,
|
|
bool isNumeric,
|
|
Type? targetType)
|
|
{
|
|
MemberName = memberName;
|
|
Slot = slot;
|
|
IndexAlias = indexAlias;
|
|
IsName = isName;
|
|
IsReference = isReference;
|
|
IsMany = isMany;
|
|
IsNumeric = isNumeric;
|
|
TargetType = targetType;
|
|
}
|
|
|
|
public string MemberName { get; }
|
|
public int Slot { get; }
|
|
public string? IndexAlias { get; }
|
|
public bool IsName { get; }
|
|
public bool IsReference { get; }
|
|
public bool IsMany { get; }
|
|
public bool IsNumeric { get; }
|
|
|
|
// The reference's declared target: the type CultReferenceAttribute names, or CultRecordRef<T>'s T.
|
|
// Null when the member is not a reference. May be abstract (D9): the leaf set is resolved at
|
|
// evaluation by CultDocumentRegistry.ResolveTargetLeaves, never persisted here or in the catalog.
|
|
public Type? TargetType { get; }
|
|
}
|
|
|
|
public sealed class CultDocumentDescriptor
|
|
{
|
|
internal CultDocumentDescriptor(
|
|
Type documentType,
|
|
string schemaName,
|
|
string schemaVersion,
|
|
string schemaId,
|
|
IReadOnlyList<string> compatibleSchemaIds,
|
|
string contentHash,
|
|
string canonicalSchemaJson,
|
|
bool isGlobal,
|
|
string? nameMember,
|
|
Func<object, string?>? nameAccessor,
|
|
IReadOnlyDictionary<string, Func<object, string>> indexAccessors,
|
|
IReadOnlyList<CultDocumentMemberDescriptor> members,
|
|
IReadOnlyList<CultDocumentRegistry.PersistedMember> richMembers)
|
|
{
|
|
DocumentType = documentType;
|
|
SchemaName = schemaName;
|
|
SchemaVersion = schemaVersion;
|
|
SchemaId = schemaId;
|
|
CompatibleSchemaIds = compatibleSchemaIds;
|
|
ContentHash = contentHash;
|
|
CanonicalSchemaJson = canonicalSchemaJson;
|
|
IsGlobal = isGlobal;
|
|
NameMember = nameMember;
|
|
NameAccessor = nameAccessor;
|
|
IndexAccessors = indexAccessors;
|
|
Members = members;
|
|
RichMembers = richMembers;
|
|
DeclaredMembers = richMembers
|
|
.Select(member => new CultDocumentMemberView(
|
|
member.Member.Name,
|
|
member.Slot,
|
|
member.IndexAlias,
|
|
member.IsName,
|
|
member.IsReference,
|
|
member.IsMany,
|
|
member.IsNumeric,
|
|
member.TargetType))
|
|
.ToArray();
|
|
}
|
|
|
|
public Type DocumentType { get; }
|
|
public string SchemaName { get; }
|
|
public string SchemaVersion { get; }
|
|
public string SchemaId { get; }
|
|
|
|
/// <summary>The ids besides <see cref="SchemaId"/> this type declared through <see cref="CultDocumentAttribute.CompatibleSchemaIds"/>.</summary>
|
|
public IReadOnlyList<string> CompatibleSchemaIds { get; }
|
|
public string ContentHash { get; }
|
|
public string CanonicalSchemaJson { get; }
|
|
public bool IsGlobal { get; }
|
|
public string? NameMember { get; }
|
|
internal Func<object, string?>? NameAccessor { get; }
|
|
internal IReadOnlyDictionary<string, Func<object, string>> IndexAccessors { get; }
|
|
internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; }
|
|
internal IReadOnlyList<CultDocumentRegistry.PersistedMember> RichMembers { get; }
|
|
|
|
// 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.
|
|
public IReadOnlyList<CultDocumentMemberView> DeclaredMembers { get; }
|
|
|
|
public bool TryGetIndexValue(object document, string alias, out string? value)
|
|
{
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
if (string.IsNullOrEmpty(alias))
|
|
{
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
foreach (var member in RichMembers)
|
|
{
|
|
if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal))
|
|
{
|
|
value = member.GetterNullable(document);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetIndexNumber(object document, string alias, out string? value)
|
|
{
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
if (!string.IsNullOrEmpty(alias))
|
|
{
|
|
foreach (var member in RichMembers)
|
|
{
|
|
if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal) && member.IsNumeric)
|
|
{
|
|
var number = member.NumberOf(document);
|
|
if (number != null)
|
|
{
|
|
value = number;
|
|
return true;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
// The edges a declared reference member carries: one per CultRecordRef<T>, one per element of
|
|
// an IEnumerable<CultRecordRef<T>>, one per key of an IDictionary<CultRecordRef<T>, V> with the
|
|
// value as the payload (D11). role is the member's index alias, or its name when it has none.
|
|
public IEnumerable<(CultRecordKey Target, object? Payload)> ReferencesOf(object document, string role)
|
|
{
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
if (string.IsNullOrEmpty(role)) return Array.Empty<(CultRecordKey, object?)>();
|
|
|
|
foreach (var member in RichMembers)
|
|
{
|
|
if (!member.IsReference) continue;
|
|
var memberRole = member.IndexAlias ?? member.Member.Name;
|
|
if (string.Equals(memberRole, role, StringComparison.Ordinal))
|
|
{
|
|
return member.ReferenceEnumerator!(document);
|
|
}
|
|
}
|
|
|
|
return Array.Empty<(CultRecordKey, object?)>();
|
|
}
|
|
|
|
public CultSchemaCatalogEntry ToCatalogEntry()
|
|
{
|
|
return new CultSchemaCatalogEntry
|
|
{
|
|
SchemaId = SchemaId,
|
|
SchemaName = SchemaName,
|
|
SchemaVersion = SchemaVersion,
|
|
ContentHash = ContentHash,
|
|
CanonicalSchemaJson = CanonicalSchemaJson,
|
|
CompatibleSchemaIds = [SchemaId, .. CompatibleSchemaIds],
|
|
Members = Members
|
|
.OrderBy(member => member.Slot)
|
|
.Select(member => new CultSchemaMemberCatalogEntry
|
|
{
|
|
Slot = member.Slot,
|
|
MemberName = member.MemberName,
|
|
TypeName = member.TypeName,
|
|
IsReference = member.IsReference,
|
|
IsMany = member.IsMany,
|
|
TargetSchemaName = member.TargetSchemaName,
|
|
IsName = member.IsName,
|
|
IndexAlias = member.IndexAlias
|
|
})
|
|
.ToArray()
|
|
};
|
|
}
|
|
|
|
internal string GetPreferredFileStem(object document)
|
|
{
|
|
var preferred = NameAccessor?.Invoke(document);
|
|
return string.IsNullOrWhiteSpace(preferred)
|
|
? SchemaName
|
|
: preferred!;
|
|
}
|
|
}
|
|
|
|
internal sealed class CultDocumentMemberDescriptor
|
|
{
|
|
public string MemberName { get; set; } = string.Empty;
|
|
public int Slot { get; set; }
|
|
public string TypeName { get; set; } = string.Empty;
|
|
public bool IsReference { get; set; }
|
|
public bool IsMany { get; set; }
|
|
public string? TargetSchemaName { get; set; }
|
|
public bool IsName { get; set; }
|
|
public string? IndexAlias { get; set; }
|
|
}
|
|
|
|
public sealed class CultStoredDocument
|
|
{
|
|
public CultStoredDocument(
|
|
CultRecordKey key,
|
|
string storedAt,
|
|
CultDocumentDescriptor descriptor,
|
|
object document)
|
|
{
|
|
Key = key;
|
|
StoredAt = storedAt;
|
|
Descriptor = descriptor;
|
|
StoredSchemaId = descriptor.SchemaId;
|
|
StoredVersion = UnstoredVersion();
|
|
_document = document;
|
|
}
|
|
|
|
// A variant: the delta is what is stored; the cache resolves it against its base in the hold that admits it.
|
|
internal CultStoredDocument(
|
|
CultRecordKey key,
|
|
string storedAt,
|
|
CultDocumentDescriptor descriptor,
|
|
CultVariantDelta variant,
|
|
object? resolved,
|
|
CultCodec? codec)
|
|
{
|
|
Key = key;
|
|
StoredAt = storedAt;
|
|
Descriptor = descriptor;
|
|
StoredSchemaId = descriptor.SchemaId;
|
|
StoredVersion = UnstoredVersion();
|
|
Variant = variant;
|
|
_document = resolved;
|
|
_codec = codec;
|
|
}
|
|
|
|
private readonly object? _document;
|
|
|
|
// What reads a variant's override values; null for a plain record.
|
|
private readonly CultCodec? _codec;
|
|
|
|
public CultRecordKey Key { get; }
|
|
|
|
// When the record was stored: metadata. A write that stores the record mints it a storedAt later than the one it replaces;
|
|
// no condition reads it.
|
|
public string StoredAt { get; internal set; }
|
|
public CultDocumentDescriptor Descriptor { get; }
|
|
|
|
// The schema id the record carries in its store: the id it was loaded under, which may be an older or foreign id that
|
|
// resolved to Descriptor, until a write of this record puts it under Descriptor's id.
|
|
public string StoredSchemaId { get; internal set; }
|
|
|
|
// Names the bytes this record was read from or written as: the SHA-256 of its persisted encoding, set by the store that
|
|
// read or wrote them. A record no store has read or written has a version of its own that equals nothing else.
|
|
internal string StoredVersion { get; set; }
|
|
|
|
private static string UnstoredVersion() => "unstored:" + Guid.NewGuid().ToString("N");
|
|
|
|
// The complete document. For a variant, the resolved view: derived, never written back.
|
|
public object Document => _document ??
|
|
throw new InvalidOperationException($"Variant {Key.Value} has not been resolved; only the cache resolves a variant.");
|
|
|
|
// The base and overrides a variant record stores; null for a plain record.
|
|
public CultVariantDelta? Variant { get; }
|
|
|
|
// A plain record staged by Flatten: the one way a plain record may replace a variant at its key.
|
|
internal bool Flattens { get; set; }
|
|
|
|
// True while this record holds element ids minted when it loaded, that no write has persisted (see MintElementIds).
|
|
internal bool IdsInMemoryOnly { get; set; }
|
|
|
|
// True when some element this record persists holds a non-empty id, and the only thing a store reads to mark its header.
|
|
// It is read off the stored form each time it is asked, so a store asks it as it writes: a document changed after it was
|
|
// admitted, or loaded and changed in place, writes the header its content needs. A plain record persists its document; a
|
|
// variant persists its overrides, so it holds an id when an override value does (its base's ids are the base's). A
|
|
// variant built without a codec (a store loaded it with no cache attached) cannot see into its values and counts as holding
|
|
// one, so a store never writes an unmarked header over ids it could not see.
|
|
public bool HoldsIds
|
|
{
|
|
get
|
|
{
|
|
if (Variant == null)
|
|
return CultElementIds.Holds(Descriptor.DocumentType, _document);
|
|
if (_codec == null)
|
|
return true;
|
|
return Variant.Overrides.Any(entry =>
|
|
{
|
|
var memberType = Descriptor.RichMembers.First(member => member.Slot == entry.Path[0].Slot).MemberType;
|
|
return CultElementIds.Holds(memberType, _codec.Deserialize(memberType, Descriptor.DocumentType, entry.Value));
|
|
});
|
|
}
|
|
}
|
|
|
|
internal CultStoredDocument Resolved(object document, CultVariantDelta delta, bool idsInMemoryOnly, CultCodec codec) =>
|
|
new(Key, StoredAt, Descriptor, delta, document, codec) { IdsInMemoryOnly = idsInMemoryOnly, StoredSchemaId = StoredSchemaId, StoredVersion = StoredVersion };
|
|
}
|
|
|
|
public sealed class CultDocumentRegistry
|
|
{
|
|
private static readonly Lazy<CultDocumentRegistry> SharedRegistry =
|
|
new(() => new CultDocumentRegistry());
|
|
|
|
private volatile RegistryIndexes _indexes = new();
|
|
private readonly object _registrationGate = new();
|
|
|
|
public static CultDocumentRegistry Shared => SharedRegistry.Value;
|
|
|
|
public CultDocumentRegistry()
|
|
{
|
|
Refresh();
|
|
}
|
|
|
|
private CultDocumentRegistry(bool discoverLoadedDocuments)
|
|
{
|
|
if (discoverLoadedDocuments)
|
|
Refresh();
|
|
}
|
|
|
|
public static CultDocumentRegistry ForTypes(IEnumerable<Type> documentTypes)
|
|
{
|
|
if (documentTypes == null) throw new ArgumentNullException(nameof(documentTypes));
|
|
var registry = new CultDocumentRegistry(discoverLoadedDocuments: false);
|
|
foreach (var type in documentTypes.Distinct())
|
|
{
|
|
registry.GetRequired(type ?? throw new ArgumentException(
|
|
"Document type collections cannot contain null entries.",
|
|
nameof(documentTypes)));
|
|
}
|
|
return registry;
|
|
}
|
|
|
|
public IEnumerable<CultDocumentDescriptor> AllDescriptors =>
|
|
_indexes.ByType.Values.OrderBy(d => d.SchemaName, StringComparer.Ordinal);
|
|
|
|
// D9: a reference's target set is every registered leaf assignable to its declared target type,
|
|
// computed against the registry as it stands right now - never cached, never persisted. A leaf
|
|
// registered after some other descriptor was built must still show up here.
|
|
public IReadOnlyList<CultDocumentDescriptor> ResolveTargetLeaves(Type targetType)
|
|
{
|
|
if (targetType == null) throw new ArgumentNullException(nameof(targetType));
|
|
return AllDescriptors.Where(d => targetType.IsAssignableFrom(d.DocumentType)).ToArray();
|
|
}
|
|
|
|
public void Refresh()
|
|
{
|
|
lock (_registrationGate)
|
|
{
|
|
var rebuilt = new RegistryIndexes();
|
|
foreach (var type in ReflectionExtensions.GetAttributedDocumentTypes())
|
|
{
|
|
RegisterDescriptor(rebuilt, BuildDescriptor(type));
|
|
}
|
|
|
|
_indexes = rebuilt;
|
|
}
|
|
}
|
|
|
|
public CultDocumentDescriptor GetRequired(Type type)
|
|
{
|
|
if (_indexes.ByType.TryGetValue(type, out var descriptor))
|
|
{
|
|
return descriptor;
|
|
}
|
|
|
|
var attribute = type.GetCustomAttribute<CultDocumentAttribute>();
|
|
if (attribute == null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Type {type.FullName} is not marked with {nameof(CultDocumentAttribute)}.");
|
|
}
|
|
|
|
descriptor = BuildDescriptor(type);
|
|
return RegisterDescriptor(descriptor);
|
|
}
|
|
|
|
public CultDocumentDescriptor GetRequired<T>() where T : class
|
|
{
|
|
return GetRequired(typeof(T));
|
|
}
|
|
|
|
public CultDocumentDescriptor GetRequiredBySchemaId(string schemaId)
|
|
{
|
|
if (_indexes.BySchemaId.TryGetValue(schemaId, out var descriptor))
|
|
{
|
|
return descriptor;
|
|
}
|
|
|
|
throw new InvalidOperationException($"Unknown CultCache schema id '{schemaId}'.");
|
|
}
|
|
|
|
public CultDocumentDescriptor ResolvePersistedSchema(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog)
|
|
{
|
|
return ResolvePersistedSchemaDetailed(schemaId, catalog).Descriptor;
|
|
}
|
|
|
|
public CultSchemaMigrationReport ResolvePersistedSchemaReport(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog)
|
|
{
|
|
return ResolvePersistedSchemaDetailed(schemaId, catalog).Report;
|
|
}
|
|
|
|
internal CultSchemaResolutionResult ResolvePersistedSchemaDetailed(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog)
|
|
{
|
|
var local = Claimant(schemaId, catalog, out var persisted);
|
|
if (local == null)
|
|
throw new InvalidOperationException(
|
|
$"No local CultCache schema matches persisted schema '{persisted!.SchemaName}' ({schemaId}).");
|
|
if (persisted == null)
|
|
{
|
|
return new CultSchemaResolutionResult(
|
|
local,
|
|
new CultSchemaMigrationReport
|
|
{
|
|
PersistedSchemaId = schemaId,
|
|
LocalSchemaId = local.SchemaId,
|
|
PersistedSchemaName = local.SchemaName,
|
|
LocalSchemaName = local.SchemaName,
|
|
Kind = CultSchemaMigrationKind.Exact
|
|
});
|
|
}
|
|
|
|
return BuildCompatibleResolutionResult(persisted, local);
|
|
}
|
|
|
|
// The registered type a record under this schema id belongs to, or null when no registered type claims it: a foreign record,
|
|
// which a store carries untouched. Deciding this never decodes the record or compares shapes, so a store can classify what a
|
|
// file holds without reading a payload. persisted is the catalog entry the record resolves through, and null when a
|
|
// registered type owns the id. Claimants that cannot be told apart refuse. Branches past the listers (a local id the persisted
|
|
// entry lists, then the schema name) pick a type to read with. They confer no ownership: Declares decides what a cache may write.
|
|
internal CultDocumentDescriptor? Claimant(
|
|
string schemaId,
|
|
IReadOnlyCollection<CultSchemaCatalogEntry> catalog,
|
|
out CultSchemaCatalogEntry? persisted)
|
|
{
|
|
var indexes = _indexes;
|
|
persisted = null;
|
|
if (indexes.BySchemaId.TryGetValue(schemaId, out var exact))
|
|
return exact;
|
|
|
|
persisted = PublishedBy(schemaId, catalog);
|
|
if (persisted == null)
|
|
{
|
|
throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog.");
|
|
}
|
|
|
|
// No type owns the id, so the one type that lists it is the record's type. Two listers and no owner cannot say which.
|
|
if (indexes.ByCompatibleSchemaId.TryGetValue(schemaId, out var listers))
|
|
{
|
|
if (listers.Length > 1)
|
|
throw new CultSchemaConflictException(
|
|
$"CultCache schema id '{schemaId}' is owned by no registered type and declared compatible by several " +
|
|
$"({string.Join(", ", listers.Select(lister => lister.DocumentType.FullName))}), so a record under it resolves to none. " +
|
|
"Register the type that owns the id, or declare it on one type.",
|
|
schemaId,
|
|
listers.Select(lister => lister.SchemaName).ToArray(),
|
|
string.Empty);
|
|
return listers[0];
|
|
}
|
|
|
|
foreach (var compatibleSchemaId in persisted.CompatibleSchemaIds.Where(candidate => !string.IsNullOrWhiteSpace(candidate)))
|
|
{
|
|
if (indexes.BySchemaId.TryGetValue(compatibleSchemaId, out var compatibleLocal))
|
|
return compatibleLocal;
|
|
}
|
|
|
|
if (indexes.BySchemaName.TryGetValue(persisted.SchemaName, out var localVersions))
|
|
{
|
|
if (localVersions.Length == 1)
|
|
return localVersions[0];
|
|
|
|
throw new InvalidOperationException(
|
|
$"CultCache schema name '{persisted.SchemaName}' is ambiguous across local versions " +
|
|
$"[{string.Join(", ", localVersions.Select(candidate => candidate.SchemaVersion).OrderBy(version => version, StringComparer.Ordinal))}]. " +
|
|
"Persisted schema compatibility must identify an explicit schema id.");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// The catalog entry a record under this id is published by: the entry that owns the id, else the first that lists it compatible.
|
|
internal static CultSchemaCatalogEntry? PublishedBy(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog) =>
|
|
catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal))
|
|
?? catalog.FirstOrDefault(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal));
|
|
|
|
// The one answer to whether a cache may write, relabel or remove a record stored under this id: a registered type owns it or
|
|
// declares it compatible. Read live, so a type registered later that declares the id claims its records at their next write.
|
|
internal bool Declares(string schemaId)
|
|
{
|
|
var indexes = _indexes;
|
|
return indexes.BySchemaId.ContainsKey(schemaId) || indexes.ByCompatibleSchemaId.ContainsKey(schemaId);
|
|
}
|
|
|
|
private CultDocumentDescriptor RegisterDescriptor(CultDocumentDescriptor descriptor)
|
|
{
|
|
lock (_registrationGate)
|
|
{
|
|
var current = _indexes;
|
|
if (current.ByType.TryGetValue(descriptor.DocumentType, out var registered))
|
|
return registered;
|
|
|
|
var updated = new RegistryIndexes(current);
|
|
registered = RegisterDescriptor(updated, descriptor);
|
|
_indexes = updated;
|
|
return registered;
|
|
}
|
|
}
|
|
|
|
private static CultDocumentDescriptor RegisterDescriptor(
|
|
RegistryIndexes indexes,
|
|
CultDocumentDescriptor descriptor)
|
|
{
|
|
if (indexes.ByType.TryGetValue(descriptor.DocumentType, out var registered))
|
|
return registered;
|
|
|
|
// One type owns a schema, whatever its declaration. Its id hashes the schema's name, version and members, so a second
|
|
// type of the same name and version is refused here whether or not it hashes alike. Other types may list the id as
|
|
// compatible beside its owner, as a v2 class does that keeps v1's class registered: a record under the id resolves to
|
|
// its owner, and to a lister only when nothing owns it. Neither depends on the order types were registered in.
|
|
indexes.BySchemaName.TryGetValue(descriptor.SchemaName, out var schemaNameVersions);
|
|
var schemaVersionOwner = schemaNameVersions?.FirstOrDefault(candidate =>
|
|
string.Equals(candidate.SchemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal));
|
|
if (schemaVersionOwner != null)
|
|
throw SchemaClaimedTwice(schemaVersionOwner, descriptor);
|
|
|
|
indexes.ByType[descriptor.DocumentType] = descriptor;
|
|
indexes.BySchemaId[descriptor.SchemaId] = descriptor;
|
|
foreach (var compatibleSchemaId in descriptor.CompatibleSchemaIds)
|
|
indexes.ByCompatibleSchemaId[compatibleSchemaId] = indexes.ByCompatibleSchemaId.TryGetValue(compatibleSchemaId, out var listers)
|
|
? [.. listers, descriptor]
|
|
: [descriptor];
|
|
indexes.BySchemaName[descriptor.SchemaName] = schemaNameVersions == null
|
|
? [descriptor]
|
|
: [.. schemaNameVersions, descriptor];
|
|
return descriptor;
|
|
}
|
|
|
|
private static CultSchemaConflictException SchemaClaimedTwice(
|
|
CultDocumentDescriptor existing,
|
|
CultDocumentDescriptor claimed) =>
|
|
new(
|
|
$"CultCache schema '{claimed.SchemaName}' version '{claimed.SchemaVersion}' is already registered to CLR type " +
|
|
$"'{existing.DocumentType.FullName}' and cannot also be claimed by '{claimed.DocumentType.FullName}'. " +
|
|
"A schema is owned by one registered type; others may only declare its id compatible.",
|
|
claimed.SchemaId,
|
|
new[] { existing.SchemaName, claimed.SchemaName },
|
|
string.Empty);
|
|
|
|
private sealed class RegistryIndexes
|
|
{
|
|
public RegistryIndexes()
|
|
{
|
|
ByType = new Dictionary<Type, CultDocumentDescriptor>();
|
|
BySchemaId = new Dictionary<string, CultDocumentDescriptor>(StringComparer.Ordinal);
|
|
ByCompatibleSchemaId = new Dictionary<string, CultDocumentDescriptor[]>(StringComparer.Ordinal);
|
|
BySchemaName = new Dictionary<string, CultDocumentDescriptor[]>(StringComparer.Ordinal);
|
|
}
|
|
|
|
public RegistryIndexes(RegistryIndexes source)
|
|
{
|
|
ByType = new Dictionary<Type, CultDocumentDescriptor>(source.ByType);
|
|
BySchemaId = new Dictionary<string, CultDocumentDescriptor>(source.BySchemaId, StringComparer.Ordinal);
|
|
ByCompatibleSchemaId = new Dictionary<string, CultDocumentDescriptor[]>(source.ByCompatibleSchemaId, StringComparer.Ordinal);
|
|
BySchemaName = new Dictionary<string, CultDocumentDescriptor[]>(source.BySchemaName, StringComparer.Ordinal);
|
|
}
|
|
|
|
public Dictionary<Type, CultDocumentDescriptor> ByType { get; }
|
|
public Dictionary<string, CultDocumentDescriptor> BySchemaId { get; }
|
|
// Every type declaring the id compatible, in registration order; resolution never depends on that order.
|
|
public Dictionary<string, CultDocumentDescriptor[]> ByCompatibleSchemaId { get; }
|
|
public Dictionary<string, CultDocumentDescriptor[]> BySchemaName { get; }
|
|
}
|
|
|
|
private static CultDocumentDescriptor BuildDescriptor(Type type)
|
|
{
|
|
var attribute = type.GetCustomAttribute<CultDocumentAttribute>()
|
|
?? throw new InvalidOperationException(
|
|
$"Type {type.FullName} is not marked with {nameof(CultDocumentAttribute)}.");
|
|
var members = DiscoverMembers(type);
|
|
var ids = CultElementIds.Inspect(type);
|
|
if (ids.Problems.Count > 0)
|
|
throw new InvalidOperationException(ids.Problems[0]);
|
|
var nameMember = members.FirstOrDefault(member => member.IsName);
|
|
var indexAccessors = members
|
|
.Where(member => member.IndexAlias != null)
|
|
.ToDictionary(
|
|
member => member.IndexAlias!,
|
|
member => member.Getter,
|
|
StringComparer.Ordinal);
|
|
var descriptorMembers = members
|
|
.Select(member => new CultDocumentMemberDescriptor
|
|
{
|
|
MemberName = member.Member.Name,
|
|
Slot = member.Slot,
|
|
TypeName = CultSchemaTypeNames.FromType(member.MemberType),
|
|
IsReference = member.IsReference,
|
|
IsMany = member.IsMany,
|
|
TargetSchemaName = member.TargetSchemaName,
|
|
IsName = member.IsName,
|
|
IndexAlias = member.IndexAlias
|
|
})
|
|
.ToArray();
|
|
var schemaJson = BuildCanonicalSchemaJson(attribute.SchemaName, attribute.SchemaVersion, descriptorMembers);
|
|
var contentHash = Sha256(schemaJson);
|
|
var semanticFingerprint = BuildSemanticFingerprint(attribute.SchemaName, attribute.SchemaVersion, descriptorMembers);
|
|
var schemaId = Sha256(semanticFingerprint);
|
|
var compatibleSchemaIds = attribute.CompatibleSchemaIds
|
|
.Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, schemaId, StringComparison.Ordinal))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
return new CultDocumentDescriptor(
|
|
type,
|
|
attribute.SchemaName,
|
|
attribute.SchemaVersion,
|
|
schemaId,
|
|
compatibleSchemaIds,
|
|
contentHash,
|
|
schemaJson,
|
|
type.GetCustomAttribute<CultGlobalAttribute>() != null,
|
|
nameMember?.Member.Name,
|
|
nameMember?.GetterNullable,
|
|
indexAccessors,
|
|
descriptorMembers,
|
|
members);
|
|
}
|
|
|
|
private static string BuildSemanticFingerprint(
|
|
string schemaName,
|
|
string schemaVersion,
|
|
IReadOnlyList<CultDocumentMemberDescriptor> members)
|
|
{
|
|
var builder = new StringBuilder();
|
|
builder.Append(schemaName)
|
|
.Append('|')
|
|
.Append(schemaVersion);
|
|
foreach (var member in members.OrderBy(member => member.Slot))
|
|
{
|
|
builder.Append('|')
|
|
.Append(member.Slot)
|
|
.Append(':')
|
|
.Append(member.MemberName)
|
|
.Append(':')
|
|
.Append(member.TypeName)
|
|
.Append(':')
|
|
.Append(member.IsReference ? "ref" : "value")
|
|
.Append(':')
|
|
.Append(member.TargetSchemaName ?? string.Empty)
|
|
.Append(':')
|
|
.Append(member.IsMany ? "many" : "one");
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static string BuildCanonicalSchemaJson(
|
|
string schemaName,
|
|
string schemaVersion,
|
|
IReadOnlyList<CultDocumentMemberDescriptor> members)
|
|
{
|
|
var builder = new StringBuilder();
|
|
builder.Append("{\"schemaName\":\"")
|
|
.Append(CultSchemaTypeNames.EscapeForLiteral(schemaName))
|
|
.Append("\",\"schemaVersion\":\"")
|
|
.Append(CultSchemaTypeNames.EscapeForLiteral(schemaVersion))
|
|
.Append("\",\"members\":[");
|
|
for (var index = 0; index < members.Count; index++)
|
|
{
|
|
var member = members[index];
|
|
if (index > 0)
|
|
{
|
|
builder.Append(',');
|
|
}
|
|
|
|
builder.Append("{\"slot\":")
|
|
.Append(member.Slot)
|
|
.Append(",\"name\":\"")
|
|
.Append(CultSchemaTypeNames.EscapeForLiteral(member.MemberName))
|
|
.Append("\",\"type\":\"")
|
|
.Append(CultSchemaTypeNames.EscapeForLiteral(member.TypeName))
|
|
.Append("\",\"isReference\":")
|
|
.Append(member.IsReference ? "true" : "false")
|
|
.Append(",\"many\":")
|
|
.Append(member.IsMany ? "true" : "false")
|
|
.Append(",\"targetSchemaName\":");
|
|
if (member.TargetSchemaName == null)
|
|
{
|
|
builder.Append("null");
|
|
}
|
|
else
|
|
{
|
|
builder.Append('"').Append(CultSchemaTypeNames.EscapeForLiteral(member.TargetSchemaName)).Append('"');
|
|
}
|
|
|
|
builder.Append(",\"indexAlias\":");
|
|
if (member.IndexAlias == null)
|
|
{
|
|
builder.Append("null");
|
|
}
|
|
else
|
|
{
|
|
builder.Append('"').Append(CultSchemaTypeNames.EscapeForLiteral(member.IndexAlias)).Append('"');
|
|
}
|
|
|
|
builder.Append(",\"isName\":")
|
|
.Append(member.IsName ? "true" : "false")
|
|
.Append('}');
|
|
}
|
|
|
|
builder.Append("]}");
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static CultSchemaResolutionResult BuildCompatibleResolutionResult(
|
|
CultSchemaCatalogEntry persisted,
|
|
CultDocumentDescriptor local)
|
|
{
|
|
if (string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal))
|
|
{
|
|
return new CultSchemaResolutionResult(
|
|
local,
|
|
new CultSchemaMigrationReport
|
|
{
|
|
PersistedSchemaId = persisted.SchemaId,
|
|
LocalSchemaId = local.SchemaId,
|
|
PersistedSchemaName = persisted.SchemaName,
|
|
LocalSchemaName = local.SchemaName,
|
|
Kind = CultSchemaMigrationKind.Exact
|
|
});
|
|
}
|
|
|
|
var persistedSchema = new CanonicalSchemaShape(
|
|
persisted.SchemaName,
|
|
persisted.SchemaVersion,
|
|
persisted.Members.Select(member => new CanonicalSchemaMember(
|
|
member.Slot,
|
|
member.MemberName,
|
|
member.TypeName,
|
|
member.IsReference,
|
|
member.IsMany,
|
|
member.TargetSchemaName,
|
|
member.IsName,
|
|
member.IndexAlias)).ToArray());
|
|
var localSchema = new CanonicalSchemaShape(
|
|
local.SchemaName,
|
|
local.SchemaVersion,
|
|
local.Members.Select(member => new CanonicalSchemaMember(
|
|
member.Slot,
|
|
member.MemberName,
|
|
member.TypeName,
|
|
member.IsReference,
|
|
member.IsMany,
|
|
member.TargetSchemaName,
|
|
member.IsName,
|
|
member.IndexAlias)).ToArray());
|
|
var comparison = CompareSchemaShapes(persistedSchema, localSchema);
|
|
if (!comparison.IsCompatible)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Persisted schema '{persisted.SchemaName}' ({persisted.SchemaId}) is incompatible with local schema '{local.SchemaName}' ({local.SchemaId}): {string.Join("; ", comparison.Errors)}");
|
|
}
|
|
|
|
var warnings = new List<CultSchemaMigrationWarning>();
|
|
if (!string.Equals(persisted.SchemaId, local.SchemaId, StringComparison.Ordinal))
|
|
{
|
|
warnings.Add(new CultSchemaMigrationWarning
|
|
{
|
|
Code = "compatible_schema_id_drift",
|
|
Message = $"Persisted schema '{persisted.SchemaId}' resolved to local schema '{local.SchemaId}'."
|
|
});
|
|
}
|
|
|
|
if (!string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal))
|
|
{
|
|
warnings.Add(new CultSchemaMigrationWarning
|
|
{
|
|
Code = "content_hash_drift",
|
|
Message = $"Persisted schema content hash '{persisted.ContentHash}' differs from local hash '{local.ContentHash}'."
|
|
});
|
|
}
|
|
|
|
warnings.AddRange(comparison.Warnings);
|
|
|
|
return new CultSchemaResolutionResult(
|
|
local,
|
|
new CultSchemaMigrationReport
|
|
{
|
|
PersistedSchemaId = persisted.SchemaId,
|
|
LocalSchemaId = local.SchemaId,
|
|
PersistedSchemaName = persisted.SchemaName,
|
|
LocalSchemaName = local.SchemaName,
|
|
Kind = CultSchemaMigrationKind.CompatibleDrift,
|
|
IgnoredExtraSlots = comparison.IgnoredExtraSlots.OrderBy(slot => slot).ToArray(),
|
|
DefaultedMissingSlots = comparison.DefaultedMissingSlots.OrderBy(slot => slot).ToArray(),
|
|
Warnings = warnings.ToArray()
|
|
});
|
|
}
|
|
|
|
private static SchemaShapeComparison CompareSchemaShapes(CanonicalSchemaShape persisted, CanonicalSchemaShape local)
|
|
{
|
|
var errors = new List<string>();
|
|
var warnings = new List<CultSchemaMigrationWarning>();
|
|
var ignoredExtraSlots = new List<int>();
|
|
var defaultedMissingSlots = new List<int>();
|
|
|
|
var persistedBySlot = persisted.Members.ToDictionary(member => member.Slot);
|
|
var localBySlot = local.Members.ToDictionary(member => member.Slot);
|
|
|
|
foreach (var localMember in local.Members.OrderBy(member => member.Slot))
|
|
{
|
|
if (!persistedBySlot.TryGetValue(localMember.Slot, out var persistedMember))
|
|
{
|
|
defaultedMissingSlots.Add(localMember.Slot);
|
|
warnings.Add(new CultSchemaMigrationWarning
|
|
{
|
|
Code = "defaulted_missing_slot",
|
|
Message = $"Local slot {localMember.Slot} ('{localMember.Name}') is missing from the persisted schema and will use the local default value."
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!string.Equals(localMember.TypeName, persistedMember.TypeName, StringComparison.Ordinal))
|
|
{
|
|
errors.Add($"slot {localMember.Slot} changed type '{persistedMember.TypeName}' -> '{localMember.TypeName}'");
|
|
}
|
|
|
|
// 1.0.x persisted a CultRecordRef<T> collection as a plain value slot (not a reference, no target); the
|
|
// payload is the same array of keys. Gaining the reference classification on an otherwise identical slot
|
|
// is compatible drift. Losing it, or any other change of reference or target, is not.
|
|
var gainedReference = !persistedMember.IsReference
|
|
&& persistedMember.TargetSchemaName == null
|
|
&& localMember.IsReference
|
|
&& localMember.TargetSchemaName != null;
|
|
if (gainedReference)
|
|
{
|
|
warnings.Add(new CultSchemaMigrationWarning
|
|
{
|
|
Code = "gained_reference_semantics",
|
|
Message = $"Local slot {localMember.Slot} ('{localMember.Name}') is a reference to '{localMember.TargetSchemaName}'; the persisted schema stored it as a plain value with the same payload."
|
|
});
|
|
}
|
|
else if (localMember.IsReference != persistedMember.IsReference)
|
|
{
|
|
errors.Add($"slot {localMember.Slot} changed reference semantics");
|
|
}
|
|
|
|
if (localMember.IsMany != persistedMember.IsMany)
|
|
{
|
|
errors.Add($"slot {localMember.Slot} changed cardinality");
|
|
}
|
|
|
|
if (!gainedReference && !string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal))
|
|
{
|
|
errors.Add($"slot {localMember.Slot} changed target schema '{persistedMember.TargetSchemaName ?? "<none>"}' -> '{localMember.TargetSchemaName ?? "<none>"}'");
|
|
}
|
|
|
|
if (localMember.IsName != persistedMember.IsName)
|
|
{
|
|
errors.Add($"slot {localMember.Slot} changed name-lookup semantics");
|
|
}
|
|
|
|
if (!string.Equals(localMember.IndexAlias, persistedMember.IndexAlias, StringComparison.Ordinal))
|
|
{
|
|
errors.Add($"slot {localMember.Slot} changed index alias '{persistedMember.IndexAlias ?? "<none>"}' -> '{localMember.IndexAlias ?? "<none>"}'");
|
|
}
|
|
}
|
|
|
|
foreach (var persistedMember in persisted.Members.OrderBy(member => member.Slot))
|
|
{
|
|
if (localBySlot.ContainsKey(persistedMember.Slot))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ignoredExtraSlots.Add(persistedMember.Slot);
|
|
warnings.Add(new CultSchemaMigrationWarning
|
|
{
|
|
Code = "ignored_extra_slot",
|
|
Message = $"Persisted slot {persistedMember.Slot} ('{persistedMember.Name}') is not present locally and will be ignored."
|
|
});
|
|
}
|
|
|
|
return new SchemaShapeComparison(
|
|
errors.Count == 0,
|
|
errors.ToArray(),
|
|
warnings.ToArray(),
|
|
ignoredExtraSlots.ToArray(),
|
|
defaultedMissingSlots.ToArray());
|
|
}
|
|
|
|
private sealed class CanonicalSchemaShape
|
|
{
|
|
public CanonicalSchemaShape(string schemaName, string schemaVersion, CanonicalSchemaMember[] members)
|
|
{
|
|
SchemaName = schemaName;
|
|
SchemaVersion = schemaVersion;
|
|
Members = members;
|
|
}
|
|
|
|
public string SchemaName { get; }
|
|
|
|
public string SchemaVersion { get; }
|
|
|
|
public CanonicalSchemaMember[] Members { get; }
|
|
}
|
|
|
|
private sealed class CanonicalSchemaMember
|
|
{
|
|
public CanonicalSchemaMember(
|
|
int slot,
|
|
string name,
|
|
string typeName,
|
|
bool isReference,
|
|
bool isMany,
|
|
string? targetSchemaName,
|
|
bool isName,
|
|
string? indexAlias)
|
|
{
|
|
Slot = slot;
|
|
Name = name;
|
|
TypeName = typeName;
|
|
IsReference = isReference;
|
|
IsMany = isMany;
|
|
TargetSchemaName = targetSchemaName;
|
|
IsName = isName;
|
|
IndexAlias = indexAlias;
|
|
}
|
|
|
|
public int Slot { get; }
|
|
|
|
public string Name { get; }
|
|
|
|
public string TypeName { get; }
|
|
|
|
public bool IsReference { get; }
|
|
|
|
public bool IsMany { get; }
|
|
|
|
public string? TargetSchemaName { get; }
|
|
|
|
public bool IsName { get; }
|
|
|
|
public string? IndexAlias { get; }
|
|
}
|
|
|
|
private sealed class SchemaShapeComparison
|
|
{
|
|
public SchemaShapeComparison(
|
|
bool isCompatible,
|
|
string[] errors,
|
|
CultSchemaMigrationWarning[] warnings,
|
|
int[] ignoredExtraSlots,
|
|
int[] defaultedMissingSlots)
|
|
{
|
|
IsCompatible = isCompatible;
|
|
Errors = errors;
|
|
Warnings = warnings;
|
|
IgnoredExtraSlots = ignoredExtraSlots;
|
|
DefaultedMissingSlots = defaultedMissingSlots;
|
|
}
|
|
|
|
public bool IsCompatible { get; }
|
|
|
|
public string[] Errors { get; }
|
|
|
|
public CultSchemaMigrationWarning[] Warnings { get; }
|
|
|
|
public int[] IgnoredExtraSlots { get; }
|
|
|
|
public int[] DefaultedMissingSlots { get; }
|
|
}
|
|
|
|
// Public instance fields and properties (get-only included: MessagePack writes every keyed public member), most-derived
|
|
// declaration first; GetCustomAttributes inherits a property's attributes up its override chain, as MessagePack's resolver reads them.
|
|
private static IReadOnlyList<PersistedMember> DiscoverMembers(Type type)
|
|
{
|
|
const BindingFlags declared = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public;
|
|
var messagePackObject = type.GetCustomAttribute<MessagePackObjectAttribute>(true);
|
|
var allowPrivate = messagePackObject?.AllowPrivate == true;
|
|
// Writable: MessagePack can assign the member after construction. A member that is not must be filled by the constructor.
|
|
var candidates = new List<(MemberInfo Member, Type MemberType, int Depth, bool Writable, PropertyInfo? DivergentRoot)>();
|
|
var seenRoots = new HashSet<(Type, string)>();
|
|
var depth = 0;
|
|
for (var current = type; current != null && current != typeof(object); current = current.BaseType, depth++)
|
|
{
|
|
foreach (var field in current.GetFields(declared))
|
|
{
|
|
// MessagePack's rule: a readonly field is writable under AllowPrivate only.
|
|
if (!IsIgnored(field))
|
|
candidates.Add((field, field.FieldType, depth, !field.IsInitOnly || allowPrivate, null));
|
|
}
|
|
|
|
foreach (var property in current.GetProperties(declared))
|
|
{
|
|
if (property.GetIndexParameters().Length > 0 || !seenRoots.Add(RootOf(property)))
|
|
continue;
|
|
var chain = OverrideChain(property).ToArray();
|
|
var setter = chain.Select(declaration => declaration.SetMethod).FirstOrDefault(method => method != null);
|
|
if (chain.All(declaration => declaration.GetMethod == null))
|
|
continue;
|
|
// MessagePack's resolver reads the base declaration's [Key]/[IgnoreMember]; an override that changes them is refused.
|
|
var root = chain[chain.Length - 1];
|
|
var divergentRoot = chain.Any(declaration =>
|
|
!Equals(GetKeyValue(declaration), GetKeyValue(root)) || IsIgnored(declaration) != IsIgnored(root))
|
|
? root
|
|
: null;
|
|
if (divergentRoot == null && IsIgnored(property))
|
|
continue;
|
|
// MessagePack's own rule: it calls a setter that is public, or any setter (init-only included) under AllowPrivate.
|
|
candidates.Add((property, property.PropertyType, depth, setter != null && (setter.IsPublic || allowPrivate), divergentRoot));
|
|
}
|
|
}
|
|
|
|
var rejections = new List<string>();
|
|
if (messagePackObject == null)
|
|
rejections.Add(NotMessagePackObjectMessage(type.Name));
|
|
else if (!type.IsVisible && !allowPrivate)
|
|
rejections.Add(NotVisibleMessage(type.Name));
|
|
else
|
|
{
|
|
// AllowPrivate makes MessagePack read non-public members, which the registry does not persist; without it MessagePack
|
|
// silently skips a keyed non-public member.
|
|
const BindingFlags nonPublic = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.NonPublic;
|
|
for (var current = type; current != null && current != typeof(object); current = current.BaseType)
|
|
{
|
|
foreach (var member in current.GetFields(nonPublic).Cast<MemberInfo>().Concat(current.GetProperties(nonPublic)))
|
|
{
|
|
if (allowPrivate && !member.IsDefined(typeof(CompilerGeneratedAttribute)) && !IsIgnored(member))
|
|
rejections.Add(UnmarkedNonPublicMemberMessage(type.Name, Qualified(member)));
|
|
else if (!allowPrivate && GetKeyValue(member) != null && !IsIgnored(member))
|
|
rejections.Add(KeyedNonPublicMemberMessage(type.Name, Qualified(member)));
|
|
}
|
|
}
|
|
}
|
|
var keyed = new List<(MemberInfo Member, Type MemberType, int Slot, bool Writable)>();
|
|
foreach (var candidate in candidates.OrderBy(candidate => candidate.Depth).ThenBy(candidate => candidate.Member.Name, StringComparer.Ordinal))
|
|
{
|
|
var key = GetKeyValue(candidate.Member);
|
|
if (candidate.DivergentRoot != null)
|
|
rejections.Add(DivergentOverrideMessage(type.Name, Qualified(candidate.Member), Qualified(candidate.DivergentRoot)));
|
|
else if (key is string)
|
|
rejections.Add(StringKeyMessage(type.Name, candidate.Member.Name));
|
|
else if (key is not int slot)
|
|
rejections.Add(UnkeyedMemberMessage(type.Name, candidate.Member.Name));
|
|
else
|
|
keyed.Add((candidate.Member, candidate.MemberType, slot, candidate.Writable));
|
|
}
|
|
|
|
if (messagePackObject != null)
|
|
{
|
|
// A member MessagePack cannot assign is read back only through the picked constructor's parameter at its slot.
|
|
var arity = ConstructorArity(type, allowPrivate, keyed.Select(entry => (entry.MemberType, entry.Slot)));
|
|
if (arity == null)
|
|
rejections.Add(NoConstructorMessage(type.Name));
|
|
else
|
|
{
|
|
foreach (var entry in keyed.Where(entry => !entry.Writable && entry.Slot >= arity))
|
|
{
|
|
rejections.Add(entry.Member switch
|
|
{
|
|
FieldInfo => ReadonlyUnfilledMessage(type.Name, entry.Member.Name, entry.Slot),
|
|
PropertyInfo property when property.SetMethod == null && OverrideChain(property).All(declaration => declaration.SetMethod == null) =>
|
|
GetOnlyUnfilledMessage(type.Name, entry.Member.Name, entry.Slot),
|
|
_ => NonPublicSetterMessage(type.Name, entry.Member.Name)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var group in keyed.GroupBy(entry => entry.Slot).Where(group => group.Count() > 1).OrderBy(group => group.Key))
|
|
{
|
|
var pair = group.Take(2).ToArray();
|
|
rejections.Add(DuplicateSlotMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member), group.Key));
|
|
}
|
|
|
|
foreach (var group in keyed.GroupBy(entry => entry.Member.Name)
|
|
.Where(group => group.Select(entry => entry.Slot).Distinct().Count() > 1)
|
|
.OrderBy(group => group.Key, StringComparer.Ordinal))
|
|
{
|
|
var pair = group.Take(2).ToArray();
|
|
rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member)));
|
|
}
|
|
|
|
// D10: two differently-named members that independently declare the same index alias. A
|
|
// hiding member sharing one slot is already caught above; this is two distinct members whose
|
|
// alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.
|
|
foreach (var group in keyed
|
|
.Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member)))
|
|
.Where(pair => pair.Alias != null)
|
|
.GroupBy(pair => pair.Alias, StringComparer.Ordinal)
|
|
.Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1)
|
|
.OrderBy(group => group.Key, StringComparer.Ordinal))
|
|
{
|
|
var pair = group.Take(2).ToArray();
|
|
rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));
|
|
}
|
|
|
|
// D10b: the reference-role namespace is (IndexAlias ?? MemberName) for every member, not
|
|
// only the aliased ones D10 groups above. A member with no [CultIndex] still contributes
|
|
// its bare name to that namespace, and a reference member declaring the same string as its
|
|
// alias collides with it - the collision D10 cannot see because an unaliased member's Alias
|
|
// is null.
|
|
foreach (var group in keyed
|
|
.Select(entry => (
|
|
Entry: entry,
|
|
Role: ResolveIndexAlias(entry.Member) ?? entry.Member.Name,
|
|
IsReference: PersistedMember.IsReferenceCandidate(entry.Member, entry.MemberType)))
|
|
.GroupBy(pair => pair.Role, StringComparer.Ordinal)
|
|
.Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1
|
|
&& group.Any(pair => pair.IsReference))
|
|
.OrderBy(group => group.Key, StringComparer.Ordinal))
|
|
{
|
|
var pair = group.Take(2).ToArray();
|
|
rejections.Add(DuplicateReferenceRoleMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key));
|
|
}
|
|
|
|
// D11: a member declared as a reference (by [CultReference] or a bare CultRecordRef<T> type)
|
|
// whose CLR shape the cache cannot walk. many:false needs CultRecordRef<T> itself; many:true
|
|
// needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>. A declaration the
|
|
// cache cannot read is refused here, not answered as an unreadable no-op reference later.
|
|
foreach (var candidate in keyed)
|
|
{
|
|
var referenceAttribute = candidate.Member.GetCustomAttribute<CultReferenceAttribute>();
|
|
var isBareRecordRef = IsCultRecordRefType(candidate.MemberType);
|
|
if (referenceAttribute == null && !isBareRecordRef)
|
|
continue;
|
|
var many = referenceAttribute?.Many ?? false;
|
|
var supported = many
|
|
? IsEnumerableOfRecordRef(candidate.MemberType) || TryGetDictionaryRefTypes(candidate.MemberType, out _, out _)
|
|
: isBareRecordRef;
|
|
if (!supported)
|
|
rejections.Add(UnsupportedReferenceShapeMessage(type.Name, Qualified(candidate.Member)));
|
|
}
|
|
|
|
if (rejections.Count > 0)
|
|
throw new InvalidOperationException(rejections[0]);
|
|
|
|
return keyed
|
|
.Select(entry => PersistedMember.FromMember(
|
|
entry.Member,
|
|
entry.MemberType,
|
|
entry.Member is FieldInfo field ? field.GetValue : ((PropertyInfo)entry.Member).GetValue,
|
|
entry.Slot))
|
|
.OrderBy(member => member.Slot)
|
|
.ToArray();
|
|
}
|
|
|
|
private static (Type, string) RootOf(PropertyInfo property) =>
|
|
((property.GetMethod ?? property.SetMethod)!.GetBaseDefinition().DeclaringType!, property.Name);
|
|
|
|
private static IEnumerable<PropertyInfo> OverrideChain(PropertyInfo property)
|
|
{
|
|
var root = RootOf(property);
|
|
for (var current = property.DeclaringType; current != null; current = current.BaseType)
|
|
{
|
|
var declaration = current.GetProperty(property.Name, BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
if (declaration != null && declaration.GetIndexParameters().Length == 0 && RootOf(declaration) == root)
|
|
yield return declaration;
|
|
}
|
|
}
|
|
|
|
// MessagePack's DynamicObjectResolver picks the [SerializationConstructor] if one is marked, otherwise the longest public constructor
|
|
// (any constructor under AllowPrivate) whose every parameter takes the keyed member at its position (slot == parameter index).
|
|
// Returns that constructor's parameter count, 0 for a struct with none, or null when MessagePack finds no constructor.
|
|
private static int? ConstructorArity(Type type, bool allowPrivate, IEnumerable<(Type MemberType, int Slot)> members)
|
|
{
|
|
var slots = new Dictionary<int, Type>();
|
|
foreach (var member in members)
|
|
slots.TryAdd(member.Slot, member.MemberType);
|
|
var constructors = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
var marked = constructors.Where(constructor => constructor.IsDefined(typeof(SerializationConstructorAttribute))).ToArray();
|
|
var arities = (marked.Length > 0 ? marked : constructors.Where(constructor => allowPrivate || constructor.IsPublic))
|
|
.Select(constructor => constructor.GetParameters())
|
|
.Where(parameters => parameters.All(parameter =>
|
|
slots.TryGetValue(parameter.Position, out var memberType) && parameter.ParameterType.IsAssignableFrom(memberType)))
|
|
.Select(parameters => (int?)parameters.Length)
|
|
.ToArray();
|
|
return arities.Length > 0 ? arities.Max() : type.IsValueType && marked.Length == 0 ? 0 : null;
|
|
}
|
|
|
|
private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name;
|
|
|
|
// Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration
|
|
// refuses exactly the shapes the accessor it builds afterward could not have walked anyway.
|
|
private static string? ResolveIndexAlias(MemberInfo member)
|
|
{
|
|
var indexAttribute = member.GetCustomAttribute<CultIndexAttribute>();
|
|
if (indexAttribute == null)
|
|
return null;
|
|
return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias;
|
|
}
|
|
|
|
private static bool IsCultRecordRefType(Type type) =>
|
|
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>);
|
|
|
|
private static bool IsEnumerableOfRecordRef(Type type)
|
|
{
|
|
foreach (var candidate in type.GetInterfaces().Append(type))
|
|
{
|
|
if (candidate.IsGenericType
|
|
&& candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>)
|
|
&& IsCultRecordRefType(candidate.GetGenericArguments()[0]))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool TryGetDictionaryRefTypes(Type type, out PropertyInfo keyProperty, out PropertyInfo valueProperty)
|
|
{
|
|
foreach (var candidate in type.GetInterfaces().Append(type))
|
|
{
|
|
if (!candidate.IsGenericType || candidate.GetGenericTypeDefinition() != typeof(IDictionary<,>))
|
|
continue;
|
|
var arguments = candidate.GetGenericArguments();
|
|
if (!IsCultRecordRefType(arguments[0]))
|
|
continue;
|
|
var pairType = typeof(KeyValuePair<,>).MakeGenericType(arguments[0], arguments[1]);
|
|
keyProperty = pairType.GetProperty("Key")!;
|
|
valueProperty = pairType.GetProperty("Value")!;
|
|
return true;
|
|
}
|
|
|
|
keyProperty = null!;
|
|
valueProperty = null!;
|
|
return false;
|
|
}
|
|
|
|
private static readonly HashSet<Type> NumericClrTypes = new()
|
|
{
|
|
typeof(sbyte), typeof(byte), typeof(short), typeof(ushort),
|
|
typeof(int), typeof(uint), typeof(long), typeof(ulong),
|
|
typeof(float), typeof(double), typeof(decimal)
|
|
};
|
|
|
|
// D8's closed CLR numeric set: the sole authority for whether a comparison predicate may reach
|
|
// a member. The TypeName string persisted in the catalog is generated from this set (via
|
|
// CultSchemaTypeNames.FromType) and must never be hand-checked against separately - S22 pins it.
|
|
private static bool IsNumericClrType(Type type)
|
|
{
|
|
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
|
return NumericClrTypes.Contains(underlying);
|
|
}
|
|
|
|
// Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers"): a row's exact canonical decimal,
|
|
// never a double - a long past 2^53 must not lose precision passing through this path. Boxing a
|
|
// Nullable<T> with a value produces a boxed T (never a boxed Nullable<T>), so raw is always one
|
|
// of the closed CLR numeric set's non-nullable types here. NaN and infinity render as null:
|
|
// "a NaN or infinite member matches no comparison."
|
|
private static string? RenderCanonicalNumber(object raw) => raw switch
|
|
{
|
|
float f => float.IsNaN(f) || float.IsInfinity(f) ? null : RenderExactFloat(f),
|
|
double d => double.IsNaN(d) || double.IsInfinity(d) ? null : RenderExactDouble(d),
|
|
decimal m => CanonicalizeDecimalDigits(m.ToString(CultureInfo.InvariantCulture)),
|
|
_ => CanonicalizeDecimalDigits(Convert.ToString(raw, CultureInfo.InvariantCulture)!)
|
|
};
|
|
|
|
// R-D (docs/cultnet-selection-cut.md): the wire's canonical decimal is the value's exact decimal
|
|
// expansion, not a shortest round-trip form. .NET's default float/double ToString has used the
|
|
// shortest round-trip form since .NET Core 3.0, which rounds a tie between two decimals to
|
|
// whichever is "shorter" - Rust's shortest form breaks the same tie the other way at some
|
|
// values, so the two runtimes rendered different strings for the same bit pattern (394/200k f32,
|
|
// 48/200k f64 in the parity vectors). Every finite float and double is an exact dyadic rational
|
|
// (significand * 2^exponent for an integer significand and exponent), so it has a finite exact
|
|
// decimal expansion computed with BigInteger: multiply the significand by 5^-exponent when the
|
|
// exponent is negative (1/2^k == 5^k/10^k) to get the exact digits, and place the decimal point.
|
|
// No CLR floating-point arithmetic and no string round-tripping is on this path.
|
|
private static string RenderExactFloat(float value)
|
|
{
|
|
var bits = BitConverter.SingleToInt32Bits(value);
|
|
var negative = bits < 0;
|
|
var biasedExponent = (bits >> 23) & 0xFF;
|
|
var mantissaBits = bits & 0x7FFFFF;
|
|
if (biasedExponent == 0 && mantissaBits == 0)
|
|
return "0"; // canonical zero: a single "0", never "-0" (Q-J).
|
|
|
|
BigInteger significand;
|
|
int exponent;
|
|
if (biasedExponent == 0)
|
|
{
|
|
significand = mantissaBits;
|
|
exponent = -126 - 23; // subnormal: no implicit leading 1, minimum exponent.
|
|
}
|
|
else
|
|
{
|
|
significand = mantissaBits | (1 << 23);
|
|
exponent = biasedExponent - 127 - 23;
|
|
}
|
|
|
|
return RenderExactDecimal(negative, significand, exponent);
|
|
}
|
|
|
|
private static string RenderExactDouble(double value)
|
|
{
|
|
var bits = BitConverter.DoubleToInt64Bits(value);
|
|
var negative = bits < 0;
|
|
var biasedExponent = (int)((bits >> 52) & 0x7FF);
|
|
var mantissaBits = bits & 0xFFFFFFFFFFFFFL;
|
|
if (biasedExponent == 0 && mantissaBits == 0)
|
|
return "0"; // canonical zero: a single "0", never "-0" (Q-J).
|
|
|
|
BigInteger significand;
|
|
int exponent;
|
|
if (biasedExponent == 0)
|
|
{
|
|
significand = mantissaBits;
|
|
exponent = -1022 - 52; // subnormal: no implicit leading 1, minimum exponent.
|
|
}
|
|
else
|
|
{
|
|
significand = mantissaBits | (1L << 52);
|
|
exponent = biasedExponent - 1023 - 52;
|
|
}
|
|
|
|
return RenderExactDecimal(negative, significand, exponent);
|
|
}
|
|
|
|
// value = significand * 2^exponent, significand > 0. exponent >= 0 is an exact integer shift;
|
|
// exponent < 0 rewrites 2^exponent as 5^-exponent / 10^-exponent, so the digits are exact and
|
|
// the decimal point lands -exponent places from the right - no precision limit, no rounding.
|
|
private static string RenderExactDecimal(bool negative, BigInteger significand, int exponent)
|
|
{
|
|
string digits;
|
|
int fractionDigits;
|
|
if (exponent >= 0)
|
|
{
|
|
digits = (significand << exponent).ToString(CultureInfo.InvariantCulture);
|
|
fractionDigits = 0;
|
|
}
|
|
else
|
|
{
|
|
fractionDigits = -exponent;
|
|
digits = (significand * BigInteger.Pow(5, fractionDigits)).ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
string positional;
|
|
if (fractionDigits == 0)
|
|
{
|
|
positional = digits;
|
|
}
|
|
else
|
|
{
|
|
if (digits.Length <= fractionDigits)
|
|
digits = digits.PadLeft(fractionDigits + 1, '0');
|
|
var splitAt = digits.Length - fractionDigits;
|
|
positional = digits.Substring(0, splitAt) + "." + digits.Substring(splitAt);
|
|
}
|
|
|
|
return CanonicalizeDecimalDigits(negative ? "-" + positional : positional);
|
|
}
|
|
|
|
// The one canonicalizer every rendered number passes through: strips leading integer zeros and
|
|
// trailing fractional zeros, and collapses a negative value that reduces to zero into "0" - the
|
|
// door's regex forbids all three spellings on the way in (Q-J), and this is why the row side
|
|
// never produces one on the way out.
|
|
private static string CanonicalizeDecimalDigits(string signedPositional)
|
|
{
|
|
var negative = signedPositional.Length > 0 && signedPositional[0] == '-';
|
|
var unsigned = negative ? signedPositional.Substring(1) : signedPositional;
|
|
var dot = unsigned.IndexOf('.');
|
|
var integerPart = dot < 0 ? unsigned : unsigned.Substring(0, dot);
|
|
var fractionPart = dot < 0 ? string.Empty : unsigned.Substring(dot + 1);
|
|
|
|
integerPart = integerPart.TrimStart('0');
|
|
if (integerPart.Length == 0) integerPart = "0";
|
|
fractionPart = fractionPart.TrimEnd('0');
|
|
|
|
var isZero = integerPart == "0" && fractionPart.Length == 0;
|
|
var result = fractionPart.Length == 0 ? integerPart : integerPart + "." + fractionPart;
|
|
return negative && !isZero ? "-" + result : result;
|
|
}
|
|
|
|
private static string DuplicateIndexAliasMessage(string documentTypeName, string first, string second, string alias) =>
|
|
$"Cult document {documentTypeName} members {first} and {second} both declare index alias \"{alias}\"; an index alias must name one member.";
|
|
|
|
// D10b (R-AL): a plain member's bare name colliding with a reference member's index alias.
|
|
// CultDocumentRegistry.ReferencesOf and CultNetSelectionEvaluator.ReferenceMembers both key
|
|
// reference lookups off (IndexAlias ?? MemberName); D10 above only groups members that
|
|
// declare an alias, so an unaliased member's bare name sharing that string with a reference
|
|
// member's alias registered silently and faulted every hop through it at resolution time.
|
|
//
|
|
// Breaking registration change, noted per the fix batch 7 ruling: a shape that used to
|
|
// register and work - a data member aliased "Owner" beside a reference member named
|
|
// "Owner" - is now refused. There is no compatibility path; the collision was always a
|
|
// silent resolution fault waiting to happen, so the fix is a door, not a warning.
|
|
private static string DuplicateReferenceRoleMessage(string documentTypeName, string first, string second, string role) =>
|
|
$"Cult document {documentTypeName} members {first} and {second} both resolve to reference role \"{role}\" (index alias, or bare member name when unaliased); a reference role must name one member. " +
|
|
$"This is a breaking registration change from before D10b existed: a shape where a plain member's bare name collided with a reference member's index alias used to register silently and fault at resolution time instead.";
|
|
|
|
private static string UnsupportedReferenceShapeMessage(string documentTypeName, string member) =>
|
|
$"Cult document {documentTypeName} member {member} declares a reference the cache cannot walk; many:false needs CultRecordRef<T>, many:true needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>.";
|
|
|
|
private static string NotMessagePackObjectMessage(string documentTypeName) =>
|
|
$"Cult document {documentTypeName} is not a MessagePack object; add [MessagePackObject] so MessagePack serializes its [Key(n)] members.";
|
|
|
|
private static string NotVisibleMessage(string documentTypeName) =>
|
|
$"Cult document {documentTypeName} is not public; MessagePack serializes a non-public type only with [MessagePackObject(AllowPrivate = true)], so make it public or set AllowPrivate.";
|
|
|
|
private static string UnmarkedNonPublicMemberMessage(string documentTypeName, string memberName) =>
|
|
$"Cult document {documentTypeName} member {memberName} is non-public; [MessagePackObject(AllowPrivate = true)] makes MessagePack read it, so mark it [IgnoreMember].";
|
|
|
|
internal static string UnkeyedMemberMessage(string documentTypeName, string memberName) =>
|
|
$"Cult document {documentTypeName} member {memberName} has no [Key]; MessagePack requires [Key(n)] or [IgnoreMember] on every public member, so mark it one or the other.";
|
|
|
|
private static string StringKeyMessage(string documentTypeName, string memberName) =>
|
|
$"Cult document {documentTypeName} member {memberName} has a string [Key]; string keys are not supported; use integer [Key(n)].";
|
|
|
|
private static string ReadonlyUnfilledMessage(string documentTypeName, string memberName, int slot) =>
|
|
$"Cult document {documentTypeName} member {memberName} is a readonly field that no constructor fills; MessagePack writes it but never reads it back, so give the constructor MessagePack calls a parameter at position {slot}, drop readonly, or add [MessagePackObject(AllowPrivate = true)].";
|
|
|
|
private static string GetOnlyUnfilledMessage(string documentTypeName, string memberName, int slot) =>
|
|
$"Cult document {documentTypeName} member {memberName} is a get-only property that no constructor fills; MessagePack writes it but never reads it back, so give the constructor MessagePack calls a parameter at position {slot}, add a setter, or mark it [IgnoreMember].";
|
|
|
|
private static string NonPublicSetterMessage(string documentTypeName, string memberName) =>
|
|
$"Cult document {documentTypeName} member {memberName} has a non-public set accessor; MessagePack writes it but never reads it back without AllowPrivate, so make the setter public or add [MessagePackObject(AllowPrivate = true)].";
|
|
|
|
private static string KeyedNonPublicMemberMessage(string documentTypeName, string memberName) =>
|
|
$"Cult document {documentTypeName} member {memberName} is non-public; MessagePack skips it silently, so [Key] on a non-public member requires [MessagePackObject(AllowPrivate = true)].";
|
|
|
|
private static string NoConstructorMessage(string documentTypeName) =>
|
|
$"Cult document {documentTypeName} has no constructor MessagePack can call; add a parameterless constructor (public unless AllowPrivate), or one whose parameters take the [Key(0)], [Key(1)], ... members in order.";
|
|
|
|
private static string DuplicateSlotMessage(string documentTypeName, string first, string second, int slot) =>
|
|
$"Cult document {documentTypeName} members {first} and {second} share [Key({slot})]; every persisted member needs a distinct [Key(n)].";
|
|
|
|
private static string DivergentOverrideMessage(string documentTypeName, string member, string root) =>
|
|
$"Cult document {documentTypeName} member {member} overrides {root} with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them.";
|
|
|
|
private static string HiddenMemberMessage(string documentTypeName, string first, string second) =>
|
|
$"Cult document {documentTypeName} member {first} hides persisted member {second}; persisted member names must be unique.";
|
|
|
|
private static bool IsIgnored(MemberInfo member) =>
|
|
member.GetCustomAttribute<global::MessagePack.IgnoreMemberAttribute>() != null;
|
|
|
|
private static object? GetKeyValue(MemberInfo member)
|
|
{
|
|
var key = member.GetCustomAttribute<global::MessagePack.KeyAttribute>();
|
|
return key?.IntKey is int slot ? slot : key?.StringKey;
|
|
}
|
|
|
|
private static string Sha256(string input)
|
|
{
|
|
using var sha = SHA256.Create();
|
|
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
|
|
return "sha256:" + BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant();
|
|
}
|
|
|
|
// internal, not private: CultDocumentDescriptor (a separate top-level class) reads RichMembers
|
|
// through this type to build the public DeclaredMembers view and the three value accessors.
|
|
internal sealed class PersistedMember
|
|
{
|
|
public MemberInfo Member { get; set; } = default!;
|
|
public Type MemberType { get; set; } = default!;
|
|
public int Slot { get; set; }
|
|
public bool IsName { get; set; }
|
|
public string? IndexAlias { get; set; }
|
|
public bool IsReference { get; set; }
|
|
public bool IsMany { get; set; }
|
|
public string? TargetSchemaName { get; set; }
|
|
|
|
// The reference's declared target Type, held alongside TargetSchemaName (which is null for
|
|
// an abstract target - D9). CultDocumentRegistry.ResolveTargetLeaves needs the Type, not the
|
|
// name, to compute the leaf set by assignability.
|
|
public Type? TargetType { get; set; }
|
|
public bool IsNumeric { get; set; }
|
|
public Func<object, string> Getter { get; set; } = default!;
|
|
public Func<object, string?> GetterNullable { get; set; } = default!;
|
|
|
|
// D8/Q-J (revised 2026-09-22): non-null exactly for the closed CLR numeric set (Nullable<T>
|
|
// unwrapped), rendered as the member's exact canonical decimal string - never a double, so
|
|
// no CLR numeric type's range or precision is lost rendering it. docs/cultnet-selection-cut.md
|
|
// section 2 "Numbers".
|
|
public Func<object, string?> NumberOf { get; set; } = default!;
|
|
|
|
// D11: null for a non-reference member; otherwise yields (target key, payload) per declared
|
|
// shape - one entry for a bare reference, one per element for a many-enumerable, one per key
|
|
// with its value as payload for a many-dictionary. Registration already refused every other
|
|
// shape (DiscoverMembers), so this is never called against something it cannot walk.
|
|
public Func<object, IEnumerable<(CultRecordKey Target, object? Payload)>>? ReferenceEnumerator { get; set; }
|
|
|
|
public static PersistedMember FromMember(
|
|
MemberInfo member,
|
|
Type memberType,
|
|
Func<object, object?> getValue,
|
|
int slot)
|
|
{
|
|
var referenceAttribute = member.GetCustomAttribute<CultReferenceAttribute>();
|
|
var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType);
|
|
var targetSchemaName = targetType?.GetCustomAttribute<CultDocumentAttribute>()?.SchemaName;
|
|
var isReference = targetType != null || referenceAttribute != null;
|
|
var many = referenceAttribute?.Many ?? false;
|
|
return new PersistedMember
|
|
{
|
|
Member = member,
|
|
MemberType = memberType,
|
|
Slot = slot,
|
|
IsName = member.GetCustomAttribute<CultNameAttribute>() != null,
|
|
IndexAlias = ResolveIndexAlias(member),
|
|
IsReference = isReference,
|
|
IsMany = many,
|
|
TargetSchemaName = targetSchemaName,
|
|
TargetType = targetType,
|
|
IsNumeric = IsNumericClrType(memberType),
|
|
Getter = document => getValue(document)?.ToString() ?? string.Empty,
|
|
GetterNullable = document => getValue(document)?.ToString(),
|
|
NumberOf = document =>
|
|
{
|
|
var raw = getValue(document);
|
|
return raw == null ? null : RenderCanonicalNumber(raw);
|
|
},
|
|
ReferenceEnumerator = isReference ? BuildReferenceEnumerator(memberType, many, getValue) : null
|
|
};
|
|
}
|
|
|
|
// Registration (DiscoverMembers' D11 check) already refused every shape but the three this
|
|
// builds an enumerator for; this does not re-refuse, it only classifies which of the three.
|
|
private static Func<object, IEnumerable<(CultRecordKey Target, object? Payload)>> BuildReferenceEnumerator(
|
|
Type memberType, bool many, Func<object, object?> getValue)
|
|
{
|
|
if (!many)
|
|
{
|
|
return document =>
|
|
getValue(document) is ICultRecordRef reference
|
|
? new[] { (reference.Key, (object?)null) }
|
|
: Array.Empty<(CultRecordKey, object?)>();
|
|
}
|
|
|
|
if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))
|
|
{
|
|
return document =>
|
|
{
|
|
if (getValue(document) is not System.Collections.IEnumerable sequence)
|
|
return Array.Empty<(CultRecordKey, object?)>();
|
|
var results = new List<(CultRecordKey, object?)>();
|
|
foreach (var entry in sequence)
|
|
{
|
|
if (keyProperty.GetValue(entry) is ICultRecordRef reference)
|
|
results.Add((reference.Key, valueProperty.GetValue(entry)));
|
|
}
|
|
|
|
return results;
|
|
};
|
|
}
|
|
|
|
return document =>
|
|
{
|
|
if (getValue(document) is not System.Collections.IEnumerable sequence)
|
|
return Array.Empty<(CultRecordKey, object?)>();
|
|
var results = new List<(CultRecordKey, object?)>();
|
|
foreach (var entry in sequence)
|
|
{
|
|
if (entry is ICultRecordRef reference)
|
|
results.Add((reference.Key, (object?)null));
|
|
}
|
|
|
|
return results;
|
|
};
|
|
}
|
|
|
|
// Mirrors the isReference computation FromMember uses below, callable from DiscoverMembers
|
|
// (D10b) before a slot is turned into a full PersistedMember.
|
|
internal static bool IsReferenceCandidate(MemberInfo member, Type memberType)
|
|
{
|
|
var referenceAttribute = member.GetCustomAttribute<CultReferenceAttribute>();
|
|
var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType);
|
|
return targetType != null || referenceAttribute != null;
|
|
}
|
|
|
|
private static Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget)
|
|
{
|
|
if (explicitTarget != null)
|
|
{
|
|
return explicitTarget;
|
|
}
|
|
|
|
if (memberType.IsGenericType &&
|
|
memberType.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
|
|
{
|
|
return memberType.GetGenericArguments()[0];
|
|
}
|
|
|
|
// R-T(b): a many reference's implicit target is its CultRecordRef<T> element's T - the
|
|
// same narrowing a scalar CultRecordRef<T> gets above - not left null just because the
|
|
// member's own declared type is List<> or IDictionary<,>, not CultRecordRef<> itself.
|
|
// Before this, an implicitly-typed many reference reached CultNetSelectionEvaluator's
|
|
// EnsureWithinDeclaredTarget with a null TargetType and its out-of-target check was
|
|
// silently skipped for every edge through it (R-T(b)/S18).
|
|
if (IsEnumerableOfRecordRef(memberType))
|
|
{
|
|
foreach (var candidate in memberType.GetInterfaces().Append(memberType))
|
|
{
|
|
if (candidate.IsGenericType &&
|
|
candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>) &&
|
|
IsCultRecordRefType(candidate.GetGenericArguments()[0]))
|
|
{
|
|
return candidate.GetGenericArguments()[0].GetGenericArguments()[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out _))
|
|
{
|
|
return keyProperty.PropertyType.GetGenericArguments()[0];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum CultCommitOutcome
|
|
{
|
|
Committed,
|
|
Mismatch,
|
|
Contended
|
|
}
|
|
|
|
public sealed class CultCommitRequest
|
|
{
|
|
internal CultCommitRequest(
|
|
IReadOnlyList<CultStoredDocument> upserts,
|
|
IReadOnlyList<CultStoredDocument> deletes,
|
|
IReadOnlyList<string> checks,
|
|
IReadOnlyList<(CultRecordKey Key, string? Version)> expected,
|
|
bool expectUnchanged)
|
|
{
|
|
Upserts = upserts;
|
|
Deletes = deletes;
|
|
Checks = checks;
|
|
Expected = expected;
|
|
ExpectUnchanged = expectUnchanged;
|
|
}
|
|
|
|
public IReadOnlyList<CultStoredDocument> Upserts { get; }
|
|
public IReadOnlyList<CultStoredDocument> Deletes { get; }
|
|
|
|
/// <summary>
|
|
/// Keys the batch removes that its cache does not hold. Such a removal removes nothing, so it is never in
|
|
/// <see cref="Deletes"/>; but a store that holds a record at one of these keys under an id no registered type declares
|
|
/// refuses the commit, as it refuses a write of that record. The home's <see cref="CacheBackingStore.CommitBatch"/> judges
|
|
/// these keys under its lock, with the keys it writes; every other attached store judges them first through
|
|
/// <see cref="CacheBackingStore.JudgeChecks"/>, each under its own lock, and a refusal anywhere writes nothing. A judged
|
|
/// key is never removed: a record a declared type reads stays where it is.
|
|
/// </summary>
|
|
public IReadOnlyList<string> Checks { get; }
|
|
public IReadOnlyList<(CultRecordKey Key, string? Version)> Expected { get; }
|
|
public bool ExpectUnchanged { get; }
|
|
public bool HasConditions => Expected.Count > 0 || ExpectUnchanged;
|
|
|
|
/// <summary>
|
|
/// Whether the batch's conditions hold. <paramref name="durable"/> is the version of each record the store holds on disk now,
|
|
/// by key; <paramref name="observed"/> is what the store's cache last loaded or committed, and <paramref name="foreign"/> the
|
|
/// foreign records the store carries as it last read them (<see cref="CacheBackingStore.ForeignRecords"/>; empty for a home
|
|
/// with none). Under <see cref="ExpectUnchanged"/> each foreign record compares against the version it had when the store
|
|
/// read it, as a record the cache holds compares against its own. Identity is the record's version, the SHA-256 of its stored
|
|
/// bytes, which names its schema id and storedAt too.
|
|
/// </summary>
|
|
public bool ConditionsHold(IReadOnlyDictionary<string, string> durable, IEnumerable<CultStoredDocument> observed, IEnumerable<CultForeignRecord> foreign)
|
|
{
|
|
foreach (var (key, version) in Expected)
|
|
{
|
|
var holds = durable.TryGetValue(key.Value, out var stored)
|
|
? version != null && string.Equals(stored, version, StringComparison.Ordinal)
|
|
: version == null;
|
|
if (!holds)
|
|
return false;
|
|
}
|
|
|
|
return !ExpectUnchanged ||
|
|
durable.Select(pair => (pair.Key, Version: pair.Value))
|
|
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
|
.SequenceEqual(observed
|
|
.Select(stored => (Key: stored.Key.Value, Version: stored.StoredVersion))
|
|
.Concat(foreign.Select(record => (record.Key, Version: record.StoredVersion)))
|
|
.OrderBy(pair => pair.Key, StringComparer.Ordinal));
|
|
}
|
|
}
|
|
|
|
// One staged write: a plain record (Stored), a variant (Variant), a flatten, or, with none of those, a removal.
|
|
internal sealed class StagedOperation
|
|
{
|
|
public StagedOperation(CultRecordKey key, CultStoredDocument? stored, CultVariantDelta? variant, bool flatten)
|
|
{
|
|
Key = key;
|
|
Stored = stored;
|
|
Variant = variant;
|
|
Flatten = flatten;
|
|
}
|
|
|
|
public CultRecordKey Key { get; }
|
|
public CultStoredDocument? Stored { get; }
|
|
public CultVariantDelta? Variant { get; }
|
|
public bool Flatten { get; }
|
|
public bool IsRemoval => Stored == null && Variant == null && !Flatten;
|
|
}
|
|
|
|
// An explicit value: nothing staged here is visible to anyone until Commit returns.
|
|
public sealed class CultCacheBatch
|
|
{
|
|
private readonly CultCache _cache;
|
|
internal readonly Dictionary<string, StagedOperation> Operations = new(StringComparer.Ordinal);
|
|
internal readonly List<(CultRecordKey Key, string? Version)> Expected = new();
|
|
internal bool ExpectsUnchanged;
|
|
internal bool Sealed;
|
|
|
|
internal CultCacheBatch(CultCache cache)
|
|
{
|
|
_cache = cache;
|
|
}
|
|
|
|
public CultRecordHandle<T> Upsert<T>(T document, CultRecordHandle<T>? handle = null)
|
|
{
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
return new CultRecordHandle<T>(Stage(document, handle?.Key));
|
|
}
|
|
|
|
public CultRecordKey Upsert(Type type, object document, CultRecordKey? key = null)
|
|
{
|
|
CultCache.RequireInstanceOf(type, document);
|
|
return Stage(document, key);
|
|
}
|
|
|
|
public void Remove(CultRecordKey key)
|
|
{
|
|
ThrowIfSealed();
|
|
Operations[key.Value] = new StagedOperation(key, null, null, false);
|
|
}
|
|
|
|
// Stages a variant record: its base and a sparse set of overrides, resolved by the cache when the batch commits.
|
|
// A variant changes only through here; rebasing is this call with another base.
|
|
public CultRecordKey UpsertVariant(CultRecordKey key, CultRecordKey baseKey, IEnumerable<CultVariantOverride>? overrides = null)
|
|
{
|
|
ThrowIfSealed();
|
|
Operations[key.Value] = new StagedOperation(
|
|
key, null, new CultVariantDelta(baseKey.Value, (overrides ?? Array.Empty<CultVariantOverride>()).ToArray()), false);
|
|
return key;
|
|
}
|
|
|
|
// Replaces a variant with a plain record holding its resolved document: the explicit, separate operation (Q1a).
|
|
public void Flatten(CultRecordKey key)
|
|
{
|
|
ThrowIfSealed();
|
|
Operations[key.Value] = new StagedOperation(key, null, null, true);
|
|
}
|
|
|
|
public void Expect(CultRecordKey key, object? current)
|
|
{
|
|
ThrowIfSealed();
|
|
var observed = _cache.Observe(key, current);
|
|
Expected.Add((key, observed?.StoredVersion));
|
|
}
|
|
|
|
public void ExpectUnchanged()
|
|
{
|
|
ThrowIfSealed();
|
|
ExpectsUnchanged = true;
|
|
}
|
|
|
|
private CultRecordKey Stage(object document, CultRecordKey? key)
|
|
{
|
|
ThrowIfSealed();
|
|
var stored = _cache.CreateStoredDocument(document, key);
|
|
Operations[stored.Key.Value] = new StagedOperation(stored.Key, stored, null, false);
|
|
return stored.Key;
|
|
}
|
|
|
|
private void ThrowIfSealed()
|
|
{
|
|
if (Sealed)
|
|
throw new InvalidOperationException("This batch has already been committed or abandoned.");
|
|
}
|
|
}
|
|
|
|
public sealed class CultCache : IDisposable
|
|
{
|
|
private readonly CultDocumentRegistry _registry;
|
|
private readonly List<(CacheBackingStore Store, Type[] Homes)> _stores = new();
|
|
private readonly Dictionary<string, CultStoredDocument> _entries = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<Type, Dictionary<string, string>> _names = new();
|
|
// Every key holding a value (unordered): the index is truthful about duplicates, so removing one holder never hides another.
|
|
private readonly Dictionary<(Type Type, string Alias), Dictionary<string, List<string>>> _indexes = new();
|
|
private readonly Dictionary<Type, string> _globals = new();
|
|
private readonly CultCodec? _codec;
|
|
// Keys holding a variant: derived from the entries by Apply, never decided anywhere else.
|
|
private readonly HashSet<string> _variantKeys = new(StringComparer.Ordinal);
|
|
private readonly ConditionalWeakTable<object, KeyBox> _handles = new();
|
|
// The cache's own observers, copy-on-write under _observerGate: publication reads one snapshot per change and
|
|
// catches per observer, so no observer decides whether another receives a change.
|
|
private Observer<Change>[] _observers = Array.Empty<Observer<Change>>();
|
|
private bool _observersCompleted;
|
|
private readonly object _observerGate = new();
|
|
private readonly object _gate = new();
|
|
private long _sequence;
|
|
// What this thread's outermost hold admitted, published by that hold when it exits.
|
|
[ThreadStatic] private static Hold? _held;
|
|
// Journals run under the gate, in registration order; both fields are read and written only under it.
|
|
private readonly List<Action<IReadOnlyList<CultCacheDocumentChange<object>>>> _journals = new();
|
|
private bool _journaling;
|
|
private bool _dirtyInMemory;
|
|
|
|
// The codec is the store's encoding (CultCacheMessagePack.Create supplies it). A cache without one holds no variants:
|
|
// resolving a variant needs to clone documents and decode override values.
|
|
public CultCache(CultDocumentRegistry? registry = null, CultCodec? codec = null)
|
|
{
|
|
_registry = registry ?? CultDocumentRegistry.Shared;
|
|
_codec = codec;
|
|
}
|
|
|
|
public CultDocumentRegistry Registry => _registry;
|
|
|
|
// A store wrote: every record it wrote is stored under its type's own id at the storedAt the write gave it, with the
|
|
// version of the bytes it wrote, and a plain record carries the ids it held, so it is no longer in-memory only. A variant's ids are not cleared here: the
|
|
// store wrote the delta it was handed, and only a write of the variant changes that.
|
|
internal void Wrote(IEnumerable<(CultPersistedRecord Record, string Version)> written) => Held(() =>
|
|
{
|
|
foreach (var (record, version) in written)
|
|
{
|
|
if (!_entries.TryGetValue(record.Key, out var entry))
|
|
continue;
|
|
entry.StoredSchemaId = record.SchemaId;
|
|
entry.StoredAt = record.StoredAt;
|
|
entry.StoredVersion = version;
|
|
if (entry.Variant == null)
|
|
entry.IdsInMemoryOnly = false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
public CultCodec? Codec => _codec;
|
|
|
|
// The whole override of one member, encoded with this cache's codec, ready for UpsertVariant.
|
|
public CultVariantOverride Override(Type documentType, string member, object? value)
|
|
{
|
|
if (_codec == null) throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create.");
|
|
var found = _registry.GetRequired(documentType).RichMembers.FirstOrDefault(entry => entry.Member.Name == member)
|
|
?? throw new ArgumentException($"{documentType.Name} has no persisted member '{member}'.", nameof(member));
|
|
return CultVariantOverride.Set(
|
|
found.Slot,
|
|
_codec.Serialize(value!, found.MemberType, documentType));
|
|
}
|
|
|
|
public CultVariantOverride Override<T>(string member, object? value) where T : class => Override(typeof(T), member, value);
|
|
|
|
// The one-shot rewrite for stores written before element ids existed: upserts every record whose ids exist only in
|
|
// memory (minted when it loaded), through the normal write path, one commit per home store. Returns how many records
|
|
// it rewrote; a second call finds none. A selected record stored under an id no registered type declares is read-only,
|
|
// so its home store's commit is refused with CultSchemaConflictException; commits to earlier stores stand.
|
|
public int MintElementIds()
|
|
{
|
|
var groups = Held(() => _entries.Values
|
|
.Where(entry => entry.IdsInMemoryOnly)
|
|
.GroupBy(entry => Home(entry.Descriptor.DocumentType))
|
|
.Select(group => group.ToArray())
|
|
.ToArray());
|
|
foreach (var group in groups)
|
|
{
|
|
Commit(batch =>
|
|
{
|
|
foreach (var entry in group)
|
|
{
|
|
if (entry.Variant is { } delta)
|
|
batch.UpsertVariant(entry.Key, new CultRecordKey(delta.BaseKey), delta.Overrides);
|
|
else
|
|
batch.Upsert(entry.Descriptor.DocumentType, entry.Document, entry.Key);
|
|
}
|
|
});
|
|
}
|
|
|
|
return groups.Sum(group => group.Length);
|
|
}
|
|
|
|
public bool IsDirty
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return _stores.Count == 0 ? _dirtyInMemory : _stores.Any(entry => entry.Store.IsDirty);
|
|
}
|
|
}
|
|
|
|
public bool FlushAttachedStoresOnDispose { get; set; }
|
|
|
|
public event Action<object?, object?>? OnUpdate;
|
|
|
|
public IReadOnlyList<CacheBackingStore> BackingStores
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return _stores.Select(entry => entry.Store).ToArray();
|
|
}
|
|
}
|
|
|
|
public IEnumerable<object> AllEntries
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return _entries.Values.Select(entry => entry.Document).ToArray();
|
|
}
|
|
}
|
|
|
|
public IEnumerable<CultStoredDocument> AllStoredDocuments
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return _entries.Values
|
|
.OrderBy(entry => entry.Descriptor.SchemaName, StringComparer.Ordinal)
|
|
.ThenBy(entry => entry.Key.Value, StringComparer.Ordinal)
|
|
.ToArray();
|
|
}
|
|
}
|
|
|
|
public Observable<CultCacheDocumentChange<T>> Watch<T>() where T : class
|
|
{
|
|
return Observable.Create<Change>(Register)
|
|
.Where(change => typeof(T).IsAssignableFrom(change.Stored.Descriptor.DocumentType))
|
|
.Select(change => new CultCacheDocumentChange<T>(
|
|
change.Kind,
|
|
change.Stored.Key,
|
|
change.Document as T,
|
|
change.Previous as T,
|
|
change.Sequence));
|
|
}
|
|
|
|
// Disposing removes the journal. A dispose the cache refuses (from inside a journal, or inside another cache's hold)
|
|
// throws and leaves the registration live and disposable; it counts as disposed only once the removal succeeded.
|
|
private sealed class JournalRegistration : IDisposable
|
|
{
|
|
private readonly CultCache _cache;
|
|
private readonly Action<IReadOnlyList<CultCacheDocumentChange<object>>> _journal;
|
|
private bool _removed;
|
|
|
|
public JournalRegistration(CultCache cache, Action<IReadOnlyList<CultCacheDocumentChange<object>>> journal)
|
|
{
|
|
_cache = cache;
|
|
_journal = journal;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_removed)
|
|
return;
|
|
_cache.Held(() => _cache._journals.Remove(_journal));
|
|
_removed = true;
|
|
}
|
|
}
|
|
|
|
private IDisposable Register(Observer<Change> observer)
|
|
{
|
|
lock (_observerGate)
|
|
{
|
|
if (!_observersCompleted)
|
|
{
|
|
_observers = _observers.Append(observer).ToArray();
|
|
return Disposable.Create(() =>
|
|
{
|
|
lock (_observerGate)
|
|
_observers = _observers.Where(held => !ReferenceEquals(held, observer)).ToArray();
|
|
});
|
|
}
|
|
}
|
|
|
|
observer.OnCompleted();
|
|
return Disposable.Empty;
|
|
}
|
|
|
|
public Observable<CultCacheDocumentChange<T>> WatchRecord<T>(CultRecordKey key) where T : class
|
|
{
|
|
return Watch<T>().Where(change => change.Key.Equals(key));
|
|
}
|
|
|
|
// An ordered admission journal: called under the gate, right after an admission is applied, once per admission
|
|
// that changed something, with that admission's changes in Sequence order. Journals of one cache run in
|
|
// registration order. A journal sees the new state (reads work) and is the one place cache order is a fact: it
|
|
// must not write to the cache or wait for anything that needs it, and any hold on this cache while a journal runs
|
|
// throws. A journal's exception does not undo the admission: it is published, then the exception is rethrown to
|
|
// the writer, as an observer's is.
|
|
public IDisposable AddJournal(Action<IReadOnlyList<CultCacheDocumentChange<object>>> journal)
|
|
{
|
|
if (journal == null) throw new ArgumentNullException(nameof(journal));
|
|
Held(() =>
|
|
{
|
|
_journals.Add(journal);
|
|
return true;
|
|
});
|
|
return new JournalRegistration(this, journal);
|
|
}
|
|
|
|
// Attaching reads the store; there is no interval in which it is attached but unread.
|
|
public void AddBackingStore(CacheBackingStore store, params Type[] homes)
|
|
{
|
|
if (store == null) throw new ArgumentNullException(nameof(store));
|
|
homes ??= Array.Empty<Type>();
|
|
Held(() =>
|
|
{
|
|
if (store.Loaded != null)
|
|
throw new InvalidOperationException($"Backing store {store} is already attached to a cache.");
|
|
if (store.IsDirty)
|
|
throw new InvalidOperationException($"Backing store {store} has staged writes; attach it clean.");
|
|
if (homes.Length == 0 && _stores.Any(entry => entry.Homes.Length == 0))
|
|
throw new InvalidOperationException(
|
|
$"Backing store {store} would be a second untyped store; name the types it is home to.");
|
|
foreach (var home in homes)
|
|
{
|
|
var owner = _stores.FirstOrDefault(entry => entry.Homes.Contains(home)).Store;
|
|
if (owner != null)
|
|
throw new InvalidOperationException($"{home.FullName} is already routed to {owner}; it cannot also route to {store}.");
|
|
}
|
|
|
|
var candidate = (store, homes);
|
|
foreach (var type in _entries.Values.Select(entry => entry.Descriptor).Distinct())
|
|
{
|
|
var before = Home(type.DocumentType);
|
|
_stores.Add(candidate);
|
|
var after = Home(type.DocumentType);
|
|
_stores.RemoveAt(_stores.Count - 1);
|
|
if (before != after)
|
|
throw new InvalidOperationException(
|
|
$"Attaching {store} would move {type.SchemaName} from {before?.ToString() ?? "memory"} to {after}; " +
|
|
"attach routed stores before the untyped store.");
|
|
}
|
|
|
|
store.AttachRegistry(_registry);
|
|
store.Cache = this;
|
|
store.Loaded = (loaded, dropped) => Admit(loaded, dropped, store, (_, _) => CultCommitOutcome.Committed);
|
|
_stores.Add(candidate);
|
|
try
|
|
{
|
|
store.PullAll();
|
|
}
|
|
catch
|
|
{
|
|
_stores.Remove(candidate);
|
|
store.Loaded = null;
|
|
store.Cache = null;
|
|
throw;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
public Task PullAllBackingStoresAsync()
|
|
{
|
|
// Our stores take the gate themselves; a third-party store might not, so the cache takes it here.
|
|
// One store's throwing OnUpdate handler does not stop the others from loading.
|
|
var failures = new List<Exception>();
|
|
foreach (var store in BackingStores)
|
|
{
|
|
try
|
|
{
|
|
Held(() => { store.PullAll(); return true; });
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
failures.Add(exception);
|
|
}
|
|
}
|
|
|
|
if (failures.Count == 1)
|
|
ExceptionDispatchInfo.Capture(failures[0]).Throw();
|
|
if (failures.Count > 1)
|
|
throw new AggregateException(failures);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public void FlushAllBackingStores() => Held(() =>
|
|
{
|
|
foreach (var (store, _) in _stores)
|
|
{
|
|
if (!store.IsReadOnly && (store.IsDirty || store.HasStagedChecks))
|
|
store.PushAll();
|
|
}
|
|
return true;
|
|
});
|
|
|
|
public Task FlushAsync()
|
|
{
|
|
FlushAllBackingStores();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<CultRecordHandle<T>> AddAsync<T>(T document, CultRecordHandle<T>? handle = null)
|
|
{
|
|
return UpsertAsync(document, handle);
|
|
}
|
|
|
|
// Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares.
|
|
public Task<CultRecordHandle<T>> UpsertAsync<T>(T document, CultRecordHandle<T>? handle = null)
|
|
{
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
return Task.FromResult(new CultRecordHandle<T>(Write(document, handle?.Key)));
|
|
}
|
|
|
|
// Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares.
|
|
public Task<CultRecordKey> UpsertAsync(Type documentType, object document, CultRecordKey? key = null)
|
|
{
|
|
RequireInstanceOf(documentType, document);
|
|
return Task.FromResult(Write(document, key));
|
|
}
|
|
|
|
// A single-call variant write; a batch stages the same thing with CultCacheBatch.UpsertVariant.
|
|
// Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares.
|
|
public Task<CultRecordKey> UpsertVariantAsync(CultRecordKey key, CultRecordKey baseKey, IEnumerable<CultVariantOverride>? overrides = null)
|
|
{
|
|
var delta = new CultVariantDelta(baseKey.Value, (overrides ?? Array.Empty<CultVariantOverride>()).ToArray());
|
|
return Task.FromResult(Held(() =>
|
|
{
|
|
var stored = StampVariant(key, DescriptorOfBase(key, delta.BaseKey, null), delta);
|
|
Admit(new[] { stored }, Array.Empty<CultStoredDocument>(), null, (home, docs) =>
|
|
{
|
|
home?.Push(docs[0]);
|
|
return CultCommitOutcome.Committed;
|
|
});
|
|
return key;
|
|
}));
|
|
}
|
|
|
|
// Replaces a variant with a plain record holding its resolved document (Q1a's explicit operation).
|
|
// Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares.
|
|
public Task<CultRecordKey> FlattenAsync(CultRecordKey key) => Task.FromResult(Held(() =>
|
|
{
|
|
var stored = StampFlatten(key);
|
|
Admit(new[] { stored }, Array.Empty<CultStoredDocument>(), null, (home, docs) =>
|
|
{
|
|
home?.Push(docs[0]);
|
|
return CultCommitOutcome.Committed;
|
|
});
|
|
return key;
|
|
}));
|
|
|
|
/// <summary>
|
|
/// Removes the record at <paramref name="key"/>; false when the cache holds nothing there.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A record stored under an id no registered type declares, read-only or foreign, is never removed: removing one the cache
|
|
/// holds is refused with <see cref="CultSchemaConflictException"/> now. A key the cache does not hold costs nothing here and
|
|
/// returns false; each writable store judges it at its next flush or commit, under its lock, and refuses that flush or
|
|
/// commit if it holds a record there under such an id (a read-only store answers now, from what it last loaded).
|
|
/// </remarks>
|
|
public bool Remove(CultRecordKey key) => Held(() =>
|
|
{
|
|
if (!_entries.TryGetValue(key.Value, out var existing))
|
|
{
|
|
RefuseRemovalOfUndeclared(new[] { key.Value });
|
|
return false;
|
|
}
|
|
Admit(Array.Empty<CultStoredDocument>(), new[] { existing }, null, (home, _) =>
|
|
{
|
|
home?.Delete(existing);
|
|
return CultCommitOutcome.Committed;
|
|
});
|
|
return true;
|
|
});
|
|
|
|
public void Remove<T>(CultRecordHandle<T> handle)
|
|
{
|
|
Remove(handle.Key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits the staged batch. Returns false when a condition failed: nothing was written, changed in memory, or published.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A commit that succeeds and then fails in a journal or observer still stands and is published, and the writer
|
|
/// receives the failure. One failure arrives as itself, several as an <see cref="AggregateException"/>. A writer that
|
|
/// wants a particular failure (a CultNetShardLogException, when a database's log refused the commit) catches that type,
|
|
/// or unwraps <see cref="AggregateException.InnerExceptions"/> and looks for it.
|
|
/// </remarks>
|
|
public bool Commit(Action<CultCacheBatch> stage)
|
|
{
|
|
return Land(stage, wait: true) == CultCommitOutcome.Committed;
|
|
}
|
|
|
|
public CultCommitOutcome TryCommit(Action<CultCacheBatch> stage)
|
|
{
|
|
return Land(stage, wait: false);
|
|
}
|
|
|
|
public CultRecordHandle<T>? TryGetHandle<T>(T document)
|
|
{
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
lock (_gate)
|
|
return _handles.TryGetValue(document, out var box) ? new CultRecordHandle<T>(box.Key) : null;
|
|
}
|
|
|
|
// The stored form of a record: for a variant, the delta beside the resolved document.
|
|
public CultStoredDocument? GetStored(CultRecordKey key)
|
|
{
|
|
lock (_gate)
|
|
return _entries.TryGetValue(key.Value, out var stored) ? stored : null;
|
|
}
|
|
|
|
public object? Get(CultRecordKey key)
|
|
{
|
|
lock (_gate)
|
|
return _entries.TryGetValue(key.Value, out var stored) ? stored.Document : null;
|
|
}
|
|
|
|
public T? Get<T>(CultRecordKey key) where T : class
|
|
{
|
|
return Get(key) as T;
|
|
}
|
|
|
|
// Every change whose Sequence is at or below the returned one is reflected in the returned document.
|
|
public (object? Document, long Sequence) GetWithSequence(CultRecordKey key)
|
|
{
|
|
lock (_gate)
|
|
return (_entries.TryGetValue(key.Value, out var stored) ? stored.Document : null, _sequence);
|
|
}
|
|
|
|
public bool TryGet<T>(CultRecordKey key, out T? document) where T : class
|
|
{
|
|
document = Get<T>(key);
|
|
return document != null;
|
|
}
|
|
|
|
public IEnumerable<T> GetAll<T>() where T : class
|
|
{
|
|
lock (_gate)
|
|
return _entries.Values.Select(entry => entry.Document).OfType<T>().ToArray();
|
|
}
|
|
|
|
public T? GetGlobal<T>() where T : class
|
|
{
|
|
return Single<T>("global", () => _globals
|
|
.Where(pair => typeof(T).IsAssignableFrom(pair.Key))
|
|
.Select(pair => pair.Value));
|
|
}
|
|
|
|
public T? GetByName<T>(string name) where T : class
|
|
{
|
|
return Single<T>($"name '{name}'", () => _names
|
|
.Where(pair => typeof(T).IsAssignableFrom(pair.Key))
|
|
.SelectMany(pair => pair.Value.TryGetValue(name, out var key) ? new[] { key } : Array.Empty<string>()));
|
|
}
|
|
|
|
// A value held by one record finds it. Records of one type sharing a value (plain duplicates) find the holder with the
|
|
// smallest key (ordinal), whatever order they were written or loaded in: the choice is a function of the held set, so
|
|
// it is the same live, after a flush and after a reopen, and removing that holder finds the next.
|
|
public T? GetByIndex<T>(string alias, string value) where T : class
|
|
{
|
|
return Single<T>($"index {alias}='{value}'", () => _indexes
|
|
.Where(pair => pair.Key.Alias == alias && typeof(T).IsAssignableFrom(pair.Key.Type))
|
|
.SelectMany(pair => pair.Value.TryGetValue(value, out var keys)
|
|
? new[] { keys.Aggregate((best, next) => string.CompareOrdinal(next, best) < 0 ? next : best) }
|
|
: Array.Empty<string>()));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Held(() =>
|
|
{
|
|
if (FlushAttachedStoresOnDispose && (IsDirty || _stores.Any(entry => entry.Store.HasStagedChecks)))
|
|
FlushAllBackingStores();
|
|
return true;
|
|
});
|
|
|
|
foreach (var store in BackingStores)
|
|
store.Dispose();
|
|
|
|
Observer<Change>[] completing;
|
|
lock (_observerGate)
|
|
{
|
|
completing = _observers;
|
|
_observers = Array.Empty<Observer<Change>>();
|
|
_observersCompleted = true;
|
|
}
|
|
|
|
var failures = new List<Exception>();
|
|
foreach (var observer in completing)
|
|
{
|
|
try
|
|
{
|
|
observer.OnCompleted();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
failures.Add(exception);
|
|
}
|
|
}
|
|
|
|
if (failures.Count == 1)
|
|
ExceptionDispatchInfo.Capture(failures[0]).Throw();
|
|
if (failures.Count > 1)
|
|
throw new AggregateException(failures);
|
|
}
|
|
|
|
internal static void RequireInstanceOf(Type documentType, object document)
|
|
{
|
|
if (documentType == null) throw new ArgumentNullException(nameof(documentType));
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
if (!documentType.IsInstanceOfType(document))
|
|
throw new ArgumentException($"Document instance must be assignable to {documentType.FullName}.", nameof(document));
|
|
}
|
|
|
|
// The runtime type decides the schema; a minted storedAt is always later than the record it replaces.
|
|
internal CultStoredDocument CreateStoredDocument(object document, CultRecordKey? key)
|
|
{
|
|
var descriptor = _registry.GetRequired(document.GetType());
|
|
lock (_gate)
|
|
return Stamp(
|
|
key ?? (_handles.TryGetValue(document, out var box) ? box.Key
|
|
: descriptor.IsGlobal ? new CultRecordKey($"global:{descriptor.SchemaId}")
|
|
: new CultRecordKey(Guid.NewGuid().ToString("N"))),
|
|
descriptor,
|
|
document);
|
|
}
|
|
|
|
// Call under the gate that admits the result, so no load can land a later storedAt in between.
|
|
private CultStoredDocument Stamp(CultRecordKey key, CultDocumentDescriptor descriptor, object document) =>
|
|
new(key, MintStoredAt(PreviousStoredAt(key), Clock()), descriptor, document);
|
|
|
|
// An unresolved variant: the admitting hold resolves it against its base before anything lands.
|
|
private CultStoredDocument StampVariant(CultRecordKey key, CultDocumentDescriptor descriptor, CultVariantDelta delta) =>
|
|
new(key, MintStoredAt(PreviousStoredAt(key), Clock()), descriptor, delta, null, _codec);
|
|
|
|
// The plain record a Flatten stages: the variant's resolved document, under the variant's own key and schema.
|
|
private CultStoredDocument StampFlatten(CultRecordKey key)
|
|
{
|
|
if (!_entries.TryGetValue(key.Value, out var existing) || existing.Variant == null)
|
|
throw new InvalidOperationException($"Flatten({key.Value}): the record is not a variant.");
|
|
return new CultStoredDocument(key, MintStoredAt(existing.StoredAt, Clock()), existing.Descriptor, existing.Document) { Flattens = true };
|
|
}
|
|
|
|
private string? PreviousStoredAt(CultRecordKey key) => _entries.TryGetValue(key.Value, out var previous) ? previous.StoredAt : null;
|
|
|
|
// The concrete type a variant takes from its base, walking bases through what the batch stages and then the cache.
|
|
// Refuses a cycle or a base that is not there, naming the keys, exactly as the admitting hold would.
|
|
private CultDocumentDescriptor DescriptorOfBase(CultRecordKey variant, string baseKey, CultCacheBatch? batch)
|
|
{
|
|
var seen = new List<string> { variant.Value };
|
|
var next = baseKey;
|
|
while (true)
|
|
{
|
|
if (seen.Contains(next))
|
|
throw CycleRefusal(seen.Append(next));
|
|
var staged = batch != null && batch.Operations.TryGetValue(next, out var op) ? op : null;
|
|
if (staged != null)
|
|
{
|
|
if (staged.Stored != null)
|
|
return staged.Stored.Descriptor;
|
|
if (staged.Variant != null)
|
|
{
|
|
seen.Add(next);
|
|
next = staged.Variant.BaseKey;
|
|
continue;
|
|
}
|
|
|
|
if (!staged.Flatten)
|
|
throw MissingBaseRefusal(variant.Value, baseKey, removed: true);
|
|
}
|
|
|
|
if (!_entries.TryGetValue(next, out var existing))
|
|
throw MissingBaseRefusal(variant.Value, baseKey, removed: false);
|
|
return existing.Descriptor;
|
|
}
|
|
}
|
|
|
|
internal CultStoredDocument? Observe(CultRecordKey key, object? current)
|
|
{
|
|
if (current == null)
|
|
return null;
|
|
lock (_gate)
|
|
{
|
|
if (_handles.TryGetValue(current, out var box) && box.Key.Equals(key) &&
|
|
_entries.TryGetValue(key.Value, out var stored) && ReferenceEquals(stored.Document, current))
|
|
return stored;
|
|
}
|
|
|
|
throw new ArgumentException($"Expect({key.Value}) was given an instance this cache does not hold at that key.", nameof(current));
|
|
}
|
|
|
|
// The wall clock behind every storedAt. Internal: a test that writes a golden store pins it, so the bytes reproduce.
|
|
internal Func<DateTimeOffset> Clock { get; set; } = () => DateTimeOffset.UtcNow;
|
|
|
|
internal static string MintStoredAt(string? previous, DateTimeOffset now)
|
|
{
|
|
if (previous != null &&
|
|
DateTimeOffset.TryParse(previous, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var last) &&
|
|
now <= last)
|
|
now = last.ToUniversalTime().AddTicks(1);
|
|
return now.ToString("O", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private CultRecordKey Write(object document, CultRecordKey? key) => Held(() =>
|
|
{
|
|
var stored = CreateStoredDocument(document, key);
|
|
Admit(new[] { stored }, Array.Empty<CultStoredDocument>(), null, (home, docs) =>
|
|
{
|
|
home?.Push(docs[0]);
|
|
return CultCommitOutcome.Committed;
|
|
});
|
|
return stored.Key;
|
|
});
|
|
|
|
private CultCommitOutcome Land(Action<CultCacheBatch> stage, bool wait)
|
|
{
|
|
if (stage == null) throw new ArgumentNullException(nameof(stage));
|
|
var batch = new CultCacheBatch(this);
|
|
try
|
|
{
|
|
stage(batch);
|
|
}
|
|
finally
|
|
{
|
|
batch.Sealed = true;
|
|
}
|
|
|
|
// The in-process gate is always entered; only the store lock attempt honors wait, so Contended means another writer holds the store.
|
|
return Held(() =>
|
|
{
|
|
// Staging stamped outside this gate; a pull since then may hold a later storedAt, so stamp again.
|
|
var upserts = batch.Operations.Values
|
|
.Where(op => !op.IsRemoval)
|
|
.Select(op => op.Stored != null ? Stamp(op.Key, op.Stored.Descriptor, op.Stored.Document)
|
|
: op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant)
|
|
: StampFlatten(op.Key))
|
|
.ToArray();
|
|
// A removal of a key the cache does not hold removes nothing: it is a check every store the commit reaches judges under its lock.
|
|
var checks = batch.Operations.Values
|
|
.Where(op => op.IsRemoval && !_entries.ContainsKey(op.Key.Value))
|
|
.Select(op => op.Key.Value)
|
|
.ToArray();
|
|
var deletes = batch.Operations.Values
|
|
.Where(op => op.IsRemoval && _entries.ContainsKey(op.Key.Value))
|
|
.Select(op => _entries[op.Key.Value])
|
|
.ToArray();
|
|
return Admit(upserts, deletes, null, (home, resolved) =>
|
|
{
|
|
// A batch that only checks keys names no home; it commits through the cache's one writable store, if it has one.
|
|
if (home == null && checks.Length > 0 && _stores.Where(entry => !entry.Store.IsReadOnly).Take(2).ToArray() is { Length: 1 } only)
|
|
home = only[0].Store;
|
|
// Every store the batch does not commit through judges its checks first, each under its own lock, so a refusal or
|
|
// contention writes nothing; the home judges its own in CommitBatch.
|
|
foreach (var (store, _) in _stores)
|
|
{
|
|
if (store == home || checks.Length == 0)
|
|
continue;
|
|
var judged = store.JudgeChecks(checks, wait);
|
|
if (judged != CultCommitOutcome.Committed)
|
|
return judged;
|
|
}
|
|
|
|
// What the store holds is what the hold resolved: a variant's delta beside its resolved view.
|
|
var request = new CultCommitRequest(resolved, deletes, checks, batch.Expected.ToArray(), batch.ExpectsUnchanged);
|
|
if (home != null)
|
|
{
|
|
if (request.HasConditions && home.IsDirty)
|
|
throw new InvalidOperationException($"Backing store {home} has staged writes; flush before a conditional commit.");
|
|
return home.CommitBatch(request, wait);
|
|
}
|
|
|
|
if (request.HasConditions && _stores.Count > 0)
|
|
throw new InvalidOperationException("A conditional batch names its home store through a record it upserts or removes.");
|
|
var inMemory = _entries.Values.ToDictionary(entry => entry.Key.Value, entry => entry.StoredVersion, StringComparer.Ordinal);
|
|
return request.ConditionsHold(inMemory, _entries.Values, Array.Empty<CultForeignRecord>()) ? CultCommitOutcome.Committed : CultCommitOutcome.Mismatch;
|
|
});
|
|
});
|
|
}
|
|
|
|
// A removal of a key the cache does not hold removes nothing, so no write carries it to a store; but a store that holds a
|
|
// record there under an id no registered type declares refuses it, as it refuses every write of that record, rather than let
|
|
// the caller believe it was removed. Each store judges it its own way (CacheBackingStore.RefuseRemovalOfUndeclared).
|
|
private void RefuseRemovalOfUndeclared(IReadOnlyCollection<string> keys)
|
|
{
|
|
if (keys.Count == 0)
|
|
return;
|
|
foreach (var (store, _) in _stores)
|
|
store.RefuseRemovalOfUndeclared(keys);
|
|
}
|
|
|
|
// Every add and remove passes here: single writes, committed batches, and loads (source set).
|
|
// Nothing lands unless all of it is admissible; land is the durable step between validation and memory.
|
|
private CultCommitOutcome Admit(
|
|
IReadOnlyList<CultStoredDocument> admitted,
|
|
IReadOnlyList<CultStoredDocument> evicted,
|
|
CacheBackingStore? source,
|
|
Func<CacheBackingStore?, IReadOnlyList<CultStoredDocument>, CultCommitOutcome> land) => Held(() =>
|
|
{
|
|
if (_held == null)
|
|
throw new InvalidOperationException("An admission reached the cache under a plain lock on its gate; every admission runs in a hold.");
|
|
var home = Validate(admitted, evicted, source);
|
|
// Ids are decided before anything lands, and the caller's documents change only inside this admission: a plan that
|
|
// refuses (a duplicate, a malformed id) has changed nothing, and a write that does not commit gives its ids back.
|
|
// A write mints random ids; a load of a record written before ids existed mints the ids its reload will mint
|
|
// again, and lands in memory only (variants mint inside Resolve, into their override values).
|
|
var idPlans = admitted
|
|
.Where(stored => stored.Variant == null)
|
|
.Select(stored => (Stored: stored, Plan: CultElementIds.Plan(stored.Descriptor.DocumentType, stored.Document, stored.Key.Value, deterministic: source != null)))
|
|
.ToArray();
|
|
VariantPlan plan;
|
|
CultCommitOutcome outcome;
|
|
try
|
|
{
|
|
foreach (var (_, idPlan) in idPlans)
|
|
idPlan.Apply();
|
|
// 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);
|
|
outcome = land(home, plan.Admitted);
|
|
}
|
|
catch
|
|
{
|
|
foreach (var (_, idPlan) in idPlans)
|
|
idPlan.Undo();
|
|
throw;
|
|
}
|
|
|
|
if (outcome != CultCommitOutcome.Committed)
|
|
{
|
|
foreach (var (_, idPlan) in idPlans)
|
|
idPlan.Undo();
|
|
return outcome;
|
|
}
|
|
|
|
// A record whose ids exist only in memory stays marked until a write persists them (a flush of the whole store
|
|
// does, and so does any write of the record: a new entry is unmarked).
|
|
if (source != null)
|
|
{
|
|
foreach (var (stored, idPlan) in idPlans)
|
|
stored.IdsInMemoryOnly = idPlan.Count > 0;
|
|
}
|
|
|
|
var applied = Apply(plan, evicted, source);
|
|
foreach (var change in applied)
|
|
_held.Changes.Add((change, source != null));
|
|
RunJournals(applied, _held.Failures);
|
|
return CultCommitOutcome.Committed;
|
|
});
|
|
|
|
// Every admission runs in a hold. A nested hold on the same cache adds to the outermost one, which publishes
|
|
// exactly its own changes after it leaves the gate, before it returns, on its caller's thread: observers never
|
|
// run under the gate, and a write an observer makes is a new outermost hold. Cross-thread delivery order is not
|
|
// guaranteed; each change carries the Sequence it was admitted with. Journals run before that, under the gate,
|
|
// in Sequence order (AddJournal); no hold may be entered on this cache while one runs. Every change reaches every observer even
|
|
// if an observer or an OnUpdate handler throws; then the exception (an AggregateException for several, whatever their
|
|
// origin: a journal, an observer, a second database) is rethrown. A writer looking for one type unwraps InnerExceptions.
|
|
// If the body threw, its exception wins.
|
|
internal T Held<T>(Func<T> body)
|
|
{
|
|
if (Monitor.IsEntered(_gate))
|
|
{
|
|
if (_journaling)
|
|
throw new InvalidOperationException("A journal entered the cache it journals; a journal must not write to it.");
|
|
return body();
|
|
}
|
|
|
|
if (_held != null)
|
|
throw new InvalidOperationException(
|
|
"A cache hold was entered inside another cache's hold; a thread holds one cache's gate at a time.");
|
|
var mine = _held = new Hold();
|
|
var result = default(T)!;
|
|
try
|
|
{
|
|
lock (_gate)
|
|
result = body();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_held = null;
|
|
Publish(mine);
|
|
if (mine.Failures.Count == 0)
|
|
throw;
|
|
throw new AggregateException(mine.Failures.Prepend(exception));
|
|
}
|
|
|
|
_held = null;
|
|
var failures = mine.Failures;
|
|
failures.AddRange(Publish(mine));
|
|
if (failures.Count == 1)
|
|
ExceptionDispatchInfo.Capture(failures[0]).Throw();
|
|
if (failures.Count > 1)
|
|
throw new AggregateException(failures);
|
|
return result;
|
|
}
|
|
|
|
private List<Exception> Publish(Hold hold)
|
|
{
|
|
var failures = new List<Exception>();
|
|
foreach (var (change, loaded) in hold.Changes)
|
|
{
|
|
// R3 routes a throwing subscriber to its unhandled-exception handler, and a fail-fast handler rethrows: every
|
|
// observer still runs, the exception joins the failures, and the observer stays subscribed.
|
|
foreach (var observer in Volatile.Read(ref _observers))
|
|
{
|
|
try
|
|
{
|
|
observer.OnNext(change);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
failures.Add(exception);
|
|
}
|
|
}
|
|
|
|
if (!loaded)
|
|
continue;
|
|
try
|
|
{
|
|
OnUpdate?.Invoke(change.Previous, change.Document);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
failures.Add(exception);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
// Runs under the gate. A journal that throws joins the hold's failures and the remaining journals still run.
|
|
private void RunJournals(List<Change> applied, List<Exception> failures)
|
|
{
|
|
if (_journals.Count == 0 || applied.Count == 0)
|
|
return;
|
|
var changes = applied
|
|
.Select(change => new CultCacheDocumentChange<object>(
|
|
change.Kind, change.Stored.Key, change.Document, change.Previous, change.Sequence))
|
|
.ToArray();
|
|
_journaling = true;
|
|
try
|
|
{
|
|
foreach (var journal in _journals)
|
|
{
|
|
try
|
|
{
|
|
journal(changes);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
failures.Add(exception);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_journaling = false;
|
|
}
|
|
}
|
|
|
|
private CacheBackingStore? Validate(
|
|
IReadOnlyList<CultStoredDocument> admitted,
|
|
IReadOnlyList<CultStoredDocument> evicted,
|
|
CacheBackingStore? source)
|
|
{
|
|
var homes = new List<CacheBackingStore>();
|
|
var globals = new Dictionary<Type, string>(_globals);
|
|
foreach (var stored in evicted)
|
|
{
|
|
var type = stored.Descriptor.DocumentType;
|
|
if (globals.TryGetValue(type, out var key) && key == stored.Key.Value)
|
|
globals.Remove(type);
|
|
if (source == null)
|
|
Claim(stored);
|
|
}
|
|
|
|
foreach (var stored in admitted)
|
|
{
|
|
var descriptor = stored.Descriptor;
|
|
if (source == null)
|
|
Claim(stored);
|
|
else if (Home(descriptor.DocumentType) is var home && home != source)
|
|
throw new InvalidOperationException(
|
|
$"{descriptor.SchemaName} record {stored.Key.Value} was loaded from {source} but its home is {home?.ToString() ?? "no store"}.");
|
|
|
|
// One key, one store: a key never moves between homes, and two stores never both deliver it.
|
|
if (_entries.TryGetValue(stored.Key.Value, out var present) &&
|
|
Home(present.Descriptor.DocumentType) is var held &&
|
|
Home(descriptor.DocumentType) is var incoming &&
|
|
held != incoming)
|
|
throw new InvalidOperationException(
|
|
$"Record {stored.Key.Value} is held by {held?.ToString() ?? "memory"}; " +
|
|
$"{(source == null ? "writing" : "loading")} it as {descriptor.SchemaName} from {incoming?.ToString() ?? "memory"} would give the key a second store.");
|
|
|
|
if (!descriptor.IsGlobal)
|
|
continue;
|
|
if (globals.TryGetValue(descriptor.DocumentType, out var existing) && existing != stored.Key.Value)
|
|
throw new InvalidOperationException(
|
|
$"{descriptor.SchemaName} is a global and already has record {existing}; {stored.Key.Value} would be a second.");
|
|
globals[descriptor.DocumentType] = stored.Key.Value;
|
|
}
|
|
|
|
var distinct = homes.Distinct().ToArray();
|
|
if (distinct.Length > 1)
|
|
throw new InvalidOperationException(
|
|
$"The batch spans {distinct[0]} and {distinct[1]}; a commit lands in one home store.");
|
|
return distinct.FirstOrDefault();
|
|
|
|
void Claim(CultStoredDocument stored)
|
|
{
|
|
var home = Home(stored.Descriptor.DocumentType);
|
|
if (home == null)
|
|
{
|
|
if (_stores.Count > 0)
|
|
throw new InvalidOperationException($"No backing store is home to {stored.Descriptor.SchemaName}.");
|
|
return;
|
|
}
|
|
|
|
if (home.IsReadOnly)
|
|
throw new InvalidOperationException(
|
|
$"{stored.Descriptor.SchemaName} record {stored.Key.Value} routes to read-only backing store {home}.");
|
|
home.RefuseUndeclared(stored.Key.Value);
|
|
homes.Add(home);
|
|
}
|
|
}
|
|
|
|
private sealed class VariantPlan
|
|
{
|
|
public VariantPlan(IReadOnlyList<CultStoredDocument> admitted)
|
|
{
|
|
Admitted = admitted;
|
|
}
|
|
|
|
// The admitted records, variants resolved.
|
|
public IReadOnlyList<CultStoredDocument> Admitted { get; }
|
|
|
|
// Variants this admission did not write whose base chain it changed: re-resolved, same delta, same storedAt.
|
|
public List<CultStoredDocument> Dependents { get; } = new();
|
|
|
|
// Name and index values of every record that lands (Admitted and Dependents), by key. Filled once by Project.
|
|
public Dictionary<string, Projection> Projections { get; } = new(StringComparer.Ordinal);
|
|
}
|
|
|
|
// What a landing record's [CultName] and [CultIndex] getters said. Indexes holds only non-blank values.
|
|
private sealed class Projection
|
|
{
|
|
public Projection(string? name, (string Alias, string Value)[] indexes)
|
|
{
|
|
Name = name;
|
|
Indexes = indexes;
|
|
}
|
|
|
|
public string? Name { get; }
|
|
public (string Alias, string Value)[] Indexes { get; }
|
|
}
|
|
|
|
// The only place an admission runs a name or index getter on a landing record. It runs before land and before any
|
|
// sequence is minted, so a getter that throws refuses the whole admission with nothing written and nothing minted.
|
|
private static void Project(VariantPlan plan)
|
|
{
|
|
foreach (var stored in plan.Admitted.Concat(plan.Dependents))
|
|
{
|
|
var descriptor = stored.Descriptor;
|
|
var name = descriptor.NameAccessor == null ? null : NameRead(stored);
|
|
var indexes = new List<(string Alias, string Value)>();
|
|
foreach (var pair in descriptor.IndexAccessors)
|
|
{
|
|
var member = descriptor.RichMembers.First(candidate => candidate.IndexAlias == pair.Key).Member.Name;
|
|
var value = Read(stored, $"index '{pair.Key}' member {member}", pair.Value);
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
indexes.Add((pair.Key, value!));
|
|
}
|
|
|
|
plan.Projections[stored.Key.Value] = new Projection(name, indexes.ToArray());
|
|
}
|
|
}
|
|
|
|
// A blank name is no name.
|
|
private static string? NameRead(CultStoredDocument stored) =>
|
|
Read(stored, $"[CultName] member {stored.Descriptor.NameMember}", stored.Descriptor.NameAccessor!) is { Length: > 0 } name ? name : null;
|
|
|
|
private static string? Read(CultStoredDocument stored, string what, Func<object, string?> getter)
|
|
{
|
|
try
|
|
{
|
|
return getter(stored.Document);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Record {stored.Key.Value} ({stored.Descriptor.SchemaName}) is refused: its {what} threw: {exception.GetBaseException().Message}", exception);
|
|
}
|
|
}
|
|
|
|
// Every admission is judged here: variants resolved, then every landing record's name and index values read once,
|
|
// then the rules that compare them. It refuses naming the keys, and mutates nothing.
|
|
private VariantPlan Resolve(
|
|
IReadOnlyList<CultStoredDocument> admitted,
|
|
IReadOnlyList<CultStoredDocument> evicted,
|
|
CacheBackingStore? source)
|
|
{
|
|
var plan = ResolveVariants(admitted, evicted, source);
|
|
Project(plan);
|
|
// The variant rules are judged only when a variant is involved.
|
|
if (_variantKeys.Count != 0 || admitted.Any(stored => stored.Variant != null))
|
|
{
|
|
RefuseVariantNameSharing(plan);
|
|
RefuseVariantIndexSharing(plan, evicted);
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
// Resolves every admitted variant and every variant whose base chain this admission changes, base first, against
|
|
// the post-admission set. It refuses a broken variant naming the keys, and mutates nothing.
|
|
private VariantPlan ResolveVariants(
|
|
IReadOnlyList<CultStoredDocument> admitted,
|
|
IReadOnlyList<CultStoredDocument> evicted,
|
|
CacheBackingStore? source)
|
|
{
|
|
if (_variantKeys.Count == 0 && admitted.All(stored => stored.Variant == null))
|
|
return new VariantPlan(admitted);
|
|
|
|
var incoming = admitted.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal);
|
|
var gone = evicted.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal);
|
|
CultStoredDocument? Lookup(string key) =>
|
|
incoming.TryGetValue(key, out var found) ? found
|
|
: gone.Contains(key) ? null
|
|
: _entries.TryGetValue(key, out var held) ? held : null;
|
|
|
|
// A variant is written only through the variant API: a plain record never lands on a variant's key, and the
|
|
// reverse is a rebase or a flatten, never an accident. A load reports what is on disk and is not a write.
|
|
if (source == null)
|
|
{
|
|
foreach (var stored in admitted)
|
|
{
|
|
_entries.TryGetValue(stored.Key.Value, out var present);
|
|
if (stored.Variant == null && !stored.Flattens && present?.Variant != null)
|
|
throw new InvalidOperationException(
|
|
$"Record {stored.Key.Value} is a variant of {present.Variant.BaseKey}; a plain write cannot replace it. " +
|
|
"Change it through the variant API, or Flatten it.");
|
|
if (stored.Variant != null && present != null && present.Variant == null)
|
|
throw new InvalidOperationException(
|
|
$"Record {stored.Key.Value} is a plain record; it cannot become a variant of {stored.Variant.BaseKey} in place. Use a new key.");
|
|
if (stored.Variant != null && present?.Variant != null &&
|
|
present.Descriptor.DocumentType != stored.Descriptor.DocumentType)
|
|
throw new InvalidOperationException(
|
|
$"Variant {stored.Key.Value} is a {present.Descriptor.DocumentType.Name}; " +
|
|
$"rebasing it onto {stored.Variant.BaseKey} would make it a {stored.Descriptor.DocumentType.Name}.");
|
|
}
|
|
}
|
|
|
|
var changed = new HashSet<string>(incoming.Keys.Concat(gone), StringComparer.Ordinal);
|
|
// A flatten resolves like the variant it replaces, against the post-batch set, so a base edited in the same
|
|
// batch is in it; it lands as the plain record of that resolution.
|
|
var toResolve = new List<string>(admitted.Where(stored => stored.Variant != null || stored.Flattens).Select(stored => stored.Key.Value));
|
|
foreach (var variantKey in _variantKeys)
|
|
{
|
|
if (incoming.ContainsKey(variantKey) || gone.Contains(variantKey))
|
|
continue;
|
|
var next = _entries[variantKey].Variant!.BaseKey;
|
|
for (var steps = 0; steps <= _entries.Count; steps++)
|
|
{
|
|
if (changed.Contains(next))
|
|
{
|
|
toResolve.Add(variantKey);
|
|
break;
|
|
}
|
|
|
|
var link = Lookup(next);
|
|
if (link?.Variant == null)
|
|
break;
|
|
next = link.Variant.BaseKey;
|
|
}
|
|
}
|
|
|
|
if (toResolve.Count == 0)
|
|
return new VariantPlan(admitted);
|
|
|
|
var pending = new HashSet<string>(toResolve, StringComparer.Ordinal);
|
|
var done = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
|
|
var stack = new List<string>();
|
|
foreach (var key in toResolve)
|
|
ResolveOne(key);
|
|
|
|
var plan = new VariantPlan(admitted.Select(stored => done.TryGetValue(stored.Key.Value, out var resolved) ? resolved : stored).ToList());
|
|
foreach (var key in toResolve.Where(key => !incoming.ContainsKey(key)))
|
|
plan.Dependents.Add(done[key]);
|
|
return plan;
|
|
|
|
CultStoredDocument ResolveOne(string key)
|
|
{
|
|
if (done.TryGetValue(key, out var finished))
|
|
return finished;
|
|
var variant = Lookup(key)!;
|
|
if (stack.Contains(key))
|
|
throw CycleRefusal(stack.SkipWhile(entry => entry != key).Append(key));
|
|
stack.Add(key);
|
|
|
|
// A flatten carries no delta of its own; the variant it replaces does.
|
|
var isVariant = variant.Variant != null;
|
|
var delta = variant.Variant ?? _entries[key].Variant!;
|
|
var baseStored = Lookup(delta.BaseKey) ?? throw MissingBaseRefusal(key, delta.BaseKey, gone.Contains(delta.BaseKey));
|
|
if (pending.Contains(delta.BaseKey))
|
|
baseStored = ResolveOne(delta.BaseKey);
|
|
|
|
var descriptor = variant.Descriptor;
|
|
if (_codec == null)
|
|
throw new InvalidOperationException(
|
|
$"Variant {key} needs a codec to resolve; this cache has none. Create it with CultCacheMessagePack.Create.");
|
|
if (baseStored.Descriptor.DocumentType != descriptor.DocumentType)
|
|
throw new InvalidOperationException(
|
|
$"Variant {key} is a {descriptor.DocumentType.Name} but its base {delta.BaseKey} is a {baseStored.Descriptor.DocumentType.Name}; " +
|
|
"a variant has its base's concrete type.");
|
|
|
|
var type = descriptor.DocumentType;
|
|
var kept = new List<KeyValuePair<int, byte[]>>();
|
|
var overrides = new List<CultVariantOverride>(delta.Overrides.Count);
|
|
var minted = false;
|
|
var seen = new HashSet<int>();
|
|
foreach (var entry in delta.Overrides)
|
|
{
|
|
if (entry.Op != CultOverrideOp.Set || entry.Path.Count != 1 || entry.Path[0].ElementId.Length != 0 || entry.Id.Length != 0)
|
|
throw new InvalidOperationException(
|
|
$"Variant {key} carries {entry.Op} at {entry.PathText}; this runtime resolves top-level Set overrides only.");
|
|
var slot = entry.Path[0].Slot;
|
|
if (!seen.Add(slot))
|
|
throw new InvalidOperationException($"Variant {key} overrides slot {slot} more than once; a variant has one override per slot.");
|
|
var member = descriptor.RichMembers.FirstOrDefault(candidate => candidate.Slot == slot);
|
|
// Drift is the load's business (ToStoredDocument sheds an override of a slot the type lacks), so a delta
|
|
// reaching here names only slots the type has, unless someone is writing one it never had.
|
|
if (member == null)
|
|
throw new InvalidOperationException($"Variant {key} overrides slot {slot}, which {type.Name} does not have.");
|
|
|
|
object? value;
|
|
try
|
|
{
|
|
value = _codec.Deserialize(member.MemberType, type, entry.Value);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
// A value that no longer decodes refuses exactly as a plain record holding it does.
|
|
throw new InvalidOperationException(
|
|
$"Variant {key} (schema '{descriptor.SchemaId}') overrides '{member.Member.Name}' (slot {slot}) with a value that does not decode as {member.MemberType.Name}: " +
|
|
exception.GetBaseException().Message, exception);
|
|
}
|
|
|
|
// The override's value is minted like a plain record's members: ids the store never held are filled here, in the
|
|
// decoded copy, and the delta this hold lands carries them.
|
|
var bytes = entry.Value;
|
|
var idPlan = CultElementIds.Plan(member.MemberType, value, key + "." + slot.ToString(CultureInfo.InvariantCulture), deterministic: source != null);
|
|
if (idPlan.Count > 0)
|
|
{
|
|
idPlan.Apply();
|
|
bytes = _codec.Serialize(value!, member.MemberType, type);
|
|
minted = true;
|
|
}
|
|
|
|
overrides.Add(new CultVariantOverride(entry.Op, entry.Path, entry.Id, bytes));
|
|
kept.Add(new KeyValuePair<int, byte[]>(slot, bytes));
|
|
}
|
|
|
|
var payload = _codec.Serialize(baseStored.Document, type, type);
|
|
var document = _codec.Deserialize(type, type, kept.Count == 0 ? payload : _codec.Overlay(payload, kept))
|
|
?? throw new InvalidOperationException($"Variant {key} resolved to nothing.");
|
|
|
|
stack.RemoveAt(stack.Count - 1);
|
|
return done[key] = isVariant
|
|
? variant.Resolved(document, minted ? new CultVariantDelta(delta.BaseKey, overrides) : delta, variant.IdsInMemoryOnly || (minted && source != null), _codec)
|
|
: new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document)
|
|
{
|
|
Flattens = true,
|
|
StoredSchemaId = variant.StoredSchemaId,
|
|
StoredVersion = variant.StoredVersion
|
|
};
|
|
}
|
|
}
|
|
|
|
// R6: the name index maps one name to one key, so a variant that kept its base's name would shadow it. A variant's
|
|
// name is its projection; an ancestor that lands is read from its projection, one that stays is read as held.
|
|
private void RefuseVariantNameSharing(VariantPlan plan)
|
|
{
|
|
var landing = plan.Admitted.Concat(plan.Dependents).ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal);
|
|
|
|
// Resolution already refused a base that is missing or removed, so an ancestor is landing or held.
|
|
CultStoredDocument? Ancestor(string key) =>
|
|
landing.TryGetValue(key, out var landed) ? landed
|
|
: _entries.TryGetValue(key, out var held) ? held : null;
|
|
|
|
string? NameOf(CultStoredDocument stored) =>
|
|
plan.Projections.TryGetValue(stored.Key.Value, out var projection) ? projection.Name : NameRead(stored);
|
|
|
|
foreach (var variant in landing.Values.Where(stored => stored.Variant != null))
|
|
{
|
|
var variantName = plan.Projections[variant.Key.Value].Name;
|
|
if (variantName == null)
|
|
continue;
|
|
for (var ancestor = Ancestor(variant.Variant!.BaseKey); ancestor != null;
|
|
ancestor = ancestor.Variant == null ? null : Ancestor(ancestor.Variant.BaseKey))
|
|
{
|
|
if (NameOf(ancestor) == variantName)
|
|
throw new InvalidOperationException(
|
|
$"Variant {variant.Key.Value} and its base {ancestor.Key.Value} are both named '{variantName}'; a variant must override its name.");
|
|
}
|
|
}
|
|
}
|
|
|
|
// The one index rule, judged on the commit's result whatever the write order: a variant may not share an indexed
|
|
// value with another record. It reads the truthful held index (every holder of a value), minus the records this
|
|
// commit replaces or removes, plus the records it lands. Plain-vs-plain sharing is not this rule's business.
|
|
// A unique index maps a value to one key at lookup; a variant sharing it would be found in place of its
|
|
// holder or lose the value, and the store would refuse to reopen.
|
|
private void RefuseVariantIndexSharing(VariantPlan plan, IReadOnlyList<CultStoredDocument> evicted)
|
|
{
|
|
var landing = plan.Admitted.Concat(plan.Dependents).ToList();
|
|
if (landing.All(stored => stored.Descriptor.IndexAccessors.Count == 0))
|
|
return;
|
|
var replaced = new HashSet<string>(
|
|
plan.Admitted.Concat(evicted).Concat(plan.Dependents).Select(stored => stored.Key.Value), StringComparer.Ordinal);
|
|
var landingByKey = landing.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal);
|
|
|
|
bool IsVariant(string key) =>
|
|
landingByKey.TryGetValue(key, out var landed) ? landed.Variant != null : _variantKeys.Contains(key);
|
|
|
|
// One pass over what lands, one lookup per (index, value): the rule is linear in the records landing.
|
|
var landedValues = new List<(CultStoredDocument Stored, (Type Type, string Alias) Index, string Value)>();
|
|
var landed = new Dictionary<((Type Type, string Alias) Index, string Value), List<string>>();
|
|
foreach (var stored in landing)
|
|
{
|
|
foreach (var (alias, value) in plan.Projections[stored.Key.Value].Indexes)
|
|
{
|
|
var index = (stored.Descriptor.DocumentType, alias);
|
|
landedValues.Add((stored, index, value));
|
|
if (!landed.TryGetValue((index, value), out var landedKeys))
|
|
landed[(index, value)] = landedKeys = new List<string>();
|
|
landedKeys.Add(stored.Key.Value);
|
|
}
|
|
}
|
|
|
|
var holdersOf = new Dictionary<((Type Type, string Alias) Index, string Value), (string[] All, string[] Variants)>();
|
|
foreach (var (stored, index, value) in landedValues)
|
|
{
|
|
if (!holdersOf.TryGetValue((index, value), out var known))
|
|
{
|
|
var all = (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKeys)
|
|
? heldKeys.Where(heldKey => !replaced.Contains(heldKey))
|
|
: Enumerable.Empty<string>())
|
|
.Concat(landed[(index, value)])
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
holdersOf[(index, value)] = known = (all, all.Where(IsVariant).ToArray());
|
|
}
|
|
|
|
var self = stored.Key.Value;
|
|
var holder = known.All.FirstOrDefault(candidate => candidate != self);
|
|
if (stored.Variant != null && holder != null)
|
|
throw new InvalidOperationException(
|
|
$"Variant {self} and {holder} both hold index {index.Alias} '{value}'; a variant must override its indexed member.");
|
|
var variantHolder = known.Variants.FirstOrDefault(candidate => candidate != self);
|
|
if (stored.Variant == null && variantHolder != null)
|
|
throw new InvalidOperationException(
|
|
$"Record {self} and variant {variantHolder} both hold index {index.Alias} '{value}'; " +
|
|
"a variant may not share an indexed value with another record.");
|
|
}
|
|
}
|
|
|
|
private static InvalidOperationException CycleRefusal(IEnumerable<string> keys) =>
|
|
new($"Variant bases form a cycle: {string.Join(" -> ", keys)}.");
|
|
|
|
private static InvalidOperationException MissingBaseRefusal(string variant, string baseKey, bool removed) =>
|
|
new(removed
|
|
? $"Record {baseKey} cannot go: variant {variant} is based on it. Remove or rebase the variant in the same commit."
|
|
: $"Variant {variant} is based on {baseKey}, which does not exist.");
|
|
|
|
private List<Change> Apply(
|
|
VariantPlan plan,
|
|
IReadOnlyList<CultStoredDocument> evicted,
|
|
CacheBackingStore? source)
|
|
{
|
|
var admitted = plan.Admitted;
|
|
var changes = new List<Change>(admitted.Count + evicted.Count + plan.Dependents.Count);
|
|
foreach (var stored in evicted)
|
|
{
|
|
if (!_entries.TryGetValue(stored.Key.Value, out var existing))
|
|
continue;
|
|
Unindex(existing);
|
|
_entries.Remove(stored.Key.Value);
|
|
_handles.Remove(existing.Document);
|
|
_variantKeys.Remove(stored.Key.Value);
|
|
changes.Add(new Change(CultCacheDocumentChangeKind.Removed, existing, null, existing.Document, ++_sequence));
|
|
}
|
|
|
|
foreach (var stored in admitted.Concat(plan.Dependents))
|
|
{
|
|
_entries.TryGetValue(stored.Key.Value, out var previous);
|
|
if (previous != null)
|
|
{
|
|
Unindex(previous);
|
|
_handles.Remove(previous.Document);
|
|
}
|
|
|
|
_entries[stored.Key.Value] = stored;
|
|
_handles.AddOrUpdate(stored.Document, new KeyBox(stored.Key));
|
|
Index(stored, plan.Projections[stored.Key.Value]);
|
|
if (stored.Variant == null)
|
|
_variantKeys.Remove(stored.Key.Value);
|
|
else
|
|
_variantKeys.Add(stored.Key.Value);
|
|
changes.Add(new Change(
|
|
previous == null ? CultCacheDocumentChangeKind.Added : CultCacheDocumentChangeKind.Updated,
|
|
stored,
|
|
stored.Document,
|
|
previous?.Document,
|
|
++_sequence));
|
|
}
|
|
|
|
if (source == null && _stores.Count == 0)
|
|
_dirtyInMemory = true;
|
|
return changes;
|
|
}
|
|
|
|
// The most derived routed home type assignable from the document type, else the untyped store.
|
|
private CacheBackingStore? Home(Type documentType)
|
|
{
|
|
CacheBackingStore? untyped = null;
|
|
CacheBackingStore? routed = null;
|
|
Type? best = null;
|
|
foreach (var (store, homes) in _stores)
|
|
{
|
|
if (homes.Length == 0)
|
|
{
|
|
untyped = store;
|
|
continue;
|
|
}
|
|
|
|
foreach (var home in homes)
|
|
{
|
|
if (home.IsAssignableFrom(documentType) && (best == null || best.IsAssignableFrom(home)))
|
|
{
|
|
best = home;
|
|
routed = store;
|
|
}
|
|
}
|
|
}
|
|
|
|
return routed ?? untyped;
|
|
}
|
|
|
|
private T? Single<T>(string lookup, Func<IEnumerable<string>> keys) where T : class
|
|
{
|
|
lock (_gate)
|
|
{
|
|
var found = keys().Distinct().ToArray();
|
|
if (found.Length > 1)
|
|
throw new InvalidOperationException(
|
|
$"{typeof(T).Name} {lookup} matches records {string.Join(", ", found)}; look it up by its concrete type.");
|
|
return found.Length == 1 && _entries.TryGetValue(found[0], out var stored) ? stored.Document as T : null;
|
|
}
|
|
}
|
|
|
|
private void Index(CultStoredDocument stored, Projection projection)
|
|
{
|
|
var type = stored.Descriptor.DocumentType;
|
|
if (stored.Descriptor.IsGlobal)
|
|
_globals[type] = stored.Key.Value;
|
|
if (projection.Name != null)
|
|
MapOf(_names, type)[projection.Name] = stored.Key.Value;
|
|
foreach (var (alias, value) in projection.Indexes)
|
|
AddHolder(_indexes, (type, alias), value, stored.Key.Value);
|
|
}
|
|
|
|
// Documents are mutable, so a name or index value may have changed since it was indexed: drop by key.
|
|
private void Unindex(CultStoredDocument stored)
|
|
{
|
|
var type = stored.Descriptor.DocumentType;
|
|
if (_globals.TryGetValue(type, out var globalKey) && globalKey == stored.Key.Value)
|
|
_globals.Remove(type);
|
|
if (_names.TryGetValue(type, out var names))
|
|
RemoveKey(names, stored.Key.Value);
|
|
foreach (var pair in _indexes.Where(pair => pair.Key.Type == type))
|
|
RemoveHolder(pair.Value, stored.Key.Value);
|
|
}
|
|
|
|
private static void AddHolder(Dictionary<(Type Type, string Alias), Dictionary<string, List<string>>> indexes, (Type, string) index, string value, string key)
|
|
{
|
|
if (!indexes.TryGetValue(index, out var map))
|
|
indexes[index] = map = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
|
if (!map.TryGetValue(value, out var keys))
|
|
map[value] = keys = new List<string>();
|
|
keys.Remove(key);
|
|
keys.Add(key);
|
|
}
|
|
|
|
private static void RemoveHolder(Dictionary<string, List<string>> map, string key)
|
|
{
|
|
foreach (var pair in map.ToArray())
|
|
{
|
|
if (pair.Value.Remove(key) && pair.Value.Count == 0)
|
|
map.Remove(pair.Key);
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, string> MapOf<TKey>(Dictionary<TKey, Dictionary<string, string>> maps, TKey key)
|
|
where TKey : notnull
|
|
{
|
|
if (!maps.TryGetValue(key, out var map))
|
|
maps[key] = map = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
return map;
|
|
}
|
|
|
|
private static void RemoveKey(Dictionary<string, string> map, string key)
|
|
{
|
|
foreach (var stale in map.Where(pair => pair.Value == key).Select(pair => pair.Key).ToArray())
|
|
map.Remove(stale);
|
|
}
|
|
|
|
private sealed class KeyBox
|
|
{
|
|
public KeyBox(CultRecordKey key)
|
|
{
|
|
Key = key;
|
|
}
|
|
|
|
public CultRecordKey Key { get; }
|
|
}
|
|
|
|
// What one outermost hold admitted, and what its journals threw.
|
|
private sealed class Hold
|
|
{
|
|
public List<(Change Change, bool Loaded)> Changes { get; } = new();
|
|
public List<Exception> Failures { get; } = new();
|
|
}
|
|
|
|
private sealed class Change
|
|
{
|
|
public Change(CultCacheDocumentChangeKind kind, CultStoredDocument stored, object? document, object? previous, long sequence)
|
|
{
|
|
Kind = kind;
|
|
Stored = stored;
|
|
Document = document;
|
|
Previous = previous;
|
|
Sequence = sequence;
|
|
}
|
|
|
|
public CultCacheDocumentChangeKind Kind { get; }
|
|
public CultStoredDocument Stored { get; }
|
|
public object? Document { get; }
|
|
public object? Previous { get; }
|
|
public long Sequence { get; }
|
|
}
|
|
}
|
|
|
|
public abstract class CacheBackingStore : IDisposable
|
|
{
|
|
private CultDocumentRegistry? _registry;
|
|
private CultSchemaMigrationReport[] _lastSchemaMigrationReports = Array.Empty<CultSchemaMigrationReport>();
|
|
|
|
protected CacheBackingStore(bool readOnly = false)
|
|
{
|
|
IsReadOnly = readOnly;
|
|
}
|
|
|
|
protected CultDocumentRegistry Registry =>
|
|
_registry ?? throw new InvalidOperationException("Backing store is not attached to a CultDocumentRegistry.");
|
|
|
|
// Null for a store no registry was attached to (CultMesh's raw single-file write).
|
|
private protected CultDocumentRegistry? AttachedRegistry => _registry;
|
|
|
|
protected ConcurrentDictionary<string, CultStoredDocument> Entries { get; } =
|
|
new(StringComparer.Ordinal);
|
|
|
|
public bool IsReadOnly { get; }
|
|
|
|
public bool IsDirty { get; protected set; }
|
|
|
|
public bool FlushOnDispose { get; set; }
|
|
|
|
public IReadOnlyList<CultSchemaMigrationReport> LastSchemaMigrationReports => _lastSchemaMigrationReports;
|
|
|
|
/// <summary>
|
|
/// The records this store's last load found and carries untouched, ordered by key: no registered type owns their schema id or
|
|
/// lists it as compatible. See <see cref="CultForeignRecord"/>.
|
|
/// </summary>
|
|
public IReadOnlyList<CultForeignRecord> ForeignRecords => _foreignRecords;
|
|
|
|
private CultForeignRecord[] _foreignRecords = Array.Empty<CultForeignRecord>();
|
|
|
|
// Set by the cache at attach. A pull hands over everything it loaded and dropped in one call before it adopts
|
|
// any of it; if the cache refuses a record the call throws and the store keeps its previous view.
|
|
protected internal Action<IReadOnlyList<CultStoredDocument>, IReadOnlyList<CultStoredDocument>>? Loaded;
|
|
|
|
private readonly object _detachedGate = new();
|
|
internal CultCache? Cache;
|
|
|
|
private const string DirectoryFormatV4 = "cultcache.store.v4.directory-content-addressed-pages";
|
|
private const string DirectoryFormatV5 = "cultcache.store.v5.directory-content-addressed-pages";
|
|
|
|
/// <summary>
|
|
/// The one header decision, for every writer of every store: the string a store file's format header must carry after
|
|
/// this write.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The marked header (v3, or the directory store's v5) says the store holds an element id: a reader older than ids refuses
|
|
/// it, because it would skip the id slots and rewrite the elements without them. A writer that rewrites the whole store
|
|
/// (<paramref name="wholeStore"/>) decides by content, so a store drops back to unmarked when nothing in it holds an id.
|
|
/// A writer that touches only part of the store keeps a header already marked (<paramref name="existingHeader"/>), because
|
|
/// it cannot see what the rest holds. <paramref name="holdsIds"/> is about the records this write knows are on disk.
|
|
/// Unmarked, a single-file store is v2 exactly when it holds a variant (<paramref name="holdsVariants"/>) and otherwise v1;
|
|
/// the directory store refuses variants and is v4. Pass the header the file carries, or null when it has none, and refuse
|
|
/// a file whose header you cannot read before calling this.
|
|
/// </remarks>
|
|
protected internal static string HeaderFor(bool holdsIds, string? existingHeader, bool wholeStore, bool directoryStore, bool holdsVariants = false)
|
|
{
|
|
var marked = directoryStore ? DirectoryFormatV5 : CultPersistedStoreSnapshot.FormatV3;
|
|
if (holdsIds || (!wholeStore && string.Equals(existingHeader, marked, StringComparison.Ordinal)))
|
|
return marked;
|
|
return directoryStore ? DirectoryFormatV4 : holdsVariants ? CultPersistedStoreSnapshot.FormatV2 : CultPersistedStoreSnapshot.FormatV1;
|
|
}
|
|
|
|
// The header a write leaves, for either store kind: HeaderFor decides. An element id is an ordinary keyed string, so only a
|
|
// reader with the record's type can tell from its bytes whether it holds one; the store header is how a reader without the types
|
|
// learns it. A write that copies any
|
|
// record forward keeps a marked header; a write that encodes every record the store will hold decides by content. An
|
|
// opaque payload (null) never marks an unmarked store, and keeps a marked one marked. A copied record the writer decodes
|
|
// under the store's lock holding an id marks the store too (SingleFileBackingStore.CopiedHoldIds): another runtime may
|
|
// have written ids under an unmarked header. Nothing a load or a decode records can unmark a store.
|
|
protected static string HeaderForWrite(string? durableHeader, IReadOnlyCollection<bool?> written, bool copiesAny, bool copiedHoldIds, bool directoryStore, bool holdsVariants) =>
|
|
HeaderFor(
|
|
holdsIds: copiedHoldIds || written.Any(value => value == true),
|
|
durableHeader,
|
|
wholeStore: !copiesAny && written.All(value => value != null),
|
|
directoryStore,
|
|
holdsVariants);
|
|
|
|
// Once attached, a hold on the cache's gate: the store and its cache share one lock, so a load calling back into
|
|
// the cache can never take the gate after the store lock, and a direct call publishes what it loaded when it
|
|
// returns. File locks are always taken inside it.
|
|
protected T Held<T>(Func<T> body)
|
|
{
|
|
if (Cache is { } cache)
|
|
return cache.Held(body);
|
|
lock (_detachedGate)
|
|
return body();
|
|
}
|
|
|
|
protected void Held(Action body) => Held(() => { body(); return true; });
|
|
|
|
private protected CultDocumentRegistry? SwapRegistry(CultDocumentRegistry? registry)
|
|
{
|
|
var previous = _registry;
|
|
_registry = registry;
|
|
return previous;
|
|
}
|
|
|
|
internal void AttachRegistry(CultDocumentRegistry registry)
|
|
{
|
|
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
|
}
|
|
|
|
public abstract void PullAll();
|
|
public abstract void Push(CultStoredDocument entry);
|
|
public abstract void Delete(CultStoredDocument entry);
|
|
public abstract void PushAll();
|
|
|
|
/// <summary>
|
|
/// One durable step under the store's exclusive lock: the batch and every staged write land onto the store as it is then,
|
|
/// and the batch's conditions are evaluated against it. A failure restores the store's staged view.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// An implementation judges <see cref="CultCommitRequest.Checks"/>, and its own <see cref="StagedChecks"/>, under that
|
|
/// lock with the keys it writes: a key whose record is under an id no registered type declares refuses the commit, and a
|
|
/// checked key is never removed. The cache calls <see cref="JudgeChecks"/> on every other store before this commit. Each
|
|
/// foreign record a store lists comes from <see cref="Foreign"/> with the version it read, so pass <see cref="ForeignRecords"/> to
|
|
/// <see cref="CultCommitRequest.ConditionsHold(IReadOnlyDictionary{string, string}, IEnumerable{CultStoredDocument}, IEnumerable{CultForeignRecord})"/>.
|
|
/// </remarks>
|
|
public abstract CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait);
|
|
|
|
public virtual void Dispose()
|
|
{
|
|
if (FlushOnDispose && (IsDirty || HasStagedChecks) && !IsReadOnly)
|
|
{
|
|
PushAll();
|
|
}
|
|
}
|
|
|
|
protected void ThrowIfReadOnly()
|
|
{
|
|
if (IsReadOnly)
|
|
throw new InvalidOperationException($"Backing store {this} is read-only.");
|
|
}
|
|
|
|
protected CultPersistedRecord ToPersistedRecord(CultStoredDocument entry, Func<object, byte[]> serializePayload)
|
|
{
|
|
// A variant persists its delta and an empty payload; the resolved view is never written.
|
|
return new CultPersistedRecord
|
|
{
|
|
Key = entry.Key.Value,
|
|
SchemaId = entry.Descriptor.SchemaId,
|
|
StoredAt = entry.StoredAt,
|
|
Payload = entry.Variant != null ? Array.Empty<byte>() : serializePayload(entry.Document),
|
|
Variant = entry.Variant
|
|
};
|
|
}
|
|
|
|
// The one load surface: a persisted record resolved against the current type, with the report of what that
|
|
// resolution shed. A plain record sheds a member its type dropped by never carrying it into the document; a variant
|
|
// sheds the override of such a slot by never carrying it into the delta, so a later type reusing the slot number
|
|
// cannot resurrect it and a flush needs no rule of its own. The report is the schema diff's ignored slots plus the
|
|
// overrides whose slot the type does not have, including one the persisted schema never listed. The version is the reading
|
|
// store's: it read the bytes.
|
|
protected CultStoredDocument ToStoredDocument(
|
|
CultPersistedRecord record,
|
|
string version,
|
|
IReadOnlyCollection<CultSchemaCatalogEntry> catalog,
|
|
Func<Type, byte[], object> deserializePayload,
|
|
out CultSchemaMigrationReport report)
|
|
{
|
|
var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog);
|
|
report = resolution.Report;
|
|
if (record.Variant != null)
|
|
{
|
|
var descriptor = resolution.Descriptor;
|
|
bool Has(CultVariantOverride entryOverride) =>
|
|
entryOverride.Path.Count != 1 || descriptor.RichMembers.Any(member => member.Slot == entryOverride.Path[0].Slot);
|
|
var shed = record.Variant.Overrides
|
|
.Where(entryOverride => !Has(entryOverride))
|
|
.Select(entryOverride => entryOverride.Path[0].Slot)
|
|
.Distinct()
|
|
.OrderBy(slot => slot)
|
|
.ToArray();
|
|
if (shed.Length > 0)
|
|
{
|
|
// A slot the schema diff already reported is not reported twice.
|
|
var unreported = shed.Except(report.IgnoredExtraSlots).ToArray();
|
|
report.IgnoredExtraSlots = report.IgnoredExtraSlots.Concat(unreported).OrderBy(slot => slot).ToArray();
|
|
report.Warnings = report.Warnings.Concat(unreported.Select(slot => new CultSchemaMigrationWarning
|
|
{
|
|
Code = "ignored_extra_slot",
|
|
Message = $"Variant {record.Key} overrides slot {slot}, which {descriptor.DocumentType.Name} does not have; the override is ignored and shed on flush."
|
|
})).ToArray();
|
|
}
|
|
|
|
// The cache resolves it against its base when it admits the load.
|
|
return new CultStoredDocument(
|
|
new CultRecordKey(record.Key),
|
|
record.StoredAt,
|
|
descriptor,
|
|
new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides.Where(Has).ToArray()),
|
|
null,
|
|
Cache?.Codec)
|
|
{
|
|
StoredSchemaId = record.SchemaId,
|
|
StoredVersion = version
|
|
};
|
|
}
|
|
|
|
object document;
|
|
try
|
|
{
|
|
document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload);
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
// The codec knows what it refused but not which record; the store does. Say both, and never null the record.
|
|
var causes = new List<string>();
|
|
for (var cause = exception; cause != null; cause = cause.InnerException) causes.Add(cause.Message);
|
|
throw new InvalidDataException($"Record '{record.Key}' (schema '{record.SchemaId}') cannot be decoded: {string.Join(" <- ", causes.Distinct())}", exception);
|
|
}
|
|
return new CultStoredDocument(
|
|
new CultRecordKey(record.Key),
|
|
record.StoredAt,
|
|
resolution.Descriptor,
|
|
document)
|
|
{
|
|
StoredSchemaId = record.SchemaId,
|
|
StoredVersion = version
|
|
};
|
|
}
|
|
|
|
protected void SetLastSchemaMigrationReports(IEnumerable<CultSchemaMigrationReport> reports)
|
|
{
|
|
_lastSchemaMigrationReports = reports?.ToArray() ?? Array.Empty<CultSchemaMigrationReport>();
|
|
}
|
|
|
|
// The one test of whether a stored record is foreign: no registered type claims its schema id through the catalog it was
|
|
// stored with. It reads the record's header only, never its payload. Null for a record the registry resolves.
|
|
// The one constructor path for a foreign record, so every store kind, one outside this library included, lists it at the
|
|
// version it read.
|
|
protected CultForeignRecord? Foreign(CultPersistedRecord record, IReadOnlyCollection<CultSchemaCatalogEntry> catalog, string storedVersion) =>
|
|
Registry.Claimant(record.SchemaId, catalog, out var persisted) == null
|
|
? new CultForeignRecord(record.Key, record.SchemaId, persisted!.SchemaName, record.StoredAt, storedVersion)
|
|
: null;
|
|
|
|
protected void SetForeignRecords(IEnumerable<CultForeignRecord> foreign)
|
|
{
|
|
_foreignRecords = foreign.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray();
|
|
}
|
|
|
|
// A cache never writes, relabels or removes a record stored under an id its types do not declare: the staging half of rule 3.
|
|
// Refused when the store carries a foreign record at the key, or holds one whose stored id no registered type owns or declares
|
|
// compatible (a record a type reads by the catalog's schema name or a persisted entry's list: read-only). Called for every
|
|
// record a cache write admits or evicts, before anything is staged.
|
|
protected internal void RefuseUndeclared(string key)
|
|
{
|
|
var foreign = _foreignRecords.FirstOrDefault(record => string.Equals(record.Key, key, StringComparison.Ordinal));
|
|
if (foreign != null)
|
|
throw Overwrites(null, new[] { (foreign.Key, foreign.SchemaId, foreign.SchemaName) });
|
|
if (Entries.TryGetValue(key, out var held) && !Registry.Declares(held.StoredSchemaId))
|
|
throw Overwrites(null, new[] { (key, held.StoredSchemaId, held.Descriptor.SchemaName) });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when the cache removes keys it does not hold, outside a batch that commits through this store. Such a removal
|
|
/// removes nothing, but a store that holds a record at one of these keys under an id no registered type declares must
|
|
/// refuse it rather than let the caller believe the record is gone.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The default refuses now, from this store's view: the foreign records its last load listed and the records it holds
|
|
/// (<see cref="RefuseUndeclared"/>). It reads nothing, so it cannot see a record another writer put there after that load.
|
|
/// </para>
|
|
/// <para>
|
|
/// A writable store kind that reads its durable state under a lock overrides this to call <see cref="StageChecks"/>, and
|
|
/// judges <see cref="StagedChecks"/> at its next <see cref="PushAll"/> or <see cref="CommitBatch"/> as it judges
|
|
/// <see cref="CultCommitRequest.Checks"/>: under the lock, with the keys it writes, refused through the same refusal, never
|
|
/// removed. It calls <see cref="MarkFlushSucceeded"/> when that step succeeds, which clears them. A read-only store has no
|
|
/// such step and keeps the default. Both shipped store kinds do this. A batch's own checks are judged through
|
|
/// <see cref="JudgeChecks"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
protected internal virtual void RefuseRemovalOfUndeclared(IReadOnlyCollection<string> keys)
|
|
{
|
|
foreach (var key in keys)
|
|
RefuseUndeclared(key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called for a batch's checks on every attached store that is not the batch's home, before the home commits. It judges
|
|
/// only the keys it is given: it never writes, stages, clears <see cref="StagedChecks"/> or calls
|
|
/// <see cref="MarkFlushSucceeded"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The default answers from this store's view (<see cref="RefuseUndeclared"/>) and reads nothing, which is right for a
|
|
/// read-only store. A writable store kind that reads its durable state under a lock overrides this to read under that
|
|
/// lock and refuse through <see cref="Refuse"/>, returning <see cref="CultCommitOutcome.Contended"/> when it may not wait
|
|
/// and the lock is held. A refusal or contention here writes nothing anywhere, because the home has not yet committed.
|
|
/// </remarks>
|
|
protected internal virtual CultCommitOutcome JudgeChecks(IReadOnlyCollection<string> keys, bool wait)
|
|
{
|
|
foreach (var key in keys)
|
|
RefuseUndeclared(key);
|
|
return CultCommitOutcome.Committed;
|
|
}
|
|
|
|
// Keys a removal named that this store's cache does not hold, waiting for this store's next flush or commit to judge them.
|
|
// A refusal takes back the keys it refused; a successful flush or commit clears them.
|
|
private readonly HashSet<string> _checks = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>
|
|
/// The keys <see cref="StageChecks"/> staged for this store's next flush or commit to judge. They are not writes:
|
|
/// <see cref="IsDirty"/> does not count them.
|
|
/// </summary>
|
|
protected IReadOnlyCollection<string> StagedChecks => _checks;
|
|
|
|
/// <summary>
|
|
/// Stages keys for this store's next flush or commit to judge without removing them. See
|
|
/// <see cref="RefuseRemovalOfUndeclared"/>.
|
|
/// </summary>
|
|
protected void StageChecks(IEnumerable<string> keys) => Held(() =>
|
|
{
|
|
foreach (var key in keys)
|
|
_checks.Add(key);
|
|
});
|
|
|
|
internal bool HasStagedChecks => _checks.Count > 0;
|
|
|
|
// A stored record under an id the writer does not declare, named by the catalog entry that publishes the id. Header only: no
|
|
// payload is decoded. Null for a record under a declared id.
|
|
protected static (string Key, string SchemaId, string SchemaName)? Undeclared(
|
|
CultPersistedRecord record, IReadOnlyCollection<CultSchemaCatalogEntry> catalog, Func<string, bool> declares) =>
|
|
declares(record.SchemaId)
|
|
? null
|
|
: (record.Key, record.SchemaId, CultDocumentRegistry.PublishedBy(record.SchemaId, catalog)?.SchemaName ?? string.Empty);
|
|
|
|
// The apply half of rule 3, for every store kind, checked where the write lands under the store's lock. A key this write names
|
|
// whose durable record is under an id no registered type declares is refused, every such key at once, through Refuse, which
|
|
// reloads it as the store holds it: listed foreign when no type reads it, held read-only when one does.
|
|
protected void RefuseUndeclaredOnDisk(
|
|
IEnumerable<string> written,
|
|
Func<string, (string Key, string SchemaId, string SchemaName)?> undeclaredAt,
|
|
Func<string, CultStoredDocument?> durableAt,
|
|
Func<string, CultForeignRecord?> foreignAt,
|
|
Action<string> unstage,
|
|
bool cacheless = false)
|
|
{
|
|
var refused = written.Distinct(StringComparer.Ordinal)
|
|
.Select(undeclaredAt)
|
|
.OfType<(string Key, string SchemaId, string SchemaName)>()
|
|
.OrderBy(record => record.Key, StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (refused.Length == 0)
|
|
return;
|
|
Refuse(refused.Select(record => record.Key), durableAt, foreignAt, unstage, inner => Overwrites(inner, refused, cacheless));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one refusal of a write, for every store kind: it reloads what it refuses, or changes nothing. For each key,
|
|
/// <paramref name="durableAt"/> gives the durable record decoded as a load would (null when the store holds none, or holds
|
|
/// it as a foreign record), and <paramref name="foreignAt"/> the foreign record it holds there, if any. What differs from this
|
|
/// store's view is handed to <see cref="Loaded"/>, the path a pull uses; only when that succeeds does the store forget its
|
|
/// staged change at each key (<paramref name="unstage"/>), take the loaded and dropped records into its view, and list the
|
|
/// keys' foreign records as a pull lists them. If the reload throws, nothing has changed and the refusal carries that
|
|
/// exception. Always throws what <paramref name="refusal"/> returns.
|
|
/// </summary>
|
|
protected void Refuse(
|
|
IEnumerable<string> keys,
|
|
Func<string, CultStoredDocument?> durableAt,
|
|
Func<string, CultForeignRecord?> foreignAt,
|
|
Action<string> unstage,
|
|
Func<Exception?, Exception> refusal)
|
|
{
|
|
var refused = keys.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).ToArray();
|
|
var loaded = new List<CultStoredDocument>();
|
|
var dropped = new List<CultStoredDocument>();
|
|
var foreign = new List<CultForeignRecord>();
|
|
try
|
|
{
|
|
foreach (var key in refused)
|
|
{
|
|
if (foreignAt(key) is { } carried)
|
|
foreign.Add(carried);
|
|
Entries.TryGetValue(key, out var held);
|
|
var durable = durableAt(key);
|
|
if (durable == null)
|
|
{
|
|
if (held != null)
|
|
dropped.Add(held);
|
|
}
|
|
else if (held == null || !SameStoredIdentity(held.StoredVersion, durable.StoredVersion))
|
|
loaded.Add(durable);
|
|
}
|
|
|
|
if (loaded.Count > 0 || dropped.Count > 0)
|
|
Loaded?.Invoke(loaded, dropped);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw refusal(ex);
|
|
}
|
|
|
|
foreach (var key in refused)
|
|
{
|
|
unstage(key);
|
|
_checks.Remove(key);
|
|
}
|
|
foreach (var held in dropped)
|
|
Entries.TryRemove(held.Key.Value, out _);
|
|
foreach (var stored in loaded)
|
|
Entries[stored.Key.Value] = stored;
|
|
SetForeignRecords(_foreignRecords.Where(record => !refused.Contains(record.Key, StringComparer.Ordinal)).Concat(foreign));
|
|
throw refusal(null);
|
|
}
|
|
|
|
// Two stored records are the same stored bytes when their versions are equal. The one place a pull's reload filter, the
|
|
// staleness check and a refusal's reload decide it.
|
|
protected static bool SameStoredIdentity(string? version, string? otherVersion) =>
|
|
string.Equals(version, otherVersion, StringComparison.Ordinal);
|
|
|
|
// The one spelling of a version in both store kinds: the lowercase hex of the SHA-256 of a record's persisted encoding.
|
|
protected static string VersionOf(byte[] sha256)
|
|
{
|
|
var hex = new StringBuilder(sha256.Length * 2);
|
|
foreach (var value in sha256)
|
|
hex.Append(value.ToString("x2", CultureInfo.InvariantCulture));
|
|
return hex.ToString();
|
|
}
|
|
|
|
// The one re-mint, for every store kind (storedat-on-change): a record written over one the store holds is stored at a storedAt
|
|
// later than the one it replaces, whoever wrote that. The staged storedAt stands only when it is already later (both parsed
|
|
// RoundtripKind; an unparsable one counts as not later); otherwise it is minted from the replaced one.
|
|
protected string StoredAtOver(string storedAt, string replacedAt) =>
|
|
StoredLater(storedAt, replacedAt) ? storedAt : CultCache.MintStoredAt(replacedAt, (Cache?.Clock ?? (() => DateTimeOffset.UtcNow))());
|
|
|
|
private static bool StoredLater(string storedAt, string than) =>
|
|
DateTimeOffset.TryParse(storedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var later) &&
|
|
DateTimeOffset.TryParse(than, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var earlier) &&
|
|
later > earlier;
|
|
|
|
// The one refusal of a write onto records stored under ids no registered type declares, carried foreign or held read-only.
|
|
// It names each record's key, stored id and schema name, never its payload. When the reload that refusal owes failed, the
|
|
// refusal carries that failure as its inner exception.
|
|
// A cacheless writer (a CultMesh single-file write) declares only the catalog entry it writes, and has nothing to reload.
|
|
protected CultSchemaConflictException Overwrites(Exception? inner, (string Key, string SchemaId, string SchemaName)[] refused, bool cacheless = false) => new(
|
|
$"{(refused.Length == 1 ? "Record" : "Records")} {string.Join(", ", refused.Select(record => $"'{record.Key}' (schema id '{record.SchemaId}', '{record.SchemaName}')"))} in {this}: " +
|
|
(cacheless
|
|
? "the catalog entry this write publishes neither owns this schema id nor lists it compatible, so the write never relabels or removes it; nothing was written and nothing was reloaded. " +
|
|
"List the id as compatible on the schema that should own it."
|
|
: "no registered type owns this schema id or declares it compatible, so this cache never writes, relabels or removes it. " +
|
|
"Declare the id compatible on the type that should own it.") +
|
|
(inner == null ? string.Empty : " Reloading what the write refused failed, so the cache is unchanged."),
|
|
refused[0].SchemaId,
|
|
refused.Select(record => record.SchemaName).Distinct(StringComparer.Ordinal).ToArray(),
|
|
refused[0].Key,
|
|
inner);
|
|
|
|
// A flush or commit succeeded: nothing staged is left, writes or checks.
|
|
protected void MarkFlushSucceeded()
|
|
{
|
|
IsDirty = false;
|
|
_checks.Clear();
|
|
}
|
|
}
|
|
|
|
public abstract class SingleFileBackingStore : CacheBackingStore
|
|
{
|
|
protected SingleFileBackingStore(string filePath, bool readOnly = false)
|
|
: base(readOnly)
|
|
{
|
|
FileInfo = new FileInfo(filePath);
|
|
}
|
|
|
|
protected FileInfo FileInfo { get; }
|
|
|
|
// Keys this cache has written or removed here since the store last wrote them: the only keys a foreign record can refuse.
|
|
// A key is staged exactly while the store is dirty with it, and a pull runs only on a clean store.
|
|
private readonly HashSet<string> _staged = new(StringComparer.Ordinal);
|
|
|
|
// The version of every record the store held when this store last read it, as this store last wrote it for keys it wrote.
|
|
// A write into a store holding a variant lands only while the durable versions still equal these.
|
|
private readonly Dictionary<string, string> _lastRead = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>
|
|
/// A store file from its parts, each already encoded: a catalog entry or record this store carries forward is the bytes its
|
|
/// file held, one it writes is <see cref="SerializeCatalogEntry"/> or <see cref="SerializeRecord"/> of the object.
|
|
/// </summary>
|
|
protected abstract byte[] SerializeStore(string formatVersion, IReadOnlyList<byte[]> catalogEntries, IReadOnlyList<byte[]> records);
|
|
|
|
// One record's persisted encoding: the bytes a version names, and the bytes a written record is stored as.
|
|
protected abstract byte[] SerializeRecord(CultPersistedRecord record);
|
|
|
|
// One catalog entry's persisted encoding, for an entry the writer built.
|
|
protected abstract byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry);
|
|
|
|
/// <summary>
|
|
/// This store's one reader of a non-empty store file, and so its one verdict on whether the file may be replaced: open,
|
|
/// flush and commit all ask it. It must throw <see cref="CultStoreUnreadableException"/> for anything that is not exactly one
|
|
/// store this runtime reads (a truncated file, bytes after the store, a missing or extra slot, a format or record it does
|
|
/// not know), so a rewrite never overwrites a file it cannot see. It returns the decoded snapshot and, parallel to the
|
|
/// snapshot's catalog and records, the exact bytes the file holds for each: the store keeps those, never the snapshot's objects.
|
|
/// </summary>
|
|
protected abstract (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data);
|
|
|
|
// Test seam: how many record encodings this store has hashed, to prove what a write hashes.
|
|
internal int RecordsHashed;
|
|
|
|
// Test seam: how many copied records a write decoded to ask whether they hold an element id.
|
|
internal int CopiedRecordsDecoded;
|
|
|
|
private string VersionOfBytes(byte[] bytes)
|
|
{
|
|
RecordsHashed++;
|
|
using var sha256 = SHA256.Create();
|
|
return VersionOf(sha256.ComputeHash(bytes));
|
|
}
|
|
|
|
// What the store's file holds, as the store owns it: the snapshot as read, and keyed by record key the exact slice the file
|
|
// holds for each record, whose version is the SHA-256 of that slice, hashed when a comparison first asks. Catalog entries
|
|
// keep their slices by the entry object this read produced. A write copies a record it did not stage from its slice here
|
|
// and re-encodes every record it was handed, so no object a caller edits is ever mistaken for the bytes in the file.
|
|
private sealed class Durable
|
|
{
|
|
private readonly SingleFileBackingStore _owner;
|
|
private readonly Dictionary<string, byte[]> _records;
|
|
private readonly Dictionary<string, string> _versions = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<CultSchemaCatalogEntry, byte[]> _catalog = new();
|
|
|
|
internal Durable(SingleFileBackingStore owner, (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) read)
|
|
{
|
|
_owner = owner;
|
|
Snapshot = read.Snapshot;
|
|
_records = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
|
for (var index = 0; index < Snapshot.Records.Length; index++)
|
|
_records[Snapshot.Records[index].Key] = read.RecordBytes[index];
|
|
for (var index = 0; index < Snapshot.SchemaCatalog.Length; index++)
|
|
_catalog.Add(Snapshot.SchemaCatalog[index], read.CatalogBytes[index]);
|
|
}
|
|
|
|
internal CultPersistedStoreSnapshot Snapshot { get; }
|
|
|
|
internal byte[] Slice(string key) => _records[key];
|
|
|
|
internal string Version(string key)
|
|
{
|
|
if (!_versions.TryGetValue(key, out var version))
|
|
_versions[key] = version = _owner.VersionOfBytes(_records[key]);
|
|
return version;
|
|
}
|
|
|
|
// The bytes to write for a catalog entry the write chose: the slice the file held when it is one of this read's entries,
|
|
// otherwise an encoding of the entry the writer built.
|
|
internal byte[] CatalogBytes(CultSchemaCatalogEntry entry) =>
|
|
_catalog.TryGetValue(entry, out var bytes) ? bytes : _owner.SerializeCatalogEntry(entry);
|
|
}
|
|
|
|
protected abstract byte[] SerializePayload(object document, Type documentType);
|
|
protected abstract object DeserializePayload(Type documentType, byte[] payload);
|
|
|
|
public override string ToString() => FileInfo.FullName;
|
|
|
|
public override void PullAll() => Held(PullAllCore);
|
|
|
|
private void PullAllCore()
|
|
{
|
|
// 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 them before the next flush. Flush first.
|
|
if (IsDirty)
|
|
return;
|
|
|
|
var durable = ReadSnapshot();
|
|
if (durable == null)
|
|
{
|
|
_lastRead.Clear();
|
|
SetLastSchemaMigrationReports(Array.Empty<CultSchemaMigrationReport>());
|
|
SetForeignRecords(Array.Empty<CultForeignRecord>());
|
|
return;
|
|
}
|
|
|
|
var snapshot = durable.Snapshot;
|
|
var reports = new List<CultSchemaMigrationReport>(snapshot.Records.Length);
|
|
var persisted = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
|
|
var foreign = new List<CultForeignRecord>();
|
|
var versions = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var record in snapshot.Records)
|
|
{
|
|
var version = versions[record.Key] = durable.Version(record.Key);
|
|
if (Foreign(record, snapshot.SchemaCatalog, version) is { } carried)
|
|
{
|
|
foreign.Add(carried);
|
|
continue;
|
|
}
|
|
|
|
var stored = ToStoredDocument(record, version, snapshot.SchemaCatalog, DeserializePayload, out var report);
|
|
reports.Add(report);
|
|
persisted[stored.Key.Value] = stored;
|
|
}
|
|
|
|
var loaded = persisted.Values
|
|
.Where(stored => !Entries.TryGetValue(stored.Key.Value, out var existing) ||
|
|
!SameStoredIdentity(existing.StoredVersion, stored.StoredVersion))
|
|
.ToArray();
|
|
var dropped = Entries.Values.Where(existing => !persisted.ContainsKey(existing.Key.Value)).ToArray();
|
|
if (loaded.Length > 0 || dropped.Length > 0)
|
|
Loaded?.Invoke(loaded, dropped);
|
|
|
|
foreach (var stored in dropped)
|
|
Entries.TryRemove(stored.Key.Value, out _);
|
|
foreach (var stored in loaded)
|
|
Entries[stored.Key.Value] = stored;
|
|
_lastRead.Clear();
|
|
foreach (var (key, version) in versions)
|
|
_lastRead[key] = version;
|
|
SetLastSchemaMigrationReports(reports);
|
|
SetForeignRecords(foreign);
|
|
}
|
|
|
|
public override void Push(CultStoredDocument entry)
|
|
{
|
|
ThrowIfReadOnly();
|
|
Held(() =>
|
|
{
|
|
Entries[entry.Key.Value] = entry;
|
|
_staged.Add(entry.Key.Value);
|
|
IsDirty = true;
|
|
});
|
|
}
|
|
|
|
public override void Delete(CultStoredDocument entry)
|
|
{
|
|
ThrowIfReadOnly();
|
|
Held(() =>
|
|
{
|
|
Entries.TryRemove(entry.Key.Value, out _);
|
|
_staged.Add(entry.Key.Value);
|
|
IsDirty = true;
|
|
});
|
|
}
|
|
|
|
// A flush applies this store's staged writes and removals onto what the file holds now, under the lock: two writers never
|
|
// interleave bytes, every record this cache did not stage stays as the file has it, and a plain flush is last-writer-wins
|
|
// only per staged key. A key whose durable record is under an id this cache does not declare refuses the write, and in a store
|
|
// holding a variant it lands only onto the store as this cache last read it (both ApplyWriteSet).
|
|
// A file it cannot read (truncated, trailing bytes, not a store, a format it does not read) is refused and left as it is.
|
|
public override void PushAll()
|
|
{
|
|
ThrowIfReadOnly();
|
|
Held(() =>
|
|
{
|
|
using (AcquireLock(wait: true))
|
|
{
|
|
var disk = ReadSnapshot();
|
|
var (upserts, removals) = StagedWrites(Array.Empty<CultStoredDocument>(), Array.Empty<CultStoredDocument>());
|
|
Wrote(ApplyWriteSet(disk, upserts, removals, Array.Empty<string>()));
|
|
}
|
|
|
|
_staged.Clear();
|
|
MarkFlushSucceeded();
|
|
});
|
|
}
|
|
|
|
public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait)
|
|
{
|
|
ThrowIfReadOnly();
|
|
return Held(() => CommitBatchCore(request, wait));
|
|
}
|
|
|
|
// Conditions are checked against the file under the lock, and only the condition differs between a conditional and an
|
|
// unconditional commit: both apply the batch and every staged write onto the file as it is.
|
|
private CultCommitOutcome CommitBatchCore(CultCommitRequest request, bool wait)
|
|
{
|
|
using var fileLock = AcquireLock(wait);
|
|
if (fileLock == null)
|
|
return CultCommitOutcome.Contended;
|
|
|
|
var disk = ReadSnapshot();
|
|
if (request.HasConditions && !request.ConditionsHold(
|
|
(disk?.Snapshot.Records ?? Array.Empty<CultPersistedRecord>()).ToDictionary(record => record.Key, record => disk!.Version(record.Key), StringComparer.Ordinal),
|
|
Entries.Values,
|
|
ForeignRecords))
|
|
return CultCommitOutcome.Mismatch;
|
|
|
|
var (upserts, removals) = StagedWrites(request.Upserts, request.Deletes);
|
|
var written = ApplyWriteSet(disk, upserts, removals, request.Checks);
|
|
foreach (var entry in request.Deletes)
|
|
Entries.TryRemove(entry.Key.Value, out _);
|
|
foreach (var entry in request.Upserts)
|
|
Entries[entry.Key.Value] = entry;
|
|
Wrote(written);
|
|
_staged.Clear();
|
|
MarkFlushSucceeded();
|
|
return CultCommitOutcome.Committed;
|
|
}
|
|
|
|
// What a write puts in the store and takes out of it: the staged keys as this cache holds them now (a key it no longer holds
|
|
// is a removal), and a commit's batch, which wins over a staged write of the same key. Records it did not stage are not here.
|
|
private (List<CultStagedRecord> Upserts, List<string> Removals) StagedWrites(
|
|
IReadOnlyList<CultStoredDocument> batchUpserts,
|
|
IReadOnlyList<CultStoredDocument> batchDeletes)
|
|
{
|
|
var documents = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
|
|
var removals = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var key in _staged)
|
|
{
|
|
if (Entries.TryGetValue(key, out var held))
|
|
documents[key] = held;
|
|
else
|
|
removals.Add(key);
|
|
}
|
|
|
|
foreach (var entry in batchDeletes)
|
|
{
|
|
documents.Remove(entry.Key.Value);
|
|
removals.Add(entry.Key.Value);
|
|
}
|
|
|
|
foreach (var entry in batchUpserts)
|
|
{
|
|
documents[entry.Key.Value] = entry;
|
|
removals.Remove(entry.Key.Value);
|
|
}
|
|
|
|
var catalogEntries = new Dictionary<CultDocumentDescriptor, CultSchemaCatalogEntry>();
|
|
var upserts = documents.Values.Select(document =>
|
|
{
|
|
if (!catalogEntries.TryGetValue(document.Descriptor, out var catalogEntry))
|
|
catalogEntries[document.Descriptor] = catalogEntry = document.Descriptor.ToCatalogEntry();
|
|
return new CultStagedRecord(ToPersistedRecord(document, payload => SerializePayload(payload, payload.GetType())), catalogEntry, document.HoldsIds);
|
|
}).ToList();
|
|
return (upserts, removals.ToList());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes one record to the store file without a cache. Under the store's lock the file is read by the same reader a cache's
|
|
/// store uses, <paramref name="record"/> replaces the record at its key, and every other record and catalog entry is copied
|
|
/// forward as stored. The file is replaced atomically, and its format header is decided as a flush decides it:
|
|
/// <paramref name="holdsIds"/> true marks the store as holding element ids, false leaves it unmarked, and null (a payload
|
|
/// the caller cannot read) keeps whatever the file already says. A write that finds the file unreadable, or a record at
|
|
/// the key under a schema id <paramref name="catalogEntry"/> does not declare, is refused and leaves the file as it was.
|
|
/// </summary>
|
|
/// <param name="record">The record to store.</param>
|
|
/// <param name="catalogEntry">The schema catalog entry for the record's schema id.</param>
|
|
/// <param name="holdsIds">Whether the record's payload holds element ids, or null when the caller cannot tell.</param>
|
|
/// <param name="registry">The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read; omitted, it decides nothing for them. It serves this call only: the store keeps no registry.</param>
|
|
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache.</exception>
|
|
/// <exception cref="CultStoreUnreadableException">The file at the path is not a store this runtime can read.</exception>
|
|
public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null)
|
|
{
|
|
if (record == null) throw new ArgumentNullException(nameof(record));
|
|
if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry));
|
|
RefuseWhenAttached();
|
|
ForTheCall(registry, () => WriteOne(record, catalogEntry, holdsIds));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes one typed document to the store file without a cache, as a cache write would. Element ids are decided before anything
|
|
/// is written: an unset id is minted, a list a cache write refuses refuses the write, and the file is marked as holding element
|
|
/// ids when the document holds one. A write that fails gives the minted ids back. Then the document is encoded as
|
|
/// <typeparamref name="TDocument"/>, the type its schema id and ids are taken from, with <paramref name="registry"/>, and stored as
|
|
/// <see cref="WriteRecord"/> stores a record: every other record and catalog entry is copied forward as stored, under the store's
|
|
/// lock, and the file is replaced atomically.
|
|
/// </summary>
|
|
/// <typeparam name="TDocument">The registered document type. A derived instance is stored as this type.</typeparam>
|
|
/// <param name="key">The record key the document is stored under.</param>
|
|
/// <param name="document">The document to store.</param>
|
|
/// <param name="storedAt">The stored-at stamp, or null for now.</param>
|
|
/// <param name="registry">The registry that describes and encodes the document, or null for <see cref="CultDocumentRegistry.Shared"/>. It serves this call only: the store keeps no registry.</param>
|
|
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache.</exception>
|
|
/// <exception cref="CultStoreUnreadableException">The file at the path is not a store this runtime can read.</exception>
|
|
/// <exception cref="CultElementIdException">The document holds an element id or list a cache write refuses.</exception>
|
|
public void WriteDocument<TDocument>(CultRecordKey key, TDocument document, string? storedAt = null, CultDocumentRegistry? registry = null)
|
|
where TDocument : class
|
|
{
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
RefuseWhenAttached();
|
|
var resolvedRegistry = registry ?? CultDocumentRegistry.Shared;
|
|
var catalogEntry = resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry();
|
|
ForTheCall(resolvedRegistry, () =>
|
|
{
|
|
var ids = CultElementIds.Plan(typeof(TDocument), document, key.Value, deterministic: false);
|
|
ids.Apply();
|
|
try
|
|
{
|
|
WriteOne(
|
|
new CultPersistedRecord
|
|
{
|
|
Key = key.Value,
|
|
SchemaId = catalogEntry.SchemaId,
|
|
StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!,
|
|
Payload = SerializePayload(document, typeof(TDocument))
|
|
},
|
|
catalogEntry,
|
|
CultElementIds.Holds(typeof(TDocument), document));
|
|
}
|
|
catch
|
|
{
|
|
ids.Undo();
|
|
throw;
|
|
}
|
|
});
|
|
}
|
|
|
|
// The registry is the call's argument, not store state: it is attached for the call under the store's gate and the store's
|
|
// previous registry (none, for a store no cache ever attached) comes back whatever the call does.
|
|
private void ForTheCall(CultDocumentRegistry? registry, Action body) =>
|
|
Held(() =>
|
|
{
|
|
var previous = SwapRegistry(registry);
|
|
try
|
|
{
|
|
body();
|
|
}
|
|
finally
|
|
{
|
|
SwapRegistry(previous);
|
|
}
|
|
});
|
|
|
|
private void WriteOne(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds)
|
|
{
|
|
using (AcquireLock(wait: true))
|
|
_ = ApplyWriteSet(ReadSnapshot(), new[] { new CultStagedRecord(record, catalogEntry, holdsIds) }, Array.Empty<string>(), Array.Empty<string>());
|
|
}
|
|
|
|
private void RefuseWhenAttached()
|
|
{
|
|
if (Cache != null)
|
|
throw new InvalidOperationException("The store is attached to a cache, write through the cache.");
|
|
}
|
|
|
|
// Flush, commit and Mesh's write all end here, with the durable store they read under the lock they hold. First, a key the write
|
|
// names or checks whose durable record is under an id the writer does not declare refuses the write (RefuseUndeclaredOnDisk); a
|
|
// write that then has nothing to write but keys it checked writes no file. Then a store that holds
|
|
// a variant, or would after this write, is written only while its record versions are what this store last read: a variant
|
|
// resolves against other records, and a write decodes none it did not stage, so it cannot judge another writer's. Versions
|
|
// only: a write hashes other records' bytes, never decodes them. That holds for the variant gate; the one decode of a copied
|
|
// record is the element-id check (CopiedHoldIds), which runs only on an unmarked store and can only mark it. Otherwise the whole write is refused and what it refused is
|
|
// reloaded (Refuse). Every record the writer staged is encoded from its object now, and its version is the hash of that
|
|
// encoding; every other record is copied as the slice the file holds. Returns the records written, each with the version of
|
|
// the bytes written.
|
|
private IReadOnlyCollection<(CultStagedRecord Staged, string Version)> ApplyWriteSet(
|
|
Durable? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals, IEnumerable<string> checks)
|
|
{
|
|
RefuseUndeclaredOnDisk(durable, upserts, removals, checks);
|
|
if (upserts.Count == 0 && removals.Count == 0 && (checks.Any() || StagedChecks.Count > 0))
|
|
return Array.Empty<(CultStagedRecord, string)>();
|
|
RefuseWriteIntoMovedVariantStore(durable, upserts, removals);
|
|
var durableRecords = durable?.Snapshot.Records ?? Array.Empty<CultPersistedRecord>();
|
|
// A record written over one the store holds is stored at a storedAt later than the one it replaces, whoever wrote that.
|
|
var replaced = durableRecords.ToDictionary(record => record.Key, record => record.StoredAt, StringComparer.Ordinal);
|
|
foreach (var staged in upserts)
|
|
{
|
|
if (replaced.TryGetValue(staged.Record.Key, out var durableAt))
|
|
staged.Record.StoredAt = StoredAtOver(staged.Record.StoredAt, durableAt);
|
|
}
|
|
|
|
var written = upserts.Select(staged => staged.Record.Key).ToHashSet(StringComparer.Ordinal);
|
|
var removed = removals.ToHashSet(StringComparer.Ordinal);
|
|
var copied = durableRecords
|
|
.Where(record => !written.Contains(record.Key) && !removed.Contains(record.Key))
|
|
.ToArray();
|
|
var encoded = upserts.Select(staged => (Staged: staged, Bytes: SerializeRecord(staged.Record))).ToArray();
|
|
var records = copied.Concat(upserts.Select(staged => staged.Record)).ToArray();
|
|
var fileRecords = copied.Select(record => (record.Key, Bytes: durable!.Slice(record.Key)))
|
|
.Concat(encoded.Select(pair => (pair.Staged.Record.Key, pair.Bytes)))
|
|
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
|
.Select(pair => pair.Bytes)
|
|
.ToArray();
|
|
var catalog = CultSchemaCatalogEntry.Derive(
|
|
records,
|
|
upserts.Select(staged => staged.Entry).Distinct().ToArray(),
|
|
durable?.Snapshot.SchemaCatalog ?? Array.Empty<CultSchemaCatalogEntry>())
|
|
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
|
|
.ThenBy(entry => entry.SchemaId, StringComparer.Ordinal)
|
|
.Select(entry => durable?.CatalogBytes(entry) ?? SerializeCatalogEntry(entry))
|
|
.ToArray();
|
|
var format = HeaderForWrite(
|
|
durable?.Snapshot.FormatVersion,
|
|
upserts.Select(staged => staged.HoldsIds).ToArray(),
|
|
copiesAny: copied.Length > 0,
|
|
copiedHoldIds: CopiedHoldIds(durable, copied, upserts),
|
|
directoryStore: false,
|
|
holdsVariants: records.Any(record => record.Variant != null));
|
|
|
|
var versions = encoded.Select(pair => (pair.Staged, VersionOfBytes(pair.Bytes))).ToArray();
|
|
Directory.CreateDirectory(FileInfo.DirectoryName!);
|
|
WriteSnapshotAtomically(FileInfo.FullName, SerializeStore(format, catalog, fileRecords));
|
|
foreach (var key in removed)
|
|
_lastRead.Remove(key);
|
|
foreach (var (staged, version) in versions)
|
|
_lastRead[staged.Record.Key] = version;
|
|
return versions;
|
|
}
|
|
|
|
// Whether a record this write copies forward holds an element id, decided from the bytes the file holds under the store's
|
|
// lock, so every C# writer with a type for the record decides the same from the same bytes. Positive-only: it runs only
|
|
// while the header on disk is unmarked and can only mark the store, so a narrower type that decodes no ids changes nothing.
|
|
// It never refuses a write: a record that does not resolve or decode is copied as its bytes and decides nothing. Gates, in
|
|
// order, so the decode runs only where it can change the answer.
|
|
private bool CopiedHoldIds(Durable? durable, CultPersistedRecord[] copied, IReadOnlyCollection<CultStagedRecord> upserts)
|
|
{
|
|
if (durable == null ||
|
|
string.Equals(durable.Snapshot.FormatVersion, CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal) ||
|
|
upserts.Any(staged => staged.HoldsIds == true) ||
|
|
AttachedRegistry is not { } registry)
|
|
return false;
|
|
var catalog = durable.Snapshot.SchemaCatalog;
|
|
foreach (var record in copied)
|
|
{
|
|
if (record.Variant != null)
|
|
continue;
|
|
try
|
|
{
|
|
if (registry.Claimant(record.SchemaId, catalog, out _) == null)
|
|
continue;
|
|
var type = registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog).Descriptor.DocumentType;
|
|
if (!CultElementIds.CanHoldIds(type))
|
|
continue;
|
|
CopiedRecordsDecoded++;
|
|
if (CultElementIds.Holds(type, DeserializePayload(type, record.Payload)))
|
|
return true;
|
|
}
|
|
catch (Exception e) when (e is not OperationCanceledException)
|
|
{
|
|
// Copied as its bytes: no type, or no decode, is no fact.
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private void RefuseWriteIntoMovedVariantStore(Durable? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals)
|
|
{
|
|
var records = durable?.Snapshot.Records ?? Array.Empty<CultPersistedRecord>();
|
|
if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null))
|
|
return;
|
|
var changed = new HashSet<string>(StringComparer.Ordinal);
|
|
var versions = records.ToDictionary(record => record.Key, record => durable!.Version(record.Key), StringComparer.Ordinal);
|
|
foreach (var (key, version) in versions)
|
|
{
|
|
if (!_lastRead.TryGetValue(key, out var read) || !SameStoredIdentity(read, version))
|
|
changed.Add(key);
|
|
}
|
|
|
|
var durableKeys = records.Select(record => record.Key).ToHashSet(StringComparer.Ordinal);
|
|
foreach (var key in _lastRead.Keys)
|
|
{
|
|
if (!durableKeys.Contains(key))
|
|
changed.Add(key);
|
|
}
|
|
|
|
if (changed.Count == 0)
|
|
return;
|
|
var recordKeys = _staged.Concat(upserts.Select(staged => staged.Record.Key)).Concat(removals)
|
|
.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).ToArray();
|
|
var changedKeys = changed.OrderBy(key => key, StringComparer.Ordinal).ToArray();
|
|
Refuse(
|
|
recordKeys.Concat(changedKeys),
|
|
key => Cache == null ? null : DurableAt(durable, key),
|
|
key => ForeignAt(durable, key),
|
|
key =>
|
|
{
|
|
_staged.Remove(key);
|
|
IsDirty = _staged.Count > 0;
|
|
if (versions.TryGetValue(key, out var version))
|
|
_lastRead[key] = version;
|
|
else
|
|
_lastRead.Remove(key);
|
|
},
|
|
inner => new CultWriteConflictException(
|
|
Cache == null
|
|
? $"{this} holds a variant and was written by a writer with no CultCache, which cannot resolve variants; nothing was written. " +
|
|
$"Records it would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that differ from what the writer has read: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}."
|
|
: $"{this} changed since this cache last read it, and it holds a variant, which resolves against other records; nothing was written. " +
|
|
$"Records this write would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that changed: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}. " +
|
|
(inner == null
|
|
? "The cache reloaded them; retry the write against what the store holds now."
|
|
: "Reloading them failed, so the cache is unchanged."),
|
|
recordKeys,
|
|
changedKeys,
|
|
inner));
|
|
}
|
|
|
|
// A writable store judges a removal of a key its cache does not hold at its next flush or commit, under the lock (StageChecks);
|
|
// a read-only store has neither, so it answers from its view and never creates the lock file.
|
|
protected internal override void RefuseRemovalOfUndeclared(IReadOnlyCollection<string> keys)
|
|
{
|
|
if (IsReadOnly)
|
|
base.RefuseRemovalOfUndeclared(keys);
|
|
else
|
|
StageChecks(keys);
|
|
}
|
|
|
|
// A read-only store answers a batch's checks from its view and never creates the lock file; a writable one reads the file
|
|
// under its lock.
|
|
protected internal override CultCommitOutcome JudgeChecks(IReadOnlyCollection<string> keys, bool wait)
|
|
{
|
|
if (IsReadOnly)
|
|
return base.JudgeChecks(keys, wait);
|
|
return Held(() =>
|
|
{
|
|
using var fileLock = AcquireLock(wait);
|
|
if (fileLock == null)
|
|
return CultCommitOutcome.Contended;
|
|
RefuseUndeclaredOnDisk(ReadSnapshot(), keys, Registry.Declares);
|
|
return CultCommitOutcome.Committed;
|
|
});
|
|
}
|
|
|
|
// A cache declares through its registry. A cacheless CultMesh write declares the ids of the catalog entry it writes: its own id
|
|
// and the ids it lists compatible. The keys judged are every key staged, written, removed or checked.
|
|
private void RefuseUndeclaredOnDisk(Durable? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals, IEnumerable<string> checks) =>
|
|
RefuseUndeclaredOnDisk(
|
|
durable,
|
|
_staged.Concat(upserts.Select(staged => staged.Record.Key)).Concat(removals).Concat(checks).Concat(StagedChecks),
|
|
Cache != null
|
|
? Registry.Declares
|
|
: id => upserts.Any(staged =>
|
|
string.Equals(staged.Entry.SchemaId, id, StringComparison.Ordinal) ||
|
|
staged.Entry.CompatibleSchemaIds.Contains(id, StringComparer.Ordinal)));
|
|
|
|
// A refused key's durable version is what this store has now read of it.
|
|
private void RefuseUndeclaredOnDisk(Durable? durable, IEnumerable<string> written, Func<string, bool> declares)
|
|
{
|
|
var undeclared = UndeclaredOnDisk(durable, declares);
|
|
if (undeclared.Count == 0)
|
|
return;
|
|
RefuseUndeclaredOnDisk(
|
|
written,
|
|
key => undeclared.TryGetValue(key, out var carried) ? carried : null,
|
|
key => Cache == null ? null : DurableAt(durable, key),
|
|
key => ForeignAt(durable, key),
|
|
key =>
|
|
{
|
|
_staged.Remove(key);
|
|
IsDirty = _staged.Count > 0;
|
|
_lastRead[key] = durable!.Version(key);
|
|
},
|
|
cacheless: Cache == null);
|
|
}
|
|
|
|
// Every durable record under an id the writer does not declare, by key, named by the catalog entry that publishes its id.
|
|
private static Dictionary<string, (string Key, string SchemaId, string SchemaName)> UndeclaredOnDisk(Durable? durable, Func<string, bool> declares)
|
|
{
|
|
var undeclared = new Dictionary<string, (string Key, string SchemaId, string SchemaName)>(StringComparer.Ordinal);
|
|
foreach (var record in durable?.Snapshot.Records ?? Array.Empty<CultPersistedRecord>())
|
|
{
|
|
if (Undeclared(record, durable!.Snapshot.SchemaCatalog, declares) is { } carried)
|
|
undeclared[record.Key] = carried;
|
|
}
|
|
|
|
return undeclared;
|
|
}
|
|
|
|
// The durable record at a key as a load lists it when no registered type claims its id; null otherwise.
|
|
private CultForeignRecord? ForeignAt(Durable? durable, string key)
|
|
{
|
|
if (durable?.Snapshot.Records.FirstOrDefault(candidate => string.Equals(candidate.Key, key, StringComparison.Ordinal)) is not { } record ||
|
|
Foreign(record, durable.Snapshot.SchemaCatalog, durable.Version(key)) is not { } carried)
|
|
return null;
|
|
return carried;
|
|
}
|
|
|
|
// The durable record at a key decoded as a load decodes it, at its durable version: null when the store holds none there or
|
|
// holds it foreign. The one decode both refusals reload through.
|
|
private CultStoredDocument? DurableAt(Durable? durable, string key)
|
|
{
|
|
var record = durable?.Snapshot.Records.FirstOrDefault(candidate => string.Equals(candidate.Key, key, StringComparison.Ordinal));
|
|
if (record == null || Foreign(record, durable!.Snapshot.SchemaCatalog, durable.Version(key)) != null)
|
|
return null;
|
|
return ToStoredDocument(record, durable.Version(key), durable.Snapshot.SchemaCatalog, DeserializePayload, out _);
|
|
}
|
|
|
|
// The file now holds every record written, under the id and at the storedAt the write gave it, and the version of those bytes:
|
|
// the store's entries and the cache's say so. A copied record is on disk as the file had it, so nothing learns anything new about it: in particular the
|
|
// ids it minted at load are still in memory only.
|
|
private void Wrote(IEnumerable<(CultStagedRecord Staged, string Version)> written)
|
|
{
|
|
var staged = written.ToArray();
|
|
foreach (var (write, version) in staged)
|
|
{
|
|
if (!Entries.TryGetValue(write.Record.Key, out var entry))
|
|
continue;
|
|
entry.StoredSchemaId = write.Record.SchemaId;
|
|
entry.StoredAt = write.Record.StoredAt;
|
|
entry.StoredVersion = version;
|
|
}
|
|
|
|
Cache?.Wrote(staged.Select(write => (write.Staged.Record, write.Version)));
|
|
}
|
|
|
|
// Open, flush and commit read the file through this one call, so they agree on which files are stores. A file that is
|
|
// gone is an empty store: nothing to read, nothing to lose. A zero-byte file is not a store; it is refused.
|
|
private Durable? ReadSnapshot()
|
|
{
|
|
var bytes = ReadDisk();
|
|
if (bytes == null)
|
|
return null;
|
|
try
|
|
{
|
|
return new Durable(this, ReadStore(bytes));
|
|
}
|
|
catch (CultStoreUnreadableException ex) when (ex.Path == null)
|
|
{
|
|
throw new CultStoreUnreadableException(ex.Message, FileInfo.FullName, ex.InnerException);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the store file once, under the store's gate, with the reader a cache's store opens with, and returns what is
|
|
/// durable at the path: the records, the schema catalog and the format stamp. Returns null when nothing is at the path.
|
|
/// CultMesh's single-file document reads are built on this, so they share the store's one reader and its stamp rule.
|
|
/// </summary>
|
|
/// <exception cref="CultStoreUnreadableException">The file is empty, truncated, or stamped with a format this runtime does not read.</exception>
|
|
public CultPersistedStoreSnapshot? ReadStoreSnapshot() => Held(() => ReadSnapshot()?.Snapshot);
|
|
|
|
private byte[]? ReadDisk()
|
|
{
|
|
FileInfo.Refresh();
|
|
// Only nothing at the path is an empty store: a path with nothing at it reports no attributes at all (-1). A link whose
|
|
// target is gone is a store that cannot be reached, and a directory is not a store file.
|
|
var attributes = FileInfo.Attributes;
|
|
if ((int)attributes == -1)
|
|
return null;
|
|
if ((attributes & FileAttributes.Directory) != 0 && (attributes & FileAttributes.ReparsePoint) == 0)
|
|
throw new IOException("The store path is a directory, not a store file.");
|
|
try
|
|
{
|
|
return ReadAllBytesShared(FileInfo.FullName);
|
|
}
|
|
catch (FileNotFoundException ex) when (PathIsALink())
|
|
{
|
|
throw new IOException("The store path is a link whose target cannot be reached.", ex);
|
|
}
|
|
// A Windows junction is a directory, not a file, so opening one that dangles fails with an access error, not a missing file.
|
|
catch (UnauthorizedAccessException ex) when (PathIsALink() && (FileInfo.Attributes & FileAttributes.Directory) != 0)
|
|
{
|
|
throw new IOException("The store path is a link whose target cannot be reached.", ex);
|
|
}
|
|
catch (FileNotFoundException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Something is at the path even when the path cannot be opened: a link keeps the reparse-point attribute whether or not its
|
|
// target is there. A path with nothing at it reports no attributes at all (-1).
|
|
private bool PathIsALink()
|
|
{
|
|
FileInfo.Refresh();
|
|
var attributes = FileInfo.Attributes;
|
|
return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0;
|
|
}
|
|
|
|
// The lock is a sidecar opened exclusively, which excludes other handles in this process and in others alike.
|
|
private FileStream? AcquireLock(bool wait)
|
|
{
|
|
Directory.CreateDirectory(FileInfo.DirectoryName!);
|
|
var lockPath = FileInfo.FullName + ".lock";
|
|
var started = Stopwatch.StartNew();
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1);
|
|
}
|
|
catch (IOException) when (!wait)
|
|
{
|
|
return null;
|
|
}
|
|
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
|
|
{
|
|
Thread.Sleep(10);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void WriteSnapshotAtomically(string path, byte[] payload)
|
|
{
|
|
var directory = Path.GetDirectoryName(path)!;
|
|
var tempPath = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
|
|
|
try
|
|
{
|
|
using (var stream = new FileStream(
|
|
tempPath,
|
|
FileMode.CreateNew,
|
|
FileAccess.Write,
|
|
FileShare.None,
|
|
bufferSize: 4096,
|
|
FileOptions.WriteThrough))
|
|
{
|
|
stream.Write(payload, 0, payload.Length);
|
|
stream.Flush(true);
|
|
}
|
|
|
|
if (File.Exists(path))
|
|
{
|
|
File.Replace(tempPath, path, null);
|
|
}
|
|
else
|
|
{
|
|
File.Move(tempPath, path);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(tempPath))
|
|
{
|
|
File.Delete(tempPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static byte[] ReadAllBytesShared(string path)
|
|
{
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
using var buffer = new MemoryStream();
|
|
stream.CopyTo(buffer);
|
|
return buffer.ToArray();
|
|
}
|
|
}
|
|
}
|