diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..82b9f017 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,232 @@ +You are an expert C#/.NET developer. You help with .NET tasks by giving clean, well-designed, error-free, fast, secure, readable, and maintainable code that follows .NET conventions. You also give insights, best practices, general software design tips, and testing best practices. + +When invoked: +- Understand the user's .NET task and context +- Propose clean, organized solutions that follow .NET conventions +- Cover security (authentication, authorization, data protection) +- Use and explain patterns: Async/Await, Dependency Injection, Unit of Work, CQRS, Gang of Four +- Apply SOLID principles +- Plan and write tests (TDD/BDD) with xUnit, NUnit, or MSTest +- Improve performance (memory, async code, data access) + +# General C# Development + +- Follow the project's own conventions first, then common C# conventions. +- Keep naming, formatting, and project structure consistent. + +## Code Design Rules + +- DON'T add interfaces/abstractions unless used for external dependencies or testing. +- Don't wrap existing abstractions. +- Don't default to `public`. Least-exposure rule: `private` > `internal` > `protected` > `public` +- Keep names consistent; pick one style (e.g., `WithHostPort` or `WithBrowserPort`) and stick to it. +- Don't edit auto-generated code (`/api/*.cs`, `*.g.cs`, `// `). +- Comments explain **why**, not what. +- Don't add unused methods/params. +- When fixing one method, check siblings for the same issue. +- Reuse existing methods as much as possible +- Add comments when adding public methods +- Move user-facing strings (e.g., AnalyzeAndConfirmNuGetConfigChanges) into resource files. Keep error/help text localizable. + +## Error Handling & Edge Cases +- **Null checks**: use `ArgumentNullException.ThrowIfNull(x)`; for strings use `string.IsNullOrWhiteSpace(x)`; guard early. Avoid blanket `!`. +- **Exceptions**: choose precise types (e.g., `ArgumentException`, `InvalidOperationException`); don't throw or catch base Exception. +- **No silent catches**: don't swallow errors; log and rethrow or let them bubble. + + +## Goals for .NET Applications + +### Productivity +- Prefer modern C# (file-scoped ns, raw """ strings, switch expr, ranges/indices, async streams) when TFM allows. +- Keep diffs small; reuse code; avoid new layers unless needed. +- Be IDE-friendly (go-to-def, rename, quick fixes work). + +### Production-ready +- Secure by default (no secrets; input validate; least privilege). +- Resilient I/O (timeouts; retry with backoff when it fits). +- Structured logging with scopes; useful context; no log spam. +- Use precise exceptions; don’t swallow; keep cause/context. + +### Performance +- Simple first; optimize hot paths when measured. +- Stream large payloads; avoid extra allocs. +- Use Span/Memory/pooling when it matters. +- Async end-to-end; no sync-over-async. + +### Cloud-native / cloud-ready +- Cross-platform; guard OS-specific APIs. +- Diagnostics: health/ready when it fits; metrics + traces. +- Observability: ILogger + OpenTelemetry hooks. +- 12-factor: config from env; avoid stateful singletons. + +# .NET quick checklist + +## Do first + +* Read TFM + C# version. +* Check `global.json` SDK. + +## Initial check + +* App type: web / desktop / console / lib. +* Packages (and multi-targeting). +* Nullable on? (`enable` / `#nullable enable`) +* Repo config: `Directory.Build.*`, `Directory.Packages.props`. + +## C# version + +* **Don't** set C# newer than TFM default. +* C# 14 (NET 10+): extension members; `field` accessor; implicit `Span` conv; `?.=`; `nameof` with unbound generic; lambda param mods w/o types; partial ctors/events; user-defined compound assign. + +## Build + +* .NET 5+: `dotnet build`, `dotnet publish`. +* .NET Framework: May use `MSBuild` directly or require Visual Studio +* Look for custom targets/scripts: `Directory.Build.targets`, `build.cmd/.sh`, `Build.ps1`. + +## Good practice +* Always compile or check docs first if there is unfamiliar syntax. Don't try to correct the syntax if code can compile. +* Don't change TFM, SDK, or `` unless asked. + + +# Async Programming Best Practices + +* **Naming:** all async methods end with `Async` (incl. CLI handlers). +* **Always await:** no fire-and-forget; if timing out, **cancel the work**. +* **Cancellation end-to-end:** accept a `CancellationToken`, pass it through, call `ThrowIfCancellationRequested()` in loops, make delays cancelable (`Task.Delay(ms, ct)`). +* **Timeouts:** use linked `CancellationTokenSource` + `CancelAfter` (or `WhenAny` **and** cancel the pending task). +* **Context:** use `ConfigureAwait(false)` in helper/library code; omit in app entry/UI. +* **Stream JSON:** `GetAsync(..., ResponseHeadersRead)` → `ReadAsStreamAsync` → `JsonDocument.ParseAsync`; avoid `ReadAsStringAsync` when large. +* **Exit code on cancel:** return non-zero (e.g., `130`). +* **`ValueTask`:** use only when measured to help; default to `Task`. +* **Async dispose:** prefer `await using` for async resources; keep streams/readers properly owned. +* **No pointless wrappers:** don’t add `async/await` if you just return the task. + +## Immutability +- Prefer records to classes for DTOs + +# Testing best practices + +## Test structure + +- Separate test project: **`[ProjectName].Tests`**. +- Mirror classes: `CatDoor` -> `CatDoorTests`. +- Name tests by behavior: `WhenCatMeowsThenCatDoorOpens`. +- Follow existing naming conventions. +- Use **public instance** classes; avoid **static** fields. +- No branching/conditionals inside tests. + +## Unit Tests + +- One behavior per test; +- Avoid Unicode symbols. +- Follow the Arrange-Act-Assert (AAA) pattern +- Use clear assertions that verify the outcome expressed by the test name +- Avoid using multiple assertions in one test method. In this case, prefer multiple tests. +- When testing multiple preconditions, write a test for each +- When testing multiple outcomes for one precondition, use parameterized tests +- Tests should be able to run in any order or in parallel +- Avoid disk I/O; if needed, randomize paths, don't clean up, log file locations. +- Test through **public APIs**; don't change visibility; avoid `InternalsVisibleTo`. +- Require tests for new/changed **public APIs**. +- Assert specific values and edge cases, not vague outcomes. + +## Test workflow + +### Run Test Command +- Look for custom targets/scripts: `Directory.Build.targets`, `test.ps1/.cmd/.sh` +- .NET Framework: May use `vstest.console.exe` directly or require Visual Studio Test Explorer +- Work on only one test until it passes. Then run other tests to ensure nothing has been broken. + +### Code coverage (dotnet-coverage) +* **Tool (one-time):** +bash + `dotnet tool install -g dotnet-coverage` +* **Run locally (every time add/modify tests):** +bash + `dotnet-coverage collect -f cobertura -o coverage.cobertura.xml dotnet test` + +## Test framework-specific guidance + +- **Use the framework already in the solution** (xUnit/NUnit/MSTest) for new tests. + +### xUnit + +* Packages: `Microsoft.NET.Test.Sdk`, `xunit`, `xunit.runner.visualstudio` +* No class attribute; use `[Fact]` +* Parameterized tests: `[Theory]` with `[InlineData]` +* Setup/teardown: constructor and `IDisposable` + +### xUnit v3 + +* Packages: `xunit.v3`, `xunit.runner.visualstudio` 3.x, `Microsoft.NET.Test.Sdk` +* `ITestOutputHelper` and `[Theory]` are in `Xunit` + +### NUnit + +* Packages: `Microsoft.NET.Test.Sdk`, `NUnit`, `NUnit3TestAdapter` +* Class `[TestFixture]`, test `[Test]` +* Parameterized tests: **use `[TestCase]`** + +### MSTest + +* Class `[TestClass]`, test `[TestMethod]` +* Setup/teardown: `[TestInitialize]`, `[TestCleanup]` +* Parameterized tests: **use `[DataTestMethod]` + `[DataRow]`** + +### Assertions + +* If **FluentAssertions/AwesomeAssertions** are already used, prefer them. +* Otherwise, use the framework’s asserts. +* Use `Throws/ThrowsAsync` (or MSTest `Assert.ThrowsException`) for exceptions. + +## Mocking + +- Avoid mocks/Fakes if possible +- External dependencies can be mocked. Never mock code whose implementation is part of the solution under test. +- Try to verify that the outputs (e.g. return values, exceptions) of the mock match the outputs of the dependency. You can write a test for this but leave it marked as skipped/explicit so that developers can verify it later. + + +# Repository specific instructions + +### Big picture (what this repo is) +- DnsClient.NET is a .NET DNS client library (core assembly: `src/DnsClient`). The main public API surface centers on `LookupClient` (see `src/DnsClient/LookupClient.cs`). +The project targets multiple frameworks (see `src/DnsClient/DnsClient.csproj`) including net8/net6/netstandard and net472. + +### Architecture & responsibilities +- The library provides DNS query functionality with support for various record types, caching, retries, and name-server discovery. Key components include: + - `LookupClient`: main entry point for DNS queries. + - `NameServer`: represents DNS servers and handles discovery of system-configured servers. + - `DnsMessage`: represents DNS messages (requests/responses). + - `Resolvers`: internal classes that handle the actual network communication and query logic. + +### Conventions & patterns to follow +- Follow existing naming conventions (e.g., `WithXxx` methods for fluent configuration). +- Use `ILogger` for logging; see `Logging/LoggerFactory.cs` for setup. +- Use `async/await` for all I/O operations; avoid blocking calls. +- Preserve multi-targeting and platform-specific code paths; use `#if` directives as needed. +- Follow existing exception handling patterns; use specific exception types (e.g., `DnsResponseException`). + +### Build, test, and CI (commands & important files) +- The solution file: `DnsClientDotNet.sln` (root). Primary project: `src/DnsClient/DnsClient.csproj`. +- Local build (Windows/PowerShell): use the .NET SDK matching TargetFrameworks. Example: + - Build release: `dotnet build DnsClientDotNet.sln -c Release` + - Run tests (net8.0): `dotnet test test/**/*.csproj -c Release -f net8.0` + - Pack: `dotnet pack src\DnsClient\DnsClient.csproj -c Release -o .\artifacts` +- CI: see `azure-pipelines-ci.yml` — Linux and Windows jobs run `dotnet build`, `dotnet test` and `dotnet pack`. Tests on Windows use `--collect "Code coverage" --settings:.runsettings`. + +### Tests & diagnostics +- Tests are under `test/`. Use `dotnet test` with the same TFMs used in CI (net8.0 on Linux). Use `.runsettings` for coverage/collectors. +- When changing network-related behavior, add integration-style tests under `test/` that are marked appropriately or mock network interfaces via existing test helpers in `test/DnsClient.TestsCommon`. + +### Integration points & external dependencies +- The library reads OS network configuration (Linux `/etc/resolv.conf` path used in `NameServer`) and has Windows-specific helpers under `src/DnsClient/Windows` (`IpHlpApi` usage). Be careful when modifying name-server discovery logic — it's platform sensitive. +- NuGet packaging includes README (`PackageReadmeFile`) and strong-name signing (`tools/key.snk` referenced in csproj). + +### Helpful places to look (concrete examples) +- Main API: `src/DnsClient/LookupClient.cs` — query flow, caching, skip worker, and name-server refresh logic. +- Name server discovery: `src/DnsClient/NameServer.cs` — ValidateNameServers(), ResolveNameServersNative(), platform branches. +- Project config & targets: `src/DnsClient/DnsClient.csproj` — TFMs, package metadata, framework-specific package refs. +- CI: `azure-pipelines-ci.yml` — exact commands and test collection flags used in CI. +- Samples: `samples/MiniDig` for a CLI usage example of the library. + diff --git a/README.md b/README.md index 87cd6fce..816b37db 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,7 @@ This has some limitations though and before you use Microsoft.Garnet in producti See https://github.com/microsoft/garnet for details. ## Beta Packages -Beta versions of the CacheManager packages are getting pushed to https://www.myget.org/gallery/cachemanager on each build. -Add the following feed, if you want to play with the not yet released bits: - - https://www.myget.org/F/cachemanager/api/v3/index.json +Beta versions of the CacheManager packages are getting pushed to https://www.myget.org/feed/Packages/dnsclient on each build. To find which check-in created which build, use this [build history](https://ci.appveyor.com/project/MichaCo/cachemanager-ak9g3/history). diff --git a/benchmarks/CacheManager.Config.Tests/Program.cs b/benchmarks/CacheManager.Config.Tests/Program.cs index 41836cd2..42b53230 100644 --- a/benchmarks/CacheManager.Config.Tests/Program.cs +++ b/benchmarks/CacheManager.Config.Tests/Program.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using CacheManager.Core; using Garnet; -using Garnet.client; using Garnet.server; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -28,7 +27,7 @@ public static GarnetServer StartServer(ILoggerFactory loggerFactory) return server; } - public static void Main(string[] args) + public static async Task Main(string[] args) { ThreadPool.SetMinThreads(100, 100); @@ -61,7 +60,7 @@ public static void Main(string[] args) builder .WithRedisCacheHandle("redis", true) - .WithExpiration(ExpirationMode.Sliding, TimeSpan.FromSeconds(60)) + .WithExpiration(ExpirationMode.Absolute, TimeSpan.FromMinutes(60)) .DisableStatistics(); builder.WithRedisBackplane("redis"); @@ -83,8 +82,21 @@ public static void Main(string[] args) builder.WithBondCompactBinarySerializer(); var cacheA = new BaseCacheManager(builder.Build()); + var cacheB = new BaseCacheManager(builder.Build()); cacheA.Clear(); + cacheA.Add("key", "value", "region"); + + var val = cacheA.Get("key", "region"); + + var val2 = cacheB.AddOrUpdate("key", "region", "added?", (v) => v + "updated"); + + await Task.Delay(100); + + Console.WriteLine(cacheA.Get("key", "region")); + + Console.ReadLine(); + for (var i = 0; i < iterations; i++) { try diff --git a/src/CacheManager.StackExchange.Redis/RedisCacheHandle.cs b/src/CacheManager.StackExchange.Redis/RedisCacheHandle.cs index 1bbf54eb..487a39f9 100644 --- a/src/CacheManager.StackExchange.Redis/RedisCacheHandle.cs +++ b/src/CacheManager.StackExchange.Redis/RedisCacheHandle.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; -using System.Data.Common; using System.Linq; -using System.Text; using CacheManager.Core; using CacheManager.Core.Internal; using Microsoft.Extensions.Logging; @@ -19,7 +17,6 @@ namespace CacheManager.Redis public class RedisCacheHandle : BaseCacheHandle { private static readonly TimeSpan MinimumExpirationTimeout = TimeSpan.FromMilliseconds(1); - private const string Base64Prefix = "base64\0"; private const string HashFieldCreated = "created"; private const string HashFieldExpirationMode = "expiration"; private const string HashFieldExpirationTimeout = "timeout"; @@ -30,8 +27,8 @@ public class RedisCacheHandle : BaseCacheHandle private static readonly string _scriptAdd = $@" if redis.call('HSETNX', KEYS[1], '{HashFieldValue}', ARGV[1]) == 1 then - if ARGV[7] ~= nil then - if (redis.call('HMSET', ARGV[7], KEYS[1], 'regionKey')) then + if KEYS[2] ~= nil then + if (redis.call('HMSET', KEYS[2], KEYS[1], 'regionKey')) then else return -2 end @@ -49,8 +46,8 @@ public class RedisCacheHandle : BaseCacheHandle end"; private static readonly string _scriptPut = $@" -if ARGV[7] ~= nil then - if (redis.call('HMSET', ARGV[7], KEYS[1], 'regionKey')) then +if KEYS[2] ~= nil then + if (redis.call('HMSET', KEYS[2], KEYS[1], 'regionKey')) then else return -2 end @@ -192,8 +189,6 @@ public override int Count } } -#pragma warning disable CS3003 // Type is not CLS-compliant - /// /// Gets the servers. /// @@ -206,8 +201,6 @@ public override int Count /// The server features. public RedisFeatures Features => _connection.Features; -#pragma warning restore CS3003 // Type is not CLS-compliant - /// /// Gets a value indicating whether we can use the lua implementation instead of manual. /// This flag will be set automatically via feature detection based on the Redis server version @@ -250,8 +243,10 @@ public override void ClearRegion(string region) { Retry(() => { + RedisKey regionKey = GetRegionKey(region); + // we are storing all keys stored in the region in the hash for key=region - var hashKeys = _connection.Database.HashKeys(region); + var hashKeys = _connection.Database.HashKeys(regionKey); if (hashKeys.Length > 0) { @@ -297,7 +292,6 @@ public override UpdateItemResult Update(string key, string region, } var tries = 0; - var fullKey = GetKey(key, region); return Retry(() => { @@ -305,7 +299,7 @@ public override UpdateItemResult Update(string key, string region, { tries++; - var item = GetCacheItemAndVersion(key, region, out int version); + var item = GetCacheItemAndVersion(key, region, out var version); if (item == null) { @@ -324,13 +318,16 @@ public override UpdateItemResult Update(string key, string region, } // resetting TTL on update, too - var result = Eval(ScriptType.Update, fullKey, new[] - { - ToRedisValue(newValue), - version, - (int)item.ExpirationMode, - (long)item.ExpirationTimeout.TotalMilliseconds, - }); + var result = Eval( + item.Key, + item.Region, + ScriptType.Update, + [ + ToRedisValue(newValue), + version, + (int)item.ExpirationMode, + (long)item.ExpirationTimeout.TotalMilliseconds, + ]); if (result != null && !result.IsNull) { @@ -349,9 +346,14 @@ public override UpdateItemResult Update(string key, string region, }); } -#pragma warning disable SA1600 -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - + /// + /// Update implementation without using lua scripts. + /// + /// The key. + /// The region. + /// The value function which should run to get a new value. + /// Number of retries in case of collisions. + /// The result. protected UpdateItemResult UpdateNoScript(string key, string region, Func updateValue, int maxRetries) { var committed = false; @@ -412,9 +414,6 @@ protected UpdateItemResult UpdateNoScript(string key, string region }); } -#pragma warning restore CS1591 -#pragma warning restore SA1600 - /// /// Adds a value to the cache. /// @@ -471,13 +470,11 @@ private CacheItem GetCacheItemAndVersion(string key, string region, return GetCacheItemInternalNoScript(key, region); } - var fullKey = GetKey(key, region); - - var result = Retry(() => Eval(ScriptType.Get, fullKey)); + var result = Retry(() => Eval(key, region, ScriptType.Get)); if (result == null || result.IsNull) { // something went wrong. HMGET should return at least a null result for each requested field - throw new InvalidOperationException("Error retrieving " + fullKey); + throw new InvalidOperationException("Error retrieving " + GetKey(key, region)); } var values = (RedisValue[])result; @@ -542,9 +539,12 @@ private CacheItem GetCacheItemAndVersion(string key, string region, return cacheItem; } -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member -#pragma warning disable SA1600 - + /// + /// Implementation of getting the cache item without using lua scripts. + /// + /// The key. + /// The region. + /// The cached item, or null if not found. protected CacheItem GetCacheItemInternalNoScript(string key, string region) { return Retry(() => @@ -632,9 +632,6 @@ protected CacheItem GetCacheItemInternalNoScript(string key, string }); } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -#pragma warning restore SA1600 - /// /// Puts the into the cache. If the item exists it will get updated /// with the new value. If the item doesn't exist, the item will be added to the cache. @@ -660,8 +657,6 @@ protected override void PutInternalPrepared(CacheItem item) => /// protected override bool RemoveInternal(string key) => RemoveInternal(key, null); -#pragma warning disable CSE0003 - /// /// Removes a value from the cache for the specified key. /// @@ -679,7 +674,7 @@ protected override bool RemoveInternal(string key, string region) // clean up region if (!string.IsNullOrWhiteSpace(region)) { - _connection.Database.HashDelete(region, fullKey, CommandFlags.FireAndForget); + _connection.Database.HashDelete(GetRegionKey(region), fullKey, CommandFlags.FireAndForget); } // remove key @@ -695,78 +690,80 @@ private void SubscribeKeyspaceNotifications() channel: RedisChannel.Literal($"__keyevent@{_redisConfiguration.Database}__:expired"), handler: (channel, key) => { - var tupple = ParseKey(key); + var (Key, Region) = ParseKey(key); if (Logger.IsEnabled(LogLevel.Debug)) { - Logger.LogDebug("Got expired event for key '{0}:{1}'", tupple.Item2, tupple.Item1); + Logger.LogDebug("Got expired event for key '{0}:{1}'", Key, Region); } // we cannot return the original value here because we don't have it - TriggerCacheSpecificRemove(tupple.Item1, tupple.Item2, CacheItemRemovedReason.Expired, null); + TriggerCacheSpecificRemove(Key, Region, CacheItemRemovedReason.Expired, null); }); _connection.Subscriber.Subscribe( channel: RedisChannel.Literal($"__keyevent@{_redisConfiguration.Database}__:evicted"), handler: (channel, key) => { - var tupple = ParseKey(key); + var (Key, Region) = ParseKey(key); if (Logger.IsEnabled(LogLevel.Debug)) { - Logger.LogDebug("Got evicted event for key '{0}:{1}'", tupple.Item2, tupple.Item1); + Logger.LogDebug("Got evicted event for key '{0}:{1}'", Region, Key); } // we cannot return the original value here because we don't have it - TriggerCacheSpecificRemove(tupple.Item1, tupple.Item2, CacheItemRemovedReason.Evicted, null); + TriggerCacheSpecificRemove(Key, Region, CacheItemRemovedReason.Evicted, null); }); _connection.Subscriber.Subscribe( channel: RedisChannel.Literal($"__keyevent@{_redisConfiguration.Database}__:del"), handler: (channel, key) => { - var tupple = ParseKey(key); + var (Key, Region) = ParseKey(key); if (Logger.IsEnabled(LogLevel.Debug)) { - Logger.LogDebug("Got del event for key '{0}:{1}'", tupple.Item2, tupple.Item1); + Logger.LogDebug("Got del event for key '{0}:{1}'", Region, Key); } // we cannot return the original value here because we don't have it - TriggerCacheSpecificRemove(tupple.Item1, tupple.Item2, CacheItemRemovedReason.ExternalDelete, null); + TriggerCacheSpecificRemove(Key, Region, CacheItemRemovedReason.ExternalDelete, null); }); } -#pragma warning restore CSE0003 - - private static Tuple ParseKey(string value) + internal static (string Key, string Region) ParseKey(string value) { - if (value == null) + if (string.IsNullOrWhiteSpace(value)) { - return Tuple.Create(null, null); + return (null, null); } - var sepIndex = value.IndexOf(':'); - var hasRegion = sepIndex > 0; - var key = value; string region = null; + var key = value; - if (hasRegion) + // checking if region is defined + if (value.StartsWith("{")) { - region = value.Substring(0, sepIndex); - key = value.Substring(sepIndex + 1); + // region is defined by {...}:key using hastagging {} for redis cluster support + ":" as separator. + var sepIndex = value.IndexOf("}:"); - if (region.StartsWith(Base64Prefix)) + // Validating region start + if (sepIndex < 0) { - region = region.Substring(Base64Prefix.Length); - region = Encoding.UTF8.GetString(Convert.FromBase64String(region)); + throw new FormatException("Invalid key format, expected region to start with '{' and end with '}:'."); } - } - if (key.StartsWith(Base64Prefix)) - { - key = key.Substring(Base64Prefix.Length); - key = Encoding.UTF8.GetString(Convert.FromBase64String(key)); + // Starting on index 1, expecting starting with "{" + region = value.Substring(1, sepIndex - 1); + + if (string.IsNullOrEmpty(region)) + { + throw new FormatException("Invalid key format, region name cannot be empty."); + } + + // key starts after 2 chars "}:" + key = value.Substring(sepIndex + 2); } - return Tuple.Create(key, region); + return (key, region); } private static void ValidateExpirationTimeout(CacheItem item) @@ -777,35 +774,38 @@ private static void ValidateExpirationTimeout(CacheItem item) } } - private string GetKey(string key, string region = null) + internal static string GetKey(string key, string region = null) { if (string.IsNullOrWhiteSpace(key)) { throw new ArgumentNullException(nameof(key)); } - // for notifications, we have to get key and region back from the key stored in redis. - // in case the key and or region itself contains the separator, there would be no way to do so... - // So, only if that feature is enabled, we'll encode the key and/or region in that case - // and the ParseKey method will respect that, too, and decodes the key and/or region. - if (_redisConfiguration.KeyspaceNotificationsEnabled && key.Contains(":")) - { - key = Base64Prefix + Convert.ToBase64String(Encoding.UTF8.GetBytes(key)); - } - - var fullKey = key; - if (!string.IsNullOrWhiteSpace(region)) { - if (_redisConfiguration.KeyspaceNotificationsEnabled && region.Contains(":")) + if (key.Contains("{") || key.Contains("}")) { - region = Base64Prefix + Convert.ToBase64String(Encoding.UTF8.GetBytes(region)); + throw new ArgumentException("Key cannot contain '{' or '}'. These are reserved for region handling in redis keys."); } + if (region.Contains("{") || region.Contains("}")) + { + throw new ArgumentException("Region cannot contain '{' or '}'. These are reserved for region handling in redis keys."); + } + + return $"{GetRegionKey(region)}:{key}"; + } + + return key; + } - fullKey = string.Concat(region, ":", key); + internal static string GetRegionKey(string region) + { + if (string.IsNullOrWhiteSpace(region)) + { + return null; } - return fullKey; + return $"{{{region}}}"; } private TCacheValue FromRedisValue(RedisValue value, string valueType) @@ -851,14 +851,13 @@ private bool Set(CacheItem item, When when, bool sync = false) return SetNoScript(item, when, sync); } - var fullKey = GetKey(item.Key, item.Region); var value = ToRedisValue(item.Value); var flags = sync ? CommandFlags.None : CommandFlags.FireAndForget; ValidateExpirationTimeout(item); - // ARGV [1]: value, [2]: type, [3]: expirationMode, [4]: expirationTimeout(millis), [5]: created(ticks) + // ARGV [1]: value, [2]: type, [3]: expirationMode, [4]: expirationTimeout(millis), [5]: created(ticks), [6]: usesDefaultExpiration var parameters = new RedisValue[] { value, @@ -866,18 +865,17 @@ private bool Set(CacheItem item, When when, bool sync = false) (int)item.ExpirationMode, (long)item.ExpirationTimeout.TotalMilliseconds, item.CreatedUtc.Ticks, - item.UsesExpirationDefaults, - string.IsNullOrWhiteSpace(item.Region) ? string.Empty : item.Region + item.UsesExpirationDefaults }; RedisResult result; if (when == When.NotExists) { - result = Eval(ScriptType.Add, fullKey, parameters, flags); + result = Eval(item.Key, item.Region, ScriptType.Add, parameters, flags); } else { - result = Eval(ScriptType.Put, fullKey, parameters, flags); + result = Eval(item.Key, item.Region, ScriptType.Put, parameters, flags); } if (result.IsNull && flags.HasFlag(CommandFlags.FireAndForget)) @@ -913,6 +911,8 @@ private bool SetNoScript(CacheItem item, When when, bool sync = fal return Retry(() => { var fullKey = GetKey(item.Key, item.Region); + var regionKey = GetRegionKey(item.Region); + var value = ToRedisValue(item.Value); ValidateExpirationTimeout(item); @@ -938,16 +938,13 @@ private bool SetNoScript(CacheItem item, When when, bool sync = fal if (!string.IsNullOrWhiteSpace(item.Region)) { // setting region lookup key if region is being used - _connection.Database.HashSet(item.Region, fullKey, "regionKey", When.Always, CommandFlags.FireAndForget); + _connection.Database.HashSet(regionKey, fullKey, "regionKey", When.Always, CommandFlags.FireAndForget); } // set the additional fields in case sliding expiration should be used in this // case we have to store the expiration mode and timeout on the hash, too so // that we can extend the expiration period every time we do a get - if (metaValues != null) - { - _connection.Database.HashSet(fullKey, metaValues, flags); - } + _connection.Database.HashSet(fullKey, metaValues, flags); if (item.ExpirationMode != ExpirationMode.None && item.ExpirationMode != ExpirationMode.Default) { @@ -964,7 +961,7 @@ private bool SetNoScript(CacheItem item, When when, bool sync = fal }); } - private RedisResult Eval(ScriptType scriptType, RedisKey redisKey, RedisValue[] values = null, CommandFlags flags = CommandFlags.None) + private RedisResult Eval(string key, string region, ScriptType scriptType, RedisValue[] values = null, CommandFlags flags = CommandFlags.None) { if (!_scriptsLoaded) { @@ -989,13 +986,18 @@ private RedisResult Eval(ScriptType scriptType, RedisKey redisKey, RedisValue[] try { + RedisKey fullKey = GetKey(key, region); + RedisKey regionKey = GetRegionKey(region); + + RedisKey[] keys = region == null ? [fullKey] : [fullKey, regionKey]; + if (_canPreloadScripts && script != null) { - return _connection.Database.ScriptEvaluate(script.Hash, new[] { redisKey }, values, flags); + return _connection.Database.ScriptEvaluate(script.Hash, keys, values, flags); } else { - return _connection.Database.ScriptEvaluate(luaScript.ExecutableScript, new[] { redisKey }, values, flags); + return _connection.Database.ScriptEvaluate(luaScript.ExecutableScript, keys, values, flags); } } catch (RedisServerException ex) when (ex.Message.StartsWith("NOSCRIPT", StringComparison.OrdinalIgnoreCase)) diff --git a/test/CacheManager.Tests/RedisTests.cs b/test/CacheManager.Tests/RedisTests.cs index 89d6dba1..27d78717 100644 --- a/test/CacheManager.Tests/RedisTests.cs +++ b/test/CacheManager.Tests/RedisTests.cs @@ -13,1575 +13,1642 @@ using Xunit; using Xunit.Sdk; -namespace CacheManager.Tests +namespace CacheManager.Tests; + +/// +/// To run the redis tests, make sure a local redis server instance is running. See redis folder under tools. +/// +[ExcludeFromCodeCoverage] +public class RedisTests : IClassFixture { - /// - /// To run the redis tests, make sure a local redis server instance is running. See redis folder under tools. - /// - [ExcludeFromCodeCoverage] - public class RedisTests : IClassFixture + private enum CacheEvent { - private enum CacheEvent - { - OnAdd, - OnPut, - OnRemove, - OnUpdate, - OnClear, - OnClearRegion - } + OnAdd, + OnPut, + OnRemove, + OnUpdate, + OnClear, + OnClearRegion + } - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public void Redis_TwoServerSetup_ClearWorks() - { - using var server1 = RedisTestFixture.StartServer(7001); - using var server2 = RedisTestFixture.StartServer(7002); + [Fact] + public void GetKey_InvalidTokensInKey_Throws() + { + Action act = () => RedisCacheHandle.GetKey("m{y}key", "myregion"); + act.Should().Throw().WithMessage("Key cannot contain*"); + } - var cache1 = new BaseCacheManager(CacheConfigurationBuilder - .BuildConfiguration(a => - { - a.WithRedisCacheHandle("redis1"); - a.WithRedisConfiguration("redis1", c => c.WithEndpoint("localhost", 7001).WithAllowAdmin()); - a.WithJsonSerializer(); - })); + [Fact] + public void GetKey_InvalidTokensInRegion_Throws() + { + Action act = () => RedisCacheHandle.GetKey("mykey", "my{region}"); + act.Should().Throw().WithMessage("Region cannot contain*"); + } - var cache2 = new BaseCacheManager(CacheConfigurationBuilder - .BuildConfiguration(a => - { - a.WithRedisCacheHandle("redis2"); - a.WithRedisConfiguration("redis2", c => c.WithEndpoint("localhost", 7002).WithAllowAdmin()); - a.WithJsonSerializer(); - })); + [Fact] + public void GetKey_ValidKeyAndRegion_ReturnsCombinedKey() + { + var key = RedisCacheHandle.GetKey("mykey", "myregion"); + key.Should().Be("{myregion}:mykey"); + } - var cacheBoth = new BaseCacheManager(CacheConfigurationBuilder - .BuildConfiguration(a => - { - a.WithRedisCacheHandle("redis"); - a.WithJsonSerializer(); - a.WithRedisConfiguration("redis", c => c - .WithEndpoint("localhost", 7001) - .WithEndpoint("localhost", 7002) - .WithAllowAdmin()); - })); + [Fact] + public void GetKey_ValidKeyNoRegion_ReturnsKey() + { + var key = RedisCacheHandle.GetKey("mykey"); + key.Should().Be("mykey"); + } + + [Fact] + public void ParseKey_Null_ReturnsNulls() + { + var (key, region) = RedisCacheHandle.ParseKey(null); + key.Should().BeNull(); + region.Should().BeNull(); + } + + [Fact] + public void ParseKey_PlainKey_NoRegion() + { + var (key, region) = RedisCacheHandle.ParseKey("plain-key"); + key.Should().Be("plain-key"); + region.Should().BeNull(); + } + + [Fact] + public void ParseKey_KeyWithRegion_ParsesRegionAndKey() + { + var (key, region) = RedisCacheHandle.ParseKey("{my-region}:my-key"); + region.Should().Be("my-region"); + key.Should().Be("my-key"); + } + [Fact] + public void ParseKey_InvalidFormatMissingColon_Throws() + { + Action act = () => RedisCacheHandle.ParseKey("{my-region}my-key"); + act.Should().Throw(); + } - var testKey = Guid.NewGuid().ToString(); - var value = Guid.NewGuid().ToString(); - - cacheBoth.Add(testKey, value); + [Fact] + public void ParseKey_RegionOnly_Throws() + { + Action act = () => RedisCacheHandle.ParseKey("{regionOnly}"); + act.Should().Throw(); + } - var exists1 = cache1.Exists(testKey); - var exists2 = cache2.Exists(testKey); + [Fact] + public void ParseKey_EmptyRegion_Throws() + { + Action act = () => RedisCacheHandle.ParseKey("{}:key"); + act.Should().Throw(); + } - Assert.True(exists1 || exists2); + [Fact] + public void ParseKey_RegionWithSeparators_ParsesCorrectly() + { + var (key, region) = RedisCacheHandle.ParseKey("{key:region:a}:key:bla"); + region.Should().Be("key:region:a"); + key.Should().Be("key:bla"); + } - if (!exists1) + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public void Redis_TwoServerSetup_ClearWorks() + { + using var server1 = RedisTestFixture.StartServer(7001); + using var server2 = RedisTestFixture.StartServer(7002); + + var cache1 = new BaseCacheManager(CacheConfigurationBuilder + .BuildConfiguration(a => { - cache1[testKey] = "other value"; - } - else + a.WithRedisCacheHandle("redis1"); + a.WithRedisConfiguration("redis1", c => c.WithEndpoint("localhost", 7001).WithAllowAdmin()); + a.WithJsonSerializer(); + })); + + var cache2 = new BaseCacheManager(CacheConfigurationBuilder + .BuildConfiguration(a => { - Assert.False(exists2); - cache2[testKey] = "other value"; - } + a.WithRedisCacheHandle("redis2"); + a.WithRedisConfiguration("redis2", c => c.WithEndpoint("localhost", 7002).WithAllowAdmin()); + a.WithJsonSerializer(); + })); - cacheBoth.Clear(); + var cacheBoth = new BaseCacheManager(CacheConfigurationBuilder + .BuildConfiguration(a => + { + a.WithRedisCacheHandle("redis"); + a.WithJsonSerializer(); + a.WithRedisConfiguration("redis", c => c + .WithEndpoint("localhost", 7001) + .WithEndpoint("localhost", 7002) + .WithAllowAdmin()); + })); - Assert.False(cache1.Exists(testKey)); - Assert.False(cache2.Exists(testKey)); - } + var testKey = Guid.NewGuid().ToString(); + var value = Guid.NewGuid().ToString(); - [Fact] - public void Redis_WithoutSerializer_ShouldThrow() - { - var cfg = CacheConfigurationBuilder.BuildConfiguration( - settings => - settings - .WithRedisConfiguration("redis-key", "localhost") - .WithRedisCacheHandle("redis-key")) as CacheManagerConfiguration; - - Action act = () => new BaseCacheManager(cfg); - act.Should().Throw().WithMessage("*requires serialization*"); - } + cacheBoth.Add(testKey, value); + + var exists1 = cache1.Exists(testKey); + var exists2 = cache2.Exists(testKey); - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public void Redis_Extensions_WithClient() + Assert.True(exists1 || exists2); + + if (!exists1) { - var configKey = Guid.NewGuid().ToString(); - var client = ConnectionMultiplexer.Connect("localhost:6379"); - var cache = CacheFactory.Build( - s => s - .WithJsonSerializer() - .WithRedisConfiguration(configKey, client) - .WithRedisCacheHandle(configKey)); - - var handle = cache.CacheHandles.OfType>().First(); - var cfg = RedisConfigurations.GetConfiguration(configKey); - - Assert.Equal(handle.Configuration.Name, configKey); - Assert.Equal(0, cfg.Database); - Assert.Equal("localhost:6379", cfg.ConnectionString); - - // cleanup - RedisConnectionManager.RemoveConnection(client.Configuration); - client.Dispose(); + cache1[testKey] = "other value"; } - - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public void Redis_Extensions_WithClientWithDb() + else { - var configKey = Guid.NewGuid().ToString(); - var client = ConnectionMultiplexer.Connect("localhost:6379"); - var cache = CacheFactory.Build( - s => s - .WithJsonSerializer() - .WithRedisConfiguration(configKey, client, 23) - .WithRedisCacheHandle(configKey)); - - var handle = cache.CacheHandles.OfType>().First(); - var cfg = RedisConfigurations.GetConfiguration(configKey); - - Assert.Equal(handle.Configuration.Name, configKey); - Assert.Equal(23, cfg.Database); - Assert.Equal("localhost:6379", cfg.ConnectionString); - - // cleanup - RedisConnectionManager.RemoveConnection(client.Configuration); - client.Dispose(); + Assert.False(exists2); + cache2[testKey] = "other value"; } - [Fact] - [Trait("category", "Redis")] - public async Task Redis_BackplaneEvents_Add() - { - var key = Guid.NewGuid().ToString(); + cacheBoth.Clear(); - await TestBackplaneEventDistributed( - CacheEvent.OnAdd, - (cacheA) => - { - cacheA.Add(key, key); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key].Should().Be(key); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key].Should().Be(key); - }); - } + Assert.False(cache1.Exists(testKey)); + Assert.False(cache2.Exists(testKey)); + } - [Fact] - [Trait("category", "Redis")] - public async Task Redis_ValidateVersion_AddPutGetUpdate() - { - var configKey = Guid.NewGuid().ToString(); - var multi = ConnectionMultiplexer.Connect("localhost"); - var cache = CacheFactory.Build( - s => s - .WithRedisConfiguration(configKey, multi) - .WithBondCompactBinarySerializer() - .WithRedisCacheHandle(configKey)); - - // don't keep it and also dispose it later (seems appveyor doesn't like too many open connections) - RedisConnectionManager.RemoveConnection(multi.Configuration); - - // act/assert - using (multi) - using (cache) - { - var key = Guid.NewGuid().ToString(); - var value = new Poco() { Id = 23, Something = "§asdad" }; - cache.Add(key, value); - await Task.Delay(10); + [Fact] + public void Redis_WithoutSerializer_ShouldThrow() + { + var cfg = CacheConfigurationBuilder.BuildConfiguration( + settings => + settings + .WithRedisConfiguration("redis-key", "localhost") + .WithRedisCacheHandle("redis-key")) as CacheManagerConfiguration; - var version = (int)multi.GetDatabase(0).HashGet(key, "version"); - version.Should().Be(1); + Action act = () => new BaseCacheManager(cfg); + act.Should().Throw().WithMessage("*requires serialization*"); + } - cache.Put(key, value); - await Task.Delay(10); + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public void Redis_Extensions_WithClient() + { + var configKey = Guid.NewGuid().ToString(); + var client = ConnectionMultiplexer.Connect("localhost:6379"); + var cache = CacheFactory.Build( + s => s + .WithJsonSerializer() + .WithRedisConfiguration(configKey, client) + .WithRedisCacheHandle(configKey)); - version = (int)multi.GetDatabase(0).HashGet(key, "version"); - version.Should().Be(2); + var handle = cache.CacheHandles.OfType>().First(); + var cfg = RedisConfigurations.GetConfiguration(configKey); - cache.Update(key, r => { r.Something = "new text"; return r; }); - await Task.Delay(10); + Assert.Equal(configKey, handle.Configuration.Name); + Assert.Equal(0, cfg.Database); + Assert.Equal("localhost:6379", cfg.ConnectionString); - version = (int)multi.GetDatabase(0).HashGet(key, "version"); - version.Should().Be(3); - cache.Get(key).Something.Should().Be("new text"); - } - } + // cleanup + RedisConnectionManager.RemoveConnection(client.Configuration); + client.Dispose(); + } - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public void Redis_UseExistingConnection() - { - var conConfig = new ConfigurationOptions() - { - ConnectTimeout = 10000, - AbortOnConnectFail = false, - ConnectRetry = 10 - }; - conConfig.EndPoints.Add("localhost:6379"); + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public void Redis_Extensions_WithClientWithDb() + { + var configKey = Guid.NewGuid().ToString(); + var client = ConnectionMultiplexer.Connect("localhost:6379"); + var cache = CacheFactory.Build( + s => s + .WithJsonSerializer() + .WithRedisConfiguration(configKey, client, 23) + .WithRedisCacheHandle(configKey)); - var multiplexer = ConnectionMultiplexer.Connect(conConfig); + var handle = cache.CacheHandles.OfType>().First(); + var cfg = RedisConfigurations.GetConfiguration(configKey); - var cfg = CacheConfigurationBuilder.BuildConfiguration( - s => s - .WithJsonSerializer() - .WithRedisConfiguration("redisKey", multiplexer) - .WithRedisCacheHandle("redisKey")); + Assert.Equal(configKey, handle.Configuration.Name); + Assert.Equal(23, cfg.Database); + Assert.Equal("localhost:6379", cfg.ConnectionString); - RedisConnectionManager.RemoveConnection(multiplexer.Configuration); + // cleanup + RedisConnectionManager.RemoveConnection(client.Configuration); + client.Dispose(); + } - using (multiplexer) - using (var cache = new BaseCacheManager(cfg)) + [Fact] + [Trait("category", "Redis")] + public async Task Redis_BackplaneEvents_Add() + { + var key = Guid.NewGuid().ToString(); + + await TestBackplaneEventDistributed( + CacheEvent.OnAdd, + (cacheA) => { - cache.Add(Guid.NewGuid().ToString(), 12345); - } - } + cacheA.Add(key, key); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key].Should().Be(key); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key].Should().Be(key); + }); + } - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_BackplaneEvents_AddWithRegion() + [Fact] + [Trait("category", "Redis")] + public async Task Redis_ValidateVersion_AddPutGetUpdate() + { + var configKey = Guid.NewGuid().ToString(); + var multi = ConnectionMultiplexer.Connect("localhost"); + var cache = CacheFactory.Build( + s => s + .WithRedisConfiguration(configKey, multi) + .WithBondCompactBinarySerializer() + .WithRedisCacheHandle(configKey)); + + // don't keep it and also dispose it later (seems appveyor doesn't like too many open connections) + RedisConnectionManager.RemoveConnection(multi.Configuration); + + // act/assert + using (multi) + using (cache) { var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); + var value = new Poco() { Id = 23, Something = "§asdad" }; + cache.Add(key, value); + await Task.Delay(10); - await TestBackplaneEventDistributed( - CacheEvent.OnAdd, - (cacheA) => - { - cacheA.Add(key, key, region); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key, region].Should().Be(key); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key, region].Should().Be(key); - }); - } + var version = (int)multi.GetDatabase(0).HashGet(key, "version"); + version.Should().Be(1); - /// - /// Testing in memory cache only with backplane through redis (not using Redis as cache at all) - /// - /// - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_BackplaneEvents_InMemory_AddWithRegion() - { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); + cache.Put(key, value); + await Task.Delay(10); - await TestBackplaneEventInMemory( - CacheEvent.OnAdd, - (cacheA, cacheB) => - { - // in memory is not distributed, adding only to CacheA the event triggered on cache B does trigger but cacheB doesn't have the item. - cacheB.Add(key, key, region); - cacheA.Add(key, key, region); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); + version = (int)multi.GetDatabase(0).HashGet(key, "version"); + version.Should().Be(2); - // cannot test origin as there might be two events triggered, one local one remote - cacheA[key, region].Should().Be(key); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); + cache.Update(key, r => { r.Something = "new text"; return r; }); + await Task.Delay(10); - // cannot test origin as there might be two events triggered, one local one remote - cacheB[key, region].Should().Be(key); - }, - expectedRemoteTriggers: 2); + version = (int)multi.GetDatabase(0).HashGet(key, "version"); + version.Should().Be(3); + cache.Get(key).Something.Should().Be("new text"); } + } - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_BackplaneEvents_Put() + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public void Redis_UseExistingConnection() + { + var conConfig = new ConfigurationOptions() { - var key = Guid.NewGuid().ToString(); + ConnectTimeout = 10000, + AbortOnConnectFail = false, + ConnectRetry = 10 + }; + conConfig.EndPoints.Add("localhost:6379"); - await TestBackplaneEventDistributed( - CacheEvent.OnPut, - (cacheA) => - { - cacheA.Add(key, key); - cacheA.Put(key, "new val"); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key].Should().Be("new val"); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key].Should().Be("new val"); - }); - } + var multiplexer = ConnectionMultiplexer.Connect(conConfig); - /// - /// Testing in memory cache only with backplane through redis (not using Redis as cache at all) - /// - /// - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_BackplaneEvents_InMemory_Put() - { - var key = Guid.NewGuid().ToString(); + var cfg = CacheConfigurationBuilder.BuildConfiguration( + s => s + .WithJsonSerializer() + .WithRedisConfiguration("redisKey", multiplexer) + .WithRedisCacheHandle("redisKey")); - await TestBackplaneEventInMemory( - CacheEvent.OnPut, - (cacheA, cacheB) => - { - // in memory is not distributed, adding only to CacheA the event triggered on cache B does trigger but cacheB doesn't have the item. - cacheA.Add(key, key); - cacheA.Put(key, "new val"); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key].Should().Be("new val"); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key].Should().Be(null); - }, - expectedRemoteTriggers: 1); - } + RedisConnectionManager.RemoveConnection(multiplexer.Configuration); - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_BackplaneEvents_PutWithRegion() + using (multiplexer) + using (var cache = new BaseCacheManager(cfg)) { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); - - await TestBackplaneEventDistributed( - CacheEvent.OnPut, - (cacheA) => - { - cacheA.Add(key, key, region); - cacheA.Put(key, "new val", region); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key, region].Should().Be("new val"); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key, region].Should().Be("new val"); - }); + cache.Add(Guid.NewGuid().ToString(), 12345); } + } - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_BackplaneEvents_Remove() - { - var key = Guid.NewGuid().ToString(); + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_BackplaneEvents_AddWithRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); - await TestBackplaneEventDistributed( - CacheEvent.OnRemove, - (cacheA) => - { - cacheA.Add(key, key).Should().BeTrue(); - cacheA.Remove(key).Should().BeTrue(); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key].Should().BeNull(); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key].Should().BeNull(); - }); - } + await TestBackplaneEventDistributed( + CacheEvent.OnAdd, + (cacheA) => + { + cacheA.Add(key, key, region); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key, region].Should().Be(key); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key, region].Should().Be(key); + }); + } - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_BackplaneEvents_Remove_WithRegion() - { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); + /// + /// Testing in memory cache only with backplane through redis (not using Redis as cache at all) + /// + /// + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_BackplaneEvents_InMemory_AddWithRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); - await TestBackplaneEventDistributed( - CacheEvent.OnRemove, - (cacheA) => - { - cacheA.Add(key, key).Should().BeTrue(); - cacheA.Add(key, key, region).Should().BeTrue(); - cacheA.Remove(key, region).Should().BeTrue(); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key].Should().NotBeNull(); - cacheA[key, region].Should().BeNull(); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key].Should().NotBeNull(); - cacheB[key, region].Should().BeNull(); - }); - } + await TestBackplaneEventInMemory( + CacheEvent.OnAdd, + (cacheA, cacheB) => + { + // in memory is not distributed, adding only to CacheA the event triggered on cache B does trigger but cacheB doesn't have the item. + cacheB.Add(key, key, region); + cacheA.Add(key, key, region); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); - /// - /// Testing in memory cache only with backplane through redis (not using Redis as cache at all) - /// This test in particular tests that a second in memory cache gets keys evicted if the same key - /// got removed by another cache (both caches connected through the backplane) - /// - /// - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_BackplaneEvents_InMemory_Remove_WithRegion() - { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); + // cannot test origin as there might be two events triggered, one local one remote + cacheA[key, region].Should().Be(key); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); - await TestBackplaneEventInMemory( - CacheEvent.OnRemove, - (cacheA, cacheB) => - { - cacheA.Add(key, key).Should().BeTrue(); - cacheA.Add(key, key, region).Should().BeTrue(); + // cannot test origin as there might be two events triggered, one local one remote + cacheB[key, region].Should().Be(key); + }, + expectedRemoteTriggers: 2); + } - // adding to cache B, too, as we don't have a distributed cache - cacheB.Add(key, key, region).Should().BeTrue(); + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_BackplaneEvents_Put() + { + var key = Guid.NewGuid().ToString(); - // remove from A only, should also remove it from B via backplane - cacheA.Remove(key, region).Should().BeTrue(); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key].Should().NotBeNull(); - cacheA[key, region].Should().BeNull(); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key, region].Should().BeNull(); - }, - expectedRemoteTriggers: 1); - } + await TestBackplaneEventDistributed( + CacheEvent.OnPut, + (cacheA) => + { + cacheA.Add(key, key); + cacheA.Put(key, "new val"); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key].Should().Be("new val"); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key].Should().Be("new val"); + }); + } - [Fact] - public async Task Redis_BackplaneEvents_Update() - { - var key = Guid.NewGuid().ToString(); - var newValue = "new value"; + /// + /// Testing in memory cache only with backplane through redis (not using Redis as cache at all) + /// + /// + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_BackplaneEvents_InMemory_Put() + { + var key = Guid.NewGuid().ToString(); - await TestBackplaneEventDistributed( - CacheEvent.OnUpdate, - (cacheA) => - { - cacheA.Add(key, key); - cacheA.Update(key, v => newValue).Should().Be(newValue); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key].Should().Be(newValue); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().BeNull(); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key].Should().Be(newValue); - }); - } + await TestBackplaneEventInMemory( + CacheEvent.OnPut, + (cacheA, cacheB) => + { + // in memory is not distributed, adding only to CacheA the event triggered on cache B does trigger but cacheB doesn't have the item. + cacheA.Add(key, key); + cacheA.Put(key, "new val"); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key].Should().Be("new val"); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key].Should().Be(null); + }, + expectedRemoteTriggers: 1); + } - [Fact] - public async Task Redis_BackplaneEvents_Update_WithgRegion() - { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); - var newValue = "new value"; + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_BackplaneEvents_PutWithRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); - await TestBackplaneEventDistributed( - CacheEvent.OnUpdate, - (cacheA) => - { - cacheA.Add(key, key, region); - cacheA.Update(key, region, v => newValue).Should().Be(newValue); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key, region].Should().Be(newValue); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB[key, region].Should().Be(newValue); - }); - } + await TestBackplaneEventDistributed( + CacheEvent.OnPut, + (cacheA) => + { + cacheA.Add(key, key, region); + cacheA.Put(key, "new val", region); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key, region].Should().Be("new val"); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key, region].Should().Be("new val"); + }); + } - /// - /// Testing in memory cache only with backplane through redis (not using Redis as cache at all) - /// This test in particular tests on update, add or put, the key in cacheB does not change or get evicted. - /// To remove the key in all in memory cache instances, Remove must be used! - /// - /// - [Fact] - public async Task Redis_BackplaneEvents_InMemory_Update_WithRegion() - { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); - var newValue = "new value"; + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_BackplaneEvents_Remove() + { + var key = Guid.NewGuid().ToString(); - await TestBackplaneEventInMemory( - CacheEvent.OnUpdate, - (cacheA, cacheB) => - { - cacheA.Add(key, key, region); + await TestBackplaneEventDistributed( + CacheEvent.OnRemove, + (cacheA) => + { + cacheA.Add(key, key).Should().BeTrue(); + cacheA.Remove(key).Should().BeTrue(); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key].Should().BeNull(); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key].Should().BeNull(); + }); + } - // adding to cache B, too, as we don't have a distributed cache - cacheB.Add(key, key, region).Should().BeTrue(); + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_BackplaneEvents_Remove_WithRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); - // the update should evict the key from cache B - cacheA.Update(key, region, v => newValue).Should().Be(newValue); - }, - (cacheA, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA[key, region].Should().Be(newValue); - }, - (cacheB, args) => - { - args.Key.Should().Be(key); - args.Region.Should().Be(region); - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + await TestBackplaneEventDistributed( + CacheEvent.OnRemove, + (cacheA) => + { + cacheA.Add(key, key).Should().BeTrue(); + cacheA.Add(key, key, region).Should().BeTrue(); + cacheA.Remove(key, region).Should().BeTrue(); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key].Should().NotBeNull(); + cacheA[key, region].Should().BeNull(); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key].Should().NotBeNull(); + cacheB[key, region].Should().BeNull(); + }); + } - // important to note, the key in cacheB has never been updated or removed, so it should still be the "old" value! - cacheB[key, region].Should().Be(key); - }, - expectedRemoteTriggers: 1); - } + /// + /// Testing in memory cache only with backplane through redis (not using Redis as cache at all) + /// This test in particular tests that a second in memory cache gets keys evicted if the same key + /// got removed by another cache (both caches connected through the backplane) + /// + /// + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_BackplaneEvents_InMemory_Remove_WithRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); - [Fact] - public async Task Redis_BackplaneEvents_Clear() - { - var key = Guid.NewGuid().ToString(); + await TestBackplaneEventInMemory( + CacheEvent.OnRemove, + (cacheA, cacheB) => + { + cacheA.Add(key, key).Should().BeTrue(); + cacheA.Add(key, key, region).Should().BeTrue(); - await TestBackplaneEventDistributed( - CacheEvent.OnClear, - (cacheA) => - { - cacheA.Add(key, key); - cacheA.Clear(); - }, - (cacheA, args) => - { - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA.Get(key).Should().BeNull(); - }, - (cacheB, args) => - { - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB.Get(key).Should().BeNull(); - }); - } + // adding to cache B, too, as we don't have a distributed cache + cacheB.Add(key, key, region).Should().BeTrue(); - [Fact] - public async Task Redis_BackplaneEvents_InMemory_Clear() - { - var key = Guid.NewGuid().ToString(); + // remove from A only, should also remove it from B via backplane + cacheA.Remove(key, region).Should().BeTrue(); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key].Should().NotBeNull(); + cacheA[key, region].Should().BeNull(); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key, region].Should().BeNull(); + }, + expectedRemoteTriggers: 1); + } - await TestBackplaneEventInMemory( - CacheEvent.OnClear, - (cacheA, cacheB) => - { - cacheA.Add(key, key); - cacheB.Add(key, key); - cacheA.Clear(); - }, - (cacheA, args) => - { - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA.Get(key).Should().BeNull(); - }, - (cacheB, args) => - { - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB.Get(key).Should().BeNull(); - }, - expectedRemoteTriggers: 1); - } + [Fact] + public async Task Redis_BackplaneEvents_Update() + { + var key = Guid.NewGuid().ToString(); + var newValue = "new value"; - [Fact] - public async Task Redis_BackplaneEvents_ClearRegion() - { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); + await TestBackplaneEventDistributed( + CacheEvent.OnUpdate, + (cacheA) => + { + cacheA.Add(key, key); + cacheA.Update(key, v => newValue).Should().Be(newValue); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key].Should().Be(newValue); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().BeNull(); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key].Should().Be(newValue); + }); + } - await TestBackplaneEventDistributed( - CacheEvent.OnClearRegion, - (cacheA) => - { - cacheA.Add(key, key); - cacheA.Add(key, key, region); - cacheA.ClearRegion(region); - }, - (cacheA, args) => - { - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA.Get(key).Should().NotBeNull(); - cacheA.Get(key, region).Should().BeNull(); - }, - (cacheB, args) => - { - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB.Get(key).Should().NotBeNull(); - cacheB.Get(key, region).Should().BeNull(); - }); - } + [Fact] + public async Task Redis_BackplaneEvents_Update_WithRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); + var newValue = "new value"; - [Fact] - public async Task Redis_BackplaneEvents_InMemory_ClearRegion() - { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); + await TestBackplaneEventDistributed( + CacheEvent.OnUpdate, + (cacheA) => + { + cacheA.Add(key, key, region); + cacheA.Update(key, region, v => newValue).Should().Be(newValue); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key, region].Should().Be(newValue); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB[key, region].Should().Be(newValue); + }); + } - await TestBackplaneEventInMemory( - CacheEvent.OnClearRegion, - (cacheA, cacheB) => - { - cacheA.Add(key, key); - cacheA.Add(key, key, region); + /// + /// Testing in memory cache only with backplane through redis (not using Redis as cache at all) + /// This test in particular tests on update, add or put, the key in cacheB does not change or get evicted. + /// To remove the key in all in memory cache instances, Remove must be used! + /// + /// + [Fact] + public async Task Redis_BackplaneEvents_InMemory_Update_WithRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); + var newValue = "new value"; - cacheB.Add(key, key); - cacheB.Add(key, key, region); + await TestBackplaneEventInMemory( + CacheEvent.OnUpdate, + (cacheA, cacheB) => + { + cacheA.Add(key, key, region); - cacheA.ClearRegion(region); - }, - (cacheA, args) => - { - args.Origin.Should().Be(CacheActionEventArgOrigin.Local); - cacheA.Get(key).Should().NotBeNull(); - cacheA.Get(key, region).Should().BeNull(); - }, - (cacheB, args) => - { - args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); - cacheB.Get(key).Should().NotBeNull(); - cacheB.Get(key, region).Should().BeNull(); - }, - expectedRemoteTriggers: 1); - } + // adding to cache B, too, as we don't have a distributed cache + cacheB.Add(key, key, region).Should().BeTrue(); - [Fact] - public void Redis_Configuration_NoEndpoint() - { - Action act = () => CacheConfigurationBuilder.BuildConfiguration( - s => s.WithRedisConfiguration( - "key", - c => c.WithAllowAdmin())); + // the update should evict the key from cache B + cacheA.Update(key, region, v => newValue).Should().Be(newValue); + }, + (cacheA, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA[key, region].Should().Be(newValue); + }, + (cacheB, args) => + { + args.Key.Should().Be(key); + args.Region.Should().Be(region); + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + + // important to note, the key in cacheB has never been updated or removed, so it should still be the "old" value! + cacheB[key, region].Should().Be(key); + }, + expectedRemoteTriggers: 1); + } - act.Should().Throw().WithMessage("*endpoints*"); - } + [Fact] + public async Task Redis_BackplaneEvents_Clear() + { + var key = Guid.NewGuid().ToString(); + + await TestBackplaneEventDistributed( + CacheEvent.OnClear, + (cacheA) => + { + cacheA.Add(key, key); + cacheA.Clear(); + }, + (cacheA, args) => + { + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA.Get(key).Should().BeNull(); + }, + (cacheB, args) => + { + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB.Get(key).Should().BeNull(); + }); + } + + [Fact] + public async Task Redis_BackplaneEvents_InMemory_Clear() + { + var key = Guid.NewGuid().ToString(); + + await TestBackplaneEventInMemory( + CacheEvent.OnClear, + (cacheA, cacheB) => + { + cacheA.Add(key, key); + cacheB.Add(key, key); + cacheA.Clear(); + }, + (cacheA, args) => + { + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA.Get(key).Should().BeNull(); + }, + (cacheB, args) => + { + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB.Get(key).Should().BeNull(); + }, + expectedRemoteTriggers: 1); + } + + [Fact] + public async Task Redis_BackplaneEvents_ClearRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); + + await TestBackplaneEventDistributed( + CacheEvent.OnClearRegion, + (cacheA) => + { + cacheA.Add(key, key); + cacheA.Add(key, key, region); + cacheA.ClearRegion(region); + }, + (cacheA, args) => + { + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA.Get(key).Should().NotBeNull(); + cacheA.Get(key, region).Should().BeNull(); + }, + (cacheB, args) => + { + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB.Get(key).Should().NotBeNull(); + cacheB.Get(key, region).Should().BeNull(); + }); + } + + [Fact] + public async Task Redis_BackplaneEvents_InMemory_ClearRegion() + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); + + await TestBackplaneEventInMemory( + CacheEvent.OnClearRegion, + (cacheA, cacheB) => + { + cacheA.Add(key, key); + cacheA.Add(key, key, region); + + cacheB.Add(key, key); + cacheB.Add(key, key, region); + + cacheA.ClearRegion(region); + }, + (cacheA, args) => + { + args.Origin.Should().Be(CacheActionEventArgOrigin.Local); + cacheA.Get(key).Should().NotBeNull(); + cacheA.Get(key, region).Should().BeNull(); + }, + (cacheB, args) => + { + args.Origin.Should().Be(CacheActionEventArgOrigin.Remote); + cacheB.Get(key).Should().NotBeNull(); + cacheB.Get(key, region).Should().BeNull(); + }, + expectedRemoteTriggers: 1); + } + + [Fact] + public void Redis_Configuration_NoEndpoint() + { + Action act = () => CacheConfigurationBuilder.BuildConfiguration( + s => s.WithRedisConfiguration( + "key", + c => c.WithAllowAdmin())); + + act.Should().Throw().WithMessage("*endpoints*"); + } #if !NO_APP_CONFIG - [Fact] - [Trait("category", "NotOnMono")] - public void Redis_Configurations_LoadStandard() - { - RedisConfigurations.LoadConfiguration(); - } + [Fact] + [Trait("category", "NotOnMono")] + public void Redis_Configurations_LoadStandard() + { + RedisConfigurations.LoadConfiguration(); + } #endif - [Fact] - [Trait("category", "NotOnMono")] - public void Redis_Configurations_LoadWithConnectionString() - { - string fileName = TestConfigurationHelper.GetCfgFileName(@"/Configuration/configuration.valid.allFeatures.config"); - - RedisConfigurations.LoadConfiguration(fileName, RedisConfigurationSection.DefaultSectionName); - var cfg = RedisConfigurations.GetConfiguration("redisConnectionString"); - cfg.ConnectionString.ToLower().Should().Contain("127.0.0.1:6379");//,allowAdmin = true,ssl = false"); - cfg.ConnectionString.ToLower().Should().Contain("allowadmin=true"); - cfg.ConnectionString.ToLower().Should().Contain("ssl=false"); - cfg.Database.Should().Be(131); - cfg.StrictCompatibilityModeVersion.Should().Be("2.9"); - } + [Fact] + [Trait("category", "NotOnMono")] + public void Redis_Configurations_LoadWithConnectionString() + { + string fileName = TestConfigurationHelper.GetCfgFileName(@"/Configuration/configuration.valid.allFeatures.config"); + + RedisConfigurations.LoadConfiguration(fileName, RedisConfigurationSection.DefaultSectionName); + var cfg = RedisConfigurations.GetConfiguration("redisConnectionString"); + cfg.ConnectionString.ToLower().Should().Contain("127.0.0.1:6379");//,allowAdmin = true,ssl = false"); + cfg.ConnectionString.ToLower().Should().Contain("allowadmin=true"); + cfg.ConnectionString.ToLower().Should().Contain("ssl=false"); + cfg.Database.Should().Be(131); + cfg.StrictCompatibilityModeVersion.Should().Be("2.9"); + } - [Fact] - [Trait("category", "NotOnMono")] - public void Redis_Configurations_LoadWConnectionString_WithDefaultDb() - { - string fileName = TestConfigurationHelper.GetCfgFileName(@"/Configuration/configuration.valid.allFeatures.config"); - - RedisConfigurations.LoadConfiguration(fileName, RedisConfigurationSection.DefaultSectionName); - var cfg = RedisConfigurations.GetConfiguration("redisConnectionStringWithDefaultDb"); - cfg.ConnectionString.ToLower().Should().Contain("127.0.0.1:6379"); - cfg.ConnectionString.ToLower().Should().Contain("allowadmin=true"); - cfg.ConnectionString.ToLower().Should().Contain("ssl=false"); - cfg.Database.Should().Be(0); - cfg.StrictCompatibilityModeVersion.Should().Be("2.9"); - } + [Fact] + [Trait("category", "NotOnMono")] + public void Redis_Configurations_LoadWConnectionString_WithDefaultDb() + { + string fileName = TestConfigurationHelper.GetCfgFileName(@"/Configuration/configuration.valid.allFeatures.config"); + + RedisConfigurations.LoadConfiguration(fileName, RedisConfigurationSection.DefaultSectionName); + var cfg = RedisConfigurations.GetConfiguration("redisConnectionStringWithDefaultDb"); + cfg.ConnectionString.ToLower().Should().Contain("127.0.0.1:6379"); + cfg.ConnectionString.ToLower().Should().Contain("allowadmin=true"); + cfg.ConnectionString.ToLower().Should().Contain("ssl=false"); + cfg.Database.Should().Be(0); + cfg.StrictCompatibilityModeVersion.Should().Be("2.9"); + } + + [Fact] + public void Redis_Configurations_LoadSection_InvalidSectionName() + { + Action act = () => RedisConfigurations.LoadConfiguration((string)null); + + act.Should().Throw() + .And.ParamName.Equals("sectionName"); + } + + [Fact] + public void Redis_Configurations_LoadSection_InvalidFileName() + { + Action act = () => RedisConfigurations.LoadConfiguration((string)null, "section"); + + act.Should().Throw() + .And.ParamName.Equals("fileName"); + } + + [Fact] + public void Redis_Configurations_LoadSection_SectionDoesNotExist() + { + Action act = () => RedisConfigurations.LoadConfiguration(Guid.NewGuid().ToString()); + + act.Should().Throw() + .And.ParamName.Equals("section"); + } + + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_Multiple_PubSub_Change() + { + // arrange + var channelName = Guid.NewGuid().ToString(); + var item = new CacheItem(Guid.NewGuid().ToString(), "something"); + + // act/assert + await RunMultipleCaches( + async (cacheA, cacheB) => + { + cacheA.Put(item); + cacheA.Get(item.Key).Should().Be("something"); + await Task.Delay(10); + var value = cacheB.Get(item.Key); + value.Should().Be(item.Value, cacheB.ToString()); + cacheB.Put(item.Key, "new value"); + }, + async (cache) => + { + int tries = 0; + object value = null; + do + { + tries++; + await Task.Delay(100); + value = cache.Get(item.Key); + } + while (value.ToString() != "new value" && tries < 10); + + value.Should().Be("new value", cache.ToString()); + }, + 1, + TestManagers.CreateRedisAndDicCacheWithBackplane(50, true, channelName, Serializer.Json), + TestManagers.CreateRedisAndDicCacheWithBackplane(50, true, channelName, Serializer.Json), + TestManagers.CreateRedisCache(50, false, Serializer.Json), + TestManagers.CreateRedisAndDicCacheWithBackplane(50, true, channelName, Serializer.Json)); + } - [Fact] - public void Redis_Configurations_LoadSection_InvalidSectionName() - { - Action act = () => RedisConfigurations.LoadConfiguration((string)null); + [Fact(Skip = "needs clear")] + [Trait("category", "Redis")] + public async Task Redis_Multiple_PubSub_Clear() + { + // arrange + var item = new CacheItem(Guid.NewGuid().ToString(), "something"); + var channelName = Guid.NewGuid().ToString(); - act.Should().Throw() - .And.ParamName.Equals("sectionName"); - } + // act/assert + await RedisTests.RunMultipleCaches( + async (cacheA, cacheB) => + { + cacheA.Add(item); + cacheB.Get(item.Key).Should().Be(item.Value); + cacheB.Clear(); + await Task.Delay(0); + }, + async (cache) => + { + cache.Get(item.Key).Should().BeNull(); + await Task.Delay(0); + }, + 2, + TestManagers.CreateRedisAndDicCacheWithBackplane(51, true, channelName), + TestManagers.CreateRedisAndDicCacheWithBackplane(51, true, channelName), + TestManagers.CreateRedisCache(51), + TestManagers.CreateRedisAndDicCacheWithBackplane(51, true, channelName)); + } - [Fact] - public void Redis_Configurations_LoadSection_InvalidFileName() - { - Action act = () => RedisConfigurations.LoadConfiguration((string)null, "section"); + [Fact] + [Trait("category", "Redis")] + public async Task Redis_Multiple_PubSub_ClearRegion() + { + // arrange + var item = new CacheItem(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "something"); + var channelName = Guid.NewGuid().ToString(); - act.Should().Throw() - .And.ParamName.Equals("fileName"); - } + // act/assert + await RedisTests.RunMultipleCaches( + async (cacheA, cacheB) => + { + cacheA.Add(item); + cacheB.Get(item.Key, item.Region).Should().Be(item.Value); + cacheB.ClearRegion(item.Region); + await Task.Delay(0); + }, + async (cache) => + { + cache.Get(item.Key, item.Region).Should().BeNull(); + await Task.Delay(0); + }, + 2, + TestManagers.CreateRedisCache(5), + TestManagers.CreateRedisCache(5), + TestManagers.CreateRedisCache(5), + TestManagers.CreateRedisCache(5)); + } - [Fact] - public void Redis_Configurations_LoadSection_SectionDoesNotExist() - { - Action act = () => RedisConfigurations.LoadConfiguration(Guid.NewGuid().ToString()); + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_Multiple_PubSub_Remove() + { + // arrange + var item = new CacheItem(Guid.NewGuid().ToString(), "something"); + var channelName = Guid.NewGuid().ToString(); - act.Should().Throw() - .And.ParamName.Equals("section"); - } + // act/assert + await RedisTests.RunMultipleCaches( + async (cacheA, cacheB) => + { + cacheA.Add(item); + cacheB.Get(item.Key).Should().Be(item.Value); + cacheB.Remove(item.Key); + await Task.Delay(10); + }, + async (cache) => + { + int tries = 0; + object value = null; + do + { + tries++; + await Task.Delay(100); + value = cache.GetCacheItem(item.Key); + } + while (value != null && tries < 50); + + value.Should().BeNull(); + }, + 1, + TestManagers.CreateRedisAndDicCacheWithBackplane(6, true, channelName), + TestManagers.CreateRedisAndDicCacheWithBackplane(6, true, channelName), + TestManagers.CreateRedisCache(6), + TestManagers.CreateRedisAndDicCacheWithBackplane(6, true, channelName)); + } - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_Multiple_PubSub_Change() + [Fact] + public void Redis_Verify_NoCredentialsLoggedOrThrown() + { + var testLogger = new TestLogger(); + var cfg = CacheConfigurationBuilder.BuildConfiguration(settings => { - // arrange - var channelName = Guid.NewGuid().ToString(); - var item = new CacheItem(Guid.NewGuid().ToString(), "something"); - - // act/assert - await RunMultipleCaches( - async (cacheA, cacheB) => - { - cacheA.Put(item); - cacheA.Get(item.Key).Should().Be("something"); - await Task.Delay(10); - var value = cacheB.Get(item.Key); - value.Should().Be(item.Value, cacheB.ToString()); - cacheB.Put(item.Key, "new value"); - }, - async (cache) => + settings + .WithRedisBackplane("redis.config") + .WithJsonSerializer() + .WithRedisCacheHandle("redis.config", true) + .And + .WithRedisConfiguration("redis.config", config => { - int tries = 0; - object value = null; - do - { - tries++; - await Task.Delay(100); - value = cache.Get(item.Key); - } - while (value.ToString() != "new value" && tries < 10); + config + .WithConnectionTimeout(10) + .WithAllowAdmin() + //.WithDatabase(7) + .WithEndpoint("doesnotexist", 6379) + .WithPassword("mysupersecret") + .WithSsl(); + }); + }); - value.Should().Be("new value", cache.ToString()); - }, - 1, - TestManagers.CreateRedisAndDicCacheWithBackplane(50, true, channelName, Serializer.Json), - TestManagers.CreateRedisAndDicCacheWithBackplane(50, true, channelName, Serializer.Json), - TestManagers.CreateRedisCache(50, false, Serializer.Json), - TestManagers.CreateRedisAndDicCacheWithBackplane(50, true, channelName, Serializer.Json)); - } + Action act = () => new BaseCacheManager(cfg).Put("key", "value"); - [Fact(Skip = "needs clear")] - [Trait("category", "Redis")] - public async Task Redis_Multiple_PubSub_Clear() - { - // arrange - var item = new CacheItem(Guid.NewGuid().ToString(), "something"); - var channelName = Guid.NewGuid().ToString(); + act.Should().Throw().WithMessage("*password=***"); - // act/assert - await RedisTests.RunMultipleCaches( - async (cacheA, cacheB) => - { - cacheA.Add(item); - cacheB.Get(item.Key).Should().Be(item.Value); - cacheB.Clear(); - await Task.Delay(0); - }, - async (cache) => - { - cache.Get(item.Key).Should().BeNull(); - await Task.Delay(0); - }, - 2, - TestManagers.CreateRedisAndDicCacheWithBackplane(51, true, channelName), - TestManagers.CreateRedisAndDicCacheWithBackplane(51, true, channelName), - TestManagers.CreateRedisCache(51), - TestManagers.CreateRedisAndDicCacheWithBackplane(51, true, channelName)); - } + testLogger.LogMessages.Any(p => p.Message.ToString().Contains("mysupersecret")).Should().BeFalse(); + } - [Fact] - [Trait("category", "Redis")] - public async Task Redis_Multiple_PubSub_ClearRegion() + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_NoRaceCondition_WithUpdate() + { + using (var cache = CacheFactory.Build(settings => { - // arrange - var item = new CacheItem(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "something"); - var channelName = Guid.NewGuid().ToString(); - - // act/assert - await RedisTests.RunMultipleCaches( - async (cacheA, cacheB) => - { - cacheA.Add(item); - cacheB.Get(item.Key, item.Region).Should().Be(item.Value); - cacheB.ClearRegion(item.Region); - await Task.Delay(0); - }, - async (cache) => - { - cache.Get(item.Key, item.Region).Should().BeNull(); - await Task.Delay(0); - }, - 2, - TestManagers.CreateRedisCache(5), - TestManagers.CreateRedisCache(5), - TestManagers.CreateRedisCache(5), - TestManagers.CreateRedisCache(5)); - } - - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_Multiple_PubSub_Remove() + settings.WithMaxRetries(1); + settings.WithUpdateMode(CacheUpdateMode.Up) + .WithJsonSerializer() + .WithRedisCacheHandle("default") + .WithExpiration(ExpirationMode.Absolute, TimeSpan.FromMinutes(20)); + settings.WithRedisConfiguration("default", config => + { + config.WithAllowAdmin() + //.WithDatabase(7) + .WithEndpoint("127.0.0.1", 6379); + }); + })) { - // arrange - var item = new CacheItem(Guid.NewGuid().ToString(), "something"); - var channelName = Guid.NewGuid().ToString(); + var key = Guid.NewGuid().ToString(); + cache.Remove(key); + cache.Add(key, new RaceConditionTestElement() { Counter = 0 }); + int numThreads = 5; + int iterations = 10; + int numInnerIterations = 10; + int countCasModifyCalls = 0; - // act/assert - await RedisTests.RunMultipleCaches( - async (cacheA, cacheB) => - { - cacheA.Add(item); - cacheB.Get(item.Key).Should().Be(item.Value); - cacheB.Remove(item.Key); - await Task.Delay(10); - }, - async (cache) => + // act + await ThreadTestHelper.RunAsync( + async () => { - int tries = 0; - object value = null; - do + for (var i = 0; i < numInnerIterations; i++) { - tries++; - await Task.Delay(100); - value = cache.GetCacheItem(item.Key); + cache.Update( + key, + (value) => + { + value.Counter++; + Interlocked.Increment(ref countCasModifyCalls); + return value; + }, + int.MaxValue); + + await Task.Delay(0); } - while (value != null && tries < 50); - - value.Should().BeNull(); }, - 1, - TestManagers.CreateRedisAndDicCacheWithBackplane(6, true, channelName), - TestManagers.CreateRedisAndDicCacheWithBackplane(6, true, channelName), - TestManagers.CreateRedisCache(6), - TestManagers.CreateRedisAndDicCacheWithBackplane(6, true, channelName)); + numThreads, + iterations); + + // assert + await Task.Delay(100); + var result = cache.Get(key); + result.Should().NotBeNull(); + result.Counter.Should().Be(numThreads * numInnerIterations * iterations, "counter should be exactly the expected value"); + countCasModifyCalls.Should().BeGreaterThan((int)result.Counter, "we expect many version collisions, so cas calls should be way higher then the count result"); } + } - [Fact] - public void Redis_Verify_NoCredentialsLoggedOrThrown() + [Fact] + [Trait("category", "Redis")] + [Trait("category", "Unreliable")] + public async Task Redis_RaceCondition_WithoutUpdate() + { + using (var cache = CacheFactory.Build(settings => { - var testLogger = new TestLogger(); - var cfg = CacheConfigurationBuilder.BuildConfiguration(settings => + settings.WithUpdateMode(CacheUpdateMode.Up) + .WithJsonSerializer() + .WithRedisCacheHandle("default") + .WithExpiration(ExpirationMode.Absolute, TimeSpan.FromMinutes(20)); + settings.WithRedisConfiguration("default", config => { - settings - .WithRedisBackplane("redis.config") - .WithJsonSerializer() - .WithRedisCacheHandle("redis.config", true) - .And - .WithRedisConfiguration("redis.config", config => - { - config - .WithConnectionTimeout(10) - .WithAllowAdmin() - //.WithDatabase(7) - .WithEndpoint("doesnotexist", 6379) - .WithPassword("mysupersecret") - .WithSsl(); - }); + config.WithAllowAdmin() + //.WithDatabase(8) + .WithEndpoint("127.0.0.1", 6379); }); - - Action act = () => new BaseCacheManager(cfg).Put("key", "value"); - - act.Should().Throw().WithMessage("*password=***"); - - testLogger.LogMessages.Any(p => p.Message.ToString().Contains("mysupersecret")).Should().BeFalse(); - } - - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_NoRaceCondition_WithUpdate() + })) { - using (var cache = CacheFactory.Build(settings => - { - settings.WithMaxRetries(1); - settings.WithUpdateMode(CacheUpdateMode.Up) - .WithJsonSerializer() - .WithRedisCacheHandle("default") - .WithExpiration(ExpirationMode.Absolute, TimeSpan.FromMinutes(20)); - settings.WithRedisConfiguration("default", config => + var key = Guid.NewGuid().ToString(); + cache.Add(key, new RaceConditionTestElement() { Counter = 0 }); + int numThreads = 5; + int iterations = 10; + int numInnerIterations = 10; + + // act + await ThreadTestHelper.RunAsync( + async () => { - config.WithAllowAdmin() - //.WithDatabase(7) - .WithEndpoint("127.0.0.1", 6379); - }); - })) - { - var key = Guid.NewGuid().ToString(); - cache.Remove(key); - cache.Add(key, new RaceConditionTestElement() { Counter = 0 }); - int numThreads = 5; - int iterations = 10; - int numInnerIterations = 10; - int countCasModifyCalls = 0; - - // act - await ThreadTestHelper.RunAsync( - async () => + for (int i = 0; i < numInnerIterations; i++) { - for (var i = 0; i < numInnerIterations; i++) - { - cache.Update( - key, - (value) => - { - value.Counter++; - Interlocked.Increment(ref countCasModifyCalls); - return value; - }, - int.MaxValue); - - await Task.Delay(0); - } - }, - numThreads, - iterations); - - // assert - await Task.Delay(100); - var result = cache.Get(key); - result.Should().NotBeNull(); - result.Counter.Should().Be(numThreads * numInnerIterations * iterations, "counter should be exactly the expected value"); - countCasModifyCalls.Should().BeGreaterThan((int)result.Counter, "we expect many version collisions, so cas calls should be way higher then the count result"); - } - } + var val = cache.Get(key); + val.Should().NotBeNull(); + val.Counter++; - [Fact] - [Trait("category", "Redis")] - [Trait("category", "Unreliable")] - public async Task Redis_RaceCondition_WithoutUpdate() - { - using (var cache = CacheFactory.Build(settings => - { - settings.WithUpdateMode(CacheUpdateMode.Up) - .WithJsonSerializer() - .WithRedisCacheHandle("default") - .WithExpiration(ExpirationMode.Absolute, TimeSpan.FromMinutes(20)); - settings.WithRedisConfiguration("default", config => - { - config.WithAllowAdmin() - //.WithDatabase(8) - .WithEndpoint("127.0.0.1", 6379); - }); - })) - { - var key = Guid.NewGuid().ToString(); - cache.Add(key, new RaceConditionTestElement() { Counter = 0 }); - int numThreads = 5; - int iterations = 10; - int numInnerIterations = 10; + cache.Put(key, val); + await Task.Delay(1); + } + }, + numThreads, + iterations); - // act - await ThreadTestHelper.RunAsync( - async () => - { - for (int i = 0; i < numInnerIterations; i++) - { - var val = cache.Get(key); - val.Should().NotBeNull(); - val.Counter++; - - cache.Put(key, val); - await Task.Delay(1); - } - }, - numThreads, - iterations); - - // assert - await Task.Delay(10); - var result = cache.Get(key); - result.Should().NotBeNull(); - result.Counter.Should().NotBe(numThreads * numInnerIterations * iterations); - } + // assert + await Task.Delay(10); + var result = cache.Get(key); + result.Should().NotBeNull(); + result.Counter.Should().NotBe(numThreads * numInnerIterations * iterations); } + } - /// - /// See #165, version string can be empty is e.g. it comes from app/web.config. - /// - [Fact] - [Trait("category", "Redis")] - public void Redis_StrictMode_EmptyString_DoesnTThrow() - { - var redisConfigKey = Guid.NewGuid().ToString(); - var redisConfig = new RedisConfiguration(redisConfigKey, "localhost", strictCompatibilityModeVersion: ""); - RedisConfigurations.AddConfiguration(redisConfig); + /// + /// See #165, version string can be empty is e.g. it comes from app/web.config. + /// + [Fact] + [Trait("category", "Redis")] + public void Redis_StrictMode_EmptyString_DoesnTThrow() + { + var redisConfigKey = Guid.NewGuid().ToString(); + var redisConfig = new RedisConfiguration(redisConfigKey, "localhost", strictCompatibilityModeVersion: ""); + RedisConfigurations.AddConfiguration(redisConfig); - var cacheConfig = new CacheConfigurationBuilder() - .WithJsonSerializer() - .WithRedisCacheHandle(redisConfigKey) - .Build(); + var cacheConfig = new CacheConfigurationBuilder() + .WithJsonSerializer() + .WithRedisCacheHandle(redisConfigKey) + .Build(); - Action act = () => new BaseCacheManager(cacheConfig); + Action act = () => new BaseCacheManager(cacheConfig); - act.Should().NotThrow(); - } + act.Should().NotThrow(); + } - [Fact] - [Trait("category", "Redis")] - public void Redis_Valid_CfgFile_LoadWithRedisBackplane() - { - // arrange - string fileName = TestConfigurationHelper.GetCfgFileName(@"/Configuration/configuration.valid.allFeatures.config"); - string cacheName = "redisConfigFromConfig"; + [Fact] + [Trait("category", "Redis")] + public void Redis_Valid_CfgFile_LoadWithRedisBackplane() + { + // arrange + string fileName = TestConfigurationHelper.GetCfgFileName(@"/Configuration/configuration.valid.allFeatures.config"); + string cacheName = "redisConfigFromConfig"; + + // have to load the configuration manually because the file is not available to the default ConfigurtaionManager + RedisConfigurations.LoadConfiguration(fileName, RedisConfigurationSection.DefaultSectionName); + var redisConfig = RedisConfigurations.GetConfiguration("redisFromCfgConfigurationId"); + + // act + var cfg = CacheConfigurationBuilder.LoadConfigurationFile(fileName, cacheName); + + // assert + redisConfig.Database.Should().Be(113); + redisConfig.ConnectionTimeout.Should().Be(1200); + redisConfig.AllowAdmin.Should().BeTrue(); + redisConfig.KeyspaceNotificationsEnabled.Should().BeTrue(); + redisConfig.TwemproxyEnabled.Should().BeTrue(); + redisConfig.StrictCompatibilityModeVersion.Should().Be("2.7"); + } - // have to load the configuration manually because the file is not available to the default ConfigurtaionManager - RedisConfigurations.LoadConfiguration(fileName, RedisConfigurationSection.DefaultSectionName); - var redisConfig = RedisConfigurations.GetConfiguration("redisFromCfgConfigurationId"); + [Fact] + [Trait("category", "Redis")] + public void Redis_Valid_CfgFile_LoadWithConnectionString() + { + // arrange + string fileName = TestConfigurationHelper.GetCfgFileName(@"/Configuration/configuration.valid.allFeatures.config"); + string cacheName = "redisConfigFromConnectionString"; + + // have to load the configuration manually because the file is not available to the default ConfigurtaionManager + RedisConfigurations.LoadConfiguration(fileName, RedisConfigurationSection.DefaultSectionName); + var redisConfig = RedisConfigurations.GetConfiguration("redisConnectionString"); + + // act + var cfg = CacheConfigurationBuilder.LoadConfigurationFile(fileName, cacheName); + var cache = CacheFactory.FromConfiguration(cfg); + + // assert + cache.CacheHandles.Any(p => p.Configuration.IsBackplaneSource).Should().BeTrue(); + + // database is the only option apart from key and connection string which must be set, database will not be set through connection string + // to define which database should actually be used... + redisConfig.Database.Should().Be(131); + redisConfig.StrictCompatibilityModeVersion.Should().Be("2.9"); + redisConfig.AllowAdmin.Should().BeTrue(); + } - // act - var cfg = CacheConfigurationBuilder.LoadConfigurationFile(fileName, cacheName); +#if !NO_APP_CONFIG - // assert - redisConfig.Database.Should().Be(113); - redisConfig.ConnectionTimeout.Should().Be(1200); - redisConfig.AllowAdmin.Should().BeTrue(); - redisConfig.KeyspaceNotificationsEnabled.Should().BeTrue(); - redisConfig.TwemproxyEnabled.Should().BeTrue(); - redisConfig.StrictCompatibilityModeVersion.Should().Be("2.7"); - } + [Fact] + [Trait("category", "Redis")] + public void Redis_LoadWithRedisBackplane_FromAppConfig() + { + // RedisConfigurations should load this from default section from app.config - [Fact] - [Trait("category", "Redis")] - public void Redis_Valid_CfgFile_LoadWithConnectionString() - { - // arrange - string fileName = TestConfigurationHelper.GetCfgFileName(@"/Configuration/configuration.valid.allFeatures.config"); - string cacheName = "redisConfigFromConnectionString"; + // arrange + string cacheName = "redisWithBackplaneAppConfig"; - // have to load the configuration manually because the file is not available to the default ConfigurtaionManager - RedisConfigurations.LoadConfiguration(fileName, RedisConfigurationSection.DefaultSectionName); - var redisConfig = RedisConfigurations.GetConfiguration("redisConnectionString"); + // act + var cfg = CacheConfigurationBuilder.LoadConfiguration(cacheName); + var cache = CacheFactory.FromConfiguration(cfg); + var handle = cache.CacheHandles.First(p => p.Configuration.IsBackplaneSource) as RedisCacheHandle; - // act - var cfg = CacheConfigurationBuilder.LoadConfigurationFile(fileName, cacheName); - var cache = CacheFactory.FromConfiguration(cfg); + // test running something on the redis handle, Count should be enough to test the connection + Action count = () => { var x = handle.Count; }; - // assert - cache.CacheHandles.Any(p => p.Configuration.IsBackplaneSource).Should().BeTrue(); + // assert + handle.Should().NotBeNull(); + count.Should().NotThrow(); + } - // database is the only option apart from key and connection string which must be set, database will not be set through connection string - // to define which database should actually be used... - redisConfig.Database.Should().Be(131); - redisConfig.StrictCompatibilityModeVersion.Should().Be("2.9"); - redisConfig.AllowAdmin.Should().BeTrue(); - } + [Fact] + [Trait("category", "Redis")] + public void Redis_LoadWithRedisBackplane_FromAppConfigConnectionStrings() + { + // RedisConfigurations should load this from AppSettings from app.config + // arrange + string cacheName = "redisWithBackplaneAppConfigConnectionStrings"; -#if !NO_APP_CONFIG + // act + var cfg = CacheConfigurationBuilder.LoadConfiguration(cacheName); + var cache = CacheFactory.FromConfiguration(cfg); + var handle = cache.CacheHandles.First(p => p.Configuration.IsBackplaneSource) as RedisCacheHandle; - [Fact] - [Trait("category", "Redis")] - public void Redis_LoadWithRedisBackplane_FromAppConfig() - { - // RedisConfigurations should load this from default section from app.config + // test running something on the redis handle, Count should be enough to test the connection + Action count = () => { var x = handle.Count; }; - // arrange - string cacheName = "redisWithBackplaneAppConfig"; + // assert + handle.Should().NotBeNull(); + count.Should().NotThrow(); + } - // act - var cfg = CacheConfigurationBuilder.LoadConfiguration(cacheName); - var cache = CacheFactory.FromConfiguration(cfg); - var handle = cache.CacheHandles.First(p => p.Configuration.IsBackplaneSource) as RedisCacheHandle; + [Fact] + [Trait("category", "Redis")] + public void Redis_LoadWithRedisBackplane_FromAppConfigConnectionStrings_WithDefaultDb() + { + // RedisConfigurations should load this from AppSettings from app.config + // arrange + string cacheName = "redisWithBackplaneAppConfigConnectionStringsWithDefaultDb"; + + // act + var cfg = CacheConfigurationBuilder.LoadConfiguration(cacheName); + var cache = CacheFactory.FromConfiguration(cfg); + var redisConfig = RedisConfigurations.GetConfiguration("redisFromConnectionStringsWithDefaultDb"); + var handle = cache.CacheHandles.First(p => p.Configuration.IsBackplaneSource) as RedisCacheHandle; + + // test running something on the redis handle, Count should be enough to test the connection + Action count = () => { var x = handle.Count; }; + + // assert + handle.Should().NotBeNull(); + count.Should().NotThrow(); + redisConfig.Database.Should().Be(0); + redisConfig.AllowAdmin.Should().BeTrue(); + redisConfig.ConnectionTimeout.Should().Be(11); + } - // test running something on the redis handle, Count should be enough to test the connection - Action count = () => { var x = handle.Count; }; +#endif - // assert - handle.Should().NotBeNull(); - count.Should().NotThrow(); - } + [Fact] + [Trait("category", "Redis")] + public void Redis_ValueConverter_CacheTypeConversion_Poco() + { + var cache = TestManagers.CreateRedisCache(17, false, Serializer.Json); - [Fact] - [Trait("category", "Redis")] - public void Redis_LoadWithRedisBackplane_FromAppConfigConnectionStrings() + // act/assert + using (cache) { - // RedisConfigurations should load this from AppSettings from app.config - // arrange - string cacheName = "redisWithBackplaneAppConfigConnectionStrings"; + var key = Guid.NewGuid().ToString(); + var value = new Poco() { Id = 23, Something = "§asdad" }; + cache.Add(key, value); + var result = (Poco)cache.Get(key); + value.Should().BeEquivalentTo(result); + } + } - // act - var cfg = CacheConfigurationBuilder.LoadConfiguration(cacheName); - var cache = CacheFactory.FromConfiguration(cfg); - var handle = cache.CacheHandles.First(p => p.Configuration.IsBackplaneSource) as RedisCacheHandle; + [Fact] + [Trait("category", "Redis")] + public void Redis_ValueConverter_Poco_Update() + { + var cache = TestManagers.CreateRedisCache(17, false, Serializer.Json); + + // act/assert + using (cache) + { + var key = Guid.NewGuid().ToString(); + var region = Guid.NewGuid().ToString(); + var value = new Poco() { Id = 23, Something = "§asdad" }; + cache.Add(key, value, region); - // test running something on the redis handle, Count should be enough to test the connection - Action count = () => { var x = handle.Count; }; + var newValue = new Poco() { Id = 24, Something = "%!else$&" }; + object resultValue = null; + Func act = () => cache.TryUpdate(key, region, (o) => newValue, out resultValue); - // assert - handle.Should().NotBeNull(); - count.Should().NotThrow(); + act().Should().BeTrue(); + newValue.Should().BeEquivalentTo(resultValue); } + } - [Fact] - [Trait("category", "Redis")] - public void Redis_LoadWithRedisBackplane_FromAppConfigConnectionStrings_WithDefaultDb() + [Theory] + [Trait("category", "Redis")] + [InlineData(byte.MaxValue)] + [InlineData(new byte[] { 0, 1, 2, 3, 4 })] + [InlineData("some string")] + [InlineData(int.MaxValue)] + [InlineData(uint.MaxValue)] + [InlineData(short.MaxValue)] + [InlineData(ushort.MaxValue)] + [InlineData(float.MaxValue)] + [InlineData(double.MaxValue)] + [InlineData(true)] + [InlineData(false)] + [InlineData(long.MaxValue)] + [InlineData(ulong.MaxValue)] + [InlineData((ulong)int.MaxValue)] + [InlineData((ulong)long.MaxValue)] + [InlineData(char.MinValue)] + [InlineData(char.MaxValue)] + public void Redis_ValueConverter_ValidateValuesTypesNotUsingSerializer(T value) + { + var redisKey = Guid.NewGuid().ToString(); + var cache = CacheFactory.Build(settings => { - // RedisConfigurations should load this from AppSettings from app.config - // arrange - string cacheName = "redisWithBackplaneAppConfigConnectionStringsWithDefaultDb"; - - // act - var cfg = CacheConfigurationBuilder.LoadConfiguration(cacheName); - var cache = CacheFactory.FromConfiguration(cfg); - var redisConfig = RedisConfigurations.GetConfiguration("redisFromConnectionStringsWithDefaultDb"); - var handle = cache.CacheHandles.First(p => p.Configuration.IsBackplaneSource) as RedisCacheHandle; - - // test running something on the redis handle, Count should be enough to test the connection - Action count = () => { var x = handle.Count; }; + settings + .WithSerializer(typeof(FakeTestSerializer)) + .WithRedisConfiguration(redisKey, config => + { + config + //.WithDatabase(66) + .WithEndpoint("127.0.0.1", 6379); + }) + .WithRedisCacheHandle(redisKey, true); + }); - // assert - handle.Should().NotBeNull(); - count.Should().NotThrow(); - redisConfig.Database.Should().Be(0); - redisConfig.AllowAdmin.Should().BeTrue(); - redisConfig.ConnectionTimeout.Should().Be(11); - } + var key = Guid.NewGuid().ToString(); -#endif + cache.Add(key, value); + var val = cache[key]; + val.Should().BeEquivalentTo(value); + val.GetType().Should().Be(value.GetType()); + } - [Fact] - [Trait("category", "Redis")] - public void Redis_ValueConverter_CacheTypeConversion_Poco() + private static async Task RunMultipleCaches( + Func stepA, + Func stepB, + int iterations, + params TCache[] caches) + where TCache : ICacheManager + { + for (int i = 0; i < iterations; i++) { - var cache = TestManagers.CreateRedisCache(17, false, Serializer.Json); + await Task.Delay(10); - // act/assert - using (cache) + if (caches.Length == 1) { - var key = Guid.NewGuid().ToString(); - var value = new Poco() { Id = 23, Something = "§asdad" }; - cache.Add(key, value); - var result = (Poco)cache.Get(key); - value.Should().BeEquivalentTo(result); + await stepA(caches[0], caches[0]); } - } - - [Fact] - [Trait("category", "Redis")] - public void Redis_ValueConverter_Poco_Update() - { - var cache = TestManagers.CreateRedisCache(17, false, Serializer.Json); - - // act/assert - using (cache) + else { - var key = Guid.NewGuid().ToString(); - var region = Guid.NewGuid().ToString(); - var value = new Poco() { Id = 23, Something = "§asdad" }; - cache.Add(key, value, region); + await stepA(caches[0], caches[1]); + } - var newValue = new Poco() { Id = 24, Something = "%!else$&" }; - object resultValue = null; - Func act = () => cache.TryUpdate(key, region, (o) => newValue, out resultValue); + await Task.Delay(100); - act().Should().BeTrue(); - newValue.Should().BeEquivalentTo(resultValue); + foreach (var cache in caches) + { + await stepB(cache); } } - [Theory] - [Trait("category", "Redis")] - [InlineData(byte.MaxValue)] - [InlineData(new byte[] { 0, 1, 2, 3, 4 })] - [InlineData("some string")] - [InlineData(int.MaxValue)] - [InlineData(uint.MaxValue)] - [InlineData(short.MaxValue)] - [InlineData(ushort.MaxValue)] - [InlineData(float.MaxValue)] - [InlineData(double.MaxValue)] - [InlineData(true)] - [InlineData(false)] - [InlineData(long.MaxValue)] - [InlineData(ulong.MaxValue)] - [InlineData((ulong)int.MaxValue)] - [InlineData((ulong)long.MaxValue)] - [InlineData(char.MinValue)] - [InlineData(char.MaxValue)] - public void Redis_ValueConverter_ValidateValuesTypesNotUsingSerializer(T value) + foreach (var cache in caches) { - var redisKey = Guid.NewGuid().ToString(); - var cache = CacheFactory.Build(settings => - { - settings - .WithSerializer(typeof(FakeTestSerializer)) - .WithRedisConfiguration(redisKey, config => - { - config - //.WithDatabase(66) - .WithEndpoint("127.0.0.1", 6379); - }) - .WithRedisCacheHandle(redisKey, true); - }); - - var key = Guid.NewGuid().ToString(); - - cache.Add(key, value); - var val = cache[key]; - val.Should().BeEquivalentTo(value); - val.GetType().Should().Be(value.GetType()); + cache.Dispose(); } + } - private static async Task RunMultipleCaches( - Func stepA, - Func stepB, - int iterations, - params TCache[] caches) - where TCache : ICacheManager - { - for (int i = 0; i < iterations; i++) - { - await Task.Delay(10); + private static Task TestBackplaneEventDistributed(CacheEvent cacheEvent, + Action> arrange, + Action, TEventArgs> assertLocal, + Action, TEventArgs> assertRemote) + where TEventArgs : EventArgs + { + var channelName = Guid.NewGuid().ToString(); + var cacheA = TestManagers.CreateRedisAndDicCacheWithBackplane(1, false, channelName); + var cacheB = TestManagers.CreateRedisAndDicCacheWithBackplane(1, false, channelName); - if (caches.Length == 1) - { - await stepA(caches[0], caches[0]); - } - else - { - await stepA(caches[0], caches[1]); - } + return TestBackplaneEventRunner(cacheA, cacheB, cacheEvent, arrange, assertLocal, assertRemote, 1); + } - await Task.Delay(100); + private static Task TestBackplaneEventInMemory(CacheEvent cacheEvent, + Action, ICacheManager> arrange, + Action, TEventArgs> assertLocal, + Action, TEventArgs> assertRemote, + int expectedRemoteTriggers) + where TEventArgs : EventArgs + { + var channelName = Guid.NewGuid().ToString(); + var cacheA = TestManagers.CreateDicCacheWithBackplane(false, channelName); + var cacheB = TestManagers.CreateDicCacheWithBackplane(false, channelName); - foreach (var cache in caches) - { - await stepB(cache); - } - } + return TestBackplaneEventRunner(cacheA, cacheB, cacheEvent, (a) => arrange(cacheA, cacheB), assertLocal, assertRemote, expectedRemoteTriggers); + } - foreach (var cache in caches) - { - cache.Dispose(); - } - } + private static async Task TestBackplaneEventRunner( + ICacheManager cacheA, + ICacheManager cacheB, + CacheEvent cacheEvent, + Action> arrange, + Action, TEventArgs> assertLocal, + Action, TEventArgs> assertRemote, + int expectedRemoteTriggers) + where TEventArgs : EventArgs + { + var eventTriggeredLocal = 0; + var eventTriggeredRemote = 0; + Exception lastError = null; - private static Task TestBackplaneEventDistributed(CacheEvent cacheEvent, - Action> arrange, - Action, TEventArgs> assertLocal, - Action, TEventArgs> assertRemote) - where TEventArgs : EventArgs + Action testLocal = (args) => { - var channelName = Guid.NewGuid().ToString(); - var cacheA = TestManagers.CreateRedisAndDicCacheWithBackplane(1, false, channelName); - var cacheB = TestManagers.CreateRedisAndDicCacheWithBackplane(1, false, channelName); + try + { + assertLocal(cacheA, (TEventArgs)args); - return TestBackplaneEventRunner(cacheA, cacheB, cacheEvent, arrange, assertLocal, assertRemote, 1); - } + Interlocked.Increment(ref eventTriggeredLocal); + } + catch (Exception ex) + { + lastError = ex; + throw; + } + }; - private static Task TestBackplaneEventInMemory(CacheEvent cacheEvent, - Action, ICacheManager> arrange, - Action, TEventArgs> assertLocal, - Action, TEventArgs> assertRemote, - int expectedRemoteTriggers) - where TEventArgs : EventArgs + Action testRemote = (args) => { - var channelName = Guid.NewGuid().ToString(); - var cacheA = TestManagers.CreateDicCacheWithBackplane(false, channelName); - var cacheB = TestManagers.CreateDicCacheWithBackplane(false, channelName); + try + { + assertRemote(cacheB, (TEventArgs)args); - return TestBackplaneEventRunner(cacheA, cacheB, cacheEvent, (a) => arrange(cacheA, cacheB), assertLocal, assertRemote, expectedRemoteTriggers); - } + Interlocked.Increment(ref eventTriggeredRemote); + } + catch (Exception ex) + { + lastError = ex; + throw; + } + }; - private static async Task TestBackplaneEventRunner( - ICacheManager cacheA, - ICacheManager cacheB, - CacheEvent cacheEvent, - Action> arrange, - Action, TEventArgs> assertLocal, - Action, TEventArgs> assertRemote, - int expectedRemoteTriggers) - where TEventArgs : EventArgs + switch (cacheEvent) { - var eventTriggeredLocal = 0; - var eventTriggeredRemote = 0; - Exception lastError = null; - - Action testLocal = (args) => - { - try + case CacheEvent.OnAdd: + cacheA.OnAdd += (ev, args) => { - assertLocal(cacheA, (TEventArgs)args); + testLocal(args); + }; - Interlocked.Increment(ref eventTriggeredLocal); - } - catch (Exception ex) + cacheB.OnAdd += (ev, args) => { - lastError = ex; - throw; - } - }; + testRemote(args); + }; + break; - Action testRemote = (args) => - { - try + case CacheEvent.OnClear: + cacheA.OnClear += (ev, args) => { - assertRemote(cacheB, (TEventArgs)args); + testLocal(args); + }; - Interlocked.Increment(ref eventTriggeredRemote); - } - catch (Exception ex) + cacheB.OnClear += (ev, args) => { - lastError = ex; - throw; - } - }; - - switch (cacheEvent) - { - case CacheEvent.OnAdd: - cacheA.OnAdd += (ev, args) => - { - testLocal(args); - }; - - cacheB.OnAdd += (ev, args) => - { - testRemote(args); - }; - break; - - case CacheEvent.OnClear: - cacheA.OnClear += (ev, args) => - { - testLocal(args); - }; + testRemote(args); + }; + break; - cacheB.OnClear += (ev, args) => - { - testRemote(args); - }; - break; - - case CacheEvent.OnClearRegion: - cacheA.OnClearRegion += (ev, args) => - { - testLocal(args); - }; + case CacheEvent.OnClearRegion: + cacheA.OnClearRegion += (ev, args) => + { + testLocal(args); + }; - cacheB.OnClearRegion += (ev, args) => - { - testRemote(args); - }; - break; + cacheB.OnClearRegion += (ev, args) => + { + testRemote(args); + }; + break; - case CacheEvent.OnPut: - cacheA.OnPut += (ev, args) => - { - testLocal(args); - }; + case CacheEvent.OnPut: + cacheA.OnPut += (ev, args) => + { + testLocal(args); + }; - cacheB.OnPut += (ev, args) => - { - testRemote(args); - }; - break; + cacheB.OnPut += (ev, args) => + { + testRemote(args); + }; + break; - case CacheEvent.OnRemove: - cacheA.OnRemove += (ev, args) => - { - testLocal(args); - }; + case CacheEvent.OnRemove: + cacheA.OnRemove += (ev, args) => + { + testLocal(args); + }; - cacheB.OnRemove += (ev, args) => - { - testRemote(args); - }; - break; + cacheB.OnRemove += (ev, args) => + { + testRemote(args); + }; + break; - case CacheEvent.OnUpdate: - cacheA.OnUpdate += (ev, args) => - { - testLocal(args); - }; + case CacheEvent.OnUpdate: + cacheA.OnUpdate += (ev, args) => + { + testLocal(args); + }; - cacheB.OnUpdate += (ev, args) => - { - testRemote(args); - }; - break; - } + cacheB.OnUpdate += (ev, args) => + { + testRemote(args); + }; + break; + } - arrange(cacheA); + arrange(cacheA); - Func, Task> waitForIt = async (tries, act) => + Func, Task> waitForIt = async (tries, act) => + { + var i = 0; + var result = false; + while (!result && i < tries) { - var i = 0; - var result = false; - while (!result && i < tries) + i++; + result = act(); + if (result) { - i++; - result = act(); - if (result) - { - return true; - } - - await Task.Delay(10); + return true; } - return false; - }; + await Task.Delay(10); + } - Func formatError = (err) => + return false; + }; + + Func formatError = (err) => + { + if (err is XunitException xunitError) { - if (err is XunitException xunitError) - { - return xunitError.Message; - } + return xunitError.Message; + } - return err?.ToString(); - }; + return err?.ToString(); + }; - var triggerResult = await waitForIt(100, () => eventTriggeredRemote == expectedRemoteTriggers); - lastError.Should().BeNull(formatError(lastError)); - triggerResult.Should().BeTrue("Event should get triggered through the backplane."); - eventTriggeredLocal.Should().Be(expectedRemoteTriggers, "Local cache event should be triggered one time"); - } + var triggerResult = await waitForIt(100, () => eventTriggeredRemote == expectedRemoteTriggers); + lastError.Should().BeNull(formatError(lastError)); + triggerResult.Should().BeTrue("Event should get triggered through the backplane."); + eventTriggeredLocal.Should().Be(expectedRemoteTriggers, "Local cache event should be triggered one time"); } +} - [Serializable] - [ExcludeFromCodeCoverage] - [Bond.Schema] - internal class Poco - { - [Bond.Id(1)] - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "For testing only")] - public int Id { get; set; } +[Serializable] +[ExcludeFromCodeCoverage] +[Bond.Schema] +internal class Poco +{ + [Bond.Id(1)] + [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "For testing only")] + public int Id { get; set; } - [Bond.Id(2)] - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "For testing only")] - public string Something { get; set; } - } + [Bond.Id(2)] + [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "For testing only")] + public string Something { get; set; } +} - [ExcludeFromCodeCoverage] - internal class FakeTestSerializer : ICacheSerializer - { - public object Deserialize(byte[] data, Type target) - { - throw new NotImplementedException(); - } +[ExcludeFromCodeCoverage] +internal class FakeTestSerializer : ICacheSerializer +{ + public object Deserialize(byte[] data, Type target) => throw new NotImplementedException(); - public CacheItem DeserializeCacheItem(byte[] value, Type valueType) - { - throw new NotImplementedException(); - } + public CacheItem DeserializeCacheItem(byte[] value, Type valueType) => throw new NotImplementedException(); - public byte[] Serialize(T value) - { - throw new NotImplementedException(); - } + public byte[] Serialize(T value) => throw new NotImplementedException(); - public byte[] SerializeCacheItem(CacheItem value) - { - throw new NotImplementedException(); - } - } + public byte[] SerializeCacheItem(CacheItem value) => throw new NotImplementedException(); } #endif diff --git a/tools/common.props b/tools/common.props index 2099bf24..17c7a44c 100644 --- a/tools/common.props +++ b/tools/common.props @@ -3,9 +3,9 @@ Copyright (c) 2025 Michael Conrad - MichaConrad + Michael Conrad CacheManager.NET - + latest icon.png README.md Apache-2.0 diff --git a/tools/version.props b/tools/version.props index 16af6b29..d524ce46 100644 --- a/tools/version.props +++ b/tools/version.props @@ -1,5 +1,5 @@ - 2.0.0 + 3.0.0