Skip to content

Content Drive: bulk file upload (multi-file selection uploads only the first file) #37166

Description

@zJaaal

Description

Content Drive accepts a multi-file selection, warns the user, and then uploads only the first file. There is no bulk upload on either side of the stack.

Current state, verified

The frontend is an explicit stub. DotContentDriveShellComponent.resolveFilesUpload branches on files.length > 1 into uploadFiles, which shows a warning toast and then calls uploadFile, which uploads files[0] and drops the rest:

content-drive.multiple-files-warning=Multiple files upload is not supported yet.<br/> Only one file will be uploaded

(Language.properties:7224, under a content-drive.work-in-progress summary.)

Everything upstream of that already handles many files. DotUploadDropzoneComponent and DotUploadButtonComponent emit a DotUploadFiles payload carrying a File[], drag-and-drop delivers the whole drop, and the type selector resolves one base type for the batch. The multi-file path exists right up to the request, and then discards the batch.

The backend has no bulk create either. DotUploadFileService.uploadFileByBaseType builds one FormData per file and fires newContentletByBaseType, so one file equals one workflow fire request. WebAssetResource (/v1/assets) exposes a single-asset PUT (:164) and nothing that accepts several. workflow/.../_bulkfire operates on existing contentlets and cannot create them from binaries. ImportContentletsProcessor is CSV import, a different operation.

The current call also passes indexPolicy: 'WAIT_FOR' (dot-content-drive-shell.component.ts:1001). Correct for one file, since the grid reloads immediately afterwards. Fired N times in a loop it serialises every upload behind an index refresh, so a naive "just loop the existing call" fix would be slow enough to look broken.

Two gaps, and they need separating

  1. Several files, one target folder. The batch the UI already collects. This is the actual ask.
  2. Folder upload, meaning a directory tree. webkitdirectory gives relative paths, so the folders have to be created before the assets land in them. Not proposed here; call it out as explicitly out of scope so it does not get assumed in.

Proposed shape: a job, reusing what #37062 introduces

Same reasoning as folder copy and bulk delete. A hundred files is a long operation with per-item outcomes, which is what the job queue is for, and the framework already has the multipart entry point for exactly this: POST /v1/jobs/{queueName}/upload consumes MULTIPART_FORM_DATA and is what ImportContentletsProcessor is fed through.

POST /api/v1/assets/_bulkupload   -> 202, queue "assetBulkUpload"

Multipart: the files, plus targetFolder (or site id at the root) and baseType (DOTASSET / FILEASSET). Answers 202 with jobId and statusUrl; status, cancel and progress come from the existing /v1/jobs/{jobId} endpoints.

The processor creates one contentlet per file through the same workflow fire the single-file path uses, so content type resolution, permissions and workflow stay identical. Per-file outcomes land in getResultMetadata with the same successCount / failCount / results shape #37062 defines, keyed by file name. Index policy moves off WAIT_FOR per file: index once at the end of the batch, or let the job's completion drive the single grid reload.

Partial failure is the normal case here

More so than for copy or delete. A batch of thirty files will routinely contain one that is too large, has a blocked extension, or collides with an existing asset name. The batch must not stop at the first failure, and the result has to name the files that did not make it. A single "upload failed" toast over a thirty-file drop is the outcome to avoid.

Acceptance Criteria

Backend

  • POST /api/v1/assets/_bulkupload accepts a multipart body with several files plus targetFolder and baseType, enqueues an assetBulkUpload job, and returns 202 with jobId and statusUrl
  • The processor is @Queue-annotated, implements Cancellable, and reports progress per file through Job#progressTracker()
  • Each file is created through the same workflow fire as the existing single-file upload, so content type resolution, permissions and workflow behavior are identical
  • One file failing does not abort the batch
  • getResultMetadata returns successCount, failCount and a per-file results array with a machine-readable error and a message for each failure, matching Async bulk folder copy and delete via the job queue #37062's shape
  • Files are not indexed with WAIT_FOR per item; the batch indexes once, or the job's completion drives the reload
  • A user without add-children permission on the target folder is rejected at submission with 403
  • Cancelling leaves already-created contentlets in place and records in the result how far the batch got
  • Per-file size and extension limits are enforced with the same rules as the single-file path, and a rejection is a per-file failure rather than a 400 for the whole batch
  • openapi.yaml regenerated and committed with the annotation changes
  • Integration tests cover: many files succeeding, mixed partial failure, a rejected extension, an oversized file, permission denied, cancellation mid-batch, and a batch large enough to exercise progress reporting

Frontend

  • Selecting or dropping several files uploads all of them
  • Both upload entry points accept several files. The hidden file input (dot-content-drive-shell.component.html:178) carries no multiple attribute, so the Upload-button path can only ever select one file and only drag-and-drop delivers a batch. onFileChange already reads the whole FileList and hands it to resolveFilesUpload unchanged, so this is the attribute plus its tests. The picker and the drop must behave identically
  • Upload's copy follows the Action Center's shape rather than its own, so the two surfaces read the same: the in-flight label matches content-drive.action-center.applying, and the outcome toast matches ...toast.executed / -detail / -partial instead of content-drive.add-dotasset-success and -error
  • Content Drive stops using content-drive.multiple-files-warning and the work-in-progress summary, and the files.length > 1 stub branch in resolveFilesUpload is removed. Leave the definitions in place: the AssetPicker still references them, along with content-drive.file-upload-in-progress and its -detail. It has the same first-file-only defect, tracked separately as AssetPicker: multi-file selection uploads only the first file #37370 and out of scope here
  • Progress is shown for the batch while the job runs, via a reusable core-web job-progress primitive owned by this ticket (see below); it must not be Content-Drive-specific or upload-specific in its API
  • The completion toast reports the server's successCount / failCount, never the number of files selected
  • A partial failure names the files that failed and why, and stays on screen long enough to be read
  • The grid refreshes once when the job finishes, not once per file
  • Uploading stays available while the job runs, or is disabled with a reason; either way the UI does not appear frozen
  • Navigating away does not lose the batch: the job keeps running and its outcome is still discoverable

Outcome copy unified with the Workflow Center

The Workflow Center names what ran and what it ran on (<b>{0}</b> ran on {1} item(s).). Lock, unlock, download and delete folder already do this. The rest do not, and the copy for the missing ones was written and never wired: content-drive.toast.workflow-in-progress, its -detail, and content-drive.toast.workflow-executed-detail (Language.properties:7240-7243) are referenced nowhere.

  • Every operation's outcome names the action and the item it ran on, matching the Workflow Center's summary/detail split
  • Folder create's summary: 'Success' is a hardcoded English literal (dot-content-drive-dialog-folder.component.ts:291), not an i18n key. It is replaced, and the outcome names the folder
  • Edit Folder's outcome (content-drive.dialog.folder.message.save-success, "Folder saved successfully") carries no detail and does not name the folder (:323). The action is the context menu's content-drive.context-menu.edit-folder, offered on EDIT permission, which reopens the create dialog with the folder as payload and branches to saveFolder() on submit
  • Both folder error paths pass the server's error.message straight through as the toast detail (:309 and :342). Replaced with resolved copy, with the raw message logged rather than shown
  • Lock and unlock errors name the item, as their success counterparts already do (Locked {0} versus "Something went wrong. The contentlet wasn't locked.")
  • The orphaned keys at Language.properties:7240-7243 are resolved: workflow-executed-detail is wired up, and the two workflow-in-progress keys are deleted, since the toolbar indicator replaces them

In-progress feedback moves to the toolbar indicator

Content Drive currently reports "this is running" four different ways: the toolbar indicator (Action Center), an info toast (upload, drag-and-drop move), blanking the whole grid with skeleton rows (context-menu workflow action), and nothing at all. This ticket settles the rule and converts every intermediate state except reindex.

  • The rule: anything asynchronous that outlives the surface that triggered it reports its in-flight state on the toolbar indicator (dot-content-drive-toolbar.component.html:107). Not a toast, not the grid's loading state, not silence. The toast is reserved for the terminal state (success, partial, error, cancelled), and the grid's LOADING status means "the listing is being fetched" and nothing else
  • Content Drive stops using content-drive.file-upload-in-progress and its -detail (dot-content-drive-shell.component.ts:1057); the batch reports on the indicator
  • The drag-and-drop move's in-flight toasts are removed (content-drive.move-to-folder-in-progress and -with-folders, shell:1138 and 1150) and the move is routed through withActionExecution instead of calling bulkFire directly from the shell, so it reports on the same indicator. This is also what proves the keyed-runs change below, which upload alone cannot exercise
  • The context-menu workflow action stops calling setStatus(DotContentDriveStatus.LOADING) (dot-folder-list-context-menu.component.ts:435 and :424), which today replaces the entire table with skeleton rows for an action fired on one row. It reports on the indicator instead, naming the item: "Applying Publish to My Title"
  • The item title is HTML-escaped before it reaches the indicator label, which is bound with [innerHTML]; actionName already is, and a content title is user-supplied
  • The context-menu workflow action's outcome toast names what happened, on both success and failure. content-drive.toast.workflow-executed-detail already exists in Language.properties:7243 and is never passed; today the toast reads "Workflow Executed" with no action and no item, and the error path (content-drive.toast.workflow-error) has no detail either
  • The indicator shows determinate progress once the job reports it, and stays indeterminate when it does not
  • The indicator offers cancellation for a cancellable run Deferred to [EPIC] Task manager UI to manage large uploads and moves #33331, which already owns it: "From this task manager UI, we can also enable the user to cancel individual file uploads, or cancel the whole upload or move." The backend still builds the capability, so this is a deferral and not a removal. What this ticket must still do is not report a cancelled run as a success: an unrecognised terminal state is reported as an error rather than a green outcome
  • actionExecution becomes a keyed collection of runs rather than a single slot, so an upload does not block a selection action or the reverse. The existing one-at-a-time guard is scoped per action rather than global
  • The indicator has a defined rule for concurrent runs (one line per run up to two, then a collapsed count)
  • Jest/Spectator specs cover: multi-file success, partial failure, a terminal state the client does not recognise reported as an error rather than a success, that a single-file upload still takes the existing path unchanged, that no operation raises an in-flight toast, that the context-menu workflow action leaves the grid rendered, and that two runs can be in flight at once without blocking each other

Priority

Medium

Additional Context

Out of scope: directory upload. Dropping a folder from the desktop needs webkitdirectory relative paths and folder creation ahead of the assets. Worth doing, materially more work, and it should not ride in on this ticket. File a follow-up when this lands.

Out of scope: resumable or chunked single-file upload. Large-single-file behavior is unchanged here.

Now IN scope: per-row busy state. (Reversed 2026-09-03.) Marking the individual rows an operation is acting on, so the grid shows which items are in flight rather than only what is running.

It was excluded on the reasoning that "upload has no rows, so this ticket cannot exercise it". That reasoning no longer holds: this ticket also converts the context menu and the drag-and-drop move to the shared indicator, and both act on rows. The feature has two real consumers inside this PR.

It is also cheaper here than anywhere else. dot-folder-list-view already swaps per row inside its #body template, and $lockedByOthers is an existing keyed per-row input in the same file, already keyed by inode. The store now exposes busyRows from the run registry.

Additional acceptance criteria:

  • While an operation runs on a row, that row shows it and stays readable and identifiable — not replaced by a skeleton, which loses the row the author wants to watch
  • The rest of the grid stays usable; rows the operation is not touching behave normally
  • Busy marks are keyed by inode, not identifier: the language filter is multi-select, so one identifier can occupy several rows and marking by identifier would mark siblings nothing is happening to
  • The settling reload does not blank the table: it refetches quietly, leaving rows visible, and the busy marks clear once fresh data lands. This keeps the outcome filter-correct — an archived or unpublished row simply is not in the new result — which an optimistic in-place update could never be, since the client cannot know whether the new state still matches an active filter
  • Busy marks clear on the failure path too, or rows stay busy forever
  • dot-folder-list-view is given dataKey="inode". It currently takes the 'identifier' default, so selecting one language version of a contentlet selects them all. The codebase already assumes inode — the search service backfills folder inodes with a comment saying the table keys on it — the shell simply never passes it

#37322 was filed for this and is superseded; it should be closed once this lands.

Out of scope: reindex. content-drive.action-center.toast.reindex-started stays as it is. Its opt-out from the indicator is deliberate and documented, and the notification bell already records the run.

This ticket owns the core-web job-progress primitive. Nothing in core-web consumes /v1/jobs today, so the first consumer has to build it, and upload is the right owner: #32356 asks for the richest version of it (per-file progress, per-file status, cancel one file or the whole batch), so a primitive that satisfies upload satisfies the folder actions too, while the reverse is not true.

Requirements for it, so #37063 can consume it without changes:

  • submit, resolve jobId, follow the job, expose progress, expose the terminal state and the result metadata
  • follows jobs over GET /v1/jobs/{jobId}/monitor with native EventSource, falling back to status polling. Worth noting: dot-content-drive-action-center.component.ts:126 records that the legacy _bulkfire SSE path was skipped because "native EventSource cannot POST a body". That objection does not apply here, since submission is a normal POST returning a jobId and monitoring is a separate GET
  • may expose cancellation over POST /v1/jobs/{jobId}/cancel, but nothing in this ticket surfaces it; the control is [EPIC] Task manager UI to manage large uploads and moves #33331's. The primitive's API should not be shaped to preclude it
  • a running job survives component teardown and navigation, and its outcome stays discoverable
  • generic in its API: no upload-specific or Content-Drive-specific shapes

Depends on #37062 for the job-result contract (successCount / failCount / results), which the folder endpoints define first.

#37063 consumes the primitive for folder copy and bulk delete. Whichever ticket lands first builds it; the specification lives here. Do not build it twice.

Related: #37062 (job-result contract), #37063 (consumes the job-progress primitive for folder copy and delete), #35436 (parent epic), #32356 (the async-upload feature this implements), #36702 (AssetPicker reusing Content Drive, second consumer of the same upload path).


Amendment — 2026-09-01

Recorded during /speckit-specify for the backend half (spec PR #37300, reviewed by
@fabrizzio-dotCMS). These supersede the corresponding statements above; everything not listed
here still stands as originally written.

1. The job-result contract: dependency direction reversed

Above: "Depends on #37062 for the job-result contract (successCount / failCount /
results), which the folder endpoints define first."

Now: this ticket owns that contract; #37062 and #37063 consume it. Both are open and
unbuilt, so waiting on #37062 would block this work on a ticket nobody has started.

And it is not a new contract. The shape already ships: BulkRefreshContentletsProcessor#getResultMetadata
(#36845 / #37131) emits total / processed / successCount / failedCount / skippedCount /
results[], with an immutable per-item record and a SUCCESS / FAILED / SKIPPED status. This
ticket generalizes that shape rather than defining a second one. Three deltas are required:

  • A machine-readable reason code. The shipped per-item record carries a human-readable message
    only. Failures here must distinguish at least: over size limit, disallowed type, name collision,
    permission denied, staged content unavailable, and unclassified.
  • A generic item key. The shipped record is keyed by contentlet identifier and inodes. An
    uploading file has neither — it does not exist until the run creates it — and neither does a
    folder path. A generic key is the substance of the generalization.
  • One spelling of the counters. The shipped counter is failedCount; this issue says
    failCount above. failedCount wins, matching what already ships.

#37062 / #37063: consume this shape. Please do not define a third one.

2. Submission entry point: a domain endpoint, not the generic job upload

Above: proposes reusing POST /v1/jobs/{queueName}/upload because it already consumes
multipart.

Now: a domain endpoint owning its own multipart handling. Two reasons:

  • JobParams declares a single @FormDataParam("file") InputStream, so the generic entry point
    accepts exactly one file today. Carrying a batch through it means changing a shared type
    with other consumers.
  • Submission-time validation. A missing target folder, an upload type that is neither DOTASSET
    nor FILEASSET, or a caller without add-children rights must be refused before a job is
    created. The generic entry point has no notion of a target folder; it would enqueue first and
    fail inside the run.

The client sends one call carrying the files and the batch parameters. Where the bytes wait
for the run is a server concern and stays out of the frontend contract.

3. Per-file size and type limits are inherited, not invented

Above: "Per-file size and extension limits are enforced with the same rules as the
single-file path."

Now: confirmed, and worth stating precisely because it was nearly specified the other way.
ESContentletAPIImpl.validateBinary already enforces a size ceiling (maxFileLength) and an
allowed-types rule (accept), both declared per content type on the binary field, during the
contentlet validation the creation path runs. This ticket applies those, not bulk-specific
ones — a batch that rejected a file the single-file upload accepts would not be equivalent.

Consequence, accepted knowingly: where an operator has configured neither, there is no ceiling.

4. New: a batch file-count cap

Nothing caps a selection today. Default 50 files per batch, configurable, enforced as a
submission-time refusal. There is deliberately no batch-total size limit.

5. New: staged content must outlive the queue wait

Not covered by the acceptance criteria above, and it is where this feature can silently lose a
user's files. The submission is answered before the files are created, so the uploaded bytes wait
somewhere in between. The product's staging mechanism expires content on a global timer
(TEMP_RESOURCE_MAX_AGE_SECONDS, default 1800s) with no per-call override, so a large batch queued
behind other work can outlive its own staged files.

Additional acceptance criteria:

  • Uploaded content stays available to the run until the run reaches a terminal state, however
    long it waits in queue
  • Content that cannot be retrieved is a per-file failure with its own reason — never a silent
    loss, and never reported as though the author supplied a bad file
  • Staged content is reclaimed on any terminal state, including for files the run never reached
  • Staged content is readable by a node other than the one that accepted the submission
  • A stated position on total staged bytes per author across concurrent batches ("none,
    deliberately" is acceptable; silence is not)

6. Frontend: cancellation is being deferred, and needs its own ticket

The frontend acceptance criterion "the indicator offers cancellation for a cancellable run" is
being deferred to the task-manager work. The backend still builds the capability, so this is a
deferral, not a removal.

Resolved: #33331 covers it explicitly, so no new ticket. The epic's own description already
claims this exact capability: "From this task manager UI, we can also enable the user to cancel
individual file uploads, or cancel the whole upload or move."
The frontend spec cites it in six
places and states the reasoning; the acceptance criterion above is now marked deferred rather than
left looking like an undelivered promise.

The one thing kept in this ticket is the half that is not about the control: a run reaching a
cancelled state must not be reported to the author as a success. That is covered by the frontend
spec's FR-024, which reports any terminal state the client does not recognise as an error.

So the backend cancel surface does have a dated consumer (#33331), and until it arrives the client
cannot mistake a cancelled run for a completed one.


Amendment — 2026-09-07

Recorded after /speckit-plan for the backend half, and after the review of spec PR
#37358 by @wezell. These supersede the corresponding
statements above and in the 2026-09-01 amendment; everything not listed here still stands.

The backend spec, wire contract and data model are in specs/37166-bulk-file-upload/.

1. Submission shape: one call, confirmed — and it is the shipped precedent

The 2026-09-01 amendment already said "one call". It was reversed to two steps mid-review and then
restored, so this records the outcome rather than leaving the PR history as the only account.

@wezell's objection was that a second upload endpoint duplicates the temp API. Accepted: the
endpoint hands every part to TempFileAPI.createTempFile as it reads, and stores only the
resulting ids in the job parameters.
It does not stage anything of its own.

Content import is the precedent, not a departure from it. ContentImportResource:102 consumes
MULTIPART_FORM_DATA, and ContentImportHelper:394 calls the staging API internally. An
intermediate draft had described import as though the client staged; it does not.

Two findings decided it against the client-stages-first alternative:

  • The expiry clock. TempFileAPI.getTempFile gates on lastModified() + TTL, per file. If the
    client staged, the earliest files of a large batch could expire before the author reached submit,
    and an unresolvable reference refuses the whole submission. Server-side staging starts that clock
    immediately before the job is created.
  • Bounding. Nothing below this endpoint caps file count or request size. With the bytes already
    written at submission time, the ceilings would be a policy about what gets processed rather
    than a bound on what can be written.

Accepted cost, recorded so it is not rediscovered: no per-file upload progress and no retry of
a single failed file. A dropped connection means resending the batch.

2. Limits — supersedes §3 and §4 of the 2026-09-01 amendment

Then Now
Files per batch 50 100, configurable (CONTENT_BULK_UPLOAD_MAX_FILES)
Batch total size "deliberately none" 1 GB, configurable (CONTENT_BULK_UPLOAD_MAX_TOTAL_BYTES)
Per-file ceiling where the content type declares none "no ceiling, accepted knowingly" 200 MB fallback, configurable

Why a batch total after all. Without one the batch is bounded only by the file count multiplied
by a per-file ceiling that is unset by default — which is to say not bounded at all. TEMP_RESOURCE_MAX_FILE_SIZE
ships as -1, and dotmarketing-config.properties says so in a comment: "authenticated users are
unlimited"
. There is also no container-level multipart limit configured. This endpoint is the
first and only bound in the path.

Why 100 and not more. The two ceilings are chosen together: at a few megabytes per file, 300
would put a realistic batch past the total, making the count cap decorative and the size cap the
one that surprises people. Under the one-call shape the count also governs how long a single
request stays open.

Why the per-file fallback is a knowing exception. Where a content type declares no
maxFileLength, a file over the fallback is rejected in a batch and accepted as a single upload.
That diverges from the equivalence rule this ticket otherwise holds to. It is accepted because the
alternative — no per-file bound at all — was judged worse, and it is documented wherever the limits
are.

Three controls here are new, not one: the file count, the per-file fallback, and the batch total.

3. New: two submission-time controls the endpoint does not inherit

Not covered by any acceptance criterion above.

verifyTempResourceEnabled() and SecurityUtils.validateReferer() live on TempFileResource, not
on the TempFileAPI this endpoint calls. No global filter supplies them for API paths
XSSPreventionWebInterceptor protects a fixed list that does not include /api/.

  • The endpoint applies a same-origin check on the request
  • The endpoint honours TEMP_RESOURCE_ENABLED rather than writing staged content behind
    a switch an operator turned off
  • Both are covered by tests

Severity, stated plainly: the classic CSRF path is already closed by sameSiteCookies=lax in
context.xml, so this is defense in depth rather than a live exploit. It is specified because the
value is operator-configurable and because content import omits both today — following that
precedent uncritically would reproduce the gap. Closing it there is tracked separately.

4. New: the ceilings are enforced while the request is read

The file count and the accumulated total are checked as each part is staged, aborting the read
the moment either is crossed, rather than after the whole body has arrived.

  • A submission over either ceiling cannot commit more than the ceiling to disk
  • Content staged before the abort is reclaimed
  • Content staged before a submission that dies while being read — the author navigated away,
    or the connection dropped — is also reclaimed. This is a different code path from a refusal:
    nothing is raised by this side, so a reclaim scoped to the refusal path never runs. Nothing
    purges staged content on a schedule, so what leaks here leaks permanently

A declared totalSizeBytes may be sent for a fast refusal before reading. It is a courtesy, never
the enforcement point.

5. Index policy, made concrete

Above: "Files are not indexed with WAIT_FOR per item; the batch indexes once, or the job's
completion drives the reload."

Now: each file is created with IndexPolicy.DEFER, and the run resolves index visibility
once at the end of the batch, before the completion signal is emitted. Both halves are needed:

  • Per-file WAIT_FOR does not merely block on a refresh — indexContentListWaitFor also flushes
    the system-wide query cache on every file, so a full batch charges every other user one flush per
    file.
  • DEFER alone is insufficient: it only enqueues into dist_reindex_journal. A run reporting
    "finished" would hand the author files a text search cannot yet find. Per
    ADR-0018
    the plain folder listing is database-resolved, so the grid is fine either way — but free-text and
    searchable-field criteria are index-routed, and that ADR names this exact failure as a recurring
    complaint.

The frontend depends on the ordering: it refreshes its listing on the completion signal, so
emitting the signal first would surface as a client-side defect for a cause living entirely on the
server.

6. Target fields: folderId / siteId, not targetFolder

Above: "Multipart: the files, plus targetFolder (or site id at the root)".

Now: two explicit, mutually exclusive fields. The workflow API this feature fires already
separates contentHost (site) from hostFolder (folder), and Content Drive currently sends a site
id in hostFolder at the root — the overloading
ADR-0020
moves away from. Exactly one of the two must be present.

The multipart shape follows content import: repeated files parts plus a JSON form part carrying
baseType, folderId | siteId and the optional totalSizeBytes. A batch over the total size
answers 413; the count, shape and origin refusals answer 400.

7. New scope: a resumable run, and a safe resubmission

Not covered by any acceptance criterion above, and the largest piece of hidden work in this
ticket
.

The job framework persists no mid-run per-item state: parameters are immutable after creation,
progress is a single float, and result is harvested once at the terminal state. Meanwhile
AbandonedJobDetector re-queues a stalled run without consulting the retry policy, so an
interrupted run is retried whether or not the processor is marked no-retry.

A re-run without resume does not duplicate data — the unique index on the lower-cased path rejects
the second create — but it makes the report lie: the files the first attempt created come back
as NAME_COLLISION, so the author is told 30 files failed when all 30 are in the folder.

  • The run records completed items durably as it goes, in a new additive job_item_result table
  • A re-queued run continues from that record instead of restarting
  • Counts reflect the whole batch across all attempts, and the submitter is notified once
  • Resubmitting after a lost or uncertain response never silently creates a second copy, and a
    duplicate resubmission is distinguishable from a batch whose files genuinely all collided

The table is named for jobs, not for uploads, on purpose: #37062, #37063 and #37165 all need the
same thing, and four near-identical tables would be the wrong outcome. Whether durable per-item
state belongs in the job framework itself is being proposed as an ADR in dotCMS/platform-adrs.

8. Where the batch guarantee starts — affects a frontend acceptance criterion

Every guarantee in this ticket — surviving the author leaving, resumability, cancellation, the
durable outcome — is a property of a run, and a run does not exist until the submission is
answered with a handle.

The frontend criterion "Navigating away does not lose the batch" therefore holds after the
202, not during the upload. If the author navigates away while the bytes are still being sent,
that request dies and no job is created: nothing is recorded and nobody is notified, because there
is nothing yet to record.

Agreed with @wezell on #37358 and accepted rather than engineered around. The cheap mitigation,
which the frontend half should take: dotAdmin is a single Angular application — legacy portlets are
hosted inside the same shell — so navigating between portlets is a route change and the document
survives. An upload request owned by a root-level service rather than by the Content Drive
component therefore survives that navigation at no extra cost
, which the job-progress primitive
already requires. A beforeunload guard covers a genuine page unload. Only a Service Worker would
survive the document being destroyed, and dotAdmin registers none; that is judged disproportionate.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions