feat(engine): ConnectionInstanceRegistry with per-id caching + invalidate on delete

This commit is contained in:
Claude
2026-08-28 14:46:15 +00:00
parent 8f4196de51
commit 8fd152d977
4 changed files with 165 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Contracts;
using VolcanicCore.Api.Engine;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Api.Endpoints;
@@ -46,8 +47,11 @@ public static class ConnectionsEndpoints
}
});
g.MapDelete("/{id}", (string id, ConnectionRepository repo) =>
repo.Delete(id) ? Results.NoContent() : Results.NotFound());
g.MapDelete("/{id}", async (string id, ConnectionRepository repo, ConnectionInstanceRegistry instances) =>
{
await instances.InvalidateAsync(id);
return repo.Delete(id) ? Results.NoContent() : Results.NotFound();
});
}
private static JsonElement StripSecrets(string configJson, IReadOnlySet<string> secretFields)

View File

@@ -0,0 +1,69 @@
using System.Text.Json;
using VolcanicCore.Api.Registries;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Api.Engine;
public sealed class ConnectionInstanceRegistry : IConnectionInstanceRegistry, IAsyncDisposable
{
private sealed record Entry(string ProviderId, object Handle);
private readonly ConnectionRepository _repo;
private readonly ConnectionProviderRegistry _providers;
private readonly Dictionary<string, Entry> _cache = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _mu = new(1, 1);
public ConnectionInstanceRegistry(ConnectionRepository repo, ConnectionProviderRegistry providers)
{
_repo = repo;
_providers = providers;
}
public async Task<object> GetAsync(string connectionId, CancellationToken ct)
{
await _mu.WaitAsync(ct);
try
{
if (_cache.TryGetValue(connectionId, out var hit)) return hit.Handle;
var row = _repo.Get(connectionId)
?? throw new KeyNotFoundException($"Connection {connectionId} not found.");
var provider = _providers.Get(row.ProviderId);
using var doc = JsonDocument.Parse(row.ConfigJson);
var handle = await provider.OpenAsync(doc.RootElement.Clone(), ct);
_cache[connectionId] = new Entry(row.ProviderId, handle);
return handle;
}
finally { _mu.Release(); }
}
public async Task InvalidateAsync(string connectionId)
{
await _mu.WaitAsync();
Entry? removed = null;
try
{
if (_cache.Remove(connectionId, out var e)) removed = e;
}
finally { _mu.Release(); }
if (removed is not null)
await _providers.Get(removed.ProviderId).CloseAsync(removed.Handle);
}
public async ValueTask DisposeAsync()
{
Entry[] snapshot;
await _mu.WaitAsync();
try
{
snapshot = _cache.Values.ToArray();
_cache.Clear();
}
finally { _mu.Release(); }
foreach (var e in snapshot)
try { await _providers.Get(e.ProviderId).CloseAsync(e.Handle); }
catch { /* best-effort on shutdown */ }
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using VolcanicCore.Api;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Endpoints;
using VolcanicCore.Api.Engine;
using VolcanicCore.Api.Registries;
using VolcanicCore.Core;
using VolcanicCore.Persistence;
@@ -112,6 +113,10 @@ builder.Services.AddSingleton<NodeRegistry>(sp =>
builder.Services.AddSingleton<ConnectionProviderRegistry>(sp =>
new ConnectionProviderRegistry(sp.GetServices<IConnectionProvider>()));
builder.Services.AddSingleton<ConnectionInstanceRegistry>();
builder.Services.AddSingleton<IConnectionInstanceRegistry>(sp =>
sp.GetRequiredService<ConnectionInstanceRegistry>());
var app = builder.Build();
// Apply migrations at boot.