Skip to content

Commit 3b2eb82

Browse files
committed
Use HTTP2 full duplex for committing session
1 parent db889ac commit 3b2eb82

File tree

11 files changed

+437
-145
lines changed

11 files changed

+437
-145
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.IO;
6+
using System.Net;
7+
using System.Net.Http;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization;
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.Extensions.Options;
13+
using Microsoft.Net.Http.Headers;
14+
15+
namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession;
16+
17+
/// <summary>
18+
/// This is an implementation of <see cref="ISessionManager"/> that connects to an upstream session store.
19+
/// <list type="bullet">
20+
/// <item>If the request is readonly, it will close the remote connection and return the session state.</item>
21+
/// <item>If the request is not readonly, it will hold onto the remote connection and initiate a new connection to PUT the results.</item>
22+
/// </list>
23+
/// </summary>
24+
/// <remarks>
25+
/// For the non-readonly mode, it is preferrable to use <see cref="SingleConnectionWriteableRemoteAppSessionStateManager"/> instead
26+
/// which will only use a single connection via HTTP2 streaming.
27+
/// </remarks>
28+
internal sealed class DoubleConnectionRemoteAppSessionManager : RemoteAppSessionStateManager
29+
{
30+
public DoubleConnectionRemoteAppSessionManager(ISessionSerializer serializer, IOptions<RemoteAppSessionStateClientOptions> options, IOptions<RemoteAppClientOptions> remoteAppClientOptions, ILogger<DoubleConnectionRemoteAppSessionManager> logger)
31+
: base(serializer, options, remoteAppClientOptions, logger)
32+
{
33+
}
34+
35+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "They are either passed into another object or are manually disposed")]
36+
protected override async Task<ISessionState> GetSessionDataAsync(string? sessionId, bool readOnly, HttpContextCore callingContext, CancellationToken token)
37+
{
38+
// The request message is manually disposed at a later time
39+
var request = new HttpRequestMessage(HttpMethod.Get, Options.Path.Relative);
40+
41+
AddSessionCookieToHeader(request, sessionId);
42+
AddReadOnlyHeader(request, readOnly);
43+
44+
var response = await BackchannelClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
45+
46+
LogRetrieveResponse(response.StatusCode);
47+
48+
response.EnsureSuccessStatusCode();
49+
50+
var remoteSessionState = await Serializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(token), token);
51+
52+
if (remoteSessionState is null)
53+
{
54+
throw new InvalidOperationException("Could not retrieve remote session state");
55+
}
56+
57+
// Propagate headers back to the caller since a new session ID may have been set
58+
// by the remote app if there was no session active previously or if the previous
59+
// session expired.
60+
PropagateHeaders(response, callingContext, HeaderNames.SetCookie);
61+
62+
if (remoteSessionState.IsReadOnly)
63+
{
64+
request.Dispose();
65+
response.Dispose();
66+
return remoteSessionState;
67+
}
68+
69+
return new RemoteSessionState(remoteSessionState, request, response, this);
70+
}
71+
72+
private sealed class SerializedSessionHttpContent : HttpContent
73+
{
74+
private readonly ISessionSerializer _serializer;
75+
private readonly ISessionState _state;
76+
77+
public SerializedSessionHttpContent(ISessionSerializer serializer, ISessionState state)
78+
{
79+
_serializer = serializer;
80+
_state = state;
81+
}
82+
83+
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
84+
=> SerializeToStreamAsync(stream, context, default);
85+
86+
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
87+
=> _serializer.SerializeAsync(_state, stream, cancellationToken);
88+
89+
protected override bool TryComputeLength(out long length)
90+
{
91+
length = 0;
92+
return false;
93+
}
94+
}
95+
96+
private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, DoubleConnectionRemoteAppSessionManager manager) : DelegatingSessionState
97+
{
98+
protected override ISessionState State => other;
99+
100+
protected override void Dispose(bool disposing)
101+
{
102+
base.Dispose(disposing);
103+
104+
if (disposing)
105+
{
106+
request.Dispose();
107+
response.Dispose();
108+
}
109+
}
110+
111+
public override async Task CommitAsync(CancellationToken token)
112+
{
113+
using var request = new HttpRequestMessage(HttpMethod.Put, manager.Options.Path.Relative)
114+
{
115+
Content = new SerializedSessionHttpContent(manager.Serializer, State)
116+
};
117+
118+
manager.AddSessionCookieToHeader(request, State.SessionID);
119+
120+
using var response = await manager.BackchannelClient.SendAsync(request, token);
121+
122+
manager.LogCommitResponse(response.StatusCode);
123+
124+
response.EnsureSuccessStatusCode();
125+
}
126+
}
127+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Threading.Tasks;
5+
6+
namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession;
7+
8+
/// <summary>
9+
/// This is used to dispatch to either <see cref="DoubleConnectionRemoteAppSessionManager"/> if in read-only mode or <see cref="SingleConnectionWriteableRemoteAppSessionStateManager"/> if not.
10+
/// </summary>
11+
internal sealed class RemoteAppSessionDispatcher(SingleConnectionWriteableRemoteAppSessionStateManager singleConnection, DoubleConnectionRemoteAppSessionManager doubleConnection) : ISessionManager
12+
{
13+
public Task<ISessionState> CreateAsync(HttpContextCore context, SessionAttribute metadata)
14+
{
15+
if (metadata.IsReadOnly)
16+
{
17+
// In readonly mode it's a simple GET request
18+
return doubleConnection.CreateAsync(context, metadata);
19+
}
20+
21+
return singleConnection.CreateAsync(context, metadata);
22+
}
23+
}

src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateClientOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ public PathString SessionEndpointPath
1515
set => Path = new(value);
1616
}
1717

18+
/// <summary>
19+
/// Gets or sets whether a single connection should be used. If the framework deployment is the source of truth that doesn't support the single connection mode (such as it can't support HTTP2), set this to <c>false</c>.
20+
/// </summary>
21+
public bool UseSingleConnection { get; set; } = true;
22+
1823
internal RelativePathString Path { get; private set; } = new(SessionConstants.SessionEndpointPath);
1924

2025
/// <summary>

src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateExtensions.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using Microsoft.AspNetCore.SystemWebAdapters;
66
using Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession;
7+
using Microsoft.Extensions.Options;
78

89
namespace Microsoft.Extensions.DependencyInjection;
910

@@ -13,7 +14,17 @@ public static ISystemWebAdapterRemoteClientAppBuilder AddSessionClient(this ISys
1314
{
1415
ArgumentNullException.ThrowIfNull(builder);
1516

16-
builder.Services.AddTransient<ISessionManager, RemoteAppSessionStateManager>();
17+
builder.Services.AddTransient<DoubleConnectionRemoteAppSessionManager>();
18+
builder.Services.AddTransient<SingleConnectionWriteableRemoteAppSessionStateManager>();
19+
builder.Services.AddTransient<RemoteAppSessionDispatcher>();
20+
builder.Services.AddSingleton<ISessionManager>(ctx =>
21+
{
22+
var options = ctx.GetRequiredService<IOptions<RemoteAppSessionStateClientOptions>>();
23+
24+
return options.Value.UseSingleConnection
25+
? ctx.GetRequiredService<RemoteAppSessionDispatcher>()
26+
: ctx.GetRequiredService<DoubleConnectionRemoteAppSessionManager>();
27+
});
1728

1829
builder.Services.AddOptions<RemoteAppSessionStateClientOptions>()
1930
.Configure(configure ?? (_ => { }))

src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs

Lines changed: 25 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.Buffers;
56
using System.IO;
67
using System.Linq;
78
using System.Net;
89
using System.Net.Http;
10+
using System.Text.Json;
11+
using System.Text.Json.Serialization;
912
using System.Threading;
1013
using System.Threading.Tasks;
1114
using Microsoft.AspNetCore.Http;
@@ -16,43 +19,46 @@
1619

1720
namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession;
1821

19-
internal partial class RemoteAppSessionStateManager : ISessionManager
22+
internal abstract partial class RemoteAppSessionStateManager : ISessionManager
2023
{
21-
private readonly ISessionSerializer _serializer;
22-
private readonly ILogger<RemoteAppSessionStateManager> _logger;
23-
private readonly RemoteAppSessionStateClientOptions _options;
24-
private readonly HttpClient _backchannelClient;
24+
private readonly ILogger _logger;
2525

2626
public RemoteAppSessionStateManager(
2727
ISessionSerializer serializer,
2828
IOptions<RemoteAppSessionStateClientOptions> options,
2929
IOptions<RemoteAppClientOptions> remoteAppClientOptions,
30-
ILogger<RemoteAppSessionStateManager> logger)
30+
ILogger logger)
3131
{
32-
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
33-
_backchannelClient = remoteAppClientOptions?.Value.BackchannelClient ?? throw new ArgumentNullException(nameof(remoteAppClientOptions));
32+
Options = options?.Value ?? throw new ArgumentNullException(nameof(options));
33+
BackchannelClient = remoteAppClientOptions?.Value.BackchannelClient ?? throw new ArgumentNullException(nameof(remoteAppClientOptions));
3434
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
35-
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
35+
Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
3636
}
3737

38+
protected ISessionSerializer Serializer { get; }
39+
40+
protected RemoteAppSessionStateClientOptions Options { get; }
41+
42+
protected HttpClient BackchannelClient { get; }
43+
3844
[LoggerMessage(EventId = 0, Level = LogLevel.Debug, Message = "Loaded {Count} items from remote session state for session {SessionId}")]
39-
private partial void LogSessionLoad(int count, string sessionId);
45+
protected partial void LogSessionLoad(int count, string sessionId);
4046

4147
[LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Unable to load remote session state for session {SessionId}")]
42-
private partial void LogRemoteSessionException(Exception exc, string? sessionId);
48+
protected partial void LogRemoteSessionException(Exception exc, string? sessionId);
4349

4450
[LoggerMessage(EventId = 2, Level = LogLevel.Trace, Message = "Received {StatusCode} response getting remote session state")]
45-
private partial void LogRetrieveResponse(HttpStatusCode statusCode);
51+
protected partial void LogRetrieveResponse(HttpStatusCode statusCode);
4652

4753
[LoggerMessage(EventId = 3, Level = LogLevel.Trace, Message = "Received {StatusCode} response committing remote session state")]
48-
private partial void LogCommitResponse(HttpStatusCode statusCode);
54+
protected partial void LogCommitResponse(HttpStatusCode statusCode);
4955

5056
public async Task<ISessionState> CreateAsync(HttpContextCore context, SessionAttribute metadata)
5157
{
5258
// If an existing remote session ID is present in the request, use its session ID.
5359
// Otherwise, leave session ID null for now; it will be provided by the remote service
5460
// when session data is loaded.
55-
var sessionId = context.Request.Cookies[_options.CookieName];
61+
var sessionId = context.Request.Cookies[Options.CookieName];
5662

5763
try
5864
{
@@ -70,103 +76,24 @@ public async Task<ISessionState> CreateAsync(HttpContextCore context, SessionAtt
7076
}
7177
}
7278

73-
private async Task<ISessionState> GetSessionDataAsync(string? sessionId, bool readOnly, HttpContextCore callingContext, CancellationToken token)
74-
{
75-
// The request message is manually disposed at a later time
76-
#pragma warning disable CA2000 // Dispose objects before losing scope
77-
var req = new HttpRequestMessage(HttpMethod.Get, _options.Path.Relative);
78-
#pragma warning restore CA2000 // Dispose objects before losing scope
79-
80-
AddSessionCookieToHeader(req, sessionId);
81-
AddReadOnlyHeader(req, readOnly);
82-
83-
var response = await _backchannelClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, token);
84-
85-
LogRetrieveResponse(response.StatusCode);
86-
87-
response.EnsureSuccessStatusCode();
88-
89-
var remoteSessionState = await _serializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(token), token);
90-
91-
if (remoteSessionState is null)
92-
{
93-
throw new InvalidOperationException("Could not retrieve remote session state");
94-
}
95-
96-
// Propagate headers back to the caller since a new session ID may have been set
97-
// by the remote app if there was no session active previously or if the previous
98-
// session expired.
99-
PropagateHeaders(response, callingContext, HeaderNames.SetCookie);
100-
101-
if (remoteSessionState.IsReadOnly)
102-
{
103-
response.Dispose();
104-
return remoteSessionState;
105-
}
106-
107-
return new RemoteSessionState(remoteSessionState, response, SetOrReleaseSessionData);
108-
}
109-
110-
/// <summary>
111-
/// Commits changes to the server. Passing null <paramref name="state"/> will release the session lock but not update session data.
112-
/// </summary>
113-
private async Task SetOrReleaseSessionData(ISessionState? state, CancellationToken cancellationToken)
114-
{
115-
using var req = new HttpRequestMessage(HttpMethod.Put, _options.Path.Relative);
116-
117-
if (state is not null)
118-
{
119-
AddSessionCookieToHeader(req, state.SessionID);
120-
req.Content = new SerializedSessionHttpContent(_serializer, state);
121-
}
122-
123-
using var response = await _backchannelClient.SendAsync(req, cancellationToken);
79+
protected abstract Task<ISessionState> GetSessionDataAsync(string? sessionId, bool readOnly, HttpContextCore callingContext, CancellationToken token);
12480

125-
LogCommitResponse(response.StatusCode);
126-
127-
response.EnsureSuccessStatusCode();
128-
}
129-
130-
private static void PropagateHeaders(HttpResponseMessage responseMessage, HttpContextCore context, string cookieName)
81+
protected static void PropagateHeaders(HttpResponseMessage responseMessage, HttpContextCore context, string cookieName)
13182
{
13283
if (context?.Response is not null && responseMessage.Headers.TryGetValues(cookieName, out var cookieValues))
13384
{
13485
context.Response.Headers.AppendList(cookieName, cookieValues.ToArray());
13586
}
13687
}
13788

138-
private void AddSessionCookieToHeader(HttpRequestMessage req, string? sessionId)
89+
protected void AddSessionCookieToHeader(HttpRequestMessage req, string? sessionId)
13990
{
14091
if (!string.IsNullOrEmpty(sessionId))
14192
{
142-
req.Headers.Add(HeaderNames.Cookie, $"{_options.CookieName}={sessionId}");
93+
req.Headers.Add(HeaderNames.Cookie, $"{Options.CookieName}={sessionId}");
14394
}
14495
}
14596

146-
private static void AddReadOnlyHeader(HttpRequestMessage req, bool readOnly)
97+
protected static void AddReadOnlyHeader(HttpRequestMessage req, bool readOnly)
14798
=> req.Headers.Add(SessionConstants.ReadOnlyHeaderName, readOnly.ToString());
148-
149-
private class SerializedSessionHttpContent : HttpContent
150-
{
151-
private readonly ISessionSerializer _serializer;
152-
private readonly ISessionState _state;
153-
154-
public SerializedSessionHttpContent(ISessionSerializer serializer, ISessionState state)
155-
{
156-
_serializer = serializer;
157-
_state = state;
158-
}
159-
160-
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
161-
=> SerializeToStreamAsync(stream, context, default);
162-
163-
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
164-
=> _serializer.SerializeAsync(_state, stream, cancellationToken);
165-
166-
protected override bool TryComputeLength(out long length)
167-
{
168-
length = 0;
169-
return false;
170-
}
171-
}
17299
}

0 commit comments

Comments
 (0)