diff --git a/ClientApps/trip-editor/src/map/leafletAdapter.ts b/ClientApps/trip-editor/src/map/leafletAdapter.ts index f29f2498..8e181b3b 100644 --- a/ClientApps/trip-editor/src/map/leafletAdapter.ts +++ b/ClientApps/trip-editor/src/map/leafletAdapter.ts @@ -9,6 +9,7 @@ import { placeMarkerIcon, previewMarkerIcon, regionMarkerIcon } from './markerRe import { placePopupHtml } from './placePopupRendering'; import { createSearchPreviewLayer } from './searchPreviewLayer'; import { createSegmentRouteWorkLayer } from './segmentRouteWorkLayer'; +import { createTripEditorTileLayer } from './tileRetryLayer'; export type { AreaPolygonWorkOptions } from './areaPolygonWorkLayer'; export type { SegmentRouteWorkOptions } from './segmentRouteWorkLayer'; @@ -67,7 +68,7 @@ export const createTripEditorMap = (element: HTMLElement, tilesUrl: string, opti map.on('moveend zoomend', updateMapViewDataset); - L.tileLayer(tilesUrl, { + createTripEditorTileLayer(tilesUrl, { attribution: providerAttribution(window.wayfarerTileConfig?.attribution), maxZoom: 19 }).addTo(map); diff --git a/ClientApps/trip-editor/src/map/tileRetryLayer.ts b/ClientApps/trip-editor/src/map/tileRetryLayer.ts new file mode 100644 index 00000000..02fd4f40 --- /dev/null +++ b/ClientApps/trip-editor/src/map/tileRetryLayer.ts @@ -0,0 +1,154 @@ +import L from 'leaflet'; + +type RetryTileImage = HTMLImageElement & { + _abortController?: AbortController | null; +}; + +const tileConfig = (): { burstCapacity?: number; retryAfterSeconds?: number } => window.wayfarerTileConfig ?? {}; +const tilePoolSize = (): number => Math.ceil((tileConfig().burstCapacity ?? 12) * 0.75); +const jitter = (delayMs: number): number => delayMs * (0.75 + Math.random() * 0.5); +let tileFetchesInFlight = 0; +const waitingTileFetches: Array<{ resolve: (acquired: boolean) => void }> = []; + +const acquireTileFetchSlot = (signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.resolve(false); + } + + if (tileFetchesInFlight < tilePoolSize()) { + tileFetchesInFlight += 1; + return Promise.resolve(true); + } + + return new Promise(resolve => { + const entry = { resolve }; + waitingTileFetches.push(entry); + signal.addEventListener('abort', () => { + const index = waitingTileFetches.indexOf(entry); + if (index !== -1) { + waitingTileFetches.splice(index, 1); + resolve(false); + } + }, { once: true }); + }); +}; + +const releaseTileFetchSlot = (): void => { + const next = waitingTileFetches.shift(); + if (next) { + next.resolve(true); + return; + } + + tileFetchesInFlight = Math.max(0, tileFetchesInFlight - 1); +}; + +/** + * Leaflet tile layer with the same retry and concurrency behavior as the legacy shared layer. + */ +class TripEditorRetryTileLayer extends L.TileLayer { + private readonly maxRetries = 5; + private readonly retryDelayMs = 1000; + + createTile(coords: L.Coords, done: L.DoneCallback): HTMLElement { + const tile: RetryTileImage = document.createElement('img'); + tile.alt = ''; + tile.setAttribute('role', 'presentation'); + + const controller = new AbortController(); + tile._abortController = controller; + this.fetchWithRetry(this.getTileUrl(coords), tile, done, 0, controller.signal); + return tile; + } + + _removeTile(key: string): void { + const tile = (this as unknown as { _tiles?: Record })._tiles?.[key]; + if (tile?.el?._abortController) { + tile.el._abortController.abort(); + tile.el._abortController = null; + } + + if (tile?.el?.src.startsWith('blob:')) { + URL.revokeObjectURL(tile.el.src); + } + + super._removeTile(key); + } + + private fetchWithRetry(url: string, tile: RetryTileImage, done: L.DoneCallback, attempt: number, signal: AbortSignal): void { + acquireTileFetchSlot(signal).then(acquired => { + if (!acquired) return; + let slotReleased = false; + const releaseSlotOnce = (): void => { + if (!slotReleased) { + slotReleased = true; + releaseTileFetchSlot(); + } + }; + + if (signal.aborted) { + releaseSlotOnce(); + return; + } + + fetch(url, { signal }) + .then(response => { + releaseSlotOnce(); + if (response.ok) return this.loadTileBlob(response, tile, done, signal); + if (response.status === 503) { + this.scheduleRetry(url, tile, done, attempt, signal, response.headers.get('Retry-After')); + return; + } + + done(new Error(`Tile fetch failed: ${response.status}`), tile); + }) + .catch(error => { + releaseSlotOnce(); + if (error instanceof DOMException && error.name === 'AbortError') return; + this.scheduleRetry(url, tile, done, attempt, signal); + }); + }); + } + + private scheduleRetry(url: string, tile: RetryTileImage, done: L.DoneCallback, attempt: number, signal: AbortSignal, retryAfterHeader?: string | null): void { + if (attempt < this.maxRetries) { + const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : Number.NaN; + const serverDelay = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? retryAfterSeconds * 1000 + : this.retryDelayMs * Math.pow(2, attempt); + const delayMs = jitter(Math.min(Math.max(serverDelay, this.retryDelayMs), 10_000)); + window.setTimeout(() => !signal.aborted && this.fetchWithRetry(url, tile, done, attempt + 1, signal), delayMs); + return; + } + + this.scheduleSlowRetry(url, tile, done, signal); + } + + private scheduleSlowRetry(url: string, tile: RetryTileImage, done: L.DoneCallback, signal: AbortSignal): void { + const retryAfterSeconds = tileConfig().retryAfterSeconds ?? 6; + window.setTimeout(() => { + if (!signal.aborted) this.fetchWithRetry(url, tile, done, this.maxRetries, signal); + }, jitter(retryAfterSeconds * 3 * 1000)); + } + + private async loadTileBlob(response: Response, tile: RetryTileImage, done: L.DoneCallback, signal: AbortSignal): Promise { + const blob = await response.blob(); + if (signal.aborted) return; + + tile.onload = (): void => { + URL.revokeObjectURL(tile.src); + done(null, tile); + }; + tile.onerror = (): void => { + URL.revokeObjectURL(tile.src); + done(new Error('Tile image decode failed'), tile); + }; + tile.src = URL.createObjectURL(blob); + } +} + +/** + * Creates the Trip Editor tile layer with fetch-based retry and concurrency controls. + */ +export const createTripEditorTileLayer = (tilesUrl: string, options: L.TileLayerOptions): L.TileLayer => + new TripEditorRetryTileLayer(tilesUrl, options); diff --git a/Services/TileCacheRefreshCoordinator.cs b/Services/TileCacheRefreshCoordinator.cs new file mode 100644 index 00000000..b61c73ed --- /dev/null +++ b/Services/TileCacheRefreshCoordinator.cs @@ -0,0 +1,268 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; + +public partial class TileCacheService +{ + /// + /// Coalesces bounded background refresh series for expired cached tiles. + /// Key: "{z}_{x}_{y}". At most one active series may exist per tile key. + /// + private static readonly ConcurrentDictionary _refreshSeries = new(); + + /// + /// Maximum number of upstream attempts in one stale-tile refresh series. + /// + private const int RefreshSeriesMaxAttempts = 3; + + /// + /// Maximum wall-clock lifetime for one stale-tile refresh series. + /// + private static readonly TimeSpan RefreshSeriesMaxDuration = TimeSpan.FromMinutes(2); + + /// + /// Initial delay before retrying a failed stale-tile refresh attempt. + /// + private static readonly TimeSpan RefreshRetryInitialDelay = TimeSpan.FromSeconds(5); + + /// + /// Maximum delay before retrying a failed stale-tile refresh attempt. + /// + private static readonly TimeSpan RefreshRetryMaxDelay = TimeSpan.FromSeconds(60); + + /// + /// Test-overridable delay provider for bounded refresh retry backoff. + /// + private static Func _refreshRetryDelayProvider = CalculateRefreshRetryDelay; + + /// + /// Test-overridable tile replacement hook for deterministic replacement-failure coverage. + /// + private static Action _replaceTileFile = ReplaceTileFileAtomicallyCore; + + /// + /// Schedules a bounded background refresh for an expired local tile. + /// Concurrent stale hits for the same key share the active series. + /// + private void ScheduleBackgroundRefresh(string tileUrl, string tileFilePath, string tileKey, + int zoom, int x, int y, string? etag, DateTime? lastModified, string? clientIp) + { + var series = new TileRefreshSeries(tileKey, tileUrl, tileFilePath, zoom, x, y, etag, lastModified, clientIp); + var activeSeries = _refreshSeries.GetOrAdd(tileKey, series); + if (!ReferenceEquals(activeSeries, series)) + { + _logger.LogDebug("Refresh already active for stale tile {TileKey}", tileKey); + return; + } + + _ = Task.Run(() => RunBackgroundRefreshSeriesAsync(series), CancellationToken.None); + } + + /// + /// Runs one bounded refresh series with exponential jittered backoff. + /// + private async Task RunBackgroundRefreshSeriesAsync(TileRefreshSeries series) + { + try + { + while (series.Attempts < RefreshSeriesMaxAttempts && + DateTime.UtcNow - series.StartedAtUtc < RefreshSeriesMaxDuration) + { + series.Attempts++; + + try + { + var refreshed = await RevalidateTileInFreshScopeAsync(series); + + if (refreshed != null) + { + return; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Background refresh attempt {Attempt} failed for tile {TileKey}", + series.Attempts, series.TileKey); + } + + if (series.Attempts >= RefreshSeriesMaxAttempts) + { + break; + } + + var remaining = RefreshSeriesMaxDuration - (DateTime.UtcNow - series.StartedAtUtc); + if (remaining <= TimeSpan.Zero) + { + break; + } + + var delay = _refreshRetryDelayProvider(series.Attempts); + await Task.Delay(delay > remaining ? remaining : delay, series.CancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + _logger.LogDebug("Background refresh cancelled for tile {TileKey}", series.TileKey); + } + finally + { + _refreshSeries.TryRemove(new KeyValuePair(series.TileKey, series)); + } + } + + /// + /// Calculates exponential refresh retry delay with jitter to avoid synchronized retries. + /// + private static TimeSpan CalculateRefreshRetryDelay(int failedAttempts) + { + var exponent = Math.Max(0, failedAttempts - 1); + var delayMs = RefreshRetryInitialDelay.TotalMilliseconds * Math.Pow(2, exponent); + delayMs = Math.Min(delayMs, RefreshRetryMaxDelay.TotalMilliseconds); + delayMs *= 0.75 + Random.Shared.NextDouble() * 0.5; + return TimeSpan.FromMilliseconds(delayMs); + } + + /// + /// Runs a background refresh attempt through a newly-created DI scope. + /// The scheduled series carries only immutable primitive values from the request. + /// + private async Task RevalidateTileInFreshScopeAsync(TileRefreshSeries series) + { + using var scope = _serviceScopeFactory.CreateScope(); + var tileCacheService = scope.ServiceProvider.GetRequiredService(); + return await tileCacheService.RevalidateTileAsync(series.TileUrl, series.TileFilePath, + series.TileKey, series.Zoom, series.X, series.Y, series.ETag, + series.LastModified, series.ClientIp, series.CancellationToken); + } + + /// + /// Creates a same-directory temporary path for atomic tile replacement. + /// + private static string CreateTempTilePath(string tileFilePath) + { + var directory = Path.GetDirectoryName(tileFilePath) ?? "."; + var fileName = Path.GetFileName(tileFilePath); + return Path.Combine(directory, $"{fileName}.{Guid.NewGuid():N}.tmp"); + } + + /// + /// Replaces the final tile with a same-directory temp file so readers never see partial bytes. + /// + private static void ReplaceTileFileAtomically(string tempFilePath, string tileFilePath) => + _replaceTileFile(tempFilePath, tileFilePath); + + /// + /// Replaces the final tile with a same-directory temp file using the production file operation. + /// + private static void ReplaceTileFileAtomicallyCore(string tempFilePath, string tileFilePath) + { + if (File.Exists(tileFilePath)) + { + File.Replace(tempFilePath, tileFilePath, null); + return; + } + + File.Move(tempFilePath, tileFilePath); + } + + /// + /// Deletes a failed refresh temp file without masking the original replacement error. + /// + private static void TryDeleteTempTile(string tempFilePath) + { + try + { + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + } + catch + { + // Best-effort cleanup only. + } + } + + /// + /// Waits until a tile refresh series is no longer active. + /// Test-only helper for observing fire-and-forget background refresh completion. + /// + internal static async Task WaitForRefreshIdleForTestingAsync(string tileKey, TimeSpan timeout) + { + var deadline = DateTime.UtcNow.Add(timeout); + while (DateTime.UtcNow < deadline) + { + if (!_refreshSeries.ContainsKey(tileKey)) + { + return true; + } + + await Task.Delay(10); + } + + return !_refreshSeries.ContainsKey(tileKey); + } + + /// + /// Cancels an active tile refresh series in tests. + /// + internal static void CancelRefreshForTesting(string tileKey) + { + if (_refreshSeries.TryGetValue(tileKey, out var series)) + { + series.CancelForTesting(); + } + } + + /// + /// Overrides refresh retry delay calculation for deterministic tests. + /// + internal static void SetRefreshRetryDelayForTesting(Func? delayProvider) + { + _refreshRetryDelayProvider = delayProvider ?? CalculateRefreshRetryDelay; + } + + /// + /// Overrides tile replacement for deterministic replacement-failure tests. + /// + internal static void SetTileFileReplacerForTesting(Action? replacer) + { + _replaceTileFile = replacer ?? ReplaceTileFileAtomicallyCore; + } + + /// + /// Captures immutable inputs and retry state for one bounded stale-tile refresh series. + /// + private sealed class TileRefreshSeries + { + public string TileKey { get; } + public string TileUrl { get; } + public string TileFilePath { get; } + public int Zoom { get; } + public int X { get; } + public int Y { get; } + public string? ETag { get; } + public DateTime? LastModified { get; } + public string? ClientIp { get; } + public DateTime StartedAtUtc { get; } = DateTime.UtcNow; + public CancellationToken CancellationToken => _cancellationTokenSource.Token; + public int Attempts { get; set; } + + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + public TileRefreshSeries(string tileKey, string tileUrl, string tileFilePath, + int zoom, int x, int y, string? etag, DateTime? lastModified, string? clientIp) + { + TileKey = tileKey; + TileUrl = tileUrl; + TileFilePath = tileFilePath; + Zoom = zoom; + X = x; + Y = y; + ETag = etag; + LastModified = lastModified; + ClientIp = clientIp; + } + + public void CancelForTesting() => _cancellationTokenSource.Cancel(); + } +} diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index d593f7d0..28cf4314 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -12,7 +12,7 @@ using Wayfarer.Parsers; using Wayfarer.Util; -public class TileCacheService +public partial class TileCacheService { private readonly ILogger _logger; @@ -106,13 +106,6 @@ public class TileCacheService /// private static readonly object _initLock = new(); - /// - /// Coalesces concurrent re-validation requests for the same tile. - /// Key: "{z}_{x}_{y}", Value: lazy task that performs exactly one conditional HTTP request. - /// Prevents duplicate outbound requests to OSM when multiple clients request the same expired tile. - /// - private static readonly ConcurrentDictionary>> _revalidationFlights = new(); - /// /// In-memory cache of sidecar metadata for zoom 0-8 tiles. /// Zoom 0-8 has ~87,000 tiles total; each entry is ~100 bytes (~8.7 MB RAM). @@ -347,12 +340,19 @@ internal static async Task ReconcileCacheSizeAsync(IServiceScopeFactory scopeFac /// /// Resets all static state so each test starts with a clean slate. /// Must be called between tests to prevent cross-test interference from - /// , , and . + /// , , and . /// internal static void ResetStaticStateForTesting() { - _revalidationFlights.Clear(); + foreach (var series in _refreshSeries.Values) + { + series.CancelForTesting(); + } + + _refreshSeries.Clear(); _sidecarCache.Clear(); + SetRefreshRetryDelayForTesting(null); + SetTileFileReplacerForTesting(null); Interlocked.Exchange(ref _currentCacheSize, 0); Interlocked.Exchange(ref _evictionInProgress, 0); Interlocked.Exchange(ref _purgeInProgress, 0); @@ -485,7 +485,7 @@ public string GetCacheDirectory() /// private async Task SendTileRequestCoreAsync(string tileUrl, Action? configureRequest = null, bool skipBudget = false, - string? clientIp = null, CancellationToken cancellationToken = default) + string? clientIp = null, bool allowHttpContext = true, CancellationToken cancellationToken = default) { // Two-phase per-IP outbound budget: peek first (fast-fail without incrementing), // then record the hit only after the global budget is acquired. This prevents @@ -503,7 +503,7 @@ public string GetCacheDirectory() if (perIpLimit > 0) { resolvedIpForBudget = clientIp; - if (resolvedIpForBudget == null) + if (resolvedIpForBudget == null && allowHttpContext) { var ctx = _httpContextAccessor.HttpContext; if (ctx != null) @@ -553,7 +553,7 @@ public string GetCacheDirectory() // OSM requires a Referer header. Derive it from the incoming HTTP request // so it automatically matches the public URL (works behind reverse proxies, // Cloudflare Tunnel, etc. when forwarded headers are configured). - var ctx = _httpContextAccessor.HttpContext; + var ctx = allowHttpContext ? _httpContextAccessor.HttpContext : null; if (ctx != null) { request.Headers.Referrer = new Uri($"{ctx.Request.Scheme}://{ctx.Request.Host}"); @@ -619,7 +619,8 @@ public string GetCacheDirectory() /// Returns the response (caller checks for 304 vs 200). /// private Task SendConditionalTileRequestAsync(string tileUrl, string? etag, - DateTime? lastModified, string? clientIp = null, CancellationToken cancellationToken = default) + DateTime? lastModified, string? clientIp = null, bool allowHttpContext = true, + CancellationToken cancellationToken = default) { return SendTileRequestCoreAsync(tileUrl, request => { @@ -636,7 +637,7 @@ public string GetCacheDirectory() { request.Headers.IfModifiedSince = new DateTimeOffset(lastModified.Value, TimeSpan.Zero); } - }, clientIp: clientIp, cancellationToken: cancellationToken); + }, clientIp: clientIp, allowHttpContext: allowHttpContext, cancellationToken: cancellationToken); } private static bool IsRedirectStatus(HttpStatusCode statusCode) @@ -1194,36 +1195,18 @@ public async Task RetrieveTileAsync(string zoomLevel, strin } } - // Tile is expired — re-validate with upstream (if we have a URL) + // Tile is expired — serve the existing local file immediately and refresh in + // the background. Revalidation must not sit on the user-facing response path + // while a complete cached file exists locally. if (!string.IsNullOrEmpty(tileUrl)) { - // Coalesce concurrent re-validations: only ONE HTTP request per expired tile. - // Use CancellationToken.None so the outbound request completes even if the - // first caller disconnects — other coalesced callers still need the result, - // and the cached data benefits future requests. Individual callers respect their - // own cancellation token when they await flight.Value. HttpClient.Timeout still - // protects against unresponsive upstream servers. - var flight = _revalidationFlights.GetOrAdd(tileKey, - _ => new Lazy>( - () => RevalidateTileAsync(tileUrl, tileFilePath, tileKey, zoomLvl, - xVal, yVal, etag, lastModified, clientIp, CancellationToken.None))); - try - { - var revalidationResult = await flight.Value; - if (revalidationResult != null) return TileRetrievalResult.Success(revalidationResult); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Re-validation failed for tile {TileKey}, serving stale", tileKey); - } - finally - { - // Only remove our own entry (value-checking overload) - _revalidationFlights.TryRemove(new KeyValuePair>>(tileKey, flight)); - } + ScheduleBackgroundRefresh(tileUrl, tileFilePath, tileKey, zoomLvl, xVal, yVal, + etag, lastModified, clientIp); } - // Graceful degradation: serve stale cached tile if re-validation failed. + // Graceful degradation: serve stale cached tile even when budget is exhausted + // or the background refresh cannot start. No lock needed for reads (see + // fast-path comment above). // No lock needed for reads (see fast-path comment above). byte[]? staleTileData = null; try @@ -1238,7 +1221,15 @@ public async Task RetrieveTileAsync(string zoomLevel, strin // File deleted by concurrent eviction/purge — treat as cache miss. } - if (staleTileData != null) return TileRetrievalResult.Success(staleTileData); + if (staleTileData != null) + { + if (zoomLvl >= DbMetadataZoomThreshold) + { + await TouchLastAccessedFromHotHitAsync(zoomLvl, xVal, yVal); + } + + return TileRetrievalResult.Success(staleTileData); + } } // 2. If the tile is not on disk, but we have a URL, attempt to fetch it. @@ -1283,8 +1274,8 @@ public async Task RetrieveTileAsync(string zoomLevel, strin /// On 304 Not Modified: updates metadata expiry and serves cached file. /// On 200 OK: replaces file on disk and updates all metadata. /// On failure: returns null (caller will serve stale cached tile). - /// Called via the coalescing dictionary to ensure - /// exactly one outbound request per expired tile. + /// Called from the bounded coordinator to ensure at most + /// one active refresh series exists per expired tile. /// Uses its own DB scope because the coalescing pattern means the originating request's /// scoped DbContext may be disposed while other callers are still awaiting the result. /// @@ -1292,7 +1283,8 @@ public async Task RetrieveTileAsync(string zoomLevel, strin int zoom, int x, int y, string? etag, DateTime? lastModified, string? clientIp = null, CancellationToken cancellationToken = default) { - using var response = await SendConditionalTileRequestAsync(tileUrl, etag, lastModified, clientIp, cancellationToken); + using var response = await SendConditionalTileRequestAsync(tileUrl, etag, lastModified, clientIp, + allowHttpContext: false, cancellationToken: cancellationToken); if (response == null) { _logger.LogWarning("Conditional tile request rejected for {TileUrl}", @@ -1364,15 +1356,17 @@ public async Task RetrieveTileAsync(string zoomLevel, strin if (response.IsSuccessStatusCode) { // 200: tile has changed. Replace file and update metadata. - var tileData = await response.Content.ReadAsByteArrayAsync(); + var tileData = await response.Content.ReadAsByteArrayAsync(cancellationToken); var newEtag = response.Headers.ETag?.Tag; var newLastModified = response.Content.Headers.LastModified?.UtcDateTime; var newExpiry = ParseCacheExpiry(response); + var tempFilePath = CreateTempTilePath(tileFilePath); await _cacheLock.WaitAsync(); try { - await File.WriteAllBytesAsync(tileFilePath, tileData); + await File.WriteAllBytesAsync(tempFilePath, tileData, cancellationToken); + ReplaceTileFileAtomically(tempFilePath, tileFilePath); if (zoom < DbMetadataZoomThreshold) { @@ -1384,6 +1378,11 @@ public async Task RetrieveTileAsync(string zoomLevel, strin }); } } + catch + { + TryDeleteTempTile(tempFilePath); + throw; + } finally { _cacheLock.Release(); @@ -2239,4 +2238,5 @@ private void TryClearHotMetadataCache() /// Reads the current admin-configured hot metadata cache budget. /// private int GetTileMetadataHotCacheSizeMb() => _applicationSettings.GetSettings().TileMetadataHotCacheSizeMB; + } diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceStaleRefreshTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceStaleRefreshTests.cs new file mode 100644 index 00000000..37132075 --- /dev/null +++ b/tests/Wayfarer.Tests/Services/TileCacheServiceStaleRefreshTests.cs @@ -0,0 +1,426 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Wayfarer.Models; +using Wayfarer.Parsers; +using Wayfarer.Services; +using Xunit; + +namespace Wayfarer.Tests.Services; + +/// +/// Focused stale tile refresh coverage for scoped background work and metadata preservation. +/// +public partial class TileCacheServiceTests +{ + [Fact] + public async Task BackgroundRefresh_UsesFreshScopeWithoutRequestHttpContext() + { + using var dir = new TempDir(); + var (db, dbName) = CreateNamedDbContext(); + var handler = new RefreshTestTileHandler(etag: "\"scope-v1\""); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var accessor = new HttpContextAccessor { HttpContext = BuildRequestContext() }; + var service = CreateService(db, dir.Path, handler, httpContextAccessor: accessor, dbName: dbName, hotCache: hotCache); + + await service.CacheTileAsync("http://tiles/9/1/2.png", "9", "1", "2"); + ExpireDbTile(db, hotCache, 9, 1, 2); + + var result = await service.RetrieveTileAsync("9", "1", "2", "http://tiles/9/1/2.png"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_1_2", TimeSpan.FromSeconds(2))); + + Assert.NotNull(result.TileData); + Assert.True(handler.Referrers.Count >= 2); + Assert.NotNull(handler.Referrers[0]); + Assert.Null(handler.Referrers[^1]); + } + + [Fact] + public async Task BackgroundRefresh_DelayedRetryConsumesGlobalBudgetWithoutRequestContext() + { + using var dir = new TempDir(); + var (db, dbName) = CreateNamedDbContext(); + var handler = new RefreshTestTileHandler(etag: "\"retry-budget\"") { DrainBudgetAfterFirstConditionalFailure = true }; + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var accessor = new HttpContextAccessor { HttpContext = BuildRequestContext() }; + var service = CreateService(db, dir.Path, handler, httpContextAccessor: accessor, dbName: dbName, hotCache: hotCache); + TileCacheService.SetRefreshRetryDelayForTesting(_ => TimeSpan.Zero); + + await service.CacheTileAsync("http://tiles/9/2/3.png", "9", "2", "3"); + ExpireDbTile(db, hotCache, 9, 2, 3); + + var result = await service.RetrieveTileAsync("9", "2", "3", "http://tiles/9/2/3.png"); + await Task.Delay(200); + TileCacheService.CancelRefreshForTesting("9_2_3"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_2_3", TimeSpan.FromSeconds(5))); + + Assert.NotNull(result.TileData); + Assert.Equal(1, handler.ConditionalCallCount); + Assert.Null(handler.Referrers.Last()); + } + + [Fact] + public async Task BackgroundRefresh_ExhaustedAttemptsAllowLaterRequestToStartNewSeries() + { + using var dir = new TempDir(); + var (db, dbName) = CreateNamedDbContext(); + var handler = new RefreshTestTileHandler(etag: "\"retry-reset\"") { ConditionalFailuresRemaining = 3 }; + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, dbName: dbName, hotCache: hotCache); + TileCacheService.SetRefreshRetryDelayForTesting(_ => TimeSpan.Zero); + + await service.CacheTileAsync("http://tiles/9/4/5.png", "9", "4", "5"); + ExpireDbTile(db, hotCache, 9, 4, 5); + + await service.RetrieveTileAsync("9", "4", "5", "http://tiles/9/4/5.png"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_4_5", TimeSpan.FromSeconds(2))); + Assert.Equal(3, handler.ConditionalCallCount); + + await service.RetrieveTileAsync("9", "4", "5", "http://tiles/9/4/5.png"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_4_5", TimeSpan.FromSeconds(2))); + + Assert.Equal(4, handler.ConditionalCallCount); + } + + [Fact] + public async Task RetrieveTileAsync_ExpiredHighZoomReturnsBeforeRefreshCompletes() + { + using var dir = new TempDir(); + var (db, dbName) = CreateNamedDbContext(); + var handler = new BlockingRefreshTileHandler("\"high-block\""); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, dbName: dbName, hotCache: hotCache); + + await service.CacheTileAsync("http://tiles/9/14/15.png", "9", "14", "15"); + var tilePath = Path.Combine(dir.Path, "9_14_15.png"); + var originalBytes = await File.ReadAllBytesAsync(tilePath); + ExpireDbTile(db, hotCache, 9, 14, 15); + + var retrieveTask = service.RetrieveTileAsync("9", "14", "15", "http://tiles/9/14/15.png"); + + Assert.Same(retrieveTask, await Task.WhenAny(retrieveTask, Task.Delay(TimeSpan.FromSeconds(1)))); + var result = await retrieveTask; + Assert.Equal(originalBytes, result.TileData); + Assert.True(await handler.WaitForConditionalRequestAsync(TimeSpan.FromSeconds(2))); + + handler.CompleteConditionalRequest(); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_14_15", TimeSpan.FromSeconds(2))); + } + + [Fact] + public async Task RetrieveTileAsync_ExpiredLowZoomReturnsBeforeRefreshCompletes() + { + using var dir = new TempDir(); + var db = CreateDbContext(); + var handler = new BlockingRefreshTileHandler("\"low-block\""); + var service = CreateService(db, dir.Path, handler); + var tilePath = Path.Combine(dir.Path, "5_14_15.png"); + var originalBytes = new byte[] { 7, 9, 11 }; + await File.WriteAllBytesAsync(tilePath, originalBytes); + WriteSidecar(tilePath, "\"low-block\"", DateTime.UtcNow.AddHours(-1)); + + var retrieveTask = service.RetrieveTileAsync("5", "14", "15", "http://tiles/5/14/15.png"); + + Assert.Same(retrieveTask, await Task.WhenAny(retrieveTask, Task.Delay(TimeSpan.FromSeconds(1)))); + var result = await retrieveTask; + Assert.Equal(originalBytes, result.TileData); + Assert.True(await handler.WaitForConditionalRequestAsync(TimeSpan.FromSeconds(2))); + + handler.CompleteConditionalRequest(); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("5_14_15", TimeSpan.FromSeconds(2))); + } + + [Fact] + public async Task BackgroundRefresh_ReplacementFailurePreservesOldFileAndMetadata() + { + using var dir = new TempDir(); + var (db, dbName) = CreateNamedDbContext(); + var handler = new RefreshTestTileHandler(etag: "\"old\"", newEtagOnRevalidation: "\"new\"") + { + ForceRevalidation200 = true + }; + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, dbName: dbName, hotCache: hotCache); + TileCacheService.SetRefreshRetryDelayForTesting(_ => TimeSpan.Zero); + TileCacheService.SetTileFileReplacerForTesting((_, _) => throw new IOException("replacement failed")); + + await service.CacheTileAsync("http://tiles/9/6/7.png", "9", "6", "7"); + var tilePath = Path.Combine(dir.Path, "9_6_7.png"); + var originalBytes = await File.ReadAllBytesAsync(tilePath); + ExpireDbTile(db, hotCache, 9, 6, 7); + + await service.RetrieveTileAsync("9", "6", "7", "http://tiles/9/6/7.png"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_6_7", TimeSpan.FromSeconds(2))); + + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(tilePath)); + Assert.Empty(Directory.GetFiles(dir.Path, "*.tmp")); + var meta = db.TileCacheMetadata.Single(); + Assert.Equal("\"old\"", meta.ETag); + Assert.Equal(originalBytes.Length, meta.Size); + } + + [Fact] + public async Task RetrieveTileAsync_StaleHighZoomHitTouchesLastAccessed() + { + using var dir = new TempDir(); + var (db, dbName) = CreateNamedDbContext(); + var handler = new RefreshTestTileHandler(etag: "\"touch\""); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, dbName: dbName, hotCache: hotCache); + + await service.CacheTileAsync("http://tiles/9/8/9.png", "9", "8", "9"); + var oldAccessed = DateTime.UtcNow.AddMinutes(-20); + ExpireDbTile(db, hotCache, 9, 8, 9, oldAccessed); + + var result = await service.RetrieveTileAsync("9", "8", "9", "http://tiles/9/8/9.png"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_8_9", TimeSpan.FromSeconds(2))); + + Assert.NotNull(result.TileData); + var meta = db.TileCacheMetadata.Single(); + Assert.True(meta.LastAccessed > oldAccessed); + } + + [Fact] + public async Task RetrieveTileAsync_LowZoom304RefreshUpdatesSidecarWithoutReplacingFile() + { + using var dir = new TempDir(); + var db = CreateDbContext(); + var handler = new RefreshTestTileHandler(etag: "\"low-304\""); + var service = CreateService(db, dir.Path, handler); + var tilePath = Path.Combine(dir.Path, "5_10_11.png"); + var originalBytes = new byte[] { 1, 3, 5 }; + await File.WriteAllBytesAsync(tilePath, originalBytes); + WriteSidecar(tilePath, "\"low-304\"", DateTime.UtcNow.AddHours(-1)); + + var result = await service.RetrieveTileAsync("5", "10", "11", "http://tiles/5/10/11.png"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("5_10_11", TimeSpan.FromSeconds(2))); + + Assert.Equal(originalBytes, result.TileData); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(tilePath)); + Assert.True(ReadSidecar(tilePath).ExpiresAtUtc > DateTime.UtcNow); + } + + [Fact] + public async Task RetrieveTileAsync_LowZoom200RefreshReplacesFileAndSidecar() + { + using var dir = new TempDir(); + var db = CreateDbContext(); + var handler = new RefreshTestTileHandler(etag: "\"low-old\"", newEtagOnRevalidation: "\"low-new\"") + { + ForceRevalidation200 = true + }; + var service = CreateService(db, dir.Path, handler); + var tilePath = Path.Combine(dir.Path, "5_12_13.png"); + await File.WriteAllBytesAsync(tilePath, new byte[] { 2, 4, 6 }); + WriteSidecar(tilePath, "\"low-old\"", DateTime.UtcNow.AddHours(-1)); + + await service.RetrieveTileAsync("5", "12", "13", "http://tiles/5/12/13.png"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("5_12_13", TimeSpan.FromSeconds(2))); + + Assert.Equal(RefreshTestTileHandler.NewPayload, await File.ReadAllBytesAsync(tilePath)); + var sidecar = ReadSidecar(tilePath); + Assert.Equal("\"low-new\"", sidecar.ETag); + Assert.True(sidecar.ExpiresAtUtc > DateTime.UtcNow); + } + + private static DefaultHttpContext BuildRequestContext() + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("wayfarer.test"); + return context; + } + + private static void ExpireDbTile(ApplicationDbContext db, TileMetadataHotCache hotCache, + int zoom, int x, int y, DateTime? lastAccessed = null) + { + var meta = db.TileCacheMetadata.Single(t => t.Zoom == zoom && t.X == x && t.Y == y); + meta.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1); + if (lastAccessed.HasValue) + { + meta.LastAccessed = lastAccessed.Value; + } + + db.SaveChanges(); + hotCache.Remove(zoom, x, y); + } + + private static void WriteSidecar(string tilePath, string etag, DateTime expiresAtUtc) + { + var sidecar = new TileSidecarMetadata + { + ETag = etag, + ExpiresAtUtc = expiresAtUtc + }; + File.WriteAllText(tilePath + ".meta", JsonSerializer.Serialize(sidecar)); + } + + private static TileSidecarMetadata ReadSidecar(string tilePath) + { + var json = File.ReadAllText(tilePath + ".meta"); + return JsonSerializer.Deserialize(json)!; + } + + private static Func CreateScopedDbFactory(ApplicationDbContext db, string? dbName) + { + if (dbName == null) + { + return () => db; + } + + return () => new ApplicationDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options, + new ServiceCollection().BuildServiceProvider()); + } + + private static SingleScopeFactory CreateTileCacheScopeFactory(IConfiguration config, HttpClient httpClient, + IApplicationSettingsService appSettings, TileMetadataHotCache hotCache, + Func scopedDbFactory) + { + SingleScopeFactory? scopeFactory = null; + scopeFactory = new SingleScopeFactory(() => + { + var scopedDb = scopedDbFactory(); + var scopedService = new TileCacheService( + NullLogger.Instance, + config, + httpClient, + scopedDb, + appSettings, + scopeFactory!, + new HttpContextAccessor(), + hotCache); + return new ServiceCollection() + .AddSingleton(scopedDb) + .AddSingleton(scopedDb) + .AddSingleton(scopedService) + .BuildServiceProvider(); + }); + return scopeFactory; + } + + /// + /// Conditional tile handler with failure and header capture controls for stale refresh tests. + /// + private sealed class RefreshTestTileHandler : HttpMessageHandler + { + public static readonly byte[] NewPayload = { 50, 60, 70, 80 }; + + private readonly string? _etag; + private readonly string? _newEtagOnRevalidation; + private readonly byte[] _payload = { 10, 20, 30, 40 }; + + public int ConditionalCallCount { get; private set; } + public int ConditionalFailuresRemaining { get; set; } + public bool DrainBudgetAfterFirstConditionalFailure { get; set; } + public bool ForceRevalidation200 { get; set; } + public List Referrers { get; } = new(); + + public RefreshTestTileHandler(string? etag, string? newEtagOnRevalidation = null) + { + _etag = etag; + _newEtagOnRevalidation = newEtagOnRevalidation; + } + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + Referrers.Add(request.Headers.Referrer); + var isConditional = request.Headers.IfNoneMatch.Any() || request.Headers.IfModifiedSince.HasValue; + if (isConditional) + { + ConditionalCallCount++; + if (ConditionalFailuresRemaining > 0) + { + ConditionalFailuresRemaining--; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + + if (DrainBudgetAfterFirstConditionalFailure && ConditionalCallCount == 1) + { + TileCacheService.OutboundBudget.DrainForTesting(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + } + + if (isConditional && !ForceRevalidation200) + { + var notModified = new HttpResponseMessage(HttpStatusCode.NotModified); + notModified.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromHours(1) }; + if (!string.IsNullOrEmpty(_etag)) + { + notModified.Headers.ETag = EntityTagHeaderValue.Parse(_etag); + } + + return Task.FromResult(notModified); + } + + var payload = isConditional && ForceRevalidation200 ? NewPayload : _payload; + var etag = isConditional && ForceRevalidation200 ? _newEtagOnRevalidation : _etag; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload) + }; + response.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromHours(1) }; + if (!string.IsNullOrEmpty(etag)) + { + response.Headers.ETag = EntityTagHeaderValue.Parse(etag); + } + + return Task.FromResult(response); + } + } + + /// + /// Blocks only conditional refresh requests so tests can prove stale bytes return first. + /// + private sealed class BlockingRefreshTileHandler : HttpMessageHandler + { + private readonly string _etag; + private readonly byte[] _payload = { 10, 20, 30, 40 }; + private readonly TaskCompletionSource _conditionalStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseConditional = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public BlockingRefreshTileHandler(string etag) => _etag = etag; + + public Task WaitForConditionalRequestAsync(TimeSpan timeout) => + Task.WhenAny(_conditionalStarted.Task, Task.Delay(timeout)) + .ContinueWith(t => ReferenceEquals(t.Result, _conditionalStarted.Task)); + + public void CompleteConditionalRequest() => _releaseConditional.TrySetResult(); + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + var isConditional = request.Headers.IfNoneMatch.Any() || request.Headers.IfModifiedSince.HasValue; + if (isConditional) + { + _conditionalStarted.TrySetResult(); + await _releaseConditional.Task.WaitAsync(cancellationToken); + var notModified = new HttpResponseMessage(HttpStatusCode.NotModified); + notModified.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromHours(1) }; + notModified.Headers.ETag = EntityTagHeaderValue.Parse(_etag); + return notModified; + } + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(_payload) + }; + response.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromHours(1) }; + response.Headers.ETag = EntityTagHeaderValue.Parse(_etag); + return response; + } + } +} diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs index 640c5df9..1b55d709 100644 --- a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs @@ -26,7 +26,7 @@ namespace Wayfarer.Tests.Services; /// static state via DrainForTesting/ResetForTesting. /// [Collection("OutboundBudget")] -public class TileCacheServiceTests : TestBase +public partial class TileCacheServiceTests : TestBase { [Fact] public async Task CacheTileAsync_StoresFileAndMetadata_ForZoomNine() @@ -450,6 +450,7 @@ public async Task RetrieveTileAsync_SendsConditionalRequest_WhenExpired() // Retrieve should send conditional request because tile is expired var result = await service.RetrieveTileAsync("9", "1", "2", "http://tiles/9/1/2.png"); var bytes = result.TileData; + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_1_2", TimeSpan.FromSeconds(2))); Assert.NotNull(bytes); Assert.True(handler.CallCount > callCountAfterCache, "Expected conditional HTTP request"); @@ -477,6 +478,7 @@ public async Task RetrieveTileAsync_HandlesNotModified304_WithoutRedownload() // Retrieve: tile is expired, handler returns 304 when If-None-Match matches var result = await service.RetrieveTileAsync("9", "1", "2", "http://tiles/9/1/2.png"); var bytes = result.TileData; + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_1_2", TimeSpan.FromSeconds(2))); Assert.NotNull(bytes); Assert.Equal(originalFile, bytes); // Same data, not re-downloaded @@ -513,8 +515,11 @@ public async Task RetrieveTileAsync_ReplacesFile_On200AfterExpiry() var result = await service.RetrieveTileAsync("9", "1", "2", "http://tiles/9/1/2.png"); var bytes = result.TileData; + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_1_2", TimeSpan.FromSeconds(2))); Assert.NotNull(bytes); + Assert.Equal(new byte[] { 50, 60, 70, 80 }, await File.ReadAllBytesAsync(Path.Combine(dir.Path, "9_1_2.png"))); + Assert.Empty(Directory.GetFiles(dir.Path, "*.tmp")); // DB metadata should now have the new etag db.Entry(meta).Reload(); Assert.Equal("\"v2\"", meta.ETag); @@ -546,6 +551,8 @@ public async Task RetrieveTileAsync_ServesStaleCache_WhenRevalidationFails() // Retrieve should serve stale cached file despite re-validation failure var result = await service.RetrieveTileAsync("9", "1", "2", "http://tiles/9/1/2.png"); var bytes = result.TileData; + TileCacheService.CancelRefreshForTesting("9_1_2"); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_1_2", TimeSpan.FromSeconds(2))); Assert.NotNull(bytes); } @@ -617,6 +624,7 @@ public async Task RetrieveTileAsync_CoalescesConcurrentRevalidations() .ToList(); var results = await Task.WhenAll(tasks); + Assert.True(await TileCacheService.WaitForRefreshIdleForTestingAsync("9_5_5", TimeSpan.FromSeconds(2))); // All should return data Assert.All(results, r => Assert.NotNull(r.TileData)); @@ -670,22 +678,14 @@ public async Task RetrieveTileAsync_ReturnsThrottled_WhenBudgetExhausted() var db = CreateDbContext(); var service = CreateService(db, dir.Path); - // Drain all tokens and stop replenishment — next AcquireAsync returns false. TileCacheService.OutboundBudget.DrainForTesting(); - // RetrieveTileAsync should signal budget exhaustion (BudgetExhausted = true). var result = await service.RetrieveTileAsync("10", "1", "1", "http://tiles/10/1/1.png"); Assert.True(result.BudgetExhausted); Assert.Null(result.TileData); } - /// - /// Creates a TileCacheService with a properly configured HttpClient. - /// Mirrors the User-Agent, Timeout, and TryParseAdd fallback logic from the - /// AddHttpClient registration in Program.cs. Accept and AcceptLanguage headers - /// are omitted because no current test exercises content negotiation. - /// /// /// Creates an with a known database name, so that /// can create independent DbContext instances that @@ -730,15 +730,9 @@ private TileCacheService CreateService(ApplicationDbContext db, string cacheDir, // the same in-memory database by name. This mirrors production behavior where // each scope gets its own DbContext with a separate change tracker. var appSettings = new StubSettingsService(maxCacheMb); - var scopeFactory = dbName != null - ? new SingleScopeFactory(() => - new ApplicationDbContext( - new DbContextOptionsBuilder() - .UseInMemoryDatabase(dbName) - .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) - .Options, - new ServiceCollection().BuildServiceProvider())) - : new SingleScopeFactory(() => db); + var effectiveHotCache = hotCache ?? new TileMetadataHotCache(NullLogger.Instance); + var scopedDbFactory = CreateScopedDbFactory(db, dbName); + var scopeFactory = CreateTileCacheScopeFactory(config, httpClient, appSettings, effectiveHotCache, scopedDbFactory); return new TileCacheService( NullLogger.Instance, config, @@ -747,7 +741,7 @@ private TileCacheService CreateService(ApplicationDbContext db, string cacheDir, appSettings, scopeFactory, httpContextAccessor ?? new HttpContextAccessor(), - hotCache ?? new TileMetadataHotCache(NullLogger.Instance)); + effectiveHotCache); } /// @@ -966,18 +960,13 @@ public void RefreshSettings() { } /// private sealed class SingleScopeFactory : IServiceScopeFactory { - private readonly Func _dbFactory; + private readonly Func _providerFactory; - public SingleScopeFactory(Func dbFactory) => _dbFactory = dbFactory; + public SingleScopeFactory(Func providerFactory) => _providerFactory = providerFactory; public IServiceScope CreateScope() { - var db = _dbFactory(); - var provider = new ServiceCollection() - .AddSingleton(db) - .AddSingleton(db) - .BuildServiceProvider(); - return new SimpleScope(provider); + return new SimpleScope(_providerFactory()); } private sealed class SimpleScope : IServiceScope @@ -1010,26 +999,30 @@ public async Task PurgeAllCacheAsync_RejectsSecondConcurrentPurge() .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options, new ServiceCollection().BuildServiceProvider()); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:TileCacheDirectory"] = dir.Path, + ["Application:ContactEmail"] = "test@example.com" + }).Build(); + var httpClient = new HttpClient(new StubTileHandler()); + var appSettings = new StubSettingsService(); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var scopeFactory = CreateTileCacheScopeFactory( + config, + httpClient, + appSettings, + hotCache, + CreateScopedDbFactory(db2, dbName)); var service2 = new TileCacheService( NullLogger.Instance, - new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["CacheSettings:TileCacheDirectory"] = dir.Path, - ["Application:ContactEmail"] = "test@example.com" - }).Build(), - new HttpClient(new StubTileHandler()), + config, + httpClient, db2, - new StubSettingsService(), - new SingleScopeFactory(() => - new ApplicationDbContext( - new DbContextOptionsBuilder() - .UseInMemoryDatabase(dbName) - .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) - .Options, - new ServiceCollection().BuildServiceProvider())), + appSettings, + scopeFactory, new HttpContextAccessor(), - new TileMetadataHotCache(NullLogger.Instance)); + hotCache); // Start the first purge. var firstPurge = service1.PurgeAllCacheAsync();