Skip to content

Support serializing session changesets #568

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
106 changes: 106 additions & 0 deletions designs/session-serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Session serialization

Session serialization is provided through the `ISessionSerializer` type. There are two modes that are available:

> [!NOTE]
> The bit offsets are general guidelines here to show the layout. The diagram tool does not have an option to turn it off at the moment. See the descriptions for details on bit-length

## Common structure

```mermaid
packet-beta
0: "M"
1-10: "Session Id (Variable length)"
11: "N"
12: "A"
13: "R"
14-17: "T"
18-21: "C"
22-31: "Key 1 Blob"
32-39: "Key 2 Blob"
40-48: "..."
49-59: "Flags (variable)"
```

Where:
- `M`: Mode `byte`
- `N`: New session `bool`
- `A`: Abandoned `bool`
- `R`: Readonly `bool`
- `T`: Timeout `7-bit encoded int`
- `C`: Key count `7-bit encoded int`

## Flags

Flags allow for additional information to be sent either direction that may not be known initially. This field was added v2 but is backwards compatible with the v1 deserializer and will operate as a no-op as it just reads the things it knows about and doesn't look for the end of a payload.

Structure:

```mermaid
packet-beta
0: "C"
1: "F1"
2: "F1L"
3-10: "Flag1 specific payload"
11: "F2"
12: "F2L"
13-20: "Flag2 specific payload"
21-25: "..."
```

Where:
- `Fn`: Flag `n`
- `C`: Flag count `7-bit encoded int`
- `Fn`: Custom identifier `7-bit encoded int`
- `FnL`: Flag payload (type determined by `Fn`)

An example is the flag section used to indicate that there is support for diffing a session state on the server:

```mermaid
packet-beta
0: "1"
1: "100"
2: "0"
```

## Unknown keys

If the unknown keys array is included, it has the following pattern:

```mermaid
packet-beta
0: "C"
1-11: "Key1"
12-20: "Key2"
21-23: "..."
24-31: "KeyN"
```

Where:

- `C` is the count *(Note: 7-bit encoded int)*

## Full Copy (Mode = 1)

The following is the structure of the key blobs when the full state is serialized:

```mermaid
packet-beta
0-10: "Key name"
11-20: "Serialized value"
```

## Diffing Support (Mode = 2)

The following is the structure of the key blobs when only the difference is serialized:

```mermaid
packet-beta
0-10: "Key name"
11: "S"
12-20: "Serialized value"
```

Where:
- *S*: A value indicating the change the key has undergone from the values in `SessionItemChangeState`

4 changes: 2 additions & 2 deletions samples/RemoteSession/RemoteSessionCore/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.SystemWebAdapters.SessionState": "Trace"
}
},
"AllowedHosts": "*",
"RemoteApp": {
"Key": "23EB1AEF-E019-4850-A257-3DB3A85495BD",
"Url": "https://localhost:44305"
}

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ protected override async Task<ISessionState> GetSessionDataAsync(string? session
var request = new HttpRequestMessage(HttpMethod.Get, Options.Path.Relative);

AddSessionCookieToHeader(request, sessionId);
AddReadOnlyHeader(request, readOnly);
AddRemoteSessionHeaders(request, readOnly);

var response = await BackchannelClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);

Expand Down Expand Up @@ -71,22 +71,19 @@ protected override async Task<ISessionState> GetSessionDataAsync(string? session
return new RemoteSessionState(remoteSessionState, request, response, this);
}

private sealed class SerializedSessionHttpContent : HttpContent
private sealed class SerializedSessionHttpContent(
ISessionSerializer serializer,
ISessionState state,
SessionSerializerContext context
) : HttpContent
{
private readonly ISessionSerializer _serializer;
private readonly ISessionState _state;

public SerializedSessionHttpContent(ISessionSerializer serializer, ISessionState state)
{
_serializer = serializer;
_state = state;
}

protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
=> SerializeToStreamAsync(stream, context, default);

protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
=> _serializer.SerializeAsync(_state, stream, cancellationToken);
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? _, CancellationToken cancellationToken)
{
return serializer.SerializeAsync(state, context, stream, cancellationToken);
}

protected override bool TryComputeLength(out long length)
{
Expand All @@ -95,7 +92,12 @@ protected override bool TryComputeLength(out long length)
}
}

private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, DoubleConnectionRemoteAppSessionManager manager) : DelegatingSessionState
private sealed class RemoteSessionState(
ISessionState other,
HttpRequestMessage request,
HttpResponseMessage response,
DoubleConnectionRemoteAppSessionManager manager
) : DelegatingSessionState
{
protected override ISessionState State => other;

Expand All @@ -112,18 +114,19 @@ protected override void Dispose(bool disposing)

public override async Task CommitAsync(CancellationToken token)
{
var sessionContext = manager.GetSupportedSerializerContext(response);
using var request = new HttpRequestMessage(HttpMethod.Put, manager.Options.Path.Relative)
{
Content = new SerializedSessionHttpContent(manager.Serializer, State)
Content = new SerializedSessionHttpContent(manager.Serializer, State, sessionContext)
};

manager.AddSessionCookieToHeader(request, State.SessionID);

using var response = await manager.BackchannelClient.SendAsync(request, token);
using var result = await manager.BackchannelClient.SendAsync(request, token);

manager.LogCommitResponse(response.StatusCode);
manager.LogCommitResponse(result.StatusCode);

response.EnsureSuccessStatusCode();
result.EnsureSuccessStatusCode();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public async Task<ISessionState> CreateAsync(HttpContextCore context, SessionAtt
// future attempts will fallback to the double until the option value is reset.
catch (HttpRequestException ex) when (ServerDoesNotSupportSingleConnection(ex))
{
LogServerDoesNotSupportSingleConnection(ex);
LogServerDoesNotSupportSingleConnection();
_options.Value.UseSingleConnection = false;
}
catch (Exception ex)
Expand Down Expand Up @@ -104,7 +104,7 @@ private static bool ServerDoesNotSupportSingleConnection(HttpRequestException ex
}

[LoggerMessage(0, LogLevel.Warning, "The server does not support the single connection mode for remote session. Falling back to double connection mode. This must be manually reset to try again.")]
private partial void LogServerDoesNotSupportSingleConnection(HttpRequestException ex);
private partial void LogServerDoesNotSupportSingleConnection();

[LoggerMessage(1, LogLevel.Error, "Failed to connect to server with an unknown reason")]
private partial void LogServerFailedSingelConnection(Exception ex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Buffers;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
Expand Down Expand Up @@ -53,6 +54,9 @@ protected RemoteAppSessionStateManager(
[LoggerMessage(EventId = 3, Level = LogLevel.Trace, Message = "Received {StatusCode} response committing remote session state")]
protected partial void LogCommitResponse(HttpStatusCode statusCode);

[LoggerMessage(EventId = 4, Level = LogLevel.Trace, Message = "Server supports version {Version} for serializing")]
protected partial void LogServerVersionSupport(byte version);

public Task<ISessionState> CreateAsync(HttpContextCore context, SessionAttribute metadata)
=> CreateAsync(context, metadata.IsReadOnly);

Expand Down Expand Up @@ -97,6 +101,24 @@ protected void AddSessionCookieToHeader(HttpRequestMessage req, string? sessionI
}
}

protected static void AddReadOnlyHeader(HttpRequestMessage req, bool readOnly)
=> req.Headers.Add(SessionConstants.ReadOnlyHeaderName, readOnly.ToString());
protected static void AddRemoteSessionHeaders(HttpRequestMessage req, bool readOnly)
{
if (readOnly)
{
req.Headers.Add(SessionConstants.ReadOnlyHeaderName, readOnly.ToString());
}

req.Headers.Add(SessionConstants.SupportedVersion, SessionSerializerContext.Latest.SupportedVersion.ToString(CultureInfo.InvariantCulture));
}

protected SessionSerializerContext GetSupportedSerializerContext(HttpResponseMessage message)
{
var context = message.Headers.TryGetValues(SessionConstants.SupportedVersion, out var versions)
? SessionSerializerContext.Parse(versions)
: SessionSerializerContext.Default;

LogServerVersionSupport(context.SupportedVersion);

return context;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ protected override async Task<ISessionState> GetSessionDataAsync(string? session
};

AddSessionCookieToHeader(request, sessionId);
AddReadOnlyHeader(request, readOnly);
AddRemoteSessionHeaders(request, readOnly);

var response = await BackchannelClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);

Expand Down Expand Up @@ -79,7 +79,7 @@ protected override async Task<ISessionState> GetSessionDataAsync(string? session
// session expired.
PropagateHeaders(response, callingContext, HeaderNames.SetCookie);

return new RemoteSessionState(remoteSessionState, request, response, content, responseStream);
return new RemoteSessionState(remoteSessionState, request, response, GetSupportedSerializerContext(response), content, responseStream);
}

[JsonSerializable(typeof(SessionPostResult))]
Expand All @@ -89,7 +89,7 @@ private sealed partial class SessionPostResultContext : JsonSerializerContext

private sealed class CommittingSessionHttpContent : HttpContent
{
private readonly TaskCompletionSource<ISessionState> _state;
private readonly TaskCompletionSource<(ISessionState, SessionSerializerContext)> _state;

public CommittingSessionHttpContent(ISessionSerializer serializer)
{
Expand All @@ -99,16 +99,17 @@ public CommittingSessionHttpContent(ISessionSerializer serializer)

public ISessionSerializer Serializer { get; }

public void Commit(ISessionState state) => _state.SetResult(state);
public void Commit(SessionSerializerContext context, ISessionState state) => _state.SetResult((state, context));

protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
=> SerializeToStreamAsync(stream, context, default);

protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
{
await stream.FlushAsync(cancellationToken);
var state = await _state.Task;
await Serializer.SerializeAsync(state, stream, cancellationToken);
var (state, sessionContext) = await _state.Task;

await Serializer.SerializeAsync(state, sessionContext, stream, cancellationToken);
}

protected override bool TryComputeLength(out long length)
Expand All @@ -118,7 +119,15 @@ protected override bool TryComputeLength(out long length)
}
}

private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, CommittingSessionHttpContent content, Stream stream) : DelegatingSessionState

private sealed class RemoteSessionState(
ISessionState other,
HttpRequestMessage request,
HttpResponseMessage response,
SessionSerializerContext sessionContext,
CommittingSessionHttpContent content,
Stream stream
) : DelegatingSessionState
{
protected override ISessionState State => other;

Expand All @@ -137,7 +146,7 @@ protected override void Dispose(bool disposing)

public override async Task CommitAsync(CancellationToken token)
{
content.Commit(State);
content.Commit(sessionContext, State);

var result = await JsonSerializer.DeserializeAsync(stream, SessionPostResultContext.Default.SessionPostResult, token);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession;

internal sealed class GetWriteableSessionHandler : HttpTaskAsyncHandler, IRequiresSessionState
internal sealed class GetWriteableSessionHandler : VersionedSessionHandler, IRequiresSessionState
{
private const byte EndOfFrame = (byte)'\n';

Expand All @@ -24,17 +24,7 @@ public GetWriteableSessionHandler(ISessionSerializer serializer, ILockedSessionC
_cache = cache;
}

public override async Task ProcessRequestAsync(HttpContext context)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(context.Session.Timeout));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, context.Response.ClientDisconnectedToken);

await ProcessRequestAsync(new HttpContextWrapper(context), cts.Token).ConfigureAwait(false);

context.ApplicationInstance.CompleteRequest();
}

public async Task ProcessRequestAsync(HttpContextBase context, CancellationToken token)
public override async Task ProcessRequestAsync(HttpContextBase context, SessionSerializerContext sessionContext, CancellationToken token)
{
// If session data is retrieved exclusively, then it needs sent to the client and
// this request needs to remain open while waiting for the client to either send updates
Expand All @@ -50,7 +40,7 @@ public async Task ProcessRequestAsync(HttpContextBase context, CancellationToken

using var wrapper = new HttpSessionStateBaseWrapper(context.Session);

await _serializer.SerializeAsync(wrapper, context.Response.OutputStream, token);
await _serializer.SerializeAsync(wrapper, sessionContext, context.Response.OutputStream, token);

// Delimit the json body with a new line to mark the end of content
context.Response.OutputStream.WriteByte(EndOfFrame);
Expand Down
Loading