Skip to content

Instantly share code, notes, and snippets.

@jimmylewis
Created September 10, 2019 07:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jimmylewis/423b51a6029575a37b7b39797c62d389 to your computer and use it in GitHub Desktop.
Save jimmylewis/423b51a6029575a37b7b39797c62d389 to your computer and use it in GitHub Desktop.
BindableBaseWithThrottling
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ThrottlingReview
{
public abstract class BindableBase : INotifyPropertyChanged
{
protected virtual bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = "")
{
if (EqualityComparer<T>.Default.Equals(storage, value))
{
return false;
}
storage = value;
NotifyPropertyChanged(propertyName);
return true;
}
#region INotifyPropertyChanged
protected virtual void NotifyPropertyChanged([CallerMemberName] string propName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}
public event PropertyChangedEventHandler PropertyChanged;
#endregion
}
}
using System;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace ThrottlingReview
{
public abstract class BindableBaseWithThrottling : BindableBase
{
private readonly TimeSpan _throttleLimit;
private readonly TaskScheduler _taskScheduler;
private readonly ConcurrentDictionary<string, Task> _cooldownTasks;
private readonly ConcurrentDictionary<string, string> _pendingNotifications;
public BindableBaseWithThrottling(TimeSpan throttleLimit, TaskScheduler taskScheduler)
{
_throttleLimit = throttleLimit;
_taskScheduler = taskScheduler;
_cooldownTasks = new ConcurrentDictionary<string, Task>();
_pendingNotifications = new ConcurrentDictionary<string, string>();
}
protected override void NotifyPropertyChanged([CallerMemberName] string propName = "")
{
if (_pendingNotifications.ContainsKey(propName))
{
// do nothing, it's already pending
}
else if (_cooldownTasks.TryGetValue(propName, out _))
{
_pendingNotifications.TryAdd(propName, propName);
}
else
{
// fire right away if we're not already in a cool down
base.NotifyPropertyChanged(propName);
Cooldown(propName);
}
}
private void Cooldown(string propName)
{
var newCooldown = new Task(async () => await Task.Delay(_throttleLimit));
if (_cooldownTasks.TryAdd(propName, newCooldown))
{
newCooldown.Start(_taskScheduler);
newCooldown.ContinueWith((t) => CheckForPendingNotifications(propName), _taskScheduler);
}
}
private void CheckForPendingNotifications(string propName)
{
_cooldownTasks.TryRemove(propName, out _);
// if there was a new pending request during the cooldown, service it now
if (_pendingNotifications.TryRemove(propName, out _))
{
base.NotifyPropertyChanged(propName);
Cooldown(propName);
}
}
}
}
using System;
using System.Threading.Tasks;
using Xunit;
namespace ThrottlingReview
{
public class BindableBaseWithThrottlingTests
{
[Fact]
public void NotifyPropertyChanged_CalledOnce_FiresImmediately()
{
int count = 0;
var scheduler = new DeterministicTaskScheduler();
var sut = new TestBindableObject(TimeSpan.FromMilliseconds(1), scheduler);
sut.PropertyChanged += (s, e) =>
{
count++;
};
sut.Value = 1; // trigger INPC notification
Assert.Equal(1, count);
}
[Fact]
public void NotifyPropertyChanged_CalledOnce_FiresOnce()
{
int count = 0;
var scheduler = new DeterministicTaskScheduler();
var sut = new TestBindableObject(TimeSpan.FromMilliseconds(1), scheduler);
sut.PropertyChanged += (s, e) =>
{
count++;
};
sut.Value = 1; // trigger INPC notification
scheduler.RunTasksUntilIdle();
Assert.Equal(1, count);
}
[Fact]
public void NotifyPropertyChanged_CalledTwiceImmediately_OnlyOneEventFiredImmediately()
{
int count = 0;
var scheduler = new DeterministicTaskScheduler();
var sut = new TestBindableObject(TimeSpan.FromMilliseconds(1), scheduler);
sut.PropertyChanged += (s, e) =>
{
count++;
};
sut.Value = 1; // trigger INPC notification
sut.Value = 2; // trigger after cooldown, but taskScheduler has not run any tasks, still in cooldown
Assert.Equal(1, count);
}
[Fact]
public void NotifyPropertyChanged_CalledTwiceImmediately_FiresSecondWithDelay()
{
int count = 0;
var scheduler = new DeterministicTaskScheduler();
var sut = new TestBindableObject(TimeSpan.FromMilliseconds(1), scheduler);
sut.PropertyChanged += (s, e) =>
{
count++;
};
sut.Value = 1; // trigger INPC notification
sut.Value = 2; // trigger INPC notification after cooldown
scheduler.RunTasksUntilIdle();
Assert.Equal(2, count);
}
[Fact]
public void NotifyPropertyChanged_CalledThriceImmediately_FiresSecondWithDelay()
{
int count = 0;
var scheduler = new DeterministicTaskScheduler();
var sut = new TestBindableObject(TimeSpan.FromMilliseconds(1), scheduler);
sut.PropertyChanged += (s, e) =>
{
count++;
};
sut.Value = 1; // trigger INPC notification
sut.Value = 2; // trigger INPC notification after cooldown
sut.Value = 3; // falls within 2nd notification
scheduler.RunTasksUntilIdle();
Assert.Equal(2, count);
}
[Fact]
public void NotifyPropertyChanged_CalledTwiceWithDelay_FiresSecondImmediately()
{
int count = 0;
var scheduler = new DeterministicTaskScheduler();
var sut = new TestBindableObject(TimeSpan.FromMilliseconds(1), scheduler);
sut.PropertyChanged += (s, e) =>
{
count++;
};
sut.Value = 1; // trigger INPC notification
scheduler.RunTasksUntilIdle();
sut.Value = 2; // trigger INPC notification after cooldown
Assert.Equal(2, count);
}
[Fact]
public void NotifyPropertyChanged_CalledWithDifferentPropertyNames_FiresImmediatelyForEach()
{
int count = 0;
var scheduler = new DeterministicTaskScheduler();
var sut = new TestBindableObject(TimeSpan.FromMilliseconds(1), scheduler);
sut.PropertyChanged += (s, e) =>
{
count++;
};
sut.Value = 1; // trigger INPC notification
sut.SecondValue = "test"; // trigger INPC notification for 2nd property name
Assert.Equal(2, count);
}
private class TestBindableObject : BindableBaseWithThrottling
{
private int _value;
private string _secondValue;
public TestBindableObject(TimeSpan throttleLimit, TaskScheduler taskScheduler)
: base(throttleLimit, taskScheduler)
{
}
public int Value
{
get { return _value; }
set { SetProperty(ref _value, value); }
}
public string SecondValue
{
get { return _secondValue; }
set { SetProperty(ref _secondValue, value); }
}
}
}
}
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ThrottlingReview
{
/// <summary>
/// TaskScheduker for executing tasks on the same thread that calls RunTasksUntilIdle() or RunPendingTasks()
/// </summary>
public class DeterministicTaskScheduler : TaskScheduler
{
private List<Task> _scheduledTasks = new List<Task>();
#region TaskScheduler methods
protected override void QueueTask(Task task)
{
_scheduledTasks.Add(task);
}
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
_scheduledTasks.Add(task);
return false;
}
protected override IEnumerable<Task> GetScheduledTasks()
{
return _scheduledTasks;
}
public override int MaximumConcurrencyLevel { get { return 1; } }
#endregion
/// <summary>
/// Executes the scheduled Tasks synchronously on the current thread. If those tasks schedule new tasks
/// they will also be executed until no pending tasks are left.
/// </summary>
public void RunTasksUntilIdle()
{
while (_scheduledTasks.Any())
{
RunPendingTasks();
}
}
/// <summary>
/// Executes the scheduled Tasks synchronously on the current thread. If those tasks schedule new tasks
/// they will only be executed with the next call to RunTasksUntilIdle() or RunPendingTasks().
/// </summary>
public void RunPendingTasks()
{
foreach (Task task in _scheduledTasks.ToArray())
{
TryExecuteTask(task);
_scheduledTasks.Remove(task);
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment