diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigurationServiceSingleFlightTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigurationServiceSingleFlightTests.cs
new file mode 100644
index 00000000..dcdedc30
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigurationServiceSingleFlightTests.cs
@@ -0,0 +1,83 @@
+using System.Linq;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Models.Config;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Concurrent cache misses on the two configuration services share one repository read
+/// (audit 2026-09-05 F12). The MemoryCache stays: the hit path and the in-place projection
+/// semantics are untouched, only the miss path is single-flighted.
+///
+public class ConfigurationServiceSingleFlightTests
+{
+ [Fact]
+ public async Task Admin_config_concurrent_misses_issue_one_repository_read()
+ {
+ var gate = new TaskCompletionSource();
+ var repo = new Mock();
+ repo.Setup(r => r.GetAdminConfigurationAsync()).Returns(gate.Task);
+ var sut = new AdminConfigurationService(repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+
+ var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync()).ToArray();
+ gate.SetResult(AdminConfiguration.CreateDefault());
+ var results = await Task.WhenAll(calls);
+
+ repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Once);
+ Assert.All(results, r => Assert.Same(results[0], r));
+ }
+
+ [Fact]
+ public async Task Admin_config_after_completion_the_next_miss_reads_again()
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetAdminConfigurationAsync()).ReturnsAsync(AdminConfiguration.CreateDefault());
+ var sut = new AdminConfigurationService(repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+
+ await sut.GetConfigurationAsync();
+ sut.InvalidateCache();
+ await sut.GetConfigurationAsync();
+
+ repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task Tenant_config_concurrent_misses_for_one_tenant_issue_one_repository_read()
+ {
+ const string tenantId = "11111111-1111-1111-1111-111111111111";
+ var gate = new TaskCompletionSource();
+ var repo = new Mock();
+ repo.Setup(r => r.GetTenantConfigurationAsync(tenantId)).Returns(gate.Task);
+ var sut = new TenantConfigurationService(repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+
+ var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync(tenantId)).ToArray();
+ gate.SetResult(TenantConfiguration.CreateDefault(tenantId));
+ var results = await Task.WhenAll(calls);
+
+ repo.Verify(r => r.GetTenantConfigurationAsync(tenantId), Times.Once);
+ Assert.All(results, r => Assert.Same(results[0], r));
+ }
+
+ [Fact]
+ public async Task Tenant_config_different_tenants_do_not_share_a_flight()
+ {
+ const string a = "11111111-1111-1111-1111-111111111111";
+ const string b = "22222222-2222-2222-2222-222222222222";
+ var repo = new Mock();
+ repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny()))
+ .Returns(id => Task.FromResult(TenantConfiguration.CreateDefault(id)));
+ var sut = new TenantConfigurationService(repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+
+ await Task.WhenAll(sut.GetConfigurationAsync(a), sut.GetConfigurationAsync(b));
+
+ repo.Verify(r => r.GetTenantConfigurationAsync(a), Times.Once);
+ repo.Verify(r => r.GetTenantConfigurationAsync(b), Times.Once);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UserDelegationKeyCacheTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UserDelegationKeyCacheTests.cs
new file mode 100644
index 00000000..941cbfcb
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/UserDelegationKeyCacheTests.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure;
+using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Models;
+using AutopilotMonitor.Functions.Services;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+public class UserDelegationKeyCacheTests
+{
+ private static (UserDelegationKeyCache cache, Mock client, Func now, Action advance) Build()
+ {
+ var clock = new DateTimeOffset(2026, 9, 5, 12, 0, 0, TimeSpan.Zero);
+ var client = new Mock();
+ client
+ .Setup(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((start, expires, _) =>
+ {
+ var key = BlobsModelFactory.UserDelegationKey("oid", "tid", start ?? clock, expires, "b", "2024-11-04", "c2lnbmVk");
+ return Task.FromResult(Response.FromValue(key, Mock.Of()));
+ });
+ var cache = new UserDelegationKeyCache(client.Object, () => clock);
+ return (cache, client, () => clock, delta => clock += delta);
+ }
+
+ [Fact]
+ public async Task Second_sas_reuses_the_key_instead_of_minting_again()
+ {
+ var (cache, client, now, _) = Build();
+
+ var first = await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
+ var second = await cache.GetAsync(now() + TimeSpan.FromMinutes(60));
+
+ Assert.Same(first, second);
+ client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Key_is_minted_for_the_full_lifetime_with_clock_skew_on_the_start()
+ {
+ var (cache, _, now, _) = Build();
+
+ var key = await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
+
+ Assert.Equal(now() - TimeSpan.FromMinutes(5), key.SignedStartsOn);
+ Assert.Equal(now() + UserDelegationKeyCache.KeyLifetime, key.SignedExpiresOn);
+ }
+
+ [Fact]
+ public async Task Key_is_re_minted_once_the_refresh_margin_is_reached()
+ {
+ var (cache, client, now, advance) = Build();
+ await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
+
+ advance(UserDelegationKeyCache.KeyLifetime - UserDelegationKeyCache.RefreshMargin + TimeSpan.FromMinutes(1));
+ await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
+
+ client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task Key_is_re_minted_when_the_sas_would_outlive_it()
+ {
+ var (cache, client, now, _) = Build();
+ await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
+
+ await cache.GetAsync(now() + UserDelegationKeyCache.KeyLifetime + TimeSpan.FromMinutes(1));
+
+ client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task Concurrent_first_callers_share_one_mint()
+ {
+ var (cache, client, now, _) = Build();
+
+ var tasks = new Task[8];
+ for (var i = 0; i < tasks.Length; i++)
+ tasks[i] = Task.Run(() => cache.GetAsync(now() + TimeSpan.FromMinutes(15)));
+ await Task.WhenAll(tasks);
+
+ client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index 6f72b971..752e0552 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -410,8 +410,17 @@ builder.Services.AddSingleton(sp => sp.GetRequiredS
// Vulnerability correlation services
builder.Services.AddHttpClient()
.AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi);
+// KevDataService keeps the hydrated catalog as instance state, but AddHttpClient registers T
+// transient: the cache warmer hydrated one instance while the correlation service and the sync
+// functions each held a cold one (audit 2026-09-05 F12). The typed-client registration stays for
+// the named handler pipeline; the singleton below is what every consumer resolves.
builder.Services.AddHttpClient()
- .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi);
+ .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi)
+ .SetHandlerLifetime(Timeout.InfiniteTimeSpan);
+builder.Services.AddSingleton(sp => new AutopilotMonitor.Functions.Services.Vulnerability.KevDataService(
+ sp.GetRequiredService().CreateClient(nameof(AutopilotMonitor.Functions.Services.Vulnerability.KevDataService)),
+ sp.GetRequiredService(),
+ sp.GetRequiredService>()));
builder.Services.AddHttpClient()
.AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi);
builder.Services.AddHttpClient()
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs
index d2b2a913..09bb1d22 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -22,6 +23,10 @@ namespace AutopilotMonitor.Functions.Services
private const string CacheKey = "admin-config";
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
+ // Concurrent misses (cold start, after invalidation) share one repository read instead of
+ // each issuing their own (audit 2026-09-05 F12). Lazy guarantees a single factory run.
+ private readonly ConcurrentDictionary>> _inFlight = new();
+
public AdminConfigurationService(IConfigRepository configRepo, ILogger logger, IMemoryCache cache)
{
_configRepo = configRepo;
@@ -43,6 +48,19 @@ namespace AutopilotMonitor.Functions.Services
return cachedConfig;
}
+ var load = _inFlight.GetOrAdd(CacheKey, _ => new Lazy>(LoadAndCacheAsync));
+ try
+ {
+ return await load.Value;
+ }
+ finally
+ {
+ _inFlight.TryRemove(new KeyValuePair>>(CacheKey, load));
+ }
+ }
+
+ private async Task LoadAndCacheAsync()
+ {
try
{
// Load from repository
@@ -213,6 +231,7 @@ namespace AutopilotMonitor.Functions.Services
public void InvalidateCache()
{
_cache.Remove(CacheKey);
+ _inFlight.TryRemove(CacheKey, out _);
}
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
index 9d8c89f3..95766712 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
@@ -28,6 +28,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger _logger;
private readonly bool _usesManagedIdentity;
+ private readonly UserDelegationKeyCache _delegationKeys;
public BlobStorageService(IConfiguration configuration, ILogger logger)
{
@@ -54,6 +55,8 @@ namespace AutopilotMonitor.Functions.Services
throw new InvalidOperationException(
"Blob Storage not configured. Set either 'AzureStorageAccountName' (for Managed Identity) or 'AzureBlobStorageConnectionString'.");
}
+
+ _delegationKeys = new UserDelegationKeyCache(_blobServiceClient);
}
///
@@ -67,6 +70,7 @@ namespace AutopilotMonitor.Functions.Services
_blobServiceClient = blobServiceClient;
_logger = logger;
_usesManagedIdentity = usesManagedIdentity;
+ _delegationKeys = new UserDelegationKeyCache(_blobServiceClient);
}
///
@@ -121,8 +125,7 @@ namespace AutopilotMonitor.Functions.Services
if (_usesManagedIdentity)
{
// Generate User Delegation SAS (no account key needed)
- var delegationKey = await _blobServiceClient.GetUserDelegationKeyAsync(
- DateTimeOffset.UtcNow.AddMinutes(-5), expiresOn);
+ var delegationKey = await _delegationKeys.GetAsync(expiresOn);
var sasBuilder = new BlobSasBuilder
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobStreamer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobStreamer.cs
index 1132e49c..a6749ad1 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobStreamer.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobStreamer.cs
@@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Functions.Diagnostics;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Models;
using Microsoft.ApplicationInsights;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -81,15 +82,12 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))
: new CancellationTokenSource();
- var sw = Stopwatch.StartNew();
- long contentLength;
- Stream content;
+ var openWatch = Stopwatch.StartNew();
+ BlobDownloadStreamingResult download;
if (destination == DiagnosticsDownloadFunction.BlobDestination.Hosted)
{
- var download = await _hostedDiagnostics.OpenReadAsync(blobName, cts.Token);
- contentLength = download.Value.Details.ContentLength;
- content = download.Value.Content;
+ download = (await _hostedDiagnostics.OpenReadAsync(blobName, cts.Token)).Value;
}
else
{
@@ -101,57 +99,79 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
var blobUrl = BuildCustomerBlobUrl(tenantConfig.DiagnosticsBlobSasUrl, blobName);
var blobClient = new BlobClient(new Uri(blobUrl));
- var download = await blobClient.DownloadStreamingAsync(cancellationToken: cts.Token);
- contentLength = download.Value.Details.ContentLength;
- content = download.Value.Content;
+ download = (await blobClient.DownloadStreamingAsync(cancellationToken: cts.Token)).Value;
}
- sw.Stop();
+ openWatch.Stop();
- var destinationLabel = destination == DiagnosticsDownloadFunction.BlobDestination.Hosted
- ? "Hosted" : "CustomerSas";
-
- // Enforce size cap before streaming (fast reject).
- if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
+ // The download result owns the network stream; disposing it on EVERY exit path
+ // (413, copy failure, client abort, success) returns the connection to the pool.
+ using (download)
{
- _logger.LogWarning(
- "DiagnosticsBlobStreamer: Blob {BlobName} for tenant {TenantId} (destination={Destination}) rejected — size {SizeBytes} exceeds limit {MaxSizeBytes}",
- blobName, tenantId, destinationLabel, contentLength, maxSizeBytes);
- content.Dispose();
+ var contentLength = download.Details.ContentLength;
+ var destinationLabel = destination == DiagnosticsDownloadFunction.BlobDestination.Hosted
+ ? "Hosted"
+ : "CustomerSas";
- return await req.ErrorAsync(HttpStatusCode.RequestEntityTooLarge, Constants.ApiErrorCodes.PayloadTooLarge,
- $"Diagnostics package size ({contentLength / (1024 * 1024)} MB) exceeds the maximum allowed size ({adminConfig.MaxDiagnosticsDownloadSizeMB} MB).");
- }
-
- _logger.LogInformation(
- "DiagnosticsBlobStreamer: Proxying blob {BlobName} for tenant {TenantId} (destination={Destination}), size {SizeBytes} bytes, fetch took {DurationMs}ms",
- blobName, tenantId, destinationLabel, contentLength, sw.ElapsedMilliseconds);
-
- var props = new Dictionary
- {
- ["TenantId"] = tenantId,
- ["BlobName"] = blobName,
- ["Destination"] = destinationLabel,
- };
- if (extraTelemetryProps != null)
- foreach (var kv in extraTelemetryProps) props[kv.Key] = kv.Value;
-
- _telemetryClient.TrackEvent("DiagnosticsDownloadProxied",
- properties: props,
- metrics: new Dictionary
+ if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
{
- ["BlobSizeBytes"] = contentLength,
- ["DurationMs"] = sw.ElapsedMilliseconds,
- });
+ _logger.LogWarning(
+ "DiagnosticsBlobStreamer: Download of {BlobName} for tenant {TenantId} rejected — size {SizeBytes} exceeds limit {MaxSizeBytes}",
+ blobName, tenantId, contentLength, maxSizeBytes);
+ return await req.ErrorAsync(HttpStatusCode.RequestEntityTooLarge, Constants.ApiErrorCodes.PayloadTooLarge,
+ $"Diagnostics package size ({contentLength / (1024 * 1024)} MB) exceeds the configured maximum ({adminConfig.MaxDiagnosticsDownloadSizeMB} MB).");
+ }
- var downloadFilename = ExtractDownloadFilename(blobName);
- var response = req.CreateResponse(HttpStatusCode.OK);
- response.Headers.Add("Content-Type", "application/octet-stream");
- response.Headers.Add("Content-Disposition", $"attachment; filename=\"{downloadFilename}\"");
- if (contentLength > 0)
- response.Headers.Add("Content-Length", contentLength.ToString());
+ var downloadFilename = ExtractDownloadFilename(blobName);
+ var response = req.CreateResponse(HttpStatusCode.OK);
+ response.Headers.Add("Content-Type", "application/octet-stream");
+ response.Headers.Add("Content-Disposition", $"attachment; filename=\"{downloadFilename}\"");
+ if (contentLength > 0)
+ response.Headers.Add("Content-Length", contentLength.ToString());
- await content.CopyToAsync(response.Body, cts.Token);
- return response;
+ // Open and transfer are measured separately: a slow open is the storage account,
+ // a slow transfer is the client or the proxy; the event fires after the copy so
+ // "proxied" means the bytes actually left, not that the blob merely opened.
+ var transferWatch = Stopwatch.StartNew();
+ var completed = false;
+ try
+ {
+ await download.Content.CopyToAsync(response.Body, cts.Token);
+ completed = true;
+ }
+ finally
+ {
+ transferWatch.Stop();
+
+ _logger.LogInformation(
+ "DiagnosticsBlobStreamer: Proxied blob {BlobName} for tenant {TenantId} (destination={Destination}), size {SizeBytes} bytes, open {OpenMs}ms, transfer {TransferMs}ms, completed={Completed}",
+ blobName, tenantId, destinationLabel, contentLength, openWatch.ElapsedMilliseconds, transferWatch.ElapsedMilliseconds, completed);
+
+ var props = new Dictionary
+ {
+ ["TenantId"] = tenantId,
+ ["BlobName"] = blobName,
+ ["Destination"] = destinationLabel,
+ ["Completed"] = completed ? "true" : "false",
+ };
+ if (extraTelemetryProps != null)
+ {
+ foreach (var kv in extraTelemetryProps)
+ props[kv.Key] = kv.Value;
+ }
+
+ _telemetryClient.TrackEvent("DiagnosticsDownloadProxied",
+ properties: props,
+ metrics: new Dictionary
+ {
+ ["BlobSizeBytes"] = contentLength,
+ ["OpenMs"] = openWatch.ElapsedMilliseconds,
+ ["TransferMs"] = transferWatch.ElapsedMilliseconds,
+ ["DurationMs"] = openWatch.ElapsedMilliseconds + transferWatch.ElapsedMilliseconds,
+ });
+ }
+
+ return response;
+ }
}
///
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs
index 35089db1..cb0c7fe3 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs
@@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger _logger;
private readonly bool _usesManagedIdentity;
+ private readonly UserDelegationKeyCache _delegationKeys;
private int _containerEnsured;
public HostedDiagnosticsBlobService(IConfiguration configuration, ILogger logger)
@@ -84,6 +85,8 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
throw new InvalidOperationException(
"HostedDiagnosticsBlobService not configured. Set either 'AzureStorageAccountName' (Managed Identity) or 'AzureBlobStorageConnectionString'.");
}
+
+ _delegationKeys = new UserDelegationKeyCache(_blobServiceClient);
}
///
@@ -99,6 +102,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
_blobServiceClient = blobServiceClient ?? throw new ArgumentNullException(nameof(blobServiceClient));
_logger = logger;
_usesManagedIdentity = usesManagedIdentity;
+ _delegationKeys = new UserDelegationKeyCache(_blobServiceClient);
}
// -------- Public API --------
@@ -330,8 +334,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
if (UsesManagedIdentity)
{
- var delegationKey = await _blobServiceClient.GetUserDelegationKeyAsync(
- DateTimeOffset.UtcNow.AddMinutes(-5), expiresOn, cancellationToken);
+ var delegationKey = await _delegationKeys.GetAsync(expiresOn, cancellationToken);
var sasUri = new BlobUriBuilder(blobClient.Uri)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs
index 4b83a1b9..f99960ba 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -27,6 +28,10 @@ namespace AutopilotMonitor.Functions.Services
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
+ // Concurrent misses for one tenant (cold start, after invalidation) share one repository
+ // read instead of each issuing their own (audit 2026-09-05 F12).
+ private readonly ConcurrentDictionary>> _inFlight = new();
+
public TenantConfigurationService(
IConfigRepository configRepo,
ILogger logger,
@@ -75,6 +80,19 @@ namespace AutopilotMonitor.Functions.Services
return (await ProjectAsync(cachedConfig))!;
}
+ var load = _inFlight.GetOrAdd(cacheKey, _ => new Lazy>(() => LoadAndCacheAsync(tenantId, cacheKey)));
+ try
+ {
+ return await load.Value;
+ }
+ finally
+ {
+ _inFlight.TryRemove(new KeyValuePair>>(cacheKey, load));
+ }
+ }
+
+ private async Task LoadAndCacheAsync(string tenantId, string cacheKey)
+ {
try
{
// Load from repository
@@ -210,6 +228,7 @@ namespace AutopilotMonitor.Functions.Services
public void InvalidateCache(string tenantId)
{
_cache.Remove($"tenant-config:{tenantId}");
+ _inFlight.TryRemove($"tenant-config:{tenantId}", out _);
}
///
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/UserDelegationKeyCache.cs b/src/Backend/AutopilotMonitor.Functions/Services/UserDelegationKeyCache.cs
new file mode 100644
index 00000000..476f8edf
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/UserDelegationKeyCache.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Models;
+
+namespace AutopilotMonitor.Functions.Services
+{
+ ///
+ /// One user delegation key per process instead of one per SAS. Under Managed Identity every
+ /// blob SAS needs a delegation key from the service; minting it per download or upload URL
+ /// costs a storage round-trip and an AAD token exchange on the hot path (audit 2026-09-05
+ /// F11). A key is valid for up to seven days; this cache mints one for
+ /// , hands it out while at least of
+ /// validity remains and the requested SAS expiry fits inside it, and re-mints under a lock
+ /// otherwise. The SAS itself keeps its own short lifetime and blob scope.
+ ///
+ public sealed class UserDelegationKeyCache
+ {
+ public static readonly TimeSpan KeyLifetime = TimeSpan.FromHours(24);
+ public static readonly TimeSpan RefreshMargin = TimeSpan.FromHours(2);
+
+ /// Clock-skew tolerance on the key start, mirrors the SAS StartsOn.
+ private static readonly TimeSpan StartSkew = TimeSpan.FromMinutes(5);
+
+ private readonly BlobServiceClient _client;
+ private readonly Func _utcNow;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private UserDelegationKey? _key;
+
+ public UserDelegationKeyCache(BlobServiceClient client, Func? utcNow = null)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
+ }
+
+ ///
+ /// Returns a key that covers a SAS expiring at , minting a
+ /// fresh one when the cached key is missing, close to expiry or too short for the SAS.
+ ///
+ public async Task GetAsync(DateTimeOffset sasExpiresOn, CancellationToken cancellationToken = default)
+ {
+ var cached = _key;
+ if (Covers(cached, sasExpiresOn)) return cached!;
+
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ cached = _key;
+ if (Covers(cached, sasExpiresOn)) return cached!;
+
+ var now = _utcNow();
+ var fresh = await _client.GetUserDelegationKeyAsync(now - StartSkew, now + KeyLifetime, cancellationToken)
+ .ConfigureAwait(false);
+ _key = fresh.Value;
+ return fresh.Value;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private bool Covers(UserDelegationKey? key, DateTimeOffset sasExpiresOn)
+ {
+ if (key == null) return false;
+ var now = _utcNow();
+ return key.SignedExpiresOn - now >= RefreshMargin && sasExpiresOn <= key.SignedExpiresOn;
+ }
+ }
+}