From 1624385cee716f8eb2aa752cba4fb7f95dbfff86 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Fri, 11 Sep 2026 19:49:06 +0800 Subject: [PATCH 1/7] fix(demo): deduplicate materialization and parallelize result uploads --- ...5a_add_demo_materialization_claim_state.py | 52 ++++ .../app/services/demo/source_materializer.py | 222 ++++++++++++------ .../contract/test_demo_documents_contract.py | 67 ++++-- .../test_page_memory_retrieval_contract.py | 174 +++++++++++++- .../shared/core/config/storage.py | 16 ++ .../models/database/demo_materialization.py | 10 +- .../services/retrieval/publication_content.py | 13 +- .../services/retrieval/publication_models.py | 2 + .../services/retrieval/publication_service.py | 81 ++++--- .../shared/services/storage/result_storage.py | 81 ++++++- .../tests/test_storage_config_contract.py | 7 + 11 files changed, 576 insertions(+), 149 deletions(-) create mode 100644 apps/api/alembic/versions/0b1c2d3e4f5a_add_demo_materialization_claim_state.py diff --git a/apps/api/alembic/versions/0b1c2d3e4f5a_add_demo_materialization_claim_state.py b/apps/api/alembic/versions/0b1c2d3e4f5a_add_demo_materialization_claim_state.py new file mode 100644 index 000000000..81f676d02 --- /dev/null +++ b/apps/api/alembic/versions/0b1c2d3e4f5a_add_demo_materialization_claim_state.py @@ -0,0 +1,52 @@ +"""Add demo materialization claim state.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0b1c2d3e4f5a" +down_revision: str | Sequence[str] | None = "e4f5a6b7c8d9" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "demo_materializations", + sa.Column("status", sa.String(length=32), nullable=True), + ) + op.add_column( + "demo_materializations", + sa.Column("claimed_at", sa.DateTime(), nullable=True), + ) + op.execute( + "UPDATE demo_materializations SET status = 'ready' WHERE status IS NULL" + ) + op.alter_column( + "demo_materializations", + "status", + existing_type=sa.String(length=32), + nullable=False, + server_default="ready", + ) + op.alter_column( + "demo_materializations", + "document_id", + existing_type=sa.String(length=36), + nullable=True, + ) + + +def downgrade() -> None: + op.alter_column( + "demo_materializations", + "document_id", + existing_type=sa.String(length=36), + nullable=False, + ) + op.drop_column("demo_materializations", "claimed_at") + op.drop_column("demo_materializations", "status") diff --git a/apps/api/app/services/demo/source_materializer.py b/apps/api/app/services/demo/source_materializer.py index df9e2f0c8..0f0bef48f 100644 --- a/apps/api/app/services/demo/source_materializer.py +++ b/apps/api/app/services/demo/source_materializer.py @@ -4,23 +4,25 @@ import shutil import tempfile +from collections.abc import Iterable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from hashlib import blake2b from pathlib import Path from uuid import uuid4 from app.services.demo.source_catalog import DemoSourceCatalog, DemoSourceDefinition -from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession -from shared.core.exceptions.domain_exceptions import ValidationException +from shared.core.exceptions.domain_exceptions import ConflictException, ValidationException from shared.models.database.demo_materialization import DemoMaterialization -from shared.models.database.document import Document from shared.models.database.job import Job from shared.models.database.job_result import JobResult from shared.services.retrieval.cache_service import invalidate_retrieval_cache_namespaces from shared.services.retrieval.publication_service import RetrievalPublicationService +from shared.services.retrieval.publication_models import DocumentPublicationScope from shared.services.storage.result_storage import get_result_storage @@ -73,17 +75,39 @@ async def materialize_sources( self._catalog.require_source(demo_source_id) for demo_source_id in selected_demo_source_ids ] - results: list[MaterializedDemoSource] = [] - for source in selected_sources: - result = await self._materialize_source( + try: + claims = await self._claim_sources( db, user_id=user_id, namespace=namespace, - source=source, + sources=selected_sources, ) - results.append(result) - + except IntegrityError as error: + await db.rollback() + raise ConflictException( + user_message="This demo source is currently being materialized.", + reason="ABORTED", + resource="Demo materialization", + ) from error await db.commit() + results: list[MaterializedDemoSource] = [] + for source in selected_sources: + try: + result = await self._materialize_source( + db, + user_id=user_id, + namespace=namespace, + source=source, + claim=claims[source.demo_source_id], + ) + results.append(result) + except Exception: + await db.rollback() + await self._release_claims( + db, + claims=claims.values(), + ) + raise await invalidate_retrieval_cache_namespaces( user_id=user_id, namespaces=[namespace], @@ -97,29 +121,8 @@ async def _materialize_source( user_id: str, namespace: str, source: DemoSourceDefinition, + claim: DemoMaterialization, ) -> MaterializedDemoSource: - await _lock_materialization_scope( - db, - user_id=user_id, - namespace=namespace, - demo_source_id=source.demo_source_id, - ) - existing = await self._get_existing_materialization( - db, - user_id=user_id, - namespace=namespace, - demo_source_id=source.demo_source_id, - ) - if existing is not None and await self._is_active_document( - db, - document_id=existing.document_id, - ): - return _materialized_source_payload( - source=source, - document_id=existing.document_id, - status="existing", - ) - document_id = f"doc_{uuid4().hex[:12]}" job_id = f"job_demo_{uuid4().hex[:12]}" job_result_id = str(uuid4()) @@ -169,12 +172,13 @@ async def _materialize_source( ) await db.flush() chunks = self._catalog.publication_chunks(source) - await db.run_sync( + published_state = await db.run_sync( lambda sync_db: self._publication_service.publish_document_state( sync_db, job_id=job_id, job_result_id=job_result_id, chunks=[dict(chunk) for chunk in chunks], + update_namespace_snapshot=False, ) ) await db.run_sync( @@ -186,58 +190,114 @@ async def _materialize_source( ) await db.flush() - if existing is None: - db.add( - DemoMaterialization( + if published_state is None or published_state.document_id != document_id: + raise RuntimeError("Demo publication did not create its requested document") + if published_state.manifest_payload is None: + raise RuntimeError("Demo publication did not create a serving manifest") + manifest_payload = published_state.manifest_payload + await db.run_sync( + lambda sync_db: self._publication_service.update_namespace_snapshot( + sync_db, + scope=DocumentPublicationScope( user_id=user_id, namespace=namespace, - demo_source_id=source.demo_source_id, document_id=document_id, - created_at=timestamp, - updated_at=timestamp, - ) + job_result_id=job_result_id, + source_file_name=source.title, + ), + manifest_payload=manifest_payload, ) - else: - existing.document_id = document_id - existing.updated_at = timestamp + ) + await db.commit() + + claim.document_id = document_id + claim.status = "ready" + claim.claimed_at = None + claim.updated_at = timestamp await db.flush() + await db.commit() return _materialized_source_payload( source=source, document_id=document_id, status="created", ) - async def _get_existing_materialization( + async def _claim_sources( self, db: AsyncSession, *, user_id: str, namespace: str, - demo_source_id: str, - ) -> DemoMaterialization | None: - result = await db.execute( - select(DemoMaterialization) - .where(DemoMaterialization.user_id == user_id) - .where(DemoMaterialization.namespace == namespace) - .where(DemoMaterialization.demo_source_id == demo_source_id) - .with_for_update() - .limit(1) - ) - return result.scalar_one_or_none() + sources: list[DemoSourceDefinition], + ) -> dict[str, DemoMaterialization]: + now = _utc_now() + claims: dict[str, DemoMaterialization] = {} + for source in sorted(sources, key=lambda item: item.demo_source_id): + lock_acquired = await db.scalar( + select( + func.pg_try_advisory_xact_lock( + _materialization_lock_id( + user_id=user_id, + namespace=namespace, + demo_source_id=source.demo_source_id, + ) + ) + ) + ) + if not lock_acquired: + raise ConflictException( + user_message="This demo source is currently being materialized.", + reason="ABORTED", + resource="Demo materialization", + resource_id=source.demo_source_id, + ) + result = await db.execute( + select(DemoMaterialization) + .where(DemoMaterialization.user_id == user_id) + .where(DemoMaterialization.namespace == namespace) + .where(DemoMaterialization.demo_source_id == source.demo_source_id) + ) + existing = result.scalar_one_or_none() + if existing is not None: + if not _is_stale_claim(existing, now=now): + raise _materialization_conflict(existing, source) + claim = existing + claim.status = "materializing" + claim.document_id = None + claim.claimed_at = now + claim.updated_at = now + else: + claim = DemoMaterialization( + user_id=user_id, + namespace=namespace, + demo_source_id=source.demo_source_id, + status="materializing", + document_id=None, + claimed_at=now, + created_at=now, + updated_at=now, + ) + db.add(claim) + claims[source.demo_source_id] = claim + await db.flush() + return claims - async def _is_active_document( + async def _release_claims( self, db: AsyncSession, *, - document_id: str, - ) -> bool: - result = await db.execute( - select(Document.document_id) - .where(Document.document_id == document_id) - .where(Document.status == "active") - .limit(1) + claims: Iterable[DemoMaterialization], + ) -> None: + claim_ids = [claim.id for claim in claims] + if not claim_ids: + return + await db.execute( + delete(DemoMaterialization).where( + DemoMaterialization.status == "materializing", + DemoMaterialization.id.in_(claim_ids), + ) ) - return result.scalar_one_or_none() is not None + await db.commit() def _deduplicate_source_ids(demo_source_ids: list[str]) -> list[str]: @@ -252,19 +312,12 @@ def _deduplicate_source_ids(demo_source_ids: list[str]) -> list[str]: return selected -async def _lock_materialization_scope( - db: AsyncSession, - *, - user_id: str, - namespace: str, - demo_source_id: str, -) -> None: - lock_id = _materialization_lock_id( - user_id=user_id, - namespace=namespace, - demo_source_id=demo_source_id, +def _is_stale_claim(materialization: DemoMaterialization, *, now: datetime) -> bool: + return ( + materialization.status == "materializing" + and materialization.claimed_at is not None + and materialization.claimed_at < now - timedelta(minutes=10) ) - await db.execute(select(func.pg_advisory_xact_lock(lock_id))) def _materialization_lock_id( @@ -278,6 +331,23 @@ def _materialization_lock_id( return int.from_bytes(digest, byteorder="big", signed=True) +def _materialization_conflict( + materialization: DemoMaterialization, + source: DemoSourceDefinition, +) -> ConflictException: + reason = "ALREADY_EXISTS" if materialization.status == "ready" else "ABORTED" + return ConflictException( + user_message=( + "This demo source has already been materialized." + if reason == "ALREADY_EXISTS" + else "This demo source is currently being materialized." + ), + reason=reason, + resource="Demo materialization", + resource_id=source.demo_source_id, + ) + + def _materialized_source_payload( *, source: DemoSourceDefinition, diff --git a/apps/api/tests/contract/test_demo_documents_contract.py b/apps/api/tests/contract/test_demo_documents_contract.py index bfe13e105..f2eab17e2 100644 --- a/apps/api/tests/contract/test_demo_documents_contract.py +++ b/apps/api/tests/contract/test_demo_documents_contract.py @@ -365,7 +365,7 @@ async def test_should_materialize_demo_source_without_parse_or_credit_charge( assert empty_cached_response.status_code == 200 assert first_response.status_code == 200 - assert retry_response.status_code == 200 + assert retry_response.status_code == 409 assert retrieval_response.status_code == 200 assert document_chunks_response.status_code == 200 @@ -373,12 +373,10 @@ async def test_should_materialize_demo_source_without_parse_or_credit_charge( assert cast(list[dict[str, Any]], empty_cached_body["results"]) == [] first_source = cast(dict[str, Any], first_response.json()["sources"][0]) - retry_source = cast(dict[str, Any], retry_response.json()["sources"][0]) document_id = str(first_source["document_id"]) assert first_source["status"] == "created" - assert retry_source["status"] == "existing" - assert retry_source["document_id"] == document_id + assert retry_response.json()["error"]["details"]["reason"] == "ALREADY_EXISTS" materialization_rows = await ContractDatabase.fetch_all( """ @@ -517,7 +515,39 @@ async def test_should_materialize_each_normalized_demo_source_once_per_request( @pytest.mark.asyncio -async def test_should_serialize_concurrent_first_demo_materialization( +async def test_should_reject_sequential_duplicate_demo_materialization( + developer_api_client_factory: Callable[ + [], AbstractAsyncContextManager[AsyncClient] + ], + monkeypatch: MonkeyPatch, +) -> None: + fake_result_storage = FakeResultStorage() + + async with developer_api_client_factory() as api_client: + import app.services.demo.source_materializer as source_materializer_module + + monkeypatch.setattr( + source_materializer_module, + "get_result_storage", + lambda: fake_result_storage, + ) + first_response = await api_client.post( + "/api/v1/demo/materializations", + json={"namespace": "contract-demo-duplicate", "demo_source_ids": [DEMO_SOURCE_ID]}, + ) + second_response = await api_client.post( + "/api/v1/demo/materializations", + json={"namespace": "contract-demo-duplicate", "demo_source_ids": [DEMO_SOURCE_ID]}, + ) + + assert first_response.status_code == 200 + assert second_response.status_code == 409 + assert second_response.json()["error"]["details"]["reason"] == "ALREADY_EXISTS" + assert len(fake_result_storage.raw_files_by_job_id) == 1 + + +@pytest.mark.asyncio +async def test_should_reject_concurrent_duplicate_demo_materialization( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] ], @@ -550,19 +580,12 @@ async def test_should_serialize_concurrent_first_demo_materialization( ), ) - assert first_response.status_code == 200 - assert second_response.status_code == 200 - - first_source = cast(dict[str, Any], first_response.json()["sources"][0]) - second_source = cast(dict[str, Any], second_response.json()["sources"][0]) - document_ids = { - str(first_source["document_id"]), - str(second_source["document_id"]), - } - statuses = {str(first_source["status"]), str(second_source["status"])} - - assert len(document_ids) == 1 - assert statuses == {"created", "existing"} + statuses = {first_response.status_code, second_response.status_code} + assert statuses == {200, 409} + conflict_response = ( + first_response if first_response.status_code == 409 else second_response + ) + assert conflict_response.json()["error"]["details"]["reason"] == "ABORTED" materialization_rows = await ContractDatabase.fetch_all( """ @@ -585,12 +608,8 @@ async def test_should_serialize_concurrent_first_demo_materialization( {"demo_source_id": DEMO_SOURCE_ID}, ) - assert materialization_rows == [ - { - "demo_source_id": DEMO_SOURCE_ID, - "document_id": next(iter(document_ids)), - } - ] + assert len(materialization_rows) == 1 + assert materialization_rows[0]["document_id"] is not None assert len(job_rows) == 1 diff --git a/apps/worker/tests/contract/test_page_memory_retrieval_contract.py b/apps/worker/tests/contract/test_page_memory_retrieval_contract.py index 1319db3fb..e9346c7ed 100644 --- a/apps/worker/tests/contract/test_page_memory_retrieval_contract.py +++ b/apps/worker/tests/contract/test_page_memory_retrieval_contract.py @@ -2,6 +2,8 @@ import os import shutil +import threading +import time from pathlib import Path import pytest @@ -29,6 +31,7 @@ from shared.services.retrieval.execution.reference_resolver import ( # noqa: E402 resolve_workflow_references, ) +from shared.core.exceptions.domain_exceptions import StorageServiceException # noqa: E402 from shared.services.storage.result_storage import JobResultStorage # noqa: E402 from shared.services.storage.page_pdf_crop import crop_source_pdf_pages # noqa: E402 @@ -267,7 +270,9 @@ class FakeStorageAdapter: def __init__(self) -> None: self.uploaded_keys: list[str] = [] - def upload_file(self, local_path: str, key: str, bucket: str | None = None): + def upload_file( + self, local_path: str, key: str, bucket: str | None = None + ) -> dict[str, str]: del local_path, bucket self.uploaded_keys.append(key) return {"key": key} @@ -322,6 +327,173 @@ def generate_presigned_url(self, *args, **kwargs) -> str: assert "results/job-1/debug.csv" not in adapter.uploaded_keys +def test_result_storage_uploads_raw_files_with_bounded_concurrency(tmp_path) -> None: + class ConcurrentStorageAdapter: + def __init__(self) -> None: + self.uploaded_keys: list[str] = [] + self.active_uploads = 0 + self.maximum_active_uploads = 0 + self.lock = threading.Lock() + + def upload_file( + self, local_path: str, key: str, bucket: str | None = None + ) -> dict[str, str]: + del local_path, bucket + with self.lock: + self.active_uploads += 1 + self.maximum_active_uploads = max( + self.maximum_active_uploads, + self.active_uploads, + ) + time.sleep(0.01) + with self.lock: + self.uploaded_keys.append(key) + self.active_uploads -= 1 + return {"key": key} + + def generate_presigned_url(self, *args, **kwargs) -> str: + del args, kwargs + return "https://assets.example.test/file" + + result_dir = tmp_path / "result" + result_dir.mkdir() + for index in range(8): + (result_dir / f"asset-{index}.bin").write_bytes(b"asset") + zip_path = tmp_path / "result.zip" + zip_path.write_bytes(b"zip") + adapter = ConcurrentStorageAdapter() + storage = JobResultStorage( + results_bucket="test-results", + storage_adapter=adapter, # type: ignore[arg-type] + upload_concurrency=3, + ) + + bundle = storage.upload( + job_id="job-concurrent", + result_dir=str(result_dir), + zip_file_path=str(zip_path), + ) + + assert len(bundle.raw_files) == 8 + assert adapter.maximum_active_uploads <= 3 + assert adapter.maximum_active_uploads > 1 + + +def test_result_storage_bounds_uploads_across_concurrent_tasks(tmp_path) -> None: + class ProcessStorageAdapter: + def __init__(self) -> None: + self.active_uploads = 0 + self.maximum_active_uploads = 0 + self.lock = threading.Lock() + + def upload_file( + self, local_path: str, key: str, bucket: str | None = None + ) -> dict[str, str]: + del local_path, bucket + if key.endswith(".zip"): + return {"key": key} + with self.lock: + self.active_uploads += 1 + self.maximum_active_uploads = max( + self.maximum_active_uploads, + self.active_uploads, + ) + time.sleep(0.02) + with self.lock: + self.active_uploads -= 1 + return {"key": key} + + def generate_presigned_url(self, *args, **kwargs) -> str: + del args, kwargs + return "https://assets.example.test/file" + + def create_result_directory(name: str) -> tuple[Path, Path]: + result_dir = tmp_path / name + result_dir.mkdir() + for index in range(8): + (result_dir / f"asset-{index}.bin").write_bytes(b"asset") + zip_path = tmp_path / f"{name}.zip" + zip_path.write_bytes(b"zip") + return result_dir, zip_path + + adapter = ProcessStorageAdapter() + first_result_dir, first_zip_path = create_result_directory("first-result") + second_result_dir, second_zip_path = create_result_directory("second-result") + first_storage = JobResultStorage( + storage_adapter=adapter, # type: ignore[arg-type] + upload_concurrency=3, + ) + second_storage = JobResultStorage( + storage_adapter=adapter, # type: ignore[arg-type] + upload_concurrency=3, + ) + failures: list[Exception] = [] + + def upload_result( + storage: JobResultStorage, + job_id: str, + result_dir: Path, + zip_path: Path, + ) -> None: + try: + storage.upload( + job_id=job_id, + result_dir=str(result_dir), + zip_file_path=str(zip_path), + ) + except Exception as exc: + failures.append(exc) + + first_thread = threading.Thread( + target=upload_result, + args=(first_storage, "first-job", first_result_dir, first_zip_path), + ) + second_thread = threading.Thread( + target=upload_result, + args=(second_storage, "second-job", second_result_dir, second_zip_path), + ) + + first_thread.start() + second_thread.start() + first_thread.join() + second_thread.join() + + assert failures == [] + assert adapter.maximum_active_uploads == 3 + + +def test_result_storage_upload_propagates_raw_file_failure(tmp_path) -> None: + class FailingStorageAdapter: + def upload_file(self, local_path: str, key: str, bucket: str | None = None): + del local_path, bucket + if key.endswith("asset-2.bin"): + raise RuntimeError("upload failed") + return {"key": key} + + def generate_presigned_url(self, *args, **kwargs) -> str: + del args, kwargs + return "https://assets.example.test/file" + + result_dir = tmp_path / "result" + result_dir.mkdir() + for index in range(4): + (result_dir / f"asset-{index}.bin").write_bytes(b"asset") + zip_path = tmp_path / "result.zip" + zip_path.write_bytes(b"zip") + storage = JobResultStorage( + results_bucket="test-results", + storage_adapter=FailingStorageAdapter(), # type: ignore[arg-type] + upload_concurrency=2, + ) + + with pytest.raises(StorageServiceException, match="Storage upload failed|upload failed"): + storage.upload( + job_id="job-failure", + result_dir=str(result_dir), + zip_file_path=str(zip_path), + ) + + def test_crop_source_pdf_pages_uploads_and_reuses_page_pdf_cache(tmp_path) -> None: from pypdf import PdfReader, PdfWriter diff --git a/packages/shared-python/shared/core/config/storage.py b/packages/shared-python/shared/core/config/storage.py index e04d69d64..930ae06cd 100644 --- a/packages/shared-python/shared/core/config/storage.py +++ b/packages/shared-python/shared/core/config/storage.py @@ -52,6 +52,12 @@ class StorageConfig(BaseModel): S3_ADDRESSING_STYLE: str = Field( default="auto", description="S3 addressing style: auto, path, or virtual" ) + S3_MAX_POOL_CONNECTIONS: int = Field( + default=20, + ge=1, + le=256, + description="Maximum connections retained by the shared S3 client pool.", + ) # OSS-only configuration. OSS_ENDPOINT: str = Field( @@ -96,6 +102,15 @@ class StorageConfig(BaseModel): le=10, description="Maximum concurrent MinerU API calls for shard parsing.", ) + RESULT_UPLOAD_CONCURRENCY: int = Field( + default=20, + ge=1, + le=64, + description=( + "Maximum concurrent raw result-file uploads per materialization. " + "ZIP bundles remain single-object uploads." + ), + ) SUPPORTED_EXTENSIONS: str = Field( default=".doc,.docx,.pdf,.txt,.xls,.xlsx,.pptx,.jpg,.jpeg,.png,.md,.html,.htm", description="Supported file extensions", @@ -146,6 +161,7 @@ def get_s3_client(self) -> BaseClient: # Configure retries. config_kwargs["retries"] = {"max_attempts": 5, "mode": "standard"} + config_kwargs["max_pool_connections"] = self.S3_MAX_POOL_CONNECTIONS config = Config(**config_kwargs) if config_kwargs else None diff --git a/packages/shared-python/shared/models/database/demo_materialization.py b/packages/shared-python/shared/models/database/demo_materialization.py index d4cc1a7a1..32bbc950b 100644 --- a/packages/shared-python/shared/models/database/demo_materialization.py +++ b/packages/shared-python/shared/models/database/demo_materialization.py @@ -29,10 +29,16 @@ class DemoMaterialization(Base): String(255), nullable=False, default="default" ) demo_source_id: Mapped[str] = mapped_column(String(128), nullable=False) - document_id: Mapped[str] = mapped_column( + status: Mapped[str] = mapped_column( + String(32), nullable=False, default="ready" + ) + document_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("documents.document_id", ondelete="CASCADE"), - nullable=False, + nullable=True, + ) + claimed_at: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True ) created_at: Mapped[datetime] = mapped_column( DateTime, default=utc_now_naive, nullable=False diff --git a/packages/shared-python/shared/services/retrieval/publication_content.py b/packages/shared-python/shared/services/retrieval/publication_content.py index 7ce75b808..90ab74e79 100644 --- a/packages/shared-python/shared/services/retrieval/publication_content.py +++ b/packages/shared-python/shared/services/retrieval/publication_content.py @@ -13,9 +13,6 @@ DocumentSection, ) from shared.services.retrieval.map_unit_index import replace_document_map_units -from shared.services.retrieval.namespace_map_snapshot import ( - patch_namespace_map_snapshot, -) from shared.services.retrieval.publication_models import DocumentPublicationScope from shared.services.retrieval.serving_manifest import persist_revision_serving_state from shared.services.retrieval.search.lexical_text import ( @@ -63,7 +60,7 @@ def replace_document_revision_content( scope: DocumentPublicationScope, chunks: list[dict[str, Any]], section_summaries: dict[str, str] | None = None, -) -> None: +) -> dict[str, Any]: """Replace retrieval sections and chunks for one published document revision.""" _delete_existing_revision_content(db, scope=scope) section_publisher = DocumentSectionPublisher( @@ -71,6 +68,7 @@ def replace_document_revision_content( scope=scope, section_summaries=section_summaries, ) + prepared_chunks: list[tuple[int, dict[str, Any], dict[str, Any], str | None, DocumentSection]] = [] for index, chunk in enumerate(chunks): safe_chunk = cast(dict[str, Any], remove_nul_characters(chunk)) chunk_metadata = _get_chunk_metadata(safe_chunk) @@ -83,6 +81,9 @@ def replace_document_revision_content( source_file_name=scope.source_file_name, ) section = section_publisher.ensure_section(section_path) + prepared_chunks.append((index, safe_chunk, chunk_metadata, source_path, section)) + db.flush() + for index, safe_chunk, chunk_metadata, source_path, section in prepared_chunks: db.add( _build_document_chunk( chunk=safe_chunk, @@ -97,7 +98,7 @@ def replace_document_revision_content( replace_document_map_units(db, scope=scope) db.flush() manifest_payload = persist_revision_serving_state(db, scope=scope) - patch_namespace_map_snapshot(db, scope=scope, manifest_payload=manifest_payload) + return manifest_payload class DocumentSectionPublisher: @@ -128,6 +129,7 @@ def ensure_section(self, section_path: str) -> DocumentSection: continue ancestor_section = DocumentSection( + section_id=f"sec_{uuid4().hex[:12]}", user_id=self._scope.user_id, namespace=self._scope.namespace, document_id=self._scope.document_id, @@ -141,7 +143,6 @@ def ensure_section(self, section_path: str) -> DocumentSection: summary=self._section_summaries.get(ancestor_path) or None, ) self._db.add(ancestor_section) - self._db.flush() self._sections_by_path[ancestor_path] = ancestor_section return self._sections_by_path[section_path] diff --git a/packages/shared-python/shared/services/retrieval/publication_models.py b/packages/shared-python/shared/services/retrieval/publication_models.py index 8c653212c..0fdea6b11 100644 --- a/packages/shared-python/shared/services/retrieval/publication_models.py +++ b/packages/shared-python/shared/services/retrieval/publication_models.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any @dataclass(frozen=True) @@ -15,6 +16,7 @@ class PublishedDocumentState: namespace: str document_id: str | None skipped_all_duplicate: bool = False + manifest_payload: dict[str, Any] | None = None @dataclass(frozen=True) diff --git a/packages/shared-python/shared/services/retrieval/publication_service.py b/packages/shared-python/shared/services/retrieval/publication_service.py index 7ee5dec2b..3cdd8ddc0 100644 --- a/packages/shared-python/shared/services/retrieval/publication_service.py +++ b/packages/shared-python/shared/services/retrieval/publication_service.py @@ -23,6 +23,7 @@ from shared.models.schemas.retrieval_namespace import normalize_retrieval_namespace from shared.services.retrieval.graph.service import DocumentGraphService, GraphScope from shared.services.retrieval.namespace_map_snapshot import ( + patch_namespace_map_snapshot, remove_document_from_namespace_map_snapshot, ) from shared.services.retrieval.publication_content import ( @@ -81,6 +82,7 @@ def publish_document_state( job_result_id: str, chunks: list[dict[str, Any]], section_summaries: dict[str, str] | None = None, + update_namespace_snapshot: bool = True, ) -> PublishedDocumentState | None: job = db.execute(select(Job).where(Job.job_id == job_id)).scalar_one_or_none() if not job: @@ -93,6 +95,7 @@ def publish_document_state( job_result_id=job_result_id, chunks=chunks, section_summaries=section_summaries, + update_namespace_snapshot=update_namespace_snapshot, ) def _publish_document_state_for_job( @@ -103,6 +106,7 @@ def _publish_document_state_for_job( job_result_id: str, chunks: list[dict[str, Any]], section_summaries: dict[str, str] | None = None, + update_namespace_snapshot: bool = True, ) -> PublishedDocumentState | None: job_metadata = job.job_metadata or {} @@ -136,16 +140,6 @@ def _publish_document_state_for_job( Document.document_id == str(document_id) ) ).scalar_one_or_none() - namespaces_to_lock = {namespace} - if existing_namespace: - namespaces_to_lock.add(str(existing_namespace)) - for namespace_to_lock in sorted(namespaces_to_lock): - lock_namespace_generation( - db, - user_id=str(job.user_id), - namespace=namespace_to_lock, - ) - document = self._upsert_document_revision( db, job=job, @@ -172,7 +166,7 @@ def _publish_document_state_for_job( job_result_id=job_result_id, source_file_name=str(source_file_name) if source_file_name else None, ) - replace_document_revision_content( + manifest_payload = replace_document_revision_content( db, scope=scope, chunks=deduped_chunks, @@ -180,32 +174,61 @@ def _publish_document_state_for_job( ) db.flush() - if existing_namespace and str(existing_namespace) != scope.namespace: - remove_document_from_namespace_map_snapshot( + if update_namespace_snapshot: + self.update_namespace_snapshot( db, - user_id=scope.user_id, - namespace=str(existing_namespace), - document_id=document.document_id, + scope=scope, + manifest_payload=manifest_payload, + previous_namespace=( + str(existing_namespace) + if existing_namespace and str(existing_namespace) != scope.namespace + else None + ), ) - # A namespace move mutates both namespace snapshots. Advance the - # old namespace generation as well so request-scoped/process-local - # snapshot caches cannot reuse the pre-move generation. - advance_namespace_generation( - db, - user_id=scope.user_id, - namespace=str(existing_namespace), - ) - advance_namespace_generation( - db, - user_id=scope.user_id, - namespace=scope.namespace, - ) return PublishedDocumentState( user_id=str(job.user_id), namespace=namespace, document_id=document.document_id, + manifest_payload=manifest_payload, ) + def update_namespace_snapshot( + self, + db: Session, + *, + scope: DocumentPublicationScope, + manifest_payload: dict[str, Any], + previous_namespace: str | None = None, + ) -> None: + """Patch one namespace snapshot while holding only its short lock.""" + namespaces = [scope.namespace] + if previous_namespace: + namespaces.insert(0, previous_namespace) + for namespace in namespaces: + lock_namespace_generation( + db, + user_id=scope.user_id, + namespace=namespace, + ) + if namespace == scope.namespace: + patch_namespace_map_snapshot( + db, + scope=scope, + manifest_payload=manifest_payload, + ) + else: + remove_document_from_namespace_map_snapshot( + db, + user_id=scope.user_id, + namespace=namespace, + document_id=scope.document_id, + ) + advance_namespace_generation( + db, + user_id=scope.user_id, + namespace=namespace, + ) + def _upsert_document_revision( self, db: Session, diff --git a/packages/shared-python/shared/services/storage/result_storage.py b/packages/shared-python/shared/services/storage/result_storage.py index 51d3ab3ce..9a5643fb1 100644 --- a/packages/shared-python/shared/services/storage/result_storage.py +++ b/packages/shared-python/shared/services/storage/result_storage.py @@ -1,13 +1,16 @@ from __future__ import annotations import os +import threading from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from typing import Protocol from loguru import logger +from shared.core.config import settings from shared.services.storage.job_file_storage import JobFileStorage from shared.services.storage.storage_adapter import StorageAdapter @@ -15,6 +18,9 @@ _EXCLUDED_DIR_NAMES = {"tmp", "temp", "__pycache__"} _CLIENT_ARTIFACT_DIRS = {"images", "tables", "page_pdfs", "page_citation_assets"} _INTERNAL_RAW_FILES = {"source.pdf"} +_MAX_RESULT_UPLOAD_CONCURRENCY = 64 +_upload_executor_lock = threading.Lock() +_upload_executors: dict[int, ThreadPoolExecutor] = {} @dataclass(frozen=True) @@ -58,12 +64,17 @@ def __init__( *, results_bucket: str | None = None, storage_adapter: StorageAdapter | None = None, + upload_concurrency: int | None = None, ) -> None: self._job_file_storage = JobFileStorage( storage_adapter=storage_adapter, results_bucket=results_bucket, ) self.results_bucket = self._job_file_storage.results_bucket + configured_concurrency = getattr(settings, "RESULT_UPLOAD_CONCURRENCY", 20) + self.upload_concurrency = self._validate_upload_concurrency( + configured_concurrency if upload_concurrency is None else upload_concurrency + ) def build_zip_key(self, *, job_id: str) -> str: return self._job_file_storage.build_result_zip_key(job_id=job_id) @@ -109,19 +120,33 @@ def upload( ) self._cleanup_file(zip_path) - raw_files: dict[str, str] = {} artifact_ref_filter = self._normalize_artifact_refs(artifact_refs) - for file_path in self._iter_raw_files(result_path): - relative_path = file_path.relative_to(result_path).as_posix() - if artifact_ref_filter is not None and relative_path not in artifact_ref_filter: - continue - raw_key = self.build_raw_key(job_id=job_id, relative_path=relative_path) - self._job_file_storage.upload_local_file( - str(file_path), - raw_key, - bucket=self.results_bucket, + upload_items = [ + ( + file_path, + file_path.relative_to(result_path).as_posix(), + ) + for file_path in self._iter_raw_files(result_path) + if artifact_ref_filter is None + or file_path.relative_to(result_path).as_posix() in artifact_ref_filter + ] + executor = self._get_upload_executor() + futures = [ + executor.submit( + self._upload_raw_file, + job_id=job_id, + file_path=file_path, + relative_path=relative_path, ) - raw_files[relative_path] = raw_key + for file_path, relative_path in upload_items + ] + try: + uploaded_items = [future.result() for future in futures] + except Exception: + for future in futures: + future.cancel() + raise + raw_files = dict(uploaded_items) return UploadedResultBundle( zip_key=zip_key, @@ -129,6 +154,40 @@ def upload( raw_files=raw_files, ) + def _upload_raw_file( + self, + *, + job_id: str, + file_path: Path, + relative_path: str, + ) -> tuple[str, str]: + raw_key = self.build_raw_key(job_id=job_id, relative_path=relative_path) + self._job_file_storage.upload_local_file( + str(file_path), + raw_key, + bucket=self.results_bucket, + ) + return relative_path, raw_key + + def _validate_upload_concurrency(self, upload_concurrency: int) -> int: + if not 1 <= upload_concurrency <= _MAX_RESULT_UPLOAD_CONCURRENCY: + raise ValueError( + "upload_concurrency must be between 1 and " + f"{_MAX_RESULT_UPLOAD_CONCURRENCY}" + ) + return upload_concurrency + + def _get_upload_executor(self) -> ThreadPoolExecutor: + with _upload_executor_lock: + executor = _upload_executors.get(self.upload_concurrency) + if executor is None: + executor = ThreadPoolExecutor( + max_workers=self.upload_concurrency, + thread_name_prefix="result-upload", + ) + _upload_executors[self.upload_concurrency] = executor + return executor + def generate_url(self, *, storage_key: str, expires_in: int = 3600) -> str | None: return self._job_file_storage.generate_download_url( storage_key, diff --git a/packages/shared-python/shared/tests/test_storage_config_contract.py b/packages/shared-python/shared/tests/test_storage_config_contract.py index d7dce29ef..8931688f0 100644 --- a/packages/shared-python/shared/tests/test_storage_config_contract.py +++ b/packages/shared-python/shared/tests/test_storage_config_contract.py @@ -43,6 +43,13 @@ def test_aws_s3_uses_default_credential_chain_when_keys_are_empty( assert client_arguments["region_name"] == "us-east-1" assert "aws_access_key_id" not in client_arguments assert "aws_secret_access_key" not in client_arguments + assert client_arguments["config"].max_pool_connections == 20 + + +def test_result_upload_concurrency_defaults_to_twenty() -> None: + config: StorageConfig = create_storage_config() + + assert config.RESULT_UPLOAD_CONCURRENCY == 20 def test_aws_s3_passes_complete_explicit_credentials( From 21e09514b7549d062ee6764c6286e34a1cd38d1c Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Sat, 12 Sep 2026 00:46:59 +0800 Subject: [PATCH 2/7] fix(retrieval): retain connected body chunks for asset filters --- AGENTS.md | 4 +- .../retrieval/hydration/result_assembly.py | 37 +++++++++--- .../shared/tests/test_asset_inline.py | 60 +++++++++++++++++++ 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa13a28fb..4dc21c38c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -589,8 +589,8 @@ are resolved to chunks, then assembled like classic. Map-nav is archived at `hydration.result_assembly.assemble_retrieval_results()`: 1. Filters by `exclude_document_ids` and `exclude_sections` -2. Filters by `allowed_chunk_types` (data_type parameter) -3. Hydrates `connect_to` targets (related table chunks inlined into text) +2. Hydrates `connect_to` targets +3. Filters by `allowed_chunk_types` while retaining body chunks connected to a requested image/table 4. Cleans asset path references from content 5. Public projection builds `source`: `{document_id, source_file_name, section_path}` plus `page_nums` for `chunk_type=page` when present diff --git a/packages/shared-python/shared/services/retrieval/hydration/result_assembly.py b/packages/shared-python/shared/services/retrieval/hydration/result_assembly.py index 610bf99fa..96a38fd64 100644 --- a/packages/shared-python/shared/services/retrieval/hydration/result_assembly.py +++ b/packages/shared-python/shared/services/retrieval/hydration/result_assembly.py @@ -30,20 +30,15 @@ async def assemble_retrieval_results( allowed_chunk_types: set[str] | None = None, revision_pins: Mapping[str, str] | None = None, ) -> list[dict[str, Any]]: - filtered_rows = filter_excluded_rows( + scoped_rows = filter_excluded_rows( rows, exclude_document_ids=exclude_document_ids, exclude_sections=exclude_sections, document_scope=document_scope, ) - if allowed_chunk_types is not None: - filtered_rows = [ - row for row in filtered_rows - if normalize_chunk_type(row.get('chunk_type')) in allowed_chunk_types - ] hydrated_rows = await hydrate_connected_target_rows( db=db, - rows=filtered_rows, + rows=scoped_rows, exclude_document_ids=exclude_document_ids, exclude_sections=exclude_sections, document_scope=document_scope, @@ -51,9 +46,14 @@ async def assemble_retrieval_results( ) rows_by_chunk_id = { str(row.get('chunk_id') or ''): row - for row in [*filtered_rows, *hydrated_rows] + for row in [*scoped_rows, *hydrated_rows] if row.get('chunk_id') } + filtered_rows = _filter_rows_by_allowed_chunk_types( + scoped_rows, + allowed_chunk_types=allowed_chunk_types, + rows_by_chunk_id=rows_by_chunk_id, + ) embedded_targets: set[str] = set() for row in filtered_rows: @@ -88,6 +88,27 @@ async def assemble_retrieval_results( return assembled +def _filter_rows_by_allowed_chunk_types( + rows: list[dict[str, Any]], + *, + allowed_chunk_types: set[str] | None, + rows_by_chunk_id: Mapping[str, dict[str, Any]], +) -> list[dict[str, Any]]: + if allowed_chunk_types is None: + return rows + + return [ + row + for row in rows + if normalize_chunk_type(row.get('chunk_type')) in allowed_chunk_types + or any( + normalize_chunk_type(rows_by_chunk_id.get(target_id, {}).get('chunk_type')) + in allowed_chunk_types + for target_id in iter_connected_target_ids(row) + ) + ] + + def _page_summary(row: dict[str, Any]) -> str: metadata = row.get('chunk_metadata') or row.get('metadata') or {} if not isinstance(metadata, dict): diff --git a/packages/shared-python/shared/tests/test_asset_inline.py b/packages/shared-python/shared/tests/test_asset_inline.py index d8345c24b..75b09c049 100644 --- a/packages/shared-python/shared/tests/test_asset_inline.py +++ b/packages/shared-python/shared/tests/test_asset_inline.py @@ -101,6 +101,66 @@ async def test_assemble_inserts_table_at_placeholder() -> None: assert "SHOULD NOT LEAK" not in content +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("asset_type", "file_path", "placeholder", "display_marker"), + [ + ("image", "images/a.png", "[images/a.png]", "[Image: images/a.png]"), + ("table", "tables/a.html", "[tables/a.html]", "[Table: tables/a.html]"), + ], +) +async def test_asset_type_filter_keeps_body_that_connects_to_requested_asset( + monkeypatch, + asset_type: str, + file_path: str, + placeholder: str, + display_marker: str, +) -> None: + body_row = { + "chunk_id": "text-1", + "chunk_type": "text", + "content": f"查看 {placeholder}", + "chunk_metadata": { + "connect_to": [ + { + "target": "asset-1", + "relation": "embeds", + "ref": placeholder, + } + ] + }, + } + asset_row = { + "chunk_id": "asset-1", + "chunk_type": asset_type, + "content": "资产说明" if asset_type == "image" else "
", + "file_path": file_path, + "chunk_metadata": {"summary": "资产说明"}, + } + + async def hydrate_connected_rows(**_kwargs: object) -> list[dict[str, object]]: + return [asset_row] + + monkeypatch.setattr( + "shared.services.retrieval.hydration.result_assembly.hydrate_connected_target_rows", + hydrate_connected_rows, + ) + + assembled = await assemble_retrieval_results( + rows=[ + body_row, + {"chunk_id": "text-2", "chunk_type": "text", "content": "无图"}, + ], + exclude_document_ids=[], + exclude_sections=[], + allowed_chunk_types={asset_type}, + ) + + assert [row["chunk_id"] for row in assembled] == ["text-1"] + assert display_marker in assembled[0]["content"] + assert "资产说明" in assembled[0]["content"] + + def test_node_unit_span_inlines_section_assets() -> None: provider = KnowhereProvider( doc_id="doc-1", From 02c7960a8e14b376fabb0ba57c6626d88a3596f8 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Sat, 12 Sep 2026 00:53:38 +0800 Subject: [PATCH 3/7] perf: bulk materialization publication writes --- .../app/services/demo/source_materializer.py | 122 ++++++++++----- .../shared/core/config/database.py | 16 ++ .../services/jobs/lifecycle/publication.py | 42 ++++-- .../shared/services/redis/__init__.py | 4 + .../services/redis/publication_semaphore.py | 142 ++++++++++++++++++ .../shared/services/redis/redis_service.py | 26 ++++ .../services/retrieval/map_unit_index.py | 71 ++++++--- 7 files changed, 351 insertions(+), 72 deletions(-) create mode 100644 packages/shared-python/shared/services/redis/publication_semaphore.py diff --git a/apps/api/app/services/demo/source_materializer.py b/apps/api/app/services/demo/source_materializer.py index 0f0bef48f..1e903f53d 100644 --- a/apps/api/app/services/demo/source_materializer.py +++ b/apps/api/app/services/demo/source_materializer.py @@ -4,6 +4,7 @@ import shutil import tempfile +import time from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -11,6 +12,8 @@ from pathlib import Path from uuid import uuid4 +import logfire + from app.services.demo.source_catalog import DemoSourceCatalog, DemoSourceDefinition from sqlalchemy.exc import IntegrityError from sqlalchemy import delete, func, select @@ -23,6 +26,8 @@ from shared.services.retrieval.cache_service import invalidate_retrieval_cache_namespaces from shared.services.retrieval.publication_service import RetrievalPublicationService from shared.services.retrieval.publication_models import DocumentPublicationScope +from shared.services.redis import RedisPublicationSemaphore, RedisServiceFactory +from shared.core.config import settings from shared.services.storage.result_storage import get_result_storage @@ -50,6 +55,7 @@ def __init__( ) -> None: self._catalog = catalog self._publication_service = publication_service or RetrievalPublicationService() + self._redis_service = RedisServiceFactory.get_service() async def materialize_sources( self, @@ -93,12 +99,19 @@ async def materialize_sources( results: list[MaterializedDemoSource] = [] for source in selected_sources: try: + semaphore = RedisPublicationSemaphore( + self._redis_service, + concurrency=settings.MATERIALIZATION_DB_PUBLICATION_CONCURRENCY, + lease_seconds=settings.MATERIALIZATION_DB_PUBLICATION_LEASE_SECONDS, + acquire_timeout_seconds=settings.MATERIALIZATION_DB_PUBLICATION_ACQUIRE_TIMEOUT_SECONDS, + ) result = await self._materialize_source( db, user_id=user_id, namespace=namespace, source=source, claim=claims[source.demo_source_id], + semaphore=semaphore, ) results.append(result) except Exception: @@ -122,15 +135,22 @@ async def _materialize_source( namespace: str, source: DemoSourceDefinition, claim: DemoMaterialization, + semaphore: RedisPublicationSemaphore, ) -> MaterializedDemoSource: document_id = f"doc_{uuid4().hex[:12]}" job_id = f"job_demo_{uuid4().hex[:12]}" job_result_id = str(uuid4()) timestamp = _utc_now() + stage_started_at = time.perf_counter() result_bundle = _upload_demo_result_bundle( job_id=job_id, source_directory=self._catalog.source_directory(source), ) + logfire.info( + "Demo materialization source bundle upload completed", + demo_source_id=source.demo_source_id, + duration_seconds=time.perf_counter() - stage_started_at, + ) db.add( Job( @@ -170,45 +190,79 @@ async def _materialize_source( updated_at=timestamp, ) ) - await db.flush() chunks = self._catalog.publication_chunks(source) - published_state = await db.run_sync( - lambda sync_db: self._publication_service.publish_document_state( - sync_db, - job_id=job_id, - job_result_id=job_result_id, - chunks=[dict(chunk) for chunk in chunks], - update_namespace_snapshot=False, - ) + stage_started_at = time.perf_counter() + wait_seconds = await semaphore.acquire() + logfire.info( + "Demo materialization publication semaphore acquired", + demo_source_id=source.demo_source_id, + wait_seconds=wait_seconds, ) - await db.run_sync( - lambda sync_db: self._publication_service.publish_document_graph( - sync_db, - job_id=job_id, - job_result_id=job_result_id, + try: + base_rows_started_at = time.perf_counter() + await db.flush() + logfire.info( + "Demo materialization base rows completed", + demo_source_id=source.demo_source_id, + duration_seconds=time.perf_counter() - base_rows_started_at, ) - ) - await db.flush() - - if published_state is None or published_state.document_id != document_id: - raise RuntimeError("Demo publication did not create its requested document") - if published_state.manifest_payload is None: - raise RuntimeError("Demo publication did not create a serving manifest") - manifest_payload = published_state.manifest_payload - await db.run_sync( - lambda sync_db: self._publication_service.update_namespace_snapshot( - sync_db, - scope=DocumentPublicationScope( - user_id=user_id, - namespace=namespace, - document_id=document_id, + published_state = await db.run_sync( + lambda sync_db: self._publication_service.publish_document_state( + sync_db, + job_id=job_id, job_result_id=job_result_id, - source_file_name=source.title, - ), - manifest_payload=manifest_payload, + chunks=[dict(chunk) for chunk in chunks], + update_namespace_snapshot=False, + ) ) - ) - await db.commit() + await db.run_sync( + lambda sync_db: self._publication_service.publish_document_graph( + sync_db, + job_id=job_id, + job_result_id=job_result_id, + ) + ) + await db.flush() + logfire.info( + "Demo materialization sections chunks and map index completed", + demo_source_id=source.demo_source_id, + duration_seconds=time.perf_counter() - stage_started_at, + chunk_count=len(chunks), + ) + + if published_state is None or published_state.document_id != document_id: + raise RuntimeError("Demo publication did not create its requested document") + if published_state.manifest_payload is None: + raise RuntimeError("Demo publication did not create a serving manifest") + manifest_payload = published_state.manifest_payload + stage_started_at = time.perf_counter() + await db.run_sync( + lambda sync_db: self._publication_service.update_namespace_snapshot( + sync_db, + scope=DocumentPublicationScope( + user_id=user_id, + namespace=namespace, + document_id=document_id, + job_result_id=job_result_id, + source_file_name=source.title, + ), + manifest_payload=manifest_payload, + ) + ) + logfire.info( + "Demo materialization namespace snapshot completed", + demo_source_id=source.demo_source_id, + duration_seconds=time.perf_counter() - stage_started_at, + ) + commit_started_at = time.perf_counter() + await db.commit() + logfire.info( + "Demo materialization publication commit completed", + demo_source_id=source.demo_source_id, + duration_seconds=time.perf_counter() - commit_started_at, + ) + finally: + await semaphore.release() claim.document_id = document_id claim.status = "ready" diff --git a/packages/shared-python/shared/core/config/database.py b/packages/shared-python/shared/core/config/database.py index a9baac5f5..53d1b97d1 100644 --- a/packages/shared-python/shared/core/config/database.py +++ b/packages/shared-python/shared/core/config/database.py @@ -47,6 +47,22 @@ class DatabaseConfig(BaseModel): default=50, description="Celery gevent worker concurrency" ) + MATERIALIZATION_DB_PUBLICATION_CONCURRENCY: int = Field( + default=2, + ge=1, + description="Maximum concurrent materialization database publications", + ) + MATERIALIZATION_DB_PUBLICATION_LEASE_SECONDS: int = Field( + default=900, + ge=60, + description="Redis lease duration for a materialization publication permit", + ) + MATERIALIZATION_DB_PUBLICATION_ACQUIRE_TIMEOUT_SECONDS: float = Field( + default=30.0, + ge=0.1, + description="Maximum time to wait for a materialization publication permit", + ) + def get_ssl_connect_args(self) -> dict: """Return SSL connect args for psycopg2.""" ssl_args = {"sslmode": self.DB_SSL_MODE} diff --git a/packages/shared-python/shared/services/jobs/lifecycle/publication.py b/packages/shared-python/shared/services/jobs/lifecycle/publication.py index a8a3e7473..4ed9df81a 100644 --- a/packages/shared-python/shared/services/jobs/lifecycle/publication.py +++ b/packages/shared-python/shared/services/jobs/lifecycle/publication.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import nullcontext from dataclasses import dataclass from typing import Any @@ -11,6 +12,8 @@ from shared.models.schemas.job_metadata import JobMetadataHelper from shared.models.schemas.retrieval_namespace import normalize_retrieval_namespace from shared.services.redis.redis_sync_service import SyncRedisServiceFactory +from shared.services.redis.publication_semaphore import SyncRedisPublicationSemaphore +from shared.core.config import settings from shared.services.retrieval.publication_service import RetrievalPublicationService from shared.services.retrieval.publication_models import ( ExistingDocumentScope, @@ -53,25 +56,38 @@ def publish_result( section_summaries: dict[str, str] | None, document_top_summary: str | None = None, ) -> JobPublicationOutcome: - previous_document_scope = self._retrieval_publication.get_existing_document_scope( - db, - job_id=job_id, + semaphore = SyncRedisPublicationSemaphore( + SyncRedisServiceFactory.get_service(), + concurrency=settings.MATERIALIZATION_DB_PUBLICATION_CONCURRENCY, + lease_seconds=settings.MATERIALIZATION_DB_PUBLICATION_LEASE_SECONDS, + acquire_timeout_seconds=settings.MATERIALIZATION_DB_PUBLICATION_ACQUIRE_TIMEOUT_SECONDS, ) - published_document_state = self._retrieval_publication.publish_document_state( - db, - job_id=job_id, - job_result_id=job_result_id, - chunks=chunks, - section_summaries=section_summaries, + job_type = db.execute( + select(Job.job_type).where(Job.job_id == job_id) + ).scalar_one_or_none() + publication_context = ( + semaphore if job_type == "demo_materialization" else nullcontext() ) - if _should_publish_document_graph(published_document_state): - assert published_document_state is not None - self._retrieval_publication.publish_document_graph( + with publication_context: + previous_document_scope = self._retrieval_publication.get_existing_document_scope( + db, + job_id=job_id, + ) + published_document_state = self._retrieval_publication.publish_document_state( db, job_id=job_id, job_result_id=job_result_id, - top_summary=document_top_summary, + chunks=chunks, + section_summaries=section_summaries, ) + if _should_publish_document_graph(published_document_state): + assert published_document_state is not None + self._retrieval_publication.publish_document_graph( + db, + job_id=job_id, + job_result_id=job_result_id, + top_summary=document_top_summary, + ) cache_invalidation = self._build_cache_invalidation( db, diff --git a/packages/shared-python/shared/services/redis/__init__.py b/packages/shared-python/shared/services/redis/__init__.py index a0cb3f46e..f61986a6e 100644 --- a/packages/shared-python/shared/services/redis/__init__.py +++ b/packages/shared-python/shared/services/redis/__init__.py @@ -20,6 +20,8 @@ __all__ = [ "RedisService", "RedisServiceFactory", + "RedisPublicationSemaphore", + "SyncRedisPublicationSemaphore", "RedisMonitor", "RedisAlertManager", "RedisAlertNotifier", @@ -38,6 +40,8 @@ _EXPORT_MODULES: dict[str, str] = { "RedisService": "shared.services.redis.redis_service", "RedisServiceFactory": "shared.services.redis.redis_service_factory", + "RedisPublicationSemaphore": "shared.services.redis.publication_semaphore", + "SyncRedisPublicationSemaphore": "shared.services.redis.publication_semaphore", "RedisMonitor": "shared.services.redis.redis_monitor", "RedisAlertManager": "shared.services.redis.redis_alerts", "RedisAlertNotifier": "shared.services.redis.redis_alerts", diff --git a/packages/shared-python/shared/services/redis/publication_semaphore.py b/packages/shared-python/shared/services/redis/publication_semaphore.py new file mode 100644 index 000000000..742b6f73e --- /dev/null +++ b/packages/shared-python/shared/services/redis/publication_semaphore.py @@ -0,0 +1,142 @@ +"""Cross-instance concurrency control for database publication.""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from typing import Any + +from loguru import logger + +from shared.services.redis.redis_service import RedisService +from shared.services.redis.redis_sync_service import SyncRedisService + +_RELEASE_SCRIPT = """ +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) +end +return 0 +""" + + +class RedisPublicationSemaphore: + """Lease-based Redis semaphore using one key per permit.""" + + def __init__( + self, + redis_service: RedisService, + *, + concurrency: int, + lease_seconds: int, + acquire_timeout_seconds: float, + poll_interval_seconds: float = 0.05, + ) -> None: + if concurrency < 1: + raise ValueError("concurrency must be at least 1") + self._redis = redis_service + self._concurrency = concurrency + self._lease_seconds = lease_seconds + self._acquire_timeout_seconds = acquire_timeout_seconds + self._poll_interval_seconds = poll_interval_seconds + self._owner = uuid.uuid4().hex + self._permit_key: str | None = None + + async def acquire(self) -> float: + """Acquire a permit and return the time spent waiting in seconds.""" + started_at = time.perf_counter() + deadline = started_at + self._acquire_timeout_seconds + while time.perf_counter() < deadline: + for permit_number in range(self._concurrency): + key = f"lock:materialization_publication:{permit_number}" + acquired = await self._redis.set_nx( + key, + self._owner, + ex=self._lease_seconds, + ) + if acquired: + self._permit_key = key + return time.perf_counter() - started_at + await asyncio.sleep(self._poll_interval_seconds) + raise TimeoutError("Timed out waiting for a materialization publication permit") + + async def release(self) -> bool: + """Release this semaphore's permit only when still its owner.""" + if self._permit_key is None: + return False + key = self._permit_key + self._permit_key = None + try: + result = await self._redis.eval( + _RELEASE_SCRIPT, + keys=[key], + args=[self._owner], + ) + return bool(result) + except Exception as error: + logger.warning(f"Failed to release publication permit: {error}") + return False + + async def __aenter__(self) -> "RedisPublicationSemaphore": + await self.acquire() + return self + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + await self.release() + + +class SyncRedisPublicationSemaphore: + """Synchronous lease-based semaphore for gevent worker publication.""" + + def __init__( + self, + redis_service: SyncRedisService, + *, + concurrency: int, + lease_seconds: int, + acquire_timeout_seconds: float, + poll_interval_seconds: float = 0.05, + ) -> None: + if concurrency < 1: + raise ValueError("concurrency must be at least 1") + self._redis = redis_service + self._concurrency = concurrency + self._lease_seconds = lease_seconds + self._acquire_timeout_seconds = acquire_timeout_seconds + self._poll_interval_seconds = poll_interval_seconds + self._owner = uuid.uuid4().hex + self._permit_key: str | None = None + + def acquire(self) -> float: + """Acquire a permit and return the time spent waiting in seconds.""" + started_at = time.perf_counter() + deadline = started_at + self._acquire_timeout_seconds + while time.perf_counter() < deadline: + for permit_number in range(self._concurrency): + key = f"lock:materialization_publication:{permit_number}" + if self._redis.set_nx(key, self._owner, ex=self._lease_seconds): + self._permit_key = key + return time.perf_counter() - started_at + time.sleep(self._poll_interval_seconds) + raise TimeoutError("Timed out waiting for a materialization publication permit") + + def release(self) -> bool: + """Release this semaphore's permit only when still its owner.""" + if self._permit_key is None: + return False + key = self._permit_key + self._permit_key = None + try: + return bool( + self._redis.eval(_RELEASE_SCRIPT, keys=[key], args=[self._owner]) + ) + except Exception as error: + logger.warning(f"Failed to release publication permit: {error}") + return False + + def __enter__(self) -> "SyncRedisPublicationSemaphore": + self.acquire() + return self + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + self.release() diff --git a/packages/shared-python/shared/services/redis/redis_service.py b/packages/shared-python/shared/services/redis/redis_service.py index c25a3e1d6..cb45d75d4 100644 --- a/packages/shared-python/shared/services/redis/redis_service.py +++ b/packages/shared-python/shared/services/redis/redis_service.py @@ -298,6 +298,32 @@ async def _operation(): original_exception=e, ) + async def eval( + self, script: str, keys: list[str], args: list[Any] | None = None + ) -> Any: + """Execute a Lua script with consistently namespaced keys.""" + try: + client = await self._get_client() + full_keys = [self._build_key(key) for key in keys] + + async def _operation() -> Any: + return await _await_redis_result( + client.eval( + script, + len(full_keys), + *(full_keys + (args or [])), + ) + ) + + return await self._execute_with_retry(_operation) + except Exception as e: + logger.error(f"Redis EVAL operation failed: {e}") + raise RedisOperationError( + internal_message=f"EVAL operation failed: {str(e)}", + operation="EVAL", + original_exception=e, + ) + # ==================== List Operations ==================== async def lpush(self, key: str, *values: Any) -> int: diff --git a/packages/shared-python/shared/services/retrieval/map_unit_index.py b/packages/shared-python/shared/services/retrieval/map_unit_index.py index c58e5f8ef..19f95200e 100644 --- a/packages/shared-python/shared/services/retrieval/map_unit_index.py +++ b/packages/shared-python/shared/services/retrieval/map_unit_index.py @@ -6,7 +6,7 @@ from hashlib import sha256 from uuid import uuid4 -from sqlalchemy import delete, select +from sqlalchemy import Table, delete, insert, select from sqlalchemy.orm import Session from shared.models.database.document import ( @@ -29,6 +29,8 @@ __all__ = ["MAP_UNIT_INDEX_FORMAT_VERSION", "replace_document_map_units"] +_BULK_INSERT_BATCH_SIZE = 5_000 + def replace_document_map_units( db: Session, @@ -86,6 +88,8 @@ def replace_document_map_units( ) persisted_count = 0 token_count = 0 + map_unit_rows: list[dict[str, object]] = [] + token_rows: list[dict[str, object]] = [] path_unit_df: Counter[str] = Counter() content_unit_df: Counter[str] = Counter() path_document_count: int = 0 @@ -113,39 +117,43 @@ def replace_document_map_units( # embeds them), so this is the same ownership the query-time scorer # sees, not a new computation. section_chunk_types = {u.chunk_type for u in provider.self_units(section_id)} - db.add( - DocumentMapUnit( - id=map_unit_id, - document_id=scope.document_id, - job_result_id=scope.job_result_id, - unit_id=unit_id, - section_id=section_id, - unit_kind=str(unit.get("kind") or "leaf"), - path_token_count=len(path_tokens), - content_token_count=len(content_tokens), - term_search_text_lower=str(unit.get("term_search_text") or "").lower(), - has_image="image" in section_chunk_types, - has_table="table" in section_chunk_types, - sort_order=sort_order, - ) + map_unit_rows.append( + { + "id": map_unit_id, + "document_id": scope.document_id, + "job_result_id": scope.job_result_id, + "unit_id": unit_id, + "section_id": section_id, + "unit_kind": str(unit.get("kind") or "leaf"), + "path_token_count": len(path_tokens), + "content_token_count": len(content_tokens), + "term_search_text_lower": str( + unit.get("term_search_text") or "" + ).lower(), + "has_image": "image" in section_chunk_types, + "has_table": "table" in section_chunk_types, + "sort_order": sort_order, + } ) for channel, frequencies in ( ("path", Counter(path_tokens)), ("content", Counter(content_tokens)), ): for token, frequency in frequencies.items(): - db.add( - DocumentMapUnitToken( - id=f"dmut_{uuid4().hex[:31]}", - map_unit_id=map_unit_id, - channel=channel, - token=token, - token_hash=sha256(token.encode("utf-8")).hexdigest(), - frequency=frequency, - ) + token_rows.append( + { + "id": f"dmut_{uuid4().hex[:31]}", + "map_unit_id": map_unit_id, + "channel": channel, + "token": token, + "token_hash": sha256(token.encode("utf-8")).hexdigest(), + "frequency": frequency, + } ) token_count += len(frequencies) persisted_count += 1 + _execute_bulk_insert(db, DocumentMapUnit, map_unit_rows) + _execute_bulk_insert(db, DocumentMapUnitToken, token_rows) db.add( DocumentMapUnitIndex( id=f"dmui_{uuid4().hex}", @@ -170,6 +178,19 @@ def replace_document_map_units( ) +def _execute_bulk_insert( + db: Session, + model: type[DocumentMapUnit] | type[DocumentMapUnitToken], + rows: list[dict[str, object]], +) -> None: + """Insert derived index rows in bounded Core batches.""" + if not rows: + return + table: Table = model.__table__ + for start in range(0, len(rows), _BULK_INSERT_BATCH_SIZE): + db.execute(insert(table), rows[start : start + _BULK_INSERT_BATCH_SIZE]) + + def _to_section_row(section: DocumentSection) -> SectionRow: return SectionRow( section_id=section.section_id, From 92b830ab2cafa108272a1b3f633185931d106e31 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Sat, 12 Sep 2026 13:38:13 +0800 Subject: [PATCH 4/7] fix: make semaphore exports explicit --- packages/shared-python/shared/services/redis/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/shared-python/shared/services/redis/__init__.py b/packages/shared-python/shared/services/redis/__init__.py index f61986a6e..befe58f38 100644 --- a/packages/shared-python/shared/services/redis/__init__.py +++ b/packages/shared-python/shared/services/redis/__init__.py @@ -11,6 +11,10 @@ from .key_builder import RedisKeyBuilder, RedisKeyType, redis_key_builder from .redis_alerts import AlertRule, RedisAlertManager, RedisAlertNotifier from .redis_monitor import RedisMonitor + from .publication_semaphore import ( + RedisPublicationSemaphore, + SyncRedisPublicationSemaphore, + ) from .redis_service import RedisService from .redis_service_factory import RedisServiceFactory from .retry_policy import RedisHealthChecker, RedisRetry From b9216392c94b2fedac43e30b8fb202b3743ef011 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Sun, 13 Sep 2026 20:58:45 +0800 Subject: [PATCH 5/7] fix(retrieval): skip map-unit count equality on asset filters Classic image/table queries compared full-revision index.unit_count to the filtered unit subset, so a complete index looked unusable and returned 500. Co-authored-by: Cursor --- ...est_retrieval_classic_map_unit_contract.py | 246 ++++++++++++++++++ .../retrieval/search/map_unit_discovery.py | 29 ++- 2 files changed, 271 insertions(+), 4 deletions(-) diff --git a/apps/api/tests/contract/test_retrieval_classic_map_unit_contract.py b/apps/api/tests/contract/test_retrieval_classic_map_unit_contract.py index d42148d8d..57cc50779 100644 --- a/apps/api/tests/contract/test_retrieval_classic_map_unit_contract.py +++ b/apps/api/tests/contract/test_retrieval_classic_map_unit_contract.py @@ -781,6 +781,252 @@ async def test_classic_route_image_filter_scores_only_units_with_images( assert results[0]["chunk_type"] == "image" +async def test_classic_route_mixed_section_asset_filters_do_not_mark_index_unusable( + developer_api_client_factory: Callable[ + [], AbstractAsyncContextManager[AsyncClient] + ], +) -> None: + identifier = uuid4().hex[:8] + namespace = f"classic-mixed-{identifier}" + async with developer_api_client_factory() as api_client: + mixed = await _publish_document( + namespace=namespace, + source_file_name="mixed-assets.pdf", + chunks=[ + { + "chunk_id": f"plain-{identifier}", + "type": "text", + "content": "mixedsection plaintext filler with no assets", + "path": "mixed-assets.pdf/Root/Plain/body", + "order": 1, + "metadata": {}, + }, + { + "chunk_id": f"chart-body-{identifier}", + "type": "text", + "content": "mixedsection chartmarker beside a plot", + "path": "mixed-assets.pdf/Root/Chart/body", + "order": 2, + "metadata": {"connect_to": [{"target": f"chart-{identifier}"}]}, + }, + { + "chunk_id": f"chart-{identifier}", + "type": "image", + "content": "mixedsection chartmarker plot", + "path": "images/mixed-chart.png", + "order": 3, + "file_path": "images/mixed-chart.png", + "metadata": {}, + }, + { + "chunk_id": f"grid-body-{identifier}", + "type": "text", + "content": "mixedsection tablemarker beside a grid", + "path": "mixed-assets.pdf/Root/Grid/body", + "order": 4, + "metadata": {"connect_to": [{"target": f"grid-{identifier}"}]}, + }, + { + "chunk_id": f"grid-{identifier}", + "type": "table", + "content": "
mixedsection tablemarker grid
", + "path": "tables/mixed-grid.html", + "order": 5, + "file_path": "tables/mixed-grid.html", + "metadata": {}, + }, + ], + ) + for extra_kind, extra_type, extra_path, extra_content in ( + ("photo", "image", "images/extra-photo.png", "unrelated landscape photo"), + ("diagram", "image", "images/extra-diagram.png", "unrelated diagram"), + ("sheet", "table", "tables/extra-sheet.html", "
unrelated sheet
"), + ("grid", "table", "tables/extra-grid.html", "
unrelated grid
"), + ): + extra_chunk_id = f"{extra_kind}-{identifier}" + await _publish_document( + namespace=namespace, + source_file_name=f"extra-{extra_kind}.pdf", + chunks=[ + { + "chunk_id": f"{extra_kind}-body-{identifier}", + "type": "text", + "content": f"unrelated {extra_kind} caption", + "path": f"extra-{extra_kind}.pdf/Root/Section/body", + "order": 1, + "metadata": {"connect_to": [{"target": extra_chunk_id}]}, + }, + { + "chunk_id": extra_chunk_id, + "type": extra_type, + "content": extra_content, + "path": extra_path, + "order": 2, + "file_path": extra_path, + "metadata": {}, + }, + ], + ) + async with contract_db_session() as db: + mixed_units = list( + ( + await db.execute( + select(DocumentMapUnit).where( + DocumentMapUnit.document_id == mixed["document_id"] + ) + ) + ).scalars() + ) + assert len(mixed_units) >= 3 + assert any(unit.has_image and not unit.has_table for unit in mixed_units) + assert any(unit.has_table and not unit.has_image for unit in mixed_units) + assert any(not unit.has_image and not unit.has_table for unit in mixed_units) + + image_response = await api_client.post( + "/api/v1/retrieval/query", + json={ + "namespace": namespace, + "query": "mixedsection chartmarker", + "top_k": 1, + "use_agentic": False, + "chunk_types": ["image"], + }, + ) + table_response = await api_client.post( + "/api/v1/retrieval/query", + json={ + "namespace": namespace, + "query": "mixedsection tablemarker", + "top_k": 1, + "use_agentic": False, + "chunk_types": ["table"], + }, + ) + unfiltered_response = await api_client.post( + "/api/v1/retrieval/query", + json={ + "namespace": namespace, + "query": "mixedsection plaintext", + "top_k": 1, + "use_agentic": False, + }, + ) + + assert image_response.status_code == 200 + image_body = cast(dict[str, object], image_response.json()) + image_results = cast(list[dict[str, object]], image_body["results"]) + assert image_body["router_used"] == "classic_topk" + assert [row["chunk_id"] for row in image_results] == [f"chart-{identifier}"] + assert image_results[0]["chunk_type"] == "image" + + assert table_response.status_code == 200 + table_body = cast(dict[str, object], table_response.json()) + table_results = cast(list[dict[str, object]], table_body["results"]) + assert table_body["router_used"] == "classic_topk" + assert [row["chunk_id"] for row in table_results] == [f"grid-{identifier}"] + assert table_results[0]["chunk_type"] == "table" + + assert unfiltered_response.status_code == 200 + unfiltered_body = cast(dict[str, object], unfiltered_response.json()) + assert unfiltered_body["router_used"] == "classic_topk" + + +@pytest.mark.parametrize( + "incomplete_index_kind", ["legacy_format", "missing_index"] +) +async def test_classic_route_image_filter_raises_when_index_is_unusable( + developer_api_client_factory: Callable[ + [], AbstractAsyncContextManager[AsyncClient] + ], + incomplete_index_kind: str, +) -> None: + identifier = uuid4().hex[:8] + namespace = f"classic-image-unusable-{incomplete_index_kind}-{identifier}" + async with developer_api_client_factory() as api_client: + document = await _publish_document( + namespace=namespace, + source_file_name="unusable-image.pdf", + chunks=[ + { + "chunk_id": f"plain-{identifier}", + "type": "text", + "content": "unusable image plaintext filler", + "path": "unusable-image.pdf/Root/Plain/body", + "order": 1, + "metadata": {}, + }, + { + "chunk_id": f"body-{identifier}", + "type": "text", + "content": "unusable image marker next to a chart", + "path": "unusable-image.pdf/Root/Chart/body", + "order": 2, + "metadata": {"connect_to": [{"target": f"chart-{identifier}"}]}, + }, + { + "chunk_id": f"chart-{identifier}", + "type": "image", + "content": "unusable image marker chart", + "path": "images/unusable-chart.png", + "order": 3, + "file_path": "images/unusable-chart.png", + "metadata": {}, + }, + ], + ) + await _publish_document( + namespace=namespace, + source_file_name="extra-image.pdf", + chunks=[ + { + "chunk_id": f"extra-body-{identifier}", + "type": "text", + "content": "unrelated extra caption", + "path": "extra-image.pdf/Root/Section/body", + "order": 1, + "metadata": {"connect_to": [{"target": f"extra-{identifier}"}]}, + }, + { + "chunk_id": f"extra-{identifier}", + "type": "image", + "content": "unrelated extra photo", + "path": "images/extra.png", + "order": 2, + "file_path": "images/extra.png", + "metadata": {}, + }, + ], + ) + if incomplete_index_kind == "legacy_format": + await ContractDatabase.execute( + """ + UPDATE document_map_unit_indexes + SET format_version = 1 + WHERE document_id = :document_id + """, + {"document_id": document["document_id"]}, + ) + else: + await ContractDatabase.execute( + """ + DELETE FROM document_map_unit_indexes + WHERE document_id = :document_id + """, + {"document_id": document["document_id"]}, + ) + with pytest.raises(RuntimeError, match="map-unit index is incomplete"): + await api_client.post( + "/api/v1/retrieval/query", + json={ + "namespace": namespace, + "query": "unusable image marker", + "top_k": 1, + "use_agentic": False, + "chunk_types": ["image"], + }, + ) + + async def test_connected_hydration_does_not_load_legacy_job_chunks( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] diff --git a/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py b/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py index 9a1561c9f..e95102866 100644 --- a/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py +++ b/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py @@ -451,9 +451,16 @@ async def map_unit_discovery( has_index_storage_mismatch = indexed_unit_count != int( actual_unit_count or 0 ) or indexed_token_count != int(actual_token_count or 0) + # Equality against indexes.unit_count is only valid when unit_rows is the + # full inventory of those revisions. Image/table type filters, signal + # paths, and exclude_sections load a subset, so require the looser + # undercount check used for unfiltered token projection. + unit_rows_are_full_revision_inventory = not ( + type_clause or signal_paths or exclude_sections + ) has_index_unit_count_mismatch = ( indexed_unit_count < len(unit_rows) - if is_unfiltered_scope + if is_unfiltered_scope or not unit_rows_are_full_revision_inventory else indexed_unit_count != len(unit_rows) ) is_index_format_incompatible = any( @@ -488,8 +495,9 @@ async def map_unit_discovery( _token_count, ) in index_parts ) + has_revision_coverage_mismatch = len(index_parts) != len(expected_revisions) has_unusable_index = ( - len(index_parts) != len(expected_revisions) + has_revision_coverage_mismatch or has_index_unit_count_mismatch or has_index_storage_mismatch or is_index_format_incompatible @@ -505,11 +513,24 @@ async def map_unit_discovery( ) except Exception as exc: logger.warning("retrieval index readiness publish failed: %s", exc) + if has_revision_coverage_mismatch: + unusable_reason = "revision_coverage" + elif is_index_format_incompatible: + unusable_reason = "format" + elif has_index_storage_mismatch: + unusable_reason = "storage" + else: + unusable_reason = "unit_count_mismatch" + chunk_types_label = ( + ",".join(sorted(chunk_types)) if chunk_types else "none" + ) raise RuntimeError( "retrieval map-unit index is incomplete or incompatible " - f"(user_id={user_id} namespace={namespace} " + f"(reason={unusable_reason} user_id={user_id} namespace={namespace} " f"expected_revisions={len(expected_revisions)} " - f"indexed_revisions={len(index_parts)})" + f"indexed_revisions={len(index_parts)} " + f"indexed_unit_count={indexed_unit_count} unit_rows={len(unit_rows)} " + f"unfiltered={is_unfiltered_scope} chunk_types={chunk_types_label})" ) if has_incomplete_index_statistics: logger.warning( From 6ff0a09c7fdb51f528bad4b2f0c5449ff5cecf3d Mon Sep 17 00:00:00 2001 From: suguanYang Date: Sun, 13 Sep 2026 21:06:25 +0800 Subject: [PATCH 6/7] Potential fix for pull request finding 'CodeQL / Potentially uninitialized local variable' Initialize the revision-inventory flag before the async storage-mismatch branch so CodeQL sees it on every path. Co-authored-by: Cursor --- .../retrieval/search/map_unit_discovery.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py b/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py index e95102866..b65b42c61 100644 --- a/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py +++ b/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py @@ -417,6 +417,13 @@ async def map_unit_discovery( indexed_unit_count = sum( unit_count for _path_idf, _content_idf, unit_count, *_rest in index_parts ) + # Equality against indexes.unit_count is only valid when unit_rows is the + # full inventory of those revisions. Image/table type filters, signal + # paths, and exclude_sections load a subset, so require the looser + # undercount check used for unfiltered token projection. + unit_rows_are_full_revision_inventory: bool = not ( + type_clause or signal_paths or exclude_sections + ) has_index_storage_mismatch: bool = False if is_unfiltered_scope and not unit_rows and expected_revisions: storage_counts: tuple[int | None, int | None] = cast( @@ -451,13 +458,6 @@ async def map_unit_discovery( has_index_storage_mismatch = indexed_unit_count != int( actual_unit_count or 0 ) or indexed_token_count != int(actual_token_count or 0) - # Equality against indexes.unit_count is only valid when unit_rows is the - # full inventory of those revisions. Image/table type filters, signal - # paths, and exclude_sections load a subset, so require the looser - # undercount check used for unfiltered token projection. - unit_rows_are_full_revision_inventory = not ( - type_clause or signal_paths or exclude_sections - ) has_index_unit_count_mismatch = ( indexed_unit_count < len(unit_rows) if is_unfiltered_scope or not unit_rows_are_full_revision_inventory From 2118ac120869ed67b43f754ca396d9d17cc326a9 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Sun, 13 Sep 2026 21:12:23 +0800 Subject: [PATCH 7/7] Potential fix for pull request finding 'CodeQL / Potentially uninitialized local variable' Inline the subset-inventory condition so the extra local is gone. CodeQL kept flagging the use of that name even after it was assigned earlier. Co-authored-by: Cursor --- .../services/retrieval/search/map_unit_discovery.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py b/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py index b65b42c61..790dc92d5 100644 --- a/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py +++ b/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py @@ -417,13 +417,6 @@ async def map_unit_discovery( indexed_unit_count = sum( unit_count for _path_idf, _content_idf, unit_count, *_rest in index_parts ) - # Equality against indexes.unit_count is only valid when unit_rows is the - # full inventory of those revisions. Image/table type filters, signal - # paths, and exclude_sections load a subset, so require the looser - # undercount check used for unfiltered token projection. - unit_rows_are_full_revision_inventory: bool = not ( - type_clause or signal_paths or exclude_sections - ) has_index_storage_mismatch: bool = False if is_unfiltered_scope and not unit_rows and expected_revisions: storage_counts: tuple[int | None, int | None] = cast( @@ -458,9 +451,13 @@ async def map_unit_discovery( has_index_storage_mismatch = indexed_unit_count != int( actual_unit_count or 0 ) or indexed_token_count != int(actual_token_count or 0) + # Equality against indexes.unit_count is only valid when unit_rows is the + # full inventory of those revisions. Image/table type filters, signal + # paths, and exclude_sections load a subset, so require the looser + # undercount check used for unfiltered token projection. has_index_unit_count_mismatch = ( indexed_unit_count < len(unit_rows) - if is_unfiltered_scope or not unit_rows_are_full_revision_inventory + if is_unfiltered_scope or type_clause or signal_paths or exclude_sections else indexed_unit_count != len(unit_rows) ) is_index_format_incompatible = any(