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