fix(vector): address PR review round 16 — type-aware index check, comments
Detect pre-existing payload indexes with the wrong schema type in `_ensure_payload_indexes`. The previous "field already in existing_schema → skip" branch silently survived a collection migrated from the int-doc_id era where `doc_id` is indexed as INTEGER, letting `MatchValue(value="123")` searches keep failing with HTTP 400 on Qdrant Cloud strict mode — exactly the production failure this PR was meant to fix. New behaviour: compare `existing_schema[field].data_type` against the declared type; on mismatch log a WARNING and append to `failed_fields` so the consolidated end-of-function summary picks it up. No auto-repair (operator intervention only — see docs/configuration.md recovery procedure). New test exercises the doc_id-INTEGER scenario end-to-end and asserts both the per-field WARNING and the summary line. Clarify the `_verify_news_items` malformed-doc_id rationale: the news API has no per-item endpoint, so a malformed doc_id genuinely cannot be verified against the source of truth. We err toward false-positive (keep) over false-negative (drop) — same conservative posture as `_verify_notes` and `_verify_deck_cards`. The producer-side validation is the real security boundary; the verifier is defence-in-depth. Both the inline comment and the WARNING message now spell this out. Add a TODO in `get_last_indexed_timestamp` flagging the O(N) cost on every incremental sync tick. The previous single-page `limit=10_000` silently bounded the scroll; paginating fixed correctness but made the unbounded cost visible. The follow-up tracker (canonical TODO at `api/visualization.py`) covers migrating the max-`indexed_at` to a sentinel point or collection metadata for O(1) lookup. Consolidate the duplicate non-numeric-doc_type TODOs at `api/visualization.py:508` and `auth/viz_routes.py:570` into a single canonical comment in `visualization.py`; `viz_routes.py` is reduced to a back-reference. Removes the rot risk of "fixed in one place, forgotten in the other." The canonical comment also references the O(1) timestamp follow-up in `scanner.py`. Document the `batch_size = 256` (qdrant_client.py) vs `_DELETION_TRACKING_PAGE_SIZE = 1024` (scanner.py) split with cross-referencing comments at each site: the smaller batch is for the read-write backfill upsert path (Qdrant accepts ~256-point chunks comfortably); the larger page is for read-only deletion-tracking scrolls where no per-page write round-trip applies. Replace `assert qdrant_client is not None` in `scan_user_documents` with `cast(AsyncQdrantClient, qdrant_client)` plus an explanatory comment. `assert` is silently elided under `-O`; `cast` is the conventional zero-cost narrower for branches the type checker can't infer from the surrounding `if not initial_sync` ternary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
68506f96c5
commit
8f4f5c0079
@@ -9,6 +9,7 @@ import random
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import cast
|
||||
|
||||
import anyio
|
||||
from anyio.abc import TaskStatus
|
||||
@@ -49,6 +50,13 @@ INDEXED_DOC_TYPES: frozenset[str] = frozenset(
|
||||
# < 100 k point per (user_id, doc_type) case. The previous single-page
|
||||
# ``limit=10_000`` silently truncated deletion sets for any user past the
|
||||
# cap, so anything indexed beyond the first 10 k was never reconciled.
|
||||
#
|
||||
# Intentionally larger than the ``batch_size = 256`` used by
|
||||
# ``_backfill_doc_id_to_string`` in ``vector/qdrant_client.py``: this is a
|
||||
# read-only scroll that just collects payloads (no write round-trip per
|
||||
# point), so the per-page memory budget is the only relevant constraint.
|
||||
# The 256 there is sized for read-write upsert batches where Qdrant
|
||||
# accepts ~256-point chunks comfortably without timing out under load.
|
||||
_DELETION_TRACKING_PAGE_SIZE: int = 1024
|
||||
|
||||
|
||||
@@ -120,6 +128,14 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
|
||||
Returns:
|
||||
Unix timestamp of most recently indexed note, or None if no notes indexed yet
|
||||
"""
|
||||
# TODO: This is O(N) over a user's indexed notes on every incremental
|
||||
# sync tick. Was accidentally bounded at 10 k before this PR (single-
|
||||
# page scroll silently truncated); paginating fixed correctness but
|
||||
# made the unbounded cost visible. Track the max ``indexed_at`` as
|
||||
# collection metadata or a dedicated sentinel point so this becomes
|
||||
# O(1). Out of scope for the current PR — see the chunk-context /
|
||||
# vector-sync follow-up tracker (referenced by the canonical TODO at
|
||||
# ``api/visualization.py``).
|
||||
try:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
@@ -263,7 +279,12 @@ async def scan_user_documents(
|
||||
qdrant_client = await get_qdrant_client() if not initial_sync else None
|
||||
indexed_doc_ids = set()
|
||||
if not initial_sync:
|
||||
assert qdrant_client is not None # narrow for the type checker
|
||||
# ``assert ... is not None`` would also narrow but raises an
|
||||
# opaque AssertionError under ``-O`` and at runtime — ``cast``
|
||||
# is the conventional zero-cost narrower for branches the type
|
||||
# checker can't infer from the surrounding ``if not
|
||||
# initial_sync`` (the ternary above ties the two together).
|
||||
qdrant_client = cast(AsyncQdrantClient, qdrant_client)
|
||||
points = await _scroll_all_points(
|
||||
qdrant_client,
|
||||
collection_name=get_settings().get_collection_name(),
|
||||
|
||||
Reference in New Issue
Block a user