2023-03-20 21:55:11 +00:00
|
|
|
package stirling.software.SPDF.utils;
|
|
|
|
|
|
2025-02-26 20:25:35 +01:00
|
|
|
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;
|
2023-04-16 22:03:30 +01:00
|
|
|
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
|
|
|
|
2024-02-07 21:40:33 -05: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;
|
2025-02-23 13:36:21 +00:00
|
|
|
|
2024-11-06 17:43:57 -07:00
|
|
|
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 {
|
2023-04-22 12:51:01 +01:00
|
|
|
|
2024-12-24 09:52:53 +00:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-11-06 17:43:57 -07:00
|
|
|
|
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;
|
2024-12-24 09:52:53 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-22 12:51:01 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-22 12:51:01 +01:00
|
|
|
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-04-22 12:51:01 +01:00
|
|
|
});
|
|
|
|
|
}
|
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)
|
2023-04-22 12:51:01 +01:00
|
|
|
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));
|
2023-04-22 12:51:01 +01:00
|
|
|
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);
|
|
|
|
|
}
|
2023-04-22 12:51:01 +01:00
|
|
|
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;
|
2024-02-07 21:40:33 -05:00
|
|
|
while ((line =
|
|
|
|
|
BoundedLineReader.readLine(
|
|
|
|
|
errorReader, 5_000_000))
|
|
|
|
|
!= null) {
|
2023-04-22 12:51:01 +01:00
|
|
|
errorLines.add(line);
|
2024-12-17 10:26:18 +01:00
|
|
|
if (liveUpdates) log.info(line);
|
2023-04-22 12:51:01 +01:00
|
|
|
}
|
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.");
|
2023-04-22 12:51:01 +01:00
|
|
|
} catch (IOException e) {
|
2024-12-17 10:26:18 +01:00
|
|
|
log.error("exception", e);
|
2023-04-22 12:51:01 +01:00
|
|
|
}
|
|
|
|
|
});
|
2023-12-30 19:11:27 +00:00
|
|
|
|
2023-04-22 12:51:01 +01:00
|
|
|
Thread outputReaderThread =
|
|
|
|
|
new Thread(
|
|
|
|
|
() -> {
|
|
|
|
|
try (BufferedReader outputReader =
|
|
|
|
|
new BufferedReader(
|
|
|
|
|
new InputStreamReader(
|
|
|
|
|
process.getInputStream(),
|
|
|
|
|
StandardCharsets.UTF_8))) {
|
|
|
|
|
String line;
|
2024-02-07 21:40:33 -05:00
|
|
|
while ((line =
|
|
|
|
|
BoundedLineReader.readLine(
|
|
|
|
|
outputReader, 5_000_000))
|
|
|
|
|
!= null) {
|
2023-04-22 12:51:01 +01:00
|
|
|
outputLines.add(line);
|
2024-12-17 10:26:18 +01:00
|
|
|
if (liveUpdates) log.info(line);
|
2023-04-22 12:51:01 +01:00
|
|
|
}
|
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.");
|
2023-04-22 12:51:01 +01:00
|
|
|
} catch (IOException e) {
|
2024-12-17 10:26:18 +01:00
|
|
|
log.error("exception", e);
|
2023-04-22 12:51:01 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
2023-04-22 12:51:01 +01:00
|
|
|
// Wait for the reader threads to finish
|
|
|
|
|
errorReaderThread.join();
|
|
|
|
|
outputReaderThread.join();
|
2023-12-30 19:11:27 +00:00
|
|
|
|
2025-02-05 02:31:41 +05:30
|
|
|
boolean isQpdf =
|
|
|
|
|
command != null && !command.isEmpty() && command.get(0).contains("qpdf");
|
|
|
|
|
|
2025-02-26 20:25:35 +01:00
|
|
|
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
|
|
|
}
|
2023-04-22 12:51:01 +01:00
|
|
|
|
2025-02-26 20:25:35 +01: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);
|
2023-04-22 12:51:01 +01:00
|
|
|
}
|
2024-03-28 17:09:21 +00:00
|
|
|
if (exitCode != 0) {
|
2025-02-05 02:31:41 +05:30
|
|
|
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) {
|
2025-02-05 02:31:41 +05:30
|
|
|
if (isQpdf && exitCode == 3) {
|
|
|
|
|
log.warn("qpdf succeeded with warnings: {}", messages);
|
|
|
|
|
} else {
|
|
|
|
|
throw new IOException(
|
|
|
|
|
"Command process failed with exit code "
|
|
|
|
|
+ exitCode
|
|
|
|
|
+ "\nLogs: "
|
|
|
|
|
+ messages);
|
|
|
|
|
}
|
2023-04-22 12:51:01 +01:00
|
|
|
}
|
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;
|
2023-04-22 12:51:01 +01:00
|
|
|
} 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-04-22 12:51:01 +01:00
|
|
|
}
|
2023-07-29 13:53:30 +01:00
|
|
|
}
|
2023-12-30 19:11:27 +00:00
|
|
|
|
2024-12-24 09:52:53 +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
|
|
|
}
|