Feeds architecture
This document provides information about the architecture and implementation of feeds. To learn about how to use the feeds framework in your applications, refer to the feeds concepts documentation.
Dev general guidelines
- All instances of feeds should be cached using the
AttachedPropertyhelper class. This helper ensures that each feed instance is cached and reused based on an owner and key. - Consequently, all feed implementations must be stateless (except for the special case of
State<T>). - When invoking a user's asynchronous method, the
SourceContextshould be set as ambient (seeFeedHelper.InvokeAsync). TheSourceContextmanages the subscription state and caching for feeds. - Untyped interfaces (such as
IMessage,IMessageEntry, etc.) exist only for data binding purposes and should not be re-implemented or used in source code.
Caching
To enable a lightweight creation syntax in property getters (such as public Feed<int> MyFeed => _anotherFeed.Select(_ => 42), which is re-evaluated each time the property is accessed), while avoiding rebuilding and re-querying the feed on every access, the framework implements two levels of caching.
Instance caching
The first level caches the feed instance itself using the AttachedProperty helper class. Each feed is attached to an owner and identified by a key. This ensures that for a given owner, there is only one instance of the declared feed. For example, calling _anotherFeed.Select(_ => 42) multiple times will always return the same feed instance.
The owner is determined by the following order of preference:
- The parent feed, if one exists (such as
_anotherFeedin the example above) - The
Targetproperty of thekeydelegate, which is the instance of the class declaring the feed - The
keydelegate itself, if it's a static delegate instance (for example, in_ => 42, theTargetwill benullbecause the delegate doesn't capture any variables)
The key is usually the delegate that is provided by the user. It’s really important here to note that a helper method like below would break the instance caching:
public static Feed<string> ToString(this IFeed<T> feed, Func<T, string> toString)
=> feed.Select(t => toString(t));
As the delegate t => toString(t) is declared in the method itself, it will be re-instantiated each time. Valid implementations would be:
public static Feed<string> ToString(this IFeed<T> feed, Func<T, string> toString)
=> feed.Select(toString); // We are directly forwarding the user delegate
public static Feed<string> ToString(this IFeed<T> feed, Func<T, string> toString)
=> AttachedProperty.GetOrCreate(owner: feed, key: toString, factory: (theFeed, theToString) => theFeed.Select(t => theToString(t))); // We are explicitly caching the instance.
Subscription caching
The second level of caching manages the "subscription" to a feed. This ensures that within a given context, enumerating or awaiting the same feed multiple times won't rebuild the value (notably, it won't re-request a value from a web API call).
This caching is achieved by the SourceContext.
These context instances are weakly attached to an owner (typically a ViewModel), and each call to context.GetOrCreateSource(feed) returns a stateful subscription to the feed that replays the last received message.
Important
When implementing an IFeed, the context provided in GetSource is only intended to be used to restore it as the current context in specific circumstances, such as when invoking a user's async method. Your feed must remain stateless, so you should not call context.GetOrCreateSource(parent).
On the other hand, any helper that allows users to "subscribe" to a feed should use SourceContext.Current.GetOrCreateSource(feed) rather than feed.GetSource(SourceContext.Current).
Issuing messages
When implementing an IFeed, you need to create and yield messages to subscribers.
If your feed doesn't have a parent feed, the easiest approach is to start with Message<T>.Initial (do not send it as the first message), and then update it:
var current = Message<int>.Initial;
for (var i = 0; i++; i < 42)
{
yield return i;
await Task.Delay(100, ct);
}
If your feed does have a parent feed, you should use the MessageManager<TParent, TResult> class, for example:
var manager = new MessageManager<TParent, TResult>();
var msgIndex = 0;
await foreach(var parentMsg in _parent.GetSource(context))
{
if (manager.Update(localMsg => localMsg.With(parentMsg).Data(msgIndex++)))
{
yield return manager.Current;
}
}
Important
Make sure that your feed always produces at least one message. If there isn't any relevant message to send, yield Message<T>.Initial before completing the IAsyncEnumerable source.
If you have a parent feed, make sure to always forward the parent message, even if it doesn't change any local value: manager.Update(localMsg => localMsg.With(parentMsg)).
Note
Enumeration of an IAsyncEnumerable is sequential (i.e., one value at a time). The MessageManager also has a constructor that allows asynchronously sending messages to an AsyncEnumerableSubject.
Axes
An axe is referring to an “informational axe” related to a given data, a.k.a. a metadata. Currently the feed framework is managing (i.e., actively generating value for) only 2 metadata: error and progress, but as Messages are designed to encapsulate a data and all its metadata, a MessageEntry can have more than those 2 well-known axes.
┌────┐1 *┌────────┐ 2┌────────────┐1 *┌────────────────┐
│Feed├────►│Message │ ┌─►│MessageEntry├────►│MessageAxis │
└────┘ ├────────┤ │ ├────────────┤ └─────┬──────────┘
│Previous├──┤ │Data │1 │1
│Current ├──┘ │Error ├──┐ │
│Changes │ │Progress │ │ │1
└────────┘ └────────────┘ │ *┌─────┴──────────┐
└─►│MessageAxisValue│
└────────────────┘
MessageEntryare basically dictionaries ofMessageAxisandMessageAxisValue, except they are returningMessageAxisValue.Unsetinstead of throwing error for unset axes.The
DataAxisis a special axis that must be set on all entries. It exists only to unify/ease implementation. You should useOption.Undefinedto “unset” the data.If you are about to add an axe, you should make sure to provided extensions methods over
IMessageBuilderandIMessageEntryto read/write it directly to/from the effective metadata type. The genericGetandSetare there for that and should not be used directly in user’s code.
Request
The subscriber of a feed can send some request to the source feed to enhance its behavior.
The most common request is the RefreshRequest.
When implementing an
IFeedyou have access to those requests using theRequests<TRequest>()method on theSourceContextyou get in theGetSource.When consuming a feed, you can send a request to that feed by creating a "child" context (
SourceContext.CreateChild()) giving you ownIRequestSource.
Token and TokenSet
In a response to a request a feed might issue a token that is then added to its messages so the subscriber that sent the request is able to track the completion of the request. This is the case for the Refresh and the Pagination which are forwarding those tokens through the refresh and the pagintation axes.
As when a subscriber sends a request, it might be handled by more than one feed. For instance, when combining two AsyncFeed instances, the refresh request will cause those two feeds to refresh their data.
Even if it's not yet the implemented, we can also imagine that an operator feed (such as the SelectAsyncFeed) might also trigger a refresh of its own projection.
Refresh and Pagination axes are working with TokenSet. It is a collection of IToken that only keep the latest tokens for a given source in relation to the subscription context.
Subscriber Combine Async A Async B
│ │ │ │
┌─ Control channel (Requests on SourceContext) ─┐
│ │ │ │ │ │
│ │ Request 1 │ │ │ │
┌──┼──◄─┼──────────────►│ Request 1 │ │ │
│ │ │ ├──────────►│ │ │
▼ │ │ │ Token A │ │ │
│ │ │ │◄──────────┘ │ │
│ │ │ │ Request 1 │ │
▼ │ │ ├──────────────────────►│ │
│ │ │ │ Token B │ │
│ │ │ │◄──────────┬───────────┤ │
▼ │ │ TokenSet[A,B] │ │ │ │
│ │ │◄──────────────┤ │ │ │
│ │ │ │ │ │ │
▼ └────┼───────────────┼───────────┼───────────┼──┘
IsExecuting │ │ │ │
│ ┌─ Message channel ──┼───────────┼───────────┼──┐
│ │ │ │ │ │ │
▼ │ │ ┤ Msg with │ │ │
│ │ │ │ token A │ │ │
│ │ │ │◄──────────┤ Msg with │ │
▼ │ │ Msg with │ │ token A │ │
│ │ │ TokenSet[A,B] │◄──────────┼───────────┤ │
└─►├───►│◄──────────────┤ │ │ │
│ │ │ │ │ │
└────┼───────────────┼───────────┼───────────┼──┘
│ │ │ │
Note
As a subscriber, you can use the TokenSetAwaiter to wait for a set that has been produced by a request.
Note
When implementing an IFeed you can use the CoercingRequestManager or the SequentialRequestManager to easily implement such request / token logic.
When you answer to a request with a token, you must then issue a new message with that token.
View
While developing in the feed framework, the most interesting view-related part is the presentation layer. Feeds are transferring messages containing the data and its metadata, but those are not binding friendly. The presentation layer has then 2 responsibilities:
- Read, i.e. from the ViewModel to the View, it will expose and maintain the state of the last message through standard binding-friendly properties and
INotifyPropertyChanged; - Write, i.e. from the View to the ViewModel, it will convert back standard properties into so called
IInput, which inherits from theIFeedinterface, and which allows the ViewModel to manage the view just like any other data source.
Those binding friendly properties are generated by the FeedsGenerator.
To trigger the generation, the class needs to have at least on constructor which has an
IInput, or anICommandBuilderparameter, or flag the type with[ReactiveBindable(true)]attribute.When we generate a binding friendly class for a type
MyTypewe are generating aBindableMyTypeclass nested into theMyTypeitself. This class inherits from theBindableViewModelBase.
Denormalizing
In order to make a record editable field per field (e.g. a user profile where you want to edit first name and last name independently), we can generate a BindableRecord object that we re-expose each property of the Record independently.
When a record is denormalized, we are generating / maintaining only one
State<Record>. Setting a value of any of the denormalized properties, will update that root state.When a record is denormalized, we can no longer directly set a record instance on the
VM.Record(the property is of typeBindableRecord). To work around this, if the record does not have aValueproperty which would conflict, we are generating a get/set propertyValuewhich can be used to set the instance (e.g.VM.Record.Value).When a property is updated, we also ensure to raise the property changes, including for the
Valueproperty (if defined) and theGetValuemethod.
Dispatching and threading considerations
The user’s ViewModel are expected to run on background thread for performance considerations. Commands and state updates from generated ViewModel classes are always ran on a background thread.
To avoid strong dependency on a dispatcher (neither by injection nor by the need to create VM on UI thread), all generated classes are auto resolving the dispatcher when an event handler is registered or when a public method is used, using the internal LazyDispatcherResolver and EventManager. Those classes are then completing their initialization once the first dispatcher is resolved.
The EventManager will capture the thread used to register an event handler and will then make sure to invoke that handler on that thread if it was a UI thread. This allows BindableVM to be data-bound to multiple window/UI threads.
┌───────────────┐ ┌───────────────┐
│EventManager │ │IInvocationList│
├───────────────┤1 *├───────────────┤
│Add(handler) ├────►│Add(handler) │
│Remove(handler)│ │Remove(handler)│
│Raise(args) │ │Raise(args) │
└───────────────┘ └───────▲───────┘
│
┌───────────────┴──────────────┐
│ │
┌───────────┴────────────┐ ┌────────────┴────────────┐
│DispatcherInvocationList│ │MultiThreadInvocationList│
└───────────▲────────────┘ └─────────────────────────┘
│
┌─────┴───────┐
│ │
┌─────┴─────┐ ┌───┴────┐
│Coaslescing│ │Queueing│
└───────────┘ └────────┘
We have one
IInvocationListper UI thread and one for all background threads (theMultiThreadInvocationList).As a bonus, when a dispatcher bound handler is about to be invoked from another thread, it can be coalesced to be raised only once (cf.
CoalescingDispatcherInvocationList).