├── .devcontainer ├── devcontainer.json └── docker-compose.yml ├── .editorconfig ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .vscode └── tasks.json ├── L1L2RedisCache.sln ├── LICENSE ├── README.md ├── src ├── CacheMessage.cs ├── Configuration │ ├── IMessagingConfigurationVerifier.cs │ ├── MessagingConfigurationVerifier.cs │ └── ServiceCollectionExtensions.cs ├── HashEntryArrayExtensions.cs ├── L1L2RedisCache.cs ├── L1L2RedisCache.csproj ├── L1L2RedisCacheLoggerExtensions.cs ├── L1L2RedisCacheOptions.cs ├── Messaging │ ├── DefaultMessagePublisher.cs │ ├── DefaultMessageSubscriber.cs │ ├── IMessagePublisher.cs │ ├── IMessageSubscriber.cs │ ├── KeyeventMessageSubscriber.cs │ ├── KeyspaceMessageSubscriber.cs │ ├── NopMessagePublisher.cs │ └── OnMessageEventArgs.cs └── MessagingType.cs └── tests ├── System ├── Benchmark │ ├── BenchmarkBase.cs │ ├── GetBenchmark.cs │ └── SetBenchmark.cs ├── L1L2RedisCache.Tests.System.csproj ├── MessagingTests.cs ├── PerformanceTests.cs ├── ReliabilityTests.cs └── appsettings.json └── Unit ├── L1L2RedisCache.Tests.Unit.csproj └── L1L2RedisCacheTests.cs /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "dockerComposeFile": "docker-compose.yml", 3 | "customizations": 4 | { 5 | "vscode": 6 | { 7 | "extensions": 8 | [ 9 | "ms-dotnettools.csdevkit", 10 | "ue.alphabetical-sorter" 11 | ], 12 | "settings": 13 | { 14 | "remote.autoForwardPorts": false 15 | } 16 | } 17 | }, 18 | "forwardPorts": 19 | [ 20 | "redis:6379" 21 | ], 22 | "name": "L1L2RedisCache", 23 | "postCreateCommand": "dotnet dev-certs https", 24 | "remoteUser": "root", 25 | "service": "devcontainer", 26 | "shutdownAction": "stopCompose", 27 | "workspaceFolder": "/workspace" 28 | } 29 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | devcontainer: 3 | image: mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm 4 | volumes: 5 | - ..:/workspace:cached 6 | command: sleep infinity 7 | redis: 8 | image: redis -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{cs,vb}] 2 | 3 | dotnet_diagnostic.severity = warning 4 | dotnet_diagnostic.CS1591.severity = none 5 | dotnet_diagnostic.IDE0001.severity = warning 6 | dotnet_diagnostic.IDE0007.severity = warning 7 | dotnet_diagnostic.IDE0008.severity = none 8 | dotnet_diagnostic.IDE0010.severity = none 9 | dotnet_diagnostic.IDE0028.severity = warning 10 | dotnet_diagnostic.IDE0055.severity = none 11 | dotnet_diagnostic.IDE0058.severity = none 12 | dotnet_diagnostic.IDE0090.severity = warning 13 | dotnet_diagnostic.IDE0160.severity = none 14 | dotnet_diagnostic.IDE0161.severity = warning 15 | dotnet_diagnostic.IDE0290.severity = warning 16 | dotnet_diagnostic.IDE0300.severity = warning 17 | dotnet_diagnostic.IDE0301.severity = warning 18 | dotnet_diagnostic.IDE0303.severity = warning 19 | dotnet_diagnostic.IDE0305.severity = warning 20 | 21 | csharp_space_between_square_brackets = true 22 | csharp_style_var_elsewhere = true:warning 23 | csharp_style_var_for_built_in_types = true:warning 24 | csharp_style_var_when_type_is_apparent = true:warning 25 | dotnet_style_namespace_match_folder = false 26 | dotnet_style_prefer_conditional_expression_over_return = false 27 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Test 11 | uses: devcontainers/ci@v0.3 12 | with: 13 | push: never 14 | runCmd: dotnet test 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Test 14 | uses: devcontainers/ci@v0.3 15 | with: 16 | push: never 17 | runCmd: dotnet test 18 | publish: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Version 23 | run: echo "VERSION=${GITHUB_REF:10}+${GITHUB_SHA::8}" >> $GITHUB_ENV 24 | - name: Setup 25 | uses: actions/setup-dotnet@v4 26 | with: 27 | dotnet-version: 9 28 | - name: Build 29 | run: dotnet build -c Release 30 | - name: Publish 31 | run: dotnet nuget push "**/*.nupkg" -k ${{ secrets.NUGET_KEY }} -n -s https://api.nuget.org/v3/index.json --skip-duplicate -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Ll]og/ 27 | 28 | # Visual Studio 2015/2017 cache/options directory 29 | .vs/ 30 | # Uncomment if you have tasks that create the project's static files in wwwroot 31 | #wwwroot/ 32 | 33 | # Visual Studio 2017 auto generated files 34 | Generated\ Files/ 35 | 36 | # MSTest test Results 37 | [Tt]est[Rr]esult*/ 38 | [Bb]uild[Ll]og.* 39 | 40 | # NUNIT 41 | *.VisualState.xml 42 | TestResult.xml 43 | 44 | # Build Results of an ATL Project 45 | [Dd]ebugPS/ 46 | [Rr]eleasePS/ 47 | dlldata.c 48 | 49 | # Benchmark Results 50 | BenchmarkDotNet.Artifacts/ 51 | 52 | # .NET Core 53 | project.lock.json 54 | project.fragment.lock.json 55 | artifacts/ 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_h.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *_wpftmp.csproj 81 | *.log 82 | *.vspscc 83 | *.vssscc 84 | .builds 85 | *.pidb 86 | *.svclog 87 | *.scc 88 | 89 | # Chutzpah Test files 90 | _Chutzpah* 91 | 92 | # Visual C++ cache files 93 | ipch/ 94 | *.aps 95 | *.ncb 96 | *.opendb 97 | *.opensdf 98 | *.sdf 99 | *.cachefile 100 | *.VC.db 101 | *.VC.VC.opendb 102 | 103 | # Visual Studio profiler 104 | *.psess 105 | *.vsp 106 | *.vspx 107 | *.sap 108 | 109 | # Visual Studio Trace Files 110 | *.e2e 111 | 112 | # TFS 2012 Local Workspace 113 | $tf/ 114 | 115 | # Guidance Automation Toolkit 116 | *.gpState 117 | 118 | # ReSharper is a .NET coding add-in 119 | _ReSharper*/ 120 | *.[Rr]e[Ss]harper 121 | *.DotSettings.user 122 | 123 | # JustCode is a .NET coding add-in 124 | .JustCode 125 | 126 | # TeamCity is a build add-in 127 | _TeamCity* 128 | 129 | # DotCover is a Code Coverage Tool 130 | *.dotCover 131 | 132 | # AxoCover is a Code Coverage Tool 133 | .axoCover/* 134 | !.axoCover/settings.json 135 | 136 | # Visual Studio code coverage results 137 | *.coverage 138 | *.coveragexml 139 | 140 | # NCrunch 141 | _NCrunch_* 142 | .*crunch*.local.xml 143 | nCrunchTemp_* 144 | 145 | # MightyMoose 146 | *.mm.* 147 | AutoTest.Net/ 148 | 149 | # Web workbench (sass) 150 | .sass-cache/ 151 | 152 | # Installshield output folder 153 | [Ee]xpress/ 154 | 155 | # DocProject is a documentation generator add-in 156 | DocProject/buildhelp/ 157 | DocProject/Help/*.HxT 158 | DocProject/Help/*.HxC 159 | DocProject/Help/*.hhc 160 | DocProject/Help/*.hhk 161 | DocProject/Help/*.hhp 162 | DocProject/Help/Html2 163 | DocProject/Help/html 164 | 165 | # Click-Once directory 166 | publish/ 167 | 168 | # Publish Web Output 169 | *.[Pp]ublish.xml 170 | *.azurePubxml 171 | # Note: Comment the next line if you want to checkin your web deploy settings, 172 | # but database connection strings (with potential passwords) will be unencrypted 173 | *.pubxml 174 | *.publishproj 175 | 176 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 177 | # checkin your Azure Web App publish settings, but sensitive information contained 178 | # in these scripts will be unencrypted 179 | PublishScripts/ 180 | 181 | # NuGet Packages 182 | *.nupkg 183 | # The packages folder can be ignored because of Package Restore 184 | **/[Pp]ackages/* 185 | # except build/, which is used as an MSBuild target. 186 | !**/[Pp]ackages/build/ 187 | # Uncomment if necessary however generally it will be regenerated when needed 188 | #!**/[Pp]ackages/repositories.config 189 | # NuGet v3's project.json files produces more ignorable files 190 | *.nuget.props 191 | *.nuget.targets 192 | 193 | # Microsoft Azure Build Output 194 | csx/ 195 | *.build.csdef 196 | 197 | # Microsoft Azure Emulator 198 | ecf/ 199 | rcf/ 200 | 201 | # Windows Store app package directories and files 202 | AppPackages/ 203 | BundleArtifacts/ 204 | Package.StoreAssociation.xml 205 | _pkginfo.txt 206 | *.appx 207 | 208 | # Visual Studio cache files 209 | # files ending in .cache can be ignored 210 | *.[Cc]ache 211 | # but keep track of directories ending in .cache 212 | !*.[Cc]ache/ 213 | 214 | # Others 215 | ClientBin/ 216 | ~$* 217 | *~ 218 | *.dbmdl 219 | *.dbproj.schemaview 220 | *.jfm 221 | *.pfx 222 | *.publishsettings 223 | orleans.codegen.cs 224 | 225 | # Including strong name files can present a security risk 226 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 227 | #*.snk 228 | 229 | # Since there are multiple workflows, uncomment next line to ignore bower_components 230 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 231 | #bower_components/ 232 | 233 | # RIA/Silverlight projects 234 | Generated_Code/ 235 | 236 | # Backup & report files from converting an old project file 237 | # to a newer Visual Studio version. Backup files are not needed, 238 | # because we have git ;-) 239 | _UpgradeReport_Files/ 240 | Backup*/ 241 | UpgradeLog*.XML 242 | UpgradeLog*.htm 243 | ServiceFabricBackup/ 244 | *.rptproj.bak 245 | 246 | # SQL Server files 247 | *.mdf 248 | *.ldf 249 | *.ndf 250 | 251 | # Business Intelligence projects 252 | *.rdl.data 253 | *.bim.layout 254 | *.bim_*.settings 255 | *.rptproj.rsuser 256 | 257 | # Microsoft Fakes 258 | FakesAssemblies/ 259 | 260 | # GhostDoc plugin setting file 261 | *.GhostDoc.xml 262 | 263 | # Node.js Tools for Visual Studio 264 | .ntvs_analysis.dat 265 | node_modules/ 266 | 267 | # Visual Studio 6 build log 268 | *.plg 269 | 270 | # Visual Studio 6 workspace options file 271 | *.opt 272 | 273 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 274 | *.vbw 275 | 276 | # Visual Studio LightSwitch build output 277 | **/*.HTMLClient/GeneratedArtifacts 278 | **/*.DesktopClient/GeneratedArtifacts 279 | **/*.DesktopClient/ModelManifest.xml 280 | **/*.Server/GeneratedArtifacts 281 | **/*.Server/ModelManifest.xml 282 | _Pvt_Extensions 283 | 284 | # Paket dependency manager 285 | .paket/paket.exe 286 | paket-files/ 287 | 288 | # FAKE - F# Make 289 | .fake/ 290 | 291 | # JetBrains Rider 292 | .idea/ 293 | *.sln.iml 294 | 295 | # CodeRush personal settings 296 | .cr/personal 297 | 298 | # Python Tools for Visual Studio (PTVS) 299 | __pycache__/ 300 | *.pyc 301 | 302 | # Cake - Uncomment if you are using it 303 | # tools/** 304 | # !tools/packages.config 305 | 306 | # Tabs Studio 307 | *.tss 308 | 309 | # Telerik's JustMock configuration file 310 | *.jmconfig 311 | 312 | # BizTalk build output 313 | *.btp.cs 314 | *.btm.cs 315 | *.odx.cs 316 | *.xsd.cs 317 | 318 | # OpenCover UI analysis results 319 | OpenCover/ 320 | 321 | # Azure Stream Analytics local run output 322 | ASALocalRun/ 323 | 324 | # MSBuild Binary and Structured Log 325 | *.binlog 326 | 327 | # NVidia Nsight GPU debugger configuration file 328 | *.nvuser 329 | 330 | # MFractors (Xamarin productivity tool) working folder 331 | .mfractor/ 332 | 333 | # Local History for Visual Studio 334 | .localhistory/ 335 | 336 | .mono/ -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": 4 | [ 5 | { 6 | "label": "build", 7 | "command": "dotnet", 8 | "type": "shell", 9 | "args": 10 | [ 11 | "build", 12 | "/property:GenerateFullPaths=true", 13 | "/consoleloggerparameters:NoSummary" 14 | ], 15 | "group": 16 | { 17 | "isDefault": true, 18 | "kind": "build", 19 | }, 20 | "presentation": 21 | { 22 | "reveal": "silent" 23 | }, 24 | "problemMatcher": "$msCompile" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /L1L2RedisCache.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache", "src\L1L2RedisCache.csproj", "{71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{79FE29CD-A4E5-46BB-9FC8-5EC921CFE5F3}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache.Tests.Unit", "tests\Unit\L1L2RedisCache.Tests.Unit.csproj", "{8791FCF7-078D-44A5-AC59-C7C2CE469D3F}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache.Tests.System", "tests\System\L1L2RedisCache.Tests.System.csproj", "{6A825E82-5BF4-43A0-BA08-9CB000FB232A}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Debug|x64 = Debug|x64 18 | Debug|x86 = Debug|x86 19 | Release|Any CPU = Release|Any CPU 20 | Release|x64 = Release|x64 21 | Release|x86 = Release|x86 22 | EndGlobalSection 23 | GlobalSection(SolutionProperties) = preSolution 24 | HideSolutionNode = FALSE 25 | EndGlobalSection 26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 27 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x64.ActiveCfg = Debug|Any CPU 30 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x64.Build.0 = Debug|Any CPU 31 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x86.ActiveCfg = Debug|Any CPU 32 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x86.Build.0 = Debug|Any CPU 33 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|Any CPU.Build.0 = Release|Any CPU 35 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x64.ActiveCfg = Release|Any CPU 36 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x64.Build.0 = Release|Any CPU 37 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x86.ActiveCfg = Release|Any CPU 38 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x86.Build.0 = Release|Any CPU 39 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x64.ActiveCfg = Debug|Any CPU 42 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x64.Build.0 = Debug|Any CPU 43 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x86.ActiveCfg = Debug|Any CPU 44 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x86.Build.0 = Debug|Any CPU 45 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x64.ActiveCfg = Release|Any CPU 48 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x64.Build.0 = Release|Any CPU 49 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x86.ActiveCfg = Release|Any CPU 50 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x86.Build.0 = Release|Any CPU 51 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 52 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|Any CPU.Build.0 = Debug|Any CPU 53 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x64.ActiveCfg = Debug|Any CPU 54 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x64.Build.0 = Debug|Any CPU 55 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x86.ActiveCfg = Debug|Any CPU 56 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x86.Build.0 = Debug|Any CPU 57 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|Any CPU.ActiveCfg = Release|Any CPU 58 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|Any CPU.Build.0 = Release|Any CPU 59 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x64.ActiveCfg = Release|Any CPU 60 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x64.Build.0 = Release|Any CPU 61 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x86.ActiveCfg = Release|Any CPU 62 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x86.Build.0 = Release|Any CPU 63 | EndGlobalSection 64 | GlobalSection(NestedProjects) = preSolution 65 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A} = {79FE29CD-A4E5-46BB-9FC8-5EC921CFE5F3} 66 | EndGlobalSection 67 | EndGlobal 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # L1L2RedisCache 2 | 3 | `L1L2RedisCache` is an implementation of [`IDistributedCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IDistributedCache.cs) with a strong focus on performance. It leverages [`IMemoryCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IMemoryCache.cs) as a level 1 cache and [`RedisCache`](https://github.com/dotnet/aspnetcore/blob/main/src/Caching/StackExchangeRedis/src/RedisCache.cs) as a level 2 cache, with level 1 evictions being managed via [Redis pub/sub](https://redis.io/topics/pubsub). 4 | 5 | `L1L2RedisCache` is heavily inspired by development insights provided over the past several years by [StackOverflow](https://stackoverflow.com/). It attempts to simplify those concepts into a highly accessible `IDistributedCache` implementation that is more performant. 6 | 7 | I expect to gracefully decomission this project when [`StackExchange.Redis`](https://github.com/StackExchange/StackExchange.Redis) has [client-side caching](https://redis.io/docs/latest/develop/use/client-side-caching/) support. 8 | 9 | ## Configuration 10 | 11 | It is intended that L1L12RedisCache be used as an `IDistributedCache` implementation. 12 | 13 | `L1L2RedisCache` can be registered during startup with the following `IServiceCollection` extension method: 14 | 15 | ``` 16 | services.AddL1L2RedisCache(options => 17 | { 18 | options.Configuration = "localhost"; 19 | options.InstanceName = "Namespace:Prefix:"; 20 | }); 21 | ``` 22 | 23 | `L1L2RedisCache` options are an extension of the standard `RedisCache` [`RedisCacheOptions`](https://github.com/dotnet/aspnetcore/blob/main/src/Caching/StackExchangeRedis/src/RedisCacheOptions.cs). The following additional customizations are supported: 24 | 25 | ### MessagingType 26 | 27 | The type of messaging system to use for L1 memory cache eviction. 28 | 29 | | MessagingType | Description | Suggestion | 30 | | - | - | - | 31 | | `Default` | Use standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages for L1 memory cache eviction. | Default behavior. The Redis server requires no additional configuration. | 32 | | `KeyeventNotifications` | Use [keyevent notifications](https://redis.io/topics/notifications) for L1 memory eviction instead of standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. The Redis server must have keyevent notifications enabled. | This is only advisable if the Redis server is already using [keyevent notifications](https://redis.io/topics/notifications) with at least a `ghE` configuration and the majority of keys in the server are managed by `L1L2RedisCache`. | 33 | | `KeyspaceNotifications` | Use [keyspace notifications](https://redis.io/topics/notifications) for L1 memory eviction instead of standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. The Redis server must have keyspace notifications enabled. | This is only advisable if the Redis server is already using [keyevent notifications](https://redis.io/topics/notifications) with at least a `ghK` configuration and the majority of keys in the server are managed by `L1L2RedisCache`. | 34 | 35 | ## Performance 36 | 37 | L1L2RedisCache will generally outperform `RedisCache`, especially in cases of high volume or large cache entries. As entries are opportunistically pulled from memory instead of Redis, costs of latency, network, and Redis operations are avoided. Respective performance gains will rely heavily on the impact of afforementioned factors. 38 | 39 | ## Considerations 40 | 41 | Due to the complex nature of a distributed L1 memory cache, cache entries with sliding expirations are only stored in L2 (Redis). These entries will show no performance improvement over the standard `RedisCache`, but incur no performance penalty. 42 | -------------------------------------------------------------------------------- /src/CacheMessage.cs: -------------------------------------------------------------------------------- 1 | namespace L1L2RedisCache; 2 | 3 | /// 4 | /// A Redis pub/sub message indicating a cache value has changed. 5 | /// 6 | public class CacheMessage 7 | { 8 | /// 9 | /// The cache key of the value that has changed. 10 | /// 11 | public string Key { get; set; } = default!; 12 | 13 | /// 14 | /// The unique publisher identifier of the cache that changed the value. 15 | /// 16 | public Guid PublisherId { get; set; } 17 | } 18 | -------------------------------------------------------------------------------- /src/Configuration/IMessagingConfigurationVerifier.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | 3 | namespace L1L2RedisCache; 4 | 5 | /// 6 | /// Verifies Redis configuration settings. 7 | /// 8 | public interface IMessagingConfigurationVerifier 9 | { 10 | /// 11 | /// Verifies Redis configuration values. 12 | /// 13 | /// The StackExchange.Redis.IDatabase for configuration values. 14 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. 15 | Task VerifyConfigurationAsync( 16 | IDatabase database, 17 | CancellationToken cancellationToken = default); 18 | } 19 | -------------------------------------------------------------------------------- /src/Configuration/MessagingConfigurationVerifier.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using StackExchange.Redis; 3 | 4 | namespace L1L2RedisCache; 5 | 6 | internal sealed class MessagingConfigurationVerifier( 7 | IOptions l1L2RedisCacheOptionsOptionsAccessor) : 8 | IMessagingConfigurationVerifier 9 | { 10 | private const string config = "notify-keyspace-events"; 11 | 12 | static MessagingConfigurationVerifier() 13 | { 14 | NotifyKeyspaceEventsConfig = new Dictionary 15 | { 16 | { MessagingType.Default, string.Empty }, 17 | { MessagingType.KeyeventNotifications, "ghE" }, 18 | { MessagingType.KeyspaceNotifications, "ghK" }, 19 | }; 20 | } 21 | 22 | internal static IDictionary NotifyKeyspaceEventsConfig { get; } 23 | 24 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; } = 25 | l1L2RedisCacheOptionsOptionsAccessor.Value; 26 | 27 | public async Task VerifyConfigurationAsync( 28 | IDatabase database, 29 | CancellationToken _ = default) 30 | { 31 | var isVerified = NotifyKeyspaceEventsConfig 32 | .TryGetValue( 33 | L1L2RedisCacheOptions.MessagingType, 34 | out var expectedValues); 35 | 36 | var configValue = (await database 37 | .ExecuteAsync( 38 | "config", 39 | "get", 40 | config) 41 | .ConfigureAwait(false)) 42 | .ToDictionary()[config] 43 | .ToString(); 44 | 45 | if (expectedValues != null) 46 | { 47 | foreach (var expectedValue in expectedValues) 48 | { 49 | if (configValue?.Contains( 50 | expectedValue, 51 | StringComparison.Ordinal) != true) 52 | { 53 | isVerified = false; 54 | } 55 | } 56 | } 57 | 58 | return isVerified; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Configuration/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using L1L2RedisCache; 2 | using Microsoft.Extensions.Caching.Distributed; 3 | using Microsoft.Extensions.Caching.StackExchangeRedis; 4 | using Microsoft.Extensions.Options; 5 | using StackExchange.Redis; 6 | 7 | namespace Microsoft.Extensions.DependencyInjection; 8 | 9 | /// 10 | /// Extension methods for setting up L1L2RedisCache related services in an Microsoft.Extensions.DependencyInjection.IServiceCollection. 11 | /// 12 | public static class ServiceCollectionExtensions 13 | { 14 | /// 15 | /// Adds L1L2RedisCache distributed caching services to the specified IServiceCollection. 16 | /// 17 | /// The IServiceCollection so that additional calls can be chained. 18 | public static IServiceCollection AddL1L2RedisCache( 19 | this IServiceCollection services, 20 | Action setupAction) 21 | { 22 | if (setupAction == null) 23 | { 24 | throw new ArgumentNullException( 25 | nameof(setupAction)); 26 | } 27 | 28 | var l1L2RedisCacheOptions = new L1L2RedisCacheOptions(); 29 | setupAction.Invoke(l1L2RedisCacheOptions); 30 | 31 | services.AddOptions(); 32 | services.Configure(setupAction); 33 | services.Configure( 34 | (options) => 35 | { 36 | if (options.ConnectionMultiplexerFactory == null) 37 | { 38 | if (options.ConfigurationOptions != null) 39 | { 40 | options.ConnectionMultiplexerFactory = () => 41 | Task.FromResult( 42 | ConnectionMultiplexer.Connect( 43 | options.ConfigurationOptions) as IConnectionMultiplexer); 44 | } 45 | else if (!string.IsNullOrEmpty(options.Configuration)) 46 | { 47 | options.ConnectionMultiplexerFactory = () => 48 | Task.FromResult( 49 | ConnectionMultiplexer.Connect( 50 | options.Configuration) as IConnectionMultiplexer); 51 | } 52 | } 53 | }); 54 | services.AddMemoryCache(); 55 | services.AddSingleton( 56 | serviceProvider => new Func( 57 | () => new RedisCache( 58 | serviceProvider.GetRequiredService>()))); 59 | services.AddSingleton(); 60 | services.AddSingleton(); 61 | 62 | services.AddSingleton(); 63 | services.AddSingleton(); 64 | services.AddSingleton( 65 | serviceProvider => 66 | { 67 | var options = serviceProvider 68 | .GetRequiredService>() 69 | .Value; 70 | 71 | return options.MessagingType switch 72 | { 73 | MessagingType.Default => 74 | serviceProvider.GetRequiredService(), 75 | _ => 76 | serviceProvider.GetRequiredService(), 77 | }; 78 | }); 79 | 80 | services.AddSingleton(); 81 | services.AddSingleton(); 82 | services.AddSingleton(); 83 | services.AddSingleton( 84 | serviceProvider => 85 | { 86 | var options = serviceProvider 87 | .GetRequiredService>() 88 | .Value; 89 | 90 | return options.MessagingType switch 91 | { 92 | MessagingType.Default => 93 | serviceProvider.GetRequiredService(), 94 | MessagingType.KeyeventNotifications => 95 | serviceProvider.GetRequiredService(), 96 | MessagingType.KeyspaceNotifications => 97 | serviceProvider.GetRequiredService(), 98 | _ => throw new NotImplementedException(), 99 | }; 100 | }); 101 | 102 | return services; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/HashEntryArrayExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Distributed; 2 | using StackExchange.Redis; 3 | 4 | namespace L1L2RedisCache; 5 | 6 | internal static class HashEntryArrayExtensions 7 | { 8 | private const string AbsoluteExpirationKey = "absexp"; 9 | private const string DataKey = "data"; 10 | private const long NotPresent = -1; 11 | private const string SlidingExpirationKey = "sldexp"; 12 | 13 | internal static DistributedCacheEntryOptions GetDistributedCacheEntryOptions( 14 | this HashEntry[] hashEntries) 15 | { 16 | var distributedCacheEntryOptions = new DistributedCacheEntryOptions(); 17 | 18 | var absoluteExpirationHashEntry = hashEntries.FirstOrDefault( 19 | hashEntry => hashEntry.Name == AbsoluteExpirationKey); 20 | if (absoluteExpirationHashEntry.Value.HasValue && 21 | absoluteExpirationHashEntry.Value != NotPresent) 22 | { 23 | distributedCacheEntryOptions.AbsoluteExpiration = new DateTimeOffset( 24 | (long)absoluteExpirationHashEntry.Value, TimeSpan.Zero); 25 | } 26 | 27 | var slidingExpirationHashEntry = hashEntries.FirstOrDefault( 28 | hashEntry => hashEntry.Name == SlidingExpirationKey); 29 | if (slidingExpirationHashEntry.Value.HasValue && 30 | slidingExpirationHashEntry.Value != NotPresent) 31 | { 32 | distributedCacheEntryOptions.SlidingExpiration = new TimeSpan( 33 | (long)slidingExpirationHashEntry.Value); 34 | } 35 | 36 | return distributedCacheEntryOptions; 37 | } 38 | 39 | internal static RedisValue GetRedisValue( 40 | this HashEntry[] hashEntries) 41 | { 42 | var dataHashEntry = hashEntries.FirstOrDefault( 43 | hashEntry => hashEntry.Name == DataKey); 44 | 45 | return dataHashEntry.Value; 46 | } 47 | } -------------------------------------------------------------------------------- /src/L1L2RedisCache.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Distributed; 2 | using Microsoft.Extensions.Caching.Memory; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Extensions.Logging.Abstractions; 5 | using Microsoft.Extensions.Options; 6 | using StackExchange.Redis; 7 | 8 | namespace L1L2RedisCache; 9 | 10 | /// 11 | /// A distributed cache implementation using both memory and Redis. 12 | /// 13 | public sealed class L1L2RedisCache : 14 | IDisposable, 15 | IDistributedCache 16 | { 17 | /// 18 | /// Initializes a new instance of L1L2RedisCache. 19 | /// 20 | public L1L2RedisCache( 21 | IMemoryCache l1Cache, 22 | IOptions l1l2RedisCacheOptionsAccessor, 23 | Func l2CacheAccessor, 24 | IMessagePublisher messagePublisher, 25 | IMessageSubscriber messageSubscriber, 26 | IMessagingConfigurationVerifier messagingConfigurationVerifier, 27 | ILogger? logger = null) 28 | { 29 | L1Cache = l1Cache ?? 30 | throw new ArgumentNullException( 31 | nameof(l1Cache)); 32 | L1L2RedisCacheOptions = l1l2RedisCacheOptionsAccessor?.Value ?? 33 | throw new ArgumentNullException( 34 | nameof(l1l2RedisCacheOptionsAccessor)); 35 | L2Cache = l2CacheAccessor?.Invoke() ?? 36 | throw new ArgumentNullException( 37 | nameof(l2CacheAccessor)); 38 | Logger = logger ?? 39 | NullLogger.Instance; 40 | MessagePublisher = messagePublisher ?? 41 | throw new ArgumentNullException( 42 | nameof(messagePublisher)); 43 | MessageSubscriber = messageSubscriber ?? 44 | throw new ArgumentNullException( 45 | nameof(messageSubscriber)); 46 | MessagingConfigurationVerifier = messagingConfigurationVerifier ?? 47 | throw new ArgumentNullException( 48 | nameof(l1l2RedisCacheOptionsAccessor)); 49 | 50 | Database = new Lazy(() => 51 | L1L2RedisCacheOptions 52 | .ConnectionMultiplexerFactory!() 53 | .GetAwaiter() 54 | .GetResult() 55 | .GetDatabase( 56 | L1L2RedisCacheOptions 57 | .ConfigurationOptions? 58 | .DefaultDatabase ?? -1)); 59 | 60 | SubscribeCancellationTokenSource = new CancellationTokenSource(); 61 | _ = SubscribeAsync( 62 | SubscribeCancellationTokenSource.Token); 63 | } 64 | 65 | private static SemaphoreSlim KeySemaphore { get; } = 66 | new SemaphoreSlim(1, 1); 67 | 68 | /// 69 | /// The StackExchange.Redis.IDatabase for the . 70 | /// 71 | public Lazy Database { get; } 72 | 73 | /// 74 | /// The IMemoryCache for L1. 75 | /// 76 | public IMemoryCache L1Cache { get; } 77 | 78 | /// 79 | /// Configuration options. 80 | /// 81 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; } 82 | 83 | /// 84 | /// The IDistributedCache for L2. 85 | /// 86 | public IDistributedCache L2Cache { get; } 87 | 88 | /// 89 | /// Optional. The logger. 90 | /// 91 | public ILogger Logger { get; } 92 | 93 | /// 94 | /// The pub/sub publisher. 95 | /// 96 | public IMessagePublisher MessagePublisher { get; } 97 | 98 | /// 99 | /// The pub/sub subscriber. 100 | /// 101 | public IMessageSubscriber MessageSubscriber { get; } 102 | 103 | /// 104 | /// The messaging configuration verifier. 105 | /// 106 | public IMessagingConfigurationVerifier MessagingConfigurationVerifier { get; } 107 | 108 | private bool IsDisposed { get; set; } 109 | private CancellationTokenSource SubscribeCancellationTokenSource { get; set; } 110 | 111 | /// 112 | /// Releases all resources used by the current instance. 113 | /// 114 | public void Dispose() 115 | { 116 | Dispose(true); 117 | GC.SuppressFinalize(this); 118 | } 119 | 120 | /// 121 | /// Gets a value with the given key. 122 | /// 123 | /// A string identifying the requested value. 124 | /// The located value or null. 125 | public byte[]? Get(string key) 126 | { 127 | var value = L1Cache.Get( 128 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}") as byte[]; 129 | 130 | if (value == null) 131 | { 132 | if (Database.Value.KeyExists( 133 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}")) 134 | { 135 | var semaphore = GetOrCreateLock( 136 | key, 137 | null); 138 | semaphore.Wait(); 139 | try 140 | { 141 | var hashEntries = GetHashEntries(key); 142 | var distributedCacheEntryOptions = hashEntries 143 | .GetDistributedCacheEntryOptions(); 144 | value = hashEntries.GetRedisValue(); 145 | 146 | SetMemoryCache( 147 | key, 148 | value!, 149 | distributedCacheEntryOptions); 150 | SetLock( 151 | key, 152 | semaphore, 153 | distributedCacheEntryOptions); 154 | } 155 | finally 156 | { 157 | semaphore.Release(); 158 | } 159 | } 160 | } 161 | 162 | return value; 163 | } 164 | 165 | /// 166 | /// Gets a value with the given key. 167 | /// 168 | /// A string identifying the requested value. 169 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. 170 | /// The System.Threading.Tasks.Task that represents the asynchronous operation, containing the located value or null. 171 | public async Task GetAsync( 172 | string key, 173 | CancellationToken token = default) 174 | { 175 | var value = L1Cache.Get( 176 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}") as byte[]; 177 | 178 | if (value == null) 179 | { 180 | if (await Database.Value 181 | .KeyExistsAsync( 182 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}") 183 | .ConfigureAwait(false)) 184 | { 185 | var semaphore = await GetOrCreateLockAsync( 186 | key, 187 | null, 188 | token) 189 | .ConfigureAwait(false); 190 | await semaphore 191 | .WaitAsync(token) 192 | .ConfigureAwait(false); 193 | try 194 | { 195 | var hashEntries = await GetHashEntriesAsync(key) 196 | .ConfigureAwait(false); 197 | var distributedCacheEntryOptions = hashEntries 198 | .GetDistributedCacheEntryOptions(); 199 | value = hashEntries.GetRedisValue(); 200 | 201 | SetMemoryCache( 202 | key, 203 | value!, 204 | distributedCacheEntryOptions); 205 | SetLock( 206 | key, 207 | semaphore, 208 | distributedCacheEntryOptions); 209 | } 210 | finally 211 | { 212 | semaphore.Release(); 213 | } 214 | } 215 | } 216 | 217 | return value; 218 | } 219 | 220 | /// 221 | /// Refreshes a value in the cache based on its key, resetting its sliding expiration timeout (if any). 222 | /// 223 | /// A string identifying the requested value. 224 | public void Refresh(string key) 225 | { 226 | L2Cache.Refresh(key); 227 | } 228 | 229 | /// 230 | /// Refreshes a value in the cache based on its key, resetting its sliding expiration timeout (if any). 231 | /// 232 | /// A string identifying the requested value. 233 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. 234 | /// The System.Threading.Tasks.Task that represents the asynchronous operation. 235 | public async Task RefreshAsync( 236 | string key, 237 | CancellationToken token = default) 238 | { 239 | await L2Cache 240 | .RefreshAsync(key, token) 241 | .ConfigureAwait(false); 242 | } 243 | 244 | /// 245 | /// Removes the value with the given key. 246 | /// 247 | /// A string identifying the requested value. 248 | public void Remove(string key) 249 | { 250 | var semaphore = GetOrCreateLock(key, null); 251 | semaphore.Wait(); 252 | try 253 | { 254 | L2Cache.Remove(key); 255 | L1Cache.Remove( 256 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); 257 | MessagePublisher.Publish( 258 | Database.Value.Multiplexer, 259 | key); 260 | L1Cache.Remove( 261 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}"); 262 | } 263 | finally 264 | { 265 | semaphore.Release(); 266 | } 267 | } 268 | 269 | /// 270 | /// Removes the value with the given key. 271 | /// 272 | /// A string identifying the requested value. 273 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. 274 | /// The System.Threading.Tasks.Task that represents the asynchronous operation. 275 | public async Task RemoveAsync( 276 | string key, 277 | CancellationToken token = default) 278 | { 279 | var semaphore = await GetOrCreateLockAsync( 280 | key, 281 | null, 282 | token) 283 | .ConfigureAwait(false); 284 | await semaphore 285 | .WaitAsync(token) 286 | .ConfigureAwait(false); 287 | try 288 | { 289 | await L2Cache 290 | .RemoveAsync(key, token) 291 | .ConfigureAwait(false); 292 | L1Cache.Remove( 293 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); 294 | await MessagePublisher 295 | .PublishAsync( 296 | Database.Value.Multiplexer, 297 | key, 298 | token) 299 | .ConfigureAwait(false); 300 | L1Cache.Remove( 301 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}"); 302 | } 303 | finally 304 | { 305 | semaphore.Release(); 306 | } 307 | } 308 | 309 | /// 310 | /// Sets a value with the given key. 311 | /// 312 | /// A string identifying the requested value. 313 | /// The value to set in the cache. 314 | /// The cache options for the value. 315 | public void Set( 316 | string key, 317 | byte[] value, 318 | DistributedCacheEntryOptions options) 319 | { 320 | var semaphore = GetOrCreateLock( 321 | key, options); 322 | semaphore.Wait(); 323 | try 324 | { 325 | L2Cache.Set( 326 | key, value, options); 327 | SetMemoryCache( 328 | key, value, options); 329 | MessagePublisher.Publish( 330 | Database.Value.Multiplexer, 331 | key); 332 | } 333 | finally 334 | { 335 | semaphore.Release(); 336 | } 337 | } 338 | 339 | /// 340 | /// Sets a value with the given key. 341 | /// 342 | /// A string identifying the requested value. 343 | /// The value to set in the cache. 344 | /// The cache options for the value. 345 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. 346 | /// The System.Threading.Tasks.Task that represents the asynchronous operation. 347 | public async Task SetAsync( 348 | string key, 349 | byte[] value, 350 | DistributedCacheEntryOptions options, 351 | CancellationToken token = default) 352 | { 353 | var semaphore = await GetOrCreateLockAsync( 354 | key, 355 | options, 356 | token) 357 | .ConfigureAwait(false); 358 | await semaphore 359 | .WaitAsync(token) 360 | .ConfigureAwait(false); 361 | try 362 | { 363 | await L2Cache 364 | .SetAsync( 365 | key, 366 | value, 367 | options, 368 | token) 369 | .ConfigureAwait(false); 370 | SetMemoryCache( 371 | key, value, options); 372 | await MessagePublisher 373 | .PublishAsync( 374 | Database.Value.Multiplexer, 375 | key, 376 | token) 377 | .ConfigureAwait(false); 378 | } 379 | finally 380 | { 381 | semaphore.Release(); 382 | } 383 | } 384 | 385 | private void Dispose(bool isDisposing) 386 | { 387 | if (IsDisposed) 388 | { 389 | return; 390 | } 391 | 392 | if (isDisposing) 393 | { 394 | SubscribeCancellationTokenSource.Dispose(); 395 | } 396 | 397 | IsDisposed = true; 398 | } 399 | 400 | private HashEntry[] GetHashEntries(string key) 401 | { 402 | var hashEntries = Array.Empty(); 403 | 404 | try 405 | { 406 | hashEntries = Database.Value.HashGetAll( 407 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); 408 | } 409 | catch (RedisServerException) { } 410 | 411 | return hashEntries; 412 | } 413 | 414 | private async Task GetHashEntriesAsync(string key) 415 | { 416 | var hashEntries = Array.Empty(); 417 | 418 | try 419 | { 420 | hashEntries = await Database.Value 421 | .HashGetAllAsync( 422 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}") 423 | .ConfigureAwait(false); 424 | } 425 | catch (RedisServerException) { } 426 | 427 | return hashEntries; 428 | } 429 | 430 | private SemaphoreSlim GetOrCreateLock( 431 | string key, 432 | DistributedCacheEntryOptions? distributedCacheEntryOptions) 433 | { 434 | KeySemaphore.Wait(); 435 | try 436 | { 437 | return L1Cache.GetOrCreate( 438 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}", 439 | cacheEntry => 440 | { 441 | cacheEntry.AbsoluteExpiration = 442 | distributedCacheEntryOptions?.AbsoluteExpiration; 443 | cacheEntry.AbsoluteExpirationRelativeToNow = 444 | distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow; 445 | cacheEntry.SlidingExpiration = 446 | distributedCacheEntryOptions?.SlidingExpiration; 447 | return new SemaphoreSlim(1, 1); 448 | }) ?? 449 | new SemaphoreSlim(1, 1); 450 | } 451 | finally 452 | { 453 | KeySemaphore.Release(); 454 | } 455 | } 456 | 457 | private async Task GetOrCreateLockAsync( 458 | string key, 459 | DistributedCacheEntryOptions? distributedCacheEntryOptions, 460 | CancellationToken cancellationToken = default) 461 | { 462 | await KeySemaphore 463 | .WaitAsync(cancellationToken) 464 | .ConfigureAwait(false); 465 | try 466 | { 467 | return await L1Cache 468 | .GetOrCreateAsync( 469 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}", 470 | cacheEntry => 471 | { 472 | cacheEntry.AbsoluteExpiration = 473 | distributedCacheEntryOptions?.AbsoluteExpiration; 474 | cacheEntry.AbsoluteExpirationRelativeToNow = 475 | distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow; 476 | cacheEntry.SlidingExpiration = 477 | distributedCacheEntryOptions?.SlidingExpiration; 478 | return Task.FromResult(new SemaphoreSlim(1, 1)); 479 | }) 480 | .ConfigureAwait(false) ?? 481 | new SemaphoreSlim(1, 1); 482 | } 483 | finally 484 | { 485 | KeySemaphore.Release(); 486 | } 487 | } 488 | 489 | private SemaphoreSlim SetLock( 490 | string key, 491 | SemaphoreSlim semaphore, 492 | DistributedCacheEntryOptions distributedCacheEntryOptions) 493 | { 494 | var memoryCacheEntryOptions = new MemoryCacheEntryOptions 495 | { 496 | AbsoluteExpiration = 497 | distributedCacheEntryOptions?.AbsoluteExpiration, 498 | AbsoluteExpirationRelativeToNow = 499 | distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow, 500 | SlidingExpiration = 501 | distributedCacheEntryOptions?.SlidingExpiration, 502 | }; 503 | 504 | return L1Cache.Set( 505 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}", 506 | semaphore, 507 | memoryCacheEntryOptions); 508 | } 509 | 510 | private void SetMemoryCache( 511 | string key, 512 | byte[] value, 513 | DistributedCacheEntryOptions distributedCacheEntryOptions) 514 | { 515 | var memoryCacheEntryOptions = new MemoryCacheEntryOptions 516 | { 517 | AbsoluteExpiration = 518 | distributedCacheEntryOptions?.AbsoluteExpiration, 519 | AbsoluteExpirationRelativeToNow = 520 | distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow, 521 | SlidingExpiration = 522 | distributedCacheEntryOptions?.SlidingExpiration, 523 | }; 524 | 525 | if (!memoryCacheEntryOptions.SlidingExpiration.HasValue) 526 | { 527 | L1Cache.Set( 528 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}", 529 | value, 530 | memoryCacheEntryOptions); 531 | } 532 | } 533 | 534 | private async Task SubscribeAsync( 535 | CancellationToken cancellationToken = default) 536 | { 537 | while (true) 538 | { 539 | try 540 | { 541 | if (!await MessagingConfigurationVerifier 542 | .VerifyConfigurationAsync( 543 | Database.Value, 544 | cancellationToken) 545 | .ConfigureAwait(false)) 546 | { 547 | Logger.MessagingConfigurationInvalid( 548 | L1L2RedisCacheOptions.MessagingType); 549 | } 550 | 551 | await MessageSubscriber 552 | .SubscribeAsync( 553 | Database.Value.Multiplexer, 554 | cancellationToken) 555 | .ConfigureAwait(false); 556 | break; 557 | } 558 | catch (RedisConnectionException redisConnectionException) 559 | { 560 | Logger.SubscriberFailed( 561 | L1L2RedisCacheOptions 562 | .SubscriberRetryDelay, 563 | redisConnectionException); 564 | 565 | await Task 566 | .Delay( 567 | L1L2RedisCacheOptions 568 | .SubscriberRetryDelay, 569 | cancellationToken) 570 | .ConfigureAwait(false); 571 | } 572 | } 573 | } 574 | } 575 | -------------------------------------------------------------------------------- /src/L1L2RedisCache.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | latest-recommended 4 | true 5 | true 6 | true 7 | enable 8 | latest 9 | CA1724;CA1812;SYSLIB1006; 10 | enable 11 | README.md 12 | git 13 | https://github.com/null-d3v/L1L2RedisCache.git 14 | netstandard2.1 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/L1L2RedisCacheLoggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | 3 | namespace L1L2RedisCache; 4 | 5 | internal static partial class L1L2RedisCacheLoggerExtensions 6 | { 7 | [LoggerMessage( 8 | Level = LogLevel.Error, 9 | Message = "Redis notify-keyspace-events config is invalid for MessagingType {MessagingType}")] 10 | public static partial void MessagingConfigurationInvalid( 11 | this ILogger logger, 12 | MessagingType messagingType, 13 | Exception? exception = null); 14 | 15 | [LoggerMessage( 16 | Level = LogLevel.Error, 17 | Message = "Failed to initialize subscriber; retrying in {SubscriberRetryDelay}")] 18 | public static partial void SubscriberFailed( 19 | this ILogger logger, 20 | TimeSpan subscriberRetryDelay, 21 | Exception? exception = null); 22 | } 23 | -------------------------------------------------------------------------------- /src/L1L2RedisCacheOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.StackExchangeRedis; 2 | using Microsoft.Extensions.Options; 3 | 4 | namespace L1L2RedisCache; 5 | 6 | /// 7 | /// Configuration options for L1L2RedisCache. 8 | /// 9 | public sealed class L1L2RedisCacheOptions : 10 | RedisCacheOptions, IOptions 11 | { 12 | /// 13 | /// Initializes a new instance of L1L2RedisCacheOptions. 14 | /// 15 | public L1L2RedisCacheOptions() : base() { } 16 | 17 | /// 18 | /// Unique identifier for the operating instance. 19 | /// 20 | public Guid Id { get; } = Guid.NewGuid(); 21 | 22 | /// 23 | /// The pub/sub channel name. 24 | /// 25 | public string Channel => $"{KeyPrefix}Channel"; 26 | 27 | /// 28 | /// A prefix to be applied to all cache keys. 29 | /// 30 | public string KeyPrefix => InstanceName ?? string.Empty; 31 | 32 | /// 33 | /// A prefix to be applied to all L1 lock cache keys. 34 | /// 35 | public string LockKeyPrefix => $"{KeyPrefix}{Id}"; 36 | 37 | /// 38 | /// The type of messaging to use for L1 memory cache eviction. 39 | /// 40 | public MessagingType MessagingType { get; set; } = 41 | MessagingType.Default; 42 | 43 | /// 44 | /// The duration of time to delay before retrying subscriber intialization. 45 | /// 46 | public TimeSpan SubscriberRetryDelay { get; set; } = 47 | TimeSpan.FromSeconds(5); 48 | 49 | L1L2RedisCacheOptions IOptions.Value => this; 50 | } 51 | -------------------------------------------------------------------------------- /src/Messaging/DefaultMessagePublisher.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using StackExchange.Redis; 3 | using System.Text.Json; 4 | 5 | namespace L1L2RedisCache; 6 | 7 | internal sealed class DefaultMessagePublisher( 8 | IOptions jsonSerializerOptionsAccessor, 9 | IOptions l1L2RedisCacheOptionsOptionsAccessor) : 10 | IMessagePublisher 11 | { 12 | public JsonSerializerOptions JsonSerializerOptions { get; set; } = 13 | jsonSerializerOptionsAccessor.Value; 14 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } = 15 | l1L2RedisCacheOptionsOptionsAccessor.Value; 16 | 17 | public void Publish( 18 | IConnectionMultiplexer connectionMultiplexer, 19 | string key) 20 | { 21 | connectionMultiplexer 22 | .GetSubscriber() 23 | .Publish( 24 | new RedisChannel( 25 | L1L2RedisCacheOptions.Channel, 26 | RedisChannel.PatternMode.Literal), 27 | JsonSerializer.Serialize( 28 | new CacheMessage 29 | { 30 | Key = key, 31 | PublisherId = L1L2RedisCacheOptions.Id, 32 | }, 33 | JsonSerializerOptions)); 34 | } 35 | 36 | public async Task PublishAsync( 37 | IConnectionMultiplexer connectionMultiplexer, 38 | string key, 39 | CancellationToken cancellationToken = default) 40 | { 41 | await connectionMultiplexer 42 | .GetSubscriber() 43 | .PublishAsync( 44 | new RedisChannel( 45 | L1L2RedisCacheOptions.Channel, 46 | RedisChannel.PatternMode.Literal), 47 | JsonSerializer.Serialize( 48 | new CacheMessage 49 | { 50 | Key = key, 51 | PublisherId = L1L2RedisCacheOptions.Id, 52 | }, 53 | JsonSerializerOptions)) 54 | .ConfigureAwait(false); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Messaging/DefaultMessageSubscriber.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Memory; 2 | using Microsoft.Extensions.Options; 3 | using StackExchange.Redis; 4 | using System.Text.Json; 5 | 6 | namespace L1L2RedisCache; 7 | 8 | internal class DefaultMessageSubscriber( 9 | IOptions jsonSerializerOptionsAcccessor, 10 | IMemoryCache l1Cache, 11 | IOptions l1L2RedisCacheOptionsOptionsAccessor) : 12 | IMessageSubscriber 13 | { 14 | public JsonSerializerOptions JsonSerializerOptions { get; set; } = 15 | jsonSerializerOptionsAcccessor.Value; 16 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } = 17 | l1L2RedisCacheOptionsOptionsAccessor.Value; 18 | public IMemoryCache L1Cache { get; set; } = 19 | l1Cache; 20 | public EventHandler? OnMessage { get; set; } 21 | public EventHandler? OnSubscribe { get; set; } 22 | 23 | public async Task SubscribeAsync( 24 | IConnectionMultiplexer connectionMultiplexer, 25 | CancellationToken cancellationToken = default) 26 | { 27 | await connectionMultiplexer 28 | .GetSubscriber() 29 | .SubscribeAsync( 30 | new RedisChannel( 31 | L1L2RedisCacheOptions.Channel, 32 | RedisChannel.PatternMode.Literal), 33 | ProcessMessage) 34 | .ConfigureAwait(false); 35 | 36 | OnSubscribe?.Invoke( 37 | this, 38 | EventArgs.Empty); 39 | } 40 | 41 | public async Task UnsubscribeAsync( 42 | IConnectionMultiplexer connectionMultiplexer, 43 | CancellationToken cancellationToken = default) 44 | { 45 | await connectionMultiplexer 46 | .GetSubscriber() 47 | .UnsubscribeAsync( 48 | new RedisChannel( 49 | L1L2RedisCacheOptions.Channel, 50 | RedisChannel.PatternMode.Literal)) 51 | .ConfigureAwait(false); 52 | } 53 | 54 | internal void ProcessMessage( 55 | RedisChannel channel, 56 | RedisValue message) 57 | { 58 | var cacheMessage = JsonSerializer 59 | .Deserialize( 60 | message.ToString(), 61 | JsonSerializerOptions); 62 | if (cacheMessage != null && 63 | cacheMessage.PublisherId != L1L2RedisCacheOptions.Id) 64 | { 65 | L1Cache.Remove( 66 | $"{L1L2RedisCacheOptions.KeyPrefix}{cacheMessage.Key}"); 67 | L1Cache.Remove( 68 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{cacheMessage.Key}"); 69 | 70 | OnMessage?.Invoke( 71 | this, 72 | new OnMessageEventArgs(cacheMessage.Key)); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Messaging/IMessagePublisher.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | 3 | namespace L1L2RedisCache; 4 | 5 | /// 6 | /// Publishes messages to other L1L2RedisCache instances indicating cache values have changed. 7 | /// 8 | public interface IMessagePublisher 9 | { 10 | /// 11 | /// Publishes a message indicating a cache value has changed. 12 | /// 13 | /// The StackExchange.Redis.IConnectionMultiplexer for publishing. 14 | /// The cache key of the value that has changed. 15 | void Publish( 16 | IConnectionMultiplexer connectionMultiplexer, 17 | string key); 18 | 19 | /// 20 | /// Publishes a message indicating a cache value has changed. 21 | /// 22 | /// The StackExchange.Redis.IConnectionMultiplexer for publishing. 23 | /// The cache key of the value that has changed. 24 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. 25 | /// The System.Threading.Tasks.Task that represents the asynchronous operation. 26 | Task PublishAsync( 27 | IConnectionMultiplexer connectionMultiplexer, 28 | string key, 29 | CancellationToken cancellationToken = default); 30 | } 31 | -------------------------------------------------------------------------------- /src/Messaging/IMessageSubscriber.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | 3 | namespace L1L2RedisCache; 4 | 5 | /// 6 | /// Subscribes to messages published by other L1L2RedisCache instances indicating cache values have changed. 7 | /// 8 | public interface IMessageSubscriber 9 | { 10 | /// 11 | /// An event that is raised when a message is recieved. 12 | /// 13 | EventHandler? OnMessage { get; set; } 14 | 15 | /// 16 | /// An event that is raised when a subscription is created. 17 | /// 18 | EventHandler? OnSubscribe { get; set; } 19 | 20 | /// 21 | /// Subscribes to messages indicating cache values have changed. 22 | /// 23 | /// The StackExchange.Redis.IConnectionMultiplexer for subscribing. 24 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. 25 | /// The System.Threading.Tasks.Task that represents the asynchronous operation. 26 | Task SubscribeAsync( 27 | IConnectionMultiplexer connectionMultiplexer, 28 | CancellationToken cancellationToken = default); 29 | 30 | /// 31 | /// Unsubscribes to messages indicating cache values have changed. 32 | /// 33 | /// The StackExchange.Redis.IConnectionMultiplexer for subscribing. 34 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. 35 | /// The System.Threading.Tasks.Task that represents the asynchronous operation. 36 | Task UnsubscribeAsync( 37 | IConnectionMultiplexer connectionMultiplexer, 38 | CancellationToken cancellationToken = default); 39 | } 40 | -------------------------------------------------------------------------------- /src/Messaging/KeyeventMessageSubscriber.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Memory; 2 | using Microsoft.Extensions.Options; 3 | using StackExchange.Redis; 4 | 5 | namespace L1L2RedisCache; 6 | 7 | internal class KeyeventMessageSubscriber( 8 | IMemoryCache l1Cache, 9 | IOptions l1L2RedisCacheOptionsOptionsAccessor) : 10 | IMessageSubscriber 11 | { 12 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } = 13 | l1L2RedisCacheOptionsOptionsAccessor.Value; 14 | public IMemoryCache L1Cache { get; set; } = 15 | l1Cache; 16 | public EventHandler? OnMessage { get; set; } 17 | public EventHandler? OnSubscribe { get; set; } 18 | 19 | public async Task SubscribeAsync( 20 | IConnectionMultiplexer connectionMultiplexer, 21 | CancellationToken cancellationToken = default) 22 | { 23 | await connectionMultiplexer 24 | .GetSubscriber() 25 | .SubscribeAsync( 26 | new RedisChannel( 27 | "__keyevent@*__:del", 28 | RedisChannel.PatternMode.Pattern), 29 | ProcessMessage) 30 | .ConfigureAwait(false); 31 | 32 | await connectionMultiplexer 33 | .GetSubscriber() 34 | .SubscribeAsync( 35 | new RedisChannel( 36 | "__keyevent@*__:hset", 37 | RedisChannel.PatternMode.Pattern), 38 | ProcessMessage) 39 | .ConfigureAwait(false); 40 | 41 | OnSubscribe?.Invoke( 42 | this, 43 | EventArgs.Empty); 44 | } 45 | 46 | public async Task UnsubscribeAsync( 47 | IConnectionMultiplexer connectionMultiplexer, 48 | CancellationToken cancellationToken = default) 49 | { 50 | await connectionMultiplexer 51 | .GetSubscriber() 52 | .UnsubscribeAsync( 53 | new RedisChannel( 54 | "__keyevent@*__:del", 55 | RedisChannel.PatternMode.Pattern)) 56 | .ConfigureAwait(false); 57 | 58 | await connectionMultiplexer 59 | .GetSubscriber() 60 | .UnsubscribeAsync( 61 | new RedisChannel( 62 | "__keyevent@*__:hset", 63 | RedisChannel.PatternMode.Pattern)) 64 | .ConfigureAwait(false); 65 | } 66 | 67 | internal void ProcessMessage( 68 | RedisChannel channel, 69 | RedisValue message) 70 | { 71 | if (message.StartsWith( 72 | L1L2RedisCacheOptions.KeyPrefix)) 73 | { 74 | var key = message 75 | .ToString()[L1L2RedisCacheOptions.KeyPrefix.Length..]; 76 | L1Cache.Remove( 77 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); 78 | L1Cache.Remove( 79 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}"); 80 | 81 | OnMessage?.Invoke( 82 | this, 83 | new OnMessageEventArgs(key)); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Messaging/KeyspaceMessageSubscriber.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Memory; 2 | using Microsoft.Extensions.Options; 3 | using StackExchange.Redis; 4 | 5 | namespace L1L2RedisCache; 6 | 7 | internal class KeyspaceMessageSubscriber( 8 | IMemoryCache l1Cache, 9 | IOptions l1L2RedisCacheOptionsOptionsAccessor) : 10 | IMessageSubscriber 11 | { 12 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } = 13 | l1L2RedisCacheOptionsOptionsAccessor.Value; 14 | public IMemoryCache L1Cache { get; set; } = 15 | l1Cache; 16 | public EventHandler? OnMessage { get; set; } 17 | public EventHandler? OnSubscribe { get; set; } 18 | 19 | public async Task SubscribeAsync( 20 | IConnectionMultiplexer connectionMultiplexer, 21 | CancellationToken cancellationToken = default) 22 | { 23 | await connectionMultiplexer 24 | .GetSubscriber() 25 | .SubscribeAsync( 26 | new RedisChannel( 27 | "__keyspace@*__:*", 28 | RedisChannel.PatternMode.Pattern), 29 | ProcessMessage) 30 | .ConfigureAwait(false); 31 | 32 | OnSubscribe?.Invoke( 33 | this, 34 | EventArgs.Empty); 35 | } 36 | 37 | public async Task UnsubscribeAsync( 38 | IConnectionMultiplexer connectionMultiplexer, 39 | CancellationToken cancellationToken = default) 40 | { 41 | await connectionMultiplexer 42 | .GetSubscriber() 43 | .UnsubscribeAsync( 44 | new RedisChannel( 45 | "__keyspace@*__:*", 46 | RedisChannel.PatternMode.Pattern)) 47 | .ConfigureAwait(false); 48 | } 49 | 50 | internal void ProcessMessage( 51 | RedisChannel channel, 52 | RedisValue message) 53 | { 54 | if (message == "del" || 55 | message == "hset") 56 | { 57 | var keyPrefixIndex = channel.ToString().IndexOf( 58 | L1L2RedisCacheOptions.KeyPrefix, 59 | StringComparison.Ordinal); 60 | if (keyPrefixIndex != -1) 61 | { 62 | var key = channel.ToString()[ 63 | (keyPrefixIndex + L1L2RedisCacheOptions.KeyPrefix.Length)..]; 64 | L1Cache.Remove( 65 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); 66 | L1Cache.Remove( 67 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}"); 68 | 69 | OnMessage?.Invoke( 70 | this, 71 | new OnMessageEventArgs(key)); 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Messaging/NopMessagePublisher.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | 3 | namespace L1L2RedisCache; 4 | 5 | internal sealed class NopMessagePublisher : 6 | IMessagePublisher 7 | { 8 | public void Publish( 9 | IConnectionMultiplexer connectionMultiplexer, 10 | string key) { } 11 | 12 | public Task PublishAsync( 13 | IConnectionMultiplexer connectionMultiplexer, 14 | string key, 15 | CancellationToken cancellationToken = default) 16 | { 17 | return Task.CompletedTask; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Messaging/OnMessageEventArgs.cs: -------------------------------------------------------------------------------- 1 | namespace L1L2RedisCache; 2 | 3 | /// 4 | /// Supplies information about a message event from an IMessageSubscriber. 5 | /// 6 | /// 7 | /// Initializes a new instance of OnMessageEventArgs. 8 | /// 9 | public class OnMessageEventArgs( 10 | string key) : 11 | EventArgs 12 | { 13 | 14 | /// 15 | /// The cache key pertaining to the message event. 16 | /// 17 | public string Key { get; set; } = key; 18 | } 19 | -------------------------------------------------------------------------------- /src/MessagingType.cs: -------------------------------------------------------------------------------- 1 | namespace L1L2RedisCache; 2 | 3 | /// 4 | /// The type of messaging system to use for L1 memory cache eviction. 5 | /// 6 | public enum MessagingType 7 | { 8 | /// 9 | /// Use standard L1L2RedisCache pub/sub messages for L1 memory cache eviction. The Redis server requires no additional configuration. 10 | /// 11 | Default = 0, 12 | 13 | /// 14 | /// Use keyevent notifications for L1 memory cache eviction instead of standard L1L2 pub/sub messages. The Redis server must have keyevent notifications enabled with at least ghE parameters. 15 | /// 16 | KeyeventNotifications = 1, 17 | 18 | /// 19 | /// Use keyspace notifications for L1 memory cache eviction instead of standard L1L2 pub/sub messages. The Redis server must have keyevent notifications enabled with at least ghK parameters. 20 | /// 21 | KeyspaceNotifications = 2, 22 | } 23 | -------------------------------------------------------------------------------- /tests/System/Benchmark/BenchmarkBase.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using Microsoft.Extensions.Caching.Distributed; 3 | using Microsoft.Extensions.Caching.Memory; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace L1L2RedisCache.Tests.System; 8 | 9 | public abstract class BenchmarkBase 10 | { 11 | [Params(100)] 12 | public int Iterations { get; set; } 13 | 14 | protected DistributedCacheEntryOptions DistributedCacheEntryOptions { get; set; } = 15 | new DistributedCacheEntryOptions 16 | { 17 | AbsoluteExpirationRelativeToNow = 18 | TimeSpan.FromHours(1), 19 | }; 20 | protected IMemoryCache? L1Cache { get; set; } 21 | protected IDistributedCache? L1L2Cache { get; set; } 22 | protected IDistributedCache? L2Cache { get; set; } 23 | 24 | [GlobalSetup] 25 | public void GlobalSetup() 26 | { 27 | var configuration = new ConfigurationBuilder() 28 | .AddJsonFile("appsettings.json") 29 | .AddEnvironmentVariables() 30 | .Build(); 31 | 32 | var services = new ServiceCollection(); 33 | services.AddSingleton(configuration); 34 | services.AddL1L2RedisCache(options => 35 | { 36 | configuration.Bind("L1L2RedisCache", options); 37 | }); 38 | var serviceProvider = services 39 | .BuildServiceProvider(); 40 | 41 | L1Cache = serviceProvider 42 | .GetRequiredService(); 43 | L1L2Cache = serviceProvider 44 | .GetRequiredService(); 45 | L2Cache = serviceProvider 46 | .GetRequiredService>() 47 | .Invoke(); 48 | } 49 | } -------------------------------------------------------------------------------- /tests/System/Benchmark/GetBenchmark.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using Microsoft.Extensions.Caching.Distributed; 3 | using Microsoft.Extensions.Caching.Memory; 4 | 5 | namespace L1L2RedisCache.Tests.System; 6 | 7 | public class GetBenchmark : BenchmarkBase 8 | { 9 | public new void GlobalSetup() 10 | { 11 | base.GlobalSetup(); 12 | 13 | for (var iteration = 1; 14 | iteration <= Iterations; 15 | iteration++) 16 | { 17 | L1L2Cache!.SetString( 18 | $"Get:{iteration}", 19 | "Value", 20 | DistributedCacheEntryOptions); 21 | L1L2Cache!.SetString( 22 | $"GetPropagation:{iteration}", 23 | "Value", 24 | DistributedCacheEntryOptions); 25 | } 26 | } 27 | 28 | [IterationSetup] 29 | public void IterationSetup() 30 | { 31 | for (var iteration = 1; 32 | iteration <= Iterations; 33 | iteration++) 34 | { 35 | L1Cache!.Remove( 36 | $"GetPropagation:{iteration}"); 37 | } 38 | } 39 | 40 | [Benchmark] 41 | public void L1Get() 42 | { 43 | for (var iteration = 1; 44 | iteration <= Iterations; 45 | iteration++) 46 | { 47 | L1Cache!.Get( 48 | $"Get:{iteration}"); 49 | } 50 | } 51 | 52 | [Benchmark] 53 | public void L1L2Get() 54 | { 55 | for (var iteration = 1; 56 | iteration <= Iterations; 57 | iteration++) 58 | { 59 | L1L2Cache!.GetString( 60 | $"Get:{iteration}"); 61 | } 62 | } 63 | 64 | [Benchmark] 65 | public void L1L2GetPropagation() 66 | { 67 | for (var iteration = 1; 68 | iteration <= Iterations; 69 | iteration++) 70 | { 71 | L1L2Cache!.GetString( 72 | $"GetPropagation:{iteration}"); 73 | } 74 | } 75 | 76 | [Benchmark] 77 | public void L2Get() 78 | { 79 | for (var iteration = 1; 80 | iteration <= Iterations; 81 | iteration++) 82 | { 83 | L2Cache!.GetString( 84 | $"Get:{iteration}"); 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /tests/System/Benchmark/SetBenchmark.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using Microsoft.Extensions.Caching.Distributed; 3 | 4 | namespace L1L2RedisCache.Tests.System; 5 | 6 | [SimpleJob] 7 | public class SetBenchmark : BenchmarkBase 8 | { 9 | [Benchmark] 10 | public void L1L2Set() 11 | { 12 | for (var iteration = 1; 13 | iteration <= Iterations; 14 | iteration++) 15 | { 16 | L1L2Cache!.SetString( 17 | $"Set:{iteration}", 18 | "Value", 19 | DistributedCacheEntryOptions); 20 | } 21 | } 22 | 23 | [Benchmark] 24 | public void L2Set() 25 | { 26 | for (var iteration = 1; 27 | iteration <= Iterations; 28 | iteration++) 29 | { 30 | L2Cache!.SetString( 31 | $"Set:{iteration}", 32 | "Value", 33 | DistributedCacheEntryOptions); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /tests/System/L1L2RedisCache.Tests.System.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | latest-recommended 4 | true 5 | enable 6 | false 7 | enable 8 | net9.0 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/System/MessagingTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Distributed; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Options; 5 | using Microsoft.VisualStudio.TestTools.UnitTesting; 6 | 7 | namespace L1L2RedisCache.Tests.System; 8 | 9 | [TestClass] 10 | public class MessagingTests 11 | { 12 | public MessagingTests() 13 | { 14 | Configuration = new ConfigurationBuilder() 15 | .AddJsonFile("appsettings.json") 16 | .AddEnvironmentVariables() 17 | .Build(); 18 | EventTimeout = TimeSpan.FromSeconds(5); 19 | } 20 | 21 | public IConfiguration Configuration { get; } 22 | public TimeSpan EventTimeout { get; } 23 | 24 | [DataRow(100, MessagingType.Default)] 25 | [DataRow(100, MessagingType.KeyeventNotifications)] 26 | [DataRow(100, MessagingType.KeyspaceNotifications)] 27 | [TestMethod] 28 | public async Task MessagingTypeTest( 29 | int iterations, 30 | MessagingType messagingType) 31 | { 32 | var primaryServices = new ServiceCollection(); 33 | primaryServices.AddSingleton(Configuration); 34 | primaryServices.AddL1L2RedisCache(options => 35 | { 36 | Configuration.Bind("L1L2RedisCache", options); 37 | options.MessagingType = messagingType; 38 | }); 39 | using var primaryServiceProvider = primaryServices 40 | .BuildServiceProvider(); 41 | 42 | var primaryL1L2Cache = primaryServiceProvider 43 | .GetRequiredService(); 44 | var primaryL1L2CacheOptions = primaryServiceProvider 45 | .GetRequiredService>() 46 | .Value; 47 | 48 | await SetAndVerifyConfigurationAsync( 49 | primaryServiceProvider, 50 | messagingType) 51 | .ConfigureAwait(false); 52 | 53 | var secondaryServices = new ServiceCollection(); 54 | secondaryServices.AddSingleton(Configuration); 55 | secondaryServices.AddL1L2RedisCache(options => 56 | { 57 | Configuration.Bind("L1L2RedisCache", options); 58 | options.MessagingType = messagingType; 59 | }); 60 | using var secondaryServiceProvider = secondaryServices 61 | .BuildServiceProvider(); 62 | 63 | var secondaryMessageSubscriber = secondaryServiceProvider 64 | .GetRequiredService(); 65 | using var messageAutoResetEvent = new AutoResetEvent(false); 66 | using var subscribeAutoResetEvent = new AutoResetEvent(false); 67 | secondaryMessageSubscriber.OnMessage += (sender, e) => 68 | { 69 | messageAutoResetEvent.Set(); 70 | }; 71 | secondaryMessageSubscriber.OnSubscribe += (sender, e) => 72 | { 73 | subscribeAutoResetEvent.Set(); 74 | }; 75 | 76 | var secondaryL1L2Cache = secondaryServiceProvider 77 | .GetRequiredService(); 78 | 79 | Assert.IsTrue( 80 | subscribeAutoResetEvent 81 | .WaitOne(EventTimeout)); 82 | 83 | for (var iteration = 0; iteration < iterations; iteration++) 84 | { 85 | var key = Guid.NewGuid().ToString(); 86 | var value = Guid.NewGuid().ToString(); 87 | 88 | // L1 population via L2 89 | await primaryL1L2Cache 90 | .SetStringAsync( 91 | key, value) 92 | .ConfigureAwait(false); 93 | Assert.IsTrue( 94 | messageAutoResetEvent 95 | .WaitOne(EventTimeout)); 96 | Assert.AreEqual( 97 | value, 98 | await secondaryL1L2Cache 99 | .GetStringAsync(key) 100 | .ConfigureAwait(false)); 101 | 102 | // L1 eviction via set 103 | // L1 population via L2 104 | await primaryL1L2Cache 105 | .SetStringAsync( 106 | key, value) 107 | .ConfigureAwait(false); 108 | Assert.IsTrue( 109 | messageAutoResetEvent 110 | .WaitOne(EventTimeout)); 111 | Assert.AreEqual( 112 | value, 113 | await secondaryL1L2Cache 114 | .GetStringAsync(key) 115 | .ConfigureAwait(false)); 116 | 117 | // L1 eviction via remove 118 | await primaryL1L2Cache 119 | .RemoveAsync(key) 120 | .ConfigureAwait(false); 121 | Assert.IsTrue( 122 | messageAutoResetEvent 123 | .WaitOne(EventTimeout)); 124 | Assert.IsNull( 125 | await secondaryL1L2Cache 126 | .GetStringAsync(key) 127 | .ConfigureAwait(false)); 128 | } 129 | } 130 | 131 | private static async Task SetAndVerifyConfigurationAsync( 132 | IServiceProvider serviceProvider, 133 | MessagingType messagingType) 134 | { 135 | var l1L2Cache = serviceProvider 136 | .GetRequiredService() as L1L2RedisCache; 137 | 138 | await l1L2Cache!.Database.Value 139 | .ExecuteAsync( 140 | "config", 141 | "set", 142 | "notify-keyspace-events", 143 | MessagingConfigurationVerifier 144 | .NotifyKeyspaceEventsConfig[messagingType]) 145 | .ConfigureAwait(false); 146 | 147 | var configurationVerifier = serviceProvider 148 | .GetRequiredService(); 149 | Assert.IsTrue( 150 | await configurationVerifier 151 | .VerifyConfigurationAsync( 152 | l1L2Cache.Database.Value) 153 | .ConfigureAwait(false)); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/System/PerformanceTests.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Configs; 2 | using BenchmarkDotNet.Extensions; 3 | using BenchmarkDotNet.Running; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | 6 | namespace L1L2RedisCache.Tests.System; 7 | 8 | [TestClass] 9 | public class PerformanceTests 10 | { 11 | private IConfig Config { get; } = 12 | DefaultConfig.Instance.WithOptions( 13 | ConfigOptions.DisableOptimizationsValidator); 14 | 15 | [TestMethod] 16 | public void GetPerformanceTest() 17 | { 18 | var benchmarkSummary = BenchmarkRunner 19 | .Run(Config); 20 | 21 | Assert.IsTrue( 22 | benchmarkSummary.Reports.All( 23 | r => r.Success)); 24 | 25 | var l1GetReport = benchmarkSummary 26 | .GetReportFor( 27 | gB => gB.L1Get()); 28 | var l1L2GetReport = benchmarkSummary 29 | .GetReportFor( 30 | gB => gB.L1L2Get()); 31 | var l1L2GetPropagationReport = benchmarkSummary 32 | .GetReportFor( 33 | gB => gB.L1L2GetPropagation()); 34 | var l2GetReport = benchmarkSummary 35 | .GetReportFor( 36 | gB => gB.L2Get()); 37 | 38 | var l2GetVsL1L2GetRatio = 39 | l2GetReport.ResultStatistics?.Median / 40 | l1L2GetReport.ResultStatistics?.Median; 41 | Assert.IsTrue( 42 | l2GetVsL1L2GetRatio > 100, 43 | $"L1L2Cache Get must perform significantly better (> 100) than RedisCache Get: {l2GetVsL1L2GetRatio}"); 44 | 45 | var l1L2GetPropagationVsl2GetRatio = 46 | l2GetReport.ResultStatistics?.Median / 47 | l1L2GetPropagationReport.ResultStatistics?.Median; 48 | Assert.IsTrue( 49 | l1L2GetPropagationVsl2GetRatio > 3, 50 | $"L1L2Cache Get must perform better (> 3) than to RedisCache Get: {l1L2GetPropagationVsl2GetRatio}"); 51 | } 52 | 53 | [TestMethod] 54 | public void SetPerformanceTest() 55 | { 56 | var benchmarkSummary = BenchmarkRunner 57 | .Run(Config); 58 | 59 | Assert.IsTrue( 60 | benchmarkSummary.Reports.All( 61 | r => r.Success)); 62 | 63 | var l1L2SetReport = benchmarkSummary 64 | .GetReportFor( 65 | gB => gB.L1L2Set()); 66 | var l2SetReport = benchmarkSummary 67 | .GetReportFor( 68 | gB => gB.L2Set()); 69 | 70 | var l1L2SetVsl2SetRatio = 71 | l1L2SetReport.ResultStatistics?.Median / 72 | l2SetReport.ResultStatistics?.Median; 73 | Assert.IsTrue( 74 | l1L2SetVsl2SetRatio < 3, 75 | $"L1L2Cache Set cannot perform significantly worse (< 3) than RedisCache Set: {l1L2SetVsl2SetRatio}"); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/System/ReliabilityTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Distributed; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | using StackExchange.Redis; 6 | 7 | namespace L1L2RedisCache.Tests.System; 8 | 9 | [TestClass] 10 | public class ReliabilityTests 11 | { 12 | public ReliabilityTests() 13 | { 14 | Configuration = new ConfigurationBuilder() 15 | .AddJsonFile("appsettings.json") 16 | .AddEnvironmentVariables() 17 | .Build(); 18 | EventTimeout = TimeSpan.FromSeconds(5); 19 | } 20 | 21 | public IConfiguration Configuration { get; } 22 | public TimeSpan EventTimeout { get; } 23 | 24 | [TestMethod] 25 | public void InitializeBadConnectionTest() 26 | { 27 | var services = new ServiceCollection(); 28 | services.AddSingleton(Configuration); 29 | services.AddL1L2RedisCache(options => 30 | { 31 | Configuration.Bind("L1L2RedisCache", options); 32 | options.Configuration = "localhost:80"; 33 | }); 34 | using var serviceProvider = services 35 | .BuildServiceProvider(); 36 | 37 | var messageSubscriber = serviceProvider 38 | .GetRequiredService(); 39 | using var subscribeAutoResetEvent = new AutoResetEvent(false); 40 | messageSubscriber.OnSubscribe += (sender, e) => 41 | { 42 | subscribeAutoResetEvent.Set(); 43 | }; 44 | 45 | var l1L2Cache = serviceProvider 46 | .GetRequiredService(); 47 | 48 | Assert.IsFalse( 49 | subscribeAutoResetEvent 50 | .WaitOne(EventTimeout)); 51 | Assert.ThrowsExceptionAsync( 52 | () => l1L2Cache 53 | .GetStringAsync(string.Empty)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/System/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "L1L2RedisCache": 3 | { 4 | "Configuration": "redis", 5 | "InstanceName": "L1L2RedisCache:Test:" 6 | } 7 | } -------------------------------------------------------------------------------- /tests/Unit/L1L2RedisCache.Tests.Unit.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | latest-recommended 4 | true 5 | enable 6 | false 7 | enable 8 | net9.0 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/Unit/L1L2RedisCacheTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Distributed; 2 | using Microsoft.Extensions.Caching.Memory; 3 | using Microsoft.Extensions.Options; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | using NSubstitute; 6 | using StackExchange.Redis; 7 | 8 | namespace L1L2RedisCache.Tests.Unit; 9 | 10 | [TestClass] 11 | public class L1L2RedisCacheTests 12 | { 13 | public L1L2RedisCacheTests() 14 | { 15 | L1Cache = new MemoryCache( 16 | Options.Create(new MemoryCacheOptions())); 17 | 18 | L2Cache = new MemoryDistributedCache( 19 | Options.Create( 20 | new MemoryDistributedCacheOptions())); 21 | 22 | L1L2RedisCacheOptions = Options 23 | .Create( 24 | new L1L2RedisCacheOptions 25 | { 26 | InstanceName = "L1L2RedisCache:Test:", 27 | }) 28 | .Value; 29 | 30 | var database = Substitute 31 | .For(); 32 | database 33 | .HashGetAll( 34 | Arg.Any(), 35 | Arg.Any()) 36 | .Returns( 37 | args => 38 | { 39 | var key = ((RedisKey)args[0]).ToString()[ 40 | (L1L2RedisCacheOptions?.InstanceName?.Length ?? 0)..]; 41 | var value = L2Cache.Get(key); 42 | return 43 | [ 44 | new HashEntry("data", value), 45 | ]; 46 | }); 47 | database 48 | .HashGetAllAsync( 49 | Arg.Any(), 50 | Arg.Any()) 51 | .Returns( 52 | async args => 53 | { 54 | var key = ((RedisKey)args[0]).ToString()[ 55 | (L1L2RedisCacheOptions?.InstanceName?.Length ?? 0)..]; 56 | var value = await L2Cache 57 | .GetAsync(key) 58 | .ConfigureAwait(false); 59 | return 60 | [ 61 | new HashEntry("data", value), 62 | ]; 63 | }); 64 | database 65 | .KeyExists( 66 | Arg.Any(), 67 | Arg.Any()) 68 | .Returns( 69 | args => 70 | { 71 | return L2Cache.Get( 72 | ((RedisKey)args[0]).ToString()) != null; 73 | }); 74 | database 75 | .KeyExistsAsync( 76 | Arg.Any(), 77 | Arg.Any()) 78 | .Returns( 79 | async args => 80 | { 81 | var key = ((RedisKey)args[0]).ToString()[ 82 | (L1L2RedisCacheOptions.InstanceName?.Length ?? 0)..]; 83 | return await L2Cache 84 | .GetAsync(key) 85 | .ConfigureAwait(false) != null; 86 | }); 87 | 88 | var connectionMultiplexer = Substitute 89 | .For(); 90 | connectionMultiplexer 91 | .GetDatabase( 92 | Arg.Any(), 93 | Arg.Any()) 94 | .Returns(database); 95 | 96 | L1L2RedisCacheOptions.ConnectionMultiplexerFactory = 97 | () => Task.FromResult(connectionMultiplexer); 98 | 99 | var messagePublisher = Substitute 100 | .For(); 101 | messagePublisher 102 | .Publish( 103 | Arg.Any(), 104 | Arg.Any()); 105 | messagePublisher 106 | .PublishAsync( 107 | Arg.Any(), 108 | Arg.Any(), 109 | Arg.Any()); 110 | 111 | var messageSubscriber = Substitute 112 | .For(); 113 | messageSubscriber 114 | .SubscribeAsync( 115 | Arg.Any(), 116 | Arg.Any()); 117 | 118 | var messagingConfigurationVerifier = Substitute 119 | .For(); 120 | messagingConfigurationVerifier 121 | .VerifyConfigurationAsync( 122 | Arg.Any(), 123 | Arg.Any()); 124 | 125 | L1L2Cache = new L1L2RedisCache( 126 | L1Cache, 127 | L1L2RedisCacheOptions, 128 | new Func(() => L2Cache), 129 | messagePublisher, 130 | messageSubscriber, 131 | messagingConfigurationVerifier); 132 | } 133 | 134 | public IMemoryCache L1Cache { get; } 135 | public IDistributedCache L1L2Cache { get; } 136 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; } 137 | public IDistributedCache L2Cache { get; } 138 | 139 | [TestMethod] 140 | public async Task GetPropagationTest() 141 | { 142 | var key = "key"; 143 | var value = " "u8.ToArray(); 144 | 145 | var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; 146 | 147 | await L2Cache 148 | .SetAsync(key, value) 149 | .ConfigureAwait(false); 150 | 151 | Assert.IsNull( 152 | L1Cache.Get(prefixedKey)); 153 | Assert.AreEqual( 154 | value, 155 | await L1L2Cache 156 | .GetAsync(key) 157 | .ConfigureAwait(false)); 158 | Assert.AreEqual( 159 | value, 160 | L1Cache.Get(prefixedKey)); 161 | } 162 | 163 | [TestMethod] 164 | public void SetTest() 165 | { 166 | var key = "key"; 167 | var value = " "u8.ToArray(); 168 | 169 | var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; 170 | 171 | L1L2Cache.Set(key, value); 172 | 173 | Assert.AreEqual( 174 | value, 175 | L1L2Cache.Get(key)); 176 | Assert.AreEqual( 177 | value, 178 | L1Cache.Get(prefixedKey)); 179 | Assert.AreEqual( 180 | value, 181 | L2Cache.Get(key)); 182 | } 183 | 184 | [TestMethod] 185 | public void SetRemoveTest() 186 | { 187 | var key = "key"; 188 | var value = " "u8.ToArray(); 189 | 190 | var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; 191 | 192 | L1L2Cache.Set(key, value); 193 | 194 | Assert.AreEqual( 195 | value, 196 | L1L2Cache.Get(key)); 197 | Assert.AreEqual( 198 | value, 199 | L1Cache.Get(prefixedKey)); 200 | Assert.AreEqual( 201 | value, 202 | L2Cache.Get(key)); 203 | 204 | L1L2Cache.Remove(key); 205 | 206 | Assert.IsNull( 207 | L1L2Cache.Get(key)); 208 | Assert.IsNull( 209 | L1Cache.Get(prefixedKey)); 210 | Assert.IsNull( 211 | L2Cache.Get(key)); 212 | } 213 | 214 | [TestMethod] 215 | public async Task SetAsyncTest() 216 | { 217 | var key = "key"; 218 | var value = " "u8.ToArray(); 219 | 220 | var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; 221 | 222 | await L1L2Cache 223 | .SetAsync(key, value) 224 | .ConfigureAwait(false); 225 | 226 | Assert.AreEqual( 227 | value, 228 | await L1L2Cache 229 | .GetAsync(key) 230 | .ConfigureAwait(false)); 231 | Assert.AreEqual( 232 | value, 233 | L1Cache.Get(prefixedKey)); 234 | Assert.AreEqual( 235 | value, 236 | await L2Cache 237 | .GetAsync(key) 238 | .ConfigureAwait(false)); 239 | } 240 | 241 | [TestMethod] 242 | public async Task SetAsyncRemoveAsyncTest() 243 | { 244 | var key = "key"; 245 | var value = " "u8.ToArray(); 246 | 247 | var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; 248 | 249 | await L1L2Cache 250 | .SetAsync(key, value) 251 | .ConfigureAwait(false); 252 | 253 | Assert.AreEqual( 254 | value, 255 | await L1L2Cache 256 | .GetAsync(key) 257 | .ConfigureAwait(false)); 258 | Assert.AreEqual( 259 | value, 260 | L1Cache.Get(prefixedKey)); 261 | Assert.AreEqual( 262 | value, 263 | await L2Cache 264 | .GetAsync(key) 265 | .ConfigureAwait(false)); 266 | 267 | await L1L2Cache 268 | .RemoveAsync(key) 269 | .ConfigureAwait(false); 270 | 271 | Assert.IsNull( 272 | await L1L2Cache 273 | .GetAsync(key) 274 | .ConfigureAwait(false)); 275 | Assert.IsNull( 276 | L1Cache.Get(prefixedKey)); 277 | Assert.IsNull( 278 | await L2Cache 279 | .GetAsync(key) 280 | .ConfigureAwait(false)); 281 | } 282 | } 283 | --------------------------------------------------------------------------------