Add CultNet client prediction authority
This commit is contained in:
parent
ff0436b529
commit
ffedd6e973
5 changed files with 268 additions and 7 deletions
|
|
@ -171,6 +171,8 @@ Shard catalog exchange now exists:
|
|||
- background shard log replicator plus schema-v0 fetcher for primary endpoints
|
||||
- restart-safe replica cursor store with a local MessagePack file
|
||||
implementation
|
||||
- client authority scopes for predicted local input documents
|
||||
- predicted/reconciled change events for client-side prediction
|
||||
|
||||
## Next Cut
|
||||
|
||||
|
|
@ -179,6 +181,8 @@ Build durable authoritative mutation logs:
|
|||
- persist per-shard primary logs
|
||||
- define the snapshot fallback boundary for compacted history
|
||||
- return explicit resync-required responses when requested history is gone
|
||||
- then add simulation-frame rollback and resimulation helpers on top of
|
||||
predicted input streams
|
||||
|
||||
That is the next foundation needed before membership, Raft-style failover, or
|
||||
CRDT policy work can be honest.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ Implemented:
|
|||
- schema-v0 shard log fetcher for `cultnet://host:port` endpoints
|
||||
- restart-safe replica cursor store, including a local MessagePack file-backed
|
||||
implementation
|
||||
- client-authority scopes for locally predicted input documents
|
||||
- predicted and reconciled database change kinds for client-side prediction
|
||||
|
||||
Not implemented yet:
|
||||
|
||||
|
|
@ -66,6 +68,7 @@ Not implemented yet:
|
|||
- leader election or automatic shard failover
|
||||
- durable log persistence and compaction
|
||||
- snapshot fallback for compacted log history
|
||||
- rollback/resimulation helpers for simulation frames
|
||||
- declared CRDT merge policies
|
||||
|
||||
## Live Invariants
|
||||
|
|
@ -79,6 +82,11 @@ Not implemented yet:
|
|||
- Raw snapshot/put/delete messages must pass through `CultNetDatabase`, not
|
||||
bypass shard policy.
|
||||
- Realtime subscriptions publish domain changes, not storage envelopes.
|
||||
- Client-owned input documents may be predicted locally only inside explicit
|
||||
`CultNetClientAuthorityScope` declarations.
|
||||
- Predicted input state is not authoritative shared state. The shard log still
|
||||
decides the committed ordering and emits reconciliation when it corrects or
|
||||
confirms local prediction.
|
||||
|
||||
## Next Coherent Slice
|
||||
|
||||
|
|
@ -87,9 +95,12 @@ Add durable mutation-log storage and snapshot fallback:
|
|||
- persist per-shard mutation logs instead of keeping them only in memory
|
||||
- define the compaction boundary that turns old log reads into snapshot reads
|
||||
- return `ResyncRequired` when a replica asks for compacted history
|
||||
- after that, add simulation-frame rollback/resimulation helpers around
|
||||
predicted input streams
|
||||
|
||||
The log is wire-readable, replicas can apply it explicitly, a pull loop can
|
||||
drive non-primary shards from primary endpoints, and replica cursors can survive
|
||||
restart. The next useful layer is making authoritative mutation logs durable and
|
||||
compaction-aware. Membership and leader election should wait until basic replica
|
||||
catch-up is boring.
|
||||
restart. Client-owned input documents can now be predicted locally and
|
||||
reconciled when the authoritative log arrives. The next useful layer is making
|
||||
authoritative mutation logs durable and compaction-aware. Membership and leader
|
||||
election should wait until basic replica catch-up is boring.
|
||||
|
|
|
|||
|
|
@ -31,7 +31,57 @@ namespace GameCult.Networking
|
|||
/// <summary>
|
||||
/// A document change was rejected.
|
||||
/// </summary>
|
||||
Rejected
|
||||
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>
|
||||
/// 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) &&
|
||||
(SchemaIds.Count == 0 || SchemaIds.Contains(schemaId, StringComparer.Ordinal)) &&
|
||||
(string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -168,6 +218,10 @@ namespace GameCult.Networking
|
|||
/// </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; }
|
||||
|
|
@ -328,7 +382,10 @@ namespace GameCult.Networking
|
|||
{
|
||||
private readonly CultCache _cache;
|
||||
private readonly CultNetDocumentRegistry _documents;
|
||||
private readonly string _runtimeId;
|
||||
private readonly List<CultNetShardDescriptor> _shards;
|
||||
private readonly List<CultNetClientAuthorityScope> _clientAuthorityScopes;
|
||||
private readonly Dictionary<string, object> _predictedDocuments = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<CultNetShardMutationLogEntry>> _mutationLogs =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, long> _nextLogSequences = new(StringComparer.Ordinal);
|
||||
|
|
@ -343,11 +400,13 @@ namespace GameCult.Networking
|
|||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
options ??= new CultNetDatabaseOptions();
|
||||
_runtimeId = options.RuntimeId;
|
||||
_documents = options.DocumentRegistry ?? new CultNetDocumentRegistry(cache.Registry);
|
||||
_shards = (options.Shards == null || options.Shards.Count == 0
|
||||
? new[] { CultNetShardDescriptor.PrimaryAll(options.RuntimeId) }
|
||||
: options.Shards)
|
||||
.ToList();
|
||||
_clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty<CultNetClientAuthorityScope>()).ToList();
|
||||
_cache.OnUpdate += PublishCacheUpdate;
|
||||
}
|
||||
|
||||
|
|
@ -364,6 +423,11 @@ namespace GameCult.Networking
|
|||
/// </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 mutation log entries for a shard after the supplied sequence.
|
||||
/// </summary>
|
||||
|
|
@ -486,6 +550,31 @@ namespace GameCult.Networking
|
|||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a locally predicted document for a client-owned input scope.
|
||||
/// </summary>
|
||||
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<T>();
|
||||
var shard = ResolveShardInternal(descriptor.SchemaId, key);
|
||||
EnsureClientAuthority(descriptor.SchemaId, key);
|
||||
|
||||
var previous = _cache.Get<T>(key);
|
||||
var handle = await _cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
|
||||
_predictedDocuments[PredictionKey(descriptor.SchemaId, key)] = document;
|
||||
Publish(new CultNetDatabaseChange<T>(
|
||||
CultNetDatabaseChangeKind.Predicted,
|
||||
key,
|
||||
descriptor.SchemaId,
|
||||
shard,
|
||||
document,
|
||||
previous));
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a document by key.
|
||||
/// </summary>
|
||||
|
|
@ -537,16 +626,23 @@ 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);
|
||||
var kind = previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated;
|
||||
var predictionKey = PredictionKey(descriptor.SchemaId, key);
|
||||
if (_predictedDocuments.Remove(predictionKey))
|
||||
{
|
||||
kind = CultNetDatabaseChangeKind.Reconciled;
|
||||
}
|
||||
|
||||
AppendMutationLogEntry(
|
||||
shard,
|
||||
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
||||
kind == CultNetDatabaseChangeKind.Reconciled ? CultNetDatabaseChangeKind.Updated : kind,
|
||||
key,
|
||||
descriptor.SchemaId,
|
||||
document,
|
||||
previous);
|
||||
PublishUntyped(
|
||||
descriptor.DocumentType,
|
||||
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
||||
kind,
|
||||
key,
|
||||
descriptor.SchemaId,
|
||||
shard,
|
||||
|
|
@ -872,12 +968,18 @@ namespace GameCult.Networking
|
|||
$"Shard '{shard.ShardId}' log entry {entry.Sequence} has a removed kind with a put payload.");
|
||||
}
|
||||
|
||||
var predictionKey = PredictionKey(descriptor.SchemaId, key);
|
||||
if (_predictedDocuments.Remove(predictionKey))
|
||||
{
|
||||
kind = CultNetDatabaseChangeKind.Reconciled;
|
||||
}
|
||||
|
||||
RecordMutationLogEntry(new CultNetShardMutationLogEntry(
|
||||
shard.ShardId,
|
||||
shard.Epoch,
|
||||
entry.Sequence,
|
||||
entry.CommittedAt,
|
||||
kind,
|
||||
kind == CultNetDatabaseChangeKind.Reconciled ? CultNetDatabaseChangeKind.Updated : kind,
|
||||
descriptor.SchemaId,
|
||||
key,
|
||||
document,
|
||||
|
|
@ -949,6 +1051,25 @@ namespace GameCult.Networking
|
|||
};
|
||||
}
|
||||
|
||||
private void EnsureClientAuthority(string schemaId, CultRecordKey key)
|
||||
{
|
||||
if (_clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, schemaId, key)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var shard = ResolveShardInternal(schemaId, key);
|
||||
throw new CultNetShardAuthorityException(
|
||||
shard,
|
||||
$"Runtime '{_runtimeId}' does not have client prediction authority for schema '{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,
|
||||
|
|
|
|||
|
|
@ -148,6 +148,12 @@ transport path. Replica cursors can be stored through
|
|||
those cursors in one local MessagePack file so replicas can resume after a
|
||||
restart.
|
||||
|
||||
For client-side prediction, `CultNetClientAuthorityScope` declares which input
|
||||
documents a runtime may author optimistically. `PutPredictedAsync` updates the
|
||||
local cache immediately and emits a `Predicted` change; when the authoritative
|
||||
shard log arrives for the same record, the database emits `Reconciled` and the
|
||||
cache holds the committed value.
|
||||
|
||||
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
|
||||
node forwards a write to the owner or rejects it honestly when forwarding is
|
||||
|
|
|
|||
|
|
@ -923,6 +923,125 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(targetDatabase.GetMutationLog("players-replica"), Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultNetDatabase_Predicts_ClientOwnedInput_AndReconciles_AuthoritativeLog()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var registry = new CultNetDocumentRegistry(cache.Registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
||||
cache.Registry,
|
||||
payloadSerializer: SerializePlayerDataPayload,
|
||||
payloadDeserializer: DeserializePlayerDataPayload));
|
||||
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
|
||||
var key = new CultRecordKey("input:client-a");
|
||||
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
||||
{
|
||||
RuntimeId = "client-a",
|
||||
DocumentRegistry = registry,
|
||||
Shards =
|
||||
[
|
||||
new CultNetShardDescriptor(
|
||||
"inputs",
|
||||
"server",
|
||||
epoch: 1,
|
||||
isPrimary: false,
|
||||
schemaIds: [schemaId],
|
||||
keyPrefix: "input:",
|
||||
primaryEndpoints: ["cultnet://server.example.test:3075"])
|
||||
],
|
||||
ClientAuthorityScopes =
|
||||
[
|
||||
new CultNetClientAuthorityScope(
|
||||
"client-a",
|
||||
schemaIds: [schemaId],
|
||||
keyPrefix: "input:client-a")
|
||||
]
|
||||
});
|
||||
var changes = new List<CultNetDatabaseChange<PlayerData>>();
|
||||
using var subscription = database.WatchRecord<PlayerData>(key)
|
||||
.Subscribe(change => changes.Add(change));
|
||||
var predicted = new PlayerData
|
||||
{
|
||||
PlayerId = Guid.NewGuid(),
|
||||
Email = "input@example.test",
|
||||
PasswordHash = "hash",
|
||||
Username = "Predicted"
|
||||
};
|
||||
var authoritative = new PlayerData
|
||||
{
|
||||
PlayerId = predicted.PlayerId,
|
||||
Email = predicted.Email,
|
||||
PasswordHash = predicted.PasswordHash,
|
||||
Username = "Authoritative"
|
||||
};
|
||||
var put = registry.CreateRawDocumentPutMessage("input-commit", new CultRecordHandle<PlayerData>(key), authoritative);
|
||||
put.ShardId = "inputs";
|
||||
put.ShardEpoch = 1;
|
||||
|
||||
await database.PutPredictedAsync(key, predicted);
|
||||
await database.ApplyShardLogResponseAsync(new CultNetShardLogResponseMessage
|
||||
{
|
||||
ShardId = "inputs",
|
||||
ShardEpoch = 1,
|
||||
Entries =
|
||||
[
|
||||
new CultNetShardLogEntryMessage
|
||||
{
|
||||
Sequence = 1,
|
||||
CommittedAt = "2026-05-19T12:00:00.0000000Z",
|
||||
ChangeKind = "updated",
|
||||
Put = put
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
Assert.That(cache.Get<PlayerData>(key)!.Username, Is.EqualTo("Authoritative"));
|
||||
Assert.That(changes.Exists(change => change.Kind == CultNetDatabaseChangeKind.Predicted), Is.True);
|
||||
Assert.That(changes.Exists(change => change.Kind == CultNetDatabaseChangeKind.Reconciled), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CultNetDatabase_Rejects_Prediction_OutsideClientAuthorityScope()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
|
||||
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
||||
{
|
||||
RuntimeId = "client-a",
|
||||
Shards =
|
||||
[
|
||||
new CultNetShardDescriptor(
|
||||
"inputs",
|
||||
"server",
|
||||
epoch: 1,
|
||||
isPrimary: false,
|
||||
schemaIds: [schemaId],
|
||||
keyPrefix: "input:")
|
||||
],
|
||||
ClientAuthorityScopes =
|
||||
[
|
||||
new CultNetClientAuthorityScope(
|
||||
"client-a",
|
||||
schemaIds: [schemaId],
|
||||
keyPrefix: "input:client-a")
|
||||
]
|
||||
});
|
||||
|
||||
Assert.That(
|
||||
async () => await database.PutPredictedAsync(
|
||||
new CultRecordKey("input:client-b"),
|
||||
new PlayerData
|
||||
{
|
||||
PlayerId = Guid.NewGuid(),
|
||||
Email = "input-b@example.test",
|
||||
PasswordHash = "hash",
|
||||
Username = "WrongClient"
|
||||
}),
|
||||
Throws.TypeOf<CultNetShardAuthorityException>()
|
||||
.With.Property(nameof(CultNetShardAuthorityException.Reason))
|
||||
.EqualTo("not_client_authority"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CultNetDatabase_Rejects_ShardLogGap()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue