dotCMS / dotCMS/core

Content Drive: folder move, batched async job endpoint and frontend wiring

Open
#37,165 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

dotCMS : Rest API dotCMS: Content Drive OKR : Application Performance Team : Scout Type : Task
Dominant language
Java
Stars
970
Forks
486
Avg merge
3d 33m
Merged PRs (30d)
170

Description

Description

Folder move over a Content Drive multi-select, end to end: the batched background job, the net-new endpoint, and the Content Drive wiring that drives it. One ticket, one operation, one deliverable — folder move works in the product when this closes.

Folder move times out on large folders today. This ticket keeps the existing move strategy and makes it survive size by processing the subtree in batches, run as a background job.

Re-split 2026-09-14. This ticket previously carried the backend only, with the frontend wiring for move, copy and delete all on #37063. The three tickets are now one per operation, each carrying its own backend and frontend, so each can be handed to one developer and finished without waiting on a sibling. No design decision changed. Copy is now #37062, bulk delete is #37063.

Decision (2026-08-25). An earlier version of this ticket proposed replacing the per-item move with an in-place identifier.parent_path rewrite modelled on renameFolder. That is not the direction. Per @nollymar the implemented strategy stays as it is and the fix is batching, which keeps the change contained and leaves folder identity semantics untouched. The rejected alternative and its trade-offs are recorded under Alternatives considered so the decision does not have to be relitigated, and so whoever revisits it starts from the analysis rather than from scratch.

Why it times out today

FolderFactoryImpl.move (FolderFactoryImpl.java:581), inside FolderAPIImpl.move's single @WrapInTransaction (FolderAPIImpl.java:1083 folder-to-folder, :1107 folder-to-host):

  1. getNewFolderRecord creates a new folder record with a new identifier
  2. findContentletsByFolder loads every contentlet in the folder into a List, unpaged
  3. moveChildContentlets re-points each one via fileAssetAPI.moveFile, pageAssetAPI.move or contentletAPI.move
  4. moveLinks walks the links; moveChildFolders recurses into every sub-folder
  5. updateOtherFolderReferences(newInode, oldInode) repairs structure.folder, permission.inode_id and permission_reference.asset_id
  6. delete(folder) removes the source folder

Two independent problems: the whole subtree runs in one transaction, and step 2 holds an unbounded list in memory per folder level.


Backend

There is no endpoint for this today, so one is part of this ticket

Folder move exists only as a Java API. Verified surface:

FolderAPI.java:433    boolean move(Folder folderToMove, Folder newParentFolder, User, boolean)
FolderAPI.java:446    boolean move(Folder folderToMove, Host newParentHost, User, boolean)
FolderAPI.java:458    boolean move(String folderId, String newFolderId, User, boolean)

and its only consumers are legacy or protocol-level:

BrowserAjax.java:1001             DWR, legacy Dojo site browser (moveFolder declared at :989)
EditFolderAction.java:633, :650   legacy Struts folder edit screen
DotWebdavHelper.java:1243, :1287  WebDAV
FolderAPITest.java:496, :668      integration tests

There is no REST endpoint: no @Path containing move anywhere outside FieldResource:331 (content-type field reordering), no _move string in the REST layer, nothing on WebAssetResource (/v1/assets, which has _download, _delete, _archive, folders/_delete, folders POST/PUT) and nothing on FolderResource (/v1/folder). dotCLI does not have it either: its only folder operation is Pusher.deleteFolder (Pusher.java:105) against folders/_delete, and a folder "move" via files push is really create-at-new-path plus per-asset upload plus delete-old, client side.

So the Java work is a modification, but the HTTP surface is net new.

Proposed contract
POST /api/v1/assets/folders/_move   -> 202, queue "folderMove"

On WebAssetResource, beside folders/_delete (:314), folders POST (:364) and folders PUT (:414). Body mirrors _copy on #37062 so the two folder relocation operations stay symmetrical:

{
  "sourcePaths": [
    "//demo.dotcms.com/projects/alpha/",
    "//demo.dotcms.com/projects/beta/"
  ],
  "destinationPath": "//demo.dotcms.com/archive/"
}

Answers 202 with the job handle:

{ "entity": { "jobId": "e6d9bae8-657b-4e2f-8524-c0222db66355", "statusUrl": "/api/v1/jobs/e6d9bae8-.../status" } }

It takes several sources, not one. Content Drive acts on a multi-select, so a single-source endpoint would just be looped by the caller and lose per-source reporting. This means the per-path result contract from #37062 applies here too, and it means the recovery problem below multiplies across sources: an interrupted bulk move can leave several folders split, not one.

Pieces to build, all following patterns already in the file:

Piece Follow
Immutable request form AbstractFolderDeletionRequestForm: @Value.Immutable, @JsonSerialize/@JsonDeserialize, @Schema with RequiredMode.REQUIRED and examples
Path to folder resolution _delete resolves assetPath; do the same for sources and destination, then hand ids to FolderAPI.move(String, String, ...) (FolderAPI.java:458), which already dispatches folder-vs-host destinations at FolderAPIImpl:1138
Permission gate WebResource.InitBuilder as in deleteFolder, plus the READ-on-source and CAN_ADD_CHILDREN-on-destination checks FolderAPIImpl.move already performs
OpenAPI @Operation annotations, then regenerate openapi.yaml

Validation that can happen before a job is created still uses status codes: malformed body 400, unresolvable destination 404, unauthorized 403. Per-source outcomes belong to the job result, in the same successCount / failCount / results shape as #37062 and #37063 — the three must not diverge.

The job-queue framework already exists

No new infrastructure. com.dotcms.jobs.business is complete and Postgres-backed: JobProcessor, @Queue, Cancellable, ProgressTracker, retry policies, PostgresJobQueue, AbandonedJobDetector, RealTimeJobMonitor. JobQueueResource (/v1/jobs) already exposes status, cancel, an SSE monitor and the paginated job listings. Precedent to copy: CleanAssetsJobProcessor.

What changes

Keep every step and its ordering. Change how it is driven:

  1. Page the contentlet load. findContentletsByFolder is replaced by a paged read so a folder level never materialises its whole content list.
  2. Commit per batch. A configurable batch size (contentlets per transaction) instead of one transaction for the subtree.
  3. Run it as a job. New folderMove queue, so the caller is not holding an HTTP request open. Progress via Job#progressTracker(), cancellation via Cancellable.
  4. Keep the per-item APIs. contentletAPI.move and friends stay in the path, so name-collision checks, versionTs bumps, cache eviction, per-asset system events and indexing all keep their current behavior.
The consequence to design rather than discover: the move stops being atomic

This is the real cost of batching, and it needs specifying, not just accepting.

Today a move is all-or-nothing. Batched, there is a window where both folders exist at different paths with the subtree's contents split between them. That window is visible in the grid, in the sidebar tree, in search and to push publish. Three things follow:

Recovery. If a batch fails, or the JVM restarts mid-move, the split is permanent: updateOtherFolderReferences and delete(folder) never ran, so the old folder is still there and permission references still point at the old inode. This needs a defined answer, not silence. Proposal: the job records the (source inode, target inode) pair durably and is resumable, re-driving the remaining children on restart. If resume is out of scope for v1, an incomplete move must at minimum be detectable and re-runnable by an admin, and the ticket should state which of the two was built.

A live permission gap. updateOtherFolderReferences currently runs at step 5, after the children have moved. In one transaction that ordering is invisible. Batched, it means that for the whole duration of the move, contentlets already relocated under the new folder sit under a parent whose permission and permission_reference rows do not exist yet. That is a real authorization window, not a cosmetic one. The reference update must move before the children are relocated, or the new folder must be created with its permissions already in place.

Duplicate visibility. The half-populated target folder is browsable while the job runs. Decide deliberately: hide the target until the job completes, or show it and accept that users can see a partially filled folder. Either is defensible; leaving it undecided is not.

Cancellation semantics

Cancelling mid-move leaves the subtree split. Contents already relocated stay at the destination. Options:

  • Stop where it is and leave both folders, reporting the split in the job result. Cheapest, worst for the user.
  • Honour cancellation only at a folder boundary, so each sub-folder is either fully moved or untouched. Bounded exposure.
  • Compensate by moving the relocated items back. Doubles the worst-case work and can itself fail.

Recommendation: cancel at folder boundaries, and refuse cancellation once the last batch of a folder has started.

Also worth handling while in here
  • Two jobs moving overlapping subtrees must not run concurrently. Reject the second submission with a readable reason.
  • moveChildFolders recurses in Java while holding per-level state; make sure batching does not turn recursion depth into a second memory problem on deep trees.

Frontend

Content Drive's action center operates on contentlets. Folders are selectable in the table but cannot be moved. This is the wiring.

#33468 already describes the intended move UX — a destination picker rather than drag-and-drop, with a toast on completion — so this is the implementation of that for folders.

The job-progress primitive comes from #37166

Nothing in core-web calls /v1/jobs today, so a reusable primitive has to be built. #37166 (bulk file upload) owns it, because #32356 asks for the richer version (per-file progress and per-file cancel) and a primitive that satisfies upload satisfies the folder actions too. This ticket consumes it. Whichever of #37062 / #37063 / #37165 / #37166 lands first builds it to #37166's stated requirements and the rest reuse it unchanged — do not build it four times.

The flow: submit and get a jobId, follow the job, render progress, handle the terminal state, then report the result and refresh.

Following the job has two options and the cheap one is available:

  • GET /v1/jobs/{jobId}/monitor is an SSE EventOutput, and because it is a GET, native EventSource works with no shim. Worth stating explicitly: dot-content-drive-action-center.component.ts:126 records that the legacy _bulkfire SSE path was skipped precisely because "native EventSource cannot POST a body". That objection does not apply here — submission is a normal POST returning a jobId, monitoring is a separate GET.
  • GET /v1/jobs/{jobId}/status polling is the fallback if SSE proves awkward behind the proxy.

Things the primitive has to get right, which are easy to miss:

  • A job outlives the component. Navigating away, or closing the dialog, does not cancel it. Either the outcome stays discoverable when the user comes back, or the UI must say plainly that it will not be. Do not silently lose a running job's result.
  • Cancellation is exposed over POST /v1/jobs/{jobId}/cancel. Move's cancellation outcome is the most confusing of the three and needs the clearest wording: cancelling is honoured at folder boundaries and can leave the subtree split across two locations.
Move brings a failure mode copy and delete do not have

Because the batched move creates the target folder, relocates children, then deletes the source, an interrupted move leaves both folders in place with contents split between them. The frontend has to be able to represent that, not just success and failure. An interrupted move may leave a duplicate folder visible until it is resumed or retried, and the UI must not present that as a completed move.

Moving a folder moves everything in it, with no workflow action on the contents

FolderAPIImpl.move checks READ on the source and CAN_ADD_CHILDREN on the destination, then relocates the subtree as systemUser, never evaluating per-contentlet permission or firing a workflow action. So the frontend never moves items individually, and the confirmation copy should say plainly that the folder's contents move with it.

Folder move is a separate endpoint from contentlet move

Not a decision for this ticket, just something not to be surprised by.

Contentlet move already works and is synchronous: PUT /api/v1/workflow/contentlet/actions/bulk/fire with the system Move action (dd4c4b7c-e9d3-4dc0-8fbf-36102f9c6324, pinned in constants.ts:321) and the target in additionalParams.additionalParamsMap._path_to_move. Content Drive ships it today for drag-and-drop at dot-content-drive-shell.component.ts:1163. Folder move is the different endpoint above, with a different shape.

Out of scope here. How Move should behave over a selection containing both files and folders is a product question, not an endpoint one, and it is not being answered on this ticket. Scope this ticket's Move to folders. If mixed selections need defining, that belongs in its own conversation with product.

Bulk means per-path results, not a boolean

The toast must report the server's counts, not the selection size — the same rule the existing quick actions follow. A per-folder failure has to name the folder and say why: name collision, permission, unresolvable path.

Acceptance Criteria

Batching and memory

  • No folder level loads its full contentlet list into memory; the read is paged
  • Contentlets are moved in configurable batches, each in its own transaction, with the batch size read through Config
  • A folder containing 50k+ assets across several levels completes without a timeout and without unbounded memory growth
  • Recursion into deep sub-folder trees does not accumulate per-level state proportional to the whole subtree

Endpoint (net new)

  • POST /api/v1/assets/folders/_move exists on WebAssetResource, accepting several sourcePaths and one destinationPath
  • An immutable request form follows the AbstractFolderDeletionRequestForm pattern, with @Schema descriptions and examples on every property
  • Source and destination paths resolve to folders (or a site root for the destination) before the job is enqueued
  • A malformed body returns 400, an unresolvable destination returns 404, and an unauthorized caller returns 403, all before any job is created
  • An unresolvable source path is a per-path failure in the job result, not a request-level error
  • A destination resolving to a site root works as well as one resolving to a folder, exercising both FolderAPIImpl.move overloads through move(String, String, ...)
  • Per-source outcomes use the same successCount / failCount / results shape as #37062 and #37063

Job

  • Move runs on a new folderMove queue, @Queue-annotated, implementing Cancellable
  • Submission returns 202 with jobId and statusUrl; status, cancel and progress come from the existing /v1/jobs/{jobId} endpoints
  • Progress is reported through Job#progressTracker(), updated only when the rounded percentage changes
  • getResultMetadata reports assets moved, assets failed with reasons, and whether the move completed or stopped early
  • A submission overlapping an in-flight move's subtree is rejected with a readable reason rather than queued

Non-atomicity, handled explicitly

  • updateOtherFolderReferences, or equivalent permission setup on the new folder inode, runs before children are relocated, so no relocated contentlet is ever parented under a folder with no permission rows
  • An interrupted move is either resumable or detectable-and-re-runnable, and which one was built is stated in the code and on the endpoint
  • The behavior of the half-populated target folder during the move is a deliberate choice (hidden or visible) and is documented
  • Cancellation is honoured at folder boundaries; a cancelled move leaves each sub-folder either fully moved or untouched, and the job result records where it stopped
  • An integration test interrupts the move partway and asserts the documented recovery behavior, rather than asserting only the happy path

Behavior preserved

  • Per-asset behavior is unchanged: name-collision checks, versionTs bumps, cache eviction, MOVE_FILE_ASSET / MOVE_PAGE_ASSET system events and indexing all still happen per item as they do today
  • Folder identity semantics are unchanged: a move still produces a new folder identifier, exactly as it does now
  • MOVE_FOLDER fires once on completion with source and target payloads (1fa9b9eb94 added those; do not regress them)
  • Moving a folder onto itself, or into one of its own descendants, is still rejected
  • Each existing caller has an explicit, recorded decision (job or synchronous), not an inherited one: BrowserAjax.moveFolder (BrowserAjax.java:1001), EditFolderAction (:633 host, :650 folder), DotWebdavHelper (:1243 folder, :1287 host)
  • Where the batching lands is a deliberate choice and is documented: inside FolderFactoryImpl.move (all callers inherit async) or in a job wrapper above FolderAPI (existing callers stay synchronous, at the cost of two paths)
  • FolderAPITest:496 and :668 still pass

Frontend

  • Move appears for folders in Content Drive and opens a destination picker consistent with #33468
  • Move follows its job through the shared primitive specified on #37166; no second implementation is added
  • If this ticket lands before #37166, it builds the primitive to #37166's stated requirements and the other tickets reuse it unchanged
  • A running move job is not cancelled by component teardown or navigation, and its outcome remains discoverable afterwards
  • Move's confirmation states that the folder's contents relocate with it
  • Progress renders while the job runs
  • The completion toast reports the server's successCount / failCount, never the selection size
  • A folder that could not be moved is named in the result along with why, not silently dropped
  • A name collision at the destination surfaces as a readable message naming the conflicting folder
  • Cancellation wording states that the subtree may be left split across two locations — the clearest wording of the three actions
  • An interrupted move that leaves a duplicate folder visible is not presented as a completed move
  • The grid and the sidebar tree both refresh once the job completes, since they reload separately
  • Move is hidden or disabled for folders the user has no permission to move, and for destinations they cannot add children to
  • Only one action runs at a time, matching the existing actionExecution guard in the store

Docs and tests

  • The endpoint's @Operation states that move is asynchronous, names the folderMove queue, points at the /v1/jobs/{jobId} endpoints, and states that the subtree relocates with the folder, that no workflow action fires on the contents, and that permissions are evaluated on the folders rather than per contentlet
  • openapi.yaml regenerated and committed alongside the annotation changes
  • Integration tests cover: folder-to-folder, folder-to-site-root, cross-site, a deep subtree with contentlets, pages, file assets and links at several levels, name collision, self-target, descendant-target, permission denied, cancellation at a folder boundary, and interruption mid-batch
  • Jest specs cover: progress updates, success, partial failure, job failure, cancellation, permission-denied, the split-subtree outcome, and teardown while a job is in flight
Priority

Medium

Additional Context
Which callers migrate, and where the batching lands

The endpoint is new, but three existing callers already invoke FolderAPI.move directly. Where the batching is implemented decides what happens to them, and it is a real fork:

  • Inside FolderFactoryImpl.move. One implementation, no duplication, and every caller gets the batched behavior for free. But they also inherit asynchrony whether or not they can handle it, and the method's return stops meaning "the move is done".
  • In a job wrapper above FolderAPI. Existing callers keep the synchronous, atomic path and only the new endpoint goes through the job. No behavior change for WebDAV or the legacy screens, at the cost of maintaining two paths, one of which still times out on large folders.

Per caller:

  • DotWebdavHelper (:1243, :1287) is the hard one. WebDAV clients expect MOVE to be complete when the response returns. Under the first option it either waits on the job (reintroducing the wait, though without holding a transaction) or returns early and risks breaking the client's next request.
  • BrowserAjax.moveFolder (:1001) is the legacy Dojo site browser. It returns a localized message string, so it can be pointed at the job with a progress affordance or left synchronous.
  • EditFolderAction (:633, :650) is the legacy Struts screen; same options, lower traffic.

This needs answering before implementation, not during.

Alternatives considered

In-place identifier.parent_path rewrite, modelled on renameFolder. Rejected 2026-08-25.

FolderFactoryImpl.renameFolder (:792) already performs a set-based subtree path rewrite: one UPDATE identifier SET parent_path = ? per distinct sub-folder path (updateChildPaths, :849), ordered depth-first to satisfy identifier_parent_path_trigger, plus snapshot-driven cache eviction, bumpVersionTsForSubtree / bumpModDateForSubFolders, and a single async ES reindex via contentletAPI.refreshContentUnderFolder. Applying the same shape to move would have made it bounded SQL with no job, no progress UI and no partial states.

Supporting findings from that analysis, kept because they stay true and are useful either way:

  • The contentlet table has no folder column, and contentlet_as_json (model v2) carries no folder or host. A contentlet's location lives solely in its identifier row, so the per-item contentletAPI.move calls are updating identifier rows one at a time.
  • ESContentletAPIImpl.move:588 does: permission checks (no-ops here, folder move runs as systemUser), a name-collision check, identifier.setParentPath/setHostId then save, a versionTs bump, in-memory setFolder/setHost, cache eviction on commit, and addContentToIndex. FileAssetAPIImpl.moveFile:529 and HTMLPageAssetAPIImpl.move:520 wrap it with a name check, NavTool eviction and a per-asset system event. No binaries move on disk.
  • Preserving the folder inode would have made updateOtherFolderReferences unnecessary. That function is a hand-maintained list (structure.folder, permission.inode_id, permission_reference.asset_id); any table holding a folder inode that is not on it gets orphaned when the inode changes. dot_rule.folder (postgres.sql:2447) is absent from it, though Rule.folder defaults to "SYSTEM_FOLDER" with no factory support, so that gap is theoretical rather than live.
  • The bulk path is not free either: identifier_parent_path_trigger (postgres.sql:1958) is FOR EACH ROW and does a lookup per row, indexed by idx_identifier_parent_path_trigger. Cheaper than N cross-tier calls, but still N in-DB operations.

Why it was rejected. It changes folder identity semantics (move currently mints a new identifier; in place it would preserve one), and push publish and the integrity checker both depend on that behavior (folders_ir, postgres.sql:2423, keys on local_inode and local_identifier). Rename went through that exact question across three follow-up fixes in six weeks (b0852556a4, 6234263bee, c947d9b509) before settling. Batching leaves identity untouched and keeps the diff contained, which was judged the better risk trade even though it does not reduce total work: batching addresses lock duration, memory and the timeout, not throughput.

The honest cost of the chosen approach is atomicity, covered above. It is the one property today's implementation has that batching gives up, which is why the recovery, permission-ordering and cancellation criteria are not optional polish.

Earlier history. An interim plan had move as a synchronous frontend call, on the assumption that this ticket would deliver the in-place path rewrite. When that rewrite was rejected in favour of batching, move became asynchronous like copy and delete.

Related: #37062 (folder copy, end to end — same job framework, result contract and request body shape), #37063 (bulk folder delete, end to end), #37166 (owns the job-progress primitive this ticket consumes), #32357 (the async folder move/copy feature this sits under), #33468 (move dialog UX), #32302 (folder CRUD backend), #33999 (parent epic).

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.

Research direction

Start with FolderFactoryImpl.java:581 and FolderAPIImpl.move, then inspect WebAssetResource beside the existing folder endpoints and CleanAssetsJobProcessor for job patterns. For the frontend, read dot-content-drive-action-center.component.ts:126 and the reusable job-progress work from #37166. Done means multi-select folder move is exposed through the asynchronous endpoint, processed in batches with the specified job behavior, and wired through Content Drive with progress, cancellation, results, and refresh.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, typescript
Domain
api, backend, frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.