feat(browser): stream bounded live session updates (#26)
This commit is contained in:
+233
-1
@@ -1177,6 +1177,159 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/sessions/stream": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Sessions"
|
||||
],
|
||||
"operationId": "StreamSessions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "contractVersion",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gameId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "environmentId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "protocolVersion",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "uint32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "regionId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeFull",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "streamCursor",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Last-Event-ID",
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"X-Rendezvous-Correlation-ID": {
|
||||
"description": "Safe request correlation identifier generated by the service.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionStreamEvent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"X-Rendezvous-Correlation-ID": {
|
||||
"description": "Safe request correlation identifier generated by the service.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"X-Rendezvous-Correlation-ID": {
|
||||
"description": "Safe request correlation identifier generated by the service.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"headers": {
|
||||
"X-Rendezvous-Correlation-ID": {
|
||||
"description": "Safe request correlation identifier generated by the service.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/sessions/{listingId}/join-attempts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2657,7 +2810,8 @@
|
||||
"BrowseSessionsResponse": {
|
||||
"required": [
|
||||
"contractVersion",
|
||||
"items"
|
||||
"items",
|
||||
"streamCursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2676,6 +2830,9 @@
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"streamCursor": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3545,6 +3702,54 @@
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"SessionStreamEvent": {
|
||||
"required": [
|
||||
"contractVersion",
|
||||
"kind",
|
||||
"cursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contractVersion": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/SessionStreamEventKind"
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionListing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"listingId": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionListingId"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionStreamEventKind": {
|
||||
"enum": [
|
||||
"sessionUpsert",
|
||||
"sessionRemove",
|
||||
"reset",
|
||||
"keepalive"
|
||||
]
|
||||
},
|
||||
"UpdateSessionRequest": {
|
||||
"required": [
|
||||
"contractVersion",
|
||||
@@ -3563,6 +3768,33 @@
|
||||
"leaseToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"regionId": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/RegionId"
|
||||
}
|
||||
]
|
||||
},
|
||||
"protocolVersion": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "uint32"
|
||||
},
|
||||
"visibility": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ListingVisibility"
|
||||
}
|
||||
]
|
||||
},
|
||||
"buildVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Live session-list updates
|
||||
|
||||
Tracking: #26
|
||||
|
||||
Live updates are an optional acceleration for an open server browser. The
|
||||
bounded `GET /v1/sessions` snapshot remains the source of truth, and join
|
||||
authorization still revalidates current capacity, presence, policy, and
|
||||
compatibility. A displayed player count is advisory, never an admission promise.
|
||||
|
||||
## Snapshot, stream, reset
|
||||
|
||||
Every `BrowseSessionsResponse` includes `streamCursor` in addition to its normal
|
||||
pagination cursor. Connect to `GET /v1/sessions/stream` with the same game,
|
||||
environment, protocol, optional region, and `excludeFull` filter. Send the most
|
||||
recent stream cursor as `Last-Event-ID`.
|
||||
|
||||
| SSE event | Contract kind | UI action |
|
||||
| --- | --- | --- |
|
||||
| `session_upsert` | `sessionUpsert` | Add or replace the complete public projection by listing ID. |
|
||||
| `session_remove` | `sessionRemove` | Remove the listing ID. |
|
||||
| `reset` | `reset` | Discard local state, fetch a fresh snapshot, then reconnect with its cursor. |
|
||||
| `keepalive` | `keepalive` | Preserve the cursor and connection; do not change UI state. |
|
||||
|
||||
Each SSE `id` equals the opaque cursor inside its JSON event. Cursors are signed,
|
||||
short-lived, monotonically ordered, and bound to the complete filter. A missing,
|
||||
expired, corrupted, foreign, future, or replay-gapped cursor produces `reset`
|
||||
instead of a potentially incomplete view. Do not parse or retain it as a stable
|
||||
identifier.
|
||||
|
||||
Updates cover creation after fresh UDP presence, public-field/capacity changes,
|
||||
presence staleness and recovery, lease expiry, deregistration, operator or
|
||||
principal revocation, and visibility/region/protocol changes. Events contain the
|
||||
same bounded public `SessionListing` as snapshots. They never contain raw peer
|
||||
endpoints, lease tokens, punch capabilities, tickets, publisher subjects, or
|
||||
internal store identifiers.
|
||||
|
||||
## SDK and polling fallback
|
||||
|
||||
```csharp
|
||||
BrowseSessionsRequest filter = new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
RegionId = new("eu-central"),
|
||||
ExcludeFull = true,
|
||||
};
|
||||
RendezvousClientResult<BrowseSessionsResponse> snapshot =
|
||||
await browser.BrowseAsync(filter, cancellationToken);
|
||||
|
||||
await foreach (RendezvousClientResult<SessionStreamEvent> update in
|
||||
browser.StreamAsync(filter, snapshot.Value!.StreamCursor, cancellationToken))
|
||||
{
|
||||
if (!update.IsSuccess)
|
||||
{
|
||||
// Switch to bounded polling with jittered backoff.
|
||||
break;
|
||||
}
|
||||
// Apply upsert/remove by listing ID. On reset, discard and browse again.
|
||||
}
|
||||
```
|
||||
|
||||
Cancellation or enumerator disposal closes the response and releases the server
|
||||
subscription. A normal connection-duration close is a reconnect signal: use the
|
||||
last applied event cursor. Repeated failures, unsupported platform HTTP stacks,
|
||||
and restrictive proxies fall back to snapshots with exponential jittered
|
||||
backoff, a capped interval, and `Retry-After`. Never open parallel streams to
|
||||
compensate for a slow UI.
|
||||
|
||||
## TestClient
|
||||
|
||||
```bash
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||
--configuration Release --no-build -- \
|
||||
watch --service https://rendezvous.example.invalid/ \
|
||||
--game space-game --environment production --region eu-central --protocol 7 \
|
||||
--run-seconds 60 --json
|
||||
```
|
||||
|
||||
`watch.snapshot`, `watch.session-upsert`, `watch.session-remove`,
|
||||
`watch.keepalive`, and `watch.reconnect` are stable diagnostics. Add
|
||||
`--exercise-reset --script` to corrupt the snapshot cursor deliberately and
|
||||
verify a typed reset plus snapshot refresh. Use `--exercise-reconnect --script`
|
||||
while producing one update to close the first stream deliberately, reconnect
|
||||
from its prior cursor, and verify that the same ordered event is replayed.
|
||||
Polished list diffing, selection retention, animation, and accessibility remain
|
||||
in each game.
|
||||
|
||||
## Bounds and slow consumers
|
||||
|
||||
The v1 journal retains at most 4,096 public-only changes. It admits at most 256
|
||||
subscribers total and 64 per tenant, reads at most 128 changes per batch,
|
||||
waits a configurable 50 milliseconds after a live change and coalesces the
|
||||
resulting batch to the final change per listing, sends a keepalive every 15
|
||||
seconds, and closes a connection after five minutes. A consumer behind the
|
||||
replay window receives `reset`; it never acquires an unbounded queue.
|
||||
|
||||
Normal optional-work concurrency and per-source/tenant rate controls apply for
|
||||
the stream lifetime. Exhaustion returns typed HTTP `429` before streaming.
|
||||
Shutdown cancels streams; reconnect only after readiness returns and expect a
|
||||
reset after a single-active restart because listings and replay are ephemeral.
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
- Disable response buffering (`X-Accel-Buffering: no` is also emitted),
|
||||
compression, transformation, and caching for `text/event-stream`.
|
||||
- Preserve `Last-Event-ID`; set upstream/read timeouts above the 15-second
|
||||
keepalive and around six minutes for the five-minute connection ceiling.
|
||||
- Flush events promptly and use HTTP/2 only when streaming semantics survive.
|
||||
- Preserve the source-IP trust boundary and abuse controls; do not add a bypass.
|
||||
|
||||
Verify the deployed proxy with an idle keepalive, update, reconnect, invalid
|
||||
cursor reset, slow reader, and graceful shutdown. An in-process pass does not
|
||||
prove that a production proxy is non-buffering.
|
||||
@@ -146,7 +146,14 @@ Never use an unbounded sleep to orchestrate processes. Wait for versioned events
|
||||
such as `host.ready` and apply a deadline. Useful success events are
|
||||
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
|
||||
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`,
|
||||
and `join.outcome-report`.
|
||||
`join.outcome-report`, `watch.snapshot`, `watch.session-upsert`,
|
||||
`watch.session-remove`, `watch.reset`, `watch.reconnect`, and `watch.complete`.
|
||||
|
||||
For a bounded live-directory diagnostic, use `watch --run-seconds 60`. Add
|
||||
`--exercise-reset --script` to prove fail-closed cursor recovery, or
|
||||
`--exercise-reconnect --script` while changing one listing to prove ordered
|
||||
`Last-Event-ID` replay after a deliberate disconnect. The full event and proxy
|
||||
contract is in [live session-list updates](live-session-updates.md).
|
||||
|
||||
| Exit | Meaning |
|
||||
| ---: | --- |
|
||||
|
||||
Reference in New Issue
Block a user