dotCMS / dotCMS/core

Content Drive: bulk folder delete, async job endpoint and frontend wiring

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

@rjvelazco is already working on this.

Since Sep 15, 2026.

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

Description

Description

Bulk folder delete over a Content Drive multi-select, end to end: the asynchronous backend endpoint and the Content Drive wiring that drives it. One ticket, one operation, one deliverable — multi-select delete works in the product when this closes.

Re-split 2026-09-14. This ticket previously carried the frontend wiring for all three folder operations (move, copy, bulk delete), with their backends on #37062 and #37165. 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 — bulk delete is still an async job for the reasons recorded below. Copy is now #37062, move is #37165.

Single-folder delete is already done and is not part of this ticket. It shipped on #35161 as a Delete Folder context menu item over POST /api/v1/assets/folders/_delete (WebAssetResource.java:314), which takes one assetPath (AbstractFolderDeletionRequestForm:24) and needed no backend work. That endpoint and its form are left exactly as they are. What this ticket adds is the multi-select path, as a sibling bulk endpoint — the precedent workflow already sets with .../bulk/fire beside the single fire.

Why this cannot be a synchronous REST call

Delete is a single unbounded transaction over a recursive walk:

Entry point What runs inside that one transaction
FolderAPIImpl.delete @WrapInTransaction (FolderAPIImpl.java:427) recursive delete of children at :472

Deleting a folder holding thousands of assets holds one long transaction and times out at the proxy, and a multi-select multiplies it by the size of the selection. An earlier version of this work proposed a synchronous endpoint and designed around the expected timeout. Designing around an expected timeout is an async design with none of the machinery, so it is built as one.

Per-folder child lists are also loaded whole rather than paged, so peak memory is bounded by the widest single folder, not by the subtree. That is survivable; the transaction is the part that is not.

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 everything the caller needs after submission:

  • GET /v1/jobs/{jobId}/status
  • POST /v1/jobs/{jobId}/cancel
  • GET /v1/jobs/{jobId}/monitor, an SSE EventOutput
  • paginated active / completed / successful / failed / canceled / abandoned

Closest precedents to copy: CleanAssetsJobProcessor and FixAssetsJobProcessor (@Queue + Cancellable + AtomicBoolean cancel flag + progress reported only when the rounded percentage changes + a summary in getResultMetadata). ImportContentletsProcessor is the older precedent.


Backend

Proposed contract

A domain endpoint that enqueues and returns immediately, following the _import precedent of a domain resource wrapping the job queue rather than making callers post to the generic /v1/jobs/{queueName}:

POST /api/v1/assets/folders/_bulkdelete   -> 202, queue "folderBulkDelete"
{ "assetPaths": ["//demo.dotcms.com/old-a/", "//demo.dotcms.com/old-b/"] }

Answers 202 with the job handle:

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

Validation that can be done at submission time still uses status codes: a malformed body is 400 before a job is created. Per-path outcomes are the job's business.

Pieces to build, all following patterns already in WebAssetResource:

Piece Follow
Immutable request form AbstractFolderDeletionRequestForm: @Value.Immutable, @JsonSerialize/@JsonDeserialize, @Schema with RequiredMode.REQUIRED and examples
Path to folder resolution folders/_delete (WebAssetResource.java:314) resolves a single assetPath; do the same per entry
Permission gate WebResource.InitBuilder as in deleteFolder
OpenAPI @Operation annotations, then regenerate openapi.yaml
Per-path results move into the job result, they do not disappear

JobProcessor.getResultMetadata(Job) returns Map<String, Object>, so the per-path contract survives verbatim, just relocated out of a synchronous response body:

{
  "successCount": 3,
  "failCount": 2,
  "results": [
    { "path": "//demo.dotcms.com/old-a/", "success": true },
    { "path": "//demo.dotcms.com/old-b/", "success": false, "error": "PERMISSION_DENIED", "message": "..." }
  ]
}

Each source folder is its own transaction, so one failure does not roll back the folders already deleted. This shape is shared with #37062 and #37165 — the three must not diverge.

The real work is chunking, not the endpoint

Wrapping this in a job does not by itself fix the transaction. A job holding one 40-minute transaction is still one 40-minute transaction; it just stops timing out at the proxy. Meaningful progress, working cancellation and bounded memory all require chunking inside the recursion, which means changing FolderFactoryImpl, not only adding a JobProcessor. Estimate this ticket on that, not on the endpoint.

Cancellation semantics, and why delete's differ from copy's

A partially deleted tree is unrecoverable. So unlike copy, cancellation is honoured only between top-level source folders, never mid-subtree: a cancelled bulk delete leaves each source either fully deleted or untouched. Two jobs deleting overlapping subtrees must not run concurrently; the second submission is rejected with a readable reason rather than queued.


Frontend

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

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, with delete's wording: cancelling leaves each source folder either fully deleted or untouched. This is a different promise from copy's, and the confirmation copy must match the action that is actually running.
Delete is recursive and permanent, and the confirmation has to say so

The confirmation is the last thing standing between a user and an unrecoverable operation. It has to state plainly that the selected folders and everything inside them are permanently deleted. Deleting a folder deletes its contents with it, with no workflow action fired on those contents.

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: permission, unresolvable path, in-use.

Acceptance Criteria

Submission

  • POST /api/v1/assets/folders/_bulkdelete accepts several assetPaths, enqueues a folderBulkDelete job and returns 202 with jobId and statusUrl
  • An immutable request form follows the AbstractFolderDeletionRequestForm pattern, with @Schema descriptions and examples on every property
  • The shipped single _delete endpoint and its form are untouched
  • A malformed body returns 400 before any job is created
  • A caller with no rights to enqueue gets 403 at submission

Job execution

  • The processor is @Queue-annotated, implements Cancellable, and reports progress through Job#progressTracker() only when the rounded percentage changes
  • Each source folder runs in its own transaction; a failure on one does not roll back sources already completed nor abort the remaining ones
  • The recursion is chunked so that a folder with tens of thousands of descendants does not run in a single transaction and does not hold the whole subtree in memory
  • getResultMetadata returns successCount, failCount and a per-path results array; every failure carries a machine-readable error and a human-readable message
  • A per-path permission failure is reported per path
  • An unresolvable path is a per-path failure, not a request-level error
  • Cancelling a bulk delete leaves each source folder either fully deleted or untouched, never partially deleted, and the job result records where it stopped
  • A submission overlapping an in-flight job's subtree is rejected with a readable reason rather than queued

Frontend

  • Selecting one or more folders enables Delete in the Action Center, submitting to _bulkdelete
  • Delete confirms first, saying the selected folders and everything inside them are permanently deleted
  • Delete 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 delete job is not cancelled by component teardown or navigation, and its outcome remains discoverable afterwards
  • 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 deleted is named in the result along with why, not silently dropped
  • Cancellation wording states that each folder is left either fully deleted or untouched — not copy's partial-subtree wording
  • The grid and the sidebar tree both refresh once the job completes, since they reload separately
  • Delete is hidden or disabled for folders the user has no permission to delete
  • Only one action runs at a time, matching the existing actionExecution guard in the store
  • The single-folder context menu delete is out of scope: it shipped on #35161

Docs and tests

  • The @Operation description states that the operation is asynchronous, that the delete is recursive and permanent, names the folderBulkDelete queue, and points at the /v1/jobs/{jobId} status, cancel and monitor endpoints
  • openapi.yaml regenerated from the annotations and committed alongside the Java changes
  • Integration tests cover: happy path over several folders, mixed partial failure, permission denied, unresolvable path, cancellation between sources, and a subtree large enough to exercise more than one chunk
  • Jest specs cover: progress updates, success, partial failure, job failure, cancellation, permission-denied, and teardown while a job is in flight
Priority

Medium

Additional Context

History of the decisions on this ticket, so they are not relitigated.

  • 2026-08-24 — bulk delete became async. It was previously proposed as a synchronous _bulkdelete, with async recorded as "deliberately out of scope". That call did not survive review; the reasoning is under Why this cannot be a synchronous REST call.
  • Delete was narrowed to multi-select only, single-folder delete having shipped on #35161.
  • 2026-09-14 — re-split by operation. Backend and frontend for bulk delete now live together here; copy moved to #37062 and move to #37165.

Add to bundle is not here. It moved to #35161, which covers the folder push publish family: push publish, add to bundle and push history, across the context menu and the Action Center. All of them resolve folder ids through PublisherAPIImpl.java:251-257 and enforce PERMISSION_PUBLISH the same way, so none of them need backend work.

Publish all was dropped as unsupported and is not being carried forward. Recording the analysis so nobody re-adds it on the strength of it:

  • the approach previously advocated was PUT /api/v1/workflow/contentlet/actions/bulk/fire with a Lucene query (+conFolder:<folderInode>) in place of contentletIds, since FireBulkActionsForm.java:18 accepts one
  • it also had a real gap: menu links are not contentlets and would not be covered by a query-based fire
  • the obvious alternative is worse. PublishFactory.publishAsset(Folder, ...) (PublishFactory.java:206) publishes contentlets directly and bypasses workflow schemes entirely

So the decision is not "too hard to implement", it is that folder-level publish-all is not a supported operation. Anything under a folder can still be published by selecting it and firing a workflow action, which is the path that respects schemes.

Related: #37062 (folder copy, end to end — same job-result contract), #37165 (folder move, end to end), #37166 (owns the job-progress primitive this ticket consumes), #35161 (single-folder delete, shipped; and the folder push publish family), #33468 (move/copy destination picker UX), #36448 (Add to Bundle toolbar action), #32302 (folder CRUD backend), #36046 (bulk delete/download endpoints for publishing), #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.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.