Skip to content

Commit 346fae3

Browse files
committed
improve download logic
1 parent 65254af commit 346fae3

6 files changed

Lines changed: 559 additions & 124 deletions

File tree

ProjBobcat/ProjBobcat.Tests/ClassOrientedTests/Download/DownloadHelperTests.cs

Lines changed: 324 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,87 @@ public async Task DownloadAsync_AutomaticallyUsesRangesAndVerifiesFile()
2828
var result = await completion;
2929
Assert.IsTrue(result.Success, result.Error?.ToString());
3030
CollectionAssert.AreEqual(data, await File.ReadAllBytesAsync(Path.Combine(directory, file.FileName)));
31-
Assert.IsTrue(handler.RequestedRanges.Count(range => range.End > range.Start) >= 2,
32-
"An 8 MiB response should be split automatically when the server supports ranges.");
31+
Assert.IsTrue(handler.RequestedRanges.Count(range => range.End > range.Start) >= 8,
32+
"An 8 MiB response should use the configured parallelism without creating sub-megabyte parts.");
33+
}
34+
finally
35+
{
36+
Directory.Delete(directory, true);
37+
}
38+
}
39+
40+
[TestMethod]
41+
public async Task DownloadAsync_ProbeLatencyDoesNotReduceTransferParallelism()
42+
{
43+
var data = CreateData(16 * 1024 * 1024);
44+
var handler = new RangeHandler(data) { ProbeDelay = TimeSpan.FromMilliseconds(900) };
45+
var directory = CreateTestDirectory();
46+
47+
try
48+
{
49+
var file = CreateFile(directory, "delayed-probe.bin", "https://primary.test/delayed-probe.bin", data);
50+
var completion = CaptureCompletion(file);
51+
52+
await DownloadHelper.DownloadAsync(file, CreateSettings(handler));
53+
54+
Assert.IsTrue((await completion).Success);
55+
Assert.IsTrue(handler.RequestedRanges.Count(range => range.End > range.Start) >= 8,
56+
"Connection setup latency must not be treated as low sustained bandwidth.");
57+
CollectionAssert.AreEqual(data, await File.ReadAllBytesAsync(Path.Combine(directory, file.FileName)));
58+
}
59+
finally
60+
{
61+
Directory.Delete(directory, true);
62+
}
63+
}
64+
65+
[TestMethod]
66+
public async Task DownloadAsync_AcceptsRangesWithoutOptionalLengthHeaders()
67+
{
68+
var data = CreateData(8 * 1024 * 1024);
69+
var handler = new RangeHandler(data) { OmitTransferLengths = true };
70+
var directory = CreateTestDirectory();
71+
72+
try
73+
{
74+
var file = CreateFile(directory, "unknown-length.bin", "https://primary.test/unknown-length.bin", data);
75+
var completion = CaptureCompletion(file);
76+
77+
await DownloadHelper.DownloadAsync(file, CreateSettings(handler));
78+
79+
Assert.IsTrue((await completion).Success);
80+
Assert.AreEqual(0, handler.WholeFileRequests,
81+
"A valid 206 response must not make the downloader fall back to a full-file request.");
82+
CollectionAssert.AreEqual(data, await File.ReadAllBytesAsync(Path.Combine(directory, file.FileName)));
83+
}
84+
finally
85+
{
86+
Directory.Delete(directory, true);
87+
}
88+
}
89+
90+
[TestMethod]
91+
public async Task DownloadAsync_SmallKnownFilesUseTheConfiguredConnectionBudgetWithoutProbes()
92+
{
93+
var data = CreateData(32 * 1024);
94+
var handler = new ConcurrentContentHandler(data, TimeSpan.FromMilliseconds(100));
95+
var directory = CreateTestDirectory();
96+
var files = Enumerable.Range(0, 32)
97+
.Select(index => CreateFile(directory, $"asset-{index}.bin", $"https://assets.test/{index}", data))
98+
.Cast<AbstractDownloadBase>()
99+
.ToArray();
100+
101+
try
102+
{
103+
var settings = CreateSettings(handler, downloadThread: 32);
104+
105+
await DownloadHelper.DownloadAsync(files, settings);
106+
107+
Assert.AreEqual(0, handler.RangeRequests,
108+
"Known single-part files should not pay for a separate range probe.");
109+
Assert.IsTrue(handler.MaximumConcurrency > 16,
110+
$"Expected the configured connection budget above the old 16-file cap; saw {handler.MaximumConcurrency}.");
111+
Assert.IsTrue(files.All(file => File.Exists(Path.Combine(directory, file.FileName))));
33112
}
34113
finally
35114
{
@@ -72,6 +151,88 @@ public async Task DownloadAsync_SwitchesToTheNextMirrorImmediately()
72151
}
73152
}
74153

154+
[TestMethod]
155+
public async Task DownloadAsync_WholeFileTriesEverySourceBeforeFailing()
156+
{
157+
var data = CreateData(256 * 1024);
158+
var handler = new FailoverHandler(data, "source-5.test");
159+
var directory = CreateTestDirectory();
160+
161+
try
162+
{
163+
var file = CreateMultiSourceFile(directory, "whole-failover.bin", data, 6);
164+
var completion = CaptureCompletion(file);
165+
166+
await DownloadHelper.DownloadAsync(file, CreateSettings(handler, retryCount: 2));
167+
168+
Assert.IsTrue((await completion).Success);
169+
Assert.IsTrue(handler.Hosts.Contains("source-5.test"), "The last configured source was never attempted.");
170+
CollectionAssert.AreEqual(data, await File.ReadAllBytesAsync(Path.Combine(directory, file.FileName)));
171+
}
172+
finally
173+
{
174+
Directory.Delete(directory, true);
175+
}
176+
}
177+
178+
[TestMethod]
179+
public async Task DownloadAsync_RangeTransferTriesEverySourceBeforeFallingBack()
180+
{
181+
var data = CreateData(8 * 1024 * 1024);
182+
var handler = new FailoverHandler(data, "source-5.test", "source-0.test");
183+
var directory = CreateTestDirectory();
184+
185+
try
186+
{
187+
var file = CreateMultiSourceFile(directory, "range-failover.bin", data, 6);
188+
var completion = CaptureCompletion(file);
189+
190+
await DownloadHelper.DownloadAsync(file, CreateSettings(handler, retryCount: 2));
191+
192+
Assert.IsTrue((await completion).Success);
193+
Assert.IsTrue(handler.Hosts.Contains("source-5.test"), "The last configured source was never attempted.");
194+
Assert.AreEqual(0, handler.WholeFileRequests,
195+
"Range failover should succeed before downgrading to a complete-file transfer.");
196+
CollectionAssert.AreEqual(data, await File.ReadAllBytesAsync(Path.Combine(directory, file.FileName)));
197+
}
198+
finally
199+
{
200+
Directory.Delete(directory, true);
201+
}
202+
}
203+
204+
[TestMethod]
205+
public async Task DownloadAsync_HashMismatchRotatesThroughEverySource()
206+
{
207+
var expected = CreateData(256 * 1024);
208+
var corrupt = expected.ToArray();
209+
corrupt[0] ^= 0xff;
210+
var handler = new PerHostContentHandler(new Dictionary<string, byte[]>
211+
{
212+
["source-0.test"] = corrupt,
213+
["source-1.test"] = corrupt,
214+
["source-2.test"] = expected
215+
});
216+
var directory = CreateTestDirectory();
217+
218+
try
219+
{
220+
var file = CreateMultiSourceFile(directory, "hash-failover.bin", expected, 3);
221+
var completion = CaptureCompletion(file);
222+
223+
await DownloadHelper.DownloadAsync(file, CreateSettings(handler, checkFile: true, retryCount: 1));
224+
225+
Assert.IsTrue((await completion).Success);
226+
Assert.IsTrue(handler.Hosts.Contains("source-2.test"),
227+
"Hash validation stopped before reaching the valid backup source.");
228+
CollectionAssert.AreEqual(expected, await File.ReadAllBytesAsync(Path.Combine(directory, file.FileName)));
229+
}
230+
finally
231+
{
232+
Directory.Delete(directory, true);
233+
}
234+
}
235+
75236
[TestMethod]
76237
public async Task DownloadAsync_RetriesATransientProbeFailure()
77238
{
@@ -171,16 +332,38 @@ static SimpleDownloadFile CreateFile(string directory, string name, string url,
171332
};
172333
}
173334

174-
static DownloadSettings CreateSettings(HttpMessageHandler handler, bool checkFile = false)
335+
static MultiSourceDownloadFile CreateMultiSourceFile(
336+
string directory,
337+
string name,
338+
byte[] data,
339+
int sourceCount)
340+
{
341+
return new MultiSourceDownloadFile
342+
{
343+
DownloadPath = directory,
344+
FileName = name,
345+
FileSize = data.Length,
346+
CheckSum = Convert.ToHexString(SHA256.HashData(data)),
347+
DownloadUris = Enumerable.Range(0, sourceCount)
348+
.Select(index => new DownloadUriInfo($"https://source-{index}.test/{name}", sourceCount - index))
349+
.ToArray()
350+
};
351+
}
352+
353+
static DownloadSettings CreateSettings(
354+
HttpMessageHandler handler,
355+
bool checkFile = false,
356+
int downloadThread = 16,
357+
int retryCount = 4)
175358
{
176359
return new DownloadSettings
177360
{
178361
HttpClientFactory = new TestHttpClientFactory(handler),
179362
CheckFile = checkFile,
180363
HashType = HashType.SHA256,
181-
RetryCount = 4,
364+
RetryCount = retryCount,
182365
DownloadParts = 16,
183-
DownloadThread = 16,
366+
DownloadThread = downloadThread,
184367
ConnectionTimeout = TimeSpan.FromSeconds(1),
185368
StallTimeout = TimeSpan.FromSeconds(1),
186369
ProgressInterval = TimeSpan.FromMilliseconds(50)
@@ -221,13 +404,17 @@ sealed class RangeHandler(byte[] data, string? failingHost = null) : HttpMessage
221404
{
222405
readonly object _sync = new();
223406
bool _truncated;
407+
int _wholeFileRequests;
224408
(long Start, long End)? _truncatedRange;
225409

226410
public ConcurrentQueue<(long Start, long End)> RequestedRanges { get; } = new();
227411
public ConcurrentBag<string> Hosts { get; } = [];
228412
public bool TruncateFirstTransfer { get; init; }
413+
public bool OmitTransferLengths { get; init; }
414+
public TimeSpan ProbeDelay { get; init; }
229415
public int TransientFailures { get; set; }
230416
public bool SawResumedRange { get; private set; }
417+
public int WholeFileRequests => Volatile.Read(ref this._wholeFileRequests);
231418

232419
protected override Task<HttpResponseMessage> SendAsync(
233420
HttpRequestMessage request,
@@ -249,6 +436,7 @@ protected override Task<HttpResponseMessage> SendAsync(
249436
var requested = request.Headers.Range?.Ranges.SingleOrDefault();
250437
if (requested == null)
251438
{
439+
Interlocked.Increment(ref this._wholeFileRequests);
252440
var complete = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(data) };
253441
complete.Content.Headers.ContentLength = data.Length;
254442
return Task.FromResult(complete);
@@ -259,7 +447,9 @@ protected override Task<HttpResponseMessage> SendAsync(
259447
this.RequestedRanges.Enqueue((start, end));
260448
var length = checked((int)(end - start + 1));
261449
var bytes = data.AsSpan(checked((int)start), length).ToArray();
262-
HttpContent content = new ByteArrayContent(bytes);
450+
HttpContent content = this.OmitTransferLengths && end > start
451+
? new UnknownLengthContent(bytes)
452+
: new ByteArrayContent(bytes);
263453

264454
lock (this._sync)
265455
{
@@ -275,12 +465,140 @@ protected override Task<HttpResponseMessage> SendAsync(
275465
}
276466
}
277467

468+
if (!this.OmitTransferLengths || end == start)
469+
content.Headers.ContentLength = length;
470+
content.Headers.ContentRange = this.OmitTransferLengths && end > start
471+
? new ContentRangeHeaderValue(start, end)
472+
: new ContentRangeHeaderValue(start, end, data.Length);
473+
var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = content };
474+
return this.ProbeDelay > TimeSpan.Zero && start == 0 && end == 0
475+
? DelayResponseAsync(response, this.ProbeDelay, cancellationToken)
476+
: Task.FromResult(response);
477+
}
478+
479+
static async Task<HttpResponseMessage> DelayResponseAsync(
480+
HttpResponseMessage response,
481+
TimeSpan delay,
482+
CancellationToken cancellationToken)
483+
{
484+
await Task.Delay(delay, cancellationToken);
485+
return response;
486+
}
487+
}
488+
489+
sealed class ConcurrentContentHandler(byte[] data, TimeSpan delay) : HttpMessageHandler
490+
{
491+
int _active;
492+
int _maximumConcurrency;
493+
int _rangeRequests;
494+
495+
public int MaximumConcurrency => Volatile.Read(ref this._maximumConcurrency);
496+
public int RangeRequests => Volatile.Read(ref this._rangeRequests);
497+
498+
protected override async Task<HttpResponseMessage> SendAsync(
499+
HttpRequestMessage request,
500+
CancellationToken cancellationToken)
501+
{
502+
if (request.Headers.Range != null) Interlocked.Increment(ref this._rangeRequests);
503+
var active = Interlocked.Increment(ref this._active);
504+
UpdateMaximum(ref this._maximumConcurrency, active);
505+
506+
try
507+
{
508+
await Task.Delay(delay, cancellationToken);
509+
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(data) };
510+
response.Content.Headers.ContentLength = data.Length;
511+
return response;
512+
}
513+
finally
514+
{
515+
Interlocked.Decrement(ref this._active);
516+
}
517+
}
518+
519+
static void UpdateMaximum(ref int maximum, int value)
520+
{
521+
var current = Volatile.Read(ref maximum);
522+
while (value > current)
523+
{
524+
var observed = Interlocked.CompareExchange(ref maximum, value, current);
525+
if (observed == current) return;
526+
current = observed;
527+
}
528+
}
529+
}
530+
531+
sealed class FailoverHandler(byte[] data, string successfulHost, string? probeHost = null) : HttpMessageHandler
532+
{
533+
int _wholeFileRequests;
534+
535+
public ConcurrentBag<string> Hosts { get; } = [];
536+
public int WholeFileRequests => Volatile.Read(ref this._wholeFileRequests);
537+
538+
protected override Task<HttpResponseMessage> SendAsync(
539+
HttpRequestMessage request,
540+
CancellationToken cancellationToken)
541+
{
542+
var host = request.RequestUri!.Host;
543+
this.Hosts.Add(host);
544+
var requested = request.Headers.Range?.Ranges.SingleOrDefault();
545+
if (requested == null) Interlocked.Increment(ref this._wholeFileRequests);
546+
547+
var isProbe = requested is { From: 0, To: 0 } &&
548+
string.Equals(host, probeHost, StringComparison.OrdinalIgnoreCase);
549+
if (!isProbe && !string.Equals(host, successfulHost, StringComparison.OrdinalIgnoreCase))
550+
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
551+
552+
if (requested == null)
553+
{
554+
var complete = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(data) };
555+
complete.Content.Headers.ContentLength = data.Length;
556+
return Task.FromResult(complete);
557+
}
558+
559+
var start = requested.From ?? 0;
560+
var end = requested.To ?? data.Length - 1;
561+
var length = checked((int)(end - start + 1));
562+
var content = new ByteArrayContent(data.AsSpan(checked((int)start), length).ToArray());
278563
content.Headers.ContentLength = length;
279564
content.Headers.ContentRange = new ContentRangeHeaderValue(start, end, data.Length);
280565
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = content });
281566
}
282567
}
283568

569+
sealed class PerHostContentHandler(IReadOnlyDictionary<string, byte[]> contentByHost) : HttpMessageHandler
570+
{
571+
public ConcurrentBag<string> Hosts { get; } = [];
572+
573+
protected override Task<HttpResponseMessage> SendAsync(
574+
HttpRequestMessage request,
575+
CancellationToken cancellationToken)
576+
{
577+
var host = request.RequestUri!.Host;
578+
this.Hosts.Add(host);
579+
if (!contentByHost.TryGetValue(host, out var data))
580+
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
581+
582+
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(data) };
583+
response.Content.Headers.ContentLength = data.Length;
584+
return Task.FromResult(response);
585+
}
586+
}
587+
588+
sealed class UnknownLengthContent(byte[] bytes) : HttpContent
589+
{
590+
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
591+
{
592+
return stream.WriteAsync(bytes).AsTask();
593+
}
594+
595+
protected override bool TryComputeLength(out long length)
596+
{
597+
length = 0;
598+
return false;
599+
}
600+
}
601+
284602
sealed class ShortContent(byte[] bytes, long advertisedLength) : HttpContent
285603
{
286604
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)

0 commit comments

Comments
 (0)