perf(siteruntime): projected single-row deployed-config reads (P1-4)

HandleEnsureInstanceStarted, HandleDeploymentStateQuery, and HandleEnable each
called GetAllDeployedConfigsAsync — materializing every deployed config,
including every ConfigJson blob — just to FirstOrDefault one row. A
volume-restore reconcile healing hundreds of instances turned that into
hundreds of full-table reads.

Add SiteStorageService.GetDeployedConfigAsync(name) (single-row, full
projection incl. ConfigJson, for callers that (re)start an Instance Actor)
and GetDeployedConfigMetadataAsync(name) (single-row, ConfigJson-free
projection, for callers that only need DeploymentId/RevisionHash/IsEnabled).
Switch the three call sites; leave decisions/semantics identical.

HandleEnsureInstanceStarted gets its own _deployedConfigLoader seam,
deliberately separate from the existing _configLoader (still used by the
boot-time load, which legitimately needs every row) so a test overriding one
cannot accidentally gate the other. Generation fencing (StoreGenerationAtRead,
Medium wave T1) is untouched: the generation is still captured on the actor
thread before the read dispatches.

Tests: new SiteStorageServiceTests lock in that both projections return the
same values GetAllDeployedConfigsAsync().FirstOrDefault(...) did. The three
row-101 ensure-started race tests (ReconcileStartsInstanceTests) are ported
to the new seam and still pass, proving generation fencing survives the
projection. Proof of failure: since this is a pure I/O-cost fix with no
observable behavior change, the new/changed tests were confirmed to fail to
even compile against the unfixed code (stash/unstash) — GetDeployedConfigAsync,
GetDeployedConfigMetadataAsync, and the deployedConfigLoader ctor param don't
exist there.
This commit is contained in:
Joseph Doherty
2026-08-19 16:15:24 -04:00
parent 942acc37b4
commit 65b3c23239
4 changed files with 227 additions and 24 deletions
@@ -92,7 +92,8 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// </summary>
private IActorRef CreateDeploymentManager(
IServiceProvider? serviceProvider = null,
Func<Task<List<DeployedInstance>>>? configLoader = null)
Func<Task<List<DeployedInstance>>>? configLoader = null,
Func<string, Task<DeployedInstance?>>? deployedConfigLoader = null)
{
var health = new DeploymentManagerStartupProbe();
var dm = ActorOf(Props.Create(() => new DeploymentManagerActor(
@@ -109,7 +110,11 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
configFetcher: null,
// In position: an expression tree rejects a named argument that skips a parameter.
startupLoadRetryInterval: null,
configLoader: configLoader)));
configLoader: configLoader,
externalSystemCache: null,
scriptScheduler: null,
connectionDefinitionLoader: null,
deployedConfigLoader: deployedConfigLoader)));
AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20),
TimeSpan.FromMilliseconds(25));
@@ -409,7 +414,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
{
const string instanceName = "RacedDisablePump";
var loader = new GatedConfigLoader(_storage);
var dm = CreateDeploymentManager(configLoader: loader.LoadAsync);
var dm = CreateDeploymentManager(deployedConfigLoader: loader.LoadAsync);
await _storage.StoreDeployedConfigAsync(
instanceName, MakeConfigJson(instanceName), "dep-rd", "rev-rd", true);
@@ -451,7 +456,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
{
const string instanceName = "RacedDeletePump";
var loader = new GatedConfigLoader(_storage);
var dm = CreateDeploymentManager(configLoader: loader.LoadAsync);
var dm = CreateDeploymentManager(deployedConfigLoader: loader.LoadAsync);
await _storage.StoreDeployedConfigAsync(
instanceName, MakeConfigJson(instanceName), "dep-rx", "rev-rx", true);
@@ -487,7 +492,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
{
const string instanceName = "UnracedPump";
var loader = new GatedConfigLoader(_storage);
var dm = CreateDeploymentManager(configLoader: loader.LoadAsync);
var dm = CreateDeploymentManager(deployedConfigLoader: loader.LoadAsync);
await _storage.StoreDeployedConfigAsync(
instanceName, MakeConfigJson(instanceName), "dep-ok", "rev-ok", true);
@@ -510,15 +515,15 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
.ResolveOne(TimeSpan.FromSeconds(5));
/// <summary>
/// A deployed-config loader that can be held open mid-read, so a test can commit a
/// lifecycle write while a heal's snapshot is provably in flight rather than hoping the
/// two race the right way round.
/// A single-instance deployed-config loader that can be held open mid-read, so a test can
/// commit a lifecycle write while a heal's snapshot is provably in flight rather than hoping
/// the two race the right way round.
///
/// <para>
/// Reads real storage every time and only parks AFTER the read, so the returned snapshot is
/// exactly what production would have seen at that instant. Inert until <see cref="Arm"/>
/// is called, which keeps the Deployment Manager's boot load (which runs through this same
/// seam) unblocked.
/// is called, which keeps the Deployment Manager's boot load — a separate seam
/// (<c>_configLoader</c>, register P1-4) untouched by this gate — unblocked.
/// </para>
/// </summary>
private sealed class GatedConfigLoader(SiteStorageService storage)
@@ -534,16 +539,16 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
public Task WaitUntilParkedAsync() => _parked.Task.WaitAsync(TimeSpan.FromSeconds(10));
public async Task<List<DeployedInstance>> LoadAsync()
public async Task<DeployedInstance?> LoadAsync(string instanceName)
{
var configs = await storage.GetAllDeployedConfigsAsync();
var config = await storage.GetDeployedConfigAsync(instanceName);
var gate = _release;
if (gate != null)
{
_parked.TrySetResult();
await gate.Task;
}
return configs;
return config;
}
}
@@ -88,6 +88,80 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.True(configs[0].IsEnabled);
}
// ── Register P1-4: projected single-row reads ──
//
// GetAllDeployedConfigsAsync + FirstOrDefault materializes every deployed config —
// including every ConfigJson blob — to find one row. These lock in that
// GetDeployedConfigAsync (single row, still carries ConfigJson) and
// GetDeployedConfigMetadataAsync (single row, deliberately WITHOUT ConfigJson) return
// exactly the same decision the old full-table-plus-FirstOrDefault path did — the
// defect here is pure I/O cost, not an observable behavior difference, so these tests
// pin the new projection's contract rather than reproduce a behavior bug.
[Fact]
public async Task GetDeployedConfigAsync_ReturnsSameRowAs_GetAllThenFirstOrDefault()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{\"test\":true}", "dep-001", "sha256:abc", isEnabled: true);
await _storage.StoreDeployedConfigAsync(
"Pump2", "{\"other\":1}", "dep-002", "sha256:def", isEnabled: false);
var viaProjection = await _storage.GetDeployedConfigAsync("Pump1");
var viaFullScan = (await _storage.GetAllDeployedConfigsAsync())
.FirstOrDefault(c => c.InstanceUniqueName == "Pump1");
Assert.NotNull(viaProjection);
Assert.NotNull(viaFullScan);
Assert.Equal(viaFullScan.InstanceUniqueName, viaProjection.InstanceUniqueName);
Assert.Equal(viaFullScan.ConfigJson, viaProjection.ConfigJson);
Assert.Equal(viaFullScan.DeploymentId, viaProjection.DeploymentId);
Assert.Equal(viaFullScan.RevisionHash, viaProjection.RevisionHash);
Assert.Equal(viaFullScan.IsEnabled, viaProjection.IsEnabled);
Assert.Equal(viaFullScan.DeployedAt, viaProjection.DeployedAt);
}
[Fact]
public async Task GetDeployedConfigAsync_UnknownInstance_ReturnsNull()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{}", "dep-001", "sha256:abc", isEnabled: true);
var result = await _storage.GetDeployedConfigAsync("NeverHeardOf");
Assert.Null(result);
}
[Fact]
public async Task GetDeployedConfigMetadataAsync_ReturnsSameIdentityAs_GetAllThenFirstOrDefault()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{\"test\":true}", "dep-001", "sha256:abc", isEnabled: true);
await _storage.StoreDeployedConfigAsync(
"Pump2", "{\"other\":1}", "dep-002", "sha256:def", isEnabled: false);
var metadata = await _storage.GetDeployedConfigMetadataAsync("Pump2");
var viaFullScan = (await _storage.GetAllDeployedConfigsAsync())
.FirstOrDefault(c => c.InstanceUniqueName == "Pump2");
Assert.NotNull(metadata);
Assert.NotNull(viaFullScan);
Assert.Equal(viaFullScan.InstanceUniqueName, metadata.InstanceUniqueName);
Assert.Equal(viaFullScan.DeploymentId, metadata.DeploymentId);
Assert.Equal(viaFullScan.RevisionHash, metadata.RevisionHash);
Assert.Equal(viaFullScan.IsEnabled, metadata.IsEnabled);
}
[Fact]
public async Task GetDeployedConfigMetadataAsync_UnknownInstance_ReturnsNull()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{}", "dep-001", "sha256:abc", isEnabled: true);
var result = await _storage.GetDeployedConfigMetadataAsync("NeverHeardOf");
Assert.Null(result);
}
[Fact]
public async Task StoreDeployedConfig_Upserts_OnConflict()
{