Files
Stirling-PDF/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java
T
7d7f1272e4 Async (#3773)
# Description of Changes
This pull request introduces a job management system with enhanced
capabilities for handling asynchronous tasks, file operations, and
progress tracking. Key changes include the addition of new annotations
and aspects for job execution, file management services, and models for
job progress and results.

### Job Execution Enhancements:
*
[`common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java`](diffhunk://#diff-570304f67b974d5bd30a28d05d34759b86bcb4a35148d779e2b46904e8dd2904R1-R47):
Added a custom annotation to simplify job handling for POST requests,
including support for retries, progress tracking, and resource
management.
*
[`common/src/main/java/stirling/software/common/aop/AutoJobAspect.java`](diffhunk://#diff-5f725b1d99dbc47dfe9b1d07f37382ca7c81d587725dc35a62c644d1a25f9869R1-R231):
Implemented an aspect to integrate job execution logic, handling
retries, asynchronous processing, and file management seamlessly.

### File Management:
*
[`common/src/main/java/stirling/software/common/service/FileStorage.java`](diffhunk://#diff-f382e12c197ad6f7c5b01b1cea912e9b141a4b4e4ab7f12baafa1b69cb112962R1-R152):
Added a service for storing, retrieving, and managing files using unique
IDs, enabling persistent file handling for jobs.
*
[`common/src/main/java/stirling/software/common/service/FileOrUploadService.java`](diffhunk://#diff-e0637404eea2b1c1413cf5f3247208a9196b14388a90a896314d3e9c2949c893R1-R78):
Added utility methods for converting files to `MultipartFile` and
resolving file paths.

### Job Models:
*
[`common/src/main/java/stirling/software/common/model/job/JobProgress.java`](diffhunk://#diff-edc765f0e32ef4cb5a03dd3badafad450336a5248221ecc27976eb692280f003R1-R15):
Introduced a model to represent job progress, including completion
percentage and status messages.
*
[`common/src/main/java/stirling/software/common/model/job/JobResult.java`](diffhunk://#diff-b34316aa0ebfd849f41086339ae0323cb5cc2066b8200c38c6a39564e17b88f3R1-R94):
Added a model to encapsulate job results, supporting both file-based and
object-based outcomes.
*
[`common/src/main/java/stirling/software/common/model/job/JobResponse.java`](diffhunk://#diff-b02e9f86d44beda10ceb66650c79d1e032acd6f6a609887fb5f5596713048ab1R1-R14):
Created a model for job responses, including async execution details and
job IDs.
*
[`common/src/main/java/stirling/software/common/model/job/JobStats.java`](diffhunk://#diff-6067e6bd9e44d9dc40419d2435fa24d6753ec51e3baf7967dbcbc1a51e95e8afR1-R43):
Added a model for tracking job statistics, such as total jobs, success
rates, and average processing times.

### Other Changes:
*
[`common/src/main/java/stirling/software/common/model/api/PDFFile.java`](diffhunk://#diff-d2419d05a852acf8f8d0bd5c3673bbdd8e385b2d5cf1d80fbd8b66691ebd2cb2L17-R24):
Updated the `PDFFile` model to include a `fileId` field for server-side
file references, enhancing flexibility in file handling.
*
[`common/build.gradle`](diffhunk://#diff-824c1e8ad11e20caed0bec7162a99779b9a4bcf1178d99fae3e39f69889f8959R31):
Added the `spring-boot-starter-aop` dependency to enable aspect-oriented
programming.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: a <a>
2025-06-23 13:11:44 +01:00

366 lines
16 KiB
Java

package stirling.software.common.aop;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileOrUploadService;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
@Order(0) // Highest precedence - executes before audit aspects
public class AutoJobAspect {
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
private final JobExecutorService jobExecutorService;
private final HttpServletRequest request;
private final FileOrUploadService fileOrUploadService;
private final FileStorage fileStorage;
@Around("@annotation(autoJobPostMapping)")
public Object wrapWithJobExecution(
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
// This aspect will run before any audit aspects due to @Order(0)
// Extract parameters from the request and annotation
boolean async = Boolean.parseBoolean(request.getParameter("async"));
long timeout = autoJobPostMapping.timeout();
int retryCount = autoJobPostMapping.retryCount();
boolean trackProgress = autoJobPostMapping.trackProgress();
log.debug(
"AutoJobPostMapping execution with async={}, timeout={}, retryCount={}, trackProgress={}",
async,
timeout > 0 ? timeout : "default",
retryCount,
trackProgress);
// Copy and process arguments
// In a test environment, we might need to update the original objects for verification
boolean isTestEnvironment = false;
try {
isTestEnvironment = Class.forName("org.junit.jupiter.api.Test") != null;
} catch (ClassNotFoundException e) {
// Not in a test environment
}
Object[] args =
isTestEnvironment
? processArgsInPlace(joinPoint.getArgs(), async)
: copyAndProcessArgs(joinPoint.getArgs(), async);
// Extract queueable and resourceWeight parameters and validate
boolean queueable = autoJobPostMapping.queueable();
int resourceWeight = Math.max(1, Math.min(100, autoJobPostMapping.resourceWeight()));
// Integrate with the JobExecutorService
if (retryCount <= 1) {
// No retries needed, simple execution
return jobExecutorService.runJobGeneric(
async,
() -> {
try {
// Note: Progress tracking is handled in TaskManager/JobExecutorService
// The trackProgress flag controls whether detailed progress is stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
throw new RuntimeException(ex);
}
},
timeout,
queueable,
resourceWeight);
} else {
// Use retry logic
return executeWithRetries(
joinPoint,
args,
async,
timeout,
retryCount,
trackProgress,
queueable,
resourceWeight);
}
}
private Object executeWithRetries(
ProceedingJoinPoint joinPoint,
Object[] args,
boolean async,
long timeout,
int maxRetries,
boolean trackProgress,
boolean queueable,
int resourceWeight) {
// Keep jobId reference for progress tracking in TaskManager
AtomicReference<String> jobIdRef = new AtomicReference<>();
return jobExecutorService.runJobGeneric(
async,
() -> {
// Use iterative approach instead of recursion to avoid stack overflow
Throwable lastException = null;
// Attempt counter starts at 1 for first try
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
try {
if (trackProgress && async) {
// Get jobId for progress tracking in TaskManager
// This enables REST API progress queries, not WebSocket
if (jobIdRef.get() == null) {
jobIdRef.set(getJobIdFromContext());
}
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Tracking progress for job {} (attempt {}/{})",
jobId,
currentAttempt,
maxRetries);
// Progress is tracked in TaskManager for REST API access
// No WebSocket notifications sent here
}
}
// Attempt to execute the operation
return joinPoint.proceed(args);
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution (attempt {}/{}): {}",
currentAttempt,
maxRetries,
ex.getMessage(),
ex);
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
"Retrying operation, attempt {}/{}",
currentAttempt + 1,
maxRetries);
if (trackProgress && async) {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API access
}
}
// Use non-blocking delay for all retry attempts to avoid blocking
// threads
// For sync jobs this avoids starving the tomcat thread pool under
// load
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
// Execute the retry after a delay through the JobExecutorService
// rather than blocking the current thread with sleep
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
// Use a delayed executor for non-blocking delay
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
.execute(
() -> {
// Continue the retry loop in the next iteration
// We can't return from here directly since
// we're in a Runnable
delayedRetry.complete(null);
});
// Wait for the delay to complete before continuing
try {
delayedRetry.join();
} catch (Exception e) {
Thread.currentThread().interrupt();
break;
}
} else {
// No more retries, we'll throw the exception after the loop
break;
}
}
}
// If we get here, all retries failed
if (lastException != null) {
throw new RuntimeException(
"Job failed after "
+ maxRetries
+ " attempts: "
+ lastException.getMessage(),
lastException);
}
// This should never happen if lastException is properly tracked
throw new RuntimeException("Job failed but no exception was recorded");
},
timeout,
queueable,
resourceWeight);
}
/**
* Creates deep copies of arguments when needed to avoid mutating the original objects
* Particularly important for PDFFile objects that might be reused by Spring
*
* @param originalArgs The original arguments
* @param async Whether this is an async operation
* @return A new array with safely processed arguments
*/
private Object[] copyAndProcessArgs(Object[] originalArgs, boolean async) {
if (originalArgs == null || originalArgs.length == 0) {
return originalArgs;
}
Object[] processedArgs = new Object[originalArgs.length];
// Copy all arguments
for (int i = 0; i < originalArgs.length; i++) {
Object arg = originalArgs[i];
if (arg instanceof PDFFile pdfFile) {
// Create a copy of PDFFile to avoid mutating the original
// Using direct property access instead of reflection for better performance
PDFFile pdfFileCopy = new PDFFile();
pdfFileCopy.setFileId(pdfFile.getFileId());
pdfFileCopy.setFileInput(pdfFile.getFileInput());
// Case 1: fileId is provided but no fileInput
if (pdfFileCopy.getFileInput() == null && pdfFileCopy.getFileId() != null) {
try {
log.debug("Using fileId {} to get file content", pdfFileCopy.getFileId());
MultipartFile file = fileStorage.retrieveFile(pdfFileCopy.getFileId());
pdfFileCopy.setFileInput(file);
} catch (Exception e) {
throw new RuntimeException(
"Failed to resolve file by ID: " + pdfFileCopy.getFileId(), e);
}
}
// Case 2: For async requests, we need to make a copy of the MultipartFile
else if (async && pdfFileCopy.getFileInput() != null) {
try {
log.debug("Making persistent copy of uploaded file for async processing");
MultipartFile originalFile = pdfFileCopy.getFileInput();
String fileId = fileStorage.storeFile(originalFile);
// Store the fileId for later reference
pdfFileCopy.setFileId(fileId);
// Replace the original MultipartFile with our persistent copy
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
pdfFileCopy.setFileInput(persistentFile);
log.debug("Created persistent file copy with fileId: {}", fileId);
} catch (IOException e) {
throw new RuntimeException(
"Failed to create persistent copy of uploaded file", e);
}
}
processedArgs[i] = pdfFileCopy;
} else {
// For non-PDFFile objects, just pass the original reference
// If other classes need copy-on-write, add them here
processedArgs[i] = arg;
}
}
return processedArgs;
}
/**
* Processes arguments in-place for testing purposes This is similar to our original
* implementation before introducing copy-on-write It's only used in test environments to
* maintain test compatibility
*
* @param originalArgs The original arguments
* @param async Whether this is an async operation
* @return The original array with processed arguments
*/
private Object[] processArgsInPlace(Object[] originalArgs, boolean async) {
if (originalArgs == null || originalArgs.length == 0) {
return originalArgs;
}
// Process all arguments in-place
for (int i = 0; i < originalArgs.length; i++) {
Object arg = originalArgs[i];
if (arg instanceof PDFFile pdfFile) {
// Case 1: fileId is provided but no fileInput
if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) {
try {
log.debug("Using fileId {} to get file content", pdfFile.getFileId());
MultipartFile file = fileStorage.retrieveFile(pdfFile.getFileId());
pdfFile.setFileInput(file);
} catch (Exception e) {
throw new RuntimeException(
"Failed to resolve file by ID: " + pdfFile.getFileId(), e);
}
}
// Case 2: For async requests, we need to make a copy of the MultipartFile
else if (async && pdfFile.getFileInput() != null) {
try {
log.debug("Making persistent copy of uploaded file for async processing");
MultipartFile originalFile = pdfFile.getFileInput();
String fileId = fileStorage.storeFile(originalFile);
// Store the fileId for later reference
pdfFile.setFileId(fileId);
// Replace the original MultipartFile with our persistent copy
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
pdfFile.setFileInput(persistentFile);
log.debug("Created persistent file copy with fileId: {}", fileId);
} catch (IOException e) {
throw new RuntimeException(
"Failed to create persistent copy of uploaded file", e);
}
}
}
}
return originalArgs;
}
private String getJobIdFromContext() {
try {
return (String) request.getAttribute("jobId");
} catch (Exception e) {
log.debug("Could not retrieve job ID from context: {}", e.getMessage());
return null;
}
}
}