Theme: three partials scan every page on every page, making the build quadratic (gbv-aktuell: 2,573 pages) #360

Closed
opened 2026-11-01 01:05:45 +01:00 by dominik.polakovics · 1 comment

This was generated by AI during triage.

Summary

Three partials scan every page of the site on every page, making a build
quadratic in the page count (2,565 pages: 19 s on one core on the lab box,
28.6 s on the reporter's, about 2.5 min on a hosted vCPU). Compute each
site-wide selection once per build through a cached partial, skip the
children scan for sections whose dropdown cannot show, bump the news bundle,
document the pattern, have harness check warn on the pattern, and add a
guard test so it cannot come back.

Current behavior

  • partials/header.html computes every top-level section's children on
    every page before checking hideChildren; for a news section that is a
    where over every item, per page.
  • partials/footer.html runs where hugo.Sites.Default.Pages.ByWeight "Params.footer" true on every page.
  • The news bundle's partials/pagetypes/news.html re-filters and re-sorts
    every item of the section on each pager page.

Reproduced during triage on a synthetic site (N news items under one news
section, 12 categories, 13 top-level pages each placing a news-list, the
stock theme plus the news and categories bundles assembled the way
internal/theme's buildSiteWithTheme does, Hugo 0.167.0 extended,
GOMAXPROCS=1):

Pages Stock With the three patches
631 2.5 s
1231 6.0 s 3.3 s
2565 19.1 s 6.7 s

Stock is superlinear (2.1x pages, 3.2x time); patched is near linear (2.2x).
hugo --templateMetrics --templateMetricsHints lists header.html,
footer.html, term.html, pagetypes/news.html in that order. Hugo printed
no WARN or ERROR on the patched build, and with the whitespace shape of
header.html kept the output was byte-identical (diff -r). Saves in the
preview pay the same cost (see Original report; not re-measured).

Desired behavior

  • The header computes a top-level entry's children only when the dropdown
    can show: the entry is a section and its source page does not set
    hideChildren: true. Menu output is unchanged for every page.
  • The footer's page selection is computed once per language per build,
    inside a cached partial that holds the scan itself; footer.html reaches
    it through partialCached keyed by the language.
  • The news section's item collection (news-item type, newest first, mapped
    to the current language, drafts out) is computed once per section per
    language per build inside a cached partial; pagetypes/news.html
    paginates what it returns.
  • A production build of a site before and after the change is
    byte-identical (diff -r), whitespace included: keep the whitespace shape
    of header.html rather than adding {{- trims (the diff in the Original
    report adds trims and drops one blank line per nav entry on every page).
  • The news bundle's version becomes 8. harness add news on an existing
    site fills the new partial in; it never overwrites the existing
    news.html and harness migrate rewrites sidecars only, so
    docs/features.md says what to apply by hand, as its version 5 note does.
  • scaffold/site/hugo.toml sets [pagination] pagerSize = 20 with a
    comment saying why (it halves the pager pages every save re-renders). The
    two documentation lines that say the pager size is "Hugo's default 10"
    (scaffold/site/AGENTS.md, docs/features.md) say what the scaffold sets.
  • harness check warns on a template that reads a site-wide page
    collection (hugo.Sites.Default.Pages, site.Pages, .Site.Pages,
    their RegularPages forms, and hugo.Sites.Default.RegularPages) unless
    the template is a partial that every caller reaches through
    partialCached. The warning names the template and says to move the scan
    into a partial reached through partialCached. A section's own .Pages
    ($src.Pages) is not warned on: its size cannot be judged statically. A
    fresh site, the scaffold theme and the shipped bundles produce no such
    warning.
  • A test in internal/theme builds a generated site of N and 2N pages in
    this shape with real hugo on one core and fails when the build-time
    ratio shows the quadratic pattern. It skips without hugo on PATH and
    under -short, like its siblings.
  • The theme README states the pattern once; the theme contract, the skills
    and the feature docs point at it.

Decisions

  • Cache the partial that holds the scan, never the argument:
    partialCached "l10n/pages.html" (where …) evaluates the where before
    the cache lookup, so it saves nothing.
  • Key the footer by site.Language.Lang and the news items by the source
    section and the language: each selection is identical for every page of
    a language (and section), and the mapped, draft-filtered result is per
    language.
  • Follow the theme's existing convention (backendlayout/default.html,
    l10n/t-en.html: "constant per site, callers reach it through
    partialCached") so no second idiom appears.
  • Byte-identical production output is an acceptance criterion, so the
    change is provably a pure optimisation.
  • term.html and elements/news-list.html are not changed: caching the
    term scan measured no gain during triage (its cost is drawing the cards,
    7.39 s vs 7.48 s), and the element runs once per placement.
  • One issue: the harness check warning ships with the fix rather than as
    a follow-up. Its rule, "which templates read a site-wide collection, and
    is each reached only through partialCached", is owned by
    internal/tplrefs (it already owns the partial-call rules harness check
    uses and already recognises partialCached and
    partials.IncludeCached as call forms); checkcmd calls it and the theme
    test suite calls the same function rather than grepping on its own.
  • No layouts-update mechanism for bundles (docs/features.md "Not covered
    yet"); the hand-apply note is the upgrade path.
  • Forked themes are not updated by deckle; redo-theme and migrate-site
    name the pattern a fork must follow, and harness check reports it.
  • The guard is timing-based because it tests the property itself. Use a
    large site, on the order of the reporter's (at least 2,000 items at N),
    accepting the test's running time; calibrate the threshold from the
    measurement above (stock 3.2x, patched 2.2x at 1231→2565 pages) with
    margin on both sides, and prove it fails against the stock partials
    before pinning it.

Key interfaces

  • scaffold/themes/deckle/layouts/partials/header.html, footer.html, a
    new footer-pages.html that returns a page collection.
  • scaffold/features/news/layouts/partials/pagetypes/news.html, a new
    news-items.html whose context is the source news section;
    scaffold/features/news/feature.yaml version.
  • scaffold/site/hugo.toml.
  • internal/tplrefs (Calls, Templates, Provided): a rule that
    classifies each template's site-wide collection reads and whether its
    callers all use partialCached; internal/checkcmd (partialCalls is
    the neighbouring check, AreaTemplates the area).
  • internal/theme: buildSiteWithTheme shows how the tests assemble a
    site with the theme and both bundles; the generator for the N-page site
    is test code beside it.
  • Docs, each fact in its owner: scaffold/themes/deckle/README.md
    "Navigation" (the pattern) and "Layout files" (the new partial);
    docs/harness.md "Check" (the new warning, in the warnings list and the
    Partials paragraph); docs/features.md "The news bundle" (version,
    file count 19→20, upgrade note) and its pager-size line;
    scaffold/site/AGENTS.md's pager-size line;
    scaffold/site/.claude/skills/redo-theme/references/theme-contract.md
    (a new numbered item under "Content model the theme reads");
    redo-theme step 4 (navigation and footer) and its harness check
    paragraph, migrate-site step 7 (porting the old theme),
    create-feature's section-page pattern, verify-site where it reads
    templates warnings.

Acceptance criteria

  • header.html evaluates a section's children only when .IsSection
    and the source page's hideChildren is not true.
  • footer.html and pagetypes/news.html reach their selections through
    partialCached partials that contain the scan; the template metrics of
    a build show each cached partial executed once per key.
  • A production build of the synthetic site is byte-identical before and
    after, and Hugo prints no WARN or ERROR.
  • The guard test fails against the stock partials and passes against
    the fixed ones with margin; it skips without hugo and under -short.
  • News bundle version: 8; docs/features.md lists the new partial, the
    new file count and the hand-apply step for an older install.
  • scaffold/site/hugo.toml sets pagerSize = 20; the two docs lines that
    said "default 10" now say what the scaffold sets.
  • harness check warns on a site layout or partial that reads a
    site-wide collection outside a partialCached-only partial (a test
    with a fixture template), and reports nothing on a fresh site with the
    news and categories bundles installed; docs/harness.md documents the
    warning and go test ./cmd/harness/ ./docs/ pass.
  • The theme README documents the pattern; the theme contract, the skills
    and create-feature point at it; go test ./scaffold/ ./docs/ ./internal/element/ pass.

How to verify

  • go test ./... with hugo on PATH (the internal/theme integration
    tests, the skills and docs drift tests, scaffold/canonical_test.go, the
    cmd/harness usage test).
  • The new guard test, run once against the stock partials to see it fail.
  • diff -r of two production builds of the synthetic site.
  • hugo --templateMetrics --templateMetricsHints on the synthetic site:
    footer-pages.html and news-items.html show cached ≈ total − keys.
  • harness check --site <fresh site with news> prints no new warning; the
    same site with the stock footer.html copied into layouts/partials/
    prints one.

Assumptions to check

  • The per-save numbers (fast render re-rendering every pager page) are the
    reporter's; the fix was measured on full builds only.
  • Hugo clears partialCached on every server rebuild, so a new item
    appears in the list on the next save.
  • Changing the scaffold's hugo.toml may touch initcmd fixtures or
    snapshot tests.
  • tplrefs.Calls scans for literal partial names only; the new rule needs
    the call form (cached or not) per call, which the scanner does not
    return today.

Out of scope

  • term.html and elements/news-list.html.
  • Updating forked themes on existing sites (gbv-aktuell) or any
    layouts-update mechanism for bundles.
  • The editor's poll cost (ADR 0030, #358).
  • Warning on a section's own .Pages scans.

Blocked by

None - can start immediately

Original report

What happens

On a site of 2,562 pages (2,534 news items, 12 categories, 13 top-level pages with news lists, stock theme, Hugo 0.167.0) the full build takes 28.6 s on one core and is superlinear: 628 pages 2.6 s, 1,228 pages 6.3 s, 2,562 pages 28.6 s. In the hosted instance of gbv-aktuell (1 throttled vCPU, about 3.5x slower than the lab box) this is the 2.5 minute "Hugo wird gestartet…" after every cold start.

hugo --templateMetrics --templateMetricsHints names the cost:

Partial Cumulative Cache potential Why
header.html 12.4 s of 28.6 s 98 % computes $children of every top-level section on every page BEFORE checking hideChildren; for the news section that is a where over 2,534 pages per page
footer.html 6.1 s 100 % where hugo.Sites.Default.Pages.ByWeight "Params.footer" true on every page
pagetypes/news.html (news bundle) 2.0 s 45 % re-filters and re-sorts all items on each of the 254 pager pages

Saves suffer from the same partials: Hugo's fast render treats a paginator as one visited unit, so once the editor has looked at /news/ in the preview, every save re-renders all 254 pager pages plus the home and the pages that carry a news-list. That is 5.8 s per save on one fast core, about 20 s on the hosted vCPU, during which Hugo starves the harness on the shared core. Editing the home page puts Hugo in the same state for the rest of the session.

Fix

Three changes, measured on the same site, build output byte-identical (diff -r of 3,141 files):

Variant Full build, 1 core Server first build Per save after visiting /news/
stock 28.6 s 40.3 s 5.8 s
header 18.9 s
header + footer 12.9 s 22.8 s 4.0 s
header + footer + news 9.1 s 21.9 s 3.1 s
… and pagination.pagerSize = 20 20.7 s 2.4 s
--- a/themes/deckle/layouts/partials/header.html
+++ b/themes/deckle/layouts/partials/header.html
@@ -65,8 +65,13 @@
         {{ $src := partial "l10n/source.html" . }}
-        {{ $children := partial "l10n/pages.html" (where $src.Pages.ByWeight "Params.hidden" "ne" true) }}
-        {{ if and .IsSection $children (ne $src.Params.hideChildren true) }}
+        {{- /* The children are computed only for a section whose dropdown can
+             show: a feed section with hideChildren (news) has thousands, and
+             scanning them on every page made the header the costliest partial
+             of a large site. */ -}}
+        {{- $children := slice -}}
+        {{- if and .IsSection (ne $src.Params.hideChildren true) -}}{{- $children = partial "l10n/pages.html" (where $src.Pages.ByWeight "Params.hidden" "ne" true) -}}{{- end }}
+        {{ if $children }}
--- a/themes/deckle/layouts/partials/footer.html
+++ b/themes/deckle/layouts/partials/footer.html
@@ -21,7 +21,7 @@
-    {{ $footerPages := partial "l10n/pages.html" (where hugo.Sites.Default.Pages.ByWeight "Params.footer" true) }}
+    {{ $footerPages := partialCached "footer-pages.html" . site.Language.Lang }}
--- /dev/null
+++ b/themes/deckle/layouts/partials/footer-pages.html
+{{- /* footer-pages: the pages that opt into the footer nav (front matter
+     `footer: true`), in weight order, mapped to the current language. The
+     selection is the same for every page of a language, so footer.html
+     reaches it through partialCached keyed by the language: the scan of
+     every site page runs once per language per build, not once per page.
+     Context: unused (partialCached needs one). Returns: a page collection. */ -}}
+{{- return partial "l10n/pages.html" (where hugo.Sites.Default.Pages.ByWeight "Params.footer" true) -}}
--- a/scaffold/features/news/layouts/partials/pagetypes/news.html
+++ b/scaffold/features/news/layouts/partials/pagetypes/news.html
@@ -35 +35 @@
-  {{- $items := where (partial "l10n/pages.html" (where $src.Pages "Type" "news-item").ByDate.Reverse) "Draft" false }}
+  {{- $items := partialCached "news-items.html" $src $src.RelPermalink site.Language.Lang }}
--- /dev/null
+++ b/scaffold/features/news/layouts/partials/news-items.html
+{{- /* news-items: a news section's items, newest first, drafts out, in the
+     current language — the collection pagetypes/news.html paginates. It is
+     the same for every pager page of the section, so news.html reaches it
+     through partialCached keyed by the section and the language: the scan
+     and sort of every item runs once per section per build, not once per
+     pager page. Context: the SOURCE news section page. */ -}}
+{{- return where (partial "l10n/pages.html" (where .Pages "Type" "news-item").ByDate.Reverse) "Draft" false -}}

Note the footer: partialCached "l10n/pages.html" (where …) does NOT help, because the where argument is evaluated before the cached call. The scan has to live inside the cached partial. The same applies to term.html in the categories bundle (337 term pages, 3.8 ms each) and to elements/news-list.html, which are smaller and can follow the same shape.

Also in scope

  • The news bundle's version bumps (new partial news-items.html); harness migrate and harness add carry it to existing sites.
  • themes/deckle/README.md lists the partials; add footer-pages.html and news-items.html.
  • A guard: a theme test (internal/theme) that builds a generated site of N and 2N pages and fails when the ratio of build times exceeds, say, 2.5, so the quadratic pattern cannot come back. The generator used for the measurement is a 150-line Go program; it can live in internal/theme/testdata or the harness.
  • Sites that forked the theme before this change (gbv-aktuell has its own, TYPO3-lookalike markup) carry the old partials. The redo-theme and migrate-site skills should name the three patterns as things a fork must not contain, and harness check could warn on where hugo.Sites.Default.Pages or where $src.Pages outside a partialCached partial.
  • Recommend pagination.pagerSize = 20 in the scaffold's hugo.toml for news sections; it halves the pages every save re-renders.

Evidence

Hosted: deckle-hosted #163 and #164 (the instance caps and the cold-start experience); ADR 0030 (issue #358) fixed the poll pile-up, this is what was underneath. Measurements on a synthetic site generated to gbv-aktuell's shape; metrics tables and the generator are available on request.

> *This was generated by AI during triage.* ## Summary Three partials scan every page of the site on every page, making a build quadratic in the page count (2,565 pages: 19 s on one core on the lab box, 28.6 s on the reporter's, about 2.5 min on a hosted vCPU). Compute each site-wide selection once per build through a cached partial, skip the children scan for sections whose dropdown cannot show, bump the news bundle, document the pattern, have `harness check` warn on the pattern, and add a guard test so it cannot come back. ## Current behavior - `partials/header.html` computes every top-level section's children on every page before checking `hideChildren`; for a news section that is a `where` over every item, per page. - `partials/footer.html` runs `where hugo.Sites.Default.Pages.ByWeight "Params.footer" true` on every page. - The news bundle's `partials/pagetypes/news.html` re-filters and re-sorts every item of the section on each pager page. Reproduced during triage on a synthetic site (N news items under one `news` section, 12 categories, 13 top-level pages each placing a `news-list`, the stock theme plus the news and categories bundles assembled the way `internal/theme`'s `buildSiteWithTheme` does, Hugo 0.167.0 extended, `GOMAXPROCS=1`): | Pages | Stock | With the three patches | | --- | --- | --- | | 631 | 2.5 s | | | 1231 | 6.0 s | 3.3 s | | 2565 | 19.1 s | 6.7 s | Stock is superlinear (2.1x pages, 3.2x time); patched is near linear (2.2x). `hugo --templateMetrics --templateMetricsHints` lists `header.html`, `footer.html`, `term.html`, `pagetypes/news.html` in that order. Hugo printed no WARN or ERROR on the patched build, and with the whitespace shape of `header.html` kept the output was byte-identical (`diff -r`). Saves in the preview pay the same cost (see Original report; not re-measured). ## Desired behavior - The header computes a top-level entry's children only when the dropdown can show: the entry is a section and its source page does not set `hideChildren: true`. Menu output is unchanged for every page. - The footer's page selection is computed once per language per build, inside a cached partial that holds the scan itself; `footer.html` reaches it through `partialCached` keyed by the language. - The news section's item collection (news-item type, newest first, mapped to the current language, drafts out) is computed once per section per language per build inside a cached partial; `pagetypes/news.html` paginates what it returns. - A production build of a site before and after the change is byte-identical (`diff -r`), whitespace included: keep the whitespace shape of `header.html` rather than adding `{{-` trims (the diff in the Original report adds trims and drops one blank line per nav entry on every page). - The news bundle's `version` becomes 8. `harness add news` on an existing site fills the new partial in; it never overwrites the existing `news.html` and `harness migrate` rewrites sidecars only, so `docs/features.md` says what to apply by hand, as its version 5 note does. - `scaffold/site/hugo.toml` sets `[pagination] pagerSize = 20` with a comment saying why (it halves the pager pages every save re-renders). The two documentation lines that say the pager size is "Hugo's default 10" (`scaffold/site/AGENTS.md`, `docs/features.md`) say what the scaffold sets. - `harness check` warns on a template that reads a site-wide page collection (`hugo.Sites.Default.Pages`, `site.Pages`, `.Site.Pages`, their `RegularPages` forms, and `hugo.Sites.Default.RegularPages`) unless the template is a partial that every caller reaches through `partialCached`. The warning names the template and says to move the scan into a partial reached through `partialCached`. A section's own `.Pages` (`$src.Pages`) is not warned on: its size cannot be judged statically. A fresh site, the scaffold theme and the shipped bundles produce no such warning. - A test in `internal/theme` builds a generated site of N and 2N pages in this shape with real `hugo` on one core and fails when the build-time ratio shows the quadratic pattern. It skips without `hugo` on PATH and under `-short`, like its siblings. - The theme README states the pattern once; the theme contract, the skills and the feature docs point at it. ## Decisions - Cache the partial that holds the scan, never the argument: `partialCached "l10n/pages.html" (where …)` evaluates the `where` before the cache lookup, so it saves nothing. - Key the footer by `site.Language.Lang` and the news items by the source section and the language: each selection is identical for every page of a language (and section), and the mapped, draft-filtered result is per language. - Follow the theme's existing convention (`backendlayout/default.html`, `l10n/t-en.html`: "constant per site, callers reach it through partialCached") so no second idiom appears. - Byte-identical production output is an acceptance criterion, so the change is provably a pure optimisation. - `term.html` and `elements/news-list.html` are not changed: caching the term scan measured no gain during triage (its cost is drawing the cards, 7.39 s vs 7.48 s), and the element runs once per placement. - One issue: the `harness check` warning ships with the fix rather than as a follow-up. Its rule, "which templates read a site-wide collection, and is each reached only through `partialCached`", is owned by `internal/tplrefs` (it already owns the partial-call rules `harness check` uses and already recognises `partialCached` and `partials.IncludeCached` as call forms); `checkcmd` calls it and the theme test suite calls the same function rather than grepping on its own. - No layouts-update mechanism for bundles (`docs/features.md` "Not covered yet"); the hand-apply note is the upgrade path. - Forked themes are not updated by deckle; `redo-theme` and `migrate-site` name the pattern a fork must follow, and `harness check` reports it. - The guard is timing-based because it tests the property itself. Use a large site, on the order of the reporter's (at least 2,000 items at N), accepting the test's running time; calibrate the threshold from the measurement above (stock 3.2x, patched 2.2x at 1231→2565 pages) with margin on both sides, and prove it fails against the stock partials before pinning it. ## Key interfaces - `scaffold/themes/deckle/layouts/partials/header.html`, `footer.html`, a new `footer-pages.html` that returns a page collection. - `scaffold/features/news/layouts/partials/pagetypes/news.html`, a new `news-items.html` whose context is the source news section; `scaffold/features/news/feature.yaml` `version`. - `scaffold/site/hugo.toml`. - `internal/tplrefs` (`Calls`, `Templates`, `Provided`): a rule that classifies each template's site-wide collection reads and whether its callers all use `partialCached`; `internal/checkcmd` (`partialCalls` is the neighbouring check, `AreaTemplates` the area). - `internal/theme`: `buildSiteWithTheme` shows how the tests assemble a site with the theme and both bundles; the generator for the N-page site is test code beside it. - Docs, each fact in its owner: `scaffold/themes/deckle/README.md` "Navigation" (the pattern) and "Layout files" (the new partial); `docs/harness.md` "Check" (the new warning, in the warnings list and the **Partials** paragraph); `docs/features.md` "The news bundle" (version, file count 19→20, upgrade note) and its pager-size line; `scaffold/site/AGENTS.md`'s pager-size line; `scaffold/site/.claude/skills/redo-theme/references/theme-contract.md` (a new numbered item under "Content model the theme reads"); `redo-theme` step 4 (navigation and footer) and its `harness check` paragraph, `migrate-site` step 7 (porting the old theme), `create-feature`'s section-page pattern, `verify-site` where it reads `templates` warnings. ## Acceptance criteria - [ ] `header.html` evaluates a section's children only when `.IsSection` and the source page's `hideChildren` is not true. - [ ] `footer.html` and `pagetypes/news.html` reach their selections through `partialCached` partials that contain the scan; the template metrics of a build show each cached partial executed once per key. - [ ] A production build of the synthetic site is byte-identical before and after, and Hugo prints no WARN or ERROR. - [ ] The guard test fails against the stock partials and passes against the fixed ones with margin; it skips without `hugo` and under `-short`. - [ ] News bundle `version: 8`; `docs/features.md` lists the new partial, the new file count and the hand-apply step for an older install. - [ ] `scaffold/site/hugo.toml` sets `pagerSize = 20`; the two docs lines that said "default 10" now say what the scaffold sets. - [ ] `harness check` warns on a site layout or partial that reads a site-wide collection outside a `partialCached`-only partial (a test with a fixture template), and reports nothing on a fresh site with the news and categories bundles installed; `docs/harness.md` documents the warning and `go test ./cmd/harness/ ./docs/` pass. - [ ] The theme README documents the pattern; the theme contract, the skills and `create-feature` point at it; `go test ./scaffold/ ./docs/ ./internal/element/` pass. ## How to verify - `go test ./...` with `hugo` on PATH (the `internal/theme` integration tests, the skills and docs drift tests, `scaffold/canonical_test.go`, the `cmd/harness` usage test). - The new guard test, run once against the stock partials to see it fail. - `diff -r` of two production builds of the synthetic site. - `hugo --templateMetrics --templateMetricsHints` on the synthetic site: `footer-pages.html` and `news-items.html` show `cached` ≈ `total` − keys. - `harness check --site <fresh site with news>` prints no new warning; the same site with the stock `footer.html` copied into `layouts/partials/` prints one. ## Assumptions to check - The per-save numbers (fast render re-rendering every pager page) are the reporter's; the fix was measured on full builds only. - Hugo clears `partialCached` on every server rebuild, so a new item appears in the list on the next save. - Changing the scaffold's `hugo.toml` may touch `initcmd` fixtures or snapshot tests. - `tplrefs.Calls` scans for literal partial names only; the new rule needs the call form (cached or not) per call, which the scanner does not return today. ## Out of scope - `term.html` and `elements/news-list.html`. - Updating forked themes on existing sites (gbv-aktuell) or any layouts-update mechanism for bundles. - The editor's poll cost (ADR 0030, #358). - Warning on a section's own `.Pages` scans. ## Blocked by None - can start immediately ## Original report ## What happens On a site of 2,562 pages (2,534 news items, 12 categories, 13 top-level pages with news lists, stock theme, Hugo 0.167.0) the full build takes 28.6 s on one core and is superlinear: 628 pages 2.6 s, 1,228 pages 6.3 s, 2,562 pages 28.6 s. In the hosted instance of gbv-aktuell (1 throttled vCPU, about 3.5x slower than the lab box) this is the 2.5 minute "Hugo wird gestartet…" after every cold start. `hugo --templateMetrics --templateMetricsHints` names the cost: | Partial | Cumulative | Cache potential | Why | | --- | --- | --- | --- | | `header.html` | 12.4 s of 28.6 s | 98 % | computes `$children` of every top-level section on every page BEFORE checking `hideChildren`; for the news section that is a `where` over 2,534 pages per page | | `footer.html` | 6.1 s | 100 % | `where hugo.Sites.Default.Pages.ByWeight "Params.footer" true` on every page | | `pagetypes/news.html` (news bundle) | 2.0 s | 45 % | re-filters and re-sorts all items on each of the 254 pager pages | Saves suffer from the same partials: Hugo's fast render treats a paginator as one visited unit, so once the editor has looked at `/news/` in the preview, every save re-renders all 254 pager pages plus the home and the pages that carry a `news-list`. That is 5.8 s per save on one fast core, about 20 s on the hosted vCPU, during which Hugo starves the harness on the shared core. Editing the home page puts Hugo in the same state for the rest of the session. ## Fix Three changes, measured on the same site, build output byte-identical (`diff -r` of 3,141 files): | Variant | Full build, 1 core | Server first build | Per save after visiting /news/ | | --- | --- | --- | --- | | stock | 28.6 s | 40.3 s | 5.8 s | | header | 18.9 s | | | | header + footer | 12.9 s | 22.8 s | 4.0 s | | header + footer + news | 9.1 s | 21.9 s | 3.1 s | | … and `pagination.pagerSize = 20` | | 20.7 s | 2.4 s | ```diff --- a/themes/deckle/layouts/partials/header.html +++ b/themes/deckle/layouts/partials/header.html @@ -65,8 +65,13 @@ {{ $src := partial "l10n/source.html" . }} - {{ $children := partial "l10n/pages.html" (where $src.Pages.ByWeight "Params.hidden" "ne" true) }} - {{ if and .IsSection $children (ne $src.Params.hideChildren true) }} + {{- /* The children are computed only for a section whose dropdown can + show: a feed section with hideChildren (news) has thousands, and + scanning them on every page made the header the costliest partial + of a large site. */ -}} + {{- $children := slice -}} + {{- if and .IsSection (ne $src.Params.hideChildren true) -}}{{- $children = partial "l10n/pages.html" (where $src.Pages.ByWeight "Params.hidden" "ne" true) -}}{{- end }} + {{ if $children }} --- a/themes/deckle/layouts/partials/footer.html +++ b/themes/deckle/layouts/partials/footer.html @@ -21,7 +21,7 @@ - {{ $footerPages := partial "l10n/pages.html" (where hugo.Sites.Default.Pages.ByWeight "Params.footer" true) }} + {{ $footerPages := partialCached "footer-pages.html" . site.Language.Lang }} --- /dev/null +++ b/themes/deckle/layouts/partials/footer-pages.html +{{- /* footer-pages: the pages that opt into the footer nav (front matter + `footer: true`), in weight order, mapped to the current language. The + selection is the same for every page of a language, so footer.html + reaches it through partialCached keyed by the language: the scan of + every site page runs once per language per build, not once per page. + Context: unused (partialCached needs one). Returns: a page collection. */ -}} +{{- return partial "l10n/pages.html" (where hugo.Sites.Default.Pages.ByWeight "Params.footer" true) -}} --- a/scaffold/features/news/layouts/partials/pagetypes/news.html +++ b/scaffold/features/news/layouts/partials/pagetypes/news.html @@ -35 +35 @@ - {{- $items := where (partial "l10n/pages.html" (where $src.Pages "Type" "news-item").ByDate.Reverse) "Draft" false }} + {{- $items := partialCached "news-items.html" $src $src.RelPermalink site.Language.Lang }} --- /dev/null +++ b/scaffold/features/news/layouts/partials/news-items.html +{{- /* news-items: a news section's items, newest first, drafts out, in the + current language — the collection pagetypes/news.html paginates. It is + the same for every pager page of the section, so news.html reaches it + through partialCached keyed by the section and the language: the scan + and sort of every item runs once per section per build, not once per + pager page. Context: the SOURCE news section page. */ -}} +{{- return where (partial "l10n/pages.html" (where .Pages "Type" "news-item").ByDate.Reverse) "Draft" false -}} ``` Note the footer: `partialCached "l10n/pages.html" (where …)` does NOT help, because the `where` argument is evaluated before the cached call. The scan has to live inside the cached partial. The same applies to `term.html` in the categories bundle (337 term pages, 3.8 ms each) and to `elements/news-list.html`, which are smaller and can follow the same shape. ## Also in scope - The news bundle's `version` bumps (new partial `news-items.html`); `harness migrate` and `harness add` carry it to existing sites. - `themes/deckle/README.md` lists the partials; add `footer-pages.html` and `news-items.html`. - A guard: a theme test (`internal/theme`) that builds a generated site of N and 2N pages and fails when the ratio of build times exceeds, say, 2.5, so the quadratic pattern cannot come back. The generator used for the measurement is a 150-line Go program; it can live in `internal/theme/testdata` or the harness. - Sites that forked the theme before this change (gbv-aktuell has its own, TYPO3-lookalike markup) carry the old partials. The `redo-theme` and `migrate-site` skills should name the three patterns as things a fork must not contain, and `harness check` could warn on `where hugo.Sites.Default.Pages` or `where $src.Pages` outside a `partialCached` partial. - Recommend `pagination.pagerSize = 20` in the scaffold's `hugo.toml` for news sections; it halves the pages every save re-renders. ## Evidence Hosted: deckle-hosted #163 and #164 (the instance caps and the cold-start experience); ADR 0030 (issue #358) fixed the poll pile-up, this is what was underneath. Measurements on a synthetic site generated to gbv-aktuell's shape; metrics tables and the generator are available on request.
Author
Owner

CI verification report (PR #364)

This report comes from CI run 388 (ci / build-and-test, head 21ce185, Hugo v0.167.0 extended). That head had a temporary test, TestVerify360, which built every site twice: once with the fixed partials and once with copies of the stock header.html, footer.html and pagetypes/news.html. The test was written to always fail so CI would print the report. It has been reverted in d98b30e, so the PR now carries only the real change.

[scale-500]    files fixed=663 stock=663 differing=0; WARN/ERROR in fixed output: false
[news-fixture] files fixed=45  stock=45  differing=0; WARN/ERROR in fixed output: false
[fixed] 2000 items 5.28s, 4000 items 11.21s, ratio 2.12 (guard limit 2.70)
[stock] 2000 items 13.24s, 4000 items 45.27s, ratio 3.42 (guard limit 2.70)

What this shows for the acceptance criteria:

  • Byte-identical production builds. The generated scale site (500 items) and the news fixture site produce the same files, byte for byte, with the fixed and the stock partials: 0 of 663 and 0 of 45 differ. Hugo printed no WARN or ERROR on the fixed builds.
  • The guard separates stock from fixed. All builds ran on one core. The fixed partials scale at 2.12x, 0.58 under the limit scaleMaxRatio = 2.7. The stock partials scale at 3.42x, 0.72 over it. This matches triage (2.2x vs 3.2x), so the limit stays at 2.7.
  • The guard and the template metrics pass. In that run, the only failure in internal/theme was TestVerify360. TestBuildTimeScalesLinearly passed, and so did its templateMetrics subtest, which asserts that footer-pages.html and news-items.html each execute once per key (total - cached == 1). Every other package passed too.
## CI verification report (PR #364) This report comes from CI run 388 (`ci / build-and-test`, head `21ce185`, Hugo v0.167.0 extended). That head had a temporary test, `TestVerify360`, which built every site twice: once with the fixed partials and once with copies of the stock `header.html`, `footer.html` and `pagetypes/news.html`. The test was written to always fail so CI would print the report. It has been reverted in `d98b30e`, so the PR now carries only the real change. ``` [scale-500] files fixed=663 stock=663 differing=0; WARN/ERROR in fixed output: false [news-fixture] files fixed=45 stock=45 differing=0; WARN/ERROR in fixed output: false [fixed] 2000 items 5.28s, 4000 items 11.21s, ratio 2.12 (guard limit 2.70) [stock] 2000 items 13.24s, 4000 items 45.27s, ratio 3.42 (guard limit 2.70) ``` What this shows for the acceptance criteria: - **Byte-identical production builds.** The generated scale site (500 items) and the news fixture site produce the same files, byte for byte, with the fixed and the stock partials: 0 of 663 and 0 of 45 differ. Hugo printed no WARN or ERROR on the fixed builds. - **The guard separates stock from fixed.** All builds ran on one core. The fixed partials scale at 2.12x, 0.58 under the limit `scaleMaxRatio = 2.7`. The stock partials scale at 3.42x, 0.72 over it. This matches triage (2.2x vs 3.2x), so the limit stays at 2.7. - **The guard and the template metrics pass.** In that run, the only failure in `internal/theme` was `TestVerify360`. `TestBuildTimeScalesLinearly` passed, and so did its `templateMetrics` subtest, which asserts that `footer-pages.html` and `news-items.html` each execute once per key (`total - cached == 1`). Every other package passed too.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#360
No description provided.