using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using GameCult.Caching; using R3; namespace GameCult.Networking { /// /// Describes the kind of domain change observed through a distributed CultCache surface. /// public enum CultNetDatabaseChangeKind { /// /// A document was added. /// Added, /// /// A document was updated. /// Updated, /// /// A document was removed. /// Removed, /// /// A document was accepted through compatible schema migration. /// SchemaMigrated, /// /// A document change was rejected. /// Rejected, /// /// A document was applied locally before authoritative commit. /// Predicted, /// /// A predicted document was replaced by the authoritative committed state. /// Reconciled } /// /// The one schema-alias matcher for every runtime-side schema match (docs/cultnet-selection-cut.md, D4). /// public static class CultNetSchemaAliasMatching { public static bool MatchesAny(IReadOnlyList candidates, string schemaId) { return candidates.Count == 0 || candidates.Any(candidate => Matches(candidate, schemaId)); } public static bool MatchesAny(IReadOnlyList candidates, CultDocumentDescriptor descriptor) { return candidates.Count == 0 || candidates.Any(candidate => Matches(candidate, descriptor)); } public static bool Matches(string candidate, string schemaId) { if (string.Equals(candidate, schemaId, StringComparison.Ordinal)) return true; var candidateName = InferSchemaName(candidate) ?? candidate; var schemaName = InferSchemaName(schemaId) ?? schemaId; return string.Equals(candidateName, schemaName, StringComparison.Ordinal); } public static bool Matches(string candidate, CultDocumentDescriptor descriptor) { if (string.Equals(candidate, descriptor.SchemaId, StringComparison.Ordinal) || string.Equals(candidate, descriptor.SchemaName, StringComparison.Ordinal) || string.Equals(candidate, descriptor.SchemaVersion, StringComparison.Ordinal)) return true; if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(compatible => string.Equals(candidate, compatible, StringComparison.Ordinal))) return true; var candidateName = InferSchemaName(candidate) ?? candidate; return string.Equals(candidateName, descriptor.SchemaName, StringComparison.Ordinal); } private static string? InferSchemaName(string schemaId) { var marker = schemaId.LastIndexOf(".v", StringComparison.Ordinal); if (marker <= 0 || marker + 2 >= schemaId.Length) return null; var version = schemaId.Substring(marker + 2); return version.All(char.IsDigit) ? schemaId.Substring(0, marker) : null; } // R-M: WithBindingSchemaAlias (a second binding-alias reconciler, scoped to one descriptor) is // deleted - CultNetDocumentRegistry.ExpandSchemaBindingAliases is the one copy every caller // uses, CultNetDatabaseServer and CultNetDatabaseSubscriptionServer included. } /// /// Declares which documents this runtime may predict locally before authoritative commit. /// public sealed class CultNetClientAuthorityScope { /// /// Creates a client authority scope. /// public CultNetClientAuthorityScope( string ownerRuntimeId, IEnumerable? schemaIds = null, string? keyPrefix = null) { OwnerRuntimeId = string.IsNullOrWhiteSpace(ownerRuntimeId) ? throw new ArgumentException("Value must be non-empty.", nameof(ownerRuntimeId)) : ownerRuntimeId; SchemaIds = schemaIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() ?? Array.Empty(); KeyPrefix = keyPrefix; } /// /// Gets the runtime that owns authoring for this scope. /// public string OwnerRuntimeId { get; } /// /// Gets the schema ids governed by this scope. Empty means all schemas. /// public IReadOnlyList SchemaIds { get; } /// /// Gets the optional record-key prefix governed by this scope. /// public string? KeyPrefix { get; } internal bool Matches(string runtimeId, string schemaId, CultRecordKey key) { return string.Equals(OwnerRuntimeId, runtimeId, StringComparison.Ordinal) && CultNetSchemaAliasMatching.MatchesAny(SchemaIds, schemaId) && (string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal)); } internal bool Matches(string runtimeId, CultDocumentDescriptor descriptor, CultRecordKey key) { return string.Equals(OwnerRuntimeId, runtimeId, StringComparison.Ordinal) && CultNetSchemaAliasMatching.MatchesAny(SchemaIds, descriptor) && (string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal)); } } /// /// Describes one shard owned or observed by a CultNet database surface. /// public sealed class CultNetShardDescriptor { /// /// Creates a shard descriptor. /// public CultNetShardDescriptor( string shardId, string ownerRuntimeId, long epoch, bool isPrimary, IEnumerable? schemaIds = null, string? keyPrefix = null, IEnumerable? primaryEndpoints = null, IEnumerable? replicaEndpoints = null, IEnumerable? readReplicaEndpoints = null, string? region = null) { ShardId = RequireNonEmpty(shardId, nameof(shardId)); OwnerRuntimeId = RequireNonEmpty(ownerRuntimeId, nameof(ownerRuntimeId)); Epoch = epoch; IsPrimary = isPrimary; SchemaIds = schemaIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() ?? Array.Empty(); KeyPrefix = keyPrefix; PrimaryEndpoints = CleanEndpoints(primaryEndpoints); ReplicaEndpoints = CleanEndpoints(replicaEndpoints); ReadReplicaEndpoints = CleanEndpoints(readReplicaEndpoints); Region = region; } /// /// Gets the stable shard identifier. /// public string ShardId { get; } /// /// Gets the runtime that currently owns writes for this shard. /// public string OwnerRuntimeId { get; } /// /// Gets the monotonic shard authority epoch. /// public long Epoch { get; } /// /// Gets whether the local database instance is authoritative for writes to this shard. /// public bool IsPrimary { get; } /// /// Gets the schema ids governed by this shard. Empty means all schemas. /// public IReadOnlyList SchemaIds { get; } /// /// Gets the optional record-key prefix governed by this shard. /// public string? KeyPrefix { get; } /// /// Gets endpoints that can accept authoritative writes for this shard. /// public IReadOnlyList PrimaryEndpoints { get; } /// /// Gets endpoints that replicate authoritative shard mutations. /// public IReadOnlyList ReplicaEndpoints { get; } /// /// Gets endpoints intended for low-latency read and subscription traffic. /// public IReadOnlyList ReadReplicaEndpoints { get; } /// /// Gets an optional locality label for this shard owner. /// public string? Region { get; } /// /// Creates a primary shard that accepts every schema and key. /// public static CultNetShardDescriptor PrimaryAll(string ownerRuntimeId = "local", string shardId = "primary") { return new CultNetShardDescriptor(shardId, ownerRuntimeId, epoch: 1, isPrimary: true); } /// /// Creates a read-only shard descriptor. /// public static CultNetShardDescriptor ReadOnly( string shardId, string ownerRuntimeId, long epoch = 1, IEnumerable? schemaIds = null, string? keyPrefix = null) { return new CultNetShardDescriptor(shardId, ownerRuntimeId, epoch, isPrimary: false, schemaIds, keyPrefix); } internal bool Matches(string schemaId, CultRecordKey key) { var schemaMatches = CultNetSchemaAliasMatching.MatchesAny(SchemaIds, schemaId); var keyMatches = string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal); return schemaMatches && keyMatches; } internal bool Matches(CultDocumentDescriptor descriptor, CultRecordKey key) { var schemaMatches = CultNetSchemaAliasMatching.MatchesAny(SchemaIds, descriptor); var keyMatches = string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal); return schemaMatches && keyMatches; } private static string RequireNonEmpty(string value, string paramName) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("Value must be non-empty.", paramName); } return value; } private static string[] CleanEndpoints(IEnumerable? endpoints) { return endpoints?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() ?? Array.Empty(); } } /// /// Options for creating a distributed CultCache database surface. /// public sealed class CultNetDatabaseOptions { /// /// Gets or sets the local runtime id advertised as shard owner for default primary shards. /// public string RuntimeId { get; set; } = "local"; /// /// Gets or sets explicit shard descriptors. When omitted, this instance owns one primary shard for all records. /// public IReadOnlyList? Shards { get; set; } /// /// Gets or sets document scopes this runtime may predict before authoritative commit. /// public IReadOnlyList? ClientAuthorityScopes { get; set; } /// /// Gets or sets the document registry used to create and apply raw CultNet document messages. /// public CultNetDocumentRegistry? DocumentRegistry { get; set; } /// /// Gets or sets the durable store for accepted shard mutation logs. /// public ICultNetShardMutationLogStore? MutationLogStore { get; set; } /// /// Gets or sets the process cursor key (R-O). Omitted in production, where a fresh random key /// per is exactly the point; a test that must mint a cursor under a /// known key, or reuse the pre-restart key deliberately, sets this explicitly. /// public CultNetSelectionCursorKey? CursorKey { get; set; } } /// /// A typed domain change emitted by a CultNet database surface. /// public sealed class CultNetDatabaseChange where T : class { /// /// Creates a database change. /// public CultNetDatabaseChange( CultNetDatabaseChangeKind kind, CultRecordKey key, string schemaId, CultNetShardDescriptor shard, T? document, T? previousDocument, string? message = null) { Kind = kind; Key = key; SchemaId = schemaId; Shard = shard; Document = document; PreviousDocument = previousDocument; Message = message; } /// /// Gets the change kind. /// public CultNetDatabaseChangeKind Kind { get; } /// /// Gets the changed record key. /// public CultRecordKey Key { get; } /// /// Gets the schema id of the changed document. /// public string SchemaId { get; } /// /// Gets the shard that accepted or rejected the change. /// public CultNetShardDescriptor Shard { get; } /// /// Gets the current document for added and updated events. /// public T? Document { get; } /// /// Gets the previous document for updated and removed events. /// public T? PreviousDocument { get; } /// /// Gets an optional diagnostic message. /// public string? Message { get; } } /// /// One ordered mutation accepted for a shard. /// public sealed class CultNetShardMutationLogEntry { /// /// Creates a shard mutation log entry. /// public CultNetShardMutationLogEntry( string shardId, long shardEpoch, long sequence, string committedAt, CultNetDatabaseChangeKind kind, string schemaId, CultRecordKey key, object? document, object? previousDocument) { ShardId = shardId; ShardEpoch = shardEpoch; Sequence = sequence; CommittedAt = committedAt; Kind = kind; SchemaId = schemaId; Key = key; Document = document; PreviousDocument = previousDocument; } /// /// Gets the shard id. /// public string ShardId { get; } /// /// Gets the shard epoch. /// public long ShardEpoch { get; } /// /// Gets the per-shard mutation sequence. /// public long Sequence { get; } /// /// Gets the commit timestamp. /// public string CommittedAt { get; } /// /// Gets the mutation kind. /// public CultNetDatabaseChangeKind Kind { get; } /// /// Gets the schema id. /// public string SchemaId { get; } /// /// Gets the record key. /// public CultRecordKey Key { get; } /// /// Gets the current document, when present. /// public object? Document { get; } /// /// Gets the previous document, when present. /// public object? PreviousDocument { get; } // The primary's own wire entry, kept for an entry this replica changed nothing for (a row its shard does not own, an // absent key): it is relayed downstream exactly as the primary sent it, in memory and on disk alike. internal CultNetShardLogEntryMessage? Relayed { get; set; } } /// /// Raised when a database write targets a shard this node does not own. /// public sealed class CultNetShardAuthorityException : InvalidOperationException { /// /// Creates a shard authority exception. /// public CultNetShardAuthorityException(CultNetShardDescriptor shard, string message, string reason = "not_primary") : base(message) { Shard = shard; Reason = reason; } /// /// Gets the shard that rejected the write. /// public CultNetShardDescriptor Shard { get; } /// /// Gets the machine-readable authority rejection reason. /// public string Reason { get; } } /// /// One shard-log sequence that a primary minted for a committed change and burned because it could not log it. /// public readonly struct CultNetBurnedSequence : IEquatable { /// /// Creates a burned sequence. /// public CultNetBurnedSequence(string shardId, long sequence) { ShardId = shardId; Sequence = sequence; } /// /// Gets the shard whose log burned the sequence. /// public string ShardId { get; } /// /// Gets the burned shard-log sequence. /// public long Sequence { get; } /// public bool Equals(CultNetBurnedSequence other) => Sequence == other.Sequence && string.Equals(ShardId, other.ShardId, StringComparison.Ordinal); /// public override bool Equals(object? obj) => obj is CultNetBurnedSequence other && Equals(other); /// public override int GetHashCode() => (StringComparer.Ordinal.GetHashCode(ShardId ?? string.Empty) * 397) ^ Sequence.GetHashCode(); /// public override string ToString() => $"{ShardId}:{Sequence}"; } /// /// Raised when a database write, or a resolution, names a schema and key that no configured shard owns. A refused write /// caches nothing. /// public sealed class CultNetUnownedSchemaException : InvalidOperationException { /// /// Creates an unowned schema exception. /// public CultNetUnownedSchemaException(string schemaId, CultRecordKey key) : base($"No shard owns schema '{schemaId}' key '{key.Value}'; the write was refused.") { SchemaId = schemaId; Key = key; } /// /// Gets the schema that no shard owns. /// public string SchemaId { get; } /// /// Gets the key of the refused write. /// public CultRecordKey Key { get; } } /// /// Raised to the writer when a shard primary committed changes and could not log them. The commits stand and the /// changes were published; each shard's log burned the sequences in and compacted past them, /// so every replica behind one resynchronizes from a snapshot. A single journal call raises one of these carrying /// every burned sequence, whatever number of changes or shards failed. It is deliberately not an /// : callers that read that type as a refused write must not mistake a /// committed one for it. /// /// /// The cache may still wrap this in an when something else failed in the same hold /// (an observer, a second database's journal). A writer that wants the log failure catches this type, or unwraps /// and looks for it. is the /// store's failure, or an of them when several changes failed. /// public sealed class CultNetShardLogException : Exception { /// /// Creates a shard log exception. /// public CultNetShardLogException(IReadOnlyList burned, Exception cause) : base($"Committed changes could not be logged; sequences {string.Join(", ", burned.Select(entry => $"{entry.ShardId}:{entry.Sequence}"))} are burned and compacted past.", cause) { Burned = burned; } /// /// Gets every sequence burned by the journal call, in commit order. /// public IReadOnlyList Burned { get; } } /// /// Database-style CultNet facade over a CultCache shard set. /// public sealed class CultNetDatabase : IDisposable { private readonly CultCache _cache; private readonly CultNetDocumentRegistry _documents; private readonly string _runtimeId; private readonly List _shards; private readonly List _clientAuthorityScopes; private readonly ICultNetShardMutationLogStore? _mutationLogStore; // Keyed by sequence, like the durable store: recording an entry twice replaces it. private readonly Dictionary> _mutationLogs = new(StringComparer.Ordinal); private readonly Dictionary _nextLogSequences = new(StringComparer.Ordinal); // The highest sequence this primary burned per shard (a change it committed and could not log). Serves as the // shard's compaction floor beside the store's own, so history the log cannot serve is answered compacted_history. private readonly Dictionary _burnedThrough = new(StringComparer.Ordinal); private readonly Dictionary _appliedShardSequences = new(StringComparer.Ordinal); // CultNet typed selection, section 2: "ordinal is the sequence of the commit that last wrote // the row". Kept on every append (LogPrimaryChange and RecordReplicatedEntry are the sites), keyed by (schemaId, // recordKey) so the evaluator's order and cursor never read a clock. private readonly Dictionary<(string SchemaId, string RecordKey), long> _lastWriteSequence = new(); private readonly Subject _changes = new(); private readonly IDisposable _cacheChanges; private readonly IDisposable _cacheJournal; // What a write door of this database knows about the change it is admitting and the cache does not: the wire // message it carries, a prediction it reconciles, the replicated log entry it applies. The journal is the only // logger and consumes the context under the cache gate; a door registers it under the identity of the change it // admits (the instance it writes, or the instance it removes), and the journal takes it once, for that change // alone. A write to the same key by anyone else is a different instance and gets no context. private readonly ConcurrentDictionary _doors = new(ReferenceIdentity.Instance); // One gate over the mutation log, its sequence counters, the last-write map and the publication stash. Lock order: // the cache gate, then this, then the log store's own lock. Nothing holding this calls the cache, and a log store // must not either (ICultNetShardMutationLogStore). Publication stays outside it: a subscriber may write the cache // from its handler. private readonly object _logGate = new(); // Changes the journal has decided and the Watch observer has not yet published, by cache Sequence. The journal // runs under the cache gate before any observer, so each change is stashed before it can be published. private readonly Dictionary _stash = new(); // Predictions this runtime made and has not seen reconciled. Only the journal reads or writes it, under the cache gate. private readonly HashSet _predictedDocuments = new(StringComparer.Ordinal); private volatile bool _disposed; /// /// Creates a database surface over a CultCache instance. /// public CultNetDatabase(CultCache cache, CultNetDatabaseOptions? options = null) { _cache = cache ?? throw new ArgumentNullException(nameof(cache)); options ??= new CultNetDatabaseOptions(); _runtimeId = options.RuntimeId; _documents = options.DocumentRegistry ?? new CultNetDocumentRegistry(cache.Registry); _mutationLogStore = options.MutationLogStore; _shards = (options.Shards == null || options.Shards.Count == 0 ? new[] { CultNetShardDescriptor.PrimaryAll(options.RuntimeId) } : options.Shards) .ToList(); _clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty()).ToList(); CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random(); InitializeLogSequencesFromStore(); // The observer first, the journal second: a change the journal stashed always finds its publisher. _cacheChanges = _cache.Watch().Subscribe(Publish); _cacheJournal = _cache.AddJournal(Journal); } /// /// This process's cursor key (R-O): random per instance unless /// supplies one, so a page minted by this database cannot be answered by a differently-keyed one, /// and a fresh - a real process restart, or a simulated one in tests - /// mints under a fresh key that a prior cursor's digest does not verify against. /// public CultNetSelectionCursorKey CursorKey { get; } /// /// Gets the local cache backing this database surface. /// public CultCache Cache => _cache; /// /// Gets the document registry used by the raw CultNet lane. /// public CultNetDocumentRegistry Documents => _documents; /// /// Gets the shard map known to this database surface. /// public IReadOnlyList Shards => _shards; /// /// Gets the document scopes this runtime may predict before authoritative commit. /// public IReadOnlyList ClientAuthorityScopes => _clientAuthorityScopes; /// Gets whether this database instance can authoritatively replace the document at a key. public bool CanWriteAuthoritatively(CultRecordKey key) where T : class { ThrowIfDisposed(); var descriptor = _cache.Registry.GetRequired(); return FindShard(descriptor, key)?.IsPrimary ?? false; } /// Gets whether this database instance can submit a prediction for the document at a key. public bool CanSubmitPrediction(CultRecordKey key) where T : class { ThrowIfDisposed(); var descriptor = _cache.Registry.GetRequired(); return _clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, descriptor, key)); } /// /// Gets mutation log entries for a shard after the supplied sequence. /// public IReadOnlyList GetMutationLog( string shardId, long afterSequence = 0, int? limit = null) { ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); lock (_logGate) { if (!_mutationLogs.TryGetValue(shardId, out var entries)) { return Array.Empty(); } var query = entries.Values.Where(entry => entry.Sequence > afterSequence); if (limit.HasValue) { query = query.Take(limit.Value); } return query.ToArray(); } } /// /// Gets replica catch-up log entries for a shard after the supplied sequence. /// public IReadOnlyList GetMutationLogMessages( string shardId, long afterSequence = 0, int? limit = null) { ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); if (_mutationLogStore != null) { return _mutationLogStore.Read(shardId, afterSequence, limit); } return GetMutationLog(shardId, afterSequence, limit) .Select(ToLogEntryMessage) .ToArray(); } /// /// Gets the highest retained-log sequence that has been compacted away for a shard. /// public long GetCompactedMutationLogSequence(string shardId) { ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); lock (_logGate) return CompactedFloor(shardId); } // The store's compaction point or this primary's own burned sequences, whichever is higher. Under _logGate. private long CompactedFloor(string shardId) { var burned = _burnedThrough.TryGetValue(shardId, out var through) ? through : 0; return Math.Max(burned, _mutationLogStore?.GetCompactedThrough(shardId) ?? 0); } /// /// Gets the latest known shard-log sequence for a shard. /// public long GetLatestMutationLogSequence(string shardId) { ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); lock (_logGate) { var retained = _mutationLogStore != null ? _mutationLogStore.Read(shardId).Select(entry => entry.Sequence) : GetMutationLog(shardId).Select(entry => entry.Sequence); return retained.Append(CompactedFloor(shardId)).Max(); } } /// /// Creates a raw document snapshot bounded to one shard. Answers through the one evaluator /// (, via ), /// binding-alias expansion included, with the shard membership check as the row filter - /// no loop of its own (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22, S2-3). /// public CultNetSnapshotResponseRawMessage CreateShardSnapshotResponse( CultNetShardDescriptor shard, string messageId, CultNetSnapshotRequestMessage? filter = null) { ThrowIfDisposed(); if (shard == null) throw new ArgumentNullException(nameof(shard)); // The log sequence is read before any document: a write that lands in between is then replayed from the log on // top of documents that already hold it (whole-document puts and deletes, applied in order), and a snapshot can // never claim a sequence whose write its documents lack. var logSequence = GetLatestMutationLogSequence(shard.ShardId); var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds); var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys); // R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not // reach the v1 door, which refuses an empty selection.schemas/keys outright. if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 }) { return new CultNetSnapshotResponseRawMessage { MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId, Documents = Array.Empty(), ShardId = shard.ShardId, ShardEpoch = shard.Epoch, ShardLogSequence = logSequence }; } var selection = new CultNetSelection { Schemas = lowSchemas, Keys = lowKeys, Projection = CultNetSelectionProjections.Document }; bool RowFilter(CultDocumentDescriptor descriptor, CultRecordKey key) => !string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key); // R-G: one evaluation answers the whole shard snapshot, not a loop that re-filters and // re-sorts the shard's row set once per 200-row page. var page = _documents.SelectAll(_cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, options: null, rowFilter: RowFilter); return new CultNetSnapshotResponseRawMessage { MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId, Documents = page.Documents ?? Array.Empty(), ShardId = shard.ShardId, ShardEpoch = shard.Epoch, ShardLogSequence = logSequence }; } /// /// Applies a shard-bounded snapshot and advances the replica cursor to its represented sequence. /// public async Task ApplyShardSnapshotResponseAsync( CultNetShardDescriptor shard, CultNetSnapshotResponseRawMessage response) { ThrowIfDisposed(); if (shard == null) throw new ArgumentNullException(nameof(shard)); if (response == null) throw new ArgumentNullException(nameof(response)); if (!string.IsNullOrWhiteSpace(response.ShardId) && !string.Equals(response.ShardId, shard.ShardId, StringComparison.Ordinal)) { throw new InvalidOperationException($"Snapshot for shard '{response.ShardId}' cannot be applied to shard '{shard.ShardId}'."); } if (response.ShardEpoch.HasValue && response.ShardEpoch.Value != shard.Epoch) { throw new CultNetShardAuthorityException( shard, $"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not snapshot epoch {response.ShardEpoch.Value}.", "stale_epoch"); } var incomingKeys = response.Documents .Where(document => ShardMatchesRawDocument(shard, document)) .Select(document => document.RecordKey) .ToHashSet(StringComparer.Ordinal); var local = GetLocalShardDocuments(shard).ToArray(); foreach (var existing in local.Where(item => !incomingKeys.Contains(item.Key.Value))) { using (Door(existing.Document, new DoorContext(shard) { NoLog = true, Removal = true })) _cache.Remove(existing.Key); } foreach (var document in response.Documents.Where(document => ShardMatchesRawDocument(shard, document))) { var key = new CultRecordKey(document.RecordKey); var descriptor = _documents.ResolveDescriptorForRawDocument(document); var applied = _documents.DeserializeRawDocument(document); using (Door(applied, new DoorContext(shard) { NoLog = true })) await _cache.UpsertAsync(descriptor.DocumentType, applied, key).ConfigureAwait(false); } var sequence = response.ShardLogSequence ?? 0; SetAppliedShardSequence(shard.ShardId, sequence); return sequence; } /// /// Gets the last replicated sequence applied for a shard. /// public long GetAppliedShardSequence(string shardId) { ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); return _appliedShardSequences.TryGetValue(shardId, out var sequence) ? sequence : 0; } internal void SetAppliedShardSequence(string shardId, long sequence) { ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); if (sequence < 0) throw new ArgumentOutOfRangeException(nameof(sequence), "Sequence must be non-negative."); _appliedShardSequences[shardId] = sequence; } /// /// Creates a shard catalog response for optional schema/key filters. /// public CultNetShardCatalogResponseMessage CreateShardCatalogResponse(CultNetShardCatalogRequestMessage request) { ThrowIfDisposed(); if (request == null) throw new ArgumentNullException(nameof(request)); var schemaIds = request.SchemaIds == null || request.SchemaIds.Length == 0 ? null : request.SchemaIds; var recordKeys = request.RecordKeys == null || request.RecordKeys.Length == 0 ? null : request.RecordKeys.Select(key => new CultRecordKey(key)).ToArray(); var descriptors = _shards .Where(shard => MatchesCatalogFilter(shard, schemaIds, recordKeys)) .Select(ToMessage) .ToArray(); return new CultNetShardCatalogResponseMessage { MessageId = string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId, Shards = descriptors }; } /// /// Resolves the shard that governs the supplied schema and key. /// /// No configured shard owns the schema and key. public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key) { ThrowIfDisposed(); return ResolveShardInternal(schemaId, key); } /// /// Gets a document by key. /// public Task GetAsync(CultRecordKey key) where T : class { ThrowIfDisposed(); var document = _cache.Get(key); return Task.FromResult(document != null && Owns(_cache.Registry.GetRequired(document.GetType()), key) ? document : null); } /// /// Gets all documents assignable to the requested type. A database serves only what its shards own: a cached row no /// shard owns is left out here, as it is from every read and projection. The cache itself still holds it. /// public IEnumerable GetAll() where T : class { ThrowIfDisposed(); return _cache.GetAll().Where(Serves); } /// /// Gets a typed document by its CultName value. /// public T? GetByName(string name) where T : class { ThrowIfDisposed(); var document = _cache.GetByName(name); return document != null && Serves(document) ? document : null; } /// /// Gets a typed document by an indexed value. /// public T? GetByIndex(string alias, string value) where T : class { ThrowIfDisposed(); var document = _cache.GetByIndex(alias, value); return document != null && Serves(document) ? document : null; } /// /// Adds or replaces a document at a specific key. /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. public async Task> PutAsync(CultRecordKey key, T document) where T : class { ThrowIfDisposed(); if (document == null) throw new ArgumentNullException(nameof(document)); var descriptor = _cache.Registry.GetRequired(document.GetType()); EnsurePrimary(ResolveShardInternal(descriptor, key), descriptor.SchemaId, key); return await _cache.UpsertAsync(document, new CultRecordHandle(key)).ConfigureAwait(false); } /// /// Applies a locally predicted document for a client-owned input scope. /// /// No shard owns the schema and key; nothing was written. public async Task> PutPredictedAsync(CultRecordKey key, T document) where T : class { ThrowIfDisposed(); if (document == null) throw new ArgumentNullException(nameof(document)); var descriptor = _cache.Registry.GetRequired(document.GetType()); var shard = ResolveShardInternal(descriptor, key); EnsureClientAuthority(descriptor, key); CultRecordHandle handle; using (Door(document, new DoorContext(shard) { Kind = CultNetDatabaseChangeKind.Predicted, NoLog = true, PredictionKey = PredictionKey(descriptor.SchemaId, key), Predicts = true })) handle = await _cache.UpsertAsync(document, new CultRecordHandle(key)).ConfigureAwait(false); return handle; } /// /// Deletes a document by key. /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. public Task DeleteAsync(CultRecordKey key) where T : class { ThrowIfDisposed(); var descriptor = _cache.Registry.GetRequired(); EnsurePrimary(ResolveShardInternal(descriptor, key), descriptor.SchemaId, key); _cache.Remove(new CultRecordHandle(key)); return Task.CompletedTask; } /// /// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's): /// it asks no remote write authority. A peer's put goes through , which does. /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. public Task ApplyPutAsync(CultNetDocumentPutRawMessage message) => ApplyPutAsync(message, authorize: null); // The peer door: the same apply, with the server's authorize callback asked once the schema is resolved and before the // shard is. The callback throws to refuse. The public overload is the host's own door and asks no remote authority. internal async Task ApplyPutAsync(CultNetDocumentPutRawMessage message, Action? authorize) { ThrowIfDisposed(); if (message == null) throw new ArgumentNullException(nameof(message)); if (message.Document == null) { throw new ArgumentException("CultNet raw document message is missing its document payload.", nameof(message)); } var key = new CultRecordKey(message.Document.RecordKey); var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document); authorize?.Invoke(descriptor.SchemaId, key); var shard = ResolveShardInternal(descriptor, key); EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch); var document = _documents.DeserializeRawDocument(message.Document); using (Door(document, new DoorContext(shard) { Put = message, PredictionKey = PredictionKey(descriptor.SchemaId, key) })) await _cache.UpsertAsync(descriptor.DocumentType, document, key).ConfigureAwait(false); return document; } /// /// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote /// write authority. A peer's delete goes through , which does. /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message) => ApplyDeleteAsync(message, authorize: null); // The peer door; see the put overload. internal Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message, Action? authorize) { ThrowIfDisposed(); if (message == null) throw new ArgumentNullException(nameof(message)); var key = new CultRecordKey(message.RecordKey); var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId); authorize?.Invoke(descriptor.SchemaId, key); var shard = ResolveShardInternal(descriptor, key); EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch); var previous = _cache.Get(key); if (previous == null) { return Task.CompletedTask; } using (Door(previous, new DoorContext(shard) { Delete = message, Removal = true })) _cache.Remove(key); return Task.CompletedTask; } /// /// Applies committed shard-log entries from an authoritative peer. /// public async Task ApplyShardLogResponseAsync(CultNetShardLogResponseMessage response) { ThrowIfDisposed(); if (response == null) throw new ArgumentNullException(nameof(response)); if (response.ResyncRequired) { throw new InvalidOperationException( $"Shard '{response.ShardId}' requires snapshot resync before log application: {response.Reason ?? "unspecified"}."); } var shard = _shards.FirstOrDefault(candidate => string.Equals(candidate.ShardId, response.ShardId, StringComparison.Ordinal)); if (shard == null) { throw new InvalidOperationException($"Shard '{response.ShardId}' is not known by this database."); } if (response.ShardEpoch != shard.Epoch) { throw new CultNetShardAuthorityException( shard, $"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not response epoch {response.ShardEpoch}.", "stale_epoch"); } var lastApplied = GetAppliedShardSequence(response.ShardId); var orderedEntries = response.Entries.OrderBy(entry => entry.Sequence).ToArray(); foreach (var entry in orderedEntries) { if (entry.Sequence <= lastApplied) { continue; } if (entry.Sequence != lastApplied + 1) { throw new InvalidOperationException( $"Shard '{response.ShardId}' log has a gap. Expected sequence {lastApplied + 1}, received {entry.Sequence}."); } await ApplyCommittedShardLogEntryAsync(shard, entry).ConfigureAwait(false); lastApplied = entry.Sequence; _appliedShardSequences[response.ShardId] = lastApplied; } return lastApplied; } /// /// Watches all changes assignable to the requested document type. /// public Observable> Watch() where T : class { ThrowIfDisposed(); return _changes .Where(change => change is CultNetDatabaseChange) .Select(change => (CultNetDatabaseChange)change); } /// /// Watches all typed domain changes as boxed change objects. /// public Observable WatchAllChanges() { ThrowIfDisposed(); return _changes; } /// /// Watches one record. /// public Observable> WatchRecord(CultRecordKey key) where T : class { return Watch().Where(change => change.Key.Equals(key)); } /// /// Watches the global record for a document type. /// public Observable> WatchGlobal() where T : class { var descriptor = _cache.Registry.GetRequired(); return WatchRecord(new CultRecordKey($"global:{descriptor.SchemaId}")); } /// /// Watches changes for a named document. /// public Observable> WatchByName(string name) where T : class { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name)); return Watch().Where(change => (change.Document != null && ReferenceEquals(_cache.GetByName(name), change.Document)) || (change.PreviousDocument != null && ReferenceEquals(_cache.GetByName(name), change.PreviousDocument))); } /// /// Watches changes for the current document mapped by an indexed value. /// public Observable> WatchByIndex(string alias, string value) where T : class { if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value must be non-empty.", nameof(alias)); if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value must be non-empty.", nameof(value)); return Watch().Where(change => (change.Document != null && ReferenceEquals(_cache.GetByIndex(alias, value), change.Document)) || (change.PreviousDocument != null && ReferenceEquals(_cache.GetByIndex(alias, value), change.PreviousDocument))); } /// /// Releases database subscriptions. /// public void Dispose() { if (_disposed) { return; } _disposed = true; // The journal first: disposing it waits for a journal in flight, so nothing is stashed after this line. What a // writer has logged and not yet published is then published here, so a change the log holds is never dropped. _cacheJournal.Dispose(); _cacheChanges.Dispose(); Publication[] unpublished; lock (_logGate) { unpublished = _stash.OrderBy(entry => entry.Key).Select(entry => entry.Value).ToArray(); _stash.Clear(); } foreach (var publication in unpublished) { Publish(publication); } _changes.Dispose(); } // The cache is the one owner of "a change was committed"; the journal is this database's one decision point and its // one logger. It runs under the cache gate, in cache Sequence order, before any observer: it takes the change's door, // settles the change kind and the prediction bookkeeping, logs the change when this runtime is the shard's primary // (a replica records only the primary's own entry, which its door carries), and stashes what the publisher needs. // A change with no door was written by someone else (a bare cache write, a load, a pull) and is logged like any // unattributed commit. It never enters the cache and calls nothing that does. private void Journal(IReadOnlyList> changes) { if (_disposed) { return; } List? failures = null; List? burned = null; List? burnCauses = null; foreach (var change in changes) { var identity = change.Document ?? change.PreviousDocument; if (identity == null) { continue; } var door = TakeDoor(change); var documentType = identity.GetType(); var descriptor = _cache.Registry.GetRequired(documentType); var shard = door?.Shard ?? FindShard(descriptor, change.Key); if (shard == null) { // No shard owns a bare cache write of this schema: the database neither logs nor publishes it. continue; } var kind = door?.Kind ?? change.Kind switch { CultCacheDocumentChangeKind.Removed => CultNetDatabaseChangeKind.Removed, CultCacheDocumentChangeKind.Added => CultNetDatabaseChangeKind.Added, _ => CultNetDatabaseChangeKind.Updated }; if (door?.PredictionKey is { } predictionKey) { if (door.Predicts) _predictedDocuments.Add(predictionKey); else if (_predictedDocuments.Remove(predictionKey)) kind = CultNetDatabaseChangeKind.Reconciled; } // The log records a reconciled change as the update it is. var logKind = kind == CultNetDatabaseChangeKind.Reconciled ? CultNetDatabaseChangeKind.Updated : kind; try { if (door?.Replica is { } replicated) { RecordReplicatedEntry(shard, logKind, descriptor.SchemaId, change, replicated); } else if (door?.NoLog != true && shard.IsPrimary) { if (LogPrimaryChange(shard, logKind, descriptor.SchemaId, change, door) is { } burn) { (burned ??= new List()).Add(new CultNetBurnedSequence(shard.ShardId, burn.Sequence)); (burnCauses ??= new List()).Add(burn.Cause); } } } catch (Exception exception) { (failures ??= new List()).Add(exception); } lock (_logGate) _stash[change.Sequence] = new Publication(documentType, kind, change.Key, descriptor.SchemaId, shard, change.Document, change.PreviousDocument); } // Every change is stashed and will be published; the writer hears of a failed log afterwards, in one exception. if (burned != null) { (failures ??= new List()).Insert( 0, new CultNetShardLogException(burned, burnCauses!.Count == 1 ? burnCauses[0] : new AggregateException(burnCauses))); } if (failures != null) { throw failures.Count == 1 ? failures[0] : new AggregateException(failures); } } // The Watch observer publishes, on the writer's thread after the cache gate opens and before the write returns, what // the journal stashed for this change. A change with no stash was admitted before this database existed. private void Publish(CultCacheDocumentChange change) { Publication? publication; lock (_logGate) { if (!_stash.Remove(change.Sequence, out publication)) { return; } } Publish(publication); } private void Publish(Publication publication) => PublishUntyped( publication.DocumentType, publication.Kind, publication.Key, publication.SchemaId, publication.Shard, publication.Document, publication.PreviousDocument); private void RecordReplicatedEntry( CultNetShardDescriptor shard, CultNetDatabaseChangeKind logKind, string schemaId, CultCacheDocumentChange change, CultNetShardLogEntryMessage replicated) { lock (_logGate) { RecordMutationLogEntry(new CultNetShardMutationLogEntry( shard.ShardId, shard.Epoch, replicated.Sequence, replicated.CommittedAt, logKind, schemaId, change.Key, change.Document, change.PreviousDocument), replicated); _lastWriteSequence[(schemaId, change.Key.Value)] = replicated.Sequence; } } // A primary mints the sequence, appends durably, then records in memory. If it cannot log the change, the change is // still committed: the sequence is burned and compacted past, so a replica behind it is told compacted_history and // resynchronizes by snapshot. The burn is returned for the journal to tell the writer in one CultNetShardLogException. private (long Sequence, Exception Cause)? LogPrimaryChange( CultNetShardDescriptor shard, CultNetDatabaseChangeKind logKind, string schemaId, CultCacheDocumentChange change, DoorContext? door) { lock (_logGate) { var sequence = NextMutationLogSequence(shard.ShardId); _lastWriteSequence[(schemaId, change.Key.Value)] = sequence; try { var entry = new CultNetShardMutationLogEntry( shard.ShardId, shard.Epoch, sequence, DateTimeOffset.UtcNow.ToString("O"), logKind, schemaId, change.Key, change.Document, change.PreviousDocument); // A door's own wire message is what the log stores (its kind, sequence and shard are restated from the entry). var wireEntry = _mutationLogStore == null ? null : door is { Put: not null } or { Delete: not null } ? new CultNetShardLogEntryMessage { Put = door.Put, Delete = door.Delete } : ToLogEntryMessage(entry); RecordMutationLogEntry(entry, wireEntry); return null; } catch (Exception cause) { _burnedThrough[shard.ShardId] = Math.Max(_burnedThrough.TryGetValue(shard.ShardId, out var burned) ? burned : 0, sequence); try { _mutationLogStore?.CompactThrough(shard.ShardId, sequence); } catch (Exception compactCause) { cause = new AggregateException(cause, compactCause); } return (sequence, cause); } } } // A door's context belongs to one specific change, recognised by shape: a put door's change carries the instance as // its new document; a removal door removes an instance, so its change carries it as the previous document and no // new one. A plain removal of an instance a put door admitted is neither, and gets no context. A door's own change // is journaled first, in its own hold, and takes its context there. private DoorContext? TakeDoor(CultCacheDocumentChange change) { var identity = change.Document ?? change.PreviousDocument; if (identity == null || !_doors.TryGetValue(identity, out var door)) { return null; } var matches = change.Document == null ? door.Removal : !door.Removal; return matches && ((ICollection>)_doors) .Remove(new KeyValuePair(identity, door)) ? door : null; } private DoorRegistration Door(object identity, DoorContext context) => new(_doors, identity, context, _doors.TryAdd(identity, context)); // Cleanup only: a door whose change never arrived (a refused write) must not leave its context behind. private readonly struct DoorRegistration : IDisposable { private readonly ConcurrentDictionary _doors; private readonly object _identity; private readonly DoorContext _context; private readonly bool _owned; public DoorRegistration(ConcurrentDictionary doors, object identity, DoorContext context, bool owned) { _doors = doors; _identity = identity; _context = context; _owned = owned; } // True when the journal took the context: the door's write was admitted. public bool Consumed => !_owned || !_doors.TryGetValue(_identity, out var held) || !ReferenceEquals(held, _context); public void Dispose() { if (_owned) ((ICollection>)_doors).Remove(new KeyValuePair(_identity, _context)); } } // What the publisher needs of a change the journal decided. private sealed class Publication { public Publication( Type documentType, CultNetDatabaseChangeKind kind, CultRecordKey key, string schemaId, CultNetShardDescriptor shard, object? document, object? previousDocument) { DocumentType = documentType; Kind = kind; Key = key; SchemaId = schemaId; Shard = shard; Document = document; PreviousDocument = previousDocument; } public Type DocumentType { get; } public CultNetDatabaseChangeKind Kind { get; } public CultRecordKey Key { get; } public string SchemaId { get; } public CultNetShardDescriptor Shard { get; } public object? Document { get; } public object? PreviousDocument { get; } } private sealed class DoorContext { public DoorContext(CultNetShardDescriptor shard) => Shard = shard; public CultNetShardDescriptor Shard { get; } public CultNetDatabaseChangeKind? Kind { get; set; } public bool NoLog { get; set; } public bool Removal { get; set; } public CultNetDocumentPutRawMessage? Put { get; set; } public CultNetDocumentDeleteMessage? Delete { get; set; } public CultNetShardLogEntryMessage? Replica { get; set; } // The prediction this door's change either records (Predicts) or ends. public string? PredictionKey { get; set; } public bool Predicts { get; set; } } private sealed class ReferenceIdentity : IEqualityComparer { public static readonly ReferenceIdentity Instance = new(); bool IEqualityComparer.Equals(object? x, object? y) => ReferenceEquals(x, y); int IEqualityComparer.GetHashCode(object value) => RuntimeHelpers.GetHashCode(value); } private CultRecordKey GetTrackedKey(object document, Type documentType) { var method = typeof(CultCache).GetMethod(nameof(CultCache.TryGetHandle))! .MakeGenericMethod(documentType); var handleObject = method.Invoke(_cache, new[] { document }); if (handleObject == null) { return new CultRecordKey(string.Empty); } var keyProperty = handleObject.GetType().GetProperty("Key"); return keyProperty?.GetValue(handleObject) is CultRecordKey key ? key : new CultRecordKey(string.Empty); } private IEnumerable GetLocalShardDocuments(CultNetShardDescriptor shard) { foreach (var document in _cache.AllEntries) { var documentType = document.GetType(); var descriptor = _cache.Registry.GetRequired(documentType); var key = GetTrackedKey(document, documentType); if (!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor, key)) { yield return new TrackedShardDocument(documentType, descriptor.SchemaId, key, document); } } } private bool ShardMatchesRawDocument(CultNetShardDescriptor shard, CultNetRawDocumentRecord document) { if (document == null || string.IsNullOrWhiteSpace(document.SchemaId) || string.IsNullOrWhiteSpace(document.RecordKey)) { return false; } try { var descriptor = _documents.ResolveDescriptorForRawDocument(document); return shard.Matches(descriptor, new CultRecordKey(document.RecordKey)); } catch (InvalidOperationException) { return false; } } private sealed class TrackedShardDocument { public TrackedShardDocument(Type documentType, string schemaId, CultRecordKey key, object document) { DocumentType = documentType; SchemaId = schemaId; Key = key; Document = document; } public Type DocumentType { get; } public string SchemaId { get; } public CultRecordKey Key { get; } public object Document { get; } } /// /// The shard-log sequence of the commit that last wrote this row, or null when the row has /// never been committed through this database. The order the CultNet selection evaluator /// pages by (docs/cultnet-selection-cut.md, section 2). /// public long? LastWriteSequence(string schemaId, CultRecordKey key) { lock (_logGate) return _lastWriteSequence.TryGetValue((schemaId, key.Value), out var sequence) ? sequence : null; } /// /// The current write-sequence watermark across every shard this database holds, 0 when nothing /// has been committed yet. Only meaningful for an answer that is not scoped to any particular /// shard's log - a selection's own asOf must use instead /// (S-9): the shard-log sequence is a per-shard counter (), /// so taking the maximum across every shard's rows mixes counters that do not compare to one /// another and can report a page as exact "as of" a sequence another, unrelated shard advanced to /// while the page's own shard sat still. /// public ulong CurrentAsOf() { lock (_logGate) return _lastWriteSequence.Count == 0 ? 0UL : (ulong)_lastWriteSequence.Values.Max(); } /// /// The write-sequence watermark of one shard's own log (S-9): the sequence of the last commit /// that shard's log recorded, or 0 when the shard has never taken a write. This is the evaluator's /// asOf for a v1 snapshot or subscription page whose matched rows all come from one shard /// ('s single-shard check) - it advances only when that /// shard's own log advances, so a cursor minted against it refuses cursor_stale exactly /// when that shard's log has moved, never because an unrelated shard elsewhere took a write the /// selection never touched. /// public ulong CurrentAsOf(string shardId) { if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); lock (_logGate) return _nextLogSequences.TryGetValue(shardId, out var next) ? (ulong)(next - 1) : 0UL; } private void RecordMutationLogEntry( CultNetShardMutationLogEntry entry, CultNetShardLogEntryMessage? wireEntry = null) { if (!_mutationLogs.TryGetValue(entry.ShardId, out var entries)) { entries = new SortedList(); _mutationLogs[entry.ShardId] = entries; } // Disk first: memory never serves an entry the durable log lacks, and a refused append records nothing. if (_mutationLogStore != null && wireEntry != null) { _mutationLogStore.Append(entry.ShardId, NormalizeWireLogEntry(entry, wireEntry)); } entries[entry.Sequence] = entry; if (!_nextLogSequences.TryGetValue(entry.ShardId, out var next) || next <= entry.Sequence) { _nextLogSequences[entry.ShardId] = entry.Sequence + 1; } } private long NextMutationLogSequence(string shardId) { var sequence = _nextLogSequences.TryGetValue(shardId, out var next) ? next : 1; _nextLogSequences[shardId] = sequence + 1; return sequence; } private void InitializeLogSequencesFromStore() { if (_mutationLogStore == null) { return; } foreach (var shard in _shards) { // R-Q: the log is the durable record of every commit, replica-applied ones included, so // the last-write sequence a restart resumes with is rebuilt from it exactly as // LogPrimaryChange/the replica apply paths maintain it live - a row's ordinal for // the selection evaluator's order and cursor must not reset to "never written" on restart. var entries = _mutationLogStore.Read(shard.ShardId).OrderBy(entry => entry.Sequence).ToArray(); var compacted = _mutationLogStore.GetCompactedThrough(shard.ShardId); var highest = 0L; var expected = compacted + 1; var hole = 0L; foreach (var entry in entries) { // A sequence the log lacks between two it holds was burned (its append and its compaction both failed, or // a compaction was interrupted). Replicas must be told compacted_history for it, not served a gapped log. if (entry.Sequence > compacted && entry.Sequence > expected) { hole = entry.Sequence - 1; } expected = Math.Max(expected, entry.Sequence + 1); if (entry.Sequence > highest) { highest = entry.Sequence; } var (schemaId, recordKey) = KeyOf(entry); if (schemaId != null && recordKey != null) { _lastWriteSequence[(schemaId, recordKey)] = entry.Sequence; } } // Sequences at or below the compaction point are spent even when the log holds none of them. highest = Math.Max(highest, compacted); if (hole > 0) { _burnedThrough[shard.ShardId] = hole; } if (highest > 0) { _nextLogSequences[shard.ShardId] = highest + 1; } } } private static (string? SchemaId, string? RecordKey) KeyOf(CultNetShardLogEntryMessage entry) { if (entry.Put?.Document != null) { return (entry.Put.Document.SchemaId, entry.Put.Document.RecordKey); } if (entry.Delete != null) { return (entry.Delete.SchemaId, entry.Delete.RecordKey); } return (null, null); } private async Task ApplyCommittedShardLogEntryAsync( CultNetShardDescriptor shard, CultNetShardLogEntryMessage entry) { if (entry.Put != null) { await ApplyCommittedPutAsync(shard, entry).ConfigureAwait(false); return; } if (entry.Delete != null) { ApplyCommittedDelete(shard, entry); return; } throw new InvalidOperationException( $"Shard '{shard.ShardId}' log entry {entry.Sequence} has no put or delete payload."); } private async Task ApplyCommittedPutAsync( CultNetShardDescriptor shard, CultNetShardLogEntryMessage entry) { var message = entry.Put!; if (message.Document == null) { throw new InvalidOperationException( $"Shard '{shard.ShardId}' log entry {entry.Sequence} put is missing its document payload."); } if (!string.Equals(message.ShardId, shard.ShardId, StringComparison.Ordinal) || message.ShardEpoch != shard.Epoch) { throw new CultNetShardAuthorityException( shard, $"Shard '{shard.ShardId}' log entry {entry.Sequence} targets a different shard or epoch.", "stale_epoch"); } var key = new CultRecordKey(message.Document.RecordKey); var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document); var kind = ChangeKindFromWire(entry.ChangeKind); if (kind == CultNetDatabaseChangeKind.Removed) { throw new InvalidOperationException( $"Shard '{shard.ShardId}' log entry {entry.Sequence} has a removed kind with a put payload."); } // The replica's log records the primary's own entry under the primary's own sequence, in the same hold that // admits the change; the journal does both. A retry after a refused append is a fresh instance, so it matches // its door again and re-records the entry. if (!shard.Matches(descriptor, key)) { RecordUnappliedEntry(shard, kind, descriptor.SchemaId, key, entry); return; } var document = _documents.DeserializeRawDocument(message.Document); using (Door(document, new DoorContext(shard) { Kind = kind, Replica = entry, PredictionKey = PredictionKey(descriptor.SchemaId, key) })) await _cache.UpsertAsync(descriptor.DocumentType, document, key).ConfigureAwait(false); } private void ApplyCommittedDelete( CultNetShardDescriptor shard, CultNetShardLogEntryMessage entry) { var message = entry.Delete!; if (!string.Equals(message.ShardId, shard.ShardId, StringComparison.Ordinal) || message.ShardEpoch != shard.Epoch) { throw new CultNetShardAuthorityException( shard, $"Shard '{shard.ShardId}' log entry {entry.Sequence} targets a different shard or epoch.", "stale_epoch"); } var key = new CultRecordKey(message.RecordKey); var descriptor = _cache.Registry.GetRequiredBySchemaId(message.SchemaId); if (!shard.Matches(descriptor, key)) { RecordUnappliedEntry(shard, CultNetDatabaseChangeKind.Removed, descriptor.SchemaId, key, entry); return; } var admitted = false; var previous = _cache.Get(key); if (previous != null) { using var door = Door(previous, new DoorContext(shard) { Kind = CultNetDatabaseChangeKind.Removed, Replica = entry, Removal = true }); _cache.Remove(key); admitted = door.Consumed; } if (!admitted) { // The key is not here, so nothing was admitted to log. RecordUnappliedEntry(shard, CultNetDatabaseChangeKind.Removed, descriptor.SchemaId, key, entry); } } // An entry that changed nothing here (an absent key, or a row this shard does not own - the snapshot path leaves such // rows out too). This replica's log must still hold every entry of the primary's, or a replica chained on it sees a gap. private void RecordUnappliedEntry( CultNetShardDescriptor shard, CultNetDatabaseChangeKind kind, string schemaId, CultRecordKey key, CultNetShardLogEntryMessage entry) { lock (_logGate) { RecordMutationLogEntry(new CultNetShardMutationLogEntry( shard.ShardId, shard.Epoch, entry.Sequence, entry.CommittedAt, kind, schemaId, key, document: null, previousDocument: null) { Relayed = entry }, entry); _lastWriteSequence[(schemaId, key.Value)] = entry.Sequence; } } private CultNetShardLogEntryMessage ToLogEntryMessage(CultNetShardMutationLogEntry entry) { if (entry.Relayed != null) { return NormalizeWireLogEntry(entry, entry.Relayed); } if (entry.Kind == CultNetDatabaseChangeKind.Removed || entry.Document == null) { return new CultNetShardLogEntryMessage { Sequence = entry.Sequence, CommittedAt = entry.CommittedAt, ChangeKind = "removed", Delete = new CultNetDocumentDeleteMessage { MessageId = Guid.NewGuid().ToString("N"), SchemaId = entry.SchemaId, RecordKey = entry.Key.Value, ShardId = entry.ShardId, ShardEpoch = entry.ShardEpoch } }; } return new CultNetShardLogEntryMessage { Sequence = entry.Sequence, CommittedAt = entry.CommittedAt, ChangeKind = entry.Kind == CultNetDatabaseChangeKind.Added ? "added" : "updated", Put = new CultNetDocumentPutRawMessage { MessageId = Guid.NewGuid().ToString("N"), Document = CreateRawDocumentRecord(entry.Key, entry.Document), ShardId = entry.ShardId, ShardEpoch = entry.ShardEpoch } }; } private CultNetShardLogEntryMessage NormalizeWireLogEntry( CultNetShardMutationLogEntry entry, CultNetShardLogEntryMessage wireEntry) { return new CultNetShardLogEntryMessage { Sequence = entry.Sequence, CommittedAt = string.IsNullOrWhiteSpace(entry.CommittedAt) ? wireEntry.CommittedAt : entry.CommittedAt, ChangeKind = entry.Kind == CultNetDatabaseChangeKind.Removed ? "removed" : entry.Kind == CultNetDatabaseChangeKind.Added ? "added" : "updated", Put = wireEntry.Put == null ? null : new CultNetDocumentPutRawMessage { MessageId = string.IsNullOrWhiteSpace(wireEntry.Put.MessageId) ? Guid.NewGuid().ToString("N") : wireEntry.Put.MessageId, Document = wireEntry.Put.Document, ShardId = entry.ShardId, ShardEpoch = entry.ShardEpoch }, Delete = wireEntry.Delete == null ? null : new CultNetDocumentDeleteMessage { MessageId = string.IsNullOrWhiteSpace(wireEntry.Delete.MessageId) ? Guid.NewGuid().ToString("N") : wireEntry.Delete.MessageId, SchemaId = wireEntry.Delete.SchemaId, RecordKey = wireEntry.Delete.RecordKey, ShardId = entry.ShardId, ShardEpoch = entry.ShardEpoch } }; } private CultNetRawDocumentRecord CreateRawDocumentRecord(CultRecordKey key, object document) { var method = typeof(CultNetDocumentRegistry) .GetMethods(BindingFlags.Public | BindingFlags.Instance) .Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) && candidate.IsGenericMethodDefinition); var documentType = document.GetType(); var handleType = typeof(CultRecordHandle<>).MakeGenericType(documentType); var handle = Activator.CreateInstance(handleType, new object[] { key }); var message = method .MakeGenericMethod(documentType) .Invoke(_documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null }); return ((CultNetDocumentPutRawMessage)message!).Document; } private static CultNetDatabaseChangeKind ChangeKindFromWire(string changeKind) { return changeKind switch { "added" => CultNetDatabaseChangeKind.Added, "updated" => CultNetDatabaseChangeKind.Updated, "removed" => CultNetDatabaseChangeKind.Removed, _ => throw new InvalidOperationException($"Unsupported shard log change kind '{changeKind}'.") }; } private void EnsureClientAuthority(CultDocumentDescriptor descriptor, CultRecordKey key) { if (_clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, descriptor, key))) { return; } var shard = ResolveShardInternal(descriptor, key); throw new CultNetShardAuthorityException( shard, $"Runtime '{_runtimeId}' does not have client prediction authority for schema '{descriptor.SchemaId}' key '{key.Value}'.", "not_client_authority"); } private static string PredictionKey(string schemaId, CultRecordKey key) { return $"{schemaId}:{key.Value}"; } private void PublishUntyped( Type documentType, CultNetDatabaseChangeKind kind, CultRecordKey key, string schemaId, CultNetShardDescriptor shard, object? document, object? previousDocument) { var changeType = typeof(CultNetDatabaseChange<>).MakeGenericType(documentType); var change = Activator.CreateInstance( changeType, kind, key, schemaId, shard, document, previousDocument, null); if (change != null) { _changes.OnNext(change); } } // No shard owns a schema and key that no descriptor matches, and nothing is written to one: every write path refuses. private CultNetShardDescriptor ResolveShardInternal(string schemaId, CultRecordKey key) => _shards.FirstOrDefault(shard => shard.Matches(schemaId, key)) ?? throw new CultNetUnownedSchemaException(schemaId, key); private CultNetShardDescriptor ResolveShardInternal(CultDocumentDescriptor descriptor, CultRecordKey key) => FindShard(descriptor, key) ?? throw new CultNetUnownedSchemaException(descriptor.SchemaId, key); internal bool Owns(CultDocumentDescriptor descriptor, CultRecordKey key) => FindShard(descriptor, key) != null; // Whether the database serves this cached instance: a shard owns its schema and key. A shard that owns everything makes // the lookup of the instance's key unnecessary. private bool Serves(object document) { if (_shards.Any(shard => shard.SchemaIds.Count == 0 && string.IsNullOrEmpty(shard.KeyPrefix))) { return true; } var type = document.GetType(); return Owns(_cache.Registry.GetRequired(type), GetTrackedKey(document, type)); } private CultNetShardDescriptor? FindShard(CultDocumentDescriptor descriptor, CultRecordKey key) => _shards.FirstOrDefault(shard => shard.Matches(descriptor, key)); private static void EnsurePrimary(CultNetShardDescriptor shard, string schemaId, CultRecordKey key, long? expectedEpoch = null) { if (expectedEpoch.HasValue && expectedEpoch.Value != shard.Epoch) { throw new CultNetShardAuthorityException( shard, $"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not requested epoch {expectedEpoch.Value}.", "stale_epoch"); } if (shard.IsPrimary) { return; } throw new CultNetShardAuthorityException( shard, $"Shard '{shard.ShardId}' owned by '{shard.OwnerRuntimeId}' does not accept local writes for schema '{schemaId}' key '{key.Value}'.", "not_primary"); } private bool MatchesCatalogFilter( CultNetShardDescriptor shard, IReadOnlyList? schemaIds, IReadOnlyList? recordKeys) { var schemaMatches = SchemaMatchesCatalogFilter(shard, schemaIds); var keyMatches = recordKeys == null || recordKeys.Count == 0 || recordKeys.Any(key => string.IsNullOrEmpty(shard.KeyPrefix) || key.Value.StartsWith(shard.KeyPrefix!, StringComparison.Ordinal)); return schemaMatches && keyMatches; } private bool SchemaMatchesCatalogFilter( CultNetShardDescriptor shard, IReadOnlyList? schemaIds) { if (schemaIds == null || schemaIds.Count == 0 || shard.SchemaIds.Count == 0) return true; var requestedSchemaIds = schemaIds.ToHashSet(StringComparer.Ordinal); if (shard.SchemaIds.Any(requestedSchemaIds.Contains)) return true; foreach (var shardSchemaId in shard.SchemaIds) { try { if (CultNetSchemaAliasMatching.MatchesAny(schemaIds, _cache.Registry.GetRequiredBySchemaId(shardSchemaId))) return true; } catch (InvalidOperationException) { // Shards can advertise schemas that are not loaded in this process. } } return false; } internal static CultNetShardDescriptorMessage ToMessage(CultNetShardDescriptor shard) { return new CultNetShardDescriptorMessage { ShardId = shard.ShardId, OwnerRuntimeId = shard.OwnerRuntimeId, Epoch = shard.Epoch, IsPrimary = shard.IsPrimary, SchemaIds = shard.SchemaIds.ToArray(), KeyPrefix = shard.KeyPrefix, PrimaryEndpoints = shard.PrimaryEndpoints.ToArray(), ReplicaEndpoints = shard.ReplicaEndpoints.ToArray(), ReadReplicaEndpoints = shard.ReadReplicaEndpoints.ToArray(), Region = shard.Region }; } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(CultNetDatabase)); } } } }