Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap
R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte
random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key
instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not
verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body
(asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no
delimiter) instead of joined with a bare "\x01"-separated string, so a record key
carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/
Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a
process-wide key generated once so existing callers that never wire up a database still
get a keyed digest. CultNetDatabase gets a CursorKey property (random unless
CultNetDatabaseOptions.CursorKey is supplied) and passes it through
CultNetDatabaseServer.CreateSelectionResponse.
Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching
the reference: its own doc comment says a cursor is never handed across runtimes, so only
collision-freedom and keying are load-bearing, not the wire bytes. That is a real
divergence from this pass's instruction to match Rust's wire encoding exactly - reported
in full in the batch report.
R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded
the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position
looked like "never written" until it was written again locally. Both now set it from the
entry's own sequence, matching AppendMutationLogEntry's local-commit path.
InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also
rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence
order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard
resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional
shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid,
R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is
a single watermark and is not exact for a shard-spanning page.
CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the
database.
Also fixes a defect the Rust Hands found while matching wire bytes:
CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: "
- the exception's own message already carries it. Now prefixed once, matching Rust's
ForReferenceOutsideTarget.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
eff62e59fb
commit
a4cf54f4a9
8 changed files with 541 additions and 41 deletions
|
|
@ -305,6 +305,12 @@ namespace GameCult.Networking
|
|||
/// 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>
|
||||
|
|
@ -494,10 +500,25 @@ namespace GameCult.Networking
|
|||
: options.Shards)
|
||||
.ToList();
|
||||
_clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty<CultNetClientAuthorityScope>()).ToList();
|
||||
CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random();
|
||||
InitializeLogSequencesFromStore();
|
||||
_cache.OnUpdate += PublishCacheUpdate;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Resolves the shard that owns a record key (R-Q: whether a matched selection's rows span more
|
||||
/// than one shard's log, and so whether the page's <c>asOf</c> is well defined).
|
||||
/// </summary>
|
||||
public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key) => ResolveShardInternal(schemaId, key);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the local cache backing this database surface.
|
||||
/// </summary>
|
||||
|
|
@ -1337,10 +1358,26 @@ namespace GameCult.Networking
|
|||
|
||||
foreach (var shard in _shards)
|
||||
{
|
||||
var highest = _mutationLogStore.Read(shard.ShardId)
|
||||
.Select(entry => entry.Sequence)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
// 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
|
||||
// AppendMutationLogEntry/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);
|
||||
var highest = 0L;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Sequence > highest)
|
||||
{
|
||||
highest = entry.Sequence;
|
||||
}
|
||||
|
||||
var (schemaId, recordKey) = KeyOf(entry);
|
||||
if (schemaId != null && recordKey != null)
|
||||
{
|
||||
_lastWriteSequence[(schemaId, recordKey)] = entry.Sequence;
|
||||
}
|
||||
}
|
||||
|
||||
if (highest > 0)
|
||||
{
|
||||
_nextLogSequences[shard.ShardId] = highest + 1;
|
||||
|
|
@ -1348,6 +1385,21 @@ namespace GameCult.Networking
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -1416,6 +1468,11 @@ namespace GameCult.Networking
|
|||
document,
|
||||
previous),
|
||||
entry);
|
||||
// R-Q: a replica apply is a commit path too - the last-write sequence this row now carries
|
||||
// is the entry's own sequence, exactly like AppendMutationLogEntry's local-commit path sets
|
||||
// it, so the selection evaluator's order and cursor agree whether a row's most recent write
|
||||
// landed locally or arrived through replication.
|
||||
_lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence;
|
||||
PublishUntyped(
|
||||
descriptor.DocumentType,
|
||||
kind,
|
||||
|
|
@ -1459,6 +1516,8 @@ namespace GameCult.Networking
|
|||
document: null,
|
||||
previousDocument: previous),
|
||||
entry);
|
||||
// R-Q: see ApplyCommittedPutAsync - a replica-applied delete is a commit too.
|
||||
_lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence;
|
||||
PublishUntyped(
|
||||
descriptor.DocumentType,
|
||||
CultNetDatabaseChangeKind.Removed,
|
||||
|
|
|
|||
Loading…
Reference in a new issue