Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
698b9c4
feat(maintenance): add REST APIs for search/replace, drop old version…
hassandotcms Apr 7, 2026
fc9d7c0
test(maintenance): add integration tests for search/replace, old vers…
hassandotcms Apr 7, 2026
65d3572
fix(test): correct exception expectation for empty searchString valid…
hassandotcms Apr 7, 2026
f52d55f
chore: add generated openapi.yaml for maintenance endpoints
hassandotcms Apr 8, 2026
7426d2c
feat(maintenance): add REST APIs for fix assets and clean orphan asse…
hassandotcms Apr 7, 2026
054bbe1
chore: update generated openapi.yaml for fix-assets and clean-assets …
hassandotcms Apr 8, 2026
1b1955c
feat(maintenance): add REST APIs for search/replace, drop old version…
hassandotcms Apr 7, 2026
e82cfab
test(maintenance): add integration tests for search/replace, old vers…
hassandotcms Apr 7, 2026
2bb8e9c
fix(test): correct exception expectation for empty searchString valid…
hassandotcms Apr 7, 2026
fcf313f
chore: add generated openapi.yaml for maintenance endpoints
hassandotcms Apr 8, 2026
6a06d66
fix(maintenance): use UTC for date conversion, add final, remove redu…
hassandotcms Apr 8, 2026
8e5b15b
fix(maintenance): throw 500 on failed dropOldVersions, remove searchS…
hassandotcms Apr 16, 2026
fb599c9
docs(maintenance): fix stale deletedCount schema, document partial fa…
hassandotcms Apr 16, 2026
ce039be
fix(maintenance): include dateStr in dropOldVersions failure message …
hassandotcms Apr 17, 2026
1fa3e19
Merge branch 'main' of https://github.com/dotCMS/core into 35199-main…
hassandotcms Apr 17, 2026
54a7063
Merge branch '35199-maintenance-search-replace-old-versions-pushed-as…
hassandotcms Apr 20, 2026
27ee06b
Merge branch 'main' of https://github.com/dotCMS/core into 35200-main…
hassandotcms Apr 20, 2026
19a9482
fix(maintenance): harden concurrency and audit logging for fix/clean …
hassandotcms Apr 20, 2026
371cc1d
Merge branch 'main' of https://github.com/dotCMS/core into 35200-main…
hassandotcms Apr 20, 2026
c9e3895
refactor(maintenance): migrate fix/clean-assets REST to JobQueueManag…
hassandotcms Apr 22, 2026
991f6f7
Merge branch 'main' of https://github.com/dotCMS/core into 35200-main…
hassandotcms Apr 22, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.dotcms.rest.api.v1.maintenance;

import com.dotcms.concurrent.DotConcurrentFactory;
import com.dotcms.concurrent.lock.ClusterLockManager;
import com.dotcms.jobs.business.api.JobQueueManagerAPI;
import com.dotcms.jobs.business.job.Job;
import com.dotcms.jobs.business.job.JobPaginatedResult;
import com.dotcms.rest.api.v1.job.JobResponseUtil;
import com.dotcms.rest.api.v1.job.JobStatusResponse;
import com.dotcms.rest.exception.ConflictException;
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.exception.DotRuntimeException;
import com.liferay.portal.model.User;
import java.util.HashMap;
import java.util.Map;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;

/**
* Creates fix-assets and clean-assets jobs on {@link JobQueueManagerAPI}. A cluster lock
* around the {@code getActiveJobs} check-and-create gives strict 409 semantics: two nodes
* cannot both pass the active-job check in the same millisecond.
*
* @author hassandotcms
*/
@ApplicationScoped
public class MaintenanceJobHelper {

public static final String FIX_ASSETS_QUEUE = "maintenanceFixAssets";
public static final String CLEAN_ASSETS_QUEUE = "maintenanceCleanAssets";

private static final String JOB_STATUS_PATH = "/api/v1/jobs/%s/status";
private static final String PARAM_USER_ID = "userId";
private static final String PARAM_REMOTE_ADDR = "remoteAddr";

private final JobQueueManagerAPI jobQueueManagerAPI;

@Inject
public MaintenanceJobHelper(final JobQueueManagerAPI jobQueueManagerAPI) {
this.jobQueueManagerAPI = jobQueueManagerAPI;
}

/**
* CDI requires a no-arg constructor for proxy creation.
*/
public MaintenanceJobHelper() {
this.jobQueueManagerAPI = null;
}

/**
* Creates a fix-assets job, rejecting with 409 Conflict if one is already running.
*/
public JobStatusResponse createFixAssetsJob(final User user, final HttpServletRequest request) {
return createSingletonJob(FIX_ASSETS_QUEUE, user, request, "fix-assets");
}

/**
* Creates a clean-orphan-assets job, rejecting with 409 Conflict if one is already running.
*/
public JobStatusResponse createCleanAssetsJob(final User user, final HttpServletRequest request) {
return createSingletonJob(CLEAN_ASSETS_QUEUE, user, request, "clean-assets");
}

/**
* Returns the latest job for a given queue — active if one is pending or running, otherwise
* the most recent completed job. Returns {@code null} if no job has ever been created.
*/
public Job getLatestJob(final String queueName) {
try {
// getJobs() orders by created_at DESC — the first row is the most recent job
// across all states (pending, running, completed, failed, canceled).
final JobPaginatedResult result = jobQueueManagerAPI.getJobs(queueName, 1, 1);
return result.jobs().isEmpty() ? null : result.jobs().get(0);
} catch (final DotDataException e) {
throw new DotRuntimeException("Failed to fetch latest job for queue " + queueName, e);
}
}

/**
* Acquires a short-lived cluster lock keyed on the queue name, re-checks the queue for any
* active job, and enqueues a new job atomically. If the lock cannot be acquired (another node
* is starting a job in this exact moment) or an active job exists, throws
* {@link ConflictException}.
*/
private JobStatusResponse createSingletonJob(
final String queueName,
final User user,
final HttpServletRequest request,
final String humanName) {

final ClusterLockManager<String> lock =
DotConcurrentFactory.getInstance().getClusterLockManager(queueName);

final JobStatusResponse result;
try {
result = lock.tryClusterLock(() ->
checkAndEnqueue(queueName, user, request, humanName));
} catch (final ConflictException e) {
throw e;
} catch (final Throwable t) {
throw new DotRuntimeException("Failed to create " + humanName + " job", t);
}

if (result == null) {
throw new ConflictException(String.format(
"Another node is currently starting a %s job; retry in a moment",
humanName));
}
return result;
}

private JobStatusResponse checkAndEnqueue(
final String queueName,
final User user,
final HttpServletRequest request,
final String humanName) throws DotDataException {

final JobPaginatedResult active = jobQueueManagerAPI.getActiveJobs(queueName, 1, 1);
if (!active.jobs().isEmpty()) {
throw new ConflictException(String.format(
"A %s job is already running (jobId=%s)",
humanName, active.jobs().get(0).id()));
}

final Map<String, Object> params = new HashMap<>();
params.put(PARAM_USER_ID, user.getUserId());
params.put(PARAM_REMOTE_ADDR, request.getRemoteAddr());

final String jobId = jobQueueManagerAPI.createJob(queueName, params);
return JobResponseUtil.buildJobStatusResponse(jobId, JOB_STATUS_PATH, request);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.dotcms.rest.WebResource;
import com.dotcms.rest.annotation.NoCache;
import com.dotcms.rest.exception.BadRequestException;
import com.dotcms.rest.exception.ConflictException;
import com.dotcms.util.ConversionUtils;
import com.dotcms.util.DbExporterUtil;
import com.dotcms.util.SizeUtil;
Expand Down Expand Up @@ -76,6 +77,10 @@
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import com.dotcms.cdi.CDIUtils;
import com.dotcms.jobs.business.job.Job;
import com.dotcms.rest.ResponseEntityJobStatusView;

/**
* This REST Endpoint exposes all the different features displayed in the <b>Maintenance</b> portlet
* inside the dotCMS back-end.
Expand All @@ -93,6 +98,13 @@ public class MaintenanceResource implements Serializable {
protected static final Lazy<Boolean> ALLOW_DOTCMS_SHUTDOWN_FROM_CONSOLE =
Lazy.of(() -> Config.getBooleanProperty("ALLOW_DOTCMS_SHUTDOWN_FROM_CONSOLE", true));

/**
* Resolved lazily via CDI the first time a fix/clean-assets endpoint is invoked. We avoid
* constructor injection so the no-arg and {@code @VisibleForTesting} constructors used by
* Jersey and existing integration tests keep working unchanged.
*/
private volatile MaintenanceJobHelper jobHelper;

/**
* Default class constructor.
*/
Expand Down Expand Up @@ -718,6 +730,179 @@ public final ResponseEntityStringView deletePushedAssets(
return new ResponseEntityStringView("success");
}

// -------------------------------------------------------------------------
// Fix Assets & Clean Assets endpoints — backed by JobQueueManagerAPI
// -------------------------------------------------------------------------

/**
* Enqueues a fix-assets job. Runs all registered FixTask classes asynchronously on the
* cluster's job queue. Returns immediately with a job id; poll the job status via
* {@code GET /api/v1/jobs/{jobId}/status} or {@link #getLatestFixAssetsJob}.
*/
@Operation(
summary = "Request a fix-assets job",
description = "Enqueues a fix-assets inconsistencies job on the cluster job queue. "
+ "Returns immediately with {jobId, statusUrl}. Rejects with 409 Conflict if "
+ "a fix-assets job is already pending or running anywhere in the cluster."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200",
description = "Job enqueued",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ResponseEntityJobStatusView.class))),
@ApiResponse(responseCode = "401",
description = "Unauthorized - authentication required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "403",
description = "Forbidden - CMS Administrator role required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "409",
description = "Conflict - a fix-assets job is already running",
content = @Content(mediaType = "application/json"))
})
@POST
Copy link
Copy Markdown
Member

@fabrizzio-dotCMS fabrizzio-dotCMS Apr 20, 2026

Choose a reason for hiding this comment

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

How about naming them like :
maintenance/assets/fix
maintenance/assets/clean

or

maintenance/assets/_fix
maintenance/assets/_clean

To please some folks

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

maintenance/assets/_fix
maintenance/assets/_clean

seems better, consistent with other apis in this file.

@Path("/assets/_fix")
@NoCache
@Produces({MediaType.APPLICATION_JSON})
public ResponseEntityJobStatusView requestFixAssetsJob(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response) {

final User user = assertBackendUser(request, response).getUser();
return new ResponseEntityJobStatusView(
jobHelper().createFixAssetsJob(user, request));
}

/**
* Returns the most recent fix-assets job — the currently active one if any, otherwise the
* most recently completed. Intended for "page reload" or "open in a second tab" scenarios
* where the client has lost the original job id.
*/
@Operation(
summary = "Get latest fix-assets job",
description = "Returns the most recent fix-assets job (active, or most recently "
+ "completed). Returns null entity if no fix-assets job has ever run."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200",
description = "Latest job status",
content = @Content(mediaType = "application/json",
schema = @Schema(type = "object",
description = "ResponseEntityView wrapping the latest Job "
+ "(id, state, progress, result) or null if none exists"))),
@ApiResponse(responseCode = "401",
description = "Unauthorized - authentication required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "403",
description = "Forbidden - CMS Administrator role required",
content = @Content(mediaType = "application/json"))
})
@GET
@Path("/assets/_fix")
@JSONP
@NoCache
@Produces({MediaType.APPLICATION_JSON})
public ResponseEntityView<Job> getLatestFixAssetsJob(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response) {

assertBackendUser(request, response);
return new ResponseEntityView<>(
jobHelper().getLatestJob(MaintenanceJobHelper.FIX_ASSETS_QUEUE));
}

/**
* Enqueues a clean-assets job. The job walks the assets directory and deletes orphan
* binary folders whose contentlet inode is no longer in the database. Returns immediately
* with a job id; poll the job status via {@code GET /api/v1/jobs/{jobId}/status} or
* {@link #getLatestCleanAssetsJob}.
*/
@Operation(
summary = "Request a clean-assets job",
description = "Enqueues a clean orphan assets job on the cluster job queue. Returns "
+ "immediately with {jobId, statusUrl}. Rejects with 409 Conflict if a "
+ "clean-assets job is already pending or running anywhere in the cluster."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200",
description = "Job enqueued",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ResponseEntityJobStatusView.class))),
@ApiResponse(responseCode = "401",
description = "Unauthorized - authentication required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "403",
description = "Forbidden - CMS Administrator role required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "409",
description = "Conflict - a clean-assets job is already running",
content = @Content(mediaType = "application/json"))
})
@POST
@Path("/assets/_clean")
@NoCache
@Produces({MediaType.APPLICATION_JSON})
public ResponseEntityJobStatusView requestCleanAssetsJob(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response) {

final User user = assertBackendUser(request, response).getUser();
return new ResponseEntityJobStatusView(
jobHelper().createCleanAssetsJob(user, request));
}

/**
* Returns the most recent clean-assets job — active if any, otherwise most recently
* completed. Intended for page-reload / second-tab scenarios.
*/
@Operation(
summary = "Get latest clean-assets job",
description = "Returns the most recent clean-assets job (active, or most recently "
+ "completed). Returns null entity if no clean-assets job has ever run."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200",
description = "Latest job status",
content = @Content(mediaType = "application/json",
schema = @Schema(type = "object",
description = "ResponseEntityView wrapping the latest Job "
+ "(id, state, progress, result) or null if none exists"))),
@ApiResponse(responseCode = "401",
description = "Unauthorized - authentication required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "403",
description = "Forbidden - CMS Administrator role required",
content = @Content(mediaType = "application/json"))
})
@GET
@Path("/assets/_clean")
@JSONP
@NoCache
@Produces({MediaType.APPLICATION_JSON})
public ResponseEntityView<Job> getLatestCleanAssetsJob(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response) {

assertBackendUser(request, response);
return new ResponseEntityView<>(
jobHelper().getLatestJob(MaintenanceJobHelper.CLEAN_ASSETS_QUEUE));
}

/**
* Lazily resolves the {@link MaintenanceJobHelper} via CDI on first use. Resource
* instances are constructed by Jersey without CDI injection, so we pull the bean on
* demand. The field is volatile so the double-checked assignment is safe.
*/
private MaintenanceJobHelper jobHelper() {
MaintenanceJobHelper local = jobHelper;
if (local == null) {
local = CDIUtils.getBean(MaintenanceJobHelper.class).orElseThrow(() ->
new DotRuntimeException("MaintenanceJobHelper CDI bean not available"));
jobHelper = local;
}
return local;
}

/**
* Verifies that calling user is a backend user required to access the Maintenance portlet.
*
Expand Down
Loading
Loading