Refuse variants after the selection is applied, and never tell a subscriber about the removal of one it was only refused

The selection paths (SelectPage, SelectAll) refused every stored variant before the selection ran, so once any variant existed a hop-bearing subscription threw out of its change handler and went silent. They now refuse only a variant the selection selects; the subscription server delivers that as a typed error and keeps its projection. The database server no longer sends removed for a key the subscriber only ever received as an error. A flatten racing the delivery check is recorded in a comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 08:28:24 +02:00
commit b572823d0d
4 changed files with 240 additions and 24 deletions

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@ -502,27 +503,48 @@ namespace GameCult.Networking
string subscriptionId,
CultNetServerPeer peer)
{
// Keys this subscriber only ever received as a refusal: it was never given them as records.
var refused = new HashSet<string>(StringComparer.Ordinal);
return _database.WatchAllChanges().Subscribe(change =>
{
CultNetDatabaseChangeRawMessage? outbound;
try
{
outbound = CreateChangeMessage(change, subscriptionId, selection);
}
catch (NotSupportedException refusal)
{
// Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key.
peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message });
return;
}
var outbound = DeliverChange(change, subscriptionId, selection, refused, error => peer.SendCultNet(error));
if (outbound != null)
{
peer.SendCultNet(outbound);
}
});
}
// One subscriber's view of one change. A variant is not a record CultNet can carry (Q6): the subscriber is told, by key, and
// is not later told the variant was removed, because it never held it.
internal CultNetDatabaseChangeRawMessage? DeliverChange(
object change,
string subscriptionId,
CultNetSelection selection,
HashSet<string> refused,
Action<CultNetErrorMessage> sendError)
{
CultNetDatabaseChangeRawMessage? outbound;
try
{
outbound = CreateChangeMessage(change, subscriptionId, selection);
}
catch (NotSupportedException refusal)
{
var refusedKey = change.GetType().GetProperty("Key")?.GetValue(change) is CultRecordKey changed ? changed.Value : string.Empty;
lock (refused)
refused.Add(refusedKey);
sendError(new CultNetErrorMessage { Error = refusal.Message });
return null;
}
if (outbound == null)
return null;
var recordKey = outbound.RecordKey ?? outbound.Document?.RecordKey ?? string.Empty;
bool wasRefused;
lock (refused)
wasRefused = refused.Remove(recordKey);
return wasRefused && outbound.ChangeKind == "removed" ? null : outbound;
}
internal CultNetDatabaseChangeRawMessage? CreateChangeMessage(
object change,
string subscriptionId,
@ -565,7 +587,7 @@ namespace GameCult.Networking
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = kind ==CultNetDatabaseChangeKind.Added ? "added" : "updated",
ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
Document = _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O"))
};
}