Skip to content

Commit

Permalink
Adaptive caching
Browse files Browse the repository at this point in the history
* Adaptive caching impl

* Adaptive caching tests

* Better nullability

* Add prop IFusionCache.HasDistributedCache

* Added a warning log for the special case of memory cache + backplane + DefaultEntryOptions.EnableBackplaneNotifications == true but without a distributed cache

* Perf boost: use factory soft timeout as lock timeout when acquiring the lock if fail-safe is enabled + there is no specific lock timeout + there is a fallback entry

* Docs: adaptive caching

* Better stale hit for old stale data
  • Loading branch information
jodydonetti authored May 1, 2022
1 parent ca7c7de commit f1380b7
Show file tree
Hide file tree
Showing 36 changed files with 1,451 additions and 560 deletions.
62 changes: 33 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,17 @@ On August 2021, FusionCache received the [Google Open Source Peer Bonus Award](h
## :heavy_check_mark: Features
These are the **key features** of FusionCache:

- [**:rocket: Cache Stampede prevention**](docs/FactoryOptimization.md): using the optimized `GetOrSet[Async]` method prevents multiple concurrent factory calls per key, with a guarantee that only 1 will be executed at the same time for the same key. This avoids overloading the data source when no data is in the cache or when a cache entry expires
- [**:twisted_rightwards_arrows: 2nd level**](docs/CacheLevels.md): FusionCache can transparently handle an optional 2nd level cache: anything that implements the standard `IDistributedCache` interface is supported like Redis, MongoDB, CosmosDB, SqlServer, etc
- [**🚀 Cache Stampede prevention**](docs/FactoryOptimization.md): using the optimized `GetOrSet[Async]` method prevents multiple concurrent factory calls per key, with a guarantee that only 1 will be executed at the same time for the same key. This avoids overloading the data source when no data is in the cache or when a cache entry expires
- [**🔀 2nd level (optional)**](docs/CacheLevels.md): FusionCache can transparently handle an optional 2nd level cache: anything that implements the standard `IDistributedCache` interface is supported like Redis, MongoDB, CosmosDB, SqlServer, etc
- [**📢 Backplane**](docs/Backplane.md): when using a distributed cache as a 2nd layer in a multi-node scenario, you can also enable a backplane to immediately notify the other nodes about changes in the cache, to keep everything synchronized without having to do anything
- [**:bomb: Fail-Safe**](docs/FailSafe.md): enabling the fail-safe mechanism prevents throwing an exception when a factory or a distributed cache call would fail, by reusing an expired entry as a temporary fallback, all transparently and with no additional code required
- [**:stopwatch: Soft/Hard timeouts**](docs/Timeouts.md): advanced timeouts management prevents waiting for too long when calling a factory or the distributed cache, to avoid hanging your application. It is possible to specify both *soft* and *hard* timeouts, and thanks to automatic background completion no data will be wasted
- [**:zap: High performance**](docs/StepByStep.md): FusionCache is optimized to minimize CPU usage and memory allocations to get better performance and lower the cost of your infrastructure all while obtaining a more stable, error resilient application
- [**:dizzy: Natively sync/async**](docs/CoreMethods.md): full native support for both the synchronous and asynchronous programming model, with sync/async methods working together harmoniously
- [**:telephone_receiver: Events**](docs/Events.md): there's a comprehensive set of events to subscribe to regarding core events inside of a FusionCache instance, both at a high level and at lower levels (memory/distributed layers)
- [**:jigsaw: Plugins**](docs/Plugins.md): thanks to a plugin subsystem it is possible to extend FusionCache with additional behaviour, like adding support for metrics, statistics, etc
- [**:page_with_curl: Extensive logging**](docs/StepByStep.md): comprehensive, structured, detailed and customizable logging via the standard `ILogger<T>` interface (you can use Serilog, NLog, etc)
- [**💣 Fail-Safe**](docs/FailSafe.md): enabling the fail-safe mechanism prevents throwing an exception when a factory or a distributed cache call would fail, by reusing an expired entry as a temporary fallback, all transparently and with no additional code required
- [**⏱ Soft/Hard timeouts**](docs/Timeouts.md): advanced timeouts management prevents waiting for too long when calling a factory or the distributed cache, to avoid hanging your application. It is possible to specify both *soft* and *hard* timeouts, and thanks to automatic background completion no data will be wasted
- [**🧙‍♂️ Adaptive Caching**](docs/AdaptiveCaching.md): there are times when you don't know upfront what the cache duration for a piece of data should be, maybe because it depends on the object being cached itself. Adaptive caching solves this elegantly
- [**⚡ High performance**](docs/StepByStep.md): FusionCache is optimized to minimize CPU usage and memory allocations to get better performance and lower the cost of your infrastructure all while obtaining a more stable, error resilient application
- [**💫 Natively sync/async**](docs/CoreMethods.md): full native support for both the synchronous and asynchronous programming model, with sync/async methods working together harmoniously
- [**📞 Events**](docs/Events.md): there's a comprehensive set of events to subscribe to regarding core events inside of a FusionCache instance, both at a high level and at lower levels (memory/distributed layers)
- [**🧩 Plugins**](docs/Plugins.md): thanks to a plugin subsystem it is possible to extend FusionCache with additional behaviour, like adding support for metrics, statistics, etc
- [**📃 Extensive logging**](docs/StepByStep.md): comprehensive, structured, detailed and customizable logging via the standard `ILogger<T>` interface (you can use Serilog, NLog, etc)

<details>
<summary>Something more 😏 ?</summary>
Expand All @@ -79,7 +80,7 @@ Also, FusionCache has some nice **additional features**:
</details>


## :package: Distribution
## 📦 Distribution

Official packages:

Expand All @@ -100,7 +101,7 @@ Third-party packages:
| [JoeShook.ZiggyCreatures.FusionCache.Metrics.AppMetrics](https://www.nuget.org/packages/JoeShook.ZiggyCreatures.FusionCache.Metrics.AppMetrics/) | [![NuGet](https://img.shields.io/nuget/v/JoeShook.ZiggyCreatures.FusionCache.Metrics.AppMetrics.svg)](https://www.nuget.org/packages/JoeShook.ZiggyCreatures.FusionCache.Metrics.AppMetrics/) | ![Nuget](https://img.shields.io/nuget/dt/JoeShook.ZiggyCreatures.FusionCache.Metrics.AppMetrics) |


## :star: Quick Start
## Quick Start

FusionCache can be installed via the nuget UI (search for the `ZiggyCreatures.FusionCache` package) or via the nuget package manager console:

Expand All @@ -116,7 +117,7 @@ Product GetProductFromDb(int id) {
}
```

:bulb: This is using the **sync** programming model, but it would be equally valid with the newer **async** one for even better performance.
💡 This is using the **sync** programming model, but it would be equally valid with the newer **async** one for even better performance.

To start using FusionCache the first thing is create a cache instance:

Expand Down Expand Up @@ -164,7 +165,7 @@ cache.GetOrSet<Product>(
);
```

That's it :tada:
That's it 🎉

<details>
<summary>Want a little bit more 😏 ?</summary>
Expand Down Expand Up @@ -227,19 +228,20 @@ The `DefaultEntryOptions` we did set before will be duplicated and only the dura

The documentation is available in the :open_file_folder: [docs](docs/README.md) folder, with:

- [**:unicorn: A Gentle Introduction**](docs/AGentleIntroduction.md): what you need to know first
- [**:twisted_rightwards_arrows: Cache Levels**](docs/CacheLevels.md): a bried description of the 2 available caching levels and how to setup them
- [**🦄 A Gentle Introduction**](docs/AGentleIntroduction.md): what you need to know first
- [**🔀 Cache Levels**](docs/CacheLevels.md): a bried description of the 2 available caching levels and how to setup them
- [**📢 Backplane**](docs/Backplane.md): how to get an always synchronized cache, even in a multi-node scenario
- [**:rocket: Cache Stampede prevention**](docs/FactoryOptimization.md): no more overloads during a cold start or after an expiration
- [**:bomb: Fail-Safe**](docs/FailSafe.md): an explanation of how the fail-safe mechanism works
- [**:stopwatch: Timeouts**](docs/Timeouts.md): the various types of timeouts at your disposal (calling a factory, using the distributed cache, etc)
- [**:level_slider: Options**](docs/Options.md): everything about the available options, both cache-wide and per-call
- [**:joystick: Core Methods**](docs/CoreMethods.md): what you need to know about the core methods available
- [**:telephone_receiver: Events**](docs/Events.md): the events hub and how to use it
- [**:jigsaw: Plugins**](docs/Plugins.md): how to create and use plugins
- [**🚀 Cache Stampede prevention**](docs/FactoryOptimization.md): no more overloads during a cold start or after an expiration
- [**💣 Fail-Safe**](docs/FailSafe.md): an explanation of how the fail-safe mechanism works
- [**⏱ Timeouts**](docs/Timeouts.md): the various types of timeouts at your disposal (calling a factory, using the distributed cache, etc)
- [**🧙‍♂️ Adaptive Caching**](docs/AdaptiveCaching.md): how to adapt cache duration (and more) based on the object being cached itself
- [**🎚 Options**](docs/Options.md): everything about the available options, both cache-wide and per-call
- [**🕹 Core Methods**](docs/CoreMethods.md): what you need to know about the core methods available
- [**📞 Events**](docs/Events.md): the events hub and how to use it
- [**🧩 Plugins**](docs/Plugins.md): how to create and use plugins


## **:woman_teacher: Step By Step**
## **👩‍🏫 Step By Step**
If you are in for a ride you can read a complete [step by step example](docs/StepByStep.md) of why a cache is useful, why FusionCache could be even more so, how to apply most of the options available and what **results** you can expect to obtain.

<div style="text-align:center;">
Expand All @@ -249,7 +251,7 @@ If you are in for a ride you can read a complete [step by step example](docs/Ste
</div>


## :ab: Comparison
## 🆎 Comparison

There are various alternatives out there with different features, different performance characteristics (cpu/memory) and in general a different set of pros/cons.

Expand All @@ -265,17 +267,19 @@ FusionCache targets `.NET Standard 2.0` so any compatible .NET implementation is

The logo is an [original creation](https://dribbble.com/shots/14854206-FusionCache-logo) and is a [sloth](https://en.wikipedia.org/wiki/Sloth) because, you know, speed.

## 💰 Funding / Support
## 💰 Support

Nothing to do here.

After years of using a lot of open source stuff for free, this is just me trying to give something back to the community.
<br/>

If you find FusionCache useful please just [**:envelope: drop me a line**](https://twitter.com/jodydonetti), I would be interested in knowing about your usage.

And if you really want to talk about money, please consider making **:heart: a donation to a good cause** of your choosing, and maybe let me know about that.
And if you really want to talk about money, please consider making ** a donation to a good cause** of your choosing, and maybe let me know about that.

## 💼 Is it Production Ready :tm: ?
Even though the current version is `0.X` for an excess of caution, FusionCache is already used **in production** on multiple **real world projects** happily handling millions of requests per day, at least these are the projects I'm aware of. Considering that just the main package has surpassed the **40K downloads mark** (thanks everybody!) it is probably used even more.
Yes!

Even though the current version is `0.X` for an excess of caution, FusionCache is already used **in production** on multiple **real world projects** happily handling millions of requests per day, or at least these are the projects I'm aware of. Considering that just the main package has surpassed the **76K downloads mark** (thanks everybody!) it's probably used even more.

And again, if you are using it please [**:envelope: drop me a line**](https://twitter.com/jodydonetti), I'd like to know!
And again, if you are using it please [** drop me a line**](https://twitter.com/jodydonetti), I'd like to know!
96 changes: 96 additions & 0 deletions docs/AdaptiveCaching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<div align="center">

![FusionCache logo](logo-128x128.png)

</div>

# 🧙‍♂️ Adaptive Caching

Sometimes when you are caching a piece of data with the `GetOrSet` method you don't know upfront what the cache duration should be: this may happen because the cache duration depends on the object being cached itself.

Some examples may be:

- **📰 news articles**: a fresh article that has just been published on a news site may very well receive some updates very soon, maybe because it has been published very fast to get a news out but then some typos have been found or a slight change is needed. In this case it would be nice to be able to cache fresh content for a low amount of time like `30 sec` or `1 min`, whereas an old article that has not been touched for a year may very well be cached for like `10 min` or more, because it will be very unlikely that a quick update will be needed after all this time
- **🔑 auth tokens**: auth tokens or similar pieces of data typically have an associated expiration, and it would be nice to cache them accordingly
- **🌍 HTTP api results**: if your remote data source is not a database but, for example, an HTTP service like a rest endpoint the HTTP response may contain caching-related informations in the form of the `Cache-Control` HTTP header, which in turns typically contains the `max-age` directive. It would be nice to use that as the duration for the cache, respecting the service specification for how much a piece of data should be cached

The problem in all these cases is that when you call `GetOrSet` you'll provide both the factory (the function to be executed to get the data) and some options for that call (via `FusionCacheEntryOptions`), but as we said some of the options (eg: the `Duration`) may depend on the result of the factory, which has not run yet.

It seems like a chicken and egg problem, right?

Thankfully we have a solution: enter **adaptive caching**.


## 👩‍🏫 How it works

When calling `GetOrSet` you can choose different overloads, and the ones with a factory are available in 2 factory flavors: one with a *context* (of type `FusionCacheFactoryExecutionContext`) and one without it.

In the ones **with** the context you can simply change the context's `Options` property however you like.

Here are 2 examples, with and without the *context* object.


### Example: without adaptive caching

As you can see we are specifying the factory as a lambda that takes as input only a cancellation token `ct` (of type `CancellationToken`) and nothing else.

```csharp
var id = 42;

// WITHOUT ADAPTIVE CACHING: THE DURATION IS FIXED TO 1 MIN
var product = cache.GetOrSet<Product>(
$"product:{id}",
ct => GetProductFromDb(id, ct),
options => options.SetDuration(TimeSpan.FromMinutes(1)) // FIXED: 1 MIN
);
```

### Example: with adaptive caching

As you can see we are specifying the factory as a lambda that takes as input both a context `ctx` (of type `FusionCacheFactoryExecutionContext`) and a cancellation token `ct` (of type `CancellationToken`), so that we are able to change the options inside the factory itself.

```csharp
var id = 42;

// WITH ADAPTIVE CACHING: THE DURATION DEPENDS ON THE OBJECT BEING CACHED
var product = cache.GetOrSet<Product>(
$"product:{id}",
(ctx, ct) => {
var product = GetProductFromDb(id, ct);

if (product is null) {
// CACHE null FOR 5 minutes
ctx.Options.Duration = TimeSpan.FromMinutes(5);
} else if (product.LastUpdatedAt > DateTime.UtcNow.AddDays(-1)) {
// CACHE PRODUCTS UPDATED IN THE LAST DAY FOR 1 MIN
ctx.Options.Duration = TimeSpan.FromMinutes(1);
} else if (product.LastUpdatedAt > DateTime.UtcNow.AddDays(-10)) {
// CACHE PRODUCTS UPDATED IN THE LAST 10 DAYS FOR 10 MIN
ctx.Options.Duration = TimeSpan.FromMinutes(10);
} else {
// CACHE ANY OLDER PRODUCT FOR 30 MIN
ctx.Options.Duration = TimeSpan.FromMinutes(30);
}

return product;
},
options => options.SetDuration(TimeSpan.FromMinutes(1)) // DEFAULT: 1 MIN
);
```

You may change other options too, like the `Priority` for example.

Of course ther are some changes that wouldn't make much sense: if for example we change the `FactorySoftTimeout` after the factory has been already executed we shouldn't expect much to happen, right 😅 ?


## ⏱ Timeouts & Background factory completion

Short version: everything works as expected!

Longer version: there are times when, if a factory is taking a lot of time to complete (because maybe the database is overloaded or there's a temporary network congestion), you would prefer stale data very fast instead of fresh data but slowly.

A nice feature of FusionCache is the ability to set soft/hard [timeouts](Timeouts.md) to get the best of both worlds: an always fast response with fresh data as soon as possible. As we know when a timeout kicks in, the running factory is not stopped but kept running in the background.

A question we may ask ourselves is if adaptive caching would still work in that scenario.

The answer is **absolutely yes**: you don't need to change anything, do extra steps or give up on some features 🎉
16 changes: 8 additions & 8 deletions docs/Backplane.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ A backplane is like a message bus where change notifications will be published t

By default, everything is handled transparently for us 🎉

## How it works
## 👩‍🏫 How it works

As an example, let's look at the flow of a `GetOrSet` operation with 3 nodes (`N1`, `N2`, `N3`):

Expand All @@ -38,7 +38,7 @@ As an example, let's look at the flow of a `GetOrSet` operation with 3 nodes (`N
As we can see we didn't have to do anything more than usual: everything else is done automatically for us.


## Packages
## 📦 Packages

Currently there are 2 official packages we can use:

Expand All @@ -50,7 +50,7 @@ Currently there are 2 official packages we can use:
If we are already using a Redis instance as a distributed cache, we just have to point the backplane to the same instance and we'll be good to go (but if we share the same Redis instance with multiple caches, please read [some notes](RedisNotes.md)).


## Example
### Example

As an example, we'll use FusionCache with [Redis](https://redis.io/), as both a **distributed cache** and a **backplane**.

Expand Down Expand Up @@ -125,7 +125,7 @@ But is it really necessary to use a distributed cache at all?
Let's find out.


## Distributed cache: is it really necessary?
## 🤔 Distributed cache: is it really necessary?

The idea seems like a nice one: in a multi-node scenario we may want to use only memory caches on each node + the backplane for cache synchronization, without having to use a shared distributed cache.

Expand Down Expand Up @@ -154,7 +154,7 @@ This is because not having a **shared state** means we don't know when something
So how can we solve this?


## Look ma: no distributed cache!
## 🥳 Look ma: no distributed cache!

The solution is to **disable automatic backplane notifications** and publish them only when we want to signal an actual change.

Expand All @@ -167,7 +167,7 @@ But then, when we **want** to publish a notification, how can we do it? Easy pea
Let's look at a concrete example.


## Example
### Example

```csharp
// INITIAL SETUP: DISABLE AUTOMATIC NOTIFICATIONS
Expand All @@ -189,11 +189,11 @@ cache.Remove(
);
```

## ⚠ External changes
## ⚠ External changes: be careful

Just to reiterate, because it's very important: when using the backplane **without** a distributed cache, any change not manually published by you would result in different nodes not being synched.

This means that, if you want to use the backplane without the distributed cache, you should be confident of the fact that **ALL** changes will be notified by you manually.
This means that, if you want to use the backplane without the distributed cache, you should be confident about the fact that **ALL** changes will be notified by you manually.

To better understand what would happen otherwise let's look at an example, again with a couple of `GetOrSet` operations on 3 nodes (`N1`, `N2`, `N3`):

Expand Down
Loading

0 comments on commit f1380b7

Please sign in to comment.