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
PaginatedContentlets switches to the Scroll API when a result set exceeds ES_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 (ESContentletScrollImpl lines 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/scroll returns "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=30 unblocks 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/.js simply 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
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).
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 fixedES_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 withsearch_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).CONTENTLET_PER_PAGE(default 1,000) ids per batch. Keep-alive is 5 min, reset on each fetch (ESContentletScrollImpllines 41–42, 92, 139).HostAssetsJobImpl.copySiteAssets)._search/scrollreturns "all shards failed".Evidence (FD #38681 cluster logs)
PaginatedContentlets$ContentletIterator.Error continuing scroll API), ~1,005 items inclearScrollDELETE /_search/scrollreturns 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)addIdentifierReindex+ ~1,516 index ops during the window — the copy hammers the same cluster it is scrolling, further slowing each batchWhy this improvement is recommended (justification)
This is worth doing as a design change rather than living on config tuning, for five reasons:
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..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.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.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
Error continuing scroll API … all shards failedin the logs.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).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..vtl,.css,.js, file-asset contentlets) that fall alphabetically after the previous failure point are present.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).ES_SCROLL_API_THRESHOLD) and for the scroll/large-site path.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, andPaginatedContentletsalready reduces each page to aList<String>of inodes; the fullContentletis hydrated lazily one at a time viacontentletAPI.find(inode, …). Materializing the full id list up front therefore costs ~88 bytes/id:The job already retains O(N) mapping state for the whole run (
copiedContentsBySourceId,copiedFoldersBySourceId,copiedContentTypesBySourceId,copiedRelationshipsBySourceId,failedContents,htmlPageInodes), so aList<String>of ids adds no new order of magnitude. The one rule: materialize ids, never hydratedContentletobjects (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).