Core Concepts
Fusion is a library that brings real-time capabilities to your .NET applications with minimal effort. This guide will introduce you to the core concepts and show you how to get started.
Fusion is built around three key abstractions:
- Computed Values, or
Computed<T>instances, are immutable results of computations that signal when they become outdated (invalidated). Once aComputed<T>gets invalidated, you can get its newest version (another instance) by calling itsUpdatemethod. - Compute Services are services exposing Computed Methods. Such methods look very similar to regular methods but produce computed values behind the scenes. You may think of them as "parameterized recipes" for computed values. When you call such a method, it either produces a new
Computed<T>bound to this specific call (i.e. to the(service, method, arguments)triplet) behind the scenes, or pulls the matching one from cache. If the cached value is still consistent (not invalidated yet), the call is resolved without actual computation. - Finally, Computed States are objects encapsulating a computed value and its auto-update loop. Any computed value knows how to produce the most up-to-date version, but doesn't do this automatically, and that's intentional. For example, if such a value is used in the UI, in some cases you may want to update it instantly (e.g. if we know the user just performed an action, so we want to show its result immediately), but in many other cases it makes sense to throttle down the update rate. Computed states solve exactly this problem: they combine a computed value and its update policy (
IUpdateDelayer).
1. Compute Services and Compute Methods
Here's a simple counter service that demonstrates Fusion's basic capabilities:
public class CounterService : IComputeService // This is a tagging interface any compute service must "implement"
{
private readonly ConcurrentDictionary<string, int> _counters = new();
[ComputeMethod] // Indicates this is a compute method
public virtual async Task<int> Get(string key) // Must be virtual & async
{
var value = _counters.GetValueOrDefault(key, 0);
WriteLine($"Get({key}) = {value}");
return value;
}
[ComputeMethod] // Indicates this is a compute method
public virtual async Task<int> Sum(string key1, string key2) // Must be virtual & async
{
var value1 = await Get(key1);
var value2 = await Get(key2);
var sum = value1 + value2;
WriteLine($"Sum({key1}, {key2}) = {sum}");
return sum;
}
// This is a regular method, so there are no special requirements
public void Increment(string key)
{
WriteLine($"Increment({key})");
_counters.AddOrUpdate(key, k => 1, (k, v) => v + 1);
using (Invalidation.Begin()) {
// Any call to a compute method inside this block means "invalidate the value for that call"
_ = Get(key); // So here we invalidate the value of this.Get(...) call with the `key` argument
}
}
}To use this service, first register it with dependency injection:
var services = new ServiceCollection();
var fusion = services.AddFusion(); // You can also use services.AddFusion(fusion => ...) pattern
fusion.AddComputeService<CounterService>();
var sp = services.BuildServiceProvider();
// And that's how we get our first compute service:
var counters = sp.GetRequiredService<CounterService>();Let's see how the behavior of compute methods in CounterService differs from the expected one:
Automatic Caching
await counters.Get("a"); // Prints: Get(a) = 0
await counters.Get("a"); // Prints nothing -- it's a cache hit; the result is 0Moreover, it works even when compute methods call each other. Notice that the Sum("a", "b") call here calls Get("a"), which gets resolved without an actual computation. On the other hand, Get("b") gets computed. But once we call it again, it also gets resolved from the cache.
await counters.Sum("a", "b"); // Prints: Get(b) = 0, Sum(a, b) = 0 -- Get(b) was called from Sum(a, b)
await counters.Sum("a", "b"); // Prints nothing -- it's a cache hit; the result is 0
await counters.Get("b"); // Prints nothing -- it's a cache hit; the result is 0Invalidation
Invalidation means marking a certain computed value as "outdated". If you have a Computed<T> instance, you can do this directly. But if it's about invalidating a value that corresponds to a certain compute method call, you can also do this by making this call inside using (Invalidation.Begin()) { ... } block:
using (Invalidation.Begin()) {
// Any call to a compute method here:
// - Won't execute the body of the compute method
// - Will complete synchronously by returning a completed (Value)Task<T> with Result = default(T)
// - Will invalidate the cached Computed<T> instance (if it exists) corresponding to the call
}And if you look at the code of the CounterService.Increment method, that's exactly what happens there to invalidate the Get(key) call on every increment.
counters.Increment("a"); // Prints: Increment(a) + invalidates Get(a) call result
await counters.Get("a"); // Prints: Get(a) = 1
await counters.Get("b"); // Prints nothing -- Get(b) call wasn't invalidated, so it's a cache hitAutomatic Dependency Tracking and Cascading Invalidation
We know that when a compute method gets called, it builds or uses an existing Computed<T> instance, which stores the cached call result and tracks its invalidation. But there is one other thing Computed<T> does: it tracks dependencies of a computation that produced this computed value.
Each Computed<T> instance knows all other Computed<T> instances that were "used" to produce it, and vice versa – each computed value can enumerate every other computed value that depends on it, directly or indirectly.
Mathematically speaking, computed values form a Directed Acyclic Graph (DAG) of dependencies between each other. And this graph evolves at runtime:
- When a compute method gets called, it produces a new node (
Computed<T>instance) in this graph. The edges of this node point to every other node it "uses"; they're added when the compute method runs – specifically, when it calls other compute methods. - When
Computed<T>gets invalidated, all of its dependencies (i.e. nodes that use it directly or indirectly) get invalidated as well. Any invalidated node is implicitly removed from the graph, because there can be no edges pointing to it.
Let's see all of this in action:
counters.Increment("a"); // Prints: Increment(a)
// Increment(a) invalidated Get(a), but since invalidations are cascading,
// and Sum(a, b) depends on Get(a), it's also invalidated.
// That's why Sum(a, b) is going to be recomputed on the next call, as well as Get(a),
// which is called by Sum(a, b).
await counters.Sum("a", "b"); // Prints: Get(a) = 2, Sum(a, b) = 2
await counters.Sum("a", "b"); // Prints nothing, it's a cache hit; the result is 0
// Even though we expect Sum(a, b) == Sum(b, a), Fusion doesn't know that.
// Remember, "cache key" for any compute method call is (service, method, args...),
// and arguments are different in this case: (a, b) != (b, a).
// So Fusion will have to compute Sum(b, a) from scratch.
// But note that Get(a) and Get(b) calls it makes are still resolved from cache.
await counters.Sum("b", "a"); // Prints: Sum(b, a) = 2 -- Get(b) and Get(a) results are already cached2. Computed Values
You already know that compute methods produce computed values (Computed<T> instances) behind the scenes.
Computed values follow a simple lifecycle:
- They start as mutable objects in the
Computingstate while being computed; you can observe aComputed<T>in this state by callingComputed.GetCurrent()inside a compute method - Once the computation ends, they become
Consistentand immutable - Finally, they may eventually turn
Inconsistent.
At any given time, there can be only one Consistent version of a computed value that corresponds to a certain computation, even though older Inconsistent versions may still reside in memory.
Let's pull a Computed<T> instance that is associated with a given call and play with it:
var computedForGetA = await Computed.Capture(() => counters.Get("a"));
WriteLine(computedForGetA.IsConsistent()); // True
WriteLine(computedForGetA.Value); // 2
var computedForSumAB = await Computed.Capture(() => counters.Sum("a", "b"));
WriteLine(computedForSumAB.IsConsistent()); // True
WriteLine(computedForSumAB.Value); // 2
// Adding invalidation handler; you can also use WhenInvalidated
computedForSumAB.Invalidated += _ => WriteLine("Sum(a, b) is invalidated");
// Manually invalidate computedForGetA, i.e. the result of counters.Get("a") call
computedForGetA.Invalidate(); // Prints: Sum(a, b) is invalidated
WriteLine(computedForGetA.IsConsistent()); // False
WriteLine(computedForSumAB.IsConsistent()); // False, invalidation is always cascading
// Manually update computedForSumAB
var newComputedForSumAB = await computedForSumAB.Update();
// Prints:
// Get(a) = 2, we invalidated it, so it was of Sum(a, b)
// Sum(a, b) = 2, .Update() call above actually triggered this call
WriteLine(newComputedForSumAB.IsConsistent()); // True
WriteLine(newComputedForSumAB.Value); // 2
// Calling .Update() for consistent Computed<T> returns the same instance
WriteLine(computedForSumAB == newComputedForSumAB); // False
WriteLine(newComputedForSumAB == await computedForSumAB.Update()); // True
// Since `Computed<T>` are almost immutable,
// the outdated computed instance is still usable:
WriteLine(computedForSumAB.IsConsistent()); // False
WriteLine(computedForSumAB.Value); // 2Reactive Updates on Invalidation
Now we are ready to write a basic reactive update loop:
_ = Task.Run(async () => {
// This is going to be our update loop
for (var i = 0; i <= 3; i++) {
await Task.Delay(1000);
counters.Increment("a");
}
});
var clock = Stopwatch.StartNew();
var computed = await Computed.Capture(() => counters.Sum("a", "b"));
WriteLine($"{clock.Elapsed:g}s: {computed}, Value = {computed.Value}");
for (var i = 0; i <= 3; i++) {
await computed.WhenInvalidated();
computed = await computed.Update();
WriteLine($"{clock.Elapsed:g}s: {computed}, Value = {computed.Value}");
}Computed.When() and Changes() Methods
You already saw WhenInvalidated() method in action. Let's look at two more useful methods:
When()method allows you to await for a computed value to satisfy certain predicate. It returns aTask<Computed<T>>.Changes()method allows you to observe changes in a computed value over time. It returns anIAsyncEnumerable<Computed<T>>, which yields the current value first, and new computed values as they become available. The async enumerable it builds is going to yield the items until the moment it gets canceled.
And finally, the example below shows that you can deconstruct a Computed<T> instance to get its ValueOrDefault and Error properties. Since Value property is not accessed during the deconstruction, it doesn't throw an exception if the computed value has an Error.
_ = Task.Run(async () => {
// This is going to be our update loop
for (var i = 0; i <= 5; i++) {
await Task.Delay(333);
counters.Increment("a");
}
});
var clock = Stopwatch.StartNew();
var computed = await Computed.Capture(() => counters.Sum("a", "b"));
// Computed<T>.When(..) example:
computed = await computed.When(x => x >= 10); // ~= .Changes().When(predicate).First()
// Computed<T>.Changes() example:
IAsyncEnumerable<Computed<int>> changes = computed.Changes();
_ = Task.Run(async () => {
await foreach (var (value, error) in changes) // Computed<T> deconstruction example
WriteLine($"{clock.Elapsed:g}s: Value = {value}, Error = {error}");
});
await Task.Delay(5000); // Wait for the changes to be processed3. State<T> and Its Variants
State is the last missing piece of a puzzle. If you are familiar with Knockout.js or MobX, state would correspond to their versions of "computed observables".
Every state tracks the most recent version of some Computed<T>. That's why states are so useful for reactive updates.
Any State<T>:
- Has
Computedproperty, which points to the most recent version ofComputed<T>it tracks. - Has a
Snapshotproperty ofStateSnapshot<T>type. This property is updated atomically and returns an immutable object describing the current "state" of theState<T>. If you ever need a "consistent" view of the state,Snapshotis the way to get it. A good example of where you'd need it is this one:- You read
state.HasValuefirst, it returnstrue - But a subsequent attempt to read
state.Valuefails because the state was updated right between these two reads.
- You read
- Both
State<T>andStateSnapshot<T>exposeLastNonErrorValueandLastNonErrorComputedproperties – these allow access to the last validValueand itsComputed<T>exposed by the state. In other words, when a state exposes anError,LastNonErrorValuestill exposes the previousValue. This feature is quite handy when you need to access both the last "correct" value (to e.g. bind it to the UI) and the newly observedError(to display it separately). - Similar to
Computed<T>, any state implementsIResult<T>by forwarding all calls to itsComputedproperty. - Similar to
IEnumerable<T>\IEnumerable, there are typed and untyped versions of anyIStateinterface.
There are two implementations of State<T>:
MutableState<T>is a mutable value (variable) inComputed<T>envelope. ItsComputedproperty returns an always-consistent computed, which gets replaced once theMutableState.Value(orError, etc.) is set; the old computed gets invalidated. You can use mutable states in compute methods or computed states – since any state tracks someComputed<T>, it can be a dependency of another computed value. Typically such states are used to describe the client-side state of certain UI elements (e.g. a value entered into a search box).ComputedState<T>is, in fact, a compute method and an update loop that triggers the recomputation after a certain delay following invalidation. The delay is just aTaskprovided byIUpdateDelayerbound to this state, so it can vary from state to state, from time to time, or even end instantly when, for example, a user action occurs – to make every state instantly reflect the change.
ComputedState<T> powers the UI updates in Fusion+Blazor apps. It is used by ComputedStateComponent<T>, a Blazor component that automatically re-renders when changes occur in its computed state.
Here is a brief description of key differences between these two states:
Constructing States
States are constructed using StateFactory – one of the singletons that .AddFusion() injects into IServiceProvider.
There is also StateFactory.Default, which is intended to be used mainly in tests. Unless you set it to a specific state factory, it will use its own "minimal" service provider.
Mutable State
Let's play with MutableState<int>:
var stateFactory = sp.StateFactory(); // Same as sp.GetRequiredService<IStateFactory>()
var state = stateFactory.NewMutable(1);
var oldComputed = state.Computed;
WriteLine($"Value: {state.Value}, Computed: {state.Computed}");
// Value: 1, Computed: StateBoundComputed<Int32>(MutableState<Int32>-Hash=39252654 v.d2, State: Consistent)
state.Set(2);
WriteLine($"Value: {state.Value}, Computed: {state.Computed}");
// Value: 2, Computed: StateBoundComputed<Int32>(MutableState<Int32>-Hash=39252654 v.h2, State: Consistent)
WriteLine($"Old computed: {oldComputed}"); // Should be invalidated
// Old computed: StateBoundComputed<Int32>(MutableState<Int32>-Hash=39252654 v.d2, State: Invalidated)
var result = Result.NewError<int>(new ApplicationException("Just a test"));
state.Set(1);
try {
WriteLine($"Value: {state.Value}, Computed: {state.Computed}");
// Accessing state.Value throws ApplicationException
}
catch (ApplicationException) {
WriteLine($"Error: {state.Error?.GetType()}, Computed: {state.Computed}");
}
WriteLine($"LastNonErrorValue: {state.LastNonErrorValue}");
// LastNonErrorValue: 2
WriteLine($"Snapshot.LastNonErrorComputed: {state.Snapshot.LastNonErrorComputed}");
// Snapshot.LastNonErrorComputed: StateBoundComputed<Int32>(MutableState<Int32>-Hash=39252654 v.h2, State: Invalidated)Computed State
Here is an example showing what ComputedState<T> and MutableState<T> can do together:
var stateFactory = sp.StateFactory();
var clock = Stopwatch.StartNew();
// We'll use this state as a dependency for the computed state
var mutableState = stateFactory.NewMutable("x");
// ComputedState<T> instances must be disposed, otherwise they'll never stop recomputing!
using var computedState = stateFactory.NewComputed(
new ComputedState<string>.Options() {
InitialValue = "<initial>",
UpdateDelayer = FixedDelayer.Get(1), // 1 second update delay
// You can attach event handlers later as well. EventConfigurator allows setting them up
// right on construction, i.e., before any of these events can occur.
EventConfigurator = state => {
// A shortcut to attach 3 event handlers: Invalidated, Updating, Updated
state.AddEventHandler(
StateEventKind.All,
(s, e) => WriteLine($"{clock.Elapsed:g}s: {e}, Value: {s.Value}, Computed: {s.Computed}"));
},
},
// This lambda describes how the computed state is computed –
// essentially, it's a compute method written as a lambda.
async (state, cancellationToken) => {
// We intentionally delay the computation here to show how the initial value works
await Task.Delay(100, cancellationToken);
var counter = await counters.Get("a");
// state.Use() is required to track the state usage inside a compute method
var mutableValue = await mutableState.Use(cancellationToken);
return $"({counter}, {mutableValue})";
});
WriteLine($"{clock.Elapsed:g}s: CREATED, Value: {computedState.Value}, Computed: {computedState.Computed}");
await computedState.Update(); // This ensures the very first value is computed
WriteLine($"{clock.Elapsed:g}s: UPDATED, Value: {computedState.Value}, Computed: {computedState.Computed}");
counters.Increment("a");
await Task.Delay(2000);
mutableState.Set("y");
await Task.Delay(2000);
/* The output – pay attention to timestamps:
0:00:00.0080204s: Invalidated, Value: <initial>, Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.st, State: Invalidated)
0:00:00.0126295s: Updating, Value: <initial>, Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.st, State: Invalidated)
0:00:00.0161148s: CREATED, Value: <initial>, Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.st, State: Invalidated)
0:00:00.1297889s: Updated, Value: (6, x), Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.10t, State: Consistent)
0:00:00.1305231s: UPDATED, Value: (6, x), Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.10t, State: Consistent)
Increment(a)
0:00:00.1308741s: Invalidated, Value: (6, x), Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.10t, State: Invalidated)
0:00:01.1392269s: Updating, Value: (6, x), Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.10t, State: Invalidated)
Get(a) = 7
0:00:01.2481635s: Updated, Value: (7, x), Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.14t, State: Consistent)
0:00:02.1347489s: Invalidated, Value: (7, x), Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.14t, State: Invalidated)
0:00:03.1433923s: Updating, Value: (7, x), Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.14t, State: Invalidated)
0:00:03.2524918s: Updated, Value: (7, y), Computed: StateBoundComputed<String>(FuncComputedStateEx<String>-Hash=27401660 v.gq, State: Consistent)
*/