diff --git a/designs/session-serialization.md b/designs/session-serialization.md new file mode 100644 index 000000000..ee7cfaec9 --- /dev/null +++ b/designs/session-serialization.md @@ -0,0 +1,85 @@ +# Session serialization + +Session serialization is provided through the `ISessionSerializer` type. There are two modes that are available: + +## Common structure + +```mermaid +packet-beta +0: "M" +1-10: "Session Id (Variable length)" +11: "N" +12: "A" +13: "R" +14: "T" +15: "C" +16-24: "Key 1 Blob" +25-33: "Key 2 Blob" +34-42: "..." +43-50: "Flags (variable)" +``` + +Where: +- *M*: Mode +- *N*: New session +- *A*: Abandoned +- *R*: Readonly +- *T*: Timeout +- *C*: Key count + +## 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` + +Where `C` is the count of flags, and each `Fn` is a flag identifier an int with 7bit encoding. Each f + +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" +``` + +## 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` + diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs index 75f79a71c..5d0436355 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs @@ -9,7 +9,21 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; public interface ISessionSerializer { + /// + /// Deserializes a session state. + /// + /// The serialized session stream. + /// A cancellation token + /// If the stream defines a serialized session changeset, it will also implement . Task DeserializeAsync(Stream stream, CancellationToken token); + /// + /// Serializes the session state. If the implements it will serialize it + /// in a mode that only tracks the changes that have occurred. + /// + /// + /// + /// + /// Task SerializeAsync(ISessionState state, Stream stream, CancellationToken token); } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs new file mode 100644 index 000000000..b7c5ec05d --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +public interface ISessionStateChangeset : ISessionState +{ + IEnumerable Changes { get; } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs new file mode 100644 index 000000000..6713bbb7b --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs @@ -0,0 +1,13 @@ +// 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; + +public enum SessionItemChangeState +{ + Unknown = 0, + NoChange = 1, + Removed = 2, + Changed = 3, + New = 4, +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs index 1ac31c160..61de2b387 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs @@ -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; diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs new file mode 100644 index 000000000..7cbf6410b --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +[DebuggerDisplay("{State}: {Key,nq}")] +public readonly struct SessionStateChangeItem(SessionItemChangeState state, string key) : IEquatable +{ + public SessionItemChangeState State => state; + + public string Key => key; + + public override bool Equals(object? obj) => obj is SessionStateChangeItem item && Equals(item); + + public override int GetHashCode() + => State.GetHashCode() ^ Key.GetHashCode(); + + public bool Equals(SessionStateChangeItem other) => + State == other.State + && string.Equals(Key, other.Key, StringComparison.Ordinal); + + public static bool operator ==(SessionStateChangeItem left, SessionStateChangeItem right) + { + return left.Equals(right); + } + + public static bool operator !=(SessionStateChangeItem left, SessionStateChangeItem right) + { + return !(left == right); + } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs index 05c3242d7..48ca88167 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Web; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; @@ -22,11 +23,43 @@ public static void CopyTo(this ISessionState result, HttpSessionStateBase state) } state.Timeout = result.Timeout; + + if (result is ISessionStateChangeset changes) + { + UpdateFromChanges(changes, state); + } + else + { + Replace(result, state); + } + } + + private static void UpdateFromChanges(ISessionStateChangeset from, HttpSessionStateBase state) + { + foreach (var change in from.Changes) + { + if (change.State is SessionItemChangeState.Changed or SessionItemChangeState.New) + { + state[change.Key] = from[change.Key]; + } + else if (change.State is SessionItemChangeState.Removed) + { + state.Remove(change.Key); + } + else if (change.State is SessionItemChangeState.Unknown) + { + + } + } + } + + private static void Replace(ISessionState from, HttpSessionStateBase state) + { state.Clear(); - foreach (var key in result.Keys) + foreach (var key in from.Keys) { - state[key] = result[key]; + state[key] = from[key]; } } } diff --git a/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs new file mode 100644 index 000000000..645eb8d3d --- /dev/null +++ b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1510:Use ArgumentNullException throw helper", Justification = "Source shared with .NET Framework that does not have the method")] +internal partial class BinarySessionSerializer : ISessionSerializer +{ + private readonly struct ChangesetWriter(ISessionKeySerializer serializer) + { + public List? Write(ISessionStateChangeset state, BinaryWriter writer) + { + writer.Write(ModeDelta); + writer.Write(state.SessionID); + + writer.Write(state.IsNewSession); + writer.Write(state.IsAbandoned); + writer.Write(state.IsReadOnly); + + writer.Write7BitEncodedInt(state.Timeout); + writer.Write7BitEncodedInt(state.Count); + + List? unknownKeys = null; + + foreach (var item in state.Changes) + { + writer.Write(item.Key); + + // New with V2 serializer + if (item.State is SessionItemChangeState.NoChange or SessionItemChangeState.Removed) + { + writer.Write7BitEncodedInt((int)item.State); + } + else if (serializer.TrySerialize(item.Key, state[item.Key], out var result)) + { + writer.Write7BitEncodedInt((int)item.State); + writer.Write7BitEncodedInt(result.Length); + writer.Write(result); + } + else + { + (unknownKeys ??= []).Add(item.Key); + writer.Write7BitEncodedInt((int)SessionItemChangeState.Unknown); + } + } + + writer.WriteFlags([]); + + return unknownKeys; + } + + public SessionStateCollection Read(BinaryReader reader) + { + var state = SessionStateCollection.CreateTracking(serializer); + + state.SessionID = reader.ReadString(); + state.IsNewSession = reader.ReadBoolean(); + state.IsAbandoned = reader.ReadBoolean(); + state.IsReadOnly = reader.ReadBoolean(); + state.Timeout = reader.Read7BitEncodedInt(); + + var count = reader.Read7BitEncodedInt(); + + for (var index = count; index > 0; index--) + { + var key = reader.ReadString(); + var changeState = (SessionItemChangeState)reader.Read7BitEncodedInt(); + + if (changeState is SessionItemChangeState.NoChange) + { + state.MarkUnchanged(key); + } + else if (changeState is SessionItemChangeState.Removed) + { + state.MarkRemoved(key); + } + else if (changeState is SessionItemChangeState.Unknown) + { + state.AddUnknownKey(key); + } + else if (changeState is SessionItemChangeState.New or SessionItemChangeState.Changed) + { + var length = reader.Read7BitEncodedInt(); + var bytes = reader.ReadBytes(length); + + if (serializer.TryDeserialize(key, bytes, out var result)) + { + if (result is not null) + { + state[key] = result; + } + } + else + { + state.AddUnknownKey(key); + } + } + } + + foreach (var (flag, payload) in reader.ReadFlags()) + { + // No flags are currently read + } + + return state; + } + } +} diff --git a/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs new file mode 100644 index 000000000..f842dfe18 --- /dev/null +++ b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1510:Use ArgumentNullException throw helper", Justification = "Source shared with .NET Framework that does not have the method")] +internal partial class BinarySessionSerializer : ISessionSerializer +{ + private readonly struct StateWriter(ISessionKeySerializer serializer) + { + private const int FLAG_DIFF_REQUESTED = 100; + + public List? Write(ISessionState state, BinaryWriter writer) + { + writer.Write(ModeState); + writer.Write(state.SessionID); + + writer.Write(state.IsNewSession); + writer.Write(state.IsAbandoned); + writer.Write(state.IsReadOnly); + + writer.Write7BitEncodedInt(state.Timeout); + writer.Write7BitEncodedInt(state.Count); + + List? unknownKeys = null; + + foreach (var item in state.Keys) + { + writer.Write(item); + + if (serializer.TrySerialize(item, state[item], out var result)) + { + writer.Write7BitEncodedInt(result.Length); + writer.Write(result); + } + else + { + (unknownKeys ??= new()).Add(item); + writer.Write7BitEncodedInt(0); + } + } + + if (unknownKeys is null) + { + writer.Write7BitEncodedInt(0); + } + else + { + writer.Write7BitEncodedInt(unknownKeys.Count); + + foreach (var key in unknownKeys) + { + writer.Write(key); + } + } + + writer.WriteFlags( + [ + (FLAG_DIFF_REQUESTED, Array.Empty()) + ]); + + + return unknownKeys; + } + + + public SessionStateCollection Read(BinaryReader reader) + { + var state = new SessionStateCollection(serializer); + + state.SessionID = reader.ReadString(); + state.IsNewSession = reader.ReadBoolean(); + state.IsAbandoned = reader.ReadBoolean(); + state.IsReadOnly = reader.ReadBoolean(); + state.Timeout = reader.Read7BitEncodedInt(); + + var count = reader.Read7BitEncodedInt(); + + for (var index = count; index > 0; index--) + { + var key = reader.ReadString(); + var length = reader.Read7BitEncodedInt(); + var bytes = reader.ReadBytes(length); + + state.SetItem(key, bytes); + } + + var unknown = reader.Read7BitEncodedInt(); + + if (unknown > 0) + { + for (var index = unknown; index > 0; index--) + { + state.AddUnknownKey(reader.ReadString()); + } + } + + // Originally this was the end of the data. Now, we have an optional set of flags, but we can stop if there is no more data + if (reader.PeekChar() != -1) + { + foreach (var (flag, payload) in reader.ReadFlags()) + { + HandleFlag(ref state, flag); + } + } + + return state; + } + + private static void HandleFlag(ref SessionStateCollection state, int flag) + { + if (flag == FLAG_DIFF_REQUESTED) + { + state = state.WithTracking(); + } + } + } +} diff --git a/src/Services/SessionState/BinarySessionSerializer.cs b/src/Services/SessionState/BinarySessionSerializer.cs index 2d7481075..4d21c1aa9 100644 --- a/src/Services/SessionState/BinarySessionSerializer.cs +++ b/src/Services/SessionState/BinarySessionSerializer.cs @@ -16,7 +16,8 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; [System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1510:Use ArgumentNullException throw helper", Justification = "Source shared with .NET Framework that does not have the method")] internal partial class BinarySessionSerializer : ISessionSerializer { - private const byte Version = 1; + private const byte ModeState = 1; + private const byte ModeDelta = 2; private readonly SessionSerializerOptions _options; private readonly ISessionKeySerializer _serializer; @@ -37,52 +38,21 @@ public BinarySessionSerializer(ICompositeSessionKeySerializer serializer, IOptio public void Write(ISessionState state, BinaryWriter writer) { - writer.Write(Version); - writer.Write(state.SessionID); + var unknownKeys = state is ISessionStateChangeset delta + ? new ChangesetWriter(_serializer).Write(delta, writer) + : new StateWriter(_serializer).Write(state, writer); - writer.Write(state.IsNewSession); - writer.Write(state.IsAbandoned); - writer.Write(state.IsReadOnly); - - writer.Write7BitEncodedInt(state.Timeout); - writer.Write7BitEncodedInt(state.Count); - - List? unknownKeys = null; - - foreach (var item in state.Keys) + if (unknownKeys is { }) { - writer.Write(item); - - if (_serializer.TrySerialize(item, state[item], out var result)) - { - writer.Write7BitEncodedInt(result.Length); - writer.Write(result); - } - else - { - (unknownKeys ??= new()).Add(item); - writer.Write7BitEncodedInt(0); - } - } - - if (unknownKeys is null) - { - writer.Write7BitEncodedInt(0); - } - else - { - writer.Write7BitEncodedInt(unknownKeys.Count); - foreach (var key in unknownKeys) { LogSerialization(key); - writer.Write(key); } - } - if (unknownKeys is not null && _options.ThrowOnUnknownSessionKey) - { - throw new UnknownSessionKeyException(unknownKeys); + if (_options.ThrowOnUnknownSessionKey) + { + throw new UnknownSessionKeyException(unknownKeys); + } } } @@ -93,12 +63,14 @@ public ISessionState Read(BinaryReader reader) throw new ArgumentNullException(nameof(reader)); } - if (reader.ReadByte() != Version) - { - throw new InvalidOperationException("Serialized session state has different version than expected"); - } + var version = reader.ReadByte(); - var state = new BinaryReaderSerializedSessionState(reader, _serializer); + var state = version switch + { + ModeState => new StateWriter(_serializer).Read(reader), + ModeDelta => new ChangesetWriter(_serializer).Read(reader), + _ => throw new InvalidOperationException("Serialized session state has unknown version.") + }; if (state.UnknownKeys is { Count: > 0 } unknownKeys) { @@ -116,6 +88,7 @@ public ISessionState Read(BinaryReader reader) return state; } + public Task DeserializeAsync(Stream stream, CancellationToken token) { using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); @@ -131,85 +104,4 @@ public Task SerializeAsync(ISessionState state, Stream stream, CancellationToken return Task.CompletedTask; } - - private class BinaryReaderSerializedSessionState : ISessionState - { - public BinaryReaderSerializedSessionState(BinaryReader reader, ISessionKeySerializer serializer) - { - SessionID = reader.ReadString(); - IsNewSession = reader.ReadBoolean(); - IsAbandoned = reader.ReadBoolean(); - IsReadOnly = reader.ReadBoolean(); - Timeout = reader.Read7BitEncodedInt(); - - var count = reader.Read7BitEncodedInt(); - - for (var index = count; index > 0; index--) - { - var key = reader.ReadString(); - var length = reader.Read7BitEncodedInt(); - var bytes = reader.ReadBytes(length); - - if (serializer.TryDeserialize(key, bytes, out var result)) - { - if (result is not null) - { - this[key] = result; - } - } - else - { - (UnknownKeys ??= new()).Add(key); - } - } - - var unknown = reader.Read7BitEncodedInt(); - - if (unknown > 0) - { - for (var index = unknown; index > 0; index--) - { - (UnknownKeys ??= new()).Add(reader.ReadString()); - } - } - } - - private Dictionary? _items; - - public object? this[string key] - { - get => _items?.TryGetValue(key, out var result) is true ? result : null; - set => (_items ??= new())[key] = value; - } - - internal List? UnknownKeys { get; private set; } - - public string SessionID { get; set; } = null!; - - public bool IsReadOnly { get; set; } - - public int Timeout { get; set; } - - public bool IsNewSession { get; set; } - - public int Count => _items?.Count ?? 0; - - public bool IsAbandoned { get; set; } - - bool ISessionState.IsSynchronized => false; - - object ISessionState.SyncRoot => this; - - IEnumerable ISessionState.Keys => _items?.Keys ?? Enumerable.Empty(); - - void ISessionState.Clear() => _items?.Clear(); - - void ISessionState.Remove(string key) => _items?.Remove(key); - - Task ISessionState.CommitAsync(CancellationToken token) => Task.CompletedTask; - - void IDisposable.Dispose() - { - } - } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs b/src/Services/SessionState/BinaryWriterReaderExtensions.cs similarity index 68% rename from src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs rename to src/Services/SessionState/BinaryWriterReaderExtensions.cs index e684db650..abe668cc3 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs +++ b/src/Services/SessionState/BinaryWriterReaderExtensions.cs @@ -1,8 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; using System.Runtime.CompilerServices; +using FlagEntry = (int Flag, System.ReadOnlyMemory Payload); + namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; /// @@ -10,6 +16,44 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; /// internal static class BinaryWriterReaderExtensions { + internal static void WriteFlags(this BinaryWriter writer, ReadOnlySpan flagEntries) + { + writer.Write7BitEncodedInt(flagEntries.Length); + + foreach (var (flag, payload) in flagEntries) + { + writer.Write7BitEncodedInt(flag); + writer.Write7BitEncodedInt(payload.Length); + writer.Write(payload.Span); + } + } + + internal static IEnumerable ReadFlags(this BinaryReader reader) + { + var length = reader.Read7BitEncodedInt(); + + while (length > 0) + { + var flag = reader.Read7BitEncodedInt(); + var payloadLength = reader.Read7BitEncodedInt(); + var payload = reader.ReadBytes(payloadLength); + + yield return new(flag, payload); + + length--; + } + } + +#if NETFRAMEWORK + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Write(this BinaryWriter writer, ReadOnlySpan bytes) + { + var array = ArrayPool.Shared.Rent(bytes.Length); + bytes.CopyTo(array); + writer.Write(array, 0, bytes.Length); + ArrayPool.Shared.Return(array); + } + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Write7BitEncodedInt(this BinaryWriter writer, int value) @@ -76,5 +120,6 @@ public static int Read7BitEncodedInt(this BinaryReader reader) result |= (uint)byteReadJustNow << (MaxBytesWithoutOverflow * 7); return (int)result; } +#endif } diff --git a/src/Services/SessionState/SessionStateCollection.cs b/src/Services/SessionState/SessionStateCollection.cs new file mode 100644 index 000000000..c6dbaa6ef --- /dev/null +++ b/src/Services/SessionState/SessionStateCollection.cs @@ -0,0 +1,215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +internal class SessionStateCollection : ISessionState +{ + private readonly Dictionary _items; + + public SessionStateCollection(ISessionKeySerializer serializer) + { + Serializer = serializer; + _items = []; + } + + protected SessionStateCollection(SessionStateCollection other) + { + _items = other._items; + + Serializer = other.Serializer; + UnknownKeys = other.UnknownKeys; + + SessionID = other.SessionID; + IsReadOnly = other.IsReadOnly; + IsNewSession = other.IsNewSession; + IsAbandoned = other.IsAbandoned; + Timeout = other.Timeout; + } + + public static SessionStateCollection CreateTracking(ISessionKeySerializer serializer) + => new SessionStateChangeset(serializer); + + public SessionStateCollection WithTracking() => new SessionStateChangeset(this); + + public ISessionKeySerializer Serializer { get; } + + public void AddUnknownKey(string key) + { + (UnknownKeys ??= new()).Add(key); + } + + public void MarkUnchanged(string key) => _items[key] = new([]); + + public void MarkRemoved(string key) => _items[key] = new((object?)null); + + public void SetItem(string key, byte[] data) => _items[key] = new(data); + + public object? this[string key] + { + get => _items.TryGetValue(key, out var result) ? result.GetValue(key, Serializer) : null; + set + { + if (_items.TryGetValue(key, out var existing)) + { + existing.SetValue(value); + } + else + { + _items[key] = new(value); + } + } + } + + public IEnumerable Changes + { + get + { + foreach (var item in _items) + { + yield return new(item.Value.State, item.Key); + } + } + } + + internal List? UnknownKeys { get; private set; } + + public string SessionID { get; set; } = null!; + + public bool IsReadOnly { get; set; } + + public int Timeout { get; set; } + + public bool IsNewSession { get; set; } + + public int Count => _items?.Count ?? 0; + + public bool IsAbandoned { get; set; } + + bool ISessionState.IsSynchronized => false; + + object ISessionState.SyncRoot => this; + + IEnumerable ISessionState.Keys => _items?.Keys ?? Enumerable.Empty(); + + void ISessionState.Clear() + { + List? newKeys = null; + + foreach (var item in _items) + { + if (item.Value.IsNew) + { + (newKeys ??= []).Add(item.Key); + } + else + { + item.Value.SetValue(null); + } + } + + if (newKeys is { }) + { + foreach (var key in newKeys) + { + _items.Remove(key); + } + } + } + + void ISessionState.Remove(string key) + { + if (_items.TryGetValue(key, out var existing)) + { + if (existing.IsNew) + { + _items.Remove(key); + } + else + { + existing.SetValue(null); + } + } + } + + Task ISessionState.CommitAsync(CancellationToken token) => Task.CompletedTask; + + void IDisposable.Dispose() + { + } + + private sealed class ItemHolder + { + private byte[]? _data; + private object? _value; + + public ItemHolder(object? value) + { + _value = value; + IsNew = true; + } + + public ItemHolder(byte[] data) + { + _data = data; + } + + public bool IsNew { get; } + + public SessionItemChangeState State => (IsNew, _data, _value) switch + { + (true, _, _) => SessionItemChangeState.New, + + // If both are null, the value has been set to null implying it no longer exists + (_, null, null) => SessionItemChangeState.Removed, + + // If the value is set, it means it has been accessed and then potentially changed + (_, _, { }) => SessionItemChangeState.Changed, + + // If the data is still set, then the value has not been accessed + (_, { }, _) => SessionItemChangeState.NoChange, + }; + + public object? GetValue(string key, ISessionKeySerializer serializer) + { + if (_data is { } data && serializer.TryDeserialize(key, data, out var obj)) + { + _value = obj; + } + + _data = null; + + return _value; + } + + public void SetValue(object? value) + + { + _value = value; + _data = null; + } + } + + private sealed class SessionStateChangeset : SessionStateCollection, ISessionStateChangeset + { + public SessionStateChangeset(ISessionKeySerializer serializer) + : base(serializer) + { + } + + public SessionStateChangeset(SessionStateCollection other) + : base(other) + { + } + } +} diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs index a370522a4..a352bced1 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs @@ -2,10 +2,15 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; using Moq; using Xunit; @@ -27,7 +32,7 @@ public async Task SerializeEmpty() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0, 1, 100, 0 }); } [Fact] @@ -67,7 +72,7 @@ public async Task SerializeIsNewSession() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0, 1, 100, 0 }); } [Fact] @@ -107,7 +112,7 @@ public async Task SerializeIsAbandoned() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0, 1, 100, 0 }); } [Fact] @@ -147,14 +152,18 @@ public async Task SerializeIsReadOnly() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0, 1, 100, 0 }); } - [Fact] - public async Task DeserializeIsReadOnly() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task DeserializeIsReadOnly(FlagOptions options) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 1, 0, 0, 0], options); + using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -170,13 +179,26 @@ public async Task DeserializeIsReadOnly() Assert.Equal(0, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } - [Fact] - public async Task DeserializeIsReadOnlyEmptyNull() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task DeserializeIsReadOnlyEmptyNull(FlagOptions options) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }; + + var data = AddFlags([1, 2, 105, 100, 0, 0, 1, 0, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -192,6 +214,15 @@ public async Task DeserializeIsReadOnlyEmptyNull() Assert.Equal(0, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } [Fact] @@ -209,14 +240,17 @@ public async Task SerializeTimeout() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 }); + Assert.Equal(new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0, 1, 100, 0 }, ms.ToArray()); } - [Fact] - public async Task DeserializeTimeout() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task DeserializeTimeout(FlagOptions options) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 20, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -232,6 +266,15 @@ public async Task DeserializeTimeout() Assert.Equal(20, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } [Fact] @@ -256,7 +299,7 @@ public async Task Serialize1Key() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0, 1, 100, 0 }); } [Fact] @@ -281,14 +324,17 @@ public async Task Serialize1KeyNull() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0, 1, 100, 0 }); } - [Fact] - public async Task Deserialize1KeyNull() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task Deserialize1KeyNull(FlagOptions options) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], options); var obj = new object(); var value = new byte[] { 0 }; @@ -309,17 +355,29 @@ public async Task Deserialize1KeyNull() Assert.Equal(1, result.Count); Assert.Same(obj, result["key1"]); Assert.Collection(result.Keys, k => Assert.Equal("key1", k)); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } - [Fact] - public async Task Deserialize1KeyV1() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task Deserialize1KeyV1(FlagOptions options) { // Arrange var obj = new object(); var keySerializer = new Mock(); keySerializer.Setup(k => k.TryDeserialize("key1", Array.Empty(), out obj)).Returns(true); - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(keySerializer.Object); @@ -336,6 +394,15 @@ public async Task Deserialize1KeyV1() Assert.Equal(1, result.Count); Assert.Equal(result.Keys, new[] { "key1" }); Assert.Equal(obj, result["key1"]); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } [Fact] @@ -360,11 +427,14 @@ public async Task Serialize1KeyNullable() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0, 1, 100, 0 }); } - [Fact] - public async Task Deserialize1Key() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task Deserialize1Key(FlagOptions options) { // Arrange var obj = new object(); @@ -372,7 +442,7 @@ public async Task Deserialize1Key() var keySerializer = new Mock(); keySerializer.Setup(k => k.TryDeserialize("key1", bytes, out obj)).Returns(true); - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(keySerializer.Object); @@ -389,19 +459,101 @@ public async Task Deserialize1Key() Assert.Equal(1, result.Count); Assert.Equal(result.Keys, new[] { "key1" }); Assert.Equal(obj, result["key1"]); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } - private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer = null) + [Fact] + public async Task RoundtripDoesntOverwrite() + { + // Arrange + var obj1 = new object(); + var bytes1 = new byte[] { 42 }; + var obj2 = new object(); + var bytes2 = new byte[] { 43 }; + var keySerializer1 = new Mock(); + RegisterKey(keySerializer1, "key1", obj1, bytes1); + + var serializer1 = CreateSerializer(keySerializer1.Object, options => options.ThrowOnUnknownSessionKey = false); + + using var initialState = new TestState() + { + { "key1", obj1 }, + { "key2", obj2 }, + }; + + // Act + var state2 = await RoundtripAsync(serializer1, initialState); + + // Assert + var changeset = Assert.IsAssignableFrom(state2); + + Assert.Collection( + changeset.Changes, + c => + { + Assert.Equal("key1", c.Key); + Assert.Equal(SessionItemChangeState.NoChange, c.State); + }, + c => + { + Assert.Equal("key2", c.Key); + Assert.Equal(SessionItemChangeState.NoChange, c.State); + }); + } + + private static async Task RoundtripAsync(BinarySessionSerializer serializer, ISessionState state) + { + using var ms = new MemoryStream(); + await serializer.SerializeAsync(state, ms, default); + ms.Position = 0; + + var result = await serializer.DeserializeAsync(ms, default); + + return result!; + } + + private static void RegisterKey(Mock keySerializer, string name, object? obj, byte[] data) + { + keySerializer.Setup(k => k.TryDeserialize(name, data, out obj)).Returns(true); + keySerializer.Setup(k => k.TrySerialize(name, obj, out data)).Returns(true); + } + + private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer = null, Action? optionsConfigure = null) { keySerializer ??= new Mock().Object; var logger = new Mock>(); var optionContainer = new Mock>(); - optionContainer.Setup(o => o.Value).Returns(new SessionSerializerOptions()); + var options = new SessionSerializerOptions(); + optionsConfigure?.Invoke(options); + optionContainer.Setup(o => o.Value).Returns(options); return new BinarySessionSerializer(new Composite(keySerializer), optionContainer.Object, logger.Object); } + public enum FlagOptions + { + None = 0, + NoChanges = 1, + Changes = 2, + } + + private static byte[] AddFlags(byte[] data, FlagOptions options) => options switch + { + FlagOptions.None => data, + FlagOptions.Changes => [.. data, 1, 100, 0], + FlagOptions.NoChanges => [.. data, 0], + _ => throw new ArgumentOutOfRangeException(nameof(options)), + }; + private sealed class Composite : ICompositeSessionKeySerializer { private readonly ISessionKeySerializer _serializer; @@ -417,4 +569,60 @@ public bool TryDeserialize(string key, byte[] bytes, out object? obj) public bool TrySerialize(string key, object? value, out byte[] bytes) => _serializer.TrySerialize(key, value, out bytes); } + + private sealed class TestState : ISessionState, IEnumerable> + { + private readonly Dictionary _items = []; + + public object? this[string key] + { + get => _items.TryGetValue(key, out var value) ? value : null; + set + { + if (value is null) + { + _items.Remove(key); + } + else + { + _items[key] = value; + } + } + } + + public void Add(string key, object value) + { + _items.Add(key, value); + } + + public string SessionID => "id"; + + public bool IsReadOnly => false; + + public int Timeout { get; set; } + + public bool IsNewSession => true; + + public int Count => _items.Count; + + public bool IsSynchronized => false; + + public object SyncRoot => _items; + + public bool IsAbandoned { get; set; } + + public IEnumerable Keys => _items.Keys; + + public void Clear() => _items.Clear(); + public Task CommitAsync(CancellationToken token) => Task.CompletedTask; + public void Dispose() + { + } + + public void Remove(string key) => _items.Remove(key); + + public IEnumerator> GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } }