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/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..1176a43d 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,19 +190,28 @@ 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 (chainTotal >= threshold) + // 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 total {chainTotal} >= 80% of full size {bestFull.Size}, switching to full package {bestFull.Name}"); - 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) + return SwitchToFull($"chain total {chainTotal} >= full size {bestFull.Size}"); } // ── Chain plan with fallback fulls ── 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..70235b08 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,132 @@ 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_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() {