From bb8255fd3b6c2333432298b6a90790ffda710027 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Tue, 23 Jun 2026 14:30:02 +0200 Subject: [PATCH 1/2] Skip redundant AddSolutionComponent calls for steps already in the solution StepRegistrar.Sync re-added every matched plugin step to the target solution on each run, firing an idempotent AddSolutionComponent request per step. Query the solution's step components once up front (SolutionService.GetSolutionStepIds) and skip the add for steps already present. Co-Authored-By: Claude Opus 4.8 --- src/dvx.Tests/SolutionServiceTests.cs | 35 ++++++++++++++++++++++++++ src/dvx.Tests/StepRegistrarTests.cs | 36 +++++++++++++++++++++++++++ src/dvx/Services/SolutionService.cs | 34 +++++++++++++++++++++++++ src/dvx/Services/StepRegistrar.cs | 9 ++++++- 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/dvx.Tests/SolutionServiceTests.cs b/src/dvx.Tests/SolutionServiceTests.cs index 2e5e4e1..3f6da58 100644 --- a/src/dvx.Tests/SolutionServiceTests.cs +++ b/src/dvx.Tests/SolutionServiceTests.cs @@ -151,5 +151,40 @@ public void GetSolutionWebResources_FiltersByComponentType61AndSolutionUniqueNam se.LinkToEntityName == "solution" && se.LinkCriteria.Conditions.Any(c => c.AttributeName == "uniquename" && c.Values.Contains("MySolution")))))); } + + // ── GetSolutionStepIds ───────────────────────────────────────────────── + + [Fact] + public void GetSolutionStepIds_ReturnsObjectIds() + { + var svc = Substitute.For(); + var id = Guid.NewGuid(); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "solutioncomponent")) + .Returns(new EntityCollection(new List + { + new Entity("solutioncomponent", Guid.NewGuid()) { ["objectid"] = id } + })); + + var result = new SolutionService(svc).GetSolutionStepIds("MySolution"); + + result.Count.ShouldBe(1); + result.ShouldContain(id); + } + + [Fact] + public void GetSolutionStepIds_FiltersByComponentType92AndSolutionUniqueName() + { + var svc = Substitute.For(); + svc.RetrieveMultiple(Arg.Any()).Returns(new EntityCollection()); + + new SolutionService(svc).GetSolutionStepIds("MySolution"); + + svc.Received(1).RetrieveMultiple(Arg.Is(q => + q.EntityName == "solutioncomponent" && + q.Criteria.Conditions.Any(c => c.AttributeName == "componenttype" && c.Values.Contains(92)) && + q.LinkEntities.Any(le => + le.LinkToEntityName == "solution" && + le.LinkCriteria.Conditions.Any(c => c.AttributeName == "uniquename" && c.Values.Contains("MySolution"))))); + } } } diff --git a/src/dvx.Tests/StepRegistrarTests.cs b/src/dvx.Tests/StepRegistrarTests.cs index 03cea09..37a7924 100644 --- a/src/dvx.Tests/StepRegistrarTests.cs +++ b/src/dvx.Tests/StepRegistrarTests.cs @@ -912,6 +912,7 @@ public void SolutionProvided_ExistingStep_AddStepToSolutionCalled() }; var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List { existingStepEntity })); var solutionService = Substitute.For(svc); + solutionService.GetSolutionStepIds("MySolution", Arg.Any()).Returns(new HashSet()); var registrar = MakeRegistrarWithSolution(svc, solutionService); registrar.Sync(AssemblyId, new[] { def }, solutionUniqueName: "MySolution"); @@ -955,5 +956,40 @@ public void SolutionProvided_VerboseTrue_PassedThroughToSolutionService() solutionService.Received(1).ValidateSolutionExists("MySolution", true); solutionService.Received(1).AddStepToSolution(Arg.Any(), "MySolution", true); } + + [Fact] + public void SolutionProvided_ExistingStepAlreadyInSolution_AddStepToSolutionNotCalled() + { + // The optimization: a matched step the solution already contains must not be re-added. + // AddSolutionComponent is an idempotent server round-trip, so re-adding is pure latency. + var def = MakeDef(); + var stepId = Guid.NewGuid(); + var existingStepEntity = new Entity("sdkmessageprocessingstep", stepId) + { + ["name"] = def.StepName + }; + var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List { existingStepEntity })); + var solutionService = Substitute.For(svc); + solutionService.GetSolutionStepIds("MySolution", Arg.Any()) + .Returns(new HashSet { stepId }); + var registrar = MakeRegistrarWithSolution(svc, solutionService); + + registrar.Sync(AssemblyId, new[] { def }, solutionUniqueName: "MySolution"); + + solutionService.DidNotReceive().AddStepToSolution(stepId, "MySolution", Arg.Any()); + } + + [Fact] + public void SolutionProvided_QueriesSolutionStepIdsOnce() + { + // Solution membership is read once per sync (before the loop), not once per step. + var svc = BuildDefaultSvc(); + var solutionService = Substitute.For(svc); + var registrar = MakeRegistrarWithSolution(svc, solutionService); + + registrar.Sync(AssemblyId, new[] { MakeDef() }, solutionUniqueName: "MySolution"); + + solutionService.Received(1).GetSolutionStepIds("MySolution", Arg.Any()); + } } } diff --git a/src/dvx/Services/SolutionService.cs b/src/dvx/Services/SolutionService.cs index 04f0826..779eefb 100644 --- a/src/dvx/Services/SolutionService.cs +++ b/src/dvx/Services/SolutionService.cs @@ -83,5 +83,39 @@ public virtual void AddWebResourceToSolution(Guid webResourceId, string solution return results; } + + /// + /// Returns the ids of every plugin step (sdkmessageprocessingstep) already a component of + /// the given solution. Lets the registrar skip redundant AddSolutionComponent calls for + /// steps the solution already contains. Mirrors . + /// + public virtual HashSet GetSolutionStepIds(string solutionUniqueName, bool verbose = false) + { + if (verbose) + Out.Dim($" Querying plugin steps in solution '{solutionUniqueName}'..."); + + var query = new QueryExpression("solutioncomponent") + { + ColumnSet = new ColumnSet("objectid"), + Criteria = new FilterExpression + { + Conditions = { new ConditionExpression("componenttype", ConditionOperator.Equal, 92) } + } + }; + var solution = query.AddLink("solution", "solutionid", "solutionid"); + solution.LinkCriteria.AddCondition("uniquename", ConditionOperator.Equal, solutionUniqueName); + + var ids = new HashSet(); + foreach (var e in svc.RetrieveMultiple(query).Entities) + { + var id = e.GetAttributeValue("objectid"); + if (id != Guid.Empty) ids.Add(id); + } + + if (verbose) + Out.Dim($" Found {ids.Count} plugin step(s) in solution."); + + return ids; + } } } \ No newline at end of file diff --git a/src/dvx/Services/StepRegistrar.cs b/src/dvx/Services/StepRegistrar.cs index 0a26093..d1c35cb 100644 --- a/src/dvx/Services/StepRegistrar.cs +++ b/src/dvx/Services/StepRegistrar.cs @@ -54,6 +54,13 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList defi byIdentity.TryAdd(s.Identity, s); } + // Steps already in the target solution. Re-adding a component is an idempotent + // server round-trip, so skip AddSolutionComponent for steps the solution already + // contains — a no-op re-sync would otherwise re-add every step, one call each. + var solutionStepIds = solutionUniqueName is not null && !dryRun + ? _solutionService.GetSolutionStepIds(solutionUniqueName, verbose) + : new HashSet(); + // ── Upsert loop ──────────────────────────────────────────────────── var consumed = new HashSet(); @@ -123,7 +130,7 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList defi else UpdateStep(def, existing.StepId, msgId, filterId, pluginTypeId, result); - if (solutionUniqueName is not null && !dryRun) + if (solutionUniqueName is not null && !dryRun && !solutionStepIds.Contains(existing.StepId)) _solutionService.AddStepToSolution(existing.StepId, solutionUniqueName, verbose); } else From d6be9ca48a80be7811bb45f93c06d74322017baa Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Wed, 24 Jun 2026 14:22:44 +0200 Subject: [PATCH 2/2] Bump dvx CLI version to 1.7.1 and update release notes with performance improvements for sync/register operations. --- src/dvx/dvx.csproj | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index c699cbb..1ed1631 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -7,7 +7,7 @@ true dvx dvx.cli - 1.7.0 + 1.7.1 Byron Matus CLI for deploying code-first Dataverse / Power Platform artifacts — plugin assemblies and web resources. default @@ -17,10 +17,8 @@ https://github.com/beyro/dvx-cli package-icon.png MIT - 1.7.0: -- Orphaned plugin step deletion is now opt-in: pass --delete-orphaned to `plugin sync` / `plugin register` to remove steps that exist in Dataverse but are no longer in code. By default, orphans are reported as warnings only. -- Bug fix: Steps backing Custom APIs and Custom Actions are now never removed as orphans. -- Bug fix: entity images on Create steps (and other non-Update messages) failed to register because the image property name was always 'Target'. It is now derived per message (Create -> Id, SetState -> EntityMoniker, etc.); messages with no valid image property skip the image with a warning. + 1.7.1: +- Improve performance of sync/register by only making a AddSolutionComponent request if steps are not already part of the solution.