Site Copy: decouple scroll read from per-item write to remove keep-alive coupling on large sites
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 970
- Forks
- 486
- Avg merge
- 3d 33m
- Merged PRs (30d)
- 170
Description
Description
Summary
Site Copy (HostAssetsJobImpl) consumes the Elasticsearch/OpenSearch Scroll API lazily while doing expensive per-item writes inside the scroll loop. Because the scroll's server-side context is held open across that per-item work, the fixed ES_SCROLL_KEEP_ALIVE_MINUTES (default 5 min) becomes coupled to how long the consumer takes to process one batch. On large sites the per-batch processing time exceeds the keep-alive, the scroll context expires, and the next continuation fails with search_phase_execution_exception / all shards failed — aborting the copy partway through.
The mechanism
PaginatedContentletsswitches to the Scroll API when a result set exceedsES_SCROLL_API_THRESHOLD(default 10,000).- The scroll fetches
CONTENTLET_PER_PAGE(default 1,000) ids per batch. Keep-alive is 5 min, reset on each fetch (ESContentletScrollImpllines 41–42, 92, 139). - Site Copy iterates the results and, for every item, hydrates + creates + saves + reindexes a new contentlet — inside the same loop that drives the scroll (
HostAssetsJobImpl.copySiteAssets). - So the wall-clock between two scroll continuation requests = time to process one full 1,000-item batch. When that exceeds 5 min, OpenSearch has already dropped the context → the next
_search/scrollreturns "all shards failed".
Evidence (FD #38681 cluster logs)
| Signal | Observation |
|---|---|
| Per-batch processing time (node 1) | Content phase 09:41:41 → 09:48:28 = 6m47s to process the first ~1,000-item batch — exceeds the 5-min keep-alive |
| Failure point | On the first scroll continuation (PaginatedContentlets$ContentletIterator.Error continuing scroll API), ~1,005 items in |
| Corroborating tell | The clearScroll DELETE /_search/scroll returns HTTP 404 Not Found — the context was already gone, exactly what an expired cursor looks like (a transient shard blip on a live context would not 404) |
| Concurrent load | ~4,047 addIdentifierReindex + ~1,516 index ops during the window — the copy hammers the same cluster it is scrolling, further slowing each batch |
| Determinism | Fails every attempt on this site; both cluster nodes hit the identical error |
Why this improvement is recommended (justification)
This is worth doing as a design change rather than living on config tuning, for five reasons:
- Config only moves the wall — it never removes it.
ES_SCROLL_KEEP_ALIVE_MINUTES=30unblocks a 77k site, but a site 5× larger, a slower/busier cluster, or a heavier content type walks straight back into the same failure. The timeout is coupled to consumer speed; no single value is correct for all sites. - Silent, partial, data-shaped failure. The copy doesn't just error — it produces a half-copied site (in FD #38681, ~1.3% of content copied, sorted alphabetically, so whole classes of files like
.vtl/.css/.jssimply never get reached). A partially-populated site looks "created" but is broken, which is worse than a clean failure and generates follow-up support load. - It holds an OpenSearch resource open for no reason. Keeping a scroll context alive across minutes of writes pins search contexts/segments on a shared cluster (
canada-es-1.dotcms.cloud), adding pressure precisely while the copy's own reindex load is peaking. Bigger keep-alive = more resource held = more strain — the mitigation actively works against cluster health. - The fix is cheap and low-risk. The scroll already yields lightweight ids, not hydrated objects, and the job already retains O(N) mapping state — so decoupling read from write is a small, contained change with a large blast-radius reduction (see Additional Context for the memory analysis).
- Recurring, customer-visible surface. Site Copy on large sites is a common enterprise operation; this failure is deterministic and will recur for any customer above the threshold. Fixing the pattern removes a class of tickets, not a single incident.
Net: decoupling the read from the write makes the keep-alive irrelevant to consumer speed — the read completes in seconds, so even the original 1-minute value would be safe — and eliminates the failure mode instead of postponing it.
Proposed approach (design decision to confirm in refinement)
Recommended — decouple read from write: run the scroll once, read-only and fast, to materialize only the id/inode list (tight loop, no per-item work → keep-alive is a non-issue), then run the copy from that list, hydrating one contentlet at a time inside the write loop as today. Smallest conceptual change; removes the coupling entirely.
Acceptance Criteria
- Site Copy of a site with >100,000 contentlets completes successfully without any
Error continuing scroll API … all shards failedin the logs. - The Site Copy content phase no longer holds an open scroll/PIT context across per-item copy work — the read that enumerates source content completes before (or independently of) the per-item write loop.
- With
ES_SCROLL_KEEP_ALIVE_MINUTESleft at its default, a large-site copy that previously failed now succeeds (i.e. success no longer depends on raising the keep-alive). - Memory stays bounded during the copy: only lightweight ids (not hydrated
Contentletobjects) are materialized up front; full contentlets are hydrated one-at-a-time in the write loop. Verified on a >100k-item site without OOM/heap regression. - Copied site is complete — all non-page assets (
.vtl,.css,.js, file-asset contentlets) that fall alphabetically after the previous failure point are present. - The
clearScroll/context-cleanup path no longer logs theUnable to parse response bodyerror on a 404 (either fixed or made non-applicable by the new approach). - Behavior is regression-tested for small sites (below
ES_SCROLL_API_THRESHOLD) and for the scroll/large-site path. - A note is added to the copy/scroll config docs describing the new behavior and what
ES_SCROLL_KEEP_ALIVE_MINUTES/CONTENTLET_PER_PAGEnow do (and don't) affect.
dotCMS Version
Evergreen
26.08.03-01
Priority
Medium
Links
Support ticket : https://helpdesk.dotcms.com/a/tickets/38681
Additional Context
Memory analysis — why "read all ids first" is safe (and where it stops being safe).
The scroll yields ContentletSearch (three strings: inode/identifier/index + a float), not hydrated contentlets, and PaginatedContentlets already reduces each page to a List<String> of inodes; the full Contentlet is hydrated lazily one at a time via contentletAPI.find(inode, …). Materializing the full id list up front therefore costs ~88 bytes/id:
| Site size | Heap for the id list |
|---|---|
| 77k (this ticket) | ~6.8 MB |
| 100k | ~8.8 MB |
| 1M | ~88 MB |
| 10M | ~880 MB ← back with a temp table / disk-backed queue beyond here |
The job already retains O(N) mapping state for the whole run (copiedContentsBySourceId, copiedFoldersBySourceId, copiedContentTypesBySourceId, copiedRelationshipsBySourceId, failedContents, htmlPageInodes), so a List<String> of ids adds no new order of magnitude. The one rule: materialize ids, never hydrated Contentlet objects (a hydrated contentlet is KBs–tens of KBs → 100k × ~10KB ≈ 1 GB → OOM). For multi-million-item sites, back the id list with a temp/tracking table rather than heap — worth naming as the known ceiling so the fix isn't mistaken for infinitely scalable.
Operational mitigation (already available, not a substitute for the fix):
ES_SCROLL_KEEP_ALIVE_MINUTES=30(default 5) — extend the context lifetime enough to cover one inter-batch gap.CONTENTLET_PER_PAGE=250(default 1000) — smaller batches → shorter inter-batch gap.ES_INDEX_OPERATIONS_TIMEOUT(15s) is not the relevant knob — it applies to the initial search query, which succeeds; the failure is on scroll continuation.
Relevant code:
dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletScrollImpl.java(keep-alive 41–42, 92, 139)dotCMS/src/main/java/com/dotmarketing/util/contentet/pagination/PaginatedContentlets.java(threshold 50–51; scroll vs offset 155–174; iterator hydration 285–288)dotCMS/src/enterprise/java/com/dotcms/enterprise/priv/HostAssetsJobImpl.java(content-copy phase; O(N) maps 306–313)ESIndexAPI.INDEX_OPERATIONS_TIMEOUT_IN_MS(104–105)
History / provenance: introduced by PR #34044 (Closes #33661); scroll code later migrated to OpenSearch under task #34647 (PRs #34691, #34742).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with HostAssetsJobImpl.copySiteAssets and trace how PaginatedContentlets drives ESContentletScrollImpl during the content-copy phase. Review the existing pagination and Site Copy regression coverage before changing behavior. Done means large and small copies complete with bounded id-only memory, no scroll continuation failure, and the documented configuration behavior is covered.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- elasticsearch, java
- Domain
- backend, databases, search
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100