feat(browser): stream bounded live session updates (#26)
This commit is contained in:
@@ -12,6 +12,8 @@ internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Val
|
||||
internal sealed class SessionBrowserService(
|
||||
IEphemeralRendezvousStore store,
|
||||
SessionBrowserCursorCodec cursors,
|
||||
SessionStreamCursorCodec streamCursors,
|
||||
SessionChangeJournal changes,
|
||||
IWallClock clock)
|
||||
{
|
||||
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||
@@ -45,9 +47,25 @@ internal sealed class SessionBrowserService(
|
||||
request.PageSize + 1,
|
||||
after,
|
||||
request.ExcludeFull);
|
||||
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
||||
query,
|
||||
cancellationToken);
|
||||
StoreResult<IReadOnlyList<StoredListing>> found = default!;
|
||||
long streamRevision = 0;
|
||||
bool stableSnapshot = false;
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
long before = changes.CurrentRevision;
|
||||
found = store.BrowseVisibleListings(query, cancellationToken);
|
||||
long afterRevision = changes.CurrentRevision;
|
||||
if (before == afterRevision)
|
||||
{
|
||||
streamRevision = afterRevision;
|
||||
stableSnapshot = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!stableSnapshot)
|
||||
{
|
||||
return new(RendezvousErrorCode.ServiceUnavailable);
|
||||
}
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||
@@ -65,7 +83,12 @@ internal sealed class SessionBrowserService(
|
||||
string? nextCursor = hasMore
|
||||
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||
: null;
|
||||
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
||||
BrowseSessionsResponse response = new()
|
||||
{
|
||||
Items = items,
|
||||
NextCursor = nextCursor,
|
||||
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
|
||||
};
|
||||
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||
<= ContractLimits.BrowserResponseMaxBytes)
|
||||
{
|
||||
@@ -76,7 +99,10 @@ internal sealed class SessionBrowserService(
|
||||
hasMore = true;
|
||||
}
|
||||
|
||||
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
||||
return new(RendezvousErrorCode.None, new BrowseSessionsResponse
|
||||
{
|
||||
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
|
||||
});
|
||||
}
|
||||
|
||||
public BrowserServiceResult<GetSessionResponse> Get(
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed record SessionChangeJournalOptions
|
||||
{
|
||||
public int ReplayCapacity { get; init; } = 4096;
|
||||
public int MaximumSubscribers { get; init; } = 256;
|
||||
public int MaximumSubscribersPerTenant { get; init; } = 64;
|
||||
public int MaximumBatchSize { get; init; } = 128;
|
||||
public TimeSpan CoalesceInterval { get; init; } = TimeSpan.FromMilliseconds(50);
|
||||
public TimeSpan KeepaliveInterval { get; init; } = TimeSpan.FromSeconds(15);
|
||||
public TimeSpan MaximumConnectionDuration { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ReplayCapacity is < 64 or > 65_536
|
||||
|| MaximumSubscribers is < 1 or > 4096
|
||||
|| MaximumSubscribersPerTenant < 1
|
||||
|| MaximumSubscribersPerTenant > MaximumSubscribers
|
||||
|| MaximumBatchSize is < 1 or > 1024
|
||||
|| CoalesceInterval < TimeSpan.Zero
|
||||
|| CoalesceInterval > TimeSpan.FromSeconds(1)
|
||||
|| KeepaliveInterval < TimeSpan.FromSeconds(1)
|
||||
|| KeepaliveInterval > TimeSpan.FromMinutes(1)
|
||||
|| MaximumConnectionDuration < KeepaliveInterval
|
||||
|| MaximumConnectionDuration > TimeSpan.FromMinutes(30))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(SessionChangeJournalOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record SessionChange(
|
||||
long Revision,
|
||||
SessionListingProjection? Before,
|
||||
SessionListingProjection? After)
|
||||
{
|
||||
public SessionListingId ListingId => (After ?? Before)!.Listing.ListingId;
|
||||
}
|
||||
|
||||
internal sealed record SessionChangeBatch(
|
||||
long CurrentRevision,
|
||||
bool RequiresReset,
|
||||
IReadOnlyList<SessionChange> Changes);
|
||||
|
||||
internal sealed class SessionListingProjection
|
||||
{
|
||||
private SessionListingProjection(SessionListing listing, bool visible)
|
||||
{
|
||||
Listing = listing;
|
||||
Visible = visible;
|
||||
}
|
||||
|
||||
public SessionListing Listing { get; }
|
||||
public bool Visible { get; }
|
||||
|
||||
public static SessionListingProjection From(StoredListing stored) => new(
|
||||
new SessionListing
|
||||
{
|
||||
ListingId = stored.Definition.ListingId,
|
||||
GameId = stored.Definition.Scope.GameId,
|
||||
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||
RegionId = stored.Definition.RegionId,
|
||||
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||
BuildVersion = stored.Definition.BuildVersion,
|
||||
DisplayName = stored.Definition.DisplayName,
|
||||
Visibility = stored.Definition.Visibility,
|
||||
PublisherTrustMode = stored.Definition.TrustMode,
|
||||
Capacity = new SessionCapacity
|
||||
{
|
||||
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||
},
|
||||
Metadata = new Dictionary<string, string>(stored.Definition.Metadata, StringComparer.Ordinal),
|
||||
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
|
||||
},
|
||||
stored.HasFreshPresence && stored.Definition.Visibility == ListingVisibility.Public);
|
||||
|
||||
public bool Matches(VisibleListingQuery query) => Visible
|
||||
&& Listing.GameId == query.Scope.GameId
|
||||
&& Listing.EnvironmentId == query.Scope.EnvironmentId
|
||||
&& Listing.ProtocolVersion == query.ProtocolVersion
|
||||
&& (!query.RegionId.HasValue || Listing.RegionId == query.RegionId.Value)
|
||||
&& (!query.ExcludeFull
|
||||
|| Listing.Capacity.CurrentPlayers < Listing.Capacity.MaximumPlayers);
|
||||
|
||||
public static bool Equivalent(SessionListingProjection? left, SessionListingProjection? right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (left is null || right is null || left.Visible != right.Visible)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SessionListing a = left.Listing;
|
||||
SessionListing b = right.Listing;
|
||||
return a.ListingId == b.ListingId
|
||||
&& a.GameId == b.GameId
|
||||
&& a.EnvironmentId == b.EnvironmentId
|
||||
&& a.RegionId == b.RegionId
|
||||
&& a.ProtocolVersion == b.ProtocolVersion
|
||||
&& string.Equals(a.BuildVersion, b.BuildVersion, StringComparison.Ordinal)
|
||||
&& string.Equals(a.DisplayName, b.DisplayName, StringComparison.Ordinal)
|
||||
&& a.Visibility == b.Visibility
|
||||
&& a.PublisherTrustMode == b.PublisherTrustMode
|
||||
&& a.Capacity.CurrentPlayers == b.Capacity.CurrentPlayers
|
||||
&& a.Capacity.MaximumPlayers == b.Capacity.MaximumPlayers
|
||||
&& a.Metadata.Count == b.Metadata.Count
|
||||
&& a.Metadata.All(item => b.Metadata.TryGetValue(item.Key, out string? value)
|
||||
&& string.Equals(item.Value, value, StringComparison.Ordinal))
|
||||
&& EndpointEquals(a.DedicatedFallback, b.DedicatedFallback);
|
||||
}
|
||||
|
||||
private static bool EndpointEquals(NetworkEndpoint? left, NetworkEndpoint? right) =>
|
||||
left is null && right is null
|
||||
|| left is not null && right is not null
|
||||
&& left.AddressFamily == right.AddressFamily
|
||||
&& string.Equals(left.Address, right.Address, StringComparison.Ordinal)
|
||||
&& left.Port == right.Port;
|
||||
}
|
||||
|
||||
internal sealed class SessionChangeJournal
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly SessionChangeJournalOptions _options;
|
||||
private readonly Queue<SessionChange> _changes = [];
|
||||
private TaskCompletionSource<long> _changed = NewSignal();
|
||||
private long _revision;
|
||||
private int _subscribers;
|
||||
private readonly Dictionary<TenantScope, int> _subscribersByTenant = [];
|
||||
|
||||
public SessionChangeJournal(SessionChangeJournalOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate();
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public SessionChangeJournalOptions Options => _options;
|
||||
|
||||
public long CurrentRevision
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish(StoredListing? before, StoredListing? after)
|
||||
{
|
||||
SessionListingProjection? previous = before is null ? null : SessionListingProjection.From(before);
|
||||
SessionListingProjection? current = after is null ? null : SessionListingProjection.From(after);
|
||||
if (SessionListingProjection.Equivalent(previous, current)
|
||||
|| previous is { Visible: false } && current is null
|
||||
|| previous is null && current is { Visible: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TaskCompletionSource<long> signal;
|
||||
long revision;
|
||||
lock (_gate)
|
||||
{
|
||||
revision = ++_revision;
|
||||
_changes.Enqueue(new SessionChange(revision, previous, current));
|
||||
while (_changes.Count > _options.ReplayCapacity)
|
||||
{
|
||||
_changes.Dequeue();
|
||||
}
|
||||
signal = _changed;
|
||||
_changed = NewSignal();
|
||||
}
|
||||
signal.TrySetResult(revision);
|
||||
}
|
||||
|
||||
public SessionChangeBatch ReadAfter(long revision)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
long oldest = _changes.TryPeek(out SessionChange? first)
|
||||
? first.Revision
|
||||
: _revision + 1;
|
||||
if (revision < oldest - 1 || revision > _revision)
|
||||
{
|
||||
return new(_revision, true, []);
|
||||
}
|
||||
|
||||
SessionChange[] changes = _changes
|
||||
.Where(change => change.Revision > revision)
|
||||
.Take(_options.MaximumBatchSize)
|
||||
.ToArray();
|
||||
return new(_revision, false, changes);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> WaitForChangeAsync(
|
||||
long revision,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Task<long> signal;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_revision > revision)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
signal = _changed.Task;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await signal.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySubscribe(TenantScope scope, out IDisposable? lease)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_subscribers >= _options.MaximumSubscribers
|
||||
|| _subscribersByTenant.GetValueOrDefault(scope)
|
||||
>= _options.MaximumSubscribersPerTenant)
|
||||
{
|
||||
lease = null;
|
||||
return false;
|
||||
}
|
||||
_subscribers++;
|
||||
_subscribersByTenant[scope] = _subscribersByTenant.GetValueOrDefault(scope) + 1;
|
||||
lease = new Subscription(this, scope);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Release(TenantScope scope)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_subscribers--;
|
||||
int remaining = _subscribersByTenant[scope] - 1;
|
||||
if (remaining == 0)
|
||||
{
|
||||
_subscribersByTenant.Remove(scope);
|
||||
}
|
||||
else
|
||||
{
|
||||
_subscribersByTenant[scope] = remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskCompletionSource<long> NewSignal() => new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private sealed class Subscription(
|
||||
SessionChangeJournal owner,
|
||||
TenantScope scope) : IDisposable
|
||||
{
|
||||
private SessionChangeJournal? _owner = owner;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(scope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed class SessionStreamCursorCodec : IDisposable
|
||||
{
|
||||
private const string Prefix = "rvs1";
|
||||
private readonly EphemeralCursorProtector _protector = new();
|
||||
|
||||
public string Encode(VisibleListingQuery query, long revision, DateTimeOffset now)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(revision);
|
||||
SessionStreamCursorPayload payload = new()
|
||||
{
|
||||
GameId = query.Scope.GameId.Value,
|
||||
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||
ProtocolVersion = query.ProtocolVersion,
|
||||
RegionId = query.RegionId?.Value,
|
||||
ExcludeFull = query.ExcludeFull,
|
||||
Revision = revision,
|
||||
ExpiresAtUnixSeconds = now.AddMinutes(10).ToUnixTimeSeconds(),
|
||||
};
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||
try
|
||||
{
|
||||
return _protector.Protect(Prefix, encoded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecode(
|
||||
string? cursor,
|
||||
VisibleListingQuery query,
|
||||
DateTimeOffset now,
|
||||
out long revision)
|
||||
{
|
||||
revision = 0;
|
||||
if (cursor is null || !_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SessionStreamCursorPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<SessionStreamCursorPayload>(
|
||||
encodedPayload,
|
||||
ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
payload = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||
}
|
||||
|
||||
if (payload is null
|
||||
|| payload.Revision < 0
|
||||
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||
|| !string.Equals(payload.GameId, query.Scope.GameId.Value, StringComparison.Ordinal)
|
||||
|| !string.Equals(payload.EnvironmentId, query.Scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||
|| payload.ProtocolVersion != query.ProtocolVersion
|
||||
|| !string.Equals(payload.RegionId, query.RegionId?.Value, StringComparison.Ordinal)
|
||||
|| payload.ExcludeFull != query.ExcludeFull)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
revision = payload.Revision;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose() => _protector.Dispose();
|
||||
|
||||
public override string ToString() => "[SessionStreamCursorCodec: key and cursors redacted]";
|
||||
}
|
||||
|
||||
internal sealed class SessionStreamCursorPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public string GameId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string EnvironmentId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public uint ProtocolVersion { get; set; }
|
||||
|
||||
public string? RegionId { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public bool ExcludeFull { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public long Revision { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public long ExpiresAtUnixSeconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed record SessionStreamReadResult(
|
||||
bool RequiresReset,
|
||||
IReadOnlyList<SessionStreamEvent> Events);
|
||||
|
||||
internal sealed class SessionStreamSubscription : IDisposable
|
||||
{
|
||||
private IDisposable? _lease;
|
||||
|
||||
public SessionStreamSubscription(
|
||||
VisibleListingQuery query,
|
||||
long revision,
|
||||
bool requiresReset,
|
||||
IDisposable lease)
|
||||
{
|
||||
Query = query;
|
||||
Revision = revision;
|
||||
RequiresReset = requiresReset;
|
||||
_lease = lease;
|
||||
}
|
||||
|
||||
public VisibleListingQuery Query { get; }
|
||||
public long Revision { get; set; }
|
||||
public bool RequiresReset { get; set; }
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _lease, null)?.Dispose();
|
||||
}
|
||||
|
||||
internal sealed class SessionStreamService(
|
||||
SessionChangeJournal changes,
|
||||
SessionStreamCursorCodec cursors,
|
||||
IWallClock clock)
|
||||
{
|
||||
public BrowserServiceResult<SessionStreamSubscription> Subscribe(
|
||||
BrowseSessionsRequest request,
|
||||
string? cursor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = Validate(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
VisibleListingQuery query = new(
|
||||
new TenantScope(request.GameId, request.EnvironmentId),
|
||||
request.ProtocolVersion,
|
||||
request.RegionId,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
ExcludeFull: request.ExcludeFull);
|
||||
if (!changes.TrySubscribe(query.Scope, out IDisposable? lease) || lease is null)
|
||||
{
|
||||
return new(RendezvousErrorCode.CapacityExceeded);
|
||||
}
|
||||
bool validCursor = cursors.TryDecode(cursor, query, clock.UtcNow, out long revision);
|
||||
if (!validCursor)
|
||||
{
|
||||
revision = changes.CurrentRevision;
|
||||
}
|
||||
return new(RendezvousErrorCode.None, new SessionStreamSubscription(
|
||||
query,
|
||||
revision,
|
||||
requiresReset: !validCursor,
|
||||
lease));
|
||||
}
|
||||
|
||||
public SessionStreamReadResult Read(SessionStreamSubscription subscription)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
if (subscription.RequiresReset)
|
||||
{
|
||||
subscription.RequiresReset = false;
|
||||
return new(true, []);
|
||||
}
|
||||
|
||||
SessionChangeBatch batch = changes.ReadAfter(subscription.Revision);
|
||||
if (batch.RequiresReset)
|
||||
{
|
||||
subscription.Revision = batch.CurrentRevision;
|
||||
return new(true, []);
|
||||
}
|
||||
if (batch.Changes.Count == 0)
|
||||
{
|
||||
return new(false, []);
|
||||
}
|
||||
|
||||
Dictionary<SessionListingId, PendingDelta> coalesced = [];
|
||||
foreach (SessionChange change in batch.Changes)
|
||||
{
|
||||
bool beforeMatches = change.Before?.Matches(subscription.Query) == true;
|
||||
bool afterMatches = change.After?.Matches(subscription.Query) == true;
|
||||
if (!beforeMatches && !afterMatches)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
coalesced[change.ListingId] = afterMatches
|
||||
? new(change.Revision, SessionStreamEventKind.SessionUpsert, change.After!.Listing)
|
||||
: new(change.Revision, SessionStreamEventKind.SessionRemove, null);
|
||||
}
|
||||
|
||||
subscription.Revision = batch.Changes[^1].Revision;
|
||||
SessionStreamEvent[] events = coalesced
|
||||
.OrderBy(static item => item.Value.Revision)
|
||||
.Select(item => ToEvent(item.Key, item.Value, subscription.Query))
|
||||
.ToArray();
|
||||
return new(false, events);
|
||||
}
|
||||
|
||||
public async Task<bool> WaitForChangeAsync(
|
||||
SessionStreamSubscription subscription,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool changed = await changes.WaitForChangeAsync(
|
||||
subscription.Revision,
|
||||
changes.Options.KeepaliveInterval,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (changed && changes.Options.CoalesceInterval > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(changes.Options.CoalesceInterval, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public SessionStreamEvent ResetEvent(SessionStreamSubscription subscription) => new()
|
||||
{
|
||||
Kind = SessionStreamEventKind.Reset,
|
||||
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
|
||||
};
|
||||
|
||||
public SessionStreamEvent KeepaliveEvent(SessionStreamSubscription subscription) => new()
|
||||
{
|
||||
Kind = SessionStreamEventKind.Keepalive,
|
||||
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
|
||||
};
|
||||
|
||||
public TimeSpan MaximumConnectionDuration => changes.Options.MaximumConnectionDuration;
|
||||
|
||||
private SessionStreamEvent ToEvent(
|
||||
SessionListingId listingId,
|
||||
PendingDelta delta,
|
||||
VisibleListingQuery query) => new()
|
||||
{
|
||||
Kind = delta.Kind,
|
||||
Cursor = cursors.Encode(query, delta.Revision, clock.UtcNow),
|
||||
Session = delta.Session,
|
||||
ListingId = delta.Kind == SessionStreamEventKind.SessionRemove ? listingId : null,
|
||||
};
|
||||
|
||||
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
return string.IsNullOrEmpty(request.GameId.Value)
|
||||
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||
|| request.ProtocolVersion == 0
|
||||
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private sealed record PendingDelta(
|
||||
long Revision,
|
||||
SessionStreamEventKind Kind,
|
||||
SessionListing? Session);
|
||||
}
|
||||
Reference in New Issue
Block a user