docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
466 lines
19 KiB
C#
466 lines
19 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading.Tasks;
|
|
using GameCult.Caching;
|
|
using LiteNetLib;
|
|
using R3;
|
|
|
|
namespace GameCult.Networking
|
|
{
|
|
/// <summary>
|
|
/// Routes CultNet schema-v0 document messages through a <see cref="CultNetDatabase"/>.
|
|
/// </summary>
|
|
public sealed class CultNetDatabaseServer : IDisposable
|
|
{
|
|
private readonly Server _server;
|
|
private readonly CultNetDatabase _database;
|
|
private readonly CultNetDatabaseServerOptions _options;
|
|
private readonly Func<CultNetSnapshotRequestMessage, CultNetServerPeer, Task> _snapshotHandler;
|
|
private readonly Func<CultNetDocumentPutRawMessage, CultNetServerPeer, Task> _putHandler;
|
|
private readonly Func<CultNetDocumentDeleteMessage, CultNetServerPeer, Task> _deleteHandler;
|
|
private readonly Func<CultNetShardCatalogRequestMessage, CultNetServerPeer, Task> _shardCatalogHandler;
|
|
private readonly Func<CultNetShardLogRequestMessage, CultNetServerPeer, Task> _shardLogHandler;
|
|
private readonly Func<CultNetDatabaseSubscribeMessage, CultNetServerPeer, Task> _subscribeHandler;
|
|
private readonly Func<CultNetDatabaseUnsubscribeMessage, CultNetServerPeer, Task> _unsubscribeHandler;
|
|
private readonly ConcurrentDictionary<string, IDisposable> _subscriptions = new(StringComparer.Ordinal);
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Creates and attaches a database message bridge to a server.
|
|
/// </summary>
|
|
public CultNetDatabaseServer(
|
|
Server server,
|
|
CultNetDatabase database,
|
|
CultNetDatabaseServerOptions? options = null)
|
|
{
|
|
_server = server ?? throw new ArgumentNullException(nameof(server));
|
|
_database = database ?? throw new ArgumentNullException(nameof(database));
|
|
_options = options ?? new CultNetDatabaseServerOptions();
|
|
_snapshotHandler = HandleSnapshotRequestAsync;
|
|
_putHandler = HandlePutAsync;
|
|
_deleteHandler = HandleDeleteAsync;
|
|
_shardCatalogHandler = HandleShardCatalogRequestAsync;
|
|
_shardLogHandler = HandleShardLogRequestAsync;
|
|
_subscribeHandler = HandleSubscribeAsync;
|
|
_unsubscribeHandler = HandleUnsubscribeAsync;
|
|
|
|
_server.OnCultNet(_snapshotHandler);
|
|
_server.OnCultNet(_putHandler);
|
|
_server.OnCultNet(_deleteHandler);
|
|
_server.OnCultNet(_shardCatalogHandler);
|
|
_server.OnCultNet(_shardLogHandler);
|
|
_server.OnCultNet(_subscribeHandler);
|
|
_server.OnCultNet(_unsubscribeHandler);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the database used by this bridge.
|
|
/// </summary>
|
|
public CultNetDatabase Database => _database;
|
|
|
|
/// <summary>
|
|
/// Applies a raw put message through the database shard policy.
|
|
/// </summary>
|
|
public Task<object> ApplyPutAsync(CultNetDocumentPutRawMessage message)
|
|
{
|
|
return _database.ApplyPutAsync(message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a raw delete message through the database shard policy.
|
|
/// </summary>
|
|
public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message)
|
|
{
|
|
return _database.ApplyDeleteAsync(message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a raw snapshot response from the database cache.
|
|
/// </summary>
|
|
public CultNetSnapshotResponseRawMessage CreateSnapshotResponse(CultNetSnapshotRequestMessage request)
|
|
{
|
|
if (request == null) throw new ArgumentNullException(nameof(request));
|
|
if (!string.IsNullOrWhiteSpace(request.ShardId))
|
|
{
|
|
var shard = _database.Shards.FirstOrDefault(candidate =>
|
|
string.Equals(candidate.ShardId, request.ShardId, StringComparison.Ordinal));
|
|
if (shard == null)
|
|
{
|
|
throw new InvalidOperationException($"Shard '{request.ShardId}' is not known by this database.");
|
|
}
|
|
|
|
if (request.ShardEpoch.HasValue && request.ShardEpoch.Value != shard.Epoch)
|
|
{
|
|
throw new CultNetShardAuthorityException(
|
|
shard,
|
|
$"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not request epoch {request.ShardEpoch.Value}.",
|
|
"stale_epoch");
|
|
}
|
|
|
|
return _database.CreateShardSnapshotResponse(
|
|
shard,
|
|
string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId,
|
|
request);
|
|
}
|
|
|
|
return _database.Documents.CreateRawSnapshotResponse(
|
|
_database.Cache,
|
|
string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId,
|
|
request);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a shard catalog response from the database shard map.
|
|
/// </summary>
|
|
public CultNetShardCatalogResponseMessage CreateShardCatalogResponse(CultNetShardCatalogRequestMessage request)
|
|
{
|
|
return _database.CreateShardCatalogResponse(request);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a shard mutation-log response from the database log.
|
|
/// </summary>
|
|
public CultNetShardLogResponseMessage CreateShardLogResponse(CultNetShardLogRequestMessage request)
|
|
{
|
|
if (request == null) throw new ArgumentNullException(nameof(request));
|
|
if (string.IsNullOrWhiteSpace(request.ShardId))
|
|
{
|
|
throw new ArgumentException("Shard log request requires a shardId.", nameof(request));
|
|
}
|
|
|
|
var shard = _database.Shards.FirstOrDefault(candidate =>
|
|
string.Equals(candidate.ShardId, request.ShardId, StringComparison.Ordinal));
|
|
if (shard == null)
|
|
{
|
|
return new CultNetShardLogResponseMessage
|
|
{
|
|
MessageId = RequestMessageId(request.MessageId),
|
|
ShardId = request.ShardId,
|
|
ResyncRequired = true,
|
|
Reason = "unknown_shard"
|
|
};
|
|
}
|
|
|
|
if (request.ShardEpoch.HasValue && request.ShardEpoch.Value != shard.Epoch)
|
|
{
|
|
return new CultNetShardLogResponseMessage
|
|
{
|
|
MessageId = RequestMessageId(request.MessageId),
|
|
ShardId = request.ShardId,
|
|
ShardEpoch = shard.Epoch,
|
|
ResyncRequired = true,
|
|
Reason = "stale_epoch"
|
|
};
|
|
}
|
|
|
|
var compactedThrough = _database.GetCompactedMutationLogSequence(request.ShardId);
|
|
if (request.AfterSequence < compactedThrough)
|
|
{
|
|
return new CultNetShardLogResponseMessage
|
|
{
|
|
MessageId = RequestMessageId(request.MessageId),
|
|
ShardId = request.ShardId,
|
|
ShardEpoch = shard.Epoch,
|
|
ResyncRequired = true,
|
|
Reason = "compacted_history",
|
|
CompactedThrough = compactedThrough
|
|
};
|
|
}
|
|
|
|
var entries = _database.GetMutationLogMessages(request.ShardId, request.AfterSequence, request.Limit)
|
|
.ToArray();
|
|
|
|
return new CultNetShardLogResponseMessage
|
|
{
|
|
MessageId = RequestMessageId(request.MessageId),
|
|
ShardId = request.ShardId,
|
|
ShardEpoch = shard.Epoch,
|
|
Entries = entries
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detaches handlers from the server.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
_server.RemoveCultNetMessageListener<CultNetSnapshotRequestMessage>(_snapshotHandler);
|
|
_server.RemoveCultNetMessageListener<CultNetDocumentPutRawMessage>(_putHandler);
|
|
_server.RemoveCultNetMessageListener<CultNetDocumentDeleteMessage>(_deleteHandler);
|
|
_server.RemoveCultNetMessageListener<CultNetShardCatalogRequestMessage>(_shardCatalogHandler);
|
|
_server.RemoveCultNetMessageListener<CultNetShardLogRequestMessage>(_shardLogHandler);
|
|
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribeHandler);
|
|
_server.RemoveCultNetMessageListener<CultNetDatabaseUnsubscribeMessage>(_unsubscribeHandler);
|
|
foreach (var subscription in _subscriptions.Values)
|
|
{
|
|
subscription.Dispose();
|
|
}
|
|
|
|
_subscriptions.Clear();
|
|
}
|
|
|
|
private Task HandleSnapshotRequestAsync(CultNetSnapshotRequestMessage request, CultNetServerPeer peer)
|
|
{
|
|
peer.SendCultNet(CreateSnapshotResponse(request));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task HandleShardCatalogRequestAsync(CultNetShardCatalogRequestMessage request, CultNetServerPeer peer)
|
|
{
|
|
peer.SendCultNet(CreateShardCatalogResponse(request));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task HandleShardLogRequestAsync(CultNetShardLogRequestMessage request, CultNetServerPeer peer)
|
|
{
|
|
try
|
|
{
|
|
peer.SendCultNet(CreateShardLogResponse(request));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer)
|
|
{
|
|
try
|
|
{
|
|
await ApplyPutAsync(message).ConfigureAwait(false);
|
|
}
|
|
catch (CultNetShardAuthorityException ex)
|
|
{
|
|
if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false))
|
|
{
|
|
_server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}");
|
|
peer.SendCultNet(CreateRoutingError(ex));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_server.Logger.LogError($"CultNet raw put failed: {ex.Message}");
|
|
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, CultNetServerPeer peer)
|
|
{
|
|
try
|
|
{
|
|
await ApplyDeleteAsync(message).ConfigureAwait(false);
|
|
}
|
|
catch (CultNetShardAuthorityException ex)
|
|
{
|
|
if (!await TryForwardDeleteAsync(ex, message).ConfigureAwait(false))
|
|
{
|
|
peer.SendCultNet(CreateRoutingError(ex));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private Task HandleSubscribeAsync(CultNetDatabaseSubscribeMessage message, CultNetServerPeer peer)
|
|
{
|
|
var subscriptionId = string.IsNullOrWhiteSpace(message.SubscriptionId)
|
|
? message.MessageId
|
|
: message.SubscriptionId;
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
{
|
|
peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." });
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var key = SubscriptionKey(peer.Peer, subscriptionId);
|
|
_subscriptions.AddOrUpdate(
|
|
key,
|
|
_ => CreateSubscription(message, subscriptionId, peer),
|
|
(_, existing) =>
|
|
{
|
|
existing.Dispose();
|
|
return CreateSubscription(message, subscriptionId, peer);
|
|
});
|
|
|
|
if (message.IncludeSnapshot)
|
|
{
|
|
peer.SendCultNet(CreateSnapshotResponse(new CultNetSnapshotRequestMessage
|
|
{
|
|
MessageId = message.MessageId,
|
|
SchemaIds = message.SchemaIds,
|
|
RecordKeys = message.RecordKeys
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
|
|
{
|
|
MessageId = message.MessageId,
|
|
Documents = Array.Empty<CultNetRawDocumentRecord>()
|
|
});
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task HandleUnsubscribeAsync(CultNetDatabaseUnsubscribeMessage message, CultNetServerPeer peer)
|
|
{
|
|
var subscriptionId = string.IsNullOrWhiteSpace(message.SubscriptionId)
|
|
? message.MessageId
|
|
: message.SubscriptionId;
|
|
if (!string.IsNullOrWhiteSpace(subscriptionId) &&
|
|
_subscriptions.TryRemove(SubscriptionKey(peer.Peer, subscriptionId), out var subscription))
|
|
{
|
|
subscription.Dispose();
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private IDisposable CreateSubscription(
|
|
CultNetDatabaseSubscribeMessage request,
|
|
string subscriptionId,
|
|
CultNetServerPeer peer)
|
|
{
|
|
// v0 has no engine of its own (docs/cultnet-selection-cut.md, D3/D4): it lowers into a
|
|
// Selection and is matched by the one evaluator, same as a v1 subscribe would be.
|
|
var selection = new CultNetSelection
|
|
{
|
|
Schemas = request.SchemaIds,
|
|
Keys = request.RecordKeys,
|
|
Projection = CultNetSelectionProjections.Document
|
|
};
|
|
return _database.WatchAllChanges().Subscribe(change =>
|
|
{
|
|
var outbound = CreateChangeMessage(change, subscriptionId, selection);
|
|
if (outbound != null)
|
|
{
|
|
peer.SendCultNet(outbound);
|
|
}
|
|
});
|
|
}
|
|
|
|
internal CultNetDatabaseChangeRawMessage? CreateChangeMessage(
|
|
object change,
|
|
string subscriptionId,
|
|
CultNetSelection selection)
|
|
{
|
|
var changeType = change.GetType();
|
|
var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(string.Empty));
|
|
var schemaId = (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? string.Empty;
|
|
var documentType = changeType.IsGenericType ? changeType.GetGenericArguments()[0] : null;
|
|
var descriptor = documentType == null ? null : _database.Cache.Registry.GetRequired(documentType);
|
|
var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated);
|
|
var document = changeType.GetProperty("Document")?.GetValue(change);
|
|
|
|
if (descriptor == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var binding = _database.Documents.GetByDocumentType(descriptor.DocumentType);
|
|
var effectiveSelection = CultNetSchemaAliasMatching.WithBindingSchemaAlias(selection, descriptor, binding?.SchemaId);
|
|
if (!CultNetSelectionEvaluator.Matches(descriptor, key, document, effectiveSelection))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (kind == CultNetDatabaseChangeKind.Removed || document == null)
|
|
{
|
|
return new CultNetDatabaseChangeRawMessage
|
|
{
|
|
MessageId = Guid.NewGuid().ToString("N"),
|
|
SubscriptionId = subscriptionId,
|
|
ChangeKind = "removed",
|
|
RecordKey = key.Value,
|
|
SchemaId = schemaId
|
|
};
|
|
}
|
|
|
|
return new CultNetDatabaseChangeRawMessage
|
|
{
|
|
MessageId = Guid.NewGuid().ToString("N"),
|
|
SubscriptionId = subscriptionId,
|
|
ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
|
|
Document = _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O"))
|
|
};
|
|
}
|
|
|
|
private static string SubscriptionKey(NetPeer peer, string subscriptionId)
|
|
{
|
|
return $"{peer.Id}:{subscriptionId}";
|
|
}
|
|
|
|
private static string RequestMessageId(string messageId)
|
|
{
|
|
return string.IsNullOrWhiteSpace(messageId)
|
|
? Guid.NewGuid().ToString("N")
|
|
: messageId;
|
|
}
|
|
|
|
private static CultNetErrorMessage CreateRoutingError(CultNetShardAuthorityException exception)
|
|
{
|
|
return new CultNetErrorMessage
|
|
{
|
|
Error = exception.Message,
|
|
RoutingHint = new CultNetShardRoutingHint
|
|
{
|
|
Reason = exception.Reason,
|
|
Shard = CultNetDatabase.ToMessage(exception.Shard)
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to forward a rejected raw put to the shard owner.
|
|
/// </summary>
|
|
public async Task<bool> TryForwardPutAsync(
|
|
CultNetShardAuthorityException exception,
|
|
CultNetDocumentPutRawMessage message)
|
|
{
|
|
if (!_options.ForwardNonPrimaryWrites ||
|
|
_options.WriteForwarder == null ||
|
|
exception.Reason != "not_primary")
|
|
{
|
|
return false;
|
|
}
|
|
|
|
message.ShardId ??= exception.Shard.ShardId;
|
|
message.ShardEpoch ??= exception.Shard.Epoch;
|
|
await _options.WriteForwarder.ForwardPutAsync(exception.Shard, message).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to forward a rejected raw delete to the shard owner.
|
|
/// </summary>
|
|
public async Task<bool> TryForwardDeleteAsync(
|
|
CultNetShardAuthorityException exception,
|
|
CultNetDocumentDeleteMessage message)
|
|
{
|
|
if (!_options.ForwardNonPrimaryWrites ||
|
|
_options.WriteForwarder == null ||
|
|
exception.Reason != "not_primary")
|
|
{
|
|
return false;
|
|
}
|
|
|
|
message.ShardId ??= exception.Shard.ShardId;
|
|
message.ShardEpoch ??= exception.Shard.Epoch;
|
|
await _options.WriteForwarder.ForwardDeleteAsync(exception.Shard, message).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
}
|
|
}
|