diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs index 08cda2c97f..2dbae858ca 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs @@ -21,7 +21,13 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes; /// /// Individual bundle files are cached locally keyed by {product}-{fileName}-{etag} so that /// repeated builds (and dev-server reloads) do not re-download unchanged content from the CDN. -/// The registry itself is always fetched (it's small and provides fresh ETags). +/// The per-product registry is normally fetched every run (it's small and provides fresh ETags), +/// with one opt-out: the scrubber maintains a shallow per-tree map at bundle/registry.json +/// mapping each product folder to an opaque change token. The map is fetched once per fetcher run; +/// when a product's token equals the token the local cache last saw, the cached registry is reused +/// and the per-product registry fetch is skipped entirely. Tokens are opaque — compared for string +/// equality only, never parsed — and a map that is absent (pre-cutover CDNs), unparseable, or +/// unreachable degrades to exactly the pre-map behavior: every product registry is fetched. /// /// /// Resilience follows the manifest's consistency model: a registry that cannot be fetched or parsed @@ -60,6 +66,13 @@ public sealed class CdnChangelogFetcher : IDisposable private readonly IFileSystem _fileSystem; private readonly ConcurrentDictionary _memoryCache = new(StringComparer.Ordinal); + /// + /// Shallow-map fetches memoized per base URI, so one run consults the CDN once no matter how many + /// products it fetches. The map is intentionally never cached to disk: it is the freshness signal + /// itself, and a stale copy would defeat its purpose. + /// + private readonly ConcurrentDictionary?>>> _shallowMaps = new(StringComparer.Ordinal); + /// /// Non-null only when a caller injects its own (tests): in that case we /// own a per-instance client and must dispose it. On the production path points @@ -108,22 +121,30 @@ public async Task> FetchAsync( } var registryUri = Combine(baseUri, [.. ChangelogKeys.BundleSegments(product), ChangelogKeys.RegistryFileName]); + var shallowToken = await TryGetShallowTokenAsync(baseUri, product, ctx).ConfigureAwait(false); - ChangelogRegistry? registry; - try - { - registry = await FetchRegistryAsync(registryUri, ctx).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - emitError($"Could not fetch changelog registry for product '{product}' from {registryUri}: {ex.Message}"); - return []; - } - + var registry = TryGetCachedRegistry(product, shallowToken); if (registry is null) { - emitError($"Changelog registry for product '{product}' at {registryUri} was empty or unparseable."); - return []; + try + { + _logger.LogInformation("Fetching changelog registry {RegistryUri}", registryUri); + var registryText = await FetchTextAsync(registryUri, ctx).ConfigureAwait(false); + registry = JsonSerializer.Deserialize(registryText, ChangelogRegistryJsonContext.Default.ChangelogRegistry); + if (registry is not null && shallowToken is not null) + WriteCachedText(RegistryCacheKey(product, shallowToken), registryText); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + emitError($"Could not fetch changelog registry for product '{product}' from {registryUri}: {ex.Message}"); + return []; + } + + if (registry is null) + { + emitError($"Changelog registry for product '{product}' at {registryUri} was empty or unparseable."); + return []; + } } if (registry.SchemaVersion > SupportedSchemaVersion) @@ -143,14 +164,73 @@ public async Task> FetchAsync( return _bundleLoader.LoadBundlesFromContent(contents, emitWarning); } - private async Task FetchRegistryAsync(Uri registryUri, Cancel ctx) + /// + /// The product's opaque change token from the tree's shallow map, or null when the map is + /// unavailable or does not list the product — in which case the caller fetches the per-product + /// registry exactly as it did before the map existed. + /// + private async Task TryGetShallowTokenAsync(Uri baseUri, string product, Cancel ctx) { - _logger.LogInformation("Fetching changelog registry {RegistryUri}", registryUri); - using var request = new HttpRequestMessage(HttpMethod.Get, registryUri); - using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false); - _ = response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(ctx).ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync(stream, ChangelogRegistryJsonContext.Default.ChangelogRegistry, ctx).ConfigureAwait(false); + var lazyMap = _shallowMaps.GetOrAdd( + baseUri.AbsoluteUri, + _ => new Lazy?>>(() => FetchShallowMapAsync(baseUri, ctx))); + var map = await lazyMap.Value.ConfigureAwait(false); + if (map is null || !map.TryGetValue(product, out var token)) + return null; + + // The token is opaque but becomes part of a local cache file name; anything that is not a + // plain path segment is ignored rather than joined into a path. + return ChangelogKeys.IsSafeFileName(token) ? token : null; + } + + /// + /// Fetches the tree's shallow map (bundle/registry.json) mapping each product folder to an + /// opaque change token. Every failure — absent on pre-cutover CDNs, unparseable, transport — degrades + /// to null so the run behaves exactly as it did before the map existed. + /// + private async Task?> FetchShallowMapAsync(Uri baseUri, Cancel ctx) + { + var mapUri = Combine(baseUri, ["bundle", ChangelogKeys.RegistryFileName]); + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, mapUri); + using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(ctx).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, ChangelogRegistryJsonContext.Default.DictionaryStringString, ctx).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug("Shallow changelog map at {MapUri} is unavailable ({Message}); fetching every product registry as usual", mapUri, ex.Message); + return null; + } + } + + /// + /// The parsed registry from the token-keyed local cache, or null when there is no shallow token + /// for the product, no cached copy for that token, or the cached copy no longer parses — every + /// miss falls back to a normal registry fetch. + /// + private ChangelogRegistry? TryGetCachedRegistry(string product, string? shallowToken) + { + if (shallowToken is null) + return null; + + var cached = TryGetCachedText(RegistryCacheKey(product, shallowToken)); + if (cached is null) + return null; + + try + { + var registry = JsonSerializer.Deserialize(cached, ChangelogRegistryJsonContext.Default.ChangelogRegistry); + if (registry is not null) + _logger.LogInformation("Changelog folder 'bundle/{Product}' is unchanged per the shallow map; using the cached registry", product); + return registry; + } + catch (JsonException) + { + return null; + } } private async Task> DownloadBundlesAsync( @@ -257,12 +337,17 @@ private static Uri Combine(Uri baseUri, IReadOnlyList segments) return new Uri($"{basePath}/{suffix}"); } - private string? TryGetCachedBundle(string product, string fileName, string? etag) + private string? TryGetCachedBundle(string product, string fileName, string? etag) => + string.IsNullOrWhiteSpace(etag) ? null : TryGetCachedText(BundleCacheKey(product, fileName, etag)); + + private void WriteCachedBundle(string product, string fileName, string? etag, string content) { - if (string.IsNullOrWhiteSpace(etag)) - return null; + if (!string.IsNullOrWhiteSpace(etag)) + WriteCachedText(BundleCacheKey(product, fileName, etag), content); + } - var cacheKey = CacheKey(product, fileName, etag); + private string? TryGetCachedText(string cacheKey) + { if (_memoryCache.TryGetValue(cacheKey, out var cached)) return cached; @@ -278,17 +363,13 @@ private static Uri Combine(Uri baseUri, IReadOnlyList segments) } catch (Exception e) { - _logger.LogError(e, "Failed to read cached changelog bundle {CachePath}", cachePath); + _logger.LogError(e, "Failed to read cached changelog file {CachePath}", cachePath); return null; } } - private void WriteCachedBundle(string product, string fileName, string? etag, string content) + private void WriteCachedText(string cacheKey, string content) { - if (string.IsNullOrWhiteSpace(etag)) - return; - - var cacheKey = CacheKey(product, fileName, etag); _ = _memoryCache.TryAdd(cacheKey, content); var cachePath = CachePath(cacheKey); @@ -302,13 +383,21 @@ private void WriteCachedBundle(string product, string fileName, string? etag, st } catch (Exception e) { - _logger.LogError(e, "Failed to write cached changelog bundle {CachePath}", cachePath); + _logger.LogError(e, "Failed to write cached changelog file {CachePath}", cachePath); } } - private static string CacheKey(string product, string fileName, string etag) => + private static string BundleCacheKey(string product, string fileName, string etag) => $"changelog-{product}-{fileName}-{etag}"; + /// + /// Registry cache entries embed the shallow token in the key: a token mismatch is simply a cache + /// miss under the new key, which re-fetches and records the fresh registry alongside it — the same + /// convention the ETag-keyed bundle cache follows. + /// + private static string RegistryCacheKey(string product, string token) => + $"registry-{product}-{token}"; + private static string CachePath(string cacheKey) => Path.Join(Paths.ApplicationData.FullName, "changelog-bundles", cacheKey); diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogRegistry.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogRegistry.cs index 72dfc3c837..d7f15e0189 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogRegistry.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogRegistry.cs @@ -43,7 +43,10 @@ public sealed record ChangelogRegistryBundle public string? ETag { get; init; } } +// Dictionary is the shallow per-tree map (bundle/registry.json): folder → opaque +// change token, maintained by the scrubber's ShallowRegistryReconciler. [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] [JsonSerializable(typeof(ChangelogRegistry))] [JsonSerializable(typeof(ChangelogRegistryBundle))] +[JsonSerializable(typeof(Dictionary))] internal sealed partial class ChangelogRegistryJsonContext : JsonSerializerContext; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs index b87cb5ba64..34d4523ca4 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs @@ -28,6 +28,9 @@ public class CdnChangelogFetcherTests private static readonly Uri BaseUri = new("https://cdn.example"); + /// The shallow per-tree map probed once per run before any per-product registry fetch. + private const string ShallowMapPath = "/bundle/registry.json"; + private static CdnChangelogFetcher CreateFetcher(StubHandler handler) => new(NullLoggerFactory.Instance, new FileSystem(), handler); @@ -289,10 +292,13 @@ public async Task FetchAsync_InvalidProduct_EmitsErrorAndDoesNotHitCdn(string pr [Fact] public async Task FetchAsync_WithETag_UsesCachedBundleOnSecondCall() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "abc123" } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => NotFound(), + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => + Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "abc123" } ] }"""), + _ => Yaml(SampleBundle) + }); var (errors, warnings, emitError, emitWarning) = Diagnostics(); var fs = new MockFileSystem(); @@ -301,12 +307,12 @@ public async Task FetchAsync_WithETag_UsesCachedBundleOnSecondCall() // First call — should fetch from CDN var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles.Should().ContainSingle(); - handler.CallCount.Should().Be(2, "registry + bundle"); + handler.CallCount.Should().Be(3, "shallow map probe + registry + bundle"); - // Second call — bundle should come from cache (only registry re-fetched) + // Second call — bundle should come from cache (only registry re-fetched; the map probe is memoized per run) var bundles2 = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles2.Should().ContainSingle(); - handler.CallCount.Should().Be(3, "only registry fetched again; bundle served from memory cache"); + handler.CallCount.Should().Be(4, "only registry fetched again; bundle served from memory cache"); errors.Should().BeEmpty(); } @@ -337,27 +343,33 @@ public async Task FetchAsync_WithETag_ReadsCacheFromDisk() fs.Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); fs.File.WriteAllText(cachePath, SampleBundle); - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "cached1" } ] }""") - : throw new InvalidOperationException("Should not fetch bundle from CDN")); + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => NotFound(), + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => + Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "cached1" } ] }"""), + _ => throw new InvalidOperationException("Should not fetch bundle from CDN") + }); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles.Should().ContainSingle(); - handler.CallCount.Should().Be(1, "only registry should be fetched; bundle served from disk"); + handler.CallCount.Should().Be(2, "only the map probe and registry should be fetched; bundle served from disk"); errors.Should().BeEmpty(); } [Fact] public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": null } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => NotFound(), + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => + Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": null } ] }"""), + _ => Yaml(SampleBundle) + }); var (errors, _, emitError, emitWarning) = Diagnostics(); var fs = new MockFileSystem(); @@ -365,11 +377,11 @@ public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn() // First call _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); - handler.CallCount.Should().Be(2); + handler.CallCount.Should().Be(3, "map probe + registry + bundle"); - // Second call — no caching, so bundle is fetched again + // Second call — no caching, so bundle is fetched again (the map probe stays memoized) _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); - handler.CallCount.Should().Be(4, "without ETag, both registry and bundle are fetched each time"); + handler.CallCount.Should().Be(5, "without ETag, both registry and bundle are fetched each time"); errors.Should().BeEmpty(); } @@ -377,25 +389,200 @@ public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn() public async Task FetchAsync_ChangedETag_FetchesNewBundle() { var etag = "v1"; - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json($$"""{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "{{etag}}" } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => NotFound(), + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => + Json($$"""{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "{{etag}}" } ] }"""), + _ => Yaml(SampleBundle) + }); var (errors, _, emitError, emitWarning) = Diagnostics(); var fs = new MockFileSystem(); using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); - handler.CallCount.Should().Be(2); + handler.CallCount.Should().Be(3, "map probe + registry + bundle"); // Simulate a new etag by creating a new fetcher (in real usage the registry returns a different etag) etag = "v2"; _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); - handler.CallCount.Should().Be(4, "new ETag means cache miss, bundle re-downloaded"); + handler.CallCount.Should().Be(5, "new ETag means cache miss, bundle re-downloaded"); errors.Should().BeEmpty(); } + // language=json + private const string EsRegistryJson = + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "abc123" } ] }"""; + + private static string CacheFilePath(string cacheKey) => + Path.Join(Paths.ApplicationData.FullName, "changelog-bundles", cacheKey); + + /// Seeds the disk cache as a previous run with shallow token would have left it. + private static MockFileSystem WarmCache(string token) + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory(Path.GetDirectoryName(CacheFilePath("x"))!); + fs.File.WriteAllText(CacheFilePath($"registry-elasticsearch-{token}"), EsRegistryJson); + fs.File.WriteAllText(CacheFilePath("changelog-elasticsearch-9.3.0.yaml-abc123"), SampleBundle); + return fs; + } + + [Fact] + public async Task FetchAsync_ShallowMapAbsent_FetchesRegistryAndBundleAsBefore() + { + // Pre-cutover CDNs have no bundle/registry.json: a 404 must degrade to the full per-product flow. + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => NotFound(), + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => Json(EsRegistryJson), + _ => Yaml(SampleBundle) + }); + var (errors, warnings, emitError, emitWarning) = Diagnostics(); + + using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, new MockFileSystem(), handler); + var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + + errors.Should().BeEmpty(); + warnings.Should().BeEmpty(); + bundles.Should().ContainSingle(); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json"); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.yaml"); + } + + [Fact] + public async Task FetchAsync_ShallowMapUnparseable_FetchesRegistryAndBundleAsBefore() + { + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => Json("{ not valid json"), + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => Json(EsRegistryJson), + _ => Yaml(SampleBundle) + }); + var (errors, warnings, emitError, emitWarning) = Diagnostics(); + + using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, new MockFileSystem(), handler); + var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + + errors.Should().BeEmpty(); + warnings.Should().BeEmpty(); + bundles.Should().ContainSingle(); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json"); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.yaml"); + } + + [Fact] + public async Task FetchAsync_ShallowTokenMatchesWarmCache_MakesNoPerProductRequests() + { + var fs = WarmCache("tok1"); + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath == ShallowMapPath + ? Json(/*lang=json,strict*/ """{ "elasticsearch": "tok1" }""") + : throw new InvalidOperationException($"Unexpected per-folder request: {req.RequestUri}")); + var (errors, warnings, emitError, emitWarning) = Diagnostics(); + + using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); + var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + + errors.Should().BeEmpty(); + warnings.Should().BeEmpty(); + bundles.Should().ContainSingle(); + bundles[0].Entries.Should().ContainSingle().Which.Title.Should().Be("Sample enhancement"); + handler.RequestedPaths.Should().Equal(ShallowMapPath); + } + + [Fact] + public async Task FetchAsync_ShallowTokenMismatch_FetchesRegistryAndRecordsNewToken() + { + var fs = WarmCache("tok-old"); + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => Json(/*lang=json,strict*/ """{ "elasticsearch": "tok-new" }"""), + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => Json(EsRegistryJson), + _ => Yaml(SampleBundle) + }); + var (errors, warnings, emitError, emitWarning) = Diagnostics(); + + using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); + var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + + errors.Should().BeEmpty(); + warnings.Should().BeEmpty(); + bundles.Should().ContainSingle(); + handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json"); + fs.File.Exists(CacheFilePath("registry-elasticsearch-tok-new")) + .Should().BeTrue("the fresh registry should be recorded under the new token for the next run"); + } + + [Fact] + public async Task FetchAsync_ShallowTokenWithColdCache_FetchesAsUsualThenSkipsOnNextRun() + { + var fs = new MockFileSystem(); + var mapJson = /*lang=json,strict*/ """{ "elasticsearch": "tok1" }"""; + var coldHandler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => Json(mapJson), + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => Json(EsRegistryJson), + _ => Yaml(SampleBundle) + }); + var (errors, warnings, emitError, emitWarning) = Diagnostics(); + + // Cold cache: the token alone cannot satisfy a skip, so the flow is identical to today. + using (var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, coldHandler)) + { + var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + bundles.Should().ContainSingle(); + coldHandler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json"); + } + + // Next run (new fetcher, same disk cache): the unchanged token skips every per-product request. + var warmHandler = new StubHandler(req => req.RequestUri!.AbsolutePath == ShallowMapPath + ? Json(mapJson) + : throw new InvalidOperationException($"Unexpected per-folder request: {req.RequestUri}")); + using (var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, warmHandler)) + { + var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + bundles.Should().ContainSingle(); + warmHandler.RequestedPaths.Should().Equal(ShallowMapPath); + } + + errors.Should().BeEmpty(); + warnings.Should().BeEmpty(); + } + + [Fact] + public async Task FetchAsync_ShallowMapPartialMatch_SkipsOnlyUnchangedFolders() + { + // One run over two products: elasticsearch has a warm cache and a matching token, kibana does + // not — only kibana's registry and bundle may hit the CDN, and the map is probed exactly once. + var fs = WarmCache("tok-es"); + var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch + { + ShallowMapPath => Json(/*lang=json,strict*/ """{ "elasticsearch": "tok-es", "kibana": "tok-kb" }"""), + "/bundle/kibana/registry.json" => + Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "kibana", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "kb1" } ] }"""), + "/bundle/kibana/9.3.0.yaml" => Yaml(SampleBundle), + var p => throw new InvalidOperationException($"Unexpected per-folder request: {p}") + }); + var (errors, warnings, emitError, emitWarning) = Diagnostics(); + + using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); + var esBundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var kibanaBundles = await fetcher.FetchAsync(BaseUri, "kibana", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + + errors.Should().BeEmpty(); + warnings.Should().BeEmpty(); + esBundles.Should().ContainSingle(); + kibanaBundles.Should().ContainSingle(); + handler.RequestedPaths.Count(p => p == ShallowMapPath).Should().Be(1, "the map is fetched once per run"); + handler.RequestedPaths.Should().NotContain(p => p.StartsWith("/bundle/elasticsearch/", StringComparison.Ordinal)); + handler.RequestedPaths.Should().Contain("/bundle/kibana/registry.json"); + handler.RequestedPaths.Should().Contain("/bundle/kibana/9.3.0.yaml"); + fs.File.Exists(CacheFilePath("registry-kibana-tok-kb")) + .Should().BeTrue("kibana's registry should be recorded under its token for the next run"); + } + + private static HttpResponseMessage NotFound() => new(HttpStatusCode.NotFound); + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };