From a18123b27fb995386898aa0f636877ecb59079aa Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 21 Jun 2026 15:05:26 +0800 Subject: [PATCH 1/2] feat(core): replace 80% size threshold with count-first chain-to-full fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the single 80%-of-full-size heuristic for a two-condition strategy: 1. Chain count exceeds MaxChainBeforeFallback (default 8) → full package 2. Combined chain size >= full size → full package Add MaxChainBeforeFallback to UpdateConfiguration (default 8), plumb it through DownloadPlanBuilder.Build() and ClientStrategy. The new logic stops switching to full when the chain total is only marginally below the full size — the runtime fallback (FallbackFull) already handles chain application failures, so an overly eager size threshold is unnecessary. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Configuration/UpdateConfiguration.cs | 18 +++++ .../Download/DownloadPlanBuilder.cs | 35 +++++++-- .../Strategy/ClientStrategy.cs | 3 +- .../Download/DownloadPlanBuilderTests.cs | 71 ++++++++++++++++++- 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs index f76eb71b..7d5e5dee 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs @@ -245,5 +245,23 @@ public abstract class UpdateConfiguration /// /// public Dictionary CustomHeaders { get; set; } + + /// + /// Maximum number of chain (delta) packages allowed before falling back to a single + /// full replacement package. When the number of chain packages for an AppType exceeds + /// this limit, the download plan switches to the full package for that AppType — + /// even if the combined chain size is smaller than the full package. + /// + /// + /// + /// A full package is also selected when the combined download size of all chain + /// packages equals or exceeds the full package size, regardless of the count. + /// + /// + /// Set to 0 or a negative value to disable the count-based fallback + /// (only the size comparison applies). + /// + /// + public int MaxChainBeforeFallback { get; set; } = 8; } } diff --git a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs index c66014d6..4c4568e0 100644 --- a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs @@ -28,8 +28,10 @@ namespace GeneralUpdate.Core.Download; /// if the current client version is below the minimum, the package is skipped. /// /// -/// All packages are treated uniformly; the builder evaluates chain vs full packages -/// based on total download size. +/// Chain vs full evaluation uses a count-first heuristic: +/// a full package is selected when the number of chain packages exceeds +/// MaxChainBeforeFallback (default 8), or when the combined download size +/// of all chain packages equals or exceeds the full package size. /// /// public static class DownloadPlanBuilder @@ -108,10 +110,17 @@ public static bool HasUpdate( /// /// The current upgrade (updater) version, or null to fall back to . /// + /// + /// Maximum number of chain packages allowed before falling back to a single full package. + /// Default is 8. Set to 0 to disable the count-based fallback; set to + /// a negative value to always prefer chain packages when their combined size is smaller + /// than the full package. + /// public static DownloadPlan Build( IEnumerable assets, string clientVersion, - string? upgradeClientVersion) + string? upgradeClientVersion, + int maxChainBeforeFallback = 8) { if (assets == null) return DownloadPlan.Empty; var parsedClient = ParseVersion(clientVersion); @@ -171,7 +180,7 @@ public static DownloadPlan Build( .Where(a => a.PackageType == (int)Configuration.PackageType.Full) .ToList(); - // ── Chain vs Full size-based decision ── + // ── Chain vs Full: count-first heuristic ── if (chainCandidates.Count > 0 && fullCandidates.Count > 0) { var bestFull = fullCandidates @@ -181,11 +190,23 @@ public static DownloadPlan Build( long chainTotal = chainCandidates .Where(a => a.AppType == bestFull.AppType) .Sum(a => a.Size); - var threshold = (long)(bestFull.Size * 0.8); + int chainCount = chainCandidates.Count(a => a.AppType == bestFull.AppType); + + if (maxChainBeforeFallback > 0 && chainCount > maxChainBeforeFallback) + { + GeneralTracer.Info($"DownloadPlanBuilder: chain count {chainCount} exceeds MaxChainBeforeFallback {maxChainBeforeFallback}, switching to full package {bestFull.Name} (chain total {chainTotal}, full size {bestFull.Size})"); + var bestFullSv = Lookup(bestFull); + var planAssets = new List { bestFull }; + planAssets.AddRange(chainCandidates + .Where(a => a.AppType != bestFull.AppType + || (Lookup(a) is { } av && bestFullSv != null && av > bestFullSv)) + .OrderBy(a => Lookup(a))); + return new DownloadPlan(planAssets, isForcibly); + } - if (chainTotal >= threshold) + if (chainTotal >= bestFull.Size) { - GeneralTracer.Info($"DownloadPlanBuilder: chain total {chainTotal} >= 80% of full size {bestFull.Size}, switching to full package {bestFull.Name}"); + GeneralTracer.Info($"DownloadPlanBuilder: chain total {chainTotal} >= full size {bestFull.Size}, switching to full package {bestFull.Name}"); var bestFullSv = Lookup(bestFull); var planAssets = new List { bestFull }; planAssets.AddRange(chainCandidates diff --git a/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs index e129c51c..80d94ff8 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs @@ -475,7 +475,8 @@ private async Task ExecuteStandardWorkflowAsync() var downloadPlan = Download.DownloadPlanBuilder.Build( sourceResult.Assets, localClientVersion, - resolvedUpgradeVersion); + resolvedUpgradeVersion, + _configInfo.MaxChainBeforeFallback); _configInfo.LastVersion = downloadPlan.Assets.LastOrDefault()?.Version; GeneralTracer.Info($"ClientStrategy: Scenario={scenario}, AssetCount={downloadPlan.Assets.Count}"); diff --git a/tests/CoreTest/Download/DownloadPlanBuilderTests.cs b/tests/CoreTest/Download/DownloadPlanBuilderTests.cs index a06415af..678f35a9 100644 --- a/tests/CoreTest/Download/DownloadPlanBuilderTests.cs +++ b/tests/CoreTest/Download/DownloadPlanBuilderTests.cs @@ -81,16 +81,15 @@ public void Build_NoAssetIsForcibly_IsForciblyFalse() } [Fact] - public void Build_FullPackageSelected_WhenChainExceedsThreshold() + public void Build_FullPackageSelected_WhenChainTotalExceedsFullSize() { - // Small chain + small full → chain_total >= 80% full → Full selected + // 2 chain packages (total 200), full=100 → 200 >= 100 → full selected var assets = new[] { Asset("chain1", "1.1.0", size: 100), Asset("chain2", "2.0.0", size: 100), Asset("full", "2.0.0", packageType: (int)PackageType.Full), }; - // chain_total=200, 80% of full(100) = 80 → 200 >= 80 → full selected var result = DownloadPlanBuilder.Build(assets, "1.0.0"); Assert.True(result.HasAssets); Assert.Single(result.Assets); @@ -98,6 +97,72 @@ public void Build_FullPackageSelected_WhenChainExceedsThreshold() Assert.Equal("2.0.0", result.Assets[0].Version); } + [Fact] + public void Build_FullPackageSelected_WhenChainCountExceedsMaxChainBeforeFallback() + { + // 12 small chain packages (total 120), full=500, maxChainBeforeFallback=10 + // → chain count 12 > 10 → full selected despite chain being much smaller + var assets = new List(); + for (int i = 1; i <= 12; i++) + assets.Add(Asset($"chain{i}", $"1.{i}.0", size: 10)); + assets.Add(Asset("full", "2.0.0", size: 500, packageType: (int)PackageType.Full)); + + var result = DownloadPlanBuilder.Build(assets, "1.0.0", null, maxChainBeforeFallback: 10); + Assert.True(result.HasAssets); + Assert.Single(result.Assets); + Assert.Equal((int)PackageType.Full, result.Assets[0].PackageType); + } + + [Fact] + public void Build_ChainSelected_WhenCountBelowThresholdAndTotalBelowFullSize() + { + // 3 chain packages (total 150), full=500, maxChainBeforeFallback=default(8) + // → chain count 3 <= 8 AND 150 < 500 → chain selected + var assets = new[] + { + Asset("chain1", "1.1.0", size: 50), + Asset("chain2", "1.2.0", size: 50), + Asset("chain3", "2.0.0", size: 50), + Asset("full", "2.0.0", size: 500, packageType: (int)PackageType.Full), + }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.True(result.HasAssets); + Assert.Equal(3, result.Assets.Count); + Assert.All(result.Assets, a => Assert.Equal((int)PackageType.Chain, a.PackageType)); + } + + [Fact] + public void Build_FullPackageSelected_WhenChainTotalEqualsFullSize() + { + // chain total == full size → full selected (boundary: >= not >) + var assets = new[] + { + Asset("chain1", "1.1.0", size: 200), + Asset("chain2", "2.0.0", size: 300), + Asset("full", "2.0.0", size: 500, packageType: (int)PackageType.Full), + }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0", null, maxChainBeforeFallback: 10); + Assert.True(result.HasAssets); + Assert.Single(result.Assets); + Assert.Equal((int)PackageType.Full, result.Assets[0].PackageType); + } + + [Fact] + public void Build_ChainSelected_WhenMaxChainBeforeFallbackIsZero() + { + // maxChainBeforeFallback=0 disables count-based fallback + // 25 chain packages (total 400), full=500 → 400 < 500 → chain selected + var assets = new List(); + for (int i = 1; i <= 25; i++) + assets.Add(Asset($"chain{i}", $"1.{i}.0", size: 16)); + assets.Add(Asset("full", "2.0.0", size: 500, packageType: (int)PackageType.Full)); + + var result = DownloadPlanBuilder.Build(assets, "1.0.0", null, maxChainBeforeFallback: 0); + Assert.True(result.HasAssets); + Assert.Equal(25, result.Assets.Count); + Assert.All(result.Assets, a => Assert.Equal((int)PackageType.Chain, a.PackageType)); + } + [Fact] public void Build_MinClientVersionTooHigh_FilteredOut() { From 0a8c4a21de9b6b50e34164aefb6db2f06a5b2df5 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 21 Jun 2026 15:09:36 +0800 Subject: [PATCH 2/2] fix(core): deduplicate switch-to-full logic, add missing mapper, mixed-AppType tests Code review fixes: - Extract duplicated 'switch to full' code block into local function SwitchToFull - Add missing MaxChainBeforeFallback mapping in ConfigurationMapper - Add 3 mixed-AppType test cases (Client+Upgrade split scenarios) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Configuration/ConfigurationMapper.cs | 1 + .../Download/DownloadPlanBuilder.cs | 25 ++++---- .../Download/DownloadPlanBuilderTests.cs | 60 +++++++++++++++++++ 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs b/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs index e5dffa23..6929d5cf 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs @@ -103,6 +103,7 @@ public static UpdateContext MapToUpdateContext(UpdateRequest source, UpdateConte target.UpgradeClientVersion = source.UpgradeClientVersion; target.ProductId = source.ProductId; target.CustomHeaders = source.CustomHeaders; + target.MaxChainBeforeFallback = source.MaxChainBeforeFallback; return target; } diff --git a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs index 4c4568e0..1176a43d 100644 --- a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs @@ -192,29 +192,26 @@ public static DownloadPlan Build( .Sum(a => a.Size); int chainCount = chainCandidates.Count(a => a.AppType == bestFull.AppType); - if (maxChainBeforeFallback > 0 && chainCount > maxChainBeforeFallback) + // Local helper: build a "switch to full" plan that replaces same-AppType + // chain packages with bestFull, while keeping chains for other AppTypes + // (and any same-AppType chains whose version exceeds the full's version). + DownloadPlan SwitchToFull(string reason) { - GeneralTracer.Info($"DownloadPlanBuilder: chain count {chainCount} exceeds MaxChainBeforeFallback {maxChainBeforeFallback}, switching to full package {bestFull.Name} (chain total {chainTotal}, full size {bestFull.Size})"); - var bestFullSv = Lookup(bestFull); + GeneralTracer.Info($"DownloadPlanBuilder: {reason}, switching to full package {bestFull.Name} (chain count {chainCount}, chain total {chainTotal}, full size {bestFull.Size})"); + var fullVersion = Lookup(bestFull); var planAssets = new List { bestFull }; planAssets.AddRange(chainCandidates .Where(a => a.AppType != bestFull.AppType - || (Lookup(a) is { } av && bestFullSv != null && av > bestFullSv)) + || (Lookup(a) is { } av && fullVersion != null && av > fullVersion)) .OrderBy(a => Lookup(a))); return new DownloadPlan(planAssets, isForcibly); } + if (maxChainBeforeFallback > 0 && chainCount > maxChainBeforeFallback) + return SwitchToFull($"chain count {chainCount} exceeds MaxChainBeforeFallback {maxChainBeforeFallback}"); + if (chainTotal >= bestFull.Size) - { - GeneralTracer.Info($"DownloadPlanBuilder: chain total {chainTotal} >= full size {bestFull.Size}, switching to full package {bestFull.Name}"); - var bestFullSv = Lookup(bestFull); - var planAssets = new List { bestFull }; - planAssets.AddRange(chainCandidates - .Where(a => a.AppType != bestFull.AppType - || (Lookup(a) is { } av && bestFullSv != null && av > bestFullSv)) - .OrderBy(a => Lookup(a))); - return new DownloadPlan(planAssets, isForcibly); - } + return SwitchToFull($"chain total {chainTotal} >= full size {bestFull.Size}"); } // ── Chain plan with fallback fulls ── diff --git a/tests/CoreTest/Download/DownloadPlanBuilderTests.cs b/tests/CoreTest/Download/DownloadPlanBuilderTests.cs index 678f35a9..70235b08 100644 --- a/tests/CoreTest/Download/DownloadPlanBuilderTests.cs +++ b/tests/CoreTest/Download/DownloadPlanBuilderTests.cs @@ -163,6 +163,66 @@ public void Build_ChainSelected_WhenMaxChainBeforeFallbackIsZero() Assert.All(result.Assets, a => Assert.Equal((int)PackageType.Chain, a.PackageType)); } + [Fact] + public void Build_MixedAppType_FullSwitchKeepsOtherAppTypeChains() + { + // Client full v2.0 + 12 Client chains (trigger count) + 3 Upgrade chains. + // → Client chains replaced by Client full, Upgrade chains kept. + var assets = new List(); + for (int i = 1; i <= 12; i++) + assets.Add(AssetWithType($"client-chain{i}", $"1.{i}.0", size: 10, appType: (int)AppType.Client)); + for (int i = 1; i <= 3; i++) + assets.Add(AssetWithType($"upgrade-chain{i}", $"1.{i}.0", size: 10, appType: (int)AppType.Upgrade)); + assets.Add(AssetWithType("client-full", "2.0.0", size: 500, packageType: (int)PackageType.Full, appType: (int)AppType.Client)); + + var result = DownloadPlanBuilder.Build(assets, "1.0.0", "1.0.0", maxChainBeforeFallback: 8); + Assert.True(result.HasAssets); + // 1 Client full + 3 Upgrade chains = 4 assets + Assert.Equal(4, result.Assets.Count); + Assert.Contains(result.Assets, a => a.PackageType == (int)PackageType.Full && a.AppType == (int)AppType.Client); + Assert.Equal(3, result.Assets.Count(a => a.AppType == (int)AppType.Upgrade)); + } + + [Fact] + public void Build_MixedAppType_OnlyFullForUpgrade_DoesNotAffectClientChains() + { + // Upgrade full v2.0 + 3 Upgrade chains (below threshold) + 5 Client chains. + // No Client full → count/size check scoped to Upgrade chains only. + // 3 Upgrade chains ≤ 8, total 30 < 500 → chain plan with Upgrade fallback. + var assets = new List(); + for (int i = 1; i <= 5; i++) + assets.Add(AssetWithType($"client-chain{i}", $"1.{i}.0", size: 20, appType: (int)AppType.Client)); + for (int i = 1; i <= 3; i++) + assets.Add(AssetWithType($"upgrade-chain{i}", $"1.{i}.0", size: 10, appType: (int)AppType.Upgrade)); + assets.Add(AssetWithType("upgrade-full", "2.0.0", size: 500, packageType: (int)PackageType.Full, appType: (int)AppType.Upgrade)); + + var result = DownloadPlanBuilder.Build(assets, "1.0.0", "1.0.0", maxChainBeforeFallback: 8); + Assert.True(result.HasAssets); + // All 8 chain packages selected, none replaced + Assert.Equal(8, result.Assets.Count); + Assert.All(result.Assets, a => Assert.Equal((int)PackageType.Chain, a.PackageType)); + } + + [Fact] + public void Build_MixedAppType_CountTriggersForClient_UpgradeChainsUnaffected() + { + // Client full + 12 Client chains (triggers count) + 12 Upgrade chains (no Upgrade full). + // → Client chains replaced by Client full, all 12 Upgrade chains kept. + var assets = new List(); + for (int i = 1; i <= 12; i++) + assets.Add(AssetWithType($"client-chain{i}", $"1.{i}.0", size: 5, appType: (int)AppType.Client)); + for (int i = 1; i <= 12; i++) + assets.Add(AssetWithType($"upgrade-chain{i}", $"1.{i}.0", size: 5, appType: (int)AppType.Upgrade)); + assets.Add(AssetWithType("client-full", "2.0.0", size: 500, packageType: (int)PackageType.Full, appType: (int)AppType.Client)); + + var result = DownloadPlanBuilder.Build(assets, "1.0.0", "1.0.0", maxChainBeforeFallback: 8); + Assert.True(result.HasAssets); + // 1 Client full + 12 Upgrade chains (no Upgrade full → kept as-is) + Assert.Equal(13, result.Assets.Count); + Assert.Equal(12, result.Assets.Count(a => a.AppType == (int)AppType.Upgrade)); + Assert.Single(result.Assets, a => a.PackageType == (int)PackageType.Full); + } + [Fact] public void Build_MinClientVersionTooHigh_FilteredOut() {