Content Drive: folder move, batched async job endpoint and frontend wiring
Nobody has claimed this yet.
- 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_pathrewrite modelled onrenameFolder. 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):
getNewFolderRecordcreates a new folder record with a new identifierfindContentletsByFolderloads every contentlet in the folder into aList, unpagedmoveChildContentletsre-points each one viafileAssetAPI.moveFile,pageAssetAPI.moveorcontentletAPI.movemoveLinkswalks the links;moveChildFoldersrecurses into every sub-folderupdateOtherFolderReferences(newInode, oldInode)repairsstructure.folder,permission.inode_idandpermission_reference.asset_iddelete(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:
- Page the contentlet load.
findContentletsByFolderis replaced by a paged read so a folder level never materialises its whole content list. - Commit per batch. A configurable batch size (contentlets per transaction) instead of one transaction for the subtree.
- Run it as a job. New
folderMovequeue, so the caller is not holding an HTTP request open. Progress viaJob#progressTracker(), cancellation viaCancellable. - Keep the per-item APIs.
contentletAPI.moveand 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.
moveChildFoldersrecurses 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}/monitoris an SSEEventOutput, and because it is a GET, nativeEventSourceworks with no shim. Worth stating explicitly:dot-content-drive-action-center.component.ts:126records that the legacy_bulkfireSSE path was skipped precisely because "nativeEventSourcecannot POST a body". That objection does not apply here — submission is a normal POST returning ajobId, monitoring is a separate GET.GET /v1/jobs/{jobId}/statuspolling 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/_moveexists onWebAssetResource, accepting severalsourcePathsand onedestinationPath - An immutable request form follows the
AbstractFolderDeletionRequestFormpattern, with@Schemadescriptions 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 returns404, and an unauthorized caller returns403, 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.moveoverloads throughmove(String, String, ...) - Per-source outcomes use the same
successCount/failCount/resultsshape as #37062 and #37063
Job
- Move runs on a new
folderMovequeue,@Queue-annotated, implementingCancellable - Submission returns
202withjobIdandstatusUrl; 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 -
getResultMetadatareports 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_ASSETsystem 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_FOLDERfires once on completion with source and target payloads (1fa9b9eb94added 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(:633host,:650folder),DotWebdavHelper(:1243folder,:1287host) - Where the batching lands is a deliberate choice and is documented: inside
FolderFactoryImpl.move(all callers inherit async) or in a job wrapper aboveFolderAPI(existing callers stay synchronous, at the cost of two paths) -
FolderAPITest:496and:668still 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
actionExecutionguard in the store
Docs and tests
- The endpoint's
@Operationstates that move is asynchronous, names thefolderMovequeue, 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.yamlregenerated 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
contentlettable has no folder column, andcontentlet_as_json(model v2) carries no folder or host. A contentlet's location lives solely in itsidentifierrow, so the per-itemcontentletAPI.movecalls are updating identifier rows one at a time. ESContentletAPIImpl.move:588does: permission checks (no-ops here, folder move runs assystemUser), a name-collision check,identifier.setParentPath/setHostIdthen save, a versionTs bump, in-memorysetFolder/setHost, cache eviction on commit, andaddContentToIndex.FileAssetAPIImpl.moveFile:529andHTMLPageAssetAPIImpl.move:520wrap 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
updateOtherFolderReferencesunnecessary. 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, thoughRule.folderdefaults 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) isFOR EACH ROWand does a lookup per row, indexed byidx_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
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 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