Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
29a382e
Add bulk refresh endpoint for reindexing a selection of contentlets
rjvelazco Aug 20, 2026
735411f
Cover the bulk refresh endpoint, processor and HTTP contract
rjvelazco Aug 20, 2026
4b63c4c
Wire the Refresh quick action to the bulk refresh endpoint
rjvelazco Aug 20, 2026
3a5b203
Merge branch 'main' into issue-36845-workflow-center-add-a-refresh-qu…
rjvelazco Aug 20, 2026
1ccfea5
Fix the bulk refresh Postman paths and register its integration test
rjvelazco Aug 20, 2026
d40612a
Fix the reindex result contract and stop failed jobs reporting as suc…
rjvelazco Aug 20, 2026
bf48e99
Pace the bulk refresh poll loops against the wall clock
rjvelazco Aug 20, 2026
54985ac
Start the job queue and seed the power user role in the reindex integ…
rjvelazco Aug 20, 2026
7639071
Evict the contentlet cache by inode, and trim the reindex status resp…
rjvelazco Aug 21, 2026
9abf25b
Report bulk reindex completion by push instead of polling
rjvelazco Aug 21, 2026
65b1057
Register the reindex completion listener at startup
rjvelazco Aug 21, 2026
372b679
Merge branch 'main' into issue-36845-workflow-center-add-a-refresh-qu…
zJaaal Aug 21, 2026
cf12d23
Stop a missed completion event leaving the reindex stuck in flight
rjvelazco Aug 21, 2026
2c27255
Background the reindex properly, and drop the selection when an actio…
rjvelazco Aug 21, 2026
4913447
Remove the stale statusUrl chain, and fix two misleading-success bugs
rjvelazco Aug 21, 2026
fe5dea1
Remove the bulk refresh cancel endpoint
rjvelazco Aug 21, 2026
242fddb
Drop the reindex completion deadline and the in-flight guard
rjvelazco Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core-web/libs/data-access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './lib/dot-alert-confirm/dot-alert-confirm.service';
export * from './lib/dot-analytics-search/dot-analytics-search.service';
export * from './lib/dot-analytics-tracker/dot-analytics-tracker.service';
export * from './lib/dot-apps/dot-apps.service';
export * from './lib/dot-bulk-refresh/dot-bulk-refresh.service';
export * from './lib/dot-categories/dot-categories.service';
export * from './lib/dot-containers/dot-containers.service';
export * from './lib/dot-content-drive/dot-content-drive.service';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createHttpFactory, HttpMethod, SpectatorHttp } from '@openng/spectator/jest';

import { DotBulkRefreshSubmitResponse } from '@dotcms/dotcms-models';

import { DotBulkRefreshService } from './dot-bulk-refresh.service';

describe('DotBulkRefreshService', () => {
let spectator: SpectatorHttp<DotBulkRefreshService>;

const createHttp = createHttpFactory(DotBulkRefreshService);

const SUBMIT_URL = '/api/v1/content/_bulkrefresh';

beforeEach(() => {
spectator = createHttp();
});

it('should submit the inodes without asking for per-item results', () => {
spectator.service.refresh(['inode-1', 'inode-2']).subscribe();

const req = spectator.expectOne(SUBMIT_URL, HttpMethod.POST);
// Counters are all the completion event carries, and the per-item records are no longer
// readable over REST now that the status endpoint is gone.
expect(req.request.body).toEqual({
contentletIds: ['inode-1', 'inode-2'],
includeItemResults: false
});
});

it('should emit the accepted job handle', () => {
let emitted: DotBulkRefreshSubmitResponse | null | undefined;
spectator.service.refresh(['inode-1']).subscribe((result) => (emitted = result));

const entity: DotBulkRefreshSubmitResponse = {
jobId: 'job-1',
submitted: 1
};
spectator.expectOne(SUBMIT_URL, HttpMethod.POST).flush({ entity });

expect(emitted).toEqual(entity);
});

it('should complete after the submit rather than waiting on the job', () => {
// The whole point of the push model: nothing here follows the run. A subscriber gets one value
// and the observable completes, so there is no interval left behind to leak or to cancel.
let completed = false;
spectator.service.refresh(['inode-1']).subscribe({ complete: () => (completed = true) });

spectator.expectOne(SUBMIT_URL, HttpMethod.POST).flush({
entity: { jobId: 'job-1', submitted: 1 }
});

expect(completed).toBe(true);
});

it('should not call the endpoint at all for an empty selection', () => {
let emitted: DotBulkRefreshSubmitResponse | null | undefined;
spectator.service.refresh([]).subscribe((result) => (emitted = result));

spectator.controller.expectNone(SUBMIT_URL);
expect(emitted).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Observable, of } from 'rxjs';

import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';

import { map } from 'rxjs/operators';

import { DotBulkRefreshSubmitResponse } from '@dotcms/dotcms-models';

const BULK_REFRESH_URL = '/api/v1/content/_bulkrefresh';

/**
* Submits a selection of contentlets to be reindexed through `POST /api/v1/content/_bulkrefresh`.
*
* The endpoint is job-backed and answers `202` immediately: the reindex continues in the background and
* this service does not wait for it. Completion arrives by push — a `BULK_REFRESH_COMPLETED` system event
* over the websocket the admin UI already holds open — so there is deliberately nothing here that polls a
* status endpoint. Asking every 1.5 seconds for up to five minutes is what this replaced.
*/
@Injectable({
providedIn: 'root'
})
export class DotBulkRefreshService {
readonly #http = inject(HttpClient);

/**
* Asks for every given contentlet to be reindexed.
*
* @param inodes Contentlet inodes. The server collapses them by identifier, so the `total` it later
* reports can be lower than what was sent.
* @returns The accepted job's handle, or `null` for an empty selection — which is not worth a request.
*/
refresh(inodes: string[]): Observable<DotBulkRefreshSubmitResponse | null> {
if (!inodes.length) {
return of(null);
}

return this.#http
.post<{
entity: DotBulkRefreshSubmitResponse;
}>(BULK_REFRESH_URL, {
contentletIds: inodes,
// Counters are all the completion event carries, and nothing in this feature reads the
// per-item records. They remain reachable through the generic job-status endpoint if a
// drill-down is ever built.
includeItemResults: false
})
.pipe(map((response) => response.entity));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export enum DotSystemEventType {
DELETE_SITE = 'DELETE_SITE',
SWITCH_SITE = 'SWITCH_SITE',
UPDATE_SITE_PERMISSIONS = 'UPDATE_SITE_PERMISSIONS',
UPDATE_PORTLET_LAYOUTS = 'UPDATE_PORTLET_LAYOUTS'
UPDATE_PORTLET_LAYOUTS = 'UPDATE_PORTLET_LAYOUTS',
/** A bulk content reindex finished; the payload carries the run's counters. */
BULK_REFRESH_COMPLETED = 'BULK_REFRESH_COMPLETED'
}

/**
Expand Down
45 changes: 45 additions & 0 deletions core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,48 @@ export interface DotContentDriveSearchResponse {
nextContentCursor: number;
nextFolderCursor: number;
}

/**
* The `202 Accepted` body of `POST /api/v1/content/_bulkrefresh`.
*
* Reindexing a selection is job-backed: the submit call only accepts the work, so this carries the
* handle to follow it rather than any outcome.
*/
export interface DotBulkRefreshSubmitResponse {
/** The job's id — the handle for the cancel call. */
jobId: string;
/**
* Inodes accepted, before the server collapses them by identifier. The de-duplicated `total`
* arrives with the result and is often smaller, so this is not a count of reindexed items.
*/
submitted: number;
}

/**
* Counters a finished bulk refresh reports.
*
* `successCount + failedCount + skippedCount === total` in every terminal state, which is what lets a
* caller know it can stop waiting and settle every row it asked about.
*/
export interface DotBulkRefreshCounts {
/** Unique identifiers reindexed, after de-duplication — not the submitted inode count. */
total: number;
successCount: number;
failedCount: number;
/** Never attempted, because the run was cancelled before reaching them. */
skippedCount: number;
/** Index writes across the whole run; higher than `total` when content has several versions. */
versionsIndexed: number;
}

/**
* The payload of a `BULK_REFRESH_COMPLETED` system event.
*
* Pushed over the websocket when a run settles, scoped to whoever submitted it. The counter fields are
* all optional: a job that finished without reporting any carries only `state`, and a caller must treat
* that as a failure rather than as a clean run over nothing, which is what all-zero counters would look
* like.
*/
export interface DotBulkRefreshCompletedEvent extends Partial<DotBulkRefreshCounts> {
state: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { of, throwError } from 'rxjs';
import { provideHttpClient } from '@angular/common/http';
import { signal } from '@angular/core';

import { MessageService } from 'primeng/api';

import {
AddToBundleService,
DotCurrentUserService,
Expand Down Expand Up @@ -192,8 +194,12 @@ describe('DotContentDriveActionCenterComponent', () => {
executeQuickAction: jest.fn(),
executeWorkflowAction: jest.fn(),
executeAddToBundle: jest.fn(),
executePushPublish: jest.fn()
executePushPublish: jest.fn(),
executeRefresh: jest.fn()
}),
// The trigger toast for a backgrounded reindex goes through PrimeNG's MessageService,
// which in the app resolves to the shell's instance so the toast outlives this dialog.
mockProvider(MessageService, { add: jest.fn() }),
mockProvider(DotMessageService, {
get: jest.fn().mockImplementation((key: string) => key)
}),
Expand Down Expand Up @@ -535,18 +541,16 @@ describe('DotContentDriveActionCenterComponent', () => {
}
});

it('should render Refresh as a disabled placeholder', () => {
it('should render Refresh as a selectable action', () => {
spectator.detectChanges();

const row = spectator.query(
'[data-testid="quick-action-REFRESH"]'
) as HTMLButtonElement;

expect(row).toBeTruthy();
expect(row.disabled).toBe(true);
expect(
spectator.query('[data-testid="quick-action-coming-soon-REFRESH"]')
).toBeTruthy();
expect(row.disabled).toBe(false);
expect(spectator.query('[data-testid="quick-action-coming-soon-REFRESH"]')).toBeNull();
});

it('should disable Push Publish without the coming-soon badge when no environment exists', () => {
Expand All @@ -568,20 +572,117 @@ describe('DotContentDriveActionCenterComponent', () => {
spectator.detectChanges();

// Disabled, so a real click cannot land — called directly to prove the guard holds if
// one ever does. Both blocked states are covered: Refresh is a placeholder, Push
// Publish has nowhere to send to.
for (const id of ['REFRESH', 'PUSH_PUBLISH']) {
spectator.component['onSelectQuickAction'](
spectator.component['$quickActions']().find((action) => action.id === id)!
);
}
// one ever does. Push Publish has nowhere to send to; Refresh is no longer blocked.
spectator.component['onSelectQuickAction'](
spectator.component['$quickActions']().find(
(action) => action.id === 'PUSH_PUBLISH'
)!
);

spectator.detectChanges();

expect(spectator.query('[data-testid="action-preview"]')).toBeNull();
expect(store.executeQuickAction).not.toHaveBeenCalled();
});

it('should execute Refresh through its own store method, not the workflow fire', () => {
// Refresh speaks inodes like Lock and Unlock but goes to a job-backed endpoint of its
// own, so routing it through `executeQuickAction` would fire a system action that does
// not exist.
executeQuickAction('REFRESH');

expect(store.executeRefresh).toHaveBeenCalledWith(expect.any(String), [
'inode-1',
'inode-2'
]);
expect(store.executeQuickAction).not.toHaveBeenCalled();
});

it('should send only the rows left checked in the Refresh preview', () => {
openQuickActionPreview('REFRESH');
toggleRow(0);
spectator.click('[data-testid="action-preview-execute"]');
spectator.detectChanges();

const [, inodes] = (store.executeRefresh as unknown as jest.Mock).mock.calls[0];

expect(inodes).toEqual(['inode-2']);
});

it('should not ask for configuration before refreshing', () => {
// Nothing to collect: a reindex takes no assignee, no destination and no environments,
// so it goes straight to the preview like Lock and Unlock do.
openQuickActionPreview('REFRESH');

expect(spectator.query('[data-testid="action-preview"]')).toBeTruthy();
});

it('should toast at trigger that the reindex runs in the background', () => {
// The only feedback the user gets now: there is no "Applying ..." indicator for a reindex,
// because it runs for minutes and cannot report progress.
const messageService = spectator.inject(MessageService);

executeQuickAction('REFRESH');

expect(messageService.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'info',
summary: 'content-drive.action-center.toast.reindex-started'
})
);
});

it('should not claim a reindex started when nothing was submitted', () => {
// Toasting with nothing submitted tells the user their reindex is running when it is not,
// and the hand-off clears their selection on the way out, so they lose the rows too. With
// the in-flight guard gone, an emptied preview is the remaining way to reach that.
const messageService = spectator.inject(MessageService);

openQuickActionPreview('REFRESH');
toggleRow(0);
toggleRow(1);
spectator.click('[data-testid="action-preview-execute"]');
spectator.detectChanges();

expect(store.executeRefresh).not.toHaveBeenCalled();
expect(messageService.add).not.toHaveBeenCalled();
expect(store.setSelectedItems).not.toHaveBeenCalled();
});

it('should not toast at trigger for the synchronous actions', () => {
// They settle in seconds and report through the toolbar indicator, so a "runs in the
// background" toast would be both wrong and noisy.
const messageService = spectator.inject(MessageService);

executeQuickAction('LOCK');

expect(messageService.add).not.toHaveBeenCalled();
});

it('should clear the grid selection when an action is handed off', () => {
// Once an action is fired the selection has served its purpose, and leaving the boxes
// ticked invited firing a second action over rows already being changed.
executeQuickAction('LOCK');

expect(store.setSelectedItems).toHaveBeenCalledWith([]);
});

it('should clear the grid selection when a reindex is handed off', () => {
// Matters most here: a reindex runs for minutes, so without this the rows stay ticked for
// the whole run.
executeQuickAction('REFRESH');

expect(store.setSelectedItems).toHaveBeenCalledWith([]);
});

it('should keep the selection when the dialog is merely dismissed', () => {
// Dismissing is not firing. Clearing here would lose a selection the user is still
// building.
openQuickActionPreview('LOCK');

expect(store.setSelectedItems).not.toHaveBeenCalled();
});

it('should keep Add to Bundle selectable', () => {
spectator.detectChanges();

Expand Down
Loading
Loading