stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:*";
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user