using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
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
}
internal 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;
}
}
///
/// 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; }
}
///
/// 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; }
}
///
/// 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; }
}
///
/// 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;
private readonly Dictionary _predictedDocuments = new(StringComparer.Ordinal);
private readonly Dictionary> _mutationLogs =
new(StringComparer.Ordinal);
private readonly Dictionary _nextLogSequences = new(StringComparer.Ordinal);
private readonly Dictionary _appliedShardSequences = new(StringComparer.Ordinal);
private readonly Subject