Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 144 additions & 7 deletions .github/workflows/frontend-e2e-extract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ jobs:
# note about Docling.
echo "PDF_PARSER=docling" >> .envs/.local/.django

# Raise Celery worker concurrency so the two PDFs uploaded
# back-to-back parse + embed concurrently instead of serialising
# behind a single child. Serial cold-start processing of both
# fixtures was the dominant cause of the ingest-wait timeout that
# blew this job's 30-minute budget. The worker start script reads
# this from its env_file (.envs/.local/.django); local dev keeps
# the default of 1.
#
# The value 2 is deliberately tied to the spec uploading exactly
# two PDF fixtures (see frontend/tests/e2e/extract-pdf-workflow.spec.ts).
# If the spec grows to upload more documents, bump this to match so
# they keep ingesting concurrently rather than queueing.
echo "CELERY_WORKER_CONCURRENCY=2" >> .envs/.local/.django

# Sanity dump (without secrets — there are none in the .test
# template, but be explicit).
echo "--- .envs/.local/.django (head) ---"
Expand Down Expand Up @@ -173,6 +187,94 @@ jobs:
print('preferred PDF parser:', ps.get_preferred_parser('application/pdf'))
"

# ────────────────────────────────────────────────────────────────
# Warm the model services BEFORE the timed test.
#
# Root cause of the flaky ingest-wait timeout: Docling loads its
# layout/table/OCR models lazily on the FIRST `/parse/` call. On a
# cold 2-core runner already hosting the docling + multimodal +
# privacy-filter images, that first cold parse balloons into a
# multi-minute, highly-variable stall — occasionally exceeding the
# 14-minute per-document wait, which (with the Playwright retry) then
# blows the job's 30-minute `timeout-minutes` and the job is
# cancelled. The embedder, by contrast, is ready in seconds.
#
# Driving one real parse here loads those models off the test clock,
# so the spec's two ingests run warm. Steps are best-effort: a warmup
# failure logs a warning but does not fail the job (the spec still
# runs and would surface a genuine regression).
# ────────────────────────────────────────────────────────────────
- name: Warm up Docling parser + embedder (load models off the test clock)
# Best-effort: the Python below swallows its own errors, but a
# docker-level failure (e.g. the django container crashed before this
# step) would otherwise fail the step and block the job. The warm-up is
# an optimisation, never a gate — keep going and let the timed spec run.
continue-on-error: true
run: |
docker compose -f local.yml exec -T django python - <<'PY'
import base64, json, sys, time, urllib.request

# Short budget: ``depends_on: condition: service_healthy`` already
# verified these services are up before this job step runs, so this
# poll just covers a brief readiness gap rather than a cold boot.
def wait_health(url, name, budget=30):
deadline = time.time() + budget
while time.time() < deadline:
try:
if urllib.request.urlopen(url, timeout=3).status == 200:
print(f"{name}: healthy", flush=True)
return True
except Exception:
pass
time.sleep(3)
print(f"WARNING: {name} not healthy after {budget}s", file=sys.stderr, flush=True)
return False

wait_health("http://vector-embedder:8000/health", "vector-embedder")
wait_health("http://docling-parser:8000/health", "docling-parser")

# Force Docling to load its parse models with one real parse of the
# small committed fixture (mounted at /app via the repo volume).
pdf = "/app/frontend/tests/fixtures/usc-title-1.pdf"
with open(pdf, "rb") as f:
pdf_b64 = base64.b64encode(f.read()).decode()
payload = json.dumps({
"filename": "warmup.pdf",
"pdf_base64": pdf_b64,
"force_ocr": False,
"roll_up_groups": False,
"llm_enhanced_hierarchy": False,
}).encode()
req = urllib.request.Request(
"http://docling-parser:8000/parse/",
data=payload,
headers={"Content-Type": "application/json"},
)
t = time.time()
try:
# Cap at 300s: the warm run does the WHOLE flow (2 uploads, both
# ingests, extract, CSV) in ~3.6 min, so a single warm-up parse
# hanging past ~5 min is almost certainly an OOM on the cold
# runner, not slow model loading — fail fast rather than burn a
# third of the 30-min job budget before the timed spec starts.
r = urllib.request.urlopen(req, timeout=300)
print(f"docling warmup parse: {r.status} in {time.time() - t:.1f}s", flush=True)
except Exception as e:
print(f"WARNING: docling warmup parse failed (non-fatal): {e}", file=sys.stderr, flush=True)

# Warm the text embedder too (cheap; ready in seconds).
try:
eb = json.dumps({"texts": ["warmup"]}).encode()
er = urllib.request.Request(
"http://vector-embedder:8000/embeddings/batch",
data=eb,
headers={"Content-Type": "application/json", "X-API-Key": "abc123"},
)
print(f"embedder warmup: {urllib.request.urlopen(er, timeout=120).status}", flush=True)
except Exception as e:
print(f"WARNING: embedder warmup failed (non-fatal): {e}", file=sys.stderr, flush=True)
PY

# ────────────────────────────────────────────────────────────────
# Run the extract spec under coverage.
#
Expand All @@ -198,7 +300,20 @@ jobs:
E2E_TEST_PASSWORD: Openc0ntracts_def@ult
run: |
set +e
yarn playwright test --grep "Extract PDF workflow" --reporter=list
# DIAGNOSTIC: --retries=0 so a cold-start stall fails the attempt
# immediately instead of triggering a retry that pushes the job past
# its 30-min `timeout-minutes` (a cancelled job prints no Playwright
# failure detail and historically skipped log capture). With retries
# off the run completes in-budget, Playwright prints which step/doc
# failed, and the always() capture below uploads worker logs + memory
# stats.
#
# TODO(#1844 follow-up): keep --retries=0 only until this fix has a
# handful of green runs proving the cold-start stall is gone; then
# restore Playwright's default retry so a genuinely transient network
# blip doesn't red-X the whole job. Tracked so this diagnostic setting
# doesn't quietly become permanent.
yarn playwright test --grep "Extract PDF workflow" --reporter=list --retries=0
E2E_EXIT=$?
mkdir -p coverage/e2e/.nyc_output
yarn nyc report --all \
Expand All @@ -225,19 +340,41 @@ jobs:
coverage xml -o /app/coverage-backend-e2e-extract.xml || true
ls -la coverage-backend-e2e-extract.xml || true

- name: Capture backend logs on failure
if: failure()
# `if: failure() || cancelled()` (not plain `failure()`) so this also runs
# when the job is CANCELLED by `timeout-minutes` — the cold-start ingest
# stall blows the 30-min budget, and a cancelled job skips `failure()`
# steps, which is why earlier runs captured nothing. `cancelled()` covers
# that gap explicitly while skipping the capture+upload on green runs (no
# artifact churn when there's nothing to diagnose). Containers are still up
# here because the `success() || failure()` stop/coverage steps above are
# skipped on cancellation. The celeryworker log + memory snapshot are the
# evidence needed to see WHAT stalls during ingest (worker first-task cost
# vs. OOM/thrash vs. a transient-retry loop).
- name: Capture backend logs + resource stats
if: failure() || cancelled()
run: |
mkdir -p artifacts
docker compose -f local.yml ps > artifacts/docker-ps.txt || true
docker compose -f local.yml logs --no-color > artifacts/docker-compose-logs.txt || true
docker compose -f local.yml ps > artifacts/docker-ps.txt 2>&1 || true
docker compose -f local.yml logs --no-color --timestamps \
> artifacts/docker-compose-logs.txt 2>&1 || true
docker compose -f local.yml logs --no-color --timestamps celeryworker \
> artifacts/celeryworker.log 2>&1 || true
docker compose -f local.yml logs --no-color --timestamps django \
> artifacts/django.log 2>&1 || true
docker stats --no-stream > artifacts/docker-stats.txt 2>&1 || true
{ echo "=== free -h ==="; free -h; \
echo "=== /proc/meminfo (head) ==="; head -5 /proc/meminfo; \
echo "=== OOM / killed-process (dmesg) ==="; \
sudo dmesg 2>/dev/null | grep -iE "oom|killed process|out of memory" | tail -40; \
} > artifacts/host-memory.txt 2>&1 || true

- name: Upload backend logs on failure
if: failure()
- name: Upload backend logs + stats
if: failure() || cancelled()
uses: actions/upload-artifact@v7
with:
name: e2e-extract-backend-logs
path: artifacts/
if-no-files-found: ignore

- name: Upload Playwright HTML report on failure
if: failure()
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Flaky `Extract pipeline (PDF upload → run → CSV)` E2E — fixed the cold-start ingest stall at the root** (`.github/workflows/frontend-e2e-extract.yml`, `local.yml`, `compose/local/django/celery/worker/start`). The check is VCR-replayed (no live LLM), so it should be deterministic, but it intermittently red-X'd across PRs (#1825/#1835/#1836). Root cause: the check failed not on an assertion but on a **job cancellation** — the first document ingest stalled past the 14-minute per-document wait, and that attempt (≈18 min) plus the Playwright retry (≈3.6 min) blew the job's 30-minute `timeout-minutes`. The stall is a **cold start**: Docling loads its layout/table/OCR models *lazily on the first `/parse/` call*, and on a cold 2-core runner already hosting the docling + multimodal + privacy-filter images that first parse balloons into a multi-minute, variable hang. The prior fix (#1846) only raised the timeout 8→14 min, which deferred the symptom into the job-timeout instead of removing it. Three changes address the cause:
- **Warm-up step** runs one real Docling parse (of the small committed `usc-title-1.pdf` fixture) plus an embedder call *before* the timed spec, so both ingests run warm. Best-effort: warm-up failures warn but don't fail the job.
- **`docker compose` readiness gating** — added `/health` healthchecks to `vector-embedder`, `multimodal-embedder`, and `docling-parser`, and gated `django` + `celeryworker` on `vector-embedder` / `docling-parser` being `service_healthy` (previously only `service_started`, so the worker could start parsing before the services were listening).
- **`CELERY_WORKER_CONCURRENCY`** — the worker start script now honors this env var (default `1`, unchanged for local dev); the extract workflow sets it to `2` so the two uploaded PDFs parse + embed concurrently instead of serialising behind a single child.
- **Canonical-CAML backfill migration `0054` crashed on cloud storage
(`AttributeError: 'bytes' object has no attribute 'encode'`).** Production
`manage.py migrate` aborted at
Expand Down
9 changes: 8 additions & 1 deletion compose/local/django/celery/worker/start
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@ set -o errexit
set -o nounset


watchfiles --ignore-paths frontend,node_modules,.git,__pycache__,.mypy_cache,.pytest_cache,media,staticfiles,docs,.claude,.playwright-mcp,playwright-report-e2e,test-results --target-type command "celery -A config.celery_app worker -l INFO --concurrency=1 -Q celery,worker_uploads"
# Worker concurrency defaults to 1 (the established local-dev default) but can
# be raised via CELERY_WORKER_CONCURRENCY. CI raises it so two documents
# uploaded back-to-back parse + embed concurrently instead of serialising
# behind a single child — a serial cold start was the dominant cause of
# ingest-wait timeouts in the e2e extract workflow.
CELERY_WORKER_CONCURRENCY="${CELERY_WORKER_CONCURRENCY:-1}"

watchfiles --ignore-paths frontend,node_modules,.git,__pycache__,.mypy_cache,.pytest_cache,media,staticfiles,docs,.claude,.playwright-mcp,playwright-report-e2e,test-results --target-type command "celery -A config.celery_app worker -l INFO --concurrency=${CELERY_WORKER_CONCURRENCY} -Q celery,worker_uploads"
50 changes: 47 additions & 3 deletions local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ services:
condition: service_healthy
redis:
condition: service_healthy
# Gate on the ingest-critical model services being reachable (their
# HTTP servers bound) before Django starts. Docling loads its parse
# models lazily on first use, so this prevents the in-process ingest
# paths (the WebSocket agent) from racing a not-yet-listening service.
vector-embedder:
condition: service_started
condition: service_healthy
# multimodal-embedder has a /health check too, but it is intentionally
# gated only on service_started (not service_healthy) here, and is not a
# direct celeryworker dependency: it serves image embeddings and is NOT
# on the text/PDF parse+embed ingest path the extract pipeline exercises.
# Gating on its health would add ~7 GB-image boot latency to every local
# `docker compose up` for a service this flow doesn't touch.
multimodal-embedder:
condition: service_started
docling-parser:
condition: service_started
condition: service_healthy
docxodus-parser:
condition: service_started
required: false
Expand Down Expand Up @@ -105,6 +115,19 @@ services:
docling-parser:
image: jscrudato/docsling-local
container_name: docling-parser
# `/health` returns 200 once the HTTP server has bound. NOTE: Docling
# loads its layout/table/OCR models lazily on the first `/parse/` call,
# so a green healthcheck means "reachable", not "models warm" — callers
# that need a warm parser must trigger one parse first (the e2e extract
# workflow does exactly this off the test clock).
healthcheck:
test:
- "CMD-SHELL"
- "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health', timeout=3).status == 200 else 1)\""
interval: 10s
timeout: 5s
retries: 12
start_period: 40s

docxodus-parser:
# The frontend WASM (docxodus in package.json) and this microservice
Expand All @@ -123,6 +146,14 @@ services:
TRANSFORMERS_OFFLINE: 1
HF_DATASETS_OFFLINE: 1
API_KEY: abc123 # local-only placeholder; override before deploying
healthcheck:
test:
- "CMD-SHELL"
- "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health', timeout=3).status == 200 else 1)\""
interval: 10s
timeout: 5s
retries: 10
start_period: 30s

multimodal-embedder:
image: ghcr.io/jsv4/vectorembeddermicroservice-multimodal:latest
Expand All @@ -132,6 +163,14 @@ services:
TRANSFORMERS_OFFLINE: 1
HF_DATASETS_OFFLINE: 1
API_KEY: abc123 # local-only placeholder; override before deploying
healthcheck:
test:
- "CMD-SHELL"
- "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health', timeout=3).status == 200 else 1)\""
interval: 10s
timeout: 5s
retries: 12
start_period: 40s

# API_KEYS value below is a LOCAL DEVELOPMENT ONLY placeholder; override via
# environment / .env before exposing the service outside the local docker
Expand Down Expand Up @@ -166,8 +205,13 @@ services:
condition: service_healthy
postgres:
condition: service_healthy
# The worker is where document parse + embed actually run, so gate it
# directly on the ingest-critical model services being reachable —
# don't rely solely on the transitive dependency through django.
vector-embedder:
condition: service_healthy
docling-parser:
condition: service_started
condition: service_healthy
docxodus-parser:
condition: service_started
required: false
Expand Down
Loading