Skip to content

Build1 publisher2 min readPublished

Two PaddleOCR containers sharing one SQLite volume can OCR the same file twice

Setting RUN_MODE=job runs the same OCR image as a one-shot pipe, taking a PDF on standard input and printing markdown. It also writes a queued row into the table a running server polls, and Postgres row locking is the fix.

The Engineer · Build desk

Illustration accompanying Two PaddleOCR containers sharing one SQLite volume can OCR the same file twice

What happened

  • Setting RUN_MODE=job in the paddleocr-pdf-api image skips the HTTP server, reads one file from standard input, saves it under /data/uploads/<job_id>/ and records a queued job in the server's database.
  • The finished pages are joined in order and printed as the command's output, so redirecting standard output to a file gives the finished markdown document.
  • A job container and a server container on one mounted volume both work the same queued row, and the server can claim it first, leaving two processes running OCR on the same file.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Anyone who wants batch runs and a live API in one system has to move the rows to PostgreSQL; the in-container SQLite store only works if the job and the server never overlap.
  • cost Whoever runs the loop pays a model load for every document, and on the non-baked tag a weight download for every document too, so batch time scales with file count before any page is read.
  • constraint The environment variable covers the process, not the delivery: on Container Apps something else has to put files on the share and collect the markdown, because standard output is no longer the delivery channel.
  • capability A one-shot run stays queryable through the same API a server exposes, so batch output does not need a second retrieval path to get at the pages.

A dev.to walkthrough of the image puts the fix in `DATABASE_URL`. With the rows in PostgreSQL, job mode locks its row before it starts work and a server skips any row it cannot lock, so several job containers and a server container can share one database without processing the same file twice [16].

Mounting a volume is still what makes a one-shot run visible to the API. A server container started on the same `/data` serves the job, so `GET /jobs` lists it and `GET /ocr/<job_id>/result` returns the pages [14]. Skip the volume and `--rm` deletes `/data` along with the SQLite database and the stored copy of the input, which leaves the printed markdown as the only surviving result [13].

Pages go into the database one at a time, as the OCR finishes each one [4]. At the end they are joined in order and printed as the command's output [5]. The generated job id, the per-page progress lines and whatever the OCR libraries decide to print all stay on the terminal, because a `>` redirect only captures standard output [6]. The job never sees a filename, so the type comes from the content: PDF, PNG, JPEG, BMP, TIFF and WEBP [8].

`-i` is not optional. Without it the container has no standard input and exits with code 2, one of three codes the process uses [9] [7]. The `-t` flag fails earlier than that: with standard input redirected from a file, Docker prints `the input device is not a TTY` and does not start the container [10].

A shell loop over `*.pdf` works, and it bills per file. Each iteration starts a container and loads the model again [17], so fifty documents means fifty model loads [20]. On the non-baked `latest` tag each run downloads the weights first, into a container that is discarded when the run ends, so the download repeats as well [12]. For anything run more than once I would use one of the baked tags, such as `latest-text-baked` on CPU or `latest-vl-baked` with `--gpus all` [11].

On Azure Container Apps the image and the variable are the same, and the file gets in another way. The job has no HTTP endpoint and nothing arrives on its standard input, so an Azure Files share is mounted into the job, input files are uploaded there, and the replica writes the markdown back to the same share [18]. The setup creates `rg-paddleocr` in `swedencentral`, a Container Apps environment with `--enable-workload-profiles`, a Standard_LRS StorageV2 account, and the `ocr-work` share with `--quota 100` [19].

What to watch

  • Documentation of what the three exit codes mean; only code 2, for a missing -i, is spelled out.
  • How the Container Apps job is triggered and how a replica picks files off the ocr-work share.
  • A test of the Postgres locking path with several job containers and a server hitting one database at once.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories