├── .editorconfig ├── .github ├── CODEOWNERS └── workflows │ ├── build-and-test.yml │ ├── create-release.yml │ └── publish-nuget.yml ├── .gitignore ├── CONTRIBUTING.md ├── IntelligentCache.Tests ├── CompositeCacheTests.cs ├── FakeRedis.cs ├── InspectableCache.cs ├── IntelligentCache.Tests.csproj ├── MemoryCacheTests.cs ├── PassThroughCacheTests.cs ├── ProtobufSerializerTest.cs ├── RedisCacheTests.cs ├── RedisInvalidationReceiverTests.cs └── RedisInvalidatorSenderTests.cs ├── IntelligentCache.sln ├── IntelligentCache ├── CompositeCache.cs ├── ICache.cs ├── IRedisSerializer.cs ├── IntelligentCache.csproj ├── JsonStringSerializer.cs ├── MemoryCache.cs ├── MultiKeyLock.cs ├── PassThroughCache.cs ├── ProtobufSerializer.cs ├── RedisCache.cs ├── RedisInvalidationReceiver.cs └── RedisInvalidationSender.cs ├── LICENSE.md ├── README.md └── doc ├── api-documentation.md ├── best-practices.md ├── icon.png └── logo.png /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @sklivvz @ocoster-is -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | workflow_dispatch: {} 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | branches: [ master ] 9 | 10 | jobs: 11 | 12 | build: 13 | name: Build 14 | runs-on: windows-latest 15 | 16 | env: 17 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 18 | steps: 19 | - name: Checkout the repository 20 | uses: actions/checkout@v2 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Run tests 25 | run: dotnet test --configuration Release /p:DisableGitVersionTask=true 26 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create a new release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ref: 7 | description: Commit to release 8 | required: false 9 | 10 | jobs: 11 | release: 12 | name: Draft Release 13 | runs-on: ubuntu-latest 14 | env: 15 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 16 | steps: 17 | - name: Checkout the repository 18 | uses: actions/checkout@v2 19 | with: 20 | fetch-depth: 0 21 | ref: ${{ github.event.inputs.ref }} 22 | 23 | - name: Install GitVersion.Tool 24 | run: dotnet tool install --global GitVersion.Tool 25 | 26 | - name: Get current version 27 | id: get-version 28 | run: echo "::set-output name=version::$(dotnet-gitversion /nofetch /showvariable NuGetVersionV2)" 29 | 30 | - name: Scaffold release notes 31 | id: scaffold-release-notes 32 | run: | 33 | $lastTag = git describe --tags --abbrev=0 34 | $range = if ($lastTag -eq $null) { "HEAD" } else { "$lastTag..HEAD" } 35 | 36 | $releaseNotes = "```````nTODO: Review these release notes`n```````n" 37 | $commit = $null 38 | git rev-list $range --first-parent --reverse --pretty=tformat:%B | 39 | % { 40 | if ($_ -match "^commit (?[a-f0-9]+)$") { 41 | if ($commit -ne $null) { 42 | $releaseNotes += "`n- " + ([String]::Join(" `n ", $commit)) 43 | } 44 | $commit = @() 45 | } else { 46 | if ($commit.Count -eq 0) { 47 | $commit += "**$_**" 48 | } else { 49 | $commit += $_ 50 | } 51 | } 52 | } 53 | 54 | echo "::set-output name=releaseNotes::$($releaseNotes.Replace("%", '%25').Replace("`r", '%0D').Replace("`n", '%0A'))" 55 | shell: pwsh 56 | 57 | - name: Create Release 58 | id: create_release 59 | uses: actions/create-release@v1 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | with: 63 | tag_name: v${{ steps.get-version.outputs.version }} 64 | release_name: Release ${{ steps.get-version.outputs.version }} 65 | body: ${{ steps.scaffold-release-notes.outputs.releaseNotes }} 66 | draft: true 67 | prerelease: false 68 | -------------------------------------------------------------------------------- /.github/workflows/publish-nuget.yml: -------------------------------------------------------------------------------- 1 | name: Release the package 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ref: 7 | description: Commit to release 8 | required: false 9 | release: 10 | types: [published] 11 | 12 | jobs: 13 | build: 14 | name: Publish package 15 | runs-on: windows-latest 16 | env: 17 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 18 | steps: 19 | - name: Checkout the repository 20 | uses: actions/checkout@v2 21 | with: 22 | fetch-depth: 0 23 | ref: ${{ github.event.inputs.ref }} 24 | 25 | - name: Ensure that there is a branch 26 | run: | 27 | git symbolic-ref -q HEAD 28 | if ($LASTEXITCODE -ne 0) { 29 | git checkout -b master 30 | } 31 | 32 | - name: Pack 33 | run: del Env:\GITHUB_ACTIONS ; dotnet pack ./IntelligentCache/IntelligentCache.csproj --configuration Release 34 | 35 | - name: Push the generated package to NuGet.org registry 36 | run: | 37 | $version = dir IntelligentCache/bin/Release/*.nupkg | % { $m = $_.Name -match "IntelligentHack\.IntelligentCache\.(.*)\.nupkg"; $Matches[1] } 38 | dotnet nuget push ./IntelligentCache/bin/Release/IntelligentHack.IntelligentCache.$version.nupkg --skip-duplicate --no-symbols true --source nuget.org --api-key ${{secrets.NUGET_API_KEY}} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | #Ignore thumbnails created by Windows 3 | Thumbs.db 4 | #Ignore files built by Visual Studio 5 | *.obj 6 | *.exe 7 | *.pdb 8 | *.user 9 | *.aps 10 | *.pch 11 | *.vspscc 12 | *_i.c 13 | *_p.c 14 | *.ncb 15 | *.suo 16 | *.tlb 17 | *.tlh 18 | *.bak 19 | *.cache 20 | *.ilk 21 | *.log 22 | [Bb]in 23 | [Dd]ebug*/ 24 | *.lib 25 | *.sbr 26 | obj/ 27 | [Rr]elease*/ 28 | _ReSharper*/ 29 | [Tt]est[Rr]esult* 30 | .vs/ 31 | #IntelliJ 32 | .idea 33 | #Nuget packages folder 34 | packages/ 35 | /doc/PNG/ 36 | /doc/SVG 37 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to IntelligentHack.IntelligentCache 2 | 3 | **Welcome!** 4 | Thanks for your interest in contributing to this project. Any contribution will 5 | be gladly accepted, provided that they are generally useful and follow the 6 | conventions of the project. 7 | 8 | If you are considering a contribution, please read and follow these guidelines. 9 | 10 | ## Pull requests 11 | 12 | All contributions should be submitted as pull requests. 13 | 14 | 1. Please create **one pull request for each feature**. This results in smaller pull requests that are easier to review and validate. 15 | 16 | 2. **Avoid reformatting existing code** unless you are making other changes to it. 17 | * Cleaning-up of `using`s is acceptable, if you made other changes to that file. 18 | * If you believe that some code is badly formatted and needs fixing, isolate that change in a separate pull request. 19 | 20 | 3. When feasible, add one or more **unit tests** that prove that the feature / fix you are submitting is working correctly. 21 | 22 | 4. Please **describe the motivation** behind the pull request. Explain what was the problem / requirement. Unless the implementation is self-explanatory, also describe the solution. 23 | * Of course, there's no need to be too verbose. Usually one or two lines will be enough. 24 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/CompositeCacheTests.cs: -------------------------------------------------------------------------------- 1 | using IntelligentHack.IntelligentCache; 2 | using System; 3 | using System.Threading.Tasks; 4 | using Xunit; 5 | 6 | namespace IntelligentCache.Tests 7 | { 8 | public class CompositeCacheTests 9 | { 10 | [Fact] 11 | public void GetSet_when_l1_hits_then_l2_not_called() 12 | { 13 | // Arrange 14 | var l1 = false; 15 | var l2 = false; 16 | var level1 = new InspectableCache((key)=>{l1=true;}); 17 | var level2 = new InspectableCache((key)=>{l2=true;}); 18 | 19 | var sut = new CompositeCache(level1, level2); 20 | 21 | // Act 22 | var result = sut.GetSet("a", ()=>"", TimeSpan.Zero); 23 | 24 | // Assert 25 | Assert.True(l1); 26 | Assert.False(l2); 27 | } 28 | 29 | [Fact] 30 | public void GetSet_when_l1_misses_then_l2_called() 31 | { 32 | // Arrange 33 | var l1 = false; 34 | var l2 = false; 35 | var level1 = new InspectableCache((key)=>{l1=true;}, cacheMiss: true); 36 | var level2 = new InspectableCache((key)=>{l2=true;}); 37 | 38 | var sut = new CompositeCache(level1, level2); 39 | 40 | // Act 41 | var result = sut.GetSet("a", ()=>"", TimeSpan.Zero); 42 | 43 | // Assert 44 | Assert.True(l1); 45 | Assert.True(l2); 46 | } 47 | 48 | [Fact] 49 | public async Task InvalidateAsync_when_l2_called_then_l1_not_called_yet() 50 | { 51 | // Arrange 52 | var l1 = false; 53 | var l2 = false; 54 | var level1 = new InspectableCache((key)=>{l1=true;}); 55 | var level2 = new InspectableCache((key)=>{l2=true;}); 56 | 57 | var sut = new CompositeCache(level1, level2); 58 | 59 | // Act 60 | await sut.InvalidateAsync("a"); 61 | 62 | // Assert 63 | Assert.True(l1); 64 | Assert.True(l2); 65 | } 66 | 67 | [Fact] 68 | public async Task InvalidateAsync_when_called_l1_and_l2_called() 69 | { 70 | // Arrange 71 | var l1First = false; 72 | var l2First = false; 73 | var level1 = new InspectableCache((key)=>{l1First=!l2First;}); 74 | var level2 = new InspectableCache((key)=>{l2First=!l1First;}); 75 | 76 | var sut = new CompositeCache(level1, level2); 77 | 78 | // Act 79 | await sut.InvalidateAsync("a"); 80 | 81 | // Assert 82 | Assert.True(l2First); 83 | Assert.False(l1First); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/FakeRedis.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable RCS1102 // Make class static. 2 | 3 | using FakeItEasy; 4 | using StackExchange.Redis; 5 | using System; 6 | using System.Collections.Generic; 7 | 8 | namespace IntelligentCache.Tests 9 | { 10 | internal class FakeRedis 11 | { 12 | public static IConnectionMultiplexer CreateConnectionMultiplexer(Func onGet = null, Action onSet = null) 13 | { 14 | var multiplexer = A.Fake(o => o.Strict()); 15 | var database = A.Fake(o => o.Strict()); 16 | 17 | A.CallTo(() => multiplexer.GetDatabase(A._, A._)).Returns(database); 18 | 19 | A.CallTo(() => database.StringGet(A._, A._)) 20 | .ReturnsLazily((RedisKey key, CommandFlags _) => onGet?.Invoke(key) ?? RedisValue.Null); 21 | 22 | A.CallTo(() => database.StringGetAsync(A._, A._)) 23 | .ReturnsLazily((RedisKey key, CommandFlags _) => onGet?.Invoke(key) ?? RedisValue.Null); 24 | 25 | A.CallTo(() => database.StringSet(A._, A._, A._, A._, A._)) 26 | .ReturnsLazily((RedisKey key, RedisValue value, TimeSpan? expiry, When _, CommandFlags __) => 27 | { 28 | onSet?.Invoke(key, value, expiry); 29 | return true; 30 | }); 31 | 32 | A.CallTo(() => database.StringSetAsync(A._, A._, A._, A._, A._)) 33 | .ReturnsLazily((RedisKey key, RedisValue value, TimeSpan? expiry, When _, CommandFlags __) => 34 | { 35 | onSet?.Invoke(key, value, expiry); 36 | return true; 37 | }); 38 | 39 | return multiplexer; 40 | } 41 | 42 | public static ISubscriber CreateSubscriber(Action onPublish = null) 43 | { 44 | var subscriber = A.Fake(); 45 | 46 | var subscriptions = new List>(); 47 | 48 | A.CallTo(() => subscriber.Subscribe(A._, A>._, A._)) 49 | .Invokes((RedisChannel _, Action handler, CommandFlags __) => subscriptions.Add(handler)); 50 | 51 | A.CallTo(() => subscriber.Publish(A._, A._, A._)) 52 | .Invokes((RedisChannel channel, RedisValue message, CommandFlags _) => PublishHandler(channel, message)); 53 | 54 | A.CallTo(() => subscriber.PublishAsync(A._, A._, A._)) 55 | .Invokes((RedisChannel channel, RedisValue message, CommandFlags _) => PublishHandler(channel, message)); 56 | 57 | void PublishHandler(RedisChannel channel, RedisValue message) 58 | { 59 | onPublish?.Invoke(channel, message); 60 | 61 | foreach (var handler in subscriptions!) 62 | { 63 | handler(channel, message); 64 | } 65 | } 66 | 67 | return subscriber; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/InspectableCache.cs: -------------------------------------------------------------------------------- 1 | using IntelligentHack.IntelligentCache; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace IntelligentCache.Tests 7 | { 8 | public class InspectableCache : ICache 9 | { 10 | private readonly Action _onCall; 11 | private readonly bool _cacheMiss; 12 | 13 | public InspectableCache(Action onCall, bool cacheMiss = false) 14 | { 15 | _onCall = onCall ?? throw new ArgumentNullException(nameof(onCall)); 16 | _cacheMiss = cacheMiss; 17 | } 18 | 19 | public T GetSet(string key, Func calculateValue, TimeSpan duration) where T: class 20 | { 21 | _onCall(key); 22 | if (_cacheMiss) calculateValue(); 23 | return default!; 24 | } 25 | 26 | public async Task GetSetAsync(string key, Func> calculateValue, TimeSpan duration, CancellationToken cancellationToken = default) where T: class 27 | { 28 | _onCall(key); 29 | if (_cacheMiss) await calculateValue(CancellationToken.None); 30 | return default!; 31 | } 32 | 33 | public void Invalidate(string key) 34 | { 35 | _onCall(key); 36 | } 37 | 38 | public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) 39 | { 40 | _onCall(key); 41 | return Task.CompletedTask; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/IntelligentCache.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0;net7.0 5 | false 6 | 8.0 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/MemoryCacheTests.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously 2 | using IntelligentHack.IntelligentCache; 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Xunit; 7 | 8 | namespace IntelligentCache.Tests 9 | { 10 | public class MemoryCacheTests 11 | { 12 | private static int _nextCachePrefixId; 13 | 14 | private static string GeneratePrefix() 15 | { 16 | var prefixId = Interlocked.Increment(ref _nextCachePrefixId); 17 | return $"test{prefixId}"; 18 | } 19 | 20 | [Fact] 21 | public void GetSet_when_key_missed_then_lambda_called() 22 | { 23 | // Arrange 24 | var sut = new MemoryCache(GeneratePrefix()); 25 | var called = false; 26 | 27 | // Act 28 | var result = sut.GetSet("testKey", () => { called = true; return "42"; }, TimeSpan.FromSeconds(10)); 29 | 30 | // Assert 31 | Assert.Equal("42", result); 32 | Assert.True(called); 33 | } 34 | 35 | [Fact] 36 | public async Task GetSetAsync_when_key_missed_then_lambda_called() 37 | { 38 | // Arrange 39 | var sut = new MemoryCache(GeneratePrefix()); 40 | var called = false; 41 | 42 | // Act 43 | var result = await sut.GetSetAsync("testKey", async ct => { called = true; return "42"; }, TimeSpan.FromSeconds(10)); 44 | 45 | // Assert 46 | Assert.Equal("42", result); 47 | Assert.True(called); 48 | } 49 | 50 | [Fact] 51 | public void GetSet_when_key_hit_then_lambda_not_called() 52 | { 53 | // Arrange 54 | var sut = new MemoryCache(GeneratePrefix()); 55 | sut.GetSet("testKey", () => { return "42"; }, TimeSpan.FromSeconds(20)); 56 | var called = false; 57 | 58 | // Act 59 | var result = sut.GetSet("testKey", () => { called = true; return "not 42"; }, TimeSpan.FromSeconds(1)); 60 | 61 | // Assert 62 | Assert.Equal("42", result); 63 | Assert.False(called); 64 | } 65 | 66 | [Fact] 67 | public async Task GetSetAsync_when_key_hit_then_lambda_not_called() 68 | { 69 | // Arrange 70 | var sut = new MemoryCache(GeneratePrefix()); 71 | sut.GetSet("testKey", () => { return "42"; }, TimeSpan.FromSeconds(20)); 72 | var called = false; 73 | 74 | // Act 75 | var result = await sut.GetSetAsync("testKey", async ct => { called = true; return "not 42"; }, TimeSpan.FromSeconds(1)); 76 | 77 | // Assert 78 | Assert.Equal("42", result); 79 | Assert.False(called); 80 | } 81 | 82 | [Fact] 83 | public void GetSet_when_key_expired_then_lambda_called() 84 | { 85 | // Arrange 86 | var sut = new MemoryCache(GeneratePrefix()); 87 | sut.GetSet("testKey", () => { return "42"; }, TimeSpan.Zero); 88 | var called = false; 89 | 90 | // Act 91 | var result = sut.GetSet("testKey", () => { called = true; return "not 42"; }, TimeSpan.FromSeconds(1)); 92 | 93 | // Assert 94 | Assert.Equal("not 42", result); 95 | Assert.True(called); 96 | } 97 | 98 | [Fact] 99 | public async Task GetSetAsync_when_key_expired_then_lambda_called() 100 | { 101 | // Arrange 102 | var sut = new MemoryCache(GeneratePrefix()); 103 | sut.GetSet("testKey", () => { return "42"; }, TimeSpan.Zero); 104 | var called = false; 105 | 106 | // Act 107 | var result = await sut.GetSetAsync("testKey", async ct => { called = true; return "not 42"; }, TimeSpan.FromSeconds(1)); 108 | 109 | // Assert 110 | Assert.Equal("not 42", result); 111 | Assert.True(called); 112 | } 113 | 114 | [Fact] 115 | public void GetSet_when_key_invalidated_then_lambda_called() 116 | { 117 | // Arrange 118 | var sut = new MemoryCache(GeneratePrefix()); 119 | sut.GetSet("testKey", () => { return "forty-two"; }, TimeSpan.FromSeconds(60)); 120 | sut.Invalidate("testKey"); 121 | var called = false; 122 | 123 | // Act 124 | var result = sut.GetSet("testKey", () => { called = true; return "not 42"; }, TimeSpan.FromSeconds(1)); 125 | 126 | // Assert 127 | Assert.True(called); 128 | Assert.Equal("not 42", result); 129 | } 130 | 131 | [Fact] 132 | public async Task GetSetAsync_when_key_invalidated_then_lambda_called() 133 | { 134 | // Arrange 135 | var sut = new MemoryCache(GeneratePrefix()); 136 | sut.GetSet("testKey", () => { return "forty-two"; }, TimeSpan.FromSeconds(60)); 137 | sut.Invalidate("testKey"); 138 | var called = false; 139 | 140 | // Act 141 | var result = await sut.GetSetAsync("testKey", async ct => { called = true; return "not 42"; }, TimeSpan.FromSeconds(1)); 142 | 143 | // Assert 144 | Assert.True(called); 145 | Assert.Equal("not 42", result); 146 | } 147 | 148 | [Fact] 149 | public void GetSet_allows_infinite_expiration() 150 | { 151 | // This test checks for a bug that was present when using an infinite expiration. 152 | 153 | // Arrange 154 | var sut = new MemoryCache(GeneratePrefix()); 155 | var called = false; 156 | 157 | // Act 158 | var result = sut.GetSet("testKey", () => { called = true; return "42"; }, TimeSpan.MaxValue); 159 | 160 | // Assert 161 | Assert.Equal("42", result); 162 | Assert.True(called); 163 | } 164 | 165 | [Fact] 166 | public void GetSet_returns_null_when_trying_to_save_null_values() 167 | { 168 | // Arrange 169 | var sut = new MemoryCache("MemoryCache-InvalidOperationException "); 170 | var called = false; 171 | var cachedValue = sut.GetSet("testKey", () => { called = true; return null; }, TimeSpan.Zero); 172 | 173 | // Act-Assert 174 | Assert.Null(cachedValue); 175 | Assert.True(called); 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/PassThroughCacheTests.cs: -------------------------------------------------------------------------------- 1 | using IntelligentHack.IntelligentCache; 2 | using System; 3 | using Xunit; 4 | 5 | namespace IntelligentCache.Tests 6 | { 7 | public class PassThroughCacheTests 8 | 9 | { 10 | [Fact] 11 | public void GetSet_lambda_is_always_called() 12 | { 13 | // Arrange 14 | var sut = new PassThroughCache(); 15 | var count = 0; 16 | 17 | // Act 18 | var result = sut.GetSet("testKey", () => { count++; return "41"; }, TimeSpan.FromSeconds(10)); 19 | 20 | result = sut.GetSet("testKey", () => { count++; return "42"; }, TimeSpan.FromSeconds(10)); 21 | 22 | // Assert 23 | Assert.Equal("42", result); 24 | Assert.Equal(2, count); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/ProtobufSerializerTest.cs: -------------------------------------------------------------------------------- 1 | using IntelligentHack.IntelligentCache; 2 | using ProtoBuf; 3 | using System.ServiceModel.Channels; 4 | using Xunit; 5 | 6 | namespace IntelligentCache.Tests 7 | { 8 | public class ProtobufSerializerTest 9 | { 10 | [ProtoContract] 11 | private class Model 12 | { 13 | [ProtoMember(1)] 14 | public string Foo { get; set; } 15 | 16 | [ProtoMember(2)] 17 | public double Bar { get; set; } 18 | } 19 | 20 | [Fact] 21 | public void Serialize_Deflate() 22 | { 23 | var model = new Model() { Bar = 15, Foo = "foo" }; 24 | IRedisSerializer protobufSerializer = new ProtobufSerializer() { CompressionFormat = CompressionFormat.Deflate }; 25 | var redisValue = protobufSerializer.Serialize(model); 26 | Assert.True(redisValue.HasValue); 27 | } 28 | 29 | [Fact] 30 | public void Serialize_GZip() 31 | { 32 | var model = new Model() { Bar = 15, Foo = "foo" }; 33 | IRedisSerializer protobufSerializer = new ProtobufSerializer() { CompressionFormat = CompressionFormat.GZip }; 34 | var redisValue = protobufSerializer.Serialize(model); 35 | Assert.True(redisValue.HasValue); 36 | } 37 | 38 | [Fact] 39 | public void Serialize_Default() 40 | { 41 | var model = new Model() { Bar = 15, Foo = "foo" }; 42 | IRedisSerializer protobufSerializer = new ProtobufSerializer() { CompressionFormat = CompressionFormat.None }; 43 | var redisValue = protobufSerializer.Serialize(model); 44 | Assert.True(redisValue.HasValue); 45 | } 46 | 47 | [Fact] 48 | public void Deserialize_Deflate() 49 | { 50 | var model = new Model() { Bar = 15, Foo = "foo" }; 51 | IRedisSerializer protobufSerializer = new ProtobufSerializer() { CompressionFormat = CompressionFormat.Deflate }; 52 | var deserialized = protobufSerializer.Deserialize(protobufSerializer.Serialize(model)); 53 | Assert.Equal(model.Bar, deserialized.Bar); 54 | Assert.Equal(model.Foo, deserialized.Foo); 55 | } 56 | 57 | [Fact] 58 | public void Deserialize_GZip() 59 | { 60 | var model = new Model() { Bar = 15, Foo = "foo" }; 61 | IRedisSerializer protobufSerializer = new ProtobufSerializer() { CompressionFormat = CompressionFormat.GZip }; 62 | var deserialized = protobufSerializer.Deserialize(protobufSerializer.Serialize(model)); 63 | Assert.Equal(model.Bar, deserialized.Bar); 64 | Assert.Equal(model.Foo, deserialized.Foo); 65 | } 66 | 67 | [Fact] 68 | public void Deserialize_Default() 69 | { 70 | var model = new Model() { Bar = 15, Foo = "foo" }; 71 | IRedisSerializer protobufSerializer = new ProtobufSerializer() { CompressionFormat = CompressionFormat.None }; 72 | var deserialized = protobufSerializer.Deserialize(protobufSerializer.Serialize(model)); 73 | Assert.Equal(model.Bar, deserialized.Bar); 74 | Assert.Equal(model.Foo, deserialized.Foo); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/RedisCacheTests.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously 2 | 3 | using IntelligentHack.IntelligentCache; 4 | using StackExchange.Redis; 5 | using System; 6 | using System.Threading.Tasks; 7 | using Xunit; 8 | 9 | namespace IntelligentCache.Tests 10 | { 11 | public class RedisCacheTests 12 | { 13 | [Fact] 14 | public void GetSet_calls_calculateValue_on_miss() 15 | { 16 | // Arrange 17 | string setKey = null; 18 | string setValue = null; 19 | TimeSpan? setExpiration = default; 20 | 21 | var multiplexer = FakeRedis.CreateConnectionMultiplexer(onSet: (key, value, expiration) => 22 | { 23 | setKey = key; 24 | setValue = value; 25 | setExpiration = expiration; 26 | }); 27 | 28 | var sut = new RedisCache(multiplexer, "prefix"); 29 | var called = false; 30 | 31 | // Act 32 | var valueFromCache = sut.GetSet("testKey", () => { called = true; return "42"; }, TimeSpan.FromSeconds(10)); 33 | 34 | // Assert 35 | Assert.True(called); 36 | Assert.Equal("42", valueFromCache); 37 | Assert.Equal("prefix:testKey", setKey); 38 | Assert.Equal("\"42\"", setValue); // Json-encoded 39 | Assert.Equal(TimeSpan.FromSeconds(10), setExpiration); 40 | } 41 | 42 | [Fact] 43 | public async Task GetSetAsync_calls_calculateValue_on_miss() 44 | { 45 | // Arrange 46 | string setKey = null; 47 | string setValue = null; 48 | TimeSpan? setExpiration = default; 49 | 50 | var multiplexer = FakeRedis.CreateConnectionMultiplexer(onSet: (key, value, expiration) => 51 | { 52 | setKey = key; 53 | setValue = value; 54 | setExpiration = expiration; 55 | }); 56 | 57 | var sut = new RedisCache(multiplexer, "prefix"); 58 | var called = false; 59 | 60 | // Act 61 | var valueFromCache = await sut.GetSetAsync("testKey", async ct => { called = true; return "42"; }, TimeSpan.FromSeconds(10)); 62 | 63 | // Assert 64 | Assert.True(called); 65 | Assert.Equal("42", valueFromCache); 66 | Assert.Equal("prefix:testKey", setKey); 67 | Assert.Equal("\"42\"", setValue); // Json-encoded 68 | Assert.Equal(TimeSpan.FromSeconds(10), setExpiration); 69 | } 70 | 71 | [Fact] 72 | public void GetSet_uses_cached_value_on_hit() 73 | { 74 | // Arrange 75 | string lookupKey = null; 76 | 77 | var multiplexer = FakeRedis.CreateConnectionMultiplexer(onGet: key => { lookupKey = key; return "\"42\""; }); 78 | 79 | var sut = new RedisCache(multiplexer, "prefix"); 80 | var called = false; 81 | 82 | // Act 83 | var valueFromCache = sut.GetSet("testKey", () => { called = true; return "not 42"; }, TimeSpan.FromSeconds(10)); 84 | 85 | // Assert 86 | Assert.False(called); 87 | Assert.Equal("42", valueFromCache); 88 | Assert.Equal("prefix:testKey", lookupKey); 89 | } 90 | 91 | [Fact] 92 | public async Task GetSetAsync_uses_cached_value_on_hit() 93 | { 94 | // Arrange 95 | string lookupKey = null; 96 | 97 | var multiplexer = FakeRedis.CreateConnectionMultiplexer(onGet: key => { lookupKey = key; return "\"42\""; }); 98 | 99 | var sut = new RedisCache(multiplexer, "prefix"); 100 | var called = false; 101 | 102 | // Act 103 | var valueFromCache = await sut.GetSetAsync("testKey", async ct => { called = true; return "not 42"; }, TimeSpan.FromSeconds(10)); 104 | 105 | // Assert 106 | Assert.False(called); 107 | Assert.Equal("42", valueFromCache); 108 | Assert.Equal("prefix:testKey", lookupKey); 109 | } 110 | 111 | [Fact] 112 | public void Invalidate_clears_the_value() 113 | { 114 | // Arrange 115 | string setKey = null; 116 | RedisValue setValue = default; 117 | TimeSpan? setExpiration = default; 118 | 119 | var multiplexer = FakeRedis.CreateConnectionMultiplexer(onSet: (key, value, expiration) => 120 | { 121 | setKey = key; 122 | setValue = value; 123 | setExpiration = expiration; 124 | }); 125 | 126 | var sut = new RedisCache(multiplexer, "prefix"); 127 | 128 | // Act 129 | sut.Invalidate("testKey"); 130 | 131 | // Assert 132 | Assert.Equal("prefix:testKey", setKey); 133 | Assert.True(setValue.IsNull); 134 | Assert.Null(setExpiration); 135 | } 136 | 137 | [Fact] 138 | public async Task InvalidateAsync_clears_the_value() 139 | { 140 | // Arrange 141 | string setKey = null; 142 | RedisValue setValue = default; 143 | TimeSpan? setExpiration = default; 144 | 145 | var multiplexer = FakeRedis.CreateConnectionMultiplexer(onSet: (key, value, expiration) => 146 | { 147 | setKey = key; 148 | setValue = value; 149 | setExpiration = expiration; 150 | }); 151 | 152 | var sut = new RedisCache(multiplexer, "prefix"); 153 | 154 | // Act 155 | await sut.InvalidateAsync("testKey"); 156 | 157 | // Assert 158 | Assert.Equal("prefix:testKey", setKey); 159 | Assert.True(setValue.IsNull); 160 | Assert.Null(setExpiration); 161 | } 162 | 163 | [Fact] 164 | public void GetSet_uses_cached_protobuf_object_on_hit() 165 | { 166 | // Arrange 167 | string lookupKey = null; 168 | var entity = new Entity { IntProp = 5 }; 169 | var protoSerializer = new ProtobufSerializer(); 170 | 171 | var multiplexer = FakeRedis.CreateConnectionMultiplexer(onGet: key => { lookupKey = key; return protoSerializer.Serialize(entity); }); 172 | 173 | var sut = new RedisCache(multiplexer, "prefix") { Serializer = protoSerializer }; 174 | var called = false; 175 | 176 | // Act 177 | var valueFromCache = sut.GetSet("testKey", () => { called = true; return new Entity(); }, TimeSpan.FromSeconds(10)); 178 | 179 | // Assert 180 | Assert.False(called); 181 | Assert.NotNull(valueFromCache); 182 | Assert.Equal(5, entity.IntProp); 183 | Assert.Equal("prefix:testKey", lookupKey); 184 | } 185 | 186 | [ProtoBuf.ProtoContract] 187 | public class Entity 188 | { 189 | [ProtoBuf.ProtoMember(1)] 190 | public int IntProp { get; set; } 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/RedisInvalidationReceiverTests.cs: -------------------------------------------------------------------------------- 1 | using IntelligentHack.IntelligentCache; 2 | using Xunit; 3 | 4 | namespace IntelligentCache.Tests 5 | { 6 | public class RedisInvalidationReceiverTests 7 | { 8 | [Fact] 9 | public void Invalidation_messages_call_Invalidate_on_inner_cache() 10 | { 11 | // Arrange 12 | string invalidatedKey = null; 13 | var innerCache = new InspectableCache(key => { invalidatedKey = key; }); 14 | 15 | var subscriber = FakeRedis.CreateSubscriber(); 16 | 17 | var sut = new RedisInvalidationReceiver(innerCache, subscriber, "invalidation"); 18 | 19 | // Act 20 | subscriber.Publish("invalidation", "testKey"); 21 | 22 | // Assert 23 | Assert.Equal("testKey", invalidatedKey); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /IntelligentCache.Tests/RedisInvalidatorSenderTests.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously 2 | 3 | using IntelligentHack.IntelligentCache; 4 | using System; 5 | using System.Threading.Tasks; 6 | using Xunit; 7 | 8 | namespace IntelligentCache.Tests 9 | { 10 | public class RedisInvalidationSenderTests 11 | { 12 | [Fact] 13 | public void Invalidate_publishes_an_invalidation_message() 14 | { 15 | // Arrange 16 | string publishedChannel = null; 17 | string publishedMessage = null; 18 | 19 | var subscriber = FakeRedis.CreateSubscriber(onPublish: (c, m) => 20 | { 21 | publishedChannel = c; 22 | publishedMessage = m; 23 | }); 24 | 25 | var sut = new RedisInvalidationSender(subscriber, "invalidation"); 26 | 27 | // Act 28 | sut.Invalidate("testKey"); 29 | 30 | // Assert 31 | Assert.Equal("invalidation", publishedChannel); 32 | Assert.Equal("testKey", publishedMessage); 33 | } 34 | 35 | [Fact] 36 | public async Task InvalidateAsync_publishes_an_invalidation_message() 37 | { 38 | // Arrange 39 | string publishedChannel = null; 40 | string publishedMessage = null; 41 | 42 | var subscriber = FakeRedis.CreateSubscriber(onPublish: (c, m) => 43 | { 44 | publishedChannel = c; 45 | publishedMessage = m; 46 | }); 47 | 48 | var sut = new RedisInvalidationSender(subscriber, "invalidation"); 49 | 50 | // Act 51 | await sut.InvalidateAsync("testKey"); 52 | 53 | // Assert 54 | Assert.Equal("invalidation", publishedChannel); 55 | Assert.Equal("testKey", publishedMessage); 56 | 57 | } 58 | 59 | [Fact] 60 | public void GetSet_always_calculates_the_value() 61 | { 62 | // Arrange 63 | var subscriber = FakeRedis.CreateSubscriber(); 64 | var sut = new RedisInvalidationSender(subscriber, "invalidation"); 65 | 66 | var count = 0; 67 | 68 | // Act 69 | var valueFromCache = sut.GetSet("testKey", () => { ++count; return "42"; }, TimeSpan.FromSeconds(10)); 70 | 71 | // Assert 72 | Assert.Equal(1, count); 73 | Assert.Equal("42", valueFromCache); 74 | } 75 | 76 | [Fact] 77 | public async Task GetSetAsync_always_calculates_the_value() 78 | { 79 | // Arrange 80 | var subscriber = FakeRedis.CreateSubscriber(); 81 | var sut = new RedisInvalidationSender(subscriber, "invalidation"); 82 | 83 | var count = 0; 84 | 85 | // Act 86 | var valueFromCache = await sut.GetSetAsync("testKey", async ct => { ++count; return "42"; }, TimeSpan.FromSeconds(10)); 87 | 88 | // Assert 89 | Assert.Equal(1, count); 90 | Assert.Equal("42", valueFromCache); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /IntelligentCache.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30320.27 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntelligentCache", "IntelligentCache\IntelligentCache.csproj", "{0419D13E-5BCC-45D0-91C0-33C20418C4AE}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A09FE95C-B99A-436E-B29F-ADA3F24BBA74}" 9 | ProjectSection(SolutionItems) = preProject 10 | .editorconfig = .editorconfig 11 | README.md = README.md 12 | EndProjectSection 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntelligentCache.Tests", "IntelligentCache.Tests\IntelligentCache.Tests.csproj", "{DE5B0C27-C362-4EDB-8C88-8973B5E7B3CA}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {0419D13E-5BCC-45D0-91C0-33C20418C4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {0419D13E-5BCC-45D0-91C0-33C20418C4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {0419D13E-5BCC-45D0-91C0-33C20418C4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {0419D13E-5BCC-45D0-91C0-33C20418C4AE}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {DE5B0C27-C362-4EDB-8C88-8973B5E7B3CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {DE5B0C27-C362-4EDB-8C88-8973B5E7B3CA}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {DE5B0C27-C362-4EDB-8C88-8973B5E7B3CA}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {DE5B0C27-C362-4EDB-8C88-8973B5E7B3CA}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {8EC6744C-275E-4402-A157-D675046C9F1A} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /IntelligentCache/CompositeCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace IntelligentHack.IntelligentCache 6 | { 7 | /// 8 | /// Composes two caches into a two-level hierarchical cache. 9 | /// 10 | public sealed class CompositeCache : ICache 11 | { 12 | private readonly ICache _level1; 13 | private readonly ICache _level2; 14 | 15 | /// 16 | /// Creates a two-level hierarchical cache. 17 | /// Values are retrieved first from the first level. 18 | /// If no value is found, the second level is used. 19 | /// 20 | /// This is the first cache to be checked. If there is a cache miss, the second level cache will be attempted 21 | /// Second level cache (usually a shared/remote cache in a webfarm). Called when the first level cache misses. 22 | public CompositeCache(ICache level1, ICache level2) 23 | { 24 | _level1 = level1 ?? throw new ArgumentNullException(nameof(level1)); 25 | _level2 = level2 ?? throw new ArgumentNullException(nameof(level2)); 26 | } 27 | 28 | /// 29 | public T GetSet(string key, Func calculateValue, TimeSpan duration) where T : class 30 | { 31 | return _level1.GetSet(key, () => _level2.GetSet(key, calculateValue, duration), duration); 32 | } 33 | 34 | /// 35 | public Task GetSetAsync(string key, Func> calculateValue, TimeSpan duration, CancellationToken cancellationToken = default) where T : class 36 | { 37 | return _level1.GetSetAsync(key, ct => _level2.GetSetAsync(key, calculateValue, duration, ct), duration, cancellationToken); 38 | } 39 | 40 | /// 41 | public void Invalidate(string key) 42 | { 43 | _level2.Invalidate(key); 44 | _level1.Invalidate(key); 45 | } 46 | 47 | /// 48 | public async Task InvalidateAsync(string key, CancellationToken cancellationToken = default) 49 | { 50 | await _level2.InvalidateAsync(key, cancellationToken).ConfigureAwait(false); 51 | await _level1.InvalidateAsync(key, cancellationToken).ConfigureAwait(false); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /IntelligentCache/ICache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace IntelligentHack.IntelligentCache 6 | { 7 | public interface ICache 8 | { 9 | /// 10 | /// Gets the value associated to the specified key asynchronously. 11 | /// If no value is currently associated, uses to retrieve it. 12 | /// 13 | /// The cache key. 14 | /// A callback that produces a new value if the key is not in cache. 15 | /// Indicates how long the value should be kept in the cache. Use to prevent expiration. 16 | Task GetSetAsync(string key, Func> calculateValue, TimeSpan duration, CancellationToken cancellationToken = default) where T: class; 17 | 18 | /// 19 | /// Gets the value associated to the specified key. 20 | /// If no value is currently associated, uses to retrieve it. 21 | /// 22 | /// The cache key. 23 | /// A callback that produces a new value if the key is not in cache. 24 | /// Indicates how long the value should be kept in the cache. Use to prevent expiration. 25 | T GetSet(string key, Func calculateValue, TimeSpan duration) where T: class; 26 | 27 | /// 28 | /// Invalidates the specified key asynchronously. 29 | /// 30 | Task InvalidateAsync(string key, CancellationToken cancellationToken = default); 31 | 32 | /// 33 | /// Invalidates the specified key. 34 | /// 35 | void Invalidate(string key); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /IntelligentCache/IRedisSerializer.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | 3 | namespace IntelligentHack.IntelligentCache 4 | { 5 | /// 6 | /// Converts objects from / to a format that can be stored on Redis. 7 | /// 8 | public interface IRedisSerializer 9 | { 10 | /// 11 | /// Converts the specified parameter to a . 12 | /// 13 | RedisValue Serialize(T instance); 14 | 15 | /// 16 | /// Converts the specified value to an object of type . 17 | /// 18 | T Deserialize(RedisValue value); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /IntelligentCache/IntelligentCache.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0;net7.0 5 | 8.0 6 | IntelligentHack.IntelligentCache 7 | IntelligentHack.IntelligentCache 8 | false 9 | 3.3 10 | IntelligentHack.IntelligentCache 11 | Marco Cecconi, Oded Coster, Antoine Aubry 12 | https://github.com/intelligenthack/intelligent-cache 13 | https://github.com/intelligenthack/intelligent-cache.git 14 | MIT 15 | This package implements a distributed cache monad ("pattern") and currently supports single and multiple layers of caching, in memory and via Redis. 16 | Cache;Redis;AspNet 17 | true 18 | Intelligent Hack 19 | 20 | 21 | 22 | 2020 23 | $([System.DateTime]::Now.Year) 24 | Copyright $(CopyrightStartYear) $(Company) 25 | Copyright $(CopyrightStartYear)-$(CopyrightEndYear) $(Company) 26 | icon.png 27 | 28 | 29 | 30 | 31 | 32 | Minimum 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | True 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /IntelligentCache/JsonStringSerializer.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using StackExchange.Redis; 3 | 4 | namespace IntelligentHack.IntelligentCache 5 | { 6 | /// 7 | /// An implementation of that encodes objects as JSON. 8 | /// 9 | public class JsonStringSerializer : IRedisSerializer 10 | { 11 | public T Deserialize(RedisValue value) 12 | { 13 | return JsonConvert.DeserializeObject(value); 14 | } 15 | 16 | public RedisValue Serialize(T instance) 17 | { 18 | return JsonConvert.SerializeObject(instance); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelligentCache/MemoryCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MemCache = System.Runtime.Caching.MemoryCache; 5 | 6 | namespace IntelligentHack.IntelligentCache 7 | { 8 | /// 9 | /// An implementation of that stores values in a . 10 | /// 11 | public class MemoryCache : ICache, IDisposable 12 | { 13 | private readonly string prefix; 14 | private readonly MultiKeyLock syncLock = new MultiKeyLock(); 15 | private readonly MemCache innerCache = MemCache.Default; 16 | private bool disposedValue; 17 | 18 | /// 19 | /// Creates a cache that runs in the server memory. 20 | /// 21 | /// This string is prefixed to the key names to partition the keys if the underlying storage is shared 22 | /// If not null, the cache will use the given instead of the default one. 23 | public MemoryCache(string prefix, MemCache innerMemoryCache = null) 24 | { 25 | if (innerMemoryCache != null) innerCache = innerMemoryCache; 26 | if (string.IsNullOrEmpty(prefix)) throw new ArgumentNullException(nameof(prefix)); 27 | this.prefix = prefix + ":"; 28 | } 29 | 30 | /// 31 | public T GetSet(string key, Func calculateValue, TimeSpan duration) where T : class 32 | { 33 | var k = prefix + key; 34 | syncLock.EnterReadLock(k); 35 | try 36 | { 37 | var res = (T)innerCache.Get(k); 38 | if (res != null) return res; 39 | } 40 | finally 41 | { 42 | syncLock.ExitReadLock(k); 43 | } 44 | 45 | syncLock.EnterUpgradeableReadLock(k); 46 | try 47 | { 48 | var res = (T)innerCache.Get(k); 49 | if (res != null) return res; 50 | 51 | syncLock.EnterWriteLock(k); 52 | try 53 | { 54 | res = calculateValue(); 55 | if (res == null) return null; // Not all caches support null values. Also, caching a null is dodgy in itself. 56 | 57 | var expiration = duration == TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.UtcNow.Add(duration); 58 | innerCache.Set(k, res, expiration); 59 | return res; 60 | } 61 | finally 62 | { 63 | syncLock.ExitWriteLock(k); 64 | } 65 | } 66 | finally 67 | { 68 | syncLock.ExitUpgradeableReadLock(k); 69 | } 70 | } 71 | 72 | /// 73 | public void Invalidate(string key) 74 | { 75 | var k = prefix + key; 76 | syncLock.EnterWriteLock(k); 77 | try 78 | { 79 | innerCache.Remove(k); 80 | } 81 | finally 82 | { 83 | syncLock.ExitWriteLock(k); 84 | } 85 | } 86 | 87 | /// 88 | public Task GetSetAsync(string key, Func> calculateValue, TimeSpan duration, CancellationToken cancellationToken = default) where T : class 89 | { 90 | var result = GetSet(key, () => calculateValue(cancellationToken).GetAwaiter().GetResult(), duration); 91 | return Task.FromResult(result); 92 | } 93 | 94 | /// 95 | public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) 96 | { 97 | Invalidate(key); 98 | return Task.CompletedTask; 99 | } 100 | 101 | protected virtual void Dispose(bool disposing) 102 | { 103 | if (!disposedValue) 104 | { 105 | if (disposing) 106 | { 107 | syncLock.Dispose(); 108 | } 109 | 110 | disposedValue = true; 111 | } 112 | } 113 | 114 | public void Dispose() 115 | { 116 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 117 | Dispose(disposing: true); 118 | GC.SuppressFinalize(this); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /IntelligentCache/MultiKeyLock.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace IntelligentHack.IntelligentCache 10 | { 11 | internal class MultiKeyLock: IDisposable 12 | { 13 | private readonly ConcurrentDictionary Map = new ConcurrentDictionary(); 14 | private bool disposedValue; 15 | 16 | private void SafeOp(string key, Action action) 17 | { 18 | var thelock = Map.GetOrAdd(key, (_) => new ReaderWriterLockSlim()); 19 | action(thelock); 20 | } 21 | public void EnterWriteLock(string key) => SafeOp(key, (r) => r.EnterWriteLock()); 22 | public void EnterReadLock(string key) => SafeOp(key, (r) => r.EnterReadLock()); 23 | public void EnterUpgradeableReadLock(string key) => SafeOp(key, (r) => r.EnterUpgradeableReadLock()); 24 | public void ExitWriteLock(string key) => SafeOp(key, (r) => r.ExitWriteLock()); 25 | public void ExitReadLock(string key) => SafeOp(key, (r) => r.ExitReadLock()); 26 | public void ExitUpgradeableReadLock(string key) => SafeOp(key, (r) => r.ExitUpgradeableReadLock()); 27 | 28 | protected virtual void Dispose(bool disposing) 29 | { 30 | if (!disposedValue) 31 | { 32 | if (disposing) 33 | { 34 | foreach (var thelock in Map.Values) 35 | { 36 | thelock.Dispose(); 37 | } 38 | } 39 | 40 | disposedValue = true; 41 | } 42 | } 43 | 44 | public void Dispose() 45 | { 46 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 47 | Dispose(disposing: true); 48 | GC.SuppressFinalize(this); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /IntelligentCache/PassThroughCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace IntelligentHack.IntelligentCache 6 | { 7 | /// 8 | /// An implementation of that always calls the callback. 9 | /// 10 | /// 11 | /// This class provides a "null object" implementation of . 12 | /// It can be useful in tests or other contexts that require a cache. 13 | /// 14 | public sealed class PassThroughCache : ICache 15 | { 16 | /// 17 | public T GetSet(string key, Func calculateValue, TimeSpan duration) where T : class 18 | { 19 | return calculateValue(); 20 | } 21 | 22 | /// 23 | public Task GetSetAsync(string key, Func> calculateValue, TimeSpan duration, CancellationToken cancellationToken = default) where T : class 24 | { 25 | return calculateValue(cancellationToken); 26 | } 27 | 28 | /// 29 | public void Invalidate(string key) 30 | { 31 | return; 32 | } 33 | 34 | /// 35 | public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) 36 | { 37 | return Task.CompletedTask; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /IntelligentCache/ProtobufSerializer.cs: -------------------------------------------------------------------------------- 1 | using ProtoBuf; 2 | using StackExchange.Redis; 3 | using System; 4 | using System.IO; 5 | using System.IO.Compression; 6 | using System.ServiceModel.Channels; 7 | 8 | namespace IntelligentHack.IntelligentCache 9 | { 10 | /// 11 | /// An implementation of that encodes objects as compressed protobuf. 12 | /// 13 | public class ProtobufSerializer : IRedisSerializer 14 | { 15 | public CompressionFormat CompressionFormat { get; set; } = CompressionFormat.GZip; 16 | 17 | public T Deserialize(RedisValue value) 18 | { 19 | switch (CompressionFormat) 20 | { 21 | case CompressionFormat.Deflate: 22 | using (var stream = new DeflateStream(new MemoryStream(value), CompressionMode.Decompress)) 23 | { 24 | return Serializer.Deserialize(stream); 25 | } 26 | case CompressionFormat.GZip: 27 | using (var stream = new GZipStream(new MemoryStream(value), CompressionMode.Decompress)) 28 | { 29 | return Serializer.Deserialize(stream); 30 | } 31 | case CompressionFormat.None: 32 | return Serializer.Deserialize(value); 33 | default: 34 | throw new InvalidOperationException("Unknown CompressionFormat"); 35 | } 36 | } 37 | 38 | public RedisValue Serialize(T instance) 39 | { 40 | switch (CompressionFormat) 41 | { 42 | case CompressionFormat.Deflate: 43 | using (var memStream = new MemoryStream()) 44 | { 45 | using (var stream = new DeflateStream(memStream, CompressionLevel.Optimal, leaveOpen: true)) 46 | { 47 | Serializer.Serialize(stream, instance); 48 | } 49 | return RedisValue.CreateFrom(memStream); 50 | } 51 | case CompressionFormat.GZip: 52 | using (var memStream = new MemoryStream()) 53 | { 54 | using (var stream = new GZipStream(memStream, CompressionLevel.Optimal, leaveOpen: true)) 55 | { 56 | Serializer.Serialize(stream, instance); 57 | } 58 | return RedisValue.CreateFrom(memStream); 59 | } 60 | case CompressionFormat.None: 61 | using (var stream = new MemoryStream()) 62 | { 63 | Serializer.Serialize(stream, instance); 64 | return RedisValue.CreateFrom(stream); 65 | } 66 | default: 67 | throw new InvalidOperationException("Unknown CompressionFormat"); 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /IntelligentCache/RedisCache.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace IntelligentHack.IntelligentCache 7 | { 8 | /// 9 | /// An implementation of that stores values on Redis. 10 | /// 11 | public class RedisCache : ICache, IDisposable 12 | { 13 | private readonly IConnectionMultiplexer redis; 14 | private readonly MultiKeyLock syncLock = new MultiKeyLock(); 15 | private readonly string prefix; 16 | private bool disposedValue; 17 | 18 | public IRedisSerializer Serializer { get; set; } = new JsonStringSerializer(); 19 | 20 | /// 21 | /// Creates a cache that is stored on a Redis instance. 22 | /// 23 | /// This string is prefixed to the key names to partition the keys if the underlying storage is shared 24 | public RedisCache(IConnectionMultiplexer redis, string prefix) 25 | { 26 | this.redis = redis ?? throw new ArgumentNullException(nameof(redis)); 27 | if (string.IsNullOrEmpty(prefix)) throw new ArgumentNullException(nameof(prefix)); 28 | this.prefix = prefix + ":"; 29 | } 30 | 31 | public async Task GetSetAsync(string key, Func> calculateValue, TimeSpan duration, CancellationToken cancellationToken = default) where T : class 32 | { 33 | var db = redis.GetDatabase(); 34 | var k = prefix + key; 35 | 36 | syncLock.EnterReadLock(k); 37 | try 38 | { 39 | var res = await db.StringGetAsync(k).ConfigureAwait(false); 40 | if (res.HasValue) return Serializer.Deserialize(res); 41 | } 42 | finally 43 | { 44 | syncLock.ExitReadLock(k); 45 | } 46 | 47 | syncLock.EnterUpgradeableReadLock(k); 48 | try 49 | { 50 | var res = await db.StringGetAsync(k).ConfigureAwait(false); 51 | if (res.HasValue) return Serializer.Deserialize(res); 52 | 53 | syncLock.EnterWriteLock(k); 54 | try 55 | { 56 | var value = await calculateValue(cancellationToken).ConfigureAwait(false); 57 | if (value == null) return null; // Not all caches support null values. Also, caching a null is dodgy in itself. 58 | 59 | await db.StringSetAsync(k, Serializer.Serialize(value), duration).ConfigureAwait(false); 60 | return value; 61 | } 62 | finally 63 | { 64 | syncLock.ExitWriteLock(k); 65 | } 66 | } 67 | finally 68 | { 69 | syncLock.ExitUpgradeableReadLock(k); 70 | } 71 | } 72 | 73 | public async Task InvalidateAsync(string key, CancellationToken cancellationToken = default) 74 | { 75 | var db = redis.GetDatabase(); 76 | var k = prefix + key; 77 | syncLock.EnterWriteLock(k); 78 | try 79 | { 80 | await db.StringSetAsync(k, RedisValue.Null).ConfigureAwait(false); 81 | } 82 | finally 83 | { 84 | syncLock.ExitWriteLock(k); 85 | } 86 | } 87 | 88 | public T GetSet(string key, Func calculateValue, TimeSpan duration) where T : class 89 | { 90 | var db = redis.GetDatabase(); 91 | var k = prefix + key; 92 | 93 | syncLock.EnterReadLock(k); 94 | try 95 | { 96 | var res = db.StringGet(k); 97 | if (res.HasValue) return Serializer.Deserialize(res); 98 | } 99 | finally 100 | { 101 | syncLock.ExitReadLock(k); 102 | } 103 | 104 | syncLock.EnterUpgradeableReadLock(k); 105 | try 106 | { 107 | var res = db.StringGet(k); 108 | if (res.HasValue) return Serializer.Deserialize(res); 109 | 110 | syncLock.EnterWriteLock(k); 111 | try 112 | { 113 | var value = calculateValue(); 114 | if (value == null) return null; // Not all caches support null values. Also, caching a null is dodgy in itself. 115 | 116 | db.StringSet(k, Serializer.Serialize(value), duration); 117 | return value; 118 | } 119 | finally 120 | { 121 | syncLock.ExitWriteLock(k); 122 | } 123 | } 124 | finally 125 | { 126 | syncLock.ExitUpgradeableReadLock(k); 127 | } 128 | } 129 | 130 | public void Invalidate(string key) 131 | { 132 | var db = redis.GetDatabase(); 133 | var k = prefix + key; 134 | syncLock.EnterWriteLock(k); 135 | try 136 | { 137 | db.StringSet(k, RedisValue.Null); 138 | } 139 | finally 140 | { 141 | syncLock.ExitWriteLock(k); 142 | } 143 | } 144 | 145 | protected virtual void Dispose(bool disposing) 146 | { 147 | if (!disposedValue) 148 | { 149 | if (disposing) 150 | { 151 | syncLock.Dispose(); 152 | } 153 | 154 | disposedValue = true; 155 | } 156 | } 157 | 158 | public void Dispose() 159 | { 160 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 161 | Dispose(disposing: true); 162 | GC.SuppressFinalize(this); 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /IntelligentCache/RedisInvalidationReceiver.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace IntelligentHack.IntelligentCache 7 | { 8 | /// 9 | /// Subscribes to invalidation messages on a Redis topic 10 | /// and invalidates its inner cache when a message is received. 11 | /// 12 | public class RedisInvalidationReceiver : ICache 13 | { 14 | private readonly ICache _inner; 15 | private readonly ISubscriber _subscriber; 16 | 17 | /// The cache to invalidate. 18 | /// An ISubscriber that allows subscribing to Redis pubsub messages. 19 | /// The channel the ISubscriber gets invalidation messages from. 20 | public RedisInvalidationReceiver(ICache inner, ISubscriber subscriber, RedisChannel channel) 21 | { 22 | _inner = inner ?? throw new ArgumentNullException(nameof(inner)); 23 | _subscriber = subscriber ?? throw new ArgumentNullException(nameof(subscriber)); 24 | _ = ((string)channel) ?? throw new ArgumentNullException(nameof(channel)); 25 | _subscriber.Subscribe(channel, Pulse); 26 | } 27 | 28 | private void Pulse(RedisChannel channel, RedisValue value) 29 | { 30 | _inner.Invalidate(value); 31 | } 32 | 33 | public T GetSet(string key, Func calculateValue, TimeSpan duration) where T : class 34 | { 35 | return _inner.GetSet(key, calculateValue, duration); 36 | } 37 | 38 | public Task GetSetAsync(string key, Func> calculateValue, TimeSpan duration, CancellationToken cancellationToken = default) where T : class 39 | { 40 | return _inner.GetSetAsync(key, calculateValue, duration); 41 | } 42 | 43 | public void Invalidate(string key) 44 | { 45 | _inner.Invalidate(key); 46 | } 47 | 48 | public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) 49 | { 50 | return _inner.InvalidateAsync(key, cancellationToken); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /IntelligentCache/RedisInvalidationSender.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace IntelligentHack.IntelligentCache 7 | { 8 | /// 9 | /// Publishes invalidation messages to a Redis topic when invalidated. 10 | /// 11 | public class RedisInvalidationSender : ICache 12 | { 13 | private readonly ISubscriber _subscriber; 14 | private readonly RedisChannel _channel; 15 | 16 | /// An ISubscriber that allows publishing Redis pubsub messages. 17 | /// The channel that invalidation messages are published to. 18 | public RedisInvalidationSender(ISubscriber subscriber, RedisChannel channel) 19 | { 20 | _subscriber = subscriber ?? throw new ArgumentNullException(nameof(subscriber)); 21 | _channel = ((string)channel) ?? throw new ArgumentNullException(nameof(channel)); 22 | } 23 | 24 | public T GetSet(string key, Func calculateValue, TimeSpan duration) where T : class 25 | { 26 | return calculateValue(); 27 | } 28 | 29 | public Task GetSetAsync(string key, Func> calculateValue, TimeSpan duration, CancellationToken cancellationToken = default) where T : class 30 | { 31 | return calculateValue(cancellationToken); 32 | } 33 | 34 | public void Invalidate(string key) 35 | { 36 | _subscriber.Publish(_channel, key); 37 | } 38 | 39 | public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) 40 | { 41 | return _subscriber.PublishAsync(_channel, key); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 IntelligentCache and its authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Intelligent Cache 4 | 5 | This package implements a distributed cache monad ("pattern") and currently supports single and multiple layers of caching, in memory and via Redis. 6 | 7 | To use the pattern, you will interact with an object of type ICache, where you can use the following operations: 8 | 9 | ```c# 10 | // Get something from the cache, on cache fail the Func is called to refresh the value and stored 11 | var foo = myCache.GetSet("foo-cache-key", ()=>{ return foo-from-db(); }, Timespan.FromHours(1) ); 12 | 13 | // alternatively 14 | 15 | // Invalidate the cache, in case we've modified foo in the db so the cache is stale 16 | myCache.Invalidate("foo-cache-key"); 17 | 18 | ``` 19 | 20 | The `ICache` object can be of different kinds -- we currently offer a memory cache for local caching and a Redis cache for distributed caching. What makes the pattern a monad is that different caches can be *composed* and this allows seamless multilayer caching. 21 | 22 | For example, to implement a multilayer cache with a local layer and a Redis layer: 23 | 24 | ```c# 25 | var memoryCache = new MemoryCache(/* params */); 26 | var redisCache = new RedisCache(/* params */); 27 | var cache = new CompositeCache(memoryCache, redisCache); 28 | ``` 29 | 30 | Note that this cache does not invalidate correctly in a web farm environment: Invalidations will work on the local server and Redis but not the other web farm webservers. In order to propagate invalidation, we introduced two new composable ICache objects: `RedisInvalidationSender` and `RedisInvalidationReceiver`. 31 | 32 | In order to create a local cache that invalidates when the remote cache is nuked, you can follow this composition pattern: 33 | 34 | ```c# 35 | ISubscriber subscriber = GetRedisSubscriber(); 36 | var invalidationChannel = "cache-invalidations"; 37 | var cache = new CompositeCache( 38 | new RedisInvalidationReceiver( 39 | new MemoryCache(/* arguments */), 40 | subscriber, 41 | invalidationChannel 42 | ), 43 | new CompositeCache( 44 | new RedisCache(/* arguments */), 45 | new RedisInvalidationSender(subscriber, invalidationChannel) 46 | ) 47 | ); 48 | ``` 49 | 50 | Take in account that when a `calculateValue` function returns a `null` value nothing is cached and a `null` value is returned back to the caller. 51 | 52 | # Details 53 | 54 | - [API Documentation](doc/api-documentation.md) 55 | 56 | - [Using with Asp.Net-Core](doc/dotnet-core.md) 57 | 58 | - [Architecture](doc/architecture.md) 59 | 60 | - [Requirements and best practices](doc/best-practices.md) 61 | 62 | # Upgrading from a previous version 63 | 64 | This package follows [semantic versioning](https://semver.org/), which means that upgrading to a higher MINOR or PATCH version should always work. Upgrading to a higher MAJOR version will require code changes. Make sure to read the release notes before upgrading. 65 | 66 | # Contributing 67 | 68 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. 69 | 70 | # License 71 | 72 | This code is released under the MIT license. Please refer to [LICENSE.md](LICENSE.md) for details. 73 | -------------------------------------------------------------------------------- /doc/api-documentation.md: -------------------------------------------------------------------------------- 1 | # API documentation 2 | 3 | ## Reading a value from the cache 4 | 5 | There is a single operation to read from the cache and compute the value in case of a miss, called `GetSet`. It takes a cache key, a callback and a duration. If the cache contains a value for the specified key, that value is returned immediately. Otherwise, the callback is invoked to compute the value, which is then stored into the cache for the specified duration. 6 | 7 | In most cases, caching data is as simple as wrapping the existing code by a call to `GetSet`. 8 | 9 | The following example shows how to get a value from the cache. 10 | 11 | ```c# 12 | ICache cache = ...; // Get the cache from the DI container 13 | string contentId = ...; 14 | string cacheKey = ""; 15 | 16 | var cachedValue = await cache.GetSet(cacheKey, async () => 17 | { 18 | // Read the value from the DB. 19 | // This callback will only be executed if the value was not found in the cache. 20 | using var sqlConnection = OpenConnection(); 21 | return await sqlConnection.QueryFirstOrDefaultAsync( 22 | "select Value from Content where Id = @contentId", 23 | new { contentId } 24 | ); 25 | }, 10); // Keep in cache for 10 seconds 26 | ``` 27 | 28 | The `GetSet` method has multiple overloads to allow different representations of the same parameters, which can be summarized as follows. 29 | 30 | | Name | Description | 31 | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | 32 | | `key` | The key used to lookup the value. This must uniquely identify the content and, in general, should be derived from the identifier of the value. | 33 | | `calculateValue` | A callback that is invoked in case of a cache miss to calculate the value. | 34 | | `duration` / `durationInSeconds` | How long the value should be kept in the cache. If this parameter is omitted, the item never expires. | 35 | | `cancellationToken` | An optional `CancellationToken` to cancel the asynchronous operations. | 36 | 37 | ## Invalidating a value from the cache 38 | 39 | When the source of cached data is modified, it may be desirable to invalidate the corresponding cache entry so that updated content is returned the next time it is requested. This is performed by calling the `Invalidate` method, as shown in the following example. 40 | 41 | ```c# 42 | ICache cache = ...; // Get the cache from the DI container 43 | string contentId = ...; 44 | string newValue = ...; 45 | string cacheKey = ""; 46 | 47 | // Update the database 48 | using var sqlConnection = OpenConnection(); 49 | await sqlConnection.ExecuteAsync( 50 | "update Content set Value = @newValue where Id = @contentId", 51 | new { newValue, contentId } 52 | ); 53 | 54 | // Invalidate the cache 55 | await cache.Invalidate(cacheKey); 56 | ``` 57 | 58 | The `Invalidate` method takes the following parameters: 59 | 60 | | Name | Description | 61 | | ----- | ----------------------------------------------------- | 62 | | `key` | The key that was used previously to lookup the value. | 63 | 64 | ## MemoryCache 65 | 66 | `MemoryCache` is an in-memory implementation that uses `System.Runtime.Caching.MemoryCache` as backing store. 67 | 68 | ```c# 69 | var cache = new MemoryCache("exampleKey"); 70 | ``` 71 | 72 | The `MemoryCache` constructor requires the following parameters: 73 | 74 | | Name | Description | 75 | |-|-| 76 | | `prefix` | This prefix is used to compose the cache key to prevent collisions with other uses of `System.Runtime.Caching.MemoryCache`. A colon (`:`) character is always appended to this value. | 77 | 78 | ## RedisCache 79 | 80 | `RedisCache` is a cache that uses [Redis](https://redis.io/) as backing store. 81 | 82 | ```c# 83 | var connectionMultiplexer = ConnectionMultiplexer.Connect("example_redis_connection_string"); 84 | var cache = new RedisCache(connectionMultiplexer, "exampleKey"); 85 | ``` 86 | 87 | The `RedisCache` constructor requires the following parameters: 88 | 89 | | Name | Description | 90 | |-|-| 91 | | `redis` | An `IConnectionMultiplexer` that mediates access to Redis. | 92 | | `prefix` | This prefix is used to compose the cache key to prevent collisions with other data stored in Redis. A colon (`:`) character is always appended to this value. | 93 | 94 | ### Custom serialization 95 | 96 | Values stored on Redis need to be serialized. By default, they are serialized to JSON. This can be customized by setting the `Serializer` property to a different implementation of `IRedisSerializer`. This library provides an implementation that uses Protobuf. Following is an example of using that serializer. 97 | 98 | ```c# 99 | var cache = new RedisCache(/* arguments */) 100 | { 101 | Serializer = new ProtobufSerializer { CompressionFormat = CompressionFormat.Deflate } 102 | }; 103 | ``` 104 | 105 | ## CompositeCache 106 | 107 | `CompositeCache` composes two caches as a two-level hierarchy. When a lookup is performed, it gives priority to the first level, then only in case of miss is the second level checked. Since CompositeCache is itself a cache, more levels can be created if needed, as shown in the following example. 108 | 109 | ```c# 110 | var multiLevelCache = new CompositeCache( 111 | level1Cache, 112 | new CompositeCache( 113 | level2cache, 114 | level3cache 115 | ) 116 | ); 117 | ``` 118 | 119 | The `CompositeCache` constructor requires the following parameters: 120 | 121 | | Name | Description | 122 | |-|-| 123 | | `level1` | The highest-priority level of the composite cache. | 124 | | `level2` | The lowest-priority level of the composite cache. | 125 | 126 | ## RedisInvalidationSender and RedisInvalidationReceiver 127 | 128 | This pair of classes implement cache invalidation across servers using Redis' pubsub mechanism. 129 | 130 | `RedisInvalidationSender` acts as a cache that broadcasts an invalidation message every time a key is invalidated. 131 | 132 | `RedisInvalidationReceiver` acts as a cache decorator that subscribes to invalidation messages and invalidates its inner cache when one is received. 133 | 134 | The following code shows a possible composition of these components to implement a 2-level cache with a MemoryCache as first level and a RedisCache as second level. 135 | 136 | ```c# 137 | ISubscriber subscriber = GetRedisSubscriber(); 138 | var invalidationChannel = "cache-invalidations"; 139 | var cache = new CompositeCache( 140 | new RedisInvalidationReceiver( 141 | new MemoryCache(/* arguments */), 142 | subscriber, 143 | invalidationChannel 144 | ), 145 | new CompositeCache( 146 | new RedisCache(/* arguments */), 147 | new RedisInvalidationSender(subscriber, invalidationChannel) 148 | ) 149 | ); 150 | ``` 151 | 152 | The `RedisInvalidationSender` constructor requires the following parameters: 153 | 154 | | Name | Description | 155 | |-|-| 156 | | `subscriber` | An ISubscriber that allows publishing Redis pubsub messages. | 157 | | `channel` | The channel where to publish invalidation messages. | 158 | 159 | The `RedisInvalidationReceiver` constructor requires the following parameters: 160 | 161 | | Name | Description | 162 | |-|-| 163 | | `inner` | The cache to invalidate. | 164 | | `subscriber` | An ISubscriber that allows subscribing to Redis pubsub messages. | 165 | | `channel` | The channel to subscribe invalidation messages from. | -------------------------------------------------------------------------------- /doc/best-practices.md: -------------------------------------------------------------------------------- 1 | # Requirements and best practices 2 | 3 | ## Values should be immutable 4 | 5 | Once a value is cached, it should not be modified, since other instances of the application will not see the change. Either make the type immutable or be careful of not modifying it. 6 | 7 | ## Value serialization 8 | 9 | In order to be able to store values on Redis, they need to be serialized. By default, values are serialized using [Json.NET](https://www.newtonsoft.com/json). Therefore you must make sure that all the values that are stored into the cache can be serialized in that way. 10 | 11 | As mentioned before, it is possible to customize the serialization by setting an implementation of `IRedisSerializer` to the `Serializer` property on `RedisCache` class. 12 | 13 | Since values can be stored in Redis for a long period of time, it is important to be careful when changing type of the values that are cached. Any property that is added, modified or renamed may cause incomplete data to be retrieved. After making such change, any content that was previously cached on Redis will be incomplete. The simplest way to solve this problem is to clear the Redis cache. The following command will delete everything with the `cache:` prefix: 14 | 15 | ```bash 16 | redis-cli --scan --pattern "cache:*" | xargs redis-cli del 17 | ``` -------------------------------------------------------------------------------- /doc/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intelligenthack/intelligentcache/36a803afaf6791f6d04418bd4261aa9fb06b1e18/doc/icon.png -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intelligenthack/intelligentcache/36a803afaf6791f6d04418bd4261aa9fb06b1e18/doc/logo.png --------------------------------------------------------------------------------