Skip to content

Commit

Permalink
settings: Add cachedSettings impl
Browse files Browse the repository at this point in the history
  • Loading branch information
rmandvikar committed Oct 14, 2023
1 parent bc557e1 commit 7b5cb4c
Show file tree
Hide file tree
Showing 3 changed files with 240 additions and 0 deletions.
154 changes: 154 additions & 0 deletions src/rm.DelegatingHandlers/CachedSettings.cs
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);
}
}
}
2 changes: 2 additions & 0 deletions src/rm.DelegatingHandlers/rm.DelegatingHandlers.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
<PackageReference Include="rm.FeatureToggle" Version="1.1.2" />
<PackageReference Include="rm.Random2" Version="3.0.1" />
<PackageReference Include="Serilog" Version="2.7.1" />

<PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName)Test" />
Expand Down
84 changes: 84 additions & 0 deletions tests/rm.DelegatingHandlersTest/CachedSettingsTests.cs
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)
{ }
}
}
}

0 comments on commit 7b5cb4c

Please sign in to comment.