Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageVersion Include="NUnit" Version="4.1.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
Expand Down
9 changes: 9 additions & 0 deletions JitHub.Data.Caching/CacheEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace JitHub.Data.Caching;

public sealed record CacheEvent(
CacheEventKind Kind,
CacheKey Key,
Type? ValueType,
object? Value,
Exception? Error,
DateTimeOffset TimestampUtc);
47 changes: 47 additions & 0 deletions JitHub.Data.Caching/CacheEventBus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Collections.Concurrent;

namespace JitHub.Data.Caching;

public sealed class CacheEventBus : ICacheEventBus
{
private readonly ConcurrentDictionary<int, Action<CacheEvent>> _subscribers = new();
private int _nextId;

public IDisposable Subscribe(Action<CacheEvent> handler)
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}

var id = Interlocked.Increment(ref _nextId);
_subscribers[id] = handler;

return new Subscription(() => _subscribers.TryRemove(id, out _));
}

public void Publish(CacheEvent cacheEvent)
{
foreach (var subscriber in _subscribers.Values)
{
try
{
subscriber(cacheEvent);
}
catch
{
// Intentionally isolate subscribers. Cache publication should never fail because a consumer misbehaves.
}
}
}

private sealed class Subscription(Action dispose) : IDisposable
{
private Action? _dispose = dispose;

public void Dispose()
{
Interlocked.Exchange(ref _dispose, null)?.Invoke();
}
}
}
8 changes: 8 additions & 0 deletions JitHub.Data.Caching/CacheEventKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace JitHub.Data.Caching;

public enum CacheEventKind
{
Updated = 0,
Removed = 1,
RefreshFailed = 2,
}
8 changes: 8 additions & 0 deletions JitHub.Data.Caching/CacheItemMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace JitHub.Data.Caching;

public sealed record CacheItemMetadata(
DateTimeOffset FetchedAtUtc,
DateTimeOffset? ExpiresAtUtc = null,
string? ETag = null,
DateTimeOffset? LastModifiedUtc = null,
Exception? LastError = null);
132 changes: 132 additions & 0 deletions JitHub.Data.Caching/CacheKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Collections.Immutable;

namespace JitHub.Data.Caching;

public readonly record struct CacheKey
{
private readonly ImmutableArray<KeyValuePair<string, string>> _parameters;

public CacheKey(string operation, string? userScope, IEnumerable<KeyValuePair<string, string>>? parameters = null)
{
if (operation is null)
{
throw new ArgumentNullException(nameof(operation));
}

operation = operation.Trim();
if (operation.Length == 0)
{
throw new ArgumentException("Operation must not be empty.", nameof(operation));
}

Operation = operation;
UserScope = string.IsNullOrWhiteSpace(userScope) ? null : userScope.Trim();

_parameters = Normalize(parameters);
}

public string Operation { get; }

public string? UserScope { get; }

public IReadOnlyList<KeyValuePair<string, string>> Parameters => _parameters;

public static CacheKey Create(string operation, string? userScope = null, params (string Key, string Value)[] parameters)
=> new(operation, userScope, parameters.Select(p => new KeyValuePair<string, string>(p.Key, p.Value)));

public bool Equals(CacheKey other)
{
if (!StringComparer.Ordinal.Equals(Operation, other.Operation))
{
return false;
}

if (!StringComparer.Ordinal.Equals(UserScope, other.UserScope))
{
return false;
}

if (_parameters.Length != other._parameters.Length)
{
return false;
}

for (var i = 0; i < _parameters.Length; i++)
{
var a = _parameters[i];
var b = other._parameters[i];

if (!StringComparer.Ordinal.Equals(a.Key, b.Key) || !StringComparer.Ordinal.Equals(a.Value, b.Value))
{
return false;
}
}

return true;
}

public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(Operation, StringComparer.Ordinal);
hash.Add(UserScope, StringComparer.Ordinal);

for (var i = 0; i < _parameters.Length; i++)
{
var p = _parameters[i];
hash.Add(p.Key, StringComparer.Ordinal);
hash.Add(p.Value, StringComparer.Ordinal);
}

return hash.ToHashCode();
}

public override string ToString()
{
if (_parameters.IsDefaultOrEmpty)
{
return UserScope is null ? Operation : $"{Operation}@{UserScope}";
}

var scope = UserScope is null ? string.Empty : $"@{UserScope}";
return $"{Operation}{scope}?{string.Join("&", _parameters.Select(p => $"{p.Key}={p.Value}"))}";
}

private static ImmutableArray<KeyValuePair<string, string>> Normalize(IEnumerable<KeyValuePair<string, string>>? parameters)
{
if (parameters is null)
{
return ImmutableArray<KeyValuePair<string, string>>.Empty;
}

var list = new List<KeyValuePair<string, string>>();
foreach (var (key, value) in parameters)
{
if (key is null)
{
throw new ArgumentNullException(nameof(parameters), "Parameter key must not be null.");
}

if (value is null)
{
throw new ArgumentNullException(nameof(parameters), "Parameter value must not be null.");
}

var normalizedKey = key.Trim();
if (normalizedKey.Length == 0)
{
throw new ArgumentException("Parameter key must not be empty.", nameof(parameters));
}

list.Add(new KeyValuePair<string, string>(normalizedKey, value.Trim()));
}

list.Sort(static (a, b) =>
{
var cmp = StringComparer.Ordinal.Compare(a.Key, b.Key);
return cmp != 0 ? cmp : StringComparer.Ordinal.Compare(a.Value, b.Value);
});

return list.ToImmutableArray();
}
}
32 changes: 32 additions & 0 deletions JitHub.Data.Caching/CacheKeyExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace JitHub.Data.Caching;

public static class CacheKeyExtensions
{
public static bool TryGetParameterValue(this CacheKey key, string parameterKey, out string? value)
{
if (parameterKey is null)
{
throw new ArgumentNullException(nameof(parameterKey));
}

// CacheKey parameters are normalized and sorted, but lookups are low-volume.
// Keep this simple and allocation-free.
foreach (var p in key.Parameters)
{
if (string.Equals(p.Key, parameterKey, StringComparison.Ordinal))
{
value = p.Value;
return true;
}
}

value = null;
return false;
}

public static string? GetParameterValue(this CacheKey key, string parameterKey)
=> key.TryGetParameterValue(parameterKey, out var value) ? value : null;

public static string GetParameterValueOrEmpty(this CacheKey key, string parameterKey)
=> key.GetParameterValue(parameterKey) ?? string.Empty;
}
Loading
Loading