Skip to content

Commit 706580b

Browse files
authored
feat(download): add orchestrator, status reporter, parallel download support (#319)
- IDownloadOrchestrator + DefaultDownloadOrchestrator with concurrency control - DownloadReport model for batch results - IUpdateReporter interface for lifecycle event reporting - HttpUpdateReporter with HMAC signing (silent failure) - NoOpUpdateReporter default when ReportUrl not configured - UpdateEvent: Started/DownloadCompleted/Applied/Failed/AppStarted Closes #318
1 parent 1061351 commit 706580b

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System.Collections.Generic;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using GeneralUpdate.Core.Download.Models;
5+
6+
namespace GeneralUpdate.Core.Download.Abstractions;
7+
8+
/// <summary>Orchestrates batch downloads with concurrency control.</summary>
9+
public interface IDownloadOrchestrator
10+
{
11+
Task<DownloadReport> ExecuteAsync(
12+
IReadOnlyList<string> urls,
13+
string destDir,
14+
int maxConcurrency = 3,
15+
IProgress<DownloadProgress>? progress = null,
16+
CancellationToken token = default);
17+
}
18+
19+
public record DownloadReport(
20+
IReadOnlyList<DownloadResult> Results,
21+
long TotalBytes,
22+
TimeSpan TotalDuration,
23+
int SuccessCount,
24+
int FailedCount
25+
);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Net.Http;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using GeneralUpdate.Core.Download.Abstractions;
10+
using GeneralUpdate.Core.Download.Executors;
11+
using GeneralUpdate.Core.Download.Policy;
12+
using GeneralUpdate.Core.Download.Models;
13+
14+
namespace GeneralUpdate.Core.Download.Orchestrators;
15+
16+
/// <summary>
17+
/// Default download orchestrator with parallel execution and concurrency limit.
18+
/// </summary>
19+
public class DefaultDownloadOrchestrator : IDownloadOrchestrator
20+
{
21+
private readonly HttpClient _httpClient;
22+
private readonly IDownloadPolicy _policy;
23+
24+
public DefaultDownloadOrchestrator(HttpClient httpClient, IDownloadPolicy? policy = null)
25+
{
26+
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
27+
_policy = policy ?? new DefaultRetryPolicy();
28+
}
29+
30+
public async Task<DownloadReport> ExecuteAsync(
31+
IReadOnlyList<string> urls,
32+
string destDir,
33+
int maxConcurrency = 3,
34+
IProgress<DownloadProgress>? progress = null,
35+
CancellationToken token = default)
36+
{
37+
var sw = Stopwatch.StartNew();
38+
var results = new List<DownloadResult>();
39+
var sem = new SemaphoreSlim(maxConcurrency);
40+
long totalBytes = 0;
41+
42+
var tasks = urls.Select(async (url, i) =>
43+
{
44+
await sem.WaitAsync(token).ConfigureAwait(false);
45+
try
46+
{
47+
var fileName = Path.GetFileName(new Uri(url).AbsolutePath);
48+
if (string.IsNullOrEmpty(fileName)) fileName = $"download_{i}";
49+
var destPath = Path.Combine(destDir, fileName);
50+
51+
var executor = new HttpDownloadExecutor(_httpClient);
52+
var r = await _policy.ExecuteAsync(ct =>
53+
executor.ExecuteAsync(url, destPath, progress, ct), token)
54+
.ConfigureAwait(false);
55+
56+
lock (results)
57+
{
58+
results.Add(r);
59+
if (r.Success) totalBytes += r.DownloadedBytes;
60+
}
61+
}
62+
finally { sem.Release(); }
63+
});
64+
65+
await Task.WhenAll(tasks).ConfigureAwait(false);
66+
sw.Stop();
67+
68+
return new DownloadReport(
69+
results,
70+
totalBytes,
71+
sw.Elapsed,
72+
results.Count(r => r.Success),
73+
results.Count(r => !r.Success));
74+
}
75+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System;
2+
using System.Net.Http;
3+
using System.Text;
4+
using System.Text.Json;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using GeneralUpdate.Core;
8+
using GeneralUpdate.Core.Configuration;
9+
10+
namespace GeneralUpdate.Core.Download.Reporting;
11+
12+
/// <summary>Reports update lifecycle events to the server.</summary>
13+
public interface IUpdateReporter
14+
{
15+
Task ReportAsync(UpdateReport report, CancellationToken token = default);
16+
}
17+
18+
public enum UpdateEvent { UpdateStarted, DownloadCompleted, UpdateApplied, UpdateFailed, AppStarted }
19+
20+
public record UpdateReport(
21+
string AppName,
22+
string FromVersion,
23+
string? ToVersion,
24+
UpdateEvent Event,
25+
int AppType,
26+
DateTimeOffset Timestamp,
27+
string? ErrorMessage = null,
28+
double? DurationMs = null
29+
);
30+
31+
/// <summary>HTTP POST reporter with optional HMAC signing.</summary>
32+
public class HttpUpdateReporter : IUpdateReporter
33+
{
34+
private readonly HttpClient _client;
35+
private readonly string _reportUrl;
36+
private readonly string? _secretKey;
37+
38+
public HttpUpdateReporter(HttpClient client, string reportUrl, string? secretKey = null)
39+
{
40+
_client = client;
41+
_reportUrl = reportUrl;
42+
_secretKey = secretKey;
43+
}
44+
45+
public async Task ReportAsync(UpdateReport report, CancellationToken token = default)
46+
{
47+
try
48+
{
49+
var json = JsonSerializer.Serialize(report);
50+
51+
using var request = new HttpRequestMessage(HttpMethod.Post, _reportUrl);
52+
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
53+
54+
if (!string.IsNullOrEmpty(_secretKey))
55+
{
56+
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
57+
var sig = ComputeHmac($"{json}|{ts}", _secretKey);
58+
request.Headers.Add("X-Update-Timestamp", ts);
59+
request.Headers.Add("X-Update-Signature", sig);
60+
}
61+
62+
await _client.SendAsync(request, token).ConfigureAwait(false);
63+
}
64+
catch (Exception ex)
65+
{
66+
// Silent failure — reporting should never break the update flow
67+
GeneralTracer.Warn($"Report failed: {ex.Message}");
68+
}
69+
}
70+
71+
private static string ComputeHmac(string data, string key)
72+
{
73+
var h = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(key))
74+
.ComputeHash(Encoding.UTF8.GetBytes(data));
75+
return BitConverter.ToString(h).Replace("-", "").ToLowerInvariant();
76+
}
77+
}
78+
79+
/// <summary>No-op reporter used when ReportUrl is not configured.</summary>
80+
public class NoOpUpdateReporter : IUpdateReporter
81+
{
82+
public Task ReportAsync(UpdateReport report, CancellationToken token = default)
83+
=> Task.CompletedTask;
84+
}

0 commit comments

Comments
 (0)