How JobQueue and JobExecutorService Handle Async PDF Processing in Stirling-PDF

Stirling-PDF processes heavy PDF operations on a background thread pool using a dynamic, resource-aware JobQueue and a facade JobExecutorService that decides whether to queue, execute immediately, or reject jobs based on real-time system load.

Stirling-Tools/Stirling-PDF relies on a sophisticated asynchronous processing pipeline to prevent server overload during intensive document operations. The JobQueue and JobExecutorService classes work together to manage async PDF processing with backpressure, dynamic capacity tuning, and virtual-thread execution. This architecture ensures that HTTP responses remain snappy while heavy conversion, compression, or manipulation tasks run safely in the background.

The Decision Point: JobExecutorService.runJobGeneric

Every async request enters the system through JobExecutorService.runJobGeneric in app/common/src/main/java/stirling/software/common/service/JobExecutorService.java. The method first mints a scoped job ID and calculates the effective timeout:

String baseJobId = UUID.randomUUID().toString();
String scopedJobKey = getScopedJobKey(baseJobId);
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;

Before spawning work, the executor queries the ResourceMonitor to determine whether the current load warrants queuing:

boolean shouldQueue =
        queueable
        && async
        && resourceMonitor.shouldQueueJob(resourceWeight);

If shouldQueue is true, the job is handed to JobQueue.queueJob; otherwise it executes immediately on the virtual-thread executor provided by ExecutorFactory.newVirtualThreadExecutor().

Dynamic Resource-Aware Queuing

When the system is under pressure, JobQueue (located in app/common/src/main/java/stirling/software/common/service/JobQueue.java) absorbs surplus work in a BlockingQueue<QueuedJob> whose capacity fluctuates based on free memory and CPU.

Creating the CompletableFuture

queueJob creates a CompletableFuture for the caller, wraps the supplied work in a QueuedJob object, and stores the job in two concurrent structures:

  • jobMap – ConcurrentHashMap<String, QueuedJob> for O(1) status lookups by job ID
  • jobQueue – the dynamic BlockingQueue that holds jobs awaiting execution
QueuedJob job = new QueuedJob(jobId, resourceWeight, work,
        timeoutMs, Instant.now(), future, false);
jobMap.put(jobId, job);
jobQueue.offer(job, 5, TimeUnit.SECONDS);

If the queue is full, the future is completed exceptionally and the client receives a rejection signal.

Scheduler Architecture

JobQueue implements Spring’s SmartLifecycle. On context start, start() invokes initializeSchedulers(), which launches two repeating tasks:

  • processQueue – runs every queueCheckIntervalMs (default 1 s) to drain jobs when resources permit
  • updateQueueCapacity – runs every 30 s to recalculate maximum queue size via ResourceMonitor

Job Execution Flow

Selecting Jobs Under Load

Inside processQueue, the scheduler samples the current resource status:

ResourceMonitor.ResourceStatus status = resourceMonitor.getCurrentStatus().get();
boolean canExecuteJobs = (status != ResourceMonitor.ResourceStatus.CRITICAL);

Depending on the status, a small batch (1–3) of jobs is polled from jobQueue, removed from jobMap, and collected into jobsToExecute. Jobs that exceed maxWaitTimeMs are still executed but flagged in the TaskManager for observability.

Virtual Thread Execution

Each QueuedJob is submitted to the virtual-thread executor (jobExecutor) defined in ExecutorFactory:

jobExecutor.execute(() -> {
    Object result = executeWithTimeout(job.work, job.timeoutMs);
    // Complete the future with ResponseEntity or error
});

executeWithTimeout runs the supplier on the same executor and enforces the per-job timeout, cancelling the CompletableFuture if the deadline passes.

Result Handling and Task Management

When the future completes, JobExecutorService.processJobResult stores any byte[], ResponseEntity<byte[]>, MultipartFile, or generic object into the TaskManager. This decouples the heavy PDF work from the HTTP layer—controllers often return a JobResponse containing the job ID immediately, while the actual bytes are fetched later via polling endpoints.

Direct Async Execution (No Queue)

For lightweight operations or when the server is healthy, JobExecutorService bypasses the queue entirely:

executor.execute(() -> {
    Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
    processJobResult(capturedJobId, result);
});

This path uses the same virtual-thread pool but skips the BlockingQueue overhead, minimizing latency for thumbnail generation or metadata extraction tasks that carry low resourceWeight values.

Cancellation and Status Monitoring

Clients can interrogate or abort jobs through JobQueue APIs:

  • cancelJob – removes the entry from jobMap, flags the QueuedJob as cancelled, aborts its future, and removes it from the queue
  • getJobPosition – walks the queue to report a job’s place in line for UI progress bars
  • getQueueStats – returns live metrics including queued count, capacity, rejected count, and current ResourceStatus

Summary

  • JobExecutorService acts as the façade, deciding between immediate execution and queuing by consulting ResourceMonitor.shouldQueueJob().
  • JobQueue provides a dynamic BlockingQueue tuned every 30 seconds based on system resources, preventing out-of-memory errors during traffic spikes.
  • Virtual threads (via ExecutorFactory) handle all actual work, enabling high concurrency without traditional thread-pool exhaustion.
  • CompletableFuture bridges asynchronous execution with HTTP responses, allowing controllers to return job IDs instantly while processing continues in the background.
  • TaskManager persists results and errors, enabling clients to poll for completion status or download generated PDFs after the fact.

Frequently Asked Questions

How does Stirling-PDF decide whether to queue a job or run it immediately?

The JobExecutorService evaluates three factors: whether the caller requested async mode, whether the job is marked as queueable, and whether ResourceMonitor.shouldQueueJob(resourceWeight) reports high system load. If all conditions are true, the job enters JobQueue; otherwise it runs immediately on the virtual-thread executor.

What happens when the JobQueue reaches capacity?

If jobQueue.offer() returns false (queue full), the CompletableFuture is completed exceptionally and the client receives a rejection response. This backpressure mechanism protects the JVM from OutOfMemoryError during bursty traffic.

How are job timeouts enforced during async PDF processing?

Both queued and directly-executed jobs pass through executeWithTimeout, which submits the work to the virtual-thread executor and starts a separate watchdogtimer. If the supplier does not complete within the configured timeoutMs, the future is cancelled and the error is recorded in TaskManager.

Can clients cancel a job after it has been queued?

Yes. Calling JobQueue.cancelJob(jobId) atomically removes the job from jobMap, marks it cancelled so the scheduler skips it, and calls future.cancel(true). If the job is already running, the cancellation flag propagates to the virtual thread, although interruption depends on the PDF library’s responsiveness to thread interrupts.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →