mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 10:15:43 +00:00
issue: #45881 ## Summary Add the `milvus-table` external format so external collection refresh can consume Milvus snapshot metadata and StorageV3 segment manifests directly. This change lets refresh resolve source manifest row counts, rebuild target StorageV3 manifests from source column groups, copy source deltalogs with deterministic preallocated IDs, and keep real external primary-key semantics for milvus-table segments. ## Why Snapshot-backed external tables are not generic file tables. The refresh path needs to preserve Milvus StorageV3 manifest structure, deltalogs, and real PK behavior instead of falling back to virtual PK semantics or format-agnostic fragment handling. ## Details - Add milvus-table external spec parsing and snapshot manifest resolution. - Add StorageV3 manifest translation for source Milvus segment manifests. - Copy milvus-table deltalogs into target manifests with deterministic LogIDs. - Wire QueryNode real-PK lookup/load handling for milvus-table segments. - Add unit and Go client coverage for spec parsing, manifest resolution, refresh, and deltalog handling. ## Validation - `git diff --cached --check` - No proto changes detected - No mockery patterns in changed test files - `source ~/.profile && source scripts/setenv.sh && make milvus` - Reset Milvus local standalone before Go tests - `go test -tags dynamic,test -gcflags="all=-N -l" -ldflags="-r ${RPATH}" github.com/milvus-io/milvus/internal/datanode/external github.com/milvus-io/milvus/internal/storagev2/packed -count=1 -timeout 300s` - `source ~/.profile && source scripts/setenv.sh && make lint-fix` Related: [#45881](https://github.com/milvus-io/milvus/issues/45881) --------- Signed-off-by: Wei Liu <wei.liu@zilliz.com>
1459 lines
50 KiB
Go
1459 lines
50 KiB
Go
// Licensed to the LF AI & Data foundation under one
|
|
// or more contributor license agreements. See the NOTICE file
|
|
// distributed with this work for additional information
|
|
// regarding copyright ownership. The ASF licenses this file
|
|
// to you under the Apache License, Version 2.0 (the
|
|
// "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package datacoord
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bytedance/mockey"
|
|
"github.com/cockroachdb/errors"
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
|
|
"github.com/milvus-io/milvus/internal/storage"
|
|
"github.com/milvus-io/milvus/internal/storagev2/packed"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/datapb"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/indexpb"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
|
|
)
|
|
|
|
// ==================== Helper Functions for Manager Tests ====================
|
|
|
|
func createTestRefreshMeta(t *testing.T) *externalCollectionRefreshMeta {
|
|
catalog := &stubCatalog{}
|
|
meta, err := newExternalCollectionRefreshMeta(context.Background(), catalog)
|
|
assert.NoError(t, err)
|
|
return meta
|
|
}
|
|
|
|
func createTestRefreshMetaWithJobs(t *testing.T, jobs []*datapb.ExternalCollectionRefreshJob, tasks []*datapb.ExternalCollectionRefreshTask) *externalCollectionRefreshMeta {
|
|
catalog := &stubCatalog{
|
|
jobs: jobs,
|
|
tasks: tasks,
|
|
}
|
|
meta, err := newExternalCollectionRefreshMeta(context.Background(), catalog)
|
|
assert.NoError(t, err)
|
|
return meta
|
|
}
|
|
|
|
func testCollectionGetter(mt *meta) func(ctx context.Context, collectionID int64) (*collectionInfo, error) {
|
|
return func(_ context.Context, collectionID int64) (*collectionInfo, error) {
|
|
coll := mt.GetCollection(collectionID)
|
|
if coll == nil {
|
|
return nil, errors.New("collection not found")
|
|
}
|
|
return coll, nil
|
|
}
|
|
}
|
|
|
|
func testMilvusTableRefreshSchema(externalSource bool) *schemapb.CollectionSchema {
|
|
field := &schemapb.FieldSchema{
|
|
FieldID: 100,
|
|
Name: "pk",
|
|
IsPrimaryKey: true,
|
|
DataType: schemapb.DataType_Int64,
|
|
}
|
|
if externalSource {
|
|
field.ExternalField = "pk"
|
|
}
|
|
return &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
Fields: []*schemapb.FieldSchema{field},
|
|
}
|
|
}
|
|
|
|
func testMilvusTableTargetRefreshSchema() *schemapb.CollectionSchema {
|
|
schema := testMilvusTableRefreshSchema(true)
|
|
schema.ExternalSource = "s3://bucket/snapshots/1/metadata/10.json"
|
|
schema.ExternalSpec = `{"format":"milvus-table","extfs":{"cloud_provider":"aws","region":"us-west-2","access_key_id":"ak","access_key_value":"sk"}}`
|
|
return schema
|
|
}
|
|
|
|
func TestSubmitRefreshJobWithIDStoresJobMetadata(t *testing.T) {
|
|
ctx := context.Background()
|
|
collectionID := int64(100)
|
|
schema := &schemapb.CollectionSchema{
|
|
Name: "ext",
|
|
ExternalSource: "s3://bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
Version: 7,
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", ExternalField: "id"},
|
|
},
|
|
}
|
|
mt := &meta{
|
|
collections: typeutil.NewConcurrentMap[UniqueID, *collectionInfo](),
|
|
}
|
|
mt.collections.Insert(collectionID, &collectionInfo{
|
|
ID: collectionID,
|
|
Schema: schema,
|
|
VChannelNames: []string{"by-dev-rootcoord-dml_0_v1"},
|
|
Partitions: []int64{1},
|
|
})
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, nil, nil)
|
|
mgr := NewExternalCollectionRefreshManager(
|
|
ctx, mt, newStubScheduler(), &stubAllocator{nextID: 2000},
|
|
refreshMeta, nil, testCollectionGetter(mt), nil, nil)
|
|
|
|
mockExplore := mockey.Mock((*externalCollectionRefreshManager).exploreExternalFiles).
|
|
Return([]*datapb.ExternalFileInfo{{FilePath: "s3://bucket/path/a.parquet", NumRows: 10}}, "manifest-path", nil).
|
|
Build()
|
|
defer mockExplore.UnPatch()
|
|
|
|
jobID, err := mgr.SubmitRefreshJobWithID(
|
|
ctx, 1001, collectionID, "ext", "", "")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(1001), jobID)
|
|
|
|
job := refreshMeta.GetJob(1001)
|
|
assert.NotNil(t, job)
|
|
assert.Equal(t, collectionID, job.GetCollectionId())
|
|
assert.Equal(t, "ext", job.GetCollectionName())
|
|
assert.Equal(t, "s3://bucket/path", job.GetExternalSource())
|
|
assert.Equal(t, `{"format":"parquet"}`, job.GetExternalSpec())
|
|
|
|
mgr.Stop()
|
|
}
|
|
|
|
func TestCreateTasksForJobCopiesJobMetadata(t *testing.T) {
|
|
ctx := context.Background()
|
|
collectionID := int64(100)
|
|
schema := &schemapb.CollectionSchema{
|
|
Name: "ext",
|
|
ExternalSource: "s3://bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
Version: 9,
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", ExternalField: "id"},
|
|
},
|
|
}
|
|
mt := &meta{
|
|
collections: typeutil.NewConcurrentMap[UniqueID, *collectionInfo](),
|
|
}
|
|
mt.collections.Insert(collectionID, &collectionInfo{
|
|
ID: collectionID,
|
|
Schema: schema,
|
|
VChannelNames: []string{"by-dev-rootcoord-dml_0_v1"},
|
|
Partitions: []int64{1},
|
|
})
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, nil, nil)
|
|
mgr := NewExternalCollectionRefreshManager(
|
|
ctx, mt, newStubScheduler(), &stubAllocator{nextID: 2000},
|
|
refreshMeta, nil, testCollectionGetter(mt), nil, nil).(*externalCollectionRefreshManager)
|
|
|
|
mockExplore := mockey.Mock((*externalCollectionRefreshManager).exploreExternalFiles).
|
|
Return([]*datapb.ExternalFileInfo{{FilePath: "s3://bucket/path/a.parquet", NumRows: 10}}, "manifest-path", nil).
|
|
Build()
|
|
defer mockExplore.UnPatch()
|
|
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1001,
|
|
CollectionId: collectionID,
|
|
CollectionName: "ext",
|
|
ExternalSource: "s3://bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
State: indexpb.JobState_JobStateInit,
|
|
}
|
|
assert.NoError(t, refreshMeta.AddJob(job))
|
|
|
|
tasks, err := mgr.createTasksForJob(ctx, job)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, tasks, 1)
|
|
assert.Equal(t, collectionID, tasks[0].GetCollectionId())
|
|
assert.Equal(t, "s3://bucket/path", tasks[0].GetExternalSource())
|
|
assert.Equal(t, `{"format":"parquet"}`, tasks[0].GetExternalSpec())
|
|
}
|
|
|
|
func TestExternalCollectionRefreshManager_ApplyFinishedJobSegmentsMergesTaskResults(t *testing.T) {
|
|
ctx := context.Background()
|
|
catalog := &stubCatalog{}
|
|
refreshMeta, err := newExternalCollectionRefreshMeta(ctx, catalog)
|
|
assert.NoError(t, err)
|
|
|
|
assert.NoError(t, refreshMeta.AddTask(&datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1001,
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateFinished,
|
|
ResultReady: true,
|
|
KeptSegments: []int64{1},
|
|
UpdatedSegments: []*datapb.SegmentInfo{newTestExternalRefreshSegment(10, 100, 7)},
|
|
}))
|
|
assert.NoError(t, refreshMeta.AddTask(&datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1002,
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateFinished,
|
|
ResultReady: true,
|
|
KeptSegments: []int64{1},
|
|
UpdatedSegments: []*datapb.SegmentInfo{newTestExternalRefreshSegment(20, 100, 7)},
|
|
}))
|
|
|
|
segments := NewSegmentsInfo()
|
|
segments.SetSegment(1, &SegmentInfo{SegmentInfo: &datapb.SegmentInfo{
|
|
ID: 1,
|
|
CollectionID: 100,
|
|
State: commonpb.SegmentState_Flushed,
|
|
NumOfRows: 5,
|
|
}})
|
|
segments.SetSegment(2, &SegmentInfo{SegmentInfo: &datapb.SegmentInfo{
|
|
ID: 2,
|
|
CollectionID: 100,
|
|
State: commonpb.SegmentState_Flushed,
|
|
NumOfRows: 6,
|
|
}})
|
|
mt := &meta{
|
|
catalog: catalog,
|
|
segments: segments,
|
|
collections: newTestCollections(100),
|
|
}
|
|
mgr := &externalCollectionRefreshManager{
|
|
mt: mt,
|
|
refreshMeta: refreshMeta,
|
|
}
|
|
|
|
err = mgr.applyFinishedJobSegments(ctx, &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
assert.Equal(t, commonpb.SegmentState_Flushed, mt.segments.GetSegment(1).GetState())
|
|
assert.Equal(t, commonpb.SegmentState_Dropped, mt.segments.GetSegment(2).GetState())
|
|
assert.Equal(t, commonpb.SegmentState_Flushed, mt.segments.GetSegment(10).GetState())
|
|
assert.Equal(t, commonpb.SegmentState_Flushed, mt.segments.GetSegment(20).GetState())
|
|
assert.Equal(t, int64(7), mt.segments.GetSegment(10).GetNumOfRows())
|
|
assert.Equal(t, int64(7), mt.segments.GetSegment(20).GetNumOfRows())
|
|
}
|
|
|
|
func TestExternalCollectionRefreshManager_ApplyFinishedJobSegmentsRejectsNonFinishedTask(t *testing.T) {
|
|
ctx := context.Background()
|
|
catalog := &stubCatalog{}
|
|
refreshMeta, err := newExternalCollectionRefreshMeta(ctx, catalog)
|
|
assert.NoError(t, err)
|
|
|
|
assert.NoError(t, refreshMeta.AddTask(&datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1001,
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateFinished,
|
|
ResultReady: true,
|
|
UpdatedSegments: []*datapb.SegmentInfo{{ID: 10, CollectionID: 100, NumOfRows: 7}},
|
|
}))
|
|
assert.NoError(t, refreshMeta.AddTask(&datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1002,
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateInProgress,
|
|
}))
|
|
|
|
mt := &meta{
|
|
catalog: catalog,
|
|
segments: NewSegmentsInfo(),
|
|
collections: newTestCollections(100),
|
|
}
|
|
updateCalls := 0
|
|
mockUpdate := mockey.Mock((*meta).UpdateSegmentsInfo).To(func(_ *meta, _ context.Context, _ ...UpdateOperator) error {
|
|
updateCalls++
|
|
return nil
|
|
}).Build()
|
|
defer mockUpdate.UnPatch()
|
|
|
|
mgr := &externalCollectionRefreshManager{
|
|
mt: mt,
|
|
refreshMeta: refreshMeta,
|
|
}
|
|
|
|
err = mgr.applyFinishedJobSegments(ctx, &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "non-finished task")
|
|
assert.Equal(t, 0, updateCalls)
|
|
}
|
|
|
|
func TestExternalCollectionRefreshManager_ApplyFinishedJobSegmentsRejectsDuplicateUpdatedSegment(t *testing.T) {
|
|
ctx := context.Background()
|
|
catalog := &stubCatalog{}
|
|
refreshMeta, err := newExternalCollectionRefreshMeta(ctx, catalog)
|
|
assert.NoError(t, err)
|
|
|
|
assert.NoError(t, refreshMeta.AddTask(&datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1001,
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateFinished,
|
|
ResultReady: true,
|
|
UpdatedSegments: []*datapb.SegmentInfo{{ID: 10, CollectionID: 100, NumOfRows: 7}},
|
|
}))
|
|
assert.NoError(t, refreshMeta.AddTask(&datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1002,
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateFinished,
|
|
ResultReady: true,
|
|
UpdatedSegments: []*datapb.SegmentInfo{{ID: 10, CollectionID: 100, NumOfRows: 8}},
|
|
}))
|
|
|
|
mt := &meta{
|
|
catalog: catalog,
|
|
segments: NewSegmentsInfo(),
|
|
collections: newTestCollections(100),
|
|
}
|
|
updateCalls := 0
|
|
mockUpdate := mockey.Mock((*meta).UpdateSegmentsInfo).To(func(_ *meta, _ context.Context, _ ...UpdateOperator) error {
|
|
updateCalls++
|
|
return nil
|
|
}).Build()
|
|
defer mockUpdate.UnPatch()
|
|
|
|
mgr := &externalCollectionRefreshManager{
|
|
mt: mt,
|
|
refreshMeta: refreshMeta,
|
|
}
|
|
|
|
err = mgr.applyFinishedJobSegments(ctx, &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "duplicate updated segment")
|
|
assert.Equal(t, 0, updateCalls)
|
|
}
|
|
|
|
func TestExternalCollectionRefreshManager_ApplyFinishedJobSegmentsRejectsMissingTaskResult(t *testing.T) {
|
|
ctx := context.Background()
|
|
catalog := &stubCatalog{}
|
|
refreshMeta, err := newExternalCollectionRefreshMeta(ctx, catalog)
|
|
assert.NoError(t, err)
|
|
|
|
assert.NoError(t, refreshMeta.AddTask(&datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1001,
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateFinished,
|
|
}))
|
|
assert.NoError(t, refreshMeta.AddTask(&datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1002,
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateFinished,
|
|
ResultReady: true,
|
|
KeptSegments: []int64{1},
|
|
UpdatedSegments: []*datapb.SegmentInfo{{ID: 20, CollectionID: 100, NumOfRows: 7}},
|
|
}))
|
|
|
|
mt := &meta{
|
|
catalog: catalog,
|
|
segments: NewSegmentsInfo(),
|
|
collections: newTestCollections(100),
|
|
}
|
|
updateCalls := 0
|
|
mockUpdate := mockey.Mock((*meta).UpdateSegmentsInfo).To(func(_ *meta, _ context.Context, _ ...UpdateOperator) error {
|
|
updateCalls++
|
|
return nil
|
|
}).Build()
|
|
defer mockUpdate.UnPatch()
|
|
|
|
mgr := &externalCollectionRefreshManager{
|
|
mt: mt,
|
|
refreshMeta: refreshMeta,
|
|
}
|
|
|
|
err = mgr.applyFinishedJobSegments(ctx, &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "without persisted refresh result")
|
|
assert.Equal(t, 0, updateCalls)
|
|
}
|
|
|
|
func TestValidateMilvusTableRefreshSchemaErrorClass(t *testing.T) {
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
ExternalSource: "s3://bucket/snapshots/1/metadata/10.json",
|
|
ExternalSpec: testMilvusTableTargetRefreshSchema().GetExternalSpec(),
|
|
}
|
|
|
|
t.Run("metadata_read_error_is_retryable", func(t *testing.T) {
|
|
readErr := errors.New("temporary metadata read failure")
|
|
mockRead := mockey.Mock(packed.ReadMilvusTableSnapshotMetadata).
|
|
Return(nil, readErr).Build()
|
|
defer mockRead.UnPatch()
|
|
|
|
err := validateMilvusTableRefreshSchema(job, testMilvusTableTargetRefreshSchema())
|
|
assert.Error(t, err)
|
|
assert.False(t, errors.Is(err, errMilvusTableRefreshSchemaInvalid))
|
|
assert.ErrorIs(t, err, readErr)
|
|
})
|
|
|
|
t.Run("missing_source_schema_is_non_retriable", func(t *testing.T) {
|
|
mockRead := mockey.Mock(packed.ReadMilvusTableSnapshotMetadata).
|
|
Return(&datapb.SnapshotMetadata{
|
|
Collection: &datapb.CollectionDescription{},
|
|
}, nil).Build()
|
|
defer mockRead.UnPatch()
|
|
|
|
err := validateMilvusTableRefreshSchema(job, testMilvusTableTargetRefreshSchema())
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, errMilvusTableRefreshSchemaInvalid)
|
|
assert.Contains(t, err.Error(), "missing collection schema")
|
|
})
|
|
|
|
t.Run("external_source_schema_is_non_retriable", func(t *testing.T) {
|
|
mockRead := mockey.Mock(packed.ReadMilvusTableSnapshotMetadata).
|
|
Return(&datapb.SnapshotMetadata{
|
|
Collection: &datapb.CollectionDescription{
|
|
Schema: testMilvusTableRefreshSchema(true),
|
|
},
|
|
}, nil).Build()
|
|
defer mockRead.UnPatch()
|
|
|
|
err := validateMilvusTableRefreshSchema(job, testMilvusTableTargetRefreshSchema())
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, errMilvusTableRefreshSchemaInvalid)
|
|
assert.Contains(t, err.Error(), "source snapshot is an external collection")
|
|
})
|
|
|
|
t.Run("schema_mismatch_is_non_retriable", func(t *testing.T) {
|
|
sourceSchema := testMilvusTableRefreshSchema(false)
|
|
sourceSchema.Fields[0].FieldID = 101
|
|
mockRead := mockey.Mock(packed.ReadMilvusTableSnapshotMetadata).
|
|
Return(&datapb.SnapshotMetadata{
|
|
Collection: &datapb.CollectionDescription{
|
|
Schema: sourceSchema,
|
|
},
|
|
}, nil).Build()
|
|
defer mockRead.UnPatch()
|
|
|
|
err := validateMilvusTableRefreshSchema(job, testMilvusTableTargetRefreshSchema())
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, errMilvusTableRefreshSchemaInvalid)
|
|
assert.Contains(t, err.Error(), "source schema does not match target collection schema")
|
|
})
|
|
|
|
t.Run("matching_schema_passes", func(t *testing.T) {
|
|
mockRead := mockey.Mock(packed.ReadMilvusTableSnapshotMetadata).
|
|
Return(&datapb.SnapshotMetadata{
|
|
Collection: &datapb.CollectionDescription{
|
|
Schema: testMilvusTableRefreshSchema(false),
|
|
},
|
|
}, nil).Build()
|
|
defer mockRead.UnPatch()
|
|
|
|
err := validateMilvusTableRefreshSchema(job, testMilvusTableTargetRefreshSchema())
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
// ==================== Test Functions ====================
|
|
|
|
func TestExternalCollectionRefreshManager_NewManager(t *testing.T) {
|
|
ctx := context.Background()
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
assert.NotNil(t, manager)
|
|
}
|
|
|
|
func TestExternalCollectionRefreshManager_StartStop(t *testing.T) {
|
|
ctx := context.Background()
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
|
|
// Mock inspector and checker run methods to avoid actual execution
|
|
mockInspectorRun := mockey.Mock((*externalCollectionRefreshInspector).run).Return().Build()
|
|
defer mockInspectorRun.UnPatch()
|
|
|
|
mockCheckerRun := mockey.Mock((*externalCollectionRefreshChecker).run).Return().Build()
|
|
defer mockCheckerRun.UnPatch()
|
|
|
|
// Start should not panic
|
|
manager.Start()
|
|
|
|
// Stop should not panic and should be idempotent
|
|
manager.Stop()
|
|
manager.Stop() // Call again to verify idempotency
|
|
}
|
|
|
|
func TestExternalCollectionRefreshManager_SubmitRefreshJobWithID(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("success", func(t *testing.T) {
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{nextID: 1000}
|
|
scheduler := newStubScheduler()
|
|
|
|
// Create a mock meta with external collection. ExternalSpec must be
|
|
// valid JSON now that createTasksForJob → exploreExternalFiles parses
|
|
// it via externalspec.ParseExternalSpec (added in Part 8 cross-bucket).
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "s3://bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
// Mock IsExternalCollection to return true
|
|
mockIsExternal := mockey.Mock(typeutil.IsExternalCollection).Return(true).Build()
|
|
defer mockIsExternal.UnPatch()
|
|
|
|
// Mock exploreExternalFiles so the test does not need real S3 + parquet.
|
|
// Returns one file so createTasksForJob produces a single task chunk.
|
|
mockExplore := mockey.Mock((*externalCollectionRefreshManager).exploreExternalFiles).
|
|
Return([]*datapb.ExternalFileInfo{{FilePath: "s3://bucket/path/file.parquet", NumRows: 100}}, "s3://bucket/path/manifest", nil).Build()
|
|
defer mockExplore.UnPatch()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), nil, nil)
|
|
|
|
jobID, err := manager.SubmitRefreshJobWithID(ctx, 1, 100, "test_collection", "", "")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(1), jobID)
|
|
|
|
// Verify job was created
|
|
job := refreshMeta.GetJob(1)
|
|
assert.NotNil(t, job)
|
|
assert.Equal(t, int64(1), job.GetJobId())
|
|
assert.Equal(t, int64(100), job.GetCollectionId())
|
|
})
|
|
|
|
t.Run("idempotent_job_exists", func(t *testing.T) {
|
|
now := time.Now().UnixMilli()
|
|
existingJob := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateInProgress,
|
|
StartTime: now,
|
|
}
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, []*datapb.ExternalCollectionRefreshJob{existingJob}, nil)
|
|
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
|
|
// Should return without error if job already exists
|
|
jobID, err := manager.SubmitRefreshJobWithID(ctx, 1, 100, "test_collection", "", "")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(1), jobID)
|
|
})
|
|
|
|
t.Run("collection_not_found", func(t *testing.T) {
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
// Empty meta, no collections
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
mt := &meta{collections: collections}
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), nil, nil)
|
|
|
|
_, err := manager.SubmitRefreshJobWithID(ctx, 1, 999, "test_collection", "", "")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not found")
|
|
})
|
|
|
|
t.Run("not_external_collection", func(t *testing.T) {
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
// Create a mock meta with non-external collection
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "", // Not external
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
// Mock typeutil.IsExternalCollection to return false
|
|
mockIsExternal := mockey.Mock(typeutil.IsExternalCollection).Return(false).Build()
|
|
defer mockIsExternal.UnPatch()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), nil, nil)
|
|
|
|
_, err := manager.SubmitRefreshJobWithID(ctx, 1, 100, "test_collection", "", "")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not an external collection")
|
|
})
|
|
|
|
t.Run("async_task_creation_failure_leaves_job_in_init", func(t *testing.T) {
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
|
|
// Mock AllocID to return error — triggers createTasksForJob failure
|
|
// in the async Phase B goroutine.
|
|
mockAllocID := mockey.Mock((*stubAllocator).AllocID).Return(int64(0), errors.New("alloc failed")).Build()
|
|
defer mockAllocID.UnPatch()
|
|
|
|
scheduler := newStubScheduler()
|
|
|
|
// Create a mock meta with external collection
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "s3://bucket/path",
|
|
ExternalSpec: "iceberg",
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
// Mock IsExternalCollection to return true
|
|
mockIsExternal := mockey.Mock(typeutil.IsExternalCollection).Return(true).Build()
|
|
defer mockIsExternal.UnPatch()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), nil, nil)
|
|
|
|
// Phase A persists the Init job and returns success. Phase B runs
|
|
// in the background; its failure (AllocID error here) is logged
|
|
// but does NOT unwind the job — that's the whole point of the
|
|
// two-phase split. The job lingers in Init until the checker tick
|
|
// retries or tryTimeoutJob marks it Failed. Stop() waits for the
|
|
// background goroutine to complete so the assertion is stable.
|
|
_, err := manager.SubmitRefreshJobWithID(ctx, 1, 100, "test_collection", "", "")
|
|
assert.NoError(t, err)
|
|
|
|
manager.Stop()
|
|
|
|
job := refreshMeta.GetJob(1)
|
|
assert.NotNil(t, job, "job should remain in Init state for retry")
|
|
assert.Equal(t, indexpb.JobState_JobStateInit, job.GetState())
|
|
assert.Empty(t, job.GetTaskIds(), "no tasks should be persisted after async failure")
|
|
})
|
|
|
|
t.Run("ffi_explore_error_marks_job_failed_non_retriable", func(t *testing.T) {
|
|
// Regression for #49233: any FFI failure during explore (NoSuchBucket,
|
|
// AccessDenied, DNS NXDOMAIN, malformed URI, ...) is wrapped by the
|
|
// loon FFI layer as ErrLoonTransient. Without classification this
|
|
// looped forever as RefreshPending. Treat all FFI explore failures
|
|
// as terminal so the job transitions to RefreshFailed and the user
|
|
// gets a clear signal.
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{nextID: 1000}
|
|
scheduler := newStubScheduler()
|
|
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "s3://bucket-does-not-exist/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
mockIsExternal := mockey.Mock(typeutil.IsExternalCollection).Return(true).Build()
|
|
defer mockIsExternal.UnPatch()
|
|
|
|
ffiErr := errors.Wrap(packed.ErrLoonTransient, "FFI operation failed: AWS Error NO_SUCH_BUCKET during ListObjectsV2")
|
|
mockExplore := mockey.Mock((*externalCollectionRefreshManager).exploreExternalFiles).
|
|
Return(nil, "", ffiErr).Build()
|
|
defer mockExplore.UnPatch()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), nil, nil)
|
|
|
|
_, err := manager.SubmitRefreshJobWithID(ctx, 1, 100, "test_collection", "", "")
|
|
assert.NoError(t, err)
|
|
|
|
manager.Stop()
|
|
|
|
job := refreshMeta.GetJob(1)
|
|
assert.NotNil(t, job)
|
|
assert.Equal(t, indexpb.JobState_JobStateFailed, job.GetState(),
|
|
"FFI explore failure must transition job to Failed, not loop in Init")
|
|
assert.Contains(t, job.GetFailReason(), "explore external files failed")
|
|
assert.Contains(t, job.GetFailReason(), "NO_SUCH_BUCKET",
|
|
"underlying error must be surfaced to operators")
|
|
})
|
|
|
|
t.Run("milvus_table_schema_error_marks_job_failed_non_retriable", func(t *testing.T) {
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{nextID: 1000}
|
|
scheduler := newStubScheduler()
|
|
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: testMilvusTableTargetRefreshSchema(),
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
mockRead := mockey.Mock(packed.ReadMilvusTableSnapshotMetadata).
|
|
Return(&datapb.SnapshotMetadata{
|
|
Collection: &datapb.CollectionDescription{},
|
|
}, nil).Build()
|
|
defer mockRead.UnPatch()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), nil, nil)
|
|
|
|
_, err := manager.SubmitRefreshJobWithID(ctx, 1, 100, "test_collection", "", "")
|
|
assert.NoError(t, err)
|
|
|
|
manager.Stop()
|
|
|
|
job := refreshMeta.GetJob(1)
|
|
assert.NotNil(t, job)
|
|
assert.Equal(t, indexpb.JobState_JobStateFailed, job.GetState(),
|
|
"schema validation failure must transition job to Failed, not loop in Init")
|
|
assert.Contains(t, job.GetFailReason(), "explore external files failed")
|
|
assert.Contains(t, job.GetFailReason(), "milvus-table refresh schema invalid")
|
|
})
|
|
|
|
t.Run("reject_when_active_job_exists", func(t *testing.T) {
|
|
now := time.Now().UnixMilli()
|
|
existingJob := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateInProgress,
|
|
StartTime: now,
|
|
}
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, []*datapb.ExternalCollectionRefreshJob{existingJob}, nil)
|
|
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
// Create a mock meta with external collection
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "s3://bucket/path",
|
|
ExternalSpec: "iceberg",
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
// Mock IsExternalCollection to return true
|
|
mockIsExternal := mockey.Mock(typeutil.IsExternalCollection).Return(true).Build()
|
|
defer mockIsExternal.UnPatch()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), nil, nil)
|
|
|
|
// Submit a new job with different ID should fail
|
|
_, err := manager.SubmitRefreshJobWithID(ctx, 2, 100, "test_collection", "", "")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "already in progress")
|
|
|
|
// Verify old job was NOT changed
|
|
oldJob := refreshMeta.GetJob(1)
|
|
assert.NotNil(t, oldJob)
|
|
assert.Equal(t, indexpb.JobState_JobStateInProgress, oldJob.GetState())
|
|
})
|
|
}
|
|
|
|
func TestExternalCollectionRefreshManager_GetJobProgress(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("job_exists", func(t *testing.T) {
|
|
now := time.Now().UnixMilli()
|
|
existingJob := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateInit,
|
|
StartTime: now,
|
|
}
|
|
existingTask := &datapb.ExternalCollectionRefreshTask{
|
|
TaskId: 1001,
|
|
JobId: 1,
|
|
State: indexpb.JobState_JobStateInProgress,
|
|
Progress: 50,
|
|
}
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, []*datapb.ExternalCollectionRefreshJob{existingJob}, []*datapb.ExternalCollectionRefreshTask{existingTask})
|
|
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
|
|
job, err := manager.GetJobProgress(ctx, 1)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, job)
|
|
assert.Equal(t, int64(1), job.GetJobId())
|
|
// State should be aggregated from tasks
|
|
assert.Equal(t, indexpb.JobState_JobStateInProgress, job.GetState())
|
|
assert.Equal(t, int64(50), job.GetProgress())
|
|
})
|
|
|
|
t.Run("job_exists_no_tasks_keeps_persisted_state", func(t *testing.T) {
|
|
now := time.Now().UnixMilli()
|
|
existingJob := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateInit,
|
|
StartTime: now,
|
|
}
|
|
// No tasks for this job
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, []*datapb.ExternalCollectionRefreshJob{existingJob}, nil)
|
|
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
|
|
job, err := manager.GetJobProgress(ctx, 1)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, job)
|
|
// When no tasks exist, should keep the persisted state (Init), not overwrite to None
|
|
assert.Equal(t, indexpb.JobState_JobStateInit, job.GetState())
|
|
})
|
|
|
|
t.Run("finished_tasks_do_not_expose_finished_before_job_persisted", func(t *testing.T) {
|
|
existingJob := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateInProgress,
|
|
Progress: 80,
|
|
}
|
|
tasks := []*datapb.ExternalCollectionRefreshTask{
|
|
{TaskId: 1001, JobId: 1, State: indexpb.JobState_JobStateFinished, Progress: 100},
|
|
{TaskId: 1002, JobId: 1, State: indexpb.JobState_JobStateFinished, Progress: 100},
|
|
}
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, []*datapb.ExternalCollectionRefreshJob{existingJob}, tasks)
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, newStubScheduler(), &stubAllocator{}, refreshMeta, nil, nil, nil, nil)
|
|
|
|
job, err := manager.GetJobProgress(ctx, 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, indexpb.JobState_JobStateInProgress, job.GetState())
|
|
assert.Equal(t, int64(99), job.GetProgress())
|
|
})
|
|
|
|
t.Run("persisted_failed_job_not_overwritten_by_finished_tasks", func(t *testing.T) {
|
|
existingJob := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
State: indexpb.JobState_JobStateFailed,
|
|
Progress: 80,
|
|
FailReason: "apply failed",
|
|
}
|
|
tasks := []*datapb.ExternalCollectionRefreshTask{
|
|
{TaskId: 1001, JobId: 1, State: indexpb.JobState_JobStateFinished, Progress: 100},
|
|
{TaskId: 1002, JobId: 1, State: indexpb.JobState_JobStateFinished, Progress: 100},
|
|
}
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, []*datapb.ExternalCollectionRefreshJob{existingJob}, tasks)
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, newStubScheduler(), &stubAllocator{}, refreshMeta, nil, nil, nil, nil)
|
|
|
|
job, err := manager.GetJobProgress(ctx, 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, indexpb.JobState_JobStateFailed, job.GetState())
|
|
assert.Equal(t, int64(80), job.GetProgress())
|
|
assert.Equal(t, "apply failed", job.GetFailReason())
|
|
})
|
|
|
|
t.Run("job_not_found", func(t *testing.T) {
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
|
|
_, err := manager.GetJobProgress(ctx, 999)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not found")
|
|
})
|
|
}
|
|
|
|
func TestExternalCollectionRefreshManager_ListJobs(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("has_jobs", func(t *testing.T) {
|
|
now := time.Now().UnixMilli()
|
|
jobs := []*datapb.ExternalCollectionRefreshJob{
|
|
{JobId: 1, CollectionId: 100, State: indexpb.JobState_JobStateInit, StartTime: now - 2000},
|
|
{JobId: 2, CollectionId: 100, State: indexpb.JobState_JobStateInProgress, StartTime: now - 1000},
|
|
{JobId: 3, CollectionId: 100, State: indexpb.JobState_JobStateFinished, StartTime: now},
|
|
}
|
|
tasks := []*datapb.ExternalCollectionRefreshTask{
|
|
{TaskId: 1001, JobId: 1, State: indexpb.JobState_JobStateFinished, Progress: 100},
|
|
{TaskId: 2001, JobId: 2, State: indexpb.JobState_JobStateInProgress, Progress: 50},
|
|
{TaskId: 3001, JobId: 3, State: indexpb.JobState_JobStateFinished, Progress: 100},
|
|
}
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, jobs, tasks)
|
|
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
|
|
result, err := manager.ListJobs(ctx, 100)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, result, 3)
|
|
// Should be sorted by StartTime descending
|
|
assert.Equal(t, int64(3), result[0].GetJobId())
|
|
assert.Equal(t, int64(2), result[1].GetJobId())
|
|
assert.Equal(t, int64(1), result[2].GetJobId())
|
|
})
|
|
|
|
t.Run("jobs_without_tasks_keep_persisted_state", func(t *testing.T) {
|
|
now := time.Now().UnixMilli()
|
|
jobs := []*datapb.ExternalCollectionRefreshJob{
|
|
{JobId: 1, CollectionId: 100, State: indexpb.JobState_JobStateInit, StartTime: now},
|
|
}
|
|
// No tasks
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, jobs, nil)
|
|
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
|
|
result, err := manager.ListJobs(ctx, 100)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, result, 1)
|
|
// When no tasks exist, should keep the persisted state (Init), not overwrite to None
|
|
assert.Equal(t, indexpb.JobState_JobStateInit, result[0].GetState())
|
|
})
|
|
|
|
t.Run("finished_tasks_do_not_expose_finished_before_job_persisted", func(t *testing.T) {
|
|
now := time.Now().UnixMilli()
|
|
jobs := []*datapb.ExternalCollectionRefreshJob{
|
|
{JobId: 1, CollectionId: 100, State: indexpb.JobState_JobStateInProgress, Progress: 80, StartTime: now},
|
|
}
|
|
tasks := []*datapb.ExternalCollectionRefreshTask{
|
|
{TaskId: 1001, JobId: 1, State: indexpb.JobState_JobStateFinished, Progress: 100},
|
|
{TaskId: 1002, JobId: 1, State: indexpb.JobState_JobStateFinished, Progress: 100},
|
|
}
|
|
refreshMeta := createTestRefreshMetaWithJobs(t, jobs, tasks)
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, newStubScheduler(), &stubAllocator{}, refreshMeta, nil, nil, nil, nil)
|
|
|
|
result, err := manager.ListJobs(ctx, 100)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, result, 1)
|
|
assert.Equal(t, indexpb.JobState_JobStateInProgress, result[0].GetState())
|
|
assert.Equal(t, int64(99), result[0].GetProgress())
|
|
})
|
|
|
|
t.Run("empty_list", func(t *testing.T) {
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
manager := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
|
|
result, err := manager.ListJobs(ctx, 100)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, result, 0)
|
|
})
|
|
}
|
|
|
|
func TestHandleJobFinished_SchemaChanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Setup: collection with source="s3://old", job with source="s3://new"
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "s3://old-bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
// Track schemaUpdater calls
|
|
updaterCalled := false
|
|
var updatedCollID int64
|
|
var updatedSource, updatedSpec string
|
|
schemaUpdater := func(_ context.Context, collectionID int64, source, spec string) error {
|
|
updaterCalled = true
|
|
updatedCollID = collectionID
|
|
updatedSource = source
|
|
updatedSpec = spec
|
|
return nil
|
|
}
|
|
|
|
mgr := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), schemaUpdater, nil)
|
|
concreteManager := mgr.(*externalCollectionRefreshManager)
|
|
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
ExternalSource: "s3://new-bucket/path",
|
|
ExternalSpec: `{"format":"parquet","version":2}`,
|
|
}
|
|
concreteManager.handleJobFinished(ctx, job)
|
|
|
|
assert.True(t, updaterCalled, "schemaUpdater should be called when source/spec changed")
|
|
assert.Equal(t, int64(100), updatedCollID)
|
|
assert.Equal(t, "s3://new-bucket/path", updatedSource)
|
|
assert.Equal(t, `{"format":"parquet","version":2}`, updatedSpec)
|
|
}
|
|
|
|
func TestHandleJobFinished_SchemaUnchanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Setup: collection with same source/spec as job
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "s3://same-bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
updaterCalled := false
|
|
schemaUpdater := func(_ context.Context, _ int64, _, _ string) error {
|
|
updaterCalled = true
|
|
return nil
|
|
}
|
|
|
|
mgr := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), schemaUpdater, nil)
|
|
concreteManager := mgr.(*externalCollectionRefreshManager)
|
|
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
ExternalSource: "s3://same-bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
}
|
|
concreteManager.handleJobFinished(ctx, job)
|
|
|
|
assert.False(t, updaterCalled, "schemaUpdater should NOT be called when source/spec unchanged")
|
|
}
|
|
|
|
func TestHandleJobFinished_NilSchemaUpdater(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
// Create manager with nil schemaUpdater
|
|
mgr := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, nil)
|
|
concreteManager := mgr.(*externalCollectionRefreshManager)
|
|
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
ExternalSource: "s3://new-bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
}
|
|
|
|
// Should not panic with nil schemaUpdater
|
|
assert.NotPanics(t, func() {
|
|
concreteManager.handleJobFinished(ctx, job)
|
|
})
|
|
}
|
|
|
|
func TestHandleJobFinished_CollectionNotFound(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Empty collections - collection not found
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
mt := &meta{collections: collections}
|
|
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
updaterCalled := false
|
|
schemaUpdater := func(_ context.Context, _ int64, _, _ string) error {
|
|
updaterCalled = true
|
|
return nil
|
|
}
|
|
|
|
mgr := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), schemaUpdater, nil)
|
|
concreteManager := mgr.(*externalCollectionRefreshManager)
|
|
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 999, // Does not exist
|
|
ExternalSource: "s3://new-bucket/path",
|
|
}
|
|
|
|
// Should not panic, should return silently
|
|
assert.NotPanics(t, func() {
|
|
concreteManager.handleJobFinished(ctx, job)
|
|
})
|
|
assert.False(t, updaterCalled, "schemaUpdater should NOT be called when collection not found")
|
|
}
|
|
|
|
func TestHandleJobFinished_SchemaUpdaterError(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "s3://old-bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
// schemaUpdater returns error
|
|
schemaUpdater := func(_ context.Context, _ int64, _, _ string) error {
|
|
return errors.New("WAL broadcast failed")
|
|
}
|
|
|
|
mgr := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), schemaUpdater, nil)
|
|
concreteManager := mgr.(*externalCollectionRefreshManager)
|
|
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
ExternalSource: "s3://new-bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
}
|
|
|
|
// Should not panic even if schemaUpdater returns error
|
|
assert.NotPanics(t, func() {
|
|
concreteManager.handleJobFinished(ctx, job)
|
|
})
|
|
}
|
|
|
|
func TestHandleJobFinished_SourceChangedOnly(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(100, &collectionInfo{
|
|
ID: 100,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "test_collection",
|
|
ExternalSource: "s3://old-bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
|
|
updaterCalled := false
|
|
schemaUpdater := func(_ context.Context, _ int64, _, _ string) error {
|
|
updaterCalled = true
|
|
return nil
|
|
}
|
|
|
|
mgr := NewExternalCollectionRefreshManager(ctx, mt, scheduler, alloc, refreshMeta, nil, testCollectionGetter(mt), schemaUpdater, nil)
|
|
concreteManager := mgr.(*externalCollectionRefreshManager)
|
|
|
|
// Only source changed, spec unchanged
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 1,
|
|
CollectionId: 100,
|
|
ExternalSource: "s3://new-bucket/path",
|
|
ExternalSpec: `{"format":"parquet"}`, // Same as collection
|
|
}
|
|
concreteManager.handleJobFinished(ctx, job)
|
|
|
|
assert.True(t, updaterCalled, "schemaUpdater should be called when only source changed")
|
|
}
|
|
|
|
// ==================== cleanupExploreTempForJob Tests ====================
|
|
|
|
// recordingChunkManager captures the prefixes passed to RemoveWithPrefix /
|
|
// Remove, and lets each call optionally return a configured error. Used by
|
|
// the cleanup-path unit tests without pulling in mockey for interface mocks
|
|
// (the manager holds a plain storage.ChunkManager field which this satisfies
|
|
// directly via method embedding).
|
|
type recordingChunkManager struct {
|
|
storage.ChunkManager
|
|
mu sync.Mutex
|
|
prefixCalls []string
|
|
removeCalls []string
|
|
prefixErr error
|
|
removeErr error
|
|
prefixBlockUntil <-chan struct{}
|
|
}
|
|
|
|
func (r *recordingChunkManager) RemoveWithPrefix(ctx context.Context, prefix string) error {
|
|
if r.prefixBlockUntil != nil {
|
|
select {
|
|
case <-r.prefixBlockUntil:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
r.mu.Lock()
|
|
r.prefixCalls = append(r.prefixCalls, prefix)
|
|
r.mu.Unlock()
|
|
return r.prefixErr
|
|
}
|
|
|
|
func (r *recordingChunkManager) Remove(ctx context.Context, key string) error {
|
|
r.mu.Lock()
|
|
r.removeCalls = append(r.removeCalls, key)
|
|
r.mu.Unlock()
|
|
return r.removeErr
|
|
}
|
|
|
|
func (r *recordingChunkManager) snapshot() (prefixes, removes []string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.prefixCalls...), append([]string(nil), r.removeCalls...)
|
|
}
|
|
|
|
func newManagerWithChunkManager(t *testing.T, cm storage.ChunkManager) *externalCollectionRefreshManager {
|
|
ctx := context.Background()
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
mgr := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, cm)
|
|
return mgr.(*externalCollectionRefreshManager)
|
|
}
|
|
|
|
func TestCleanupExploreTempForJob_Success(t *testing.T) {
|
|
cm := &recordingChunkManager{}
|
|
mgr := newManagerWithChunkManager(t, cm)
|
|
|
|
mgr.cleanupExploreTempForJob(42)
|
|
|
|
prefixes, removes := cm.snapshot()
|
|
assert.Equal(t, []string{"__explore_temp__/coord_42"}, prefixes)
|
|
assert.Equal(t, []string{"__explore_temp__/coord_42"}, removes)
|
|
}
|
|
|
|
func TestCleanupExploreTempForJob_NilChunkManager(t *testing.T) {
|
|
mgr := newManagerWithChunkManager(t, nil)
|
|
|
|
// Nil chunkManager path must be safe and a no-op.
|
|
assert.NotPanics(t, func() {
|
|
mgr.cleanupExploreTempForJob(99)
|
|
})
|
|
}
|
|
|
|
func TestCleanupExploreTempForJob_RemoveWithPrefixError(t *testing.T) {
|
|
cm := &recordingChunkManager{prefixErr: errors.New("prefix walk failed")}
|
|
mgr := newManagerWithChunkManager(t, cm)
|
|
|
|
// Errors must be logged and swallowed; Remove should still be called as
|
|
// the second pass (local-FS cleanup of the dir marker).
|
|
assert.NotPanics(t, func() {
|
|
mgr.cleanupExploreTempForJob(7)
|
|
})
|
|
prefixes, removes := cm.snapshot()
|
|
assert.Equal(t, []string{"__explore_temp__/coord_7"}, prefixes)
|
|
assert.Equal(t, []string{"__explore_temp__/coord_7"}, removes)
|
|
}
|
|
|
|
func TestCleanupExploreTempForJob_RemoveError(t *testing.T) {
|
|
cm := &recordingChunkManager{removeErr: errors.New("delete failed")}
|
|
mgr := newManagerWithChunkManager(t, cm)
|
|
|
|
assert.NotPanics(t, func() {
|
|
mgr.cleanupExploreTempForJob(8)
|
|
})
|
|
prefixes, removes := cm.snapshot()
|
|
assert.Equal(t, []string{"__explore_temp__/coord_8"}, prefixes)
|
|
assert.Equal(t, []string{"__explore_temp__/coord_8"}, removes)
|
|
}
|
|
|
|
func TestCleanupExploreTempForJob_RespectsManagerCtxCancel(t *testing.T) {
|
|
// Build a chunkManager that blocks inside RemoveWithPrefix until the
|
|
// manager ctx is canceled. If the cleanup derives its ctx from m.ctx
|
|
// (as intended by P2-7), cancellation must abort the call within the
|
|
// deadline instead of hanging for the 30s fallback timeout.
|
|
unblock := make(chan struct{})
|
|
cm := &recordingChunkManager{prefixBlockUntil: unblock}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
mgr := NewExternalCollectionRefreshManager(ctx, nil, scheduler, alloc, refreshMeta, nil, nil, nil, cm).(*externalCollectionRefreshManager)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
mgr.cleanupExploreTempForJob(123)
|
|
}()
|
|
|
|
// Cancel the manager ctx; RemoveWithPrefix should unblock via ctx.Done().
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
close(unblock)
|
|
t.Fatal("cleanupExploreTempForJob did not honor manager ctx cancellation")
|
|
}
|
|
close(unblock)
|
|
}
|
|
|
|
// ==================== handleJobFinished cleanup hook ====================
|
|
|
|
func TestHandleJobFinished_TriggersExploreTempCleanup(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Build a collection whose schema will change, so schemaUpdater is
|
|
// invoked and we exercise the full defer path.
|
|
collections := typeutil.NewConcurrentMap[UniqueID, *collectionInfo]()
|
|
collections.Insert(200, &collectionInfo{
|
|
ID: 200,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "coll",
|
|
ExternalSource: "s3://old",
|
|
ExternalSpec: `{"format":"parquet"}`,
|
|
},
|
|
})
|
|
mt := &meta{collections: collections}
|
|
|
|
cm := &recordingChunkManager{}
|
|
refreshMeta := createTestRefreshMeta(t)
|
|
alloc := &stubAllocator{}
|
|
scheduler := newStubScheduler()
|
|
schemaUpdater := func(_ context.Context, _ int64, _, _ string) error { return nil }
|
|
|
|
mgr := NewExternalCollectionRefreshManager(
|
|
ctx, mt, scheduler, alloc, refreshMeta, nil,
|
|
testCollectionGetter(mt), schemaUpdater, cm,
|
|
).(*externalCollectionRefreshManager)
|
|
|
|
job := &datapb.ExternalCollectionRefreshJob{
|
|
JobId: 555,
|
|
CollectionId: 200,
|
|
ExternalSource: "s3://new",
|
|
ExternalSpec: `{"format":"parquet","v":2}`,
|
|
}
|
|
mgr.handleJobFinished(ctx, job)
|
|
|
|
prefixes, removes := cm.snapshot()
|
|
assert.Equal(t, []string{"__explore_temp__/coord_555"}, prefixes, "Finished path must clean up the job-specific prefix")
|
|
assert.Equal(t, []string{"__explore_temp__/coord_555"}, removes)
|
|
|
|
// notifiedJobs must hold an entry so forgetJob later skips redundant cleanup.
|
|
mgr.notifiedMu.Lock()
|
|
_, present := mgr.notifiedJobs[555]
|
|
mgr.notifiedMu.Unlock()
|
|
assert.True(t, present, "handleJobFinished should mark jobID in notifiedJobs")
|
|
}
|
|
|
|
// ==================== handleJobFailed Tests ====================
|
|
|
|
func TestHandleJobFailed_TriggersCleanupAndDedups(t *testing.T) {
|
|
cm := &recordingChunkManager{}
|
|
mgr := newManagerWithChunkManager(t, cm)
|
|
|
|
mgr.handleJobFailed(777)
|
|
mgr.handleJobFailed(777) // second call must no-op via notifiedJobs dedup
|
|
|
|
prefixes, removes := cm.snapshot()
|
|
assert.Equal(t, []string{"__explore_temp__/coord_777"}, prefixes)
|
|
assert.Equal(t, []string{"__explore_temp__/coord_777"}, removes)
|
|
|
|
mgr.notifiedMu.Lock()
|
|
_, present := mgr.notifiedJobs[777]
|
|
mgr.notifiedMu.Unlock()
|
|
assert.True(t, present, "handleJobFailed should mark jobID in notifiedJobs")
|
|
}
|
|
|
|
// ==================== forgetJob Tests ====================
|
|
|
|
func TestForgetJob_SkipsCleanupWhenAlreadyHandled(t *testing.T) {
|
|
cm := &recordingChunkManager{}
|
|
mgr := newManagerWithChunkManager(t, cm)
|
|
|
|
// Simulate the Finished path having already cleaned the job.
|
|
mgr.notifiedMu.Lock()
|
|
mgr.notifiedJobs[321] = struct{}{}
|
|
mgr.notifiedMu.Unlock()
|
|
|
|
mgr.forgetJob(321)
|
|
|
|
prefixes, _ := cm.snapshot()
|
|
assert.Empty(t, prefixes, "forgetJob must skip cleanup when notifiedJobs entry is present")
|
|
|
|
mgr.notifiedMu.Lock()
|
|
_, stillPresent := mgr.notifiedJobs[321]
|
|
mgr.notifiedMu.Unlock()
|
|
assert.False(t, stillPresent, "forgetJob must still delete the dedup entry")
|
|
}
|
|
|
|
func TestForgetJob_CleansUpWhenNeverHandled(t *testing.T) {
|
|
cm := &recordingChunkManager{}
|
|
mgr := newManagerWithChunkManager(t, cm)
|
|
|
|
// Job never entered a terminal-state handler — forgetJob is the fallback
|
|
// path (e.g. crash between Failed transition and callback firing).
|
|
mgr.forgetJob(654)
|
|
|
|
prefixes, removes := cm.snapshot()
|
|
assert.Equal(t, []string{"__explore_temp__/coord_654"}, prefixes)
|
|
assert.Equal(t, []string{"__explore_temp__/coord_654"}, removes)
|
|
}
|
|
|
|
func TestForgetJob_NilChunkManagerSafe(t *testing.T) {
|
|
mgr := newManagerWithChunkManager(t, nil)
|
|
|
|
assert.NotPanics(t, func() {
|
|
mgr.forgetJob(1)
|
|
})
|
|
}
|
|
|
|
// ==================== dedup path exhaustiveness ====================
|
|
|
|
func TestCleanup_DoubleHandleJobFailedDoesNotDouble(t *testing.T) {
|
|
cm := &recordingChunkManager{}
|
|
mgr := newManagerWithChunkManager(t, cm)
|
|
|
|
mgr.handleJobFailed(111)
|
|
mgr.forgetJob(111) // checker GC path — must skip because handleJobFailed marked it
|
|
|
|
prefixes, _ := cm.snapshot()
|
|
if len(prefixes) != 1 {
|
|
t.Fatalf("expected exactly 1 prefix cleanup for Failed+GC flow, got %d: %v", len(prefixes), prefixes)
|
|
}
|
|
assert.Equal(t, fmt.Sprintf("__explore_temp__/coord_%d", 111), prefixes[0])
|
|
}
|