Skip to content

feat(content): add bulk refresh endpoint and enable the Refresh quick action - #37131

Open
rjvelazco wants to merge 21 commits into
mainfrom
issue-36845-workflow-center-add-a-refresh-quick-action-that-reindexes-the-selection
Open

feat(content): add bulk refresh endpoint and enable the Refresh quick action#37131
rjvelazco wants to merge 21 commits into
mainfrom
issue-36845-workflow-center-add-a-refresh-quick-action-that-reindexes-the-selection

Conversation

@rjvelazco

@rjvelazco rjvelazco commented Aug 20, 2026

Copy link
Copy Markdown
Member

Fixes #36845

Adds a Refresh quick action to the Content Drive Action Center that reindexes every selected contentlet, and the bulk endpoint behind it.

The single-item PUT /api/v1/content/_refresh/{identifierOrInode} already existed; there was no bulk equivalent over REST (only the legacy Struts cmd=full_reindex_list). This adds one rather than looping _refresh N times from the client — a client-side loop gives no cancellation, no survival across a page reload, and no server-side bound on cost.

This is not a full index rebuild. POST /api/v1/esindex/reindex is a different operation over the whole index and is untouched.

Endpoints

Method Path Purpose
POST /api/v1/content/_bulkrefresh Submit a selection → 202 + {jobId, statusUrl, submitted}
GET /api/v1/content/_bulkrefresh/{jobId} Status snapshot; poll for progress and the final result
POST /api/v1/content/_bulkrefresh/{jobId}/cancel Request cancellation

Key decisions

Job-backed, not synchronous. Reindexing a 500-item selection synchronously would hold a request open for minutes. 202 rather than 200 because the work is accepted, not done — a client must not be able to read the response as "reindexed".

Indexing is synchronous per item (IndexPolicy.WAIT_FOR). The default DEFER only enqueues into dist_reindex_journal and returns, which is why the single-item endpoint can answer true with nothing reindexed. This endpoint exists so that "done" means done — and there's an integration test that deletes content from the index and asserts it's findable again once the job reports SUCCESS, which only holds if the write is synchronous.

Per identifier, not per inode. Content missing from search is rarely confined to one language, so three selected language rows of the same contentlet are one reindex. Each result still names every inode that resolved to it, so the client can settle each row the user selected.

Not a workflow action or a new SystemAction. Bulk fire resolves its target set by searching the index (WorkflowAPIImpl builds a +inode:( … ) +(wfstep:…) Lucene query), which is circular for an operation whose job is to fix the index — content missing from the index cannot be found by an index search, so the items that most need this are exactly the ones that path could never reach. The processor goes DB-first via findAllVersions. It would also inherit hardcoded IndexPolicy.DEFER, silent wfstep skips, and workflow side effects (actionlets, new versions, task/history rows) for an operation that changes no content.

Unresolvable inodes are per-item failures, not request errors. A selection can go stale between the click and the submit; one dead row must not cost the caller the rest of the batch.

Role-gated server-side on CMS Power User or CMS Administrator, matching the legacy view_contentlets.jsp check, so nobody who could press the old button loses access and nobody who could not gains it. "Always available" in the issue means not gated by content state — that still holds.

Capped by CONTENT_BULK_REFRESH_MAX_ITEMS (default 500), and no Lucene query is accepted: an unbounded set plus synchronous writes is a self-inflicted full reindex.

Frontend

Refresh was already rendered but disabled. It's now selectable for any selection with ≥1 contentlet regardless of live/archived/locked state, goes straight to the preview (a reindex needs no configuration), and reports through the existing store settle path — so the toast, grid reload and selection clear all come unchanged.

It gets its own toast copy. The default partial-outcome message names workflow causes — "failed (you may not have permission, or the content is locked by another user), skipped (this action is not on their workflow step)". Neither is why a reindex falls short, and a shortfall explained by the wrong cause sends the user off to fix something that was never the problem. So DotContentDriveActionExecutionResult gained an optional partialDetailKey; absent, the default still applies, leaving workflow actions byte-identical.

Final toast:

  • Clean run → success: "Refresh ran on 12 item(s)."
  • Anything short → warning: "Refresh: 9 reindexed, 2 failed (the content could not be read or written to the index), 1 skipped (the reindex was cancelled before reaching them)."

Testing

Suite Result
Backend unit (processor + form) 18/18 passing
portlets-content-drive (full) 1165/1165 passing
dot-bulk-refresh service 7/7 passing
Lint — content-drive, data-access, models clean
Integration (14 tests) written, compiling, not yet run
Postman (BulkRefreshResource) written, not yet run

Backend unit tests were confirmed failing first (14 errors, all UnsupportedOperationException) before implementation, per the constitution's TDD gate.

⚠️ The integration and Postman suites have not been executed — no Docker daemon available locally. They need a run before merge. The integration test that matters most is test_bulkRefresh_makesContentMissingFromTheIndexFindableAgain, which is what proves the WAIT_FOR policy actually took effect.

Video

video.mov
video.mov

Reviewer notes / open items

  • AC update with latest SVN #1 is unmet: the issue asks that the role-gating decision be recorded on the issue. It's implemented and documented in code, but the issue still has no comment saying so.
  • No client-side role gate. core-web has no power-user awareness — currentUserIsAdmin comes from getCurrentUser().admin and there's no role list — so the only gate available would hide Refresh from exactly the users the legacy button was written for. A visible action that answers 403 through the existing error handler beats silently withholding a capability from people who have it. Flagging in case you'd rather add a lookup.
  • No live counters. The status endpoint reports a progress float but nothing item-wise mid-run, so the dialog can show a percentage but not "5 of 12". Deliberate, per PO discussion. Adding them means surfacing the processor's counters via JobQueueManagerAPI.getInstance(jobId) on the status endpoint.
  • No SSE. An earlier revision had an SSE progress stream; removed after PO discussion. The SSEMonitorUtil overload it needed was reverted, so that shared util has zero diff against main.
  • No cancel control in the UI. The endpoint supports it; there's no affordance since the dialog closes on execute.
  • Unrelated pre-existing failure: PushPublishService fails 4 tests (2026-08-20 expected vs 2026-08-19 received) — its spec builds the expected date from new Date().toISOString() (UTC) while the service uses local time, so it fails every day after ~20:00 local. Untouched by this PR; worth its own ticket.
  • openapi.yaml is regenerated (+132) and committed alongside the annotations, per the repo rule.

🤖 Generated with Claude Code

rjvelazco and others added 3 commits August 19, 2026 22:06
POST /api/v1/content/_bulkrefresh is the bulk counterpart of the existing
single-item PUT /api/v1/content/_refresh/{identifierOrInode}, which is
unchanged. Not a full index rebuild -- POST /api/v1/esindex/reindex is a
different operation and stays untouched.

Job-backed: the POST answers 202 with a job id and a status URL, and the
client polls that URL until the job is terminal. Reindexing a large
selection synchronously would hold a request open for minutes.

Indexing is deliberately synchronous per item (IndexPolicy.WAIT_FOR). The
default DEFER only enqueues into dist_reindex_journal and returns, which is
why the single-item endpoint can answer true with nothing reindexed. This
endpoint exists so that "done" means done.

Work is done per identifier across all its versions, not per submitted
inode: content missing from search is rarely confined to one language, so
three selected language rows of the same contentlet are one reindex. Each
result still names every inode that resolved to it, so a client can settle
each row it selected.

An inode that no longer resolves is a per-item failure, not a request
error -- a selection can go stale between the click and the submit, and one
dead row must not cost the caller the rest of the batch. Failures are caught
per identifier for the same reason.

Not modelled as a workflow action or a SystemAction: bulk fire resolves its
target set by searching the index, which is circular for an operation whose
job is to fix the index. Content missing from the index cannot be found by
an index search, so the items that most need this are exactly the ones that
path could never reach. The processor goes DB-first via findAllVersions.

Gated on CMS Power User or CMS Administrator, matching the legacy button in
view_contentlets.jsp, so nobody who could press it loses access and nobody
who could not gains it. Capped by CONTENT_BULK_REFRESH_MAX_ITEMS (default
500) and accepts no Lucene query: an unbounded set plus synchronous writes
is a self-inflicted full reindex.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unit tests drive the processor through mocked collaborators: per-identifier
de-duplication, per-item failure isolation (including a DotSecurityException
from findAllVersions as an item failure rather than a job failure), the
cancel boundary, and the WAIT_FOR policy reaching the index call. Counters
are read back through getResultMetadata -- the same path production reads --
rather than a test-only accessor.

The integration test that matters most deletes a contentlet from the index
behind dotCMS's back and asserts it is findable again once the job reports
SUCCESS. That is what actually proves indexing is synchronous: under DEFER
the job would report SUCCESS having only written a journal row, and this
assertion would fail.

The cancel test asserts the invariant rather than winning the race. Whether
cancellation lands mid-flight or the run finishes first, the counters still
close over total; a test that depended on that timing would be flaky rather
than informative.

Postman covers the contract outside the UI: submit, poll to terminal, and
the 400/404 paths. Registered in the default-split group alongside
ContentImportResource, its job-backed peer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refresh was rendered but disabled in the Content Drive Action Center. It is
now selectable for any selection containing at least one contentlet,
regardless of live, archived or locked state -- none of those affect whether
the index copy of a contentlet is stale, which is all a reindex fixes.

DotBulkRefreshService submits the selection and polls the job to completion,
emitting the final counters once. The first poll is immediate so a fast job
is not held back by the interval, polling stops on the terminal value
itself, and a five-minute cap keeps a job on a dead node from leaving the
toolbar claiming work is in progress for the rest of the session.

No live counters. The status endpoint reports a progress float but nothing
item-wise while the job runs, so the outcome lands once, at the end.

The run reports through the same store settle path as every other quick
action, so the existing toast, grid reload and selection clear all apply
unchanged -- but with its own copy. The default partial-outcome message
names workflow causes ("you may not have permission, or the content is
locked", "not on their workflow step"); neither is why a reindex falls
short, and a shortfall explained by the wrong cause sends the user off to
fix something that was never the problem. So
DotContentDriveActionExecutionResult gained an optional partialDetailKey and
Refresh supplies its own. Absent, the default still applies, leaving
workflow actions byte-identical.

A finished job that reports no counters is routed to the error handler
rather than given a substituted count: the inode count would claim every
item was reindexed and zero would claim none were, and the first errs in
the reassuring direction.

Not gated client-side, even though the endpoint requires CMS Power User or
CMS Administrator. core-web has no power-user awareness -- currentUserIsAdmin
comes from getCurrentUser().admin and there is no role list -- so the only
gate available would hide Refresh from exactly the users the legacy button
was written for. A visible action that answers 403 is a better failure than
a capability silently withheld from people who have it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Aug 20, 2026
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 6m 8s —— View job


Code review — bulk refresh (reindex the selection)

Reviewed the latest state (ed8e4bab) across the whole feature: processor, helper, resource, completion listener, form, the value/view classes, the system-event + startup registration, Language.properties, and the frontend store feature, service, action-center wiring, shell effect and models.

I re-checked each finding from the earlier rounds against the current code and they hold up:

  • Cache eviction is inode-keyed and cold-before-read. refresh() evicts submitted inodes and every version inode via VersionableAPI before contentletAPI.findAllVersions, then re-evicts through the Contentlet overload for the derived page/site/relationship caches. The read that feeds the index is genuinely cold.
  • Empty-versions no longer counts green. versions.isEmpty() routes to fail(...), so a vanished identifier is a failure, not a silent success.
  • Counter accounting closes. total is fixed to the collapsed work-item count up front and every item increments exactly one of success/failed/skipped (including the unresolved and empty-version paths), so success + failed + skipped == total in every terminal state — which is exactly what the frontend closes guard relies on. Cancellation skips the remainder, so it still closes.
  • jobId correlation. The completion event carries jobId, and reportRefreshCompleted only settles ids in refreshJobIds, so another tab / Login-As run no longer toasts over this grid.
  • Failure vs cancellation vs partial are distinguished correctly in both the notification (CANCELED → WARNING, state != SUCCESS || (0 succeeded && failed>0) → ERROR) and the toast path.
  • Empty body → 400, listener registered at startup (not a lazy CDI bean), and the message keys used all exist in Language.properties.

I did not find any new blocking issues introduced by this PR.

Non-blocking notes

  • AbstractBulkRefreshSubmitResponse.jobId() (dotCMS/…/bulkrefresh/AbstractBulkRefreshSubmitResponse.java:23) and DotBulkRefreshSubmitResponse.jobId (core-web/…/dot-content-drive.model.ts:315) both document the id as "the handle for the cancel call." This PR removed the dedicated cancel endpoint (completion is push-only now). The id is still cancellable through the generic /api/v1/jobs/{jobId}/… API, so the comment isn't wrong, but "the cancel call" reads as a dedicated endpoint that no longer exists — worth a one-word tweak next time either file is touched. Not worth its own commit.

Looks good to me. ✅

Two CI failures from the first run, both in test wiring rather than in the
endpoint.

The Postman collection read entity.result.metadata. JobView.result() is
serialised by OptionalJobResultSerializer, which flattens the metadata map
straight into `result` rather than nesting it under a `metadata` key -- so
that path is undefined over HTTP, and reading .successCount off it threw a
TypeError. The Java accessor really is JobResult.metadata(), which is why
the integration tests are right and only the HTTP layer was wrong. Left a
note at each call site so the "obvious" fix back to .metadata is not made
later.

BulkRefreshResourceIntegrationTest was in no @suite, so CI silently never
ran it -- 14 tests that would have stayed dead weight in this PR and every
future one. Registered in Junit5Suite1 next to
ContentImportResourceIntegrationTest, its closest analogue and the other
job-backed endpoint in that suite.

Also dropped a setTimeout from the poll loop that paced nothing (Newman does
not honour it) and added a terminal-state assertion to the no-results poll,
so exhausting the poll cap reports that it stopped waiting instead of
throwing on a null result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cess

Code review found two Critical bugs, both in the frontend's reading of the
job status response.

The client read `result.metadata`, which does not exist over HTTP.
OptionalJobResultSerializer flattens the job's metadata map straight into
`result`, so that path was always undefined: refresh() always emitted null,
executeRefresh always took the no-counters branch, and every successful
reindex surfaced as an error toast with the grid never reloading. The same
flattening had already been found and fixed in the Postman collection last
commit, and documented in a comment there -- the TypeScript model was never
brought in line, and the service spec built its fixture in the nested shape,
so the suite went green against a contract the server never emits. The
fixture is now the real wire shape and a test pins the flattened read
specifically.

The client also discarded job.state. A job that dies mid-run still carries
the counters it had reached, so an all-zero result from FAILED_PERMANENTLY
was indistinguishable from a clean run over nothing and rendered as
"Refresh ran on 0 item(s)" in green. State now travels with the counters,
and a run is only reported when it settled in SUCCESS or CANCELED *and* its
counters close over total -- which also catches a job that stopped after 3
of 10 and would otherwise have hidden the 7 never attempted.

Three silent-failure modes in the poll pipeline:

- switchMap cancelled a status call that outlived the 1500ms interval, so
  under sustained latency no poll ever completed and only the overall
  timeout ended the run. Now exhaustMap.
- A single transient poll failure abandoned a run the server was still
  progressing. Now retried three times.
- A bare TimeoutError has no `status`, so the shared HTTP error handler
  matched no handler and said nothing at all -- indicator cleared, grid
  stale, no explanation. Now normalised to a 504.

Status and cancel were gated only on "is a backend user", which made the
403 on submit decorative: any backend user could cancel a Power User's
in-flight reindex, or read a job back and recover the submitted inode list
and the submitter's id from its parameters. Both now require canRefresh.

Also: the class javadoc overclaimed. WAIT_FOR guarantees the write is
attempted rather than enqueued, but the underlying bulk call logs
per-document failures without throwing, so SUCCESS is not a per-document
guarantee. Scope of the claim is now stated. Removed SSE references left
behind when SSE was dropped, gave DotBulkRefreshJobState a real union
instead of one collapsing to string, guarded NaN progress on an empty work
list, and restored alphabetical order in the data-access barrel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run 3 failed on "expected [ 'SUCCESS', 'CANCELED', ... ] to include 'PENDING'":
the poll loop burned all 60 attempts while the job was still queued, and gave
up before it had even started running.

Removing the no-op setTimeout last commit was correct — Newman does not honour
it — but it left the loop with no pacing whatsoever, so 60 polls fired in under
a second. Counting attempts was never the right bound either; what matters is
elapsed time. The loop now waits a real 500ms per poll and gives up after 60
seconds of wall clock rather than after N attempts.

The pacing is a short synchronous wait because that is the only thing that
actually delays inside a Postman sandbox. It runs once per poll, so each script
execution stays far below any sandbox timeout even though the loop as a whole
can span a minute. The sibling ContentImportResource collection keeps a
setTimeout that likewise does nothing; what carries it is its own elapsed-time
bound, which is the part worth copying.

Failure now names the last state seen, so "queued and never picked up" is
distinguishable from "ran and died" instead of both reading as an opaque
"did not reach terminal".

The rest of the collection passed: 64 assertions executed with this as the only
failure, and Check Terminal Result's assertions passed, which confirms the
entity.result path fix from the previous commit is correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ration test

First real execution of these tests: 7 of 12 errored, in two distinct ways,
both in the test's own setup rather than in the endpoint.

Five timed out after 120s waiting for a terminal state. The job queue accepts
jobs but does not process anything until it is explicitly started, so all 25
jobs created during the run sat at state=PENDING and the processor never ran
once -- zero "reindexing" log lines against 25 "created by user" lines.
ContentImportResourceIntegrationTest shares this suite and never noticed,
because it only asserts job *creation*: 24 tests in under nine seconds. This
was the first test here that actually needed a processor to execute.
JobQueueManagerAPIIntegrationTest already had the answer -- start() plus
awaitStart() behind an isStarted() guard -- so that is what this now does.

The power user case failed differently and instantly: loadRoleByKey answers
null for a role this database does not have, rather than throwing, and
doesUserHaveRole(user, null) is then silently false. So the lookup was passed
straight into user creation, producing a "power user" holding no such role and
a permission check that could only ever fail. Now created if absent, mirroring
TestUserUtils.getOrCreateAdminRole.

Worth noting for review: canRefresh is the same expression as
view_contentlets.jsp:209, so the production gate is faithful to the legacy
one. But both inherit that silent null -- on an install without the CMS Power
User role, every Power User is denied and nothing says why. Not introduced
here, and not changed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@zJaaal zJaaal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Read the full diff and cross-checked the backend against the APIs it calls. This is unusually well-documented work and the architecture calls hold up: job-backed over synchronous, per-identifier over per-inode, and the reasoning for not modelling this as a workflow action or SystemAction is correct (bulk fire really does resolve its target set by searching the index, which is circular here).

One finding is worth blocking on, the rest are cleanups. Inline comments have the detail.

🔴 The cache eviction is a no-op and defeats the operation. contentletCache.remove(identifier) against an inode-keyed cache evicts nothing, and findAllVersions then reads back through that same cache. A stale cached version gets written to the index, which is the failure this endpoint exists to fix. Same call exists in the single-item _refresh, so it's inherited rather than new.

🟠 Every status poll returns the full submitted inode list (JobContract.parameters() isn't @JsonIgnored), ~200 times per run at up to 500 UUIDs each.

🟠 The integration test starts the shared job queue and never stops it, in a suite that also runs ContentImportResourceIntegrationTest and JobProcessorDiscoveryTest.

🟡 Also: the toast counts identifiers where the dialog counted rows; BulkRefreshHelper's no-arg constructor builds a bean that NPEs; status/cancel accept any job id regardless of queue; the two 200 responses are missing @Schema against the repo rule; the poll retries 401/403/404.

Verified as correct

Worth stating explicitly, since these are the load-bearing claims:

  • IndexPolicy.WAIT_FOR genuinely bypasses the journal (ContentletIndexAPIImpl.java:2308). The inTransaction() branch below it doesn't apply here: processNextJob is @CloseDBIfOpened, not @WrapInTransaction.
  • The "counters sit directly on result, not under metadata" comment in dot-content-drive.model.ts is exactly right per OptionalJobResultSerializer.
  • @Dependent plus processorInstancesByJobId.computeIfAbsent gives one processor instance per job, so the mutable counters don't leak between runs.
  • Jdk8Module is registered on the job queue's mapper, so Optional<String> identifier() round-trips through the persisted metadata.
  • Path routing (matches the /v1/content/_import precedent), CDI discovery, and exception mapping all resolve: IllegalArgumentException → 400, DotSecurityException → 403, DoesNotExistException → 404, and the form's ValueInstantiationException → 400.
  • The FAILEDFAILED_PERMANENTLY transition the frontend depends on does happen: handleJobFailure marks FAILED and requeues, then processJobWithRetry promotes it via handleNonRetryableFailedJob. Worth knowing the gap between the two is bounded only by the 5-minute UI timeout, so a slow promotion surfaces to the user as the 504 "stopped waiting" copy rather than as a failure.

On the open items in the description

The two that look blocking are the ones already flagged: the integration and Postman suites have never been run, and test_bulkRefresh_makesContentMissingFromTheIndexFindableAgain is the only thing standing behind the central WAIT_FOR claim. The no-client-side-role-gate decision is defensible as argued. AC #1 is a one-comment fix on the issue.

🤖 Reviewed by Claude Code, posted by @zJaaal.

Comment thread core-web/libs/data-access/src/lib/dot-bulk-refresh/dot-bulk-refresh.service.ts Outdated
@rjvelazco
rjvelazco marked this pull request as draft August 20, 2026 20:26
…onse

Review found the cache eviction was a no-op that defeated the operation, and
it was right.

ContentletCache is keyed by inode: ContentletCacheImpl stores under
add(inode, contentlet) and remove(Contentlet) delegates to
remove(getInode()). Removing by identifier evicted nothing. That matters more
here than almost anywhere, because findAllVersions reads back *through* that
cache -- ESContentFactoryImpl.findContentlets serves hits straight from it --
so a stale cached version was loaded and written to the index. The comment
above the call claimed it prevented exactly that. Now the submitted inodes and
every returned version are evicted by inode, and a second read supplies what
gets indexed, so the indexed object came from the database rather than the
cache. Inherited from the single-item _refresh, which still has it.

The status endpoint returned JobView, which serializes parameters() -- for
this queue the entire submitted inode list plus the submitter's id, up to 500
UUIDs, on every one of the ~200 polls a run attracts. Replaced with a view
carrying id, state, progress and the result metadata, keeping the flattened
result shape clients already read.

The polling subscription needed takeUntilDestroyed. take(1) sits on the outer
observable, which does not emit until the poll loop has finished, so it could
not stop an infinite timer; the store is component-scoped, so leaving the
portlet orphaned a poll that kept firing, could raise a global error dialog
minutes later about invisible work, and would patch a store already replaced.

Also: versionsIndexed now counts inside the loop so a partial failure reports
the versions that did land; status and cancel reject job ids belonging to other
queues rather than reaching across them; both 200s declare a @Schema per the
repo rule; the poll no longer retries 401/403/404, which cannot improve on
being asked again; the integration test hands the job queue back in the state
it found it; and its cancel test no longer swallows every exception.

Two pushed back on. The no-arg constructor on BulkRefreshHelper mirrors
ContentImportHelper:93 exactly -- the cited precedent does have one -- and
removing it risks an unproxyable-bean deployment failure, so it stays with the
reason written down. And creating the CMS Power User role when absent is not a
dead branch: this database genuinely lacks it, which is why that test failed
before the helper existed, so failing loudly would only reinstate the failure.
It now logs when it fires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client polled GET /_bulkrefresh/{jobId} every 1.5s for up to five minutes
-- roughly 200 requests per run -- because SSE had been removed and nothing
else told it the job had finished. That endpoint is now gone, and completion
arrives over the websocket the admin UI already holds open.

A listener on JobCompletedEvent pushes a BULK_REFRESH_COMPLETED system event
carrying the run's counters, scoped with Visibility.USER to whoever submitted
it, and records a notification so the outcome survives navigating away. The
Content Drive store subscribes and feeds the existing settle path, so the
toast copy, severity, grid reload and selection clear all behave exactly as
before.

JobCompletedEvent rather than the processor tail: it fires for every terminal
state including permanent failure, so the failure path needs no
catch-and-rethrow. JobFailedEvent is deliberately unused -- it signals a
*retryable* failure, where the job returns to the queue and is not finished.
And we push our own event rather than relying on job events reaching the
browser, because those travel as CLUSTER_WIDE_EVENT, which is explicitly
excluded from the websocket.

A custom event rather than the generic MESSAGE toast keeps i18n on the client
instead of having the server compose user-facing copy, renders in Content
Drive's own toast, and lets the same handler trigger the grid reload.

Improves on the legacy batch reindex rather than copying it. That one sets no
IndexPolicy, so it defers to the journal and its "finished successfully" means
enqueued, not searchable; it runs inline on the request thread at 50 items or
fewer; it announces completion to every CMS Administrator in the system user's
locale even when every item failed; and it raises one notification per failed
inode. This keeps WAIT_FOR, always backgrounds, tells only the submitter, and
words the notification on what actually happened.

Deleted with the endpoint: the status view types, the whole RxJS poll pipeline
(timer/exhaustMap/retry/takeWhile/last/timeout), and five now-unused model
types. The Postman collection loses its poll requests, so completion can no
longer be observed over HTTP -- that assertion now rests on the integration
test alone.

Verified: eslint clean on data-access, dotcms-models and portlets-content-drive;
nx format:check clean; 24 backend unit tests and 1172 frontend tests passing,
including a test that the socket subscription is actually wired rather than
just the reporter it calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal zJaaal added the PR: docker image Build & push a per-PR test image to dotcms/dotcms-test label Aug 21, 2026
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

🐳 PR Docker test image

Latest build for commit ed8e4ba pushed to dotcms/dotcms-test:

docker pull dotcms/dotcms-test:pr-37131-issue-36845-workflow-center-add-a-refresh-quick-action-that-reindexes-the-selection
docker pull dotcms/dotcms-test:pr-37131-issue-36845-workflow-center-add-a-refresh-quick-action-that-reindexes-the-selection_ed8e4ba

Reported from manual testing: the toolbar sat on "applying refresh to 2 items"
forever even though the POST returned 202 and the job finished.

Two separate causes. The first was already fixed: before the listener was
registered at startup it was never constructed, so the completion event was
never pushed at all. Any build predating that commit shows exactly this.

The second is this commit. Removing the poll loop removed its five-minute
timeout with it, and nothing replaced it, so if the completion event never
arrives there is no request whose failure surfaces and the run stays marked
in flight for the rest of the session. That marker is the in-flight guard on
every quick action here, so a stuck refresh also locked out Lock, Unlock and
Add to Bundle, with only a page reload to escape. The event can legitimately go
missing without anything being broken: system events disabled, the socket
dropped between submit and completion, or the job abandoned on a dead node.

A bounded wait now clears the marker and reports that we stopped waiting rather
than claiming an outcome, since there are no honest counts to report. The run is
held by object identity, not a flag, so a timer belonging to a finished run
cannot clear a later one -- a flag would already have been cleared by the first
settle.

Three tests cover it, one of which caught its own flaw first: ticking the full
timeout fired both runs' timers at once, which proved nothing until the two runs
were staggered in time.

Verified: eslint clean on the three touched core-web projects, nx format:check
clean, 24 backend and 1279 frontend tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n fires

Four changes to Refresh and one to every action.

Refresh no longer sets actionExecution. That single field drove the toolbar's
"Applying ... to N item(s)" indicator and locked the Action Center, and neither
suits a job that runs for minutes and reports no progress. So the indicator is
gone for Refresh, the dialog stays usable while a reindex runs, and a toast at
trigger says the work continues in the background. The completion toast is
untouched, because the shell effect reads only actionExecutionResult.

Double-firing is still refused, via a dedicated refreshInFlight flag rather than
the shared marker: it guards the reindex against itself without blocking Lock,
Unlock, Add to Bundle or workflow actions. The five-minute timeout is repointed
at that flag rather than deleted -- its old job disappeared with the indicator,
but the new flag can get stuck the same way if the completion event never
arrives.

reportRefreshCompleted no longer clears actionExecution on any branch. That was
harmless while a reindex held the only lock; now that it does not, a reindex
completing mid-Lock would have un-gated Lock early and published a Refresh
toast over it. There is a regression test for that specific collision.

Firing any action now clears the grid selection, at the single hand-off point
every action already funnels through -- and deliberately not when the dialog is
merely dismissed, since the user may still be building a selection. This also
closes existing desync: selection previously only cleared via loadItems in the
result effect, so it never cleared on the reindex timeout, on any catchError
branch, or when loadItems hit an early return, where the boxes stayed checked
permanently.

Clearing the store alone would not have unchecked anything. The shell passed no
[selection], leaving the grid in its uncontrolled mode where it owns its own
checked set and only drops it when the items reference changes. The shell now
binds it, putting the grid in the controlled mode the action preview already
uses. Verified that pagination, sort and filter still clear: they route through
the search effect to loadItems, which empties the selection in the store.

Also removed three console.log lines that were sitting uncommitted in the tree.

Verified: eslint clean across data-access, dotcms-models, portlets-content-drive
and ui (import-order errors the auto-fixer caught are fixed), nx format:check
clean, 1283 + 197 + 5 frontend tests and 24 backend tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A review scoped to stale artefacts found that iterating on this feature left
real drift, plus two bugs that reproduce the exact misleading success the
endpoint was written to remove.

Bugs first.

The trigger toast fired unconditionally. executeRefresh returns early when a
reindex is already in flight, but the toast and the hand-off ran regardless -- so
firing Refresh again within the five-minute window told the user a reindex had
started, cleared their selection, and submitted nothing.

A settled run's timeout could abort a later one. Each executeRefresh armed a
five-minute timer that was never cancelled on completion, so run 1's timer fired
long after run 1 finished, saw run 2's flag set, and cleared it with a bogus 504.
This was a regression I introduced: the identity check that used to prevent it
went away with the switch to a boolean flag, and I deleted its test in the same
edit -- a test whose own comment had warned that a flag would not survive this.
The timer is now held and released wherever a run ends. Both bugs have tests
that fail against the previous commit.

Then the staleness. statusUrl was still built, returned, typed, documented,
asserted in the integration test and asserted in Postman -- pointing at the
GET /_bulkrefresh/{jobId} endpoint that was deleted when polling was replaced by
push. Every client that read it would have got a 404. Removed end to end, along
with the now-unused request parameter it needed, an orphaned javax.ws.rs.GET
import, and two Postman variables left over from the deleted request.

Also corrected roughly a dozen comments that still described the polling design,
including two that contradicted a correct comment a few lines below, and one
security claim that was simply false: the javadoc said the role gate stopped a
backend user reading the submitted inode list, but the generic
GET /api/v1/jobs/{jobId}/status returns the whole job with no role check. That
exposure is pre-existing and untouched here; only the claim of protection was
new, and it is now stated accurately.

Kept deliberately: the cancel endpoint, and the per-item result machinery.
includeItemResults is hardcoded false so the records are never produced in
practice, but they remain readable through the generic job-status endpoint, so
the comments now say that plainly instead of claiming a drill-down that does not
exist.

Dropped one tautological test -- it asserted the service never calls a status
endpoint, which the single-request service body makes impossible to fail.

Verified: eslint clean across four core-web projects, nx format:check clean,
1285 + 4 + 197 frontend tests and 24 backend tests passing, openapi regenerated
with exactly two paths and no statusUrl in the submit schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing called it. The Content Drive UI never wired a cancel control, and with the
polling status endpoint already gone there was no client left holding a job id.

Removed POST /v1/content/_bulkrefresh/{jobId}/cancel, the helper's cancelJob and
getJob (getJob existed only to queue-scope cancellation), and the resource's
authorized() — cancel was its only caller, since submit does its own role check in
BulkRefreshHelper.submit. Dropped the Postman request and the two integration tests
whose production methods no longer exist.

The processor stays Cancellable: a stuck reindex is still killable through the
generic POST /api/v1/jobs/{jobId}/cancel, so the invariant test moved onto that path
rather than being deleted with the endpoint.

Also corrected the submit operation description, which still claimed to return "the
URLs to follow the run" after statusUrl was removed, and carried the job-parameters
exposure caveat over to canRefresh rather than losing it with authorized().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The five-minute timer only existed to un-wedge refreshInFlight, and that guard only
existed to stop a double-fire. Neither survives the move to a backgrounded action:
nothing on screen waits for a reindex any more, so there is nothing for a deadline to
unblock, and a client giving up after five minutes reported a 504 about a run it had
no information about — most likely one that had succeeded, and one the server records
in the notification bell whether or not the socket delivered the event.

Removing the timer alone would have been worse than keeping it: a lost completion
event would leave the flag set and the Refresh button a silent no-op for the rest of
the session. So the pair goes together. The double-fire it guarded is now handled by
firing clearing the selection — executeQuickAction and executeRefresh both return
early on an empty set, so a second run takes a deliberate re-selection, and running
a reindex twice is wasteful rather than wrong.

Tests: the three timer tests collapse into one asserting nothing is left ticking, the
guard test becomes "a second reindex can be fired", and the component's "refused
while in flight" case becomes an emptied preview — the remaining way to reach the
toast with nothing submitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco
rjvelazco marked this pull request as ready for review August 21, 2026 20:17
@rjvelazco
rjvelazco requested a review from zJaaal August 21, 2026 20:17

@zJaaal zJaaal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review of the bulk refresh endpoint and the Refresh quick action. 9 findings, posted inline, most severe first.

Highest impact: the permission gate resolves a role key that does not exist, so it degrades to admin-only, and the frontend consumes the completion event with no jobId correlation. That missing correlation is the root of three separate inline findings (clearing an unrelated action's guard, closing a dialog mid-edit, reacting to another tab's run); keeping the jobId from the 202 and filtering on it collapses all three into one fix.

Checked and found correct, so no need to re-verify: per-job processor instances (the AtomicInteger counters are safe), @Dependent scope against JobProcessorDiscovery, JobCompletedEvent firing on permanent failure, WAIT_FOR writing synchronously, the exception mappers producing the documented 400/403, the refreshed-partial placeholder order, and the dot-folder-list-view controlled [selection] mode.

Review authored by Claude (Claude Code), posted via @zJaaal's account.

@zJaaal zJaaal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last feedback, good to merge after resolving my latests comments

…empty reindexes

Seven of nine review findings, plus notes on the two handled differently.

Correlation is the big one. BULK_REFRESH_COMPLETED is scoped to the submitting user,
not to a tab, so a grid reacted to runs it never started — toasting counts for content
it had not selected. The listener now puts the job id in the payload and the store keeps
the id from its own 202, ignoring everything else. That closes three findings at once.

Two ways the settle path disturbed unrelated work:

- The SUCCESS branch went through onSettled, which clears actionExecution. A reindex
  landing while a Lock was in flight wiped Lock's indicator and reopened its replay
  guard, so Lock could be fired again over rows already changing. It now publishes only
  the result.
- The shell closed the dialog on any result. A pushed outcome can arrive minutes later,
  while the user is filling in another action's form. Results now carry `backgrounded`,
  and the shell neither closes the dialog nor reloads the grid under an open one.

Also: an identifier whose versions vanished between resolution and reindex was counted
a success with zero index writes; a user-cancelled run was reported as a failure at
ERROR, disagreeing with the toast that has always treated CANCELED as a partial; and an
absent request body answered 500 instead of the documented 400.

The role gate keeps its behaviour and loses its silence. `loadRoleByKey("CMS Power
User")` finds nothing in the starter data, so the gate is admin-only in practice — but
view_contentlets.jsp:209 makes the identical call, so this matches legacy rather than
regressing it. Granting real Power Users access would widen the gate beyond legacy,
which is a product decision; the miss is now logged instead of failing quietly.

The double findAllVersions is gone, but not the suggested way: evicting only by inode
would have skipped the page, site and relationship caches that remove(Contentlet)
invalidates. Cheap VersionableAPI inodes evict before the read so it is cold, the
Contentlets evict after so the derived caches go — one permission-filtered read instead
of two, nothing lost.

Not done: adding `max` to @SiZe. Bean validation runs after Jackson has deserialized the
body, so it cannot stop the list being materialized; only a container body limit can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco
rjvelazco enabled auto-merge August 24, 2026 14:29
Comment thread dotcms-integration/src/test/java/com/dotcms/Junit5Suite1.java
Answers a review question on the suite registration. The split between this suite and
the MainSuites is by test framework, not by subject area, and the file gave no hint
either way.

The MainSuites run @RunWith(MainBaseSuite.class) over JUnit 4's @SuiteClasses, so a
jupiter test listed there contributes zero tests and nothing reports that it did not
run. That is not hypothetical: this PR's integration test sat in no suite at all for a
while, passing locally while CI never executed it, and a green pipeline that runs none
of your new tests is the worst version of that failure.

Comment only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco
rjvelazco disabled auto-merge August 24, 2026 17:25
@rjvelazco
rjvelazco enabled auto-merge August 24, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code PR: docker image Build & push a per-PR test image to dotcms/dotcms-test

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Workflow Center: add a Refresh quick action that reindexes the selection

4 participants