Skip to content

Commit ee8fc4e

Browse files
[fix] Enable adaptive downscale after client timeouts (#291)
1 parent fccfb43 commit ee8fc4e

2 files changed

Lines changed: 123 additions & 56 deletions

File tree

src/ByteSync.Client/Services/Communications/Transfers/Uploading/AdaptiveUploadController.cs

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class AdaptiveUploadController : IAdaptiveUploadController
1414
private const int MAX_CHUNK_SIZE_BYTES = 16 * 1024 * 1024; // 16 MB
1515
private const int MIN_PARALLELISM = 2;
1616
private const int MAX_PARALLELISM = 4;
17+
private const int CLIENT_TIMEOUTS_BEFORE_DOWNSCALE = 2;
1718

1819
private const double MULTIPLIER_2_X = 2.0;
1920
private const double MULTIPLIER_1_75_X = 1.75;
@@ -36,6 +37,7 @@ public class AdaptiveUploadController : IAdaptiveUploadController
3637
private readonly Queue<long> _recentBytes;
3738
private int _successesInWindow;
3839
private int _windowSize;
40+
private int _consecutiveClientTimeouts;
3941
private readonly ILogger<AdaptiveUploadController> _logger;
4042
private readonly object _syncRoot = new();
4143

@@ -86,11 +88,20 @@ public void RecordUploadResult(UploadResult uploadResult)
8688
{
8789
lock (_syncRoot)
8890
{
89-
if (IsClientSideFailure(uploadResult.FailureKind))
91+
if (uploadResult.FailureKind == UploadFailureKind.ClientCancellation)
9092
{
93+
_consecutiveClientTimeouts = 0;
9194
return;
9295
}
9396

97+
if (uploadResult.FailureKind == UploadFailureKind.ClientTimeout)
98+
{
99+
HandleClientTimeout(uploadResult.FileId);
100+
return;
101+
}
102+
103+
_consecutiveClientTimeouts = 0;
104+
94105
EnqueueSample(uploadResult.Elapsed, uploadResult.IsSuccess, uploadResult.ActualBytes);
95106

96107
if (HandleBandwidthReset(uploadResult.IsSuccess, uploadResult.StatusCode))
@@ -151,9 +162,26 @@ private void EnqueueSample(TimeSpan elapsed, bool isSuccess, long actualBytes)
151162
}
152163
}
153164

154-
private static bool IsClientSideFailure(UploadFailureKind failureKind)
165+
private void HandleClientTimeout(string? fileId)
155166
{
156-
return failureKind is UploadFailureKind.ClientCancellation or UploadFailureKind.ClientTimeout;
167+
_consecutiveClientTimeouts += 1;
168+
if (_consecutiveClientTimeouts < CLIENT_TIMEOUTS_BEFORE_DOWNSCALE)
169+
{
170+
_logger.LogDebug(
171+
"Adaptive: file {FileId} client timeout {TimeoutCount}/{Threshold}. Waiting before downscale",
172+
fileId ?? "-",
173+
_consecutiveClientTimeouts,
174+
CLIENT_TIMEOUTS_BEFORE_DOWNSCALE);
175+
176+
return;
177+
}
178+
179+
_logger.LogInformation(
180+
"Adaptive: file {FileId} client timeout threshold reached ({TimeoutCount}). Downscaling upload settings",
181+
fileId ?? "-",
182+
_consecutiveClientTimeouts);
183+
_consecutiveClientTimeouts = 0;
184+
Downscale(fileId, "client timeouts");
157185
}
158186

159187
private bool HandleBandwidthReset(bool isSuccess, int? statusCode)
@@ -192,40 +220,45 @@ private bool TryHandleDownscale(TimeSpan maxElapsed, string? fileId)
192220
{
193221
if (maxElapsed > _downscaleThreshold)
194222
{
195-
if (_currentParallelism > MIN_PARALLELISM)
196-
{
197-
_logger.LogInformation(
198-
"Adaptive: file {FileId} Downscale. Reducing parallelism {Prev} -> {Next}. Resetting window (window before {WindowBefore})",
199-
fileId ?? "-",
200-
_currentParallelism, _currentParallelism - 1,
201-
_windowSize);
202-
_currentParallelism -= 1;
203-
_windowSize = _currentParallelism;
204-
ResetWindow();
205-
206-
return true;
207-
}
208-
209-
var reduced = (int)Math.Max(MIN_CHUNK_SIZE_BYTES, _currentChunkSizeBytes * 0.75);
210-
if (reduced != _currentChunkSizeBytes)
211-
{
212-
_currentChunkSizeBytes = reduced;
213-
_logger.LogInformation(
214-
"Adaptive: file {FileId} Downscale. maxElapsed={MaxElapsedMs} ms > {ThresholdMs} ms. New chunkSize={ChunkKb} KB",
215-
fileId ?? "-",
216-
maxElapsed.TotalMilliseconds,
217-
_downscaleThreshold.TotalMilliseconds,
218-
Math.Round(_currentChunkSizeBytes / 1024d));
219-
}
220-
221-
ResetWindow();
222-
223+
Downscale(fileId, $"maxElapsed={maxElapsed.TotalMilliseconds} ms > {_downscaleThreshold.TotalMilliseconds} ms");
223224
return true;
224225
}
225226

226227
return false;
227228
}
228229

230+
private void Downscale(string? fileId, string reason)
231+
{
232+
if (_currentParallelism > MIN_PARALLELISM)
233+
{
234+
_logger.LogInformation(
235+
"Adaptive: file {FileId} Downscale ({Reason}). Reducing parallelism {Prev} -> {Next}. Resetting window (window before {WindowBefore})",
236+
fileId ?? "-",
237+
reason,
238+
_currentParallelism,
239+
_currentParallelism - 1,
240+
_windowSize);
241+
_currentParallelism -= 1;
242+
_windowSize = _currentParallelism;
243+
ResetWindow();
244+
245+
return;
246+
}
247+
248+
var reduced = (int)Math.Max(MIN_CHUNK_SIZE_BYTES, _currentChunkSizeBytes * 0.75);
249+
if (reduced != _currentChunkSizeBytes)
250+
{
251+
_currentChunkSizeBytes = reduced;
252+
_logger.LogInformation(
253+
"Adaptive: file {FileId} Downscale ({Reason}). New chunkSize={ChunkKb} KB",
254+
fileId ?? "-",
255+
reason,
256+
Math.Round(_currentChunkSizeBytes / 1024d));
257+
}
258+
259+
ResetWindow();
260+
}
261+
229262
private void TryHandleUpscale(string? fileId)
230263
{
231264
var recentDurations = _recentDurations.ToArray();
@@ -362,6 +395,7 @@ private void ResetState()
362395
_currentChunkSizeBytes = Math.Clamp(INITIAL_CHUNK_SIZE_BYTES, MIN_CHUNK_SIZE_BYTES, MAX_CHUNK_SIZE_BYTES);
363396
_currentParallelism = MIN_PARALLELISM;
364397
_windowSize = _currentParallelism;
398+
_consecutiveClientTimeouts = 0;
365399
}
366400

367401
ResetWindow();

tests/ByteSync.Client.UnitTests/Services/Communications/Transfers/Uploading/AdaptiveUploadControllerTests.cs

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public void Downscale_ReducesParallelism_WhenAboveMin()
9393
FeedFastWindow(_controller);
9494
}
9595

96+
_controller.CurrentChunkSizeBytes.Should().BeGreaterThanOrEqualTo(4 * 1024 * 1024);
9697
_controller.CurrentParallelism.Should().BeGreaterThan(2);
9798
var beforeParallelism = _controller.CurrentParallelism;
9899
var beforeChunk = _controller.CurrentChunkSizeBytes;
@@ -184,40 +185,58 @@ public void ClientTimeout_DoesNotResetChunkSize()
184185
}
185186

186187
[Test]
187-
public void ClientTimeout_DoesNotEnterAdaptiveWindow_AndDoesNotResetChunkSize()
188+
public void ClientTimeouts_DownscaleBelowInitialChunkSize_WhenAtMinParallelism()
189+
{
190+
// Arrange
191+
_controller.CurrentParallelism.Should().Be(2);
192+
_controller.CurrentChunkSizeBytes.Should().Be(500 * 1024);
193+
194+
// Act - Two consecutive client-side timeouts trigger controlled downscale
195+
FeedClientTimeouts(_controller, 2);
196+
197+
// Assert
198+
_controller.CurrentParallelism.Should().Be(2);
199+
_controller.CurrentChunkSizeBytes.Should().BeLessThan(500 * 1024);
200+
_controller.CurrentChunkSizeBytes.Should().Be(375 * 1024);
201+
}
202+
203+
[Test]
204+
public void ClientTimeouts_ReduceParallelismFirst_WhenAboveMinParallelism()
188205
{
189-
// Arrange - Inflate chunk and make sure parallelism is just at min (=2)
190-
var safety = 10;
191-
while (_controller.CurrentChunkSizeBytes < 1024 * 1024 && safety-- > 0)
206+
// Arrange
207+
var safety = 100;
208+
while (_controller.CurrentChunkSizeBytes < 4 * 1024 * 1024 && safety-- > 0)
192209
{
193210
FeedFastWindow(_controller);
194211
}
212+
213+
_controller.CurrentParallelism.Should().BeGreaterThan(2);
214+
var beforeParallelism = _controller.CurrentParallelism;
215+
var beforeChunk = _controller.CurrentChunkSizeBytes;
216+
217+
// Act
218+
FeedClientTimeouts(_controller, 2);
219+
220+
// Assert
221+
_controller.CurrentParallelism.Should().Be(beforeParallelism - 1);
222+
_controller.CurrentChunkSizeBytes.Should().Be(beforeChunk);
223+
}
224+
225+
[Test]
226+
public void ClientTimeouts_DoNotReduceChunkSizeBelowMinimum()
227+
{
228+
// Arrange
195229
_controller.CurrentParallelism.Should().Be(2);
196-
var inflatedChunk = _controller.CurrentChunkSizeBytes;
197230

198-
// Act - Feed a window of slow client-timeout failures (with the new failure kind)
199-
var p = _controller.CurrentParallelism;
200-
for (var i = 0; i < p; i++)
231+
// Act
232+
for (var i = 0; i < 20; i++)
201233
{
202-
RecordUploadResult(
203-
_controller,
204-
TimeSpan.FromSeconds(60),
205-
isSuccess: false,
206-
partNumber: i + 1,
207-
statusCode: 0,
208-
failureKind: UploadFailureKind.ClientTimeout);
234+
FeedClientTimeouts(_controller, 2);
209235
}
210236

211-
RecordUploadResult(
212-
_controller,
213-
TimeSpan.FromSeconds(1),
214-
isSuccess: true,
215-
partNumber: 100);
216-
217-
// Assert - the timeout samples were ignored and cannot trigger a later downscale
218-
_controller.CurrentChunkSizeBytes.Should().NotBe(500 * 1024);
219-
_controller.CurrentChunkSizeBytes.Should().Be(inflatedChunk,
220-
because: "client-side cancellations are not bandwidth signals and must not influence chunk sizing");
237+
// Assert
238+
_controller.CurrentChunkSizeBytes.Should().Be(64 * 1024);
239+
_controller.CurrentParallelism.Should().Be(2);
221240
}
222241

223242
[Test]
@@ -248,6 +267,20 @@ private static void FeedWindow(AdaptiveUploadController controller, TimeSpan ela
248267
}
249268
}
250269

270+
private static void FeedClientTimeouts(AdaptiveUploadController controller, int count)
271+
{
272+
for (var i = 0; i < count; i++)
273+
{
274+
RecordUploadResult(
275+
controller,
276+
TimeSpan.FromSeconds(60),
277+
isSuccess: false,
278+
partNumber: i + 1,
279+
statusCode: 0,
280+
failureKind: UploadFailureKind.ClientTimeout);
281+
}
282+
}
283+
251284
private static void RecordUploadResult(
252285
AdaptiveUploadController controller,
253286
TimeSpan elapsed,

0 commit comments

Comments
 (0)