dotCMS / dotCMS/core

LIVE render: page template parsed 3x on a page's first render, twice without page context (deterministic 404 on cold pages)

Open
#37,386 0 comments 0 reactions 1 assignee View on GitHub

@danielsilva-dotcms is already working on this.

Since Sep 8, 2026.

OKR : Customer Support Team : Maintenance
Dominant language
Java
Stars
970
Forks
486
Avg merge
3d 33m
Merged PRs (30d)
170

Description

Problem Statement

On the first LIVE render of a page — i.e. whenever the page's compiled Velocity resource is not in cache — dotCMS parses the page's template three times within a single HTTP request instead of once. Two of those parses run in a Velocity context with neither $dotPageContent nor $HTMLPAGE_IDENTIFIER set. Every subsequent render of that page parses once, correctly.

This is deterministic, not intermittent. It reproduces 100% of the time on a cold page on a stock instance with the official demo starter and a single Advanced Template — no plugins, no custom content, no vanity URLs.

Any template logic that depends on page-scoped variables therefore executes against an empty context on a page's first render. Where that logic gates output — as it does on the reporting customer's site, via a macro that calls $response.sendError(404) when a page-scoped lookup comes back empty — the first visitor to any cold page receives a 404, and a refresh then succeeds.

Impact scales with how often pages go cold: cache eviction, publishes, node restarts, deploys. On the reporting customer's 2-pod cloud environment, a sweep of 105 published pages returned 95 × 404 after ~20h idle, decaying to ~8 × 404 once traffic had warmed the site. Because a failed render is never added to the page cache (VelocityLiveMode only calls pageCache.add when the response status is unchanged) while a successful one is cached for the page's TTL, a single successful request masks the fault for the whole TTL window — which is why this presented as intermittent for weeks.

Evidence
  • The two extra parses occur inside the same request object (same HttpServletRequest identity hash).
  • $HTMLPAGE_IDENTIFIER is empty on both. That value is set in exactly one place — PageRenderUtil.java:225 — and PageLoader emits it into the compiled page template. So those parses are not running inside the compiled page.
  • PageLoader.java:130 emits #set($dotPageContent = $dotcontent.find("<inode>")) and only emits #parse('<mode>/<templateInode>.template') afterwards, so the extra parses cannot be an early pass through the compiled page either.
  • javax.servlet.include.request_uri and javax.servlet.error.request_uri are both empty on the failing parses, so neither is a servlet include nor an error dispatch.
  • The $dotcontent viewtool is present and functional throughout, and ContentUtils.find() logs nothing — so $dotcontent.find(pageInode) is never called on those parses, rather than failing.
  • x-dotrequest-cost corroborates: @RequestCost(Price.VELOCITY_MERGE) charges per template merge, and cold renders cost several times a warm one.

Not identified: which code path in core issues the two extra parses. Whoever picks this up should start from the reproduction rather than from a theory — five plausible mechanisms were tested and eliminated during triage (the /api/v1/page/render two-pass builder, BlockDirectiveCache output caching, the page-cache TTL / striped-lock release change in 5a3033a42a, #parse vs #dotParse theme inclusion, and the page-cache key / query string).

Steps to Reproduce

Reproduces 100% on a cold page. ~10 minutes end to end.

1. Stand up 26.08.19-04

docker-compose.yml with three services:

  • db: pgvector/pgvector:pg18
  • opensearch: opensearchproject/opensearch:1, OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
  • dotcms: dotcms/dotcms:26.08.19-04, CMS_JAVA_OPTS: '-Xmx1g ', DOT_INITIAL_ADMIN_PASSWORD: 'admin', CUSTOM_STARTER_URL: 'https://repo.dotcms.com/artifactory/libs-release-local/com/dotcms/starter/20260630/starter-20260630.zip'

2. Create an Advanced Template (drawed: false) with this body:

<!doctype html>
<html><head><title>fd38742</title></head><body>
#set($n = $request.getAttribute("parseCount"))
#if(!$n)
#set($n = 0)
#end
#set($n = $n + 1)
#set($ignore = $request.setAttribute("parseCount", $n))
PARSE_MARK n=$!{n} pc=$!{dotPageContent.identifier} hp=$!{HTMLPAGE_IDENTIFIER}
#if(!$dotPageContent)
$response.sendError(404)
#end
</body></html>
curl -u admin@dotcms.com:admin -H 'Content-Type: application/json' \
  -X POST http://localhost:8082/api/v1/templates -d @template.json
curl -u admin@dotcms.com:admin -H 'Content-Type: application/json' \
  -X PUT http://localhost:8082/api/v1/templates/_publish -d '["<templateId>"]'

3. Create ~50 pages on that template with cachettl: 3600, via PUT /api/v1/workflow/actions/default/fire/PUBLISH, contentType htmlpageasset, urls /fd-repro-1/fd-repro-50.

4. Restart the dotCMS container — any cache flush that evicts the compiled page resource will do.

5. Request each page twice:

for i in $(seq 1 50); do
  a=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8082/fd-repro-$i")
  b=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8082/fd-repro-$i")
  echo "/fd-repro-$i first=$a second=$b"
done
Observed

first=404 second=200 on every page (12/12 and 50/50 in separate runs).

Remove the #if(!$dotPageContent)$response.sendError(404)#end block and read PARSE_MARK from the response body instead. The parseCount request attribute survives across parses, so the visible n is the total number of times the template was parsed in that request:

Test Result
First render of each of 50 pages 49 × n=3 (the 1 × n=1 had been pre-warmed)
12 repeat hits on one warm page n=1 every time
Warm page → container restart → 3 hits n=1n=3n=1n=1
Scope note

Confirmed on an Advanced Template (drawed=false, the #parse('<mode>/<templateInode>.template') branch in PageLoader). A drawn/theme template on the same instance showed a cold render costing ~2.8× a warm one (x-dotrequest-cost 218 vs 79), consistent with the same duplication, but this was not verified with the parse counter — worth confirming, as it determines the blast radius.

Acceptance Criteria

  • A page's template resource is parsed exactly once per LIVE page render, including the first render after the compiled page resource is evicted.
  • Any Velocity evaluation of a page template is guaranteed to have $dotPageContent and $HTMLPAGE_IDENTIFIER populated, or provably never occurs outside the page-render context.
  • The reproduction above yields only n=1 on every request, including first renders.
  • Regression test covering the parse-count-per-request invariant for an anonymous LIVE render on an Advanced Template.
  • Confirm or rule out drawn/theme templates (see Scope note).
  • Root cause documented — specifically which code path issues the extra parses.

dotCMS Version

Reproduced on 26.08.19-04 (clean local instance, official demo starter, single Advanced Template). Originally reported on 26.08.19-04 on dotCMS Cloud, 2-pod cluster.

Not reproduced on the reporting customer's pre-upgrade 24.12.27_lts_v24 instance, which serves the same content and templates — but that instance was never tested under the cold-cache conditions that make the failure appear, so "introduced by the upgrade" is a reasonable read rather than a confirmed one. Worth checking against the current LTS line, since any customer using Advanced Templates would be exposed.

Severity

High - Major functionality broken

Links

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.