perf(site-runtime): instance reconcile sweep reads metadata only and fetches config_json just for the instances it repairs (review 2026-10-21 01/SR-P1)
Adds SiteStorageService.GetAllDeployedConfigMetadataAsync. The sweep decides from the projection, then fetches single rows for its starts/restarts off- thread and re-validates them (generation guard, revision match, pipeline/ running guards), releasing the per-revision claim of any repair that does not act. Boot load unchanged.
This commit is contained in:
@@ -107,6 +107,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// </remarks>
|
||||
private readonly Func<string, Task<DeployedInstance?>> _deployedConfigLoader;
|
||||
/// <summary>
|
||||
/// Reads every deployed row's identity metadata WITHOUT <c>config_json</c> for the instance
|
||||
/// reconcile sweep (review 2026-10-21 01/SR-P1). Defaults to
|
||||
/// <see cref="SiteStorageService.GetAllDeployedConfigMetadataAsync"/>; overridable via the ctor.
|
||||
/// Deliberately separate from <see cref="_configLoader"/>, which stays the boot-only full read.
|
||||
/// </summary>
|
||||
private readonly Func<Task<List<DeployedConfigMetadata>>> _configMetadataLoader;
|
||||
/// <summary>
|
||||
/// Reads the durable <c>data_connection_definitions</c> rows (deferred register rows 89 + 90).
|
||||
/// Defaults to <see cref="SiteStorageService.GetAllDataConnectionDefinitionsAsync"/>;
|
||||
/// overridable via the ctor so a test can drive a read failure.
|
||||
@@ -315,6 +322,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// ensure-started heal uses; defaults to reading from <paramref name="storage"/>. Primarily
|
||||
/// for tests that need to hold the read open mid-flight to drive the freshness race.
|
||||
/// </param>
|
||||
/// <param name="configMetadataLoader">
|
||||
/// Optional override for the instance reconcile sweep's metadata-only read (01/SR-P1);
|
||||
/// defaults to reading from <paramref name="storage"/>.
|
||||
/// </param>
|
||||
/// <param name="secretsMirror">
|
||||
/// This node's <see cref="DeployedSecretsMirror"/>,
|
||||
/// reconciled synchronously by <see cref="HandleDeployArtifacts"/> right after a shipped
|
||||
@@ -339,7 +350,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
ScriptExecutionScheduler? scriptScheduler = null,
|
||||
Func<Task<List<StoredDataConnectionDefinition>>>? connectionDefinitionLoader = null,
|
||||
Func<string, Task<DeployedInstance?>>? deployedConfigLoader = null,
|
||||
DeployedSecretsMirror? secretsMirror = null)
|
||||
DeployedSecretsMirror? secretsMirror = null,
|
||||
Func<Task<List<DeployedConfigMetadata>>>? configMetadataLoader = null)
|
||||
{
|
||||
_scriptScheduler = scriptScheduler;
|
||||
_secretsMirror = secretsMirror;
|
||||
@@ -360,6 +372,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
: TimeSpan.Zero;
|
||||
_configLoader = configLoader ?? (() => _storage.GetAllDeployedConfigsAsync());
|
||||
_deployedConfigLoader = deployedConfigLoader ?? (name => _storage.GetDeployedConfigAsync(name));
|
||||
_configMetadataLoader = configMetadataLoader ?? (() => _storage.GetAllDeployedConfigMetadataAsync());
|
||||
_connectionDefinitionLoader = connectionDefinitionLoader
|
||||
?? (() => _storage.GetAllDataConnectionDefinitionsAsync());
|
||||
_logger = logger;
|
||||
@@ -405,6 +418,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
// no longer matches the stored one (row 72).
|
||||
Receive<InstanceSweepTick>(_ => HandleInstanceSweepTick());
|
||||
Receive<InstanceSweepLoaded>(HandleInstanceSweepLoaded);
|
||||
// 01/SR-P1: the full rows of the instances a sweep decided to start/restart.
|
||||
Receive<InstanceSweepRepairsLoaded>(HandleInstanceSweepRepairsLoaded);
|
||||
// Terminal reply of a sweep-driven stale-revision restart. Every OTHER deploy path
|
||||
// replies to central (or to a test probe); only the sweep makes this actor its own
|
||||
// deployer, so a response landing here is always its own and is surfaced, never
|
||||
@@ -624,20 +639,39 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
ReadStoreSnapshotAsync()
|
||||
{
|
||||
var configs = await _configLoader().ConfigureAwait(false);
|
||||
return (configs, await ReadConnectionDefinitionsAsync().ConfigureAwait(false));
|
||||
}
|
||||
|
||||
IReadOnlyList<StoredDataConnectionDefinition>? definitions = null;
|
||||
/// <summary>
|
||||
/// The sweep's variant of <see cref="ReadStoreSnapshotAsync"/> (review 2026-10-21 01/SR-P1):
|
||||
/// identical failure semantics, but the configs half is the metadata-only projection — the
|
||||
/// sweep compares name / enabled / revision only, and fetches a full row solely for the
|
||||
/// instances it goes on to start or restart (<see cref="HandleInstanceSweepRepairsLoaded"/>).
|
||||
/// </summary>
|
||||
private async Task<(List<DeployedConfigMetadata> Configs, IReadOnlyList<StoredDataConnectionDefinition>? Definitions)>
|
||||
ReadSweepSnapshotAsync()
|
||||
{
|
||||
var configs = await _configMetadataLoader().ConfigureAwait(false);
|
||||
return (configs, await ReadConnectionDefinitionsAsync().ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The definitions half of a store snapshot; a failure is swallowed into
|
||||
/// <see langword="null"/> (keep the overlay) — see <see cref="ReadStoreSnapshotAsync"/>.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<StoredDataConnectionDefinition>?> ReadConnectionDefinitionsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
definitions = await _connectionDefinitionLoader().ConfigureAwait(false);
|
||||
return await _connectionDefinitionLoader().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex, "Could not read the data connection definitions — the DCL arbitration "
|
||||
+ "overlay keeps its previous contents until the next read succeeds");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (configs, definitions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1949,7 +1983,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
|
||||
// Row 101: capture on the actor thread BEFORE the off-thread read starts.
|
||||
var generationAtRead = _storeWriteGeneration;
|
||||
ReadStoreSnapshotAsync().ContinueWith(t => t.IsCompletedSuccessfully
|
||||
ReadSweepSnapshotAsync().ContinueWith(t => t.IsCompletedSuccessfully
|
||||
? new InstanceSweepLoaded(t.Result.Configs, null, t.Result.Definitions, generationAtRead)
|
||||
: new InstanceSweepLoaded([], t.Exception?.GetBaseException().Message))
|
||||
.PipeTo(Self);
|
||||
@@ -1993,6 +2027,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
// row out of its own file on the next tick.
|
||||
ApplyConnectionDefinitionOverlay(msg.ConnectionDefinitions);
|
||||
|
||||
var repairs = new List<SweepRepair>();
|
||||
foreach (var config in msg.Configs)
|
||||
{
|
||||
var instanceName = config.InstanceUniqueName;
|
||||
@@ -2010,10 +2045,94 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
if (!_instanceActors.ContainsKey(instanceName))
|
||||
{
|
||||
// Row 78 — an enabled config with no actor.
|
||||
if (!TryClaimSweepAttempt(instanceName, config.RevisionHash))
|
||||
continue;
|
||||
if (TryClaimSweepAttempt(instanceName, config.RevisionHash))
|
||||
repairs.Add(new SweepRepair(instanceName, config.RevisionHash, Restart: false));
|
||||
continue;
|
||||
}
|
||||
|
||||
StartStoredInstance(config, SweepReason,
|
||||
// Row 72 — a running actor serving a revision the store has replaced.
|
||||
if (IsRunningStaleRevision(config))
|
||||
repairs.Add(new SweepRepair(instanceName, config.RevisionHash, Restart: true));
|
||||
}
|
||||
|
||||
if (repairs.Count == 0)
|
||||
return;
|
||||
|
||||
// 01/SR-P1: the snapshot carried no config_json, so fetch the full rows of exactly the
|
||||
// instances being repaired — off-thread, one single-row read each — and act when they
|
||||
// land. Ticks stay suppressed meanwhile so a slow fetch cannot overlap the next read.
|
||||
_instanceSweepInFlight = true;
|
||||
var generationAtFetch = _storeWriteGeneration;
|
||||
var loader = _deployedConfigLoader;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
var rows = new Dictionary<string, DeployedInstance?>(StringComparer.Ordinal);
|
||||
foreach (var repair in repairs)
|
||||
rows[repair.InstanceName] = await loader(repair.InstanceName).ConfigureAwait(false);
|
||||
return rows;
|
||||
}).ContinueWith(t => t.IsCompletedSuccessfully
|
||||
? new InstanceSweepRepairsLoaded(repairs, t.Result, null, generationAtFetch)
|
||||
: new InstanceSweepRepairsLoaded(
|
||||
repairs, null, t.Exception?.GetBaseException().Message, generationAtFetch))
|
||||
.PipeTo(Self);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actor-thread half of a sweep's repairs (review 2026-10-21 01/SR-P1): the full rows for the
|
||||
/// instances <see cref="HandleInstanceSweepLoaded"/> decided to start or restart have landed.
|
||||
///
|
||||
/// <para>
|
||||
/// Every decision is RE-VALIDATED against the fetched row and the current runtime state,
|
||||
/// because both may have moved while the fetch was in flight: a snapshot this node has since
|
||||
/// mutated past is dropped whole (row 101's generation guard, exactly as for the metadata
|
||||
/// read); a row that disappeared, was disabled, or now carries a DIFFERENT revision than the
|
||||
/// one claimed is skipped; and the deploy-pipeline / running-actor guards are re-checked. A
|
||||
/// repair that does not act RELEASES its per-revision claim, so the next tick re-evaluates it
|
||||
/// rather than the instance being silently written off.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void HandleInstanceSweepRepairsLoaded(InstanceSweepRepairsLoaded msg)
|
||||
{
|
||||
_instanceSweepInFlight = false;
|
||||
|
||||
if (msg.Error != null || msg.Rows == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Instance reconcile sweep could not read the configs it meant to repair: {Error}", msg.Error);
|
||||
foreach (var repair in msg.Repairs)
|
||||
ReleaseSweepAttempt(repair.InstanceName, repair.RevisionHash);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsStoreSnapshotStale(msg.StoreGenerationAtRead, "instance sweep repair"))
|
||||
{
|
||||
foreach (var repair in msg.Repairs)
|
||||
ReleaseSweepAttempt(repair.InstanceName, repair.RevisionHash);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var repair in msg.Repairs)
|
||||
{
|
||||
var name = repair.InstanceName;
|
||||
msg.Rows.TryGetValue(name, out var row);
|
||||
|
||||
if (row == null || !row.IsEnabled ||
|
||||
!string.Equals(row.RevisionHash, repair.RevisionHash, StringComparison.Ordinal) ||
|
||||
InstanceIsOwnedByADeployPipeline(name))
|
||||
{
|
||||
ReleaseSweepAttempt(name, repair.RevisionHash);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!repair.Restart)
|
||||
{
|
||||
if (_instanceActors.ContainsKey(name))
|
||||
{
|
||||
ReleaseSweepAttempt(name, repair.RevisionHash);
|
||||
continue;
|
||||
}
|
||||
|
||||
StartStoredInstance(row, SweepReason,
|
||||
"An enabled instance had a deployed config in the site store but no running "
|
||||
+ "Instance Actor. Configs that arrive by LocalDb replication (a standby "
|
||||
+ "seeded from its peer, or a snapshot resync) are written after the "
|
||||
@@ -2022,8 +2141,29 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
continue;
|
||||
}
|
||||
|
||||
// Row 72 — a running actor serving a revision the store has replaced.
|
||||
TryRestartStaleInstance(config);
|
||||
if (!_instanceActors.ContainsKey(name) ||
|
||||
!_runningRevisions.TryGetValue(name, out var runningRevision) ||
|
||||
string.Equals(runningRevision, row.RevisionHash, StringComparison.Ordinal))
|
||||
{
|
||||
ReleaseSweepAttempt(name, repair.RevisionHash);
|
||||
continue;
|
||||
}
|
||||
|
||||
RestartStaleInstance(row, runningRevision);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives back a sweep claim that was taken but not acted on, so the next tick may try the
|
||||
/// same (instance, revision) again. Only releases the exact claim — a newer revision claimed
|
||||
/// in the meantime is left alone.
|
||||
/// </summary>
|
||||
private void ReleaseSweepAttempt(string instanceName, string revisionHash)
|
||||
{
|
||||
if (_sweepAttemptedRevisions.TryGetValue(instanceName, out var claimed) &&
|
||||
string.Equals(claimed, revisionHash, StringComparison.Ordinal))
|
||||
{
|
||||
_sweepAttemptedRevisions.Remove(instanceName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,15 +2215,22 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// entirely while a deploy pipeline owns the instance, so it can never interleave with a
|
||||
/// real redeploy.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This half only DECIDES and claims the attempt from the metadata snapshot; the config body
|
||||
/// is fetched afterwards and the restart performed by <see cref="RestartStaleInstance"/>
|
||||
/// (review 2026-10-21 01/SR-P1).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void TryRestartStaleInstance(DeployedInstance config)
|
||||
/// <returns><see langword="true"/> when a restart should be attempted (claim taken).</returns>
|
||||
private bool IsRunningStaleRevision(DeployedConfigMetadata config)
|
||||
{
|
||||
var instanceName = config.InstanceUniqueName;
|
||||
var storedRevision = config.RevisionHash;
|
||||
|
||||
// No stored revision to compare against — nothing can be concluded, so do nothing.
|
||||
if (string.IsNullOrEmpty(storedRevision))
|
||||
return;
|
||||
return false;
|
||||
|
||||
// No recorded running revision. Unreachable while CreateInstanceActor is the single
|
||||
// creation funnel; treated as "unknown", never as "stale", so a future path that
|
||||
@@ -2091,14 +2238,23 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
if (!_runningRevisions.TryGetValue(instanceName, out var runningRevision) ||
|
||||
string.IsNullOrEmpty(runningRevision))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(runningRevision, storedRevision, StringComparison.Ordinal))
|
||||
return;
|
||||
return false;
|
||||
|
||||
if (!TryClaimSweepAttempt(instanceName, storedRevision))
|
||||
return;
|
||||
return TryClaimSweepAttempt(instanceName, storedRevision);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second half of the row-72 repair: re-applies the fetched stored row through the redeploy
|
||||
/// path (see <see cref="IsRunningStaleRevision"/> for the decision and its safety rules).
|
||||
/// </summary>
|
||||
private void RestartStaleInstance(DeployedInstance config, string runningRevision)
|
||||
{
|
||||
var instanceName = config.InstanceUniqueName;
|
||||
var storedRevision = config.RevisionHash;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Instance {Instance} is running stale revision {RunningRevision} while the deployed-config "
|
||||
@@ -2556,7 +2712,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// STOPPING live connections that nothing re-creates until an instance redeploy; rows push
|
||||
/// superseded configs onto live connections and flip-flop with the next fresh read.</item>
|
||||
/// <item>CONFIGS half, deploy-revision leg: a snapshot carrying the revision a deploy has just
|
||||
/// replaced makes <see cref="TryRestartStaleInstance"/> "repair" the instance BACKWARD —
|
||||
/// replaced makes <see cref="RestartStaleInstance"/> "repair" the instance BACKWARD —
|
||||
/// it redeploys the snapshot's own (old) config JSON through the redeploy path.
|
||||
/// <see cref="InstanceIsOwnedByADeployPipeline"/> does not cover this: it only holds while
|
||||
/// the deploy is in flight, and the stale snapshot can land after the pipeline released
|
||||
@@ -3901,11 +4057,32 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// complete, and the next tick is already a fresh read.
|
||||
/// </param>
|
||||
internal record InstanceSweepLoaded(
|
||||
List<DeployedInstance> Configs,
|
||||
List<DeployedConfigMetadata> Configs,
|
||||
string? Error,
|
||||
IReadOnlyList<StoredDataConnectionDefinition>? ConnectionDefinitions = null,
|
||||
long StoreGenerationAtRead = 0);
|
||||
|
||||
/// <summary>
|
||||
/// One repair a sweep decided on from the metadata snapshot (01/SR-P1): start a missing
|
||||
/// instance, or restart one running a stale revision, at <paramref name="RevisionHash"/>.
|
||||
/// </summary>
|
||||
internal sealed record SweepRepair(string InstanceName, string RevisionHash, bool Restart);
|
||||
|
||||
/// <summary>
|
||||
/// Carries the full rows of a sweep's repairs back onto the actor thread (01/SR-P1).
|
||||
/// <paramref name="Rows"/> is null when the fetch failed (<paramref name="Error"/> set); a
|
||||
/// <see langword="null"/> VALUE means the row is gone.
|
||||
/// </summary>
|
||||
/// <param name="StoreGenerationAtRead">
|
||||
/// <see cref="_storeWriteGeneration"/> when the fetch was dispatched — same row-101 guard as
|
||||
/// <see cref="InstanceSweepLoaded"/>.
|
||||
/// </param>
|
||||
internal sealed record InstanceSweepRepairsLoaded(
|
||||
IReadOnlyList<SweepRepair> Repairs,
|
||||
IReadOnlyDictionary<string, DeployedInstance?>? Rows,
|
||||
string? Error,
|
||||
long StoreGenerationAtRead);
|
||||
|
||||
/// <summary>
|
||||
/// Row 101: piped back to self once an off-thread mutation of one of the two snapshot tables
|
||||
/// has COMMITTED, purely so <see cref="_storeWriteGeneration"/> can be bumped on the actor
|
||||
|
||||
@@ -194,6 +194,45 @@ public class SiteStorageService
|
||||
IsEnabled: reader.GetInt64(3) != 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the deployment-identity metadata of EVERY deployed instance —
|
||||
/// <c>(name, deployment id, revision hash, is_enabled)</c> — without reading the
|
||||
/// <c>config_json</c> column.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Review 2026-10-21 01/SR-P1: the Deployment Manager's instance reconcile sweep runs every
|
||||
/// <c>InstanceSweepIntervalSeconds</c> (default 60 s) and compares only name / enabled /
|
||||
/// revision, but it used to read through <see cref="GetAllDeployedConfigsAsync"/> — on a
|
||||
/// 500-instance site with ~70 KB configs that is ~35 MB of large-object-heap strings per tick
|
||||
/// on a perfectly healthy site, forever. The sweep now reads this projection and fetches the
|
||||
/// full row via <see cref="GetDeployedConfigAsync"/> only for the instances it actually starts
|
||||
/// or restarts. The boot load still uses <see cref="GetAllDeployedConfigsAsync"/> — it runs
|
||||
/// once and needs every blob.
|
||||
/// </remarks>
|
||||
/// <returns>A task that resolves to one metadata row per deployed instance.</returns>
|
||||
public async Task<List<DeployedConfigMetadata>> GetAllDeployedConfigMetadataAsync()
|
||||
{
|
||||
await using var connection = OpenConnection();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT instance_unique_name, deployment_id, revision_hash, is_enabled
|
||||
FROM deployed_configurations";
|
||||
|
||||
var results = new List<DeployedConfigMetadata>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results.Add(new DeployedConfigMetadata(
|
||||
InstanceUniqueName: reader.GetString(0),
|
||||
DeploymentId: reader.GetString(1),
|
||||
RevisionHash: reader.GetString(2),
|
||||
IsEnabled: reader.GetInt64(3) != 0));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a deployed instance configuration. Uses UPSERT semantics.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user