Skip to content

Commit c6b8233

Browse files
JusterZhuclaude
andauthored
feat(core): replace 80% size threshold with count-first chain-to-full fallback (#531)
* feat(core): replace 80% size threshold with count-first chain-to-full fallback 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent feea1a2 commit c6b8233

5 files changed

Lines changed: 176 additions & 13 deletions

File tree

src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ public static UpdateContext MapToUpdateContext(UpdateRequest source, UpdateConte
103103
target.UpgradeClientVersion = source.UpgradeClientVersion;
104104
target.ProductId = source.ProductId;
105105
target.CustomHeaders = source.CustomHeaders;
106+
target.MaxChainBeforeFallback = source.MaxChainBeforeFallback;
106107

107108
return target;
108109
}

src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,5 +245,23 @@ public abstract class UpdateConfiguration
245245
/// </remarks>
246246
/// </remarks>
247247
public Dictionary<string, string> CustomHeaders { get; set; }
248+
249+
/// <summary>
250+
/// Maximum number of chain (delta) packages allowed before falling back to a single
251+
/// full replacement package. When the number of chain packages for an AppType exceeds
252+
/// this limit, the download plan switches to the full package for that AppType —
253+
/// even if the combined chain size is smaller than the full package.
254+
/// </summary>
255+
/// <remarks>
256+
/// <para>
257+
/// A full package is also selected when the combined download size of all chain
258+
/// packages equals or exceeds the full package size, regardless of the count.
259+
/// </para>
260+
/// <para>
261+
/// Set to <c>0</c> or a negative value to disable the count-based fallback
262+
/// (only the size comparison applies).
263+
/// </para>
264+
/// </remarks>
265+
public int MaxChainBeforeFallback { get; set; } = 8;
248266
}
249267
}

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

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ namespace GeneralUpdate.Core.Download;
2828
/// if the current client version is below the minimum, the package is skipped.</description></item>
2929
/// </list>
3030
/// <para>
31-
/// All packages are treated uniformly; the builder evaluates chain vs full packages
32-
/// based on total download size.
31+
/// Chain vs full evaluation uses a count-first heuristic:
32+
/// a full package is selected when the number of chain packages exceeds
33+
/// <c>MaxChainBeforeFallback</c> (default 8), or when the combined download size
34+
/// of all chain packages equals or exceeds the full package size.
3335
/// </para>
3436
/// </remarks>
3537
public static class DownloadPlanBuilder
@@ -108,10 +110,17 @@ public static bool HasUpdate(
108110
/// <param name="upgradeClientVersion">
109111
/// The current upgrade (updater) version, or null to fall back to <paramref name="clientVersion"/>.
110112
/// </param>
113+
/// <param name="maxChainBeforeFallback">
114+
/// Maximum number of chain packages allowed before falling back to a single full package.
115+
/// Default is <c>8</c>. Set to <c>0</c> to disable the count-based fallback; set to
116+
/// a negative value to always prefer chain packages when their combined size is smaller
117+
/// than the full package.
118+
/// </param>
111119
public static DownloadPlan Build(
112120
IEnumerable<DownloadAsset> assets,
113121
string clientVersion,
114-
string? upgradeClientVersion)
122+
string? upgradeClientVersion,
123+
int maxChainBeforeFallback = 8)
115124
{
116125
if (assets == null) return DownloadPlan.Empty;
117126
var parsedClient = ParseVersion(clientVersion);
@@ -171,7 +180,7 @@ public static DownloadPlan Build(
171180
.Where(a => a.PackageType == (int)Configuration.PackageType.Full)
172181
.ToList();
173182

174-
// ── Chain vs Full size-based decision ──
183+
// ── Chain vs Full: count-first heuristic ──
175184
if (chainCandidates.Count > 0 && fullCandidates.Count > 0)
176185
{
177186
var bestFull = fullCandidates
@@ -181,19 +190,28 @@ public static DownloadPlan Build(
181190
long chainTotal = chainCandidates
182191
.Where(a => a.AppType == bestFull.AppType)
183192
.Sum(a => a.Size);
184-
var threshold = (long)(bestFull.Size * 0.8);
193+
int chainCount = chainCandidates.Count(a => a.AppType == bestFull.AppType);
185194

186-
if (chainTotal >= threshold)
195+
// Local helper: build a "switch to full" plan that replaces same-AppType
196+
// chain packages with bestFull, while keeping chains for other AppTypes
197+
// (and any same-AppType chains whose version exceeds the full's version).
198+
DownloadPlan SwitchToFull(string reason)
187199
{
188-
GeneralTracer.Info($"DownloadPlanBuilder: chain total {chainTotal} >= 80% of full size {bestFull.Size}, switching to full package {bestFull.Name}");
189-
var bestFullSv = Lookup(bestFull);
200+
GeneralTracer.Info($"DownloadPlanBuilder: {reason}, switching to full package {bestFull.Name} (chain count {chainCount}, chain total {chainTotal}, full size {bestFull.Size})");
201+
var fullVersion = Lookup(bestFull);
190202
var planAssets = new List<DownloadAsset> { bestFull };
191203
planAssets.AddRange(chainCandidates
192204
.Where(a => a.AppType != bestFull.AppType
193-
|| (Lookup(a) is { } av && bestFullSv != null && av > bestFullSv))
205+
|| (Lookup(a) is { } av && fullVersion != null && av > fullVersion))
194206
.OrderBy(a => Lookup(a)));
195207
return new DownloadPlan(planAssets, isForcibly);
196208
}
209+
210+
if (maxChainBeforeFallback > 0 && chainCount > maxChainBeforeFallback)
211+
return SwitchToFull($"chain count {chainCount} exceeds MaxChainBeforeFallback {maxChainBeforeFallback}");
212+
213+
if (chainTotal >= bestFull.Size)
214+
return SwitchToFull($"chain total {chainTotal} >= full size {bestFull.Size}");
197215
}
198216

199217
// ── Chain plan with fallback fulls ──

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,8 @@ private async Task ExecuteStandardWorkflowAsync()
475475
var downloadPlan = Download.DownloadPlanBuilder.Build(
476476
sourceResult.Assets,
477477
localClientVersion,
478-
resolvedUpgradeVersion);
478+
resolvedUpgradeVersion,
479+
_configInfo.MaxChainBeforeFallback);
479480
_configInfo.LastVersion = downloadPlan.Assets.LastOrDefault()?.Version;
480481
GeneralTracer.Info($"ClientStrategy: Scenario={scenario}, AssetCount={downloadPlan.Assets.Count}");
481482

tests/CoreTest/Download/DownloadPlanBuilderTests.cs

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,23 +81,148 @@ public void Build_NoAssetIsForcibly_IsForciblyFalse()
8181
}
8282

8383
[Fact]
84-
public void Build_FullPackageSelected_WhenChainExceedsThreshold()
84+
public void Build_FullPackageSelected_WhenChainTotalExceedsFullSize()
8585
{
86-
// Small chain + small full → chain_total >= 80% full → Full selected
86+
// 2 chain packages (total 200), full=100200 >= 100 → full selected
8787
var assets = new[]
8888
{
8989
Asset("chain1", "1.1.0", size: 100),
9090
Asset("chain2", "2.0.0", size: 100),
9191
Asset("full", "2.0.0", packageType: (int)PackageType.Full),
9292
};
93-
// chain_total=200, 80% of full(100) = 80 → 200 >= 80 → full selected
9493
var result = DownloadPlanBuilder.Build(assets, "1.0.0");
9594
Assert.True(result.HasAssets);
9695
Assert.Single(result.Assets);
9796
Assert.Equal((int)PackageType.Full, result.Assets[0].PackageType);
9897
Assert.Equal("2.0.0", result.Assets[0].Version);
9998
}
10099

100+
[Fact]
101+
public void Build_FullPackageSelected_WhenChainCountExceedsMaxChainBeforeFallback()
102+
{
103+
// 12 small chain packages (total 120), full=500, maxChainBeforeFallback=10
104+
// → chain count 12 > 10 → full selected despite chain being much smaller
105+
var assets = new List<DownloadAsset>();
106+
for (int i = 1; i <= 12; i++)
107+
assets.Add(Asset($"chain{i}", $"1.{i}.0", size: 10));
108+
assets.Add(Asset("full", "2.0.0", size: 500, packageType: (int)PackageType.Full));
109+
110+
var result = DownloadPlanBuilder.Build(assets, "1.0.0", null, maxChainBeforeFallback: 10);
111+
Assert.True(result.HasAssets);
112+
Assert.Single(result.Assets);
113+
Assert.Equal((int)PackageType.Full, result.Assets[0].PackageType);
114+
}
115+
116+
[Fact]
117+
public void Build_ChainSelected_WhenCountBelowThresholdAndTotalBelowFullSize()
118+
{
119+
// 3 chain packages (total 150), full=500, maxChainBeforeFallback=default(8)
120+
// → chain count 3 <= 8 AND 150 < 500 → chain selected
121+
var assets = new[]
122+
{
123+
Asset("chain1", "1.1.0", size: 50),
124+
Asset("chain2", "1.2.0", size: 50),
125+
Asset("chain3", "2.0.0", size: 50),
126+
Asset("full", "2.0.0", size: 500, packageType: (int)PackageType.Full),
127+
};
128+
var result = DownloadPlanBuilder.Build(assets, "1.0.0");
129+
Assert.True(result.HasAssets);
130+
Assert.Equal(3, result.Assets.Count);
131+
Assert.All(result.Assets, a => Assert.Equal((int)PackageType.Chain, a.PackageType));
132+
}
133+
134+
[Fact]
135+
public void Build_FullPackageSelected_WhenChainTotalEqualsFullSize()
136+
{
137+
// chain total == full size → full selected (boundary: >= not >)
138+
var assets = new[]
139+
{
140+
Asset("chain1", "1.1.0", size: 200),
141+
Asset("chain2", "2.0.0", size: 300),
142+
Asset("full", "2.0.0", size: 500, packageType: (int)PackageType.Full),
143+
};
144+
var result = DownloadPlanBuilder.Build(assets, "1.0.0", null, maxChainBeforeFallback: 10);
145+
Assert.True(result.HasAssets);
146+
Assert.Single(result.Assets);
147+
Assert.Equal((int)PackageType.Full, result.Assets[0].PackageType);
148+
}
149+
150+
[Fact]
151+
public void Build_ChainSelected_WhenMaxChainBeforeFallbackIsZero()
152+
{
153+
// maxChainBeforeFallback=0 disables count-based fallback
154+
// 25 chain packages (total 400), full=500 → 400 < 500 → chain selected
155+
var assets = new List<DownloadAsset>();
156+
for (int i = 1; i <= 25; i++)
157+
assets.Add(Asset($"chain{i}", $"1.{i}.0", size: 16));
158+
assets.Add(Asset("full", "2.0.0", size: 500, packageType: (int)PackageType.Full));
159+
160+
var result = DownloadPlanBuilder.Build(assets, "1.0.0", null, maxChainBeforeFallback: 0);
161+
Assert.True(result.HasAssets);
162+
Assert.Equal(25, result.Assets.Count);
163+
Assert.All(result.Assets, a => Assert.Equal((int)PackageType.Chain, a.PackageType));
164+
}
165+
166+
[Fact]
167+
public void Build_MixedAppType_FullSwitchKeepsOtherAppTypeChains()
168+
{
169+
// Client full v2.0 + 12 Client chains (trigger count) + 3 Upgrade chains.
170+
// → Client chains replaced by Client full, Upgrade chains kept.
171+
var assets = new List<DownloadAsset>();
172+
for (int i = 1; i <= 12; i++)
173+
assets.Add(AssetWithType($"client-chain{i}", $"1.{i}.0", size: 10, appType: (int)AppType.Client));
174+
for (int i = 1; i <= 3; i++)
175+
assets.Add(AssetWithType($"upgrade-chain{i}", $"1.{i}.0", size: 10, appType: (int)AppType.Upgrade));
176+
assets.Add(AssetWithType("client-full", "2.0.0", size: 500, packageType: (int)PackageType.Full, appType: (int)AppType.Client));
177+
178+
var result = DownloadPlanBuilder.Build(assets, "1.0.0", "1.0.0", maxChainBeforeFallback: 8);
179+
Assert.True(result.HasAssets);
180+
// 1 Client full + 3 Upgrade chains = 4 assets
181+
Assert.Equal(4, result.Assets.Count);
182+
Assert.Contains(result.Assets, a => a.PackageType == (int)PackageType.Full && a.AppType == (int)AppType.Client);
183+
Assert.Equal(3, result.Assets.Count(a => a.AppType == (int)AppType.Upgrade));
184+
}
185+
186+
[Fact]
187+
public void Build_MixedAppType_OnlyFullForUpgrade_DoesNotAffectClientChains()
188+
{
189+
// Upgrade full v2.0 + 3 Upgrade chains (below threshold) + 5 Client chains.
190+
// No Client full → count/size check scoped to Upgrade chains only.
191+
// 3 Upgrade chains ≤ 8, total 30 < 500 → chain plan with Upgrade fallback.
192+
var assets = new List<DownloadAsset>();
193+
for (int i = 1; i <= 5; i++)
194+
assets.Add(AssetWithType($"client-chain{i}", $"1.{i}.0", size: 20, appType: (int)AppType.Client));
195+
for (int i = 1; i <= 3; i++)
196+
assets.Add(AssetWithType($"upgrade-chain{i}", $"1.{i}.0", size: 10, appType: (int)AppType.Upgrade));
197+
assets.Add(AssetWithType("upgrade-full", "2.0.0", size: 500, packageType: (int)PackageType.Full, appType: (int)AppType.Upgrade));
198+
199+
var result = DownloadPlanBuilder.Build(assets, "1.0.0", "1.0.0", maxChainBeforeFallback: 8);
200+
Assert.True(result.HasAssets);
201+
// All 8 chain packages selected, none replaced
202+
Assert.Equal(8, result.Assets.Count);
203+
Assert.All(result.Assets, a => Assert.Equal((int)PackageType.Chain, a.PackageType));
204+
}
205+
206+
[Fact]
207+
public void Build_MixedAppType_CountTriggersForClient_UpgradeChainsUnaffected()
208+
{
209+
// Client full + 12 Client chains (triggers count) + 12 Upgrade chains (no Upgrade full).
210+
// → Client chains replaced by Client full, all 12 Upgrade chains kept.
211+
var assets = new List<DownloadAsset>();
212+
for (int i = 1; i <= 12; i++)
213+
assets.Add(AssetWithType($"client-chain{i}", $"1.{i}.0", size: 5, appType: (int)AppType.Client));
214+
for (int i = 1; i <= 12; i++)
215+
assets.Add(AssetWithType($"upgrade-chain{i}", $"1.{i}.0", size: 5, appType: (int)AppType.Upgrade));
216+
assets.Add(AssetWithType("client-full", "2.0.0", size: 500, packageType: (int)PackageType.Full, appType: (int)AppType.Client));
217+
218+
var result = DownloadPlanBuilder.Build(assets, "1.0.0", "1.0.0", maxChainBeforeFallback: 8);
219+
Assert.True(result.HasAssets);
220+
// 1 Client full + 12 Upgrade chains (no Upgrade full → kept as-is)
221+
Assert.Equal(13, result.Assets.Count);
222+
Assert.Equal(12, result.Assets.Count(a => a.AppType == (int)AppType.Upgrade));
223+
Assert.Single(result.Assets, a => a.PackageType == (int)PackageType.Full);
224+
}
225+
101226
[Fact]
102227
public void Build_MinClientVersionTooHigh_FilteredOut()
103228
{

0 commit comments

Comments
 (0)