Files
wehub-resource-sync 498b235461
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:17 +08:00

4120 lines
166 KiB
Go

package testcases
import (
"bytes"
"context"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/apache/arrow/go/v17/parquet/pqarrow"
miniogo "github.com/minio/minio-go/v7"
miniocreds "github.com/minio/minio-go/v7/pkg/credentials"
"github.com/stretchr/testify/require"
"github.com/milvus-io/milvus/client/v2/column"
"github.com/milvus-io/milvus/client/v2/entity"
"github.com/milvus-io/milvus/client/v2/index"
client "github.com/milvus-io/milvus/client/v2/milvusclient"
"github.com/milvus-io/milvus/tests/go_client/base"
"github.com/milvus-io/milvus/tests/go_client/common"
hp "github.com/milvus-io/milvus/tests/go_client/testcases/helper"
)
// --- MinIO helpers (configurable via environment variables) ---
func envOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
switch key {
case "MINIO_ADDRESS":
if address, _, ok := inferGoSDKMinIOConfig(); ok {
return address
}
case "MINIO_BUCKET":
if _, bucket, ok := inferGoSDKMinIOConfig(); ok {
return bucket
}
case "MINIO_ROOT_PATH":
if _, _, ok := inferGoSDKMinIOConfig(); ok {
// The go-sdk Helm deployment writes Milvus object data under
// minio.rootPath=file. Keep the inferred MinIO config complete so
// tests that inspect persisted objects use the same prefix.
return "file"
}
}
return defaultVal
}
func inferGoSDKMinIOConfig() (string, string, bool) {
return inferGoSDKMinIOConfigFromAddr(hp.GetAddr())
}
func inferGoSDKMinIOConfigFromAddr(addr string) (string, string, bool) {
host := hostFromMilvusAddr(addr)
serviceName, namespaceSuffix, _ := strings.Cut(host, ".")
if !strings.HasPrefix(serviceName, "gosdk-") || !strings.HasSuffix(serviceName, "-milvus") {
return "", "", false
}
minioServiceName := strings.TrimSuffix(serviceName, "-milvus") + "-minio"
minioHost := minioServiceName
if namespaceSuffix != "" {
minioHost += "." + namespaceSuffix
}
return net.JoinHostPort(minioHost, "9000"), "milvus-bucket", true
}
func hostFromMilvusAddr(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return ""
}
if u, err := url.Parse(addr); err == nil && u.Host != "" {
return u.Hostname()
}
addr = strings.TrimPrefix(addr, "//")
if idx := strings.IndexByte(addr, '/'); idx >= 0 {
addr = addr[:idx]
}
if host, _, err := net.SplitHostPort(addr); err == nil {
return strings.Trim(host, "[]")
}
if idx := strings.LastIndexByte(addr, ':'); idx >= 0 {
addr = addr[:idx]
}
return strings.Trim(addr, "[]")
}
type minioConfig struct {
address string
accessKey string
secretKey string
bucket string
rootPath string
}
func getMinIOConfig() minioConfig {
return minioConfig{
address: envOrDefault("MINIO_ADDRESS", "localhost:9000"),
accessKey: envOrDefault("MINIO_ACCESS_KEY", "minioadmin"),
secretKey: envOrDefault("MINIO_SECRET_KEY", "minioadmin"),
bucket: envOrDefault("MINIO_BUCKET", "a-bucket"),
rootPath: envOrDefault("MINIO_ROOT_PATH", "files"),
}
}
func newMinIOClient(cfg minioConfig) (*miniogo.Client, error) {
return miniogo.New(cfg.address, &miniogo.Options{
Creds: miniocreds.NewStaticV4(cfg.accessKey, cfg.secretKey, ""),
Secure: false,
})
}
// extTestURI wraps a MinIO-relative object path into a full MinIO URI pointing
// at the configured bucket. The minio:// scheme is unambiguous, so tests do
// not need to carry AWS-family region or provider fields just to disambiguate
// s3:// from AWS S3.
func extTestURI(cfg minioConfig, relPath string) string {
return fmt.Sprintf("minio://%s/%s/%s", cfg.address, cfg.bucket, relPath)
}
// extTestSpec returns an ExternalSpec JSON fragment for external-table tests
// that target Milvus's own MinIO.
func extTestSpec(cfg minioConfig, format string) string {
return fmt.Sprintf(
`{"format":%q,"extfs":{"cloud_provider":"minio","access_key_id":%q,"access_key_value":%q}}`,
format, cfg.accessKey, cfg.secretKey)
}
// cleanupMinIOPrefix removes all objects under a prefix in the given bucket.
func cleanupMinIOPrefix(ctx context.Context, mc *miniogo.Client, bucket, prefix string) {
for obj := range mc.ListObjects(ctx, bucket, miniogo.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
}) {
if obj.Err != nil {
continue
}
_ = mc.RemoveObject(ctx, bucket, obj.Key, miniogo.RemoveObjectOptions{})
}
}
func skipIfMinIOUnreachable(ctx context.Context, t *testing.T, mc *miniogo.Client, bucket string) {
t.Helper()
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if _, err := mc.BucketExists(checkCtx, bucket); err != nil {
t.Skipf("MinIO unreachable (bucket=%s): %v", bucket, err)
}
}
// --- Parquet generation ---
const (
testVecDim = 4
perfTestVecDim = 128
)
const (
externalDataSchemaBasic = "basic"
externalDataSchemaMulti = "multi"
externalDataSchemaLarge = "large"
externalDataSchemaNullableVector = "nullable_vector"
externalDataSchemaSnapshotRestore = "snapshot_restore"
)
func generateParquetBytes(schema string, numRows, startID int64, dim int) ([]byte, error) {
switch schema {
case externalDataSchemaBasic, externalDataSchemaMulti,
externalDataSchemaLarge, externalDataSchemaNullableVector,
externalDataSchemaSnapshotRestore:
return generateParquetBytesWithCompression(schema, numRows, startID, dim, "")
default:
return nil, fmt.Errorf("unknown parquet data schema: %s", schema)
}
}
// generateSnapshotRestoreParquetBytes creates a compact Parquet file for the
// snapshot restore e2e. It stores float vectors as FixedSizeBinary so the test
// focuses on the snapshot restore path instead of list-vector decoding.
func generateSnapshotRestoreParquetBytes(numRows int64, startID int64) ([]byte, error) {
return generateParquetBytes(
externalDataSchemaSnapshotRestore, numRows, startID, testVecDim)
}
func generateParquetBytesWithCompression(schema string, numRows, startID int64, dim int, compression string) ([]byte, error) {
tmp, err := os.CreateTemp("", "external-table-*.parquet")
if err != nil {
return nil, fmt.Errorf("create temp parquet file: %w", err)
}
tmpPath := tmp.Name()
if err := tmp.Close(); err != nil {
return nil, fmt.Errorf("close temp parquet file: %w", err)
}
defer os.Remove(tmpPath)
args := []string{
"generate_parquet_data.py",
"--schema", schema,
tmpPath,
strconv.FormatInt(numRows, 10),
"--start-id", strconv.FormatInt(startID, 10),
"--vec-dim", strconv.Itoa(dim),
"--bin-vec-dim", strconv.Itoa(testBinVecDim),
}
if compression != "" {
args = append(args, "--compression", compression)
}
output, err := exec.Command("python3", args...).CombinedOutput() // #nosec G204
if err != nil {
return nil, fmt.Errorf("generate parquet data via python: %w: %s", err, strings.TrimSpace(string(output)))
}
data, err := os.ReadFile(tmpPath)
if err != nil {
return nil, fmt.Errorf("read generated parquet file: %w", err)
}
if len(data) == 0 {
return nil, fmt.Errorf("generated empty parquet file: %s", strings.TrimSpace(string(output)))
}
return data, nil
}
func externalTakeDenseBytes(row int64, byteWidth int, seed int64) []byte {
data := make([]byte, byteWidth)
for i := range data {
data[i] = byte((seed + row*int64(byteWidth) + int64(i)) % 251)
}
return data
}
func externalTakeInt8Vector(row int64) []int8 {
data := make([]int8, testVecDim)
for i := range data {
data[i] = int8((row*3 + int64(i)) % 127)
}
return data
}
func externalTakeInt8VectorBytes(row int64) []byte {
values := externalTakeInt8Vector(row)
data := make([]byte, len(values))
for i, value := range values {
data[i] = byte(value)
}
return data
}
func generateExternalTakeVectorParquetBytes(numRows int64) ([]byte, error) {
const binVecDim = 16
binVecByteWidth := binVecDim / 8
fp16ByteWidth := testVecDim * 2
bf16ByteWidth := testVecDim * 2
arrowSchema := arrow.NewSchema(
[]arrow.Field{
{Name: "id", Type: arrow.PrimitiveTypes.Int64},
{Name: "bin_vec", Type: &arrow.FixedSizeBinaryType{ByteWidth: binVecByteWidth}},
{Name: "fp16_vec", Type: &arrow.FixedSizeBinaryType{ByteWidth: fp16ByteWidth}},
{Name: "bf16_vec", Type: &arrow.FixedSizeBinaryType{ByteWidth: bf16ByteWidth}},
{Name: "int8_vec", Type: &arrow.FixedSizeBinaryType{ByteWidth: testVecDim}},
},
nil,
)
var buf bytes.Buffer
writer, err := pqarrow.NewFileWriter(arrowSchema, &buf, nil, pqarrow.DefaultWriterProps())
if err != nil {
return nil, fmt.Errorf("create parquet writer: %w", err)
}
pool := memory.NewGoAllocator()
builder := array.NewRecordBuilder(pool, arrowSchema)
defer builder.Release()
idBuilder := builder.Field(0).(*array.Int64Builder)
binBuilder := builder.Field(1).(*array.FixedSizeBinaryBuilder)
fp16Builder := builder.Field(2).(*array.FixedSizeBinaryBuilder)
bf16Builder := builder.Field(3).(*array.FixedSizeBinaryBuilder)
int8Builder := builder.Field(4).(*array.FixedSizeBinaryBuilder)
for row := int64(0); row < numRows; row++ {
idBuilder.Append(row)
binBuilder.Append(externalTakeDenseBytes(row, binVecByteWidth, 11))
fp16Builder.Append(externalTakeDenseBytes(row, fp16ByteWidth, 31))
bf16Builder.Append(externalTakeDenseBytes(row, bf16ByteWidth, 51))
int8Builder.Append(externalTakeInt8VectorBytes(row))
}
record := builder.NewRecord()
defer record.Release()
if err := writer.Write(record); err != nil {
return nil, fmt.Errorf("write record: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close writer: %w", err)
}
return buf.Bytes(), nil
}
// --- Multi-type parquet generation ---
// generateMultiTypeParquetBytes creates a Parquet file with multiple data types:
// - id (Int64), bool_val (Boolean), int8_val (Int8), int16_val (Int16),
// - int32_val (Int32), float_val (Float32), double_val (Float64),
// - embedding (FixedSizeList[Float32, testVecDim])
//
// Data formulas for row i (startID+i):
//
// bool_val = (i is even), int8_val = i%100, int16_val = i*10,
// int32_val = i*100, float_val = i*1.5, double_val = i*0.01
const testBinVecDim = 8 // BinaryVector dimension must be multiple of 8
// --- Helpers ---
func uploadParquetToMinIO(ctx context.Context, t *testing.T, mc *miniogo.Client, bucket, objectKey string, data []byte) {
t.Helper()
_, err := mc.PutObject(ctx, bucket, objectKey,
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err, "upload parquet file to MinIO: %s", objectKey)
t.Logf("Uploaded %s/%s (%d bytes)", bucket, objectKey, len(data))
}
func waitForRefreshComplete(ctx context.Context, t *testing.T, mc *base.MilvusClient, jobID int64, timeout time.Duration) {
t.Helper()
deadline := time.After(timeout)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatalf("Refresh job %d timed out after %s", jobID, timeout)
case <-ticker.C:
progress, err := mc.GetRefreshExternalCollectionProgress(ctx,
client.NewGetRefreshExternalCollectionProgressOption(jobID))
require.NoError(t, err)
t.Logf("Job %d: state=%s progress=%d%%", jobID, progress.State, progress.Progress)
if progress.State == entity.RefreshStateCompleted {
return
}
if progress.State == entity.RefreshStateFailed {
t.Fatalf("Refresh job %d failed: %s", jobID, progress.Reason)
}
}
}
}
func refreshAndWait(ctx context.Context, t *testing.T, mc *base.MilvusClient, collName string) int64 {
t.Helper()
refreshResult, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
require.Greater(t, refreshResult.JobID, int64(0))
t.Logf("Started refresh job: %d", refreshResult.JobID)
waitForRefreshComplete(ctx, t, mc, refreshResult.JobID, 120*time.Second)
return refreshResult.JobID
}
func indexAndLoadCollection(ctx context.Context, t *testing.T, mc *base.MilvusClient, collName, vecFieldName string) {
t.Helper()
idx := index.NewAutoIndex(entity.L2)
idxTask, err := mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, vecFieldName, idx))
common.CheckErr(t, err, true)
err = idxTask.Await(ctx)
common.CheckErr(t, err, true)
t.Log("Vector index created on " + vecFieldName)
loadTask, err := mc.LoadCollection(ctx, client.NewLoadCollectionOption(collName))
common.CheckErr(t, err, true)
err = loadTask.Await(ctx)
common.CheckErr(t, err, true)
t.Log("Collection loaded")
}
// indexAndLoadCollectionWithScalarAndVector creates scalar indexes on id and value fields,
// a vector index on the embedding field, verifies all indexes are built successfully,
// and then loads the collection.
func indexAndLoadCollectionWithScalarAndVector(ctx context.Context, t *testing.T, mc *base.MilvusClient, collName string, totalRows int64) {
t.Helper()
// Create scalar index on "id" field
idIdx := index.NewAutoIndex(entity.IP)
idIdxTask, err := mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, "id", idIdx))
common.CheckErr(t, err, true)
err = idIdxTask.Await(ctx)
common.CheckErr(t, err, true)
t.Log("Scalar index created on id field")
// NOTE: Skip scalar index on "value" field — InvertedIndexTantivy::Load
// crashes on ARM64 macOS (Tantivy FFI bug, not related to external table).
// Re-enable when running on Linux CI.
// valIdx := index.NewAutoIndex(entity.IP)
// valIdxTask, err := mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, "value", valIdx))
// common.CheckErr(t, err, true)
// err = valIdxTask.Await(ctx)
// common.CheckErr(t, err, true)
t.Log("Scalar index on value field: SKIPPED (ARM64 Tantivy bug)")
// Create vector index on "embedding" field
vecIdx := index.NewAutoIndex(entity.L2)
vecIdxTask, err := mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, "embedding", vecIdx))
common.CheckErr(t, err, true)
err = vecIdxTask.Await(ctx)
common.CheckErr(t, err, true)
t.Log("Vector index created on embedding field")
// Verify all indexes via DescribeIndex (skip "value" — ARM64 Tantivy bug)
for _, fieldName := range []string{"id", "embedding"} {
descIdx, err := mc.DescribeIndex(ctx, client.NewDescribeIndexOption(collName, fieldName))
common.CheckErr(t, err, true)
require.Equal(t, index.IndexState(3), descIdx.State,
"index on %s should be Finished (state=3)", fieldName) // 3 = IndexState_Finished
require.Equal(t, totalRows, descIdx.TotalRows,
"index on %s: TotalRows should be %d", fieldName, totalRows)
require.Equal(t, totalRows, descIdx.IndexedRows,
"index on %s: IndexedRows should be %d", fieldName, totalRows)
require.Equal(t, int64(0), descIdx.PendingIndexRows,
"index on %s: PendingIndexRows should be 0", fieldName)
t.Logf("Index on %s: state=%d totalRows=%d indexedRows=%d pendingRows=%d",
fieldName, descIdx.State, descIdx.TotalRows, descIdx.IndexedRows, descIdx.PendingIndexRows)
}
// Load collection
loadTask, err := mc.LoadCollection(ctx, client.NewLoadCollectionOption(collName))
common.CheckErr(t, err, true)
err = loadTask.Await(ctx)
common.CheckErr(t, err, true)
t.Log("Collection loaded")
}
// --- E2E Tests ---
func TestExternalCollectionSnapshotRestoreAndAccess(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
skipIfMinIOUnreachable(ctx, t, minioClient, minioCfg.bucket)
collName := common.GenRandomString("ext_snapshot_restore", 6)
restoredCollName := fmt.Sprintf("restored_%s", collName)
snapshotName := fmt.Sprintf("snapshot_%s", common.GenRandomString("ext", 6))
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
collectionsToClean := []string{collName, restoredCollName}
t.Cleanup(func() {
_ = mc.DropSnapshot(context.Background(), client.NewDropSnapshotOption(snapshotName, collName))
for _, name := range collectionsToClean {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(name))
}
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, fmt.Sprintf("%s/", extPath))
})
const numRows = int64(100)
data, err := generateSnapshotRestoreParquetBytes(numRows, 0)
require.NoError(t, err)
objectKey := fmt.Sprintf("%s/data.parquet", extPath)
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, objectKey, data)
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithExternalField("id")).
WithField(entity.NewField().
WithName("value").
WithDataType(entity.FieldTypeFloat).
WithExternalField("value")).
WithField(entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
refreshAndWait(ctx, t, mc, collName)
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
err = mc.CreateSnapshot(ctx, client.NewCreateSnapshotOption(snapshotName, collName).
WithDescription("external collection snapshot restore e2e"))
common.CheckErr(t, err, true)
jobID, err := mc.RestoreSnapshot(ctx, client.NewRestoreSnapshotOption(snapshotName, collName, restoredCollName))
common.CheckErr(t, err, true)
_, err = waitForRestoreComplete(ctx, mc, jobID, 3*time.Minute)
common.CheckErr(t, err, true)
loadTask, err := mc.LoadCollection(ctx, client.NewLoadCollectionOption(restoredCollName).WithReplica(1))
common.CheckErr(t, err, true)
err = loadTask.Await(ctx)
common.CheckErr(t, err, true)
countRes, err := mc.Query(ctx, client.NewQueryOption(restoredCollName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, numRows, count)
filterRes, err := mc.Query(ctx, client.NewQueryOption(restoredCollName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id < 10").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 10, filterRes.GetColumn("id").Len())
vec := make([]float32, testVecDim)
for i := range vec {
vec[i] = float32(i) * 0.1
}
searchRes, err := mc.Search(ctx, client.NewSearchOption(restoredCollName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(searchRes))
require.Greater(t, searchRes[0].ResultCount, 0)
}
// TestRefreshExternalCollectionAndVerifySegments tests the full external collection workflow:
// 1. Upload parquet test data to MinIO
// 2. Create an external collection with matching schema
// 3. Trigger a refresh job
// 4. Wait for refresh to complete
// 5. Verify the generated segment count and total row count
func TestRefreshExternalCollectionAndVerifySegments(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
// Connect to MinIO (skip if unavailable)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible (exists=%v, err=%v), skipping",
minioCfg.bucket, exists, err)
}
collName := common.GenRandomString("ext_refresh", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// ---------------------------------------------------------------
// Step 1: Generate and upload parquet data to MinIO
// ---------------------------------------------------------------
const numFiles = 2
const rowsPerFile = int64(1000)
totalExpectedRows := int64(numFiles) * rowsPerFile
for i := 0; i < numFiles; i++ {
data, err := generateParquetBytes(externalDataSchemaBasic, rowsPerFile, int64(i)*rowsPerFile, testVecDim)
require.NoError(t, err, "generate parquet file %d", i)
objectKey := fmt.Sprintf("%s/data%d.parquet", extPath, i)
_, err = minioClient.PutObject(ctx, minioCfg.bucket, objectKey,
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err, "upload parquet file %d to MinIO", i)
t.Logf("Uploaded %s/%s (%d bytes, %d rows)", minioCfg.bucket, objectKey, len(data), rowsPerFile)
}
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/", extPath))
})
// ---------------------------------------------------------------
// Step 2: Create external collection
// ---------------------------------------------------------------
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(
entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithExternalField("id"),
).
WithField(
entity.NewField().
WithName("value").
WithDataType(entity.FieldTypeFloat).
WithExternalField("value"),
).
WithField(
entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"),
)
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created external collection: %s", collName)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// Verify collection exists with correct external source
coll, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
common.CheckErr(t, err, true)
require.Equal(t, extTestURI(minioCfg, extPath), coll.Schema.ExternalSource)
// ---------------------------------------------------------------
// Step 3: Trigger refresh
// ---------------------------------------------------------------
refreshResult, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
require.Greater(t, refreshResult.JobID, int64(0))
t.Logf("Started refresh job: %d", refreshResult.JobID)
// ---------------------------------------------------------------
// Step 4: Poll until refresh completes (timeout 120s)
// ---------------------------------------------------------------
jobID := refreshResult.JobID
deadline := time.After(120 * time.Second)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatalf("Refresh job %d timed out after 120s", jobID)
case <-ticker.C:
progress, err := mc.GetRefreshExternalCollectionProgress(ctx,
client.NewGetRefreshExternalCollectionProgressOption(jobID))
require.NoError(t, err)
t.Logf("Job %d: state=%s progress=%d%%", jobID, progress.State, progress.Progress)
if progress.State == entity.RefreshStateCompleted {
goto verify
}
if progress.State == entity.RefreshStateFailed {
t.Fatalf("Refresh job %d failed: %s", jobID, progress.Reason)
}
}
}
verify:
// ---------------------------------------------------------------
// Step 5: Verify row count via collection statistics
// ---------------------------------------------------------------
// NOTE: GetPersistentSegmentInfo uses channel-based segment lookup which does
// not work for external collections (external segments lack InsertChannel).
// GetCollectionStats uses collection-ID-based lookup (coll2Segments index)
// which correctly includes external segments.
stats, err := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, err, true)
rowCountStr, ok := stats["row_count"]
require.True(t, ok, "collection stats should contain row_count")
rowCount, err := strconv.ParseInt(rowCountStr, 10, 64)
require.NoError(t, err, "parse row_count")
require.Equal(t, totalExpectedRows, rowCount,
"total row count should match uploaded parquet data")
t.Logf("Verified: collection stats row_count=%d (expected %d)", rowCount, totalExpectedRows)
// ---------------------------------------------------------------
// Step 6: Verify via ListRefreshExternalCollectionJobs
// ---------------------------------------------------------------
jobs, err := mc.ListRefreshExternalCollectionJobs(ctx,
client.NewListRefreshExternalCollectionJobsOption(collName))
common.CheckErr(t, err, true)
require.Greater(t, len(jobs), 0, "should have at least one refresh job")
found := false
for _, job := range jobs {
if job.JobID == jobID {
require.Equal(t, entity.RefreshStateCompleted, job.State)
found = true
break
}
}
require.True(t, found, "should find the completed refresh job in job list")
// ---------------------------------------------------------------
// Step 7: Regression for issue #48626 — explore temp dir must be
// reclaimed after the refresh job reaches a terminal state, not
// left as orphaned 0-byte placeholders on object storage.
// The datacoord Finished handler fires cleanupExploreTempForJob
// via defer, so by the time GetRefreshExternalCollectionProgress
// reports Completed + schema updated, the prefix must be gone.
// ---------------------------------------------------------------
exploreTempPrefix := fmt.Sprintf("__explore_temp__/coord_%d/", jobID)
var leaked []string
for obj := range minioClient.ListObjects(ctx, minioCfg.bucket, miniogo.ListObjectsOptions{
Prefix: exploreTempPrefix,
Recursive: true,
}) {
if obj.Err != nil {
continue
}
leaked = append(leaked, obj.Key)
}
require.Empty(t, leaked,
"explore temp prefix %q should be fully reclaimed after refresh completes (issue #48626)",
exploreTempPrefix)
t.Logf("Verified issue #48626: explore temp prefix %q is clean", exploreTempPrefix)
}
// TestExternalCollectionParquetCompressionCodecs is the regression test for
// issue #48869. Part 8 added `arrow:with_snappy=True` and `arrow:with_lz4=True`
// to the conan recipe; if either gets dropped or the bundled Arrow is rebuilt
// without codec support, external collections backed by compressed parquet
// silently break at query time. This test generates a tiny parquet file with
// each codec via pyarrow (Go arrow can't write LZ4 in a C++-readable format)
// and verifies refresh + row count match.
func TestExternalCollectionParquetCompressionCodecs(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
const numRows = 256
for _, codec := range []string{"snappy", "lz4"} {
codec := codec
t.Run(codec, func(t *testing.T) {
data, err := generateParquetBytesWithCompression(externalDataSchemaBasic, numRows, 0, testVecDim, codec)
require.NoError(t, err, "generate %s-compressed parquet", codec)
collName := common.GenRandomString("ext_codec_"+codec, 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
objectKey := fmt.Sprintf("%s/data.parquet", extPath)
_, err = minioClient.PutObject(ctx, minioCfg.bucket, objectKey,
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err, "upload %s-compressed parquet", codec)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, extPath+"/")
})
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).WithDim(testVecDim).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
refreshResult, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
jobID := refreshResult.JobID
deadline := time.After(120 * time.Second)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for loop := true; loop; {
select {
case <-deadline:
t.Fatalf("refresh for %s codec timed out", codec)
case <-ticker.C:
p, err := mc.GetRefreshExternalCollectionProgress(ctx,
client.NewGetRefreshExternalCollectionProgressOption(jobID))
require.NoError(t, err)
if p.State == entity.RefreshStateCompleted {
loop = false
}
if p.State == entity.RefreshStateFailed {
t.Fatalf("refresh for %s codec failed: %s", codec, p.Reason)
}
}
}
stats, err := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, err, true)
rowCountStr, ok := stats["row_count"]
require.True(t, ok)
rowCount, err := strconv.ParseInt(rowCountStr, 10, 64)
require.NoError(t, err)
require.Equal(t, int64(numRows), rowCount,
"%s-compressed parquet row count mismatch — codec likely not linked into Arrow", codec)
t.Logf("Verified issue #48869 codec=%s: refresh + row count match", codec)
})
}
}
// TestExternalCollectionLoadAndQuery tests the full external collection lifecycle:
// 1. Upload parquet test data to MinIO
// 2. Create an external collection
// 3. Refresh and wait for completion
// 4. Create index on vector field
// 5. Load collection
// 6. Query count(*)
// 7. Query with filter and output fields
// 8. Search with vector
func TestExternalCollectionLoadAndQuery(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
// Connect to MinIO (skip if unavailable)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible (exists=%v, err=%v), skipping",
minioCfg.bucket, exists, err)
}
collName := common.GenRandomString("ext_load_query", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// ---------------------------------------------------------------
// Step 1: Generate and upload parquet data to MinIO
// ---------------------------------------------------------------
const numFiles = 2
const rowsPerFile = int64(1000)
totalExpectedRows := int64(numFiles) * rowsPerFile
for i := 0; i < numFiles; i++ {
data, err := generateParquetBytes(externalDataSchemaBasic, rowsPerFile, int64(i)*rowsPerFile, testVecDim)
require.NoError(t, err, "generate parquet file %d", i)
objectKey := fmt.Sprintf("%s/data%d.parquet", extPath, i)
_, err = minioClient.PutObject(ctx, minioCfg.bucket, objectKey,
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err, "upload parquet file %d to MinIO", i)
t.Logf("Uploaded %s/%s (%d bytes, %d rows)", minioCfg.bucket, objectKey, len(data), rowsPerFile)
}
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/", extPath))
})
// ---------------------------------------------------------------
// Step 2: Create external collection
// ---------------------------------------------------------------
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(
entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithExternalField("id"),
).
WithField(
entity.NewField().
WithName("value").
WithDataType(entity.FieldTypeFloat).
WithExternalField("value"),
).
WithField(
entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"),
)
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created external collection: %s", collName)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// Verify collection exists with correct external source
coll, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
common.CheckErr(t, err, true)
require.Equal(t, extTestURI(minioCfg, extPath), coll.Schema.ExternalSource)
// ---------------------------------------------------------------
// Step 3: Trigger refresh and poll until complete
// ---------------------------------------------------------------
refreshResult, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
require.Greater(t, refreshResult.JobID, int64(0))
t.Logf("Started refresh job: %d", refreshResult.JobID)
jobID := refreshResult.JobID
deadline := time.After(120 * time.Second)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatalf("Refresh job %d timed out after 120s", jobID)
case <-ticker.C:
progress, err := mc.GetRefreshExternalCollectionProgress(ctx,
client.NewGetRefreshExternalCollectionProgressOption(jobID))
require.NoError(t, err)
t.Logf("Job %d: state=%s progress=%d%%", jobID, progress.State, progress.Progress)
if progress.State == entity.RefreshStateCompleted {
goto indexAndLoad
}
if progress.State == entity.RefreshStateFailed {
t.Fatalf("Refresh job %d failed: %s", jobID, progress.Reason)
}
}
}
indexAndLoad:
// ---------------------------------------------------------------
// Step 4: Create vector index and load collection
// (skip scalar indexes to avoid ARM64 Tantivy/HNSW crashes)
// ---------------------------------------------------------------
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
// ---------------------------------------------------------------
// Step 6: Query count(*)
// ---------------------------------------------------------------
countRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, totalExpectedRows, count,
"count(*) should match total uploaded rows")
t.Logf("Query count(*) = %d (expected %d)", count, totalExpectedRows)
// ---------------------------------------------------------------
// Step 7: Query with filter and output fields
// ---------------------------------------------------------------
filterRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id < 100").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
idCol := filterRes.GetColumn("id")
require.Equal(t, 100, idCol.Len(),
"filter id < 100 should return exactly 100 rows")
t.Logf("Query with filter returned %d rows", idCol.Len())
// ---------------------------------------------------------------
// Step 8: Search with vector
// ---------------------------------------------------------------
vec := make([]float32, testVecDim)
for i := range vec {
vec[i] = float32(i) * 0.1
}
searchRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(searchRes), "should have 1 result set for 1 query vector")
require.Greater(t, searchRes[0].ResultCount, 0, "search should return results")
t.Logf("Search returned %d results", searchRes[0].ResultCount)
// ---------------------------------------------------------------
// Step 9: Search with filter (hybrid search)
// ---------------------------------------------------------------
hybridRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithFilter("id >= 500 && id < 1000").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(hybridRes))
require.Greater(t, hybridRes[0].ResultCount, 0, "hybrid search should return results")
// Verify all returned IDs satisfy the filter
hybridIDCol := hybridRes[0].GetColumn("id")
for i := 0; i < hybridIDCol.Len(); i++ {
val, _ := hybridIDCol.GetAsInt64(i)
require.GreaterOrEqual(t, val, int64(500), "filtered search result id should be >= 500")
require.Less(t, val, int64(1000), "filtered search result id should be < 1000")
}
t.Logf("Hybrid search (filter id in [500,1000)) returned %d results", hybridRes[0].ResultCount)
// ---------------------------------------------------------------
// Step 10: Query with pagination (offset + limit)
// ---------------------------------------------------------------
pageRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id >= 0").
WithOutputFields("id").
WithOffset(10).
WithLimit(20))
common.CheckErr(t, err, true)
pageIDCol := pageRes.GetColumn("id")
require.Equal(t, 20, pageIDCol.Len(), "pagination should return exactly 20 rows")
firstID, _ := pageIDCol.GetAsInt64(0)
require.Equal(t, int64(10), firstID, "first row after offset=10 should have id=10")
t.Logf("Pagination query (offset=10, limit=20) returned %d rows, first id=%d", pageIDCol.Len(), firstID)
// ---------------------------------------------------------------
// Step 11: Search with multiple query vectors
// ---------------------------------------------------------------
vec2 := make([]float32, testVecDim)
for i := range vec2 {
vec2[i] = float32(999-i) * 0.1
}
multiRes, err := mc.Search(ctx, client.NewSearchOption(collName, 3,
[]entity.Vector{entity.FloatVector(vec), entity.FloatVector(vec2)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id"))
common.CheckErr(t, err, true)
require.Equal(t, 2, len(multiRes), "should have 2 result sets for 2 query vectors")
require.Greater(t, multiRes[0].ResultCount, 0, "first query should return results")
require.Greater(t, multiRes[1].ResultCount, 0, "second query should return results")
t.Logf("Multi-vector search returned %d and %d results", multiRes[0].ResultCount, multiRes[1].ResultCount)
// ---------------------------------------------------------------
// Step 12: Query with output embedding field (vector retrieval)
// ---------------------------------------------------------------
vecRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id == 0").
WithOutputFields("id", "embedding"))
common.CheckErr(t, err, true)
vecIDCol := vecRes.GetColumn("id")
require.Equal(t, 1, vecIDCol.Len(), "should return exactly 1 row for id==0")
embCol := vecRes.GetColumn("embedding")
require.NotNil(t, embCol, "embedding column should be present in output")
require.Equal(t, 1, embCol.Len(), "embedding column should have 1 row")
t.Log("Query with embedding output field succeeded")
}
func TestExternalCollectionNullableFloatVectorTakeOutput(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible (exists=%v, err=%v), skipping",
minioCfg.bucket, exists, err)
}
collName := common.GenRandomString("ext_nullable_vec", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
data, err := generateParquetBytes(externalDataSchemaNullableVector, 3, 0, testVecDim)
require.NoError(t, err, "generate nullable vector parquet")
objectKey := fmt.Sprintf("%s/data.parquet", extPath)
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, objectKey, data)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/", extPath))
})
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).WithNullable(true).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
refreshAndWait(ctx, t, mc, collName)
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
queryRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id >= 0").
WithOutputFields("id", "embedding"))
common.CheckErr(t, err, true)
require.Equal(t, 3, queryRes.ResultCount)
idCol := queryRes.GetColumn("id")
embeddingCol := queryRes.GetColumn("embedding")
require.NotNil(t, idCol)
require.NotNil(t, embeddingCol)
type vectorRow struct {
isNull bool
vector []float32
}
rows := make(map[int64]vectorRow, queryRes.ResultCount)
for i := 0; i < queryRes.ResultCount; i++ {
id, err := idCol.GetAsInt64(i)
require.NoError(t, err)
isNull, err := embeddingCol.IsNull(i)
require.NoError(t, err)
row := vectorRow{isNull: isNull}
if !isNull {
raw, err := embeddingCol.Get(i)
require.NoError(t, err)
vec, ok := raw.(entity.FloatVector)
require.Truef(t, ok, "embedding row %d has unexpected type %T", i, raw)
row.vector = []float32(vec)
}
rows[id] = row
}
require.Len(t, rows, 3)
assertVector := func(id int64, expected []float32) {
t.Helper()
row, ok := rows[id]
require.True(t, ok, "missing row id=%d", id)
require.False(t, row.isNull, "embedding should not be null for id=%d", id)
require.Equal(t, len(expected), len(row.vector), "embedding dim mismatch for id=%d", id)
for i := range expected {
require.InDelta(t, expected[i], row.vector[i], 1e-6,
"embedding[%d] mismatch for id=%d", i, id)
}
}
assertVector(0, []float32{0, 1, 2, 3})
require.True(t, rows[1].isNull, "embedding should be null for id=1")
assertVector(2, []float32{8, 9, 10, 11})
}
func TestExternalCollectionAdditionalVectorTakeOutput(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible (exists=%v, err=%v), skipping",
minioCfg.bucket, exists, err)
}
collName := common.GenRandomString("ext_take_vec", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
const numRows = int64(8)
data, err := generateExternalTakeVectorParquetBytes(numRows)
require.NoError(t, err, "generate vector take parquet")
objectKey := fmt.Sprintf("%s/data.parquet", extPath)
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, objectKey, data)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/", extPath))
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("bin_vec").WithDataType(entity.FieldTypeBinaryVector).
WithDim(16).WithExternalField("bin_vec")).
WithField(entity.NewField().WithName("fp16_vec").WithDataType(entity.FieldTypeFloat16Vector).
WithDim(testVecDim).WithExternalField("fp16_vec")).
WithField(entity.NewField().WithName("bf16_vec").WithDataType(entity.FieldTypeBFloat16Vector).
WithDim(testVecDim).WithExternalField("bf16_vec")).
WithField(entity.NewField().WithName("int8_vec").WithDataType(entity.FieldTypeInt8Vector).
WithDim(testVecDim).WithExternalField("int8_vec"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
refreshAndWait(ctx, t, mc, collName)
indexes := map[string]index.Index{
"bin_vec": index.NewBinFlatIndex(entity.HAMMING),
"fp16_vec": index.NewFlatIndex(entity.L2),
"bf16_vec": index.NewFlatIndex(entity.L2),
"int8_vec": index.NewGenericIndex("int8_hnsw", map[string]string{
index.MetricTypeKey: string(entity.COSINE),
index.IndexTypeKey: "HNSW",
"M": "8",
"efConstruction": "64",
}),
}
for fieldName, idx := range indexes {
idxTask, idxErr := mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, fieldName, idx))
common.CheckErr(t, idxErr, true)
require.NoError(t, idxTask.Await(ctx), "create index on %s", fieldName)
}
loadTask, loadErr := mc.LoadCollection(ctx, client.NewLoadCollectionOption(collName))
common.CheckErr(t, loadErr, true)
require.NoError(t, loadTask.Await(ctx))
assertVectorRows := func(result client.ResultSet, context string) {
t.Helper()
idCol := result.GetColumn("id")
require.NotNil(t, idCol, "%s id column", context)
for _, fieldName := range []string{"bin_vec", "fp16_vec", "bf16_vec", "int8_vec"} {
require.NotNil(t, result.GetColumn(fieldName), "%s %s column", context, fieldName)
}
for i := 0; i < result.ResultCount; i++ {
id, err := idCol.GetAsInt64(i)
require.NoError(t, err)
rawBin, err := result.GetColumn("bin_vec").Get(i)
require.NoError(t, err)
require.Equal(t, externalTakeDenseBytes(id, 2, 11), []byte(rawBin.(entity.BinaryVector)),
"%s bin_vec row id=%d", context, id)
rawFP16, err := result.GetColumn("fp16_vec").Get(i)
require.NoError(t, err)
require.Equal(t, externalTakeDenseBytes(id, testVecDim*2, 31), []byte(rawFP16.(entity.Float16Vector)),
"%s fp16_vec row id=%d", context, id)
rawBF16, err := result.GetColumn("bf16_vec").Get(i)
require.NoError(t, err)
require.Equal(t, externalTakeDenseBytes(id, testVecDim*2, 51), []byte(rawBF16.(entity.BFloat16Vector)),
"%s bf16_vec row id=%d", context, id)
rawInt8, err := result.GetColumn("int8_vec").Get(i)
require.NoError(t, err)
require.Equal(t, externalTakeInt8Vector(id), []int8(rawInt8.(entity.Int8Vector)),
"%s int8_vec row id=%d", context, id)
}
}
queryRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id in [1, 3, 5]").
WithOutputFields("id", "bin_vec", "fp16_vec", "bf16_vec", "int8_vec"))
common.CheckErr(t, err, true)
require.Equal(t, 3, queryRes.ResultCount)
assertVectorRows(queryRes, "query output")
searchRes, err := mc.Search(ctx, client.NewSearchOption(collName, 3,
[]entity.Vector{entity.Int8Vector(externalTakeInt8Vector(3))}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("int8_vec").
WithOutputFields("id", "bin_vec", "fp16_vec", "bf16_vec", "int8_vec"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(searchRes))
require.Greater(t, searchRes[0].ResultCount, 0)
assertVectorRows(searchRes[0], "int8 search output")
}
func TestExternalCollectionSparseVectorCurrentlyRejected(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
collName := common.GenRandomString("ext_sparse_reject", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("sparse_vec").WithDataType(entity.FieldTypeSparseVector).
WithExternalField("sparse_vec"))
err := mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
require.Error(t, err)
require.Contains(t, err.Error(), "does not support field type SparseFloatVector")
}
// TestExternalCollectionIncrementalRefresh tests that refreshing an external collection
// after modifying the underlying parquet files correctly updates segments:
// 1. Initial files: data0 (ids 0-499) + data1 (ids 500-999) → 1000 rows
// 2. Remove data1, add data2 (ids 2000-2299) → 800 rows
// 3. Verify: data0 intact, data1 gone, data2 present
func TestExternalCollectionIncrementalRefresh(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*600) // 10 min for two refresh+load cycles
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_incr_refresh", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, extPath+"/")
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// ---------------------------------------------------------------
// Phase 1: Upload initial data files
// ---------------------------------------------------------------
data0, err := generateParquetBytes(externalDataSchemaBasic, 500, 0, testVecDim) // ids 0-499
require.NoError(t, err)
data1, err := generateParquetBytes(externalDataSchemaBasic, 500, 500, testVecDim) // ids 500-999
require.NoError(t, err)
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, extPath+"/data0.parquet", data0)
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, extPath+"/data1.parquet", data1)
// ---------------------------------------------------------------
// Create external collection
// ---------------------------------------------------------------
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).WithDim(testVecDim).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created external collection: %s", collName)
// ---------------------------------------------------------------
// First refresh + load
// ---------------------------------------------------------------
refreshAndWait(ctx, t, mc, collName)
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
// Verify: count = 1000
countRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, _ := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.Equal(t, int64(1000), count, "initial count should be 1000")
t.Logf("Phase 1: count(*) = %d", count)
// Verify: data1 range (ids 500-999) is present
res1, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id >= 500 && id < 1000").
WithOutputFields("id"))
common.CheckErr(t, err, true)
require.Equal(t, 500, res1.GetColumn("id").Len(), "should find 500 rows in data1 range")
// ---------------------------------------------------------------
// Phase 2: Release, modify files, refresh again
// ---------------------------------------------------------------
err = mc.ReleaseCollection(ctx, client.NewReleaseCollectionOption(collName))
require.NoError(t, err, "release collection")
t.Log("Collection released")
// Remove data1.parquet (ids 500-999)
err = minioClient.RemoveObject(ctx, minioCfg.bucket, extPath+"/data1.parquet", miniogo.RemoveObjectOptions{})
require.NoError(t, err, "remove data1.parquet")
t.Log("Removed data1.parquet from MinIO")
// Upload data2.parquet (ids 2000-2299, 300 rows)
data2, err := generateParquetBytes(externalDataSchemaBasic, 300, 2000, testVecDim)
require.NoError(t, err)
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, extPath+"/data2.parquet", data2)
// Second refresh
refreshAndWait(ctx, t, mc, collName)
// Verify collection stats: count = 500 + 300 = 800
stats, err := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, err, true)
rowCountStr, ok := stats["row_count"]
require.True(t, ok, "stats should contain row_count")
rowCount, _ := strconv.ParseInt(rowCountStr, 10, 64)
require.Equal(t, int64(800), rowCount, "after file change: 500 (data0) + 300 (data2) = 800")
t.Logf("Phase 2: collection stats row_count = %d", rowCount)
// ---------------------------------------------------------------
// Phase 3: Reload and verify data correctness
// ---------------------------------------------------------------
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
// Verify total count = 800
countRes2, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count2, _ := countRes2.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.Equal(t, int64(800), count2, "count should be 800 after file change")
// Verify: data0 range (ids 0-499) still intact
res0, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id >= 0 && id < 500").
WithOutputFields("id"))
common.CheckErr(t, err, true)
require.Equal(t, 500, res0.GetColumn("id").Len(), "data0 (ids 0-499) should be intact")
// Verify: data1 range (ids 500-999) is gone
res1Gone, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id >= 500 && id < 1000").
WithOutputFields("id"))
common.CheckErr(t, err, true)
data1Len := 0
if col := res1Gone.GetColumn("id"); col != nil {
data1Len = col.Len()
}
require.Equal(t, 0, data1Len, "data1 (ids 500-999) should be removed after refresh")
// Verify: data2 range (ids 2000-2299) is present
res2, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id >= 2000 && id < 2300").
WithOutputFields("id"))
common.CheckErr(t, err, true)
require.Equal(t, 300, res2.GetColumn("id").Len(), "data2 (ids 2000-2299) should be present")
// Verify: search still works after incremental refresh
vec := make([]float32, testVecDim)
for i := range vec {
vec[i] = float32(i) * 0.1
}
searchRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id"))
common.CheckErr(t, err, true)
require.Greater(t, searchRes[0].ResultCount, 0, "search should return results after incremental refresh")
t.Log("Phase 3: all verifications passed — incremental refresh works correctly")
}
// TestRefreshExternalCollectionAfterAddColumnReturnsCorrectData verifies that
// adding an external field to an already refreshed external collection patches
// existing same-fragment segments and returns correct values for the new field.
func TestRefreshExternalCollectionAfterAddColumnReturnsCorrectData(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*600)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
skipIfMinIOUnreachable(ctx, t, minioClient, minioCfg.bucket)
collName := common.GenRandomString("ext_add_col", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, extPath+"/")
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
const rowsPerFile = int64(25)
totalExpectedRows := rowsPerFile * 2
for _, file := range []struct {
name string
startID int64
}{
{name: "data0.parquet", startID: 0},
{name: "data1.parquet", startID: 100},
} {
data, genErr := generateParquetBytes(externalDataSchemaLarge, rowsPerFile, file.startID, testVecDim)
require.NoError(t, genErr)
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, extPath+"/"+file.name, data)
}
// Create the collection without the parquet "score" column. The first
// refresh creates external segments whose manifests and fake binlogs cover
// only id, value, and embedding.
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
refreshAndWait(ctx, t, mc, collName)
stats, err := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, err, true)
rowCount, err := strconv.ParseInt(stats["row_count"], 10, 64)
require.NoError(t, err)
require.Equal(t, totalExpectedRows, rowCount)
scoreField := entity.NewField().
WithName("score").
WithDataType(entity.FieldTypeDouble).
WithNullable(true).
WithExternalField("score")
err = mc.AddCollectionField(ctx, client.NewAddCollectionFieldOption(collName, scoreField))
common.CheckErr(t, err, true)
described, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
common.CheckErr(t, err, true)
hasScore := false
for _, field := range described.Schema.Fields {
if field.Name == "score" {
hasScore = true
require.Equal(t, entity.FieldTypeDouble, field.DataType)
require.Equal(t, "score", field.ExternalField)
require.True(t, field.Nullable)
}
}
require.True(t, hasScore, "DescribeCollection should include the added external score field")
refreshAndWait(ctx, t, mc, collName)
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
countRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, totalExpectedRows, count)
lowIDRows, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id < 5").
WithOutputFields("id", "score"))
common.CheckErr(t, err, true)
assertExternalScoreRows(t, lowIDRows, map[int64]float64{
0: 0.00,
1: 0.01,
2: 0.02,
3: 0.03,
4: 0.04,
})
scoreFilterRows, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("score >= 1.0 && score < 1.05").
WithOutputFields("id", "score"))
common.CheckErr(t, err, true)
assertExternalScoreRows(t, scoreFilterRows, map[int64]float64{
100: 1.00,
101: 1.01,
102: 1.02,
103: 1.03,
104: 1.04,
})
}
func assertExternalScoreRows(t *testing.T, result client.ResultSet, expected map[int64]float64) {
t.Helper()
idCol := result.GetColumn("id")
scoreCol := result.GetColumn("score")
require.NotNil(t, idCol, "query result should contain id")
require.NotNil(t, scoreCol, "query result should contain score")
require.Equal(t, len(expected), idCol.Len())
require.Equal(t, len(expected), scoreCol.Len())
actual := make(map[int64]float64, idCol.Len())
for i := 0; i < idCol.Len(); i++ {
id, err := idCol.GetAsInt64(i)
require.NoError(t, err)
isNull, err := scoreCol.IsNull(i)
require.NoError(t, err)
require.False(t, isNull, "score should be non-null for id %d", id)
score, err := scoreCol.GetAsDouble(i)
require.NoError(t, err)
actual[id] = score
}
require.Len(t, actual, len(expected))
for id, expectedScore := range expected {
score, ok := actual[id]
require.True(t, ok, "missing row for id %d in result %v", id, actual)
require.InDelta(t, expectedScore, score, 1e-9, "unexpected score for id %d", id)
}
}
// TestExternalCollectionMultipleDataTypes tests external collections with various data types:
// Bool, Int8, Int16, Int32, Int64, Float, Double, VarChar, FloatVector
func TestExternalCollectionMultipleDataTypes(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_multi_type", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, extPath+"/")
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// ---------------------------------------------------------------
// Step 1: Generate and upload multi-type parquet data
// ---------------------------------------------------------------
const numRows = int64(100)
data, err := generateParquetBytes(externalDataSchemaMulti, numRows, 0, testVecDim)
require.NoError(t, err)
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, extPath+"/data.parquet", data)
// ---------------------------------------------------------------
// Step 2: Create external collection with multi-type schema
// ---------------------------------------------------------------
schema := buildMultiTypeExternalSchema(collName,
extTestURI(minioCfg, extPath),
extTestSpec(minioCfg, "parquet"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created multi-type external collection: %s", collName)
runMultiTypeRefreshIndexLoadVerify(ctx, t, mc, collName, numRows)
}
// runMultiTypeRefreshIndexLoadVerify drives the post-create stages of the
// multi-type external-collection test (refresh -> index all vector fields ->
// load -> exhaustive query/search verification). Shared between the parquet
// and vortex multi-type tests so the same schema + verification plan covers
// both formats. Assumes the dataset shape produced by
// generateParquetBytes(..., externalDataSchemaMulti, ...) /
// generate_vortex_data.py --schema multi with
// the given numRows.
func runMultiTypeRefreshIndexLoadVerify(ctx context.Context, t *testing.T,
mc *base.MilvusClient, collName string, numRows int64,
) {
runMultiTypeRefreshIndexLoadVerifyWithVectorFields(ctx, t, mc, collName, numRows, "", "",
[]string{"embedding", "bin_vec", "fp16_vec", "bf16_vec", "int8_vec"})
}
// runMultiTypeRefreshIndexLoadVerifyWithSourceSpec accepts optional refresh
// source+spec (used by iceberg paths that re-supply extfs credentials on
// refresh; proxy now enforces both-or-neither).
func runMultiTypeRefreshIndexLoadVerifyWithSourceSpec(ctx context.Context, t *testing.T,
mc *base.MilvusClient, collName string, numRows int64, refreshSource, refreshSpec string,
) {
runMultiTypeRefreshIndexLoadVerifyWithVectorFields(ctx, t, mc, collName, numRows, refreshSource, refreshSpec,
[]string{"embedding", "bin_vec", "fp16_vec", "bf16_vec", "int8_vec"})
}
func runMultiTypeRefreshIndexLoadVerifyWithVectorFields(ctx context.Context, t *testing.T,
mc *base.MilvusClient, collName string, numRows int64, refreshSource, refreshSpec string, vectorFields []string,
) {
// ---------------------------------------------------------------
// Step 3: Refresh + index all vector fields + load
// ---------------------------------------------------------------
if refreshSource == "" && refreshSpec == "" {
refreshAndWait(ctx, t, mc, collName)
} else {
opt := client.NewRefreshExternalCollectionOption(collName).
WithExternalSource(refreshSource).
WithExternalSpec(refreshSpec)
refreshResult, err := mc.RefreshExternalCollection(ctx, opt)
common.CheckErr(t, err, true)
require.Greater(t, refreshResult.JobID, int64(0))
waitForRefreshComplete(ctx, t, mc, refreshResult.JobID, 120*time.Second)
}
// Create index on all vector fields
for _, vecField := range vectorFields {
var metricType entity.MetricType
switch vecField {
case "bin_vec":
metricType = entity.HAMMING
default:
metricType = entity.L2
}
idx := index.NewAutoIndex(metricType)
idxTask, idxErr := mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, vecField, idx))
common.CheckErr(t, idxErr, true)
require.NoError(t, idxTask.Await(ctx))
t.Logf("Index created on %s (metric=%s)", vecField, metricType)
}
// Load collection
loadTask, loadErr := mc.LoadCollection(ctx, client.NewLoadCollectionOption(collName))
common.CheckErr(t, loadErr, true)
require.NoError(t, loadTask.Await(ctx))
t.Log("Collection loaded")
// ---------------------------------------------------------------
// Step 4: Verify count(*)
// ---------------------------------------------------------------
countRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, _ := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.Equal(t, numRows, count, "count should match uploaded rows")
t.Logf("count(*) = %d", count)
// ---------------------------------------------------------------
// Step 5: Bool filter — bool_val == true → even ids → 50 rows
// ---------------------------------------------------------------
boolRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("bool_val == true").
WithOutputFields("id", "bool_val"))
common.CheckErr(t, err, true)
require.Equal(t, int(numRows/2), boolRes.GetColumn("id").Len(),
"bool_val==true should match even-id rows")
t.Logf("Bool filter: %d rows", boolRes.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 6: Int8 filter — int8_val < 10 → ids 0-9 → 10 rows
// ---------------------------------------------------------------
int8Res, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("int8_val < 10").
WithOutputFields("id", "int8_val"))
common.CheckErr(t, err, true)
require.Equal(t, 10, int8Res.GetColumn("id").Len(), "int8_val < 10 should be 10 rows")
t.Logf("Int8 filter: %d rows", int8Res.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 7: Int16 filter — int16_val >= 500 → ids >= 50 → 50 rows
// ---------------------------------------------------------------
int16Res, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("int16_val >= 500").
WithOutputFields("id", "int16_val"))
common.CheckErr(t, err, true)
require.Equal(t, 50, int16Res.GetColumn("id").Len(), "int16_val >= 500 should be 50 rows")
t.Logf("Int16 filter: %d rows", int16Res.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 8: Int32 filter — int32_val < 5000 → ids < 50 → 50 rows
// ---------------------------------------------------------------
int32Res, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("int32_val < 5000").
WithOutputFields("id", "int32_val"))
common.CheckErr(t, err, true)
require.Equal(t, 50, int32Res.GetColumn("id").Len(), "int32_val < 5000 should be 50 rows")
t.Logf("Int32 filter: %d rows", int32Res.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 9: Float filter — float_val < 15.0 → id*1.5 < 15.0 → ids 0-9 → 10 rows
// ---------------------------------------------------------------
floatRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("float_val < 15.0").
WithOutputFields("id", "float_val"))
common.CheckErr(t, err, true)
require.Equal(t, 10, floatRes.GetColumn("id").Len(), "float_val < 15.0 should be 10 rows")
t.Logf("Float filter: %d rows", floatRes.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 10: Double filter — double_val < 0.5 → id*0.01 < 0.5 → ids 0-49 → 50 rows
// ---------------------------------------------------------------
doubleRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("double_val < 0.5").
WithOutputFields("id", "double_val"))
common.CheckErr(t, err, true)
require.Equal(t, 50, doubleRes.GetColumn("id").Len(), "double_val < 0.5 should be 50 rows")
t.Logf("Double filter: %d rows", doubleRes.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 10b: VarChar prefix filter — varchar_val like "str_004%" → ids 40-49 → 10 rows
// ---------------------------------------------------------------
varcharRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter(`varchar_val like "str_004%"`).
WithOutputFields("id", "varchar_val"))
common.CheckErr(t, err, true)
require.Equal(t, 10, varcharRes.GetColumn("id").Len(), `varchar_val like "str_004%" should be 10 rows`)
t.Logf("VarChar prefix filter: %d rows", varcharRes.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 10c: VarChar equality filter — varchar_val == "str_0042" → id=42 → 1 row
// ---------------------------------------------------------------
varcharEqRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter(`varchar_val == "str_0042"`).
WithOutputFields("id", "varchar_val"))
common.CheckErr(t, err, true)
require.Equal(t, 1, varcharEqRes.GetColumn("id").Len(), `varchar_val == "str_0042" should be 1 row`)
eqID, _ := varcharEqRes.GetColumn("id").GetAsInt64(0)
require.Equal(t, int64(42), eqID, "matched row should be id=42")
t.Logf("VarChar equality filter: matched id=%d", eqID)
// ---------------------------------------------------------------
// Step 11: Verify specific row values for id=42
// ---------------------------------------------------------------
row42, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id == 42").
WithOutputFields("id", "bool_val", "int8_val", "int16_val", "int32_val", "float_val", "double_val", "varchar_val", "json_val", "ts_val"))
common.CheckErr(t, err, true)
require.Equal(t, 1, row42.GetColumn("id").Len(), "should find exactly 1 row with id==42")
boolVal, err := row42.GetColumn("bool_val").GetAsBool(0)
require.NoError(t, err)
require.Equal(t, true, boolVal, "id=42 (even) → bool_val should be true")
int8Val, err := row42.GetColumn("int8_val").GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(42), int8Val, "int8_val for id=42 should be 42")
int16Val, err := row42.GetColumn("int16_val").GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(420), int16Val, "int16_val for id=42 should be 420 (42*10)")
int32Val, err := row42.GetColumn("int32_val").GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(4200), int32Val, "int32_val for id=42 should be 4200 (42*100)")
floatVal, err := row42.GetColumn("float_val").GetAsDouble(0)
require.NoError(t, err)
require.InDelta(t, 63.0, floatVal, 0.1, "float_val for id=42 should be ~63.0 (42*1.5)")
doubleVal, err := row42.GetColumn("double_val").GetAsDouble(0)
require.NoError(t, err)
require.InDelta(t, 0.42, doubleVal, 0.001, "double_val for id=42 should be ~0.42 (42*0.01)")
varcharVal, err := row42.GetColumn("varchar_val").GetAsString(0)
require.NoError(t, err)
require.Equal(t, "str_0042", varcharVal, "varchar_val for id=42 should be 'str_0042'")
jsonRaw, err := row42.GetColumn("json_val").Get(0)
require.NoError(t, err)
jsonVal := string(jsonRaw.([]byte))
require.Contains(t, jsonVal, `"key":42`, "json_val for id=42 should contain key=42")
require.Contains(t, jsonVal, `"name":"item_42"`, "json_val for id=42 should contain name=item_42")
// Timestamptz: verify it's returned (as string representation)
tsRaw, err := row42.GetColumn("ts_val").Get(0)
require.NoError(t, err)
require.NotNil(t, tsRaw, "ts_val for id=42 should not be nil")
t.Logf("ts_val for id=42: %v (type=%T)", tsRaw, tsRaw)
t.Log("All field values verified for id=42 (including JSON, Timestamptz)")
// ---------------------------------------------------------------
// Step 12: Search — verify nearest neighbor matches exact vector
// ---------------------------------------------------------------
vec := make([]float32, testVecDim)
for i := range vec {
vec[i] = float32(42)*0.1 + float32(i) // same formula as id=42's embedding
}
searchRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(searchRes))
require.Greater(t, searchRes[0].ResultCount, 0, "search should return at least 1 result")
nearestID, _ := searchRes[0].GetColumn("id").GetAsInt64(0)
t.Logf("Search: nearest neighbor for id=42's vector is id=%d (top-5 returned %d results)", nearestID, searchRes[0].ResultCount)
// ---------------------------------------------------------------
// Step 12b: JSON filter — json_val["key"] < 10 → ids 0-9 → 10 rows
// ---------------------------------------------------------------
jsonRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter(`json_val["key"] < 10`).
WithOutputFields("id", "json_val"))
common.CheckErr(t, err, true)
require.Equal(t, 10, jsonRes.GetColumn("id").Len(), `json_val["key"] < 10 should be 10 rows`)
t.Logf("JSON filter: %d rows", jsonRes.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 12c: Geometry value check — query geo_val for id=42
// ---------------------------------------------------------------
geoRow, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id == 42").
WithOutputFields("id", "geo_val"))
common.CheckErr(t, err, true)
geoVal, err := geoRow.GetColumn("geo_val").GetAsString(0)
require.NoError(t, err)
require.Contains(t, geoVal, "42", "geo_val for id=42 should contain coordinate 42")
t.Logf("Geometry value verified: %s", geoVal)
// ---------------------------------------------------------------
// Step 12e: Array filter — array_contains(array_int, 297) → id=99 (99*3=297) → 1 row
// Use 297 because only id=99 has array [99, 198, 297]
// ---------------------------------------------------------------
arrayRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("array_contains(array_int, 297)").
WithOutputFields("id"))
common.CheckErr(t, err, true)
require.Equal(t, 1, arrayRes.GetColumn("id").Len(), "array_contains(array_int, 297) should match id=99")
arrayMatchID, _ := arrayRes.GetColumn("id").GetAsInt64(0)
require.Equal(t, int64(99), arrayMatchID)
t.Logf("Array filter: matched id=%d", arrayMatchID)
// ---------------------------------------------------------------
// Step 12f: Array as OUTPUT field — regression for issue #48619
// Querying with array_int / array_str in output_fields used to crash
// segcore (SIGSEGV in ArrayChunkWriter::calculate_size) because the
// external chunk loader passed an arrow::ListArray where Milvus
// expected a BinaryArray of serialized ScalarFieldProto bytes.
// ---------------------------------------------------------------
arrayIntOut, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id == 42").
WithOutputFields("id", "array_int"))
common.CheckErr(t, err, true)
require.Equal(t, 1, arrayIntOut.GetColumn("id").Len(), "should find id==42")
t.Logf("Array(Int32) as output field returned %d row(s) without crash",
arrayIntOut.GetColumn("id").Len())
arrayStrOut, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id == 42").
WithOutputFields("id", "array_str"))
common.CheckErr(t, err, true)
require.Equal(t, 1, arrayStrOut.GetColumn("id").Len(), "should find id==42")
t.Logf("Array(VarChar) as output field returned %d row(s) without crash",
arrayStrOut.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 12g: Geometry as OUTPUT field across many rows — exercise
// chunked-load path (single-row check at step 12c only loads one
// chunk; querying many rows pulls more cells through the translator).
// ---------------------------------------------------------------
geoBulkRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id < 50").
WithOutputFields("id", "geo_val"))
common.CheckErr(t, err, true)
require.Equal(t, 50, geoBulkRes.GetColumn("id").Len(), "id<50 should be 50 rows")
t.Logf("Geometry as output field across %d rows without crash",
geoBulkRes.GetColumn("id").Len())
// ---------------------------------------------------------------
// Step 13: Compound filter across types — bool AND int range AND varchar
// ---------------------------------------------------------------
compoundRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter(`bool_val == true && int32_val < 5000 && varchar_val like "str_00%"`).
WithOutputFields("id"))
common.CheckErr(t, err, true)
// ids 0-49 have int32_val < 5000, of which 25 are even (bool_val==true)
// varchar_val like "str_00%" matches ids 0-99 (all have str_00xx format), so no extra filter
require.Equal(t, 25, compoundRes.GetColumn("id").Len(),
`compound filter: bool_val==true AND int32_val<5000 AND varchar like "str_00%" → 25 rows`)
t.Log("Compound filter across types (incl. varchar) passed")
}
// --- Lance format helpers ---
// generateLanceDataOnMinIO uses the Python lance library to write a Lance dataset
// directly to MinIO/S3. This is necessary because lance format writing requires the
// Rust-based lance library, which is not accessible from Go directly.
func generateLanceDataOnMinIO(t *testing.T, s3URI, schema string, numRows, startID int) {
t.Helper()
minioCfg := getMinIOConfig()
args := []string{
"--schema", schema,
s3URI,
strconv.Itoa(numRows),
"--start-id", strconv.Itoa(startID),
"--vec-dim", strconv.Itoa(testVecDim),
}
if schema == externalDataSchemaMulti {
args = append(args, "--bin-vec-dim", strconv.Itoa(testBinVecDim))
}
// Secrets go through the child process environment, not argv, so
// `ps -ef` and CI logs don't expose them — see runPythonScript.
out, err := runPythonScript(t, "python3", "generate_lance_data.py",
map[string]string{
"MINIO_ADDRESS": minioCfg.address,
"MINIO_ACCESS_KEY": minioCfg.accessKey,
"MINIO_SECRET_KEY": minioCfg.secretKey,
},
args...)
require.NoError(t, err, "generate lance data: %s", out)
require.Contains(t, out, "OK", "lance data generation should succeed")
t.Logf("Generated lance data: %s", out)
}
// runPythonScript runs a python script with the given positional arguments and
// extra environment variables. Secrets MUST be passed via extraEnv rather than
// embedded in args — argv is visible in `ps -ef`, `/proc/<pid>/cmdline`, and
// most CI log collectors, whereas envp is only readable by the process owner.
// This helper replaces an earlier runShellCommand wrapper that built a single
// `bash -c "KEY=val ... python3 ..."` string and leaked every caller's AKSK
// to the process command line.
func runPythonScript(t *testing.T, pythonBin, scriptPath string, extraEnv map[string]string, args ...string) (string, error) {
t.Helper()
cmd := exec.Command(pythonBin, append([]string{scriptPath}, args...)...) // #nosec G204
cmd.Env = append(os.Environ(), mapToEnvSlice(extraEnv)...)
output, err := cmd.CombinedOutput()
return string(output), err
}
// mapToEnvSlice converts a string map into the "KEY=VALUE" slice form
// expected by exec.Cmd.Env.
func mapToEnvSlice(m map[string]string) []string {
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
return out
}
// TestExternalCollectionLanceFormat tests external collections with lance-table format:
// 1. Generate Lance dataset on MinIO using Python
// 2. Create external collection with lance-table format
// 3. Refresh and wait for completion
// 4. Create index and load collection
// 5. Query count(*) and verify row count
// 6. Query with filter
// 7. Search with vector
func TestExternalCollectionLanceFormat(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*600) // 10 min for lance operations
mc := hp.CreateDefaultMilvusClient(ctx, t)
// Connect to MinIO
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_lance", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// ---------------------------------------------------------------
// Step 1: Generate Lance dataset on MinIO
// ---------------------------------------------------------------
const totalRows = 100000
s3URI := fmt.Sprintf("s3://%s/%s", minioCfg.bucket, extPath)
generateLanceDataOnMinIO(t, s3URI, externalDataSchemaBasic, totalRows, 0)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/%s/", minioCfg.rootPath, extPath))
})
// ---------------------------------------------------------------
// Step 2: Create external collection with lance-table format
// ---------------------------------------------------------------
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "lance-table")).
WithField(
entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithExternalField("id"),
).
WithField(
entity.NewField().
WithName("value").
WithDataType(entity.FieldTypeFloat).
WithExternalField("value"),
).
WithField(
entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"),
)
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created external collection with lance-table format: %s", collName)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// Verify collection
coll, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
common.CheckErr(t, err, true)
require.Equal(t, extTestURI(minioCfg, extPath), coll.Schema.ExternalSource)
// Spec redacted at proxy edge.
require.Contains(t, coll.Schema.ExternalSpec, `"format":"lance-table"`)
require.Contains(t, coll.Schema.ExternalSpec, `"access_key_id":"***"`)
// ---------------------------------------------------------------
// Step 3: Refresh and wait for completion
// ---------------------------------------------------------------
refreshAndWait(ctx, t, mc, collName)
// Verify row count via stats
stats, err := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, err, true)
rowCountStr, ok := stats["row_count"]
require.True(t, ok, "stats should contain row_count")
rowCount, err := strconv.ParseInt(rowCountStr, 10, 64)
require.NoError(t, err)
require.Equal(t, int64(totalRows), rowCount,
"collection stats row_count should match lance dataset")
t.Logf("Refresh complete: row_count=%d", rowCount)
// ---------------------------------------------------------------
// Step 4: Create scalar + vector indexes and load collection
// ---------------------------------------------------------------
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
// ---------------------------------------------------------------
// Step 5: Query count(*)
// ---------------------------------------------------------------
countRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(totalRows), count,
"count(*) should match lance dataset rows")
t.Logf("Query count(*) = %d", count)
// ---------------------------------------------------------------
// Step 6: Query with filter
// ---------------------------------------------------------------
filterRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id < 100").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
idCol := filterRes.GetColumn("id")
require.Equal(t, 100, idCol.Len(),
"filter id < 100 should return exactly 100 rows")
t.Logf("Query with filter returned %d rows", idCol.Len())
// Verify specific value
val42, err := filterRes.GetColumn("value").GetAsDouble(42)
require.NoError(t, err)
expected42 := float64(42) * 1.5
require.InDelta(t, expected42, val42, 0.01,
"value for id=42 should be 42*1.5=63.0")
// ---------------------------------------------------------------
// Step 6b: Verify data correctness — sample rows across the range
// ---------------------------------------------------------------
sampleIDs := []int64{0, 1, 999, 50000, 99999}
for _, sid := range sampleIDs {
sampleRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter(fmt.Sprintf("id == %d", sid)).
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, sampleRes.GetColumn("id").Len(),
"should find exactly 1 row for id=%d", sid)
sampleVal, err := sampleRes.GetColumn("value").GetAsDouble(0)
require.NoError(t, err)
require.InDelta(t, float64(sid)*1.5, sampleVal, 0.01,
"value for id=%d should be %f", sid, float64(sid)*1.5)
}
t.Logf("Verified %d sample rows across id range", len(sampleIDs))
// ---------------------------------------------------------------
// Step 6c: Verify large range query
// ---------------------------------------------------------------
largeRangeRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id >= 50000 && id < 60000").
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
rangeCount, err := largeRangeRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(10000), rangeCount,
"id in [50000, 60000) should return 10000 rows")
t.Logf("Large range query [50000, 60000) returned %d rows", rangeCount)
// ---------------------------------------------------------------
// Step 7: Search with vector — verify nearest neighbor is id=0
// ---------------------------------------------------------------
vec := make([]float32, testVecDim)
for i := range vec {
vec[i] = float32(i) * 0.1 // same as id=0's embedding
}
searchRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(searchRes), "should have 1 result set")
require.Greater(t, searchRes[0].ResultCount, 0, "search should return results")
nearestID, _ := searchRes[0].GetColumn("id").GetAsInt64(0)
nearestVal, _ := searchRes[0].GetColumn("value").GetAsDouble(0)
t.Logf("Search: nearest neighbor id=%d value=%.2f", nearestID, nearestVal)
// ---------------------------------------------------------------
// Step 8: Search with filter (hybrid search)
// ---------------------------------------------------------------
hybridRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithFilter("id >= 50000 && id < 60000").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(hybridRes))
require.Greater(t, hybridRes[0].ResultCount, 0, "hybrid search should return results")
hybridIDCol := hybridRes[0].GetColumn("id")
for i := 0; i < hybridIDCol.Len(); i++ {
val, _ := hybridIDCol.GetAsInt64(i)
require.GreaterOrEqual(t, val, int64(50000))
require.Less(t, val, int64(60000))
}
t.Logf("Hybrid search (id in [50000,60000)) returned %d results with correct values", hybridRes[0].ResultCount)
t.Log("Lance format external collection test passed!")
}
// generateVortexDataOnMinIO uses the Python vortex library to write a Vortex
// dataset directly to a MinIO/S3 bucket. This mirrors the Lance test pattern
// where a Python script handles format-specific data generation.
func generateVortexDataOnMinIO(t *testing.T, outputPath, schema string, numRows int, bucket string) {
t.Helper()
minioCfg := getMinIOConfig()
args := []string{
"--schema", schema,
outputPath,
strconv.Itoa(numRows),
"--vec-dim", strconv.Itoa(testVecDim),
}
if schema == externalDataSchemaMulti {
args = append(args, "--bin-vec-dim", strconv.Itoa(testBinVecDim))
}
out, err := runPythonScript(t, "python3", "generate_vortex_data.py",
map[string]string{
"MINIO_ADDRESS": minioCfg.address,
"MINIO_ACCESS_KEY": minioCfg.accessKey,
"MINIO_SECRET_KEY": minioCfg.secretKey,
"MINIO_BUCKET": bucket,
},
args...)
require.NoError(t, err, "generate vortex data: %s", out)
require.Contains(t, out, "OK", "vortex data generation should succeed")
t.Logf("Generated vortex data: %s", out)
}
// buildMultiTypeExternalSchema constructs the schema mirroring the one used
// by TestExternalCollectionMultipleDataTypes. Shared across format tests so
// that the same field set + index plan can be exercised with parquet, vortex,
// lance, etc.
func buildMultiTypeExternalSchema(collName, externalSource, externalSpec string) *entity.Schema {
return entity.NewSchema().
WithName(collName).
WithExternalSource(externalSource).
WithExternalSpec(externalSpec).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("bool_val").WithDataType(entity.FieldTypeBool).WithExternalField("bool_val")).
WithField(entity.NewField().WithName("int8_val").WithDataType(entity.FieldTypeInt8).WithExternalField("int8_val")).
WithField(entity.NewField().WithName("int16_val").WithDataType(entity.FieldTypeInt16).WithExternalField("int16_val")).
WithField(entity.NewField().WithName("int32_val").WithDataType(entity.FieldTypeInt32).WithExternalField("int32_val")).
WithField(entity.NewField().WithName("float_val").WithDataType(entity.FieldTypeFloat).WithExternalField("float_val")).
WithField(entity.NewField().WithName("double_val").WithDataType(entity.FieldTypeDouble).WithExternalField("double_val")).
WithField(entity.NewField().WithName("varchar_val").WithDataType(entity.FieldTypeVarChar).WithMaxLength(64).WithExternalField("varchar_val")).
WithField(entity.NewField().WithName("json_val").WithDataType(entity.FieldTypeJSON).WithExternalField("json_val")).
WithField(entity.NewField().WithName("array_int").WithDataType(entity.FieldTypeArray).WithElementType(entity.FieldTypeInt32).WithMaxCapacity(16).WithExternalField("array_int")).
WithField(entity.NewField().WithName("array_str").WithDataType(entity.FieldTypeArray).WithElementType(entity.FieldTypeVarChar).WithMaxLength(64).WithMaxCapacity(16).WithExternalField("array_str")).
WithField(entity.NewField().WithName("ts_val").WithDataType(entity.FieldTypeTimestamptz).WithExternalField("ts_val")).
WithField(entity.NewField().WithName("geo_val").WithDataType(entity.FieldTypeGeometry).WithExternalField("geo_val")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).WithDim(testVecDim).WithExternalField("embedding")).
WithField(entity.NewField().WithName("bin_vec").WithDataType(entity.FieldTypeBinaryVector).WithDim(testBinVecDim).WithExternalField("bin_vec")).
WithField(entity.NewField().WithName("fp16_vec").WithDataType(entity.FieldTypeFloat16Vector).WithDim(testVecDim).WithExternalField("fp16_vec")).
WithField(entity.NewField().WithName("bf16_vec").WithDataType(entity.FieldTypeBFloat16Vector).WithDim(testVecDim).WithExternalField("bf16_vec")).
WithField(entity.NewField().WithName("int8_vec").WithDataType(entity.FieldTypeInt8Vector).WithDim(testVecDim).WithExternalField("int8_vec"))
}
func buildVortexMultiTypeExternalSchema(collName, externalSource, externalSpec string) *entity.Schema {
return entity.NewSchema().
WithName(collName).
WithExternalSource(externalSource).
WithExternalSpec(externalSpec).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("bool_val").WithDataType(entity.FieldTypeBool).WithExternalField("bool_val")).
WithField(entity.NewField().WithName("int8_val").WithDataType(entity.FieldTypeInt8).WithExternalField("int8_val")).
WithField(entity.NewField().WithName("int16_val").WithDataType(entity.FieldTypeInt16).WithExternalField("int16_val")).
WithField(entity.NewField().WithName("int32_val").WithDataType(entity.FieldTypeInt32).WithExternalField("int32_val")).
WithField(entity.NewField().WithName("float_val").WithDataType(entity.FieldTypeFloat).WithExternalField("float_val")).
WithField(entity.NewField().WithName("double_val").WithDataType(entity.FieldTypeDouble).WithExternalField("double_val")).
WithField(entity.NewField().WithName("varchar_val").WithDataType(entity.FieldTypeVarChar).WithMaxLength(64).WithExternalField("varchar_val")).
WithField(entity.NewField().WithName("json_val").WithDataType(entity.FieldTypeJSON).WithExternalField("json_val")).
WithField(entity.NewField().WithName("array_int").WithDataType(entity.FieldTypeArray).WithElementType(entity.FieldTypeInt32).WithMaxCapacity(16).WithExternalField("array_int")).
WithField(entity.NewField().WithName("array_str").WithDataType(entity.FieldTypeArray).WithElementType(entity.FieldTypeVarChar).WithMaxLength(64).WithMaxCapacity(16).WithExternalField("array_str")).
WithField(entity.NewField().WithName("ts_val").WithDataType(entity.FieldTypeTimestamptz).WithExternalField("ts_val")).
WithField(entity.NewField().WithName("geo_val").WithDataType(entity.FieldTypeGeometry).WithExternalField("geo_val")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).WithDim(testVecDim).WithExternalField("embedding"))
}
// TestExternalCollectionVortexFormat tests external collections with vortex format:
// 1. Generate vortex dataset on MinIO using the generate_vortex_data tool
// 2. Create external collection with vortex format
// 3. Refresh and wait for completion
// 4. Create index and load collection
// 5. Query count(*) and verify row count
// 6. Query with filter
// 7. Search with vector
func TestExternalCollectionVortexFormat(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*600)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_vortex", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// ---------------------------------------------------------------
// Step 1: Generate vortex dataset on MinIO
// ---------------------------------------------------------------
const totalRows = 100000
generateVortexDataOnMinIO(t, extPath, externalDataSchemaBasic, totalRows, minioCfg.bucket)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/", extPath))
})
// ---------------------------------------------------------------
// Step 2: Create external collection with vortex format
// ---------------------------------------------------------------
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "vortex")).
WithField(
entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithExternalField("id"),
).
WithField(
entity.NewField().
WithName("value").
WithDataType(entity.FieldTypeFloat).
WithExternalField("value"),
).
WithField(
entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"),
).
// VARCHAR / JSON / GEOMETRY trigger vortex-specific bugs (#49352, #49353)
// that are not covered by the int/float/vector-only baseline.
WithField(
entity.NewField().
WithName("varchar_val").
WithDataType(entity.FieldTypeVarChar).
WithMaxLength(64).
WithExternalField("varchar_val"),
).
WithField(
entity.NewField().
WithName("json_val").
WithDataType(entity.FieldTypeJSON).
WithExternalField("json_val"),
).
WithField(
entity.NewField().
WithName("geo_val").
WithDataType(entity.FieldTypeGeometry).
WithExternalField("geo_val"),
)
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created external collection with vortex format: %s", collName)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
coll, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
common.CheckErr(t, err, true)
require.Equal(t, extTestURI(minioCfg, extPath), coll.Schema.ExternalSource)
// Spec redacted at proxy edge.
require.Contains(t, coll.Schema.ExternalSpec, `"format":"vortex"`)
require.Contains(t, coll.Schema.ExternalSpec, `"access_key_id":"***"`)
// ---------------------------------------------------------------
// Step 3: Refresh and wait for completion
// ---------------------------------------------------------------
refreshAndWait(ctx, t, mc, collName)
stats, err := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, err, true)
rowCountStr, ok := stats["row_count"]
require.True(t, ok, "stats should contain row_count")
rowCount, err := strconv.ParseInt(rowCountStr, 10, 64)
require.NoError(t, err)
require.Equal(t, int64(totalRows), rowCount,
"collection stats row_count should match vortex dataset")
t.Logf("Refresh complete: row_count=%d", rowCount)
// ---------------------------------------------------------------
// Step 4: Create scalar + vector indexes and load collection
// ---------------------------------------------------------------
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
// ---------------------------------------------------------------
// Step 5: Query count(*)
// ---------------------------------------------------------------
countRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(totalRows), count,
"count(*) should match vortex dataset rows")
t.Logf("Query count(*) = %d", count)
// ---------------------------------------------------------------
// Step 6: Query with filter
// ---------------------------------------------------------------
filterRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id < 100").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
idCol := filterRes.GetColumn("id")
require.Equal(t, 100, idCol.Len(),
"filter id < 100 should return exactly 100 rows")
t.Logf("Query with filter returned %d rows", idCol.Len())
val42, err := filterRes.GetColumn("value").GetAsDouble(42)
require.NoError(t, err)
expected42 := float64(42) * 1.5
require.InDelta(t, expected42, val42, 0.01,
"value for id=42 should be 42*1.5=63.0")
// ---------------------------------------------------------------
// Step 6b: Verify data correctness — sample rows across the range
// ---------------------------------------------------------------
sampleIDs := []int64{0, 1, 999, 50000, 99999}
for _, sid := range sampleIDs {
sampleRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter(fmt.Sprintf("id == %d", sid)).
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, sampleRes.GetColumn("id").Len(),
"should find exactly 1 row for id=%d", sid)
sampleVal, err := sampleRes.GetColumn("value").GetAsDouble(0)
require.NoError(t, err)
require.InDelta(t, float64(sid)*1.5, sampleVal, 0.01,
"value for id=%d should be %f", sid, float64(sid)*1.5)
}
t.Logf("Verified %d sample rows across id range", len(sampleIDs))
// ---------------------------------------------------------------
// Step 6c: Verify large range query
// ---------------------------------------------------------------
largeRangeRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id >= 50000 && id < 60000").
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
rangeCount, err := largeRangeRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(10000), rangeCount,
"id in [50000, 60000) should return 10000 rows")
t.Logf("Large range query [50000, 60000) returned %d rows", rangeCount)
// ---------------------------------------------------------------
// Step 7: Search with vector — verify nearest neighbor is id=0
// ---------------------------------------------------------------
vec := make([]float32, testVecDim)
for i := range vec {
vec[i] = float32(i) * 0.1 // same as id=0's embedding
}
searchRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(searchRes), "should have 1 result set")
require.Greater(t, searchRes[0].ResultCount, 0, "search should return results")
nearestID, _ := searchRes[0].GetColumn("id").GetAsInt64(0)
nearestVal, _ := searchRes[0].GetColumn("value").GetAsDouble(0)
t.Logf("Search: nearest neighbor id=%d value=%.2f", nearestID, nearestVal)
// ---------------------------------------------------------------
// Step 8: Search with filter (hybrid search)
// ---------------------------------------------------------------
hybridRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithFilter("id >= 50000 && id < 60000").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(hybridRes))
require.Greater(t, hybridRes[0].ResultCount, 0, "hybrid search should return results")
hybridIDCol := hybridRes[0].GetColumn("id")
for i := 0; i < hybridIDCol.Len(); i++ {
val, _ := hybridIDCol.GetAsInt64(i)
require.GreaterOrEqual(t, val, int64(50000))
require.Less(t, val, int64(60000))
}
t.Logf("Hybrid search (id in [50000,60000)) returned %d results with correct values", hybridRes[0].ResultCount)
t.Log("Vortex format external collection test passed!")
}
// TestExternalCollectionMultipleDataTypesVortex mirrors the scalar/array/json/
// geometry/timestamp coverage from TestExternalCollectionMultipleDataTypes.
// Vortex 0.56.0 cannot write Arrow FixedSizeBinary, so binary/half/int8 vector
// fields remain covered by the parquet and lance multi-type tests.
func TestExternalCollectionMultipleDataTypesVortex(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_multi_vortex", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, extPath+"/")
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// Step 1: Generate multi-type vortex dataset on MinIO
const numRows = int64(100)
generateVortexDataOnMinIO(t, extPath, externalDataSchemaMulti, int(numRows), minioCfg.bucket)
// Step 2: Create external collection with Vortex-compatible multi-type schema
schema := buildVortexMultiTypeExternalSchema(collName,
extTestURI(minioCfg, extPath),
extTestSpec(minioCfg, "vortex"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created multi-type vortex external collection: %s", collName)
// Step 3 onwards: shared refresh + index + load + verify
runMultiTypeRefreshIndexLoadVerifyWithVectorFields(ctx, t, mc, collName, numRows, "", "", []string{"embedding"})
}
// TestExternalCollectionMultipleDataTypesLance mirrors
// TestExternalCollectionMultipleDataTypesVortex but writes the dataset in lance
// format. Exercises the same multi-type schema and shared
// refresh/index/load/query/search verification across the lance bridge.
func TestExternalCollectionMultipleDataTypesLance(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_multi_lance", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, extPath+"/")
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
const numRows = int64(100)
// Lance writer takes a full s3:// URI (different from vortex's relative path).
s3URI := fmt.Sprintf("s3://%s/%s", minioCfg.bucket, extPath)
generateLanceDataOnMinIO(t, s3URI, externalDataSchemaMulti, int(numRows), 0)
schema := buildMultiTypeExternalSchema(collName,
extTestURI(minioCfg, extPath),
extTestSpec(minioCfg, "lance-table"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created multi-type lance external collection: %s", collName)
runMultiTypeRefreshIndexLoadVerify(ctx, t, mc, collName, numRows)
}
// TestExternalCollectionFloat32ListVector verifies that external collections
// whose files store vectors as FixedSizeList<Float32, dim> (native float list)
// can be refreshed, indexed, loaded, and queried correctly.
// Currently only Parquet is tested because Parquet readers return native Arrow
// types regardless of the output schema. Vortex and Lance require schema-level
// changes to support float32 list vectors (tracked separately).
func TestExternalCollectionFloat32ListVector(t *testing.T) {
t.Parallel()
ctx := hp.CreateContext(t, time.Second*600)
mc := hp.CreateDefaultMilvusClient(ctx, t)
// Connect to MinIO
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
const numRows = 2000
type formatCase struct {
name string
specJSON string
setupData func(t *testing.T, extPath string)
cleanup func(extPath string)
}
formats := []formatCase{
{
name: "parquet",
specJSON: extTestSpec(minioCfg, "parquet"),
setupData: func(t *testing.T, extPath string) {
t.Helper()
data, genErr := generateParquetBytes(externalDataSchemaBasic, numRows, 0, testVecDim)
require.NoError(t, genErr, "generate parquet")
objectKey := fmt.Sprintf("%s/data.parquet", extPath)
_, putErr := minioClient.PutObject(ctx, minioCfg.bucket, objectKey,
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, putErr, "upload parquet to MinIO")
t.Logf("Uploaded %s/%s (%d bytes, %d rows)", minioCfg.bucket, objectKey, len(data), numRows)
},
cleanup: func(extPath string) {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, extPath+"/")
},
},
}
for _, fc := range formats {
fc := fc // capture for subtest closure
t.Run(fc.name, func(t *testing.T) {
// Replace hyphens with underscores for valid collection names
safePrefix := strings.ReplaceAll(fc.name, "-", "_")
collName := common.GenRandomString("ext_f32_"+safePrefix, 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// Step 1: Generate and upload data
fc.setupData(t, extPath)
t.Cleanup(func() { fc.cleanup(extPath) })
// Step 2: Create external collection
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(fc.specJSON).
WithField(entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithExternalField("id")).
WithField(entity.NewField().
WithName("value").
WithDataType(entity.FieldTypeFloat).
WithExternalField("value")).
WithField(entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"))
err := mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created external collection [%s]: %s", fc.name, collName)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// Step 3: Refresh and wait
refreshAndWait(ctx, t, mc, collName)
// Verify row count via stats
stats, err := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, err, true)
rowCountStr, ok := stats["row_count"]
require.True(t, ok, "stats should contain row_count")
rowCount, err := strconv.ParseInt(rowCountStr, 10, 64)
require.NoError(t, err)
require.Equal(t, int64(numRows), rowCount,
"row_count should match uploaded data")
t.Logf("Refresh complete: row_count=%d", rowCount)
// Step 4: Index + Load
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
// Step 5: Query count(*)
countRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(numRows), count,
"count(*) should match uploaded rows")
t.Logf("count(*) = %d", count)
// Step 6: Query with filter + output fields
filterRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithFilter("id < 100").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 100, filterRes.GetColumn("id").Len(),
"filter id < 100 should return 100 rows")
t.Logf("Query with filter returned %d rows", filterRes.GetColumn("id").Len())
// Verify a specific value (id=42 → value=42*1.5=63.0)
val42, err := filterRes.GetColumn("value").GetAsDouble(42)
require.NoError(t, err)
require.InDelta(t, float64(42)*1.5, val42, 0.01,
"value for id=42 should be 63.0")
// Step 7: Vector search
vec := make([]float32, testVecDim)
for i := range vec {
vec[i] = float32(i) * 0.1 // matches id=0's embedding
}
searchRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(searchRes), "1 result set for 1 query vector")
require.Greater(t, searchRes[0].ResultCount, 0, "search should return results")
nearestID, _ := searchRes[0].GetColumn("id").GetAsInt64(0)
t.Logf("Search nearest: id=%d", nearestID)
// Step 8: Hybrid search (vector + filter)
hybridRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithFilter("id >= 500 && id < 1500").
WithOutputFields("id"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(hybridRes))
require.Greater(t, hybridRes[0].ResultCount, 0, "hybrid search should return results")
hybridIDCol := hybridRes[0].GetColumn("id")
for i := 0; i < hybridIDCol.Len(); i++ {
val, _ := hybridIDCol.GetAsInt64(i)
require.GreaterOrEqual(t, val, int64(500), "id should be >= 500")
require.Less(t, val, int64(1500), "id should be < 1500")
}
t.Logf("Hybrid search (id in [500,1500)) returned %d results", hybridRes[0].ResultCount)
t.Logf("[%s] All checks passed!", fc.name)
})
}
}
// TestExternalCollectionDifferentBucket tests external collection with data in a
// different MinIO bucket than what Milvus uses (a-bucket).
// This verifies that external_source with s3://external-bucket/path/ URI works
// when the data resides in a separate bucket on the same MinIO instance.
func TestExternalCollectionDifferentBucket(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
// Connect to MinIO
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
skipIfMinIOUnreachable(ctx, t, minioClient, minioCfg.bucket)
// Use a different bucket than Milvus's default (a-bucket)
externalBucket := "external-bucket"
// Create the external bucket if it doesn't exist
exists, err := minioClient.BucketExists(ctx, externalBucket)
require.NoError(t, err, "check bucket existence")
if !exists {
err = minioClient.MakeBucket(ctx, externalBucket, miniogo.MakeBucketOptions{})
require.NoError(t, err, "create external bucket")
t.Logf("Created bucket: %s", externalBucket)
}
collName := common.GenRandomString("ext_diff_bucket", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// ---------------------------------------------------------------
// Step 1: Generate and upload parquet data to the EXTERNAL bucket
// ---------------------------------------------------------------
const numFiles = 2
const rowsPerFile = int64(500)
totalExpectedRows := int64(numFiles) * rowsPerFile
for i := 0; i < numFiles; i++ {
data, err := generateParquetBytes(externalDataSchemaBasic, rowsPerFile, int64(i)*rowsPerFile, testVecDim)
require.NoError(t, err, "generate parquet file %d", i)
objectKey := fmt.Sprintf("%s/data%d.parquet", extPath, i)
uploadParquetToMinIO(ctx, t, minioClient, externalBucket, objectKey, data)
}
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, externalBucket,
fmt.Sprintf("%s/", extPath))
})
// ---------------------------------------------------------------
// Step 2: Create external collection with minio://external-bucket/path URI
// ---------------------------------------------------------------
// The key difference: ExternalSource uses a full minio:// URI pointing to
// a different bucket than Milvus's own bucket (a-bucket).
// URI format: minio://host:port/bucket/path — post-refactor validator
// requires explicit non-empty scheme + host. Empty-host shorthand
// (s3:///bucket/path) was dropped.
externalSource := fmt.Sprintf("minio://%s/%s/%s", minioCfg.address, externalBucket, extPath)
t.Logf("ExternalSource: %s (Milvus bucket: %s)", externalSource, minioCfg.bucket)
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(externalSource).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(
entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithExternalField("id"),
).
WithField(
entity.NewField().
WithName("value").
WithDataType(entity.FieldTypeFloat).
WithExternalField("value"),
).
WithField(
entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"),
)
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created external collection: %s", collName)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// ---------------------------------------------------------------
// Step 3: Trigger refresh and wait for completion
// ---------------------------------------------------------------
refreshAndWait(ctx, t, mc, collName)
// ---------------------------------------------------------------
// Step 4: Verify row count
// ---------------------------------------------------------------
stats, err := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, err, true)
rowCountStr, ok := stats["row_count"]
require.True(t, ok, "collection stats should contain row_count")
rowCount, err := strconv.ParseInt(rowCountStr, 10, 64)
require.NoError(t, err, "parse row_count")
require.Equal(t, totalExpectedRows, rowCount,
"total row count should match uploaded parquet data")
t.Logf("Verified: row_count=%d (expected %d)", rowCount, totalExpectedRows)
// ---------------------------------------------------------------
// Step 5: Create index, load, and query
// ---------------------------------------------------------------
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
// Query count(*)
countRes, err := mc.Query(ctx, client.NewQueryOption(collName).
WithConsistencyLevel(entity.ClStrong).
WithOutputFields(common.QueryCountFieldName))
common.CheckErr(t, err, true)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, totalExpectedRows, count,
"count(*) should match total uploaded rows")
t.Logf("Query count(*) = %d", count)
// Search
vec := make([]float32, testVecDim)
for i := range vec {
vec[i] = float32(i) * 0.1
}
searchRes, err := mc.Search(ctx, client.NewSearchOption(collName, 5,
[]entity.Vector{entity.FloatVector(vec)}).
WithConsistencyLevel(entity.ClStrong).
WithANNSField("embedding").
WithOutputFields("id", "value"))
common.CheckErr(t, err, true)
require.Equal(t, 1, len(searchRes), "should have 1 result set")
require.Greater(t, searchRes[0].ResultCount, 0, "search should return results")
t.Logf("Search returned %d results", searchRes[0].ResultCount)
t.Log("External collection with different bucket test completed!")
}
// TestRefreshExternalCollectionUpdatesSchema verifies that when RefreshExternalCollection
// is called with new ExternalSource and ExternalSpec, the collection schema is updated
// and subsequent DescribeCollection reflects the new values.
func TestRefreshExternalCollectionUpdatesSchema(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
// Connect to MinIO
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_schema_upd", 6)
// Prepare two data paths with different parquet files. Validator requires
// fully-qualified scheme://host/bucket/key URIs — bare relative paths
// are rejected post-refactor.
pathV1 := extTestURI(minioCfg, fmt.Sprintf("external-e2e-test/%s/v1", collName))
pathV2 := extTestURI(minioCfg, fmt.Sprintf("external-e2e-test/%s/v2", collName))
relV1 := fmt.Sprintf("external-e2e-test/%s/v1", collName)
relV2 := fmt.Sprintf("external-e2e-test/%s/v2", collName)
const rowsPerFile = int64(100)
for _, p := range []struct {
path string
startID int64
}{
{relV1, 0},
{relV2, 1000},
} {
data, genErr := generateParquetBytes(externalDataSchemaBasic, rowsPerFile, p.startID, testVecDim)
require.NoError(t, genErr)
objectKey := fmt.Sprintf("%s/data.parquet", p.path)
_, putErr := minioClient.PutObject(ctx, minioCfg.bucket, objectKey,
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, putErr)
t.Logf("Uploaded %s/%s (%d rows)", minioCfg.bucket, objectKey, rowsPerFile)
}
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("external-e2e-test/%s/", collName))
})
// ---------------------------------------------------------------
// Step 1: Create external collection with v1 source and parquet spec
// ---------------------------------------------------------------
specV1 := extTestSpec(minioCfg, "parquet")
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(pathV1).
WithExternalSpec(specV1).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).WithDim(testVecDim).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created external collection: %s (source=%s)", collName, pathV1)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// Verify initial schema
coll, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
common.CheckErr(t, err, true)
require.Equal(t, pathV1, coll.Schema.ExternalSource, "initial ExternalSource should be pathV1")
// DescribeCollection redacts secret extfs values at the proxy edge;
// assert format + non-secret keys + redaction markers, not byte-equality.
require.Contains(t, coll.Schema.ExternalSpec, `"format":"parquet"`)
require.Contains(t, coll.Schema.ExternalSpec, `"access_key_id":"***"`)
require.Contains(t, coll.Schema.ExternalSpec, `"access_key_value":"***"`)
require.NotContains(t, coll.Schema.ExternalSpec, `"region":`)
// ---------------------------------------------------------------
// Step 2: First refresh (with default source/spec) — establishes baseline
// ---------------------------------------------------------------
refreshResult, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
waitRefreshComplete(t, ctx, mc, refreshResult.JobID, 120*time.Second)
t.Logf("First refresh completed (jobID=%d)", refreshResult.JobID)
// ---------------------------------------------------------------
// Step 3: Second refresh with NEW source and spec
// ---------------------------------------------------------------
specV2 := extTestSpec(minioCfg, "parquet")
refreshResult2, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName).
WithExternalSource(pathV2).
WithExternalSpec(specV2))
common.CheckErr(t, err, true)
require.Greater(t, refreshResult2.JobID, int64(0))
t.Logf("Second refresh started (jobID=%d, newSource=%s, newSpec=%s)",
refreshResult2.JobID, pathV2, specV2)
waitRefreshComplete(t, ctx, mc, refreshResult2.JobID, 120*time.Second)
t.Logf("Second refresh completed (jobID=%d)", refreshResult2.JobID)
// ---------------------------------------------------------------
// Step 4: Verify schema updated via DescribeCollection
// ---------------------------------------------------------------
coll2, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
common.CheckErr(t, err, true)
require.Equal(t, pathV2, coll2.Schema.ExternalSource,
"ExternalSource should be updated to pathV2 after refresh")
// Redacted via proxy edge; verify shape + non-secret values.
require.Contains(t, coll2.Schema.ExternalSpec, `"format":"parquet"`)
require.Contains(t, coll2.Schema.ExternalSpec, `"access_key_id":"***"`)
require.Contains(t, coll2.Schema.ExternalSpec, `"access_key_value":"***"`)
require.NotContains(t, coll2.Schema.ExternalSpec, `"region":`)
t.Logf("Verified: schema updated — source=%s, spec=%s", coll2.Schema.ExternalSource, coll2.Schema.ExternalSpec)
_ = specV2 // value used to drive the refresh; final spec compared structurally above.
}
// TestExternalSourceFormats is a table-driven test covering the 3 supported
// data formats (parquet / lance-table / vortex) against a fully qualified
// external_source URI (scheme://endpoint/bucket/key).
//
// Legacy bare-relative-path and empty-host shorthand forms were removed in
// the extfs-isolation refactor — the validator now requires an explicit URI
// with non-empty scheme+host. Bucket-mode variation is therefore collapsed
// into a single cross-endpoint form targeting a dedicated external bucket
// (different from Milvus's own bucket) with explicit credentials in
// spec.extfs. This is the only URI shape the new validator accepts.
func TestExternalSourceFormats(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*600)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible", minioCfg.bucket)
}
// Ensure external-bucket target exists
const externalBucket = "external-bucket"
if ok, _ := minioClient.BucketExists(ctx, externalBucket); !ok {
require.NoError(t, minioClient.MakeBucket(ctx, externalBucket, miniogo.MakeBucketOptions{}))
}
const rowsPerFile = int64(100)
const numRows = 100
// Build extfs credentials JSON fragment once. The minio:// external source
// carries the endpoint and bucket, so tests only need credentials here.
extfsFragment := fmt.Sprintf(
`"extfs":{"access_key_id":%q,"access_key_value":%q}`,
minioCfg.accessKey, minioCfg.secretKey)
type testCase struct {
name string
format string // parquet, lance-table, vortex
}
cases := []testCase{
{"parquet", "parquet"},
{"lance_table", "lance-table"},
{"vortex", "vortex"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
collName := common.GenRandomString("ext_fmt", 6)
dataPath := fmt.Sprintf("external-e2e-test/%s", collName)
// Fully qualified URI (scheme://endpoint/bucket/key) targeting
// the dedicated external bucket.
targetBucket := externalBucket
externalSource := fmt.Sprintf("minio://%s/%s/%s", minioCfg.address, externalBucket, dataPath)
// Generate and upload test data
switch tc.format {
case "parquet":
data, genErr := generateParquetBytes(externalDataSchemaBasic, rowsPerFile, 0, testVecDim)
require.NoError(t, genErr)
objectKey := fmt.Sprintf("%s/data.parquet", dataPath)
uploadParquetToMinIO(ctx, t, minioClient, targetBucket, objectKey, data)
case "lance-table":
s3URI := fmt.Sprintf("s3://%s/%s", targetBucket, dataPath)
generateLanceDataOnMinIO(t, s3URI, externalDataSchemaBasic, numRows, 0)
case "vortex":
generateVortexDataOnMinIO(t, dataPath, externalDataSchemaBasic, numRows, targetBucket)
}
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, targetBucket,
fmt.Sprintf("%s/", dataPath))
})
// Create external collection
specJSON := fmt.Sprintf(`{"format":%q,%s}`, tc.format, extfsFragment)
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(externalSource).
WithExternalSpec(specJSON).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).WithDim(testVecDim).WithExternalField("embedding"))
err := mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Logf("Created collection: %s (source=%s, format=%s)", collName, externalSource, tc.format)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// Refresh and wait
refreshAndWait(ctx, t, mc, collName)
// Verify row count via stats
stats, statErr := mc.GetCollectionStats(ctx, client.NewGetCollectionStatsOption(collName))
common.CheckErr(t, statErr, true)
rowCountStr := stats["row_count"]
rowCount, parseErr := strconv.ParseInt(rowCountStr, 10, 64)
require.NoError(t, parseErr)
require.Equal(t, int64(numRows), rowCount,
"expected %d rows, got %d", numRows, rowCount)
t.Logf("Verified stats: %d rows", rowCount)
// Index + Load + Query
indexAndLoadCollection(ctx, t, mc, collName, "embedding")
// Query count(*)
countRes, queryErr := mc.Query(ctx, client.NewQueryOption(collName).
WithOutputFields("count(*)"))
common.CheckErr(t, queryErr, true)
count, _ := countRes.Fields[0].GetAsInt64(0)
require.Equal(t, int64(numRows), count,
"query count(*) expected %d, got %d", numRows, count)
// Query with filter: id < 10
filterRes, filterErr := mc.Query(ctx, client.NewQueryOption(collName).
WithFilter("id < 10").
WithOutputFields("id", "value"))
common.CheckErr(t, filterErr, true)
require.Equal(t, 10, filterRes.Fields[0].Len(),
"expected 10 rows with id < 10, got %d", filterRes.Fields[0].Len())
t.Logf("Verified: index + load + query count(*)=%d, filter=10 rows", count)
})
}
}
// waitRefreshComplete polls refresh job progress until it reaches Completed or fails with timeout.
func waitRefreshComplete(t *testing.T, ctx context.Context, mc *base.MilvusClient, jobID int64, timeout time.Duration) {
t.Helper()
deadline := time.After(timeout)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatalf("Refresh job %d timed out after %s", jobID, timeout)
case <-ticker.C:
progress, err := mc.GetRefreshExternalCollectionProgress(ctx,
client.NewGetRefreshExternalCollectionProgressOption(jobID))
require.NoError(t, err)
t.Logf("Job %d: state=%s progress=%d%%", jobID, progress.State, progress.Progress)
if progress.State == entity.RefreshStateCompleted {
return
}
if progress.State == entity.RefreshStateFailed {
t.Fatalf("Refresh job %d failed: %s", jobID, progress.Reason)
}
}
}
}
// TestExternalCollectionLoadPerfProfile writes 500K rows with 768d vectors + scalar fields,
// triggers refresh + index + load, and measures load performance with timing logs.
func TestExternalCollectionLoadPerfProfile(t *testing.T) {
if !isTruthyEnv("GO_CLIENT_ENABLE_EXTERNAL_TABLE_PERF_PROFILE") {
t.Skip("set GO_CLIENT_ENABLE_EXTERNAL_TABLE_PERF_PROFILE=1 to run the external table perf profile")
}
ctx := hp.CreateContext(t, time.Second*600)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
skipIfMinIOUnreachable(ctx, t, minioClient, minioCfg.bucket)
collName := common.GenRandomString("ext_perf_profile", 6)
extPath := "external-e2e-test/" + collName
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket, extPath+"/")
})
const totalRows = 500000
const vecDim = 128
t.Logf("Generating %d rows with %d-dim vectors + scalars...", totalRows, vecDim)
genStart := time.Now()
data, err := generateParquetBytes(externalDataSchemaLarge, totalRows, 0, vecDim)
require.NoError(t, err)
t.Logf("Generated %d bytes (%.1f MB) in %v", len(data), float64(len(data))/1048576.0, time.Since(genStart))
uploadStart := time.Now()
uploadParquetToMinIO(ctx, t, minioClient, minioCfg.bucket, extPath+"/data.parquet", data)
t.Logf("Uploaded to MinIO in %v", time.Since(uploadStart))
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("score").WithDataType(entity.FieldTypeDouble).WithExternalField("score")).
WithField(entity.NewField().WithName("label").WithDataType(entity.FieldTypeInt32).WithExternalField("label")).
WithField(entity.NewField().WithName("tag").WithDataType(entity.FieldTypeVarChar).WithMaxLength(256).WithExternalField("tag")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).WithDim(vecDim).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
t.Log("Starting refresh...")
refreshStart := time.Now()
refreshAndWait(ctx, t, mc, collName)
t.Logf("Refresh done in %v", time.Since(refreshStart))
t.Log("Creating index...")
indexStart := time.Now()
idxTask, err := mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, "embedding",
index.NewAutoIndex(entity.L2)))
common.CheckErr(t, err, true)
err = idxTask.Await(ctx)
common.CheckErr(t, err, true)
t.Logf("Index created in %v", time.Since(indexStart))
// Wait for index metadata to fully propagate.
time.Sleep(5 * time.Second)
// NOTE: Load is intentionally skipped here.
// The test script will measure memory baseline after index,
// then trigger Load via pymilvus to capture the true memory delta.
t.Log("=== PERF PROFILE COMPLETE (index only, no load) ===")
t.Logf("Total rows: %d, Vector dim: %d", totalRows, vecDim)
t.Logf("Raw vector data: %.1f MB", float64(totalRows)*float64(vecDim)*4/1048576.0)
t.Logf("Estimated total row size: %d bytes (vec=%d + scalars~30)", vecDim*4+30, vecDim*4)
}
// TestRefreshExternalCollectionZeroRowParquet covers issue #49225:
//
// A zero-row parquet file in the external bucket previously crashed
// datanode with "integer divide by zero" inside balanceFragmentsToSegments,
// putting the standalone Milvus pod into CrashLoopBackOff. The fix
// short-circuits at DataCoord.createTasksForJob, marking the refresh job
// Failed with a non-retriable reason instead.
//
// Pass criteria:
// 1. refresh_external_collection accepts the request and returns a job_id.
// 2. The job transitions to a terminal state (Failed expected) within the
// poll deadline — i.e. it does NOT hang in Pending forever AND the
// datanode does NOT crash mid-poll.
// 3. The Failed reason mentions zero rows so operators have actionable
// signal.
func TestRefreshExternalCollectionZeroRowParquet(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible (exists=%v, err=%v), skipping",
minioCfg.bucket, exists, err)
}
collName := common.GenRandomString("ext_zero_row", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// Upload a single well-formed parquet file with zero rows.
data, err := generateParquetBytes(externalDataSchemaBasic, 0, 0, testVecDim)
require.NoError(t, err, "generate zero-row parquet")
require.NotEmpty(t, data, "zero-row parquet must still be a valid file with header/footer")
objectKey := fmt.Sprintf("%s/empty.parquet", extPath)
_, err = minioClient.PutObject(ctx, minioCfg.bucket, objectKey,
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err, "upload zero-row parquet to MinIO")
t.Logf("Uploaded zero-row parquet %s/%s (%d bytes)",
minioCfg.bucket, objectKey, len(data))
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/", extPath))
})
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
refreshResult, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
require.Greater(t, refreshResult.JobID, int64(0))
jobID := refreshResult.JobID
t.Logf("Started refresh job: %d", jobID)
// Must reach a terminal state within 60s. Before the fix this loop hit
// the deadline because the datanode CrashLoopBackOff prevented any task
// from running to completion.
deadline := time.After(60 * time.Second)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var finalState entity.RefreshExternalCollectionState
var finalReason string
for done := false; !done; {
select {
case <-deadline:
t.Fatalf("Refresh job %d did not reach terminal state within 60s "+
"(zero-row parquet likely panicked datanode)", jobID)
case <-ticker.C:
progress, err := mc.GetRefreshExternalCollectionProgress(ctx,
client.NewGetRefreshExternalCollectionProgressOption(jobID))
require.NoError(t, err)
t.Logf("Job %d: state=%s reason=%q", jobID, progress.State, progress.Reason)
if progress.State == entity.RefreshStateCompleted ||
progress.State == entity.RefreshStateFailed {
finalState = progress.State
finalReason = progress.Reason
done = true
}
}
}
require.Equal(t, entity.RefreshStateFailed, finalState,
"zero-row external source must surface as RefreshFailed, not silent Completed")
require.Contains(t, strings.ToLower(finalReason), "zero",
"failure reason should mention zero rows so operators can act; got %q", finalReason)
}
// Regression for issue #48637: a typo in external_field mapping (name
// points to a column that does not exist in the parquet) must surface
// the C++ "Column 'xxx' not found in schema" root cause in the
// RefreshFailed reason returned to the client, not the generic
// "sampling failed for all N segment(s)" message that forces operators
// to SSH into datanode logs.
func TestRefreshExternalCollectionWrongExternalField(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_wrong_field", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// Well-formed parquet with real columns id/value/embedding.
data, err := generateParquetBytes(externalDataSchemaBasic, 8, 0, testVecDim)
require.NoError(t, err)
_, err = minioClient.PutObject(ctx, minioCfg.bucket,
fmt.Sprintf("%s/data.parquet", extPath),
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err)
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/", extPath))
})
// Schema maps "embedding" to a column "wrong_col_a" that does not exist.
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).WithExternalField("wrong_col_a"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
refreshResult, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
jobID := refreshResult.JobID
deadline := time.After(60 * time.Second)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var finalState entity.RefreshExternalCollectionState
var finalReason string
for done := false; !done; {
select {
case <-deadline:
t.Fatalf("Refresh job %d did not reach terminal state within 60s", jobID)
case <-ticker.C:
progress, err := mc.GetRefreshExternalCollectionProgress(ctx,
client.NewGetRefreshExternalCollectionProgressOption(jobID))
require.NoError(t, err)
t.Logf("Job %d: state=%s reason=%q", jobID, progress.State, progress.Reason)
if progress.State == entity.RefreshStateCompleted ||
progress.State == entity.RefreshStateFailed {
finalState = progress.State
finalReason = progress.Reason
done = true
}
}
}
require.Equal(t, entity.RefreshStateFailed, finalState,
"wrong external_field must surface as RefreshFailed")
// Root cause from C++ layer must reach the client.
require.Contains(t, finalReason, "wrong_col_a",
"reason must name the offending column; got %q", finalReason)
require.Contains(t, strings.ToLower(finalReason), "not found",
"reason must state the column was not found; got %q", finalReason)
require.Contains(t, finalReason, "external_field mappings",
"reason must hint at the real fix; got %q", finalReason)
}
// Security regression: DescribeCollection must redact secret extfs keys
// (access_key_id / access_key_value / ssl_ca_cert) in the ExternalSpec
// JSON returned to the client. Any caller with Describe privilege would
// otherwise be able to read another tenant's object-storage credentials.
func TestDescribeExternalCollectionRedactsCredentials(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
if _, err := newMinIOClient(minioCfg); err != nil {
t.Skipf("MinIO unavailable, skipping: %v", err)
}
collName := common.GenRandomString("ext_redact", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// Embed secret credentials in extfs; they are persisted into etcd and
// must NOT flow back out unredacted through DescribeCollection.
rawSpec := fmt.Sprintf(
`{"format":"parquet","extfs":{"access_key_id":%q,"access_key_value":%q}}`,
"AKIAEXAMPLELEAKME", "supersecretvaluemustnotleak")
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(rawSpec).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).WithExternalField("embedding"))
err := mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
desc, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
require.NoError(t, err)
returnedSpec := desc.Schema.ExternalSpec
t.Logf("returned ExternalSpec: %s", returnedSpec)
require.NotContains(t, returnedSpec, "AKIAEXAMPLELEAKME",
"access_key_id plaintext leaked via DescribeCollection")
require.NotContains(t, returnedSpec, "supersecretvaluemustnotleak",
"access_key_value plaintext leaked via DescribeCollection")
require.Contains(t, returnedSpec, `"access_key_id":"***"`,
"access_key_id must be redacted to ***")
require.Contains(t, returnedSpec, `"access_key_value":"***"`,
"access_key_value must be redacted to ***")
// Non-secret values remain visible for operator troubleshooting.
require.Contains(t, returnedSpec, `"format":"parquet"`,
"non-secret spec values must remain readable")
}
// Regression for issue #49335: a refresh_external_collection call carrying
// an explicit (external_source, external_spec) override must persist the
// new tuple back into the collection schema, so that subsequent
// describe_collection reflects the new source AND a later reuse-path
// refresh (no override) targets the new source rather than silently
// reverting to the original one.
//
// Pre-fix behavior: the post-completion schema-sync path
// (server_external_schema_updater.updateExternalSchemaViaWAL) went through
// AlterCollection, which the atomic-tuple guard introduced in PR #49302
// rejected — the data load against NEW succeeded but the persisted
// schema stayed pointing at OLD forever.
func TestRefreshExternalCollectionOverridePersistsNewSource(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_override", 6)
pathA := fmt.Sprintf("external-e2e-test/%s/a", collName)
pathB := fmt.Sprintf("external-e2e-test/%s/b", collName)
// Upload distinct parquet payloads to bucket/A and bucket/B.
dataA, err := generateParquetBytes(externalDataSchemaBasic, 8, 0, testVecDim)
require.NoError(t, err)
dataB, err := generateParquetBytes(externalDataSchemaBasic, 8, 1000, testVecDim)
require.NoError(t, err)
for _, p := range []struct {
path string
bytes []byte
}{{pathA, dataA}, {pathB, dataB}} {
_, err = minioClient.PutObject(ctx, minioCfg.bucket,
fmt.Sprintf("%s/data.parquet", p.path),
bytes.NewReader(p.bytes), int64(len(p.bytes)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err)
}
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("external-e2e-test/%s/", collName))
})
uriA := extTestURI(minioCfg, pathA)
uriB := extTestURI(minioCfg, pathB)
spec := extTestSpec(minioCfg, "parquet")
// Create with SRC_A.
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(uriA).
WithExternalSpec(spec).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
// Initial refresh against SRC_A (reuse path).
refreshA, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
waitRefreshTerminal(t, ctx, mc, refreshA.JobID, entity.RefreshStateCompleted)
// Override refresh to SRC_B with explicit external_source.
refreshB, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName).
WithExternalSource(uriB).
WithExternalSpec(spec))
common.CheckErr(t, err, true)
waitRefreshTerminal(t, ctx, mc, refreshB.JobID, entity.RefreshStateCompleted)
// Persisted schema must now reflect SRC_B. Pre-fix this still showed pathA.
desc, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(collName))
require.NoError(t, err)
require.Equal(t, uriB, desc.Schema.ExternalSource,
"override refresh must persist new external_source; pre-fix #49335 left it stale")
}
func waitRefreshTerminal(t *testing.T, ctx context.Context, mc *base.MilvusClient, jobID int64, expect entity.RefreshExternalCollectionState) {
t.Helper()
deadline := time.After(60 * time.Second)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatalf("refresh job %d did not reach terminal state within 60s", jobID)
case <-ticker.C:
progress, err := mc.GetRefreshExternalCollectionProgress(ctx,
client.NewGetRefreshExternalCollectionProgressOption(jobID))
require.NoError(t, err)
t.Logf("Job %d: state=%s reason=%q", jobID, progress.State, progress.Reason)
if progress.State == entity.RefreshStateCompleted ||
progress.State == entity.RefreshStateFailed {
require.Equal(t, expect, progress.State,
"refresh terminal state mismatch (reason=%s)", progress.Reason)
return
}
}
}
}
func TestRefreshExternalCollectionMilvusTableSnapshot(t *testing.T) {
ctx := hp.CreateContext(t, 5*time.Minute)
mc := hp.CreateDefaultMilvusClient(ctx, t)
const rowCount = 5000
const deletedCount = 500
sourceName := common.GenRandomString("mt_src", 6)
sourceSchema := entity.NewSchema().
WithName(sourceName).
WithField(entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithIsPrimaryKey(true)).
WithField(entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim))
require.NoError(t, mc.CreateCollection(ctx, client.NewCreateCollectionOption(sourceName, sourceSchema)))
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(sourceName))
})
ids := make([]int64, rowCount)
vectors := make([][]float32, rowCount)
for i := 0; i < rowCount; i++ {
ids[i] = int64(i)
vectors[i] = []float32{float32(i), float32(i) + 1, float32(i) + 2, float32(i) + 3}
}
_, err := mc.Insert(ctx, client.NewColumnBasedInsertOption(sourceName,
column.NewColumnInt64("id", ids),
column.NewColumnFloatVector("embedding", testVecDim, vectors)))
require.NoError(t, err)
flushTask, err := mc.Flush(ctx, client.NewFlushOption(sourceName))
require.NoError(t, err)
require.NoError(t, flushTask.Await(ctx))
deletedIDs := make([]int64, deletedCount)
for i := 0; i < deletedCount; i++ {
deletedIDs[i] = int64(i)
}
delRes, err := mc.Delete(ctx, client.NewDeleteOption(sourceName).WithInt64IDs("id", deletedIDs))
require.NoError(t, err)
require.Equal(t, int64(deletedCount), delRes.DeleteCount)
// Wait for the standalone flush rate limiter to reset before flushing
// the delete log. The local default is 0.1 flush requests per second.
time.Sleep(10 * time.Second)
flushTask, err = mc.Flush(ctx, client.NewFlushOption(sourceName))
require.NoError(t, err)
require.NoError(t, flushTask.Await(ctx))
sourceDesc, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(sourceName))
require.NoError(t, err)
snapshotName := common.GenRandomString("mt_snap", 6)
require.NoError(t, mc.CreateSnapshot(ctx, client.NewCreateSnapshotOption(snapshotName, sourceName).
WithDescription("milvus-table external snapshot e2e")))
t.Cleanup(func() {
_ = mc.DropSnapshot(context.Background(), client.NewDropSnapshotOption(snapshotName, sourceName))
})
snapshotInfo, err := mc.DescribeSnapshot(ctx, client.NewDescribeSnapshotOption(snapshotName, sourceName))
require.NoError(t, err)
require.NotEmpty(t, snapshotInfo.GetS3Location())
minioCfg := getMinIOConfig()
externalSource := extTestURI(minioCfg, snapshotInfo.GetS3Location())
externalSpec := extTestSpec(minioCfg, "milvus-table")
externalName := common.GenRandomString("mt_ext", 6)
externalSchema := entity.NewSchema().
WithName(externalName).
WithExternalSource(externalSource).
WithExternalSpec(externalSpec).
WithField(entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithIsPrimaryKey(true).
WithExternalField("id")).
WithField(entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"))
require.NoError(t, mc.CreateCollection(ctx, client.NewCreateCollectionOption(externalName, externalSchema)))
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(externalName))
})
refresh, err := mc.RefreshExternalCollection(ctx, client.NewRefreshExternalCollectionOption(externalName))
require.NoError(t, err)
waitRefreshTerminal(t, ctx, mc, refresh.JobID, entity.RefreshStateCompleted)
idxTask, err := mc.CreateIndex(ctx,
client.NewCreateIndexOption(externalName, "embedding", index.NewFlatIndex(entity.L2)))
require.NoError(t, err)
require.NoError(t, idxTask.Await(ctx))
loadTask, err := mc.LoadCollection(ctx, client.NewLoadCollectionOption(externalName))
require.NoError(t, err)
require.NoError(t, loadTask.Await(ctx))
countRes, err := mc.Query(ctx, client.NewQueryOption(externalName).
WithOutputFields(common.QueryCountFieldName).
WithConsistencyLevel(entity.ClStrong))
require.NoError(t, err)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(rowCount-deletedCount), count)
searchRes, err := mc.Search(ctx,
client.NewSearchOption(externalName, 5, []entity.Vector{entity.FloatVector(vectors[deletedCount])}).
WithOutputFields("id"))
require.NoError(t, err)
require.NotEmpty(t, searchRes)
require.Equal(t, 5, searchRes[0].ResultCount)
idCol := searchRes[0].GetColumn("id")
require.NotNil(t, idCol)
require.Equal(t, searchRes[0].ResultCount, idCol.Len())
for i := 0; i < idCol.Len(); i++ {
id, err := idCol.GetAsInt64(i)
require.NoError(t, err)
require.GreaterOrEqual(t, id, int64(deletedCount))
}
nearestID, err := idCol.GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(deletedCount), nearestID)
t.Logf("milvus-table snapshot e2e passed: sourceCollectionID=%d snapshot=%s externalSource=%s",
sourceDesc.ID, snapshotName, externalSource)
}
func TestRefreshExternalCollectionMilvusTableSnapshotVirtualPK(t *testing.T) {
ctx := hp.CreateContext(t, 5*time.Minute)
mc := hp.CreateDefaultMilvusClient(ctx, t)
const rowCount = 5000
const deletedCount = 500
const targetRow = 1234
sourceName := common.GenRandomString("mt_vpk_src", 6)
sourceSchema := entity.NewSchema().
WithName(sourceName).
WithField(entity.NewField().
WithName("id").
WithDataType(entity.FieldTypeInt64).
WithIsPrimaryKey(true)).
WithField(entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim))
require.NoError(t, mc.CreateCollection(ctx, client.NewCreateCollectionOption(sourceName, sourceSchema)))
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(sourceName))
})
ids := make([]int64, rowCount)
vectors := make([][]float32, rowCount)
for i := 0; i < rowCount; i++ {
ids[i] = int64(i)
vectors[i] = []float32{float32(i), float32(i) + 1, float32(i) + 2, float32(i) + 3}
}
_, err := mc.Insert(ctx, client.NewColumnBasedInsertOption(sourceName,
column.NewColumnInt64("id", ids),
column.NewColumnFloatVector("embedding", testVecDim, vectors)))
require.NoError(t, err)
flushTask, err := mc.Flush(ctx, client.NewFlushOption(sourceName))
require.NoError(t, err)
require.NoError(t, flushTask.Await(ctx))
deletedIDs := make([]int64, deletedCount)
for i := 0; i < deletedCount; i++ {
deletedIDs[i] = int64(i)
}
delRes, err := mc.Delete(ctx, client.NewDeleteOption(sourceName).WithInt64IDs("id", deletedIDs))
require.NoError(t, err)
require.Equal(t, int64(deletedCount), delRes.DeleteCount)
// Wait for the standalone flush rate limiter to reset before flushing
// the delete log. The local default is 0.1 flush requests per second.
time.Sleep(10 * time.Second)
flushTask, err = mc.Flush(ctx, client.NewFlushOption(sourceName))
require.NoError(t, err)
require.NoError(t, flushTask.Await(ctx))
sourceDesc, err := mc.DescribeCollection(ctx, client.NewDescribeCollectionOption(sourceName))
require.NoError(t, err)
snapshotName := common.GenRandomString("mt_vpk_snap", 6)
require.NoError(t, mc.CreateSnapshot(ctx, client.NewCreateSnapshotOption(snapshotName, sourceName).
WithDescription("milvus-table external snapshot virtual pk e2e")))
t.Cleanup(func() {
_ = mc.DropSnapshot(context.Background(), client.NewDropSnapshotOption(snapshotName, sourceName))
})
snapshotInfo, err := mc.DescribeSnapshot(ctx, client.NewDescribeSnapshotOption(snapshotName, sourceName))
require.NoError(t, err)
require.NotEmpty(t, snapshotInfo.GetS3Location())
minioCfg := getMinIOConfig()
externalSource := extTestURI(minioCfg, snapshotInfo.GetS3Location())
externalSpec := extTestSpec(minioCfg, "milvus-table")
externalName := common.GenRandomString("mt_vpk_ext", 6)
externalSchema := entity.NewSchema().
WithName(externalName).
WithExternalSource(externalSource).
WithExternalSpec(externalSpec).
WithField(entity.NewField().
WithName("source_id").
WithDataType(entity.FieldTypeInt64).
WithExternalField("id")).
WithField(entity.NewField().
WithName("embedding").
WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).
WithExternalField("embedding"))
require.NoError(t, mc.CreateCollection(ctx, client.NewCreateCollectionOption(externalName, externalSchema)))
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(externalName))
})
refresh, err := mc.RefreshExternalCollection(ctx, client.NewRefreshExternalCollectionOption(externalName))
require.NoError(t, err)
waitRefreshTerminal(t, ctx, mc, refresh.JobID, entity.RefreshStateCompleted)
idxTask, err := mc.CreateIndex(ctx,
client.NewCreateIndexOption(externalName, "embedding", index.NewFlatIndex(entity.L2)))
require.NoError(t, err)
require.NoError(t, idxTask.Await(ctx))
loadTask, err := mc.LoadCollection(ctx, client.NewLoadCollectionOption(externalName))
require.NoError(t, err)
require.NoError(t, loadTask.Await(ctx))
countRes, err := mc.Query(ctx, client.NewQueryOption(externalName).
WithOutputFields(common.QueryCountFieldName).
WithConsistencyLevel(entity.ClStrong))
require.NoError(t, err)
count, err := countRes.GetColumn(common.QueryCountFieldName).GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(rowCount-deletedCount), count)
deletedQueryRes, err := mc.Query(ctx, client.NewQueryOption(externalName).
WithFilter("source_id in [0,1,499]").
WithOutputFields("source_id").
WithConsistencyLevel(entity.ClStrong))
require.NoError(t, err)
require.Zero(t, deletedQueryRes.ResultCount)
deletedQuerySourceIDCol := deletedQueryRes.GetColumn("source_id")
require.NotNil(t, deletedQuerySourceIDCol)
require.Zero(t, deletedQuerySourceIDCol.Len())
deletedSearchRes, err := mc.Search(ctx,
client.NewSearchOption(externalName, 5, []entity.Vector{entity.FloatVector(vectors[0])}).
WithOutputFields("source_id"))
require.NoError(t, err)
require.Len(t, deletedSearchRes, 1)
require.Equal(t, 5, deletedSearchRes[0].ResultCount)
deletedSearchSourceIDCol := deletedSearchRes[0].GetColumn("source_id")
require.NotNil(t, deletedSearchSourceIDCol)
for i := 0; i < deletedSearchSourceIDCol.Len(); i++ {
sourceID, err := deletedSearchSourceIDCol.GetAsInt64(i)
require.NoError(t, err)
require.GreaterOrEqual(t, sourceID, int64(deletedCount))
}
searchRes, err := mc.Search(ctx,
client.NewSearchOption(externalName, 1, []entity.Vector{entity.FloatVector(vectors[targetRow])}).
WithOutputFields("source_id"))
require.NoError(t, err)
require.Len(t, searchRes, 1)
require.Equal(t, 1, searchRes[0].ResultCount)
require.NotNil(t, searchRes[0].IDs)
virtualID, err := searchRes[0].IDs.GetAsInt64(0)
require.NoError(t, err)
sourceID, err := searchRes[0].GetColumn("source_id").GetAsInt64(0)
require.NoError(t, err)
require.Equal(t, int64(targetRow), sourceID)
require.NotEqual(t, sourceID, virtualID,
"search IDs should be synthesized virtual PKs, not source real PKs")
t.Logf("milvus-table virtual pk snapshot e2e passed: sourceCollectionID=%d snapshot=%s externalSource=%s",
sourceDesc.ID, snapshotName, externalSource)
}
// Regression for the explore-manifest index-drift bug: the source
// directory is intentionally polluted with non-parquet strays
// (`_SUCCESS`, `.crc`, `README.md`) interleaved with valid parquet
// shards. Pre-fix, DataCoord filtered them out before slicing tasks
// while DataNode read the unfiltered manifest and sliced against the
// stale index space — picking `_SUCCESS` as a "parquet" file and
// failing with "Invalid parquet magic" or silently dropping a real
// shard. Post-fix, both layers normalize (sort + format-filter) the
// manifest identically, so refresh completes and total row count
// matches the sum of the parquet shards only.
func TestRefreshExternalCollectionFiltersNonParquetStrays(t *testing.T) {
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
mc := hp.CreateDefaultMilvusClient(ctx, t)
minioCfg := getMinIOConfig()
minioClient, err := newMinIOClient(minioCfg)
require.NoError(t, err)
exists, err := minioClient.BucketExists(ctx, minioCfg.bucket)
if err != nil || !exists {
t.Skipf("MinIO bucket %q not accessible, skipping", minioCfg.bucket)
}
collName := common.GenRandomString("ext_strays", 6)
extPath := fmt.Sprintf("external-e2e-test/%s", collName)
// 3 valid parquet shards, varying sizes to detect index drift.
const rowsPerShard = int64(8)
const numShards = 3
for i := 0; i < numShards; i++ {
data, err := generateParquetBytes(externalDataSchemaBasic, rowsPerShard, int64(i)*rowsPerShard, testVecDim)
require.NoError(t, err)
_, err = minioClient.PutObject(ctx, minioCfg.bucket,
fmt.Sprintf("%s/part-%05d.parquet", extPath, i),
bytes.NewReader(data), int64(len(data)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err)
}
// Stray non-parquet files interleaved by lex order with the parquets.
strays := map[string][]byte{
"_SUCCESS": []byte(""),
"_committed": []byte("xyz"),
"part-00.crc": []byte("crc-checksum-bytes"),
"README.md": []byte("# this dir\n"),
"metadata": []byte(`{"v":1}`),
}
for name, payload := range strays {
_, err = minioClient.PutObject(ctx, minioCfg.bucket,
fmt.Sprintf("%s/%s", extPath, name),
bytes.NewReader(payload), int64(len(payload)),
miniogo.PutObjectOptions{ContentType: "application/octet-stream"})
require.NoError(t, err)
}
t.Cleanup(func() {
cleanupMinIOPrefix(context.Background(), minioClient, minioCfg.bucket,
fmt.Sprintf("%s/", extPath))
})
schema := entity.NewSchema().
WithName(collName).
WithExternalSource(extTestURI(minioCfg, extPath)).
WithExternalSpec(extTestSpec(minioCfg, "parquet")).
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithExternalField("id")).
WithField(entity.NewField().WithName("value").WithDataType(entity.FieldTypeFloat).WithExternalField("value")).
WithField(entity.NewField().WithName("embedding").WithDataType(entity.FieldTypeFloatVector).
WithDim(testVecDim).WithExternalField("embedding"))
err = mc.CreateCollection(ctx, client.NewCreateCollectionOption(collName, schema))
common.CheckErr(t, err, true)
t.Cleanup(func() {
_ = mc.DropCollection(context.Background(), client.NewDropCollectionOption(collName))
})
refresh, err := mc.RefreshExternalCollection(ctx,
client.NewRefreshExternalCollectionOption(collName))
common.CheckErr(t, err, true)
waitRefreshTerminal(t, ctx, mc, refresh.JobID, entity.RefreshStateCompleted)
// Load + count to assert no shard was dropped and no stray was read
// (a stray would either fail load or — worse — succeed silently with
// the wrong row count if the bug let mis-sliced indices slip
// through).
idxOpt := client.NewCreateIndexOption(collName, "embedding", index.NewAutoIndex(entity.MetricType("L2")))
idxTask, err := mc.CreateIndex(ctx, idxOpt)
common.CheckErr(t, err, true)
require.NoError(t, idxTask.Await(ctx))
loadTask, err := mc.LoadCollection(ctx, client.NewLoadCollectionOption(collName))
common.CheckErr(t, err, true)
require.NoError(t, loadTask.Await(ctx))
res, err := mc.Query(ctx, client.NewQueryOption(collName).
WithFilter("").
WithOutputFields(common.QueryCountFieldName).
WithConsistencyLevel(entity.ClStrong))
require.NoError(t, err)
require.Greater(t, len(res.Fields), 0)
got := res.Fields[0].FieldData().GetScalars().GetLongData().GetData()
require.Len(t, got, 1)
expected := rowsPerShard * numShards
require.Equal(t, expected, got[0],
"row count must equal sum of parquet shards (%d); strays must be filtered, no shard may be dropped",
expected)
}