CultLib/src/GameCult.Caching/CultCache.cs
2026-05-09 01:32:43 +01:00

1327 lines
48 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using GameCult.Logging;
using R3;
namespace GameCult.Caching
{
/// <summary>
/// Persisted schema metadata embedded in a CultCache backing store.
/// </summary>
public sealed class CultSchemaCatalogEntry
{
/// <summary>
/// Gets or sets the content-derived schema identifier.
/// </summary>
public string SchemaId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the stable schema name.
/// </summary>
public string SchemaName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the schema version string.
/// </summary>
public string SchemaVersion { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the canonical schema content hash.
/// </summary>
public string ContentHash { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the canonical schema description.
/// </summary>
public string CanonicalSchemaJson { get; set; } = string.Empty;
/// <summary>
/// Gets or sets schema identifiers compatible with this entry.
/// </summary>
public string[] CompatibleSchemaIds { get; set; } = Array.Empty<string>();
}
/// <summary>
/// Persisted serialized document record.
/// </summary>
public sealed class CultPersistedRecord
{
/// <summary>
/// Gets or sets the persisted record key.
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the schema identifier used to deserialize the payload.
/// </summary>
public string SchemaId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the storage timestamp.
/// </summary>
public string StoredAt { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the serialized document payload.
/// </summary>
public byte[] Payload { get; set; } = Array.Empty<byte>();
}
/// <summary>
/// Complete snapshot persisted by a single-file CultCache backing store.
/// </summary>
public sealed class CultPersistedStoreSnapshot
{
/// <summary>
/// Gets or sets the backing store format version.
/// </summary>
public string FormatVersion { get; set; } = "cultcache.store.v1";
/// <summary>
/// Gets or sets the embedded schema catalog.
/// </summary>
public CultSchemaCatalogEntry[] SchemaCatalog { get; set; } = Array.Empty<CultSchemaCatalogEntry>();
/// <summary>
/// Gets or sets the persisted records.
/// </summary>
public CultPersistedRecord[] Records { get; set; } = Array.Empty<CultPersistedRecord>();
}
/// <summary>
/// Runtime descriptor for a CultCache document type.
/// </summary>
public sealed class CultDocumentDescriptor
{
internal CultDocumentDescriptor(
Type documentType,
string schemaName,
string schemaVersion,
string schemaId,
string contentHash,
string canonicalSchemaJson,
bool isGlobal,
string? nameMember,
Func<object, string?>? nameAccessor,
Func<object, byte[]>? generatedPayloadSerializer,
Func<byte[], object>? generatedPayloadDeserializer,
IReadOnlyDictionary<string, Func<object, string>> indexAccessors,
IReadOnlyList<CultDocumentMemberDescriptor> members)
{
DocumentType = documentType;
SchemaName = schemaName;
SchemaVersion = schemaVersion;
SchemaId = schemaId;
ContentHash = contentHash;
CanonicalSchemaJson = canonicalSchemaJson;
IsGlobal = isGlobal;
NameMember = nameMember;
NameAccessor = nameAccessor;
GeneratedPayloadSerializer = generatedPayloadSerializer;
GeneratedPayloadDeserializer = generatedPayloadDeserializer;
IndexAccessors = indexAccessors;
Members = members;
}
/// <summary>
/// Gets the CLR document type.
/// </summary>
public Type DocumentType { get; }
/// <summary>
/// Gets the stable schema name.
/// </summary>
public string SchemaName { get; }
/// <summary>
/// Gets the schema version string.
/// </summary>
public string SchemaVersion { get; }
/// <summary>
/// Gets the content-derived schema identifier.
/// </summary>
public string SchemaId { get; }
/// <summary>
/// Gets the canonical schema content hash.
/// </summary>
public string ContentHash { get; }
/// <summary>
/// Gets the canonical schema description.
/// </summary>
public string CanonicalSchemaJson { get; }
/// <summary>
/// Gets whether this document type stores one global record.
/// </summary>
public bool IsGlobal { get; }
/// <summary>
/// Gets the document name member, if any.
/// </summary>
public string? NameMember { get; }
internal Func<object, string?>? NameAccessor { get; }
/// <summary>
/// Gets the generated payload serializer, if one is available.
/// </summary>
public Func<object, byte[]>? GeneratedPayloadSerializer { get; }
/// <summary>
/// Gets the generated payload deserializer, if one is available.
/// </summary>
public Func<byte[], object>? GeneratedPayloadDeserializer { get; }
internal IReadOnlyDictionary<string, Func<object, string>> IndexAccessors { get; }
internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; }
/// <summary>
/// Converts this descriptor into a persisted schema catalog entry.
/// </summary>
public CultSchemaCatalogEntry ToCatalogEntry()
{
return new CultSchemaCatalogEntry
{
SchemaId = SchemaId,
SchemaName = SchemaName,
SchemaVersion = SchemaVersion,
ContentHash = ContentHash,
CanonicalSchemaJson = CanonicalSchemaJson,
CompatibleSchemaIds = [SchemaId]
};
}
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; }
}
/// <summary>
/// Stores a resolved document together with its descriptor and key.
/// </summary>
public sealed class CultStoredDocument
{
/// <summary>
/// Creates a stored document wrapper.
/// </summary>
public CultStoredDocument(
CultRecordKey key,
string storedAt,
CultDocumentDescriptor descriptor,
object document)
{
Key = key;
StoredAt = storedAt;
Descriptor = descriptor;
Document = document;
}
/// <summary>
/// Gets the record key.
/// </summary>
public CultRecordKey Key { get; }
/// <summary>
/// Gets the storage timestamp.
/// </summary>
public string StoredAt { get; }
/// <summary>
/// Gets the document descriptor.
/// </summary>
public CultDocumentDescriptor Descriptor { get; }
/// <summary>
/// Gets the document instance.
/// </summary>
public object Document { get; }
}
/// <summary>
/// Discovers, indexes, and resolves CultCache document descriptors.
/// </summary>
public sealed class CultDocumentRegistry
{
private static readonly Lazy<CultDocumentRegistry> SharedRegistry =
new(() => new CultDocumentRegistry());
private readonly ConcurrentDictionary<Type, CultDocumentDescriptor> _byType = new();
private readonly ConcurrentDictionary<string, CultDocumentDescriptor> _bySchemaId =
new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CultDocumentDescriptor> _bySchemaName =
new(StringComparer.Ordinal);
/// <summary>
/// Gets the shared process-wide document registry.
/// </summary>
public static CultDocumentRegistry Shared => SharedRegistry.Value;
/// <summary>
/// Creates a registry and discovers currently loaded document metadata.
/// </summary>
public CultDocumentRegistry()
{
Refresh();
}
/// <summary>
/// Gets all known document descriptors.
/// </summary>
public IEnumerable<CultDocumentDescriptor> AllDescriptors => _byType.Values.OrderBy(d => d.SchemaName, StringComparer.Ordinal);
/// <summary>
/// Rebuilds the registry from generated metadata and reflected document attributes.
/// </summary>
public void Refresh()
{
_byType.Clear();
_bySchemaId.Clear();
_bySchemaName.Clear();
var generatedTypes = new HashSet<Type>();
foreach (var definition in CultGeneratedDocumentMetadataLoader.LoadDefinitions())
{
var descriptor = BuildDescriptor(definition);
RegisterDescriptor(descriptor);
generatedTypes.Add(descriptor.DocumentType);
}
foreach (var type in ReflectionExtensions.GetAttributedDocumentTypes().Where(type => !generatedTypes.Contains(type)))
{
RegisterDescriptor(BuildDescriptor(type));
}
}
/// <summary>
/// Gets the descriptor for a document type, building it when needed.
/// </summary>
public CultDocumentDescriptor GetRequired(Type type)
{
if (_byType.TryGetValue(type, out var descriptor))
{
return descriptor;
}
descriptor = TryBuildGeneratedDescriptor(type);
if (descriptor != null)
{
RegisterDescriptor(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);
RegisterDescriptor(descriptor);
return descriptor;
}
/// <summary>
/// Gets the descriptor for a document type.
/// </summary>
public CultDocumentDescriptor GetRequired<T>() where T : class
{
return GetRequired(typeof(T));
}
/// <summary>
/// Gets a descriptor by its schema identifier.
/// </summary>
public CultDocumentDescriptor GetRequiredBySchemaId(string schemaId)
{
if (_bySchemaId.TryGetValue(schemaId, out var descriptor))
{
return descriptor;
}
throw new InvalidOperationException($"Unknown CultCache schema id '{schemaId}'.");
}
/// <summary>
/// Resolves a persisted schema identifier against the local registry and embedded catalog.
/// </summary>
public CultDocumentDescriptor ResolvePersistedSchema(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog)
{
if (_bySchemaId.TryGetValue(schemaId, out var exact))
{
return exact;
}
var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal));
if (persisted == null)
{
throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog.");
}
if (_bySchemaName.TryGetValue(persisted.SchemaName, out var local))
{
return local;
}
throw new InvalidOperationException(
$"No local CultCache schema matches persisted schema '{persisted.SchemaName}' ({schemaId}).");
}
private void RegisterDescriptor(CultDocumentDescriptor descriptor)
{
_byType[descriptor.DocumentType] = descriptor;
_bySchemaId[descriptor.SchemaId] = descriptor;
_bySchemaName[descriptor.SchemaName] = descriptor;
}
private static CultDocumentDescriptor? TryBuildGeneratedDescriptor(Type type)
{
var definition = CultGeneratedDocumentMetadataLoader.LoadDefinitions(type.Assembly)
.FirstOrDefault(candidate => candidate.DocumentType == type);
return definition == null
? null
: BuildDescriptor(definition);
}
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 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);
return new CultDocumentDescriptor(
type,
attribute.SchemaName,
attribute.SchemaVersion,
schemaId,
contentHash,
schemaJson,
type.GetCustomAttribute<CultGlobalAttribute>() != null,
nameMember?.Member.Name,
nameMember?.GetterNullable,
null,
null,
indexAccessors,
descriptorMembers);
}
private static CultDocumentDescriptor BuildDescriptor(CultGeneratedDocumentDefinition definition)
{
var descriptorMembers = definition.Members
.OrderBy(member => member.Slot)
.Select(member => new CultDocumentMemberDescriptor
{
MemberName = member.MemberName,
Slot = member.Slot,
TypeName = member.TypeName,
IsReference = member.IsReference,
IsMany = member.IsMany,
TargetSchemaName = member.TargetSchemaName,
IsName = member.IsName,
IndexAlias = member.IndexAlias
})
.ToArray();
var schemaJson = BuildCanonicalSchemaJson(definition.SchemaName, definition.SchemaVersion, descriptorMembers);
var contentHash = Sha256(schemaJson);
var semanticFingerprint = BuildSemanticFingerprint(definition.SchemaName, definition.SchemaVersion, descriptorMembers);
var schemaId = Sha256(semanticFingerprint);
return new CultDocumentDescriptor(
definition.DocumentType,
definition.SchemaName,
definition.SchemaVersion,
schemaId,
contentHash,
schemaJson,
definition.IsGlobal,
definition.NameMember,
definition.NameAccessor,
definition.SerializePayload,
definition.DeserializePayload,
definition.IndexAccessors.ToDictionary(accessor => accessor.Alias, accessor => accessor.Accessor, StringComparer.Ordinal),
descriptorMembers);
}
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 IReadOnlyList<PersistedMember> DiscoverMembers(Type type)
{
var members = new List<PersistedMember>();
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
{
if (IsIgnored(field))
{
continue;
}
members.Add(PersistedMember.FromMember(field, field.FieldType, value => field.GetValue(value), GetKeyValue(field)));
}
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (property.GetMethod == null || property.SetMethod == null || IsIgnored(property))
{
continue;
}
members.Add(PersistedMember.FromMember(property, property.PropertyType, value => property.GetValue(value), GetKeyValue(property)));
}
var explicitMembers = members.Where(member => member.ExplicitSlot.HasValue).OrderBy(member => member.ExplicitSlot.Value).ToArray();
var implicitMembers = members.Where(member => !member.ExplicitSlot.HasValue).OrderBy(member => member.MetadataToken).ToArray();
var assigned = new List<PersistedMember>(members.Count);
var nextSlot = explicitMembers.Length == 0 ? 0 : explicitMembers.Max(member => member.ExplicitSlot.Value) + 1;
foreach (var member in explicitMembers)
{
member.Slot = member.ExplicitSlot.Value;
assigned.Add(member);
}
foreach (var member in implicitMembers)
{
member.Slot = nextSlot++;
assigned.Add(member);
}
return assigned.OrderBy(member => member.Slot).ToArray();
}
private static bool IsIgnored(MemberInfo member)
{
return member.GetCustomAttributes().Any(attribute =>
{
var name = attribute.GetType().FullName;
return name == "MessagePack.IgnoreMemberAttribute";
});
}
private static int? GetKeyValue(MemberInfo member)
{
foreach (var attribute in member.GetCustomAttributes())
{
var name = attribute.GetType().FullName;
if (name == "MessagePack.KeyAttribute")
{
var property = attribute.GetType().GetProperty("IntKey");
if (property?.GetValue(attribute) is int intKey)
{
return intKey;
}
var ctorArg = attribute.GetType().GetProperty("StringKey");
if (ctorArg?.GetValue(attribute) is string)
{
return null;
}
}
}
return null;
}
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();
}
private sealed class PersistedMember
{
public MemberInfo Member { get; set; } = default!;
public Type MemberType { get; set; } = default!;
public int MetadataToken { get; set; }
public int? ExplicitSlot { get; set; }
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; }
public Func<object, string> Getter { get; set; } = default!;
public Func<object, string?> GetterNullable { get; set; } = default!;
public static PersistedMember FromMember(
MemberInfo member,
Type memberType,
Func<object, object?> getValue,
int? explicitSlot)
{
var referenceAttribute = member.GetCustomAttribute<CultReferenceAttribute>();
var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType);
var targetSchemaName = targetType?.GetCustomAttribute<CultDocumentAttribute>()?.SchemaName;
return new PersistedMember
{
Member = member,
MemberType = memberType,
MetadataToken = member.MetadataToken,
ExplicitSlot = explicitSlot,
Slot = explicitSlot ?? -1,
IsName = member.GetCustomAttribute<CultNameAttribute>() != null,
IndexAlias = ResolveIndexAlias(member),
IsReference = targetType != null || referenceAttribute != null,
IsMany = referenceAttribute?.Many ?? false,
TargetSchemaName = targetSchemaName,
Getter = document => getValue(document)?.ToString() ?? string.Empty,
GetterNullable = document => getValue(document)?.ToString()
};
}
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 Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget)
{
if (explicitTarget != null)
{
return explicitTarget;
}
if (memberType.IsGenericType &&
memberType.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
{
return memberType.GetGenericArguments()[0];
}
return null;
}
}
}
/// <summary>
/// In-memory document cache with pluggable persisted backing stores.
/// </summary>
public sealed class CultCache : IDisposable
{
private readonly CultDocumentRegistry _registry;
private readonly List<CacheBackingStore> _backingStores = new();
private readonly ConcurrentDictionary<string, CultStoredDocument> _entries = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<Type, ConcurrentDictionary<string, string>> _nameMaps = new();
private readonly ConcurrentDictionary<(Type Type, string Alias), ConcurrentDictionary<string, string>> _indexMaps = new();
private readonly ConcurrentDictionary<Type, string> _globalKeys = new();
private readonly ConditionalWeakTable<object, DocumentHandleBox> _documentHandles = new();
private ILogger _logger = new NullLogger();
/// <summary>
/// Creates a cache using the supplied document registry or the shared registry.
/// </summary>
public CultCache(CultDocumentRegistry? registry = null)
{
_registry = registry ?? CultDocumentRegistry.Shared;
InitializeGlobals();
}
/// <summary>
/// Gets or sets the cache logger.
/// </summary>
public ILogger Logger
{
get => _logger;
set => _logger = value ?? new NullLogger();
}
/// <summary>
/// Raised when a backing store adds, updates, or removes a document.
/// </summary>
public event Action<object?, object?>? OnUpdate;
/// <summary>
/// Gets all document instances currently held by the cache.
/// </summary>
public IEnumerable<object> AllEntries => _entries.Values.Select(entry => entry.Document);
/// <summary>
/// Gets the document registry used by this cache.
/// </summary>
public CultDocumentRegistry Registry => _registry;
/// <summary>
/// Attaches a backing store to this cache.
/// </summary>
public void AddBackingStore(CacheBackingStore store)
{
if (store == null) throw new ArgumentNullException(nameof(store));
store.AttachRegistry(_registry);
store.Logger = Logger;
store.EntryAdded.Subscribe(entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true).GetAwaiter().GetResult());
store.EntryUpdated.Subscribe(entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true).GetAwaiter().GetResult());
store.EntryDeleted.Subscribe(entry => RemoveStoredDocumentInternal(entry, store, raiseUpdate: true));
_backingStores.Add(store);
}
/// <summary>
/// Pulls all documents from every attached backing store.
/// </summary>
public async Task PullAllBackingStoresAsync()
{
foreach (var store in _backingStores)
{
store.PullAll();
}
await Task.CompletedTask;
}
/// <summary>
/// Adds or replaces a typed document and returns its record handle.
/// </summary>
public async Task<CultRecordHandle<T>> AddAsync<T>(T document, CultRecordHandle<T>? handle = null)
{
if (document == null) throw new ArgumentNullException(nameof(document));
var stored = await AddStoredDocumentInternal(
CreateStoredDocument(typeof(T), document, handle?.Key),
source: null,
raiseUpdate: false);
return new CultRecordHandle<T>(stored.Key);
}
/// <summary>
/// Gets the record handle for a document instance, if it is tracked.
/// </summary>
public CultRecordHandle<T>? TryGetHandle<T>(T document)
{
if (document == null) throw new ArgumentNullException(nameof(document));
return _documentHandles.TryGetValue(document, out var box)
? new CultRecordHandle<T>(box.Key)
: null;
}
/// <summary>
/// Gets a document by record key.
/// </summary>
public object? Get(CultRecordKey key)
{
return _entries.TryGetValue(key.Value, out var stored)
? stored.Document
: null;
}
/// <summary>
/// Gets a typed document by record key.
/// </summary>
public T? Get<T>(CultRecordKey key) where T : class
{
return Get(key) as T;
}
/// <summary>
/// Gets all cached documents assignable to the requested type.
/// </summary>
public IEnumerable<T> GetAll<T>() where T : class
{
var type = typeof(T);
return _entries.Values
.Where(entry => type.IsAssignableFrom(entry.Descriptor.DocumentType))
.Select(entry => (T)entry.Document);
}
/// <summary>
/// Gets the global document for the requested type, if one exists.
/// </summary>
public T? GetGlobal<T>() where T : class
{
return _globalKeys.TryGetValue(typeof(T), out var key)
? Get<T>(new CultRecordKey(key))
: null;
}
/// <summary>
/// Gets a typed document by its CultName value.
/// </summary>
public T? GetByName<T>(string name) where T : class
{
var type = typeof(T);
if (_nameMaps.TryGetValue(type, out var map) &&
map.TryGetValue(name, out var key))
{
return Get<T>(new CultRecordKey(key));
}
return null;
}
/// <summary>
/// Gets a typed document by an indexed value.
/// </summary>
public T? GetByIndex<T>(string alias, string value) where T : class
{
if (_indexMaps.TryGetValue((typeof(T), alias), out var map) &&
map.TryGetValue(value, out var key))
{
return Get<T>(new CultRecordKey(key));
}
return null;
}
/// <summary>
/// Resolves a typed document reference against this cache.
/// </summary>
public T? Resolve<T>(CultRecordRef<T> reference) where T : class
{
return Get<T>(reference.Key);
}
/// <summary>
/// Removes a document by typed handle.
/// </summary>
public void Remove<T>(CultRecordHandle<T> handle)
{
if (_entries.TryGetValue(handle.Key.Value, out var stored))
{
RemoveStoredDocumentInternal(stored, source: null, raiseUpdate: false);
}
}
/// <summary>
/// Disposes attached disposable backing stores.
/// </summary>
public void Dispose()
{
foreach (var store in _backingStores.OfType<IDisposable>())
{
store.Dispose();
}
}
internal CultStoredDocument CreateStoredDocument(Type documentType, object document, CultRecordKey? key = null, string? storedAt = null)
{
var descriptor = _registry.GetRequired(documentType);
var resolvedKey = key ?? ResolveKey(document, descriptor);
return new CultStoredDocument(
resolvedKey,
storedAt ?? DateTimeOffset.UtcNow.ToString("O"),
descriptor,
document);
}
private async Task<CultStoredDocument> AddStoredDocumentInternal(
CultStoredDocument stored,
CacheBackingStore? source,
bool raiseUpdate)
{
CultStoredDocument? existing = null;
_entries.TryGetValue(stored.Key.Value, out existing);
if (existing != null)
{
RemoveIndexes(existing);
}
_entries[stored.Key.Value] = stored;
_documentHandles.Remove(stored.Document);
_documentHandles.Add(stored.Document, new DocumentHandleBox(stored.Key));
AddIndexes(stored);
foreach (var store in _backingStores)
{
if (store != source)
{
store.Push(stored);
}
}
if (raiseUpdate)
{
OnUpdate?.Invoke(existing?.Document, stored.Document);
}
await Task.CompletedTask;
return stored;
}
private void RemoveStoredDocumentInternal(
CultStoredDocument stored,
CacheBackingStore? source,
bool raiseUpdate)
{
if (!_entries.TryRemove(stored.Key.Value, out var existing))
{
return;
}
RemoveIndexes(existing);
_documentHandles.Remove(existing.Document);
foreach (var store in _backingStores)
{
if (store != source)
{
store.Delete(existing);
}
}
if (raiseUpdate)
{
OnUpdate?.Invoke(existing.Document, null);
}
}
private void InitializeGlobals()
{
foreach (var descriptor in _registry.AllDescriptors.Where(candidate => candidate.IsGlobal))
{
if (descriptor.DocumentType.GetConstructor(Type.EmptyTypes) == null)
{
continue;
}
var instance = Activator.CreateInstance(descriptor.DocumentType);
if (instance == null)
{
continue;
}
AddStoredDocumentInternal(
new CultStoredDocument(
new CultRecordKey($"global:{descriptor.SchemaId}"),
DateTimeOffset.UtcNow.ToString("O"),
descriptor,
instance),
source: null,
raiseUpdate: false).GetAwaiter().GetResult();
}
}
private CultRecordKey ResolveKey(object document, CultDocumentDescriptor descriptor)
{
if (_documentHandles.TryGetValue(document, out var existing))
{
return existing.Key;
}
if (descriptor.IsGlobal)
{
return new CultRecordKey($"global:{descriptor.SchemaId}");
}
return new CultRecordKey(Guid.NewGuid().ToString("N"));
}
private void AddIndexes(CultStoredDocument stored)
{
if (stored.Descriptor.IsGlobal)
{
_globalKeys[stored.Descriptor.DocumentType] = stored.Key.Value;
}
if (stored.Descriptor.NameAccessor?.Invoke(stored.Document) is { Length: > 0 } name)
{
var map = _nameMaps.GetOrAdd(
stored.Descriptor.DocumentType,
_ => new ConcurrentDictionary<string, string>(StringComparer.Ordinal));
map[name] = stored.Key.Value;
}
foreach (var pair in stored.Descriptor.IndexAccessors)
{
var value = pair.Value(stored.Document);
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var map = _indexMaps.GetOrAdd(
(stored.Descriptor.DocumentType, pair.Key),
_ => new ConcurrentDictionary<string, string>(StringComparer.Ordinal));
map[value] = stored.Key.Value;
}
}
private void RemoveIndexes(CultStoredDocument stored)
{
if (stored.Descriptor.IsGlobal)
{
_globalKeys.TryRemove(stored.Descriptor.DocumentType, out _);
}
if (stored.Descriptor.NameAccessor?.Invoke(stored.Document) is { Length: > 0 } name &&
_nameMaps.TryGetValue(stored.Descriptor.DocumentType, out var nameMap))
{
nameMap.TryRemove(name, out _);
}
foreach (var pair in stored.Descriptor.IndexAccessors)
{
var value = pair.Value(stored.Document);
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
if (_indexMaps.TryGetValue((stored.Descriptor.DocumentType, pair.Key), out var map))
{
map.TryRemove(value, out _);
}
}
}
private sealed class DocumentHandleBox
{
public DocumentHandleBox(CultRecordKey key)
{
Key = key;
}
public CultRecordKey Key { get; }
}
}
/// <summary>
/// Base class for CultCache persistence adapters.
/// </summary>
public abstract class CacheBackingStore : IDisposable
{
private CultDocumentRegistry? _registry;
private ILogger _logger = new NullLogger();
/// <summary>
/// Gets the attached document registry.
/// </summary>
protected CultDocumentRegistry Registry =>
_registry ?? throw new InvalidOperationException("Backing store is not attached to a CultDocumentRegistry.");
/// <summary>
/// Gets the backing store entries by record key.
/// </summary>
protected ConcurrentDictionary<string, CultStoredDocument> Entries { get; } =
new(StringComparer.Ordinal);
/// <summary>
/// Gets or sets the backing store logger.
/// </summary>
public ILogger Logger
{
get => _logger;
set => _logger = value ?? new NullLogger();
}
/// <summary>
/// Publishes documents added by the backing store.
/// </summary>
public Subject<CultStoredDocument> EntryAdded { get; } = new();
/// <summary>
/// Publishes documents updated by the backing store.
/// </summary>
public Subject<CultStoredDocument> EntryUpdated { get; } = new();
/// <summary>
/// Publishes documents deleted by the backing store.
/// </summary>
public Subject<CultStoredDocument> EntryDeleted { get; } = new();
internal void AttachRegistry(CultDocumentRegistry registry)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
}
/// <summary>
/// Pulls all persisted records into the backing store.
/// </summary>
public abstract void PullAll();
/// <summary>
/// Pushes one stored document into the backing store.
/// </summary>
public abstract void Push(CultStoredDocument entry);
/// <summary>
/// Deletes one stored document from the backing store.
/// </summary>
public abstract void Delete(CultStoredDocument entry);
/// <summary>
/// Persists all current backing store entries.
/// </summary>
public abstract void PushAll(bool soft = false);
/// <summary>
/// Releases backing store event subjects.
/// </summary>
public virtual void Dispose()
{
EntryAdded.Dispose();
EntryUpdated.Dispose();
EntryDeleted.Dispose();
}
/// <summary>
/// Converts a stored document into a persisted record.
/// </summary>
protected CultPersistedRecord ToPersistedRecord(CultStoredDocument entry, Func<object, byte[]> serializePayload)
{
return new CultPersistedRecord
{
Key = entry.Key.Value,
SchemaId = entry.Descriptor.SchemaId,
StoredAt = entry.StoredAt,
Payload = serializePayload(entry.Document)
};
}
/// <summary>
/// Converts a persisted record into a stored document.
/// </summary>
protected CultStoredDocument ToStoredDocument(
CultPersistedRecord record,
IReadOnlyCollection<CultSchemaCatalogEntry> catalog,
Func<Type, byte[], object> deserializePayload)
{
var descriptor = Registry.ResolvePersistedSchema(record.SchemaId, catalog);
var document = deserializePayload(descriptor.DocumentType, record.Payload);
return new CultStoredDocument(
new CultRecordKey(record.Key),
record.StoredAt,
descriptor,
document);
}
}
/// <summary>
/// Base class for backing stores that persist a complete snapshot to one file.
/// </summary>
public abstract class SingleFileBackingStore : CacheBackingStore
{
/// <summary>
/// Creates a single-file backing store.
/// </summary>
protected SingleFileBackingStore(string filePath)
{
FileInfo = new FileInfo(filePath);
}
/// <summary>
/// Gets the file used by this backing store.
/// </summary>
protected FileInfo FileInfo { get; }
/// <summary>
/// Serializes a full store snapshot.
/// </summary>
protected abstract byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot);
/// <summary>
/// Deserializes a full store snapshot.
/// </summary>
protected abstract CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data);
/// <summary>
/// Serializes one document payload.
/// </summary>
protected abstract byte[] SerializePayload(object document);
/// <summary>
/// Deserializes one document payload.
/// </summary>
protected abstract object DeserializePayload(Type documentType, byte[] payload);
/// <summary>
/// Loads every persisted record from disk.
/// </summary>
public override void PullAll()
{
if (!FileInfo.Exists)
{
return;
}
var snapshot = DeserializeSnapshot(File.ReadAllBytes(FileInfo.FullName));
foreach (var record in snapshot.Records)
{
var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload);
Entries[stored.Key.Value] = stored;
EntryAdded.OnNext(stored);
}
}
/// <summary>
/// Stages one stored document in memory.
/// </summary>
public override void Push(CultStoredDocument entry)
{
Entries[entry.Key.Value] = entry;
}
/// <summary>
/// Removes one staged document.
/// </summary>
public override void Delete(CultStoredDocument entry)
{
Entries.TryRemove(entry.Key.Value, out _);
}
/// <summary>
/// Writes the staged snapshot to disk.
/// </summary>
public override void PushAll(bool soft = false)
{
var snapshot = new CultPersistedStoreSnapshot
{
SchemaCatalog = Entries.Values
.Select(entry => entry.Descriptor.ToCatalogEntry())
.GroupBy(entry => entry.SchemaId, StringComparer.Ordinal)
.Select(group => group.First())
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
.ToArray(),
Records = Entries.Values
.OrderBy(entry => entry.Key.Value, StringComparer.Ordinal)
.Select(entry => ToPersistedRecord(entry, SerializePayload))
.ToArray()
};
Directory.CreateDirectory(FileInfo.DirectoryName!);
WriteSnapshotAtomically(FileInfo.FullName, SerializeSnapshot(snapshot));
}
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);
}
}
}
}
}