Parallelize invoice image fetch, add telemetry, generalize HtmlConverter

- InvoiceService.ResolveInlineImagesAsync now fetches an invoice's
  distinct whitelisted images concurrently (SemaphoreSlim-capped at 4)
  instead of one at a time. It ran inline on every invoice render,
  including repeated draft-preview renders, so N sequential mfr-portal
  round-trips could otherwise add N x latency to a user-facing request.
- Add fuchs.invoice.inlineimages.fetches (counter, tagged by outcome:
  success/http_error/bad_content_type/oversized/exception) and
  fuchs.invoice.inlineimages.resolve.duration (histogram) to
  FuchsTelemetry, matching the project's observability convention for
  outbound-HTTP operations.
- MigraDocExtensions.HtmlConverter's pre-resolved-image parameter is
  renamed inlineImages -> resolvedImages with generic docs: the
  converter fetches nothing and knows nothing about invoices — which
  images to resolve, and any whitelist/network policy, is entirely the
  caller's concern (InvoiceService, ADR 0018 today; any future caller
  tomorrow).
- Add tests asserting fetches actually overlap (not just faster) and
  that the new counter/histogram are recorded.
- Update ADR 0018 to describe the concurrent fetch, telemetry, and the
  generalized converter parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:17:09 +02:00
co-authored by Claude Sonnet 5
parent 406c6f2860
commit f407341f5f
5 changed files with 177 additions and 45 deletions
+84
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Net;
using System.Net.Http;
@@ -36,6 +37,34 @@ public class InvoiceInlineImageTests
}
}
/// <summary>Delays each response and records how many requests were in flight at once, so a test
/// can assert fetches actually overlapped instead of inferring it from timing alone.</summary>
private sealed class ConcurrencyTrackingHandler : HttpMessageHandler
{
private int _inFlight;
public int MaxObservedConcurrent;
private readonly TimeSpan _delay;
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public ConcurrencyTrackingHandler(TimeSpan delay, Func<HttpRequestMessage, HttpResponseMessage> respond)
{
_delay = delay;
_respond = respond;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
int now = Interlocked.Increment(ref _inFlight);
InterlockedMax(ref MaxObservedConcurrent, now);
try { await Task.Delay(_delay, ct); return _respond(request); }
finally { Interlocked.Decrement(ref _inFlight); }
}
private static void InterlockedMax(ref int target, int value)
{
int initial;
do { initial = target; if (value <= initial) return; }
while (Interlocked.CompareExchange(ref target, value, initial) != initial);
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
@@ -216,4 +245,59 @@ public class InvoiceInlineImageTests
Assert.Equal(20, result.Count);
Assert.Equal(20, handler.RequestedUrls.Count);
}
[Fact]
public async Task ResolveInlineImagesAsync_MultipleImages_FetchesConcurrentlyNotOneAtATime()
{
var handler = new ConcurrencyTrackingHandler(TimeSpan.FromMilliseconds(150), _ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
var descs = Enumerable.Range(1, 4)
.Select(i => $"<img src=\"{svc.AllowedInlineImagePrefix}img{i}.png\">")
.ToArray();
var invoice = InvoiceWithDesc(descs);
await svc.ResolveInlineImagesAsync(invoice);
Assert.True(handler.MaxObservedConcurrent > 1,
$"expected overlapping fetches, but max observed concurrency was {handler.MaxObservedConcurrent}");
}
[Fact]
public async Task ResolveInlineImagesAsync_Success_RecordsSuccessCounterAndResolveDuration()
{
long fetchOutcomeDelta = 0;
int durationRecords = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name != "Fuchs.Intranet") return;
if (inst.Name is "fuchs.invoice.inlineimages.fetches" or "fuchs.invoice.inlineimages.resolve.duration")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((inst, value, tags, _) =>
{
if (inst.Name == "fuchs.invoice.inlineimages.fetches")
foreach (var t in tags)
if (t.Key == "outcome" && (string?)t.Value == "success")
Interlocked.Add(ref fetchOutcomeDelta, value);
});
listener.SetMeasurementEventCallback<double>((inst, _, _, _) =>
{
if (inst.Name == "fuchs.invoice.inlineimages.resolve.duration")
Interlocked.Increment(ref durationRecords);
});
listener.Start();
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "pic.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
await svc.ResolveInlineImagesAsync(invoice);
Assert.Equal(1, fetchOutcomeDelta);
Assert.True(durationRecords >= 1, "resolve-duration histogram should have recorded one measurement.");
}
}