-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
bc557e1
commit 7b5cb4c
Showing
3 changed files
with
240 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
using System; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Nito.AsyncEx; | ||
|
||
namespace rm.Hacks | ||
{ | ||
public interface ISettings | ||
{ | ||
string SentinelKey { get; } | ||
} | ||
|
||
public interface ISettingsProvider<ISettings> | ||
{ | ||
Task<ISettings> GetSettingsAsync(CancellationToken cancellationToken); | ||
} | ||
|
||
public interface ICachedSettingsProvider<ISettings> : ISettingsProvider<ISettings> | ||
{ | ||
void Stop(); | ||
} | ||
|
||
public class CacheSettings | ||
{ | ||
public TimeSpan Ttl { get; init; } | ||
} | ||
|
||
public abstract class CachedSettingsProvider<TSettings> : ICachedSettingsProvider<TSettings>, IDisposable | ||
where TSettings : ISettings | ||
{ | ||
private readonly ISettingsProvider<TSettings> settingsProvider; | ||
private readonly CacheSettings cacheSettings; | ||
|
||
private readonly object locker = new object(); | ||
|
||
private TSettings settingsLocked; | ||
private TSettings Settings | ||
{ | ||
get { lock (locker) { return settingsLocked; } } | ||
set { lock (locker) { settingsLocked = value; } } | ||
} | ||
|
||
private readonly AsyncManualResetEvent setupEvent = new AsyncManualResetEvent(set: false); | ||
|
||
private readonly Timer timer; | ||
|
||
public CachedSettingsProvider( | ||
ISettingsProvider<TSettings> settingsProvider, | ||
CacheSettings cacheSettings) | ||
{ | ||
this.settingsProvider = settingsProvider | ||
?? throw new ArgumentNullException(nameof(settingsProvider)); | ||
this.cacheSettings = cacheSettings | ||
?? throw new ArgumentNullException(nameof(cacheSettings)); | ||
|
||
// setup | ||
timer = new Timer(CallbackAsync, null, TimeSpan.Zero, Timeout.InfiniteTimeSpan); | ||
} | ||
|
||
private async void CallbackAsync(object state) | ||
{ | ||
try | ||
{ | ||
var newSettings = await settingsProvider.GetSettingsAsync(default) | ||
.ConfigureAwait(false); | ||
if (newSettings == null) | ||
{ | ||
throw new NullReferenceException(nameof(newSettings)); | ||
} | ||
var currentSettings = Settings; | ||
if (currentSettings == null || IsSentinelUpdated(currentSettings, newSettings)) | ||
{ | ||
Settings = newSettings; | ||
} | ||
|
||
setupEvent.Set(); | ||
} | ||
catch (Exception) | ||
{ | ||
// log ex! | ||
} | ||
finally | ||
{ | ||
Start(); | ||
} | ||
} | ||
|
||
private void Start() | ||
{ | ||
if (disposed) | ||
{ | ||
return; | ||
} | ||
|
||
// note: time creep is fine | ||
timer.Change(cacheSettings.Ttl, Timeout.InfiniteTimeSpan); | ||
} | ||
|
||
private bool IsSentinelUpdated(TSettings currentSettings, TSettings newSettings) | ||
{ | ||
var updated = currentSettings.SentinelKey != newSettings.SentinelKey; | ||
#if DEBUG | ||
if (!updated) | ||
{ | ||
Console.WriteLine($"[{DateTime.Now.TimeOfDay.ToString(@"hh\:mm\:ss\.fff")}] !{newSettings} Ignored!"); | ||
} | ||
#endif | ||
return updated; | ||
} | ||
|
||
public async Task<TSettings> GetSettingsAsync(CancellationToken cancellationToken) | ||
{ | ||
// wait for the first call to finish | ||
await setupEvent.WaitAsync(cancellationToken) | ||
.ConfigureAwait(false); | ||
|
||
var settings = Settings; | ||
return settings; | ||
} | ||
|
||
public void Stop() | ||
{ | ||
if (disposed) | ||
{ | ||
return; | ||
} | ||
|
||
timer.Change(Timeout.Infinite, Timeout.Infinite); | ||
} | ||
|
||
private bool disposed = false; | ||
|
||
protected void Dispose(bool disposing) | ||
{ | ||
if (!disposed) | ||
{ | ||
if (disposing) | ||
{ | ||
// stop to avoid race | ||
Stop(); | ||
|
||
timer?.Dispose(); | ||
|
||
disposed = true; | ||
} | ||
} | ||
} | ||
|
||
public void Dispose() | ||
{ | ||
Dispose(true); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
using System.Diagnostics; | ||
using AutoFixture; | ||
using AutoFixture.AutoMoq; | ||
using NUnit.Framework; | ||
using rm.Hacks; | ||
|
||
namespace rm.HacksTest | ||
{ | ||
[TestFixture] | ||
public class CachedSettingsTests | ||
{ | ||
[Explicit] | ||
[Test] | ||
public async Task Verify() | ||
{ | ||
var fixture = new Fixture().Customize(new AutoMoqCustomization()); | ||
|
||
var cacheSettings = new CacheSettings { Ttl = TimeSpan.FromMilliseconds(100) }; | ||
fixture.Register(() => cacheSettings); | ||
fixture.Register<ISettingsProvider<Settings>>(() => new SettingsProvider()); | ||
|
||
using var cachedSettingsProvider = fixture.Create<InMemoryCachedSettingsProvider>(); | ||
|
||
Console.WriteLine($"[{DateTime.Now.TimeOfDay.ToString(@"hh\:mm\:ss\.fff")}] start!"); | ||
|
||
for (int i = 0; i < 100; i++) | ||
{ | ||
var settings = await cachedSettingsProvider.GetSettingsAsync(default); | ||
Console.WriteLine($"[{DateTime.Now.TimeOfDay.ToString(@"hh\:mm\:ss\.fff")}] {settings}"); | ||
|
||
await Task.Delay(5); | ||
} | ||
|
||
// showcase | ||
cachedSettingsProvider.Stop(); | ||
} | ||
|
||
public class SettingsProvider : ISettingsProvider<Settings> | ||
{ | ||
private int i = 0; | ||
|
||
public async Task<Settings> GetSettingsAsync(CancellationToken cancellationToken) | ||
{ | ||
// simulate http call delay | ||
await Task.Delay(100) | ||
.ConfigureAwait(false); | ||
|
||
var settings = | ||
new Settings | ||
{ | ||
Property1 = i.ToString(), | ||
// change sentinel every other time (by clearing LSB) | ||
SentinelProperty = (i & ~1).ToString(), | ||
}; | ||
|
||
i++; | ||
|
||
return settings; | ||
} | ||
} | ||
|
||
[DebuggerDisplay("{Stringify}")] | ||
public class Settings : ISettings | ||
{ | ||
public string SentinelKey => SentinelProperty!; | ||
|
||
public string? SentinelProperty { get; init; } | ||
public string? Property1 { get; init; } | ||
|
||
public override string ToString() => Stringify; | ||
|
||
private string Stringify => $"{nameof(Property1)}:{Property1,4}, {nameof(SentinelKey)}:{SentinelKey,4}"; | ||
} | ||
|
||
public class InMemoryCachedSettingsProvider : CachedSettingsProvider<Settings> | ||
{ | ||
public InMemoryCachedSettingsProvider( | ||
ISettingsProvider<Settings> settingsProvider, | ||
CacheSettings cacheSettings) | ||
: base(settingsProvider, cacheSettings) | ||
{ } | ||
} | ||
} | ||
} |