From d0bf0de7b16367532f8f857108c2c500d8a1c170 Mon Sep 17 00:00:00 2001 From: Joshua Hanson <454819+joshhanson314@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:25:47 -0400 Subject: [PATCH 1/4] #4867 Add Azure Table Storage ISessionStore package Adds Csla.Blazor.State.AzureTableStorage, an opt-in ISessionStore for Blazor state management backed by Azure Table Storage, enabled with UseAzureTableStorageSessionStore() after AddServerSideBlazor(). Co-Authored-By: Claude Opus 5.5 --- ...zureTableStorageConfigurationExtensions.cs | 68 ++++++ .../AzureTableStorageOptions.cs | 52 +++++ .../AzureTableStorageSessionStore.cs | 116 ++++++++++ ...Csla.Blazor.State.AzureTableStorage.csproj | 26 +++ .../SessionEntity.cs | 55 +++++ Source/csla.build.sln | 203 ++++++++++++++++++ Source/csla.test.sln | 85 ++++++++ .../AzureTableStorageSessionStoreTests.cs | 153 +++++++++++++ .../ConfigurationExtensionsTests.cs | 126 +++++++++++ ...lazor.State.AzureTableStorage.Tests.csproj | 25 +++ 10 files changed, 909 insertions(+) create mode 100644 Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs create mode 100644 Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs create mode 100644 Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs create mode 100644 Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj create mode 100644 Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs create mode 100644 Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs create mode 100644 Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs create mode 100644 Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs new file mode 100644 index 0000000000..6643ee66e7 --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs @@ -0,0 +1,68 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Configuration extensions for the Azure Table Storage session store +//----------------------------------------------------------------------- + +using Azure.Data.Tables; +using Csla.Blazor.State.AzureTableStorage; +using Csla.State; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Csla.Configuration +{ + /// + /// Implement extension methods to register the Azure Table Storage + /// session store with the CSLA configuration system. + /// + public static class AzureTableStorageConfigurationExtensions + { + /// + /// Configures CSLA to use the Azure Table Storage + /// implementation. + /// + /// The CslaOptions instance. + /// An optional delegate to configure the store. + /// The CslaOptions instance. + /// + /// Call this after AddServerSideBlazor, which registers the default + /// in-memory session store; this method replaces that registration. The + /// session store is only used when UseInMemoryApplicationContextManager + /// is . + /// + /// The comes from + /// when it is set, + /// otherwise from the keyed service named by + /// , otherwise from the + /// default registered in the container. + /// + /// + /// is . + public static CslaOptions UseAzureTableStorageSessionStore(this CslaOptions config, Action? options = null) + { + ArgumentNullException.ThrowIfNull(config); + + var storeOptions = new AzureTableStorageOptions(); + options?.Invoke(storeOptions); + + // The client is resolved privately rather than registered, so the store + // never changes which TableServiceClient the rest of the app receives. + config.Services.Replace(ServiceDescriptor.Singleton(sp => + ActivatorUtilities.CreateInstance( + sp, GetTableServiceClient(sp, storeOptions), storeOptions))); + return config; + } + + internal static TableServiceClient GetTableServiceClient(IServiceProvider serviceProvider, AzureTableStorageOptions options) + { + if (!string.IsNullOrEmpty(options.ConnectionString)) + return new TableServiceClient(options.ConnectionString); + if (options.ServiceKey is not null) + return serviceProvider.GetRequiredKeyedService(options.ServiceKey); + return serviceProvider.GetRequiredService(); + } + } +} diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs new file mode 100644 index 0000000000..041a0b1fdf --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs @@ -0,0 +1,52 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Options for the Azure Table Storage session store +//----------------------------------------------------------------------- + +namespace Csla.Blazor.State.AzureTableStorage; + +/// +/// Options for configuring the Azure Table Storage +/// implementation. +/// +public class AzureTableStorageOptions +{ + /// + /// Gets or sets the Azure Storage connection string used to + /// create the . + /// + /// + /// Leave or empty to use a + /// that is already + /// registered in the dependency injection container (for example, one + /// registered by .NET Aspire or by managed-identity wiring). + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets the dependency injection service key used to resolve a + /// keyed . + /// + /// + /// Set this when the host registered the client as a keyed service (for + /// example builder.AddKeyedAzureTableClient("state")). Ignored when + /// is supplied. + /// + public object? ServiceKey { get; set; } + + /// + /// Gets or sets the name of the Azure Storage table used to store + /// sessions. Defaults to CslaStateSessionManager. + /// + public string TableName { get; set; } = "CslaStateSessionManager"; + + /// + /// Gets or sets a value used to discriminate sessions belonging to this + /// application when multiple applications share the same storage table. + /// Defaults to an empty string. + /// + public string ApplicationDiscriminator { get; set; } = string.Empty; +} diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs new file mode 100644 index 0000000000..855da0f23b --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs @@ -0,0 +1,116 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Azure Table Storage implementation of ISessionStore +//----------------------------------------------------------------------- + +using Azure; +using Azure.Data.Tables; +using Csla.Serialization; +using Csla.State; +using Microsoft.Extensions.Logging; + +namespace Csla.Blazor.State.AzureTableStorage; + +/// +/// Azure Table Storage implementation of . +/// +/// +/// Read and write failures propagate to the caller. A failed read must not +/// look like a missing session, because the session manager would then +/// replace the stored session with a new, empty one. +/// +public class AzureTableStorageSessionStore : ISessionStore +{ + private readonly string _applicationDiscriminator; + private readonly ISerializationFormatter _formatter; + private readonly ILogger _logger; + private readonly TableClient _tableClient; + + /// + /// Initializes a new instance of the + /// class, creating the + /// storage table if it does not exist. + /// + /// The Azure table service client. + /// The CSLA serialization formatter. + /// The session store options. + /// The logger. + /// When any argument is . + public AzureTableStorageSessionStore( + TableServiceClient tableServiceClient, + ISerializationFormatter formatter, + AzureTableStorageOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(tableServiceClient); + ArgumentNullException.ThrowIfNull(options); + + _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _applicationDiscriminator = options.ApplicationDiscriminator; + + _tableClient = tableServiceClient.GetTableClient(options.TableName); + _tableClient.CreateIfNotExists(); + } + + /// + public async ValueTask GetSessionAsync(string key) + { + ArgumentNullException.ThrowIfNull(key); + + _logger.LogDebug("Retrieving session {Key} from table {Table}", key, _tableClient.Name); + + var response = await _tableClient.GetEntityIfExistsAsync(key, _applicationDiscriminator).ConfigureAwait(false); + if (!response.HasValue || response.Value is null) + return null; + return (Session?)_formatter.Deserialize(response.Value.Value); + } + + /// + public async ValueTask AddOrUpdateSessionAsync(string key, Session session) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(session); + + _logger.LogDebug("Saving session {Key} to table {Table}", key, _tableClient.Name); + + var entity = new SessionEntity + { + PartitionKey = key, + RowKey = _applicationDiscriminator, + Value = _formatter.Serialize(session), + LastTouched = session.LastTouched, + }; + await _tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace).ConfigureAwait(false); + } + + /// + public async ValueTask PurgeExpiredSessionsAsync(TimeSpan expiration) + { + ArgumentOutOfRangeException.ThrowIfNegative(expiration.Ticks, nameof(expiration)); + + var cutoff = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - (long)expiration.TotalSeconds; + var discriminator = _applicationDiscriminator; + + var expired = _tableClient.QueryAsync( + x => x.RowKey == discriminator && x.LastTouched < cutoff, + select: [nameof(SessionEntity.PartitionKey), nameof(SessionEntity.RowKey)]); + + await foreach (var entity in expired.ConfigureAwait(false)) + { + // Purging is best effort: another instance may already have removed + // the session, and one failure should not stop the rest of the sweep. + try + { + await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey).ConfigureAwait(false); + } + catch (RequestFailedException ex) + { + _logger.LogWarning(ex, "Failed to purge expired session {Key} from table {Table}", entity.PartitionKey, _tableClient.Name); + } + } + } +} diff --git a/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj b/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj new file mode 100644 index 0000000000..63ddefa581 --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj @@ -0,0 +1,26 @@ + + + + + net10.0;net11.0 + CSLA .NET Azure Table Storage session store + Azure Table Storage implementation of the CSLA .NET ISessionStore for Blazor server/WASM state. + true + ..\..\Bin + CSLA .NET Azure Table Storage session store + CSLA;Azure;Table Storage;Session;Blazor;State + + + + + + + + + + + + + + + diff --git a/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs b/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs new file mode 100644 index 0000000000..97bc6a7026 --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs @@ -0,0 +1,55 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Azure Table Storage entity for a stored session +//----------------------------------------------------------------------- + +using Azure; +using Azure.Data.Tables; + +namespace Csla.Blazor.State.AzureTableStorage; + +/// +/// Azure Table Storage entity representing a single stored +/// . +/// +/// +/// The holds the session key and the +/// holds the application discriminator, so that +/// multiple applications can safely share a single storage table. +/// +public class SessionEntity : ITableEntity +{ + /// + /// Gets or sets the partition key (the session key). + /// + public required string PartitionKey { get; set; } + + /// + /// Gets or sets the row key (the application discriminator). + /// + public required string RowKey { get; set; } + + /// + /// Gets or sets the entity timestamp (managed by Azure). + /// + public DateTimeOffset? Timestamp { get; set; } + + /// + /// Gets or sets the entity ETag (managed by Azure). + /// + public ETag ETag { get; set; } = ETag.All; + + /// + /// Gets or sets the serialized session data. + /// + public required byte[] Value { get; set; } + + /// + /// Gets or sets the Unix-seconds timestamp of the last time + /// the session was touched (used for expiration purging). + /// + public required long LastTouched { get; set; } +} diff --git a/Source/csla.build.sln b/Source/csla.build.sln index 715961a794..9d4bec918b 100644 --- a/Source/csla.build.sln +++ b/Source/csla.build.sln @@ -69,6 +69,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Testing", "Csla.Testin EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Avalonia", "Csla.Xaml.Avalonia\Csla.Xaml.Avalonia.csproj", "{F6080E51-675F-47A8-B125-55DCF66359F8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage", "Csla.Blazor.State.AzureTableStorage\Csla.Blazor.State.AzureTableStorage.csproj", "{7BB42513-1D79-474D-B31E-99673AD1BEBF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Ad-Hoc|Any CPU = Ad-Hoc|Any CPU @@ -3973,6 +3975,206 @@ Global {F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x64.Build.0 = Debug|Any CPU {F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x86.ActiveCfg = Debug|Any CPU {F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Any CPU.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|AnyOS.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|AnyOS.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|ARM.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|ARM.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhone.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhone.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win32.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win32.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win64.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win64.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x64.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x64.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x86.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x86.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x86.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -4000,6 +4202,7 @@ Global {50A9114F-5D22-43EF-43CF-559234473966} = {580424DE-C982-4E80-A948-EF42FC3AB110} {5ECC7150-E415-4FD6-99A4-08110DB54EE1} = {85799E16-9B3B-4BD5-98D6-238E31291134} {F6080E51-675F-47A8-B125-55DCF66359F8} = {A26EBA9F-1777-46F2-994A-4C4D3055873D} + {7BB42513-1D79-474D-B31E-99673AD1BEBF} = {A26EBA9F-1777-46F2-994A-4C4D3055873D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A9AB8D62-C92D-4E97-A981-46E0A5AF56D1} diff --git a/Source/csla.test.sln b/Source/csla.test.sln index e60cc753f9..b5f1d570c6 100644 --- a/Source/csla.test.sln +++ b/Source/csla.test.sln @@ -95,6 +95,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Avalonia", "Csla. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Wpf.Tests", "tests\Csla.Xaml.Wpf.Tests\Csla.Xaml.Wpf.Tests.csproj", "{48FD4B37-2ED2-4221-8DC5-A358C21A2E11}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage", "Csla.Blazor.State.AzureTableStorage\Csla.Blazor.State.AzureTableStorage.csproj", "{87583187-4AF8-4707-9D05-9118DA296FBD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage.Tests", "tests\Csla.Blazor.State.AzureTableStorage.Tests\Csla.Blazor.State.AzureTableStorage.Tests.csproj", "{D8D85314-A28F-4916-B3C9-47F030137828}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1559,6 +1563,86 @@ Global {48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x64.Build.0 = Debug|Any CPU {48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x86.ActiveCfg = Debug|Any CPU {48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x86.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|ARM.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|ARM.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x64.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x64.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x86.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x86.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|ARM.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x64.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x64.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x86.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x86.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|Any CPU.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|ARM.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|ARM.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x64.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x64.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x86.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x86.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|Any CPU.Build.0 = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|ARM.ActiveCfg = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|ARM.Build.0 = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x64.ActiveCfg = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x64.Build.0 = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x86.ActiveCfg = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x86.Build.0 = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|Any CPU.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|Any CPU.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|ARM.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|ARM.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x64.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x64.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x86.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x86.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|ARM.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|ARM.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x64.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x86.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|ARM.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x64.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x64.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x86.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x86.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|Any CPU.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|ARM.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|ARM.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x64.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x64.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x86.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x86.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|Any CPU.Build.0 = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|ARM.ActiveCfg = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|ARM.Build.0 = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x64.ActiveCfg = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x64.Build.0 = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x86.ActiveCfg = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x86.Build.0 = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|Any CPU.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|Any CPU.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|ARM.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|ARM.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x64.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x64.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x86.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x86.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1589,6 +1673,7 @@ Global {50A9114F-5D22-43EF-43CF-559234473966} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE} {45D65B40-1387-4D6F-AFA7-E8AE40E442FC} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE} {8276B605-DBCF-484A-9603-BA1A6201A9F3} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {87583187-4AF8-4707-9D05-9118DA296FBD} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {46D05EF4-18C0-4E24-A0A3-A11C7FFFFBBA} diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs new file mode 100644 index 0000000000..4774dd12e6 --- /dev/null +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs @@ -0,0 +1,153 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Integration tests for the Azure Table Storage session store (require Azurite) +//----------------------------------------------------------------------- + +using Azure.Data.Tables; +using FluentAssertions; +using Csla.Configuration; +using Csla.Serialization; +using Csla.State; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Csla.Blazor.State.AzureTableStorage.Tests; + +/// +/// Round-trip tests against a real Azure Table endpoint. They use the +/// well-known Azurite development connection string and are excluded from CI +/// because they require the emulator (or a live storage account) to be running. +/// Start Azurite locally (for example: azurite) before running them. +/// +[TestClass] +[TestCategory("SkipOnCIServer")] +public class AzureTableStorageSessionStoreTests +{ + private const string DevelopmentConnectionString = "UseDevelopmentStorage=true"; + private const string Discriminator = "test-app"; + + private ServiceProvider _provider = null!; + private TableServiceClient _serviceClient = null!; + private string _tableName = null!; + + [TestInitialize] + public void TestInitialize() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCsla(); + _provider = services.BuildServiceProvider(); + + // Unique table per test so runs do not collide. Table names must be alphanumeric. + _tableName = "CslaTest" + Guid.NewGuid().ToString("N"); + _serviceClient = new TableServiceClient(DevelopmentConnectionString); + } + + [TestCleanup] + public void TestCleanup() + { + _serviceClient.DeleteTable(_tableName); + _provider.Dispose(); + } + + private AzureTableStorageSessionStore CreateStore(string discriminator = Discriminator) + { + var options = new AzureTableStorageOptions + { + TableName = _tableName, + ApplicationDiscriminator = discriminator, + }; + return new AzureTableStorageSessionStore( + _serviceClient, + _provider.GetRequiredService(), + options, + _provider.GetRequiredService>()); + } + + private async Task AddStaleEntityAsync(string key, string discriminator) + { + var entity = new SessionEntity + { + PartitionKey = key, + RowKey = discriminator, + Value = [0], + LastTouched = DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds(), + }; + await _serviceClient.GetTableClient(_tableName).UpsertEntityAsync(entity); + } + + private async Task EntityExistsAsync(string key, string discriminator) + { + var response = await _serviceClient.GetTableClient(_tableName).GetEntityIfExistsAsync(key, discriminator); + return response.HasValue; + } + + [TestMethod] + public async Task GetSessionAsync_MissingKey_ReturnsNull() + { + var store = CreateStore(); + + var result = await store.GetSessionAsync("does-not-exist"); + + result.Should().BeNull(); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_ThenGet_RoundTripsValues() + { + var store = CreateStore(); + var session = new Session { ["greeting"] = "hello" }; + + await store.AddOrUpdateSessionAsync("key-1", session); + var loaded = await store.GetSessionAsync("key-1"); + + loaded.Should().NotBeNull(); + loaded!["greeting"].Should().Be("hello"); + loaded.LastTouched.Should().Be(session.LastTouched); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_Twice_Upserts() + { + var store = CreateStore(); + + await store.AddOrUpdateSessionAsync("key-2", new Session { ["v"] = 1 }); + await store.AddOrUpdateSessionAsync("key-2", new Session { ["v"] = 2 }); + var loaded = await store.GetSessionAsync("key-2"); + + loaded.Should().NotBeNull(); + loaded!["v"].Should().Be(2); + } + + [TestMethod] + public async Task SessionsWithSameKey_AreIsolatedByDiscriminator() + { + var storeA = CreateStore("app-a"); + var storeB = CreateStore("app-b"); + + await storeA.AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "a" }); + await storeB.AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "b" }); + + (await storeA.GetSessionAsync("shared-key"))!["app"].Should().Be("a"); + (await storeB.GetSessionAsync("shared-key"))!["app"].Should().Be("b"); + } + + [TestMethod] + public async Task PurgeExpiredSessionsAsync_RemovesOnlyThisApplicationsStaleSessions() + { + var store = CreateStore(); + await AddStaleEntityAsync("stale", Discriminator); + await AddStaleEntityAsync("stale-other-app", "other-app"); + await store.AddOrUpdateSessionAsync("fresh", new Session { ["v"] = 1 }); + + await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1)); + + (await EntityExistsAsync("stale", Discriminator)).Should().BeFalse(); + (await EntityExistsAsync("stale-other-app", "other-app")).Should().BeTrue(); + (await EntityExistsAsync("fresh", Discriminator)).Should().BeTrue(); + } +} diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs new file mode 100644 index 0000000000..b59af312d4 --- /dev/null +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs @@ -0,0 +1,126 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// CI-safe DI registration tests for the Azure Table Storage session store +//----------------------------------------------------------------------- + +using Azure.Data.Tables; +using FluentAssertions; +using Csla.Configuration; +using Csla.State; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Csla.Blazor.State.AzureTableStorage.Tests; + +/// +/// Verifies the registrations made by . +/// No test resolves the session store itself, because its constructor contacts +/// Azure Storage; creating a does not. +/// +[TestClass] +public class ConfigurationExtensionsTests +{ + private const string DevelopmentConnectionString = "UseDevelopmentStorage=true"; + + [TestMethod] + public void UseAzureTableStorageSessionStore_RegistersSingletonISessionStore() + { + var services = new ServiceCollection(); + + new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString); + + var descriptor = services.Single(d => d.ServiceType == typeof(ISessionStore)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + descriptor.ImplementationFactory.Should().NotBeNull(); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_AfterAddServerSideBlazor_ReplacesInMemoryStore() + { + var services = new ServiceCollection(); + + services.AddCsla(o => o + .AddServerSideBlazor(b => b.UseInMemoryApplicationContextManager = false) + .UseAzureTableStorageSessionStore(s => s.ConnectionString = DevelopmentConnectionString)); + + var descriptor = services.Single(d => d.ServiceType == typeof(ISessionStore)); + descriptor.ImplementationType.Should().BeNull(); + descriptor.ImplementationFactory.Should().NotBeNull(); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_DoesNotRegisterTableServiceClient() + { + var services = new ServiceCollection(); + + new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString); + + services.Should().NotContain(d => d.ServiceType == typeof(TableServiceClient)); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_ReturnsSameConfigInstance() + { + var config = new CslaOptions(new ServiceCollection()); + + var result = config.UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString); + + result.Should().BeSameAs(config); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_NullConfig_Throws() + { + CslaOptions config = null!; + + var act = () => config.UseAzureTableStorageSessionStore(); + + act.Should().Throw(); + } + + [TestMethod] + public void GetTableServiceClient_ConnectionString_IgnoresRegisteredClient() + { + var registered = new TableServiceClient(new Uri("https://registered.table.core.windows.net"), new TableSharedKeyCredential("registered", Convert.ToBase64String(new byte[32]))); + var provider = new ServiceCollection() + .AddSingleton(registered) + .BuildServiceProvider(); + var options = new AzureTableStorageOptions { ConnectionString = DevelopmentConnectionString }; + + var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, options); + + client.Should().NotBeSameAs(registered); + client.AccountName.Should().Be("devstoreaccount1"); + } + + [TestMethod] + public void GetTableServiceClient_ServiceKey_ResolvesKeyedClient() + { + var keyed = new TableServiceClient(DevelopmentConnectionString); + var provider = new ServiceCollection() + .AddKeyedSingleton("state", keyed) + .AddSingleton(new TableServiceClient(DevelopmentConnectionString)) + .BuildServiceProvider(); + var options = new AzureTableStorageOptions { ServiceKey = "state" }; + + var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, options); + + client.Should().BeSameAs(keyed); + } + + [TestMethod] + public void GetTableServiceClient_NoConnectionStringOrKey_ResolvesDefaultClient() + { + var registered = new TableServiceClient(DevelopmentConnectionString); + var provider = new ServiceCollection() + .AddSingleton(registered) + .BuildServiceProvider(); + + var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, new AzureTableStorageOptions()); + + client.Should().BeSameAs(registered); + } +} diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj new file mode 100644 index 0000000000..a499ca0154 --- /dev/null +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0;net11.0 + false + true + ..\..\Csla\CslaKey.snk + + + + + + + + + + + + + + + + + + From eb6768fe77c6eddce9da6ac5b4601c2f3e615920 Mon Sep 17 00:00:00 2001 From: Joshua Hanson <454819+joshhanson314@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:23:38 -0400 Subject: [PATCH 2/4] #4867 Address review feedback on the Azure Table Storage session store Keep live sessions in memory and return them by reference, because SessionManager and ApplicationContextManagerBlazor change the returned session in place. The table is read on a cache miss, written on every AddOrUpdateSessionAsync, and written again for live sessions during purge. Purge deletes stale rows using the queried ETag. Resolve the serialization formatter in a new scope per operation, since it depends on the scoped ApplicationContext and keeps per-call state. Register with AddSingleton, register the options in DI, make the store and SessionEntity internal, log through LoggerMessage methods, and use an injected TimeProvider for the purge cutoff. Co-Authored-By: Claude Opus 5.5 --- ...zureTableStorageConfigurationExtensions.cs | 16 ++- .../AzureTableStorageSessionStore.cs | 123 +++++++++++++---- .../SessionEntity.cs | 2 +- .../AzureTableStorageSessionStoreTests.cs | 126 ++++++++++++++---- .../ConfigurationExtensionsTests.cs | 33 ++++- ...lazor.State.AzureTableStorage.Tests.csproj | 1 + 6 files changed, 245 insertions(+), 56 deletions(-) diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs index 6643ee66e7..5d36aff0fa 100644 --- a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs @@ -29,9 +29,11 @@ public static class AzureTableStorageConfigurationExtensions /// The CslaOptions instance. /// /// Call this after AddServerSideBlazor, which registers the default - /// in-memory session store; this method replaces that registration. The - /// session store is only used when UseInMemoryApplicationContextManager - /// is . + /// in-memory session store; the last registration wins. The session store + /// is only used when UseInMemoryApplicationContextManager is + /// . Call ISessionManager.PurgeSessions + /// periodically: besides removing expired sessions, it saves changes made + /// to live sessions to the table. /// /// The comes from /// when it is set, @@ -48,11 +50,15 @@ public static CslaOptions UseAzureTableStorageSessionStore(this CslaOptions conf var storeOptions = new AzureTableStorageOptions(); options?.Invoke(storeOptions); + // Singletons, because the singleton SessionManager depends on ISessionStore. + config.Services.AddSingleton(storeOptions); + config.Services.TryAddSingleton(TimeProvider.System); + // The client is resolved privately rather than registered, so the store // never changes which TableServiceClient the rest of the app receives. - config.Services.Replace(ServiceDescriptor.Singleton(sp => + config.Services.AddSingleton(sp => ActivatorUtilities.CreateInstance( - sp, GetTableServiceClient(sp, storeOptions), storeOptions))); + sp, GetTableServiceClient(sp, sp.GetRequiredService()))); return config; } diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs index 855da0f23b..6488999ac7 100644 --- a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs @@ -6,10 +6,12 @@ // Azure Table Storage implementation of ISessionStore //----------------------------------------------------------------------- +using System.Collections.Concurrent; using Azure; using Azure.Data.Tables; using Csla.Serialization; using Csla.State; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Csla.Blazor.State.AzureTableStorage; @@ -18,14 +20,30 @@ namespace Csla.Blazor.State.AzureTableStorage; /// Azure Table Storage implementation of . /// /// +/// +/// Live sessions are kept in memory and returned by reference, as +/// InMemorySessionStore does, because the session manager and the +/// Blazor context manager change the returned session in place. The table +/// is read only when a session is not in memory, such as after a restart. +/// +/// +/// Sessions are written to the table by , +/// and every live session is written again by +/// , which saves changes made in place +/// and refreshes the stored last-touched time. +/// +/// /// Read and write failures propagate to the caller. A failed read must not /// look like a missing session, because the session manager would then /// replace the stored session with a new, empty one. +/// /// -public class AzureTableStorageSessionStore : ISessionStore +internal sealed partial class AzureTableStorageSessionStore : ISessionStore { + private readonly ConcurrentDictionary _sessions = []; private readonly string _applicationDiscriminator; - private readonly ISerializationFormatter _formatter; + private readonly IServiceScopeFactory _scopeFactory; + private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly TableClient _tableClient; @@ -35,20 +53,23 @@ public class AzureTableStorageSessionStore : ISessionStore /// storage table if it does not exist. /// /// The Azure table service client. - /// The CSLA serialization formatter. /// The session store options. + /// Creates the scopes the serialization formatter is resolved in. + /// The time provider used to find expired sessions. /// The logger. /// When any argument is . public AzureTableStorageSessionStore( TableServiceClient tableServiceClient, - ISerializationFormatter formatter, AzureTableStorageOptions options, + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider, ILogger logger) { ArgumentNullException.ThrowIfNull(tableServiceClient); ArgumentNullException.ThrowIfNull(options); - _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _applicationDiscriminator = options.ApplicationDiscriminator; @@ -61,12 +82,17 @@ public AzureTableStorageSessionStore( { ArgumentNullException.ThrowIfNull(key); - _logger.LogDebug("Retrieving session {Key} from table {Table}", key, _tableClient.Name); + if (_sessions.TryGetValue(key, out var session)) + return session; + LogLoadingSession(_logger, key, _tableClient.Name); var response = await _tableClient.GetEntityIfExistsAsync(key, _applicationDiscriminator).ConfigureAwait(false); if (!response.HasValue || response.Value is null) return null; - return (Session?)_formatter.Deserialize(response.Value.Value); + + // Concurrent first reads of one key must share a single instance, + // or changes made to the losing copy would be lost. + return _sessions.GetOrAdd(key, Deserialize(response.Value.Value)); } /// @@ -75,16 +101,8 @@ public async ValueTask AddOrUpdateSessionAsync(string key, Session session) ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(session); - _logger.LogDebug("Saving session {Key} to table {Table}", key, _tableClient.Name); - - var entity = new SessionEntity - { - PartitionKey = key, - RowKey = _applicationDiscriminator, - Value = _formatter.Serialize(session), - LastTouched = session.LastTouched, - }; - await _tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace).ConfigureAwait(false); + _sessions[key] = session; + await SaveAsync(key, session).ConfigureAwait(false); } /// @@ -92,25 +110,84 @@ public async ValueTask PurgeExpiredSessionsAsync(TimeSpan expiration) { ArgumentOutOfRangeException.ThrowIfNegative(expiration.Ticks, nameof(expiration)); - var cutoff = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - (long)expiration.TotalSeconds; - var discriminator = _applicationDiscriminator; + var cutoff = _timeProvider.GetUtcNow().ToUnixTimeSeconds() - (long)expiration.TotalSeconds; + // Purging is best effort: one failure should not stop the rest of the sweep. + foreach (var item in _sessions) + { + if (item.Value.LastTouched < cutoff) + { + _sessions.TryRemove(item); + continue; + } + try + { + await SaveAsync(item.Key, item.Value).ConfigureAwait(false); + } + catch (Exception ex) when (ex is RequestFailedException or InvalidOperationException) + { + // InvalidOperationException: a request changed the session while it was being serialized. + LogSaveFailed(_logger, ex, item.Key, _tableClient.Name); + } + } + + var discriminator = _applicationDiscriminator; var expired = _tableClient.QueryAsync( x => x.RowKey == discriminator && x.LastTouched < cutoff, select: [nameof(SessionEntity.PartitionKey), nameof(SessionEntity.RowKey)]); await foreach (var entity in expired.ConfigureAwait(false)) { - // Purging is best effort: another instance may already have removed - // the session, and one failure should not stop the rest of the sweep. + if (_sessions.ContainsKey(entity.PartitionKey)) + continue; try { - await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey).ConfigureAwait(false); + // The ETag makes the delete fail if the session was saved after the query. + await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey, entity.ETag).ConfigureAwait(false); } catch (RequestFailedException ex) { - _logger.LogWarning(ex, "Failed to purge expired session {Key} from table {Table}", entity.PartitionKey, _tableClient.Name); + LogPurgeFailed(_logger, ex, entity.PartitionKey, _tableClient.Name); } } } + + private async Task SaveAsync(string key, Session session) + { + LogSavingSession(_logger, key, _tableClient.Name); + var entity = new SessionEntity + { + PartitionKey = key, + RowKey = _applicationDiscriminator, + Value = Serialize(session), + LastTouched = session.LastTouched, + }; + await _tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace).ConfigureAwait(false); + } + + // The formatter depends on the scoped ApplicationContext and keeps + // per-call state, so each operation resolves its own in a new scope. + private byte[] Serialize(Session session) + { + using var scope = _scopeFactory.CreateScope(); + return scope.ServiceProvider.GetRequiredService().Serialize(session); + } + + private Session Deserialize(byte[] data) + { + using var scope = _scopeFactory.CreateScope(); + return (Session)scope.ServiceProvider.GetRequiredService().Deserialize(data)!; + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Loading session {Key} from table {Table}")] + private static partial void LogLoadingSession(ILogger logger, string key, string table); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Saving session {Key} to table {Table}")] + private static partial void LogSavingSession(ILogger logger, string key, string table); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to save session {Key} to table {Table} during purge")] + private static partial void LogSaveFailed(ILogger logger, Exception exception, string key, string table); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to purge expired session {Key} from table {Table}")] + private static partial void LogPurgeFailed(ILogger logger, Exception exception, string key, string table); } diff --git a/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs b/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs index 97bc6a7026..5b4e61164b 100644 --- a/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs +++ b/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs @@ -20,7 +20,7 @@ namespace Csla.Blazor.State.AzureTableStorage; /// holds the application discriminator, so that /// multiple applications can safely share a single storage table. /// -public class SessionEntity : ITableEntity +internal sealed class SessionEntity : ITableEntity { /// /// Gets or sets the partition key (the session key). diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs index 4774dd12e6..290f224385 100644 --- a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs @@ -9,10 +9,10 @@ using Azure.Data.Tables; using FluentAssertions; using Csla.Configuration; -using Csla.Serialization; using Csla.State; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Csla.Blazor.State.AzureTableStorage.Tests; @@ -23,6 +23,10 @@ namespace Csla.Blazor.State.AzureTableStorage.Tests; /// because they require the emulator (or a live storage account) to be running. /// Start Azurite locally (for example: azurite) before running them. /// +/// +/// A second store instance over the same table stands in for a restarted or +/// different server: it has no sessions in memory, so it reads the table. +/// [TestClass] [TestCategory("SkipOnCIServer")] public class AzureTableStorageSessionStoreTests @@ -32,6 +36,7 @@ public class AzureTableStorageSessionStoreTests private ServiceProvider _provider = null!; private TableServiceClient _serviceClient = null!; + private FakeTimeProvider _timeProvider = null!; private string _tableName = null!; [TestInitialize] @@ -42,6 +47,9 @@ public void TestInitialize() services.AddCsla(); _provider = services.BuildServiceProvider(); + // Session.Touch uses the system clock, so the fake clock starts at the real time. + _timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + // Unique table per test so runs do not collide. Table names must be alphanumeric. _tableName = "CslaTest" + Guid.NewGuid().ToString("N"); _serviceClient = new TableServiceClient(DevelopmentConnectionString); @@ -63,8 +71,9 @@ private AzureTableStorageSessionStore CreateStore(string discriminator = Discrim }; return new AzureTableStorageSessionStore( _serviceClient, - _provider.GetRequiredService(), options, + _provider.GetRequiredService(), + _timeProvider, _provider.GetRequiredService>()); } @@ -80,7 +89,7 @@ private async Task AddStaleEntityAsync(string key, string discriminator) await _serviceClient.GetTableClient(_tableName).UpsertEntityAsync(entity); } - private async Task EntityExistsAsync(string key, string discriminator) + private async Task EntityExistsAsync(string key, string discriminator = Discriminator) { var response = await _serviceClient.GetTableClient(_tableName).GetEntityIfExistsAsync(key, discriminator); return response.HasValue; @@ -97,47 +106,67 @@ public async Task GetSessionAsync_MissingKey_ReturnsNull() } [TestMethod] - public async Task AddOrUpdateSessionAsync_ThenGet_RoundTripsValues() + public async Task GetSessionAsync_ReturnsTheStoredInstance() { var store = CreateStore(); - var session = new Session { ["greeting"] = "hello" }; + var session = new Session { ["v"] = 1 }; + await store.AddOrUpdateSessionAsync("key", session); - await store.AddOrUpdateSessionAsync("key-1", session); - var loaded = await store.GetSessionAsync("key-1"); + var loaded = await store.GetSessionAsync("key"); - loaded.Should().NotBeNull(); - loaded!["greeting"].Should().Be("hello"); - loaded.LastTouched.Should().Be(session.LastTouched); + loaded.Should().BeSameAs(session); } [TestMethod] - public async Task AddOrUpdateSessionAsync_Twice_Upserts() + public async Task GetSessionAsync_ChangesMadeInPlace_AreVisibleToLaterReads() { var store = CreateStore(); + await store.AddOrUpdateSessionAsync("key", new Session()); + + (await store.GetSessionAsync("key"))!["v"] = "changed"; + var loaded = await store.GetSessionAsync("key"); + + loaded!["v"].Should().Be("changed"); + } + + [TestMethod] + public async Task GetSessionAsync_NotInMemory_LoadsFromTable() + { + await CreateStore().AddOrUpdateSessionAsync("key", new Session { ["greeting"] = "hello" }); + var restarted = CreateStore(); - await store.AddOrUpdateSessionAsync("key-2", new Session { ["v"] = 1 }); - await store.AddOrUpdateSessionAsync("key-2", new Session { ["v"] = 2 }); - var loaded = await store.GetSessionAsync("key-2"); + var first = await restarted.GetSessionAsync("key"); + var second = await restarted.GetSessionAsync("key"); + + first.Should().NotBeNull(); + first!["greeting"].Should().Be("hello"); + second.Should().BeSameAs(first); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_Twice_WritesLatestToTable() + { + var store = CreateStore(); + + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 }); + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 2 }); + var loaded = await CreateStore().GetSessionAsync("key"); - loaded.Should().NotBeNull(); loaded!["v"].Should().Be(2); } [TestMethod] public async Task SessionsWithSameKey_AreIsolatedByDiscriminator() { - var storeA = CreateStore("app-a"); - var storeB = CreateStore("app-b"); - - await storeA.AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "a" }); - await storeB.AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "b" }); + await CreateStore("app-a").AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "a" }); + await CreateStore("app-b").AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "b" }); - (await storeA.GetSessionAsync("shared-key"))!["app"].Should().Be("a"); - (await storeB.GetSessionAsync("shared-key"))!["app"].Should().Be("b"); + (await CreateStore("app-a").GetSessionAsync("shared-key"))!["app"].Should().Be("a"); + (await CreateStore("app-b").GetSessionAsync("shared-key"))!["app"].Should().Be("b"); } [TestMethod] - public async Task PurgeExpiredSessionsAsync_RemovesOnlyThisApplicationsStaleSessions() + public async Task PurgeExpiredSessionsAsync_RemovesOnlyThisApplicationsStaleRows() { var store = CreateStore(); await AddStaleEntityAsync("stale", Discriminator); @@ -146,8 +175,55 @@ public async Task PurgeExpiredSessionsAsync_RemovesOnlyThisApplicationsStaleSess await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1)); - (await EntityExistsAsync("stale", Discriminator)).Should().BeFalse(); + (await EntityExistsAsync("stale")).Should().BeFalse(); (await EntityExistsAsync("stale-other-app", "other-app")).Should().BeTrue(); - (await EntityExistsAsync("fresh", Discriminator)).Should().BeTrue(); + (await EntityExistsAsync("fresh")).Should().BeTrue(); + } + + [TestMethod] + public async Task PurgeExpiredSessionsAsync_SavesChangesMadeInPlace() + { + var store = CreateStore(); + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 }); + (await store.GetSessionAsync("key"))!["v"] = 2; + + await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1)); + + (await CreateStore().GetSessionAsync("key"))!["v"].Should().Be(2); + } + + [TestMethod] + public async Task PurgeExpiredSessionsAsync_RemovesExpiredSessionsFromMemoryAndTable() + { + var store = CreateStore(); + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 }); + + _timeProvider.Advance(TimeSpan.FromHours(2)); + await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1)); + + (await EntityExistsAsync("key")).Should().BeFalse(); + (await store.GetSessionAsync("key")).Should().BeNull(); + } + + [TestMethod] + public async Task Store_ResolvedWithScopeValidation_HandlesConcurrentOperations() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCsla(o => o.UseAzureTableStorageSessionStore(s => + { + s.ConnectionString = DevelopmentConnectionString; + s.TableName = _tableName; + s.ApplicationDiscriminator = Discriminator; + })); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var store = provider.GetRequiredService(); + var keys = Enumerable.Range(0, 20).Select(i => $"key-{i}").ToList(); + + await Task.WhenAll(keys.Select(k => store.AddOrUpdateSessionAsync(k, new Session { ["k"] = k }).AsTask())); + var restarted = CreateStore(); + var loaded = await Task.WhenAll(keys.Select(k => restarted.GetSessionAsync(k).AsTask())); + + loaded.Select(s => s!["k"]).Should().Equal(keys); } } diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs index b59af312d4..3fdc26bced 100644 --- a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs @@ -11,6 +11,7 @@ using Csla.Configuration; using Csla.State; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Csla.Blazor.State.AzureTableStorage.Tests; @@ -38,7 +39,7 @@ public void UseAzureTableStorageSessionStore_RegistersSingletonISessionStore() } [TestMethod] - public void UseAzureTableStorageSessionStore_AfterAddServerSideBlazor_ReplacesInMemoryStore() + public void UseAzureTableStorageSessionStore_AfterAddServerSideBlazor_IsLastISessionStoreRegistration() { var services = new ServiceCollection(); @@ -46,11 +47,39 @@ public void UseAzureTableStorageSessionStore_AfterAddServerSideBlazor_ReplacesIn .AddServerSideBlazor(b => b.UseInMemoryApplicationContextManager = false) .UseAzureTableStorageSessionStore(s => s.ConnectionString = DevelopmentConnectionString)); - var descriptor = services.Single(d => d.ServiceType == typeof(ISessionStore)); + var descriptor = services.Last(d => d.ServiceType == typeof(ISessionStore)); descriptor.ImplementationType.Should().BeNull(); descriptor.ImplementationFactory.Should().NotBeNull(); } + [TestMethod] + public void UseAzureTableStorageSessionStore_RegistersConfiguredOptionsAsSingleton() + { + var services = new ServiceCollection(); + + new CslaOptions(services).UseAzureTableStorageSessionStore(o => + { + o.ConnectionString = DevelopmentConnectionString; + o.TableName = "CustomTable"; + }); + + var descriptor = services.Single(d => d.ServiceType == typeof(AzureTableStorageOptions)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + ((AzureTableStorageOptions)descriptor.ImplementationInstance!).TableName.Should().Be("CustomTable"); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_KeepsRegisteredTimeProvider() + { + var services = new ServiceCollection(); + var timeProvider = new FakeTimeProvider(); + services.AddSingleton(timeProvider); + + new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString); + + services.Single(d => d.ServiceType == typeof(TimeProvider)).ImplementationInstance.Should().BeSameAs(timeProvider); + } + [TestMethod] public void UseAzureTableStorageSessionStore_DoesNotRegisterTableServiceClient() { diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj index a499ca0154..64444e5999 100644 --- a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj @@ -9,6 +9,7 @@ + From db28cdc6267fcb4c17624c20f8bb89f886108d8a Mon Sep 17 00:00:00 2001 From: Joshua Hanson <454819+joshhanson314@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:14:21 -0400 Subject: [PATCH 3/4] #4867 Stop serializing live sessions during purge and split large sessions Purge serialized sessions that requests change in place, which could write data that cannot be deserialized, and could overwrite a newer session saved for the same key. Purge now refreshes only the stored LastTouched column of live sessions. AddOrUpdateSessionAsync serializes, writes, and updates the cache under a per-key lock, publishing the session only after the write succeeds. Azure Tables limits a binary property to 64 KiB, so session data is split across up to 11 columns (704 KiB). Larger sessions fail with InvalidOperationException before anything is written or cached. Co-Authored-By: Claude Opus 5.5 --- ...zureTableStorageConfigurationExtensions.cs | 11 +- .../AzureTableStorageSessionStore.cs | 117 ++++++++++++++---- .../SessionEntity.cs | 55 -------- .../AzureTableStorageSessionStoreTests.cs | 94 ++++++++++++-- 4 files changed, 181 insertions(+), 96 deletions(-) delete mode 100644 Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs index 5d36aff0fa..e0021a8dc6 100644 --- a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs @@ -32,8 +32,15 @@ public static class AzureTableStorageConfigurationExtensions /// in-memory session store; the last registration wins. The session store /// is only used when UseInMemoryApplicationContextManager is /// . Call ISessionManager.PurgeSessions - /// periodically: besides removing expired sessions, it saves changes made - /// to live sessions to the table. + /// periodically: besides removing expired sessions, it refreshes the stored + /// last-touched time of live sessions, so other servers do not purge them. + /// + /// Live sessions are kept in memory. Session data is written to the table + /// when a session is saved through ISessionManager.UpdateSession, + /// such as when the WebAssembly client sends its state; changes made in + /// place are written with the next save. A session can serialize to at + /// most 704 KiB. + /// /// /// The comes from /// when it is set, diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs index 6488999ac7..7501bdd06e 100644 --- a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs @@ -27,10 +27,18 @@ namespace Csla.Blazor.State.AzureTableStorage; /// is read only when a session is not in memory, such as after a restart. /// /// -/// Sessions are written to the table by , -/// and every live session is written again by -/// , which saves changes made in place -/// and refreshes the stored last-touched time. +/// Session data is written to the table only by +/// , which serializes the session before +/// publishing it to the cache. refreshes +/// only the stored last-touched time of live sessions and never serializes +/// them, because a change a request makes during serialization can produce +/// data that cannot be deserialized. +/// +/// +/// Each row holds one session in the LastTouched column and up to +/// binary columns of bytes, +/// because Azure Tables limits a binary property to 64 KiB and an entity +/// to 1 MiB. /// /// /// Read and write failures propagate to the caller. A failed read must not @@ -40,7 +48,16 @@ namespace Csla.Blazor.State.AzureTableStorage; /// internal sealed partial class AzureTableStorageSessionStore : ISessionStore { + internal const int ChunkSize = 64 * 1024; + // The 1 MiB entity limit can be measured on the request, where binary + // values are base64 encoded (Azurite measures it that way), so 11 chunks + // (704 KiB) is the most that fits however it is measured. + internal const int MaxChunks = 11; + private const string ValueColumn = "Value"; + private const string LastTouchedColumn = "LastTouched"; + private readonly ConcurrentDictionary _sessions = []; + private readonly SemaphoreSlim[] _keyLocks = [.. Enumerable.Range(0, 32).Select(_ => new SemaphoreSlim(1, 1))]; private readonly string _applicationDiscriminator; private readonly IServiceScopeFactory _scopeFactory; private readonly TimeProvider _timeProvider; @@ -86,23 +103,35 @@ public AzureTableStorageSessionStore( return session; LogLoadingSession(_logger, key, _tableClient.Name); - var response = await _tableClient.GetEntityIfExistsAsync(key, _applicationDiscriminator).ConfigureAwait(false); + var response = await _tableClient.GetEntityIfExistsAsync(key, _applicationDiscriminator).ConfigureAwait(false); if (!response.HasValue || response.Value is null) return null; // Concurrent first reads of one key must share a single instance, // or changes made to the losing copy would be lost. - return _sessions.GetOrAdd(key, Deserialize(response.Value.Value)); + return _sessions.GetOrAdd(key, Deserialize(ReadData(response.Value))); } /// + /// The session serializes to more than the store can hold. public async ValueTask AddOrUpdateSessionAsync(string key, Session session) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(session); - _sessions[key] = session; - await SaveAsync(key, session).ConfigureAwait(false); + // The lock keeps the table and the cache in the same order for one key. + var keyLock = GetKeyLock(key); + await keyLock.WaitAsync().ConfigureAwait(false); + try + { + LogSavingSession(_logger, key, _tableClient.Name); + await _tableClient.UpsertEntityAsync(CreateEntity(key, session), TableUpdateMode.Replace).ConfigureAwait(false); + _sessions[key] = session; + } + finally + { + keyLock.Release(); + } } /// @@ -122,19 +151,16 @@ public async ValueTask PurgeExpiredSessionsAsync(TimeSpan expiration) } try { - await SaveAsync(item.Key, item.Value).ConfigureAwait(false); + await RefreshLastTouchedAsync(item.Key).ConfigureAwait(false); } - catch (Exception ex) when (ex is RequestFailedException or InvalidOperationException) + catch (RequestFailedException ex) { - // InvalidOperationException: a request changed the session while it was being serialized. - LogSaveFailed(_logger, ex, item.Key, _tableClient.Name); + LogRefreshFailed(_logger, ex, item.Key, _tableClient.Name); } } - var discriminator = _applicationDiscriminator; - var expired = _tableClient.QueryAsync( - x => x.RowKey == discriminator && x.LastTouched < cutoff, - select: [nameof(SessionEntity.PartitionKey), nameof(SessionEntity.RowKey)]); + var filter = TableClient.CreateQueryFilter($"RowKey eq {_applicationDiscriminator} and LastTouched lt {cutoff}"); + var expired = _tableClient.QueryAsync(filter, select: [nameof(TableEntity.PartitionKey), nameof(TableEntity.RowKey)]); await foreach (var entity in expired.ConfigureAwait(false)) { @@ -152,19 +178,56 @@ public async ValueTask PurgeExpiredSessionsAsync(TimeSpan expiration) } } - private async Task SaveAsync(string key, Session session) + // Writes only the timestamp, so the stored session data is never replaced + // by a copy serialized while a request was changing it. + private async Task RefreshLastTouchedAsync(string key) + { + var keyLock = GetKeyLock(key); + await keyLock.WaitAsync().ConfigureAwait(false); + try + { + if (!_sessions.TryGetValue(key, out var session)) + return; + var entity = new TableEntity(key, _applicationDiscriminator) { [LastTouchedColumn] = session.LastTouched }; + await _tableClient.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Merge).ConfigureAwait(false); + } + finally + { + keyLock.Release(); + } + } + + private SemaphoreSlim GetKeyLock(string key) => + _keyLocks[(uint)StringComparer.Ordinal.GetHashCode(key) % (uint)_keyLocks.Length]; + + private TableEntity CreateEntity(string key, Session session) { - LogSavingSession(_logger, key, _tableClient.Name); - var entity = new SessionEntity + var data = Serialize(session); + if (data.Length > MaxChunks * ChunkSize) + throw new InvalidOperationException( + $"Session {key} serializes to {data.Length} bytes, more than the {MaxChunks * ChunkSize} bytes the Azure Table Storage session store can hold."); + + var entity = new TableEntity(key, _applicationDiscriminator) { [LastTouchedColumn] = session.LastTouched }; + var chunkCount = Math.Max(1, (data.Length + ChunkSize - 1) / ChunkSize); + for (var index = 0; index < chunkCount; index++) { - PartitionKey = key, - RowKey = _applicationDiscriminator, - Value = Serialize(session), - LastTouched = session.LastTouched, - }; - await _tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace).ConfigureAwait(false); + var offset = index * ChunkSize; + entity[GetValueColumn(index)] = data.AsSpan(offset, Math.Min(ChunkSize, data.Length - offset)).ToArray(); + } + return entity; } + private static byte[] ReadData(TableEntity entity) + { + var chunks = new List(); + for (var index = 0; index < MaxChunks && entity.GetBinary(GetValueColumn(index)) is { } chunk; index++) + chunks.Add(chunk); + return chunks.Count == 1 ? chunks[0] : [.. chunks.SelectMany(c => c)]; + } + + // The first column keeps the name "Value", so single-column rows still read. + private static string GetValueColumn(int index) => index == 0 ? ValueColumn : ValueColumn + index; + // The formatter depends on the scoped ApplicationContext and keeps // per-call state, so each operation resolves its own in a new scope. private byte[] Serialize(Session session) @@ -185,8 +248,8 @@ private Session Deserialize(byte[] data) [LoggerMessage(Level = LogLevel.Debug, Message = "Saving session {Key} to table {Table}")] private static partial void LogSavingSession(ILogger logger, string key, string table); - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to save session {Key} to table {Table} during purge")] - private static partial void LogSaveFailed(ILogger logger, Exception exception, string key, string table); + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to refresh the last-touched time of session {Key} in table {Table}")] + private static partial void LogRefreshFailed(ILogger logger, Exception exception, string key, string table); [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to purge expired session {Key} from table {Table}")] private static partial void LogPurgeFailed(ILogger logger, Exception exception, string key, string table); diff --git a/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs b/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs deleted file mode 100644 index 5b4e61164b..0000000000 --- a/Source/Csla.Blazor.State.AzureTableStorage/SessionEntity.cs +++ /dev/null @@ -1,55 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) Marimer LLC. All rights reserved. -// Website: https://cslanet.com -// -// Azure Table Storage entity for a stored session -//----------------------------------------------------------------------- - -using Azure; -using Azure.Data.Tables; - -namespace Csla.Blazor.State.AzureTableStorage; - -/// -/// Azure Table Storage entity representing a single stored -/// . -/// -/// -/// The holds the session key and the -/// holds the application discriminator, so that -/// multiple applications can safely share a single storage table. -/// -internal sealed class SessionEntity : ITableEntity -{ - /// - /// Gets or sets the partition key (the session key). - /// - public required string PartitionKey { get; set; } - - /// - /// Gets or sets the row key (the application discriminator). - /// - public required string RowKey { get; set; } - - /// - /// Gets or sets the entity timestamp (managed by Azure). - /// - public DateTimeOffset? Timestamp { get; set; } - - /// - /// Gets or sets the entity ETag (managed by Azure). - /// - public ETag ETag { get; set; } = ETag.All; - - /// - /// Gets or sets the serialized session data. - /// - public required byte[] Value { get; set; } - - /// - /// Gets or sets the Unix-seconds timestamp of the last time - /// the session was touched (used for expiration purging). - /// - public required long LastTouched { get; set; } -} diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs index 290f224385..3b3f6e763d 100644 --- a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs @@ -77,24 +77,30 @@ private AzureTableStorageSessionStore CreateStore(string discriminator = Discrim _provider.GetRequiredService>()); } + private static long OneDayAgo => DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds(); + private async Task AddStaleEntityAsync(string key, string discriminator) { - var entity = new SessionEntity + var entity = new TableEntity(key, discriminator) { - PartitionKey = key, - RowKey = discriminator, - Value = [0], - LastTouched = DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds(), + ["Value"] = new byte[] { 0 }, + ["LastTouched"] = OneDayAgo, }; await _serviceClient.GetTableClient(_tableName).UpsertEntityAsync(entity); } - private async Task EntityExistsAsync(string key, string discriminator = Discriminator) + private async Task GetEntityAsync(string key, string discriminator = Discriminator) { - var response = await _serviceClient.GetTableClient(_tableName).GetEntityIfExistsAsync(key, discriminator); - return response.HasValue; + var response = await _serviceClient.GetTableClient(_tableName).GetEntityIfExistsAsync(key, discriminator); + return response.HasValue ? response.Value : null; } + private async Task EntityExistsAsync(string key, string discriminator = Discriminator) => + await GetEntityAsync(key, discriminator) is not null; + + private static Session CreateSessionOfSize(int approximateBytes) => + new() { ["payload"] = new string('x', approximateBytes) }; + [TestMethod] public async Task GetSessionAsync_MissingKey_ReturnsNull() { @@ -181,15 +187,79 @@ public async Task PurgeExpiredSessionsAsync_RemovesOnlyThisApplicationsStaleRows } [TestMethod] - public async Task PurgeExpiredSessionsAsync_SavesChangesMadeInPlace() + public async Task PurgeExpiredSessionsAsync_RefreshesLastTouchedWithoutWritingData() { var store = CreateStore(); - await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 }); - (await store.GetSessionAsync("key"))!["v"] = 2; + var session = new Session { ["v"] = 1 }; + await store.AddOrUpdateSessionAsync("key", session); + await _serviceClient.GetTableClient(_tableName).UpdateEntityAsync( + new TableEntity("key", Discriminator) { ["LastTouched"] = OneDayAgo }, Azure.ETag.All, TableUpdateMode.Merge); + session["v"] = 2; await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1)); - (await CreateStore().GetSessionAsync("key"))!["v"].Should().Be(2); + (await GetEntityAsync("key"))!.GetInt64("LastTouched").Should().Be(session.LastTouched); + (await CreateStore().GetSessionAsync("key"))!["v"].Should().Be(1); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_ConcurrentSameKey_CacheAndTableAgree() + { + var store = CreateStore(); + + await Task.WhenAll(Enumerable.Range(0, 20).Select(i => + store.AddOrUpdateSessionAsync("key", new Session { ["v"] = i }).AsTask())); + + var cached = await store.GetSessionAsync("key"); + var stored = await CreateStore().GetSessionAsync("key"); + stored!["v"].Should().Be(cached!["v"]); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_LargerThanOneColumn_RoundTrips() + { + var session = CreateSessionOfSize(200 * 1024); + + await CreateStore().AddOrUpdateSessionAsync("key", session); + var loaded = await CreateStore().GetSessionAsync("key"); + + (await GetEntityAsync("key"))!.ContainsKey("Value3").Should().BeTrue(); + loaded!["payload"].Should().Be(session["payload"]); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_NearSizeLimit_RoundTrips() + { + var session = CreateSessionOfSize(AzureTableStorageSessionStore.MaxChunks * AzureTableStorageSessionStore.ChunkSize - 4096); + + await CreateStore().AddOrUpdateSessionAsync("key", session); + var loaded = await CreateStore().GetSessionAsync("key"); + + loaded!["payload"].Should().Be(session["payload"]); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_OverSizeLimit_ThrowsAndDoesNotCache() + { + var store = CreateStore(); + var session = CreateSessionOfSize(AzureTableStorageSessionStore.MaxChunks * AzureTableStorageSessionStore.ChunkSize + 1024); + + var act = async () => await store.AddOrUpdateSessionAsync("key", session); + + await act.Should().ThrowAsync(); + (await store.GetSessionAsync("key")).Should().BeNull(); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_SmallerSession_RemovesLeftoverColumns() + { + var store = CreateStore(); + await store.AddOrUpdateSessionAsync("key", CreateSessionOfSize(200 * 1024)); + + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 }); + + (await GetEntityAsync("key"))!.ContainsKey("Value1").Should().BeFalse(); + (await CreateStore().GetSessionAsync("key"))!["v"].Should().Be(1); } [TestMethod] From b83d92486871d6456731d7e7459faf20a7c7cd16 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 1 Nov 2026 16:48:16 -0600 Subject: [PATCH 4/4] #4867 Address Stefan's review of the Azure Table Storage session store - Implement IDisposable to dispose the per-key SemaphoreSlim locks - Use instance LoggerMessage methods that pick up the _logger field - Use the GetOrAdd factory overload so only the winning read deserializes - Log an ETag precondition failure during purge at Debug, not Warning - Use nameof(Session.LastTouched) for the column name - Move the oversize-session message into the Csla resource file - Use cref links in the configuration docs and make the class remarks less tied to framework internals - Dispose service providers in the registration tests Co-Authored-By: Claude Opus 5.5 --- ...zureTableStorageConfigurationExtensions.cs | 8 +- .../AzureTableStorageSessionStore.cs | 78 +++++++++++-------- Source/Csla/Properties/Resources.Designer.cs | 9 +++ Source/Csla/Properties/Resources.resx | 4 + .../ConfigurationExtensionsTests.cs | 6 +- 5 files changed, 66 insertions(+), 39 deletions(-) diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs index e0021a8dc6..9b205de1a9 100644 --- a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs @@ -24,19 +24,19 @@ public static class AzureTableStorageConfigurationExtensions /// Configures CSLA to use the Azure Table Storage /// implementation. /// - /// The CslaOptions instance. + /// The instance. /// An optional delegate to configure the store. - /// The CslaOptions instance. + /// The instance. /// /// Call this after AddServerSideBlazor, which registers the default /// in-memory session store; the last registration wins. The session store /// is only used when UseInMemoryApplicationContextManager is - /// . Call ISessionManager.PurgeSessions + /// . Call /// periodically: besides removing expired sessions, it refreshes the stored /// last-touched time of live sessions, so other servers do not purge them. /// /// Live sessions are kept in memory. Session data is written to the table - /// when a session is saved through ISessionManager.UpdateSession, + /// when a session is saved through , /// such as when the WebAssembly client sends its state; changes made in /// place are written with the next save. A session can serialize to at /// most 704 KiB. diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs index 7501bdd06e..f4223d5f53 100644 --- a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs @@ -7,8 +7,10 @@ //----------------------------------------------------------------------- using System.Collections.Concurrent; +using System.Net; using Azure; using Azure.Data.Tables; +using Csla.Properties; using Csla.Serialization; using Csla.State; using Microsoft.Extensions.DependencyInjection; @@ -21,40 +23,32 @@ namespace Csla.Blazor.State.AzureTableStorage; /// /// /// -/// Live sessions are kept in memory and returned by reference, as -/// InMemorySessionStore does, because the session manager and the -/// Blazor context manager change the returned session in place. The table -/// is read only when a session is not in memory, such as after a restart. +/// Live sessions are kept in memory and returned by reference, so callers +/// can change a session in place. The table is read only when a session is +/// not in memory, such as after a restart. /// /// /// Session data is written to the table only by -/// , which serializes the session before -/// publishing it to the cache. refreshes -/// only the stored last-touched time of live sessions and never serializes -/// them, because a change a request makes during serialization can produce -/// data that cannot be deserialized. +/// . +/// refreshes only the stored last-touched time of live sessions, so it never +/// serializes a session that a request may be changing. /// /// -/// Each row holds one session in the LastTouched column and up to -/// binary columns of bytes, -/// because Azure Tables limits a binary property to 64 KiB and an entity -/// to 1 MiB. -/// -/// -/// Read and write failures propagate to the caller. A failed read must not -/// look like a missing session, because the session manager would then -/// replace the stored session with a new, empty one. +/// Read and write failures propagate to the caller, so a failed read is never +/// mistaken for a missing session. /// /// -internal sealed partial class AzureTableStorageSessionStore : ISessionStore +internal sealed partial class AzureTableStorageSessionStore : ISessionStore, IDisposable { + // Azure Tables limits a binary property to 64 KiB, so session data is split + // across several columns of this size. internal const int ChunkSize = 64 * 1024; // The 1 MiB entity limit can be measured on the request, where binary // values are base64 encoded (Azurite measures it that way), so 11 chunks // (704 KiB) is the most that fits however it is measured. internal const int MaxChunks = 11; private const string ValueColumn = "Value"; - private const string LastTouchedColumn = "LastTouched"; + private const string LastTouchedColumn = nameof(Session.LastTouched); private readonly ConcurrentDictionary _sessions = []; private readonly SemaphoreSlim[] _keyLocks = [.. Enumerable.Range(0, 32).Select(_ => new SemaphoreSlim(1, 1))]; @@ -102,14 +96,15 @@ public AzureTableStorageSessionStore( if (_sessions.TryGetValue(key, out var session)) return session; - LogLoadingSession(_logger, key, _tableClient.Name); + LogLoadingSession(key, _tableClient.Name); var response = await _tableClient.GetEntityIfExistsAsync(key, _applicationDiscriminator).ConfigureAwait(false); if (!response.HasValue || response.Value is null) return null; - // Concurrent first reads of one key must share a single instance, - // or changes made to the losing copy would be lost. - return _sessions.GetOrAdd(key, Deserialize(ReadData(response.Value))); + // Callers that load the same key at the same time all get the instance + // that is cached first, so they all change the same session in place. + var entity = response.Value; + return _sessions.GetOrAdd(key, _ => Deserialize(ReadData(entity))); } /// @@ -124,7 +119,7 @@ public async ValueTask AddOrUpdateSessionAsync(string key, Session session) await keyLock.WaitAsync().ConfigureAwait(false); try { - LogSavingSession(_logger, key, _tableClient.Name); + LogSavingSession(key, _tableClient.Name); await _tableClient.UpsertEntityAsync(CreateEntity(key, session), TableUpdateMode.Replace).ConfigureAwait(false); _sessions[key] = session; } @@ -155,7 +150,7 @@ public async ValueTask PurgeExpiredSessionsAsync(TimeSpan expiration) } catch (RequestFailedException ex) { - LogRefreshFailed(_logger, ex, item.Key, _tableClient.Name); + LogRefreshFailed(ex, item.Key, _tableClient.Name); } } @@ -171,13 +166,27 @@ public async ValueTask PurgeExpiredSessionsAsync(TimeSpan expiration) // The ETag makes the delete fail if the session was saved after the query. await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey, entity.ETag).ConfigureAwait(false); } + catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.PreconditionFailed) + { + // Expected: the session was saved after the query, so it is still in use. + LogPurgeSkipped(entity.PartitionKey, _tableClient.Name); + } catch (RequestFailedException ex) { - LogPurgeFailed(_logger, ex, entity.PartitionKey, _tableClient.Name); + LogPurgeFailed(ex, entity.PartitionKey, _tableClient.Name); } } } + /// + /// Releases the locks used to coordinate writes. + /// + public void Dispose() + { + foreach (var keyLock in _keyLocks) + keyLock.Dispose(); + } + // Writes only the timestamp, so the stored session data is never replaced // by a copy serialized while a request was changing it. private async Task RefreshLastTouchedAsync(string key) @@ -186,6 +195,8 @@ private async Task RefreshLastTouchedAsync(string key) await keyLock.WaitAsync().ConfigureAwait(false); try { + // Read the session under the lock, so the timestamp written is that of + // the session the last AddOrUpdateSessionAsync stored. if (!_sessions.TryGetValue(key, out var session)) return; var entity = new TableEntity(key, _applicationDiscriminator) { [LastTouchedColumn] = session.LastTouched }; @@ -205,7 +216,7 @@ private TableEntity CreateEntity(string key, Session session) var data = Serialize(session); if (data.Length > MaxChunks * ChunkSize) throw new InvalidOperationException( - $"Session {key} serializes to {data.Length} bytes, more than the {MaxChunks * ChunkSize} bytes the Azure Table Storage session store can hold."); + string.Format(Resources.SessionStoreSessionTooLarge, key, data.Length, MaxChunks * ChunkSize)); var entity = new TableEntity(key, _applicationDiscriminator) { [LastTouchedColumn] = session.LastTouched }; var chunkCount = Math.Max(1, (data.Length + ChunkSize - 1) / ChunkSize); @@ -243,14 +254,17 @@ private Session Deserialize(byte[] data) } [LoggerMessage(Level = LogLevel.Debug, Message = "Loading session {Key} from table {Table}")] - private static partial void LogLoadingSession(ILogger logger, string key, string table); + private partial void LogLoadingSession(string key, string table); [LoggerMessage(Level = LogLevel.Debug, Message = "Saving session {Key} to table {Table}")] - private static partial void LogSavingSession(ILogger logger, string key, string table); + private partial void LogSavingSession(string key, string table); [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to refresh the last-touched time of session {Key} in table {Table}")] - private static partial void LogRefreshFailed(ILogger logger, Exception exception, string key, string table); + private partial void LogRefreshFailed(Exception exception, string key, string table); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Kept expired session {Key} in table {Table}, because it was saved after the purge query")] + private partial void LogPurgeSkipped(string key, string table); [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to purge expired session {Key} from table {Table}")] - private static partial void LogPurgeFailed(ILogger logger, Exception exception, string key, string table); + private partial void LogPurgeFailed(Exception exception, string key, string table); } diff --git a/Source/Csla/Properties/Resources.Designer.cs b/Source/Csla/Properties/Resources.Designer.cs index f216c58268..153463ddb5 100644 --- a/Source/Csla/Properties/Resources.Designer.cs +++ b/Source/Csla/Properties/Resources.Designer.cs @@ -1086,6 +1086,15 @@ public static string SessionManagerSessionStateCouldNotBeRetrieved { } } + /// + /// Looks up a localized string similar to Session {0} serializes to {1} bytes, more than the {2} bytes the session store can hold.. + /// + public static string SessionStoreSessionTooLarge { + get { + return ResourceManager.GetString("SessionStoreSessionTooLarge", resourceCulture); + } + } + /// /// Looks up a localized string similar to t. /// diff --git a/Source/Csla/Properties/Resources.resx b/Source/Csla/Properties/Resources.resx index a4110d513b..fac07193a5 100644 --- a/Source/Csla/Properties/Resources.resx +++ b/Source/Csla/Properties/Resources.resx @@ -569,4 +569,8 @@ {0}.Result is not available; it may have been removed by trimming. + + Session {0} serializes to {1} bytes, more than the {2} bytes the session store can hold. + 0 - session key, 1 - serialized size, 2 - maximum size + \ No newline at end of file diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs index 3fdc26bced..34699b070d 100644 --- a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs @@ -114,7 +114,7 @@ public void UseAzureTableStorageSessionStore_NullConfig_Throws() public void GetTableServiceClient_ConnectionString_IgnoresRegisteredClient() { var registered = new TableServiceClient(new Uri("https://registered.table.core.windows.net"), new TableSharedKeyCredential("registered", Convert.ToBase64String(new byte[32]))); - var provider = new ServiceCollection() + using var provider = new ServiceCollection() .AddSingleton(registered) .BuildServiceProvider(); var options = new AzureTableStorageOptions { ConnectionString = DevelopmentConnectionString }; @@ -129,7 +129,7 @@ public void GetTableServiceClient_ConnectionString_IgnoresRegisteredClient() public void GetTableServiceClient_ServiceKey_ResolvesKeyedClient() { var keyed = new TableServiceClient(DevelopmentConnectionString); - var provider = new ServiceCollection() + using var provider = new ServiceCollection() .AddKeyedSingleton("state", keyed) .AddSingleton(new TableServiceClient(DevelopmentConnectionString)) .BuildServiceProvider(); @@ -144,7 +144,7 @@ public void GetTableServiceClient_ServiceKey_ResolvesKeyedClient() public void GetTableServiceClient_NoConnectionStringOrKey_ResolvesDefaultClient() { var registered = new TableServiceClient(DevelopmentConnectionString); - var provider = new ServiceCollection() + using var provider = new ServiceCollection() .AddSingleton(registered) .BuildServiceProvider();