Skip to content

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

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

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

Conversation

@rjvelazco

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.

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 1m 20s —— View job


SDK Compatibility Analysis

  • Read SDK breaking-change categories reference
  • Get full PR diff (origin/main...HEAD)
  • Analyze diff against each category (G-1/G-2/G-3, R-1, U-1/U-2, H-1)
  • Post verdict

Verdict: No SDK-breaking changes detected.

Reviewed all 29 changed files (diff 71394e54a7...54985ace80) against every category in docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md:

  • GraphQL (G-1/G-2/G-3) — no changes to any GraphQL schema, resolver, or page-api.ts/buildPageQuery surface. Nothing in this PR touches graphql.page/graphql.content.
  • REST response shapes (R-1) — the new /api/v1/content/_bulkrefresh, /api/v1/content/_bulkrefresh/{jobId}, and .../cancel endpoints (BulkRefreshResource.java) are brand new routes with brand new response types (ResponseEntityBulkRefreshSubmitView, job status body). No existing /api/v1/nav, /api/v1/content, or /api/v1/page/* response field was renamed, removed, or restructured — purely additive per the openapi.yaml diff.
  • UVE postMessage protocol (U-1/U-2) — no changes to __DOTCMS_UVE_EVENT__, DotCMSUVEAction, events.ts, or public.ts. The frontend changes are scoped to the Content Drive portlet's Action Center (dot-content-drive-action-center.component.ts, withActionExecution.ts, action-center.ts), which is admin-UI (dotcms-ui) territory, not @dotcms/uve.
  • SDK compatibility headers (H-1) — no changes to SdkVersionWebInterceptor, MinSdkVersion, or sdk-compatibility.ts/compareVersions().

This matches the reference doc's non-breaking calibration examples directly: a new REST endpoint old SDKs never call, plus admin-UI-only frontend changes. No comment or label action taken.

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.


try {
// Cache first: a stale cache entry would otherwise be re-indexed as-is.
this.contentletCache.remove(workItem.identifier);

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.

🔴 This eviction is a no-op, and it defeats the point of the operation.

ContentletCache is keyed by inode, not identifier: add(String inode, Contentlet), and remove(Contentlet c) delegates to remove(c.getInode()) (ContentletCacheImpl.java:107, :125). Passing an identifier evicts nothing.

That matters more here than it would elsewhere, because the very next call reads through that cache: findAllVersionsESContentFactoryImpl.findAllVersionsfindContentlets(inodes), which serves hits straight from contentletCache (ESContentFactoryImpl.java:1294). So when a cached version is stale, this loads the stale object and writes it to the index, which is the exact failure the comment above says it is preventing.

Mitigating: the single-item _refresh has the identical call (ContentResource.java:801), so this is faithful to precedent rather than newly wrong. But a PR whose thesis is "done should mean done" probably shouldn't inherit that one.

The fix needs the inodes before the load. Either evict workItem.inodes first, then evict each returned version's inode and re-read, or go through a path that bypasses the cache.

🤖 Reviewed by Claude Code, posted by @zJaaal.

this.contentletIndexAPI.addContentToIndex(version, includeDependencies);
}

final int indexed = null == versions ? 0 : versions.size();

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.

Dead null check: the enhanced-for over versions two lines above would already have thrown NPE. versions.size() is enough.

Related, a few lines up: if version 3 of 5 throws, the item is recorded FAILED and versionsIndexed gets nothing, even though two versions were written. Worth incrementing inside the loop if the counter is meant to describe work done.

🤖 Reviewed by Claude Code, posted by @zJaaal.

@ApiResponse(responseCode = "404", description = "Not Found - unknown job id"),
@ApiResponse(responseCode = "500", description = "Internal Server Error")
})
public ResponseEntityView<JobView> getJobStatus(@Context final HttpServletRequest request,

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.

🟠 Every status poll ships the entire submitted inode list back to the client.

JobContract.parameters() has no @JsonIgnore, so the JobView returned here serializes contentletIds and userId in full. The frontend polls at 1.5s for up to 5 minutes: ~200 responses, each carrying up to 500 UUIDs (~18KB) at the configured cap. DotBulkRefreshJob only reads id, state, progress and result.

Interesting that the javadoc on authorized() already leans on this exposure as a reason for the role gate, so it was spotted as a security matter but not as a bandwidth one. A trimmed status view would settle both.

🟡 Separately: neither this nor cancelJob checks job.queueName(). GET /api/v1/content/_bulkrefresh/{anyJobId} returns any job's full JobView, and the cancel path cancels any job of any type.

Not an escalation, to be clear: /api/v1/jobs/{jobId}/cancel already permits exactly this to any backend user with no role gate at all (JobQueueResource.java:368), so this endpoint is strictly more restrictive than what already ships. But a one-line queue-name check makes the 404 honest and keeps the endpoint's contract to itself.

🤖 Reviewed by Claude Code, posted by @zJaaal.

+ "per-item records.",
tags = {"Content"},
responses = {
@ApiResponse(responseCode = "200", description = "Job status retrieved"),

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.

🟡 CLAUDE.md: "REST @Schema: Must match actual return type."

This returns ResponseEntityView<JobView> and cancelJob returns ResponseEntityStringView, but both 200s declare only a description, so the committed openapi has bare "200": description: for each. ResponseEntityJobView and ResponseEntityStringView already exist in com.dotcms.rest.

The 202 on submit does this correctly with ResponseEntityBulkRefreshSubmitView, so this reads as an omission rather than a deliberate pattern.

🤖 Reviewed by Claude Code, posted by @zJaaal.


private final JobQueueManagerAPI jobQueueManagerAPI;

public BulkRefreshHelper() {

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.

🟡 This isn't required, and it produces a broken bean.

ContentImportHelper, the class cited above as the pattern this follows, is @ApplicationScoped with only the @Inject constructor and no no-arg one. Weld instantiates normal-scoped client proxies without calling a constructor, so nothing needs this.

What it does add is a public path to an instance whose jobQueueManagerAPI is null, which then NPEs on submit, getJob and cancelJob. Suggest deleting it.

🤖 Reviewed by Claude Code, posted by @zJaaal.

// wait below times out. ContentImportResourceIntegrationTest never noticed because it only
// asserts job *creation* — 24 tests in under nine seconds — so this suite had no test that
// actually needed a processor to execute until now.
if (!jobQueueManagerAPI.isStarted()) {

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.

🟠 This starts the shared job queue and nothing ever stops it.

There's no @AfterAll to undo it, and the class is registered in Junit5Suite1 alongside JobProcessorDiscoveryTest and the job-queue tests. Once started, the queue keeps draining jobs for the rest of the JVM.

The comment right above identifies exactly this hazard for ContentImportResourceIntegrationTest ("only asserts job creation") and then leaves the queue running for it. Declaration order happens to put BulkRefresh after ContentImport, which is luck rather than isolation. An @AfterAll that stops the queue when this class was the one to start it would close it.

Also missing in this class: any cleanup of the contentlets and users created, and content goes on the default site rather than a dedicated one (see the repo's shared-DB isolation pattern: dedicated site + @After cleanup).

🤖 Reviewed by Claude Code, posted by @zJaaal.

final String jobId = submit(adminUser, inodes, false, true);
try {
resource.cancelJob(requestFor(adminUser), response, jobId);
} catch (final Exception e) {

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.

🟡 Swallowing every exception hides a genuine 500 behind "the race landed the other way". Narrow it to the type you actually expect from an already-terminal job, or assert on the message. As written, a cancelJob that starts throwing NullPointerException still passes this test.

🤖 Reviewed by Claude Code, posted by @zJaaal.


return null != existing
? existing
: new RoleDataGen().key(Role.CMS_POWER_USER).nextPersisted();

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.

🟡 This persists a fabricated role keyed CMS Power User into a shared test DB if the lookup misses. The branch is probably dead against a real starter, but if it ever fires it poisons every subsequent loadRoleByKey(Role.CMS_POWER_USER) in the JVM, including production code paths under test. Worth failing loudly instead of creating one.

🤖 Reviewed by Claude Code, posted by @zJaaal.

this.#status(jobId).pipe(
// One blip over a five-minute run is ~200 requests' worth of exposure. The job
// is unaffected by a failed poll, so retrying beats abandoning it.
retry({ count: DOT_BULK_REFRESH_POLL_RETRIES, delay: () => timer(1000) })

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.

🟡 This retries indiscriminately: a 401, 403 or 404 gets three retries a second apart before it surfaces. None of those get better by asking again, and a 404 in particular means the job id is wrong, which the user should hear immediately.

A predicate that only retries 5xx and network failures (status >= 500 || status === 0) would keep the "one blip over ~200 requests" reasoning intact while letting the terminal statuses through.

🤖 Reviewed by Claude Code, posted by @zJaaal.


onSettled({
actionName,
successCount: counts.successCount,

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.

🟡 The toast reports de-duplicated identifiers as if they were selected rows.

counts.successCount is per identifier, but the dialog counted eligible inodes. Select three language rows of one contentlet and the dialog offers "3 items" while the toast says "Refresh ran on 1 item(s)."

DotBulkRefreshCounts.total is documented honestly as post-dedup, so this is known at the model layer, but nothing reconciles it at the UI. Either submit with includeItemResults: true and sum inodes.length across the records (the server deliberately names every inode back for precisely this), or have the counters carry an inode-level figure alongside the identifier one.

🤖 Reviewed by Claude Code, posted by @zJaaal.

Comment on lines +270 to +273
bulkRefreshService
.refresh(inodes)
.pipe(
take(1),

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.

🟠 This polling subscription needs takeUntilDestroyed.

It's the one action in this file where the omission actually bites. The others issue a single HTTP request, so take(1) completes them after one round trip and the worst case on destroy is a stale toast. This one is a different shape: timer(0, 1500) inside the service is an infinite interval, and the take(1) here sits on the outer observable, which doesn't emit until the poll loop has already finished. It does nothing to stop the loop.

DotContentDriveStore is component-scoped (dot-content-drive-shell.component.ts:120), so it dies when the user leaves the portlet. This subscription doesn't. It keeps issuing GET /_bulkrefresh/{jobId} every 1.5s until the job settles or the 5-minute timeout() fires, up to ~200 requests against a screen nobody is on.

Two consequences past the wasted traffic:

  • httpErrorManagerService.handle(...) is global. The 504-shaped timeout error, or the "did not report a usable outcome" 500 below, surfaces a dialog minutes after the user has navigated to a different portlet, about work they can no longer see.
  • Navigate back and a fresh store is constructed with actionExecution: undefined, so the toolbar shows nothing in flight while the orphaned poll from the previous instance is still running and will eventually patchState a dead store. Two instances, one job, one of them lying.

withMethods already runs in an injection context (that's how the inject(...) default parameters on line 70 work), so DestroyRef goes in the same list:

withMethods(
    (
        store,
        ...
        bulkRefreshService = inject(DotBulkRefreshService),
        destroyRef = inject(DestroyRef)
    ) => {
        ...
        bulkRefreshService
            .refresh(inodes)
            .pipe(
                catchError(/* unchanged */),
                takeUntilDestroyed(destroyRef)
            )
            .subscribe(...)

The bare takeUntilDestroyed() form would throw here, since the .pipe() runs later inside the method rather than during store construction, hence the explicit destroyRef. The pattern is already established in this portlet at dot-content-drive-field-filter-menu.component.ts:127, so it needs no new convention.

Worth noting in a comment so it isn't later mistaken for a regression: this stops the client, not the job. The reindex continues server-side, which is the stated design intent. But that intent is about outliving the dialog, not the store, and since there's no reattach-by-jobId logic anywhere, nothing is lost by dropping the poll on destroy.

Minor, while you're in here: the take(1) is redundant. last() inside #awaitCompletion already guarantees a single emission before completion.

🤖 Reviewed by Claude Code, posted by @zJaaal.

@rjvelazco
rjvelazco marked this pull request as draft August 20, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

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

2 participants