init
This commit is contained in:
@@ -202,6 +202,11 @@ public class AppConfig {
|
||||
public boolean disablePixel() {
|
||||
return Boolean.getBoolean(env.getProperty("DISABLE_PIXEL"));
|
||||
}
|
||||
|
||||
@Bean(name = "showQueueStatus")
|
||||
public boolean showQueueStatus() {
|
||||
return applicationProperties.getUi().isQueueStatusEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "machineType")
|
||||
public String determineMachineType() {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
|
||||
/**
|
||||
* Annotation to conditionally enable components based on the availability of UnoServer. Components
|
||||
* annotated with this will only be created if UnoServer is available on the system.
|
||||
*/
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Conditional(UnoServerAvailableCondition.class)
|
||||
public @interface ConditionalOnUnoServerAvailable {}
|
||||
@@ -0,0 +1,193 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Condition;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Condition that checks if UnoServer is available on the system. This condition will pass if: 1.
|
||||
* The unoserver executable is found via RuntimePathConfig (from unoConvertPath) 2. The unoserver
|
||||
* executable is found at /opt/venv/bin/unoserver (Docker path) 3. The unoserver executable is found
|
||||
* in PATH 4. The unoserver executable is found in any common installation directories
|
||||
*/
|
||||
@Slf4j
|
||||
public class UnoServerAvailableCondition implements Condition {
|
||||
|
||||
// Common installation paths to check
|
||||
private static final String[] COMMON_UNOSERVER_PATHS = {
|
||||
"/opt/venv/bin/unoserver", // Docker path
|
||||
"/usr/bin/unoserver", // Linux system path
|
||||
"/usr/local/bin/unoserver", // Linux local path
|
||||
"/opt/homebrew/bin/unoserver", // Mac Homebrew path
|
||||
"/opt/libreoffice/program/unoserver" // Custom LibreOffice path
|
||||
};
|
||||
|
||||
@Override
|
||||
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
|
||||
log.info("Checking if UnoServer is available on the system...");
|
||||
|
||||
// Collect all paths to check
|
||||
List<String> pathsToCheck = new ArrayList<>();
|
||||
|
||||
// Check for Docker environment
|
||||
boolean isDocker = Files.exists(Path.of("/.dockerenv"));
|
||||
log.debug("Docker environment detected: {}", isDocker);
|
||||
|
||||
// Try to get unoserver path from RuntimePathConfig first (highest priority)
|
||||
String unoserverFromRuntimeConfig = getUnoServerPathFromRuntimeConfig(context);
|
||||
if (unoserverFromRuntimeConfig != null) {
|
||||
pathsToCheck.add(unoserverFromRuntimeConfig);
|
||||
}
|
||||
|
||||
// Add common installation paths
|
||||
for (String path : COMMON_UNOSERVER_PATHS) {
|
||||
pathsToCheck.add(path);
|
||||
}
|
||||
|
||||
// Add "unoserver" to check in PATH
|
||||
pathsToCheck.add("unoserver");
|
||||
|
||||
// Try all paths one by one
|
||||
for (String path : pathsToCheck) {
|
||||
log.debug("Checking for UnoServer at: {}", path);
|
||||
|
||||
if (isExecutableAvailable(path)) {
|
||||
log.info("UnoServer found at: {}, enabling UnoServerManager", path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, we didn't find unoserver anywhere
|
||||
log.warn(
|
||||
"UnoServer not found in any of the expected locations. UnoServerManager will be disabled.");
|
||||
log.info(
|
||||
"To enable Office document conversions, please install UnoServer or use the 'fat' Docker image variant.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to get the unoserver path from RuntimePathConfig by checking the parent directory of
|
||||
* unoConvertPath.
|
||||
*
|
||||
* @param context The condition context
|
||||
* @return The unoserver path if found, null otherwise
|
||||
*/
|
||||
private String getUnoServerPathFromRuntimeConfig(ConditionContext context) {
|
||||
try {
|
||||
RuntimePathConfig runtimePathConfig =
|
||||
context.getBeanFactory().getBean(RuntimePathConfig.class);
|
||||
if (runtimePathConfig != null) {
|
||||
String unoConvertPath = runtimePathConfig.getUnoConvertPath();
|
||||
log.debug("UnoConvert path from RuntimePathConfig: {}", unoConvertPath);
|
||||
|
||||
if (unoConvertPath != null && !unoConvertPath.isEmpty()) {
|
||||
// First check if unoConvertPath itself exists
|
||||
File unoConvertFile = new File(unoConvertPath);
|
||||
if (!unoConvertFile.exists() || !unoConvertFile.canExecute()) {
|
||||
log.info("UnoConvert not found at path: {}", unoConvertPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// If unoConvertPath exists, check for unoserver in the same directory
|
||||
Path unoConvertDir = Paths.get(unoConvertPath).getParent();
|
||||
if (unoConvertDir != null) {
|
||||
Path potentialUnoServerPath = unoConvertDir.resolve("unoserver");
|
||||
File unoServerFile = potentialUnoServerPath.toFile();
|
||||
|
||||
if (unoServerFile.exists() && unoServerFile.canExecute()) {
|
||||
log.debug("UnoServer found at: {}", potentialUnoServerPath);
|
||||
return potentialUnoServerPath.toString();
|
||||
} else {
|
||||
log.debug(
|
||||
"UnoServer not found at expected path: {}",
|
||||
potentialUnoServerPath);
|
||||
// Continue checking other paths
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"RuntimePathConfig not available yet, falling back to default checks: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive check if an executable is available in the system
|
||||
*
|
||||
* @param executableName The name or path of the executable to check
|
||||
* @return true if the executable is found and executable, false otherwise
|
||||
*/
|
||||
private boolean isExecutableAvailable(String executableName) {
|
||||
// First, check if it's an absolute path and the file exists
|
||||
if (executableName.startsWith("/") || executableName.contains(":\\")) {
|
||||
File file = new File(executableName);
|
||||
boolean exists = file.exists() && file.canExecute();
|
||||
log.debug(
|
||||
"Checking executable at absolute path {}: {}",
|
||||
executableName,
|
||||
exists ? "Found" : "Not found");
|
||||
return exists;
|
||||
}
|
||||
|
||||
// Next, try to execute the command with --version to verify it works
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(executableName, "--version");
|
||||
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
if (exitCode == 0) {
|
||||
log.debug("Executable {} exists in PATH (--version returned 0)", executableName);
|
||||
return true;
|
||||
} else {
|
||||
// Try with --help as a fallback
|
||||
pb = new ProcessBuilder(executableName, "--help");
|
||||
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
|
||||
process = pb.start();
|
||||
exitCode = process.waitFor();
|
||||
|
||||
if (exitCode == 0) {
|
||||
log.debug("Executable {} exists in PATH (--help returned 0)", executableName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking for executable {}: {}", executableName, e.getMessage());
|
||||
}
|
||||
|
||||
// Finally, check each directory in PATH for the executable file
|
||||
if (!executableName.contains("/") && !executableName.contains("\\")) {
|
||||
String pathEnv = System.getenv("PATH");
|
||||
if (pathEnv != null) {
|
||||
String[] pathDirs = pathEnv.split(File.pathSeparator);
|
||||
for (String pathDir : pathDirs) {
|
||||
File file = new File(pathDir, executableName);
|
||||
if (file.exists() && file.canExecute()) {
|
||||
log.debug(
|
||||
"Found executable {} in PATH directory {}",
|
||||
executableName,
|
||||
pathDir);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Executable {} not found", executableName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import io.github.pixee.security.SystemCommand;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.ApplicationProperties;
|
||||
import stirling.software.SPDF.utils.ConversionTask;
|
||||
|
||||
/**
|
||||
* UnoServerManager is responsible for managing multiple instances of unoserver based on application
|
||||
* configuration.
|
||||
*
|
||||
* <p>This component is only created if UnoServer is available on the system.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnUnoServerAvailable
|
||||
public class UnoServerManager {
|
||||
|
||||
private static final int INSTANCE_CHECK_TIMEOUT_MS = 1000;
|
||||
private static final long INSTANCE_STARTUP_TIMEOUT_MS = 30000;
|
||||
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // Health check every minute
|
||||
|
||||
@Autowired private ApplicationProperties properties;
|
||||
|
||||
@Autowired private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@Getter private List<ServerInstance> instances = new ArrayList<>();
|
||||
|
||||
private AtomicInteger currentInstanceIndex = new AtomicInteger(0);
|
||||
private ScheduledExecutorService healthCheckExecutor;
|
||||
|
||||
// The path to the UnoServer executable that was found during initialization
|
||||
private String detectedUnoServerPath;
|
||||
|
||||
// Circuit breaker settings for external servers
|
||||
private static final int FAILURE_THRESHOLD = 3; // Number of failures before circuit opens
|
||||
private static final long CIRCUIT_RESET_TIME_MS = 30000; // Time before retrying a failed server
|
||||
|
||||
// Performance metrics
|
||||
private final AtomicInteger totalConversions = new AtomicInteger(0);
|
||||
private final AtomicInteger failedConversions = new AtomicInteger(0);
|
||||
private final Map<Integer, AtomicInteger> conversionsPerInstance = new ConcurrentHashMap<>();
|
||||
|
||||
// Queue tracking
|
||||
private final ConcurrentHashMap<String, ConversionTask> activeTasks = new ConcurrentHashMap<>();
|
||||
private final AtomicInteger taskIdCounter = new AtomicInteger(0);
|
||||
private final Map<Integer, AtomicInteger> activeTasksPerInstance = new ConcurrentHashMap<>();
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
try {
|
||||
int maxInstances =
|
||||
properties.getProcessExecutor().getSessionLimit().getLibreOfficeSessionLimit();
|
||||
boolean useExternal = properties.getProcessExecutor().isUseExternalUnoconvServers();
|
||||
List<String> externalServers = properties.getProcessExecutor().getUnoconvServers();
|
||||
int basePort = properties.getProcessExecutor().getBaseUnoconvPort();
|
||||
boolean manageUnoServer = properties.getProcessExecutor().isManageUnoServer();
|
||||
|
||||
log.info(
|
||||
"Initializing UnoServerManager with maxInstances: {}, useExternal: {}, externalServers: {}, unoConvertPath: {}, manageUnoServer: {}",
|
||||
maxInstances,
|
||||
useExternal,
|
||||
externalServers,
|
||||
runtimePathConfig.getUnoConvertPath(),
|
||||
manageUnoServer);
|
||||
|
||||
// Get valid UnoServer executable path
|
||||
String unoServerPath = findValidUnoServerPath();
|
||||
|
||||
if (unoServerPath == null) {
|
||||
log.warn("UnoServer executable not found. Office conversions will be disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Using UnoServer at: {}", unoServerPath);
|
||||
|
||||
// Store the path for use by server instances
|
||||
this.detectedUnoServerPath = unoServerPath;
|
||||
|
||||
if (useExternal && !externalServers.isEmpty()) {
|
||||
// Configure for external servers
|
||||
for (String serverAddress : externalServers) {
|
||||
String[] parts = serverAddress.split(":");
|
||||
String host = parts[0];
|
||||
int port = parts.length > 1 ? Integer.parseInt(parts[1]) : basePort;
|
||||
ServerInstance instance = new ServerInstance(host, port, false);
|
||||
instances.add(instance);
|
||||
conversionsPerInstance.put(instances.size() - 1, new AtomicInteger(0));
|
||||
activeTasksPerInstance.put(instances.size() - 1, new AtomicInteger(0));
|
||||
}
|
||||
log.info("Configured {} external UnoServer instances", instances.size());
|
||||
} else if (manageUnoServer) {
|
||||
// Configure for local instances only if manageUnoServer is true
|
||||
boolean anyInstanceStarted = false;
|
||||
|
||||
for (int i = 0; i < maxInstances; i++) {
|
||||
int port = basePort + i;
|
||||
ServerInstance instance = new ServerInstance("127.0.0.1", port, true);
|
||||
instances.add(instance);
|
||||
conversionsPerInstance.put(i, new AtomicInteger(0));
|
||||
activeTasksPerInstance.put(i, new AtomicInteger(0));
|
||||
|
||||
try {
|
||||
instance.start();
|
||||
anyInstanceStarted = true;
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to start UnoServer instance on port {}: {}",
|
||||
port,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyInstanceStarted) {
|
||||
log.warn(
|
||||
"Failed to start any UnoServer instances. Office conversions may be affected.");
|
||||
}
|
||||
|
||||
log.info("Started {} local UnoServer instances", instances.size());
|
||||
} else {
|
||||
log.info(
|
||||
"Application is configured to not manage UnoServer instances. Assuming external management.");
|
||||
}
|
||||
|
||||
// Start the health check scheduler
|
||||
startHealthCheck();
|
||||
|
||||
// Log initial health status
|
||||
logHealthStatus();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to initialize UnoServerManager: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans multiple locations to find a valid UnoServer executable
|
||||
*
|
||||
* @return Path to UnoServer if found, null otherwise
|
||||
*/
|
||||
private String findValidUnoServerPath() {
|
||||
// Common paths to check for UnoServer
|
||||
List<String> pathsToCheck = new ArrayList<>();
|
||||
|
||||
// Try to derive the path from unoConvertPath first (highest priority)
|
||||
String unoConvertPath = runtimePathConfig.getUnoConvertPath();
|
||||
if (unoConvertPath != null && !unoConvertPath.isEmpty()) {
|
||||
File unoConvertFile = new File(unoConvertPath);
|
||||
if (unoConvertFile.exists() && unoConvertFile.canExecute()) {
|
||||
Path unoConvertDir = Paths.get(unoConvertPath).getParent();
|
||||
if (unoConvertDir != null) {
|
||||
Path potentialUnoServerPath = unoConvertDir.resolve("unoserver");
|
||||
pathsToCheck.add(potentialUnoServerPath.toString());
|
||||
}
|
||||
} else {
|
||||
log.warn("UnoConvert not found at configured path: {}", unoConvertPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Add common installation paths
|
||||
pathsToCheck.add("/opt/venv/bin/unoserver"); // Docker path
|
||||
pathsToCheck.add("/usr/bin/unoserver"); // Linux system path
|
||||
pathsToCheck.add("/usr/local/bin/unoserver"); // Linux local path
|
||||
pathsToCheck.add("/opt/homebrew/bin/unoserver"); // Mac Homebrew path
|
||||
pathsToCheck.add("/opt/libreoffice/program/unoserver"); // Custom LibreOffice path
|
||||
|
||||
// Check each path
|
||||
for (String path : pathsToCheck) {
|
||||
File file = new File(path);
|
||||
if (file.exists() && file.canExecute()) {
|
||||
log.info("Found valid UnoServer at: {}", path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// If no absolute path works, try to find it in PATH
|
||||
String pathEnv = System.getenv("PATH");
|
||||
if (pathEnv != null) {
|
||||
String[] pathDirs = pathEnv.split(File.pathSeparator);
|
||||
for (String pathDir : pathDirs) {
|
||||
File file = new File(pathDir, "unoserver");
|
||||
if (file.exists() && file.canExecute()) {
|
||||
log.info("Found UnoServer in PATH at: {}", file.getAbsolutePath());
|
||||
return file.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("UnoServer executable not found in any standard location");
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Starts periodic health checks for all instances */
|
||||
private void startHealthCheck() {
|
||||
healthCheckExecutor = Executors.newSingleThreadScheduledExecutor();
|
||||
healthCheckExecutor.scheduleAtFixedRate(
|
||||
this::performHealthCheck,
|
||||
HEALTH_CHECK_INTERVAL_MS,
|
||||
HEALTH_CHECK_INTERVAL_MS,
|
||||
TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** Perform health check on all instances */
|
||||
private void performHealthCheck() {
|
||||
log.debug("Running UnoServer health check for {} instances", instances.size());
|
||||
int healthy = 0;
|
||||
int unhealthy = 0;
|
||||
|
||||
for (int i = 0; i < instances.size(); i++) {
|
||||
ServerInstance instance = instances.get(i);
|
||||
boolean isRunning = instance.isRunning();
|
||||
|
||||
if (isRunning) {
|
||||
healthy++;
|
||||
instance.resetFailureCount(); // Reset failure count for healthy instance
|
||||
} else {
|
||||
unhealthy++;
|
||||
log.warn(
|
||||
"UnoServer instance {}:{} is not running",
|
||||
instance.getHost(),
|
||||
instance.getPort());
|
||||
|
||||
// For managed instances, try to restart if needed
|
||||
if (instance.isManaged()) {
|
||||
try {
|
||||
instance.restartIfNeeded();
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to restart UnoServer instance {}:{}",
|
||||
instance.getHost(),
|
||||
instance.getPort(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("UnoServer health check: {} healthy, {} unhealthy instances", healthy, unhealthy);
|
||||
|
||||
// Log metrics periodically
|
||||
logMetrics();
|
||||
}
|
||||
|
||||
/** Logs the current health status of all instances */
|
||||
private void logHealthStatus() {
|
||||
StringBuilder status = new StringBuilder("UnoServer Instances Status:\n");
|
||||
|
||||
for (int i = 0; i < instances.size(); i++) {
|
||||
ServerInstance instance = instances.get(i);
|
||||
boolean isRunning = instance.isRunning();
|
||||
int convCount = conversionsPerInstance.get(i).get();
|
||||
|
||||
status.append(
|
||||
String.format(
|
||||
" [%d] %s:%d - Status: %s, Managed: %s, Conversions: %d, Failures: %d\n",
|
||||
i,
|
||||
instance.getHost(),
|
||||
instance.getPort(),
|
||||
isRunning ? "RUNNING" : "DOWN",
|
||||
instance.isManaged() ? "YES" : "NO",
|
||||
convCount,
|
||||
instance.getFailureCount()));
|
||||
}
|
||||
|
||||
log.info(status.toString());
|
||||
}
|
||||
|
||||
/** Logs performance metrics for UnoServer conversions */
|
||||
private void logMetrics() {
|
||||
int total = totalConversions.get();
|
||||
int failed = failedConversions.get();
|
||||
float successRate = total > 0 ? (float) (total - failed) / total * 100 : 0;
|
||||
|
||||
log.info(
|
||||
"UnoServer metrics - Total: {}, Failed: {}, Success Rate: {:.2f}%",
|
||||
total, failed, successRate);
|
||||
|
||||
// Log per-instance metrics
|
||||
StringBuilder instanceMetrics = new StringBuilder("Conversions per instance:\n");
|
||||
for (int i = 0; i < instances.size(); i++) {
|
||||
ServerInstance instance = instances.get(i);
|
||||
int count = conversionsPerInstance.get(i).get();
|
||||
float percentage = total > 0 ? (float) count / total * 100 : 0;
|
||||
|
||||
instanceMetrics.append(
|
||||
String.format(
|
||||
" [%d] %s:%d - Count: %d (%.2f%%), Avg Time: %.2fms\n",
|
||||
i,
|
||||
instance.getHost(),
|
||||
instance.getPort(),
|
||||
count,
|
||||
percentage,
|
||||
instance.getAverageConversionTime()));
|
||||
}
|
||||
|
||||
log.info(instanceMetrics.toString());
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void cleanup() {
|
||||
log.info("Shutting down UnoServer instances and health check scheduler");
|
||||
|
||||
// Shutdown health check scheduler
|
||||
if (healthCheckExecutor != null) {
|
||||
healthCheckExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
// Shutdown all instances
|
||||
for (ServerInstance instance : instances) {
|
||||
instance.stop();
|
||||
}
|
||||
|
||||
// Log final metrics
|
||||
logMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next available server instance using load-balancing and circuit breaker pattern for
|
||||
* fault tolerance
|
||||
*
|
||||
* @return The next UnoServer instance to use
|
||||
*/
|
||||
public ServerInstance getNextInstance() {
|
||||
if (instances.isEmpty()) {
|
||||
throw new IllegalStateException("No UnoServer instances available");
|
||||
}
|
||||
|
||||
// First try to find a healthy instance with the least active tasks
|
||||
int minActiveTasks = Integer.MAX_VALUE;
|
||||
int selectedIndex = -1;
|
||||
|
||||
for (int i = 0; i < instances.size(); i++) {
|
||||
ServerInstance instance = instances.get(i);
|
||||
|
||||
// Check if instance is available (not in circuit-open state)
|
||||
if (instance.isAvailable() && instance.isRunning()) {
|
||||
int activeTasks = activeTasksPerInstance.get(i).get();
|
||||
|
||||
// If this instance has fewer active tasks, select it
|
||||
if (activeTasks < minActiveTasks) {
|
||||
minActiveTasks = activeTasks;
|
||||
selectedIndex = i;
|
||||
|
||||
// If we found an instance with no active tasks, use it immediately
|
||||
if (minActiveTasks == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a suitable instance, use it
|
||||
if (selectedIndex >= 0) {
|
||||
ServerInstance instance = instances.get(selectedIndex);
|
||||
|
||||
// Track this instance being selected
|
||||
conversionsPerInstance.get(selectedIndex).incrementAndGet();
|
||||
activeTasksPerInstance.get(selectedIndex).incrementAndGet();
|
||||
totalConversions.incrementAndGet();
|
||||
|
||||
log.debug(
|
||||
"Selected UnoServer instance {}:{} with {} active tasks",
|
||||
instance.getHost(),
|
||||
instance.getPort(),
|
||||
minActiveTasks);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// If all healthy instances are busy or no healthy instances found, use round-robin as
|
||||
// fallback
|
||||
log.warn(
|
||||
"No available UnoServer instances found with good health. Using round-robin fallback.");
|
||||
|
||||
// Try to find any available instance using round-robin
|
||||
for (int attempt = 0; attempt < instances.size(); attempt++) {
|
||||
int index = currentInstanceIndex.getAndIncrement() % instances.size();
|
||||
ServerInstance instance = instances.get(index);
|
||||
|
||||
// Check if the instance is available (circuit closed)
|
||||
if (instance.isAvailable()) {
|
||||
// Track this instance being selected
|
||||
conversionsPerInstance.get(index).incrementAndGet();
|
||||
activeTasksPerInstance.get(index).incrementAndGet();
|
||||
totalConversions.incrementAndGet();
|
||||
|
||||
log.debug(
|
||||
"Selected UnoServer instance {}:{} using round-robin fallback",
|
||||
instance.getHost(),
|
||||
instance.getPort());
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort - if all circuits are open, use the next instance anyway
|
||||
int index = currentInstanceIndex.get() % instances.size();
|
||||
ServerInstance instance = instances.get(index);
|
||||
|
||||
log.warn(
|
||||
"All UnoServer instances are in circuit-open state. Using instance at {}:{} as fallback.",
|
||||
instance.getHost(),
|
||||
instance.getPort());
|
||||
|
||||
// Track metrics even for fallback case
|
||||
conversionsPerInstance.get(index).incrementAndGet();
|
||||
activeTasksPerInstance.get(index).incrementAndGet();
|
||||
totalConversions.incrementAndGet();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new task for tracking office conversions
|
||||
*
|
||||
* @param taskName A descriptive name for the task
|
||||
* @param instance The server instance that will handle this task
|
||||
* @return A unique task ID for tracking
|
||||
*/
|
||||
public String createTask(String taskName, ServerInstance instance) {
|
||||
String taskId = "office-" + taskIdCounter.incrementAndGet();
|
||||
ConversionTask task = new ConversionTask(taskName, taskId);
|
||||
|
||||
// Calculate queue position based on number of active tasks across all instances
|
||||
int runningTasks = 0;
|
||||
int availableInstances = 0;
|
||||
|
||||
for (int i = 0; i < instances.size(); i++) {
|
||||
if (instances.get(i).isRunning() && instances.get(i).isAvailable()) {
|
||||
availableInstances++;
|
||||
runningTasks += activeTasksPerInstance.get(i).get();
|
||||
}
|
||||
}
|
||||
|
||||
// If all instances are busy, set a queue position
|
||||
if (runningTasks >= availableInstances && availableInstances > 0) {
|
||||
int queuePosition = runningTasks - availableInstances + 1;
|
||||
task.setQueuePosition(queuePosition);
|
||||
}
|
||||
|
||||
// Store the task in our tracking map
|
||||
activeTasks.put(taskId, task);
|
||||
|
||||
// Find the instance index for updating metrics
|
||||
for (int i = 0; i < instances.size(); i++) {
|
||||
if (instances.get(i) == instance) {
|
||||
activeTasksPerInstance.get(i).incrementAndGet();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Created task {} with ID {}", taskName, taskId);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a task, updating metrics and removing it from active tasks
|
||||
*
|
||||
* @param taskId The task ID to complete
|
||||
* @param instance The server instance that handled this task
|
||||
* @param durationMs The time taken to complete the task in milliseconds
|
||||
*/
|
||||
public void completeTask(String taskId, ServerInstance instance, long durationMs) {
|
||||
ConversionTask task = activeTasks.remove(taskId);
|
||||
if (task != null) {
|
||||
task.complete();
|
||||
}
|
||||
|
||||
// Find the instance index for updating metrics
|
||||
for (int i = 0; i < instances.size(); i++) {
|
||||
if (instances.get(i) == instance) {
|
||||
activeTasksPerInstance.get(i).decrementAndGet();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Record the success for circuit breaker and metrics
|
||||
recordSuccess(instance, durationMs);
|
||||
|
||||
log.debug("Completed task with ID {}, duration: {}ms", taskId, durationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails a task, updating metrics and removing it from active tasks
|
||||
*
|
||||
* @param taskId The task ID to fail
|
||||
* @param instance The server instance that handled this task
|
||||
* @param errorMessage The error message explaining the failure
|
||||
*/
|
||||
public void failTask(String taskId, ServerInstance instance, String errorMessage) {
|
||||
ConversionTask task = activeTasks.remove(taskId);
|
||||
if (task != null) {
|
||||
task.fail(errorMessage);
|
||||
}
|
||||
|
||||
// Find the instance index for updating metrics
|
||||
for (int i = 0; i < instances.size(); i++) {
|
||||
if (instances.get(i) == instance) {
|
||||
activeTasksPerInstance.get(i).decrementAndGet();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Record the failure for circuit breaker and metrics
|
||||
recordFailure(instance);
|
||||
|
||||
log.warn("Failed task with ID {}: {}", taskId, errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all active conversion tasks
|
||||
*
|
||||
* @return A map of task IDs to ConversionTask objects
|
||||
*/
|
||||
public Map<String, ConversionTask> getActiveTasks() {
|
||||
return new HashMap<>(activeTasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a successful conversion for metrics
|
||||
*
|
||||
* @param instance The server instance that succeeded
|
||||
* @param durationMs The time taken for the conversion in milliseconds
|
||||
*/
|
||||
public void recordSuccess(ServerInstance instance, long durationMs) {
|
||||
instance.recordSuccess(durationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a failed conversion for metrics and circuit breaker
|
||||
*
|
||||
* @param instance The server instance that failed
|
||||
*/
|
||||
public void recordFailure(ServerInstance instance) {
|
||||
failedConversions.incrementAndGet();
|
||||
instance.recordFailure();
|
||||
}
|
||||
|
||||
/** Represents a single UnoServer instance with circuit breaker functionality */
|
||||
public class ServerInstance {
|
||||
@Getter private final String host;
|
||||
@Getter private final int port;
|
||||
@Getter private final boolean managed;
|
||||
private ExecutorService executorService;
|
||||
private Process process;
|
||||
private boolean running = false;
|
||||
|
||||
// Circuit breaker state
|
||||
private final AtomicInteger failureCount = new AtomicInteger(0);
|
||||
private volatile Instant lastFailureTime = null;
|
||||
private volatile boolean circuitOpen = false;
|
||||
|
||||
// Performance metrics
|
||||
private final AtomicLong totalConversionTimeMs = new AtomicLong(0);
|
||||
private final AtomicInteger conversionCount = new AtomicInteger(0);
|
||||
private final AtomicLong lastConversionDuration = new AtomicLong(0);
|
||||
|
||||
public ServerInstance(String host, int port, boolean managed) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.managed = managed;
|
||||
if (!managed) {
|
||||
// For external servers, we assume they're running initially
|
||||
this.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets the number of failures for circuit breaker */
|
||||
public int getFailureCount() {
|
||||
return failureCount.get();
|
||||
}
|
||||
|
||||
/** Resets the failure count for circuit breaker */
|
||||
public void resetFailureCount() {
|
||||
failureCount.set(0);
|
||||
circuitOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a successful conversion
|
||||
*
|
||||
* @param durationMs The duration of the conversion in milliseconds
|
||||
*/
|
||||
public void recordSuccess(long durationMs) {
|
||||
conversionCount.incrementAndGet();
|
||||
totalConversionTimeMs.addAndGet(durationMs);
|
||||
lastConversionDuration.set(durationMs);
|
||||
|
||||
// Reset failure count on success
|
||||
resetFailureCount();
|
||||
}
|
||||
|
||||
/** Records a conversion failure */
|
||||
public void recordFailure() {
|
||||
int currentFailures = failureCount.incrementAndGet();
|
||||
lastFailureTime = Instant.now();
|
||||
|
||||
// Open circuit if threshold reached
|
||||
if (currentFailures >= FAILURE_THRESHOLD) {
|
||||
log.warn(
|
||||
"Circuit breaker opened for UnoServer instance {}:{} after {} failures",
|
||||
host,
|
||||
port,
|
||||
currentFailures);
|
||||
circuitOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this instance is available based on circuit breaker status
|
||||
*
|
||||
* @return true if available, false if circuit is open
|
||||
*/
|
||||
public boolean isAvailable() {
|
||||
// If circuit is closed, instance is available
|
||||
if (!circuitOpen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If circuit is open but reset time has passed, try half-open state
|
||||
if (lastFailureTime != null
|
||||
&& Duration.between(lastFailureTime, Instant.now()).toMillis()
|
||||
> CIRCUIT_RESET_TIME_MS) {
|
||||
log.info("Circuit breaker half-open for UnoServer instance {}:{}", host, port);
|
||||
circuitOpen = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Circuit is open
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the average conversion time in milliseconds
|
||||
*
|
||||
* @return The average conversion time or 0 if no conversions yet
|
||||
*/
|
||||
public double getAverageConversionTime() {
|
||||
int count = conversionCount.get();
|
||||
return count > 0 ? (double) totalConversionTimeMs.get() / count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last conversion duration in milliseconds
|
||||
*
|
||||
* @return The last conversion duration
|
||||
*/
|
||||
public long getLastConversionDuration() {
|
||||
return lastConversionDuration.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the UnoServer instance is running
|
||||
*
|
||||
* @return true if the server is accessible, false otherwise
|
||||
*/
|
||||
public boolean isRunning() {
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress(host, port), INSTANCE_CHECK_TIMEOUT_MS);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the UnoServer if it's a managed instance
|
||||
*
|
||||
* @throws IOException if the server fails to start
|
||||
*/
|
||||
public synchronized void start() throws IOException {
|
||||
if (!managed
|
||||
|| (process != null && process.isAlive())
|
||||
|| !properties.getProcessExecutor().isManageUnoServer()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Starting UnoServer on {}:{}", host, port);
|
||||
|
||||
try {
|
||||
// Use the detected UnoServer path from parent class
|
||||
String unoServerPath = UnoServerManager.this.detectedUnoServerPath;
|
||||
|
||||
// If not available (shouldn't happen), try to determine it
|
||||
if (unoServerPath == null || unoServerPath.isEmpty()) {
|
||||
log.warn(
|
||||
"detectedUnoServerPath is null, attempting to find unoserver executable");
|
||||
unoServerPath = findUnoServerExecutable();
|
||||
|
||||
if (unoServerPath == null) {
|
||||
throw new IOException(
|
||||
"UnoServer executable not found. Cannot start server instance.");
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Using UnoServer executable: {}", unoServerPath);
|
||||
|
||||
// Create the command with the correct path and options
|
||||
String command =
|
||||
String.format("%s --port %d --interface %s", unoServerPath, port, host);
|
||||
|
||||
// Final verification that the executable exists and is executable
|
||||
File executableFile = new File(unoServerPath);
|
||||
if (!executableFile.exists() || !executableFile.canExecute()) {
|
||||
throw new IOException(
|
||||
"UnoServer executable not found or not executable at: "
|
||||
+ executableFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
// Run the command
|
||||
log.debug("Executing command: {}", command);
|
||||
process = SystemCommand.runCommand(Runtime.getRuntime(), command);
|
||||
|
||||
// Start a background thread to monitor the process
|
||||
executorService = Executors.newSingleThreadExecutor();
|
||||
executorService.submit(
|
||||
() -> {
|
||||
try {
|
||||
int exitCode = process.waitFor();
|
||||
log.info(
|
||||
"UnoServer process on port {} exited with code {}",
|
||||
port,
|
||||
exitCode);
|
||||
running = false;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("UnoServer monitoring thread was interrupted", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the server to start up with timeout
|
||||
long startTime = System.currentTimeMillis();
|
||||
boolean startupSuccess = false;
|
||||
|
||||
while (System.currentTimeMillis() - startTime < INSTANCE_STARTUP_TIMEOUT_MS) {
|
||||
if (isRunning()) {
|
||||
running = true;
|
||||
startupSuccess = true;
|
||||
log.info("UnoServer started successfully on {}:{}", host, port);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if process is still alive
|
||||
if (process == null || !process.isAlive()) {
|
||||
int exitCode = process != null ? process.exitValue() : -1;
|
||||
log.warn(
|
||||
"UnoServer process terminated prematurely with exit code: {}, continuing without it",
|
||||
exitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(1000); // Check every second
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("Interrupted while waiting for UnoServer to start", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!startupSuccess) {
|
||||
// Timeout occurred, clean up and log warning
|
||||
if (process != null && process.isAlive()) {
|
||||
process.destroy();
|
||||
}
|
||||
log.warn(
|
||||
"Failed to start UnoServer within timeout period of {} seconds, continuing without it",
|
||||
(INSTANCE_STARTUP_TIMEOUT_MS / 1000));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to start UnoServer: {}, continuing without it", e.getMessage());
|
||||
// Don't rethrow - continue without the server
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to find the UnoServer executable
|
||||
*
|
||||
* @return Path to UnoServer executable or null if not found
|
||||
*/
|
||||
private String findUnoServerExecutable() {
|
||||
// Try to derive from unoConvertPath first
|
||||
String unoConvertPath = UnoServerManager.this.runtimePathConfig.getUnoConvertPath();
|
||||
if (unoConvertPath != null && !unoConvertPath.isEmpty()) {
|
||||
Path unoConvertDir = Paths.get(unoConvertPath).getParent();
|
||||
if (unoConvertDir != null) {
|
||||
Path potentialUnoServerPath = unoConvertDir.resolve("unoserver");
|
||||
File unoServerFile = potentialUnoServerPath.toFile();
|
||||
|
||||
if (unoServerFile.exists() && unoServerFile.canExecute()) {
|
||||
return potentialUnoServerPath.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check common paths
|
||||
String[] commonPaths = {
|
||||
"/opt/venv/bin/unoserver", "/usr/bin/unoserver", "/usr/local/bin/unoserver"
|
||||
};
|
||||
|
||||
for (String path : commonPaths) {
|
||||
File file = new File(path);
|
||||
if (file.exists() && file.canExecute()) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Stops the UnoServer if it's a managed instance */
|
||||
public synchronized void stop() {
|
||||
if (!managed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the monitoring thread
|
||||
if (executorService != null) {
|
||||
executorService.shutdownNow();
|
||||
}
|
||||
|
||||
// Stop the server process
|
||||
if (process != null && process.isAlive()) {
|
||||
log.info("Stopping UnoServer on port {}", port);
|
||||
process.destroy();
|
||||
}
|
||||
running = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the UnoServer if it's a managed instance and not running
|
||||
*
|
||||
* @return true if restart succeeded or wasn't needed, false otherwise
|
||||
*/
|
||||
public synchronized boolean restartIfNeeded() {
|
||||
if (!managed || running || !properties.getProcessExecutor().isManageUnoServer()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Attempting to restart UnoServer on {}:{}", host, port);
|
||||
start();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to restart UnoServer on port {}, continuing without it", port, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the connection string for this instance
|
||||
*
|
||||
* @return A connection string in the format host:port
|
||||
*/
|
||||
public String getConnectionString() {
|
||||
return host + ":" + port;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.utils.ConversionTask;
|
||||
|
||||
/**
|
||||
* Fallback configuration for when UnoServerManager is not available. This will provide friendly
|
||||
* error messages when users try to use LibreOffice conversion features without having UnoServer
|
||||
* installed.
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class UnoServerManagerFallback {
|
||||
|
||||
/**
|
||||
* Creates a bean that provides a friendly error message when LibreOffice conversion is
|
||||
* attempted but UnoServer is not available.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(UnoServerManager.class)
|
||||
public UnoServerNotAvailableHandler unoServerNotAvailableHandler(
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
log.info("UnoServer is not available. Office document conversions will be disabled.");
|
||||
log.info("If you need Office document conversions, please install UnoServer.");
|
||||
log.info("For Docker users, use the 'fat' image variant which includes UnoServer.");
|
||||
|
||||
// Log the path where we would expect to find unoconvert
|
||||
if (runtimePathConfig != null) {
|
||||
log.info("Expected unoconvert path: {}", runtimePathConfig.getUnoConvertPath());
|
||||
}
|
||||
|
||||
return new UnoServerNotAvailableHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler that provides friendly error messages when LibreOffice conversion is attempted but
|
||||
* UnoServer is not available.
|
||||
*/
|
||||
public static class UnoServerNotAvailableHandler {
|
||||
/** Method that throws a friendly exception when office conversions are attempted. */
|
||||
public void throwUnoServerNotAvailableException() {
|
||||
throw new UnoServerNotAvailableException(
|
||||
"UnoServer (LibreOffice) is not available. Office document conversions are disabled. "
|
||||
+ "To enable this feature, please install UnoServer or use the 'fat' Docker image variant.");
|
||||
}
|
||||
|
||||
/** Creates a failed conversion task with a friendly error message. */
|
||||
public ConversionTask createFailedTask(String taskName) {
|
||||
ConversionTask task = new ConversionTask(taskName, (String) null);
|
||||
task.fail(
|
||||
"UnoServer (LibreOffice) is not available. Office document conversions are disabled.");
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exception thrown when UnoServer features are used but UnoServer is not available. */
|
||||
public static class UnoServerNotAvailableException extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public UnoServerNotAvailableException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user