perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user