diff --git a/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Storage/FhirMemoryCacheTests.cs b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Storage/FhirMemoryCacheTests.cs new file mode 100644 index 000000000..88cbb8fa1 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Storage/FhirMemoryCacheTests.cs @@ -0,0 +1,194 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.Health.Fhir.Core.Features.Storage; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.Fhir.Core.UnitTests.Features.Storage +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Security)] + public sealed class FhirMemoryCacheTests + { + private readonly ILogger _logger = Substitute.For(); + + private const string DefaultKey = "key"; + private const string DefaultValue = "value"; + + [Theory] + [InlineData(01, 01 * 1024 * 1024)] + [InlineData(14, 14 * 1024 * 1024)] + [InlineData(55, 55 * 1024 * 1024)] + public void GivenAnEmptyCache_CheckTheCacheMemoryLimit(int limitSizeInMegabytes, long expectedLimitSizeInBytes) + { + var cache = new FhirMemoryCache(Guid.NewGuid().ToString(), limitSizeInMegabytes, TimeSpan.FromMinutes(1), _logger); + + Assert.Equal(expectedLimitSizeInBytes, cache.CacheMemoryLimit); + } + + [Fact] + public void GivenACache_RaiseErrorsIfParametersAreInvalid() + { + Assert.Throws( + () => new FhirMemoryCache( + null, + limitSizeInMegabytes: 0, + TimeSpan.FromMinutes(1), + _logger)); + + Assert.Throws( + () => new FhirMemoryCache( + Guid.NewGuid().ToString(), + limitSizeInMegabytes: 0, + TimeSpan.FromMinutes(1), + _logger)); + + Assert.Throws( + () => new FhirMemoryCache( + Guid.NewGuid().ToString(), + limitSizeInMegabytes: 1, + TimeSpan.FromMinutes(1), + null)); + + var cache = CreateRegularMemoryCache(); + + Assert.Throws(() => cache.GetOrAdd(null, DefaultValue)); + Assert.Throws(() => cache.TryAdd(null, DefaultValue)); + Assert.Throws(() => cache.GetOrAdd(string.Empty, DefaultValue)); + Assert.Throws(() => cache.TryAdd(string.Empty, DefaultValue)); + Assert.Throws(() => cache.GetOrAdd(DefaultKey, null)); + Assert.Throws(() => cache.TryAdd(DefaultKey, null)); + } + + [Fact] + public void GivenAnEmptyCache_WhenAddingValue_ThenValueShouldBeAdded() + { + var cache = CreateRegularMemoryCache(); + + var result1 = cache.GetOrAdd(DefaultKey, DefaultValue); + Assert.Equal(DefaultValue, result1); + + const string anotherValue = "Another Value"; + var result2 = cache.GetOrAdd(DefaultKey, anotherValue); + Assert.NotEqual(anotherValue, result2); + Assert.Equal(DefaultValue, result1); + } + + [Fact] + public void GivenAnEmptyCache_WhenAddingValue_ThenValueShouldBeAddedAndCanBeRetrieved() + { + var cache = CreateRegularMemoryCache(); + + cache.GetOrAdd(DefaultKey, DefaultValue); + + Assert.True(cache.TryGet(DefaultKey, out var result)); + Assert.Equal(DefaultValue, result); + } + + [Fact] + public void GivenAnEmptyCache_WhenAddingValueIfIgnoreCaseEnabled_ThenMultipleSimilarKeysShouldWorkAsExpected() + { + var cache = new FhirMemoryCache(Guid.NewGuid().ToString(), limitSizeInMegabytes: 1, TimeSpan.FromMinutes(1), _logger, ignoreCase: true); + + cache.GetOrAdd(DefaultKey, DefaultValue); + + Assert.True(cache.TryGet(DefaultKey, out var result)); + Assert.Equal(DefaultValue, result); + + Assert.True(cache.TryGet(DefaultKey.ToUpper(), out result)); + Assert.Equal(DefaultValue, result); + + Assert.True(cache.TryGet("Key", out result)); + Assert.Equal(DefaultValue, result); + + Assert.True(cache.TryGet("kEy", out result)); + Assert.Equal(DefaultValue, result); + } + + [Fact] + public void GivenAnEmptyCache_WhenGettingAnItemThatDoNotExist_ThenReturnFalse() + { + var cache = CreateRegularMemoryCache(); + + Assert.False(cache.TryGet(DefaultKey, out var result)); + Assert.Equal(default, result); + } + + [Fact] + public void GivenAnEmptyCache_WhenAddingValueIfIgnoreCaseDisabled_ThenMultipleSimilarKeysShouldWorkAsExpected() + { + var cache = CreateRegularMemoryCache(); + + cache.GetOrAdd(DefaultKey, DefaultValue); + + Assert.True(cache.TryGet("key", out var result)); + Assert.Equal(DefaultValue, result); + + Assert.False(cache.TryGet("KEY", out result)); + + Assert.False(cache.TryGet("Key", out result)); + + Assert.False(cache.TryGet("kEy", out result)); + } + + [Fact] + public void GivenAnEmptyCache_WhenAddingValue_ThenValueShouldBeAddedAndCanBeRetrievedUsingTryGetValue() + { + var cache = CreateRegularMemoryCache(); + + cache.GetOrAdd(DefaultKey, DefaultValue); + + Assert.True(cache.TryGet(DefaultKey, out var result)); + Assert.Equal(DefaultValue, result); + } + + [Fact] + public void GivenAnEmptyCache_WhenAddingValue_ThenValueShouldBeAddedAndCanBeRetrievedUsingTryGetValueWithOut() + { + var cache = CreateRegularMemoryCache(); + + cache.GetOrAdd(DefaultKey, DefaultValue); + + Assert.True(cache.TryGet(DefaultKey, out var result)); + Assert.Equal(DefaultValue, result); + } + + [Fact] + public void GivenAnEmptyCache_WhenAddingValue_ThenValueShouldBeAddedAndCanBeRetrievedUsingTryGetValueWithOutAndValue() + { + var cache = CreateRegularMemoryCache(); + + cache.GetOrAdd(DefaultKey, DefaultValue); + + Assert.True(cache.TryGet(DefaultKey, out var result)); + Assert.Equal(DefaultValue, result); + } + + [Fact] + public void GivenAnEmptyCache_WhenRunningOperations_ThenItemsShouldBeRespected() + { + var cache = CreateRegularMemoryCache(); + + cache.GetOrAdd(DefaultKey, 2112); + Assert.True(cache.TryGet(DefaultKey, out var result)); + Assert.Equal(2112, result); + + Assert.True(cache.Remove(DefaultKey)); + + Assert.False(cache.TryGet(DefaultKey, out result)); + } + + private IMemoryCache CreateRegularMemoryCache() + { + return new FhirMemoryCache(Guid.NewGuid().ToString(), limitSizeInMegabytes: 1, TimeSpan.FromMinutes(1), _logger); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Storage/FhirMemoryCache.cs b/src/Microsoft.Health.Fhir.Core/Features/Storage/FhirMemoryCache.cs new file mode 100644 index 000000000..68bc4ffc4 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Storage/FhirMemoryCache.cs @@ -0,0 +1,166 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Runtime.Caching; +using EnsureThat; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Health.Fhir.Core.Features.Storage +{ + public sealed class FhirMemoryCache : IMemoryCache + { + private const int DefaultLimitSizeInMegabytes = 50; + private const int DefaultExpirationTimeInMinutes = 24 * 60; + + private readonly string _cacheName; + private readonly ILogger _logger; + private readonly ObjectCache _cache; + private readonly TimeSpan _expirationTime; + private readonly bool _ignoreCase; + + public FhirMemoryCache(string name, ILogger logger, bool ignoreCase = false) + : this( + name, + limitSizeInMegabytes: DefaultLimitSizeInMegabytes, + expirationTime: TimeSpan.FromMinutes(DefaultExpirationTimeInMinutes), + logger) + { + } + + public FhirMemoryCache(string name, int limitSizeInMegabytes, TimeSpan expirationTime, ILogger logger, bool ignoreCase = false) + { + EnsureArg.IsNotNull(name, nameof(name)); + EnsureArg.IsGt(limitSizeInMegabytes, 0, nameof(name)); + EnsureArg.IsNotNull(logger, nameof(logger)); + + _cacheName = name; + _cache = new MemoryCache( + _cacheName, + new NameValueCollection() + { + { "CacheMemoryLimitMegabytes", limitSizeInMegabytes.ToString() }, + }); + _expirationTime = expirationTime; + _logger = logger; + _ignoreCase = ignoreCase; + } + + public long CacheMemoryLimit => ((MemoryCache)_cache).CacheMemoryLimit; + + /// + /// Get or add the value to cache. + /// + /// Type of the value in cache + /// Key + /// Value + /// Value in cache + public T GetOrAdd(string key, T value) + { + EnsureArg.IsNotNullOrWhiteSpace(key, nameof(key)); + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + key = FormatKey(key); + + CacheItem newCacheItem = new CacheItem(key, value); + + CacheItem cachedItem = _cache.AddOrGetExisting( + newCacheItem, + GetDefaultCacheItemPolicy()); + + if (cachedItem.Value == null) + { + // If the item cache item is null, then the item was added to the cache. + return (T)newCacheItem.Value; + } + else + { + return (T)cachedItem.Value; + } + } + + /// + /// Add the value to cache if it does not exist. + /// + /// Key + /// Value + /// Returns true if the item was added to the cache, returns false if there is an item with the same key in cache. + public bool TryAdd(string key, T value) + { + EnsureArg.IsNotNullOrWhiteSpace(key, nameof(key)); + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + key = FormatKey(key); + + return _cache.Add(key, value, GetDefaultCacheItemPolicy()); + } + + /// + /// Get an item from the cache. + /// + /// Key + /// Value + public T Get(string key) + { + key = FormatKey(key); + + return (T)_cache[key]; + } + + /// + /// Try to retrieve an item from cache, if it does not exist then returns the for that generic type. + /// + /// Key + /// Value + /// True if the value exists in cache + public bool TryGet(string key, out T value) + { + key = FormatKey(key); + + CacheItem cachedItem = _cache.GetCacheItem(key); + + if (cachedItem != null) + { + value = (T)cachedItem.Value; + return true; + } + + _logger.LogTrace("Item does not exist in '{CacheName}' cache. Returning default value.", _cacheName); + value = default; + + return false; + } + + /// + /// Removed the item indexed by the key. + /// + /// Key of the item to be removed from cache. + /// Returns false if the items does not exist in cache. + public bool Remove(string key) + { + key = FormatKey(key); + + object objectInCache = _cache.Remove(key); + + return objectInCache != null; + } + + private string FormatKey(string key) => _ignoreCase ? key.ToLowerInvariant() : key; + + private CacheItemPolicy GetDefaultCacheItemPolicy() => new CacheItemPolicy() + { + Priority = CacheItemPriority.Default, + SlidingExpiration = _expirationTime, + }; + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Storage/IMemoryCache.cs b/src/Microsoft.Health.Fhir.Core/Features/Storage/IMemoryCache.cs new file mode 100644 index 000000000..58ed05650 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Storage/IMemoryCache.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; + +namespace Microsoft.Health.Fhir.Core.Features.Storage +{ + public interface IMemoryCache + { + long CacheMemoryLimit { get; } + + T GetOrAdd(string key, T value); + + bool TryAdd(string key, T value); + + T Get(string key); + + bool TryGet(string key, out T value); + + bool Remove(string key); + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirModel.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirModel.cs index 4b4f4ab60..502c83d18 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirModel.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirModel.cs @@ -21,6 +21,7 @@ using Microsoft.Health.Fhir.Core.Configs; using Microsoft.Health.Fhir.Core.Features.Definition; using Microsoft.Health.Fhir.Core.Features.Operations; using Microsoft.Health.Fhir.Core.Features.Search.Registry; +using Microsoft.Health.Fhir.Core.Features.Storage; using Microsoft.Health.Fhir.Core.Messages.Storage; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.SqlServer.Features.Schema; @@ -30,6 +31,7 @@ using Microsoft.Health.SqlServer.Features.Client; using Microsoft.Health.SqlServer.Features.Schema; using Microsoft.Health.SqlServer.Features.Schema.Model; using Microsoft.Health.SqlServer.Features.Storage; +using Namotion.Reflection; using Newtonsoft.Json; namespace Microsoft.Health.Fhir.SqlServer.Features.Storage @@ -52,8 +54,8 @@ namespace Microsoft.Health.Fhir.SqlServer.Features.Storage private Dictionary _resourceTypeToId; private Dictionary _resourceTypeIdToTypeName; private Dictionary _searchParamUriToId; - private ConcurrentDictionary _systemToId; - private ConcurrentDictionary _quantityCodeToId; + private FhirMemoryCache _systemToId; + private FhirMemoryCache _quantityCodeToId; private Dictionary _claimNameToId; private Dictionary _compartmentTypeToId; private int _highestInitializedVersion; @@ -153,7 +155,7 @@ namespace Microsoft.Health.Fhir.SqlServer.Features.Storage public bool TryGetSystemId(string system, out int systemId) { ThrowIfNotInitialized(); - return _systemToId.TryGetValue(system, out systemId); + return _systemToId.TryGet(system, out systemId); } public int GetSystemId(string system) @@ -175,7 +177,7 @@ namespace Microsoft.Health.Fhir.SqlServer.Features.Storage public bool TryGetQuantityCodeId(string code, out int quantityCodeId) { ThrowIfNotInitialized(); - return _quantityCodeToId.TryGetValue(code, out quantityCodeId); + return _quantityCodeToId.TryGet(code, out quantityCodeId); } public async Task EnsureInitialized() @@ -279,8 +281,6 @@ namespace Microsoft.Health.Fhir.SqlServer.Features.Storage var resourceTypeToId = new Dictionary(StringComparer.Ordinal); var resourceTypeIdToTypeName = new Dictionary(); var searchParamUriToId = new Dictionary(); - var systemToId = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - var quantityCodeToId = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var claimNameToId = new Dictionary(StringComparer.Ordinal); var compartmentTypeToId = new Dictionary(); @@ -335,26 +335,26 @@ namespace Microsoft.Health.Fhir.SqlServer.Features.Storage // result set 5 await reader.NextResultAsync(cancellationToken); + _systemToId = new FhirMemoryCache("systemToId", _logger, ignoreCase: true); while (await reader.ReadAsync(cancellationToken)) { var (value, systemId) = reader.ReadRow(VLatest.System.Value, VLatest.System.SystemId); - systemToId.TryAdd(value, systemId); + _systemToId.TryAdd(value, systemId); } // result set 6 await reader.NextResultAsync(cancellationToken); + _quantityCodeToId = new FhirMemoryCache("quantityCodeToId", _logger, ignoreCase: true); while (await reader.ReadAsync(cancellationToken)) { (string value, int quantityCodeId) = reader.ReadRow(VLatest.QuantityCode.Value, VLatest.QuantityCode.QuantityCodeId); - quantityCodeToId.TryAdd(value, quantityCodeId); + _quantityCodeToId.TryAdd(value, quantityCodeId); } _resourceTypeToId = resourceTypeToId; _resourceTypeIdToTypeName = resourceTypeIdToTypeName; _searchParamUriToId = searchParamUriToId; - _systemToId = systemToId; - _quantityCodeToId = quantityCodeToId; _claimNameToId = claimNameToId; _compartmentTypeToId = compartmentTypeToId; _resourceTypeIdRange = (lowestResourceTypeId, highestResourceTypeId); @@ -422,9 +422,9 @@ namespace Microsoft.Health.Fhir.SqlServer.Features.Storage } } - private int GetStringId(ConcurrentDictionary cache, string stringValue, Table table, Column idColumn, Column stringColumn) + private int GetStringId(FhirMemoryCache cache, string stringValue, Table table, Column idColumn, Column stringColumn) { - if (cache.TryGetValue(stringValue, out int id)) + if (cache.TryGet(stringValue, out int id)) { return id; }