Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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,47 @@
package com.dotcms.rest.api.v1.publishing;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import org.immutables.value.Value;

import java.util.List;

/**
* Represents the acknowledgment response for a purge operation.
* Contains the confirmation message and the list of statuses being purged.
*
* @author hassandotcms
* @since Jan 2026
*/
@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*")
@Value.Immutable
@JsonSerialize(as = PurgeResultView.class)
@JsonDeserialize(as = PurgeResultView.class)
@Schema(description = "Purge operation acknowledgment with requested statuses")
public interface AbstractPurgeResultView {

/**
* Confirmation message indicating the purge operation has started.
*
* @return Acknowledgment message
*/
@Schema(
description = "Confirmation message indicating the purge operation has started",
example = "Purge operation started. Results will be notified when complete.",
requiredMode = Schema.RequiredMode.REQUIRED
)
String message();

/**
* List of status names that are being purged.
*
* @return List of status names requested for purge
*/
@Schema(
description = "List of status names being purged",
example = "[\"SUCCESS\", \"FAILED_TO_PUBLISH\"]",
requiredMode = Schema.RequiredMode.REQUIRED
)
List<String> statusesRequested();
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
import com.google.common.annotations.VisibleForTesting;

import java.time.Instant;
import java.util.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -61,6 +66,31 @@ public class PublishingJobsHelper {
Status.PUBLISHING_BUNDLE
);

/**
* Statuses that are safe to purge (not in-progress).
* Default statuses used when no status filter is specified for bulk purge.
*/
public static final Set<Status> SAFE_PURGE_STATUSES = Set.of(
// Terminal statuses
Status.SUCCESS,
Status.SUCCESS_WITH_WARNINGS,
Status.FAILED_TO_PUBLISH,
Status.FAILED_TO_BUNDLE,
Status.FAILED_TO_SENT,
Status.FAILED_TO_SEND_TO_ALL_GROUPS,
Status.FAILED_TO_SEND_TO_SOME_GROUPS,
Status.FAILED_INTEGRITY_CHECK,
Status.INVALID_TOKEN,
Status.LICENSE_REQUIRED,
// Queued status (can cancel)
Status.WAITING_FOR_PUBLISHING,
// Intermediate non-blocking statuses
Status.BUNDLE_REQUESTED,
Status.BUNDLE_SENT_SUCCESSFULLY,
Status.RECEIVED_BUNDLE,
Status.BUNDLE_SAVED_SUCCESSFULLY
);

private final BundleAPI bundleAPI;
private final PublisherAPI publisherAPI;
private final PublishAuditUtil publishAuditUtil;
Expand Down Expand Up @@ -173,6 +203,21 @@ public List<String> getValidStatusNames() {
.collect(Collectors.toList());
}

/**
* Checks if any of the provided statuses are in-progress (cannot be purged).
*
* @param statuses List of statuses to check
* @return List of in-progress statuses found (empty if all are safe)
*/
public List<Status> getInProgressStatuses(final List<Status> statuses) {
if (statuses == null) {
return List.of();
}
return statuses.stream()
.filter(IN_PROGRESS_STATUSES::contains)
.collect(Collectors.toList());
}

/**
* Checks if the given status indicates the bundle is actively being processed.
* Bundles with in-progress status cannot be deleted to prevent data corruption.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

import com.dotcms.publisher.bundle.bean.Bundle;
import com.dotcms.publisher.bundle.business.BundleAPI;
import com.dotcms.api.system.event.message.MessageSeverity;
import com.dotcms.api.system.event.message.SystemMessageEventUtil;
import com.dotcms.api.system.event.message.builder.SystemMessageBuilder;
import com.dotcms.concurrent.DotConcurrentFactory;
import com.dotcms.concurrent.DotSubmitter;
import com.dotcms.publisher.bundle.business.BundleAPI;
import com.dotcms.publisher.bundle.business.BundleDeleteResult;
import com.dotcms.publisher.business.DotPublisherException;
import com.dotcms.publisher.business.PublishAuditAPI;
import com.dotcms.publisher.business.PublishAuditStatus;
Expand All @@ -15,6 +22,8 @@
import com.dotcms.rest.exception.NotFoundException;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.util.DateUtil;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UtilMethods;
import com.google.common.annotations.VisibleForTesting;
Expand All @@ -36,6 +45,7 @@
import javax.ws.rs.core.MediaType;

import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -446,4 +456,206 @@ public Response deletePublishingJob(

return Response.ok(Map.of("message", "Bundle deleted successfully")).build();
}

/**
* Bulk deletes publishing jobs by status.
*
* <p>This endpoint is designed for cleaning up terminal state bundles (completed/failed)
* or canceling queued bundles. It CANNOT purge in-progress bundles to prevent data
* corruption.</p>
*
* <h3>Safe to Purge:</h3>
* <ul>
* <li>Terminal: SUCCESS, FAILED_TO_PUBLISH, FAILED_TO_BUNDLE, etc.</li>
* <li>Queued: WAITING_FOR_PUBLISHING (cancels scheduled publishes)</li>
* </ul>
*
* <h3>Cannot Purge (400 Bad Request):</h3>
* <ul>
* <li>BUNDLING - Creating bundle archive</li>
* <li>SENDING_TO_ENDPOINTS - Transmitting to targets</li>
* <li>PUBLISHING_BUNDLE - Applying at receiver</li>
* </ul>
*
* @param request The HTTP request
* @param response The HTTP response
* @param status Comma-separated status values to purge (optional)
* @return Acknowledgment message with purge details
*/
@Operation(
summary = "Bulk delete publishing jobs by status",
description = "Removes all bundles matching the specified status filter. " +
"Cannot purge in-progress bundles (BUNDLING, SENDING_TO_ENDPOINTS, PUBLISHING_BUNDLE). " +
"If no status specified, uses safe defaults (all terminal + queued statuses)."
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Purge operation initiated (processes in background)",
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = ResponseEntityPurgeView.class)
)
),
@ApiResponse(
responseCode = "400",
description = "Invalid status value or attempted to purge in-progress statuses",
content = @Content(mediaType = MediaType.APPLICATION_JSON)
),
@ApiResponse(
responseCode = "401",
description = "Unauthorized - authentication required",
content = @Content(mediaType = MediaType.APPLICATION_JSON)
),
@ApiResponse(
responseCode = "403",
description = "Forbidden - insufficient permissions",
content = @Content(mediaType = MediaType.APPLICATION_JSON)
)
})
@DELETE
@Path("/purge")
@JSONP
@NoCache
@Produces({MediaType.APPLICATION_JSON, "application/javascript"})
public ResponseEntityPurgeView purgePublishingJobs(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response,
@Parameter(
description = "Comma-separated status values to purge. If omitted, uses safe defaults " +
"(all terminal + queued, excludes in-progress). " +
"Cannot include: BUNDLING, SENDING_TO_ENDPOINTS, PUBLISHING_BUNDLE",
example = "SUCCESS,FAILED_TO_PUBLISH"
)
@QueryParam("status") final String status) throws DotDataException {

// Initialize request context and authenticate user (requires backend user)
final InitDataObject initData = new WebResource.InitBuilder(webResource)
.requiredBackendUser(true)
.requiredFrontendUser(false)
.requestAndResponse(request, response)
.rejectWhenNoUser(true)
.init();

final User user = initData.getUser();

// Determine statuses to purge
final List<Status> statusList;
if (UtilMethods.isSet(status)) {
// Validate provided statuses
final List<String> invalidStatuses = publishingJobsHelper.getInvalidStatuses(status);
if (!invalidStatuses.isEmpty()) {
throw new BadRequestException(
String.format("Invalid status value(s): %s. Valid values: %s",
String.join(", ", invalidStatuses),
String.join(", ", publishingJobsHelper.getValidStatusNames())));
}
statusList = publishingJobsHelper.parseStatuses(status);
} else {
// Use safe defaults
statusList = new ArrayList<>(PublishingJobsHelper.SAFE_PURGE_STATUSES);
}

// Check for in-progress statuses (400 Bad Request)
final List<Status> inProgressFound = publishingJobsHelper.getInProgressStatuses(statusList);
if (!inProgressFound.isEmpty()) {
throw new BadRequestException(
String.format("Cannot purge bundles with in-progress statuses: %s. " +
"These statuses are excluded to prevent data corruption.",
inProgressFound.stream()
.map(Status::name)
.collect(Collectors.joining(", "))));
}

Logger.info(this, String.format("Purging publishing jobs with statuses: %s by user: %s",
statusList.stream().map(Status::name).collect(Collectors.joining(", ")),
user.getUserId()));

// Execute purge asynchronously (consistent with legacy bulk delete pattern)
final DotSubmitter dotSubmitter = DotConcurrentFactory
.getInstance().getSubmitter(DotConcurrentFactory.DOT_SYSTEM_THREAD_POOL);

dotSubmitter.execute(() -> {
try {
final BundleDeleteResult result = bundleAPI.get()
.deleteAllBundles(user, statusList.toArray(new Status[0]));

sendPurgeResultMessage(initData, result);

} catch (DotDataException e) {
Logger.error(this, "Error purging publishing jobs", e);
sendPurgeErrorMessage(initData, e);
}
});

// Return immediate acknowledgment
final List<String> statusNames = statusList.stream()
.map(Status::name)
.collect(Collectors.toList());

return new ResponseEntityPurgeView(PurgeResultView.builder()
.message("Purge operation started. Results will be notified when complete.")
.statusesRequested(statusNames)
.build());
}

/**
* Sends success/warning message after purge completes.
*/
private void sendPurgeResultMessage(final InitDataObject initData,
final BundleDeleteResult result) {
try {
final int deletedCount = result.getDeleteBundleSet().size();
final int failedCount = result.getFailedBundleSet().size();
final String userId = initData.getUser().getUserId();

final String message;
final MessageSeverity severity;

if (failedCount == 0) {
message = String.format("%d bundles purged successfully", deletedCount);
severity = MessageSeverity.INFO;
} else {
message = String.format("%d bundles purged successfully, %d failed", deletedCount, failedCount);
severity = MessageSeverity.WARNING;
}

final SystemMessageEventUtil systemMessageEventUtil = SystemMessageEventUtil.getInstance();
systemMessageEventUtil.pushMessage(
new SystemMessageBuilder()
.setMessage(message)
.setLife(DateUtil.SEVEN_SECOND_MILLIS)
.setSeverity(severity)
.create(),
List.of(userId));

Logger.info(this, String.format("Purge completed: %s (user: %s)", message, userId));

} catch (Exception e) {
Logger.error(this, "Error sending purge result message", e);
}
}

/**
* Sends error message if purge fails.
*/
private void sendPurgeErrorMessage(final InitDataObject initData,
final Exception e) {
try {
final String userId = initData.getUser().getUserId();
final String message = String.format("Purge operation failed: %s", e.getMessage());

final SystemMessageEventUtil systemMessageEventUtil = SystemMessageEventUtil.getInstance();
systemMessageEventUtil.pushMessage(
new SystemMessageBuilder()
.setMessage(message)
.setLife(DateUtil.TEN_SECOND_MILLIS)
.setSeverity(MessageSeverity.ERROR)
.create(),
List.of(userId));

} catch (Exception ex) {
Logger.error(this, "Error sending purge error message", ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.dotcms.rest.api.v1.publishing;

import com.dotcms.rest.ResponseEntityView;

/**
* Response view for purge operation acknowledgment.
* Used for Swagger documentation of the purge endpoint response.
*
* @author hassandotcms
* @since Jan 2026
*/
public class ResponseEntityPurgeView extends ResponseEntityView<PurgeResultView> {

/**
* Creates a new ResponseEntityPurgeView with the given entity.
*
* @param entity PurgeResultView containing purge operation details
*/
public ResponseEntityPurgeView(final PurgeResultView entity) {
super(entity);
}
}
Loading
Loading