Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,401 changes: 1,401 additions & 0 deletions docs/core-execution-flow.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/c#/GeneralUpdate.Bowl/GeneralUpdate.Bowl.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<RepositoryType>public</RepositoryType>
<PackageTags>upgrade,update,crash,recovery,rollback,monitor,backup</PackageTags>
<PackageReleaseNotes>10.5.0-beta.1: Refactored to instance-based async design with DI support. Removed IPC dependency and macOS platform support.</PackageReleaseNotes>
<Version>10.5.0-beta.6</Version>
<Version>10.5.0-beta.7</Version>
<PackageIcon>bowl.jpeg</PackageIcon>
<TargetFrameworks>netstandard2.0;</TargetFrameworks>
</PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
18 changes: 18 additions & 0 deletions src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,5 +245,23 @@ public abstract class UpdateConfiguration
/// </remarks>
/// </remarks>
public Dictionary<string, string> CustomHeaders { get; set; }

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Set to <c>0</c> or a negative value to disable the count-based fallback
/// (only the size comparison applies).
/// </para>
/// </remarks>
public int MaxChainBeforeFallback { get; set; } = 8;
}
}
36 changes: 27 additions & 9 deletions src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ namespace GeneralUpdate.Core.Download;
/// if the current client version is below the minimum, the package is skipped.</description></item>
/// </list>
/// <para>
/// 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
/// <c>MaxChainBeforeFallback</c> (default 8), or when the combined download size
/// of all chain packages equals or exceeds the full package size.
/// </para>
/// </remarks>
public static class DownloadPlanBuilder
Expand Down Expand Up @@ -108,10 +110,17 @@ public static bool HasUpdate(
/// <param name="upgradeClientVersion">
/// The current upgrade (updater) version, or null to fall back to <paramref name="clientVersion"/>.
/// </param>
/// <param name="maxChainBeforeFallback">
/// Maximum number of chain packages allowed before falling back to a single full package.
/// Default is <c>8</c>. Set to <c>0</c> 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.
/// </param>
public static DownloadPlan Build(
IEnumerable<DownloadAsset> assets,
string clientVersion,
string? upgradeClientVersion)
string? upgradeClientVersion,
int maxChainBeforeFallback = 8)
{
if (assets == null) return DownloadPlan.Empty;
var parsedClient = ParseVersion(clientVersion);
Expand Down Expand Up @@ -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
Expand All @@ -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<DownloadAsset> { 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 ──
Expand Down
2 changes: 1 addition & 1 deletion src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<PackageReleaseNotes>10.5.0-beta.1: Zero-config SetSource() API, manifest.json auto-discovery, IUpdateHooks extension points, appsettings.json LoadFromConfiguration() support, SSL/HttpClient lifecycle fixes, and OSS update flow improvements.</PackageReleaseNotes>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Version>10.5.0-beta.6</Version>
<Version>10.5.0-beta.7</Version>
<TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
<IsAotCompatible Condition="'$(TargetFramework)' != 'netstandard2.0'">true</IsAotCompatible>
<EnableTrimAnalyzer Condition="'$(TargetFramework)' != 'netstandard2.0'">true</EnableTrimAnalyzer>
Expand Down
3 changes: 2 additions & 1 deletion src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<RepositoryUrl>https://github.com/JusterZhu/GeneralUpdate</RepositoryUrl>
<Description>Binary diff/patch algorithms (BSDIFF, HDiffPatch) with Brotli compression for generating and applying incremental updates. Includes parallel file processing with progress reporting and cancellation support.</Description>
<Authors>JusterZhu</Authors>
<Version>10.5.0-beta.6</Version>
<Version>10.5.0-beta.7</Version>
<PackageProjectUrl>https://github.com/GeneralLibrary/GeneralUpdate</PackageProjectUrl>
<Copyright>Copyright © 2020-2026 JusterZhu. All rights reserved.</Copyright>
<LangVersion>default</LangVersion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<TrimMode>full</TrimMode>
<!-- NuGet Package Metadata -->
<Version>10.5.0-beta.6</Version>
<Version>10.5.0-beta.7</Version>
<Authors>JusterZhu</Authors>
<Company>juster.zhu</Company>
<Title>GeneralUpdate.Drivelution</Title>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<!-- NuGet Package Metadata -->
<Version>10.5.0-beta.6</Version>
<Version>10.5.0-beta.7</Version>
<Authors>JusterZhu</Authors>
<Company>juster.zhu</Company>
<Title>GeneralUpdate.Extension</Title>
Expand Down
131 changes: 128 additions & 3 deletions tests/CoreTest/Download/DownloadPlanBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,148 @@ 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=100200 >= 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);
Assert.Equal((int)PackageType.Full, result.Assets[0].PackageType);
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<DownloadAsset>();
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<DownloadAsset>();
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<DownloadAsset>();
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<DownloadAsset>();
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<DownloadAsset>();
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()
{
Expand Down
Loading