Skip to content

Commit a601280

Browse files
mishig25claude
andauthored
feat: page-level HTML build cache backed by HF storage buckets (#800)
Building the HTML docs of a large library (transformers: ~400 en pages, 14 + 18 min per main build) prerenders every page even when a commit changed one of them. This adds an opt-in page-level cache (--html_page_cache <hf://buckets/...|local dir>) so the SvelteKit build only prerenders pages whose content actually changed: - key = sha256(generated mdx, kit tree hash, package, language, doc-builder version). Keying on the post-autodoc, post-link-resolution mdx means docstring changes invalidate exactly the affected pages. - keys are version-normalized: resolved internal links embed /docs/{pkg}/{version}/{lang}/, which would make otherwise-identical pages differ between main and pr_N builds. On a cross-version reuse, page URLs are rewritten to the target version while asset URLs stay pinned to the source version (its content-hashed assets remain hosted by the additive doc bucket; chunk-to-chunk imports resolve relative to the chunk's own URL). - storage: manifest per (package, language) + content-addressed blobs, over a local dir or an HF storage bucket via huggingface_hub. - cache failures are never fatal (degrade to building the pages). - shared workflows: main builds read+write the hf-doc-build/ doc-build-cache bucket; PR builds are read-only (no secrets, public bucket), so untrusted code cannot poison the cache. Verified on the accelerate e2e (local dir and a real bucket): cold populates 48 pages with output byte-identical to an uncached build; warm reuses 46/48 (the 2 misses are the docstring pages containing nondeterministic python object addresses - correct invalidation); single-file change rebuilds exactly that page, mixed output equivalent to a full fresh build; cross-version (--version pr_5) reuses 35/48 with page links rewritten to pr_5 and all asset refs pinned to main. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 41c17d1 commit a601280

6 files changed

Lines changed: 490 additions & 3 deletions

File tree

.github/workflows/build_main_documentation.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,13 @@ jobs:
211211
shell: bash
212212
env:
213213
NODE_OPTIONS: --max-old-space-size=6656
214+
# needed for --html_page_cache_write (trusted main builds only)
215+
HF_TOKEN: ${{ secrets.hf_token }}
214216
run: |
215217
source .venv/bin/activate
216218
echo "doc_folder has been set to ${{ env.doc_folder }}"
217219
cd doc-builder
218-
args="--build_dir ../build_dir --clean --html ${{ inputs.additional_args }} --repo_owner ${{ inputs.repo_owner }} --repo_name ${{ inputs.package }} --version_tag_suffix=${{ inputs.version_tag_suffix }}"
220+
args="--build_dir ../build_dir --clean --html --html_page_cache hf://buckets/hf-doc-build/doc-build-cache --html_page_cache_write ${{ inputs.additional_args }} --repo_owner ${{ inputs.repo_owner }} --repo_name ${{ inputs.package }} --version_tag_suffix=${{ inputs.version_tag_suffix }}"
219221
220222
if [ ! -z "${{ inputs.notebook_folder }}" ];
221223
then

.github/workflows/build_pr_documentation.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ jobs:
220220
log_file="../build_dir/doc-build.log"
221221
: > "$log_file"
222222
set -o pipefail
223-
args="--build_dir ../build_dir --clean --version pr_${{ inputs.pr_number }} --html ${{ inputs.additional_args }} --repo_owner ${{ inputs.repo_owner }} --repo_name ${{ inputs.package }} --version_tag_suffix=${{ inputs.version_tag_suffix }}"
223+
args="--build_dir ../build_dir --clean --version pr_${{ inputs.pr_number }} --html --html_page_cache hf://buckets/hf-doc-build/doc-build-cache ${{ inputs.additional_args }} --repo_owner ${{ inputs.repo_owner }} --repo_name ${{ inputs.package }} --version_tag_suffix=${{ inputs.version_tag_suffix }}"
224224
225225
if [ -z "${{ inputs.languages }}" ];
226226
then

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ doc-builder build hub ~/git/hub-docs/docs/source --build_dir ~/tmp/test-build --
112112

113113
**Important**: When specifying a semantic version with `--version`, it **must start with the letter "v"** (e.g., `v1.0.0`, `v2.3.1`). For branch names like `main`, `master`, or other default branches, the "v" prefix is not required.
114114

115+
### Page-level HTML build cache
116+
117+
Building the HTML docs of a large library (e.g. `transformers`) prerenders hundreds of pages, even when a commit only changed one of them. `--html_page_cache` enables a page-level cache so that only pages whose generated content changed are prerendered again:
118+
119+
```bash
120+
# cache in a Hugging Face storage bucket (https://huggingface.co/docs/hub/storage-buckets) ...
121+
doc-builder build datasets ~/git/datasets/docs/source --build_dir ~/tmp/test-build --html \
122+
--html_page_cache hf://buckets/hf-doc-build/doc-build-cache
123+
124+
# ... or in a local directory
125+
doc-builder build datasets ~/git/datasets/docs/source --build_dir ~/tmp/test-build --html \
126+
--html_page_cache ~/tmp/doc-page-cache --html_page_cache_write
127+
```
128+
129+
A page is reused when its generated MDX (post autodoc and internal-link resolution — so docstring changes invalidate exactly the affected pages), the `kit` folder, and the doc-builder version are unchanged. Keys are version-normalized: pages that only differ by the built version (e.g. `main` vs `pr_123` in resolved internal links) share a cache entry, and on a cross-version reuse the page URLs are rewritten to the target version while asset URLs stay pinned to the source version, whose content-hashed assets remain hosted.
130+
131+
`--html_page_cache_write` also stores freshly built pages. Only enable it on trusted builds (the shared GitHub workflows enable it on main-branch builds and keep PR builds read-only), so that untrusted code cannot poison the cache. Cache failures are never fatal: any error degrades to building the affected pages.
132+
115133
### Notebook conversion
116134

117135
`doc-builder` can also automatically convert some of the documentation guides or tutorials into notebooks. This requires two steps:

src/doc_builder/build_cache.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
# Copyright 2026 The HuggingFace Team. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Page-level HTML build cache for `doc-builder build --html`.
17+
18+
Building the HTML docs of a large library (e.g. transformers) prerenders hundreds of
19+
pages even when a commit only changed one of them. This module caches each built page
20+
keyed by the content that produced it, so subsequent builds only run the SvelteKit
21+
build for pages whose content actually changed.
22+
23+
Design:
24+
25+
- A page's cache key is `sha256` of its **generated mdx content** (i.e. after autodoc
26+
and internal-link resolution, so docstring changes from library code invalidate
27+
exactly the affected pages), the **kit tree hash** (any component/dependency change
28+
invalidates everything), the doc-builder version and the language.
29+
- The mdx content is *version-normalized* before hashing: resolved internal links and
30+
base paths embed `/docs/{package}/{version}/{language}/`, which would make otherwise
31+
identical pages differ between e.g. `main` and `pr_123` builds. The version segment
32+
is replaced with a placeholder so those pages share a key.
33+
- The cache stores a manifest per `(package, language)` mapping each page to
34+
`{key, blob, source_version}`, plus content-addressed HTML blobs.
35+
- On a same-version hit, the cached HTML is reused byte-identical. On a cross-version
36+
hit, page-URL occurrences of the source base path are rewritten to the current
37+
version, while asset URLs (`.../_app/...`) stay pinned to the source version: the
38+
serving bucket is additive, so the source version's content-hashed assets remain
39+
hosted, and chunk-to-chunk imports resolve relative to the chunk's own URL.
40+
- Storage backends: a local directory (used in tests / local builds) or a Hugging Face
41+
storage bucket (`hf://buckets/...`, see https://huggingface.co/docs/hub/storage-buckets).
42+
- Cache failures are never fatal: any error degrades to building the affected pages.
43+
44+
Security note: cache writes should only be enabled on trusted builds (e.g. main-branch
45+
builds). PR builds should be read-only so that a malicious PR cannot poison pages that
46+
a later trusted build would reuse.
47+
"""
48+
49+
import hashlib
50+
import json
51+
import re
52+
import shutil
53+
import tempfile
54+
import traceback
55+
from dataclasses import dataclass
56+
from pathlib import Path
57+
58+
from . import __version__
59+
60+
VERSION_PLACEHOLDER = "__DOC_BUILDER_VERSION__"
61+
62+
# node_modules etc. are not part of the staged kit (see commands/build.py) and local
63+
# build artifacts must not influence the cache key
64+
KIT_HASH_IGNORE_DIRS = {"node_modules", ".svelte-kit", "build"}
65+
66+
67+
def hash_kit_tree(kit_folder):
68+
"""
69+
Deterministic hash of the kit folder contents (everything that is staged for the
70+
SvelteKit build: sources, preprocessors, configs, lockfile).
71+
"""
72+
kit_folder = Path(kit_folder)
73+
sha = hashlib.sha256()
74+
files = [
75+
f
76+
for f in sorted(kit_folder.rglob("*"))
77+
if f.is_file() and not any(part in KIT_HASH_IGNORE_DIRS for part in f.relative_to(kit_folder).parts)
78+
]
79+
for f in files:
80+
sha.update(f.relative_to(kit_folder).as_posix().encode("utf-8"))
81+
sha.update(b"\0")
82+
sha.update(f.read_bytes())
83+
sha.update(b"\0")
84+
return sha.hexdigest()
85+
86+
87+
def normalize_version(text, package, version, language):
88+
"""
89+
Replaces the versioned doc base path (embedded by internal-link resolution and the
90+
SvelteKit base path) with a placeholder, so content that only differs by the built
91+
version compares equal.
92+
"""
93+
return text.replace(f"/docs/{package}/{version}/{language}/", f"/docs/{package}/{VERSION_PLACEHOLDER}/{language}/")
94+
95+
96+
def page_cache_key(mdx_content, kit_hash, package, version, language):
97+
"""
98+
Cache key for one page. `mdx_content` is the final generated mdx (post autodoc and
99+
link resolution).
100+
"""
101+
normalized = normalize_version(mdx_content, package, version, language)
102+
sha = hashlib.sha256()
103+
for part in (normalized, kit_hash, package, language, __version__):
104+
sha.update(part.encode("utf-8"))
105+
sha.update(b"\0")
106+
return sha.hexdigest()
107+
108+
109+
def rewrite_cached_html(html, package, source_version, target_version, language):
110+
"""
111+
Rewrites a cached page built for `source_version` so it can be served under
112+
`target_version`: page URLs (resolved internal links, SvelteKit base config,
113+
metadata) move to the target version, while asset URLs (`.../_app/...`) keep
114+
pointing at the source version, whose content-hashed assets remain hosted.
115+
"""
116+
if source_version == target_version:
117+
return html
118+
source_base = f"/docs/{package}/{source_version}/{language}/"
119+
target_base = f"/docs/{package}/{target_version}/{language}/"
120+
# negative lookahead: leave asset URLs on the source version
121+
return re.sub(re.escape(source_base) + r"(?!_app/)", target_base.replace("\\", r"\\\\"), html)
122+
123+
124+
@dataclass
125+
class FetchedPage:
126+
page: str # rel mdx posix path, e.g. "quicktour.mdx"
127+
html_path: Path # local path of the fetched (and possibly rewritten) html
128+
source_version: str
129+
130+
131+
class PageCache:
132+
"""
133+
Page-level HTML cache over a local directory or an HF storage bucket.
134+
135+
Layout under the cache root:
136+
<package>/<language>/manifest.json
137+
blobs/<key[:2]>/<key>.html
138+
"""
139+
140+
def __init__(self, cache_url, package, version, language, write=False):
141+
self.package = package
142+
self.version = version
143+
self.language = language
144+
self.write = write
145+
self.is_bucket = cache_url.startswith("hf://")
146+
self.cache_url = cache_url.rstrip("/")
147+
if not self.is_bucket:
148+
Path(self.cache_url).mkdir(parents=True, exist_ok=True)
149+
self._workdir = Path(tempfile.mkdtemp(prefix="doc_builder_page_cache_"))
150+
151+
# ------------------------------------------------------------------ paths
152+
@property
153+
def _manifest_rel(self):
154+
return f"{self.package}/{self.language}/manifest.json"
155+
156+
def _blob_rel(self, key):
157+
return f"blobs/{key[:2]}/{key}.html"
158+
159+
# ---------------------------------------------------------------- backend
160+
def _bucket_id_and_prefix(self):
161+
# hf://buckets/<owner>/<bucket>[/<prefix>]
162+
parts = self.cache_url.removeprefix("hf://buckets/").split("/")
163+
bucket_id = "/".join(parts[:2])
164+
prefix = "/".join(parts[2:])
165+
return bucket_id, prefix
166+
167+
def _remote_path(self, rel):
168+
_, prefix = self._bucket_id_and_prefix()
169+
return f"{prefix}/{rel}" if prefix else rel
170+
171+
def _download(self, rel_paths, dest_paths):
172+
"""Downloads `rel_paths` to `dest_paths`. Returns the successfully fetched indices."""
173+
if not rel_paths:
174+
return []
175+
if not self.is_bucket:
176+
fetched = []
177+
for i, (rel, dest) in enumerate(zip(rel_paths, dest_paths, strict=True)):
178+
src = Path(self.cache_url) / rel
179+
if src.is_file():
180+
Path(dest).parent.mkdir(parents=True, exist_ok=True)
181+
shutil.copyfile(src, dest)
182+
fetched.append(i)
183+
return fetched
184+
from huggingface_hub import download_bucket_files
185+
186+
bucket_id, _ = self._bucket_id_and_prefix()
187+
for dest in dest_paths:
188+
Path(dest).parent.mkdir(parents=True, exist_ok=True)
189+
try:
190+
download_bucket_files(
191+
bucket_id,
192+
files=[(self._remote_path(rel), str(dest)) for rel, dest in zip(rel_paths, dest_paths, strict=True)],
193+
)
194+
except Exception:
195+
# transient error: fall back to per-file downloads
196+
for rel, dest in zip(rel_paths, dest_paths, strict=True):
197+
try:
198+
download_bucket_files(bucket_id, files=[(self._remote_path(rel), str(dest))])
199+
except Exception:
200+
pass
201+
# `download_bucket_files` skips files missing on the remote instead of raising,
202+
# so report success based on what actually landed on disk
203+
return [i for i, dest in enumerate(dest_paths) if Path(dest).is_file()]
204+
205+
def _upload(self, items):
206+
"""Uploads `(local_path, rel_path)` pairs."""
207+
if not items:
208+
return
209+
if not self.is_bucket:
210+
for local, rel in items:
211+
dest = Path(self.cache_url) / rel
212+
dest.parent.mkdir(parents=True, exist_ok=True)
213+
shutil.copyfile(local, dest)
214+
return
215+
from huggingface_hub import batch_bucket_files
216+
217+
bucket_id, _ = self._bucket_id_and_prefix()
218+
batch_bucket_files(bucket_id, add=[(str(local), self._remote_path(rel)) for local, rel in items])
219+
220+
# ----------------------------------------------------------------- public
221+
def load_manifest(self):
222+
"""
223+
Returns the manifest for this package/language: `{page: {key, blob, source_version}}`.
224+
Empty on any failure (treated as a cold cache).
225+
"""
226+
try:
227+
dest = self._workdir / "manifest.json"
228+
fetched = self._download([self._manifest_rel], [dest])
229+
if not fetched:
230+
return {}
231+
return json.loads(dest.read_text(encoding="utf-8"))
232+
except Exception:
233+
print("[page-cache] could not load the cache manifest, treating the cache as cold")
234+
traceback.print_exc()
235+
return {}
236+
237+
def fetch_pages(self, wanted, manifest):
238+
"""
239+
`wanted` is `{page: key}` for the current build. Fetches every page whose key
240+
matches the manifest and returns `{page: FetchedPage}` for the successful ones.
241+
Cross-version entries are rewritten for the current version.
242+
"""
243+
hits = {
244+
page: manifest[page]
245+
for page, key in wanted.items()
246+
if page in manifest and manifest[page].get("key") == key
247+
}
248+
if not hits:
249+
return {}
250+
try:
251+
pages = list(hits.keys())
252+
rel_paths = [self._blob_rel(hits[page]["blob"]) for page in pages]
253+
dest_paths = [self._workdir / "fetched" / page for page in pages]
254+
fetched_indices = self._download(rel_paths, dest_paths)
255+
fetched = {}
256+
for i in fetched_indices:
257+
page = pages[i]
258+
entry = hits[page]
259+
source_version = entry.get("source_version", self.version)
260+
if source_version != self.version:
261+
html = dest_paths[i].read_text(encoding="utf-8")
262+
html = rewrite_cached_html(html, self.package, source_version, self.version, self.language)
263+
dest_paths[i].write_text(html, encoding="utf-8")
264+
fetched[page] = FetchedPage(page=page, html_path=dest_paths[i], source_version=source_version)
265+
return fetched
266+
except Exception:
267+
print("[page-cache] fetching cached pages failed, building all pages")
268+
traceback.print_exc()
269+
return {}
270+
271+
def store_pages(self, built, manifest, reused):
272+
"""
273+
Uploads freshly built pages and the updated manifest (only when `write=True`).
274+
275+
- `built` is `{page: (key, html_path)}` for pages built in this run.
276+
- `manifest` is the previously loaded manifest.
277+
- `reused` is `{page: FetchedPage}` for cache hits merged into this build.
278+
"""
279+
if not self.write:
280+
return
281+
try:
282+
uploads = []
283+
new_manifest = {}
284+
for page in reused:
285+
new_manifest[page] = dict(manifest[page])
286+
for page, (key, html_path) in built.items():
287+
blob = key
288+
uploads.append((html_path, self._blob_rel(blob)))
289+
new_manifest[page] = {"key": key, "blob": blob, "source_version": self.version}
290+
manifest_path = self._workdir / "manifest_out.json"
291+
manifest_path.write_text(json.dumps(new_manifest, indent=1, sort_keys=True), encoding="utf-8")
292+
uploads.append((manifest_path, self._manifest_rel))
293+
self._upload(uploads)
294+
print(f"[page-cache] stored {len(built)} built page(s) and the updated manifest")
295+
except Exception:
296+
print("[page-cache] storing built pages failed (the build itself is unaffected)")
297+
traceback.print_exc()

0 commit comments

Comments
 (0)