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:
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user