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//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 (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) }