Optimize Fuchs_DataService: parallel file sync, shared HttpClient, cancellation

Reviewed the data service against mfr_interface_description.md. The OData entity
sync already follows @odata.nextLink and now inherits the MFR client's transient
retry + timeout, so it is spec-aligned. Reliability/performance improvements:

- MFRClient.GetFile no longer news up an HttpClient per call (socket-exhaustion
  risk); added GetFileAsync backed by one shared static HttpClient with
  per-request auth, and GetFile delegates to it.
- GetInvoiceFiles_async now downloads + stores invoice PDFs in parallel
  (bounded concurrency 4) via Parallel.ForEachAsync instead of sequentially.
- Threaded CancellationToken from the MfrSync job through UpdateIfNecessary_async/
  UpdateRequested_async/GetInvoiceFiles_async and the entity-sync loops for
  graceful shutdown (cooperative checks between iterations). Entity-table sync
  is left sequential on purpose (referential ordering by updateneed).

IFdsMfr sync methods gained optional CancellationToken params (default) — the
web app only uses the read methods, so this stays source-compatible with Fuchs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:27:55 +02:00
parent 2a75664625
commit 2c17171e77
5 changed files with 77 additions and 36 deletions
+28 -8
View File
@@ -84,26 +84,46 @@ public class MFRClient : IDisposable
return first;
}
// One shared HttpClient per process avoids socket exhaustion from per-call instances.
// Auth + user-agent are set per request so the shared instance stays stateless.
private static readonly HttpClient _sharedHttpClient = new() { Timeout = TimeSpan.FromMinutes(5) };
public byte[]? GetFile(string address, bool throwErrorIfNotOk = true)
=> GetFileAsync(address, throwErrorIfNotOk).GetAwaiter().GetResult();
public async Task<byte[]?> GetFileAsync(string address, bool throwErrorIfNotOk = true, CancellationToken cancellationToken = default)
{
byte[]? data = null;
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(DownloadUserAgent);
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{_clientCredentials.Username}:{_clientCredentials.Password}"));
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
try
{
data = httpClient.GetByteArrayAsync(new Uri(address)).GetAwaiter().GetResult();
using var req = new HttpRequestMessage(HttpMethod.Get, new Uri(address));
req.Headers.UserAgent.ParseAdd(DownloadUserAgent);
var credentials = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes($"{_clientCredentials.Username}:{_clientCredentials.Password}"));
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
using var resp = await _sharedHttpClient.SendAsync(req, cancellationToken);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("GetFile non-success status {Status} — address={Address}", resp.StatusCode, address);
if (throwErrorIfNotOk && !HideCustomExceptions)
throw new MFRClientException(resp.StatusCode, $"GetFile failed ({(int)resp.StatusCode})", address);
return null;
}
return await resp.Content.ReadAsByteArrayAsync(cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "GetFile failed with HttpRequestException — address={Address}", address);
return null;
}
catch (Exception)
{
// Swallow
return null;
}
return data;
}
public async Task<string> GetEntities(bool throwErrorIfNotOk = true)