fix: harden task3 qdrant compatibility boundaries

This commit is contained in:
2026-08-11 07:34:44 +02:00
parent 0724a73300
commit 99e0024973
11 changed files with 410 additions and 227 deletions
+9 -1
View File
@@ -32,12 +32,20 @@ def build_vector_store(cfg: Config, *, require_write: bool = False) -> VectorSto
match resource.type:
case "qdrant":
expected_dimension = cfg.embeddings.dim if cfg.embeddings is not None else None
if resource.collection_lifecycle == "require_existing" and (
expected_dimension is None or expected_dimension <= 0
):
raise ConfigError(
"require_existing Qdrant vectors require an explicit positive embedding dimension"
)
return QdrantVectorStore(
base_url=resource.base_url,
collection=resource.collection,
workspace_id=cfg._workspace_id,
workspace_revision=cfg._workspace_revision,
expected_dimension=cfg.embeddings.dim if cfg.embeddings is not None else None,
expected_dimension=expected_dimension,
expected_distance="Cosine",
collection_lifecycle=resource.collection_lifecycle,
)
case other: # pragma: no cover - Pydantic's discriminator rejects this first.
+59 -25
View File
@@ -13,9 +13,12 @@ from tht.adapters.vector._shared import (
validate_known_kinds,
)
from tht.ports.vector import (
SemanticIndexIncompatibleError,
VectorCapabilities,
VectorHealth,
VectorResponseError,
VectorStoreError,
VectorTransportError,
VectorWriteRecord,
require_positive_limit,
)
@@ -55,6 +58,7 @@ class QdrantVectorStore:
workspace_id: str,
workspace_revision: str | None = None,
expected_dimension: int | None = None,
expected_distance: str | None = "Cosine",
collection_lifecycle: str = "create_if_missing",
request: Callable[..., object] | None = None,
connect_timeout: float = 2.0,
@@ -65,8 +69,16 @@ class QdrantVectorStore:
self._workspace_id = workspace_id
self._workspace_revision = workspace_revision
self._expected_dimension = expected_dimension
self._expected_distance = expected_distance
if collection_lifecycle not in ("create_if_missing", "require_existing"):
raise ValueError("Unsupported Qdrant collection lifecycle")
if collection_lifecycle == "require_existing" and (
expected_dimension is None or expected_dimension <= 0 or expected_distance is None
):
raise ValueError(
"require_existing requires an explicit positive expected dimension "
"and expected distance"
)
self._collection_lifecycle = collection_lifecycle
self._request = request or requests.request
self._timeout = (connect_timeout, read_timeout)
@@ -100,9 +112,17 @@ class QdrantVectorStore:
dimension = info["config"]["params"]["vectors"]["size"]
dimensions = (dimension,)
compatible = (
dimension_compatible = (
None if self._expected_dimension is None else dimensions == (self._expected_dimension,)
)
observed_distance = info["config"]["params"]["vectors"].get("distance")
distance_compatible = (
None if self._expected_distance is None else observed_distance == self._expected_distance
)
compatible = (
None if dimension_compatible is None and distance_compatible is None
else dimension_compatible is not False and distance_compatible is not False
)
return VectorHealth(
ok=compatible is not False,
read_configured=True,
@@ -161,7 +181,7 @@ class QdrantVectorStore:
)
points = response.get("result", {}).get("points")
if not isinstance(points, list):
raise VectorStoreError("Qdrant returned malformed query response")
raise VectorResponseError("Qdrant returned malformed query response")
hits = [self._hit_from_point(point) for point in points]
return sorted(hits, key=lambda hit: (-hit.similarity, hit.id))[:limit]
@@ -179,11 +199,11 @@ class QdrantVectorStore:
for point in points:
payload = point.get("payload")
if not isinstance(payload, dict):
raise VectorStoreError("Qdrant returned malformed scroll response")
raise VectorResponseError("Qdrant returned malformed scroll response")
record_key = payload.get("record_key")
content_hash = payload.get("content_hash")
if not isinstance(record_key, str) or not isinstance(content_hash, str):
raise VectorStoreError("Qdrant returned malformed scroll response")
raise VectorResponseError("Qdrant returned malformed scroll response")
hashes[record_key] = content_hash
return hashes
@@ -207,11 +227,18 @@ class QdrantVectorStore:
),
}
)
self._call(
"PUT",
f"/collections/{self._collection}/points?wait=true",
{"points": points},
)
try:
self._call(
"PUT",
f"/collections/{self._collection}/points?wait=true",
{"points": points},
)
except VectorTransportError as exc:
if self._collection_lifecycle == "require_existing" and exc.status_code == 404:
raise SemanticIndexIncompatibleError(
"Qdrant collection disappeared during semantic index write"
) from exc
raise
return len(records)
def delete_kinds(self, collection: str, kinds: list[str]) -> int:
@@ -308,10 +335,10 @@ class QdrantVectorStore:
def _ensure_collection(self, *, strict: bool) -> dict | None:
response = self._call("GET", f"/collections/{self._collection}", None, allow_missing=True)
if response is None:
if self._collection_lifecycle == "require_existing":
raise SemanticIndexIncompatibleError("Qdrant collection is missing")
if not strict:
raise VectorStoreError("Qdrant collection is missing")
if self._collection_lifecycle == "require_existing":
raise VectorStoreError("Qdrant collection configuration mismatch (semantic_index_incompatible)")
self._call(
"PUT",
f"/collections/{self._collection}",
@@ -327,24 +354,31 @@ class QdrantVectorStore:
result = response.get("result") if isinstance(response, dict) else None
config = result.get("config", {}).get("params", {}).get("vectors") if isinstance(result, dict) else None
if not isinstance(config, dict):
raise VectorStoreError("Qdrant returned malformed collection response")
raise VectorResponseError("Qdrant returned malformed collection response")
size = config.get("size")
distance = config.get("distance")
if (
self._expected_dimension is not None
and (size != self._expected_dimension or distance != "Cosine")
self._expected_dimension is not None and size != self._expected_dimension
) or (
self._expected_distance is not None and distance != self._expected_distance
):
raise VectorStoreError("Qdrant collection configuration mismatch (semantic_index_incompatible)")
if self._collection_lifecycle == "require_existing":
raise SemanticIndexIncompatibleError(
"Qdrant collection dimension or distance is incompatible"
)
raise VectorStoreError("Qdrant collection configuration mismatch")
payload_schema = result.get("payload_schema")
if not isinstance(payload_schema, dict):
raise VectorStoreError("Qdrant returned malformed collection response")
raise VectorResponseError("Qdrant returned malformed collection response")
for field_name in _KEYWORD_INDEXES:
field = payload_schema.get(field_name)
if not isinstance(field, dict) or field.get("data_type") != "keyword":
if self._collection_lifecycle == "require_existing":
raise SemanticIndexIncompatibleError(
"Qdrant collection payload indexes are incompatible"
)
if not strict:
raise VectorStoreError("Qdrant collection payload indexes mismatch")
if self._collection_lifecycle == "require_existing":
raise VectorStoreError("Qdrant collection configuration mismatch (semantic_index_incompatible)")
self._call(
"PUT",
f"/collections/{self._collection}/index",
@@ -370,13 +404,13 @@ class QdrantVectorStore:
result = response.get("result", {})
page = result.get("points")
if not isinstance(page, list):
raise VectorStoreError("Qdrant returned malformed scroll response")
raise VectorResponseError("Qdrant returned malformed scroll response")
points.extend(page)
next_page_offset = result.get("next_page_offset")
if next_page_offset is None:
return points
if next_page_offset in seen_offsets:
raise VectorStoreError("Qdrant returned malformed scroll response")
raise VectorResponseError("Qdrant returned malformed scroll response")
seen_offsets.add(next_page_offset)
offset = next_page_offset
@@ -384,7 +418,7 @@ class QdrantVectorStore:
payload = point.get("payload")
score = point.get("score")
if not isinstance(payload, dict) or not isinstance(score, (int, float)):
raise VectorStoreError("Qdrant returned malformed query response")
raise VectorResponseError("Qdrant returned malformed query response")
return hit_from_metadata(float(score), payload)
def _call(self, method: str, path: str, payload: dict | None, allow_missing: bool = False) -> dict | None:
@@ -396,19 +430,19 @@ class QdrantVectorStore:
timeout=self._timeout,
)
except requests.RequestException as exc:
raise VectorStoreError(_sanitize_exception(exc)) from exc
raise VectorTransportError(_sanitize_exception(exc)) from exc
if response.status_code == 404 and allow_missing:
return None
if not response.ok:
raise VectorStoreError(f"Qdrant request failed: HTTP {response.status_code}")
raise VectorTransportError(f"Qdrant request failed: HTTP {response.status_code}", status_code=response.status_code)
if response.status_code == 204 or not getattr(response, "text", ""):
return {}
try:
data = response.json()
except Exception as exc:
raise VectorStoreError("Qdrant returned malformed JSON response") from exc
raise VectorResponseError("Qdrant returned malformed JSON response") from exc
if not isinstance(data, dict):
raise VectorStoreError("Qdrant returned malformed JSON response")
raise VectorResponseError("Qdrant returned malformed JSON response")
return data
+7 -1
View File
@@ -18,7 +18,7 @@ from tht.cli.schema_cmd import (
physical_path,
)
from tht.config import Config, ConfigError
from tht.ports.vector import VectorWriteRecord
from tht.ports.vector import SemanticIndexIncompatibleError, VectorWriteRecord
from tht.vectorstore.store import SyncStats, content_hash
vector_app = typer.Typer(help="Indice semantico Qdrant (derivato, rigenerabile)")
@@ -265,6 +265,12 @@ def index_schema_cmd(
_vector_cfg_or_error(cfg)
physical, annotations = _load_schema_artifacts(cfg)
payload = index_schema_data(cfg, physical=physical, annotations=annotations)
except SemanticIndexIncompatibleError:
if json_output:
typer.echo(json.dumps({"status": "failed", "code": "semantic_index_incompatible"}, sort_keys=True, separators=(",", ":")))
raise typer.Exit(code=1) from None
typer.secho("ERRORE: indice semantico incompatibile.", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1) from None
except _MachineVectorError as error:
if json_output:
typer.echo(json.dumps({"status": "failed", "code": error.code}, sort_keys=True, separators=(",", ":")))
+33 -11
View File
@@ -550,17 +550,8 @@ def load_config(path: Path) -> Config:
_validate_internal_embedding_contract(expanded, path)
_validate_internal_vector_contract(expanded, path)
translated, used_legacy = translate_legacy_config(expanded)
_validate_vector_resource_consistency(expanded, translated, path)
vectors = translated.get("vectors")
resource_vector = expanded.get("resources", {}).get("vector") if isinstance(
expanded.get("resources"), dict
) else None
if (
isinstance(vectors, dict)
and vectors.get("type") == "qdrant"
and isinstance(resource_vector, dict)
and "collection_lifecycle" in resource_vector
):
vectors["collection_lifecycle"] = resource_vector["collection_lifecycle"]
# runtime_identity is the registry marker. The lifecycle is an internal
# runtime policy, never a descriptor-controlled option.
if (
@@ -673,6 +664,37 @@ def _validate_internal_embedding_contract(raw: dict[str, Any], path: Path) -> No
)
def _normalize_qdrant_base_url(value: str) -> str:
parsed = urlparse(value)
hostname = (parsed.hostname or "").lower()
try:
port = parsed.port
except ValueError:
port = None
host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
netloc = f"{host}:{port}" if port is not None else host
return f"{parsed.scheme.lower()}://{netloc}{parsed.path.rstrip('/') or '/'}"
def _validate_vector_resource_consistency(raw: dict[str, Any], translated: dict[str, Any], path: Path) -> None:
"""Reject divergent top-level and compatibility Qdrant resource views."""
resources = raw.get("resources")
resource = resources.get("vector") if isinstance(resources, dict) else None
vectors = translated.get("vectors")
if not isinstance(resource, dict) or not isinstance(vectors, dict):
return
if vectors.get("type") != "qdrant" or resource.get("engine") != "qdrant":
raise ConfigError(
f"Configurazione non valida in {path}: vectors and resources.vector must both describe qdrant"
)
if _normalize_qdrant_base_url(vectors.get("base_url", "")) != _normalize_qdrant_base_url(
resource.get("base_url", "")
) or vectors.get("collection") != resource.get("collection"):
raise ConfigError(
f"Configurazione non valida in {path}: vectors and resources.vector disagree"
)
def _validate_internal_vector_contract(raw: dict[str, Any], path: Path) -> None:
resources = raw.get("resources")
if not isinstance(resources, dict):
@@ -684,7 +706,7 @@ def _validate_internal_vector_contract(raw: dict[str, Any], path: Path) -> None:
engine = vector.get("engine")
base_url = vector.get("base_url")
collection = vector.get("collection")
allowed = {"engine", "base_url", "collection", "collection_lifecycle"}
allowed = {"engine", "base_url", "collection"}
unexpected = sorted(set(vector) - allowed)
if unexpected:
raise ConfigError(
+24
View File
@@ -45,6 +45,27 @@ class VectorStoreError(Exception):
"""Base error exposed by vector adapters."""
class SemanticIndexIncompatibleError(VectorStoreError):
"""The configured semantic collection cannot safely serve this workspace."""
code = "semantic_index_incompatible"
def __init__(self, message: str = "Qdrant semantic index is incompatible"):
super().__init__(f"{self.code}: {message}")
class VectorTransportError(VectorStoreError):
"""Qdrant could not be reached or returned an HTTP failure."""
def __init__(self, message: str, *, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class VectorResponseError(VectorStoreError):
"""Qdrant returned a malformed response."""
class VectorWriteUnavailable(VectorStoreError):
"""Raised when a deployment has no vector writer credential."""
@@ -86,13 +107,16 @@ class VectorStore(Protocol):
__all__ = [
"SemanticIndexIncompatibleError",
"VectorCapabilities",
"VectorHealth",
"VectorHit",
"VectorReadUnavailable",
"VectorRecord",
"VectorResponseError",
"VectorStore",
"VectorStoreError",
"VectorTransportError",
"VectorWriteRecord",
"VectorWriteUnavailable",
"require_positive_limit",