-
-
Notifications
You must be signed in to change notification settings - Fork 192
Merging ChangeSets
DynamicData has two operators for combining multiple Observable ChangeSets into one: MergeChangeSets and MergeManyChangeSets. They both produce a single downstream changeset that reflects the union of everything happening upstream, but they take their inputs in different shapes and they both go well beyond what regular Rx Merge can do.
This article covers the use cases, how the two operators differ, why plain Merge is the wrong tool for the job, and how to control what happens when two upstream changesets produce values with the same key.
The first thing most people try when they have a few IObservable<IChangeSet<T, TKey>> is to call Merge on them and get back to building features. That works fine until two of those upstream changesets emit values with the same key, and then it stops working in subtle and unpleasant ways.
Merge is unaware of changesets. It just interleaves the notifications from N observables into one. If source A emits Add #42 and source B emits Add #42, the downstream consumer sees two separate Add events for the same key. In a typical pipeline, the second one silently replaces the first, and the original value is lost. The same problem shows up with Remove: if A emits Remove #42 while B still has #42 alive, the merged stream tells downstream #42 is gone even though it isn't, and either source removing the key takes it out of the result entirely.
MergeChangeSets and MergeManyChangeSets solve this by tracking which upstream contributed which value for each key. When the same key appears in multiple sources, only one wins (either the first one observed, or whichever the consumer prefers via an IComparer). When the winner goes away, the operator emits an Update event with the next-best available value instead of removing the key entirely. When the last source for a key removes it, only then does a Remove propagate downstream.
The two operators do the same job but consume their inputs differently:
-
MergeChangeSetstakes a known set of changeset observables. You hand it a collection (or two observables, or an observable of observables) and it merges them. Use this when the sources themselves are the things you have, and there's no parent collection driving them.Loadingflowchart LR A[ChangeSet] B[ChangeSet] C[ChangeSet] D[ChangeSet] M[MergeChangeSets] K[Key Conflict Resolution] O[Merged ChangeSet] A --> M B --> M C --> M D --> M M --> K K --> O style A fill:#fff0a8,stroke:#333,stroke-width:1px,color:#000 style B fill:#fff0a8,stroke:#333,stroke-width:1px,color:#000 style C fill:#fff0a8,stroke:#333,stroke-width:1px,color:#000 style D fill:#fff0a8,stroke:#333,stroke-width:1px,color:#000 style M fill:#b8e6b8,stroke:#333,stroke-width:1px,color:#000 style K fill:#b8d4f0,stroke:#333,stroke-width:1px,color:#000 style O fill:#e0b8e8,stroke:#333,stroke-width:1px,color:#000 -
MergeManyChangeSetstakes a parent changeset plus a selector function. For every item in the parent changeset, the selector returns a child changeset observable, andMergeManyChangeSetsmerges all of those children together. When the parent adds an item, its child changeset joins the merge. When the parent removes an item, its child changeset is unsubscribed and any of its contributed values are removed (or updated to the next-best source).Loadingflowchart LR P[Parent ChangeSet] S{{selector}} A[Child ChangeSet] B[Child ChangeSet] C[Child ChangeSet] M[MergeManyChangeSets] K[Key Conflict Resolution] O[Merged ChangeSet] P --> S S -.-> A S -.-> B S -.-> C A --> M B --> M C --> M M --> K K --> O style P fill:#ffb8b8,stroke:#333,stroke-width:1px,color:#000 style S fill:#ffd4a8,stroke:#333,stroke-width:1px,color:#000 style A fill:#fff0a8,stroke:#333,stroke-width:1px,color:#000 style B fill:#fff0a8,stroke:#333,stroke-width:1px,color:#000 style C fill:#fff0a8,stroke:#333,stroke-width:1px,color:#000 style M fill:#b8e6b8,stroke:#333,stroke-width:1px,color:#000 style K fill:#b8d4f0,stroke:#333,stroke-width:1px,color:#000 style O fill:#e0b8e8,stroke:#333,stroke-width:1px,color:#000
The rule of thumb:
- If you have N independent ChangeSet streams, use
MergeChangeSets. - If you have a collection of items that each expose a ChangeSet stream, use
MergeManyChangeSets.
There is one more difference worth knowing: MergeChangeSets will fail the downstream if any of its inputs fail (like Merge). MergeManyChangeSets will not fail if a child fails (like MergeMany). Errors from child changesets are absorbed.
A finer-grained decision table:
| Situation | Operator |
|---|---|
You have a fixed (or IEnumerable) collection of changesets |
MergeChangeSets |
| Your set of source changesets is itself an observable |
MergeChangeSets (observable overload) |
| You want errors from any source to propagate downstream | MergeChangeSets |
| You have a parent collection where each item exposes its own changeset | MergeManyChangeSets |
| The selector that picks which value wins depends on the child only | Either, with IComparer<TDestination>
|
| The selector depends on the parent item |
MergeManyChangeSets source-priority overload |
| Some sources may fail and you want to keep going | MergeManyChangeSets |
| You just want to combine two changesets and not worry about overlaps | MergeChangeSets(other) |
Both operators accept optional comparers that control what happens when keys collide across sources. There are three of them, and they do different things.
Used to deduplicate updates. When a child changeset emits an update for a key, the operator compares the new value to the previously-emitted value. If they compare as equal, nothing is emitted downstream. This is purely an optimization to avoid noisy Update events for values that haven't really changed.
Used to prioritize between values when multiple sources have the same key. When two children both produce a value for the same key, the operator picks the one that the comparer ranks lower. When a winning value is removed from a child, the operator emits an Update downstream with the next-best value from the remaining children (or a Remove if there are none left).
If you don't supply this comparer, the first value observed for a given key wins, and removal falls back to whichever value happens to be available next.
MergeManyChangeSets has a parent variant that takes an additional IComparer<TObject> that operates on the source item, not the child value. This lets you base priority on something about the parent (popularity, weight, position) rather than the child. When two parents tie on the source comparer, the child comparer (if provided) is used as a tiebreaker.
The source-priority overloads have an additional resortOnSourceRefresh flag. If true, a Refresh notification in the source changeset causes the operator to re-evaluate priorities (which is what you want if you're using AutoRefresh on a mutable property of the parent). If false, Refresh events don't trigger re-prioritization.
Both operators come in Cache and List flavors, plus cross-form variants where the parent is one kind and the children are the other:
| Parent | Child | Operator |
|---|---|---|
IObservable<IChangeSet<T, TKey>> |
(none, MCS only) | Cache MergeChangeSets
|
IObservable<IChangeSet<T>> |
(none, MCS only) | List MergeChangeSets
|
IObservable<IChangeSet<TParent, TParentKey>> |
IObservable<IChangeSet<TChild, TChildKey>> |
Cache-to-Cache MergeManyChangeSets
|
IObservable<IChangeSet<TParent>> |
IObservable<IChangeSet<TChild>> |
List-to-List MergeManyChangeSets
|
IObservable<IChangeSet<TParent>> |
IObservable<IChangeSet<TChild, TChildKey>> |
List-to-Cache MergeManyChangeSets
|
IObservable<IChangeSet<TParent, TParentKey>> |
IObservable<IChangeSet<TChild>> |
Cache-to-List MergeManyChangeSets
|
The Cache versions support the full set of conflict-resolution comparers described above. The List versions support IEqualityComparer (positional changesets don't have a key to prioritize on, but they still benefit from dedup).
The simplest case: you've got an enumerable of changeset observables and you want one merged result.
interface IItem
{
IObservable<IChangeSet<ISubItem, int>> SubItemChanges { get; }
}
IEnumerable<IItem> items = GetTheItems();
IObservable<IChangeSet<ISubItem, int>> mergedSubItems = items.Select(i => i.SubItemChanges)
.MergeChangeSets();The result is just another changeset, so it can go straight into another operator, or be subscribed to directly, or materialized with AsObservableCache(). How it's consumed downstream is up to you.
Use this overload when the set of sources grows over time but you don't need full Add/Remove changeset semantics on the parent. Every value that arrives on the outer observable contributes its changeset to the merge.
IObservable<IItem> itemObservable = GetItemObservable();
using var mergedSubItems = itemObservable.Select(i => i.SubItemChanges)
.MergeChangeSets()
.AsObservableCache();Convenience overload when you have exactly two changesets to combine. This one subscribes directly and reacts to each kind of change.
IItem item1 = GetItem(1);
IItem item2 = GetItem(2);
using var subscription = item1.SubItemChanges.MergeChangeSets(item2.SubItemChanges)
.OnItemAdded(sub => Console.WriteLine($"Added: {sub.Id}"))
.OnItemUpdated((curr, prev) => Console.WriteLine($"Updated: {curr.Id}"))
.OnItemRemoved(sub => Console.WriteLine($"Removed: {sub.Id}"))
.Subscribe();This is where MergeChangeSets really earns its keep. Different comparers produce different aggregated views of the same data.
record MarketPrice(Guid MarketId, string ItemId, decimal Price, DateTimeOffset Timestamp);
IEnumerable<IObservableCache<MarketPrice, string>> priceCaches = GetTheMarketPrices();
var lowestPrice = Comparer<MarketPrice>.Create(static (x, y) => x.Price.CompareTo(y.Price));
var latestPrice = Comparer<MarketPrice>.Create(static (x, y) => y.Timestamp.CompareTo(x.Timestamp));
// Always shows the lowest price across all markets
using var lowestPrices = priceCaches.Select(m => m.Connect())
.MergeChangeSets(lowestPrice)
.AsObservableCache();
// Always shows the most recent price across all markets
using var latestPrices = priceCaches.Select(m => m.Connect())
.MergeChangeSets(latestPrice)
.AsObservableCache();As prices are added, removed, or updated in any of the source caches, the merged caches reflect those changes, but only when the new value is the best choice according to the comparer. When a winning value is removed, the merged cache emits an Update for the next-best, or a Remove if there are no other choices.
When the children of a SourceCache themselves expose changesets, MergeManyChangeSets flattens them into one. As items are added to or removed from the parent, their child changesets join or leave the merge automatically.
interface IOwner
{
string Id { get; }
IObservableCache<IAnimal, string> Animals { get; }
}
IObservableCache<IOwner, string> owners = GetOwners();
IObservable<IChangeSet<IAnimal, string>> allAnimals =
owners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect());allAnimals is a live changeset of every animal owned by every owner. When an owner is removed, all of their animals disappear from the merged changeset. When an owner is added, their animals show up. Feed it into whatever consumer you want.
Sometimes the right value to emit isn't determined by the child, it's determined by something about the parent.
Imagine an app that aggregates movie review sites. Each critic has reviewed some movies, but you want the cache to always show the review from the most popular critic that has reviewed each movie. Popularity changes over time.
interface IMovieReview
{
Guid MovieId { get; }
Guid CriticId { get; }
double Rating { get; }
DateTimeOffset DatePublished { get; }
}
interface IMovieCritic : INotifyPropertyChanged
{
Guid Id { get; }
string Name { get; }
double Popularity { get; }
IObservableCache<IMovieReview, Guid> Reviews { get; }
}
IObservableCache<IMovieCritic, Guid> critics = GetCritics();
var byPopularity = Comparer<IMovieCritic>.Create(static (x, y) => y.Popularity.CompareTo(x.Popularity));
ReadOnlyObservableCollection<IMovieReview> bestReviews;
using var sub = critics.Connect()
.AutoRefresh(c => c.Popularity)
.MergeManyChangeSets(
(critic, _) => critic.Reviews.Connect(),
byPopularity,
resortOnSourceRefresh: true)
.Bind(out bestReviews)
.Subscribe();That's all there is to it. bestReviews will exhibit all of the following behaviors automatically:
- When a new critic is added, any movies they've reviewed appear, unless a more popular critic has already reviewed those movies.
- When a critic is removed, movies only reviewed by them are removed. Movies also reviewed by others are updated to the next-most-popular critic's review.
- When a critic posts a new review for a movie nobody else has reviewed, it's added. If others have reviewed it, the new review only replaces the existing one if its critic is more popular.
- When the most popular critic deletes their review for a movie, the result is updated to the next-most-popular critic's review for that movie.
- When a critic's popularity changes (via
AutoRefresh), reviews are re-prioritized. Anything where the popularity change reordered the critics will emit anUpdate.
What if two critics have identical popularity and both have reviewed the same movie? Use the overload that takes both a parent comparer and a child comparer. The child comparer is the tiebreaker.
var firstPublished = Comparer<IMovieReview>.Create(static (x, y) => x.DatePublished.CompareTo(y.DatePublished));
using var bestReviews = critics.Connect()
.AutoRefresh(c => c.Popularity)
.MergeManyChangeSets(
(critic, _) => critic.Reviews.Connect(),
byPopularity,
resortOnSourceRefresh: true,
firstPublished)
.AsObservableCache();Now when two critics with identical popularity have both reviewed a movie, the earlier review wins.
When the parent is a SourceList (positional) but each item exposes a cache changeset (keyed), use the list-to-cache overload. Same selector pattern, output is a cache changeset.
ISourceList<IOwner> owners = GetOwners();
IObservable<IChangeSet<IAnimal, string>> allAnimals =
owners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect());The opposite direction works too (cache-to-list), and follows the same pattern.
The conflict resolution behavior is easier to understand if you can see what happens at each step. Here's a complete example that builds two markets, merges them with LowestPriceComparer, subscribes with logging handlers, and walks through a series of changes that exercise the comparer.
record MarketPrice(string ItemId, decimal Price)
{
public override string ToString() => $"{ItemId}=${Price}";
}
void Main()
{
using var marketA = new SourceCache<MarketPrice, string>(p => p.ItemId);
using var marketB = new SourceCache<MarketPrice, string>(p => p.ItemId);
var lowestPrice = Comparer<MarketPrice>.Create(static (x, y) => x.Price.CompareTo(y.Price));
using var sub = new[] { marketA.Connect(), marketB.Connect() }
.MergeChangeSets(lowestPrice)
.OnItemAdded(p => Console.WriteLine($"Added: {p}"))
.OnItemUpdated((curr, prev) => Console.WriteLine($"Updated: {prev} -> {curr}"))
.OnItemRemoved(p => Console.WriteLine($"Removed: {p}"))
.Subscribe();
Console.WriteLine("-- Market A adds Widget at $5");
marketA.AddOrUpdate(new MarketPrice("Widget", 5m));
Console.WriteLine("-- Market B adds Widget at $7 (not the best price)");
marketB.AddOrUpdate(new MarketPrice("Widget", 7m));
Console.WriteLine("-- Market A raises Widget to $8 (B is now cheaper)");
marketA.AddOrUpdate(new MarketPrice("Widget", 8m));
Console.WriteLine("-- Market B removes Widget (A is the only seller again)");
marketB.Remove("Widget");
Console.WriteLine("-- Market A removes Widget (no one is selling)");
marketA.Remove("Widget");
}-- Market A adds Widget at $5
Added: Widget=$5
-- Market B adds Widget at $7 (not the best price)
-- Market A raises Widget to $8 (B is now cheaper)
Updated: Widget=$5 -> Widget=$7
-- Market B removes Widget (A is the only seller again)
Updated: Widget=$7 -> Widget=$8
-- Market A removes Widget (no one is selling)
Removed: Widget=$8
Watch what MergeChangeSets does at each step:
- Market A adds Widget at $5. Only source, becomes the winner, downstream sees
Add. - Market B adds Widget at $7. Compared against the current winner ($5), it loses, so downstream sees nothing. But the operator still tracks it internally.
- Market A raises Widget to $8. Re-evaluation: Market B's $7 is now the best price, downstream sees
Updatefrom $5 to $7. - Market B removes Widget. Re-evaluation: Market A's $8 is the only one left and becomes the winner, downstream sees
Updatefrom $7 to $8. - Market A removes Widget. No more sources for this key, downstream sees
Remove.
If you ran the same code without the comparer (MergeChangeSets() with no arguments), the first added value would win and would stay the winner until removed, regardless of what other markets do. The comparer is what lets you express priority.
The core thing MergeManyChangeSets does is tie the lifetime of child changesets to the lifetime of their parents. When a parent is added, its children flow into the merge. When a parent is removed, its children flow out. And anything the comparer is doing happens on top of that. This walkthrough exercises all of that, with a parent collection of streaming services, each containing the movies they have licensed. The same movie can show up on more than one service, often at different video qualities, and we want the merged view to always reflect the best quality available anywhere.
enum VideoQuality { SD, HD, FullHD, FourK }
record Movie(string MovieId, string Title, VideoQuality Quality)
{
public override string ToString() => $"\"{Title}\" [{Quality}]";
}
record StreamingService(string Name)
{
public SourceCache<Movie, string> Movies { get; } = new(m => m.MovieId);
}
void Main()
{
using var services = new SourceCache<StreamingService, string>(s => s.Name);
var bestQuality = Comparer<Movie>.Create(static (x, y) => y.Quality.CompareTo(x.Quality));
using var sub = services.Connect()
.MergeManyChangeSets(s => s.Movies.Connect(), bestQuality)
.OnItemAdded(m => Console.WriteLine($"Added: {m}"))
.OnItemUpdated((curr, prev) => Console.WriteLine($"Updated: {prev} -> {curr}"))
.OnItemRemoved(m => Console.WriteLine($"Removed: {m}"))
.Subscribe();
var notflix = new StreamingService("Notflix");
var duhsney = new StreamingService("Duhsney-");
var hooloo = new StreamingService("Hooloo");
Console.WriteLine("-- Notflix launches with Florist Gump (HD), Pulp Friction (FullHD), Lord of the Strings (FullHD), and Straw Wars (HD)");
notflix.Movies.AddOrUpdate(new[]
{
new Movie("M001", "Florist Gump", VideoQuality.HD),
new Movie("M002", "Pulp Friction", VideoQuality.FullHD),
new Movie("M003", "Lord of the Strings", VideoQuality.FullHD),
new Movie("M007", "Straw Wars", VideoQuality.HD)
});
services.AddOrUpdate(notflix);
Console.WriteLine("-- Notflix upgrades Florist Gump to FullHD");
notflix.Movies.AddOrUpdate(new Movie("M001", "Florist Gump", VideoQuality.FullHD));
Console.WriteLine("-- Duhsney- launches with Florist Gump (4K), Soy Story (HD), and The Lyin' King (4K)");
// Florist Gump promotes (Notflix's FullHD loses to 4K) -> Update, not Add
duhsney.Movies.AddOrUpdate(new[]
{
new Movie("M001", "Florist Gump", VideoQuality.FourK),
new Movie("M004", "Soy Story", VideoQuality.HD),
new Movie("M005", "The Lyin' King", VideoQuality.FourK)
});
services.AddOrUpdate(duhsney);
Console.WriteLine("-- Hooloo launches with Florist Gump (HD), Soy Story (FullHD), The Bored Identity (FullHD), Straw Wars (4K), The Umpire Strikes Back (4K), and The Lyin' King (HD)");
// Florist Gump (HD) and The Lyin' King (HD) lose to Duhsney-'s 4K and emit nothing.
// Soy Story (FullHD) and Straw Wars (4K) beat current winners and emit Updates.
hooloo.Movies.AddOrUpdate(new[]
{
new Movie("M001", "Florist Gump", VideoQuality.HD),
new Movie("M004", "Soy Story", VideoQuality.FullHD),
new Movie("M006", "The Bored Identity", VideoQuality.FullHD),
new Movie("M007", "Straw Wars", VideoQuality.FourK),
new Movie("M008", "The Umpire Strikes Back", VideoQuality.FourK),
new Movie("M005", "The Lyin' King", VideoQuality.HD)
});
services.AddOrUpdate(hooloo);
Console.WriteLine("-- Duhsney- drops Soy Story");
// No event: Duhsney-'s contribution was already losing to Hooloo's FullHD.
duhsney.Movies.Remove("M004");
Console.WriteLine("-- Duhsney- downgrades Florist Gump to HD (budget cuts)");
// Update from 4K to FullHD (Notflix), not HD (Duhsney-): Notflix's FullHD beats both HDs.
duhsney.Movies.AddOrUpdate(new Movie("M001", "Florist Gump", VideoQuality.HD));
Console.WriteLine("-- Hooloo upgrades Soy Story to 4K");
hooloo.Movies.AddOrUpdate(new Movie("M004", "Soy Story", VideoQuality.FourK));
Console.WriteLine("-- Hooloo drops The Bored Identity");
hooloo.Movies.Remove("M006");
Console.WriteLine("-- Duhsney- shuts down entirely");
// The Lyin' King promotes to Hooloo's HD -> Update, not Remove.
services.Remove(duhsney);
Console.WriteLine("-- Hooloo shuts down entirely");
// Straw Wars promotes to Notflix's HD -> Update. Everything else with no other source emits Remove.
services.Remove(hooloo);
Console.WriteLine("-- Notflix shuts down entirely");
services.Remove(notflix);
}-- Notflix launches with Florist Gump (HD), Pulp Friction (FullHD), Lord of the Strings (FullHD), and Straw Wars (HD)
Added: "Florist Gump" [HD]
Added: "Pulp Friction" [FullHD]
Added: "Lord of the Strings" [FullHD]
Added: "Straw Wars" [HD]
-- Notflix upgrades Florist Gump to FullHD
Updated: "Florist Gump" [HD] -> "Florist Gump" [FullHD]
-- Duhsney- launches with Florist Gump (4K), Soy Story (HD), and The Lyin' King (4K)
Added: "Soy Story" [HD]
Added: "The Lyin' King" [FourK]
Updated: "Florist Gump" [FullHD] -> "Florist Gump" [FourK]
-- Hooloo launches with Florist Gump (HD), Soy Story (FullHD), The Bored Identity (FullHD), Straw Wars (4K), The Umpire Strikes Back (4K), and The Lyin' King (HD)
Added: "The Bored Identity" [FullHD]
Added: "The Umpire Strikes Back" [FourK]
Updated: "Soy Story" [HD] -> "Soy Story" [FullHD]
Updated: "Straw Wars" [HD] -> "Straw Wars" [FourK]
-- Duhsney- drops Soy Story
-- Duhsney- downgrades Florist Gump to HD (budget cuts)
Updated: "Florist Gump" [FourK] -> "Florist Gump" [FullHD]
-- Hooloo upgrades Soy Story to 4K
Updated: "Soy Story" [FullHD] -> "Soy Story" [FourK]
-- Hooloo drops The Bored Identity
Removed: "The Bored Identity" [FullHD]
-- Duhsney- shuts down entirely
Updated: "The Lyin' King" [FourK] -> "The Lyin' King" [HD]
-- Hooloo shuts down entirely
Updated: "Straw Wars" [FourK] -> "Straw Wars" [HD]
Removed: "Soy Story" [FourK]
Removed: "The Umpire Strikes Back" [FourK]
Removed: "The Lyin' King" [HD]
-- Notflix shuts down entirely
Removed: "Florist Gump" [FullHD]
Removed: "Pulp Friction" [FullHD]
Removed: "Lord of the Strings" [FullHD]
Removed: "Straw Wars" [HD]
Each step either propagates to the consumer or is silently absorbed because of the comparer:
-
Notflix launches with four movies. All new keys with no competition, so all four propagate as
Addevents. -
Notflix upgrades Florist Gump. Same key, same source, propagates as an
Update. -
Duhsney- launches with three movies and all three replay into the merge at once. Florist Gump (4K) beats Notflix's FullHD and emits an
Update. Soy Story and The Lyin' King are new keys and emitAddevents. One parent operation, three downstream events. -
Hooloo launches with six movies and replays them all. Florist Gump (HD) loses to Duhsney-'s 4K and is silently absorbed. Soy Story (FullHD) beats Duhsney-'s HD and emits an
Update. The Bored Identity is a new key and emits anAdd. Straw Wars (4K) beats Notflix's HD and emits anUpdate. The Umpire Strikes Back is a new key and emits anAdd. The Lyin' King (HD) loses to Duhsney-'s 4K and is silently absorbed. Two silent absorptions, twoUpdateevents, twoAddevents out of one parent operation.At this point, the contested keys look like this (★ marks the current winner):
Movie Notflix Duhsney- Hooloo Florist Gump FullHD 4K ★ HD Soy Story HD FullHD ★ Straw Wars HD 4K ★ The Lyin' King 4K ★ HD Pulp Friction (Notflix, FullHD), Lord of the Strings (Notflix, FullHD), The Bored Identity (Hooloo, FullHD), and The Umpire Strikes Back (Hooloo, 4K) are uncontested winners.
-
Duhsney- drops Soy Story. Duhsney-'s contribution for that key was already losing to Hooloo's FullHD, so dropping it changes nothing downstream.
-
Duhsney- downgrades Florist Gump from 4K to HD. Re-evaluating the candidates (Notflix-FullHD, Duhsney--HD, Hooloo-HD), Notflix's FullHD is now the best, so we see an
Updatefrom 4K back to FullHD. -
Hooloo upgrades Soy Story to 4K. Hooloo was already the winner, so this is just an improvement to the winning value and we see a regular
Update. -
Hooloo drops The Bored Identity. No other source has it, so we see a
Remove.Four small operations have shifted things since the previous table. State going into the shutdowns:
Movie Notflix Duhsney- Hooloo Florist Gump FullHD ★ HD HD Straw Wars HD 4K ★ The Lyin' King 4K ★ HD Pulp Friction (Notflix, FullHD), Lord of the Strings (Notflix, FullHD), Soy Story (Hooloo, 4K, now uncontested), and The Umpire Strikes Back (Hooloo, 4K) are uncontested winners.
-
Duhsney- shuts down entirely. Its remaining contents were Florist Gump (HD, losing) and The Lyin' King (4K, current winner). Florist Gump is silently absorbed. The Lyin' King loses its winning source, so Hooloo's HD is promoted and we see an
Updatefrom 4K to HD. A parent removal that emits anUpdateinstead of aRemovebecause another source was waiting in the wings. -
Hooloo shuts down entirely. Florist Gump (HD) was losing and is silently absorbed. Soy Story (4K) and The Umpire Strikes Back (4K) were sole winners with no other source and emit
Removeevents. Straw Wars (4K) was the winner but Notflix still has HD, so it gets promoted and emits anUpdatefrom 4K to HD. The Lyin' King (HD, which only just became the winner in the previous step) has no other source left and emits aRemove. -
Notflix shuts down entirely. All four of its remaining winning contributions (Florist Gump, Pulp Friction, Lord of the Strings, Straw Wars) have no other sources and emit
Removeevents.
The takeaway is that MergeManyChangeSets is doing two jobs at the same time. It's tracking the parent/child lifetime relationship (children flow in and out with their parents) and applying conflict resolution across all the children that are currently alive. Operations that touch a losing source are silently absorbed. Operations that touch the winner re-evaluate the candidates and emit whatever downstream event reflects the new winner. Neither of those things is something Merge could do for you.