From d3933e391990c04be28f7b92e32d4b3a36a14af5 Mon Sep 17 00:00:00 2001 From: Jake Meiergerd Date: Mon, 26 Feb 2024 02:31:50 -0600 Subject: [PATCH] ExpireAfter Redesign (#868) * Fixed potential infinite-looping in the "SchedulerIsInaccurate" ExpireAfter tests. Reworked stress-testing for ExpireAfter operators, to better ensure that multi-threading contentions are occurring. Added test coverage for "Moved" changes, on the cache stream version of ExpireAfter. Added test coverage for "RemoveRange" changes, on the list version of ExpireAfter. * Enhanced benchmarks for ExpireAfter operators to improve code coverage and include actual expiration behavior. Really, just copied the stress-testing code from tests. Also bumped the .Benchmarks project to .NET 8, to match .Tests. * Re-designed ExpireAfter operators, from scratch, eliminating a variety of defects, including #716. --- .editorconfig | 12 +- .../Cache/ExpireAfter_Cache_ForSource.cs | 132 ++++-- .../Cache/ExpireAfter_Cache_ForStream.cs | 151 ++++-- .../DynamicData.Benchmarks.csproj | 3 +- .../List/ExpireAfter_List.cs | 192 ++++++-- .../Cache/ExpireAfterFixture.ForSource.cs | 301 +++++++----- .../Cache/ExpireAfterFixture.ForStream.cs | 413 +++++++++------- .../Cache/ExpireAfterFixture.cs | 11 +- .../List/ExpireAfterFixture.cs | 383 ++++++++------- .../Utilities/FakeScheduler.cs | 27 ++ .../Utilities/TestSourceCache.cs | 2 +- .../Utilities/TestSourceList.cs | 2 +- .../Cache/Internal/ExpireAfter.ForSource.cs | 412 ++++++++++++++++ .../Cache/Internal/ExpireAfter.ForStream.cs | 408 ++++++++++++++++ src/DynamicData/Cache/Internal/TimeExpirer.cs | 105 ---- src/DynamicData/Cache/ObservableCacheEx.cs | 153 +++--- src/DynamicData/DynamicData.csproj | 4 + src/DynamicData/List/Internal/ExpireAfter.cs | 448 ++++++++++++++++-- src/DynamicData/List/ObservableListEx.cs | 35 +- .../Polyfills/ListEnsureCapacity.cs | 16 + 20 files changed, 2373 insertions(+), 837 deletions(-) create mode 100644 src/DynamicData/Cache/Internal/ExpireAfter.ForSource.cs create mode 100644 src/DynamicData/Cache/Internal/ExpireAfter.ForStream.cs delete mode 100644 src/DynamicData/Cache/Internal/TimeExpirer.cs create mode 100644 src/DynamicData/Polyfills/ListEnsureCapacity.cs diff --git a/.editorconfig b/.editorconfig index 138ad077e..7bb2dff13 100644 --- a/.editorconfig +++ b/.editorconfig @@ -370,7 +370,7 @@ dotnet_diagnostic.SA1136.severity = error dotnet_diagnostic.SA1137.severity = error dotnet_diagnostic.SA1139.severity = error dotnet_diagnostic.SA1200.severity = none -dotnet_diagnostic.SA1201.severity = error +dotnet_diagnostic.SA1201.severity = none dotnet_diagnostic.SA1202.severity = error dotnet_diagnostic.SA1203.severity = error dotnet_diagnostic.SA1204.severity = error @@ -424,10 +424,10 @@ dotnet_diagnostic.SA1508.severity = error dotnet_diagnostic.SA1509.severity = error dotnet_diagnostic.SA1510.severity = error dotnet_diagnostic.SA1511.severity = error -dotnet_diagnostic.SA1512.severity = error -dotnet_diagnostic.SA1513.severity = error +dotnet_diagnostic.SA1512.severity = none +dotnet_diagnostic.SA1513.severity = none dotnet_diagnostic.SA1514.severity = error -dotnet_diagnostic.SA1515.severity = error +dotnet_diagnostic.SA1515.severity = none dotnet_diagnostic.SA1516.severity = error dotnet_diagnostic.SA1517.severity = error dotnet_diagnostic.SA1518.severity = error @@ -503,7 +503,7 @@ dotnet_diagnostic.RCS1058.severity=warning dotnet_diagnostic.RCS1068.severity=warning dotnet_diagnostic.RCS1073.severity=warning dotnet_diagnostic.RCS1084.severity=error -dotnet_diagnostic.RCS1085.severity=error +dotnet_diagnostic.RCS1085.severity=none dotnet_diagnostic.RCS1105.severity=error dotnet_diagnostic.RCS1112.severity=error dotnet_diagnostic.RCS1128.severity=error @@ -547,4 +547,4 @@ end_of_line = lf [*.{cmd, bat}] end_of_line = crlf -vsspell_dictionary_languages = en-US \ No newline at end of file +vsspell_dictionary_languages = en-US diff --git a/src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForSource.cs b/src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForSource.cs index 84c2a17bb..7b424058f 100644 --- a/src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForSource.cs +++ b/src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForSource.cs @@ -1,9 +1,11 @@ using System; -using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using BenchmarkDotNet.Attributes; +using Bogus; + namespace DynamicData.Benchmarks.Cache; [MemoryDiagnoser] @@ -11,52 +13,122 @@ namespace DynamicData.Benchmarks.Cache; public class ExpireAfter_Cache_ForSource { public ExpireAfter_Cache_ForSource() - => _items = Enumerable - .Range(1, 1_000) + { + // Not exercising Moved, since SourceCache<> doesn't support it. + _changeReasons = + [ + ChangeReason.Add, + ChangeReason.Refresh, + ChangeReason.Remove, + ChangeReason.Update + ]; + + // Weights are chosen to make the cache size likely to grow over time, + // exerting more pressure on the system the longer the benchmark runs. + // Also, to prevent bogus operations (E.G. you can't remove an item from an empty cache). + _changeReasonWeightsWhenCountIs0 = + [ + 1f, // Add + 0f, // Refresh + 0f, // Remove + 0f // Update + ]; + + _changeReasonWeightsOtherwise = + [ + 0.30f, // Add + 0.25f, // Refresh + 0.20f, // Remove + 0.25f // Update + ]; + + _editCount = 5_000; + _maxChangeCount = 20; + + var randomizer = new Randomizer(1234567); + + var minItemLifetime = TimeSpan.FromMilliseconds(1); + var maxItemLifetime = TimeSpan.FromMilliseconds(10); + _items = Enumerable.Range(1, _editCount * _maxChangeCount) .Select(id => new Item() { - Id = id + Id = id, + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null }) - .ToArray(); + .ToImmutableArray(); + } [Benchmark] - [Arguments(1, 0)] - [Arguments(1, 1)] - [Arguments(10, 0)] - [Arguments(10, 1)] - [Arguments(10, 10)] - [Arguments(100, 0)] - [Arguments(100, 1)] - [Arguments(100, 10)] - [Arguments(100, 100)] - [Arguments(1_000, 0)] - [Arguments(1_000, 1)] - [Arguments(1_000, 10)] - [Arguments(1_000, 100)] - [Arguments(1_000, 1_000)] - public void AddsRemovesAndFinalization(int addCount, int removeCount) + public void RandomizedEditsAndExpirations() { using var source = new SourceCache(static item => item.Id); using var subscription = source .ExpireAfter( - timeSelector: static _ => TimeSpan.FromMinutes(60), - interval: null) + timeSelector: static item => item.Lifetime, + interval: null) .Subscribe(); - for (var i = 0; i < addCount; ++i) - source.AddOrUpdate(_items[i]); - - for (var i = 0; i < removeCount; ++i) - source.RemoveKey(_items[i].Id); + PerformRandomizedEdits(source); subscription.Dispose(); } - private readonly IReadOnlyList _items; + private void PerformRandomizedEdits(SourceCache source) + { + var randomizer = new Randomizer(1234567); + + var nextItemIndex = 0; - private sealed class Item + for (var i = 0; i < _editCount; ++i) + { + source.Edit(updater => + { + var changeCount = randomizer.Int(1, _maxChangeCount); + for (var i = 0; i < changeCount; ++i) + { + var changeReason = randomizer.WeightedRandom(_changeReasons, updater.Count switch + { + 0 => _changeReasonWeightsWhenCountIs0, + _ => _changeReasonWeightsOtherwise + }); + + switch (changeReason) + { + case ChangeReason.Add: + updater.AddOrUpdate(_items[nextItemIndex++]); + break; + + case ChangeReason.Refresh: + updater.Refresh(updater.Keys.ElementAt(randomizer.Int(0, updater.Count - 1))); + break; + + case ChangeReason.Remove: + updater.RemoveKey(updater.Keys.ElementAt(randomizer.Int(0, updater.Count - 1))); + break; + + case ChangeReason.Update: + updater.AddOrUpdate(updater.Items.ElementAt(randomizer.Int(0, updater.Count - 1))); + break; + } + } + }); + } + } + + private readonly ChangeReason[] _changeReasons; + private readonly float[] _changeReasonWeightsOtherwise; + private readonly float[] _changeReasonWeightsWhenCountIs0; + private readonly int _editCount; + private readonly ImmutableArray _items; + private readonly int _maxChangeCount; + + private sealed record Item { - public int Id { get; init; } + public required int Id { get; init; } + + public required TimeSpan? Lifetime { get; init; } } } diff --git a/src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForStream.cs b/src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForStream.cs index 11784f891..a13cefbf0 100644 --- a/src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForStream.cs +++ b/src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForStream.cs @@ -1,9 +1,12 @@ using System; -using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using System.Reactive.Subjects; using BenchmarkDotNet.Attributes; +using Bogus; + namespace DynamicData.Benchmarks.Cache; [MemoryDiagnoser] @@ -12,76 +15,124 @@ public class ExpireAfter_Cache_ForStream { public ExpireAfter_Cache_ForStream() { - var additions = new List>(capacity: 1_000); - var removals = new List>(capacity: 1_000); + // Not exercising Moved, since ChangeAwareCache<> doesn't support it, and I'm too lazy to implement it by hand. + var changeReasons = new[] + { + ChangeReason.Add, + ChangeReason.Refresh, + ChangeReason.Remove, + ChangeReason.Update + }; + + // Weights are chosen to make the cache size likely to grow over time, + // exerting more pressure on the system the longer the benchmark runs. + // Also, to prevent bogus operations (E.G. you can't remove an item from an empty cache). + var changeReasonWeightsWhenCountIs0 = new[] + { + 1f, // Add + 0f, // Refresh + 0f, // Remove + 0f // Update + }; - for (var id = 1; id <= 1_000; ++id) + var changeReasonWeightsOtherwise = new[] { - var item = new Item() - { - Id = id - }; + 0.30f, // Add + 0.25f, // Refresh + 0.20f, // Remove + 0.25f // Update + }; - additions.Add(new ChangeSet(capacity: 1) - { - new(reason: ChangeReason.Add, - key: id, - current: item) - }); + var maxChangeCount = 20; + var minItemLifetime = TimeSpan.FromMilliseconds(1); + var maxItemLifetime = TimeSpan.FromMilliseconds(10); + + var randomizer = new Randomizer(1234567); + + var nextItemId = 1; - removals.Add(new ChangeSet() + var changeSets = ImmutableArray.CreateBuilder>(initialCapacity: 5_000); + + var cache = new ChangeAwareCache(); + + while (changeSets.Count < changeSets.Capacity) + { + var changeCount = randomizer.Int(1, maxChangeCount); + for (var i = 0; i < changeCount; ++i) { - new(reason: ChangeReason.Remove, - key: item.Id, - current: item) - }); + var changeReason = randomizer.WeightedRandom(changeReasons, cache.Count switch + { + 0 => changeReasonWeightsWhenCountIs0, + _ => changeReasonWeightsOtherwise + }); + + switch (changeReason) + { + case ChangeReason.Add: + cache.AddOrUpdate( + item: new Item() + { + Id = nextItemId, + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null + }, + key: nextItemId); + ++nextItemId; + break; + + case ChangeReason.Refresh: + cache.Refresh(cache.Keys.ElementAt(randomizer.Int(0, cache.Count - 1))); + break; + + case ChangeReason.Remove: + cache.Remove(cache.Keys.ElementAt(randomizer.Int(0, cache.Count - 1))); + break; + + case ChangeReason.Update: + var id = cache.Keys.ElementAt(randomizer.Int(0, cache.Count - 1)); + cache.AddOrUpdate( + item: new Item() + { + Id = id, + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null + }, + key: id); + break; + } + } + + changeSets.Add(cache.CaptureChanges()); } - _additions = additions; - _removals = removals; + _changeSets = changeSets.MoveToImmutable(); } [Benchmark] - [Arguments(1, 0)] - [Arguments(1, 1)] - [Arguments(10, 0)] - [Arguments(10, 1)] - [Arguments(10, 10)] - [Arguments(100, 0)] - [Arguments(100, 1)] - [Arguments(100, 10)] - [Arguments(100, 100)] - [Arguments(1_000, 0)] - [Arguments(1_000, 1)] - [Arguments(1_000, 10)] - [Arguments(1_000, 100)] - [Arguments(1_000, 1_000)] - public void AddsRemovesAndFinalization(int addCount, int removeCount) + public void RandomizedEditsAndExpirations() { using var source = new Subject>(); using var subscription = source - .ExpireAfter(static _ => TimeSpan.FromMinutes(60)) + .ExpireAfter( + timeSelector: static item => item.Lifetime, + pollingInterval: null) .Subscribe(); - var itemLifetime = TimeSpan.FromMilliseconds(1); - - var itemsToRemove = new List(); - - for (var i = 0; i < addCount; ++i) - source.OnNext(_additions[i]); - - for (var i = 0; i < removeCount; ++i) - source.OnNext(_removals[i]); + foreach (var changeSet in _changeSets) + source.OnNext(changeSet); subscription.Dispose(); } - private readonly IReadOnlyList> _additions; - private readonly IReadOnlyList> _removals; + private readonly ImmutableArray> _changeSets; - private sealed class Item + private sealed record Item { - public int Id { get; init; } + public required int Id { get; init; } + + public required TimeSpan? Lifetime { get; init; } } } diff --git a/src/DynamicData.Benchmarks/DynamicData.Benchmarks.csproj b/src/DynamicData.Benchmarks/DynamicData.Benchmarks.csproj index 734850cbb..60495e2c6 100644 --- a/src/DynamicData.Benchmarks/DynamicData.Benchmarks.csproj +++ b/src/DynamicData.Benchmarks/DynamicData.Benchmarks.csproj @@ -2,7 +2,7 @@ Exe - net6.0-windows + net8.0-windows AnyCPU false ;1591;1701;1702;1705;CA1822;CA1001 @@ -11,6 +11,7 @@ + diff --git a/src/DynamicData.Benchmarks/List/ExpireAfter_List.cs b/src/DynamicData.Benchmarks/List/ExpireAfter_List.cs index 93911129d..ec9a39316 100644 --- a/src/DynamicData.Benchmarks/List/ExpireAfter_List.cs +++ b/src/DynamicData.Benchmarks/List/ExpireAfter_List.cs @@ -1,9 +1,11 @@ using System; -using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using BenchmarkDotNet.Attributes; +using Bogus; + namespace DynamicData.Benchmarks.List; [MemoryDiagnoser] @@ -11,43 +13,175 @@ namespace DynamicData.Benchmarks.List; public class ExpireAfter_List { public ExpireAfter_List() - => _items = Enumerable - .Range(0, 1_000) - .Select(_ => new object()) - .ToArray(); + { + // Not exercising Refresh, since SourceList<> doesn't support it. + _changeReasons = + [ + ListChangeReason.Add, + ListChangeReason.AddRange, + ListChangeReason.Clear, + ListChangeReason.Moved, + ListChangeReason.Remove, + ListChangeReason.RemoveRange, + ListChangeReason.Replace + ]; + + // Weights are chosen to make the list size likely to grow over time, + // exerting more pressure on the system the longer the benchmark runs, + // while still ensuring that at least a few clears are executed. + // Also, to prevent bogus operations (E.G. you can't remove an item from an empty list). + _changeReasonWeightsWhenCountIs0 = + [ + 0.5f, // Add + 0.5f, // AddRange + 0.0f, // Clear + 0.0f, // Moved + 0.0f, // Remove + 0.0f, // RemoveRange + 0.0f // Replace + ]; + + _changeReasonWeightsWhenCountIs1 = + [ + 0.250f, // Add + 0.250f, // AddRange + 0.001f, // Clear + 0.000f, // Moved + 0.150f, // Remove + 0.150f, // RemoveRange + 0.199f // Replace + ]; + + _changeReasonWeightsOtherwise = + [ + 0.200f, // Add + 0.200f, // AddRange + 0.001f, // Clear + 0.149f, // Moved + 0.150f, // Remove + 0.150f, // RemoveRange + 0.150f // Replace + ]; + + _editCount = 1_000; + _maxChangeCount = 10; + _maxRangeSize = 50; + + var randomizer = new Randomizer(1234567); + + var minItemLifetime = TimeSpan.FromMilliseconds(1); + var maxItemLifetime = TimeSpan.FromMilliseconds(10); + _items = Enumerable.Range(1, _editCount * _maxChangeCount * _maxRangeSize) + .Select(id => new Item() + { + Id = id, + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null + }) + .ToImmutableArray(); + } [Benchmark] - [Arguments(1, 0)] - [Arguments(1, 1)] - [Arguments(10, 0)] - [Arguments(10, 1)] - [Arguments(10, 10)] - [Arguments(100, 0)] - [Arguments(100, 1)] - [Arguments(100, 10)] - [Arguments(100, 100)] - [Arguments(1_000, 0)] - [Arguments(1_000, 1)] - [Arguments(1_000, 10)] - [Arguments(1_000, 100)] - [Arguments(1_000, 1_000)] - public void AddsRemovesAndFinalization(int addCount, int removeCount) + public void RandomizedEditsAndExpirations() { - using var source = new SourceList(); + using var source = new SourceList(); using var subscription = source - .ExpireAfter(static _ => TimeSpan.FromMinutes(60), pollingInterval: null) + .ExpireAfter( + timeSelector: static item => item.Lifetime, + pollingInterval: null) .Subscribe(); - for (var i = 0; i < addCount; ++i) - source.Add(_items[i]); - - var targetCount = addCount - removeCount; - while (source.Count > targetCount) - source.RemoveAt(0); + PerformRandomizedEdits(source); subscription.Dispose(); } - private readonly IReadOnlyList _items; + private void PerformRandomizedEdits(SourceList source) + { + var randomizer = new Randomizer(1234567); + + var nextItemIndex = 0; + + for (var i = 0; i < _editCount; ++i) + { + source.Edit(updater => + { + var changeCount = randomizer.Int(1, _maxChangeCount); + for (var i = 0; i < changeCount; ++i) + { + var changeReason = randomizer.WeightedRandom(_changeReasons, updater.Count switch + { + 0 => _changeReasonWeightsWhenCountIs0, + 1 => _changeReasonWeightsWhenCountIs1, + _ => _changeReasonWeightsOtherwise + }); + + switch (changeReason) + { + case ListChangeReason.Add: + updater.Add(_items[nextItemIndex++]); + break; + + case ListChangeReason.AddRange: + updater.AddRange(Enumerable + .Range(0, randomizer.Int(1, _maxRangeSize)) + .Select(_ => _items[nextItemIndex++])); + break; + + case ListChangeReason.Replace: + updater.Replace( + original: randomizer.ListItem(updater), + replaceWith: _items[nextItemIndex++]); + break; + + case ListChangeReason.Remove: + updater.RemoveAt(randomizer.Int(0, updater.Count - 1)); + break; + + case ListChangeReason.RemoveRange: + var removeCount = randomizer.Int(1, Math.Min(_maxRangeSize, updater.Count)); + updater.RemoveRange( + index: randomizer.Int(0, updater.Count - removeCount), + count: removeCount); + break; + + case ListChangeReason.Moved: + int originalIndex; + int destinationIndex; + + do + { + originalIndex = randomizer.Int(0, updater.Count - 1); + destinationIndex = randomizer.Int(0, updater.Count - 1); + } while (originalIndex == destinationIndex); + + updater.Move(originalIndex, destinationIndex); + break; + + case ListChangeReason.Clear: + updater.Clear(); + break; + } + } + }); + } + } + + private readonly ListChangeReason[] _changeReasons; + private readonly float[] _changeReasonWeightsOtherwise; + private readonly float[] _changeReasonWeightsWhenCountIs0; + private readonly float[] _changeReasonWeightsWhenCountIs1; + private readonly int _editCount; + private readonly ImmutableArray _items; + private readonly int _maxChangeCount; + private readonly int _maxRangeSize; + + private sealed record Item + { + public required int Id { get; init; } + + public required TimeSpan? Lifetime { get; init; } + } } diff --git a/src/DynamicData.Tests/Cache/ExpireAfterFixture.ForSource.cs b/src/DynamicData.Tests/Cache/ExpireAfterFixture.ForSource.cs index 77f38be68..d4f5148ef 100644 --- a/src/DynamicData.Tests/Cache/ExpireAfterFixture.ForSource.cs +++ b/src/DynamicData.Tests/Cache/ExpireAfterFixture.ForSource.cs @@ -8,8 +8,10 @@ using Bogus; using FluentAssertions; using Xunit; +using Xunit.Abstractions; using DynamicData.Tests.Utilities; +using System.Diagnostics; namespace DynamicData.Tests.Cache; @@ -17,6 +19,11 @@ public static partial class ExpireAfterFixture { public sealed class ForSource { + private readonly ITestOutputHelper _output; + + public ForSource(ITestOutputHelper output) + => _output = output; + [Fact] public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() { @@ -31,13 +38,13 @@ public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.AddOrUpdate(new[] { item1, item2, item3 }); scheduler.AdvanceBy(1); - var item4 = new Item() { Id = 4 }; + var item4 = new TestItem() { Id = 4 }; source.AddOrUpdate(item4); scheduler.AdvanceBy(1); @@ -52,7 +59,7 @@ public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1, item3 }.Select(item => new KeyValuePair(item.Id, item)), "items #1 and #3 should have expired"); + results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1, item3 }.Select(item => new KeyValuePair(item.Id, item)), "items #1 and #3 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item4 }, "items #1 and #3 should have been removed"); results.TryGetRecordedCompletion().Should().BeFalse(); @@ -72,12 +79,12 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.AddOrUpdate(item1); scheduler.AdvanceBy(1); // Extend the expiration to a later time - var item2 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item2 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; source.AddOrUpdate(item2); scheduler.AdvanceBy(1); @@ -92,7 +99,7 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() source.Items.Should().BeEquivalentTo(new[] { item2 }, "no changes should have occurred"); // Shorten the expiration to an earlier time - var item3 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; + var item3 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; source.AddOrUpdate(item3); scheduler.AdvanceBy(1); @@ -101,7 +108,7 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() source.Items.Should().BeEquivalentTo(new[] { item3 }, "item #1 was replaced"); // One more update with no changes to the expiration - var item4 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; + var item4 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; source.AddOrUpdate(item4); scheduler.AdvanceBy(1); @@ -113,7 +120,7 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item4 }.Select(item => new KeyValuePair(item.Id, item4)), "item #1 should have expired"); + results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item4 }.Select(item => new KeyValuePair(item.Id, item4)), "item #1 should have expired"); source.Items.Should().BeEmpty("item #1 should have expired"); scheduler.AdvanceTo(DateTimeOffset.MaxValue.Ticks); @@ -140,37 +147,37 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; - var item4 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; - var item5 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; + var item4 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; + var item5 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; source.AddOrUpdate(new[] { item1, item2, item3, item4, item5 }); scheduler.AdvanceBy(1); // Additional expirations at 20ms. - var item6 = new Item() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; - var item7 = new Item() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item6 = new TestItem() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item7 = new TestItem() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; source.AddOrUpdate(new[] { item6, item7 }); scheduler.AdvanceBy(1); // Out-of-order expiration - var item8 = new Item() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; + var item8 = new TestItem() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; source.AddOrUpdate(item8); scheduler.AdvanceBy(1); // Non-expiring item - var item9 = new Item() { Id = 9 }; + var item9 = new TestItem() { Id = 9 }; source.AddOrUpdate(item9); scheduler.AdvanceBy(1); // Replacement changing lifetime. - var item10 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; + var item10 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; source.AddOrUpdate(item10); scheduler.AdvanceBy(1); // Replacement not-affecting lifetime. - var item11 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; + var item11 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; source.AddOrUpdate(item11); scheduler.AdvanceBy(1); @@ -179,6 +186,8 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() source.Refresh(item3); scheduler.AdvanceBy(1); + // Not testing Move changes, since ISourceCache doesn't actually provide an API to generate them. + // Verify initial state, after all emissions results.TryGetRecordedError().Should().BeNull(); @@ -204,7 +213,7 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1, item2, item6, item7, item8 }.Select(item => new KeyValuePair(item.Id, item)), "items #1, #2, #6, #7, and #8 should have expired"); + results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1, item2, item6, item7, item8 }.Select(item => new KeyValuePair(item.Id, item)), "items #1, #2, #6, #7, and #8 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item3, item9, item10, item11 }, "items #1, #2, #6, #7, and #8 should have been removed"); // Item scheduled to expire at 30ms, but won't be picked up yet @@ -219,7 +228,7 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Skip(1).Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().Skip(1).ElementAt(0).Should().BeEquivalentTo(new[] { item3 }.Select(item => new KeyValuePair(item.Id, item)), "item #3 should have expired"); + results.EnumerateRecordedValues().Skip(1).ElementAt(0).Should().BeEquivalentTo(new[] { item3 }.Select(item => new KeyValuePair(item.Id, item)), "item #3 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item9, item10, item11 }, "item #3 should have been removed"); // Item scheduled to expire at 45ms, but won't be picked up yet @@ -234,7 +243,7 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Skip(2).Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().Skip(2).ElementAt(0).Should().BeEquivalentTo(new[] { item10 }.Select(item => new KeyValuePair(item.Id, item)), "item #10 should have expired"); + results.EnumerateRecordedValues().Skip(2).ElementAt(0).Should().BeEquivalentTo(new[] { item10 }.Select(item => new KeyValuePair(item.Id, item)), "item #10 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item9, item11 }, "item #10 should have been removed"); // Expired items should be polled, but none should be found @@ -249,7 +258,7 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Skip(3).Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().Skip(3).ElementAt(0).Should().BeEquivalentTo(new[] { item11 }.Select(item => new KeyValuePair(item.Id, item)), "item #11 should have expired"); + results.EnumerateRecordedValues().Skip(3).ElementAt(0).Should().BeEquivalentTo(new[] { item11 }.Select(item => new KeyValuePair(item.Id, item)), "item #11 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item9 }, "item #11 should have been removed"); // Next poll should not find anything to expire. @@ -262,7 +271,7 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() results.TryGetRecordedCompletion().Should().BeFalse(); } - [Fact(Skip = "Existing defect, very minor defect, items defined to never expire actually do, at DateTimeOffset.MaxValue")] + [Fact] public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() { using var source = CreateTestSource(); @@ -276,37 +285,37 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; - var item4 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; - var item5 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; + var item4 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; + var item5 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; source.AddOrUpdate(new[] { item1, item2, item3, item4, item5 }); scheduler.AdvanceBy(1); // Additional expirations at 20ms. - var item6 = new Item() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; - var item7 = new Item() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item6 = new TestItem() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item7 = new TestItem() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; source.AddOrUpdate(new[] { item6, item7 }); scheduler.AdvanceBy(1); // Out-of-order expiration - var item8 = new Item() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; + var item8 = new TestItem() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; source.AddOrUpdate(item8); scheduler.AdvanceBy(1); // Non-expiring item - var item9 = new Item() { Id = 9 }; + var item9 = new TestItem() { Id = 9 }; source.AddOrUpdate(item9); scheduler.AdvanceBy(1); // Replacement changing lifetime. - var item10 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; + var item10 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; source.AddOrUpdate(item10); scheduler.AdvanceBy(1); // Replacement not-affecting lifetime. - var item11 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; + var item11 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; source.AddOrUpdate(item11); scheduler.AdvanceBy(1); @@ -315,6 +324,8 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() source.Refresh(item3); scheduler.AdvanceBy(1); + // Not testing Move changes, since ISourceCache doesn't actually provide an API to generate them. + // Verify initial state, after all emissions results.TryGetRecordedError().Should().BeNull(); @@ -325,28 +336,28 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1 }.Select(item => new KeyValuePair(item.Id, item)), "item #1 should have expired"); + results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1 }.Select(item => new KeyValuePair(item.Id, item)), "item #1 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item2, item3, item6, item7, item8, item9, item10, item11 }, "item #1 should have been removed"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(15).Ticks); results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Skip(1).Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().Skip(1).ElementAt(0).Should().BeEquivalentTo(new[] { item8 }.Select(item => new KeyValuePair(item.Id, item)), "item #8 should have expired"); + results.EnumerateRecordedValues().Skip(1).ElementAt(0).Should().BeEquivalentTo(new[] { item8 }.Select(item => new KeyValuePair(item.Id, item)), "item #8 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item2, item3, item6, item7, item9, item10, item11 }, "item #8 should have expired"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(20).Ticks); results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Skip(2).Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().Skip(2).ElementAt(0).Should().BeEquivalentTo(new[] { item2, item6, item7 }.Select(item => new KeyValuePair(item.Id, item)), "items #2, #6, and #7 should have expired"); + results.EnumerateRecordedValues().Skip(2).ElementAt(0).Should().BeEquivalentTo(new[] { item2, item6, item7 }.Select(item => new KeyValuePair(item.Id, item)), "items #2, #6, and #7 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item3, item9, item10, item11 }, "items #2, #6, and #7 should have been removed"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(30).Ticks); results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Skip(3).Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().Skip(3).ElementAt(0).Should().BeEquivalentTo(new[] { item3 }.Select(item => new KeyValuePair(item.Id, item)), "item #3 should have expired"); + results.EnumerateRecordedValues().Skip(3).ElementAt(0).Should().BeEquivalentTo(new[] { item3 }.Select(item => new KeyValuePair(item.Id, item)), "item #3 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item9, item10, item11 }, "item #3 should have been removed"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(40).Ticks); @@ -359,14 +370,14 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Skip(4).Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().Skip(4).ElementAt(0).Should().BeEquivalentTo(new[] { item10 }.Select(item => new KeyValuePair(item.Id, item)), "item #10 should have expired"); + results.EnumerateRecordedValues().Skip(4).ElementAt(0).Should().BeEquivalentTo(new[] { item10 }.Select(item => new KeyValuePair(item.Id, item)), "item #10 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item9, item11 }, "item #10 should have expired"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(50).Ticks); results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Skip(5).Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().Skip(5).ElementAt(0).Should().BeEquivalentTo(new[] { item11 }.Select(item => new KeyValuePair(item.Id, item)), "item #11 should have expired"); + results.EnumerateRecordedValues().Skip(5).ElementAt(0).Should().BeEquivalentTo(new[] { item11 }.Select(item => new KeyValuePair(item.Id, item)), "item #11 should have expired"); source.Items.Should().BeEquivalentTo(new[] { item9 }, "item #11 should have expired"); // Remaining item should never expire @@ -380,7 +391,7 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() } // Covers https://github.com/reactivemarbles/DynamicData/issues/716 - [Fact(Skip = "Existing defect, removals are skipped when scheduler invokes early")] + [Fact] public void SchedulerIsInaccurate_RemovalsAreNotSkipped() { using var source = CreateTestSource(); @@ -397,7 +408,7 @@ public void SchedulerIsInaccurate_RemovalsAreNotSkipped() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.AddOrUpdate(item1); @@ -405,25 +416,17 @@ public void SchedulerIsInaccurate_RemovalsAreNotSkipped() results.EnumerateRecordedValues().Should().BeEmpty("no expirations should have occurred"); source.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); - // Simulate the scheduler invoking all actions 1ms early. - while(scheduler.ScheduledActions.Count is not 0) - { - if (scheduler.ScheduledActions[0].DueTime is DateTimeOffset dueTime) - scheduler.Now = dueTime - TimeSpan.FromMilliseconds(1); - - scheduler.ScheduledActions[0].Invoke(); - scheduler.ScheduledActions.RemoveAt(0); - } + scheduler.SimulateUntilIdle(inaccuracyOffset: TimeSpan.FromMilliseconds(-1)); results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1 }.Select(item => new KeyValuePair(item.Id, item)), "item #1 should have expired"); + results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1 }.Select(item => new KeyValuePair(item.Id, item)), "item #1 should have expired"); source.Items.Should().BeEmpty("item #1 should have been removed"); results.TryGetRecordedCompletion().Should().BeFalse(); } - [Fact(Skip = "Existing defect, completion is not propagated from the source")] + [Fact] public void SourceCompletes_CompletionIsPropagated() { using var source = CreateTestSource(); @@ -437,7 +440,7 @@ public void SourceCompletes_CompletionIsPropagated() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - source.AddOrUpdate(new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }); + source.AddOrUpdate(new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }); scheduler.AdvanceBy(1); source.Complete(); @@ -452,14 +455,14 @@ public void SourceCompletes_CompletionIsPropagated() results.EnumerateInvalidNotifications().Should().BeEmpty(); } - [Fact(Skip = "Existing defect, completion is not propagated from the source")] + [Fact] public void SourceCompletesImmediately_CompletionIsPropagated() { using var source = CreateTestSource(); var scheduler = CreateTestScheduler(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.AddOrUpdate(item1); scheduler.AdvanceBy(1); @@ -480,7 +483,7 @@ public void SourceCompletesImmediately_CompletionIsPropagated() results.EnumerateInvalidNotifications().Should().BeEmpty(); } - [Fact(Skip = "Exsiting defect, errors are re-thrown instead of propagated, operator does not use safe subscriptions")] + [Fact] public void SourceErrors_ErrorIsPropagated() { using var source = CreateTestSource(); @@ -494,7 +497,7 @@ public void SourceErrors_ErrorIsPropagated() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - source.AddOrUpdate(new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }); + source.AddOrUpdate(new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }); scheduler.AdvanceBy(1); var error = new Exception("This is a test"); @@ -510,14 +513,14 @@ public void SourceErrors_ErrorIsPropagated() results.EnumerateInvalidNotifications().Should().BeEmpty(); } - [Fact(Skip = "Existing defect, immediately-occuring error is not propagated")] + [Fact] public void SourceErrorsImmediately_ErrorIsPropagated() { using var source = CreateTestSource(); var scheduler = CreateTestScheduler(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.AddOrUpdate(item1); scheduler.AdvanceBy(1); @@ -545,70 +548,72 @@ public void SourceErrorsImmediately_ErrorIsPropagated() [Fact] public void SourceIsNull_ThrowsException() => FluentActions.Invoking(() => ObservableCacheEx.ExpireAfter( - source: (null as ISourceCache)!, + source: (null as ISourceCache)!, timeSelector: static _ => default, interval: null)) .Should().Throw(); - [Fact(Skip = "Existing defect, operator does not properly handle items with a null timeout, when using a real scheduler, it passes a TimeSpan to the scheduler that is outside of the supported range")] + [Fact] public async Task ThreadPoolSchedulerIsUsedWithoutPolling_ExpirationIsThreadSafe() { - using var source = CreateTestSource(); + using var source = new TestSourceCache(static item => item.Id); var scheduler = ThreadPoolScheduler.Instance; using var subscription = source .ExpireAfter( - timeSelector: CreateTimeSelector(scheduler), + timeSelector: static item => item.Lifetime, scheduler: scheduler) .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var maxExpiration = PerformStressEdits( + PerformStressEdits( source: source, - scheduler: scheduler, - stressCount: 10_000, - minItemLifetime: TimeSpan.FromMilliseconds(10), - maxItemLifetime: TimeSpan.FromMilliseconds(50), + editCount: 10_000, + minItemLifetime: TimeSpan.FromMilliseconds(2), + maxItemLifetime: TimeSpan.FromMilliseconds(10), maxChangeCount: 10); - await Observable.Timer(maxExpiration + TimeSpan.FromMilliseconds(100), scheduler); + await WaitForCompletionAsync(source, results, timeout: TimeSpan.FromMinutes(1)); results.TryGetRecordedError().Should().BeNull(); - results.EnumerateRecordedValues().SelectMany(static removals => removals).Should().AllSatisfy(static pair => pair.Value.Expiration.Should().NotBeNull("only items with an expiration should have expired")); + results.EnumerateRecordedValues().SelectMany(static removals => removals).Should().AllSatisfy(static pair => pair.Value.Lifetime.Should().NotBeNull("only items with an expiration should have expired")); results.TryGetRecordedCompletion().Should().BeFalse(); - source.Items.Should().AllSatisfy(item => item.Expiration.Should().BeNull("all items with an expiration should have expired")); + source.Items.Should().AllSatisfy(item => item.Lifetime.Should().BeNull("all items with an expiration should have expired")); + + _output.WriteLine($"{results.EnumerateRecordedValues().Count()} Expirations occurred, for {results.EnumerateRecordedValues().SelectMany(static item => item).Count()} items"); } [Fact] public async Task ThreadPoolSchedulerIsUsedWithPolling_ExpirationIsThreadSafe() { - using var source = CreateTestSource(); + using var source = new TestSourceCache(static item => item.Id); var scheduler = ThreadPoolScheduler.Instance; using var subscription = source .ExpireAfter( - timeSelector: CreateTimeSelector(scheduler), + timeSelector: static item => item.Lifetime, pollingInterval: TimeSpan.FromMilliseconds(10), scheduler: scheduler) .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var maxExpiration = PerformStressEdits( + PerformStressEdits( source: source, - scheduler: scheduler, - stressCount: 10_000, - minItemLifetime: TimeSpan.FromMilliseconds(10), - maxItemLifetime: TimeSpan.FromMilliseconds(50), + editCount: 10_000, + minItemLifetime: TimeSpan.FromMilliseconds(2), + maxItemLifetime: TimeSpan.FromMilliseconds(10), maxChangeCount: 10); - await Observable.Timer(maxExpiration + TimeSpan.FromMilliseconds(100), scheduler); + await WaitForCompletionAsync(source, results, timeout: TimeSpan.FromMinutes(1)); results.TryGetRecordedError().Should().BeNull(); - results.EnumerateRecordedValues().SelectMany(static removals => removals).Should().AllSatisfy(pair => pair.Value.Expiration.Should().NotBeNull("only items with an expiration should have expired")); + results.EnumerateRecordedValues().SelectMany(static removals => removals).Should().AllSatisfy(pair => pair.Value.Lifetime.Should().NotBeNull("only items with an expiration should have expired")); results.TryGetRecordedCompletion().Should().BeFalse(); - source.Items.Should().AllSatisfy(item => item.Expiration.Should().BeNull("all items with an expiration should have expired")); + source.Items.Should().AllSatisfy(item => item.Lifetime.Should().BeNull("all items with an expiration should have expired")); + + _output.WriteLine($"{results.EnumerateRecordedValues().Count()} Expirations occurred, for {results.EnumerateRecordedValues().SelectMany(static item => item).Count()} items"); } [Fact] @@ -618,7 +623,7 @@ public void TimeSelectorIsNull_ThrowsException() interval: null)) .Should().Throw(); - [Fact(Skip = "Exsiting defect, errors are re-thrown instead of propagated, user code is not protected")] + [Fact] public void TimeSelectorThrows_ErrorIsPropagated() { using var source = CreateTestSource(); @@ -634,7 +639,7 @@ public void TimeSelectorThrows_ErrorIsPropagated() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - source.AddOrUpdate(new Item() { Id = 1 }); + source.AddOrUpdate(new TestItem() { Id = 1 }); scheduler.AdvanceBy(1); results.TryGetRecordedError().Should().Be(error); @@ -644,77 +649,121 @@ public void TimeSelectorThrows_ErrorIsPropagated() results.EnumerateInvalidNotifications().Should().BeEmpty(); } - private static TestSourceCache CreateTestSource() + private static TestSourceCache CreateTestSource() => new(static item => item.Id); - private static DateTimeOffset PerformStressEdits( - ISourceCache source, - IScheduler scheduler, - int stressCount, + private static void PerformStressEdits( + ISourceCache source, + int editCount, TimeSpan minItemLifetime, TimeSpan maxItemLifetime, int maxChangeCount) { - var nextItemId = 1; + // Not exercising Moved, since SourceCache<> doesn't support it. + var changeReasons = new[] + { + ChangeReason.Add, + ChangeReason.Refresh, + ChangeReason.Remove, + ChangeReason.Update + }; + + // Weights are chosen to make the cache size likely to grow over time, + // exerting more pressure on the system the longer the benchmark runs. + // Also, to prevent bogus operations (E.G. you can't remove an item from an empty cache). + var changeReasonWeightsWhenCountIs0 = new[] + { + 1f, // Add + 0f, // Refresh + 0f, // Remove + 0f // Update + }; + + var changeReasonWeightsOtherwise = new[] + { + 0.30f, // Add + 0.25f, // Refresh + 0.20f, // Remove + 0.25f // Update + }; + var randomizer = new Randomizer(1234567); - var maxExpiration = DateTimeOffset.MinValue; + + var items = Enumerable.Range(1, editCount * maxChangeCount) + .Select(id => new StressItem() + { + Id = id, + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null + }) + .ToArray(); + + var nextItemIndex = 0; - for (var i = 0; i < stressCount; ++i) - source.Edit(mutator => + for (var i = 0; i < editCount; ++i) + { + source.Edit(updater => { var changeCount = randomizer.Int(1, maxChangeCount); - for (var i = 0; i < changeCount; ++i) { - var changeReason = (mutator.Count is 0) - ? ChangeReason.Add - : randomizer.Enum(exclude: ChangeReason.Moved); - - if (changeReason is ChangeReason.Add) + var changeReason = randomizer.WeightedRandom(changeReasons, updater.Count switch { - mutator.AddOrUpdate(new Item() - { - Id = nextItemId++, - Expiration = GenerateExpiration() - }); - continue; - } - - var key = randomizer.CollectionItem((ICollection)mutator.Keys); + 0 => changeReasonWeightsWhenCountIs0, + _ => changeReasonWeightsOtherwise + }); switch (changeReason) { + case ChangeReason.Add: + updater.AddOrUpdate(items[nextItemIndex++]); + break; + case ChangeReason.Refresh: - mutator.Refresh(key); + updater.Refresh(updater.Keys.ElementAt(randomizer.Int(0, updater.Count - 1))); break; case ChangeReason.Remove: - mutator.RemoveKey(key); + updater.RemoveKey(updater.Keys.ElementAt(randomizer.Int(0, updater.Count - 1))); break; case ChangeReason.Update: - source.AddOrUpdate(new Item() + updater.AddOrUpdate(new StressItem() { - Id = key, - Expiration = GenerateExpiration() + Id = updater.Keys.ElementAt(randomizer.Int(0, updater.Count - 1)), + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null }); break; } } }); + } + } - return maxExpiration; - - DateTimeOffset? GenerateExpiration() + private static async Task WaitForCompletionAsync( + ISourceCache source, + TestableObserver>> results, + TimeSpan timeout) + { + // Wait up to full minute for the operator to finish processing expirations + // (this is mainly a problem for GitHub PR builds, where test runs take a lot longer, due to more limited resources). + var stopwatch = new Stopwatch(); + stopwatch.Start(); + var pollingInterval = TimeSpan.FromMilliseconds(100); + while (stopwatch.Elapsed < timeout) { - if (randomizer.Bool()) - return null; + await Task.Delay(pollingInterval); - var expiration = scheduler.Now + randomizer.TimeSpan(minItemLifetime, maxItemLifetime); - if (expiration > maxExpiration) - maxExpiration = expiration; - - return expiration; + // Identify "completion" as either an error, a completion signal, or all expiring items being removed. + if ((results.TryGetRecordedError() is not null) + || results.TryGetRecordedCompletion() + || source.Items.All(static item => item.Lifetime is null)) + { + break; + } } } } diff --git a/src/DynamicData.Tests/Cache/ExpireAfterFixture.ForStream.cs b/src/DynamicData.Tests/Cache/ExpireAfterFixture.ForStream.cs index 316a4ad5d..0b58fc48f 100644 --- a/src/DynamicData.Tests/Cache/ExpireAfterFixture.ForStream.cs +++ b/src/DynamicData.Tests/Cache/ExpireAfterFixture.ForStream.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reactive.Concurrency; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading.Tasks; @@ -11,7 +12,8 @@ using Xunit; using DynamicData.Tests.Utilities; -using System.Reactive.Disposables; +using Xunit.Abstractions; +using System.Diagnostics; namespace DynamicData.Tests.Cache; @@ -22,7 +24,7 @@ public sealed class ForStream [Fact] public void ExpiredItemIsRemoved_RemovalIsSkipped() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -33,10 +35,10 @@ public void ExpiredItemIsRemoved_RemovalIsSkipped() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1), new(reason: ChangeReason.Add, key: item2.Id, current: item2), @@ -55,7 +57,7 @@ public void ExpiredItemIsRemoved_RemovalIsSkipped() results.Data.Items.Should().BeEquivalentTo(new[] { item2, item3 }, "item #1 should have been removed"); // Send a notification to remove an item that's already been removed - source.OnNext(new ChangeSet() + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Remove, key: item1.Id, current: item1), }); @@ -70,7 +72,7 @@ public void ExpiredItemIsRemoved_RemovalIsSkipped() [Fact] public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -81,10 +83,10 @@ public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1), new(reason: ChangeReason.Add, key: item2.Id, current: item2), @@ -92,14 +94,14 @@ public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() }); scheduler.AdvanceBy(1); - var item4 = new Item() { Id = 4 }; - source.OnNext(new ChangeSet() + var item4 = new TestItem() { Id = 4 }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item4.Id, current: item4) }); scheduler.AdvanceBy(1); - source.OnNext(new ChangeSet() + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Remove, key: item2.Id, current: item2) }); @@ -121,7 +123,7 @@ public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() [Fact] public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -132,16 +134,16 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1) }); scheduler.AdvanceBy(1); // Extend the expiration to a later time - var item2 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - source.OnNext(new ChangeSet() + var item2 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Update, key: item2.Id, current: item2, previous: item1) }); @@ -157,8 +159,8 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() results.Messages.Skip(2).Should().BeEmpty("no expirations should have occurred"); // Shorten the expiration to an earlier time - var item3 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; - source.OnNext(new ChangeSet() + var item3 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Update, key: item3.Id, current: item3, previous: item2) }); @@ -169,15 +171,15 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() results.Data.Items.Should().BeEquivalentTo(new[] { item3 }, "item #1 was replaced"); // One more update with no changes to the expiration - var item4 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; - source.OnNext(new ChangeSet() + var item4 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Update, key: item4.Id, current: item4, previous: item3) }); scheduler.AdvanceBy(1); results.Error.Should().BeNull(); - results.Messages.Skip(3).Count().Should().Be(1, "1 source operation was performed."); + results.Messages.Skip(3).Count().Should().Be(1, "1 source operation was performed"); results.Data.Items.Should().BeEquivalentTo(new[] { item4 }, "item #1 was replaced"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(15).Ticks); @@ -197,7 +199,7 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() [Fact] public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -209,12 +211,12 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; - var item4 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; - var item5 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; + var item4 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; + var item5 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1), new(reason: ChangeReason.Add, key: item2.Id, current: item2), @@ -225,9 +227,9 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() scheduler.AdvanceBy(1); // Additional expirations at 20ms. - var item6 = new Item() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; - var item7 = new Item() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; - source.OnNext(new ChangeSet() + var item6 = new TestItem() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item7 = new TestItem() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item6.Id, current: item6), new(reason: ChangeReason.Add, key: item7.Id, current: item7) @@ -235,32 +237,32 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() scheduler.AdvanceBy(1); // Out-of-order expiration - var item8 = new Item() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; - source.OnNext(new ChangeSet() + var item8 = new TestItem() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item8.Id, current: item8) }); scheduler.AdvanceBy(1); // Non-expiring item - var item9 = new Item() { Id = 9 }; - source.OnNext(new ChangeSet() + var item9 = new TestItem() { Id = 9 }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item9.Id, current: item9) }); scheduler.AdvanceBy(1); // Replacement changing lifetime. - var item10 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; - source.OnNext(new ChangeSet() + var item10 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Update, key: item10.Id, current: item10, previous: item4) }); scheduler.AdvanceBy(1); // Replacement not-affecting lifetime. - var item11 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; - source.OnNext(new ChangeSet() + var item11 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Update, key: item11.Id, current: item11, previous: item5) }); @@ -268,16 +270,24 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() // Refresh should not affect scheduled expiration. item3.Expiration = DateTimeOffset.FromUnixTimeMilliseconds(55); - source.OnNext(new ChangeSet() + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Refresh, key: item3.Id, current: item3) }); scheduler.AdvanceBy(1); + // Move changes should be ignored completely. + source.OnNext(new ChangeSet() + { + new(reason: ChangeReason.Moved, key: item3.Id, current: item3, previous: default, currentIndex: 3, previousIndex: 2), + new(reason: ChangeReason.Moved, key: item1.Id, current: item1, previous: default, currentIndex: 1, previousIndex: 0), + new(reason: ChangeReason.Moved, key: item1.Id, current: item1, previous: default, currentIndex: 4, previousIndex: 1) + }); + // Verify initial state, after all emissions results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(7, "7 source operations were performed"); + results.Messages.Count.Should().Be(7, "8 source operations were performed, and 1 should have been ignored"); results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3, item6, item7, item8, item9, item10, item11 }, "9 items were added, 2 were replaced, and 1 was refreshed"); // Item scheduled to expire at 10ms, but won't be picked up yet @@ -347,10 +357,10 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() results.IsCompleted.Should().BeFalse(); } - [Fact(Skip = "Existing defect, very minor defect, items defined to never expire actually do, at DateTimeOffset.MaxValue")] + [Fact] public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -361,12 +371,12 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; - var item4 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; - var item5 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; + var item4 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; + var item5 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1), new(reason: ChangeReason.Add, key: item2.Id, current: item2), @@ -377,9 +387,9 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() scheduler.AdvanceBy(1); // Additional expirations at 20ms. - var item6 = new Item() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; - var item7 = new Item() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; - source.OnNext(new ChangeSet() + var item6 = new TestItem() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item7 = new TestItem() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item6.Id, current: item6), new(reason: ChangeReason.Add, key: item7.Id, current: item7) @@ -387,32 +397,32 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() scheduler.AdvanceBy(1); // Out-of-order expiration - var item8 = new Item() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; - source.OnNext(new ChangeSet() + var item8 = new TestItem() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item8.Id, current: item8) }); scheduler.AdvanceBy(1); // Non-expiring item - var item9 = new Item() { Id = 9 }; - source.OnNext(new ChangeSet() + var item9 = new TestItem() { Id = 9 }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item9.Id, current: item9) }); scheduler.AdvanceBy(1); // Replacement changing lifetime. - var item10 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; - source.OnNext(new ChangeSet() + var item10 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Update, key: item10.Id, current: item10, previous: item4) }); scheduler.AdvanceBy(1); // Replacement not-affecting lifetime. - var item11 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; - source.OnNext(new ChangeSet() + var item11 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Update, key: item11.Id, current: item11, previous: item5) }); @@ -420,16 +430,24 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() // Refresh should not affect scheduled expiration. item3.Expiration = DateTimeOffset.FromUnixTimeMilliseconds(55); - source.OnNext(new ChangeSet() + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Refresh, key: item3.Id, current: item3) }); scheduler.AdvanceBy(1); + // Move changes should be ignored completely. + source.OnNext(new ChangeSet() + { + new(reason: ChangeReason.Moved, key: item3.Id, current: item3, previous: default, currentIndex: 3, previousIndex: 2), + new(reason: ChangeReason.Moved, key: item1.Id, current: item1, previous: default, currentIndex: 1, previousIndex: 0), + new(reason: ChangeReason.Moved, key: item1.Id, current: item1, previous: default, currentIndex: 4, previousIndex: 1) + }); + // Verify initial state, after all emissions results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(7, "7 source operations were performed"); + results.Messages.Count.Should().Be(7, "8 source operations were performed, and 1 should have been ignored"); results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3, item6, item7, item8, item9, item10, item11 }, "11 items were added, 2 were replaced, and 1 was refreshed"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(10).Ticks); @@ -464,7 +482,7 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(45).Ticks); results.Error.Should().BeNull(); - results.Messages.Skip(12).Count().Should().Be(1, "1 expiration should have occurred"); + results.Messages.Skip(11).Count().Should().Be(1, "1 expiration should have occurred"); results.Data.Items.Should().BeEquivalentTo(new[] { item9, item11 }, "item #10 should have expired"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(50).Ticks); @@ -482,10 +500,10 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() results.IsCompleted.Should().BeFalse(); } - [Fact(Skip = "Existing defect, completion does not wait")] + [Fact] public void RemovalsArePending_CompletionWaitsForRemovals() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -496,10 +514,10 @@ public void RemovalsArePending_CompletionWaitsForRemovals() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2 }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2 }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1), new(reason: ChangeReason.Add, key: item2.Id, current: item2), @@ -530,15 +548,13 @@ public void RemovalsArePending_CompletionWaitsForRemovals() results.IsCompleted.Should().BeTrue(); results.Messages.Skip(2).Count().Should().Be(1, "1 expiration should have occurred"); results.Data.Items.Should().BeEquivalentTo(new[] { item2 }, "item #3 should have expired"); - - results.IsCompleted.Should().BeFalse(); } // Covers https://github.com/reactivemarbles/DynamicData/issues/716 - [Fact(Skip = "Existing defect, removals are skipped when scheduler invokes early")] + [Fact] public void SchedulerIsInaccurate_RemovalsAreNotSkipped() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = new FakeScheduler() { @@ -552,8 +568,8 @@ public void SchedulerIsInaccurate_RemovalsAreNotSkipped() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1) }); @@ -563,15 +579,7 @@ public void SchedulerIsInaccurate_RemovalsAreNotSkipped() results.Messages.Count.Should().Be(1, "1 source operation was performed"); results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); - // Simulate the scheduler invoking all actions 1ms early. - while(scheduler.ScheduledActions.Count is not 0) - { - if (scheduler.ScheduledActions[0].DueTime is DateTimeOffset dueTime) - scheduler.Now = dueTime - TimeSpan.FromMilliseconds(1); - - scheduler.ScheduledActions[0].Invoke(); - scheduler.ScheduledActions.RemoveAt(0); - } + scheduler.SimulateUntilIdle(inaccuracyOffset: TimeSpan.FromMilliseconds(-1)); results.Error.Should().BeNull(); results.Messages.Skip(1).Count().Should().Be(1, "1 expiration should have occurred"); @@ -583,7 +591,7 @@ public void SchedulerIsInaccurate_RemovalsAreNotSkipped() [Fact] public void SourceCompletes_CompletionIsPropagated() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -594,10 +602,10 @@ public void SourceCompletes_CompletionIsPropagated() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1 }; - var item2 = new Item() { Id = 2 }; - var item3 = new Item() { Id = 3 }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1 }; + var item2 = new TestItem() { Id = 2 }; + var item3 = new TestItem() { Id = 3 }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1), new(reason: ChangeReason.Add, key: item2.Id, current: item2), @@ -619,13 +627,13 @@ public void SourceCompletes_CompletionIsPropagated() [Fact] public void SourceCompletesImmediately_CompletionIsPropagated() { - var item1 = new Item() { Id = 1 }; - var item2 = new Item() { Id = 2 }; - var item3 = new Item() { Id = 3 }; + var item1 = new TestItem() { Id = 1 }; + var item2 = new TestItem() { Id = 2 }; + var item3 = new TestItem() { Id = 3 }; - var source = Observable.Create>(observer => + var source = Observable.Create>(observer => { - observer.OnNext(new ChangeSet() + observer.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1), new(reason: ChangeReason.Add, key: item2.Id, current: item2), @@ -656,7 +664,7 @@ public void SourceCompletesImmediately_CompletionIsPropagated() [Fact] public void SourceErrors_ErrorIsPropagated() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -667,8 +675,8 @@ public void SourceErrors_ErrorIsPropagated() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1) }); @@ -693,13 +701,13 @@ public void SourceErrors_ErrorIsPropagated() [Fact] public void SourceErrorsImmediately_ErrorIsPropagated() { - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; var error = new Exception("This is a test"); - var source = Observable.Create>(observer => + var source = Observable.Create>(observer => { - observer.OnNext(new ChangeSet() + observer.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1) }); @@ -731,37 +739,35 @@ public void SourceErrorsImmediately_ErrorIsPropagated() [Fact] public void SourceIsNull_ThrowsException() => FluentActions.Invoking(() => ObservableCacheEx.ExpireAfter( - source: (null as IObservable>)!, + source: (null as IObservable>)!, timeSelector: static _ => default)) .Should().Throw(); - [Fact(Skip = "Existing defect, operator does not properly handle items with a null timeout, when using a real scheduler, it passes a TimeSpan to the scheduler that is outside of the supported range")] + [Fact] public async Task ThreadPoolSchedulerIsUsedWithoutPolling_ExpirationIsThreadSafe() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = ThreadPoolScheduler.Instance; using var results = source .ExpireAfter( - timeSelector: CreateTimeSelector(scheduler), + timeSelector: static item => item.Lifetime, scheduler: scheduler) .ValidateSynchronization() .AsAggregator(); - var maxExpiration = PublishStressChangeSets( + PublishStressChangeSets( source: source, - scheduler: scheduler, - stressCount: 10_000, - minItemLifetime: TimeSpan.FromMilliseconds(10), - maxItemLifetime: TimeSpan.FromMilliseconds(50), - maxChangeCount: 10); + editCount: 10_000, + minItemLifetime: TimeSpan.FromMilliseconds(2), + maxItemLifetime: TimeSpan.FromMilliseconds(10), + maxChangeCount: 20); - await Observable.Timer(maxExpiration + TimeSpan.FromMilliseconds(100), scheduler); + await WaitForCompletionAsync(results, timeout: TimeSpan.FromMinutes(1)); results.Error.Should().BeNull(); - results.Messages.SelectMany(static changeSet => changeSet.Where(change => change.Reason is ChangeReason.Remove)).Should().AllSatisfy(static change => change.Current.Expiration.Should().NotBeNull("only items with an expiration should have expired")); - results.Data.Items.Should().AllSatisfy(item => item.Expiration.Should().BeNull("all items with an expiration should have expired")); + results.Data.Items.Should().AllSatisfy(item => item.Lifetime.Should().BeNull("all items with an expiration should have expired")); results.IsCompleted.Should().BeFalse(); } @@ -769,46 +775,45 @@ public async Task ThreadPoolSchedulerIsUsedWithoutPolling_ExpirationIsThreadSafe [Fact] public async Task ThreadPoolSchedulerIsUsedWithPolling_ExpirationIsThreadSafe() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = ThreadPoolScheduler.Instance; using var results = source .ExpireAfter( - timeSelector: CreateTimeSelector(scheduler), + timeSelector: static item => item.Lifetime, pollingInterval: TimeSpan.FromMilliseconds(10), scheduler: scheduler) .ValidateSynchronization() .AsAggregator(); - var maxExpiration = PublishStressChangeSets( + PublishStressChangeSets( source: source, - scheduler: scheduler, - stressCount: 10_000, - minItemLifetime: TimeSpan.FromMilliseconds(10), - maxItemLifetime: TimeSpan.FromMilliseconds(50), - maxChangeCount: 10); + editCount: 10_000, + minItemLifetime: TimeSpan.FromMilliseconds(2), + maxItemLifetime: TimeSpan.FromMilliseconds(10), + maxChangeCount: 20); - await Observable.Timer(maxExpiration + TimeSpan.FromMilliseconds(100), scheduler); + await WaitForCompletionAsync(results, timeout: TimeSpan.FromMinutes(1)); var now = scheduler.Now; results.Error.Should().BeNull(); - results.Data.Items.Should().AllSatisfy(item => item.Expiration.Should().BeNull("all items with an expiration should have expired")); + results.Data.Items.Should().AllSatisfy(item => item.Lifetime.Should().BeNull("all items with an expiration should have expired")); results.IsCompleted.Should().BeFalse(); } [Fact] public void TimeSelectorIsNull_ThrowsException() - => FluentActions.Invoking(() => new Subject>().ExpireAfter( + => FluentActions.Invoking(() => new Subject>().ExpireAfter( timeSelector: null!)) .Should().Throw(); - [Fact(Skip = "Exsiting defect, errors are re-thrown instead of propagated, user code is not protected")] - public void TimeSelectorThrows_SubscriptionReceivesError() + [Fact] + public void TimeSelectorThrows_ErrorIsPropagated() { - using var source = new Subject>(); + using var source = new Subject>(); var scheduler = CreateTestScheduler(); @@ -821,93 +826,137 @@ public void TimeSelectorThrows_SubscriptionReceivesError() .ValidateSynchronization() .AsAggregator(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - source.OnNext(new ChangeSet() + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + source.OnNext(new ChangeSet() { new(reason: ChangeReason.Add, key: item1.Id, current: item1) }); scheduler.AdvanceBy(1); - results.Error.Should().BeNull(); + results.Error.Should().Be(error); results.Messages.Should().BeEmpty("no source operations should have been processed"); results.IsCompleted.Should().BeFalse(); } - private static DateTimeOffset PublishStressChangeSets( - IObserver> source, - IScheduler scheduler, - int stressCount, + private static void PublishStressChangeSets( + IObserver> source, + int editCount, TimeSpan minItemLifetime, TimeSpan maxItemLifetime, int maxChangeCount) { - var nextItemId = 1; + // Not exercising Moved, since ChangeAwareCache<> doesn't support it, and I'm too lazy to implement it by hand. + var changeReasons = new[] + { + ChangeReason.Add, + ChangeReason.Refresh, + ChangeReason.Remove, + ChangeReason.Update + }; + + // Weights are chosen to make the cache size likely to grow over time, + // exerting more pressure on the system the longer the benchmark runs. + // Also, to prevent bogus operations (E.G. you can't remove an item from an empty cache). + var changeReasonWeightsWhenCountIs0 = new[] + { + 1f, // Add + 0f, // Refresh + 0f, // Remove + 0f // Update + }; + + var changeReasonWeightsOtherwise = new[] + { + 0.30f, // Add + 0.25f, // Refresh + 0.20f, // Remove + 0.25f // Update + }; + var randomizer = new Randomizer(1234567); - var maxExpiration = DateTimeOffset.MinValue; - var cache = new ChangeAwareCache(); + var nextItemId = 1; + + var changeSets = new List>(capacity: editCount); + + var cache = new ChangeAwareCache(); - for (var i = 0; i < stressCount; ++i) + while (changeSets.Count < changeSets.Capacity) { var changeCount = randomizer.Int(1, maxChangeCount); - - for (var j = 0; j < changeCount; ++j) + for (var i = 0; i < changeCount; ++i) { - var changeReason = (cache.Count is 0) - ? ChangeReason.Add - : randomizer.Enum(exclude: ChangeReason.Moved); - - if (changeReason is ChangeReason.Add) + var changeReason = randomizer.WeightedRandom(changeReasons, cache.Count switch { - var item = new Item() - { - Id = nextItemId++, - Expiration = GenerateExpiration() - }; - - cache.AddOrUpdate(item, item.Id); - continue; - } - - var key = randomizer.CollectionItem((ICollection)cache.Keys); + 0 => changeReasonWeightsWhenCountIs0, + _ => changeReasonWeightsOtherwise + }); switch (changeReason) { + case ChangeReason.Add: + cache.AddOrUpdate( + item: new StressItem() + { + Id = nextItemId, + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null + }, + key: nextItemId); + ++nextItemId; + break; + case ChangeReason.Refresh: - cache.Refresh(key); + cache.Refresh(cache.Keys.ElementAt(randomizer.Int(0, cache.Count - 1))); break; case ChangeReason.Remove: - cache.Remove(key); + cache.Remove(cache.Keys.ElementAt(randomizer.Int(0, cache.Count - 1))); break; case ChangeReason.Update: - var item = new Item() - { - Id = key, - Expiration = GenerateExpiration() - }; - - cache.AddOrUpdate(item, item.Id); + var id = cache.Keys.ElementAt(randomizer.Int(0, cache.Count - 1)); + cache.AddOrUpdate( + item: new StressItem() + { + Id = id, + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null + }, + key: id); break; } } - source.OnNext(cache.CaptureChanges()); + changeSets.Add(cache.CaptureChanges()); } - return maxExpiration; - - DateTimeOffset? GenerateExpiration() - { - if (randomizer.Bool()) - return null; - - var expiration = scheduler.Now + randomizer.TimeSpan(minItemLifetime, maxItemLifetime); - if (expiration > maxExpiration) - maxExpiration = expiration; - - return expiration; + foreach(var changeSet in changeSets) + source.OnNext(changeSet); + } + + private static async Task WaitForCompletionAsync( + ChangeSetAggregator results, + TimeSpan timeout) + { + // Wait up to full minute for the operator to finish processing expirations + // (this is mainly a problem for GitHub PR builds, where test runs take a lot longer, due to more limited resources). + var stopwatch = new Stopwatch(); + stopwatch.Start(); + var pollingInterval = TimeSpan.FromMilliseconds(100); + while (stopwatch.Elapsed < timeout) + { + await Task.Delay(pollingInterval); + + // Identify "completion" as either an error, a completion signal, or all expiring items being removed. + if ((results.Error is not null) + || results.IsCompleted + || results.Data.Items.All(static item => item.Lifetime is null)) + { + break; + } } } } diff --git a/src/DynamicData.Tests/Cache/ExpireAfterFixture.cs b/src/DynamicData.Tests/Cache/ExpireAfterFixture.cs index 36eb30183..f701d6a38 100644 --- a/src/DynamicData.Tests/Cache/ExpireAfterFixture.cs +++ b/src/DynamicData.Tests/Cache/ExpireAfterFixture.cs @@ -15,13 +15,20 @@ private static TestScheduler CreateTestScheduler() return scheduler; } - private static Func CreateTimeSelector(IScheduler scheduler) + private static Func CreateTimeSelector(IScheduler scheduler) => item => item.Expiration - scheduler.Now; - private class Item + private sealed class TestItem { public required int Id { get; init; } public DateTimeOffset? Expiration { get; set; } } + + private sealed record StressItem + { + public required int Id { get; init; } + + public required TimeSpan? Lifetime { get; init; } + } } diff --git a/src/DynamicData.Tests/List/ExpireAfterFixture.cs b/src/DynamicData.Tests/List/ExpireAfterFixture.cs index 18e224f0b..7b8d946df 100644 --- a/src/DynamicData.Tests/List/ExpireAfterFixture.cs +++ b/src/DynamicData.Tests/List/ExpireAfterFixture.cs @@ -9,18 +9,25 @@ using Bogus; using FluentAssertions; using Xunit; +using Xunit.Abstractions; using DynamicData.Tests.Utilities; using System.Collections.Generic; +using System.Diagnostics; namespace DynamicData.Tests.List; public sealed class ExpireAfterFixture { + private readonly ITestOutputHelper _output; + + public ExpireAfterFixture(ITestOutputHelper output) + => _output = output; + [Fact] public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); @@ -31,29 +38,36 @@ public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - source.AddRange(new[] { item1, item2, item3 }); + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item4 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item5 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item6 = new TestItem() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + source.AddRange(new[] { item1, item2, item3, item4, item5, item6 }); scheduler.AdvanceBy(1); - var item4 = new Item() { Id = 4 }; - source.Add(item4); + var item7 = new TestItem() { Id = 7 }; + source.Add(item7); scheduler.AdvanceBy(1); source.Remove(item2); scheduler.AdvanceBy(1); + // item4 and item5 + source.RemoveRange(index: 2, count: 2); + scheduler.AdvanceBy(1); + results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Should().BeEmpty("no items should have expired"); - source.Items.Should().BeEquivalentTo(new[] { item1, item3, item4 }, "3 items were added, and one was removed"); + source.Items.Should().BeEquivalentTo(new[] { item1, item3, item6, item7 }, "7 items were added, and 3 were removed"); scheduler.AdvanceTo(DateTimeOffset.FromUnixTimeMilliseconds(10).Ticks); results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Count().Should().Be(1, "1 expiration should have occurred"); - results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1, item3 }, "items #1 and #3 should have expired"); - source.Items.Should().BeEquivalentTo(new[] { item4 }, "items #1 and #3 should have been removed"); + results.EnumerateRecordedValues().ElementAt(0).Should().BeEquivalentTo(new[] { item1, item3, item6 }, "items #1, #3, and #6 should have expired"); + source.Items.Should().BeEquivalentTo(new[] { item7 }, "items #1 and #3 should have been removed"); results.TryGetRecordedCompletion().Should().BeFalse(); } @@ -61,7 +75,7 @@ public void ItemIsRemovedBeforeExpiration_ExpirationIsCancelled() [Fact] public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); @@ -72,12 +86,12 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.Add(item1); scheduler.AdvanceBy(1); // Extend the expiration to a later time - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; source.Replace(item1, item2); scheduler.AdvanceBy(1); @@ -92,7 +106,7 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() source.Items.Should().BeEquivalentTo(new[] { item2 }, "no changes should have occurred"); // Shorten the expiration to an earlier time - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; source.Replace(item2, item3); scheduler.AdvanceBy(1); @@ -101,7 +115,7 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() source.Items.Should().BeEquivalentTo(new[] { item3 }, "item #2 was replaced"); // One more update with no changes to the expiration - var item4 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; + var item4 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15) }; source.Replace(item3, item4); scheduler.AdvanceBy(1); @@ -125,10 +139,10 @@ public void NextItemToExpireIsReplaced_ExpirationIsRescheduledIfNeeded() results.TryGetRecordedCompletion().Should().BeFalse(); } - [Fact(Skip = "Existing defect, operator emits empty sets of expired items, instead of skipping emission")] + [Fact] public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); @@ -140,37 +154,37 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; - var item4 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; - var item5 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; + var item4 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; + var item5 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; source.AddRange(new[] { item1, item2, item3, item4, item5 }); scheduler.AdvanceBy(1); // Additional expirations at 20ms. - var item6 = new Item() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; - var item7 = new Item() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item6 = new TestItem() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item7 = new TestItem() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; source.AddRange(new[] { item6, item7 }); scheduler.AdvanceBy(1); // Out-of-order expiration - var item8 = new Item() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; + var item8 = new TestItem() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; source.Add(item8); scheduler.AdvanceBy(1); // Non-expiring item - var item9 = new Item() { Id = 9 }; + var item9 = new TestItem() { Id = 9 }; source.Add(item9); scheduler.AdvanceBy(1); // Replacement changing lifetime. - var item10 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; + var item10 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; source.Replace(item4, item10); scheduler.AdvanceBy(1); // Replacement not-affecting lifetime. - var item11 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; + var item11 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(100) }; source.Replace(item5, item11); scheduler.AdvanceBy(1); @@ -179,6 +193,8 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() source.Move(2, 3); scheduler.AdvanceBy(1); + // Not testing Refresh changes, since ISourceList doesn't actually provide an API to generate them. + // Verify initial state, after all emissions results.TryGetRecordedError().Should().BeNull(); @@ -262,10 +278,10 @@ public void PollingIntervalIsGiven_RemovalsAreScheduledAtInterval() results.TryGetRecordedCompletion().Should().BeFalse(); } - [Fact(Skip = "Existing defect, very minor defect, items defined to never expire actually do, at DateTimeOffset.MaxValue")] + [Fact] public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); @@ -276,41 +292,41 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; - var item2 = new Item() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; - var item3 = new Item() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; - var item4 = new Item() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; - var item5 = new Item() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item2 = new TestItem() { Id = 2, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20) }; + var item3 = new TestItem() { Id = 3, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(30) }; + var item4 = new TestItem() { Id = 4, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(40) }; + var item5 = new TestItem() { Id = 5, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; source.AddRange(new[] { item1, item2, item3, item4, item5 }); scheduler.AdvanceBy(1); // Additional expirations at 20ms. - var item6 = new Item() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; - var item7 = new Item() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item6 = new TestItem() { Id = 6, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; + var item7 = new TestItem() { Id = 7, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(20)}; source.AddRange(new[] { item6, item7 }); scheduler.AdvanceBy(1); // Out-of-order expiration - var item8 = new Item() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; + var item8 = new TestItem() { Id = 8, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(15)}; source.Add(item8); scheduler.AdvanceBy(1); // Non-expiring item - var item9 = new Item() { Id = 9 }; + var item9 = new TestItem() { Id = 9 }; source.Add(item9); scheduler.AdvanceBy(1); // Replacement changing lifetime. - var item10 = new Item() { Id = 10, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; + var item10 = new TestItem() { Id = 10, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(45) }; source.Replace(item4, item10); scheduler.AdvanceBy(1); // Replacement not-affecting lifetime. - var item11 = new Item() { Id = 11, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; + var item11 = new TestItem() { Id = 11, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(50) }; source.Replace(item5, item11); scheduler.AdvanceBy(1); - // Move should not affect scheduled expiration. + // Moved items should still expire correctly, but its expiration time should not change. item3.Expiration = DateTimeOffset.FromUnixTimeMilliseconds(55); source.Move(2, 3); scheduler.AdvanceBy(1); @@ -379,10 +395,10 @@ public void PollingIntervalIsNotGiven_RemovalsAreScheduledImmediately() } // Covers https://github.com/reactivemarbles/DynamicData/issues/716 - [Fact(Skip = "Existing defect, removals are skipped when scheduler invokes early")] + [Fact] public void SchedulerIsInaccurate_RemovalsAreNotSkipped() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = new FakeScheduler() { @@ -396,7 +412,7 @@ public void SchedulerIsInaccurate_RemovalsAreNotSkipped() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.Add(item1); @@ -404,15 +420,7 @@ public void SchedulerIsInaccurate_RemovalsAreNotSkipped() results.EnumerateRecordedValues().Should().BeEmpty("no expirations should have occurred"); source.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); - // Simulate the scheduler invoking all actions 1ms early. - while(scheduler.ScheduledActions.Count is not 0) - { - if (scheduler.ScheduledActions[0].DueTime is DateTimeOffset dueTime) - scheduler.Now = dueTime - TimeSpan.FromMilliseconds(1); - - scheduler.ScheduledActions[0].Invoke(); - scheduler.ScheduledActions.RemoveAt(0); - } + scheduler.SimulateUntilIdle(inaccuracyOffset: TimeSpan.FromMilliseconds(-1)); results.TryGetRecordedError().Should().BeNull(); results.EnumerateRecordedValues().Count().Should().Be(1, "1 expiration should have occurred"); @@ -422,10 +430,10 @@ public void SchedulerIsInaccurate_RemovalsAreNotSkipped() results.TryGetRecordedCompletion().Should().BeFalse(); } - [Fact(Skip = "Existing defect, completion is not propagated from the source")] + [Fact] public void SourceCompletes_CompletionIsPropagated() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); @@ -436,7 +444,7 @@ public void SourceCompletes_CompletionIsPropagated() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - source.Add(new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }); + source.Add(new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }); scheduler.AdvanceBy(1); source.Complete(); @@ -451,14 +459,14 @@ public void SourceCompletes_CompletionIsPropagated() results.EnumerateInvalidNotifications().Should().BeEmpty(); } - [Fact(Skip = "Existing defect, completion is not propagated from the source")] + [Fact] public void SourceCompletesImmediately_CompletionIsPropagated() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.Add(item1); scheduler.AdvanceBy(1); @@ -479,10 +487,10 @@ public void SourceCompletesImmediately_CompletionIsPropagated() results.EnumerateInvalidNotifications().Should().BeEmpty(); } - [Fact(Skip = "Exsiting defect, errors are re-thrown instead of propagated, operator does not use safe subscriptions")] + [Fact] public void SourceErrors_ErrorIsPropagated() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); @@ -493,7 +501,7 @@ public void SourceErrors_ErrorIsPropagated() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - source.Add(new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }); + source.Add(new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }); scheduler.AdvanceBy(1); var error = new Exception("This is a test"); @@ -509,14 +517,14 @@ public void SourceErrors_ErrorIsPropagated() results.EnumerateInvalidNotifications().Should().BeEmpty(); } - [Fact(Skip = "Existing defect, immediately-occuring error is not propagated")] + [Fact] public void SourceErrorsImmediately_ErrorIsPropagated() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); - var item1 = new Item() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; + var item1 = new TestItem() { Id = 1, Expiration = DateTimeOffset.FromUnixTimeMilliseconds(10) }; source.Add(item1); scheduler.AdvanceBy(1); @@ -544,85 +552,87 @@ public void SourceErrorsImmediately_ErrorIsPropagated() [Fact] public void SourceIsNull_ThrowsException() => FluentActions.Invoking(() => ObservableListEx.ExpireAfter( - source: (null as ISourceList)!, + source: (null as ISourceList)!, timeSelector: static _ => default, pollingInterval: null)) .Should().Throw(); - [Fact(Skip = "Existing defect, operator does not properly handle items with a null timeout, when using a real scheduler, it passes a TimeSpan to the scheduler that is outside of the supported range")] + [Fact] public async Task ThreadPoolSchedulerIsUsedWithoutPolling_ExpirationIsThreadSafe() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = ThreadPoolScheduler.Instance; using var subscription = source .ExpireAfter( - timeSelector: CreateTimeSelector(scheduler), + timeSelector: static item => item.Lifetime, scheduler: scheduler) .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var maxExpiration = PerformStressEdits( + PerformStressEdits( source: source, - scheduler: scheduler, - stressCount: 10_000, - minItemLifetime: TimeSpan.FromMilliseconds(10), - maxItemLifetime: TimeSpan.FromMilliseconds(50), + editCount: 10_000, + minItemLifetime: TimeSpan.FromMilliseconds(2), + maxItemLifetime: TimeSpan.FromMilliseconds(10), maxChangeCount: 10, - maxRangeSize: 10); + maxRangeSize: 50); - await Observable.Timer(maxExpiration + TimeSpan.FromMilliseconds(100), scheduler); + await WaitForCompletionAsync(source, results, TimeSpan.FromMinutes(1)); results.TryGetRecordedError().Should().BeNull(); - results.EnumerateRecordedValues().SelectMany(static removals => removals).Should().AllSatisfy(static item => item.Expiration.Should().NotBeNull("only items with an expiration should have expired")); + results.EnumerateRecordedValues().SelectMany(static removals => removals).Should().AllSatisfy(static item => item.Lifetime.Should().NotBeNull("only items with an expiration should have expired")); results.TryGetRecordedCompletion().Should().BeFalse(); - source.Items.Should().AllSatisfy(item => item.Expiration.Should().BeNull("all items with an expiration should have expired")); + source.Items.Should().AllSatisfy(item => item.Lifetime.Should().BeNull("all items with an expiration should have expired")); + + _output.WriteLine($"{results.EnumerateRecordedValues().Count()} Expirations occurred, for {results.EnumerateRecordedValues().SelectMany(static item => item).Count()} items"); } - [Fact(Skip = "Existing defect, deadlocks")] + [Fact] public async Task ThreadPoolSchedulerIsUsedWithPolling_ExpirationIsThreadSafe() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = ThreadPoolScheduler.Instance; using var subscription = source .ExpireAfter( - timeSelector: CreateTimeSelector(scheduler), + timeSelector: static item => item.Lifetime, pollingInterval: TimeSpan.FromMilliseconds(10), scheduler: scheduler) .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - var maxExpiration = PerformStressEdits( + PerformStressEdits( source: source, - scheduler: scheduler, - stressCount: 10_000, - minItemLifetime: TimeSpan.FromMilliseconds(10), - maxItemLifetime: TimeSpan.FromMilliseconds(50), + editCount: 10_000, + minItemLifetime: TimeSpan.FromMilliseconds(2), + maxItemLifetime: TimeSpan.FromMilliseconds(10), maxChangeCount: 10, - maxRangeSize: 10); + maxRangeSize: 50); - await Observable.Timer(maxExpiration + TimeSpan.FromMilliseconds(100), scheduler); + await WaitForCompletionAsync(source, results, TimeSpan.FromMinutes(1)); results.TryGetRecordedError().Should().BeNull(); - results.EnumerateRecordedValues().SelectMany(static removals => removals).Should().AllSatisfy(item => item.Expiration.Should().NotBeNull("only items with an expiration should have expired")); + results.EnumerateRecordedValues().SelectMany(static removals => removals).Should().AllSatisfy(item => item.Lifetime.Should().NotBeNull("only items with an expiration should have expired")); results.TryGetRecordedCompletion().Should().BeFalse(); - source.Items.Should().AllSatisfy(item => item.Expiration.Should().BeNull("all items with an expiration should have expired")); + source.Items.Should().AllSatisfy(item => item.Lifetime.Should().BeNull("all items with an expiration should have expired")); + + _output.WriteLine($"{results.EnumerateRecordedValues().Count()} Expirations occurred, for {results.EnumerateRecordedValues().SelectMany(static item => item).Count()} items"); } [Fact] public void TimeSelectorIsNull_ThrowsException() - => FluentActions.Invoking(() => new TestSourceList().ExpireAfter( + => FluentActions.Invoking(() => new TestSourceList().ExpireAfter( timeSelector: null!, pollingInterval: null)) .Should().Throw(); - [Fact(Skip = "Exsiting defect, errors are re-thrown instead of propagated, user code is not protected")] + [Fact] public void TimeSelectorThrows_ThrowsException() { - using var source = new TestSourceList(); + using var source = new TestSourceList(); var scheduler = CreateTestScheduler(); @@ -635,7 +645,7 @@ public void TimeSelectorThrows_ThrowsException() .ValidateSynchronization() .RecordNotifications(out var results, scheduler); - source.Add(new Item() { Id = 1 }); + source.Add(new TestItem() { Id = 1 }); scheduler.AdvanceBy(1); results.TryGetRecordedError().Should().Be(error); @@ -653,87 +663,120 @@ private static TestScheduler CreateTestScheduler() return scheduler; } - private static Func CreateTimeSelector(IScheduler scheduler) + private static Func CreateTimeSelector(IScheduler scheduler) => item => item.Expiration - scheduler.Now; - private static DateTimeOffset PerformStressEdits( - ISourceList source, - IScheduler scheduler, - int stressCount, + private static void PerformStressEdits( + ISourceList source, + int editCount, TimeSpan minItemLifetime, TimeSpan maxItemLifetime, int maxChangeCount, int maxRangeSize) { - var nextItemId = 1; + // Not exercising Refresh, since SourceList<> doesn't support it. + var changeReasons = new[] + { + ListChangeReason.Add, + ListChangeReason.AddRange, + ListChangeReason.Clear, + ListChangeReason.Moved, + ListChangeReason.Remove, + ListChangeReason.RemoveRange, + ListChangeReason.Replace + }; + + // Weights are chosen to make the list size likely to grow over time, + // exerting more pressure on the system the longer the benchmark runs, + // while still ensuring that at least a few clears are executed. + // Also, to prevent bogus operations (E.G. you can't remove an item from an empty list). + var changeReasonWeightsWhenCountIs0 = new[] + { + 0.5f, // Add + 0.5f, // AddRange + 0.0f, // Clear + 0.0f, // Moved + 0.0f, // Remove + 0.0f, // RemoveRange + 0.0f // Replace + }; + + var changeReasonWeightsWhenCountIs1 = new[] + { + 0.250f, // Add + 0.250f, // AddRange + 0.001f, // Clear + 0.000f, // Moved + 0.150f, // Remove + 0.150f, // RemoveRange + 0.199f // Replace + }; + + var changeReasonWeightsOtherwise = new[] + { + 0.200f, // Add + 0.200f, // AddRange + 0.001f, // Clear + 0.149f, // Moved + 0.150f, // Remove + 0.150f, // RemoveRange + 0.150f // Replace + }; + var randomizer = new Randomizer(1234567); - var maxExpiration = DateTimeOffset.MinValue; - for (var i = 0; i < stressCount; ++i) - source.Edit(mutator => + var items = Enumerable.Range(1, editCount * maxChangeCount * maxRangeSize) + .Select(id => new StressItem() { - var changeCount = randomizer.Int(1, maxChangeCount); + Id = id, + Lifetime = randomizer.Bool() + ? TimeSpan.FromTicks(randomizer.Long(minItemLifetime.Ticks, maxItemLifetime.Ticks)) + : null + }) + .ToArray(); + var nextItemIndex = 0; + + for (var i = 0; i < editCount; ++i) + { + source.Edit(updater => + { + var changeCount = randomizer.Int(1, maxChangeCount); for (var i = 0; i < changeCount; ++i) { - var changeReason = mutator.Count switch + var changeReason = randomizer.WeightedRandom(changeReasons, updater.Count switch { - 0 => randomizer.Enum(exclude: new[] - { - ListChangeReason.Replace, - ListChangeReason.Remove, - ListChangeReason.RemoveRange, - ListChangeReason.Refresh, - ListChangeReason.Moved, - ListChangeReason.Clear - }), - 1 => randomizer.Enum(exclude: new[] - { - ListChangeReason.Refresh, - ListChangeReason.Moved - }), - _ => randomizer.Enum(exclude: ListChangeReason.Refresh) - }; + 0 => changeReasonWeightsWhenCountIs0, + 1 => changeReasonWeightsWhenCountIs1, + _ => changeReasonWeightsOtherwise + }); switch (changeReason) { case ListChangeReason.Add: - mutator.Add(new Item() - { - Id = nextItemId++, - Expiration = GenerateExpiration() - }); + updater.Add(items[nextItemIndex++]); break; case ListChangeReason.AddRange: - mutator.AddRange(Enumerable + updater.AddRange(Enumerable .Range(0, randomizer.Int(1, maxRangeSize)) - .Select(_ => new Item() - { - Id = nextItemId++, - Expiration = GenerateExpiration() - }) - .ToArray()); + .Select(_ => items[nextItemIndex++])); break; case ListChangeReason.Replace: - mutator.Replace( - original: randomizer.ListItem(mutator), - replaceWith: new Item() - { - Id = nextItemId++, - Expiration = GenerateExpiration() - }); + updater.Replace( + original: randomizer.ListItem(updater), + replaceWith: items[nextItemIndex++]); break; case ListChangeReason.Remove: - mutator.RemoveAt(randomizer.Int(0, mutator.Count - 1)); + updater.RemoveAt(randomizer.Int(0, updater.Count - 1)); break; case ListChangeReason.RemoveRange: - var removeCount = randomizer.Int(1, Math.Min(maxRangeSize, mutator.Count)); - mutator.RemoveRange( - index: randomizer.Int(0, mutator.Count - removeCount), + var removeCount = randomizer.Int(1, Math.Min(maxRangeSize, updater.Count)); + updater.RemoveRange( + index: randomizer.Int(0, updater.Count - removeCount), count: removeCount); break; @@ -743,39 +786,57 @@ private static DateTimeOffset PerformStressEdits( do { - originalIndex = randomizer.Int(0, mutator.Count - 1); - destinationIndex = randomizer.Int(0, mutator.Count - 1); - } while (originalIndex != destinationIndex); + originalIndex = randomizer.Int(0, updater.Count - 1); + destinationIndex = randomizer.Int(0, updater.Count - 1); + } while (originalIndex == destinationIndex); - mutator.Move(originalIndex, destinationIndex); + updater.Move(originalIndex, destinationIndex); break; case ListChangeReason.Clear: - mutator.Clear(); + updater.Clear(); break; } } }); + } + } - return maxExpiration; - - DateTimeOffset? GenerateExpiration() + private static async Task WaitForCompletionAsync( + ISourceList source, + TestableObserver> results, + TimeSpan timeout) + { + // Wait up to full minute for the operator to finish processing expirations + // (this is mainly a problem for GitHub PR builds, where test runs take a lot longer, due to more limited resources). + var stopwatch = new Stopwatch(); + stopwatch.Start(); + var pollingInterval = TimeSpan.FromMilliseconds(100); + while (stopwatch.Elapsed < timeout) { - if (randomizer.Bool()) - return null; - - var expiration = scheduler.Now + randomizer.TimeSpan(minItemLifetime, maxItemLifetime); - if (expiration > maxExpiration) - maxExpiration = expiration; + await Task.Delay(pollingInterval); - return expiration; + // Identify "completion" as either an error, a completion signal, or all expiring items being removed. + if ((results.TryGetRecordedError() is not null) + || results.TryGetRecordedCompletion() + || source.Items.All(static item => item.Lifetime is null)) + { + break; + } } } - private class Item + private sealed class TestItem { public required int Id { get; init; } public DateTimeOffset? Expiration { get; set; } } + + private sealed record StressItem + { + public required int Id { get; init; } + + public required TimeSpan? Lifetime { get; init; } + } } diff --git a/src/DynamicData.Tests/Utilities/FakeScheduler.cs b/src/DynamicData.Tests/Utilities/FakeScheduler.cs index cb0a0170b..fc2b19bbf 100644 --- a/src/DynamicData.Tests/Utilities/FakeScheduler.cs +++ b/src/DynamicData.Tests/Utilities/FakeScheduler.cs @@ -50,6 +50,33 @@ public IDisposable Schedule( dueTime: dueTime, action: action); + // Simulate the scheduler invoking actions, allowing for each action to schedule followup actions, until none remain. + public void SimulateUntilIdle(TimeSpan inaccuracyOffset = default) + { + while(ScheduledActions.Count is not 0) + { + // If the action doesn't have a DueTime, invoke it immediately + if (ScheduledActions[0].DueTime is not DateTimeOffset dueTime) + { + ScheduledActions[0].Invoke(); + ScheduledActions.RemoveAt(0); + } + // If the action has a DueTime, invoke it when that time is reached, including inaccuracy, if given. + // Inaccuracy is simulated by comparing each dueTime against an "effective clock" offset by the desired inaccuracy + // E.G. if the given inaccuracy offset is -1ms, we want to simulate the scheduler invoking actions 1ms early. + // For a dueTime of 10ms, that means we'd want to invoke when the clock is 9ms, so we need to the "effective clock" to be 10ms, + // and need to subtract the -1ms to get there. + else if (dueTime <= (Now - inaccuracyOffset)) + { + ScheduledActions[0].Invoke(); + ScheduledActions.RemoveAt(0); + } + + // Advance time by at least one tick after every action, to eliminate infinite-looping + Now += TimeSpan.FromTicks(1); + } + } + private IDisposable ScheduleCore( TState state, DateTimeOffset? dueTime, diff --git a/src/DynamicData.Tests/Utilities/TestSourceCache.cs b/src/DynamicData.Tests/Utilities/TestSourceCache.cs index 605ac3b74..f08be1598 100644 --- a/src/DynamicData.Tests/Utilities/TestSourceCache.cs +++ b/src/DynamicData.Tests/Utilities/TestSourceCache.cs @@ -58,9 +58,9 @@ public IObservable> Connect( public void Dispose() { + _source.Dispose(); _error.Dispose(); _hasCompleted.Dispose(); - _source.Dispose(); } public void Edit(Action> updateAction) diff --git a/src/DynamicData.Tests/Utilities/TestSourceList.cs b/src/DynamicData.Tests/Utilities/TestSourceList.cs index fbf6bb24f..e708c7239 100644 --- a/src/DynamicData.Tests/Utilities/TestSourceList.cs +++ b/src/DynamicData.Tests/Utilities/TestSourceList.cs @@ -44,9 +44,9 @@ public void Complete() public void Dispose() { + _source.Dispose(); _error.Dispose(); _hasCompleted.Dispose(); - _source.Dispose(); } public void Edit(Action> updateAction) diff --git a/src/DynamicData/Cache/Internal/ExpireAfter.ForSource.cs b/src/DynamicData/Cache/Internal/ExpireAfter.ForSource.cs new file mode 100644 index 000000000..bf8006e83 --- /dev/null +++ b/src/DynamicData/Cache/Internal/ExpireAfter.ForSource.cs @@ -0,0 +1,412 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; + +using DynamicData.Internal; + +namespace DynamicData.Cache.Internal; + +internal static partial class ExpireAfter +{ + public static class ForSource + where TObject : notnull + where TKey : notnull + { + public static IObservable>> Create( + ISourceCache source, + Func timeSelector, + TimeSpan? pollingInterval = null, + IScheduler? scheduler = null) + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + timeSelector.ThrowArgumentNullExceptionIfNull(nameof(timeSelector)); + + return Observable.Create>>(observer => (pollingInterval is TimeSpan pollingIntervalValue) + ? new PollingSubscription( + observer: observer, + pollingInterval: pollingIntervalValue, + scheduler: scheduler, + source: source, + timeSelector: timeSelector) + : new OnDemandSubscription( + observer: observer, + scheduler: scheduler, + source: source, + timeSelector: timeSelector)); + } + + private abstract class SubscriptionBase + : IDisposable + { + private readonly Dictionary _expirationDueTimesByKey; + private readonly IObserver>> _observer; + private readonly Action> _onEditingSource; + private readonly List _proposedExpirationsQueue; + private readonly List> _removedItemsBuffer; + private readonly IScheduler _scheduler; + private readonly ISourceCache _source; + private readonly IDisposable _sourceSubscription; + private readonly Func _timeSelector; + + private bool _hasSourceCompleted; + private ScheduledManagement? _nextScheduledManagement; + + protected SubscriptionBase( + IObserver>> observer, + IScheduler? scheduler, + ISourceCache source, + Func timeSelector) + { + _observer = observer; + _source = source; + _timeSelector = timeSelector; + + _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; + + _onEditingSource = OnEditingSource; + + _expirationDueTimesByKey = new(); + _proposedExpirationsQueue = new(); + _removedItemsBuffer = new(); + + _sourceSubscription = source + .Connect() + // It's important to set this flag outside the context of a lock, because it'll be read outside of lock as well. + .Finally(() => _hasSourceCompleted = true) + .Synchronize(SynchronizationGate) + .SubscribeSafe( + onNext: OnSourceNext, + onError: OnSourceError, + onCompleted: OnSourceCompleted); + } + + public void Dispose() + { + lock (SynchronizationGate) + { + _sourceSubscription.Dispose(); + + TryCancelNextScheduledManagement(); + } + } + + protected IScheduler Scheduler + => _scheduler; + + // Instead of using a dedicated _synchronizationGate object, we can save an allocation by using any object that is never exposed to public consumers. + protected object SynchronizationGate + => _expirationDueTimesByKey; + + protected abstract DateTimeOffset? GetNextManagementDueTime(); + + protected DateTimeOffset? GetNextProposedExpirationDueTime() + => _proposedExpirationsQueue.Count is 0 + ? null + : _proposedExpirationsQueue[0].DueTime; + + protected abstract void OnExpirationsManaged(DateTimeOffset dueTime); + + private void ClearExpiration(TKey key) + // This is what puts the "proposed" in _proposedExpirationsQueue. + // Finding the position of the item to remove from the queue would be O(log n), at best, + // so just leave it and flush it later during normal processing of the queue. + => _expirationDueTimesByKey.Remove(key); + + private void ManageExpirations() + { + // This check is needed, to make sure we don't try and call .Edit() on a disposed _source, + // since the scheduler only promises "best effort" to cancel a scheduled action. + // It's safe to skip locking here becuase once this flag is set, it's never unset. + if (_hasSourceCompleted) + return; + + // Putting the entire management process here inside a .Edit() call for a couple of reasons: + // - It keeps the edit delegate from becoming a closure, so we can use a reusable cached delegate. + // - It batches multiple expirations occurring at the same time into one source operation, so it only emits one changeset. + // - It eliminates the possibility of _itemStatesByKey and other internal state becoming out-of-sync with _source, by effectively locking _source. + // - It eliminates a rare deadlock that I honestly can't fully explain, but was able to reproduce reliably with few hundred iterations of the ThreadPoolSchedulerIsUsedWithoutPolling_ExpirationIsThreadSafe test. + _source.Edit(_onEditingSource); + } + + private void OnEditingSource(ISourceUpdater updater) + { + lock (SynchronizationGate) + { + // The scheduler only promises "best effort" to cancel scheduled operations, so we need to make sure. + if (_nextScheduledManagement is not ScheduledManagement thisScheduledManagement) + return; + + _nextScheduledManagement = null; + + var now = Scheduler.Now; + + // Buffer removals, so we can optimize the allocation for the final changeset, or skip it entirely. + // Also, so we can optimize removal from the queue as a range removal. + var proposedExpirationIndex = 0; + for (; proposedExpirationIndex < _proposedExpirationsQueue.Count; ++proposedExpirationIndex) + { + var proposedExpiration = _proposedExpirationsQueue[proposedExpirationIndex]; + if (proposedExpiration.DueTime > now) + { + break; + } + + // The state of _expirationQueue is allowed to go out-of-sync with _expirationDueTimesByKey, + // so make sure the item still needs to be removed, before removing it. + if (_expirationDueTimesByKey.TryGetValue(proposedExpiration.Key, out var expirationDueTime) && (expirationDueTime <= now)) + { + _expirationDueTimesByKey.Remove(proposedExpiration.Key); + + _removedItemsBuffer.Add(new( + key: proposedExpiration.Key, + value: updater.Lookup(proposedExpiration.Key).Value)); + + updater.RemoveKey(proposedExpiration.Key); + } + } + _proposedExpirationsQueue.RemoveRange(0, proposedExpirationIndex); + + // We can end up with no expiring items here because the scheduler only promises "best effort" to cancel scheduled operations, + // or because of a race condition with the source. + if (_removedItemsBuffer.Count is not 0) + { + _observer.OnNext(_removedItemsBuffer.ToArray()); + + _removedItemsBuffer.Clear(); + } + + OnExpirationsManaged(thisScheduledManagement.DueTime); + + // We just changed the expirations queue, so run cleanup and management scheduling. + OnExpirationsChanged(); + } + } + + private void OnExpirationsChanged() + { + // Clear out any expirations at the front of the queue that are no longer valid. + var removeToIndex = _proposedExpirationsQueue.FindIndex(expiration => _expirationDueTimesByKey.ContainsKey(expiration.Key)); + if (removeToIndex > 0) + _proposedExpirationsQueue.RemoveRange(0, removeToIndex); + + // Check if we need to re-schedule the next management operation + if (GetNextManagementDueTime() is DateTimeOffset nextManagementDueTime) + { + if (_nextScheduledManagement?.DueTime != nextManagementDueTime) + { + if (_nextScheduledManagement is ScheduledManagement nextScheduledManagement) + nextScheduledManagement.Cancellation.Dispose(); + + _nextScheduledManagement = new() + { + Cancellation = _scheduler.Schedule( + state: this, + dueTime: nextManagementDueTime, + action: static (_, @this) => + { + @this.ManageExpirations(); + + return Disposable.Empty; + }), + DueTime = nextManagementDueTime + }; + } + } + else + { + TryCancelNextScheduledManagement(); + } + } + + private void OnSourceCompleted() + { + // If the source completes, we can no longer remove items from it, so any pending expirations are moot. + TryCancelNextScheduledManagement(); + + _observer.OnCompleted(); + } + + private void OnSourceError(Exception error) + { + TryCancelNextScheduledManagement(); + + _observer.OnError(error); + } + + private void OnSourceNext(IChangeSet changes) + { + try + { + var now = _scheduler.Now; + + var haveExpirationsChanged = false; + + foreach (var change in changes.ToConcreteType()) + { + switch (change.Reason) + { + case ChangeReason.Add: + { + if (_timeSelector.Invoke(change.Current) is TimeSpan expireAfter) + { + haveExpirationsChanged |= TrySetExpiration( + key: change.Key, + dueTime: now + expireAfter); + } + } + break; + + case ChangeReason.Remove: + ClearExpiration(change.Key); + haveExpirationsChanged = true; + break; + + case ChangeReason.Update: + { + if (_timeSelector.Invoke(change.Current) is TimeSpan expireAfter) + { + haveExpirationsChanged = TrySetExpiration( + key: change.Key, + dueTime: now + expireAfter); + } + else + { + ClearExpiration(change.Key); + haveExpirationsChanged = true; + } + } + break; + } + } + + if (haveExpirationsChanged) + OnExpirationsChanged(); + } + catch (Exception error) + { + TryCancelNextScheduledManagement(); + + _observer.OnError(error); + } + } + + private void TryCancelNextScheduledManagement() + { + _nextScheduledManagement?.Cancellation.Dispose(); + _nextScheduledManagement = null; + } + + private bool TrySetExpiration( + TKey key, + DateTimeOffset dueTime) + { + var oldDueTime = _expirationDueTimesByKey.TryGetValue(key, out var existingDueTime) + ? existingDueTime + : null as DateTimeOffset?; + + // Always update the item state, cause even if ExpireAt doesn't change, the item itself might have. + _expirationDueTimesByKey[key] = dueTime; + + if (dueTime == oldDueTime) + return false; + + var insertionIndex = _proposedExpirationsQueue.BinarySearch(dueTime, static (dueTime, expiration) => dueTime.CompareTo(expiration.DueTime)); + if (insertionIndex < 0) + insertionIndex = ~insertionIndex; + + _proposedExpirationsQueue.Insert( + index: insertionIndex, + item: new() + { + DueTime = dueTime, + Key = key + }); + + // Intentionally not removing the old expiration for this item, if applicable, see ClearExpiration() + + return true; + } + + private readonly struct ProposedExpiration + { + public required DateTimeOffset DueTime { get; init; } + + public required TKey Key { get; init; } + } + + private readonly struct ScheduledManagement + { + public required IDisposable Cancellation { get; init; } + + public required DateTimeOffset DueTime { get; init; } + } + } + + private sealed class OnDemandSubscription + : SubscriptionBase + { + public OnDemandSubscription( + IObserver>> observer, + IScheduler? scheduler, + ISourceCache source, + Func timeSelector) + : base( + observer, + scheduler, + source, + timeSelector) + { + } + + protected override DateTimeOffset? GetNextManagementDueTime() + => GetNextProposedExpirationDueTime(); + + protected override void OnExpirationsManaged(DateTimeOffset dueTime) + { + } + } + + private sealed class PollingSubscription + : SubscriptionBase + { + private readonly TimeSpan _pollingInterval; + + private DateTimeOffset _lastManagementDueTime; + + public PollingSubscription( + IObserver>> observer, + TimeSpan pollingInterval, + IScheduler? scheduler, + ISourceCache source, + Func timeSelector) + : base( + observer, + scheduler, + source, + timeSelector) + { + _pollingInterval = pollingInterval; + + _lastManagementDueTime = Scheduler.Now; + } + + protected override DateTimeOffset? GetNextManagementDueTime() + { + var now = Scheduler.Now; + var nextDueTime = _lastManagementDueTime + _pollingInterval; + + // Throttle down the polling frequency if polls are taking longer than the ideal interval. + return (nextDueTime > now) + ? nextDueTime + : now; + } + + protected override void OnExpirationsManaged(DateTimeOffset dueTime) + => _lastManagementDueTime = dueTime; + } + } +} diff --git a/src/DynamicData/Cache/Internal/ExpireAfter.ForStream.cs b/src/DynamicData/Cache/Internal/ExpireAfter.ForStream.cs new file mode 100644 index 000000000..9d5b42ce2 --- /dev/null +++ b/src/DynamicData/Cache/Internal/ExpireAfter.ForStream.cs @@ -0,0 +1,408 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; + +using DynamicData.Internal; + +namespace DynamicData.Cache.Internal; + +internal static partial class ExpireAfter +{ + public static class ForStream + where TObject : notnull + where TKey : notnull + { + public static IObservable> Create( + IObservable> source, + Func timeSelector, + TimeSpan? pollingInterval = null, + IScheduler? scheduler = null) + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + timeSelector.ThrowArgumentNullExceptionIfNull(nameof(timeSelector)); + + return Observable.Create>(observer => (pollingInterval is TimeSpan pollingIntervalValue) + ? new PollingSubscription( + observer: observer, + pollingInterval: pollingIntervalValue, + scheduler: scheduler, + source: source, + timeSelector: timeSelector) + : new OnDemandSubscription( + observer: observer, + scheduler: scheduler, + source: source, + timeSelector: timeSelector)); + } + + private abstract class SubscriptionBase + : IDisposable + { + private readonly Dictionary _expirationDueTimesByKey; + private readonly ChangeAwareCache _itemsCache; + private readonly IObserver> _observer; + private readonly List _proposedExpirationsQueue; + private readonly IScheduler _scheduler; + private readonly IDisposable _sourceSubscription; + private readonly Func _timeSelector; + + private bool _hasSourceCompleted; + private ScheduledManagement? _nextScheduledManagement; + + protected SubscriptionBase( + IObserver> observer, + IScheduler? scheduler, + IObservable> source, + Func timeSelector) + { + _observer = observer; + _timeSelector = timeSelector; + + _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; + + _expirationDueTimesByKey = new(); + _itemsCache = new(); + _proposedExpirationsQueue = new(); + + _sourceSubscription = source + .Synchronize(SynchronizationGate) + .SubscribeSafe( + onNext: OnSourceNext, + onError: OnSourceError, + onCompleted: OnSourceCompleted); + } + + public void Dispose() + { + lock (SynchronizationGate) + { + _sourceSubscription.Dispose(); + + TryCancelNextScheduledManagement(); + } + } + + protected IScheduler Scheduler + => _scheduler; + + // Instead of using a dedicated _synchronizationGate object, we can save an allocation by using any object that is never exposed to public consumers. + protected object SynchronizationGate + => _expirationDueTimesByKey; + + protected abstract DateTimeOffset? GetNextManagementDueTime(); + + protected DateTimeOffset? GetNextProposedExpirationDueTime() + => _proposedExpirationsQueue.Count is 0 + ? null + : _proposedExpirationsQueue[0].DueTime; + + protected abstract void OnExpirationsManaged(DateTimeOffset dueTime); + + private void ClearExpiration(TKey key) + // This is what puts the "proposed" in _proposedExpirationsQueue. + // Finding the position of the item to remove from the queue would be O(log n), at best, + // so just leave it and flush it later during normal processing of the queue. + => _expirationDueTimesByKey.Remove(key); + + private void ManageExpirations() + { + lock (SynchronizationGate) + { + // The scheduler only promises "best effort" to cancel scheduled operations, so we need to make sure. + if (_nextScheduledManagement is not ScheduledManagement thisScheduledManagement) + return; + + _nextScheduledManagement = null; + + var now = Scheduler.Now; + + // Buffer removals, so we can optimize the allocation for the final changeset, or skip it entirely. + // Also, so we can optimize removal from the queue as a range removal. + var proposedExpirationIndex = 0; + for (; proposedExpirationIndex < _proposedExpirationsQueue.Count; ++proposedExpirationIndex) + { + var proposedExpiration = _proposedExpirationsQueue[proposedExpirationIndex]; + if (proposedExpiration.DueTime > now) + { + break; + } + + // The state of _expirationQueue is allowed to go out-of-sync with _itemStatesByKey, + // so make sure the item still needs to be removed, before removing it. + if (_expirationDueTimesByKey.TryGetValue(proposedExpiration.Key, out var expirationDueTime) && (expirationDueTime <= now)) + { + _expirationDueTimesByKey.Remove(proposedExpiration.Key); + + _itemsCache.Remove(proposedExpiration.Key); + } + } + _proposedExpirationsQueue.RemoveRange(0, proposedExpirationIndex); + + // The scheduler only promises "best effort" to cancel scheduled operations, so we can end up with no items being expired. + var downstreamChanges = _itemsCache.CaptureChanges(); + if (downstreamChanges.Count is not 0) + _observer.OnNext(downstreamChanges); + + OnExpirationsManaged(thisScheduledManagement.DueTime); + + // We just changed the expirations queue, so run cleanup and management scheduling. + OnExpirationsChanged(); + } + } + + private void OnExpirationsChanged() + { + // Clear out any expirations at the front of the queue that are no longer valid. + var removeToIndex = _proposedExpirationsQueue.FindIndex(expiration => _expirationDueTimesByKey.ContainsKey(expiration.Key)); + if (removeToIndex > 0) + _proposedExpirationsQueue.RemoveRange(0, removeToIndex); + + // If we're out of items to expire, and the source has completed, we'll never have any further changes to publish. + if ((_expirationDueTimesByKey.Count is 0) && _hasSourceCompleted) + { + TryCancelNextScheduledManagement(); + + _observer.OnCompleted(); + + return; + } + + // Check if we need to re-schedule the next management operation + if (GetNextManagementDueTime() is DateTimeOffset nextManagementDueTime) + { + if (_nextScheduledManagement?.DueTime != nextManagementDueTime) + { + if (_nextScheduledManagement is ScheduledManagement nextScheduledManagement) + nextScheduledManagement.Cancellation.Dispose(); + + _nextScheduledManagement = new() + { + Cancellation = _scheduler.Schedule( + state: this, + dueTime: nextManagementDueTime, + action: (_, @this) => + { + @this.ManageExpirations(); + + return Disposable.Empty; + }), + DueTime = nextManagementDueTime + }; + } + } + else + { + TryCancelNextScheduledManagement(); + } + } + + private void OnSourceCompleted() + { + _hasSourceCompleted = true; + + // Postpone downstream completion if there are any expirations pending. + if (_expirationDueTimesByKey.Count is 0) + { + TryCancelNextScheduledManagement(); + + _observer.OnCompleted(); + } + } + + private void OnSourceError(Exception error) + { + TryCancelNextScheduledManagement(); + + _observer.OnError(error); + } + + private void OnSourceNext(IChangeSet upstreamChanges) + { + try + { + var now = _scheduler.Now; + + var haveExpirationsChanged = false; + + foreach (var change in upstreamChanges.ToConcreteType()) + { + switch (change.Reason) + { + case ChangeReason.Add: + { + if (_timeSelector.Invoke(change.Current) is TimeSpan expireAfter) + { + haveExpirationsChanged |= TrySetExpiration( + key: change.Key, + dueTime: now + expireAfter); + } + _itemsCache.AddOrUpdate(change.Current, change.Key); + } + break; + + // Ignore Move changes completely, as this is functionally really just a fancy filter operator. + + case ChangeReason.Remove: + ClearExpiration(change.Key); + _itemsCache.Remove(change.Key); + break; + + case ChangeReason.Refresh: + _itemsCache.Refresh(change.Key); + break; + + case ChangeReason.Update: + { + if (_timeSelector.Invoke(change.Current) is TimeSpan expireAfter) + { + haveExpirationsChanged = TrySetExpiration( + key: change.Key, + dueTime: now + expireAfter); + } + else + { + ClearExpiration(change.Key); + haveExpirationsChanged = true; + } + + _itemsCache.AddOrUpdate(change.Current, change.Key); + } + break; + } + } + + if (haveExpirationsChanged) + OnExpirationsChanged(); + + var downstreamChanges = _itemsCache.CaptureChanges(); + if (downstreamChanges.Count is not 0) + _observer.OnNext(downstreamChanges); + } + catch (Exception error) + { + TryCancelNextScheduledManagement(); + + _observer.OnError(error); + } + } + + private void TryCancelNextScheduledManagement() + { + _nextScheduledManagement?.Cancellation.Dispose(); + _nextScheduledManagement = null; + } + + private bool TrySetExpiration( + TKey key, + DateTimeOffset dueTime) + { + var oldDueTime = _expirationDueTimesByKey.TryGetValue(key, out var expirationDueTime) + ? expirationDueTime + : null as DateTimeOffset?; + + // Always update the item state, cause even if ExpireAt doesn't change, the item itself might have. + _expirationDueTimesByKey[key] = dueTime; + + if (dueTime == oldDueTime) + return false; + + var insertionIndex = _proposedExpirationsQueue.BinarySearch(dueTime, static (expireAt, expiration) => expireAt.CompareTo(expiration.DueTime)); + if (insertionIndex < 0) + insertionIndex = ~insertionIndex; + + _proposedExpirationsQueue.Insert( + index: insertionIndex, + item: new() + { + DueTime = dueTime, + Key = key + }); + + // Intentionally not removing the old expiration for this item, if applicable, see ClearExpiration() + + return true; + } + + private readonly struct ProposedExpiration + { + public required DateTimeOffset DueTime { get; init; } + + public required TKey Key { get; init; } + } + + private readonly struct ScheduledManagement + { + public required IDisposable Cancellation { get; init; } + + public required DateTimeOffset DueTime { get; init; } + } + } + + private sealed class OnDemandSubscription + : SubscriptionBase + { + public OnDemandSubscription( + IObserver> observer, + IScheduler? scheduler, + IObservable> source, + Func timeSelector) + : base( + observer, + scheduler, + source, + timeSelector) + { + } + + protected override DateTimeOffset? GetNextManagementDueTime() + => GetNextProposedExpirationDueTime(); + + protected override void OnExpirationsManaged(DateTimeOffset dueTime) + { + } + } + + private sealed class PollingSubscription + : SubscriptionBase + { + private readonly TimeSpan _pollingInterval; + + private DateTimeOffset _lastManagementDueTime; + + public PollingSubscription( + IObserver> observer, + TimeSpan pollingInterval, + IScheduler? scheduler, + IObservable> source, + Func timeSelector) + : base( + observer, + scheduler, + source, + timeSelector) + { + _pollingInterval = pollingInterval; + + _lastManagementDueTime = Scheduler.Now; + } + + protected override DateTimeOffset? GetNextManagementDueTime() + { + var now = Scheduler.Now; + var nextDueTime = _lastManagementDueTime + _pollingInterval; + + // Throttle down the polling frequency if polls are taking longer than the ideal interval. + return (nextDueTime > now) + ? nextDueTime + : now; + } + + protected override void OnExpirationsManaged(DateTimeOffset dueTime) + => _lastManagementDueTime = dueTime; + } + } +} diff --git a/src/DynamicData/Cache/Internal/TimeExpirer.cs b/src/DynamicData/Cache/Internal/TimeExpirer.cs deleted file mode 100644 index ad48c800b..000000000 --- a/src/DynamicData/Cache/Internal/TimeExpirer.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. -// Roland Pheasant licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; - -using DynamicData.Kernel; - -namespace DynamicData.Cache.Internal; - -internal sealed class TimeExpirer(IObservable> source, Func timeSelector, TimeSpan? interval, IScheduler scheduler) - where TObject : notnull - where TKey : notnull -{ - private readonly IObservable> _source = source ?? throw new ArgumentNullException(nameof(source)); - - private readonly Func _timeSelector = timeSelector ?? throw new ArgumentNullException(nameof(timeSelector)); - - public IObservable> ExpireAfter() => Observable.Create>( - observer => - { - var cache = new IntermediateCache(_source); - - var published = cache.Connect().Publish(); - var subscriber = published.SubscribeSafe(observer); - - var autoRemover = published.ForExpiry(_timeSelector, interval, scheduler).Finally(observer.OnCompleted).Subscribe( - keys => - { - try - { - cache.Edit(updater => updater.Remove(keys.Select(kv => kv.Key))); - } - catch (Exception ex) - { - observer.OnError(ex); - } - }); - - var connected = published.Connect(); - - return Disposable.Create( - () => - { - connected.Dispose(); - subscriber.Dispose(); - autoRemover.Dispose(); - cache.Dispose(); - }); - }); - - public IObservable>> ForExpiry() => Observable.Create>>( - observer => - { - var dateTime = DateTime.Now; - - var autoRemover = _source.Do(_ => dateTime = scheduler.Now.UtcDateTime).Transform( - (t, v) => - { - var removeAt = _timeSelector(t); - var expireAt = removeAt.HasValue ? dateTime.Add(removeAt.Value) : DateTime.MaxValue; - return new ExpirableItem(t, v, expireAt); - }).AsObservableCache(); - - void RemovalAction() - { - try - { - var toRemove = autoRemover.KeyValues.Where(kv => kv.Value.ExpireAt <= scheduler.Now.UtcDateTime).ToList(); - - observer.OnNext(toRemove.ConvertAll(kv => new KeyValuePair(kv.Key, kv.Value.Value))); - } - catch (Exception ex) - { - observer.OnError(ex); - } - } - - var removalSubscription = new SingleAssignmentDisposable(); - if (interval.HasValue) - { - // use polling - removalSubscription.Disposable = scheduler.ScheduleRecurringAction(interval.Value, RemovalAction); - } - else - { - // create a timer for each distinct time - removalSubscription.Disposable = autoRemover.Connect().DistinctValues(ei => ei.ExpireAt).SubscribeMany( - datetime => - { - var expireAt = datetime.Subtract(scheduler.Now.UtcDateTime); - return Observable.Timer(expireAt, scheduler).Take(1).Subscribe(_ => RemovalAction()); - }).Subscribe(); - } - - return Disposable.Create( - () => - { - removalSubscription.Dispose(); - autoRemover.Dispose(); - }); - }); -} diff --git a/src/DynamicData/Cache/ObservableCacheEx.cs b/src/DynamicData/Cache/ObservableCacheEx.cs index c593c2a85..07c8c9443 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.cs @@ -1357,9 +1357,14 @@ public static IObservable> Except(this /// or /// timeSelector. /// - public static IObservable> ExpireAfter(this IObservable> source, Func timeSelector) - where TObject : notnull - where TKey : notnull => ExpireAfter(source, timeSelector, GlobalConfig.DefaultScheduler); + public static IObservable> ExpireAfter( + this IObservable> source, + Func timeSelector) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForStream.Create( + source: source, + timeSelector: timeSelector); /// /// Automatically removes items from the stream after the time specified by @@ -1376,15 +1381,16 @@ public static IObservable> ExpireAfter( /// or /// timeSelector. /// - public static IObservable> ExpireAfter(this IObservable> source, Func timeSelector, IScheduler scheduler) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - timeSelector.ThrowArgumentNullExceptionIfNull(nameof(timeSelector)); - - return source.ExpireAfter(timeSelector, null, scheduler); - } + public static IObservable> ExpireAfter( + this IObservable> source, + Func timeSelector, + IScheduler scheduler) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForStream.Create( + source: source, + timeSelector: timeSelector, + scheduler: scheduler); /// /// Automatically removes items from the stream on the next poll after the time specified by @@ -1401,9 +1407,16 @@ public static IObservable> ExpireAfter( /// source /// or /// timeSelector. - public static IObservable> ExpireAfter(this IObservable> source, Func timeSelector, TimeSpan? pollingInterval) - where TObject : notnull - where TKey : notnull => ExpireAfter(source, timeSelector, pollingInterval, GlobalConfig.DefaultScheduler); + public static IObservable> ExpireAfter( + this IObservable> source, + Func timeSelector, + TimeSpan? pollingInterval) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForStream.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval); /// /// Automatically removes items from the stream on the next poll after the time specified by @@ -1421,15 +1434,18 @@ public static IObservable> ExpireAfter( /// source /// or /// timeSelector. - public static IObservable> ExpireAfter(this IObservable> source, Func timeSelector, TimeSpan? pollingInterval, IScheduler scheduler) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - timeSelector.ThrowArgumentNullExceptionIfNull(nameof(timeSelector)); - - return new TimeExpirer(source, timeSelector, pollingInterval, scheduler).ExpireAfter(); - } + public static IObservable> ExpireAfter( + this IObservable> source, + Func timeSelector, + TimeSpan? pollingInterval, + IScheduler scheduler) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForStream.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval, + scheduler: scheduler); /// /// Automatically removes items from the cache after the time specified by @@ -1444,9 +1460,16 @@ public static IObservable> ExpireAfter( /// source /// or /// timeSelector. - public static IObservable>> ExpireAfter(this ISourceCache source, Func timeSelector, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull => source.ExpireAfter(timeSelector, null, scheduler); + public static IObservable>> ExpireAfter( + this ISourceCache source, + Func timeSelector, + IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForSource.Create( + source: source, + timeSelector: timeSelector, + scheduler: scheduler); /// /// Automatically removes items from the cache after the time specified by @@ -1463,9 +1486,16 @@ public static IObservable>> ExpireAfter< /// source /// or /// timeSelector. - public static IObservable>> ExpireAfter(this ISourceCache source, Func timeSelector, TimeSpan? interval = null) - where TObject : notnull - where TKey : notnull => ExpireAfter(source, timeSelector, interval, GlobalConfig.DefaultScheduler); + public static IObservable>> ExpireAfter( + this ISourceCache source, + Func timeSelector, + TimeSpan? interval = null) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForSource.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: interval); /// /// Ensures there are no duplicated keys in the observable changeset. @@ -1499,37 +1529,18 @@ public static IObservable> EnsureUniqueKeyssource /// or /// timeSelector. - public static IObservable>> ExpireAfter(this ISourceCache source, Func timeSelector, TimeSpan? pollingInterval, IScheduler? scheduler) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - timeSelector.ThrowArgumentNullExceptionIfNull(nameof(timeSelector)); - - scheduler ??= GlobalConfig.DefaultScheduler; - - return Observable.Create>>( - observer => source.Connect().ForExpiry(timeSelector, pollingInterval, scheduler).Finally(observer.OnCompleted).Subscribe( - toRemove => - { - try - { - // remove from cache and notify which items have been auto removed - var keyValuePairs = toRemove as KeyValuePair[] ?? toRemove.AsArray(); - if (keyValuePairs.Length == 0) - { - return; - } - - source.Remove(keyValuePairs.Select(kv => kv.Key)); - observer.OnNext(keyValuePairs); - } - catch (Exception ex) - { - observer.OnError(ex); - } - })); - } + public static IObservable>> ExpireAfter( + this ISourceCache source, + Func timeSelector, + TimeSpan? pollingInterval, + IScheduler? scheduler) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForSource.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval, + scheduler: scheduler); /// /// Filters the specified source. @@ -6344,26 +6355,6 @@ public static IObservable> Xor(this IOb return sources.Combine(CombineOperator.Xor); } - /// - /// Automatically removes items from the cache after the time specified by - /// the time selector elapses. - /// - /// The type of the object. - /// The type of the key. - /// The cache. - /// The time selector. Return null if the item should never be removed. - /// A polling interval. Since multiple timer subscriptions can be expensive, - /// it may be worth setting the interval. - /// - /// The scheduler. - /// An observable of enumerable of the key values which has been removed. - /// source - /// or - /// timeSelector. - internal static IObservable>> ForExpiry(this IObservable> source, Func timeSelector, TimeSpan? interval, IScheduler scheduler) - where TObject : notnull - where TKey : notnull => new TimeExpirer(source, timeSelector, interval, scheduler).ForExpiry(); - private static IObservable> Combine(this IObservableList> source, CombineOperator type) where TObject : notnull where TKey : notnull diff --git a/src/DynamicData/DynamicData.csproj b/src/DynamicData/DynamicData.csproj index 61c4f6809..ad0f30c94 100644 --- a/src/DynamicData/DynamicData.csproj +++ b/src/DynamicData/DynamicData.csproj @@ -19,4 +19,8 @@ Dynamic Data is a comprehensive caching and data manipulation solution which int + + + + diff --git a/src/DynamicData/List/Internal/ExpireAfter.cs b/src/DynamicData/List/Internal/ExpireAfter.cs index e000bb2a8..46a51f077 100644 --- a/src/DynamicData/List/Internal/ExpireAfter.cs +++ b/src/DynamicData/List/Internal/ExpireAfter.cs @@ -6,75 +6,427 @@ using System.Reactive.Disposables; using System.Reactive.Linq; -using DynamicData.Kernel; +using DynamicData.Internal; namespace DynamicData.List.Internal; -internal sealed class ExpireAfter(ISourceList sourceList, Func expireAfter, TimeSpan? pollingInterval, IScheduler scheduler, object locker) +internal sealed class ExpireAfter where T : notnull { - private readonly Func _expireAfter = expireAfter ?? throw new ArgumentNullException(nameof(expireAfter)); - private readonly IScheduler _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler)); + public static IObservable> Create( + ISourceList source, + Func timeSelector, + TimeSpan? pollingInterval = null, + IScheduler? scheduler = null) + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + timeSelector.ThrowArgumentNullExceptionIfNull(nameof(timeSelector)); - private readonly ISourceList _sourceList = sourceList ?? throw new ArgumentNullException(nameof(sourceList)); + return Observable.Create>(observer => (pollingInterval is TimeSpan pollingIntervalValue) + ? new PollingSubscription( + observer: observer, + pollingInterval: pollingIntervalValue, + scheduler: scheduler, + source: source, + timeSelector: timeSelector) + : new OnDemandSubscription( + observer: observer, + scheduler: scheduler, + source: source, + timeSelector: timeSelector)); + } - public IObservable> Run() => Observable.Create>( - observer => + private abstract class SubscriptionBase + : IDisposable + { + private readonly List _expirationDueTimes; + private readonly List _expiringIndexesBuffer; + private readonly IObserver> _observer; + private readonly Action> _onEditingSource; + private readonly IScheduler _scheduler; + private readonly ISourceList _source; + private readonly IDisposable _sourceSubscription; + private readonly Func _timeSelector; + + private bool _hasSourceCompleted; + private ScheduledManagement? _nextScheduledManagement; + + protected SubscriptionBase( + IObserver> observer, + IScheduler? scheduler, + ISourceList source, + Func timeSelector) + { + _observer = observer; + _source = source; + _timeSelector = timeSelector; + + _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; + + _onEditingSource = OnEditingSource; + + _expirationDueTimes = new(); + _expiringIndexesBuffer = new(); + + _sourceSubscription = source + .Connect() + // It's important to set this flag outside the context of a lock, because it'll be read outside of lock as well. + .Finally(() => _hasSourceCompleted = true) + .Synchronize(SynchronizationGate) + .SubscribeSafe( + onNext: OnSourceNext, + onError: OnSourceError, + onCompleted: OnSourceCompleted); + } + + public void Dispose() + { + lock (SynchronizationGate) { - var dateTime = _scheduler.Now.UtcDateTime; - long orderItemWasAdded = -1; + _sourceSubscription.Dispose(); - var autoRemover = _sourceList.Connect().Synchronize(locker).Do(_ => dateTime = _scheduler.Now.UtcDateTime).Cast( - t => - { - var removeAt = _expireAfter(t); - var expireAt = removeAt.HasValue ? dateTime.Add(removeAt.Value) : DateTime.MaxValue; - return new ExpirableItem(t, expireAt, Interlocked.Increment(ref orderItemWasAdded)); - }).AsObservableList(); + TryCancelNextScheduledManagement(); + } + } + + protected IScheduler Scheduler + => _scheduler; + + // Instead of using a dedicated _synchronizationGate object, we can save an allocation by using any object that is never exposed to public consumers. + protected object SynchronizationGate + => _expirationDueTimes; + + protected abstract DateTimeOffset? GetNextManagementDueTime(); + + protected DateTimeOffset? GetNextProposedExpirationDueTime() + { + var result = null as DateTimeOffset?; + + foreach (var dueTime in _expirationDueTimes) + { + if ((dueTime is DateTimeOffset value) && ((result is null) || (value < result))) + result = value; + } + + return result; + } + + protected abstract void OnExpirationsManaged(DateTimeOffset dueTime); + + private void ManageExpirations() + { + // This check is needed, to make sure we don't try and call .Edit() on a disposed _source, + // since the scheduler only promises "best effort" to cancel a scheduled action. + // It's safe to skip locking here becuase once this flag is set, it's never unset. + if (_hasSourceCompleted) + return; + + // Putting the entire management process here inside a .Edit() call for a couple of reasons: + // - It keeps the edit delegate from becoming a closure, so we can use a reusable cached delegate. + // - It batches multiple expirations occurring at the same time into one source operation, so it only emits one changeset. + // - It eliminates the possibility of our internal state/item caches becoming out-of-sync with _source, by effectively locking _source. + // - It eliminates a rare deadlock that I honestly can't fully explain, but was able to reproduce reliably with few hundred iterations of the ThreadPoolSchedulerIsUsedWithoutPolling_ExpirationIsThreadSafe test, on the Cache-equivalent of this operator. + _source.Edit(_onEditingSource); + } + + private void OnEditingSource(IExtendedList updater) + { + lock (SynchronizationGate) + { + // The scheduler only promises "best effort" to cancel scheduled operations, so we need to make sure. + if (_nextScheduledManagement is not ScheduledManagement thisScheduledManagement) + return; + + _nextScheduledManagement = null; - void RemovalAction() + var now = Scheduler.Now; + + // One major note here: we are NOT updating our internal state, except to mark items as no longer needing to expire. + // Once we're done with the source.Edit() here, it will fire of a changeset for the removals, which will get handled by OnSourceNext(), + // thus bringing all of our internal state back into sync. + + // Buffer removals, so we can eliminate the need for index adjustments as we update the source + for (var i = 0; i < _expirationDueTimes.Count; ++i) { - try + if ((_expirationDueTimes[i] is DateTimeOffset dueTime) && (dueTime <= now)) { - lock (locker) - { - var toRemove = autoRemover.Items.Where(ei => ei.ExpireAt <= _scheduler.Now.DateTime).Select(ei => ei.Item).ToList(); + _expiringIndexesBuffer.Add(i); - observer.OnNext(toRemove); - } + // This shouldn't be necessary, but it guarantees we don't accidentally expire an item more than once, + // in the event of a race condition or something we haven't predicted. + _expirationDueTimes[i] = null; } - catch (Exception ex) + } + + // I'm pretty sure it shouldn't be possible to end up with no removals here, but it costs basically nothing to check. + if (_expiringIndexesBuffer.Count is not 0) + { + // Processing removals in reverse-index order eliminates the need for us to adjust index of each .RemoveAt() call, as we go. + _expiringIndexesBuffer.Sort(static (x, y) => y.CompareTo(x)); + + var removedItems = new T[_expiringIndexesBuffer.Count]; + for (var i = 0; i < _expiringIndexesBuffer.Count; ++i) { - observer.OnError(ex); + var removedIndex = _expiringIndexesBuffer[i]; + removedItems[i] = updater[removedIndex]; + updater.RemoveAt(removedIndex); } + + _observer.OnNext(removedItems); + + _expiringIndexesBuffer.Clear(); } - var removalSubscription = new SingleAssignmentDisposable(); - if (pollingInterval.HasValue) + OnExpirationsManaged(thisScheduledManagement.DueTime); + + // We just changed due times, so run cleanup and management scheduling. + OnExpirationDueTimesChanged(); + } + } + + private void OnExpirationDueTimesChanged() + { + // Check if we need to re-schedule the next management operation + if (GetNextManagementDueTime() is DateTimeOffset nextManagementDueTime) + { + if (_nextScheduledManagement?.DueTime != nextManagementDueTime) { - // use polling - // ReSharper disable once InconsistentlySynchronizedField - removalSubscription.Disposable = _scheduler.ScheduleRecurringAction(pollingInterval.Value, RemovalAction); + if (_nextScheduledManagement is ScheduledManagement nextScheduledManagement) + nextScheduledManagement.Cancellation.Dispose(); + + _nextScheduledManagement = new() + { + Cancellation = _scheduler.Schedule( + state: this, + dueTime: nextManagementDueTime, + action: (_, @this) => + { + @this.ManageExpirations(); + + return Disposable.Empty; + }), + DueTime = nextManagementDueTime + }; } - else + } + else + { + TryCancelNextScheduledManagement(); + } + } + + private void OnSourceCompleted() + { + // If the source completes, we can no longer remove items from it, so any pending expirations are moot. + TryCancelNextScheduledManagement(); + + _observer.OnCompleted(); + } + + private void OnSourceError(Exception error) + { + TryCancelNextScheduledManagement(); + + _observer.OnError(error); + } + + private void OnSourceNext(IChangeSet changes) + { + try + { + var now = _scheduler.Now; + + var haveExpirationDueTimesChanged = false; + + foreach (var change in changes) { - // create a timer for each distinct time - removalSubscription.Disposable = autoRemover.Connect().DistinctValues(ei => ei.ExpireAt).SubscribeMany( - datetime => - { - // ReSharper disable once InconsistentlySynchronizedField - var expireAt = datetime.Subtract(_scheduler.Now.UtcDateTime); - - // ReSharper disable once InconsistentlySynchronizedField - return Observable.Timer(expireAt, _scheduler).Take(1).Subscribe(_ => RemovalAction()); - }).Subscribe(); + switch (change.Reason) + { + case ListChangeReason.Add: + { + var dueTime = now + _timeSelector.Invoke(change.Item.Current); + + _expirationDueTimes.Insert( + index: change.Item.CurrentIndex, + item: dueTime); + + haveExpirationDueTimesChanged |= dueTime is not null; + } + break; + + case ListChangeReason.AddRange: + { + _expirationDueTimes.EnsureCapacity(_expirationDueTimes.Count + change.Range.Count); + + var itemIndex = change.Range.Index; + foreach (var item in change.Range) + { + var dueTime = now + _timeSelector.Invoke(item); + + _expirationDueTimes.Insert( + index: itemIndex, + item: dueTime); + + haveExpirationDueTimesChanged |= dueTime is not null; + + ++itemIndex; + } + } + break; + + case ListChangeReason.Clear: + foreach (var dueTime in _expirationDueTimes) + { + if (dueTime is not null) + { + haveExpirationDueTimesChanged = true; + break; + } + } + + _expirationDueTimes.Clear(); + break; + + case ListChangeReason.Moved: + { + var expirationDueTime = _expirationDueTimes[change.Item.PreviousIndex]; + + _expirationDueTimes.RemoveAt(change.Item.PreviousIndex); + _expirationDueTimes.Insert( + index: change.Item.CurrentIndex, + item: expirationDueTime); + } + break; + + case ListChangeReason.Remove: + { + if (_expirationDueTimes[change.Item.CurrentIndex] is not null) + { + haveExpirationDueTimesChanged = true; + } + + _expirationDueTimes.RemoveAt(change.Item.CurrentIndex); + } + break; + + case ListChangeReason.RemoveRange: + { + var rangeEndIndex = change.Range.Index + change.Range.Count - 1; + for (var i = change.Range.Index; i <= rangeEndIndex; ++i) + { + if (_expirationDueTimes[i] is not null) + { + haveExpirationDueTimesChanged = true; + break; + } + } + + _expirationDueTimes.RemoveRange(change.Range.Index, change.Range.Count); + } + break; + + case ListChangeReason.Replace: + { + var oldDueTime = _expirationDueTimes[change.Item.CurrentIndex]; + var newDueTime = now + _timeSelector.Invoke(change.Item.Current); + + // Ignoring the possibility that the item's index has changed as well, because ISourceList does not allow for this. + + _expirationDueTimes[change.Item.CurrentIndex] = newDueTime; + + haveExpirationDueTimesChanged |= newDueTime != oldDueTime; + } + break; + + // Ignoring Refresh changes, since ISourceList doesn't generate them. + } } - return Disposable.Create( - () => - { - removalSubscription.Dispose(); - autoRemover.Dispose(); - }); - }); + if (haveExpirationDueTimesChanged) + OnExpirationDueTimesChanged(); + } + catch (Exception error) + { + TryCancelNextScheduledManagement(); + + _observer.OnError(error); + } + } + + private void TryCancelNextScheduledManagement() + { + _nextScheduledManagement?.Cancellation.Dispose(); + _nextScheduledManagement = null; + } + + private readonly record struct ScheduledManagement + { + public required IDisposable Cancellation { get; init; } + + public required DateTimeOffset DueTime { get; init; } + } + } + + private sealed class OnDemandSubscription + : SubscriptionBase + { + public OnDemandSubscription( + IObserver> observer, + IScheduler? scheduler, + ISourceList source, + Func timeSelector) + : base( + observer, + scheduler, + source, + timeSelector) + { + } + + protected override DateTimeOffset? GetNextManagementDueTime() + => GetNextProposedExpirationDueTime(); + + protected override void OnExpirationsManaged(DateTimeOffset dueTime) + { + } + } + + private sealed class PollingSubscription + : SubscriptionBase + { + private readonly TimeSpan _pollingInterval; + + private DateTimeOffset _lastManagementDueTime; + + public PollingSubscription( + IObserver> observer, + TimeSpan pollingInterval, + IScheduler? scheduler, + ISourceList source, + Func timeSelector) + : base( + observer, + scheduler, + source, + timeSelector) + { + _pollingInterval = pollingInterval; + + _lastManagementDueTime = Scheduler.Now; + } + + protected override DateTimeOffset? GetNextManagementDueTime() + { + var now = Scheduler.Now; + var nextDueTime = _lastManagementDueTime + _pollingInterval; + + // Make sure we don't flood the system with polls, if the processing time of a poll ever exceeds the polling interval. + return (nextDueTime > now) + ? nextDueTime + : now; + } + + protected override void OnExpirationsManaged(DateTimeOffset dueTime) + => _lastManagementDueTime = dueTime; + } } diff --git a/src/DynamicData/List/ObservableListEx.cs b/src/DynamicData/List/ObservableListEx.cs index cca8351f1..c196b37aa 100644 --- a/src/DynamicData/List/ObservableListEx.cs +++ b/src/DynamicData/List/ObservableListEx.cs @@ -660,8 +660,16 @@ public static IObservable> Except(this IObservableListSelector returning when to expire the item. Return null for non-expiring item. /// The scheduler. /// An observable which emits the enumerable of items. - public static IObservable> ExpireAfter(this ISourceList source, Func timeSelector, IScheduler? scheduler = null) - where T : notnull => source.ExpireAfter(timeSelector, null, scheduler); + public static IObservable> ExpireAfter( + this ISourceList source, + Func timeSelector, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ExpireAfter.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: null, + scheduler: scheduler); /// /// Removes items from the cache according to the value specified by the time selector function. @@ -672,18 +680,17 @@ public static IObservable> ExpireAfter(this ISourceList sou /// Enter the polling interval to optimise expiry timers, if omitted 1 timer is created for each unique expiry time. /// The scheduler. /// An observable which emits the enumerable of items. - public static IObservable> ExpireAfter(this ISourceList source, Func timeSelector, TimeSpan? pollingInterval = null, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - timeSelector.ThrowArgumentNullExceptionIfNull(nameof(timeSelector)); - - var locker = new object(); - var limiter = new ExpireAfter(source, timeSelector, pollingInterval, scheduler ?? GlobalConfig.DefaultScheduler, locker); - - return limiter.Run().Synchronize(locker).Do(source.RemoveMany); - } + public static IObservable> ExpireAfter( + this ISourceList source, + Func timeSelector, + TimeSpan? pollingInterval = null, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ExpireAfter.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval, + scheduler: scheduler); /// /// Filters the source using the specified valueSelector. diff --git a/src/DynamicData/Polyfills/ListEnsureCapacity.cs b/src/DynamicData/Polyfills/ListEnsureCapacity.cs new file mode 100644 index 000000000..d07461812 --- /dev/null +++ b/src/DynamicData/Polyfills/ListEnsureCapacity.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace System.Collections.Generic; + +internal static class ListEnsureCapacity +{ + public static void EnsureCapacity( + this List list, + int capacity) + { + if (list.Capacity < capacity) + list.Capacity = capacity; + } +}