fix(controlplane): validate and compose a deployment from ONE snapshot read

StartDeployment read the whole config DB twice with no transaction — DraftSnapshotFactory for
the validator, then ConfigComposer for the artifact, plus clusters/nodes and Script.SourceCode
again in between. An AdminUI save landing between the two shipped an artifact the DraftValidator
never saw.

- DraftSnapshot carries ServerClusters, ClusterNodes and ApiKeys; DraftSnapshotFactory reads
  every table inside one SNAPSHOT transaction on a relational provider (no-op on InMemory or
  under an ambient transaction).
- ConfigComposer.Compose(DraftSnapshot) is pure over the snapshot (ordinal-sorted keys);
  SnapshotAndFlattenAsync(db) delegates to it, byte-identical.
- AdminOperationsActor validates topology, estimates compile cost and composes the artifact
  from the one snapshot it holds.
- Migration EnableSnapshotIsolation: ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON
  (suppressTransaction; no model change).

Tests: the SQL Server snapshot read ignores a row committed mid-read (positive control: a later
read sees it); an edit injected between the Equipment and Tags reads is absent from the shipped
artifact and rejected by the next deploy; Compose(snapshot) == SnapshotAndFlattenAsync(db).

Refs #733

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 06:36:12 -04:00
parent b437c34f92
commit 73e74e270d
10 changed files with 2077 additions and 36 deletions
@@ -223,18 +223,14 @@ public sealed class AdminOperationsActor : ReceiveActor
// pair on the row itself, but it cannot see the per-node ClusterNode.Enabled flag — an
// operator can disable a node (effective enabled-count = 1) while leaving RedundancyMode at
// Hot/Warm and the constraint stays green, which would boot the runtime into an
// InvalidTopology band. ValidateClusterTopology catches that drift, but it isn't carried on
// the generation-versioned DraftSnapshot (the cluster/node rows aren't versioned), so it must
// be run separately here against the live rows. Read-only (AsNoTracking); errors fold into the
// same reject summary alongside the snapshot rules so a deploy failing either check is
// rejected with both sets of messages. ClusterId-ordered for a deterministic summary.
var clusters = await db.ServerClusters
.AsNoTracking()
.OrderBy(c => c.ClusterId)
.ToListAsync();
var nodesByCluster = (await db.ClusterNodes
.AsNoTracking()
.ToListAsync())
// InvalidTopology band. ValidateClusterTopology catches that drift; it is not part of
// DraftValidator.Validate, so it runs separately here over the snapshot's cluster/node rows.
// Errors fold into the same reject summary alongside the snapshot rules so a deploy failing
// either check is rejected with both sets of messages. ClusterId-ordered for a deterministic summary.
// R03:3.2 — both come off the SAME snapshot read as every other table (one SNAPSHOT
// transaction), not a second query that could observe a later edit.
var clusters = draft.ServerClusters.OrderBy(c => c.ClusterId, StringComparer.Ordinal).ToList();
var nodesByCluster = draft.ClusterNodes
.GroupBy(n => n.ClusterId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
foreach (var cluster in clusters)
@@ -310,8 +306,9 @@ public sealed class AdminOperationsActor : ReceiveActor
try
{
const double PerScriptMiB = 1.66; // measured post-A0 per-script RSS (design doc 2026-06-07)
var scriptSources = await db.Scripts.AsNoTracking().Select(s => s.SourceCode).ToListAsync();
var compiled = scriptSources
// Projected from the snapshot already in hand — Script.SourceCode used to be read three times.
var compiled = draft.Scripts
.Select(s => s.SourceCode)
.Where(src => !PassthroughScript.TryMatch(src, out _))
.Distinct(StringComparer.Ordinal)
.Count();
@@ -331,7 +328,9 @@ public sealed class AdminOperationsActor : ReceiveActor
advisory = null;
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(db);
// Composed from the validated snapshot, never from a second read: an edit that lands between
// validation and here is simply not in this artifact (R03:3.2).
var artifact = ConfigComposer.Compose(draft);
var deploymentId = DeploymentId.NewId();
var revHash = RevisionHash.Parse(artifact.RevisionHash);