Skip to content

Commit 657d42e

Browse files
theletterfclaudecotti
authored
Cache cross-link index across serve hot reloads (#3219)
* Cache cross-link index across serve hot reloads (#2845) Every .md save in serve mode was re-fetching link-index.json over S3 for each cross-link entry, twice per repo, blocking the browser refresh. Cache FetchedCrossLinks across reloads (re-fetched only on configuration changes), and fold the duplicate per-repo GetRegistry calls into one fetch per registry up front. Same wins flow through to docs-builder build and codex builds via DocSetConfigurationCrossLinkFetcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Skip full rebuild and validation on content-only changes For plain .md edits during serve, skip the DocumentationSet rebuild, ResolveDirectoryTree, and in-memory validation build entirely — the serve path already reads fresh content from disk via ParseFullAsync. Full rebuilds still run for structural changes (config/toc edits, file add/delete). Also watch common asset files (images, yml, toml) and trigger a browser-only refresh when they change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Propagate cancellation in TryGetRegistry, recreate fetcher on config reload Don't swallow OperationCanceledException in TryGetRegistry so cancellation propagates promptly instead of degrading to null. Recreate _crossLinkFetcher and _codexReader when configuration reloads so registry switches in docset.yml take effect immediately. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Felipe Cotti <felipe.cotti@elastic.co>
1 parent f834fcd commit 657d42e

4 files changed

Lines changed: 87 additions & 20 deletions

File tree

src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ public record FetchedCrossLinks
3434
/// </summary>
3535
public FrozenSet<string>? CodexRepositories { get; init; }
3636

37+
/// <summary>
38+
/// True when all declared repositories resolved without falling back to placeholder data.
39+
/// When false, callers should avoid caching so a subsequent reload retries the fetch.
40+
/// </summary>
41+
public bool IsComplete { get; init; } = true;
42+
3743
public static FetchedCrossLinks Empty { get; } = new()
3844
{
3945
DeclaredRepositories = [],

src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,36 @@ public override async Task<FetchedCrossLinks> FetchCrossLinks(Cancel ctx)
3333
var publicReader = linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous();
3434
var useDualRegistry = configuration.Registry != DocSetRegistry.Public && _codexReader is not null;
3535

36+
// Fetch each registry once up front so per-repository lookups don't trigger N S3 round-trips.
37+
var publicRegistry = await TryGetRegistry(publicReader, ctx);
38+
var codexRegistry = useDualRegistry ? await TryGetRegistry(_codexReader!, ctx) : null;
39+
var hadFetchFailures = false;
40+
3641
foreach (var entry in configuration.CrossLinkEntries)
3742
{
3843
_ = declaredRepositories.Add(entry.Repository);
3944
var isCodexEntry = useDualRegistry && entry.Registry != DocSetRegistry.Public;
4045
var reader = isCodexEntry ? _codexReader! : publicReader;
46+
var registry = isCodexEntry ? codexRegistry : publicRegistry;
4147

4248
if (isCodexEntry)
4349
_ = codexRepositories.Add(entry.Repository);
4450

4551
try
4652
{
47-
var linkReference = await FetchCrossLinksFromReader(reader, entry.Repository, this, ctx);
53+
if (registry is null || !registry.Repositories.TryGetValue(entry.Repository, out var repoBranches))
54+
throw new Exception($"Repository {entry.Repository} not found in link index");
55+
56+
var linkIndexEntry = GetNextContentSourceLinkIndexEntry(repoBranches, entry.Repository);
57+
var linkReference = await FetchLinkIndexEntryFromReader(reader, entry.Repository, linkIndexEntry, ctx);
58+
4859
linkReferences.Add(entry.Repository, linkReference);
60+
linkIndexEntries.Add(entry.Repository, linkIndexEntry);
4961
registryUrlsByRepository[entry.Repository] = reader.RegistryUrl;
50-
51-
var registry = await reader.GetRegistry(ctx);
52-
if (registry.Repositories.TryGetValue(entry.Repository, out var repoBranches))
53-
{
54-
var linkIndexEntry = GetNextContentSourceLinkIndexEntry(repoBranches, entry.Repository);
55-
linkIndexEntries.Add(entry.Repository, linkIndexEntry);
56-
}
5762
}
5863
catch (Exception ex)
5964
{
65+
hadFetchFailures = true;
6066
_logger.LogWarning(ex, "Error fetching link data for repository '{Repository}'. Cross-links to this repository may not resolve correctly.", entry.Repository);
6167
_ = registryUrlsByRepository.TryAdd(entry.Repository, reader.RegistryUrl);
6268

@@ -86,6 +92,24 @@ public override async Task<FetchedCrossLinks> FetchCrossLinks(Cancel ctx)
8692
LinkIndexEntries = linkIndexEntries.ToFrozenDictionary(),
8793
RegistryUrlsByRepository = registryUrlsByRepository.ToFrozenDictionary(),
8894
CodexRepositories = codexRepositories.Count > 0 ? codexRepositories.ToFrozenSet() : null,
95+
IsComplete = !hadFetchFailures,
8996
};
9097
}
98+
99+
private async Task<LinkRegistry?> TryGetRegistry(ILinkIndexReader reader, Cancel ctx)
100+
{
101+
try
102+
{
103+
return await reader.GetRegistry(ctx);
104+
}
105+
catch (OperationCanceledException)
106+
{
107+
throw;
108+
}
109+
catch (Exception ex)
110+
{
111+
_logger.LogWarning(ex, "Failed to fetch link index registry from {RegistryUrl}", reader.RegistryUrl);
112+
return null;
113+
}
114+
}
91115
}

src/tooling/docs-builder/Http/ReloadGeneratorService.cs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5+
using System.Collections.Frozen;
56
using Microsoft.Extensions.Hosting;
67
using Microsoft.Extensions.Logging;
78
using Westwind.AspNetCore.LiveReload;
@@ -28,13 +29,18 @@ public sealed class ReloadGeneratorService(
2829
ILogger<ReloadGeneratorService> logger
2930
) : IHostedService, IDisposable
3031
{
32+
private static readonly FrozenSet<string> AssetExtensions = new[]
33+
{
34+
".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp",
35+
".yml", ".yaml", ".toml"
36+
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
37+
3138
private FileSystemWatcher? _watcher;
3239
private CancellationTokenSource? _serviceCts;
3340
private ReloadableGeneratorState ReloadableGenerator { get; } = reloadableGenerator;
3441
private InMemoryBuildState InMemoryBuildState { get; } = inMemoryBuildState;
3542
private ILogger Logger { get; } = logger;
3643

37-
//debounce reload requests due to many file changes
3844
private readonly Debouncer _debouncer = new(TimeSpan.FromMilliseconds(200));
3945

4046
public async Task StartAsync(Cancel cancellationToken)
@@ -78,6 +84,8 @@ await Task.WhenAll(
7884
watcher.Filters.Add("docset.yml");
7985
watcher.Filters.Add("_docset.yml");
8086
watcher.Filters.Add("toc.yml");
87+
foreach (var ext in AssetExtensions)
88+
watcher.Filters.Add($"*{ext}");
8189
watcher.IncludeSubdirectories = true;
8290
watcher.EnableRaisingEvents = true;
8391
_watcher = watcher;
@@ -88,18 +96,17 @@ private void Reload(bool reloadConfiguration = false)
8896
var token = _serviceCts?.Token ?? Cancel.None;
8997
_ = _debouncer.ExecuteAsync(async ctx =>
9098
{
91-
var sourcePath = ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName;
92-
93-
// Start in-memory validation build (runs in parallel)
94-
var validationTask = InMemoryBuildState.StartBuildAsync(sourcePath, ctx);
95-
96-
// Wait for live reload to complete, then refresh the browser immediately
9799
await ReloadableGenerator.ReloadAsync(ctx, reloadConfiguration);
98100
Logger.LogInformation("Reload complete!");
99101
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
100102

101-
// Wait for validation build to complete
102-
await validationTask;
103+
// Only run the full validation build for structural changes (config/toc edits, file add/delete).
104+
// Content-only .md edits are picked up on the next request via ParseFullAsync.
105+
if (reloadConfiguration)
106+
{
107+
var sourcePath = ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName;
108+
await InMemoryBuildState.StartBuildAsync(sourcePath, ctx);
109+
}
103110
}, token);
104111
}
105112

@@ -124,6 +131,9 @@ private static bool ShouldIgnorePath(string path) =>
124131
private static bool IsConfigFile(string path) =>
125132
path.EndsWith("docset.yml") || path.EndsWith("toc.yml");
126133

134+
private static bool IsAssetFile(string path) =>
135+
AssetExtensions.Contains(Path.GetExtension(path));
136+
127137
private void OnChanged(object sender, FileSystemEventArgs e)
128138
{
129139
if (e.ChangeType != WatcherChangeTypes.Changed)
@@ -138,6 +148,8 @@ private void OnChanged(object sender, FileSystemEventArgs e)
138148
Reload(reloadConfiguration: true);
139149
else if (e.FullPath.EndsWith(".md"))
140150
Reload();
151+
else if (IsAssetFile(e.FullPath))
152+
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
141153
#if DEBUG
142154
if (e.FullPath.EndsWith(".cshtml"))
143155
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
@@ -152,6 +164,8 @@ private void OnCreated(object sender, FileSystemEventArgs e)
152164
Logger.LogInformation("Created: {FullPath}", e.FullPath);
153165
if (e.FullPath.EndsWith(".md") || IsConfigFile(e.FullPath))
154166
Reload(reloadConfiguration: true);
167+
else if (IsAssetFile(e.FullPath))
168+
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
155169
}
156170

157171
private void OnDeleted(object sender, FileSystemEventArgs e)
@@ -162,6 +176,8 @@ private void OnDeleted(object sender, FileSystemEventArgs e)
162176
Logger.LogInformation("Deleted: {FullPath}", e.FullPath);
163177
if (e.FullPath.EndsWith(".md") || IsConfigFile(e.FullPath))
164178
Reload(reloadConfiguration: true);
179+
else if (IsAssetFile(e.FullPath))
180+
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
165181
}
166182

167183
private void OnRenamed(object sender, RenamedEventArgs e)
@@ -174,6 +190,8 @@ private void OnRenamed(object sender, RenamedEventArgs e)
174190
Logger.LogInformation(" New: {NewFullPath}", e.FullPath);
175191
if (e.FullPath.EndsWith(".md") || e.OldFullPath.EndsWith(".md") || IsConfigFile(e.FullPath) || IsConfigFile(e.OldFullPath))
176192
Reload(reloadConfiguration: true);
193+
else if (IsAssetFile(e.FullPath) || IsAssetFile(e.OldFullPath))
194+
_ = LiveReloadMiddleware.RefreshWebSocketRequest();
177195
#if DEBUG
178196
if (e.FullPath.EndsWith(".cshtml"))
179197
_ = LiveReloadMiddleware.RefreshWebSocketRequest();

src/tooling/docs-builder/Http/ReloadableGeneratorState.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ public class ReloadableGeneratorState : IDisposable
2727
private readonly ILoggerFactory _logFactory;
2828
private readonly BuildContext _context;
2929
private readonly bool _isWatchBuild;
30-
private readonly DocSetConfigurationCrossLinkFetcher _crossLinkFetcher;
31-
private readonly ILinkIndexReader? _codexReader;
30+
private DocSetConfigurationCrossLinkFetcher _crossLinkFetcher;
31+
private ILinkIndexReader? _codexReader;
32+
private FetchedCrossLinks? _cachedCrossLinks;
3233

3334
public ReloadableGeneratorState(ILoggerFactory logFactory,
3435
IDirectoryInfo sourcePath,
@@ -66,11 +67,29 @@ bool isWatchBuild
6667

6768
public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true)
6869
{
70+
// Content-only changes (e.g. .md edits) don't need a full rebuild:
71+
// RenderLayout -> ParseFullAsync reads fresh content from disk on each request.
72+
if (!reloadConfiguration && _cachedCrossLinks is not null)
73+
return;
74+
6975
SourcePath.Refresh();
7076
OutputPath.Refresh();
7177
if (reloadConfiguration)
78+
{
7279
_context.ReloadConfiguration();
73-
var crossLinks = await _crossLinkFetcher.FetchCrossLinks(ctx);
80+
(_codexReader as IDisposable)?.Dispose();
81+
_codexReader = _context.Configuration.Registry != DocSetRegistry.Public
82+
? new GitLinkIndexReader(_context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData)
83+
: null;
84+
_crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(_logFactory, _context.Configuration, codexLinkIndexReader: _codexReader);
85+
}
86+
var crossLinks = _cachedCrossLinks;
87+
if (crossLinks is null || reloadConfiguration)
88+
{
89+
crossLinks = await _crossLinkFetcher.FetchCrossLinks(ctx);
90+
// Only cache successful fetches so transient failures get retried on the next reload.
91+
_cachedCrossLinks = crossLinks.IsComplete ? crossLinks : null;
92+
}
7493
IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null
7594
? new CodexAwareUriResolver(crossLinks.CodexRepositories)
7695
: null;

0 commit comments

Comments
 (0)