From 297ffc3557187d370b7927b2b6632f7c4cab842b Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sat, 25 Apr 2026 18:46:29 +0700 Subject: [PATCH 1/2] [fix] Enable adaptive downscale after client timeouts Made-with: Cursor --- .../Uploading/AdaptiveUploadController.cs | 95 +++++++++++++------ .../AdaptiveUploadControllerTests.cs | 82 +++++++++++----- 2 files changed, 121 insertions(+), 56 deletions(-) diff --git a/src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs b/src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs index 07cbb59a..8dcb42aa 100644 --- a/src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs +++ b/src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs @@ -14,6 +14,7 @@ public class AdaptiveUploadController : IAdaptiveUploadController private const int MAX_CHUNK_SIZE_BYTES = 16 * 1024 * 1024; // 16 MB private const int MIN_PARALLELISM = 2; private const int MAX_PARALLELISM = 4; + private const int CLIENT_TIMEOUTS_BEFORE_DOWNSCALE = 2; private const double MULTIPLIER_2_X = 2.0; private const double MULTIPLIER_1_75_X = 1.75; @@ -36,6 +37,7 @@ public class AdaptiveUploadController : IAdaptiveUploadController private readonly Queue _recentBytes; private int _successesInWindow; private int _windowSize; + private int _consecutiveClientTimeouts; private readonly ILogger _logger; private readonly object _syncRoot = new(); @@ -86,11 +88,19 @@ public void RecordUploadResult(UploadResult uploadResult) { lock (_syncRoot) { - if (IsClientSideFailure(uploadResult.FailureKind)) + if (uploadResult.FailureKind == UploadFailureKind.ClientCancellation) { return; } + if (uploadResult.FailureKind == UploadFailureKind.ClientTimeout) + { + HandleClientTimeout(uploadResult.FileId); + return; + } + + _consecutiveClientTimeouts = 0; + EnqueueSample(uploadResult.Elapsed, uploadResult.IsSuccess, uploadResult.ActualBytes); if (HandleBandwidthReset(uploadResult.IsSuccess, uploadResult.StatusCode)) @@ -151,9 +161,26 @@ private void EnqueueSample(TimeSpan elapsed, bool isSuccess, long actualBytes) } } - private static bool IsClientSideFailure(UploadFailureKind failureKind) + private void HandleClientTimeout(string? fileId) { - return failureKind is UploadFailureKind.ClientCancellation or UploadFailureKind.ClientTimeout; + _consecutiveClientTimeouts += 1; + if (_consecutiveClientTimeouts < CLIENT_TIMEOUTS_BEFORE_DOWNSCALE) + { + _logger.LogDebug( + "Adaptive: file {FileId} client timeout {TimeoutCount}/{Threshold}. Waiting before downscale", + fileId ?? "-", + _consecutiveClientTimeouts, + CLIENT_TIMEOUTS_BEFORE_DOWNSCALE); + + return; + } + + _logger.LogInformation( + "Adaptive: file {FileId} client timeout threshold reached ({TimeoutCount}). Downscaling upload settings", + fileId ?? "-", + _consecutiveClientTimeouts); + _consecutiveClientTimeouts = 0; + Downscale(fileId, "client timeouts"); } private bool HandleBandwidthReset(bool isSuccess, int? statusCode) @@ -192,40 +219,45 @@ private bool TryHandleDownscale(TimeSpan maxElapsed, string? fileId) { if (maxElapsed > _downscaleThreshold) { - if (_currentParallelism > MIN_PARALLELISM) - { - _logger.LogInformation( - "Adaptive: file {FileId} Downscale. Reducing parallelism {Prev} -> {Next}. Resetting window (window before {WindowBefore})", - fileId ?? "-", - _currentParallelism, _currentParallelism - 1, - _windowSize); - _currentParallelism -= 1; - _windowSize = _currentParallelism; - ResetWindow(); - - return true; - } - - var reduced = (int)Math.Max(MIN_CHUNK_SIZE_BYTES, _currentChunkSizeBytes * 0.75); - if (reduced != _currentChunkSizeBytes) - { - _currentChunkSizeBytes = reduced; - _logger.LogInformation( - "Adaptive: file {FileId} Downscale. maxElapsed={MaxElapsedMs} ms > {ThresholdMs} ms. New chunkSize={ChunkKb} KB", - fileId ?? "-", - maxElapsed.TotalMilliseconds, - _downscaleThreshold.TotalMilliseconds, - Math.Round(_currentChunkSizeBytes / 1024d)); - } - - ResetWindow(); - + Downscale(fileId, $"maxElapsed={maxElapsed.TotalMilliseconds} ms > {_downscaleThreshold.TotalMilliseconds} ms"); return true; } return false; } + private void Downscale(string? fileId, string reason) + { + if (_currentParallelism > MIN_PARALLELISM) + { + _logger.LogInformation( + "Adaptive: file {FileId} Downscale ({Reason}). Reducing parallelism {Prev} -> {Next}. Resetting window (window before {WindowBefore})", + fileId ?? "-", + reason, + _currentParallelism, + _currentParallelism - 1, + _windowSize); + _currentParallelism -= 1; + _windowSize = _currentParallelism; + ResetWindow(); + + return; + } + + var reduced = (int)Math.Max(MIN_CHUNK_SIZE_BYTES, _currentChunkSizeBytes * 0.75); + if (reduced != _currentChunkSizeBytes) + { + _currentChunkSizeBytes = reduced; + _logger.LogInformation( + "Adaptive: file {FileId} Downscale ({Reason}). New chunkSize={ChunkKb} KB", + fileId ?? "-", + reason, + Math.Round(_currentChunkSizeBytes / 1024d)); + } + + ResetWindow(); + } + private void TryHandleUpscale(string? fileId) { var recentDurations = _recentDurations.ToArray(); @@ -362,6 +394,7 @@ private void ResetState() _currentChunkSizeBytes = Math.Clamp(INITIAL_CHUNK_SIZE_BYTES, MIN_CHUNK_SIZE_BYTES, MAX_CHUNK_SIZE_BYTES); _currentParallelism = MIN_PARALLELISM; _windowSize = _currentParallelism; + _consecutiveClientTimeouts = 0; } ResetWindow(); diff --git a/tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs b/tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs index e7e28825..e282dab6 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs @@ -184,40 +184,58 @@ public void ClientTimeout_DoesNotResetChunkSize() } [Test] - public void ClientTimeout_DoesNotEnterAdaptiveWindow_AndDoesNotResetChunkSize() + public void ClientTimeouts_DownscaleBelowInitialChunkSize_WhenAtMinParallelism() { - // Arrange - Inflate chunk and make sure parallelism is just at min (=2) - var safety = 10; - while (_controller.CurrentChunkSizeBytes < 1024 * 1024 && safety-- > 0) + // Arrange + _controller.CurrentParallelism.Should().Be(2); + _controller.CurrentChunkSizeBytes.Should().Be(500 * 1024); + + // Act - Two consecutive client-side timeouts trigger controlled downscale + FeedClientTimeouts(_controller, 2); + + // Assert + _controller.CurrentParallelism.Should().Be(2); + _controller.CurrentChunkSizeBytes.Should().BeLessThan(500 * 1024); + _controller.CurrentChunkSizeBytes.Should().Be(375 * 1024); + } + + [Test] + public void ClientTimeouts_ReduceParallelismFirst_WhenAboveMinParallelism() + { + // Arrange + var safety = 100; + while (_controller.CurrentChunkSizeBytes < 4 * 1024 * 1024 && safety-- > 0) { FeedFastWindow(_controller); } + + _controller.CurrentParallelism.Should().BeGreaterThan(2); + var beforeParallelism = _controller.CurrentParallelism; + var beforeChunk = _controller.CurrentChunkSizeBytes; + + // Act + FeedClientTimeouts(_controller, 2); + + // Assert + _controller.CurrentParallelism.Should().Be(beforeParallelism - 1); + _controller.CurrentChunkSizeBytes.Should().Be(beforeChunk); + } + + [Test] + public void ClientTimeouts_DoNotReduceChunkSizeBelowMinimum() + { + // Arrange _controller.CurrentParallelism.Should().Be(2); - var inflatedChunk = _controller.CurrentChunkSizeBytes; - // Act - Feed a window of slow client-timeout failures (with the new failure kind) - var p = _controller.CurrentParallelism; - for (var i = 0; i < p; i++) + // Act + for (var i = 0; i < 20; i++) { - RecordUploadResult( - _controller, - TimeSpan.FromSeconds(60), - isSuccess: false, - partNumber: i + 1, - statusCode: 0, - failureKind: UploadFailureKind.ClientTimeout); + FeedClientTimeouts(_controller, 2); } - RecordUploadResult( - _controller, - TimeSpan.FromSeconds(1), - isSuccess: true, - partNumber: 100); - - // Assert - the timeout samples were ignored and cannot trigger a later downscale - _controller.CurrentChunkSizeBytes.Should().NotBe(500 * 1024); - _controller.CurrentChunkSizeBytes.Should().Be(inflatedChunk, - because: "client-side cancellations are not bandwidth signals and must not influence chunk sizing"); + // Assert + _controller.CurrentChunkSizeBytes.Should().Be(64 * 1024); + _controller.CurrentParallelism.Should().Be(2); } [Test] @@ -248,6 +266,20 @@ private static void FeedWindow(AdaptiveUploadController controller, TimeSpan ela } } + private static void FeedClientTimeouts(AdaptiveUploadController controller, int count) + { + for (var i = 0; i < count; i++) + { + RecordUploadResult( + controller, + TimeSpan.FromSeconds(60), + isSuccess: false, + partNumber: i + 1, + statusCode: 0, + failureKind: UploadFailureKind.ClientTimeout); + } + } + private static void RecordUploadResult( AdaptiveUploadController controller, TimeSpan elapsed, From f774b3982ec6a12675117dd7861ada2720314fa6 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sun, 26 Apr 2026 07:11:02 +0700 Subject: [PATCH 2/2] [fix] Address adaptive timeout review comments Made-with: Cursor --- .../Transfers/Uploading/AdaptiveUploadController.cs | 1 + .../Transfers/Uploading/AdaptiveUploadControllerTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs b/src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs index 8dcb42aa..e910950f 100644 --- a/src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs +++ b/src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs @@ -90,6 +90,7 @@ public void RecordUploadResult(UploadResult uploadResult) { if (uploadResult.FailureKind == UploadFailureKind.ClientCancellation) { + _consecutiveClientTimeouts = 0; return; } diff --git a/tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs b/tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs index e282dab6..4b00588c 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs @@ -93,6 +93,7 @@ public void Downscale_ReducesParallelism_WhenAboveMin() FeedFastWindow(_controller); } + _controller.CurrentChunkSizeBytes.Should().BeGreaterThanOrEqualTo(4 * 1024 * 1024); _controller.CurrentParallelism.Should().BeGreaterThan(2); var beforeParallelism = _controller.CurrentParallelism; var beforeChunk = _controller.CurrentChunkSizeBytes;