Files
Stirling-PDF/src/main/java/stirling/software/SPDF/utils/ProcessExecutor.java
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

657 lines
26 KiB
Java
Raw Normal View History

2023-03-20 21:55:11 +00:00
package stirling.software.SPDF.utils;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.InterruptedIOException;
2023-03-20 21:55:11 +00:00
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
2025-05-26 00:03:19 +01:00
import java.util.Comparator;
2023-03-25 22:16:26 +00:00
import java.util.List;
2023-04-01 21:02:54 +01:00
import java.util.Map;
2025-05-26 00:03:19 +01:00
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
2025-05-26 00:03:19 +01:00
import java.util.concurrent.ConcurrentLinkedQueue;
2023-04-01 21:02:54 +01:00
import java.util.concurrent.Semaphore;
2024-01-10 00:33:07 +00:00
import java.util.concurrent.TimeUnit;
2025-05-26 00:03:19 +01:00
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
2024-01-10 00:33:07 +00:00
import io.github.pixee.security.BoundedLineReader;
2025-05-26 00:03:19 +01:00
import lombok.Getter;
2024-12-17 10:26:18 +01:00
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties;
2024-12-17 10:26:18 +01:00
@Slf4j
2025-05-26 00:03:19 +01:00
@Component
2023-03-20 21:55:11 +00:00
public class ProcessExecutor {
private static final Map<Processes, ProcessExecutor> instances = new ConcurrentHashMap<>();
2025-05-26 00:03:19 +01:00
private static ApplicationProperties applicationProperties;
private Semaphore semaphore;
private boolean liveUpdates = true;
private long timeoutDuration = 10; // Default timeout of 10 minutes
@Autowired
public void setApplicationProperties(ApplicationProperties applicationProperties) {
ProcessExecutor.applicationProperties = applicationProperties;
// Initialize instances if not already done
initializeExecutorInstances();
}
/**
* Initialize all executor instances with the application properties This ensures that the
* static instances are correctly configured after application startup
*/
private void initializeExecutorInstances() {
if (applicationProperties != null) {
// Pre-initialize all process types
for (Processes type : Processes.values()) {
getInstance(type);
}
log.info("Initialized ProcessExecutor instances for all process types");
}
}
2025-05-26 00:03:19 +01:00
@Autowired
public ProcessExecutor() {
this.processType = null; // This instance is just for Spring DI
this.semaphore = new Semaphore(1); // Default to 1 permit
}
private ProcessExecutor(
Processes processType, int semaphoreLimit, boolean liveUpdates, long timeout) {
this.processType = processType;
this.semaphore = new Semaphore(semaphoreLimit);
this.liveUpdates = liveUpdates;
this.timeoutDuration = timeout;
2023-04-01 21:02:54 +01:00
}
2023-03-20 21:55:11 +00:00
2025-05-26 00:03:19 +01:00
// Task tracking
private Processes processType;
private final Queue<ConversionTask> queuedTasks = new ConcurrentLinkedQueue<>();
private final Map<String, ConversionTask> activeTasks = new ConcurrentHashMap<>();
private final Map<String, ConversionTask> completedTasks = new ConcurrentHashMap<>();
private static final int MAX_COMPLETED_TASKS = 100; // Maximum number of completed tasks to keep
// Metrics
private final AtomicInteger totalTasksProcessed = new AtomicInteger(0);
private final AtomicInteger failedTasks = new AtomicInteger(0);
private final AtomicInteger totalQueueTime = new AtomicInteger(0);
private final AtomicInteger totalProcessTime = new AtomicInteger(0);
// For testing - allows injecting a mock
private static ProcessExecutor mockInstance;
public static void setStaticMockInstance(ProcessExecutor mock) {
mockInstance = mock;
}
public static ProcessExecutor getInstance(Processes processType) {
2024-01-10 00:33:07 +00:00
return getInstance(processType, true);
2024-01-09 22:39:21 +00:00
}
public static ProcessExecutor getInstance(Processes processType, boolean liveUpdates) {
2025-05-26 00:03:19 +01:00
// For testing - return the mock if set
if (mockInstance != null) {
return mockInstance;
}
return instances.computeIfAbsent(
processType,
key -> {
2025-05-26 00:03:19 +01:00
int semaphoreLimit = 1; // Default if applicationProperties is null
long timeoutMinutes = 10; // Default if applicationProperties is null
if (applicationProperties != null) {
semaphoreLimit =
switch (key) {
case LIBRE_OFFICE ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getLibreOfficeSessionLimit();
case PDFTOHTML ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPdfToHtmlSessionLimit();
case PYTHON_OPENCV ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPythonOpenCvSessionLimit();
case WEASYPRINT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getWeasyPrintSessionLimit();
case INSTALL_APP ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getInstallAppSessionLimit();
case TESSERACT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getTesseractSessionLimit();
case QPDF ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getQpdfSessionLimit();
case CALIBRE ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getCalibreSessionLimit();
};
timeoutMinutes =
switch (key) {
case LIBRE_OFFICE ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getLibreOfficeTimeoutMinutes();
case PDFTOHTML ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPdfToHtmlTimeoutMinutes();
case PYTHON_OPENCV ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPythonOpenCvTimeoutMinutes();
case WEASYPRINT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getWeasyPrintTimeoutMinutes();
case INSTALL_APP ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getInstallAppTimeoutMinutes();
case TESSERACT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getTesseractTimeoutMinutes();
case QPDF ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getQpdfTimeoutMinutes();
case CALIBRE ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getCalibreTimeoutMinutes();
};
}
return new ProcessExecutor(key, semaphoreLimit, liveUpdates, timeoutMinutes);
});
}
2023-03-20 21:55:11 +00:00
2025-05-26 00:03:19 +01:00
/**
* Creates a new conversion task and adds it to the queue
*
* @param taskName A descriptive name for the task
* @return The created conversion task
*/
public ConversionTask createTask(String taskName) {
ConversionTask task = new ConversionTask(taskName, this.processType);
queuedTasks.add(task);
updateQueuePositions();
log.debug(
"Created new task {} for {} process, queue position: {}",
task.getId(),
processType,
task.getQueuePosition());
return task;
}
/**
* Gets a task by its ID
*
* @param taskId The task ID
* @return The task or null if not found
*/
public ConversionTask getTask(String taskId) {
// Check active tasks first
ConversionTask task = activeTasks.get(taskId);
if (task != null) {
return task;
}
// Check queued tasks
for (ConversionTask queuedTask : queuedTasks) {
if (queuedTask.getId().equals(taskId)) {
return queuedTask;
}
}
// Check completed tasks
return completedTasks.get(taskId);
}
/**
* Gets all tasks for this process type
*
* @return List of all tasks
*/
public List<ConversionTask> getAllTasks() {
List<ConversionTask> allTasks = new ArrayList<>();
allTasks.addAll(queuedTasks);
allTasks.addAll(activeTasks.values());
allTasks.addAll(completedTasks.values());
return allTasks;
}
/**
* Gets all active tasks for this process type
*
* @return List of active tasks
*/
public List<ConversionTask> getActiveTasks() {
return new ArrayList<>(activeTasks.values());
}
/**
* Gets all queued tasks for this process type
*
* @return List of queued tasks
*/
public List<ConversionTask> getQueuedTasks() {
return new ArrayList<>(queuedTasks);
}
/**
* Gets the current queue length
*
* @return Number of tasks in queue
*/
public int getQueueLength() {
return queuedTasks.size();
}
/**
* Gets the number of active tasks
*
* @return Number of tasks currently running
*/
public int getActiveTaskCount() {
return activeTasks.size();
}
/**
* Gets the maximum number of concurrent tasks
*
* @return Maximum concurrent tasks
*/
public int getMaxConcurrentTasks() {
return semaphore.availablePermits() + semaphore.getQueueLength();
}
/**
* Gets the estimated wait time based on current queue and average processing time
*
* @return Estimated wait time in milliseconds
*/
public long getEstimatedWaitTimeMs() {
if (queuedTasks.isEmpty()) {
return 0;
}
int processed = totalTasksProcessed.get();
if (processed == 0) {
return 30000; // Default 30 seconds if no data
}
double avgProcessTime = totalProcessTime.get() / (double) processed;
int activeCount = activeTasks.size();
int maxConcurrent = semaphore.availablePermits() + semaphore.getQueueLength();
int queueLength = queuedTasks.size();
// Calculate how many queue cycles are needed
double cycles = Math.ceil(queueLength / (double) maxConcurrent);
// Estimate wait time
return (long) (avgProcessTime * cycles);
}
/** Updates the queue positions for all queued tasks */
private synchronized void updateQueuePositions() {
int position = 0;
for (ConversionTask task : queuedTasks) {
task.setQueuePosition(++position);
}
}
/**
* Run a command with a task for queue tracking
*
* @param command The command to execute
* @param taskName A descriptive name for the task
* @return The result of the execution
*/
public ProcessExecutorResult runCommandWithTask(List<String> command, String taskName)
throws IOException, InterruptedException {
return runCommandWithTask(command, null, taskName);
}
/**
* Run a command without creating a task (direct execution)
*
* @param command The command to execute
* @return The result of the execution
*/
public ProcessExecutorResult runCommand(List<String> command)
throws IOException, InterruptedException {
return runCommandWithTask(command, "Unnamed command");
}
/**
* Run a command with a task for queue tracking
*
* @param command The command to execute
* @param workingDirectory The working directory
* @param taskName A descriptive name for the task
* @return The result of the execution
*/
public ProcessExecutorResult runCommandWithTask(
List<String> command, File workingDirectory, String taskName)
throws IOException, InterruptedException {
// Create and track the task
ConversionTask task = createTask(taskName);
try {
return runCommandWithOutputHandling(command, workingDirectory, task);
} catch (Exception e) {
task.fail(e.getMessage());
throw e;
}
}
/** Legacy method for backwards compatibility */
2023-07-29 13:53:30 +01:00
public ProcessExecutorResult runCommandWithOutputHandling(List<String> command)
throws IOException, InterruptedException {
2023-07-22 16:57:40 +01:00
return runCommandWithOutputHandling(command, null);
}
2023-12-30 19:11:27 +00:00
2025-05-26 00:03:19 +01:00
/** Legacy method for backwards compatibility */
2023-07-29 13:53:30 +01:00
public ProcessExecutorResult runCommandWithOutputHandling(
List<String> command, File workingDirectory) throws IOException, InterruptedException {
2025-05-26 00:03:19 +01:00
return runCommandWithOutputHandling(command, workingDirectory, null);
}
/** Main method to run a command and handle its output */
private ProcessExecutorResult runCommandWithOutputHandling(
List<String> command, File workingDirectory, ConversionTask task)
throws IOException, InterruptedException {
2023-07-29 13:53:30 +01:00
String messages = "";
2024-01-10 00:33:07 +00:00
int exitCode = 1;
2025-05-26 00:03:19 +01:00
// If no task was provided, create an anonymous one
boolean createdTask = false;
if (task == null) {
task = createTask("Anonymous " + processType + " task");
createdTask = true;
}
// Wait for a permit from the semaphore (this is where queuing happens)
semaphore.acquire();
2025-05-26 00:03:19 +01:00
// Task is now running
task.start(Thread.currentThread());
queuedTasks.remove(task);
activeTasks.put(task.getId(), task);
updateQueuePositions(); // Update queue positions for remaining tasks
try {
log.info("Running command for task {}: {}", task.getId(), String.join(" ", command));
ProcessBuilder processBuilder = new ProcessBuilder(command);
2023-12-30 19:11:27 +00:00
2023-07-22 16:57:40 +01:00
// Use the working directory if it's set
if (workingDirectory != null) {
processBuilder.directory(workingDirectory);
}
Process process = processBuilder.start();
// Read the error stream and standard output stream concurrently
List<String> errorLines = new ArrayList<>();
List<String> outputLines = new ArrayList<>();
Thread errorReaderThread =
new Thread(
() -> {
try (BufferedReader errorReader =
new BufferedReader(
new InputStreamReader(
process.getErrorStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line =
BoundedLineReader.readLine(
errorReader, 5_000_000))
!= null) {
errorLines.add(line);
2024-12-17 10:26:18 +01:00
if (liveUpdates) log.info(line);
}
2024-01-10 00:33:07 +00:00
} catch (InterruptedIOException e) {
2024-12-17 10:26:18 +01:00
log.warn("Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
2024-12-17 10:26:18 +01:00
log.error("exception", e);
}
});
2023-12-30 19:11:27 +00:00
Thread outputReaderThread =
new Thread(
() -> {
try (BufferedReader outputReader =
new BufferedReader(
new InputStreamReader(
process.getInputStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line =
BoundedLineReader.readLine(
outputReader, 5_000_000))
!= null) {
outputLines.add(line);
2024-12-17 10:26:18 +01:00
if (liveUpdates) log.info(line);
}
2024-01-10 00:33:07 +00:00
} catch (InterruptedIOException e) {
2024-12-17 10:26:18 +01:00
log.warn("Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
2024-12-17 10:26:18 +01:00
log.error("exception", e);
}
});
errorReaderThread.start();
outputReaderThread.start();
// Wait for the conversion process to complete
2024-01-10 00:33:07 +00:00
boolean finished = process.waitFor(timeoutDuration, TimeUnit.MINUTES);
if (!finished) {
// Terminate the process
process.destroy();
// Interrupt the reader threads
errorReaderThread.interrupt();
outputReaderThread.interrupt();
throw new IOException("Process timeout exceeded.");
}
exitCode = process.exitValue();
// Wait for the reader threads to finish
errorReaderThread.join();
outputReaderThread.join();
2023-12-30 19:11:27 +00:00
boolean isQpdf =
command != null && !command.isEmpty() && command.get(0).contains("qpdf");
if (!outputLines.isEmpty()) {
2024-03-28 17:09:21 +00:00
String outputMessage = String.join("\n", outputLines);
messages += outputMessage;
if (!liveUpdates) {
2024-12-17 10:26:18 +01:00
log.info("Command output:\n" + outputMessage);
2024-01-09 22:39:21 +00:00
}
2024-03-28 17:09:21 +00:00
}
if (!errorLines.isEmpty()) {
2024-03-28 17:09:21 +00:00
String errorMessage = String.join("\n", errorLines);
messages += errorMessage;
if (!liveUpdates) {
2024-12-17 10:26:18 +01:00
log.warn("Command error output:\n" + errorMessage);
}
2024-03-28 17:09:21 +00:00
if (exitCode != 0) {
if (isQpdf && exitCode == 3) {
log.warn("qpdf succeeded with warnings: {}", messages);
} else {
throw new IOException(
"Command process failed with exit code "
+ exitCode
+ ". Error message: "
+ errorMessage);
}
2024-03-28 17:09:21 +00:00
}
}
if (exitCode != 0) {
if (isQpdf && exitCode == 3) {
log.warn("qpdf succeeded with warnings: {}", messages);
} else {
throw new IOException(
"Command process failed with exit code "
+ exitCode
+ "\nLogs: "
+ messages);
}
}
2025-05-26 00:03:19 +01:00
// Task completed successfully
task.complete();
totalTasksProcessed.incrementAndGet();
totalProcessTime.addAndGet((int) task.getProcessingTimeMs());
totalQueueTime.addAndGet((int) task.getQueueTimeMs());
// Move from active to completed
activeTasks.remove(task.getId());
addToCompletedTasks(task);
log.debug(
"Task {} completed in {}ms (queue: {}ms, processing: {}ms)",
task.getId(),
task.getTotalTimeMs(),
task.getQueueTimeMs(),
task.getProcessingTimeMs());
} catch (Exception e) {
// Task failed
task.fail(e.getMessage());
failedTasks.incrementAndGet();
// Move from active to completed
activeTasks.remove(task.getId());
addToCompletedTasks(task);
log.error(
"Task {} failed after {}ms (queue: {}ms, processing: {}ms): {}",
task.getId(),
task.getTotalTimeMs(),
task.getQueueTimeMs(),
task.getProcessingTimeMs(),
e.getMessage());
throw e;
} finally {
semaphore.release();
2025-05-26 00:03:19 +01:00
// For anonymous tasks, don't keep them in completed tasks
if (createdTask) {
completedTasks.remove(task.getId());
}
}
return new ProcessExecutorResult(exitCode, messages, task.getId());
}
/** Adds a task to the completed tasks map, maintaining size limit */
private synchronized void addToCompletedTasks(ConversionTask task) {
// Add the task to completed tasks
completedTasks.put(task.getId(), task);
// If we exceed the limit, remove oldest completed tasks
if (completedTasks.size() > MAX_COMPLETED_TASKS) {
List<ConversionTask> oldestTasks =
completedTasks.values().stream()
.sorted(Comparator.comparing(ConversionTask::getEndTime))
.limit(completedTasks.size() - MAX_COMPLETED_TASKS)
.collect(Collectors.toList());
for (ConversionTask oldTask : oldestTasks) {
completedTasks.remove(oldTask.getId());
}
}
}
/** Periodically log queue statistics (once per minute) */
@Scheduled(fixedRate = 60000)
public void logQueueStatistics() {
if (!queuedTasks.isEmpty() || !activeTasks.isEmpty()) {
int total = totalTasksProcessed.get();
int failed = failedTasks.get();
float successRate = total > 0 ? (float) (total - failed) / total * 100 : 0;
float avgQueueTime = total > 0 ? (float) totalQueueTime.get() / total : 0;
float avgProcessTime = total > 0 ? (float) totalProcessTime.get() / total : 0;
log.info(
"{} queue status: Active={}, Queued={}, Completed={}, AvgQueue={}ms, AvgProcess={}ms, SuccessRate={:.2f}%",
processType,
activeTasks.size(),
queuedTasks.size(),
total,
avgQueueTime,
avgProcessTime,
successRate);
}
2023-07-29 13:53:30 +01:00
}
2023-12-30 19:11:27 +00:00
public enum Processes {
LIBRE_OFFICE,
PDFTOHTML,
PYTHON_OPENCV,
WEASYPRINT,
INSTALL_APP,
CALIBRE,
TESSERACT,
QPDF
}
2025-05-26 00:03:19 +01:00
@Getter
2023-07-29 13:53:30 +01:00
public class ProcessExecutorResult {
2025-05-26 00:03:19 +01:00
private final int rc;
private final String messages;
private final String taskId;
2023-12-30 19:11:27 +00:00
2023-07-29 13:53:30 +01:00
public ProcessExecutorResult(int rc, String messages) {
2025-05-26 00:03:19 +01:00
this(rc, messages, null);
2023-07-29 13:53:30 +01:00
}
2023-12-30 19:11:27 +00:00
2025-05-26 00:03:19 +01:00
public ProcessExecutorResult(int rc, String messages, String taskId) {
2023-07-29 13:53:30 +01:00
this.rc = rc;
this.messages = messages;
2025-05-26 00:03:19 +01:00
this.taskId = taskId;
2023-07-29 13:53:30 +01:00
}
2023-04-01 21:02:54 +01:00
}
2023-03-20 21:55:11 +00:00
}