perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:22 -04:00
parent ee193cd2bb
commit 48b3c40a7f
59 changed files with 3051 additions and 231 deletions
@@ -266,7 +266,7 @@ public class ManagementActor : ReceiveActor
PreviewBundleCommand or ImportBundleCommand => AdminOnly,
// Deployer operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtEnableInstanceCommand
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
@@ -339,6 +339,7 @@ public class ManagementActor : ReceiveActor
GetInstanceCommand cmd => await HandleGetInstance(sp, cmd, user),
CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user),
MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user),
MgmtDeploySiteCommand cmd => await HandleDeploySite(sp, cmd, user),
MgmtEnableInstanceCommand cmd => await HandleEnableInstance(sp, cmd, user),
MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user),
MgmtDeleteInstanceCommand cmd => await HandleDeleteInstance(sp, cmd, user),
@@ -568,8 +569,15 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleListTemplates(IServiceProvider sp, ListTemplatesCommand cmd)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var templates = await repo.GetAllTemplatesAsync();
return Page(templates, cmd.Skip, cmd.Take);
// Paged and projected in the DATABASE. This used to load every template's
// full child graph — five Includes under AsSplitQuery, including every
// script body in the system — and then throw all but one page away in
// memory, so listing page 1 of 20 cost the same as listing everything.
// Child collections are reduced to counts, which is what every list
// surface (CLI table, UI tree) actually renders; a caller that needs a
// template's members already fetches it with GetTemplate.
return await repo.GetTemplateSummariesAsync(cmd.Skip, cmd.Take);
}
// Additive offset paging (arch-review P2). Take = null (or non-positive) keeps
@@ -830,6 +838,26 @@ public class ManagementActor : ReceiveActor
: throw new ManagementCommandException(result.Error);
}
/// <summary>
/// Bulk-deploys every deployable instance at a site.
///
/// <para>
/// Site scope is enforced on the SITE (not per instance) because the site is
/// the command's target: a site-scoped Deployer may bulk-deploy only sites in
/// their scope, and every instance the batch touches belongs to that site by
/// construction — so the per-instance check would be redundant.
/// </para>
/// </summary>
private static async Task<object?> HandleDeploySite(IServiceProvider sp, MgmtDeploySiteCommand cmd, AuthenticatedUser user)
{
EnforceSiteScope(user, cmd.SiteId);
var svc = sp.GetRequiredService<DeploymentService>();
var result = await svc.DeploySiteAsync(cmd.SiteId, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
@@ -900,14 +928,58 @@ public class ManagementActor : ReceiveActor
$"Attribute '{attrName}' is locked and cannot be overridden. No overrides were applied.");
}
var svc = sp.GetRequiredService<InstanceService>();
var results = new List<InstanceAttributeOverride>();
// Apply as ONE read + ONE commit. The previous loop called
// InstanceService.SetAttributeOverrideAsync per entry, and each of those
// re-read the instance, re-read the template's attributes, re-read the
// instance's existing overrides, then committed and wrote an audit row —
// K read-modify-commit cycles for what is logically one batch. That is
// both K× the round trips and K× the windows in which a mid-batch fault
// can leave the instance partially mutated, which is exactly what the
// pre-validation above was bolted on to mitigate.
//
// Now: the existing override rows are read once in bulk, every entry is
// added or updated against that snapshot, and a single SaveChangesAsync
// commits the whole batch — making the all-or-nothing promise real rather
// than best-effort. One audit row summarises the batch.
var existingOverrides = await repo.GetOverridesByInstanceIdAsync(cmd.InstanceId);
var existingByName = existingOverrides
.GroupBy(o => o.AttributeName, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var results = new List<InstanceAttributeOverride>(cmd.Overrides.Count);
foreach (var (attrName, overrideValue) in cmd.Overrides)
{
var result = await svc.SetAttributeOverrideAsync(cmd.InstanceId, attrName, overrideValue, user.Username);
if (!result.IsSuccess) throw new ManagementCommandException(result.Error);
results.Add(result.Value);
// attrsByName lookup is guaranteed to hit — the pre-validation loop
// above rejects the whole batch on an unknown or locked attribute.
var elementDataType = attrsByName[attrName].ElementDataType;
if (existingByName.TryGetValue(attrName, out var existing))
{
existing.OverrideValue = overrideValue;
existing.ElementDataType = elementDataType;
await repo.UpdateInstanceAttributeOverrideAsync(existing);
results.Add(existing);
}
else
{
var created = new InstanceAttributeOverride(attrName)
{
InstanceId = cmd.InstanceId,
OverrideValue = overrideValue,
ElementDataType = elementDataType
};
await repo.AddInstanceAttributeOverrideAsync(created);
results.Add(created);
}
}
await repo.SaveChangesAsync();
var auditService = sp.GetRequiredService<IAuditService>();
await auditService.LogAsync(
user.Username, "SetOverrides", "Instance", cmd.InstanceId.ToString(), instance.UniqueName,
new { Count = results.Count, Attributes = cmd.Overrides.Keys.ToArray() });
return results;
}
@@ -2439,50 +2511,68 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IDeploymentManagerRepository>();
// Opportunistic retention sweep for terminal records. Rate-limited
// process-wide and best-effort inside the service, so it costs this query
// nothing on all but the first call in a retention interval — and it is
// deliberately on the LIST path, the operation that degrades when the
// insert-only DeploymentRecords table is allowed to grow unbounded.
//
// GetService, not GetRequiredService: retention maintenance is a
// side-benefit of this handler, not a precondition of answering the query.
// A composition root that wires the management surface without the
// deployment services (as the actor's own tests do) must still be able to
// list deployments.
if (sp.GetService<DeploymentService>() is { } deploymentService)
await deploymentService.TryPurgeTerminalDeploymentRecordsAsync();
// Page and Status are honoured DB-side. Both arrived on the command
// contract (and the CLI has been sending --page / --page-size / --status
// all along) but the handler previously ignored all three and returned the
// whole table.
var status = ParseDeploymentStatus(cmd.Status);
var skip = Math.Max(0, (Math.Max(1, cmd.Page) - 1) * Math.Max(1, cmd.PageSize));
var take = cmd.PageSize > 0 ? cmd.PageSize : (int?)null;
// Instance-scoped query: enforce against the target instance's site
// before reading its deployment history.
if (cmd.InstanceId.HasValue)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
return await repo.GetDeploymentsByInstanceIdAsync(cmd.InstanceId.Value);
return await repo.QueryDeploymentSummariesAsync(
cmd.InstanceId.Value, status, instanceIdScope: null, skip, take);
}
// Unfiltered query: a site-scoped Deployment user must only see records
// for instances at sites within their scope. DeploymentRecord has no
// SiteId, so resolve each record's instance to its site and filter
// (mirrors the HandleListInstances / HandleListSites filter pattern).
var records = await repo.GetAllDeploymentRecordsAsync();
// SiteId, so the scope is resolved to the set of in-scope instance ids and
// pushed into the query as an id filter — rather than loading every record
// AND every instance and intersecting them in memory, which made an
// unfiltered listing cost two full-table loads regardless of page size.
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
return records;
return await repo.QueryDeploymentSummariesAsync(null, status, instanceIdScope: null, skip, take);
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
// Pre-load all instances ONCE via the repository's
// bulk method and build an InstanceId -> SiteId? lookup, instead of issuing
// GetInstanceByIdAsync per distinct record.InstanceId (textbook N+1). The
// unfiltered branch now hits the configuration database exactly twice
// (deployment records + instances) regardless of fleet size.
var allInstances = await templateRepo.GetAllInstancesAsync();
var instanceSiteLookup = new Dictionary<int, int?>(allInstances.Count);
foreach (var instance in allInstances)
{
instanceSiteLookup[instance.Id] = instance.SiteId;
}
var scopedInstanceIds = allInstances
.Where(i => permittedIds.Contains(i.SiteId.ToString()))
.Select(i => i.Id)
.ToArray();
var scoped = new List<DeploymentRecord>();
foreach (var record in records)
{
if (instanceSiteLookup.TryGetValue(record.InstanceId, out var siteId)
&& siteId.HasValue
&& permittedIds.Contains(siteId.Value.ToString()))
{
scoped.Add(record);
}
}
return scoped;
return await repo.QueryDeploymentSummariesAsync(null, status, scopedInstanceIds, skip, take);
}
/// <summary>
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
/// An unparseable value is treated as "no filter" rather than an error, which
/// preserves the previous handler's behaviour of ignoring the field entirely.
/// </summary>
private static DeploymentStatus? ParseDeploymentStatus(string? status) =>
!string.IsNullOrWhiteSpace(status) && Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed)
? parsed
: null;
// ========================================================================
// Audit Log handler
// ========================================================================
@@ -42,6 +42,10 @@ public static class ManagementEndpoints
[
typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand),
// A bulk site deploy is by construction the longest command in the set —
// it is N instance deploys, so it must never inherit the ordinary Ask
// timeout or the caller would 504 while the batch is still applying.
typeof(MgmtDeploySiteCommand),
];
/// <summary>