Preventing OOM in Large Data Rendering Workers: Chunking and PDF Merging

Batch workers that process and render documents often run into memory limits when dealing with power users. When our document generation worker suffered repeated crashes on large accounts, the root cause was a combination of open-ended lists and rendering-library limitations. This post walks through the diagnostics, the chunk-based rendering solution, and a secondary consumer idempotency issue it exposed.

1. The Symptom: Unhealthy Readiness Probes

Our document generation worker (a Kotlin/JVM service running on Kubernetes) began restarting repeatedly. The K8s events showed:

Warning  Unhealthy  Readiness probe failed: /actuator/health/readiness: context deadline exceeded
Normal   Killing    Container failed liveness probe, will be restarted

The pod logs showed that fetching the dataset was taking an enormous amount of time, while the actual rendering step was relatively fast:

create-template-context: success (220765 ms)   ← 3 minutes and 40 seconds
render-html: success (6038 ms)                  ← 6 seconds

Because the JVM was configured with -XX:+ExitOnOutOfMemoryError, the container was terminating immediately when it ran out of heap space. During the crash, the JVM became unresponsive, causing the Kubernetes liveness probe to timeout and trigger a container kill.

2. The Root Cause: Triple In-Memory Copies

Our worker allows users to export up to 4 years of transaction history. For highly active accounts, this can include hundreds of thousands of transactions.

The service code was performing the following steps:

  1. It split the query into 90-day chunks, fetched the records, and accumulated them into a single, open-ended MutableList (Copy 1).
  2. It mapped these records to UI display items (Copy 2).
  3. It split the items into pages for Thymeleaf layout templates (Copy 3).
  4. Finally, the PDF rendering library (openhtmltopdf) converted the entire HTML DOM into a single, massive PDF document.

Having three distinct copies of hundreds of thousands of objects in the JVM heap, combined with the heavy memory footprint of the HTML-to-PDF rendering engine (which builds the entire layout DOM in memory), easily exceeded the 10GB JVM heap limit (-Xmx10240m) and crashed the process.

3. The Solution: Chunked PDF Rendering and Merging

Because the PDF rendering library does not support streaming layout output, we could not simply stream the HTML to a file. We had to keep the memory footprint bounded.

The solution was to render the document in paginated chunks and merge them using PDFBox.

New Generation Pipeline:

  1. Fetch the transaction history using a database cursor or Kotlin Sequence (avoiding loading everything into a list).
  2. Group the items into chunks of N pages (e.g., 50 pages per chunk).
  3. Render each chunk to a separate, temporary PDF file on disk. This keeps the in-memory HTML DOM size small and predictable.
  4. Use Apache PDFBox's PDFMergerUtility to merge the temporary PDF files into the final output document.
  5. Clean up the temporary files.

This pattern capped our memory usage. The memory complexity dropped from O(N) where N is the total history length, to O(1) in terms of heap space, as the maximum heap usage was limited to the size of a single 50-page chunk.

4. Secondary Discovery: The Duplicate Event Trap

During our investigation of the OOM crashes, we uncovered an interesting user complaint: "Sometimes, I receive a success email for my document, followed immediately by a failure email, and my document download status is marked as failed."

The log analysis revealed the following sequence:

02:19:35.263 worker [Attempt 1] EVENT_RECV (messageId=a303feee)
02:19:35.270 worker [Attempt 1] calls /api/fetch -> returns 200 OK
02:22:45.582 worker [Attempt 1] PDF generated successfully
02:22:45.644 worker [Attempt 1] calls /api/complete -> returns 200 OK (Sends Success Email)
02:22:45.650 worker [Attempt 2] EVENT_RECV (messageId=a303feee) *Re-delivered*
02:22:45.654 worker [Attempt 2] calls /api/fetch -> returns 500 (Status is already COMPLETED)
02:22:45.665 worker [Attempt 2] fails with FeignException
02:22:45.688 worker [Attempt 2] calls /api/fail -> returns 200 OK (Sends Failure Email & marks failed)

What happened: Because the generation took over 3 minutes, the Kafka broker sometimes assumed the consumer was dead and initiated a consumer group rebalance. This caused the message to be re-delivered to another worker.

The fix: We updated the worker and the server API to be idempotent:

  • If the API returns a status showing the job is already completed, the worker skips processing and immediately acknowledges the Kafka offset.
  • The server refuses to overwrite a COMPLETED status with a FAILED status.
Tip: To prevent Kafka rebalances on long-running operations, you should also increase max.poll.interval.ms or decouple the Kafka consumer thread from the actual processing thread by passing the task to a background thread pool while keeping the consumer thread polling.

5. Conclusion

When building batch workers, memory constraints require careful layout design. Avoid loading entire datasets into memory. Splitting documents into logical chunks, rendering them individually, and merging them at the file system level is a highly effective way to keep memory usage flat. Furthermore, because long-running tasks are prone to network timeouts and re-deliveries, ensuring that your task processor is idempotent is critical to preventing race conditions.

G

Giri (Dong-gil Nam)

Backend Software Engineer

Passionate about building scalable systems and sharing technical insights. Specializing in JVM internals, distributed systems, and performance optimization.