Add CultNet client prediction authority

This commit is contained in:
Meta Vens 2026-05-19 18:30:03 +01:00
commit ffedd6e973
5 changed files with 268 additions and 7 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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,

View file

@ -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

View file

@ -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()
{