Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// -------------------------------------------------------------------------------------------------
// 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));
}

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

var result = cache.GetOrAdd(DefaultKey, DefaultValue);

Assert.Equal(DefaultValue, result);
}

[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_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("key", 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("key", 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));
}

[Fact]
public void GivenAnEmptyCache_WhenAddingARange_AllValuesShouldBeIngested()
{
int anchor = 'a';
var originalValues = new Dictionary<string, int>();
for (int i = 0; i < 20; i++)
{
originalValues.Add(((char)(anchor + i)).ToString(), i);
}

var cache = CreateRegularMemoryCache<int>();
cache.AddRange(originalValues);

foreach (var item in originalValues)
{
Assert.Equal(item.Value, cache.Get(item.Key));
}
}

private IMemoryCache<T> CreateRegularMemoryCache<T>()
{
return new FhirMemoryCache<T>(Guid.NewGuid().ToString(), limitSizeInMegabytes: 1, TimeSpan.FromMinutes(1), _logger);
}
}
}
186 changes: 186 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,186 @@
// -------------------------------------------------------------------------------------------------
// 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;
private readonly object _lock;

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;

_lock = new object();
}

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)
{
key = FormatKey(key);

lock (_lock)
{
if (_cache.Contains(key))
{
return (T)_cache[key];
}

AddInternal(key, value);
}

return 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>
public bool TryAdd(string key, T value)
{
key = FormatKey(key);

lock (_lock)
{
if (_cache.Contains(key))
{
return false;
}

AddInternal(key, value);
}

return true;
}

/// <summary>
/// Add a range of values to the cache.
/// </summary>
/// <param name="keyValuePairs">Range of values</param>
public void AddRange(IReadOnlyDictionary<string, T> keyValuePairs)
{
foreach (KeyValuePair<string, T> item in keyValuePairs)
{
string key = FormatKey(item.Key);
AddInternal(key, item.Value);
}
}

/// <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);

lock (_lock)
{
if (_cache.Contains(key))
{
value = (T)_cache[key];
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 bool AddInternal(string key, T value)
{
CacheItemPolicy cachePolicy = new CacheItemPolicy()
{
Priority = CacheItemPriority.Default,
SlidingExpiration = _expirationTime,
};

return _cache.Add(key, value, cachePolicy);
}
}
}
Loading