From 196a99ffef40b64fa0bc349bf7ea7bb4a43b271b Mon Sep 17 00:00:00 2001 From: oskar Date: Sun, 12 Jul 2026 20:51:04 +0200 Subject: [PATCH] fix(paperless-worker): celery command bypassed manage.py + missing shared scratch dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two config bugs found on the already-deployed split-host OCR worker (module 3): (1) `command: celery ...` was routed through manage.py by the image entrypoint because it didn't start with "/" — fixed with an absolute gosu+celery path. (2) SCRATCH_DIR (/tmp/paperless) was not shared over NFS like data/media/consume, so tasks picked up by worker@SOLARIA instead of worker@PIHA failed with "File not found" — fixed by adding a paperless_scratch NFS volume/bind mount on both sides. Verified live on PIHA + SOLARIA: test PDFs dropped into consume/ were split across both workers, the SOLARIA-picked task completed OCR with zero File not found errors, test documents cleaned up afterward. Co-Authored-By: Claude Sonnet 5 --- docs/backlog.md | 41 ++++++++++ services/paperless-worker/README.md | 81 ++++++++++++++++++-- services/paperless-worker/docker-compose.yml | 32 +++++++- services/paperless/docker-compose.yml | 13 ++++ 4 files changed, 158 insertions(+), 9 deletions(-) diff --git a/docs/backlog.md b/docs/backlog.md index dfbee03..5ebd918 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -602,3 +602,44 @@ już nie zna → błąd → `set -e` przerywa deploy w połowie. recreate (`docker compose down --remove-orphans` albo jawne usunięcie hash-prefixed kontenerów), ewentualnie jawny `-p` (COMPOSE_PROJECT_NAME) żeby nazwy były deterministyczne. Deploy mózgu NIE MOŻE zostawiać control-plane w stanie zero-kontenerów. + +## Fix: paperless-worker@SOLARIA — dwa bugi configu, naprawione i zweryfikowane na żywo (2026-07-12) + +**Kontekst.** Moduł 3 (`services/paperless-worker/`, split-host OCR worker) był +zdeployowany i brał zadania z kolejki, ale miał dwa bugi w compose: + +1. **`command: celery ...` nie odpalał celery.** Obraz paperless-ngx + (`/sbin/docker-entrypoint.sh`) routuje każdy argument NIE zaczynający się + od `/` do `manage.py` — więc `celery` lądował jako nieznana subkomenda + Django, nie jako program. Fix: `command: /usr/sbin/gosu paperless + /usr/local/bin/celery ...` (ścieżka absolutna wchodzi w gałąź `exec "$@"` + entrypointu; `gosu paperless` z przodu bo ta gałąź nie dostaje automatycznego + gosu, inaczej proces poszedłby jako root i zepsuł właściciela plików na NFS). +2. **Brak współdzielonego `SCRATCH_DIR`.** Paperless@PIHA staguje wgrywany + plik w `/tmp/paperless` (domyślny `SCRATCH_DIR`) i niesie tę ścieżkę w + payloadzie zadania celery jako ścieżkę absolutną. Worker@SOLARIA miał + własny, lokalny `/tmp/paperless` — gdy odbierał zadanie zamiast workera + PIHA, padał `Cannot consume ...: File not found`. Fix: NFS volume + `paperless_scratch` (ten sam wzorzec co `data/media/consume`) na + `/opt/homelab/data/paperless/scratch` (już istniał na PIHA, `chown 1000:1000`), + mount na `/tmp/paperless` po obu stronach. + +Oba fixy + uzasadnienie: `services/paperless/docker-compose.yml`, +`services/paperless-worker/docker-compose.yml`, `services/paperless-worker/README.md`. +Zweryfikowane end-to-end na żywo (branch `task/paperless-worker-fix`, jeszcze +niezmergowany do master w momencie pisania tego wpisu): 3 dokumenty testowe +wrzucone do `consume/` na PIHA, jeden odebrany i dokończony przez worker@SOLARIA +(log: `ocrmypdf`/`tesseract` → `ConsumeTaskPlugin completed with: Success`), +zero `File not found`. Dokumenty testowe usunięte po teście (`document.delete()` ++ ręczny cleanup plików) — produkcyjne 6 dokumentów nietknięte. + +**Otwarte (świadomie odłożone, nie blokuje działania):** +- `hosts/solaria/services.yaml` i `inventory/topology.yaml` nie mają wpisu + `paperless-worker` (SOLARIA ma tam tylko `node-agent`) — było zaplanowane w + cutover checkliście README jako krok "przy deployu", ale nigdy nie zrobione. + Bez tego wpisu supervisor/observer nie widzą tego serwisu w desired-state — + drift (np. worker padnie i nie wstanie) nie zostanie automatycznie wykryty + przez agent system, tylko przez brak przetwarzania kolejki. +- Test formalnego fallbacku (stop worker@SOLARIA → kolejka mieli na PIHA → + start → drenaż) nie był wykonany w tej sesji — mechanizm nie zmienił się + tym fixem (był już OK), ale warto zweryfikować przy okazji. diff --git a/services/paperless-worker/README.md b/services/paperless-worker/README.md index 253c8b9..f03803b 100644 --- a/services/paperless-worker/README.md +++ b/services/paperless-worker/README.md @@ -6,6 +6,15 @@ Ten sam obraz co `services/paperless/`, ale z nadpisanym poleceniem — działa ze storage widzianym przez NFS z PIHA. Realizuje wzorzec kb-02 „serwis always-on + compute-worker on-demand z fallback". +**Status (2026-07-12): ZDEPLOYOWANY I DZIAŁA.** Worker bierze zadania z kolejki +razem z workerem PIHA, kończy OCR bez błędów, dokumenty trafiają do Paperless +niezależnie od tego, który worker odebrał zadanie. Zweryfikowane end-to-end: +kilka dokumentów testowych wrzuconych do `consume/` na PIHA, część odebrana +przez worker@SOLARIA (log: `Consuming ...` → `ocrmypdf`/`tesseract` → +`ConsumeTaskPlugin completed with: Success` → dokument widoczny w DB), zero +`File not found`. Dwa bugi znalezione i naprawione po drodze — patrz sekcje +„Gotcha: `command` i entrypoint obrazu" i „SCRATCH_DIR" niżej. + ## Wynik badania: czy split-host worker jest wykonalny? **TAK — z zastrzeżeniami.** Ustalenia (GH discussion #3900 + docs): @@ -50,6 +59,55 @@ ze storage widzianym przez NFS z PIHA. Realizuje wzorzec kb-02 - **Wersje obrazów muszą być identyczne** (PIHA i SOLARIA) — wspólny schemat DB i sygnatury zadań. Podbijać oba compose naraz. +## Gotcha: `command` i entrypoint obrazu + +Nadpisanie `command:` w compose na `celery --app paperless worker ...` (jak +sugeruje GH discussion #3900 i punkt 1 wyżej) **nie działa** — pada +`Unknown command: 'celery'`. Przyczyna w `/sbin/docker-entrypoint.sh` obrazu: + +```bash +if [[ "$1" != "/"* ]]; then + exec gosu paperless python3 manage.py "$@" # argv[0] bez "/" -> manage.py +else + exec "$@" # argv[0] zaczyna się od "/" -> exec wprost +fi +``` + +Każdy argument NIE zaczynający się od `/` jest traktowany jako nazwa komendy +Django i lądowany w `manage.py ` — stąd `celery` jest interpretowane jako +nieznana subkomenda `manage.py`, a nie program do uruchomienia. Fix: ścieżka +absolutna, żeby wejść w gałąź `exec "$@"`, plus jawne `gosu paperless` z +przodu (bo ten branch NIE dostaje automatycznego gosu jak branch manage.py) — +inaczej proces poszedłby jako root, co psuje właściciela plików na NFS +(musi być uid 1000, patrz "UID mapping" niżej): + +```yaml +command: /usr/sbin/gosu paperless /usr/local/bin/celery --app paperless worker --loglevel INFO --concurrency ${WORKER_CONCURRENCY:-4} +``` + +Ścieżki zweryfikowane w obrazie `ghcr.io/paperless-ngx/paperless-ngx:2.14`: +`gosu` = `/usr/sbin/gosu`, `celery` = `/usr/local/bin/celery`, user `paperless` += uid/gid 1000. + +## SCRATCH_DIR — musi być współdzielony tak samo jak data/media/consume + +Paperless zapisuje wgrywany plik do katalogu roboczego przed konsumpcją — +`SCRATCH_DIR`, domyślnie `/tmp/paperless` (paperless-ngx: +`tempfile.gettempdir() / "paperless"` w `src/paperless/settings.py`, gdy +`PAPERLESS_SCRATCH_DIR` nie jest ustawione — nie trzeba go więc ustawiać +jawnie, domyślna wartość już pasuje do mount pointu poniżej). Payload zadania +celery niesie ścieżkę do tego pliku jako ścieżkę **absolutną**. Ponieważ +worker@PIHA i worker@SOLARIA dzielą jedną kolejkę, ten, który akurat odbierze +zadanie, musi widzieć dokładnie ten sam plik pod dokładnie tą samą ścieżką — +identyczna zasada jak dla `data/media/consume` (patrz komentarz w +`services/paperless/docker-compose.yml`). To zostało pominięte przy +pierwszym wdrożeniu modułu 3: dokumenty konsumowane na PIHA, ale odebrane do +OCR przez SOLARIĘ, padały z `Cannot consume ...: File not found`, bo SOLARIA +miała własny, lokalny, pusty `/tmp/paperless`. Fix: NFS-owy wolumen +`paperless_scratch` (ten sam wzorzec co `paperless_data/media/consume`) na +katalog `/opt/homelab/data/paperless/scratch` (już istnieje na PIHA, +`chown 1000:1000`), montowany na `/tmp/paperless` po obu stronach. + ## NFS: export na PIHA, mount na SOLARIA Transfer idzie po **LAN** (PIHA `192.168.31.5` ↔ SOLARIA `192.168.31.70`, @@ -90,11 +148,18 @@ Weryfikacja po deployu: `./healthcheck.sh` robi test zapisu na mount. ## Cutover checklist (przy deployu modułu 3 — po działającym module 2) -1. `services/paperless/` działa na PIHA (healthcheck zielony). -2. Export NFS na PIHA (wyżej) + `showmount -e 192.168.31.5` z SOLARII. -3. `nfs-common` na SOLARII. -4. `.env` z `env.example` — sekrety SKOPIOWANE z PIHA, nie nowe. -5. `docker compose up -d` + `./healthcheck.sh`. -6. Test: wrzucić PDF do consume na PIHA → obciążenie CPU na SOLARII, nie PIHA. -7. Test fallbacku: stop workera → zadanie czeka/mieli PIHA → start → drenaż. -8. Wpis w `hosts/solaria/services.yaml` + topology (dopiero przy deployu). +1. ✅ `services/paperless/` działa na PIHA (healthcheck zielony). +2. ✅ Export NFS na PIHA (wyżej) + `showmount -e 192.168.31.5` z SOLARII. +3. ✅ `nfs-common` na SOLARII. +4. ✅ `.env` z `env.example` — sekrety SKOPIOWANE z PIHA, nie nowe. +5. ✅ `docker compose up -d` + `./healthcheck.sh`. +6. ✅ Test (2026-07-12): PDF-y wrzucone do consume na PIHA, część odebrana i + dokończona przez worker@SOLARIA (dowód w logach, zero File not found). +7. ⬜ Test fallbacku: stop workera → zadanie czeka/mieli PIHA → start → drenaż + (jeszcze niewykonany formalnie, ale mechanizm nie zmienił się tym fixem — + fallback na PIHA działał już wcześniej, patrz sekcja "Fallback" wyżej). +8. ⬜ **OTWARTE**: wpis `paperless-worker` w `hosts/solaria/services.yaml` + + `inventory/topology.yaml` (obecnie SOLARIA ma tam tylko `node-agent`) — + bez tego supervisor/observer nie widzą tego serwisu w desired-state, więc + drift między `hosts/solaria/services.yaml` a rzeczywistością nie jest + wykrywany. Patrz `docs/backlog.md`. diff --git a/services/paperless-worker/docker-compose.yml b/services/paperless-worker/docker-compose.yml index fb30763..068703a 100644 --- a/services/paperless-worker/docker-compose.yml +++ b/services/paperless-worker/docker-compose.yml @@ -21,7 +21,19 @@ services: # install, then execs this instead of the full s6 service tree. # OCR (ocrmypdf/tesseract) is CPU-bound — the win here is SOLARIA's # 24 cores, not the GPU (stock paperless OCR does not use CUDA). - command: celery --app paperless worker --loglevel INFO --concurrency ${WORKER_CONCURRENCY:-4} + # + # MUST be an absolute path. /sbin/docker-entrypoint.sh branches on + # argv[0]: anything NOT starting with "/" is treated as a Django + # management-command name and handed to `manage.py` (`exec gosu paperless + # python3 manage.py "$@"`) — so a bare `celery ...` command fails with + # "Unknown command: 'celery'". An absolute path takes the `else exec "$@"` + # branch instead, running celery directly. `gosu paperless` in front + # replaces the implicit gosu the manage.py branch would have applied, so + # the process still runs as uid 1000 (paperless), not root — required so + # files it writes on the NFS-shared storage keep the correct owner and + # match USERMAP_UID/GID below. Verified against the image: gosu is at + # /usr/sbin/gosu, celery at /usr/local/bin/celery. + command: /usr/sbin/gosu paperless /usr/local/bin/celery --app paperless worker --loglevel INFO --concurrency ${WORKER_CONCURRENCY:-4} env_file: - .env environment: @@ -58,6 +70,18 @@ services: - paperless_data:/usr/src/paperless/data - paperless_media:/usr/src/paperless/media - paperless_consume:/usr/src/paperless/consume + # SCRATCH_DIR (default /tmp/paperless — paperless-ngx sets it to + # tempfile.gettempdir()/"paperless" when PAPERLESS_SCRATCH_DIR is unset, + # see src/paperless/settings.py) is where paperless@PIHA stages the + # uploaded file before consuming it. The celery task payload carries + # that path as an ABSOLUTE path, and this worker opens the same path on + # its own filesystem — without a shared mount here, any task picked up + # by SOLARIA (instead of the local PIHA worker) fails with "Cannot + # consume ...: File not found". Must be NFS-mounted at the identical + # container path, same reasoning as data/media/consume above. No need + # to set PAPERLESS_SCRATCH_DIR explicitly: the default already resolves + # to /tmp/paperless on both sides. + - paperless_scratch:/tmp/paperless healthcheck: # Pings THIS worker through the broker — proves broker connectivity and # a live celery process in one shot. @@ -91,3 +115,9 @@ volumes: type: nfs o: addr=192.168.31.5,rw,nfsvers=4.1,hard,timeo=150 device: ":/opt/homelab/data/paperless/consume" + paperless_scratch: + driver: local + driver_opts: + type: nfs + o: addr=192.168.31.5,rw,nfsvers=4.1,hard,timeo=150 + device: ":/opt/homelab/data/paperless/scratch" diff --git a/services/paperless/docker-compose.yml b/services/paperless/docker-compose.yml index 7483c69..31ac7e3 100644 --- a/services/paperless/docker-compose.yml +++ b/services/paperless/docker-compose.yml @@ -76,6 +76,19 @@ services: - /opt/homelab/data/paperless/media:/usr/src/paperless/media - /opt/homelab/data/paperless/consume:/usr/src/paperless/consume - /opt/homelab/data/paperless/export:/usr/src/paperless/export + # SCRATCH_DIR — where paperless stages an uploaded file before + # consuming it (default /tmp/paperless; see paperless-ngx + # src/paperless/settings.py, tempfile.gettempdir()/"paperless"). The + # celery task payload carries this staged file's path as an ABSOLUTE + # path. Since the built-in worker here and the worker@SOLARIA share one + # task queue, whichever one picks up the task must be able to open that + # exact path — so, same rule as data/media/consume above, this MUST be + # NFS-exported and mounted at the identical container path on both + # hosts. Was missed when module 3 was first wired up: documents + # consumed on PIHA but OCR'd by the SOLARIA worker failed with "Cannot + # consume ...: File not found" because SOLARIA had its own local, + # empty /tmp/paperless. + - /opt/homelab/data/paperless/scratch:/tmp/paperless ports: # LAN-only bind; npm@PIHA proxies paper.kapala.org -> 192.168.31.5:8210. # TODO AT DEPLOY: port 8210 free per the 2026-06-30 inventory —