Fix batch 4 (S-9/S-11): shard-scoped asOf, and the v1 subscribe door runs before the hop refusal

S-9: CurrentAsOf() returned the maximum shard-log sequence across every
shard the database holds, but that sequence is a per-shard counter -
mixing shards' counters together could report a page as exact "as of" a
sequence a different, untouched shard advanced to while the page's own
shard sat still. CultNetDatabase gains CurrentAsOf(shardId), the watermark
of one shard's own log, and CultNetDocumentRegistry.SelectPage now scopes
asOf to the single shard EnsureSingleShard already resolves the matched
rows to, falling back to the caller-supplied value only when the selection
matches no rows.

S-11: CultNetDatabaseServer.HandleSubscribeV1Async refused a hop-bearing
selection before running the door (Validate), so a selection invalid for
an unrelated reason (an empty keys list, an undeclared index, ...) got a
hop refusal that had nothing to do with why it was actually rejected. The
door now runs first, unconditionally. The hop refusal also always named
the field "cites" even when the selection carried Cited instead; it now
names whichever hop is actually set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 21:12:07 +02:00
commit b3a8f6d195
4 changed files with 115 additions and 25 deletions

View file

@ -135,7 +135,10 @@ namespace GameCult.Networking
// one shard's log is refused rather than answered against a watermark that is not
// exact for all of them.
shardIdOf: (schemaId, key) => _database.ResolveShard(schemaId, key).ShardId,
cursorKey: _database.CursorKey);
cursorKey: _database.CursorKey,
// S-9: once the matched rows resolve to one shard, asOf is that shard's own watermark,
// not CurrentAsOf()'s database-wide maximum across every shard.
asOfForShard: _database.CurrentAsOf);
}
/// <summary>
@ -377,22 +380,9 @@ namespace GameCult.Networking
return Task.CompletedTask;
}
// This server delivers live changes through the single-row fast path (CreateChangeMessage /
// CultNetSelectionEvaluator.Matches), which is set-independent by construction - the same
// ceiling v0 subscriptions on this server already have. A hop-bearing selection is
// set-dependent (D6) and needs the subscription server's reconcile loop instead, so it is
// refused here rather than reaching Matches' own InvalidOperationException on the first change.
if (message.Selection.HasHop)
{
peer.SendCultNet(new CultNetErrorMessage
{
Error = "selection_invalid: selection.cites/selection.cited need the subscription server's reconcile loop (D6); this server only fast-matches a single row.",
Code = "selection_invalid",
Details = new CultNetErrorDetails { Field = "cites" }
});
return Task.CompletedTask;
}
// S-11: the door runs first, unconditionally (R-F) - a selection that is invalid for reasons
// unrelated to its hop (an empty keys list, an undeclared index, ...) reports that refusal,
// not a hop refusal that has nothing to do with why it was actually rejected.
try
{
message.Selection.Validate(_database.Cache.Registry.AllDescriptors.ToArray());
@ -403,6 +393,25 @@ namespace GameCult.Networking
return Task.CompletedTask;
}
// This server delivers live changes through the single-row fast path (CreateChangeMessage /
// CultNetSelectionEvaluator.Matches), which is set-independent by construction - the same
// ceiling v0 subscriptions on this server already have. A hop-bearing selection is
// set-dependent (D6) and needs the subscription server's reconcile loop instead, so it is
// refused here, after the door, rather than reaching Matches' own InvalidOperationException
// on the first change. The refused field names whichever hop is actually set - cited, not
// always cites - so the refusal describes the selection that was actually sent.
if (message.Selection.HasHop)
{
var hopField = message.Selection.Cites != null ? "cites" : "cited";
peer.SendCultNet(new CultNetErrorMessage
{
Error = $"selection_invalid: selection.{hopField} needs the subscription server's reconcile loop (D6); this server only fast-matches a single row.",
Code = "selection_invalid",
Details = new CultNetErrorDetails { Field = hopField }
});
return Task.CompletedTask;
}
var key = SubscriptionKey(peer.Peer, subscriptionId);
_subscriptions.AddOrUpdate(
key,