Add a docker-compose for integration test services

This commit is contained in:
Yiorgis Gozadinos 2026-06-04 10:58:40 +03:00
parent 1717bd4996
commit e37d764ab2
No known key found for this signature in database
8 changed files with 154 additions and 63 deletions

View file

@ -11,6 +11,8 @@ services:
- "5001:5001" # exposed on the host for ad-hoc debugging (5001) - "5001:5001" # exposed on the host for ad-hoc debugging (5001)
environment: environment:
- DOCLING_SERVE_ENABLE_UI=1 - DOCLING_SERVE_ENABLE_UI=1
# Allow remote VLM calls (picture descriptions via an external model API).
- DOCLING_SERVE_ENABLE_REMOTE_SERVICES=true
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health"] test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
@ -27,6 +29,8 @@ services:
- "5002:5001" # host 5002 → container 5001 (debug only) - "5002:5001" # host 5002 → container 5001 (debug only)
environment: environment:
- DOCLING_SERVE_ENABLE_UI=1 - DOCLING_SERVE_ENABLE_UI=1
# Allow remote VLM calls (picture descriptions via an external model API).
- DOCLING_SERVE_ENABLE_REMOTE_SERVICES=true
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health"] test: ["CMD", "curl", "-f", "http://localhost:5001/health"]

View file

@ -23,6 +23,8 @@ import pydantic_ai.models # noqa: E402
import pytest # noqa: E402 import pytest # noqa: E402
import yaml # noqa: E402 import yaml # noqa: E402
from .services import reachable # noqa: E402
if TYPE_CHECKING: if TYPE_CHECKING:
from vcr import VCR from vcr import VCR
@ -148,3 +150,42 @@ def doclaynet_first_page_pdf(tmp_path_factory) -> Path:
finally: finally:
src.close() src.close()
return out_path return out_path
# --- external services for integration tests ---
#
# Integration tests (marked `integration`, excluded in CI via `-m "not
# integration"`) need external services. Bring them up with
# docker compose -f tests/docker/docker-compose.yml up -d
# These fixtures hand the test a connection URL when the service is reachable
# (or when the matching env var points at an external instance), and skip the
# test otherwise.
_COMPOSE_HINT = (
"start it with `docker compose -f tests/docker/docker-compose.yml up -d`"
)
@pytest.fixture(scope="session")
def postgres_dburi() -> str:
"""A reachable Postgres queue URL. Uses HAIKU_RAG_TEST_PG_DBURI when set,
otherwise the docker-compose `postgres` service. Skips when neither is up."""
override = os.environ.get("HAIKU_RAG_TEST_PG_DBURI")
if override:
return override
if not reachable("localhost", 55432):
pytest.skip(f"Postgres not reachable on localhost:55432 — {_COMPOSE_HINT}")
return "postgresql+asyncpg://haiku:secret@localhost:55432/haiku_rag_test"
@pytest.fixture(scope="session")
def docling_serve_url() -> str:
"""A reachable docling-serve base URL. Uses HAIKU_RAG_TEST_DOCLING_SERVE_URL
when set, otherwise the docker-compose `docling-serve` service. Skips when
neither is up."""
override = os.environ.get("HAIKU_RAG_TEST_DOCLING_SERVE_URL")
if override:
return override
if not reachable("localhost", 5001):
pytest.skip(f"docling-serve not reachable on localhost:5001 — {_COMPOSE_HINT}")
return "http://localhost:5001"

View file

@ -1,18 +0,0 @@
services:
seaweedfs:
image: chrislusf/seaweedfs
ports:
- "8333:8333"
command: server -s3 -s3.config=/etc/seaweedfs/s3-config.json
volumes:
- ./s3-config.json:/etc/seaweedfs/s3-config.json:ro
createbucket:
image: chrislusf/seaweedfs
depends_on:
- seaweedfs
entrypoint: >
/bin/sh -c "
sleep 3 &&
echo 's3.bucket.create -name test-bucket' | weed shell -master seaweedfs:9333
"

View file

@ -0,0 +1,50 @@
# Services for the integration tests (marked `integration`, excluded in CI).
# Start them all:
# docker compose -f tests/docker/docker-compose.yml up -d
# Stop and clean up:
# docker compose -f tests/docker/docker-compose.yml down -v
#
# Tests skip automatically when a service isn't reachable, so you only need to
# bring up the ones you care about. The docling-serve picture-description test
# also needs Ollama running on the host with the `ministral-3` model.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: haiku
POSTGRES_PASSWORD: secret
POSTGRES_DB: haiku_rag_test
ports:
- "55432:5432"
tmpfs:
- /var/lib/postgresql/data
docling-serve:
image: quay.io/docling-project/docling-serve:latest
ports:
- "5001:5001"
environment:
# Required for picture-description via a remote VLM (e.g. host Ollama).
- DOCLING_SERVE_ENABLE_REMOTE_SERVICES=true
- DOCLING_SERVE_ENABLE_UI=1
extra_hosts:
# Let the container reach the host's Ollama for VLM picture descriptions.
- "host.docker.internal:host-gateway"
seaweedfs:
image: chrislusf/seaweedfs
ports:
- "8333:8333"
command: server -s3 -s3.config=/etc/seaweedfs/s3-config.json
volumes:
- ./s3-config.json:/etc/seaweedfs/s3-config.json:ro
createbucket:
image: chrislusf/seaweedfs
depends_on:
- seaweedfs
entrypoint: >
/bin/sh -c "
sleep 3 &&
echo 's3.bucket.create -name test-bucket' | weed shell -master seaweedfs:9333
"

View file

@ -1,9 +1,10 @@
"""Postgres-backed queue tests. Skipped unless HAIKU_RAG_TEST_PG_DBURI points """Postgres-backed queue tests. Marked `integration` (excluded in CI). The
at a reachable Postgres (a SQLAlchemy async URL, e.g. `postgres_dburi` fixture connects to the docker-compose `postgres` service
postgresql+asyncpg://user:pw@localhost:5432/haiku_rag_test). They exercise the (`docker compose -f tests/docker/docker-compose.yml up -d`), or uses
dialect-specific SQL the SQLite suite cannot: ON CONFLICT, COALESCE upserts, HAIKU_RAG_TEST_PG_DBURI when set, and skips when neither is reachable. They
the partial unique index, and FOR UPDATE SKIP LOCKED under real concurrent exercise the dialect-specific SQL the SQLite suite cannot: ON CONFLICT, COALESCE
connections. upserts, the partial unique index, and FOR UPDATE SKIP LOCKED under real
concurrent connections.
Each test owns its engine for its whole body. asyncpg binds connections to the Each test owns its engine for its whole body. asyncpg binds connections to the
loop that created them and pytest-asyncio uses a per-test loop, so opening the loop that created them and pytest-asyncio uses a per-test loop, so opening the
@ -12,7 +13,6 @@ avoids cross-loop fixture handoff.
""" """
import asyncio import asyncio
import os
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -25,22 +25,16 @@ from haiku.rag.ingester.queue.db import metadata
from haiku.rag.ingester.queue.models import JobOp, JobStatus, SyncRow from haiku.rag.ingester.queue.models import JobOp, JobStatus, SyncRow
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
PG_DBURI = os.environ.get("HAIKU_RAG_TEST_PG_DBURI") pytestmark = pytest.mark.integration
pytestmark = pytest.mark.skipif(
not PG_DBURI,
reason="Set HAIKU_RAG_TEST_PG_DBURI to run the Postgres queue tests",
)
@asynccontextmanager @asynccontextmanager
async def queue_engine(): async def queue_engine(dburi: str):
"""An engine scoped to a throwaway Postgres schema, so concurrent xdist """An engine scoped to a throwaway Postgres schema, so concurrent xdist
workers each get their own isolated copy of the queue tables.""" workers each get their own isolated copy of the queue tables."""
assert PG_DBURI is not None # guarded by the module skip marker
schema = f"q_{uuid.uuid4().hex[:12]}" schema = f"q_{uuid.uuid4().hex[:12]}"
engine = create_async_engine( engine = create_async_engine(
make_url(PG_DBURI), make_url(dburi),
poolclass=NullPool, poolclass=NullPool,
connect_args={"server_settings": {"search_path": schema}}, connect_args={"server_settings": {"search_path": schema}},
) )
@ -56,10 +50,10 @@ async def queue_engine():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enqueue_dedup_via_partial_unique_index(): async def test_enqueue_dedup_via_partial_unique_index(postgres_dburi):
"""ON CONFLICT DO NOTHING against uq_jobs_live drops a second live job for """ON CONFLICT DO NOTHING against uq_jobs_live drops a second live job for
the same (source_id, uri), regardless of op.""" the same (source_id, uri), regardless of op."""
async with queue_engine() as engine: async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine) jobs = JobRepo(engine)
first = await jobs.enqueue("s", "u", JobOp.UPSERT) first = await jobs.enqueue("s", "u", JobOp.UPSERT)
second = await jobs.enqueue("s", "u", JobOp.DELETE) second = await jobs.enqueue("s", "u", JobOp.DELETE)
@ -68,10 +62,10 @@ async def test_enqueue_dedup_via_partial_unique_index():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enqueue_after_terminal_succeeds(): async def test_enqueue_after_terminal_succeeds(postgres_dburi):
"""Once a job is terminal it no longer satisfies the partial index, so a """Once a job is terminal it no longer satisfies the partial index, so a
re-enqueue for the same URI is allowed.""" re-enqueue for the same URI is allowed."""
async with queue_engine() as engine: async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine) jobs = JobRepo(engine)
first = await jobs.enqueue("s", "u", JobOp.UPSERT) first = await jobs.enqueue("s", "u", JobOp.UPSERT)
assert first is not None assert first is not None
@ -83,11 +77,11 @@ async def test_enqueue_after_terminal_succeeds():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skip_locked_claims_each_job_once(): async def test_skip_locked_claims_each_job_once(postgres_dburi):
"""FOR UPDATE SKIP LOCKED: many concurrent claims over real Postgres """FOR UPDATE SKIP LOCKED: many concurrent claims over real Postgres
connections each take a distinct job, with none claimed twice. Without connections each take a distinct job, with none claimed twice. Without
SKIP LOCKED, concurrent transactions would grab the same row.""" SKIP LOCKED, concurrent transactions would grab the same row."""
async with queue_engine() as engine: async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine) jobs = JobRepo(engine)
enqueued = [] enqueued = []
for i in range(20): for i in range(20):
@ -104,9 +98,9 @@ async def test_skip_locked_claims_each_job_once():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reap_stale_clamps_attempts(): async def test_reap_stale_clamps_attempts(postgres_dburi):
"""The attempts-1 clamp (CASE) renders and runs on Postgres.""" """The attempts-1 clamp (CASE) renders and runs on Postgres."""
async with queue_engine() as engine: async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine) jobs = JobRepo(engine)
job = await jobs.enqueue("s", "u", JobOp.UPSERT) job = await jobs.enqueue("s", "u", JobOp.UPSERT)
assert job is not None assert job is not None
@ -121,10 +115,10 @@ async def test_reap_stale_clamps_attempts():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sync_state_upsert_coalesce_preserves_revision(): async def test_sync_state_upsert_coalesce_preserves_revision(postgres_dburi):
"""ON CONFLICT DO UPDATE with COALESCE leaves an existing revision in place """ON CONFLICT DO UPDATE with COALESCE leaves an existing revision in place
when a later upsert passes revision=None.""" when a later upsert passes revision=None."""
async with queue_engine() as engine: async with queue_engine(postgres_dburi) as engine:
sync = SyncStateRepo(engine) sync = SyncStateRepo(engine)
await sync.upsert("s", "u", revision="v1", content_hash="h1") await sync.upsert("s", "u", revision="v1", content_hash="h1")
await sync.upsert("s", "u", revision=None, content_hash=None, ingested=True) await sync.upsert("s", "u", revision=None, content_hash=None, ingested=True)
@ -136,8 +130,8 @@ async def test_sync_state_upsert_coalesce_preserves_revision():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sync_state_batch_upsert(): async def test_sync_state_batch_upsert(postgres_dburi):
async with queue_engine() as engine: async with queue_engine(postgres_dburi) as engine:
sync = SyncStateRepo(engine) sync = SyncStateRepo(engine)
await sync.batch_upsert( await sync.batch_upsert(
[ [
@ -149,8 +143,8 @@ async def test_sync_state_batch_upsert():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prune_terminal_removes_old_rows(): async def test_prune_terminal_removes_old_rows(postgres_dburi):
async with queue_engine() as engine: async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine) jobs = JobRepo(engine)
job = await jobs.enqueue("s", "u", JobOp.UPSERT) job = await jobs.enqueue("s", "u", JobOp.UPSERT)
assert job is not None assert job is not None

11
tests/services.py Normal file
View file

@ -0,0 +1,11 @@
import socket
def reachable(host: str, port: int, timeout: float = 1.0) -> bool:
"""True if a TCP connection to host:port succeeds within timeout. Used by
integration tests to skip when their backing service isn't running."""
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False

View file

@ -1339,6 +1339,21 @@ class TestDoclingServeConverterPictureDescription:
assert "picture_description_api" not in data assert "picture_description_api" not in data
async def _skip_without_ollama_model(model: str) -> None:
"""Skip when the host Ollama isn't serving `model`. docling-serve calls it
for VLM picture descriptions, so without it the test would fail with an
opaque error from inside the container rather than skip."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get("http://localhost:11434/api/tags")
response.raise_for_status()
names = [m["name"] for m in response.json().get("models", [])]
except Exception as exc: # noqa: BLE001
pytest.skip(f"Ollama not reachable on localhost:11434 ({exc})")
if not any(name == model or name.startswith(f"{model}:") for name in names):
pytest.skip(f"Ollama model '{model}' not pulled (run `ollama pull {model}`)")
class TestDoclingServeConverterIntegration: class TestDoclingServeConverterIntegration:
"""Integration tests with real docling-serve recorded via VCR.""" """Integration tests with real docling-serve recorded via VCR."""
@ -1379,12 +1394,14 @@ class TestDoclingServeConverterIntegration:
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration @pytest.mark.integration
async def test_picture_description_end_to_end( async def test_picture_description_end_to_end(
self, config, doclaynet_first_page_pdf self, config, docling_serve_url, doclaynet_first_page_pdf
): ):
"""End-to-end test: convert PDF with VLM picture descriptions via docling-serve. """End-to-end test: convert PDF with VLM picture descriptions via docling-serve.
Note: Not using VCR because this test involves polling with changing task IDs. Note: Not using VCR because this test involves polling with changing task IDs.
""" """
await _skip_without_ollama_model("ministral-3")
config.providers.docling_serve.base_url = docling_serve_url
pdf_path = doclaynet_first_page_pdf pdf_path = doclaynet_first_page_pdf
config.processing.pictures = "description" config.processing.pictures = "description"
config.processing.conversion_options.picture_description.model.provider = ( config.processing.conversion_options.picture_description.model.provider = (

View file

@ -1,9 +1,8 @@
# Start SeaweedFS before running: # Start the services before running:
# docker compose -f tests/docker/docker-compose.s3.yml up -d # docker compose -f tests/docker/docker-compose.yml up -d
# Stop after: # Stop after:
# docker compose -f tests/docker/docker-compose.s3.yml down -v # docker compose -f tests/docker/docker-compose.yml down -v
import socket
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@ -12,6 +11,7 @@ from haiku.rag.app import HaikuRAGApp
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig, LanceDBConfig from haiku.rag.config.models import AppConfig, LanceDBConfig
from haiku.rag.store.engine import Store from haiku.rag.store.engine import Store
from tests.services import reachable
S3_ENDPOINT = "http://localhost:8333" S3_ENDPOINT = "http://localhost:8333"
S3_BUCKET = "test-bucket" S3_BUCKET = "test-bucket"
@ -24,19 +24,11 @@ S3_STORAGE_OPTIONS = {
} }
def _s3_available() -> bool:
try:
s = socket.create_connection(("localhost", 8333), timeout=1)
s.close()
return True
except OSError:
return False
pytestmark = [ pytestmark = [
pytest.mark.integration, pytest.mark.integration,
pytest.mark.skipif( pytest.mark.skipif(
not _s3_available(), reason="SeaweedFS not running on localhost:8333" not reachable("localhost", 8333),
reason="SeaweedFS not running on localhost:8333",
), ),
] ]