F1: RecordUnappliedEntry keeps the primary's wire entry on the in-memory log entry (internal Relayed) and ToLogEntryMessage returns it through the same normalisation the store uses, so a replica with no store no longer relays a skipped put as a delete; memory and disk relay identically. Soul's 4d probes are committed (chained replica keeps the row, key-prefixed reads serve only owned keys, primary-all serves everything; the two measurements are explicit) with a memory/disk relay-parity test. Rust: captured-bytes cases for unowned_schema and variant_unsupported (with and without a subscription). The error-code set is stated as a closed enum in the schema description (both copies) and in cultnet-distributed-database.md: adding a code is one coordinated change across runtimes, since Rust and TypeScript refuse an unknown code. The Soul 4c probe file's header says what it guards. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2089 lines
90 KiB
C#
2089 lines
90 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Describes the kind of domain change observed through a distributed CultCache surface.
|
|
/// </summary>
|
|
public enum CultNetDatabaseChangeKind
|
|
{
|
|
/// <summary>
|
|
/// A document was added.
|
|
/// </summary>
|
|
Added,
|
|
/// <summary>
|
|
/// A document was updated.
|
|
/// </summary>
|
|
Updated,
|
|
/// <summary>
|
|
/// A document was removed.
|
|
/// </summary>
|
|
Removed,
|
|
/// <summary>
|
|
/// A document was accepted through compatible schema migration.
|
|
/// </summary>
|
|
SchemaMigrated,
|
|
/// <summary>
|
|
/// A document change was rejected.
|
|
/// </summary>
|
|
Rejected,
|
|
/// <summary>
|
|
/// A document was applied locally before authoritative commit.
|
|
/// </summary>
|
|
Predicted,
|
|
/// <summary>
|
|
/// A predicted document was replaced by the authoritative committed state.
|
|
/// </summary>
|
|
Reconciled
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one schema-alias matcher for every runtime-side schema match (docs/cultnet-selection-cut.md, D4).
|
|
/// </summary>
|
|
public static class CultNetSchemaAliasMatching
|
|
{
|
|
public static bool MatchesAny(IReadOnlyList<string> candidates, string schemaId)
|
|
{
|
|
return candidates.Count == 0 ||
|
|
candidates.Any(candidate => Matches(candidate, schemaId));
|
|
}
|
|
|
|
public static bool MatchesAny(IReadOnlyList<string> 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.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Declares which documents this runtime may predict locally before authoritative commit.
|
|
/// </summary>
|
|
public sealed class CultNetClientAuthorityScope
|
|
{
|
|
/// <summary>
|
|
/// Creates a client authority scope.
|
|
/// </summary>
|
|
public CultNetClientAuthorityScope(
|
|
string ownerRuntimeId,
|
|
IEnumerable<string>? 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<string>();
|
|
KeyPrefix = keyPrefix;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the runtime that owns authoring for this scope.
|
|
/// </summary>
|
|
public string OwnerRuntimeId { get; }
|
|
/// <summary>
|
|
/// Gets the schema ids governed by this scope. Empty means all schemas.
|
|
/// </summary>
|
|
public IReadOnlyList<string> SchemaIds { get; }
|
|
/// <summary>
|
|
/// Gets the optional record-key prefix governed by this scope.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes one shard owned or observed by a CultNet database surface.
|
|
/// </summary>
|
|
public sealed class CultNetShardDescriptor
|
|
{
|
|
/// <summary>
|
|
/// Creates a shard descriptor.
|
|
/// </summary>
|
|
public CultNetShardDescriptor(
|
|
string shardId,
|
|
string ownerRuntimeId,
|
|
long epoch,
|
|
bool isPrimary,
|
|
IEnumerable<string>? schemaIds = null,
|
|
string? keyPrefix = null,
|
|
IEnumerable<string>? primaryEndpoints = null,
|
|
IEnumerable<string>? replicaEndpoints = null,
|
|
IEnumerable<string>? 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<string>();
|
|
KeyPrefix = keyPrefix;
|
|
PrimaryEndpoints = CleanEndpoints(primaryEndpoints);
|
|
ReplicaEndpoints = CleanEndpoints(replicaEndpoints);
|
|
ReadReplicaEndpoints = CleanEndpoints(readReplicaEndpoints);
|
|
Region = region;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the stable shard identifier.
|
|
/// </summary>
|
|
public string ShardId { get; }
|
|
/// <summary>
|
|
/// Gets the runtime that currently owns writes for this shard.
|
|
/// </summary>
|
|
public string OwnerRuntimeId { get; }
|
|
/// <summary>
|
|
/// Gets the monotonic shard authority epoch.
|
|
/// </summary>
|
|
public long Epoch { get; }
|
|
/// <summary>
|
|
/// Gets whether the local database instance is authoritative for writes to this shard.
|
|
/// </summary>
|
|
public bool IsPrimary { get; }
|
|
/// <summary>
|
|
/// Gets the schema ids governed by this shard. Empty means all schemas.
|
|
/// </summary>
|
|
public IReadOnlyList<string> SchemaIds { get; }
|
|
/// <summary>
|
|
/// Gets the optional record-key prefix governed by this shard.
|
|
/// </summary>
|
|
public string? KeyPrefix { get; }
|
|
/// <summary>
|
|
/// Gets endpoints that can accept authoritative writes for this shard.
|
|
/// </summary>
|
|
public IReadOnlyList<string> PrimaryEndpoints { get; }
|
|
/// <summary>
|
|
/// Gets endpoints that replicate authoritative shard mutations.
|
|
/// </summary>
|
|
public IReadOnlyList<string> ReplicaEndpoints { get; }
|
|
/// <summary>
|
|
/// Gets endpoints intended for low-latency read and subscription traffic.
|
|
/// </summary>
|
|
public IReadOnlyList<string> ReadReplicaEndpoints { get; }
|
|
/// <summary>
|
|
/// Gets an optional locality label for this shard owner.
|
|
/// </summary>
|
|
public string? Region { get; }
|
|
|
|
/// <summary>
|
|
/// Creates a primary shard that accepts every schema and key.
|
|
/// </summary>
|
|
public static CultNetShardDescriptor PrimaryAll(string ownerRuntimeId = "local", string shardId = "primary")
|
|
{
|
|
return new CultNetShardDescriptor(shardId, ownerRuntimeId, epoch: 1, isPrimary: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a read-only shard descriptor.
|
|
/// </summary>
|
|
public static CultNetShardDescriptor ReadOnly(
|
|
string shardId,
|
|
string ownerRuntimeId,
|
|
long epoch = 1,
|
|
IEnumerable<string>? 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<string>? endpoints)
|
|
{
|
|
return endpoints?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
|
|
?? Array.Empty<string>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Options for creating a distributed CultCache database surface.
|
|
/// </summary>
|
|
public sealed class CultNetDatabaseOptions
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the local runtime id advertised as shard owner for default primary shards.
|
|
/// </summary>
|
|
public string RuntimeId { get; set; } = "local";
|
|
/// <summary>
|
|
/// Gets or sets explicit shard descriptors. When omitted, this instance owns one primary shard for all records.
|
|
/// </summary>
|
|
public IReadOnlyList<CultNetShardDescriptor>? Shards { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets document scopes this runtime may predict before authoritative commit.
|
|
/// </summary>
|
|
public IReadOnlyList<CultNetClientAuthorityScope>? ClientAuthorityScopes { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets the document registry used to create and apply raw CultNet document messages.
|
|
/// </summary>
|
|
public CultNetDocumentRegistry? DocumentRegistry { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets the durable store for accepted shard mutation logs.
|
|
/// </summary>
|
|
public ICultNetShardMutationLogStore? MutationLogStore { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets the process cursor key (R-O). Omitted in production, where a fresh random key
|
|
/// per <see cref="CultNetDatabase"/> 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.
|
|
/// </summary>
|
|
public CultNetSelectionCursorKey? CursorKey { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A typed domain change emitted by a CultNet database surface.
|
|
/// </summary>
|
|
public sealed class CultNetDatabaseChange<T> where T : class
|
|
{
|
|
/// <summary>
|
|
/// Creates a database change.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the change kind.
|
|
/// </summary>
|
|
public CultNetDatabaseChangeKind Kind { get; }
|
|
/// <summary>
|
|
/// Gets the changed record key.
|
|
/// </summary>
|
|
public CultRecordKey Key { get; }
|
|
/// <summary>
|
|
/// Gets the schema id of the changed document.
|
|
/// </summary>
|
|
public string SchemaId { get; }
|
|
/// <summary>
|
|
/// Gets the shard that accepted or rejected the change.
|
|
/// </summary>
|
|
public CultNetShardDescriptor Shard { get; }
|
|
/// <summary>
|
|
/// Gets the current document for added and updated events.
|
|
/// </summary>
|
|
public T? Document { get; }
|
|
/// <summary>
|
|
/// Gets the previous document for updated and removed events.
|
|
/// </summary>
|
|
public T? PreviousDocument { get; }
|
|
/// <summary>
|
|
/// Gets an optional diagnostic message.
|
|
/// </summary>
|
|
public string? Message { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// One ordered mutation accepted for a shard.
|
|
/// </summary>
|
|
public sealed class CultNetShardMutationLogEntry
|
|
{
|
|
/// <summary>
|
|
/// Creates a shard mutation log entry.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the shard id.
|
|
/// </summary>
|
|
public string ShardId { get; }
|
|
/// <summary>
|
|
/// Gets the shard epoch.
|
|
/// </summary>
|
|
public long ShardEpoch { get; }
|
|
/// <summary>
|
|
/// Gets the per-shard mutation sequence.
|
|
/// </summary>
|
|
public long Sequence { get; }
|
|
/// <summary>
|
|
/// Gets the commit timestamp.
|
|
/// </summary>
|
|
public string CommittedAt { get; }
|
|
/// <summary>
|
|
/// Gets the mutation kind.
|
|
/// </summary>
|
|
public CultNetDatabaseChangeKind Kind { get; }
|
|
/// <summary>
|
|
/// Gets the schema id.
|
|
/// </summary>
|
|
public string SchemaId { get; }
|
|
/// <summary>
|
|
/// Gets the record key.
|
|
/// </summary>
|
|
public CultRecordKey Key { get; }
|
|
/// <summary>
|
|
/// Gets the current document, when present.
|
|
/// </summary>
|
|
public object? Document { get; }
|
|
/// <summary>
|
|
/// Gets the previous document, when present.
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raised when a database write targets a shard this node does not own.
|
|
/// </summary>
|
|
public sealed class CultNetShardAuthorityException : InvalidOperationException
|
|
{
|
|
/// <summary>
|
|
/// Creates a shard authority exception.
|
|
/// </summary>
|
|
public CultNetShardAuthorityException(CultNetShardDescriptor shard, string message, string reason = "not_primary") : base(message)
|
|
{
|
|
Shard = shard;
|
|
Reason = reason;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the shard that rejected the write.
|
|
/// </summary>
|
|
public CultNetShardDescriptor Shard { get; }
|
|
/// <summary>
|
|
/// Gets the machine-readable authority rejection reason.
|
|
/// </summary>
|
|
public string Reason { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// One shard-log sequence that a primary minted for a committed change and burned because it could not log it.
|
|
/// </summary>
|
|
public readonly struct CultNetBurnedSequence : IEquatable<CultNetBurnedSequence>
|
|
{
|
|
/// <summary>
|
|
/// Creates a burned sequence.
|
|
/// </summary>
|
|
public CultNetBurnedSequence(string shardId, long sequence)
|
|
{
|
|
ShardId = shardId;
|
|
Sequence = sequence;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the shard whose log burned the sequence.
|
|
/// </summary>
|
|
public string ShardId { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the burned shard-log sequence.
|
|
/// </summary>
|
|
public long Sequence { get; }
|
|
|
|
/// <inheritdoc />
|
|
public bool Equals(CultNetBurnedSequence other) => Sequence == other.Sequence && string.Equals(ShardId, other.ShardId, StringComparison.Ordinal);
|
|
|
|
/// <inheritdoc />
|
|
public override bool Equals(object? obj) => obj is CultNetBurnedSequence other && Equals(other);
|
|
|
|
/// <inheritdoc />
|
|
public override int GetHashCode() => (StringComparer.Ordinal.GetHashCode(ShardId ?? string.Empty) * 397) ^ Sequence.GetHashCode();
|
|
|
|
/// <inheritdoc />
|
|
public override string ToString() => $"{ShardId}:{Sequence}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raised when a database write, or a resolution, names a schema and key that no configured shard owns. A refused write
|
|
/// caches nothing.
|
|
/// </summary>
|
|
public sealed class CultNetUnownedSchemaException : InvalidOperationException
|
|
{
|
|
/// <summary>
|
|
/// Creates an unowned schema exception.
|
|
/// </summary>
|
|
public CultNetUnownedSchemaException(string schemaId, CultRecordKey key)
|
|
: base($"No shard owns schema '{schemaId}' key '{key.Value}'; the write was refused.")
|
|
{
|
|
SchemaId = schemaId;
|
|
Key = key;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the schema that no shard owns.
|
|
/// </summary>
|
|
public string SchemaId { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the key of the refused write.
|
|
/// </summary>
|
|
public CultRecordKey Key { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="Burned"/> 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
|
|
/// <see cref="InvalidOperationException"/>: callers that read that type as a refused write must not mistake a
|
|
/// committed one for it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The cache may still wrap this in an <see cref="AggregateException"/> 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
|
|
/// <see cref="AggregateException.InnerExceptions"/> and looks for it. <see cref="Exception.InnerException"/> is the
|
|
/// store's failure, or an <see cref="AggregateException"/> of them when several changes failed.
|
|
/// </remarks>
|
|
public sealed class CultNetShardLogException : Exception
|
|
{
|
|
/// <summary>
|
|
/// Creates a shard log exception.
|
|
/// </summary>
|
|
public CultNetShardLogException(IReadOnlyList<CultNetBurnedSequence> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets every sequence burned by the journal call, in commit order.
|
|
/// </summary>
|
|
public IReadOnlyList<CultNetBurnedSequence> Burned { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Database-style CultNet facade over a CultCache shard set.
|
|
/// </summary>
|
|
public sealed class CultNetDatabase : IDisposable
|
|
{
|
|
private readonly CultCache _cache;
|
|
private readonly CultNetDocumentRegistry _documents;
|
|
private readonly string _runtimeId;
|
|
private readonly List<CultNetShardDescriptor> _shards;
|
|
private readonly List<CultNetClientAuthorityScope> _clientAuthorityScopes;
|
|
private readonly ICultNetShardMutationLogStore? _mutationLogStore;
|
|
// Keyed by sequence, like the durable store: recording an entry twice replaces it.
|
|
private readonly Dictionary<string, SortedList<long, CultNetShardMutationLogEntry>> _mutationLogs =
|
|
new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, long> _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<string, long> _burnedThrough = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, long> _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<object> _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<object, DoorContext> _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<long, Publication> _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<string> _predictedDocuments = new(StringComparer.Ordinal);
|
|
private volatile bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Creates a database surface over a CultCache instance.
|
|
/// </summary>
|
|
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<CultNetClientAuthorityScope>()).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<object>().Subscribe(Publish);
|
|
_cacheJournal = _cache.AddJournal(Journal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// This process's cursor key (R-O): random per instance unless <see cref="CultNetDatabaseOptions.CursorKey"/>
|
|
/// supplies one, so a page minted by this database cannot be answered by a differently-keyed one,
|
|
/// and a fresh <see cref="CultNetDatabase"/> - 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.
|
|
/// </summary>
|
|
public CultNetSelectionCursorKey CursorKey { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the local cache backing this database surface.
|
|
/// </summary>
|
|
public CultCache Cache => _cache;
|
|
/// <summary>
|
|
/// Gets the document registry used by the raw CultNet lane.
|
|
/// </summary>
|
|
public CultNetDocumentRegistry Documents => _documents;
|
|
/// <summary>
|
|
/// Gets the shard map known to this database surface.
|
|
/// </summary>
|
|
public IReadOnlyList<CultNetShardDescriptor> Shards => _shards;
|
|
|
|
/// <summary>
|
|
/// Gets the document scopes this runtime may predict before authoritative commit.
|
|
/// </summary>
|
|
public IReadOnlyList<CultNetClientAuthorityScope> ClientAuthorityScopes => _clientAuthorityScopes;
|
|
|
|
/// <summary>Gets whether this database instance can authoritatively replace the document at a key.</summary>
|
|
public bool CanWriteAuthoritatively<T>(CultRecordKey key) where T : class
|
|
{
|
|
ThrowIfDisposed();
|
|
var descriptor = _cache.Registry.GetRequired<T>();
|
|
return FindShard(descriptor, key)?.IsPrimary ?? false;
|
|
}
|
|
|
|
/// <summary>Gets whether this database instance can submit a prediction for the document at a key.</summary>
|
|
public bool CanSubmitPrediction<T>(CultRecordKey key) where T : class
|
|
{
|
|
ThrowIfDisposed();
|
|
var descriptor = _cache.Registry.GetRequired<T>();
|
|
return _clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, descriptor, key));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets mutation log entries for a shard after the supplied sequence.
|
|
/// </summary>
|
|
public IReadOnlyList<CultNetShardMutationLogEntry> 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<CultNetShardMutationLogEntry>();
|
|
}
|
|
|
|
var query = entries.Values.Where(entry => entry.Sequence > afterSequence);
|
|
if (limit.HasValue)
|
|
{
|
|
query = query.Take(limit.Value);
|
|
}
|
|
|
|
return query.ToArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets replica catch-up log entries for a shard after the supplied sequence.
|
|
/// </summary>
|
|
public IReadOnlyList<CultNetShardLogEntryMessage> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the highest retained-log sequence that has been compacted away for a shard.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the latest known shard-log sequence for a shard.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a raw document snapshot bounded to one shard. Answers through the one evaluator
|
|
/// (<see cref="CultNetSelectionEvaluator"/>, via <see cref="CultNetDocumentRegistry.SelectPage"/>),
|
|
/// 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).
|
|
/// </summary>
|
|
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<CultNetRawDocumentRecord>(),
|
|
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<CultNetRawDocumentRecord>(),
|
|
ShardId = shard.ShardId,
|
|
ShardEpoch = shard.Epoch,
|
|
ShardLogSequence = logSequence
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a shard-bounded snapshot and advances the replica cursor to its represented sequence.
|
|
/// </summary>
|
|
public async Task<long> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the last replicated sequence applied for a shard.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a shard catalog response for optional schema/key filters.
|
|
/// </summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the shard that governs the supplied schema and key.
|
|
/// </summary>
|
|
/// <exception cref="CultNetUnownedSchemaException">No configured shard owns the schema and key.</exception>
|
|
public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key)
|
|
{
|
|
ThrowIfDisposed();
|
|
return ResolveShardInternal(schemaId, key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a document by key.
|
|
/// </summary>
|
|
public Task<T?> GetAsync<T>(CultRecordKey key) where T : class
|
|
{
|
|
ThrowIfDisposed();
|
|
var document = _cache.Get<T>(key);
|
|
return Task.FromResult(document != null && Owns(_cache.Registry.GetRequired(document.GetType()), key) ? document : null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public IEnumerable<T> GetAll<T>() where T : class
|
|
{
|
|
ThrowIfDisposed();
|
|
return _cache.GetAll<T>().Where(Serves);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a typed document by its CultName value.
|
|
/// </summary>
|
|
public T? GetByName<T>(string name) where T : class
|
|
{
|
|
ThrowIfDisposed();
|
|
var document = _cache.GetByName<T>(name);
|
|
return document != null && Serves(document) ? document : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a typed document by an indexed value.
|
|
/// </summary>
|
|
public T? GetByIndex<T>(string alias, string value) where T : class
|
|
{
|
|
ThrowIfDisposed();
|
|
var document = _cache.GetByIndex<T>(alias, value);
|
|
return document != null && Serves(document) ? document : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds or replaces a document at a specific key.
|
|
/// </summary>
|
|
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
|
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
|
public async Task<CultRecordHandle<T>> PutAsync<T>(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<T>(key)).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a locally predicted document for a client-owned input scope.
|
|
/// </summary>
|
|
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
|
public async Task<CultRecordHandle<T>> PutPredictedAsync<T>(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<T> 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<T>(key)).ConfigureAwait(false);
|
|
return handle;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a document by key.
|
|
/// </summary>
|
|
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
|
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
|
public Task DeleteAsync<T>(CultRecordKey key) where T : class
|
|
{
|
|
ThrowIfDisposed();
|
|
var descriptor = _cache.Registry.GetRequired<T>();
|
|
EnsurePrimary(ResolveShardInternal(descriptor, key), descriptor.SchemaId, key);
|
|
|
|
_cache.Remove(new CultRecordHandle<T>(key));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a raw document mutation after checking shard authority.
|
|
/// </summary>
|
|
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
|
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
|
public async Task<object> ApplyPutAsync(CultNetDocumentPutRawMessage message)
|
|
{
|
|
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);
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a raw document delete after checking shard authority.
|
|
/// </summary>
|
|
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
|
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
|
public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message)
|
|
{
|
|
ThrowIfDisposed();
|
|
if (message == null) throw new ArgumentNullException(nameof(message));
|
|
|
|
var key = new CultRecordKey(message.RecordKey);
|
|
var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId);
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies committed shard-log entries from an authoritative peer.
|
|
/// </summary>
|
|
public async Task<long> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Watches all changes assignable to the requested document type.
|
|
/// </summary>
|
|
public Observable<CultNetDatabaseChange<T>> Watch<T>() where T : class
|
|
{
|
|
ThrowIfDisposed();
|
|
return _changes
|
|
.Where(change => change is CultNetDatabaseChange<T>)
|
|
.Select(change => (CultNetDatabaseChange<T>)change);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Watches all typed domain changes as boxed change objects.
|
|
/// </summary>
|
|
public Observable<object> WatchAllChanges()
|
|
{
|
|
ThrowIfDisposed();
|
|
return _changes;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Watches one record.
|
|
/// </summary>
|
|
public Observable<CultNetDatabaseChange<T>> WatchRecord<T>(CultRecordKey key) where T : class
|
|
{
|
|
return Watch<T>().Where(change => change.Key.Equals(key));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Watches the global record for a document type.
|
|
/// </summary>
|
|
public Observable<CultNetDatabaseChange<T>> WatchGlobal<T>() where T : class
|
|
{
|
|
var descriptor = _cache.Registry.GetRequired<T>();
|
|
return WatchRecord<T>(new CultRecordKey($"global:{descriptor.SchemaId}"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Watches changes for a named document.
|
|
/// </summary>
|
|
public Observable<CultNetDatabaseChange<T>> WatchByName<T>(string name) where T : class
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name));
|
|
return Watch<T>().Where(change =>
|
|
(change.Document != null && ReferenceEquals(_cache.GetByName<T>(name), change.Document)) ||
|
|
(change.PreviousDocument != null && ReferenceEquals(_cache.GetByName<T>(name), change.PreviousDocument)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Watches changes for the current document mapped by an indexed value.
|
|
/// </summary>
|
|
public Observable<CultNetDatabaseChange<T>> WatchByIndex<T>(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<T>().Where(change =>
|
|
(change.Document != null && ReferenceEquals(_cache.GetByIndex<T>(alias, value), change.Document)) ||
|
|
(change.PreviousDocument != null && ReferenceEquals(_cache.GetByIndex<T>(alias, value), change.PreviousDocument)));
|
|
}
|
|
/// <summary>
|
|
/// Releases database subscriptions.
|
|
/// </summary>
|
|
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<CultCacheDocumentChange<object>> changes)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
List<Exception>? failures = null;
|
|
List<CultNetBurnedSequence>? burned = null;
|
|
List<Exception>? 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<CultNetBurnedSequence>()).Add(new CultNetBurnedSequence(shard.ShardId, burn.Sequence));
|
|
(burnCauses ??= new List<Exception>()).Add(burn.Cause);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
(failures ??= new List<Exception>()).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<Exception>()).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<object> 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<object> 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<object> 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<object> 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<KeyValuePair<object, DoorContext>>)_doors)
|
|
.Remove(new KeyValuePair<object, DoorContext>(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<object, DoorContext> _doors;
|
|
private readonly object _identity;
|
|
private readonly DoorContext _context;
|
|
private readonly bool _owned;
|
|
|
|
public DoorRegistration(ConcurrentDictionary<object, DoorContext> 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<KeyValuePair<object, DoorContext>>)_doors).Remove(new KeyValuePair<object, DoorContext>(_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<object>
|
|
{
|
|
public static readonly ReferenceIdentity Instance = new();
|
|
|
|
bool IEqualityComparer<object>.Equals(object? x, object? y) => ReferenceEquals(x, y);
|
|
|
|
int IEqualityComparer<object>.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<TrackedShardDocument> 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; }
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public long? LastWriteSequence(string schemaId, CultRecordKey key)
|
|
{
|
|
lock (_logGate)
|
|
return _lastWriteSequence.TryGetValue((schemaId, key.Value), out var sequence) ? sequence : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>asOf</c> must use <see cref="CurrentAsOf(string)"/> instead
|
|
/// (S-9): the shard-log sequence is a per-shard counter (<see cref="NextMutationLogSequence"/>),
|
|
/// 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.
|
|
/// </summary>
|
|
public ulong CurrentAsOf()
|
|
{
|
|
lock (_logGate)
|
|
return _lastWriteSequence.Count == 0 ? 0UL : (ulong)_lastWriteSequence.Values.Max();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>asOf</c> for a v1 snapshot or subscription page whose matched rows all come from one shard
|
|
/// (<see cref="CultNetDocumentRegistry"/>'s single-shard check) - it advances only when that
|
|
/// shard's own log advances, so a cursor minted against it refuses <c>cursor_stale</c> exactly
|
|
/// when that shard's log has moved, never because an unrelated shard elsewhere took a write the
|
|
/// selection never touched.
|
|
/// </summary>
|
|
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<long, CultNetShardMutationLogEntry>();
|
|
_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<string>? schemaIds,
|
|
IReadOnlyList<CultRecordKey>? 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<string>? 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));
|
|
}
|
|
}
|
|
}
|
|
}
|