From 5dcb4810b85c155ddec3e664767839b67c0ad382 Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Thu, 8 Dec 2022 16:33:51 -0800 Subject: [PATCH] Add GetRefDataText by identifier. (#49) Improve ReferenceDataOrchestrator. --- CHANGELOG.md | 4 + Common.targets | 2 +- .../CoreEx.Abstractions.csproj | 29 ------ README.md | 1 + .../RefData/Extended/ReferenceDataBaseEx.cs | 8 ++ .../RefData/ReferenceDataOrchestrator.cs | 89 +++++++++---------- .../Framework/RefData/ReferenceDataTest.cs | 55 ++++++++++++ 7 files changed, 112 insertions(+), 76 deletions(-) delete mode 100644 CoreEx.Abstractions/CoreEx.Abstractions.csproj diff --git a/CHANGELOG.md b/CHANGELOG.md index c851ac5a..d3bfd608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Represents the **NuGet** versions. +## v2.1.0 +- *Enhancement:* Added additional `ReferenceDataBaseEx.GetRefDataText` method overload with a parameter of `id`; as an alternative to the pre-existing `code`. +- *Enhancement:* `ReferenceDataOrchestrator` caching supports virtual `OnGetCacheKey` to enable overridding classes to further define the key characteristics. + ## v2.0.0 - *Enhancement:* Added support for [`MySQL`](https://dev.mysql.com/) through introduction of `MySqlDatabase` that supports similar pattern to `SqlServerDatabase`. - *Enhancement:* Added new `EncodedStringToDateTimeConverter` to simulate row versioning from a `DateTime` (timestamp) as an alternative. diff --git a/Common.targets b/Common.targets index 44a2f68c..3b2e1ba0 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 2.0.0 + 2.1.0 preview Avanade Avanade diff --git a/CoreEx.Abstractions/CoreEx.Abstractions.csproj b/CoreEx.Abstractions/CoreEx.Abstractions.csproj deleted file mode 100644 index d2f826f6..00000000 --- a/CoreEx.Abstractions/CoreEx.Abstractions.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - netstandard2.1 - CoreEx - CoreEx.Abstractions - CoreEx .NET Core Extensions (Abstractions). - CoreEx .NET Core Extensions (Abstractions). - coreex api function referencedata jsonserializer events httpclient settings - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md index 97848319..67234416 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Package | Status | Source & documentation `CoreEx` | [![NuGet version](https://badge.fury.io/nu/CoreEx.svg)](https://badge.fury.io/nu/CoreEx) | [Link](./src/CoreEx) `CoreEx.AutoMapper` | [![NuGet version](https://badge.fury.io/nu/CoreEx.AutoMapper.svg)](https://badge.fury.io/nu/CoreEx.AutoMapper) | [Link](./src/CoreEx.AutoMapper) `CoreEx.Azure` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Azure.svg)](https://badge.fury.io/nu/CoreEx.Azure) | [Link](./src/CoreEx.Azure) +`CoreEx.Cosmos` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Cosmos.svg)](https://badge.fury.io/nu/CoreEx.Cosmos) | [Link](./src/CoreEx.Cosmos) `CoreEx.Database` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Database.svg)](https://badge.fury.io/nu/CoreEx.Database) | [Link](./src/CoreEx.Database) `CoreEx.Database.SqlServer` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Database.SqlServer.svg)](https://badge.fury.io/nu/CoreEx.Database.SqlServer) | [Link](./src/CoreEx.Database.SqlServer) `CoreEx.Database.MySql` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Database.MySql.svg)](https://badge.fury.io/nu/CoreEx.Database.MySql) | [Link](./src/CoreEx.Database.MySql) diff --git a/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs b/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs index 31c2d39a..ff11b6a1 100644 --- a/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs +++ b/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs @@ -239,6 +239,14 @@ protected override IEnumerable GetPropertyValues() /// This is intended to be consumed by classes that wish to provide an opt-in serialization of corresponding . public static string? GetRefDataText(string? code) => code != null && ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled ? ConvertFromCode(code)?.Text : null; + /// + /// Gets the corresponding for the specified where is true. + /// + /// The . + /// The . + /// This is intended to be consumed by classes that wish to provide an opt-in serialization of corresponding . + public static string? GetRefDataText(object? id) => id != null && ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled ? ConvertFromId((TId)id)?.Text : null; + #region MappingsDictionary /// diff --git a/src/CoreEx/RefData/ReferenceDataOrchestrator.cs b/src/CoreEx/RefData/ReferenceDataOrchestrator.cs index 7fbdebcd..5d35feea 100644 --- a/src/CoreEx/RefData/ReferenceDataOrchestrator.cs +++ b/src/CoreEx/RefData/ReferenceDataOrchestrator.cs @@ -23,9 +23,9 @@ namespace CoreEx.RefData /// Provides the centralized reference data orchestration. Primarily responsible for the management of one or more instances. /// /// Provides cached access to the underlying reference data collections via the likes of , or . - /// To improve performance the reference data should be cached where possible. The supports a default implementation; however, this can be overridden and/or extended by - /// overriding the method. The create is executed in the context of a - /// to limit/minimize any impact on the processing of the current request by isolating all scoped services. + /// By default, will include as part of the cache keu + /// To improve performance the reference data is cached. The enables this via an implementation; default is where not explicitly specified. + /// The underlying reference data loading is executed in the context of a to limit/minimize any impact on the processing of the current request by isolating all scoped services. public class ReferenceDataOrchestrator { /// @@ -36,9 +36,8 @@ public class ReferenceDataOrchestrator private readonly object _lock = new(); private readonly ConcurrentDictionary _typeToProvider = new(); private readonly ConcurrentDictionary _nameToType = new(StringComparer.OrdinalIgnoreCase); - private readonly IMemoryCache? _cache; private readonly SemaphoreSlim _primarySemaphore = new(1, 1); - private readonly ConcurrentDictionary _semaphores = new(); + private readonly ConcurrentDictionary _semaphores = new(); private readonly TimeSpan _absoluteExpirationRelativeToNow = TimeSpan.FromHours(2); private readonly TimeSpan _slidingExpiration = TimeSpan.FromMinutes(30); private readonly Lazy _logger; @@ -53,11 +52,11 @@ public class ReferenceDataOrchestrator /// Initializes a new instance of the class. /// /// The needed to instantiated the registered providers. - /// The optional to be used where not specifically overridden by . - protected ReferenceDataOrchestrator(IServiceProvider serivceProvider, IMemoryCache? cache) + /// The . + protected ReferenceDataOrchestrator(IServiceProvider serivceProvider, IMemoryCache cache) { ServiceProvider = serivceProvider ?? throw new ArgumentNullException(nameof(serivceProvider)); - _cache = cache; + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); _logger = new Lazy(ServiceProvider.GetRequiredService>); } @@ -81,6 +80,11 @@ protected ReferenceDataOrchestrator(IServiceProvider serivceProvider, IMemoryCac /// public IServiceProvider ServiceProvider { get; } + /// + /// Gets the underlying . + /// + protected IMemoryCache Cache { get; } + /// /// Gets the . /// @@ -177,7 +181,7 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere /// /// The . /// The corresponding where found; otherwise, null. - public IReferenceDataCollection? GetByType(Type type) => _cache.TryGetValue(type, out IReferenceDataCollection coll) ? coll : Invokers.Invoker.RunSync(() => GetByTypeAsync(type)); + public IReferenceDataCollection? GetByType(Type type) => Cache.TryGetValue(OnGetCacheKey(type), out IReferenceDataCollection coll) ? coll : Invokers.Invoker.RunSync(() => GetByTypeAsync(type)); /// /// Gets the for the specified (will throw where not found). @@ -191,7 +195,7 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere /// /// The . /// The corresponding where found; otherwise, will throw an . - public IReferenceDataCollection GetByTypeRequired(Type type) => _cache.TryGetValue(type, out IReferenceDataCollection coll) ? coll : Invokers.Invoker.RunSync(() => GetByTypeRequiredAsync(type)); + public IReferenceDataCollection GetByTypeRequired(Type type) => Cache.TryGetValue(OnGetCacheKey(type), out IReferenceDataCollection coll) ? coll : Invokers.Invoker.RunSync(() => GetByTypeRequiredAsync(type)); /// /// Gets the for the specified . @@ -256,48 +260,41 @@ public async Task GetByTypeRequiredAsync(Type type, Ca /// sufficient. The contains the logic to invoke the underlying ; this is executed in the context of a /// to limit/minimize any impact on the processing of the current request by isolating all scoped services. Additionally, semaphore locks are used to /// manage concurrency to ensure cache loading is thread-safe. - protected async virtual Task OnGetOrCreateAsync(Type type, Func> getCollAsync, CancellationToken cancellationToken = default) + private async Task OnGetOrCreateAsync(Type type, Func> getCollAsync, CancellationToken cancellationToken = default) { - if (_cache == null) - return await getCollAsync(type, cancellationToken).ConfigureAwait(false); - else - { - // Try and get as most likely already in the cache; where exists then exit fast. - if (_cache.TryGetValue(type, out IReferenceDataCollection coll)) - return coll; - - // As the GetOrAdd is not thread-safe a primary semaphore is used to get the key-based semaphore. - SemaphoreSlim semaphore; - await _primarySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - semaphore = _semaphores.GetOrAdd(type, _ => new SemaphoreSlim(1, 1)); - } - finally - { - _primarySemaphore.Release(); - } + // Try and get as most likely already in the cache; where exists then exit fast. + var key = OnGetCacheKey(type); + if (Cache.TryGetValue(key, out IReferenceDataCollection coll)) + return coll; - // Where not found use a semaphore for the key (type) to ensure only a single task is retrieving. - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // Does a get or create as it may have been added as we went to lock. - coll = await _cache.GetOrCreateAsync(type, async entry => - { - OnCreateCacheEntry(entry); - return await getCollAsync(type, cancellationToken).ConfigureAwait(false); - }).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } + // Get or add a new semaphore for the cache key so we can manage single concurrency for that key only. + SemaphoreSlim semaphore = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - return coll; + // Use the semaphore to manage a single thread to perform the "expensive" get operation. + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Does a get or create as it may have been added as we went to lock. + return await Cache.GetOrCreateAsync(key, async entry => + { + OnCreateCacheEntry(entry); + return await getCollAsync(type, cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + } + finally + { + semaphore.Release(); } } + /// + /// Gets the cache key to be used (defaults to ). + /// + /// The . + /// The cache key. + /// To support the likes of multi-tenancy caching then the resulting cache key should be overridden to include the both the and . + protected virtual object OnGetCacheKey(Type type) => type; + /// /// Provides an opportunity to the maintain the data prior to the cache create function being invoked (as a result of ). /// diff --git a/tests/CoreEx.Test/Framework/RefData/ReferenceDataTest.cs b/tests/CoreEx.Test/Framework/RefData/ReferenceDataTest.cs index ff6b76dc..08d5ccea 100644 --- a/tests/CoreEx.Test/Framework/RefData/ReferenceDataTest.cs +++ b/tests/CoreEx.Test/Framework/RefData/ReferenceDataTest.cs @@ -725,6 +725,40 @@ public async Task Caching_Prefetch() Assert.IsTrue(c!.ContainsId("BB")); } + [Test] + public async Task Caching_Concurrency() + { + IServiceCollection sc = new ServiceCollection(); + sc.AddLogging(); + sc.AddJsonSerializer(); + sc.AddExecutionContext(); + sc.AddScoped(); + sc.AddReferenceDataOrchestrator(); + var sp = sc.BuildServiceProvider(); + + using var scope = sp.CreateScope(); + var ec = scope.ServiceProvider.GetService(); + + var colls = new IReferenceDataCollection?[5]; + + var tasks = new Task[5]; + tasks[0] = Task.Run(() => colls[0] = ReferenceDataOrchestrator.Current.GetByType()); + tasks[1] = Task.Run(() => colls[1] = ReferenceDataOrchestrator.Current.GetByType()); + tasks[2] = Task.Run(() => colls[2] = ReferenceDataOrchestrator.Current.GetByType()); + tasks[3] = Task.Run(() => colls[3] = ReferenceDataOrchestrator.Current.GetByType()); + tasks[4] = Task.Run(() => colls[4] = ReferenceDataOrchestrator.Current.GetByType()); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + for (int i = 0; i < colls.Length; i++) + { + Assert.IsNotNull(colls[i]); + Assert.AreEqual(2, colls[i]!.Count); + } + + Assert.AreSame(colls[0], colls[4]); // First and last should be same object ref. + } + [Test] public void Serialization_STJ_NoOrchestrator() { @@ -943,6 +977,27 @@ public async Task GetAsync(Type type, CancellationToke } } + public class RefDataConcurrencyProvider : IReferenceDataProvider + { + private int _count; + + public Type[] Types => new Type[] { typeof(RefData) }; + + public async Task GetAsync(Type type, CancellationToken cancellationToken = default) + { + System.Diagnostics.Debug.WriteLine($"GetAsync=>Enter({_count})"); + Interlocked.Increment(ref _count); + if (_count > 1) + //throw new InvalidOperationException("ReferenceData has loaded already; this should not occur as the ReferenceDataOrchestrator should ensure multi-load under concurrency does not occur."); + Assert.Fail("ReferenceData has loaded already; this should not occur as the ReferenceDataOrchestrator should ensure multi-load under concurrency does not occur."); + + var coll = new RefDataCollection() { new RefData { Id = 1, Code = "A" }, new RefData { Id = 2, Code = "B" } }; + await Task.Delay(100).ConfigureAwait(false); + System.Diagnostics.Debug.WriteLine($"GetAsync=>Exit({_count})"); + return coll; + } + } + public class TestData : CoreEx.Entities.Extended.EntityBase { private int _id;