Skip to content

Commit

Permalink
FHIR Model and temporarily cached data (#3705)
Browse files Browse the repository at this point in the history
* Change SqlServerFhirModel to use FhirMemoryCache
* Improvements in FhirMemoryCache.
* Added support to multi case cache.
* Test improvements.
* New tests. Removing lock to be aligned with the MemoryCache.
  • Loading branch information
fhibf authored Feb 10, 2024
1 parent d23bf8f commit 869dbab
Show file tree
Hide file tree
Showing 4 changed files with 396 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -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<ILogger>();

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<string>(Guid.NewGuid().ToString(), limitSizeInMegabytes, TimeSpan.FromMinutes(1), _logger);

Assert.Equal(expectedLimitSizeInBytes, cache.CacheMemoryLimit);
}

[Fact]
public void GivenACache_RaiseErrorsIfParametersAreInvalid()
{
Assert.Throws<ArgumentNullException>(
() => new FhirMemoryCache<string>(
null,
limitSizeInMegabytes: 0,
TimeSpan.FromMinutes(1),
_logger));

Assert.Throws<ArgumentOutOfRangeException>(
() => new FhirMemoryCache<string>(
Guid.NewGuid().ToString(),
limitSizeInMegabytes: 0,
TimeSpan.FromMinutes(1),
_logger));

Assert.Throws<ArgumentNullException>(
() => new FhirMemoryCache<string>(
Guid.NewGuid().ToString(),
limitSizeInMegabytes: 1,
TimeSpan.FromMinutes(1),
null));

var cache = CreateRegularMemoryCache<string>();

Assert.Throws<ArgumentNullException>(() => cache.GetOrAdd(null, DefaultValue));
Assert.Throws<ArgumentNullException>(() => cache.TryAdd(null, DefaultValue));
Assert.Throws<ArgumentException>(() => cache.GetOrAdd(string.Empty, DefaultValue));
Assert.Throws<ArgumentException>(() => cache.TryAdd(string.Empty, DefaultValue));
Assert.Throws<ArgumentNullException>(() => cache.GetOrAdd(DefaultKey, null));
Assert.Throws<ArgumentNullException>(() => cache.TryAdd(DefaultKey, null));
}

[Fact]
public void GivenAnEmptyCache_WhenAddingValue_ThenValueShouldBeAdded()
{
var cache = CreateRegularMemoryCache<string>();

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<string>();

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<string>(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<string>();

Assert.False(cache.TryGet(DefaultKey, out var result));
Assert.Equal(default, result);
}

[Fact]
public void GivenAnEmptyCache_WhenAddingValueIfIgnoreCaseDisabled_ThenMultipleSimilarKeysShouldWorkAsExpected()
{
var cache = CreateRegularMemoryCache<string>();

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<string>();

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<string>();

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<string>();

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<long>();

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<T> CreateRegularMemoryCache<T>()
{
return new FhirMemoryCache<T>(Guid.NewGuid().ToString(), limitSizeInMegabytes: 1, TimeSpan.FromMinutes(1), _logger);
}
}
}
166 changes: 166 additions & 0 deletions src/Microsoft.Health.Fhir.Core/Features/Storage/FhirMemoryCache.cs
Original file line number Diff line number Diff line change
@@ -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<T> : IMemoryCache<T>
{
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;

/// <summary>
/// Get or add the value to cache.
/// </summary>
/// <typeparam name="T">Type of the value in cache</typeparam>
/// <param name="key">Key</param>
/// <param name="value">Value</param>
/// <returns>Value in cache</returns>
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;
}
}

/// <summary>
/// Add the value to cache if it does not exist.
/// </summary>
/// <param name="key">Key</param>
/// <param name="value">Value</param>
/// <returns>Returns true if the item was added to the cache, returns false if there is an item with the same key in cache.</returns>
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());
}

/// <summary>
/// Get an item from the cache.
/// </summary>
/// <param name="key">Key</param>
/// <returns>Value</returns>
public T Get(string key)
{
key = FormatKey(key);

return (T)_cache[key];
}

/// <summary>
/// Try to retrieve an item from cache, if it does not exist then returns the <see cref="default"/> for that generic type.
/// </summary>
/// <param name="key">Key</param>
/// <param name="value">Value</param>
/// <returns>True if the value exists in cache</returns>
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;
}

/// <summary>
/// Removed the item indexed by the key.
/// </summary>
/// <param name="key">Key of the item to be removed from cache.</param>
/// <returns>Returns false if the items does not exist in cache.</returns>
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,
};
}
}
24 changes: 24 additions & 0 deletions src/Microsoft.Health.Fhir.Core/Features/Storage/IMemoryCache.cs
Original file line number Diff line number Diff line change
@@ -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<T>
{
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);
}
}
Loading

0 comments on commit 869dbab

Please sign in to comment.