From dac712b09c0a3b1931657f12b13bbcf4c948c973 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 05:29:35 +0200 Subject: [PATCH] CultNetDatabaseServer: a selection skips cached rows no shard owns instead of failing ResolveShard now throws for an unowned schema, so one such cached row would have failed the whole selection. The server passes a row filter (CultNetDatabase.Owns) to CreateSelectionResponse, which gains an optional rowFilter that SelectPage already took, so those rows are not part of what the database serves and the rest is answered. Co-Authored-By: Claude Sonnet 5.5 --- src/GameCult.Networking/CultNetDatabase.cs | 2 ++ .../CultNetDatabaseServer.cs | 2 ++ .../CultNetDocumentRegistry.cs | 6 ++++-- .../CultNetJournalTests.cs | 21 +++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 083d309f..07336488 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -1922,6 +1922,8 @@ namespace GameCult.Networking private CultNetShardDescriptor ResolveShardInternal(CultDocumentDescriptor descriptor, CultRecordKey key) => FindShard(descriptor, key) ?? throw new CultNetUnownedSchemaException(descriptor.SchemaId, key); + internal bool Owns(CultDocumentDescriptor descriptor, CultRecordKey key) => FindShard(descriptor, key) != null; + private CultNetShardDescriptor? FindShard(CultDocumentDescriptor descriptor, CultRecordKey key) => _shards.FirstOrDefault(shard => shard.Matches(descriptor, key)); diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index d53315ed..78124acc 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -135,6 +135,8 @@ namespace GameCult.Networking // R-Q: asOf is one shard-log watermark, so a selection whose matched rows span more than // one shard's log is refused rather than answered against a watermark that is not // exact for all of them. + // A row whose schema no shard owns is not part of what this database serves: it is left out, not an error. + rowFilter: _database.Owns, shardIdOf: (schemaId, key) => _database.ResolveShard(schemaId, key).ShardId, cursorKey: _database.CursorKey, // S-9: once the matched rows resolve to one shard, asOf is that shard's own watermark, diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index eb23d503..a47f28b3 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -338,6 +338,7 @@ namespace GameCult.Networking /// row's ordinal (the reference: ); /// is the snapshot the page is exact for when the selection is not shard-scoped, or the fallback /// used only when is null or the selection matches no rows (S-9). + /// , when supplied, leaves out the cached rows it rejects before evaluation. /// public CultNetSnapshotResponseRawV1Message CreateSelectionResponse( CultCache cache, @@ -348,13 +349,14 @@ namespace GameCult.Networking CultNetDocumentMessageOptions? options = null, Func? shardIdOf = null, CultNetSelectionCursorKey? cursorKey = null, - Func? asOfForShard = null) + Func? asOfForShard = null, + Func? rowFilter = null) { if (cache == null) throw new ArgumentNullException(nameof(cache)); if (selection == null) throw new ArgumentNullException(nameof(selection)); if (ordinalOf == null) throw new ArgumentNullException(nameof(ordinalOf)); - var page = SelectPage(cache, selection, ordinalOf, asOf, options, shardIdOf: shardIdOf, cursorKey: cursorKey, asOfForShard: asOfForShard); + var page = SelectPage(cache, selection, ordinalOf, asOf, options, rowFilter, shardIdOf, cursorKey, asOfForShard); return new CultNetSnapshotResponseRawV1Message { MessageId = RequireNonEmpty(messageId, nameof(messageId)), diff --git a/tests/GameCult.Networking.Tests/CultNetJournalTests.cs b/tests/GameCult.Networking.Tests/CultNetJournalTests.cs index 5e18bcd7..3bbb72ef 100644 --- a/tests/GameCult.Networking.Tests/CultNetJournalTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetJournalTests.cs @@ -745,6 +745,27 @@ namespace GameCult.Networking.Tests Assert.That(cache.Get(Unowned), Is.Null); } + // One cached row of a schema no shard owns is not part of what this database serves: the selection answers with the + // rest, it does not fail as a whole. + [Test] + public async Task ASelectionSkipsACachedRowNoShardOwnsAndAnswersWithTheRest() + { + var (database, cache, _, _) = OwnedNothingOfNote(); + await database.PutAsync(One, Note("owned")); + await cache.UpsertAsync(new MeshQuickstartNote { NoteId = "u", Body = "unowned" }, new CultRecordHandle(Unowned)); + using var server = new Server(cache, ServerSecurityOptions.Development()); + using var databaseServer = new CultNetDatabaseServer(server, database); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = "select-all", + Selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document } + }); + + Assert.That(response.Documents!.Select(document => document.RecordKey), Is.EqualTo(new[] { One.Value })); + Assert.That(response.Matched, Is.EqualTo(1u)); + } + private sealed class CapturingLogger : GameCult.Logging.ILogger { public List Errors { get; } = new();