feat(content): add bulk refresh endpoint and enable the Refresh quick action - #37131
Conversation
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>
|
Claude finished @rjvelazco's task in 1m 20s —— View job SDK Compatibility Analysis
Verdict: No SDK-breaking changes detected. Reviewed all 29 changed files (diff
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. |
…ick-action-that-reindexes-the-selection
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
left a comment
There was a problem hiding this comment.
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_FORgenuinely bypasses the journal (ContentletIndexAPIImpl.java:2308). TheinTransaction()branch below it doesn't apply here:processNextJobis@CloseDBIfOpened, not@WrapInTransaction.- The "counters sit directly on
result, not undermetadata" comment indot-content-drive.model.tsis exactly right perOptionalJobResultSerializer. @DependentplusprocessorInstancesByJobId.computeIfAbsentgives one processor instance per job, so the mutable counters don't leak between runs.Jdk8Moduleis registered on the job queue's mapper, soOptional<String> identifier()round-trips through the persisted metadata.- Path routing (matches the
/v1/content/_importprecedent), CDI discovery, and exception mapping all resolve:IllegalArgumentException→ 400,DotSecurityException→ 403,DoesNotExistException→ 404, and the form'sValueInstantiationException→ 400. - The
FAILED→FAILED_PERMANENTLYtransition the frontend depends on does happen:handleJobFailuremarks FAILED and requeues, thenprocessJobWithRetrypromotes it viahandleNonRetryableFailedJob. 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); |
There was a problem hiding this comment.
🔴 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: findAllVersions → ESContentFactoryImpl.findAllVersions → findContentlets(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(); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
🟠 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"), |
There was a problem hiding this comment.
🟡 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() { |
There was a problem hiding this comment.
🟡 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()) { |
There was a problem hiding this comment.
🟠 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) { |
There was a problem hiding this comment.
🟡 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(); |
There was a problem hiding this comment.
🟡 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) }) |
There was a problem hiding this comment.
🟡 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, |
There was a problem hiding this comment.
🟡 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.
| bulkRefreshService | ||
| .refresh(inodes) | ||
| .pipe( | ||
| take(1), |
There was a problem hiding this comment.
🟠 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 eventuallypatchStatea 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.
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 Strutscmd=full_reindex_list). This adds one rather than looping_refreshN times from the client — a client-side loop gives no cancellation, no survival across a page reload, and no server-side bound on cost.Endpoints
POST/api/v1/content/_bulkrefresh202+{jobId, statusUrl, submitted}GET/api/v1/content/_bulkrefresh/{jobId}POST/api/v1/content/_bulkrefresh/{jobId}/cancelKey decisions
Job-backed, not synchronous. Reindexing a 500-item selection synchronously would hold a request open for minutes.
202rather than200because 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 defaultDEFERonly enqueues intodist_reindex_journaland returns, which is why the single-item endpoint can answertruewith 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 reportsSUCCESS, 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 (WorkflowAPIImplbuilds 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 viafindAllVersions. It would also inherit hardcodedIndexPolicy.DEFER, silentwfstepskips, 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.jspcheck, 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
DotContentDriveActionExecutionResultgained an optionalpartialDetailKey; absent, the default still applies, leaving workflow actions byte-identical.Final toast:
Testing
portlets-content-drive(full)dot-bulk-refreshserviceBulkRefreshResource)Backend unit tests were confirmed failing first (14 errors, all
UnsupportedOperationException) before implementation, per the constitution's TDD gate.test_bulkRefresh_makesContentMissingFromTheIndexFindableAgain, which is what proves theWAIT_FORpolicy actually took effect.Reviewer notes / open items
core-webhas no power-user awareness —currentUserIsAdmincomes fromgetCurrentUser().adminand 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.progressfloat 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 viaJobQueueManagerAPI.getInstance(jobId)on the status endpoint.SSEMonitorUtiloverload it needed was reverted, so that shared util has zero diff againstmain.PushPublishServicefails 4 tests (2026-08-20expected vs2026-08-19received) — its spec builds the expected date fromnew 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.yamlis regenerated (+132) and committed alongside the annotations, per the repo rule.🤖 Generated with Claude Code