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") };