Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Cache interface for reachability graphs and slices.
|
||||
/// </summary>
|
||||
public interface IReachGraphCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a cached graph by its digest.
|
||||
/// </summary>
|
||||
Task<ReachGraphMinimal?> GetAsync(
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cache a graph with optional TTL.
|
||||
/// </summary>
|
||||
Task SetAsync(
|
||||
string digest,
|
||||
ReachGraphMinimal graph,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a cached slice by digest and slice key.
|
||||
/// </summary>
|
||||
Task<byte[]?> GetSliceAsync(
|
||||
string digest,
|
||||
string sliceKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cache a slice with optional TTL.
|
||||
/// </summary>
|
||||
Task SetSliceAsync(
|
||||
string digest,
|
||||
string sliceKey,
|
||||
byte[] slice,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate all cached data for a digest.
|
||||
/// </summary>
|
||||
Task InvalidateAsync(
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a graph is cached.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the ReachGraph cache.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default TTL for cached full graphs.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTtl { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// TTL for cached slices (shorter as they're query-dependent).
|
||||
/// </summary>
|
||||
public TimeSpan SliceTtl { get; init; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum size in bytes for caching a graph.
|
||||
/// Graphs larger than this are not cached.
|
||||
/// </summary>
|
||||
public int MaxGraphSizeBytes { get; init; } = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// <summary>
|
||||
/// Whether to compress data before caching.
|
||||
/// </summary>
|
||||
public bool CompressInCache { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Key prefix for namespacing.
|
||||
/// </summary>
|
||||
public string KeyPrefix { get; init; } = "reachgraph";
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis database number to use.
|
||||
/// </summary>
|
||||
public int Database { get; init; } = 0;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.ReachGraph.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis-based cache implementation for reachability graphs.
|
||||
///
|
||||
/// Key patterns:
|
||||
/// {prefix}:{tenant}:{digest} - Full graph (compressed JSON)
|
||||
/// {prefix}:{tenant}:{digest}:slice:{hash} - Slice cache (compressed JSON)
|
||||
/// </summary>
|
||||
public sealed class ReachGraphValkeyCache : IReachGraphCache
|
||||
{
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly CanonicalReachGraphSerializer _serializer;
|
||||
private readonly ReachGraphCacheOptions _options;
|
||||
private readonly ILogger<ReachGraphValkeyCache> _logger;
|
||||
private readonly string _tenantId;
|
||||
|
||||
public ReachGraphValkeyCache(
|
||||
IConnectionMultiplexer redis,
|
||||
CanonicalReachGraphSerializer serializer,
|
||||
IOptions<ReachGraphCacheOptions> options,
|
||||
ILogger<ReachGraphValkeyCache> logger,
|
||||
string tenantId)
|
||||
{
|
||||
_redis = redis ?? throw new ArgumentNullException(nameof(redis));
|
||||
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphMinimal?> GetAsync(
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
|
||||
var key = BuildGraphKey(digest);
|
||||
var db = _redis.GetDatabase(_options.Database);
|
||||
|
||||
try
|
||||
{
|
||||
var value = await db.StringGetAsync(key);
|
||||
if (value.IsNullOrEmpty)
|
||||
{
|
||||
_logger.LogDebug("Cache miss for graph {Digest}", digest);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache hit for graph {Digest}", digest);
|
||||
|
||||
var bytes = (byte[])value!;
|
||||
var json = _options.CompressInCache ? DecompressGzip(bytes) : bytes;
|
||||
return _serializer.Deserialize(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get graph {Digest} from cache", digest);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(
|
||||
string digest,
|
||||
ReachGraphMinimal graph,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var json = _serializer.SerializeMinimal(graph);
|
||||
|
||||
if (json.Length > _options.MaxGraphSizeBytes)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Graph {Digest} exceeds max cache size ({Size} > {Max}), skipping cache",
|
||||
digest, json.Length, _options.MaxGraphSizeBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildGraphKey(digest);
|
||||
var value = _options.CompressInCache ? CompressGzip(json) : json;
|
||||
var expiry = ttl ?? _options.DefaultTtl;
|
||||
|
||||
var db = _redis.GetDatabase(_options.Database);
|
||||
|
||||
try
|
||||
{
|
||||
await db.StringSetAsync(key, value, expiry);
|
||||
_logger.LogDebug(
|
||||
"Cached graph {Digest} ({Size} bytes, TTL: {Ttl})",
|
||||
digest, value.Length, expiry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cache graph {Digest}", digest);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]?> GetSliceAsync(
|
||||
string digest,
|
||||
string sliceKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(sliceKey);
|
||||
|
||||
var key = BuildSliceKey(digest, sliceKey);
|
||||
var db = _redis.GetDatabase(_options.Database);
|
||||
|
||||
try
|
||||
{
|
||||
var value = await db.StringGetAsync(key);
|
||||
if (value.IsNullOrEmpty)
|
||||
{
|
||||
_logger.LogDebug("Cache miss for slice {Digest}:{SliceKey}", digest, sliceKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache hit for slice {Digest}:{SliceKey}", digest, sliceKey);
|
||||
|
||||
var bytes = (byte[])value!;
|
||||
return _options.CompressInCache ? DecompressGzip(bytes) : bytes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get slice {Digest}:{SliceKey} from cache", digest, sliceKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetSliceAsync(
|
||||
string digest,
|
||||
string sliceKey,
|
||||
byte[] slice,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(sliceKey);
|
||||
ArgumentNullException.ThrowIfNull(slice);
|
||||
|
||||
var key = BuildSliceKey(digest, sliceKey);
|
||||
var value = _options.CompressInCache ? CompressGzip(slice) : slice;
|
||||
var expiry = ttl ?? _options.SliceTtl;
|
||||
|
||||
var db = _redis.GetDatabase(_options.Database);
|
||||
|
||||
try
|
||||
{
|
||||
await db.StringSetAsync(key, value, expiry);
|
||||
_logger.LogDebug(
|
||||
"Cached slice {Digest}:{SliceKey} ({Size} bytes, TTL: {Ttl})",
|
||||
digest, sliceKey, value.Length, expiry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cache slice {Digest}:{SliceKey}", digest, sliceKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InvalidateAsync(
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
|
||||
var db = _redis.GetDatabase(_options.Database);
|
||||
var server = _redis.GetServers().FirstOrDefault();
|
||||
|
||||
if (server is null)
|
||||
{
|
||||
_logger.LogWarning("No Redis server available for key enumeration");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Delete the main graph key
|
||||
var graphKey = BuildGraphKey(digest);
|
||||
await db.KeyDeleteAsync(graphKey);
|
||||
|
||||
// Delete all slice keys for this graph
|
||||
var slicePattern = $"{_options.KeyPrefix}:{_tenantId}:{digest}:slice:*";
|
||||
var sliceKeys = server.Keys(_options.Database, slicePattern).ToArray();
|
||||
|
||||
if (sliceKeys.Length > 0)
|
||||
{
|
||||
await db.KeyDeleteAsync(sliceKeys);
|
||||
_logger.LogDebug("Invalidated {Count} slice keys for graph {Digest}", sliceKeys.Length, digest);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Invalidated cache for graph {Digest}", digest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate cache for graph {Digest}", digest);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ExistsAsync(
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
|
||||
var key = BuildGraphKey(digest);
|
||||
var db = _redis.GetDatabase(_options.Database);
|
||||
|
||||
try
|
||||
{
|
||||
return await db.KeyExistsAsync(key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check existence for graph {Digest}", digest);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildGraphKey(string digest) =>
|
||||
$"{_options.KeyPrefix}:{_tenantId}:{digest}";
|
||||
|
||||
private string BuildSliceKey(string digest, string sliceKey) =>
|
||||
$"{_options.KeyPrefix}:{_tenantId}:{digest}:slice:{sliceKey}";
|
||||
|
||||
private static byte[] CompressGzip(byte[] data)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true))
|
||||
{
|
||||
gzip.Write(data);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] DecompressGzip(byte[] compressed)
|
||||
{
|
||||
using var input = new MemoryStream(compressed);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
gzip.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.ReachGraph.Cache</RootNamespace>
|
||||
<Description>Valkey/Redis cache layer for StellaOps ReachGraph</Description>
|
||||
<Authors>StellaOps</Authors>
|
||||
<PackageId>StellaOps.ReachGraph.Cache</PackageId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.ReachGraph.Cache.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user