feat: replace NATS ingest with procrastinate Postgres queue (#183)
Re-architect document ingest from the shared NATS-glued document-processor to a per-tenant, in-process model owned by nextcloud-mcp-server (Deck #183). The MCP server now owns both sides of ingest: - Producer (api role): the scanner defers one job per changed document into the app's Postgres via procrastinate (queueing_lock dedup; no execution lock, so a crashed worker can't deadlock a doc — Qdrant upserts are idempotent). - Consumer (worker role): `nextcloud-mcp-server worker` drains the queue and runs the existing process_document pipeline; a periodic task reclaims jobs orphaned in `doing` by a crash. INGEST_QUEUE selects the transport (auto: postgres when DATABASE_URL is Postgres, else the in-process anyio queue for SQLite/dev). procrastinate manages its own tables (applied on a fresh DB at startup and by `db upgrade`). The vector-sync status surface reads job counts from Postgres in postgres mode. procrastinate + psycopg3 ship in the [postgres] extra; the app's own engine still uses asyncpg (driver unification is a follow-up handled in the rendered Helm chart). NATS JetStream, the Postgres-queue stub, the bus status subscriber, and nats-py are removed. BREAKING CHANGE: the external-NATS-ingest env vars are removed (INGEST_MODE, STATUS_BACKEND, INGEST_BUS_URL, INGEST_BUS_NUM_REPLICAS, FACT_EVENT_EMITTER). Use INGEST_QUEUE (memory|postgres) and the `worker` command instead. TENANT_ID is retained (no longer NATS-subject-charset-validated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b91af923d2
commit
21b7922bac
@@ -0,0 +1,139 @@
|
||||
"""End-to-end Postgres smoke for the procrastinate ingest queue (Deck #183).
|
||||
|
||||
Validates the queue mechanics the in-memory connector can't: real
|
||||
``queueing_lock`` partial-unique dedup, idempotent schema apply, and the
|
||||
``list_queues`` stats the status surface reads. Opt-in like
|
||||
``test_storage_postgres.py``::
|
||||
|
||||
docker compose --profile postgres up -d postgres-test
|
||||
export TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp
|
||||
uv run pytest tests/integration/test_ingest_queue_postgres.py -v -m postgres
|
||||
|
||||
Skipped when ``TEST_DATABASE_URL`` is unset or the service is unreachable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
import nextcloud_mcp_server.config as config_module
|
||||
from nextcloud_mcp_server.vector.queue.procrastinate import (
|
||||
INGEST_QUEUE_NAME,
|
||||
ProcrastinateTaskProducer,
|
||||
apply_ingest_queue_schema,
|
||||
build_app_for_url,
|
||||
get_ingest_job_counts,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.postgres]
|
||||
|
||||
|
||||
def _postgres_url() -> str | None:
|
||||
return os.environ.get("TEST_DATABASE_URL") or None
|
||||
|
||||
|
||||
def _reachable(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
try:
|
||||
with socket.create_connection(
|
||||
(parsed.hostname or "localhost", parsed.port or 5432), timeout=1.0
|
||||
):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_url() -> str:
|
||||
url = _postgres_url()
|
||||
if not url:
|
||||
pytest.skip(
|
||||
"TEST_DATABASE_URL not set — run "
|
||||
"`docker compose --profile postgres up -d postgres-test` and export "
|
||||
"TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp"
|
||||
)
|
||||
if not _reachable(url):
|
||||
pytest.skip(f"Postgres at {url} is not reachable")
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fresh_app(postgres_url: str, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Drop+recreate the public schema, then apply procrastinate's schema."""
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
engine = create_async_engine(postgres_url, future=True)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("DROP SCHEMA public CASCADE"))
|
||||
await conn.execute(text("CREATE SCHEMA public"))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
# get_procrastinate_conninfo derives ssl from settings; point it at the URL.
|
||||
monkeypatch.setattr(config_module, "get_database_url", lambda: postgres_url)
|
||||
monkeypatch.setattr(config_module, "get_database_ssl", lambda: None)
|
||||
|
||||
app = build_app_for_url(postgres_url)
|
||||
await apply_ingest_queue_schema(app)
|
||||
return app
|
||||
|
||||
|
||||
def _task(doc_id: str, doc_type: str = "note") -> DocumentTask:
|
||||
return DocumentTask(
|
||||
user_id="alice",
|
||||
doc_id=doc_id,
|
||||
doc_type=doc_type,
|
||||
operation="index",
|
||||
modified_at=100,
|
||||
etag=f"etag-{doc_id}",
|
||||
)
|
||||
|
||||
|
||||
async def test_ingest_queue_end_to_end(fresh_app):
|
||||
"""One self-contained smoke against real Postgres.
|
||||
|
||||
Kept as a single test so each assertion runs against the same freshly-applied
|
||||
schema — splitting across functions reintroduces the inter-test ``DROP
|
||||
SCHEMA`` that confuses pooled psycopg connections' cached prepared statements
|
||||
(a test-harness artifact, not a production path: prod never drops the schema).
|
||||
"""
|
||||
# 1. Schema is present and a second apply is a no-op (idempotent).
|
||||
await apply_ingest_queue_schema(fresh_app)
|
||||
|
||||
async with fresh_app.open_async():
|
||||
present = await fresh_app.connector.execute_query_one_async(
|
||||
"SELECT to_regclass('procrastinate_jobs') IS NOT NULL AS present"
|
||||
)
|
||||
assert present["present"] is True
|
||||
|
||||
# 2. Defer + real queueing_lock dedup (one todo per doc).
|
||||
producer = ProcrastinateTaskProducer(fresh_app)
|
||||
await producer.send(_task("1"))
|
||||
await producer.send(_task("1")) # deduped by queueing_lock
|
||||
await producer.send(_task("2"))
|
||||
|
||||
rows = await fresh_app.connector.execute_query_all_async(
|
||||
"SELECT count(*) AS n FROM procrastinate_jobs "
|
||||
"WHERE queue_name = %(q)s AND status = 'todo'",
|
||||
q=INGEST_QUEUE_NAME,
|
||||
)
|
||||
assert rows[0]["n"] == 2
|
||||
|
||||
# 3. The status-surface counts read agrees.
|
||||
counts = await get_ingest_job_counts(fresh_app)
|
||||
assert counts.get("todo") == 2
|
||||
|
||||
# 4. Fresh todo jobs are not "doing", so none are stalled.
|
||||
stalled = list(
|
||||
await fresh_app.job_manager.get_stalled_jobs(
|
||||
queue=INGEST_QUEUE_NAME, seconds_since_heartbeat=0
|
||||
)
|
||||
)
|
||||
assert stalled == []
|
||||
Reference in New Issue
Block a user