Reconcile live subscription authority

This commit is contained in:
Meta Vens 2026-08-19 13:55:03 +02:00
commit 73ab8b65e1
2 changed files with 376 additions and 70 deletions

View file

@ -30,6 +30,9 @@ namespace GameCult.Networking
new ConcurrentDictionary<SubscriptionKey, IDisposable>(SubscriptionKeyComparer.Instance);
private readonly ConcurrentDictionary<SubscriptionKey, CultNetDatabaseSubscribeMessage> _requests =
new ConcurrentDictionary<SubscriptionKey, CultNetDatabaseSubscribeMessage>(SubscriptionKeyComparer.Instance);
private readonly ConcurrentDictionary<SubscriptionKey, SubscriptionProjectionState> _projections =
new ConcurrentDictionary<SubscriptionKey, SubscriptionProjectionState>(SubscriptionKeyComparer.Instance);
private readonly object _lifecycleGate = new();
private bool _disposed;
/// <summary>Attaches live database subscription handlers to a schema server.</summary>
@ -61,34 +64,48 @@ namespace GameCult.Networking
/// </summary>
public event Action<CultNetDatabaseSubscriptionDemand>? DemandChanged;
/// <summary>
/// Re-evaluates every live peer projection against current authorization and source state.
/// Records that lost visibility are explicitly removed and body demand is withdrawn while
/// the request is unauthorized. The subscription intent remains available for a later
/// authorized generation on the same established peer.
/// </summary>
public void Reconcile()
{
lock (_lifecycleGate)
{
if (_disposed) return;
foreach (var entry in _requests.ToArray())
Reconcile(entry.Key, entry.Value);
}
}
/// <summary>Detaches handlers and releases all active watches.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribe);
_server.RemoveCultNetMessageListener<CultNetDatabaseUnsubscribeMessage>(_unsubscribe);
if (_peerLifecycle != null)
_peerLifecycle.PeerDisconnected -= HandlePeerDisconnected;
foreach (var entry in _subscriptions)
lock (_lifecycleGate)
{
entry.Value.Dispose();
if (_requests.TryRemove(entry.Key, out var request))
PublishDemand(request, entry.Key, active: false);
if (_disposed) return;
_disposed = true;
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribe);
_server.RemoveCultNetMessageListener<CultNetDatabaseUnsubscribeMessage>(_unsubscribe);
if (_peerLifecycle != null)
_peerLifecycle.PeerDisconnected -= HandlePeerDisconnected;
foreach (var entry in _subscriptions.ToArray())
Withdraw(entry.Key, sendRemovals: false, forgetRequest: true);
_subscriptions.Clear();
_requests.Clear();
_projections.Clear();
}
_subscriptions.Clear();
_requests.Clear();
}
private void HandlePeerDisconnected(ICultNetSchemaServerPeer peer)
{
foreach (var key in _subscriptions.Keys.Where(candidate =>
SubscriptionKeyComparer.SamePeer(candidate.Peer, peer)).ToArray())
lock (_lifecycleGate)
{
if (_subscriptions.TryRemove(key, out var subscription))
subscription.Dispose();
if (_requests.TryRemove(key, out var request))
PublishDemand(request, key, active: false);
foreach (var key in _subscriptions.Keys.Where(candidate =>
SubscriptionKeyComparer.SamePeer(candidate.Peer, peer)).ToArray())
Withdraw(key, sendRemovals: false, forgetRequest: true);
}
}
@ -108,54 +125,43 @@ namespace GameCult.Networking
return Task.CompletedTask;
}
var key = new SubscriptionKey(peer, subscriptionId);
if (_requests.TryGetValue(key, out var previous))
PublishDemand(previous, key, active: false);
_subscriptions.AddOrUpdate(
key,
_ => Watch(request, subscriptionId, peer),
(_, current) =>
{
current.Dispose();
return Watch(request, subscriptionId, peer);
});
_requests[key] = request;
PublishDemand(request, key, active: true);
if (request.IncludeSnapshot)
lock (_lifecycleGate)
{
var snapshot = _database.Documents.CreateRawSnapshotResponse(
_database.Cache,
request.MessageId,
new CultNetSnapshotRequestMessage
var key = new SubscriptionKey(peer, subscriptionId);
Withdraw(key, sendRemovals: true, forgetRequest: true);
var projection = new SubscriptionProjectionState();
_projections[key] = projection;
_requests[key] = request;
try
{
_subscriptions[key] = Watch(request, subscriptionId, peer, key);
if (request.IncludeSnapshot)
{
MessageId = request.MessageId,
SchemaIds = request.SchemaIds,
RecordKeys = request.RecordKeys
});
if (_authorizeRecord != null)
{
snapshot.Documents = snapshot.Documents
.Where(document => _authorizeRecord(
request, peer, document.RecordKey, document.SchemaId))
.ToArray();
var snapshot = CreateProjectedSnapshot(request, peer);
foreach (var entry in snapshot.BySourceRecordKey)
projection.DeliveredBySourceRecordKey[entry.Key] = entry.Value;
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
{
MessageId = request.MessageId,
Documents = snapshot.Documents
});
}
else
{
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
{
MessageId = request.MessageId,
Documents = Array.Empty<CultNetRawDocumentRecord>()
});
}
PublishDemand(request, key, active: true);
projection.DemandActive = true;
}
if (_projectRecord != null)
catch
{
snapshot.Documents = snapshot.Documents
.Select(document => _projectRecord(request, peer, document))
.Where(document => document != null)
.Cast<CultNetRawDocumentRecord>()
.ToArray();
Withdraw(key, sendRemovals: false, forgetRequest: true);
throw;
}
peer.SendCultNet(snapshot);
}
else
{
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
{
MessageId = request.MessageId,
Documents = Array.Empty<CultNetRawDocumentRecord>()
});
}
return Task.CompletedTask;
}
@ -168,9 +174,8 @@ namespace GameCult.Networking
if (!string.IsNullOrWhiteSpace(subscriptionId))
{
var key = new SubscriptionKey(peer, subscriptionId);
if (_subscriptions.TryRemove(key, out var subscription)) subscription.Dispose();
if (_requests.TryRemove(key, out var subscribed))
PublishDemand(subscribed, key, active: false);
lock (_lifecycleGate)
Withdraw(key, sendRemovals: false, forgetRequest: true);
}
return Task.CompletedTask;
}
@ -213,20 +218,209 @@ namespace GameCult.Networking
private IDisposable Watch(
CultNetDatabaseSubscribeMessage request,
string subscriptionId,
ICultNetSchemaServerPeer peer)
ICultNetSchemaServerPeer peer,
SubscriptionKey key)
{
return _database.WatchAllChanges().Subscribe(change =>
{
var message = CreateAuthorizedChange(change, request, subscriptionId, peer);
if (message?.Document != null && _projectRecord != null)
lock (_lifecycleGate)
{
message.Document = _projectRecord(request, peer, message.Document);
if (message.Document == null) return;
if (_disposed || !_requests.ContainsKey(key) || !_projections.TryGetValue(key, out var projection))
return;
if (_authorizeRequest?.Invoke(request, peer) == false)
{
Reconcile(key, request);
return;
}
var sourceRecordKey = ResolveChangeRecordKey(change);
var message = CreateAuthorizedChange(change, request, subscriptionId, peer);
var projected = message?.Document;
if (projected != null && _projectRecord != null)
projected = _projectRecord(request, peer, projected);
ApplyProjectedChange(
projection,
sourceRecordKey,
projected,
peer,
subscriptionId);
}
if (message != null) peer.SendCultNet(message);
});
}
private void Reconcile(SubscriptionKey key, CultNetDatabaseSubscribeMessage request)
{
if (!_projections.TryGetValue(key, out var projection)) return;
var authorized = _authorizeRequest?.Invoke(request, key.Peer) != false;
var next = authorized
? CreateProjectedSnapshot(request, key.Peer)
: ProjectedSnapshot.Empty;
foreach (var previous in projection.DeliveredBySourceRecordKey.ToArray())
{
if (!next.BySourceRecordKey.TryGetValue(previous.Key, out var current))
{
SendRemoval(key.Peer, key.Id, previous.Value);
continue;
}
if (!string.Equals(previous.Value.RecordKey, current.RecordKey, StringComparison.Ordinal) ||
!string.Equals(previous.Value.SchemaId, current.SchemaId, StringComparison.Ordinal))
{
SendRemoval(key.Peer, key.Id, previous.Value);
SendUpsert(key.Peer, key.Id, current, added: true);
}
else if (!Equivalent(previous.Value, current))
{
SendUpsert(key.Peer, key.Id, current, added: false);
}
}
foreach (var current in next.BySourceRecordKey)
{
if (!projection.DeliveredBySourceRecordKey.ContainsKey(current.Key))
SendUpsert(key.Peer, key.Id, current.Value, added: true);
}
projection.DeliveredBySourceRecordKey.Clear();
foreach (var current in next.BySourceRecordKey)
projection.DeliveredBySourceRecordKey[current.Key] = current.Value;
if (projection.DemandActive != authorized)
{
projection.DemandActive = authorized;
PublishDemand(request, key, authorized);
}
}
private ProjectedSnapshot CreateProjectedSnapshot(
CultNetDatabaseSubscribeMessage request,
ICultNetSchemaServerPeer peer)
{
var snapshot = _database.Documents.CreateRawSnapshotResponse(
_database.Cache,
request.MessageId,
new CultNetSnapshotRequestMessage
{
MessageId = request.MessageId,
SchemaIds = request.SchemaIds,
RecordKeys = request.RecordKeys
});
var bySourceRecordKey = new Dictionary<string, CultNetRawDocumentRecord>(StringComparer.Ordinal);
var projectedRecordKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var source in snapshot.Documents)
{
var sourceRecordKey = source.RecordKey;
if (_authorizeRecord?.Invoke(request, peer, sourceRecordKey, source.SchemaId) == false)
continue;
var projected = _projectRecord == null ? source : _projectRecord(request, peer, source);
if (projected == null) continue;
if (!projectedRecordKeys.Add(projected.RecordKey))
throw new InvalidOperationException(
$"Database subscription projection produced duplicate record key '{projected.RecordKey}'.");
bySourceRecordKey[sourceRecordKey] = projected;
}
return new ProjectedSnapshot(bySourceRecordKey);
}
private void ApplyProjectedChange(
SubscriptionProjectionState projection,
string sourceRecordKey,
CultNetRawDocumentRecord? current,
ICultNetSchemaServerPeer peer,
string subscriptionId)
{
projection.DeliveredBySourceRecordKey.TryGetValue(sourceRecordKey, out var previous);
if (current == null)
{
if (previous != null)
{
projection.DeliveredBySourceRecordKey.Remove(sourceRecordKey);
SendRemoval(peer, subscriptionId, previous);
}
return;
}
if (previous == null)
{
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
SendUpsert(peer, subscriptionId, current, added: true);
return;
}
if (!string.Equals(previous.RecordKey, current.RecordKey, StringComparison.Ordinal) ||
!string.Equals(previous.SchemaId, current.SchemaId, StringComparison.Ordinal))
{
SendRemoval(peer, subscriptionId, previous);
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
SendUpsert(peer, subscriptionId, current, added: true);
return;
}
if (!Equivalent(previous, current))
{
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
SendUpsert(peer, subscriptionId, current, added: false);
}
}
private static string ResolveChangeRecordKey(object change)
{
var value = change.GetType().GetProperty("Key")?.GetValue(change);
return value is CultRecordKey key ? key.Value : string.Empty;
}
private static bool Equivalent(CultNetRawDocumentRecord left, CultNetRawDocumentRecord right) =>
string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) &&
string.Equals(left.SchemaName, right.SchemaName, StringComparison.Ordinal) &&
string.Equals(left.SchemaVersion, right.SchemaVersion, StringComparison.Ordinal) &&
string.Equals(left.SchemaContentHash, right.SchemaContentHash, StringComparison.Ordinal) &&
string.Equals(left.RecordKey, right.RecordKey, StringComparison.Ordinal) &&
string.Equals(left.PayloadEncoding, right.PayloadEncoding, StringComparison.Ordinal) &&
left.Payload.SequenceEqual(right.Payload) &&
string.Equals(left.SourceRuntimeId, right.SourceRuntimeId, StringComparison.Ordinal) &&
string.Equals(left.SourceAgentId, right.SourceAgentId, StringComparison.Ordinal) &&
string.Equals(left.SourceRole, right.SourceRole, StringComparison.Ordinal);
private static void SendUpsert(
ICultNetSchemaServerPeer peer,
string subscriptionId,
CultNetRawDocumentRecord document,
bool added) =>
peer.SendCultNet(new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = added ? "added" : "updated",
Document = document
});
private static void SendRemoval(
ICultNetSchemaServerPeer peer,
string subscriptionId,
CultNetRawDocumentRecord document) =>
peer.SendCultNet(new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = "removed",
RecordKey = document.RecordKey,
SchemaId = document.SchemaId
});
private void Withdraw(SubscriptionKey key, bool sendRemovals, bool forgetRequest)
{
if (_subscriptions.TryRemove(key, out var subscription))
subscription.Dispose();
if (_projections.TryRemove(key, out var projection))
{
if (sendRemovals)
{
foreach (var delivered in projection.DeliveredBySourceRecordKey.Values)
SendRemoval(key.Peer, key.Id, delivered);
}
if (projection.DemandActive && _requests.TryGetValue(key, out var activeRequest))
PublishDemand(activeRequest, key, active: false);
}
if (forgetRequest)
_requests.TryRemove(key, out _);
}
private CultNetDatabaseChangeRawMessage? CreateAuthorizedChange(
object change,
CultNetDatabaseSubscribeMessage request,
@ -320,6 +514,27 @@ namespace GameCult.Networking
return ((CultNetDocumentPutRawMessage)put!).Document;
}
private sealed class SubscriptionProjectionState
{
public Dictionary<string, CultNetRawDocumentRecord> DeliveredBySourceRecordKey { get; } =
new(StringComparer.Ordinal);
public bool DemandActive { get; set; }
}
private sealed class ProjectedSnapshot
{
public ProjectedSnapshot(Dictionary<string, CultNetRawDocumentRecord> bySourceRecordKey)
{
BySourceRecordKey = bySourceRecordKey;
Documents = bySourceRecordKey.Values.ToArray();
}
public static ProjectedSnapshot Empty { get; } =
new(new Dictionary<string, CultNetRawDocumentRecord>(StringComparer.Ordinal));
public Dictionary<string, CultNetRawDocumentRecord> BySourceRecordKey { get; }
public CultNetRawDocumentRecord[] Documents { get; }
}
private sealed class SubscriptionKey
{
public SubscriptionKey(ICultNetSchemaServerPeer peer, string id)