Files
eb7e63ebcf test: add Spark-Milvus backfill E2E coverage (#52155)
## 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>
2026-09-21 15:41:36 +08:00

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")