Skip to content

Commit 2921b1f

Browse files
authored
feat: CVP-first upgrade with automatic chain fallback (#500)
* feat: CVP-first upgrade with automatic chain fallback - Remove upgradeMode from VersionService request (new SDK sends no mode) - DownloadPlanBuilder: CVP-first selection, retain chain for other AppTypes - ClientStrategy: CVP download/apply fallback to chain from cached response - No second server request needed on CVP failure * fix: address Copilot review and test failure - DownloadPlanBuilder: match CVP FromVersion against correct AppType local version - DownloadPlanBuilder: prefer highest-version CVP when multiple match - ClientStrategy: fix isCvpAttempt to check Any(a.IsCrossVersion) instead of Count==1 - ClientStrategy: exclude OperationCanceledException from CVP fallback catch - ClientStrategy: add missing ConfigureAwait(false) on new awaits - VersionService: update XML docs to remove outdated upgradeMode reference - Tests: update Build_CrossVersionIncluded test for CVP-first behavior - Tests: add Build_CvpWithMixedAppTypes test for multi-AppType CVP plans
1 parent 4a651e9 commit 2921b1f

4 files changed

Lines changed: 202 additions & 69 deletions

File tree

src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,41 @@ public static DownloadPlan Build(
133133

134134
if (candidates.Count == 0) return DownloadPlan.Empty;
135135

136+
// ── CVP-first selection ──
137+
// If a matching cross-version package (CVP) exists whose FromVersion
138+
// equals the client's current version, prefer it over chain packages.
139+
// This gives the client a single-package shortcut from old → latest.
140+
// Prefer the CVP with the highest target version when multiple CVPs match.
141+
var matchingCvp = candidates
142+
.Where(a => a.IsCrossVersion)
143+
.Where(a =>
144+
{
145+
var fromVer = ParseVersion(a.FromVersion);
146+
if (fromVer == null) return false;
147+
var localVersion = (a.AppType == (int)AppType.Upgrade)
148+
? parsedUpgrade
149+
: parsedClient;
150+
return fromVer == localVersion;
151+
})
152+
.OrderByDescending(a => ParseVersion(a.Version))
153+
.FirstOrDefault();
154+
155+
if (matchingCvp != null)
156+
{
157+
// CVP covers one AppType in a single hop. Still need chain packages
158+
// for other AppTypes, and for the same AppType beyond the CVP's target.
159+
var cvpAppType = matchingCvp.AppType;
160+
var cvpVersion = ParseVersion(matchingCvp.Version);
161+
var planAssets = new List<DownloadAsset> { matchingCvp };
162+
planAssets.AddRange(candidates
163+
.Where(a => !a.IsCrossVersion)
164+
.Where(a => a.AppType != cvpAppType
165+
|| (cvpVersion != null && ParseVersion(a.Version) > cvpVersion))
166+
.OrderBy(a => ParseVersion(a.Version)));
167+
return new DownloadPlan(planAssets, isForcibly);
168+
}
169+
170+
// No matching CVP: return all chain packages sorted by version (ascending)
136171
return new DownloadPlan(candidates, isForcibly);
137172
}
138173

src/c#/GeneralUpdate.Core/Network/VersionService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ public static Task<VersionRespDTO> Validate(string url, string version,
226226
/// Execution flow:
227227
/// <list type="number">
228228
/// <item><description>Constructs a parameter dictionary with the version, app type, app key,
229-
/// platform, product ID, and upgrade mode.</description></item>
229+
/// platform, and product ID.</description></item>
230230
/// <item><description>Sends the parameters via a POST request using <see cref="PostAsync{T}"/>.</description></item>
231231
/// <item><description>Deserializes the response into a <see cref="VersionRespDTO"/>.</description></item>
232232
/// </list>
@@ -243,7 +243,7 @@ public static Task<VersionRespDTO> Validate(string url, string version,
243243
private async Task<VersionRespDTO> ValidateAsync(string url, string v, int at, string appKey, int pf, string pid,
244244
CancellationToken t = default)
245245
{
246-
var p = new Dictionary<string, object> { ["version"] = v, ["appType"] = at, ["appKey"] = appKey, ["platform"] = pf, ["productId"] = pid, ["upgradeMode"] = 1 };
246+
var p = new Dictionary<string, object> { ["version"] = v, ["appType"] = at, ["appKey"] = appKey, ["platform"] = pf, ["productId"] = pid };
247247
return await PostAsync<VersionRespDTO>(url, p, VersionRespJsonContext.Default.VersionRespDTO, t);
248248
}
249249

src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs

Lines changed: 140 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,12 @@ private async Task ExecuteStandardWorkflowAsync()
479479
_configInfo.LastVersion = downloadPlan.Assets.LastOrDefault()?.Version;
480480
GeneralTracer.Info($"ClientStrategy: Scenario={scenario}, AssetCount={downloadPlan.Assets.Count}");
481481

482+
// Store original assets and CVP flag for chain fallback.
483+
// If the CVP download/apply fails, we rebuild the plan from cached chain
484+
// packages without a second server request.
485+
var isCvpAttempt = downloadPlan.Assets.Any(a => a.IsCrossVersion);
486+
var originalAssets = sourceResult.Assets.ToList();
487+
482488
// Dispatch update info event with populated version data (full GeneralSpacestation-compatible fields)
483489
var versionInfos = downloadPlan.Assets.Select(a => new VersionEntry
484490
{
@@ -561,78 +567,109 @@ private async Task ExecuteStandardWorkflowAsync()
561567

562568
_osStrategy!.Create(_configInfo);
563569

564-
// Download ALL packages via orchestrator (requirement 6: client downloads everything
565-
// regardless of whether client or upgrade needs updating)
570+
// ════════════════════════════════════════════════════════════════════
571+
// Download + Apply with CVP fallback to chain.
572+
// If the CVP download OR apply fails, retry with chain packages
573+
// from the cached original response without a second server request.
574+
// ════════════════════════════════════════════════════════════════════
566575
var orchOptions = Download.Models.DownloadOrchestratorOptions.From(_configInfo);
567-
GeneralTracer.Info($"ClientStrategy: downloading {downloadPlan.Assets.Count} asset(s).");
568-
if (_orchestrator != null)
576+
577+
async Task ExecuteDownloadAsync(Download.Models.DownloadPlan plan)
569578
{
570-
await _orchestrator.ExecuteAsync(downloadPlan, _configInfo.TempPath).ConfigureAwait(false);
579+
if (_orchestrator != null)
580+
{
581+
await _orchestrator.ExecuteAsync(plan, _configInfo.TempPath).ConfigureAwait(false);
582+
}
583+
else
584+
{
585+
var httpClient = GeneralUpdate.Core.Network.HttpClientProvider.Shared;
586+
var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(
587+
httpClient, orchOptions, _customDownloadPolicy,
588+
_customDownloadExecutor, _customDownloadPipelineFactory);
589+
await orchestrator.ExecuteAsync(plan, _configInfo.TempPath).ConfigureAwait(false);
590+
}
571591
}
572-
else
592+
593+
// Download + report + build version lists + scenario dispatch
594+
async Task DownloadAndApplyAsync(Download.Models.DownloadPlan plan, UpdateScenario sc)
573595
{
574-
var httpClient = GeneralUpdate.Core.Network.HttpClientProvider.Shared;
575-
var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(
576-
httpClient, orchOptions, _customDownloadPolicy,
577-
_customDownloadExecutor, _customDownloadPipelineFactory);
578-
await orchestrator.ExecuteAsync(downloadPlan, _configInfo.TempPath).ConfigureAwait(false);
579-
}
596+
GeneralTracer.Info($"ClientStrategy: downloading {plan.Assets.Count} asset(s).");
597+
await ExecuteDownloadAsync(plan).ConfigureAwait(false);
580598

581-
await SafeReportDownloadCompletedAsync(hooksCtx).ConfigureAwait(false);
582-
await SafeOnDownloadCompletedAsync(hooksCtx).ConfigureAwait(false);
599+
await SafeReportDownloadCompletedAsync(hooksCtx).ConfigureAwait(false);
600+
await SafeOnDownloadCompletedAsync(hooksCtx).ConfigureAwait(false);
583601

584-
// Build VersionEntry list with AppType preserved from server response.
585-
var downloadVersions = downloadPlan.Assets.Select(a => new VersionEntry
586-
{
587-
RecordId = a.RecordId,
588-
Name = a.Name,
589-
Hash = a.SHA256,
590-
Url = a.Url,
591-
Version = a.Version,
592-
Format = _configInfo.Format.ToExtension(),
593-
AppType = a.AppType ?? (int)AppType.Client
594-
}).ToList();
602+
// Build VersionEntry list with AppType preserved from server response.
603+
var dVersions = plan.Assets.Select(a => new VersionEntry
604+
{
605+
RecordId = a.RecordId,
606+
Name = a.Name,
607+
Hash = a.SHA256,
608+
Url = a.Url,
609+
Version = a.Version,
610+
Format = _configInfo.Format.ToExtension(),
611+
AppType = a.AppType ?? (int)AppType.Client
612+
}).ToList();
613+
614+
var uVersions = dVersions.Where(v => v.AppType == (int)AppType.Upgrade).ToList();
615+
var cVersions = dVersions.Where(v => v.AppType == (int)AppType.Client).ToList();
616+
GeneralTracer.Info(
617+
$"ClientStrategy: Upgrade packages={uVersions.Count}, MainApp packages={cVersions.Count}");
595618

596-
var upgradeVersions = downloadVersions.Where(v => v.AppType == (int)AppType.Upgrade).ToList();
597-
var clientVersions = downloadVersions.Where(v => v.AppType == (int)AppType.Client).ToList();
598-
GeneralTracer.Info(
599-
$"ClientStrategy: Upgrade packages={upgradeVersions.Count}, MainApp packages={clientVersions.Count}");
619+
// ── Dispatch by scenario ──
620+
switch (sc)
621+
{
622+
case UpdateScenario.UpgradeOnly:
623+
await ApplyUpgradePackagesAsync(uVersions).ConfigureAwait(false);
624+
await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false);
625+
await SafeReportUpdateAppliedAsync(hooksCtx, _upgradeRecordId).ConfigureAwait(false);
626+
GeneralTracer.Info("ClientStrategy: Upgrade-only update applied, client continues running.");
627+
break;
628+
629+
case UpdateScenario.MainOnly:
630+
SendProcessIpc(cVersions);
631+
await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false);
632+
await SafeReportUpdateAppliedAsync(hooksCtx, _mainRecordId).ConfigureAwait(false);
633+
if (LaunchAfterPrepare)
634+
{
635+
await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false);
636+
await LaunchUpgradeProcessAsync().ConfigureAwait(false);
637+
}
638+
break;
639+
640+
case UpdateScenario.Both:
641+
await ApplyUpgradePackagesAsync(uVersions).ConfigureAwait(false);
642+
await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false);
643+
await SafeReportUpdateAppliedAsync(hooksCtx, _upgradeRecordId).ConfigureAwait(false);
644+
SendProcessIpc(cVersions);
645+
if (LaunchAfterPrepare)
646+
{
647+
await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false);
648+
await LaunchUpgradeProcessAsync().ConfigureAwait(false);
649+
}
650+
break;
651+
case UpdateScenario.None:
652+
default:
653+
throw new InvalidOperationException($"Unhandled update scenario: {sc}");
654+
}
655+
}
600656

601-
// ── Dispatch by scenario — one switch, four states, zero nested if-else ──
602-
switch (scenario)
657+
try
603658
{
604-
case UpdateScenario.UpgradeOnly:
605-
await ApplyUpgradePackagesAsync(upgradeVersions).ConfigureAwait(false);
606-
await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false);
607-
await SafeReportUpdateAppliedAsync(hooksCtx, _upgradeRecordId).ConfigureAwait(false);
608-
GeneralTracer.Info("ClientStrategy: Upgrade-only update applied, client continues running.");
609-
break;
610-
611-
case UpdateScenario.MainOnly:
612-
SendProcessIpc(clientVersions);
613-
await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false);
614-
await SafeReportUpdateAppliedAsync(hooksCtx, _mainRecordId).ConfigureAwait(false);
615-
if (LaunchAfterPrepare)
616-
{
617-
await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false);
618-
await LaunchUpgradeProcessAsync().ConfigureAwait(false);
619-
}
620-
break;
621-
622-
case UpdateScenario.Both:
623-
await ApplyUpgradePackagesAsync(upgradeVersions).ConfigureAwait(false);
624-
await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false);
625-
await SafeReportUpdateAppliedAsync(hooksCtx, _upgradeRecordId).ConfigureAwait(false);
626-
SendProcessIpc(clientVersions);
627-
if (LaunchAfterPrepare)
628-
{
629-
await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false);
630-
await LaunchUpgradeProcessAsync().ConfigureAwait(false);
631-
}
632-
break;
633-
case UpdateScenario.None:
634-
default:
635-
throw new InvalidOperationException($"Unhandled update scenario: {scenario}");
659+
await DownloadAndApplyAsync(downloadPlan, scenario).ConfigureAwait(false);
660+
}
661+
catch (Exception ex) when (isCvpAttempt && ex is not OperationCanceledException)
662+
{
663+
GeneralTracer.Warn($"ClientStrategy: CVP attempt failed, falling back to chain packages. {ex.Message}");
664+
var fallback = BuildChainFallback(originalAssets, localClientVersion, resolvedUpgradeVersion);
665+
if (fallback == null)
666+
throw new InvalidOperationException(
667+
"CVP failed and no chain packages are available for fallback.", ex);
668+
669+
(downloadPlan, scenario) = fallback.Value;
670+
UpdateRecordIdsFromPlan(downloadPlan);
671+
GeneralTracer.Info($"ClientStrategy: retrying with {downloadPlan.Assets.Count} chain asset(s), Scenario={scenario}");
672+
await DownloadAndApplyAsync(downloadPlan, scenario).ConfigureAwait(false);
636673
}
637674
}
638675

@@ -1122,5 +1159,45 @@ await Reporter
11221159
}
11231160
}
11241161

1162+
/// <summary>
1163+
/// Builds a chain-only fallback plan from the cached original server response,
1164+
/// excluding any CVP assets. Returns null when no chain packages are available.
1165+
/// </summary>
1166+
private (Download.Models.DownloadPlan Plan, UpdateScenario Scenario)? BuildChainFallback(
1167+
List<Download.Models.DownloadAsset> originalAssets,
1168+
string localClientVersion,
1169+
string? resolvedUpgradeVersion)
1170+
{
1171+
var chainAssets = originalAssets.Where(a => !a.IsCrossVersion).ToList();
1172+
var plan = DownloadPlanBuilder.Build(chainAssets, localClientVersion, resolvedUpgradeVersion);
1173+
if (!plan.HasAssets) return null;
1174+
1175+
_configInfo.LastVersion = plan.Assets.LastOrDefault()?.Version;
1176+
_configInfo.IsMainUpdate = DownloadPlanBuilder.HasUpdate(chainAssets, AppType.Client, localClientVersion);
1177+
_configInfo.IsUpgradeUpdate = DownloadPlanBuilder.HasUpdate(chainAssets, AppType.Upgrade, resolvedUpgradeVersion);
1178+
1179+
var sc = (_configInfo.IsMainUpdate, _configInfo.IsUpgradeUpdate) switch
1180+
{
1181+
(false, true) => UpdateScenario.UpgradeOnly,
1182+
(true, false) => UpdateScenario.MainOnly,
1183+
(true, true) => UpdateScenario.Both,
1184+
_ => UpdateScenario.None
1185+
};
1186+
1187+
return (plan, sc);
1188+
}
1189+
1190+
/// <summary>
1191+
/// Updates <see cref="_mainRecordId"/> and <see cref="_upgradeRecordId"/> from the
1192+
/// current download plan so status reports use correct record identifiers after fallback.
1193+
/// </summary>
1194+
private void UpdateRecordIdsFromPlan(Download.Models.DownloadPlan plan)
1195+
{
1196+
_mainRecordId = plan.Assets
1197+
.FirstOrDefault(a => (a.AppType ?? (int)AppType.Client) == (int)AppType.Client)?.RecordId ?? 0;
1198+
_upgradeRecordId = plan.Assets
1199+
.FirstOrDefault(a => a.AppType == (int)AppType.Upgrade)?.RecordId ?? 0;
1200+
}
1201+
11251202
#endregion
11261203
}

tests/CoreTest/Download/DownloadPlanBuilderTests.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,40 @@ public void Build_NoAssetIsForcibly_IsForciblyFalse()
8282
}
8383

8484
[Fact]
85-
public void Build_CrossVersionIncluded_ReturnsAllMatchingAssets()
85+
public void Build_CrossVersionIncluded_CvpFirst_ReturnsCvpOnly()
8686
{
87+
// When a matching CVP exists, the plan selects the CVP and drops
88+
// same-AppType chain packages (CVP covers the full range).
8789
var assets = new[]
8890
{
8991
Asset("cross", "5.0.0", isCrossVersion: true, fromVersion: "1.0.0"),
9092
Asset("inc", "2.0.0"), Asset("inc2", "3.0.0")
9193
};
9294
var result = DownloadPlanBuilder.Build(assets, "1.0.0");
9395
Assert.True(result.HasAssets);
96+
Assert.Single(result.Assets);
97+
Assert.True(result.Assets[0].IsCrossVersion);
98+
Assert.Equal("5.0.0", result.Assets[0].Version);
99+
}
100+
101+
[Fact]
102+
public void Build_CvpWithMixedAppTypes_KeepsChainForOtherTypes()
103+
{
104+
// CVP covers Client (AppType=1). Upgrade chain packages (AppType=2)
105+
// should still be included since the CVP doesn't cover that AppType.
106+
var assets = new[]
107+
{
108+
AssetWithType("cvp", "5.0.0", appType: 1, isCrossVersion: true, fromVersion: "1.0.0"),
109+
AssetWithType("upgrade1", "2.0.0", appType: 2),
110+
AssetWithType("upgrade2", "3.0.0", appType: 2),
111+
};
112+
var result = DownloadPlanBuilder.Build(assets, "1.0.0");
113+
Assert.True(result.HasAssets);
94114
Assert.Equal(3, result.Assets.Count);
95-
Assert.Equal("2.0.0", result.Assets[0].Version);
96-
Assert.Equal("3.0.0", result.Assets[1].Version);
97-
Assert.Equal("5.0.0", result.Assets[2].Version);
115+
Assert.Equal("5.0.0", result.Assets[0].Version); // CVP first
116+
Assert.True(result.Assets[0].IsCrossVersion);
117+
Assert.Equal("2.0.0", result.Assets[1].Version); // chain for other AppType
118+
Assert.Equal("3.0.0", result.Assets[2].Version);
98119
}
99120

100121
[Fact]

0 commit comments

Comments
 (0)