mirror of
https://github.com/milvus-io/milvus.git
synced 2026-10-28 17:05:53 +08:00
## What this PR does Adds dedicated Spark-Milvus Backfill E2E coverage and the runtime tooling needed to execute it against real Storage V3 and Storage V2 segments. - Adds opt-in, serial-only pytest coverage for Backfill modes, null semantics, snapshot boundaries, schema fencing, manifest versions, compaction, and invalid inputs. - Adds Kubernetes Job and reusable Spark Toolbox runners with credential redaction, RBAC validation, evidence collection, and cleanup. - Builds `zilliztech/spark-milvus` in the toolbox pod and runs Spark locally through the validated wrapper and native-library runtime. - Separates Storage V3 and Storage V2 execution through pytest markers and independent Milvus deployments. - Uses the dedicated `SparkBackfill` tag, so the normal `L0 L1 L2 ClusterOnly` nightly selection does not include this suite. - Documents manual toolbox deployment and dedicated-suite execution. Fixes #52130 ## Pipeline isolation The suite is ignored during ordinary Python client collection unless `--run-spark-backfill` is explicitly provided. The dedicated pipeline additionally selects `--tags SparkBackfill`; existing PR, nightly, and weekly tag sets therefore select zero Spark Backfill cases. V3 and V2 remain separate marker selections because they require different Milvus storage configurations and must run serially without pytest-xdist. ## Test plan - [x] Dedicated Spark Backfill pipeline completed successfully. - [x] Storage V3: 30 cases collected; 26 passed and 4 strict xfails track the known schema-fencing gap in #51318. - [x] Dedicated `SparkBackfill` tag collects 31 cases: 30 V3 and 1 V2. - [x] Normal nightly tags `L0 L1 L2 ClusterOnly` select 0 Spark Backfill cases, even when the opt-in collection gate is opened. - [x] `ruff check tests/python_client/common/common_type.py tests/python_client/conftest.py tests/python_client/spark_backfill` - [x] `git diff upstream/master...HEAD --check` ## Operational notes - Spark execution uses `spark-submit --master local[2]` in one toolbox pod. - The toolbox is discovered by `app=spark-milvus-toolbox` and is managed outside pytest. - V3 and V2 must use independent Milvus deployments. --------- Signed-off-by: Eric Hou <eric.hou@zilliz.com> Co-authored-by: Eric Hou <eric.hou@zilliz.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
235 lines
8.8 KiB
Python
235 lines
8.8 KiB
Python
"""High-level reusable Backfill E2E case object."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
|
|
from .backfill_helpers import (
|
|
BackfillContractError,
|
|
SnapshotMetadataView,
|
|
build_backfill_arguments,
|
|
collection_field_ids,
|
|
commit_backfill_result,
|
|
list_object_keys,
|
|
object_key,
|
|
parse_snapshot_metadata,
|
|
persistent_segment_storage_versions,
|
|
read_json_object,
|
|
unique_name,
|
|
upload_file,
|
|
write_backfill_parquet,
|
|
)
|
|
from .contracts import extract_read_probe_result
|
|
from .k8s_runner import SparkJobRequest, SparkJobResult
|
|
|
|
# Keep positive E2E snapshots stable across Spark startup, execution, and commit.
|
|
# Fixture teardown drops the snapshot early, so this is a maximum lifetime, not a delay.
|
|
DEFAULT_COMPACTION_PROTECTION_SECONDS = 600
|
|
|
|
|
|
def infer_root_path(snapshot_location: str, bucket: str = "") -> str:
|
|
key = object_key(snapshot_location, bucket or snapshot_location.split("://", 1)[-1].split("/", 1)[0])
|
|
marker = "/snapshots/"
|
|
if marker in f"/{key}":
|
|
prefix = f"/{key}".split(marker, 1)[0].lstrip("/")
|
|
return prefix
|
|
raise BackfillContractError(
|
|
f"cannot derive storage root path from snapshot location {snapshot_location!r}: "
|
|
f"expected a {marker} path segment"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class BackfillCase:
|
|
client: Any
|
|
minio_client: Any
|
|
runner: Any
|
|
settings: Any
|
|
tmp_path: Path
|
|
collection_name: str
|
|
snapshot_names: list[str]
|
|
snapshot_location: str
|
|
snapshot: SnapshotMetadataView | None
|
|
prefix: str
|
|
source_rows: Sequence[Mapping[str, Any]]
|
|
|
|
@property
|
|
def root_path(self) -> str:
|
|
return infer_root_path(self.snapshot_location, self.settings.minio_bucket)
|
|
|
|
def create_snapshot(
|
|
self,
|
|
*,
|
|
compaction_protection_seconds: int = DEFAULT_COMPACTION_PROTECTION_SECONDS,
|
|
) -> SnapshotMetadataView:
|
|
snapshot_name = unique_name("spark_backfill_snapshot")
|
|
self.client.create_snapshot(
|
|
snapshot_name,
|
|
self.collection_name,
|
|
compaction_protection_seconds=compaction_protection_seconds,
|
|
)
|
|
self.snapshot_names.append(snapshot_name)
|
|
info = self.client.describe_snapshot(snapshot_name, self.collection_name)
|
|
raw = read_json_object(self.minio_client, self.settings.minio_bucket, info.s3_location)
|
|
raw_segment_ids = raw.get("segment_ids", raw.get("segmentIds", []))
|
|
storage_versions = persistent_segment_storage_versions(
|
|
self.client,
|
|
self.collection_name,
|
|
raw_segment_ids,
|
|
)
|
|
view = parse_snapshot_metadata(
|
|
raw,
|
|
info.s3_location,
|
|
segment_storage_versions=storage_versions,
|
|
)
|
|
expected_kind = self.snapshot.storage_kind if self.snapshot is not None else view.storage_kind
|
|
if view.storage_kind != expected_kind:
|
|
raise AssertionError(
|
|
f"Storage {expected_kind.upper()} suite requires {expected_kind.upper()} Snapshot segments, "
|
|
f"observed {view.storage_kind!r}"
|
|
)
|
|
self.snapshot_location = info.s3_location
|
|
self.snapshot = view
|
|
return view
|
|
|
|
def upload_parquet(
|
|
self,
|
|
case_id: str,
|
|
rows: Sequence[Mapping[str, Any]],
|
|
*,
|
|
dim: int = 4,
|
|
include_pk: bool = True,
|
|
score_type=None,
|
|
vector_type=None,
|
|
target_fields: Sequence[str] = ("bf_score", "bf_label", "bf_vector"),
|
|
target_field_types: Mapping[str, Any] | None = None,
|
|
) -> str:
|
|
local_path = self.tmp_path / case_id / "input.parquet"
|
|
write_backfill_parquet(
|
|
local_path,
|
|
rows,
|
|
dim=dim,
|
|
include_pk=include_pk,
|
|
score_type=score_type,
|
|
vector_type=vector_type,
|
|
target_fields=target_fields,
|
|
target_field_types=target_field_types,
|
|
)
|
|
return upload_file(
|
|
self.minio_client,
|
|
self.settings.minio_bucket,
|
|
f"{self.prefix}/{case_id}/input.parquet",
|
|
local_path,
|
|
)
|
|
|
|
def result_uri(self, case_id: str) -> str:
|
|
return f"s3a://{self.settings.minio_bucket}/{self.prefix}/{case_id}/result.json"
|
|
|
|
def run_backfill(
|
|
self,
|
|
*,
|
|
case_id: str,
|
|
parquet_uri: str,
|
|
mode: str,
|
|
batch_size: int | str = 1024,
|
|
result_uri: str | None = None,
|
|
extra_arguments: Sequence[str] = (),
|
|
) -> tuple[SparkJobResult, str]:
|
|
result_uri = result_uri or self.result_uri(case_id)
|
|
snapshot_path = (
|
|
f"s3a://{self.settings.minio_bucket}/{object_key(self.snapshot_location, self.settings.minio_bucket)}"
|
|
)
|
|
arguments = build_backfill_arguments(
|
|
parquet_path=parquet_uri,
|
|
snapshot_path=snapshot_path,
|
|
result_path=result_uri,
|
|
s3_endpoint=self.settings.spark_minio_endpoint,
|
|
s3_bucket=self.settings.minio_bucket,
|
|
s3_root_path=self.root_path,
|
|
mode=mode,
|
|
batch_size=batch_size,
|
|
)
|
|
arguments.extend(str(value) for value in extra_arguments)
|
|
result = self.runner.run(
|
|
SparkJobRequest(
|
|
case_id=case_id,
|
|
operation="backfill",
|
|
payload={"arguments": arguments},
|
|
)
|
|
)
|
|
return result, result_uri
|
|
|
|
def read_result(self, result_uri: str) -> dict[str, Any]:
|
|
return read_json_object(self.minio_client, self.settings.minio_bucket, result_uri)
|
|
|
|
def upload_result(self, case_id: str, result: Mapping[str, Any]) -> str:
|
|
local_path = self.tmp_path / case_id / "result.json"
|
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
local_path.write_text(json.dumps(result, indent=2, sort_keys=True), encoding="utf-8")
|
|
return upload_file(
|
|
self.minio_client,
|
|
self.settings.minio_bucket,
|
|
f"{self.prefix}/{case_id}/result.json",
|
|
local_path,
|
|
)
|
|
|
|
def commit(self, result_uri: str) -> tuple[int, dict]:
|
|
return commit_backfill_result(self.settings.management_endpoint, result_uri)
|
|
|
|
def drop_snapshots_and_refresh(self) -> None:
|
|
for snapshot_name in self.snapshot_names:
|
|
self.client.drop_snapshot(snapshot_name, self.collection_name)
|
|
self.client.refresh_load(self.collection_name)
|
|
|
|
def list_case_objects(self, case_id: str) -> list[str]:
|
|
return list_object_keys(self.minio_client, self.settings.minio_bucket, f"{self.prefix}/{case_id}/")
|
|
|
|
def list_result_objects(self, result_uri: str) -> list[str]:
|
|
result_key = object_key(result_uri, self.settings.minio_bucket)
|
|
parent = str(PurePosixPath(result_key).parent).rstrip("/") + "/"
|
|
return list_object_keys(self.minio_client, self.settings.minio_bucket, parent)
|
|
|
|
def run_read_probe(self, case_id: str = "read") -> tuple[SparkJobResult, dict[str, Any]]:
|
|
projection_fields = ["id", "base_float"]
|
|
projection_ids = collection_field_ids(self.client, self.collection_name, projection_fields)
|
|
options = {
|
|
"milvus.uri": self.settings.spark_milvus_uri,
|
|
"milvus.collection.name": self.collection_name,
|
|
"milvus.database.name": "default",
|
|
"fs.address": self.settings.spark_minio_endpoint,
|
|
"fs.bucket_name": self.settings.minio_bucket,
|
|
"fs.root_path": self.root_path,
|
|
"fs.use_ssl": "false",
|
|
"fs.use_virtual_host": "false",
|
|
"fs.region": "us-east-1",
|
|
}
|
|
spec = {
|
|
"options": options,
|
|
"primaryKey": "id",
|
|
"projectionFields": projection_fields,
|
|
"projectionOptions": {"fieldIDs": ",".join(str(projection_ids[field]) for field in projection_fields)},
|
|
"sql": "SELECT COUNT(*) AS total, AVG(base_float) AS avg_float FROM milvus_backfill_read",
|
|
"vectorSearch": {
|
|
"query": self.source_rows[0]["vector"],
|
|
"topK": 5,
|
|
"metric": "L2",
|
|
"column": "vector",
|
|
"idColumn": "id",
|
|
},
|
|
}
|
|
result = self.runner.run(SparkJobRequest(case_id=case_id, operation="read", payload=spec))
|
|
if not result.succeeded:
|
|
raise BackfillContractError(
|
|
f"Spark Read probe failed (exit code {result.exit_code}); "
|
|
f"see {result.evidence_dir}/pod.log:\n{result.logs}"
|
|
)
|
|
return result, extract_read_probe_result(result.logs)
|
|
|
|
def write_local_evidence(self, job_result: SparkJobResult, filename: str, payload: Any) -> None:
|
|
path = Path(job_result.evidence_dir) / filename
|
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str), encoding="utf-8")
|