Exclude Python virtualenvs from the Docker build context (#7658)

# Description of Changes

Stirling engine docker slimming

Exclude Python virtualenvs from the Docker build context
Drop unused provider SDKs from the engine dependency set
Retry the SQLite WAL switch when workers race on startup
Build the engine image in two stages and run it unprivileged
Swap voyage SDK for api call removing 200MB bloat
Bundle the AI engine in the fat image
Publish the AI engine as a standalone image


886MB to 295MB in docker file

And Docker fat is only 230MB bigger after adding (since it already has
python and some deps)

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-08-24 16:23:55 +00:00
committed by GitHub
parent 158187ac46
commit ddee690c58
13 changed files with 575 additions and 1802 deletions
+8
View File
@@ -96,6 +96,14 @@ configs/
__pycache__/
**/__pycache__/
# Python virtualenvs. Large, platform-specific, and their symlinks break the build.
.venv/
**/.venv/
venv/
**/venv/
*.egg-info/
**/*.egg-info/
# Local env
.env
.env.*
+127
View File
@@ -18,6 +18,16 @@ on:
required: false
type: boolean
default: false
build_engine:
description: "Build & push the standalone stirling-engine image."
required: false
type: boolean
default: true
force_engine_rebuild:
description: "Rebuild stirling-engine even if its source hash is unchanged."
required: false
type: boolean
default: false
push:
branches:
- release
@@ -50,6 +60,7 @@ jobs:
env:
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
@@ -387,3 +398,119 @@ jobs:
else
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing"
fi
# Standalone AI engine image, same shape as the unoserver image above.
- name: Compute engine image source hash
id: engineHash
if: env.RUN_ENGINE == 'true'
run: |
set -eu
hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \
find engine/src -type f -print0 | sort -z | xargs -0 cat; } \
| sha256sum | cut -d' ' -f1)
echo "hash=${hash}" >> "$GITHUB_OUTPUT"
echo "Engine source hash: ${hash}"
- name: Decide whether to publish engine image
id: engineDecision
if: env.RUN_ENGINE == 'true'
env:
ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }}
ENGINE_HASH: ${{ steps.engineHash.outputs.hash }}
ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine
ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash
FORCE_REBUILD: ${{ inputs.force_engine_rebuild }}
GH_REF: ${{ github.ref }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -eu
mode="skip"
tags=""
read_published_hash() {
local ref="$1"
docker buildx imagetools inspect "$ref" --raw 2>/dev/null \
| jq -r --arg key "$ENGINE_HASH_ANNOTATION" \
'.annotations[$key] // empty' \
2>/dev/null || true
}
# Manual dispatch from any branch routes to the :alpha publish path.
EFFECTIVE_REF="$GH_REF"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
EFFECTIVE_REF="refs/heads/testMain"
fi
case "$EFFECTIVE_REF" in
refs/heads/release)
if [ "${FORCE_REBUILD}" = "true" ]; then
echo "force_engine_rebuild=true — building stable regardless"
mode="stable"
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then
echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping"
else
echo "stirling-engine:${ENGINE_VERSION} is new — will publish"
mode="stable"
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
fi
;;
refs/heads/main|refs/heads/testMain)
published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha")
if [ "${FORCE_REBUILD}" = "true" ]; then
echo "force_engine_rebuild=true — rebuilding :alpha regardless"
mode="alpha"
tags="${ENGINE_IMAGE}:alpha"
elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_HASH" ]; then
echo "Published :alpha source hash matches (${published_hash}) — skipping"
else
if [ -z "$published_hash" ]; then
echo ":alpha has no source-hash annotation (first publish) — will publish"
else
echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish"
fi
mode="alpha"
tags="${ENGINE_IMAGE}:alpha"
fi
;;
*)
echo "Branch ${GH_REF} does not publish engine image"
;;
esac
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
- name: Build and push engine image
id: build-push-engine
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./engine/Dockerfile
push: true
cache-from: type=gha,scope=stirling-engine
cache-to: type=gha,mode=max,scope=stirling-engine
tags: ${{ steps.engineDecision.outputs.tags }}
# Manifest annotation read by the decision step above to detect drift.
annotations: |
index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign engine image
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable'
env:
DIGEST: ${{ steps.build-push-engine.outputs.digest }}
TAGS: ${{ steps.engineDecision.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
else
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping engine image signing"
fi
+36
View File
@@ -67,6 +67,30 @@ COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# Stage 2b: AI engine. Built at its final path so the venv resolves after the copy, on uv's
# managed CPython because the runtime base ships Python 3.12.
FROM ghcr.io/astral-sh/uv:bookworm-slim@sha256:22334efe746f1b69217d455049b484d7b8cacfb2d5f42555580b62415a98e0a3 AS engine-build
ENV UV_PYTHON_INSTALL_DIR=/opt/stirling-engine/python
WORKDIR /opt/stirling-engine
COPY engine/pyproject.toml engine/uv.lock ./
# One layer: trimming in a second RUN would cache the untrimmed copy too, and this build
# exports every layer to a GHA cache that is capped repo-wide. Trimming saves ~20MB.
RUN --mount=type=cache,target=/root/.cache/uv \
set -eux; \
apt-get update && apt-get install -y --no-install-recommends binutils; \
uv python install 3.13; \
uv sync --frozen --no-dev --no-install-project --group engine --python-preference only-managed; \
P="$(ls -d /opt/stirling-engine/python/cpython-*)"; \
rm -rf "$P/share" "$P/include" \
"$P/lib/python3.13/idlelib" "$P/lib/python3.13/tkinter" \
"$P/lib/python3.13/ensurepip" "$P/lib/python3.13/pydoc_data" \
"$P/lib/python3.13/test" "$P/lib/python3.13/lib2to3"; \
find /opt/stirling-engine -name '__pycache__' -type d -prune -exec rm -rf {} + ; \
find /opt/stirling-engine \( -name '*.so' -o -name '*.so.*' \) -print0 \
| xargs -0 -r strip --strip-unneeded 2>/dev/null || true; \
apt-get purge -y binutils; apt-get autoremove -y; rm -rf /var/lib/apt/lists/*
# Stage 3: Final runtime image on top of pre-built base
FROM ${BASE_IMAGE}
@@ -84,6 +108,12 @@ COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
COPY --link --chown=1000:1000 scripts/ /scripts/
# init-without-ocr.sh starts the engine when this directory exists, so other images are unaffected.
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/python /opt/stirling-engine/python
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/.venv /opt/stirling-engine/.venv
COPY --link --chown=1000:1000 engine/.env /opt/stirling-engine/.env
COPY --link --chown=1000:1000 engine/src/ /opt/stirling-engine/src/
# Fonts go to system dir, root ownership is correct (world-readable)
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
@@ -97,6 +127,8 @@ RUN set -eux; \
ln -s /storage /app/storage; \
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \
chown stirlingpdfuser:stirlingpdfgroup /app; \
mkdir -p /opt/stirling-engine/data; \
chown -R stirlingpdfuser:stirlingpdfgroup /opt/stirling-engine/data; \
chmod 750 /tmp/stirling-pdf; \
chmod 750 /tmp/stirling-pdf/heap_dumps; \
fc-cache -f
@@ -116,6 +148,10 @@ ENV VERSION_TAG=$VERSION_TAG \
PGID=1000 \
UMASK=022 \
FAT_DOCKER=true \
AIENGINE_ENABLED=true \
STIRLING_ENGINE_HOME=/opt/stirling-engine \
STIRLING_ENGINE_PORT=5001 \
STIRLING_ENGINE_WORKERS=2 \
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
+29 -21
View File
@@ -1,35 +1,43 @@
# syntax=docker/dockerfile:1.5
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca
ARG TASK_VERSION=3.52.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& ARCH=$(dpkg --print-architecture) \
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
&& dpkg -i /tmp/task.deb \
&& rm /tmp/task.deb \
&& rm -rf /var/lib/apt/lists/*
# uv resolves the venv here so its ~52MB binary stays out of the runtime image.
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca AS builder
# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`.
WORKDIR /app/engine
COPY engine/pyproject.toml engine/uv.lock engine/.env ./
COPY engine/scripts/ ./scripts/
COPY engine/pyproject.toml engine/uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --group engine
uv sync --frozen --no-dev --no-install-project --group engine
COPY engine/src/ ./src/
WORKDIR /app
COPY Taskfile.yml ./
COPY .taskfiles/ ./.taskfiles/
FROM python:3.13-slim-bookworm@sha256:00faa2debb87529f9f0764e9491d8ba400a3678976616c3bd7cb193745ac20d1 AS runtime
# Created before the COPYs so they land owned; a later chown -R duplicates the venv layer.
RUN set -eux; \
groupadd --system --gid 1000 stirling; \
useradd --system --uid 1000 --gid 1000 --home /app/engine stirling; \
mkdir -p /app/engine/data; \
chown stirling:stirling /app/engine /app/engine/data
WORKDIR /app/engine
COPY --from=builder --chown=stirling:stirling /app/engine/.venv ./.venv
# settings.py resolves ENGINE_ROOT to /app/engine, so .env must sit here.
COPY --chown=stirling:stirling engine/.env ./
COPY --chown=stirling:stirling engine/src/ ./src/
ENV PATH="/app/engine/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV STIRLING_ENGINE_WORKERS=4
# Container runs on a fixed port; skip the host-only free-port probe (its script
# is not shipped in the image). engine:run honours these.
ENV ENGINE_PORT_PROBE=false
ENV STIRLING_ENGINE_PORT=5001
# Fail closed: without a secret the document routes trust caller-supplied X-User-Id.
# Set STIRLING_ENGINE_SHARED_SECRET (the backend sends it as X-Engine-Auth), or false to opt out.
ENV STIRLING_ENGINE_REQUIRE_AUTH=true
# `stirling` resolves from the working directory.
WORKDIR /app/engine/src
USER stirling
EXPOSE 5001
CMD ["task", "engine:run"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD ["python", "-c", "import os,sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('STIRLING_ENGINE_PORT','5001'), timeout=4).status==200 else 1)"]
CMD ["sh", "-c", "exec uvicorn stirling.api.app:app --host 0.0.0.0 --port ${STIRLING_ENGINE_PORT:-5001} --workers ${STIRLING_ENGINE_WORKERS:-4}"]
+3 -2
View File
@@ -16,8 +16,9 @@ engine = [
"psycopg[binary,pool]>=3.3.4",
"pydantic>=2.13.4",
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration.
"pydantic-ai>=1.107.2,<2.0.0",
"pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0",
# Explicit extras: the `pydantic-ai` meta-package pulls all 20 providers (~230MB).
# No `voyageai` extra either; stirling.documents.voyage speaks its API directly.
"pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0",
"pydantic-settings>=2.15.0",
"python-dotenv>=1.2.2",
"sqlite-vec>=0.1.9",
+13
View File
@@ -70,6 +70,19 @@ Provider credentials (and any local overrides) go in the uncommitted
VOYAGE_API_KEY=your-key
```
### Embedding providers
`STIRLING_RAG_EMBEDDING_MODEL` is a `provider:model` string. Any OpenAI-compatible
`/v1/embeddings` endpoint (vLLM, Ollama, TEI, llama.cpp) works by pointing a base URL
at it. Note `OPENAI_BASE_URL` is global and also redirects chat completions; push
`provider`/`api_key`/`base_url` through admin AI settings to move embeddings only.
Ollama reads `OLLAMA_BASE_URL`, and omitting it fails the first embed call, not startup.
```
STIRLING_RAG_EMBEDDING_MODEL=ollama:nomic-embed-text
OLLAMA_BASE_URL=http://ollama:11434/v1
```
## Backends
**`sqlite`** - Embedded sqlite-vec. Single `.db` file, zero ops. Ideal for dev
+11 -1
View File
@@ -6,6 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider
from stirling.documents.chunker import chunk_text
from stirling.documents.store import Document
from stirling.documents.voyage import build_voyage_model
# Keep each upstream embed request under every major provider's per-call limit while
# still batching large enough that a book-sized document ingests in a reasonable number
@@ -14,6 +15,9 @@ from stirling.documents.store import Document
DEFAULT_EMBED_BATCH_SIZE = 256
VOYAGE_PROVIDER = "voyageai"
def _build_embedder(
model_name: str,
*,
@@ -23,11 +27,17 @@ def _build_embedder(
) -> Embedder:
"""Construct an :class:`Embedder`; explicit provider/api_key/base_url is the config-push path, else env form."""
if not provider and not api_key and not base_url:
# Env form is a "provider:model" string; Voyage needs the SDK-free adapter.
env_provider, sep, env_model = model_name.partition(":")
if sep and env_provider.lower() == VOYAGE_PROVIDER:
return Embedder(build_voyage_model(env_model))
return Embedder(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name in ("voyageai", "openai"):
if provider_name == VOYAGE_PROVIDER:
return Embedder(build_voyage_model(model_name, api_key=key, base_url=base_url or None))
if provider_name == "openai":
return Embedder(f"{provider_name}:{model_name}")
if provider_name in ("ollama", "custom"):
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
@@ -2,9 +2,11 @@ from __future__ import annotations
import asyncio
import json
import logging
import math
import re
import sqlite3
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -19,11 +21,31 @@ _READ_PERMISSION = "read"
# write lock. With multiple worker processes opening the same file, they collide on
# startup schema-init and get "database is locked". Wait for the lock instead.
_BUSY_TIMEOUT_MS = 5000
# journal_mode answers SQLITE_BUSY without consulting the busy handler, so it needs its own retry.
_WAL_SWITCH_ATTEMPTS = 10
_WAL_RETRY_DELAY_S = 0.1
# sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS``
# so lexicographic comparison against ``datetime('now')`` matches chronological order.
_SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
logger = logging.getLogger(__name__)
def _enable_wal(conn: sqlite3.Connection) -> None:
"""Switch the connection to WAL, tolerating workers racing to do the same."""
for _ in range(_WAL_SWITCH_ATTEMPTS):
try:
conn.execute("PRAGMA journal_mode=WAL")
return
except sqlite3.OperationalError:
row = conn.execute("PRAGMA journal_mode").fetchone()
if row is not None and str(row[0]).lower() == "wal":
return # another worker won the race and already switched it
time.sleep(_WAL_RETRY_DELAY_S)
logger.warning("Could not switch the document store to WAL; continuing on the default journal mode.")
def _to_sqlite_utc(dt: datetime | None) -> str | None:
if dt is None:
return None
@@ -58,7 +80,7 @@ class SqliteVecStore(DocumentStore):
if self._db_path is not None:
# Set before the WAL switch below: that pragma also takes the lock.
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA journal_mode=WAL")
_enable_wal(conn)
self._conn = conn
self._lock = asyncio.Lock()
+57
View File
@@ -0,0 +1,57 @@
"""VoyageAI embeddings over its OpenAI-shaped REST API.
The `voyageai` SDK pulls PIL, numpy, tokenizers and langchain at import for multimodal,
chunking and local-inference features the engine never uses (~207MB).
"""
from __future__ import annotations
import os
from collections.abc import Sequence
from pydantic_ai.embeddings import EmbeddingResult, EmbeddingSettings
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
from pydantic_ai.embeddings.result import EmbedInputType
from pydantic_ai.providers.openai import OpenAIProvider
VOYAGE_BASE_URL = "https://api.voyageai.com/v1"
VOYAGE_API_KEY_ENV = "VOYAGE_API_KEY"
# Keeps a keyless engine bootable, and stops the client falling back to OPENAI_API_KEY.
_MISSING_API_KEY = "stirling-voyage-api-key-not-configured"
class VoyageEmbeddingModel(OpenAIEmbeddingModel):
"""Voyage embeddings spoken over the OpenAI wire format."""
async def embed(
self,
inputs: str | Sequence[str],
*,
input_type: EmbedInputType,
settings: EmbeddingSettings | None = None,
) -> EmbeddingResult:
"""Embed `inputs`, forwarding Voyage's `input_type` that the OpenAI model drops."""
if self._client.api_key == _MISSING_API_KEY:
raise ValueError(
f"VoyageAI embeddings need an API key: set {VOYAGE_API_KEY_ENV} or push one via admin AI settings."
)
merged: EmbeddingSettings = {**(settings or {})}
# extra_body is declared `object`, so narrow rather than assume a mapping.
current = merged.get("extra_body")
extra_body: dict[str, object] = dict(current) if isinstance(current, dict) else {}
extra_body.setdefault("input_type", input_type)
merged["extra_body"] = extra_body
return await super().embed(inputs, input_type=input_type, settings=merged)
def build_voyage_model(
model_name: str,
*,
api_key: str | None = None,
base_url: str | None = None,
) -> VoyageEmbeddingModel:
"""Build a Voyage embedding model; a missing key only fails once an embed is attempted."""
key = api_key or os.environ.get(VOYAGE_API_KEY_ENV) or _MISSING_API_KEY
provider = OpenAIProvider(base_url=base_url or VOYAGE_BASE_URL, api_key=key)
return VoyageEmbeddingModel(model_name, provider=provider)
+18
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
import pytest
from stirling.contracts import PageText
@@ -647,3 +649,19 @@ def _dummy_tool_def() -> object:
"""Sentinel passed to ``_prepare_search_knowledge``. The callback only inspects
``_search_count``; it doesn't read anything off the tool_def or context."""
return object()
# concurrent store startup
def test_many_stores_open_the_same_file_without_locking_out(tmp_path: Path) -> None:
"""Workers all construct a store against one file on boot; the WAL switch races."""
import concurrent.futures
db_path = tmp_path / "rag.db"
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
stores = list(pool.map(lambda _: SqliteVecStore(db_path), range(8)))
assert len(stores) == 8
mode = stores[0]._conn.execute("PRAGMA journal_mode").fetchone()[0]
assert str(mode).lower() == "wal"
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
import json
import math
import os
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
import httpx
import pytest
from pydantic_ai import Embedder
from pydantic_ai.providers.openai import OpenAIProvider
from stirling.documents.embedder import _build_embedder
from stirling.documents.voyage import VOYAGE_BASE_URL, VoyageEmbeddingModel, build_voyage_model
# Voyage's documented response body: OpenAI's shape, minus prompt_tokens.
VOYAGE_RESPONSE = {
"object": "list",
"data": [
{"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0},
{"object": "embedding", "embedding": [0.4, 0.5, 0.6], "index": 1},
],
"model": "voyage-4",
"usage": {"total_tokens": 7},
}
@dataclass
class SentRequest:
url: str
auth: str | None
body: dict[str, Any]
def _recording_model(sent: list[SentRequest]) -> VoyageEmbeddingModel:
def handler(request: httpx.Request) -> httpx.Response:
sent.append(
SentRequest(
url=str(request.url),
auth=request.headers.get("authorization"),
body=json.loads(request.content),
)
)
return httpx.Response(200, json=VOYAGE_RESPONSE)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
provider = OpenAIProvider(base_url=VOYAGE_BASE_URL, api_key="pa-test-key", http_client=client)
return VoyageEmbeddingModel("voyage-4", provider=provider)
@pytest.mark.anyio
async def test_posts_to_voyage_embeddings_endpoint_with_bearer_auth() -> None:
sent: list[SentRequest] = []
await Embedder(_recording_model(sent)).embed_documents(["alpha", "beta"])
assert sent[0].url == f"{VOYAGE_BASE_URL}/embeddings"
assert sent[0].auth == "Bearer pa-test-key"
assert sent[0].body["model"] == "voyage-4"
assert sent[0].body["input"] == ["alpha", "beta"]
@pytest.mark.anyio
@pytest.mark.parametrize(
("call", "expected"),
[("embed_query", "query"), ("embed_documents", "document")],
)
async def test_forwards_voyage_input_type(call: str, expected: str) -> None:
"""The stock OpenAI model drops this field; Voyage needs it."""
sent: list[SentRequest] = []
embedder = Embedder(_recording_model(sent))
await getattr(embedder, call)(["text"])
assert sent[0].body["input_type"] == expected
@pytest.mark.anyio
async def test_caller_settings_win_over_the_default_input_type() -> None:
sent: list[SentRequest] = []
await Embedder(_recording_model(sent)).embed_documents(
["text"], settings={"extra_body": {"input_type": "query", "output_dimension": 512}}
)
assert sent[0].body["input_type"] == "query"
assert sent[0].body["output_dimension"] == 512
@pytest.mark.anyio
async def test_parses_voyage_response_into_embeddings() -> None:
result = await Embedder(_recording_model([])).embed_documents(["alpha", "beta"])
assert result.embeddings == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
def test_build_voyage_model_reads_the_api_key_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
assert build_voyage_model("voyage-4").model_name == "voyage-4"
def test_build_voyage_model_without_a_key_still_constructs(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
assert build_voyage_model("voyage-4").model_name == "voyage-4"
@pytest.mark.anyio
async def test_embedding_without_a_key_fails_with_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
model = build_voyage_model("voyage-4")
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
await Embedder(model).embed_documents(["text"])
@pytest.mark.anyio
async def test_an_openai_key_is_never_sent_to_voyage(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
model = build_voyage_model("voyage-4")
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
await Embedder(model).embed_documents(["text"])
def test_env_form_routes_voyageai_through_the_adapter(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
embedder = _build_embedder("voyageai:voyage-4")
assert isinstance(embedder.model, VoyageEmbeddingModel)
assert embedder.model.model_name == "voyage-4"
def test_config_push_form_routes_voyageai_through_the_adapter() -> None:
embedder = _build_embedder("voyage-4", provider="voyageai", api_key="pa-pushed-key")
assert isinstance(embedder.model, VoyageEmbeddingModel)
def test_the_voyageai_sdk_is_not_installed() -> None:
"""Guards the ~207MB the SDK would add back."""
with pytest.raises(ImportError):
__import__("voyageai")
# Live checks, skipped unless VOYAGE_API_KEY is set so CI stays offline.
live_only = pytest.mark.skipif(
not os.environ.get("VOYAGE_API_KEY"),
reason="set VOYAGE_API_KEY to run the live VoyageAI checks",
)
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
dot = sum(x * y for x, y in zip(a, b, strict=True))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
@live_only
@pytest.mark.anyio
async def test_live_voyage_returns_usable_embeddings() -> None:
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(["alpha", "beta"])
assert len(result.embeddings) == 2
assert len(result.embeddings[0]) == 1024
@live_only
@pytest.mark.anyio
async def test_live_voyage_honours_input_type_server_side() -> None:
"""Voyage embeds the same text differently per input_type."""
embedder = Embedder(build_voyage_model("voyage-4"))
text = "How do I combine two PDFs?"
as_query = await embedder.embed_query(text)
as_document = await embedder.embed_documents([text])
assert _cosine(as_query.embeddings[0], as_document.embeddings[0]) < 0.999
@live_only
@pytest.mark.anyio
async def test_live_voyage_ranks_the_relevant_document_first() -> None:
embedder = Embedder(build_voyage_model("voyage-4"))
docs = await embedder.embed_documents(
["Stirling PDF merges and splits PDF files.", "The capital of France is Paris."]
)
query = await embedder.embed_query("How do I combine two PDFs?")
relevant = _cosine(query.embeddings[0], docs.embeddings[0])
irrelevant = _cosine(query.embeddings[0], docs.embeddings[1])
assert relevant > irrelevant
@live_only
@pytest.mark.anyio
async def test_live_voyage_accepts_voyage_only_parameters() -> None:
"""output_dimension has no OpenAI equivalent, so this proves extra_body lands."""
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(
["dimension test"], settings={"extra_body": {"output_dimension": 256}}
)
assert len(result.embeddings[0]) == 256
+2 -1777
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -890,6 +890,11 @@ log "Setting permissions..."
mkdir -p /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps /logs /configs /configs/heap_dumps /configs/cache /customFiles /pipeline /storage || true
CHOWN_PATHS=("$HOME" "/logs" "/scripts" "/configs" "/customFiles" "/pipeline" "/storage" "/tmp/stirling-pdf" "/app.jar")
[ -d /usr/share/fonts/truetype ] && CHOWN_PATHS+=("/usr/share/fonts/truetype")
# Chowned here rather than at build time so it follows PUID/PGID remapping.
if [ -d "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}" ]; then
mkdir -p "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data" || true
CHOWN_PATHS+=("${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data")
fi
CHOWN_OK=true
for p in "${CHOWN_PATHS[@]}"; do
if [ -e "$p" ]; then
@@ -962,6 +967,38 @@ else
JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher")
fi
# ---------- AI engine ----------
# Only the fat image ships it. The backend already defaults to http://localhost:5001.
STIRLING_ENGINE_HOME="${STIRLING_ENGINE_HOME:-/opt/stirling-engine}"
ENGINE_PID=""
if [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ] && [ "${AIENGINE_ENABLED:-true}" = "false" ]; then
log "AI engine bundled but AIENGINE_ENABLED=false; not starting it."
elif [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ]; then
log "Starting bundled AI engine on port ${STIRLING_ENGINE_PORT:-5001}..."
ENGINE_CMD=(
"$STIRLING_ENGINE_HOME/.venv/bin/python" -m uvicorn
stirling.api.app:app
--host 127.0.0.1
--port "${STIRLING_ENGINE_PORT:-5001}"
--workers "${STIRLING_ENGINE_WORKERS:-2}"
--app-dir "$STIRLING_ENGINE_HOME/src"
)
# init.sh exports PYTHONPATH for unoserver's 3.12 venv; inheriting it breaks the 3.13 engine.
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
env -u PYTHONPATH "${ENGINE_CMD[@]}" &
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
env -u PYTHONPATH \
HOME="$(getent passwd "$RUNTIME_USER" | cut -d: -f6)" \
USER="$RUNTIME_USER" \
LOGNAME="$RUNTIME_USER" \
setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "${ENGINE_CMD[@]}" &
else
env -u PYTHONPATH "${ENGINE_CMD[@]}" &
fi
ENGINE_PID=$!
log "AI engine started (PID $ENGINE_PID)"
fi
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
"${JAVA_CMD[@]}" &
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
@@ -1074,6 +1111,12 @@ fi
wait "$JAVA_PID" || true
exit_code=$?
if [ -n "$ENGINE_PID" ] && kill -0 "$ENGINE_PID" 2>/dev/null; then
log "Stopping AI engine (PID $ENGINE_PID)..."
kill "$ENGINE_PID" 2>/dev/null || true
wait "$ENGINE_PID" 2>/dev/null || true
fi
case "$exit_code" in
0) log "Stirling PDF exited normally." ;;
137) log "Stirling PDF was OOM-killed (exit 137). Check container memory limits." ;;