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 IDjobQueue– the dynamicBlockingQueuethat 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 everyqueueCheckIntervalMs(default 1 s) to drain jobs when resources permitupdateQueueCapacity– runs every 30 s to recalculate maximum queue size viaResourceMonitor
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 fromjobMap, flags theQueuedJobas cancelled, aborts its future, and removes it from the queuegetJobPosition– walks the queue to report a job’s place in line for UI progress barsgetQueueStats– returns live metrics including queued count, capacity, rejected count, and currentResourceStatus
Summary
- JobExecutorService acts as the façade, deciding between immediate execution and queuing by consulting
ResourceMonitor.shouldQueueJob(). - JobQueue provides a dynamic
BlockingQueuetuned 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →