Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement minimal implementation of HybridCache #55147

Merged
merged 80 commits into from
Apr 19, 2024
Merged

Conversation

mgravell
Copy link
Member

@mgravell mgravell commented Apr 15, 2024

Implement minimal implementation of HybridCache

This is the first "make something that works" iteration of HybridCache, that builds on the abstract API proposed in #55084 (epic: #53255 also: #54647); it will be re-targeted at main as soon as #55084 is merged

Broad design overview in src/Caching/Hybrid/src/Internal/readme.md

Implemented this wave:

  • stampede protection (concurrent request combining)
  • serialization (including configuration)
  • underlying service invocation
  • L2 (distributed cache) DI, read, and write-back
  • L1 (memory cache) DI, read, and write-back
  • immutable vs mutable type support (defensive copies for mutable)
  • support for legacy (byte[]) and "buffer" (ReadOnlySequence<byte>, IBufferWriter<byte>) L2 backends
  • flags detection to disable L1, L2, underlying
  • direct set-value with L1/L2 write
  • Redis and SQL Server support for "buffer" L2 backend
  • supports net9.0; ns2.0; ns2.1; netfx
  • benchmarks for IDistributedCache vs IBufferDistributedCache
  • extensive tests for Redis and SQL Server implementations of IBufferDistributedCache (and we found a SqlClient glitch: Inconsistent handling of empty BLOB slices as parameters SqlClient#2465)

Explicitly not implemented yet:

  • tagging
  • L2-assisted cache invalidation
  • additional storage metadata
  • metrics
  • logging
  • compression
  • support for L3+

Here's the basic headline numbers comparing HybridCache to a class IDistributedCache "get, test, deserialize | fetch+serialize+set" loop using a local Redis backend and a simple POCO type (the fetch is artificial, but the test is fundamentally a 100% hit scenario):

BenchmarkDotNet=v0.13.0, OS=Windows 10.0.26100
AMD Ryzen 9 7900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK=9.0.100-preview.4.24215.2
  [Host]     : .NET 9.0.0 (9.0.24.20603), X64 RyuJIT
  DefaultJob : .NET 9.0.0 (9.0.24.21104), X64 RyuJIT


|                  Method |          Mean |        Error |       StdDev | Ratio |  Gen 0 | Gen 1 | Gen 2 | Allocated |
|------------------------ |--------------:|-------------:|-------------:|------:|-------:|------:|------:|----------:|
|     HitDistributedCache | 553,492.01 ns | 5,439.496 ns | 4,821.972 ns | 1.000 |      - |     - |     - |   1,464 B |
|          HitHybridCache |     608.02 ns |    12.098 ns |    29.218 ns | 0.001 | 0.0105 |     - |     - |     176 B |
| HitHybridCacheImmutable |      33.02 ns |     0.664 ns |     1.145 ns | 0.000 |      - |     - |     - |         - |

(yes, callers could add L1 support, but many don't because it is awkward; stampede support is very tricky to get right, so is almost never implemented; the point of HybridCache is to make it easy to get all the right behaviors)

@dotnet-issue-labeler dotnet-issue-labeler bot added the area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions label Apr 15, 2024
Copy link
Member

@amcasey amcasey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where I ran out of time for today. Mostly simple questions.

@mgravell mgravell enabled auto-merge (squash) April 19, 2024 16:16
Copy link
Member

@amcasey amcasey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Responses to responses.

}
return value;

static void Throw() => throw new ObjectDisposedException("The cache item has been recycled before the value was obtained");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason the JIT doesn't inline methods with throws is that it wants to keep them on the stack in the event of an exception?


partial class DefaultHybridCache
{
internal readonly struct StampedeKey : IEquatable<StampedeKey>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ToString seems adequate to me.


// otherwise, either nothing existed - or the thing that already exists can't be joined;
// in that case, go ahead and use the state that we invented a moment ago (outside of the lock)
_currentOperations[stampedeKey] = stampedeState;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is technically still a race here. Up to you to decide if it's worth fixing though.

Copy link
Member Author

@mgravell mgravell Apr 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only competition scenario involves the remove loop, but that would mean we also couldn't join with the thing being removed (or we would have exited 5 lines above), which means we're still doing the right thing by running our own, even if we end up being the only caller. I concede there may be some extreme edge case where we end up unnecessarily having a worker, but: our aim here is to reduce that to almost zero; at that point, we're good, IMO

{
_key = key;
_flags = flags;
_hashCode = key.GetHashCode() ^ (int)flags;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pointed to the apisof page because it tells you what frameworks the API exists on and what package you need to use on what framework.

TLDR; there is a package with the API for netfx.

@@ -21,17 +21,21 @@ internal sealed class DefaultJsonSerializer<T> : IHybridCacheSerializer<T>
T IHybridCacheSerializer<T>.Deserialize(ReadOnlySequence<byte> source)
{
var reader = new Utf8JsonReader(source);
#pragma warning disable IDE0079 // unnecessary suppression: TFM-dependent
#pragma warning disable IL2026, IL3050 // AOT bits
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something that will be fixed in future previews?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe so, yes

// give it a moment for the eviction callback to kick in
for (var i = 0; i < 10 && cacheItem.NeedsEvictionCallback; i++)
{
await Task.Delay(250);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I strongly dislike test code like this (even though I occasionally do it too), any way to make it deterministic instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're at the mercy of MemoryCache's timing on calling the eviction callback, which seems to be non-deterministic - I'm guessing they'r using QUWI. We could make it deterministic by adding an event or something that lets us know when that happens, but that is bending the design a bit far for testing. We could perhaps add a DEBUG-only event that we hook in DEBUG?

Copy link
Member

@amcasey amcasey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand all of it, but I also don't see anything that makes me think we shouldn't merge it.

@mgravell mgravell merged commit 8660e3a into main Apr 19, 2024
26 checks passed
@mgravell mgravell deleted the marc/hybrid-api-basic branch April 19, 2024 21:24
@dotnet-policy-service dotnet-policy-service bot added this to the 9.0-preview4 milestone Apr 19, 2024
@amcasey
Copy link
Member

amcasey commented Apr 19, 2024

Ah, I missed that this was auto-merge. Well, I stand by my claim that it was mergeable, but I think a follow-up changing addressing the remaining feedback is in order.

@mgravell
Copy link
Member Author

amcasey 13 hours ago
Sorry, I'm going to push since this seems important and I'm still not following it. Are you saying that the built-in byte[].Length will return a negative value if it came from the pool? Did we forcibly set the sign bit somewhere? Or maybe there's some reason this isn't the built-in byte[].Length?

adding comment

@mgravell
Copy link
Member Author

The reason the JIT doesn't inline methods with throws is that it wants to keep them on the stack in the event of an exception?

I can't speak for the "why", but: it does reduce the inlineable method size to the point where the main thing (the non-exceptional case) can often be inlined; I believe modern JIT can also see methods that always / only throw, and never try inlining, which again: can leave the non-exceptional cases more inlineable; we don't really care about the stack frames - it is more about body size, IIRC

@mgravell
Copy link
Member Author

I pointed to the apisof page because it tells you what frameworks the API exists on and what package you need to use on what framework.

TLDR; there is a package with the API for netfx.

@BrennanConroy this really doesn't warrant an extra package; heck, we could probably remove the flags part of the hash and just use the key's hashcode, and we'd be absolutely fine

@mgravell
Copy link
Member Author

@BrennanConroy race condition concerns addressed here: eeaf857

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants