stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -0,0 +1,27 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.IO;
using System.IO.Compression;
namespace StellaOps.ReachGraph.Cache;
public sealed partial class ReachGraphValkeyCache
{
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();
}
}

View File

@@ -0,0 +1,35 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.Logging;
namespace StellaOps.ReachGraph.Cache;
public sealed partial class ReachGraphValkeyCache
{
/// <inheritdoc />
public async Task<bool> ExistsAsync(
string digest,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
cancellationToken.ThrowIfCancellationRequested();
var key = BuildGraphKey(digest);
var db = _redis.GetDatabase(_options.Database);
try
{
var exists = await db.KeyExistsAsync(key).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return exists;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check existence for graph {Digest}", digest);
return false;
}
}
}

View File

@@ -0,0 +1,92 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.Logging;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Cache;
public sealed partial class ReachGraphValkeyCache
{
/// <inheritdoc />
public async Task<ReachGraphMinimal?> GetAsync(
string digest,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
cancellationToken.ThrowIfCancellationRequested();
var key = BuildGraphKey(digest);
var db = _redis.GetDatabase(_options.Database);
try
{
var value = await db.StringGetAsync(key).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
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 (OperationCanceledException)
{
throw;
}
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);
cancellationToken.ThrowIfCancellationRequested();
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).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug(
"Cached graph {Digest} ({Size} bytes, TTL: {Ttl})",
digest, value.Length, expiry);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cache graph {Digest}", digest);
}
}
}

View File

@@ -0,0 +1,70 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using System.Collections.Generic;
namespace StellaOps.ReachGraph.Cache;
public sealed partial class ReachGraphValkeyCache
{
private const int DefaultScanPageSize = 200;
/// <inheritdoc />
public async Task InvalidateAsync(
string digest,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
cancellationToken.ThrowIfCancellationRequested();
var db = _redis.GetDatabase(_options.Database);
var graphKey = BuildGraphKey(digest);
try
{
await db.KeyDeleteAsync(graphKey).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var endpoints = _redis.GetEndPoints();
if (endpoints.Length == 0)
{
_logger.LogWarning("No Redis endpoints available for key enumeration");
return;
}
var slicePattern = BuildSlicePattern(digest);
var sliceKeys = new List<RedisKey>();
foreach (var endpoint in endpoints)
{
cancellationToken.ThrowIfCancellationRequested();
var server = _redis.GetServer(endpoint);
if (server is null || !server.IsConnected)
{
continue;
}
sliceKeys.AddRange(server.Keys(
_options.Database,
slicePattern,
pageSize: DefaultScanPageSize));
}
if (sliceKeys.Count > 0)
{
await db.KeyDeleteAsync(sliceKeys.ToArray()).ConfigureAwait(false);
_logger.LogDebug("Invalidated {Count} slice keys for graph {Digest}", sliceKeys.Count, digest);
}
_logger.LogInformation("Invalidated cache for graph {Digest}", digest);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate cache for graph {Digest}", digest);
}
}
}

View File

@@ -0,0 +1,84 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.Logging;
namespace StellaOps.ReachGraph.Cache;
public sealed partial class ReachGraphValkeyCache
{
/// <inheritdoc />
public async Task<byte[]?> GetSliceAsync(
string digest,
string sliceKey,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
ArgumentException.ThrowIfNullOrEmpty(sliceKey);
cancellationToken.ThrowIfCancellationRequested();
var key = BuildSliceKey(digest, sliceKey);
var db = _redis.GetDatabase(_options.Database);
try
{
var value = await db.StringGetAsync(key).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
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 (OperationCanceledException)
{
throw;
}
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);
cancellationToken.ThrowIfCancellationRequested();
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).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug(
"Cached slice {Digest}:{SliceKey} ({Size} bytes, TTL: {Ttl})",
digest, sliceKey, value.Length, expiry);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cache slice {Digest}:{SliceKey}", digest, sliceKey);
}
}
}

View File

@@ -1,12 +1,8 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.Serialization;
using System.IO.Compression;
namespace StellaOps.ReachGraph.Cache;
@@ -17,7 +13,7 @@ namespace StellaOps.ReachGraph.Cache;
/// {prefix}:{tenant}:{digest} - Full graph (compressed JSON)
/// {prefix}:{tenant}:{digest}:slice:{hash} - Slice cache (compressed JSON)
/// </summary>
public sealed class ReachGraphValkeyCache : IReachGraphCache
public sealed partial class ReachGraphValkeyCache : IReachGraphCache
{
private readonly IConnectionMultiplexer _redis;
private readonly CanonicalReachGraphSerializer _serializer;
@@ -39,224 +35,12 @@ public sealed class ReachGraphValkeyCache : IReachGraphCache
_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();
}
private string BuildSlicePattern(string digest) =>
$"{_options.KeyPrefix}:{_tenantId}:{digest}:slice:*";
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0103-T | DONE | Revalidated 2026-01-08; test coverage audit for ReachGraph.Cache. |
| AUDIT-0103-A | TODO | Pending approval (revalidated 2026-01-08). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-08 | DONE | Split ReachGraphValkeyCache into <= 100-line partials, added ConfigureAwait(false) + cancellation handling, multi-endpoint invalidation, and new unit tests; `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Cache.Tests/StellaOps.ReachGraph.Cache.Tests.csproj` passed 2026-02-03. |