Add CultNet shard mutation logs
This commit is contained in:
parent
061ffe54bc
commit
65078c54a7
5 changed files with 255 additions and 16 deletions
|
|
@ -164,15 +164,16 @@ Shard catalog exchange now exists:
|
|||
- injectable non-primary write forwarding policy
|
||||
- concrete schema-v0 write forwarder for `cultnet://host:port` primary
|
||||
endpoints
|
||||
- in-memory per-shard mutation logs and catch-up by last seen sequence
|
||||
|
||||
## Next Cut
|
||||
|
||||
Build per-shard replica logs:
|
||||
Build wire-level replica log catch-up:
|
||||
|
||||
- assign sequence numbers to accepted shard mutations
|
||||
- stream log entries to replicas and subscribers
|
||||
- compact logs into snapshots
|
||||
- make missed entries trigger explicit resync
|
||||
- `cultnet.shard_log_request.v0`
|
||||
- `cultnet.shard_log_response.v0`
|
||||
- raw put/delete representation for log entries
|
||||
- explicit resync-required failure when a replica asks for compacted history
|
||||
|
||||
That is the next foundation needed before membership, Raft-style failover, or
|
||||
CRDT policy work can be honest.
|
||||
|
|
|
|||
|
|
@ -47,12 +47,16 @@ Implemented:
|
|||
- injectable non-primary write forwarding policy
|
||||
- schema-v0 write forwarder that dials advertised `cultnet://host:port`
|
||||
primary endpoints
|
||||
- in-memory per-shard mutation logs with sequence numbers, epochs, mutation
|
||||
kind, schema id, key, and document references
|
||||
- mutation-log catch-up by last seen sequence
|
||||
|
||||
Not implemented yet:
|
||||
|
||||
- membership/failure detection
|
||||
- leader election or automatic shard failover
|
||||
- replicated log
|
||||
- wire-level replica log request/response messages
|
||||
- durable log persistence and compaction
|
||||
- declared CRDT merge policies
|
||||
|
||||
## Live Invariants
|
||||
|
|
@ -69,13 +73,15 @@ Not implemented yet:
|
|||
|
||||
## Next Coherent Slice
|
||||
|
||||
Add per-shard replica logs:
|
||||
Add wire-level replica log catch-up:
|
||||
|
||||
- assign monotonically increasing sequence numbers to accepted shard mutations
|
||||
- stream log entries to replicas and subscribers
|
||||
- compact log entries into CultCache snapshots
|
||||
- reject or resync replicas that miss entries
|
||||
- `cultnet.shard_log_request.v0`
|
||||
- `cultnet.shard_log_response.v0`
|
||||
- serialize accepted mutations as raw put/delete records
|
||||
- let replicas ask for entries after a known sequence
|
||||
- return an explicit resync-required error when the requested sequence has
|
||||
already been compacted away
|
||||
|
||||
Forwarding now has both a policy seam and a concrete schema-v0 dialer. The next
|
||||
useful layer is ordered replication. Membership and leader election should wait
|
||||
until the replication log shape is boring.
|
||||
The in-memory log shape now exists. The next useful layer is moving that log
|
||||
over the wire. Membership and leader election should wait until replication
|
||||
catch-up is boring.
|
||||
|
|
|
|||
|
|
@ -229,6 +229,74 @@ namespace GameCult.Networking
|
|||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a database write targets a shard this node does not own.
|
||||
/// </summary>
|
||||
|
|
@ -261,6 +329,9 @@ namespace GameCult.Networking
|
|||
private readonly CultCache _cache;
|
||||
private readonly CultNetDocumentRegistry _documents;
|
||||
private readonly List<CultNetShardDescriptor> _shards;
|
||||
private readonly Dictionary<string, List<CultNetShardMutationLogEntry>> _mutationLogs =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, long> _nextLogSequences = new(StringComparer.Ordinal);
|
||||
private readonly Subject<object> _changes = new();
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -292,6 +363,30 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public IReadOnlyList<CultNetShardDescriptor> Shards => _shards;
|
||||
|
||||
/// <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));
|
||||
if (!_mutationLogs.TryGetValue(shardId, out var entries))
|
||||
{
|
||||
return Array.Empty<CultNetShardMutationLogEntry>();
|
||||
}
|
||||
|
||||
var query = entries.Where(entry => entry.Sequence > afterSequence);
|
||||
if (limit.HasValue)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
return query.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a shard catalog response for optional schema/key filters.
|
||||
/// </summary>
|
||||
|
|
@ -353,6 +448,13 @@ namespace GameCult.Networking
|
|||
|
||||
var previous = _cache.Get<T>(key);
|
||||
var handle = await _cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
|
||||
AppendMutationLogEntry(
|
||||
shard,
|
||||
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
||||
key,
|
||||
descriptor.SchemaId,
|
||||
document,
|
||||
previous);
|
||||
Publish(new CultNetDatabaseChange<T>(
|
||||
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
||||
key,
|
||||
|
|
@ -377,6 +479,13 @@ namespace GameCult.Networking
|
|||
if (previous != null)
|
||||
{
|
||||
_cache.Remove(new CultRecordHandle<T>(key));
|
||||
AppendMutationLogEntry(
|
||||
shard,
|
||||
CultNetDatabaseChangeKind.Removed,
|
||||
key,
|
||||
descriptor.SchemaId,
|
||||
document: null,
|
||||
previousDocument: previous);
|
||||
Publish(new CultNetDatabaseChange<T>(
|
||||
CultNetDatabaseChangeKind.Removed,
|
||||
key,
|
||||
|
|
@ -407,6 +516,13 @@ namespace GameCult.Networking
|
|||
var descriptor = _cache.Registry.GetRequiredBySchemaId(message.Document.SchemaId);
|
||||
var previous = _cache.Get(key);
|
||||
var document = await _documents.ApplyRawDocumentPutMessageAsync(_cache, message).ConfigureAwait(false);
|
||||
AppendMutationLogEntry(
|
||||
shard,
|
||||
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
||||
key,
|
||||
descriptor.SchemaId,
|
||||
document,
|
||||
previous);
|
||||
PublishUntyped(
|
||||
descriptor.DocumentType,
|
||||
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
||||
|
|
@ -441,6 +557,13 @@ namespace GameCult.Networking
|
|||
var handleType = typeof(CultRecordHandle<>).MakeGenericType(descriptor.DocumentType);
|
||||
var handle = Activator.CreateInstance(handleType, new object[] { key });
|
||||
removeMethod.Invoke(_cache, new[] { handle });
|
||||
AppendMutationLogEntry(
|
||||
shard,
|
||||
CultNetDatabaseChangeKind.Removed,
|
||||
key,
|
||||
descriptor.SchemaId,
|
||||
document: null,
|
||||
previousDocument: previous);
|
||||
PublishUntyped(
|
||||
descriptor.DocumentType,
|
||||
CultNetDatabaseChangeKind.Removed,
|
||||
|
|
@ -580,6 +703,39 @@ namespace GameCult.Networking
|
|||
_changes.OnNext(change);
|
||||
}
|
||||
|
||||
private void AppendMutationLogEntry(
|
||||
CultNetShardDescriptor shard,
|
||||
CultNetDatabaseChangeKind kind,
|
||||
CultRecordKey key,
|
||||
string schemaId,
|
||||
object? document,
|
||||
object? previousDocument)
|
||||
{
|
||||
var sequence = _nextLogSequences.TryGetValue(shard.ShardId, out var next)
|
||||
? next
|
||||
: 1;
|
||||
_nextLogSequences[shard.ShardId] = sequence + 1;
|
||||
|
||||
var entry = new CultNetShardMutationLogEntry(
|
||||
shard.ShardId,
|
||||
shard.Epoch,
|
||||
sequence,
|
||||
DateTimeOffset.UtcNow.ToString("O"),
|
||||
kind,
|
||||
schemaId,
|
||||
key,
|
||||
document,
|
||||
previousDocument);
|
||||
|
||||
if (!_mutationLogs.TryGetValue(shard.ShardId, out var entries))
|
||||
{
|
||||
entries = new List<CultNetShardMutationLogEntry>();
|
||||
_mutationLogs[shard.ShardId] = entries;
|
||||
}
|
||||
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
private void PublishUntyped(
|
||||
Type documentType,
|
||||
CultNetDatabaseChangeKind kind,
|
||||
|
|
|
|||
|
|
@ -135,8 +135,9 @@ R3 watch streams, raw document puts/deletes, raw snapshot responses,
|
|||
schema/key-filtered live subscriptions, shard catalog exchange, and stale-epoch
|
||||
write rejection. Non-primary write forwarding is available behind an injectable
|
||||
`ICultNetShardWriteForwarder`, with `CultNetSchemaWriteForwarder` as the
|
||||
schema-v0 `cultnet://host:port` endpoint dialer. Per-shard replica logs are the
|
||||
next missing organ.
|
||||
schema-v0 `cultnet://host:port` endpoint dialer. It also records accepted
|
||||
mutations in per-shard ordered logs for local replica catch-up. Wire-level log
|
||||
catch-up is the next missing organ.
|
||||
|
||||
The first coherent shard policy is primary ownership. Each shard has one
|
||||
authoritative writer at a time. Clients may connect to any node; a non-owner
|
||||
|
|
|
|||
|
|
@ -629,6 +629,81 @@ namespace GameCult.Networking.Tests
|
|||
Throws.TypeOf<FormatException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultNetDatabase_Appends_PerShardMutationLog()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
|
||||
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
||||
{
|
||||
Shards =
|
||||
[
|
||||
new CultNetShardDescriptor(
|
||||
"players-log",
|
||||
"runtime-a",
|
||||
epoch: 4,
|
||||
isPrimary: true,
|
||||
schemaIds: [schemaId],
|
||||
keyPrefix: "player:")
|
||||
]
|
||||
});
|
||||
var key = new CultRecordKey("player:log");
|
||||
var player = new PlayerData
|
||||
{
|
||||
PlayerId = Guid.NewGuid(),
|
||||
Email = "log@example.test",
|
||||
PasswordHash = "hash",
|
||||
Username = "Log"
|
||||
};
|
||||
|
||||
await database.PutAsync(key, player);
|
||||
player.Username = "LogUpdated";
|
||||
await database.PutAsync(key, player);
|
||||
await database.DeleteAsync<PlayerData>(key);
|
||||
|
||||
var entries = database.GetMutationLog("players-log");
|
||||
|
||||
Assert.That(entries, Has.Count.EqualTo(3));
|
||||
Assert.That(new[] { entries[0].Sequence, entries[1].Sequence, entries[2].Sequence }, Is.EqualTo([1, 2, 3]));
|
||||
Assert.That(entries[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added));
|
||||
Assert.That(entries[1].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Updated));
|
||||
Assert.That(entries[2].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Removed));
|
||||
Assert.That(entries[0].ShardEpoch, Is.EqualTo(4));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultNetDatabase_MutationLog_CanCatchUpAfterSequence()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
|
||||
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
||||
{
|
||||
Shards =
|
||||
[
|
||||
new CultNetShardDescriptor(
|
||||
"players-catchup",
|
||||
"runtime-a",
|
||||
epoch: 1,
|
||||
isPrimary: true,
|
||||
schemaIds: [schemaId],
|
||||
keyPrefix: "player:")
|
||||
]
|
||||
});
|
||||
|
||||
await database.PutAsync(
|
||||
new CultRecordKey("player:one"),
|
||||
new PlayerData { PlayerId = Guid.NewGuid(), Email = "one@example.test", PasswordHash = "hash", Username = "One" });
|
||||
await database.PutAsync(
|
||||
new CultRecordKey("player:two"),
|
||||
new PlayerData { PlayerId = Guid.NewGuid(), Email = "two@example.test", PasswordHash = "hash", Username = "Two" });
|
||||
|
||||
var entries = database.GetMutationLog("players-catchup", afterSequence: 1);
|
||||
|
||||
Assert.That(entries, Has.Count.EqualTo(1));
|
||||
Assert.That(entries[0].Sequence, Is.EqualTo(2));
|
||||
Assert.That(entries[0].Key.Value, Is.EqualTo("player:two"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CultNetDatabaseServer_Creates_Filtered_SubscriptionChange()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue