Merge pull request #418 from ggozad/feat/ingester-postgres-queue

Support a database server for the ingester queue (SQLite + Postgres)
This commit is contained in:
Yiorgis Gozadinos 2026-06-04 14:07:14 +03:00 committed by GitHub
commit c688871b61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1173 additions and 696 deletions

View file

@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Added
- `ingester.queue.dburi`: a SQLAlchemy async URL (e.g. `postgresql+asyncpg://user:pw@host/db`) points the ingester queue at a database server. SQLite remains the default when unset. The Postgres path claims jobs with `FOR UPDATE SKIP LOCKED`, so multiple ingester processes can share one queue.
## [0.53.0] - 2026-06-03
### Added

View file

@ -2,8 +2,9 @@
The ingester is a long-running service that watches sources for
changes and feeds documents into haiku.rag's LanceDB. It runs as a
separate process (`haiku-ingester serve`), owns its own SQLite job
queue, and exposes a small HTTP control plane for operations.
separate process (`haiku-ingester serve`), owns its own job queue
(SQLite by default, or a database server), and exposes a small HTTP
control plane for operations.
Use the ingester when:
@ -37,8 +38,8 @@ pip install 'haiku.rag-slim[ingester]'
pip install 'haiku.rag[ingester]'
```
That pulls `fastapi`, `uvicorn`, `aiosqlite`, and the `[s3]` extra.
The production binary is `haiku-ingester`.
That pulls `fastapi`, `uvicorn`, `sqlalchemy`, `aiosqlite`, `asyncpg`, and
the `[s3]` extra. The production binary is `haiku-ingester`.
## Configure sources
@ -372,6 +373,33 @@ The reaper deletes terminal rows whose `completed_at` is older than the window
on its `reaper_interval_s` cadence. Set `retention_days: null` to keep all
terminal rows.
#### Using a database server
If you already run a database server, point the queue at it with
`ingester.queue.dburi`, a SQLAlchemy async URL. SQLite is used when `dburi` is
unset.
```yaml
ingester:
queue:
dburi: postgresql+asyncpg://haiku:secret@db:5432/haiku_rag
```
Postgres (`postgresql+asyncpg://`) is supported alongside the default SQLite.
The `asyncpg` driver ships with the `[ingester]` extra. `dburi` overrides
`path`, and the `--queue` CLI flag is ignored while it is set. Create the schema
the same way as for SQLite:
```bash
haiku-ingester queue init
```
Workers claim jobs with `FOR UPDATE SKIP LOCKED`, so several `haiku-ingester
serve` processes can share one Postgres queue and scale out horizontally. One
caveat: idle workers wake on new work instantly only within their own process.
Workers in other processes pick up enqueued jobs on their next
`poll_idle_interval_s` tick rather than immediately.
### Logs
The service logs via Python `logging` to stderr through a Rich handler.

View file

@ -149,6 +149,49 @@ before bringing the stack up:
echo "INGESTER_TOKEN=$(openssl rand -hex 32)" >> .env
```
### Using a database server for the queue
By default the queue is a SQLite file on the `./data` volume. To run it on
Postgres instead, point `ingester.queue.dburi` at the server in
`haiku.rag.yaml`:
```yaml
ingester:
queue:
dburi: postgresql+asyncpg://haiku:secret@postgres:5432/haiku_rag
```
Add a Postgres service and wire the ingester to it with a
`docker-compose.override.yml` (auto-loaded by Compose):
```yaml
services:
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=haiku
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=haiku_rag
volumes:
- ./pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "haiku", "-d", "haiku_rag"]
interval: 5s
timeout: 3s
retries: 12
restart: unless-stopped
haiku-ingester:
depends_on:
postgres:
condition: service_healthy
```
Workers claim jobs with `FOR UPDATE SKIP LOCKED`, so the ingester can run as
several replicas against one Postgres queue to scale ingestion out. The
LanceDB single-writer rule still holds, so multiple writers need LanceDB Cloud
or another shared store rather than the local file volume.
## Documentation
- [Remote Processing](https://ggozad.github.io/haiku.rag/remote-processing/)

View file

@ -11,6 +11,8 @@ services:
- "5001:5001" # exposed on the host for ad-hoc debugging (5001)
environment:
- 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
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
@ -27,6 +29,8 @@ services:
- "5002:5001" # host 5002 → container 5001 (debug only)
environment:
- 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
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]

View file

@ -260,11 +260,17 @@ class EvaluationsConfig(BaseModel):
class QueueConfig(BaseModel):
"""SQLite queue for the production ingester."""
"""Job queue for the production ingester. Defaults to a filesystem SQLite
file; set `dburi` to point it at a database server instead."""
path: Path = Field(
default_factory=lambda: get_default_data_dir() / "ingester.db",
description="Location of the ingester's SQLite queue file.",
description="SQLite queue file. Used when dburi is unset.",
)
dburi: str | None = Field(
default=None,
description="SQLAlchemy async URL for the queue, e.g. "
"postgresql+asyncpg://user:pw@host/db. Overrides path when set.",
)
retention_days: int | None = Field(
default=30,

View file

@ -16,7 +16,7 @@ from haiku.rag.ingester.workers.pool import WorkerPool
from haiku.rag.ingester.workers.retry import RetryPolicy
if TYPE_CHECKING:
import aiosqlite
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
@ -33,14 +33,14 @@ class BatchReport(BaseModel):
class IngesterApp:
"""Top-level lifecycle for the production ingester.
Owns: SQLite queue connection, JobRepo/SyncStateRepo, PollerManager,
Owns: queue engine, JobRepo/SyncStateRepo, PollerManager,
WorkerPool, and a HaikuRAG client for the worker pool to ingest through.
"""
def __init__(self, *, config: AppConfig, db_path: Path):
self._config = config
self._db_path = db_path
self._queue_conn: aiosqlite.Connection | None = None
self._engine: AsyncEngine | None = None
self._jobs: JobRepo | None = None
self._sync: SyncStateRepo | None = None
self._pool: WorkerPool | None = None
@ -48,22 +48,18 @@ class IngesterApp:
@asynccontextmanager
async def _resources(self):
"""Open the queue connection and construct the repos, client, pollers
"""Open the queue engine and construct the repos, client, pollers
and worker pool. Yields with everything built but nothing started
callers own the start/stop lifecycle. Closes the client and queue
connection on exit."""
engine on exit."""
from haiku.rag.client import HaikuRAG
from haiku.rag.converters import get_converter
ingester_cfg = self._config.ingester
self._queue_conn = await open_queue(ingester_cfg.queue.path)
self._engine = await open_queue(ingester_cfg.queue)
try:
# Single lock shared by both repos so cross-repo calls on the
# same connection (e.g. worker's mark_succeeded then sync.upsert)
# serialize at the cursor/commit boundary.
queue_lock = asyncio.Lock()
self._jobs = JobRepo(self._queue_conn, lock=queue_lock)
self._sync = SyncStateRepo(self._queue_conn, lock=queue_lock)
self._jobs = JobRepo(self._engine)
self._sync = SyncStateRepo(self._engine)
supported_extensions = get_converter(self._config).supported_extensions
retry = RetryPolicy(
@ -107,13 +103,13 @@ class IngesterApp:
)
yield
finally:
# Close the queue connection unconditionally. aiosqlite runs the
# underlying sqlite3 in a background thread; leaving it open holds
# the event loop alive and blocks process exit on early failures
# (e.g. HaikuRAG raising MigrationRequiredError).
if self._queue_conn is not None:
await self._queue_conn.close()
self._queue_conn = None
# Dispose the engine unconditionally. aiosqlite runs the underlying
# sqlite3 in a background thread; leaving the pool open holds the
# event loop alive and blocks process exit on early failures (e.g.
# HaikuRAG raising MigrationRequiredError).
if self._engine is not None:
await self._engine.dispose()
self._engine = None
async def _stop_pool(self) -> None:
"""Stop the worker pool, honouring the shutdown grace, then drain any

View file

@ -4,11 +4,13 @@ from pathlib import Path
import typer
from dotenv import find_dotenv, load_dotenv
from sqlalchemy import make_url
load_dotenv(find_dotenv(usecwd=True))
from haiku.rag.config import ( # noqa: E402
AppConfig,
QueueConfig,
find_config_file,
get_config,
load_yaml_config,
@ -60,7 +62,7 @@ def cli() -> None:
queue_cli = typer.Typer(
name="queue",
no_args_is_help=True,
help="Operate the ingester's SQLite job queue.",
help="Operate the ingester's job queue.",
)
_cli.add_typer(queue_cli)
@ -79,13 +81,26 @@ def _load_config_with_override(config_path: Path | None) -> AppConfig:
return get_config()
def _resolve_queue_path(config: AppConfig, override: Path | None) -> Path:
return Path(override).expanduser() if override else config.ingester.queue.path
def _resolve_queue_config(config: AppConfig, override: Path | None) -> QueueConfig:
"""The configured queue, with `--queue` applied as a path override. The
override is ignored when a dburi is set the queue lives in a server."""
queue = config.ingester.queue
if override is not None and queue.dburi is None:
return queue.model_copy(update={"path": Path(override).expanduser()})
return queue
async def _ensure_schema(path: Path) -> None:
conn = await open_queue(path)
await conn.close()
def _queue_target(queue: QueueConfig) -> str:
"""A display string for the queue location, with any dburi password
masked so it isn't echoed to the terminal or logs."""
if queue.dburi:
return make_url(queue.dburi).render_as_string(hide_password=True)
return str(queue.path)
async def _ensure_schema(queue: QueueConfig) -> None:
engine = await open_queue(queue)
await engine.dispose()
@queue_cli.command("init")
@ -98,9 +113,9 @@ def queue_init(
),
) -> None:
"""Create the queue DB and apply the current schema. Idempotent."""
path = _resolve_queue_path(get_config(), queue)
asyncio.run(_ensure_schema(path))
typer.echo(f"Queue initialized at {path}")
queue_config = _resolve_queue_config(get_config(), queue)
asyncio.run(_ensure_schema(queue_config))
typer.echo(f"Queue initialized at {_queue_target(queue_config)}")
@queue_cli.command("migrate")
@ -113,9 +128,9 @@ def queue_migrate(
),
) -> None:
"""Apply any pending schema migrations to an existing queue DB. Idempotent."""
path = _resolve_queue_path(get_config(), queue)
asyncio.run(_ensure_schema(path))
typer.echo(f"Queue at {path} is up to date")
queue_config = _resolve_queue_config(get_config(), queue)
asyncio.run(_ensure_schema(queue_config))
typer.echo(f"Queue at {_queue_target(queue_config)} is up to date")
def _resolve_db_path(config: AppConfig, override: Path | None) -> Path:

View file

@ -0,0 +1,95 @@
import sqlalchemy as sa
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine
SCHEMA_VERSION = 1
metadata = sa.MetaData()
jobs = sa.Table(
"jobs",
metadata,
sa.Column("id", sa.Text, primary_key=True),
sa.Column("source_id", sa.Text, nullable=False),
sa.Column("uri", sa.Text, nullable=False),
sa.Column("op", sa.Text, nullable=False),
sa.Column("content_hash", sa.Text),
sa.Column("revision", sa.Text),
sa.Column("status", sa.Text, nullable=False),
sa.Column("attempts", sa.Integer, nullable=False, server_default=sa.text("0")),
sa.Column("max_attempts", sa.Integer, nullable=False, server_default=sa.text("5")),
sa.Column("last_error", sa.Text),
sa.Column("extra", sa.Text),
sa.Column("enqueued_at", sa.Text, nullable=False),
sa.Column("scheduled_at", sa.Text, nullable=False),
sa.Column("claimed_at", sa.Text),
sa.Column("claimed_by", sa.Text),
sa.Column("completed_at", sa.Text),
)
# A (source_id, uri) pair can only have one live job (queued or claimed) at a
# time, regardless of op. Live UPSERT and DELETE for the same URI can't both
# exist — preventing a DELETE worker from removing a document a sibling UPSERT
# just ingested. Once succeeded or dead, the row no longer satisfies the WHERE
# clause and a re-enqueue is allowed.
_live = jobs.c.status.in_(["queued", "claimed"])
sa.Index(
"uq_jobs_live",
jobs.c.source_id,
jobs.c.uri,
unique=True,
sqlite_where=_live,
postgresql_where=_live,
)
_queued = jobs.c.status == "queued"
sa.Index(
"idx_jobs_claimable",
jobs.c.scheduled_at,
sqlite_where=_queued,
postgresql_where=_queued,
)
_succeeded = jobs.c.status == "succeeded"
sa.Index(
"idx_jobs_succeeded_completed",
jobs.c.completed_at,
sqlite_where=_succeeded,
postgresql_where=_succeeded,
)
sync_state = sa.Table(
"sync_state",
metadata,
sa.Column("source_id", sa.Text, primary_key=True),
sa.Column("uri", sa.Text, primary_key=True),
sa.Column("revision", sa.Text),
sa.Column("content_hash", sa.Text),
sa.Column("last_seen_at", sa.Text, nullable=False),
sa.Column("last_ingested_at", sa.Text),
)
schema_version = sa.Table(
"schema_version",
metadata,
sa.Column("version", sa.Integer, primary_key=True),
)
def install_sqlite_pragmas(engine: AsyncEngine) -> None:
"""Register a connect listener that sets the per-connection pragmas the
queue relies on. SQLite-only Postgres has no equivalent and asyncpg
rejects PRAGMA, so the listener is never attached for it."""
if engine.dialect.name != "sqlite":
return
@event.listens_for(engine.sync_engine, "connect")
def _set_pragmas(dbapi_conn, _record):
cursor = dbapi_conn.cursor()
try:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=30000")
finally:
cursor.close()

View file

@ -1,56 +1,64 @@
from pathlib import Path
import sqlalchemy as sa
from sqlalchemy.engine import URL, make_url
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
import aiosqlite
from haiku.rag.config.models import QueueConfig
from haiku.rag.ingester.queue.db import (
SCHEMA_VERSION,
install_sqlite_pragmas,
metadata,
schema_version,
)
from haiku.rag.ingester.queue.schema import ALL_DDL, SCHEMA_VERSION
__all__ = ["SCHEMA_VERSION", "apply_migrations", "open_queue"]
__all__ = ["SCHEMA_VERSION", "apply_migrations", "make_engine", "open_queue"]
async def _exec(conn: aiosqlite.Connection, sql: str, *params) -> None:
"""Execute a statement and finalize the cursor. aiosqlite cursors stay
attached until closed, blocking subsequent commits with 'SQL statements
in progress'."""
async with conn.execute(sql, params):
pass
async def apply_migrations(conn: aiosqlite.Connection) -> int:
"""Idempotently create tables/indexes/views and pin schema_version.
Returns the schema version after the call. Safe to call on a fresh DB
or on one already at the latest version.
"""
await _exec(conn, "PRAGMA journal_mode=WAL")
await _exec(conn, "PRAGMA synchronous=NORMAL")
await _exec(conn, "PRAGMA foreign_keys=ON")
for stmt in ALL_DDL:
await _exec(conn, stmt)
async with conn.execute("SELECT version FROM schema_version LIMIT 1") as cursor:
row = await cursor.fetchone()
if row is None:
await _exec(
conn, "INSERT INTO schema_version (version) VALUES (?)", SCHEMA_VERSION
)
def make_engine(config: QueueConfig) -> AsyncEngine:
"""Build the queue's AsyncEngine from config. Uses `dburi` when set,
otherwise a `sqlite+aiosqlite` URL pointing at the resolved `path`
(creating the parent directory). SQLite is capped to a single pooled
connection so the claim stays atomic without row locks; Postgres uses
pool_pre_ping so a long-running ingester survives a DB restart or idle
connection drop."""
if config.dburi:
url = make_url(config.dburi)
else:
current = row[0]
if current < SCHEMA_VERSION: # pragma: no cover - no migrations yet
# No diff migrations exist yet — future versions add UPDATE/ALTER
# statements between here and the version bump.
await _exec(conn, "UPDATE schema_version SET version = ?", SCHEMA_VERSION)
path = config.path.expanduser().resolve()
path.parent.mkdir(parents=True, exist_ok=True)
# URL.create keeps the path literal — building a string and reparsing
# would treat `?`/`#` in the filename as query/fragment.
url = URL.create("sqlite+aiosqlite", database=str(path))
await conn.commit()
if url.get_backend_name() == "sqlite":
engine = create_async_engine(url, pool_size=1, max_overflow=0)
else:
engine = create_async_engine(url, pool_pre_ping=True)
install_sqlite_pragmas(engine)
return engine
async def apply_migrations(engine: AsyncEngine) -> int:
"""Idempotently create tables/indexes and pin schema_version.
Returns the schema version after the call. Safe on a fresh DB or one
already at the latest version.
"""
async with engine.begin() as conn:
await conn.run_sync(metadata.create_all)
current = (
await conn.execute(sa.select(schema_version.c.version).limit(1))
).scalar_one_or_none()
if current is None:
await conn.execute(sa.insert(schema_version).values(version=SCHEMA_VERSION))
elif current < SCHEMA_VERSION: # pragma: no cover - no migrations yet
# No diff migrations exist yet — future versions add ALTER/UPDATE
# statements between create_all and the version bump.
await conn.execute(sa.update(schema_version).values(version=SCHEMA_VERSION))
return SCHEMA_VERSION
async def open_queue(path: str | Path) -> aiosqlite.Connection:
"""Open the queue database at `path`, creating it (and the parent dir)
if needed, and ensuring the schema is up-to-date."""
db_path = Path(path).expanduser().resolve()
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = await aiosqlite.connect(str(db_path))
conn.row_factory = aiosqlite.Row
await apply_migrations(conn)
return conn
async def open_queue(config: QueueConfig) -> AsyncEngine:
"""Build the queue engine and ensure its schema is up to date."""
engine = make_engine(config)
await apply_migrations(engine)
return engine

View file

@ -1,10 +1,15 @@
import asyncio
import json
import uuid
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
import aiosqlite
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects import sqlite as sqlite_dialect
from sqlalchemy.ext.asyncio import AsyncEngine
from haiku.rag.ingester.queue.db import jobs, sync_state
from haiku.rag.ingester.queue.models import (
Job,
JobOp,
@ -22,7 +27,19 @@ def _parse_dt(value: str | None) -> datetime | None:
return datetime.fromisoformat(value) if value else None
def _row_to_job(row: aiosqlite.Row) -> Job:
def _insert(table: sa.Table, dialect: str):
"""Dialect-specific INSERT exposing on_conflict_* (and `.excluded`)."""
if dialect == "postgresql":
return postgresql.insert(table)
return sqlite_dialect.insert(table)
def _attempts_minus_one() -> sa.ColumnElement[int]:
"""attempts - 1, floored at 0. Renders identically on both dialects."""
return sa.case((jobs.c.attempts - 1 < 0, 0), else_=jobs.c.attempts - 1)
def _row_to_job(row: Mapping) -> Job:
extra_text = row["extra"]
return Job(
id=row["id"],
@ -44,7 +61,7 @@ def _row_to_job(row: aiosqlite.Row) -> Job:
)
def _row_to_sync_state(row: aiosqlite.Row) -> SyncStateRow:
def _row_to_sync_state(row: Mapping) -> SyncStateRow:
return SyncStateRow(
source_id=row["source_id"],
uri=row["uri"],
@ -56,24 +73,12 @@ def _row_to_sync_state(row: aiosqlite.Row) -> SyncStateRow:
class JobRepo:
def __init__(
self,
conn: aiosqlite.Connection,
lock: asyncio.Lock | None = None,
):
# Row access by name in helpers below.
conn.row_factory = aiosqlite.Row
self._conn = conn
# Serialize repo calls on the shared connection so cursors from one
# coroutine don't sit "in progress" when another tries to commit.
# aiosqlite executes statements on a single worker thread, but
# individual cursors don't finalize until closed or GC'd — SQLite
# then refuses commit() with "SQL statements in progress". When
# JobRepo and SyncStateRepo share the same connection, callers must
# pass the same lock instance so cross-repo calls also serialize.
self._lock = lock or asyncio.Lock()
# Notified after a successful enqueue so workers can wake
# immediately instead of polling on a fixed sleep interval.
def __init__(self, engine: AsyncEngine):
self._engine = engine
self._dialect = engine.dialect.name
# Notified after a successful enqueue so workers in this process wake
# immediately instead of polling on a fixed sleep interval. Workers in
# other processes (a shared Postgres queue) fall back to polling.
self.job_available = asyncio.Condition()
async def enqueue(
@ -88,37 +93,33 @@ class JobRepo:
extra: dict | None = None,
) -> Job | None:
"""Enqueue an upsert/delete job. Returns the inserted Job, or None if a
live (queued/claimed) job already exists for the same (source_id, uri,
op). The partial unique index enforces atomicity."""
live (queued/claimed) job already exists for the same (source_id, uri).
The partial unique index enforces atomicity."""
job_id = str(uuid.uuid4())
now = _utcnow_iso()
extra_json = json.dumps(extra) if extra is not None else None
async with self._lock:
async with self._conn.execute(
"""
INSERT INTO jobs (
id, source_id, uri, op, content_hash, revision, status,
attempts, max_attempts, last_error, extra,
enqueued_at, scheduled_at
) VALUES (?, ?, ?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, ?)
ON CONFLICT DO NOTHING
RETURNING *
""",
(
job_id,
source_id,
uri,
op.value,
content_hash,
revision,
max_attempts,
extra_json,
now,
now,
),
) as cursor:
row = await cursor.fetchone()
await self._conn.commit()
stmt = (
_insert(jobs, self._dialect)
.values(
id=job_id,
source_id=source_id,
uri=uri,
op=op.value,
content_hash=content_hash,
revision=revision,
status="queued",
attempts=0,
max_attempts=max_attempts,
last_error=None,
extra=extra_json,
enqueued_at=now,
scheduled_at=now,
)
.on_conflict_do_nothing()
.returning(*jobs.c)
)
async with self._engine.begin() as conn:
row = (await conn.execute(stmt)).mappings().one_or_none()
if row is not None:
async with self.job_available:
self.job_available.notify_all()
@ -126,36 +127,42 @@ class JobRepo:
async def claim_next(self, worker_id: str) -> Job | None:
"""Atomically claim the oldest queued job whose scheduled_at <= now.
Implemented as a single UPDATE ... RETURNING no SELECT/UPDATE race."""
A single `UPDATE ... WHERE id = (SELECT ... LIMIT 1) RETURNING` keeps
the claim atomic across connections: on Postgres the subquery adds
`FOR UPDATE SKIP LOCKED`; on SQLite the whole statement runs under one
write lock, so a racing connection re-evaluates the subquery against
the committed state and finds the row already claimed."""
now = _utcnow_iso()
async with self._lock:
async with self._conn.execute(
"""
UPDATE jobs
SET status = 'claimed',
claimed_at = ?,
claimed_by = ?,
attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE status = 'queued' AND scheduled_at <= ?
ORDER BY scheduled_at
LIMIT 1
)
RETURNING *
""",
(now, worker_id, now),
) as cursor:
row = await cursor.fetchone()
await self._conn.commit()
candidate = (
sa.select(jobs.c.id)
.where(jobs.c.status == "queued", jobs.c.scheduled_at <= now)
.order_by(jobs.c.scheduled_at, jobs.c.id)
.limit(1)
.with_for_update(skip_locked=True)
.scalar_subquery()
)
claim = (
sa.update(jobs)
.where(jobs.c.id == candidate)
.values(
status="claimed",
claimed_at=now,
claimed_by=worker_id,
attempts=jobs.c.attempts + 1,
)
.returning(*jobs.c)
)
async with self._engine.begin() as conn:
row = (await conn.execute(claim)).mappings().one_or_none()
return _row_to_job(row) if row else None
async def get_job(self, job_id: str) -> Job | None:
async with self._lock:
async with self._conn.execute(
"SELECT * FROM jobs WHERE id = ?", (job_id,)
) as cursor:
row = await cursor.fetchone()
async with self._engine.connect() as conn:
row = (
(await conn.execute(sa.select(jobs).where(jobs.c.id == job_id)))
.mappings()
.one_or_none()
)
return _row_to_job(row) if row else None
async def mark_succeeded(self, job_id: str, claimed_by: str) -> bool:
@ -163,27 +170,35 @@ class JobRepo:
`status='claimed' AND claimed_by=?` so a reaper-resurrected job
picked up by a different worker isn't clobbered by the original
slow worker. Returns True when the row was updated."""
async with self._lock:
async with self._conn.execute(
"UPDATE jobs SET status='succeeded', completed_at=? "
"WHERE id=? AND status='claimed' AND claimed_by=? RETURNING id",
(_utcnow_iso(), job_id, claimed_by),
) as cursor:
row = await cursor.fetchone()
await self._conn.commit()
stmt = (
sa.update(jobs)
.where(
jobs.c.id == job_id,
jobs.c.status == "claimed",
jobs.c.claimed_by == claimed_by,
)
.values(status="succeeded", completed_at=_utcnow_iso())
.returning(jobs.c.id)
)
async with self._engine.begin() as conn:
row = (await conn.execute(stmt)).first()
return row is not None
async def mark_dead(self, job_id: str, error: str, claimed_by: str) -> bool:
"""Transition a still-claimed job to `dead`. See `mark_succeeded`
for the guard semantics."""
async with self._lock:
async with self._conn.execute(
"UPDATE jobs SET status='dead', completed_at=?, last_error=? "
"WHERE id=? AND status='claimed' AND claimed_by=? RETURNING id",
(_utcnow_iso(), error, job_id, claimed_by),
) as cursor:
row = await cursor.fetchone()
await self._conn.commit()
stmt = (
sa.update(jobs)
.where(
jobs.c.id == job_id,
jobs.c.status == "claimed",
jobs.c.claimed_by == claimed_by,
)
.values(status="dead", completed_at=_utcnow_iso(), last_error=error)
.returning(jobs.c.id)
)
async with self._engine.begin() as conn:
row = (await conn.execute(stmt)).first()
return row is not None
async def reschedule(
@ -194,22 +209,24 @@ class JobRepo:
slow worker can't clobber a re-claim that happened after the reaper
reset its claim. Returns True when the row was updated."""
scheduled = (datetime.now(UTC) + timedelta(seconds=delay_seconds)).isoformat()
async with self._lock:
async with self._conn.execute(
"""
UPDATE jobs
SET status='queued',
scheduled_at=?,
claimed_at=NULL,
claimed_by=NULL,
last_error=?
WHERE id=? AND status='claimed' AND claimed_by=?
RETURNING id
""",
(scheduled, error, job_id, claimed_by),
) as cursor:
row = await cursor.fetchone()
await self._conn.commit()
stmt = (
sa.update(jobs)
.where(
jobs.c.id == job_id,
jobs.c.status == "claimed",
jobs.c.claimed_by == claimed_by,
)
.values(
status="queued",
scheduled_at=scheduled,
claimed_at=None,
claimed_by=None,
last_error=error,
)
.returning(jobs.c.id)
)
async with self._engine.begin() as conn:
row = (await conn.execute(stmt)).first()
return row is not None
async def retry(self, job_id: str) -> Job:
@ -219,24 +236,22 @@ class JobRepo:
rows (re-ingest via UPSERT instead). Raises KeyError when the row
is missing or in a non-retryable state."""
now = _utcnow_iso()
async with self._lock:
async with self._conn.execute(
"""
UPDATE jobs
SET status='queued',
attempts=0,
last_error=NULL,
claimed_at=NULL,
claimed_by=NULL,
completed_at=NULL,
scheduled_at=?
WHERE id=? AND status IN ('dead', 'queued')
RETURNING *
""",
(now, job_id),
) as cursor:
row = await cursor.fetchone()
await self._conn.commit()
stmt = (
sa.update(jobs)
.where(jobs.c.id == job_id, jobs.c.status.in_(["dead", "queued"]))
.values(
status="queued",
attempts=0,
last_error=None,
claimed_at=None,
claimed_by=None,
completed_at=None,
scheduled_at=now,
)
.returning(*jobs.c)
)
async with self._engine.begin() as conn:
row = (await conn.execute(stmt)).mappings().one_or_none()
if not row:
raise KeyError(f"Job {job_id!r} not found or not retryable")
return _row_to_job(row)
@ -244,13 +259,13 @@ class JobRepo:
async def cancel(self, job_id: str) -> bool:
"""True iff a queued/claimed row was removed; terminal jobs aren't
cancellable (succeeded/dead rows are kept for history)."""
async with self._lock:
async with self._conn.execute(
"DELETE FROM jobs WHERE id=? AND status IN ('queued', 'claimed') RETURNING id",
(job_id,),
) as cursor:
row = await cursor.fetchone()
await self._conn.commit()
stmt = (
sa.delete(jobs)
.where(jobs.c.id == job_id, jobs.c.status.in_(["queued", "claimed"]))
.returning(jobs.c.id)
)
async with self._engine.begin() as conn:
row = (await conn.execute(stmt)).first()
return row is not None
async def list_jobs(
@ -262,25 +277,16 @@ class JobRepo:
limit: int = 50,
offset: int = 0,
) -> list[Job]:
clauses: list[str] = []
params: list[object] = []
query = sa.select(jobs)
if status is not None:
clauses.append("status = ?")
params.append(status.value)
query = query.where(jobs.c.status == status.value)
if source_id is not None:
clauses.append("source_id = ?")
params.append(source_id)
query = query.where(jobs.c.source_id == source_id)
if uri is not None:
clauses.append("uri = ?")
params.append(uri)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.extend([limit, offset])
async with self._lock:
async with self._conn.execute(
f"SELECT * FROM jobs {where} ORDER BY enqueued_at DESC LIMIT ? OFFSET ?",
params,
) as cursor:
rows = await cursor.fetchall()
query = query.where(jobs.c.uri == uri)
query = query.order_by(jobs.c.enqueued_at.desc()).limit(limit).offset(offset)
async with self._engine.connect() as conn:
rows = (await conn.execute(query)).mappings().all()
return [_row_to_job(r) for r in rows]
async def has_pending(self, source_id: str) -> bool:
@ -290,78 +296,78 @@ class JobRepo:
outstanding work the queue's unique index would dedupe new enqueues
anyway, so a sweep into a saturated queue is pure wasted listing work.
"""
async with self._lock:
async with self._conn.execute(
"SELECT 1 FROM jobs WHERE source_id=? AND status IN ('queued','claimed') LIMIT 1",
(source_id,),
) as cursor:
row = await cursor.fetchone()
query = (
sa.select(jobs.c.id)
.where(
jobs.c.source_id == source_id,
jobs.c.status.in_(["queued", "claimed"]),
)
.limit(1)
)
async with self._engine.connect() as conn:
row = (await conn.execute(query)).first()
return row is not None
async def counts_by_status(self) -> dict[str, int]:
async with self._lock:
async with self._conn.execute(
"SELECT status, COUNT(*) AS n FROM jobs GROUP BY status"
) as cursor:
rows = await cursor.fetchall()
return {row["status"]: row["n"] for row in rows}
query = sa.select(jobs.c.status, sa.func.count().label("n")).group_by(
jobs.c.status
)
async with self._engine.connect() as conn:
rows = (await conn.execute(query)).all()
return {status: n for status, n in rows}
async def counts_by_status_since(self, since: datetime) -> dict[str, int]:
"""status -> count of jobs that reached a terminal state at or after
`since` (by completed_at). Only succeeded/dead set completed_at, so
those are the only keys returned. Lets a one-shot batch report the
work it finished, independent of terminal rows from earlier runs."""
async with self._lock:
async with self._conn.execute(
"SELECT status, COUNT(*) AS n FROM jobs "
"WHERE completed_at >= ? GROUP BY status",
(since.isoformat(),),
) as cursor:
rows = await cursor.fetchall()
return {row["status"]: row["n"] for row in rows}
query = (
sa.select(jobs.c.status, sa.func.count().label("n"))
.where(jobs.c.completed_at >= since.isoformat())
.group_by(jobs.c.status)
)
async with self._engine.connect() as conn:
rows = (await conn.execute(query)).all()
return {status: n for status, n in rows}
async def count_succeeded_since(self, seconds: int) -> int:
"""How many jobs reached `succeeded` in the last `seconds` seconds.
Drives the dashboard's rolling-throughput chips."""
threshold = (datetime.now(UTC) - timedelta(seconds=seconds)).isoformat()
async with self._lock:
async with self._conn.execute(
"SELECT COUNT(*) AS n FROM jobs WHERE status='succeeded' AND completed_at >= ?",
(threshold,),
) as cursor:
row = await cursor.fetchone()
return int(row["n"]) if row else 0
query = sa.select(sa.func.count()).where(
jobs.c.status == "succeeded", jobs.c.completed_at >= threshold
)
async with self._engine.connect() as conn:
count = (await conn.execute(query)).scalar()
return int(count or 0)
async def oldest_queued_age_seconds(self) -> float | None:
"""Age (in seconds) of the oldest job sitting in `queued` whose
scheduled_at is in the past. Returns None when nothing is waiting.
Tells operators whether work is backing up."""
now = datetime.now(UTC)
async with self._lock:
async with self._conn.execute(
"SELECT MIN(scheduled_at) AS oldest FROM jobs "
"WHERE status='queued' AND scheduled_at <= ?",
(now.isoformat(),),
) as cursor:
row = await cursor.fetchone()
if not row or row["oldest"] is None:
query = sa.select(sa.func.min(jobs.c.scheduled_at)).where(
jobs.c.status == "queued", jobs.c.scheduled_at <= now.isoformat()
)
async with self._engine.connect() as conn:
oldest = (await conn.execute(query)).scalar()
if oldest is None:
return None
return (now - datetime.fromisoformat(row["oldest"])).total_seconds()
return (now - datetime.fromisoformat(oldest)).total_seconds()
async def counts_by_source(self, *statuses: str) -> dict[str, int]:
"""source_id → count of jobs in any of the given statuses. Drives the
dashboard's per-source DLQ and backlog summaries."""
if not statuses:
return {}
placeholders = ",".join("?" * len(statuses))
async with self._lock:
async with self._conn.execute(
f"SELECT source_id, COUNT(*) AS n FROM jobs "
f"WHERE status IN ({placeholders}) GROUP BY source_id",
statuses,
) as cursor:
rows = await cursor.fetchall()
return {row["source_id"]: row["n"] for row in rows}
query = (
sa.select(jobs.c.source_id, sa.func.count().label("n"))
.where(jobs.c.status.in_(statuses))
.group_by(jobs.c.source_id)
)
async with self._engine.connect() as conn:
rows = (await conn.execute(query)).all()
return {source_id: n for source_id, n in rows}
async def release_if_claimed(self, job_id: str, claimed_by: str) -> bool:
"""Reset a still-claimed job back to queued, immediately reclaimable.
@ -370,23 +376,24 @@ class JobRepo:
re-claimed after a reaper reset. Decrements attempts to undo the
increment from `claim_next`, since a cancellation isn't a failed
attempt. Returns True if the row was released."""
now = _utcnow_iso()
async with self._lock:
async with self._conn.execute(
"""
UPDATE jobs
SET status='queued',
claimed_at=NULL,
claimed_by=NULL,
scheduled_at=?,
attempts=MAX(0, attempts - 1)
WHERE id=? AND status='claimed' AND claimed_by=?
RETURNING id
""",
(now, job_id, claimed_by),
) as cursor:
row = await cursor.fetchone()
await self._conn.commit()
stmt = (
sa.update(jobs)
.where(
jobs.c.id == job_id,
jobs.c.status == "claimed",
jobs.c.claimed_by == claimed_by,
)
.values(
status="queued",
claimed_at=None,
claimed_by=None,
scheduled_at=_utcnow_iso(),
attempts=_attempts_minus_one(),
)
.returning(jobs.c.id)
)
async with self._engine.begin() as conn:
row = (await conn.execute(stmt)).first()
return row is not None
async def prune_dead(self, source_id: str, uri: str) -> int:
@ -394,31 +401,27 @@ class JobRepo:
successful DELETE to clear stale UPSERT failures for the same URI
the document is gone, so a "couldn't ingest this" entry is no longer
actionable. Returns the number of rows removed."""
async with self._lock:
cursor = await self._conn.execute(
"DELETE FROM jobs WHERE source_id=? AND uri=? AND status='dead'",
(source_id, uri),
)
rowcount = cursor.rowcount or 0
await cursor.close()
await self._conn.commit()
return rowcount
stmt = sa.delete(jobs).where(
jobs.c.source_id == source_id,
jobs.c.uri == uri,
jobs.c.status == "dead",
)
async with self._engine.begin() as conn:
result = await conn.execute(stmt)
return result.rowcount or 0
async def prune_terminal(self, max_age_seconds: int) -> int:
"""Delete terminal jobs (succeeded/dead) whose completed_at is older
than max_age_seconds. General housekeeping so the table doesn't grow
without bound. Returns the number of rows removed."""
threshold = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).isoformat()
async with self._lock:
cursor = await self._conn.execute(
"DELETE FROM jobs WHERE status IN ('succeeded','dead') "
"AND completed_at < ?",
(threshold,),
)
rowcount = cursor.rowcount or 0
await cursor.close()
await self._conn.commit()
return rowcount
stmt = sa.delete(jobs).where(
jobs.c.status.in_(["succeeded", "dead"]),
jobs.c.completed_at < threshold,
)
async with self._engine.begin() as conn:
result = await conn.execute(stmt)
return result.rowcount or 0
async def reap_stale(self, claim_timeout_seconds: int) -> int:
"""Reset claimed jobs whose claimed_at is older than the timeout
@ -427,35 +430,25 @@ class JobRepo:
threshold = (
datetime.now(UTC) - timedelta(seconds=claim_timeout_seconds)
).isoformat()
async with self._lock:
cursor = await self._conn.execute(
"""
UPDATE jobs
SET status='queued',
claimed_at=NULL,
claimed_by=NULL,
attempts=MAX(0, attempts - 1)
WHERE status='claimed' AND claimed_at < ?
""",
(threshold,),
stmt = (
sa.update(jobs)
.where(jobs.c.status == "claimed", jobs.c.claimed_at < threshold)
.values(
status="queued",
claimed_at=None,
claimed_by=None,
attempts=_attempts_minus_one(),
)
rowcount = cursor.rowcount or 0
await cursor.close()
await self._conn.commit()
return rowcount
)
async with self._engine.begin() as conn:
result = await conn.execute(stmt)
return result.rowcount or 0
class SyncStateRepo:
def __init__(
self,
conn: aiosqlite.Connection,
lock: asyncio.Lock | None = None,
):
conn.row_factory = aiosqlite.Row
self._conn = conn
# Pass the same lock instance JobRepo uses when both wrap one
# connection. See JobRepo for the SQLite cursor + commit constraint.
self._lock = lock or asyncio.Lock()
def __init__(self, engine: AsyncEngine):
self._engine = engine
self._dialect = engine.dialect.name
async def get_revision_snapshot(self, source_id: str) -> dict[str, str]:
"""uri -> revision map for URIs that have a stored revision. Sources
@ -464,35 +457,63 @@ class SyncStateRepo:
that didn't complete) are excluded — they have no revision to
compare against; the closing-loop DELETE diff uses list_known_uris
instead."""
async with self._lock:
async with self._conn.execute(
"SELECT uri, revision FROM sync_state WHERE source_id=? AND revision IS NOT NULL",
(source_id,),
) as cursor:
rows = await cursor.fetchall()
return {row["uri"]: row["revision"] for row in rows}
query = sa.select(sync_state.c.uri, sync_state.c.revision).where(
sync_state.c.source_id == source_id,
sync_state.c.revision.is_not(None),
)
async with self._engine.connect() as conn:
rows = (await conn.execute(query)).all()
return {uri: revision for uri, revision in rows}
async def list_known_uris(self, source_id: str) -> set[str]:
"""Every URI the source has ever produced. Used by the closing-loop
diff in discover() so a URI previously seen but no longer visible
(FS file deleted, HTTP URL removed from config) emits DELETE."""
async with self._lock:
async with self._conn.execute(
"SELECT uri FROM sync_state WHERE source_id=?",
(source_id,),
) as cursor:
rows = await cursor.fetchall()
return {row["uri"] for row in rows}
query = sa.select(sync_state.c.uri).where(sync_state.c.source_id == source_id)
async with self._engine.connect() as conn:
rows = (await conn.execute(query)).all()
return {uri for (uri,) in rows}
async def get_row(self, source_id: str, uri: str) -> SyncStateRow | None:
async with self._lock:
async with self._conn.execute(
"SELECT * FROM sync_state WHERE source_id=? AND uri=?",
(source_id, uri),
) as cursor:
row = await cursor.fetchone()
query = sa.select(sync_state).where(
sync_state.c.source_id == source_id, sync_state.c.uri == uri
)
async with self._engine.connect() as conn:
row = (await conn.execute(query)).mappings().one_or_none()
return _row_to_sync_state(row) if row else None
def _upsert_stmt(
self,
source_id: str,
uri: str,
revision: str | None,
content_hash: str | None,
last_seen_at: str,
last_ingested_at: str | None,
):
ins = _insert(sync_state, self._dialect).values(
source_id=source_id,
uri=uri,
revision=revision,
content_hash=content_hash,
last_seen_at=last_seen_at,
last_ingested_at=last_ingested_at,
)
excluded = ins.excluded
return ins.on_conflict_do_update(
index_elements=[sync_state.c.source_id, sync_state.c.uri],
set_={
"revision": sa.func.coalesce(excluded.revision, sync_state.c.revision),
"content_hash": sa.func.coalesce(
excluded.content_hash, sync_state.c.content_hash
),
"last_seen_at": excluded.last_seen_at,
"last_ingested_at": sa.func.coalesce(
excluded.last_ingested_at, sync_state.c.last_ingested_at
),
},
)
async def upsert(
self,
source_id: str,
@ -507,57 +528,32 @@ class SyncStateRepo:
`revision=None` and `content_hash=None` leave any existing values
untouched."""
now = _utcnow_iso()
ingested_at = now if ingested else None
async with self._lock:
async with self._conn.execute(
"""
INSERT INTO sync_state (
source_id, uri, revision, content_hash, last_seen_at, last_ingested_at
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(source_id, uri) DO UPDATE SET
revision = COALESCE(excluded.revision, revision),
content_hash = COALESCE(excluded.content_hash, content_hash),
last_seen_at = excluded.last_seen_at,
last_ingested_at = COALESCE(excluded.last_ingested_at, last_ingested_at)
""",
(source_id, uri, revision, content_hash, now, ingested_at),
):
pass
await self._conn.commit()
stmt = self._upsert_stmt(
source_id, uri, revision, content_hash, now, now if ingested else None
)
async with self._engine.begin() as conn:
await conn.execute(stmt)
async def batch_upsert(self, rows: list[SyncRow]) -> None:
"""Batch insert-or-update sync_state rows in a single transaction."""
if not rows:
return
now = _utcnow_iso()
async with self._lock:
async with self._engine.begin() as conn:
for source_id, uri, revision, content_hash, ingested in rows:
ingested_at = now if ingested else None
await self._conn.execute(
"""
INSERT INTO sync_state (
source_id, uri, revision, content_hash,
last_seen_at, last_ingested_at
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(source_id, uri) DO UPDATE SET
revision = COALESCE(excluded.revision, revision),
content_hash = COALESCE(excluded.content_hash, content_hash),
last_seen_at = excluded.last_seen_at,
last_ingested_at = COALESCE(
excluded.last_ingested_at, last_ingested_at
)
""",
(source_id, uri, revision, content_hash, now, ingested_at),
stmt = self._upsert_stmt(
source_id,
uri,
revision,
content_hash,
now,
now if ingested else None,
)
await self._conn.commit()
await conn.execute(stmt)
async def delete(self, source_id: str, uri: str) -> None:
async with self._lock:
async with self._conn.execute(
"DELETE FROM sync_state WHERE source_id=? AND uri=?",
(source_id, uri),
):
pass
await self._conn.commit()
stmt = sa.delete(sync_state).where(
sync_state.c.source_id == source_id, sync_state.c.uri == uri
)
async with self._engine.begin() as conn:
await conn.execute(stmt)

View file

@ -1,80 +0,0 @@
SCHEMA_VERSION = 1
JOBS_DDL = """
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
uri TEXT NOT NULL,
op TEXT NOT NULL,
content_hash TEXT,
revision TEXT,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
last_error TEXT,
extra TEXT,
enqueued_at TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
claimed_at TEXT,
claimed_by TEXT,
completed_at TEXT
)
"""
# Partial unique index: a (source_id, uri) pair can only have one live job
# (queued or claimed) at a time, regardless of op. Live UPSERT and DELETE
# for the same URI can't both exist — preventing a DELETE worker from
# removing a document a sibling UPSERT just ingested. Once succeeded or
# dead, the row no longer satisfies the WHERE clause and a re-enqueue is
# allowed.
JOBS_LIVE_INDEX = """
CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_live
ON jobs(source_id, uri)
WHERE status IN ('queued', 'claimed')
"""
JOBS_CLAIMABLE_INDEX = """
CREATE INDEX IF NOT EXISTS idx_jobs_claimable
ON jobs(scheduled_at)
WHERE status = 'queued'
"""
JOBS_SUCCEEDED_COMPLETED_INDEX = """
CREATE INDEX IF NOT EXISTS idx_jobs_succeeded_completed
ON jobs(completed_at)
WHERE status = 'succeeded'
"""
SYNC_STATE_DDL = """
CREATE TABLE IF NOT EXISTS sync_state (
source_id TEXT NOT NULL,
uri TEXT NOT NULL,
revision TEXT,
content_hash TEXT,
last_seen_at TEXT NOT NULL,
last_ingested_at TEXT,
PRIMARY KEY (source_id, uri)
)
"""
SCHEMA_VERSION_DDL = """
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
)
"""
DLQ_VIEW = """
CREATE VIEW IF NOT EXISTS dlq AS
SELECT * FROM jobs WHERE status = 'dead'
"""
ALL_DDL: tuple[str, ...] = (
JOBS_DDL,
JOBS_LIVE_INDEX,
JOBS_CLAIMABLE_INDEX,
JOBS_SUCCEEDED_COMPLETED_INDEX,
SYNC_STATE_DDL,
SCHEMA_VERSION_DDL,
DLQ_VIEW,
)

View file

@ -58,7 +58,9 @@ cross-encoder = ["sentence-transformers>=3.0.0"]
ingester = [
"fastapi>=0.125",
"uvicorn[standard]>=0.32",
"sqlalchemy[asyncio]>=2.0",
"aiosqlite>=0.20",
"asyncpg>=0.29",
"haiku.rag-slim[s3]",
]
# TUI (chat and inspect commands)

View file

@ -135,6 +135,10 @@ filterwarnings = ["error", "ignore::UserWarning", "ignore::DeprecationWarning"]
[tool.coverage.run]
source = ["haiku_rag_slim"]
# aiosqlite runs sqlite (and the queue's PRAGMA connect listener) off the main
# flow via a greenlet + worker thread; track both so that code isn't reported
# as uncovered.
concurrency = ["greenlet", "thread"]
omit = [
"haiku_rag_slim/haiku/rag/chat/*",
"haiku_rag_slim/haiku/rag/inspector/*",

View file

@ -23,6 +23,8 @@ import pydantic_ai.models # noqa: E402
import pytest # noqa: E402
import yaml # noqa: E402
from .services import reachable # noqa: E402
if TYPE_CHECKING:
from vcr import VCR
@ -148,3 +150,42 @@ def doclaynet_first_page_pdf(tmp_path_factory) -> Path:
finally:
src.close()
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

@ -0,0 +1,34 @@
import aiosqlite
import pytest
from haiku.rag.config import QueueConfig
from haiku.rag.ingester.queue.migrations import open_queue
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
@pytest.fixture
async def engine(tmp_path):
eng = await open_queue(QueueConfig(path=tmp_path / "queue.db"))
yield eng
await eng.dispose()
@pytest.fixture
async def conn(tmp_path, engine):
"""Raw connection to the queue file for test-only SQL inspection and
backdating. Shares the WAL database the engine writes through."""
connection = await aiosqlite.connect(str(tmp_path / "queue.db"))
connection.row_factory = aiosqlite.Row
await connection.execute("PRAGMA busy_timeout=30000")
yield connection
await connection.close()
@pytest.fixture
def jobs(engine):
return JobRepo(engine)
@pytest.fixture
def sync(engine):
return SyncStateRepo(engine)

View file

@ -1,16 +1,12 @@
import asyncio
from datetime import UTC, datetime
import aiosqlite
import httpx
import pytest
from httpx import ASGITransport
from haiku.rag.config import AppConfig
from haiku.rag.ingester.api.server import APIState, build_app
from haiku.rag.ingester.queue.migrations import apply_migrations
from haiku.rag.ingester.queue.models import JobOp, JobStatus
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
from haiku.rag.ingester.sources.base import (
FetchResult,
SourceEvent,
@ -18,31 +14,6 @@ from haiku.rag.ingester.sources.base import (
)
@pytest.fixture
async def conn(tmp_path):
path = tmp_path / "queue.db"
connection = await aiosqlite.connect(str(path))
connection.row_factory = aiosqlite.Row
await apply_migrations(connection)
yield connection
await connection.close()
@pytest.fixture
def queue_lock():
return asyncio.Lock()
@pytest.fixture
def jobs(conn, queue_lock):
return JobRepo(conn, lock=queue_lock)
@pytest.fixture
def sync(conn, queue_lock):
return SyncStateRepo(conn, lock=queue_lock)
@pytest.fixture
def state(jobs, sync):
return APIState(

View file

@ -111,6 +111,18 @@ def test_queue_migrate(tmp_path, monkeypatch):
assert "up to date" in result.output
def test_queue_target_masks_dburi_password():
from haiku.rag.config import QueueConfig
from haiku.rag.ingester.cli import _queue_target
target = _queue_target(
QueueConfig(dburi="postgresql+asyncpg://user:secret@host:5432/db")
)
assert "secret" not in target
assert "***" in target
assert "user" in target and "host:5432/db" in target
# --- config loading ---

View file

@ -2,7 +2,6 @@ import asyncio
from datetime import UTC, datetime
from pathlib import Path
import aiosqlite
import pytest
from haiku.rag.config import (
@ -15,9 +14,7 @@ from haiku.rag.config import (
from haiku.rag.ingester.pollers.circuit_breaker import CircuitBreaker
from haiku.rag.ingester.pollers.manager import PollerManager
from haiku.rag.ingester.pollers.periodic import PeriodicPoller
from haiku.rag.ingester.queue.migrations import apply_migrations
from haiku.rag.ingester.queue.models import JobOp, JobStatus
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
from haiku.rag.ingester.sources.base import (
FetchResult,
SourceEvent,
@ -25,31 +22,6 @@ from haiku.rag.ingester.sources.base import (
)
@pytest.fixture
async def conn(tmp_path):
path = tmp_path / "queue.db"
connection = await aiosqlite.connect(str(path))
connection.row_factory = aiosqlite.Row
await apply_migrations(connection)
yield connection
await connection.close()
@pytest.fixture
def queue_lock():
return asyncio.Lock()
@pytest.fixture
def jobs(conn, queue_lock):
return JobRepo(conn, lock=queue_lock)
@pytest.fixture
def sync(conn, queue_lock):
return SyncStateRepo(conn, lock=queue_lock)
class _StubSource:
"""Test double that yields a scripted sequence of events on each
discover() call. `fetch` and `supports` aren't exercised by pollers."""

View file

@ -1,38 +1,18 @@
import asyncio
from datetime import UTC, datetime, timedelta
import aiosqlite
import pytest
import sqlalchemy as sa
from haiku.rag.ingester.queue.migrations import apply_migrations, open_queue
from haiku.rag.config import QueueConfig
from haiku.rag.ingester.queue.db import jobs as jobs_table
from haiku.rag.ingester.queue.migrations import (
apply_migrations,
make_engine,
open_queue,
)
from haiku.rag.ingester.queue.models import JobOp, JobStatus, SyncRow
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
@pytest.fixture
async def conn(tmp_path):
path = tmp_path / "queue.db"
connection = await aiosqlite.connect(str(path))
connection.row_factory = aiosqlite.Row
await apply_migrations(connection)
yield connection
await connection.close()
@pytest.fixture
def queue_lock():
return asyncio.Lock()
@pytest.fixture
def jobs(conn, queue_lock):
return JobRepo(conn, lock=queue_lock)
@pytest.fixture
def sync(conn, queue_lock):
return SyncStateRepo(conn, lock=queue_lock)
from haiku.rag.ingester.queue.repository import JobRepo, _insert
# --- migrations / schema ---
@ -46,9 +26,9 @@ async def test_apply_migrations_sets_schema_version(conn):
@pytest.mark.asyncio
async def test_apply_migrations_is_idempotent(conn):
await apply_migrations(conn)
await apply_migrations(conn)
async def test_apply_migrations_is_idempotent(engine, conn):
await apply_migrations(engine)
await apply_migrations(engine)
cursor = await conn.execute("SELECT COUNT(*) AS n FROM schema_version")
row = await cursor.fetchone()
assert row["n"] == 1
@ -57,26 +37,49 @@ async def test_apply_migrations_is_idempotent(conn):
@pytest.mark.asyncio
async def test_open_queue_creates_file_and_schema(tmp_path):
path = tmp_path / "subdir" / "queue.db"
connection = await open_queue(path)
eng = await open_queue(QueueConfig(path=path))
try:
assert path.exists()
cursor = await connection.execute("SELECT version FROM schema_version")
row = await cursor.fetchone()
assert row is not None
async with eng.connect() as conn:
version = (
await conn.execute(sa.text("SELECT version FROM schema_version"))
).scalar()
assert version is not None
finally:
await connection.close()
await eng.dispose()
@pytest.mark.asyncio
async def test_dlq_view_exposes_dead_jobs(conn, jobs):
job = await jobs.enqueue("s", "u", JobOp.UPSERT)
claimed = await jobs.claim_next("w")
assert claimed is not None
await jobs.mark_dead(job.id, "permanent", "w")
async def test_make_engine_postgres_is_pre_ping():
"""The Postgres branch builds a pre-ping engine without connecting."""
engine = make_engine(QueueConfig(dburi="postgresql+asyncpg://u:p@localhost/db"))
try:
assert engine.dialect.name == "postgresql"
assert engine.pool._pre_ping is True
finally:
await engine.dispose()
cursor = await conn.execute("SELECT id FROM dlq")
rows = await cursor.fetchall()
assert [r["id"] for r in rows] == [job.id]
def test_insert_uses_dialect_specific_construct():
"""_insert dispatches to the dialect's INSERT (which exposes on_conflict_*)."""
from sqlalchemy.dialects.postgresql import Insert as PostgresInsert
from sqlalchemy.dialects.sqlite import Insert as SqliteInsert
assert isinstance(_insert(jobs_table, "postgresql"), PostgresInsert)
assert isinstance(_insert(jobs_table, "sqlite"), SqliteInsert)
@pytest.mark.asyncio
async def test_open_queue_handles_path_with_url_chars(tmp_path):
"""A `?` (or `#`) is a valid POSIX filename char but has URL meaning.
The queue must open the literal file, not a truncated one."""
path = tmp_path / "queue?weird.db"
eng = await open_queue(QueueConfig(path=path))
try:
assert path.exists()
assert not (tmp_path / "queue").exists()
finally:
await eng.dispose()
# --- enqueue ---
@ -265,6 +268,43 @@ async def test_claim_next_atomic_under_concurrency(jobs):
assert {c.id for c in claimed} == {j.id for j in enqueued}
@pytest.mark.asyncio
async def test_claim_next_atomic_across_independent_engines(tmp_path):
"""Two engines on one SQLite file stand in for two ingester processes.
The claim is a single UPDATE...WHERE id=(subquery), so it stays atomic
across connections pool_size=1 only serializes within one engine."""
cfg = QueueConfig(path=tmp_path / "shared-queue.db")
engine_a = await open_queue(cfg)
engine_b = await open_queue(cfg)
try:
repo_a = JobRepo(engine_a)
repo_b = JobRepo(engine_b)
enqueued = []
for i in range(10):
job = await repo_a.enqueue("s", f"u{i}", JobOp.UPSERT)
assert job is not None
enqueued.append(job)
# Claims alternate across the two engines, contending on the same file.
results = await asyncio.gather(
*((repo_a if i % 2 == 0 else repo_b).claim_next(f"w{i}") for i in range(20))
)
claimed = [r for r in results if r is not None]
assert len(claimed) == 10
assert len({c.id for c in claimed}) == 10
assert {c.id for c in claimed} == {j.id for j in enqueued}
for job in enqueued:
refreshed = await repo_a.get_job(job.id)
assert refreshed is not None
assert refreshed.status is JobStatus.CLAIMED
assert refreshed.attempts == 1
finally:
await engine_a.dispose()
await engine_b.dispose()
# --- terminal transitions ---
@ -797,29 +837,6 @@ async def test_counts_by_source_no_statuses_returns_empty(jobs):
assert await jobs.counts_by_source() == {}
# --- cross-repo lock sharing ---
def test_repos_share_lock_when_constructed_with_one(conn):
"""JobRepo and SyncStateRepo wrap one shared aiosqlite.Connection in
production. They must accept and share a single asyncio.Lock so cross-
repo calls serialize at the cursor/commit boundary otherwise a
SyncStateRepo.upsert cursor open while JobRepo.mark_succeeded tries
to commit would trip SQLite's 'SQL statements in progress' error."""
shared = asyncio.Lock()
j = JobRepo(conn, lock=shared)
s = SyncStateRepo(conn, lock=shared)
assert j._lock is s._lock is shared
def test_repos_default_to_independent_locks_when_used_alone(conn):
"""Backward-compat: a single-repo caller can still construct without
passing a lock and each repo creates its own."""
j = JobRepo(conn)
s = SyncStateRepo(conn)
assert j._lock is not s._lock
# --- sync state ---

View file

@ -0,0 +1,156 @@
"""Postgres-backed queue tests. Marked `integration` (excluded in CI). The
`postgres_dburi` fixture connects to the docker-compose `postgres` service
(`docker compose -f tests/docker/docker-compose.yml up -d`), or uses
HAIKU_RAG_TEST_PG_DBURI when set, and skips when neither is reachable. They
exercise the dialect-specific SQL the SQLite suite cannot: ON CONFLICT, COALESCE
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
loop that created them and pytest-asyncio uses a per-test loop, so opening the
engine inside the test (with NullPool, so no connection is reused across loops)
avoids cross-loop fixture handoff.
"""
import asyncio
import uuid
from contextlib import asynccontextmanager
import pytest
from sqlalchemy import make_url, text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from haiku.rag.ingester.queue.db import metadata
from haiku.rag.ingester.queue.models import JobOp, JobStatus, SyncRow
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
pytestmark = pytest.mark.integration
@asynccontextmanager
async def queue_engine(dburi: str):
"""An engine scoped to a throwaway Postgres schema, so concurrent xdist
workers each get their own isolated copy of the queue tables."""
schema = f"q_{uuid.uuid4().hex[:12]}"
engine = create_async_engine(
make_url(dburi),
poolclass=NullPool,
connect_args={"server_settings": {"search_path": schema}},
)
async with engine.begin() as conn:
await conn.execute(text(f'CREATE SCHEMA "{schema}"'))
await conn.run_sync(metadata.create_all)
try:
yield engine
finally:
async with engine.begin() as conn:
await conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
await engine.dispose()
@pytest.mark.asyncio
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
the same (source_id, uri), regardless of op."""
async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine)
first = await jobs.enqueue("s", "u", JobOp.UPSERT)
second = await jobs.enqueue("s", "u", JobOp.DELETE)
assert first is not None
assert second is None
@pytest.mark.asyncio
async def test_enqueue_after_terminal_succeeds(postgres_dburi):
"""Once a job is terminal it no longer satisfies the partial index, so a
re-enqueue for the same URI is allowed."""
async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine)
first = await jobs.enqueue("s", "u", JobOp.UPSERT)
assert first is not None
claimed = await jobs.claim_next("w")
assert claimed is not None
await jobs.mark_succeeded(claimed.id, "w")
second = await jobs.enqueue("s", "u", JobOp.UPSERT)
assert second is not None and second.id != first.id
@pytest.mark.asyncio
async def test_skip_locked_claims_each_job_once(postgres_dburi):
"""FOR UPDATE SKIP LOCKED: many concurrent claims over real Postgres
connections each take a distinct job, with none claimed twice. Without
SKIP LOCKED, concurrent transactions would grab the same row."""
async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine)
enqueued = []
for i in range(20):
job = await jobs.enqueue("s", f"u{i}", JobOp.UPSERT)
assert job is not None
enqueued.append(job)
results = await asyncio.gather(*(jobs.claim_next(f"w{i}") for i in range(40)))
claimed = [r for r in results if r is not None]
assert len(claimed) == 20
assert len({c.id for c in claimed}) == 20
assert {c.id for c in claimed} == {j.id for j in enqueued}
@pytest.mark.asyncio
async def test_reap_stale_clamps_attempts(postgres_dburi):
"""The attempts-1 clamp (CASE) renders and runs on Postgres."""
async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine)
job = await jobs.enqueue("s", "u", JobOp.UPSERT)
assert job is not None
claimed = await jobs.claim_next("w")
assert claimed is not None and claimed.attempts == 1
reset = await jobs.reap_stale(claim_timeout_seconds=0)
assert reset == 1
refreshed = await jobs.get_job(job.id)
assert refreshed is not None
assert refreshed.status is JobStatus.QUEUED
assert refreshed.attempts == 0
@pytest.mark.asyncio
async def test_sync_state_upsert_coalesce_preserves_revision(postgres_dburi):
"""ON CONFLICT DO UPDATE with COALESCE leaves an existing revision in place
when a later upsert passes revision=None."""
async with queue_engine(postgres_dburi) as engine:
sync = SyncStateRepo(engine)
await sync.upsert("s", "u", revision="v1", content_hash="h1")
await sync.upsert("s", "u", revision=None, content_hash=None, ingested=True)
row = await sync.get_row("s", "u")
assert row is not None
assert row.revision == "v1"
assert row.content_hash == "h1"
assert row.last_ingested_at is not None
@pytest.mark.asyncio
async def test_sync_state_batch_upsert(postgres_dburi):
async with queue_engine(postgres_dburi) as engine:
sync = SyncStateRepo(engine)
await sync.batch_upsert(
[
SyncRow("s", "u1", "r1", "h1", False),
SyncRow("s", "u2", "r2", "h2", True),
]
)
assert await sync.get_revision_snapshot("s") == {"u1": "r1", "u2": "r2"}
@pytest.mark.asyncio
async def test_prune_terminal_removes_old_rows(postgres_dburi):
async with queue_engine(postgres_dburi) as engine:
jobs = JobRepo(engine)
job = await jobs.enqueue("s", "u", JobOp.UPSERT)
assert job is not None
await jobs.claim_next("w")
await jobs.mark_succeeded(job.id, "w")
# completed_at is "now", so a zero-width window prunes it.
pruned = await jobs.prune_terminal(max_age_seconds=0)
assert pruned == 1
assert await jobs.get_job(job.id) is None

View file

@ -3,45 +3,17 @@
import asyncio
from unittest.mock import AsyncMock
import aiosqlite
import pytest
from haiku.rag.client import HaikuRAG
from haiku.rag.config import FSSourceConfig, HTTPSourceConfig
from haiku.rag.ingester.pollers.manager import PollerManager
from haiku.rag.ingester.queue.migrations import apply_migrations
from haiku.rag.ingester.queue.models import JobOp
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
from haiku.rag.ingester.sources.http import HTTPSource
from haiku.rag.ingester.workers.pool import WorkerPool
from haiku.rag.store.models.document import Document
@pytest.fixture
async def conn(tmp_path):
path = tmp_path / "queue.db"
connection = await aiosqlite.connect(str(path))
connection.row_factory = aiosqlite.Row
await apply_migrations(connection)
yield connection
await connection.close()
@pytest.fixture
def queue_lock():
return asyncio.Lock()
@pytest.fixture
def jobs(conn, queue_lock):
return JobRepo(conn, lock=queue_lock)
@pytest.fixture
def sync(conn, queue_lock):
return SyncStateRepo(conn, lock=queue_lock)
async def _wait_for(predicate, *, timeout: float = 5.0, interval: float = 0.05):
"""Poll `predicate` until it returns truthy or `timeout` elapses."""
deadline = asyncio.get_running_loop().time() + timeout

View file

@ -1,44 +1,16 @@
import asyncio
from unittest.mock import AsyncMock
import aiosqlite
import pytest
from haiku.rag.client import HaikuRAG
from haiku.rag.ingester.exceptions import PermanentError, TransientError
from haiku.rag.ingester.queue.migrations import apply_migrations
from haiku.rag.ingester.queue.models import JobOp, JobStatus
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
from haiku.rag.ingester.workers.pool import WorkerPool
from haiku.rag.ingester.workers.retry import RetryPolicy
from haiku.rag.store.models.document import Document
@pytest.fixture
async def conn(tmp_path):
path = tmp_path / "queue.db"
connection = await aiosqlite.connect(str(path))
connection.row_factory = aiosqlite.Row
await apply_migrations(connection)
yield connection
await connection.close()
@pytest.fixture
def queue_lock():
return asyncio.Lock()
@pytest.fixture
def jobs(conn, queue_lock):
return JobRepo(conn, lock=queue_lock)
@pytest.fixture
def sync(conn, queue_lock):
return SyncStateRepo(conn, lock=queue_lock)
@pytest.fixture
def client():
return AsyncMock(spec=HaikuRAG)

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
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:
"""Integration tests with real docling-serve recorded via VCR."""
@ -1379,12 +1394,14 @@ class TestDoclingServeConverterIntegration:
@pytest.mark.asyncio
@pytest.mark.integration
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.
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
config.processing.pictures = "description"
config.processing.conversion_options.picture_description.model.provider = (

View file

@ -1,9 +1,8 @@
# Start SeaweedFS before running:
# docker compose -f tests/docker/docker-compose.s3.yml up -d
# Start the services before running:
# docker compose -f tests/docker/docker-compose.yml up -d
# 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
import pytest
@ -12,6 +11,7 @@ from haiku.rag.app import HaikuRAGApp
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig, LanceDBConfig
from haiku.rag.store.engine import Store
from tests.services import reachable
S3_ENDPOINT = "http://localhost:8333"
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 = [
pytest.mark.integration,
pytest.mark.skipif(
not _s3_available(), reason="SeaweedFS not running on localhost:8333"
not reachable("localhost", 8333),
reason="SeaweedFS not running on localhost:8333",
),
]

157
uv.lock
View file

@ -243,6 +243,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
@ -1420,6 +1460,73 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" },
]
[[package]]
name = "greenlet"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" },
{ url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" },
{ url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" },
{ url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" },
{ url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" },
{ url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" },
{ url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" },
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" },
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" },
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" },
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
]
[[package]]
name = "griffelib"
version = "2.0.1"
@ -1594,8 +1701,10 @@ groq = [
]
ingester = [
{ name = "aiosqlite" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "obstore" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] },
]
jina = [
@ -1631,6 +1740,7 @@ zeroentropy = [
[package.metadata]
requires-dist = [
{ name = "aiosqlite", marker = "extra == 'ingester'", specifier = ">=0.20" },
{ name = "asyncpg", marker = "extra == 'ingester'", specifier = ">=0.29" },
{ name = "cohere", marker = "extra == 'cohere'", specifier = ">=5.21.1" },
{ name = "docling", marker = "extra == 'docling'", specifier = ">=2.93.0" },
{ name = "docling-core", specifier = ">=2.75.0" },
@ -1660,6 +1770,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "rich", specifier = ">=14.3.3" },
{ name = "sentence-transformers", marker = "extra == 'cross-encoder'", specifier = ">=3.0.0" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'ingester'", specifier = ">=2.0" },
{ name = "textual", marker = "extra == 'tui'", specifier = ">=8.2.4" },
{ name = "textual-image", marker = "extra == 'tui'", specifier = ">=0.8.5" },
{ name = "torch", marker = "extra == 'jina'", specifier = ">=2.0.0" },
@ -4805,6 +4916,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.50"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" },
{ url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" },
{ url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" },
{ url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" },
{ url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" },
{ url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" },
{ url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" },
{ url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" },
{ url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" },
{ url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" },
{ url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" },
{ url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" },
{ url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" },
{ url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" },
{ url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "sse-starlette"
version = "3.3.3"