Skip to content

Commit

Permalink
ExpireAfter Redesign (#868)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
JakenVeina committed Feb 26, 2024
1 parent 8faa0dd commit d3933e3
Show file tree
Hide file tree
Showing 20 changed files with 2,373 additions and 837 deletions.
12 changes: 6 additions & 6 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -547,4 +547,4 @@ end_of_line = lf
[*.{cmd, bat}]
end_of_line = crlf

vsspell_dictionary_languages = en-US
vsspell_dictionary_languages = en-US
132 changes: 102 additions & 30 deletions src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForSource.cs
Original file line number Diff line number Diff line change
@@ -1,62 +1,134 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;

using BenchmarkDotNet.Attributes;

using Bogus;

namespace DynamicData.Benchmarks.Cache;

[MemoryDiagnoser]
[MarkdownExporterAttribute.GitHub]
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<Item, int>(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<Item> _items;
private void PerformRandomizedEdits(SourceCache<Item, int> 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<Item> _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; }
}
}
151 changes: 101 additions & 50 deletions src/DynamicData.Benchmarks/Cache/ExpireAfter_Cache_ForStream.cs
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -12,76 +15,124 @@ public class ExpireAfter_Cache_ForStream
{
public ExpireAfter_Cache_ForStream()
{
var additions = new List<IChangeSet<Item, int>>(capacity: 1_000);
var removals = new List<IChangeSet<Item, int>>(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<Item, int>(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<Item, int>()
var changeSets = ImmutableArray.CreateBuilder<IChangeSet<Item, int>>(initialCapacity: 5_000);

var cache = new ChangeAwareCache<Item, int>();

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<IChangeSet<Item, int>>();

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<Item>();

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<IChangeSet<Item, int>> _additions;
private readonly IReadOnlyList<IChangeSet<Item, int>> _removals;
private readonly ImmutableArray<IChangeSet<Item, int>> _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; }
}
}

0 comments on commit d3933e3

Please sign in to comment.