mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 10:15:43 +00:00
issue: #51253 > Rebased on master after #51254 merged. The `db_name`/`db_id` part of the original PR is now covered by #51254; this PR is reduced to the two remaining gaps on the same query-by-id path. ## Problem When `DescribeCollection` is called with **only a `collectionID`** — no collection name, no db name (e.g. the HTTP management API on `:9091`) — two gaps remain after #51254: 1. **Top-level `collection_name` is empty** on both the cached provider path and the `describeCollectionTask` path. The response is initialized from the (empty) `request.CollectionName` before the name is resolved, and never backfilled. 2. **The cached provider discards the caller-provided collection id.** After deriving the name from the id, it re-resolves the id from that name via `GetCollectionID(db, name)`. Id-only lookups are cached under the request db name — empty here — so with same-name collections in different databases, a concurrent cache refresh can rebind the entry under the `""` key and the request would silently describe the wrong collection (the derived name matches, the id does not). ## Fix - `service_provider.go`: - Backfill `resp.CollectionName` from the resolved cached schema when the request carried no name. Requests that pass a name (including aliases) still echo it unchanged. - Skip the name→id re-resolution when the caller already supplied the id. `GetCollectionInfo` already validates the cache entry against the id (`collInfo.collID != collectionID` → refresh by id), so the caller-provided id is authoritative end to end. Name+id requests keep the existing name-precedence behavior. - Resolve identifiers on local copies instead of rewriting `request.CollectionName`/`CollectionID` in place — the access log interceptor and the success metric labels serialize the request after the handler returns, and previously recorded the resolved values instead of what the client sent. - `task.go`: backfill `t.result.CollectionName` from the coordinator result on the non-cached path. - `meta_cache.go`: fix a stale comment on `ResolveCollectionAlias` — `update()` now always keys `collInfo` by the real collection name (aliases live in the separate alias map), the old comment described pre-refactor behavior. - Tests: - `TestCachedProxyServiceProvider_DescribeCollection_ByIDFillsNameAndUsesRequestID`: drives the id-only path; `GetCollectionID` is deliberately not mocked, so any regression back to re-resolving the id panics the test. - `TestDescribeCollectionTask_FillsNameFromResultWhenQueriedByID`: same assertion for the non-cached path. - Removed the now-unused `GetCollectionID` expectation from the test added in #51254 (that call no longer happens on the id-only path). ## Follow-up commit: entity-keyed meta cache with a cluster-wide id index Reviewing this path surfaced a deeper issue in the proxy meta cache, fixed in the second commit: - By-id lookups (`GetCollectionName`, `GetCollectionInfo` with an empty name) linearly scanned a whole db bucket under the read lock, on every request even on cache hits. - The bucket scanned/filled was keyed by the *request* db name — empty for id-only calls — so entries landed in a bogus `""` bucket: a collection also cached under its real db was duplicated, and same-name collections of different databases evicted each other from the single `""`-keyed slot. The cache is now entity-keyed instead of request-keyed: - Fills land under the collection's **actual database** (carried in the describe response); the `""` bucket no longer exists. - A cluster-wide `collectionID → entry` index serves by-id lookups in **O(1)** regardless of the request db — matching rootcoord, which resolves by-id describes straight from `collID2Meta` without consulting the db name. - Name lookups normalize an empty db name to `default`, mirroring rootcoord's backward-compat normalization (`meta_table.getCollectionByNameInternal`). This also closes a latent cross-database mis-hit: a name lookup with an empty db could previously return whichever same-name collection was last id-cached under the `""` bucket. - All removal paths maintain the index (pointer-identity-guarded unindexing); invalidation sweeps stay exhaustive. ## Verification - Ran locally against a current master core build: the full `TestMetaCache*`, `TestCachedProxyServiceProvider*`, `TestDescribeCollectionTask*`, alias and partition test sets all pass, plus the **full `internal/proxy` suite** (only the pre-existing etcd-dependent `TestProxyRpcLimit` fails locally — it needs a live etcd, unrelated to this change). - New tests: `TestCachedProxyServiceProvider_DescribeCollection_ByIDFillsNameAndUsesRequestID` (id-only path; also asserts the request is not rewritten), `TestDescribeCollectionTask_FillsNameFromResultWhenQueriedByID`, `TestMetaCache_ByIDIndexRealDBBucket` (same-name collections in two databases filled by id: no eviction, entries under real dbs, by-name reuses by-id fill, every removal path cleans the index), `TestMetaCache_EmptyDBNameSharesDefaultEntry`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Update: proxy meta cache rebuilt around an id-primary store (2 new commits) Following review discussions on invalidation cost and the alias-DDL races, this PR now also rebuilds the cache (issue: #51533, design doc: `docs/design-docs/design_docs/20260716-proxy-metacache-id-inverted-index.md` in this PR): 1. **`fix: forward the pre-alter alias target id in the AlterAlias expiration`** — rootcoord resolves the pre-alter target under its database lock and carries it in the new additive `AlterAliasMessageHeader.old_collection_id`; the ack callback emits a second expiration entry so proxies evict BOTH AlterAlias targets by id. Closes the race where a concurrent Describe re-points the proxy's alias resolution before the expiration arrives, leaving the old target permanently stale. Older rootcoords (field 0) fall back to the proxy's hint resolution. 2. **`enhance: rebuild the proxy meta cache around an id-primary store`** — the primary store is keyed by the cluster-unique collection id (single source of truth, with a per-database generation); name/alias resolution become hints validated against the primary on read; partition caches are keyed by id; fills are ordered against invalidations by a fill RWMutex (drain in-flight describes before evicting), replacing the per-collection timestamp floor. Every invalidation becomes O(1) — no more full-cache scans under the write lock on Load/Release/Drop/Rename/Alter broadcasts, and DropDatabase/AlterDatabase becomes a generation bump. Also removes a latent stale read (alias-keyed partition entries surviving DropCollection). Verification: targeted cache suites green (rename, alias re-point under concurrent describe, drop+recreate with a reused name, db-generation invalidation, cross-db isolation, gated-mock fill/invalidation ordering); three negative controls (hint validation / dbGen check / fill drain disabled) each fail exactly the test guarding them; full-package `-race` delegated to CI. --- ## Update: simplification series (final form) Following design review, the cache was iteratively simplified to an **id-primary store with declared hints** — every mechanism that could be replaced by an invariant was deleted (net-negative diffs throughout): - Primary store keyed by the cluster-unique collection id; liveness IS presence. Name/alias resolution are hints written ONLY together with their entry (declared aliases) and validated against the primary on every read — a stale hint can only cost one extra describe, never a stale read. - Fill/invalidation ordering via a fill RWMutex (drain in-flight describes before evicting); the per-collection timestamp floor, database generations, background GC, alias negative cache, per-partition cache, and the resolve-and-forget DescribeAlias path are all **deleted**. Evictions clean everything the entry owns synchronously; **no invalidation path performs any RPC**. - Old-rootcoord fallbacks: id-describes without DbName are served uncached; alias-DDL broadcasts without ids trigger hint resolution plus a holder scan **gated on that exact fingerprint** (dead code once rootcoords are upgraded), closing the ghost-alias case with no healing event. - **Accepted gaps (WONT-FIX)** are recorded in the design doc §6 — notably the upgrade-window new-target `Aliases` display lag (the association exists only at rootcoord; self-heals on first use; display-only). See `docs/design-docs/design_docs/20260716-proxy-metacache-id-inverted-index.md` for the full design, invariants and trade-offs. Signed-off-by: xiaofanluan <xf@hjjaq.com> Co-authored-by: xiaofanluan <xf@hjjaq.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5257 lines
203 KiB
Go
5257 lines
203 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 proxy
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cockroachdb/errors"
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"google.golang.org/grpc"
|
|
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/milvuspb"
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
|
|
"github.com/milvus-io/milvus/internal/mocks"
|
|
"github.com/milvus-io/milvus/internal/proxy/privilege"
|
|
"github.com/milvus-io/milvus/internal/types"
|
|
"github.com/milvus-io/milvus/pkg/v3/common"
|
|
"github.com/milvus-io/milvus/pkg/v3/metrics"
|
|
"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/proto/internalpb"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/proxypb"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/querypb"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/rootcoordpb"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/crypto"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/funcutil"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/merr"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/paramtable"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
|
|
)
|
|
|
|
var dbName = GetCurDBNameFromContextOrDefault(context.Background())
|
|
|
|
type MockMixCoordClientInterface struct {
|
|
types.MixCoordClient
|
|
Error bool
|
|
AccessCount int32
|
|
|
|
listPolicy func(ctx context.Context, in *internalpb.ListPolicyRequest) (*internalpb.ListPolicyResponse, error)
|
|
showLoadCollections func(ctx context.Context, in *querypb.ShowCollectionsRequest) (*querypb.ShowCollectionsResponse, error)
|
|
getShardLeaders func(ctx context.Context, in *querypb.GetShardLeadersRequest) (*querypb.GetShardLeadersResponse, error)
|
|
listResourceGroups func(ctx context.Context, in *milvuspb.ListResourceGroupsRequest) (*milvuspb.ListResourceGroupsResponse, error)
|
|
describeResourceGroup func(ctx context.Context, in *querypb.DescribeResourceGroupRequest) (*querypb.DescribeResourceGroupResponse, error)
|
|
}
|
|
|
|
func EqualSchema(t *testing.T, expect, actual *schemapb.CollectionSchema) {
|
|
assert.Equal(t, expect.AutoID, actual.AutoID)
|
|
assert.Equal(t, expect.Description, actual.Description)
|
|
assert.Equal(t, expect.Name, actual.Name)
|
|
assert.Equal(t, expect.EnableDynamicField, actual.EnableDynamicField)
|
|
assert.Equal(t, len(expect.Fields), len(actual.Fields))
|
|
for i := range expect.Fields {
|
|
assert.Equal(t, expect.Fields[i], actual.Fields[i])
|
|
}
|
|
assert.Equal(t, len(expect.Functions), len(actual.Functions))
|
|
for i := range expect.Functions {
|
|
assert.Equal(t, expect.Functions[i], actual.Functions[i])
|
|
}
|
|
assert.Equal(t, len(expect.Properties), len(actual.Properties))
|
|
for i := range expect.Properties {
|
|
assert.Equal(t, expect.Properties[i], actual.Properties[i])
|
|
}
|
|
}
|
|
|
|
func (m *MockMixCoordClientInterface) IncAccessCount() {
|
|
atomic.AddInt32(&m.AccessCount, 1)
|
|
}
|
|
|
|
func (m *MockMixCoordClientInterface) GetAccessCount() int {
|
|
ret := atomic.LoadInt32(&m.AccessCount)
|
|
return int(ret)
|
|
}
|
|
|
|
func (m *MockMixCoordClientInterface) ShowPartitions(ctx context.Context, in *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
if m.Error {
|
|
return nil, errors.New("mocked error")
|
|
}
|
|
if in.CollectionName == "collection1" || in.CollectionName == "collection1_alias" || in.CollectionID == 1 {
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []typeutil.UniqueID{1, 2},
|
|
CreatedTimestamps: []uint64{100, 200},
|
|
CreatedUtcTimestamps: []uint64{100, 200},
|
|
PartitionNames: []string{"par1", "par2"},
|
|
}, nil
|
|
}
|
|
if in.CollectionName == "collection2" || in.CollectionID == 2 {
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []typeutil.UniqueID{3, 4},
|
|
CreatedTimestamps: []uint64{201, 202},
|
|
CreatedUtcTimestamps: []uint64{201, 202},
|
|
PartitionNames: []string{"par1", "par2"},
|
|
}, nil
|
|
}
|
|
if in.CollectionName == "errorCollection" {
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []typeutil.UniqueID{5, 6},
|
|
CreatedTimestamps: []uint64{201},
|
|
CreatedUtcTimestamps: []uint64{201},
|
|
PartitionNames: []string{"par1", "par2"},
|
|
}, nil
|
|
}
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: &commonpb.Status{
|
|
ErrorCode: commonpb.ErrorCode_UnexpectedError,
|
|
},
|
|
PartitionIDs: []typeutil.UniqueID{},
|
|
CreatedTimestamps: []uint64{},
|
|
CreatedUtcTimestamps: []uint64{},
|
|
PartitionNames: []string{},
|
|
}, nil
|
|
}
|
|
|
|
func (m *MockMixCoordClientInterface) DescribeCollection(ctx context.Context, in *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
if m.Error {
|
|
return nil, errors.New("mocked error")
|
|
}
|
|
m.IncAccessCount()
|
|
if in.CollectionName == "collection1" || in.CollectionName == "collection1_alias" || in.CollectionID == 1 {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: typeutil.UniqueID(1),
|
|
Schema: &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Name: "collection1",
|
|
},
|
|
Aliases: []string{"collection1_alias"},
|
|
DbName: dbName,
|
|
RequestTime: 100,
|
|
}, nil
|
|
}
|
|
if in.CollectionName == "collection2" || in.CollectionID == 2 {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: typeutil.UniqueID(2),
|
|
Schema: &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Name: "collection2",
|
|
},
|
|
DbName: dbName,
|
|
RequestTime: 100,
|
|
}, nil
|
|
}
|
|
if in.CollectionName == "errorCollection" {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: typeutil.UniqueID(3),
|
|
Schema: &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
},
|
|
DbName: dbName,
|
|
RequestTime: 100,
|
|
}, nil
|
|
}
|
|
|
|
err := merr.WrapErrCollectionNotFound(in.CollectionName)
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Status(err),
|
|
Schema: nil,
|
|
}, nil
|
|
}
|
|
|
|
func (m *MockMixCoordClientInterface) GetCredential(ctx context.Context, req *rootcoordpb.GetCredentialRequest, opts ...grpc.CallOption) (*rootcoordpb.GetCredentialResponse, error) {
|
|
if m.Error {
|
|
return nil, errors.New("mocked error")
|
|
}
|
|
m.IncAccessCount()
|
|
if req.Username == "mockUser" {
|
|
encryptedPassword, _ := crypto.PasswordEncrypt("mockPass")
|
|
return &rootcoordpb.GetCredentialResponse{
|
|
Status: merr.Success(),
|
|
Username: "mockUser",
|
|
Password: encryptedPassword,
|
|
}, nil
|
|
}
|
|
|
|
err := fmt.Errorf("can't find credential: %s", req.Username)
|
|
return nil, err
|
|
}
|
|
|
|
func (m *MockMixCoordClientInterface) ListCredUsers(ctx context.Context, req *milvuspb.ListCredUsersRequest, opts ...grpc.CallOption) (*milvuspb.ListCredUsersResponse, error) {
|
|
if m.Error {
|
|
return nil, errors.New("mocked error")
|
|
}
|
|
|
|
return &milvuspb.ListCredUsersResponse{
|
|
Status: merr.Success(),
|
|
Usernames: []string{"mockUser"},
|
|
}, nil
|
|
}
|
|
|
|
func (m *MockMixCoordClientInterface) ListPolicy(ctx context.Context, in *internalpb.ListPolicyRequest, opts ...grpc.CallOption) (*internalpb.ListPolicyResponse, error) {
|
|
if m.listPolicy != nil {
|
|
return m.listPolicy(ctx, in)
|
|
}
|
|
return &internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) GetComponentStates(ctx context.Context, req *milvuspb.GetComponentStatesRequest, opts ...grpc.CallOption) (*milvuspb.ComponentStates, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetTimeTickChannel get timetick channel name
|
|
func (c *MockMixCoordClientInterface) GetTimeTickChannel(ctx context.Context, req *internalpb.GetTimeTickChannelRequest, opts ...grpc.CallOption) (*milvuspb.StringResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetStatisticsChannel just define a channel, not used currently
|
|
func (c *MockMixCoordClientInterface) GetStatisticsChannel(ctx context.Context, req *internalpb.GetStatisticsChannelRequest, opts ...grpc.CallOption) (*milvuspb.StringResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) GetMetrics(ctx context.Context, req *milvuspb.GetMetricsRequest, opts ...grpc.CallOption) (*milvuspb.GetMetricsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// ShowConfigurations gets specified configurations para of RootCoord
|
|
func (c *MockMixCoordClientInterface) ShowConfigurations(ctx context.Context, req *internalpb.ShowConfigurationsRequest, opts ...grpc.CallOption) (*internalpb.ShowConfigurationsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// CreateCollection create collection
|
|
func (c *MockMixCoordClientInterface) CreateCollection(ctx context.Context, in *milvuspb.CreateCollectionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// DropCollection drop collection
|
|
func (c *MockMixCoordClientInterface) DropCollection(ctx context.Context, in *milvuspb.DropCollectionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// HasCollection check collection existence
|
|
func (c *MockMixCoordClientInterface) HasCollection(ctx context.Context, in *milvuspb.HasCollectionRequest, opts ...grpc.CallOption) (*milvuspb.BoolResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// CreatePartition create partition
|
|
func (c *MockMixCoordClientInterface) AddCollectionField(ctx context.Context, in *milvuspb.AddCollectionFieldRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) AddCollectionStructField(ctx context.Context, in *milvuspb.AddCollectionStructFieldRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// describeCollectionInternal return collection info
|
|
func (c *MockMixCoordClientInterface) describeCollectionInternal(ctx context.Context, in *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DescribeCollectionInternal(ctx context.Context, in *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// ShowCollections list all collection names
|
|
func (c *MockMixCoordClientInterface) ShowCollections(ctx context.Context, in *milvuspb.ShowCollectionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowCollectionsResponse, error) {
|
|
return &milvuspb.ShowCollectionsResponse{
|
|
Status: merr.Success(),
|
|
}, nil
|
|
}
|
|
|
|
// ShowCollectionIDs returns all collection IDs.
|
|
func (c *MockMixCoordClientInterface) ShowCollectionIDs(ctx context.Context, in *rootcoordpb.ShowCollectionIDsRequest, opts ...grpc.CallOption) (*rootcoordpb.ShowCollectionIDsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) AlterCollection(ctx context.Context, request *milvuspb.AlterCollectionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) AlterCollectionField(ctx context.Context, request *milvuspb.AlterCollectionFieldRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// CreatePartition create partition
|
|
func (c *MockMixCoordClientInterface) CreatePartition(ctx context.Context, in *milvuspb.CreatePartitionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// DropPartition drop partition
|
|
func (c *MockMixCoordClientInterface) DropPartition(ctx context.Context, in *milvuspb.DropPartitionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// HasPartition check partition existence
|
|
func (c *MockMixCoordClientInterface) HasPartition(ctx context.Context, in *milvuspb.HasPartitionRequest, opts ...grpc.CallOption) (*milvuspb.BoolResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// showPartitionsInternal list all partitions in collection
|
|
func (c *MockMixCoordClientInterface) showPartitionsInternal(ctx context.Context, in *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ShowPartitionsInternal(ctx context.Context, in *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// AllocTimestamp global timestamp allocator
|
|
func (c *MockMixCoordClientInterface) AllocTimestamp(ctx context.Context, in *rootcoordpb.AllocTimestampRequest, opts ...grpc.CallOption) (*rootcoordpb.AllocTimestampResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// AllocID global ID allocator
|
|
func (c *MockMixCoordClientInterface) AllocID(ctx context.Context, in *rootcoordpb.AllocIDRequest, opts ...grpc.CallOption) (*rootcoordpb.AllocIDResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// UpdateChannelTimeTick used to handle ChannelTimeTickMsg
|
|
func (c *MockMixCoordClientInterface) UpdateChannelTimeTick(ctx context.Context, in *internalpb.ChannelTimeTickMsg, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// ShowSegments list all segments
|
|
func (c *MockMixCoordClientInterface) ShowSegments(ctx context.Context, in *milvuspb.ShowSegmentsRequest, opts ...grpc.CallOption) (*milvuspb.ShowSegmentsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetVChannels returns all vchannels belonging to the pchannel.
|
|
func (c *MockMixCoordClientInterface) GetPChannelInfo(ctx context.Context, in *rootcoordpb.GetPChannelInfoRequest, opts ...grpc.CallOption) (*rootcoordpb.GetPChannelInfoResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// InvalidateCollectionMetaCache notifies RootCoord to release the collection cache in Proxies.
|
|
func (c *MockMixCoordClientInterface) InvalidateCollectionMetaCache(ctx context.Context, in *proxypb.InvalidateCollMetaCacheRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// CreateAlias create collection alias
|
|
func (c *MockMixCoordClientInterface) CreateAlias(ctx context.Context, req *milvuspb.CreateAliasRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// DropAlias drop collection alias
|
|
func (c *MockMixCoordClientInterface) DropAlias(ctx context.Context, req *milvuspb.DropAliasRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// AlterAlias alter collection alias
|
|
func (c *MockMixCoordClientInterface) AlterAlias(ctx context.Context, req *milvuspb.AlterAliasRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// DescribeAlias describe alias
|
|
func (c *MockMixCoordClientInterface) DescribeAlias(ctx context.Context, req *milvuspb.DescribeAliasRequest, opts ...grpc.CallOption) (*milvuspb.DescribeAliasResponse, error) {
|
|
return &milvuspb.DescribeAliasResponse{
|
|
Status: &commonpb.Status{
|
|
ErrorCode: commonpb.ErrorCode_CollectionNotExists,
|
|
Reason: "alias not found",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ListAliases list all aliases of db or collection
|
|
func (c *MockMixCoordClientInterface) ListAliases(ctx context.Context, req *milvuspb.ListAliasesRequest, opts ...grpc.CallOption) (*milvuspb.ListAliasesResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) CreateCredential(ctx context.Context, req *internalpb.CredentialInfo, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) UpdateCredential(ctx context.Context, req *internalpb.CredentialInfo, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DeleteCredential(ctx context.Context, req *milvuspb.DeleteCredentialRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) CreateRole(ctx context.Context, req *milvuspb.CreateRoleRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) AlterRole(ctx context.Context, req *milvuspb.AlterRoleRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DropRole(ctx context.Context, req *milvuspb.DropRoleRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) OperateUserRole(ctx context.Context, req *milvuspb.OperateUserRoleRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) SelectRole(ctx context.Context, req *milvuspb.SelectRoleRequest, opts ...grpc.CallOption) (*milvuspb.SelectRoleResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) SelectUser(ctx context.Context, req *milvuspb.SelectUserRequest, opts ...grpc.CallOption) (*milvuspb.SelectUserResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) OperatePrivilege(ctx context.Context, req *milvuspb.OperatePrivilegeRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) SelectGrant(ctx context.Context, req *milvuspb.SelectGrantRequest, opts ...grpc.CallOption) (*milvuspb.SelectGrantResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) CheckHealth(ctx context.Context, req *milvuspb.CheckHealthRequest, opts ...grpc.CallOption) (*milvuspb.CheckHealthResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) RenameCollection(ctx context.Context, req *milvuspb.RenameCollectionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) CreateDatabase(ctx context.Context, in *milvuspb.CreateDatabaseRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DropDatabase(ctx context.Context, in *milvuspb.DropDatabaseRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ListDatabases(ctx context.Context, in *milvuspb.ListDatabasesRequest, opts ...grpc.CallOption) (*milvuspb.ListDatabasesResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DescribeDatabase(ctx context.Context, req *rootcoordpb.DescribeDatabaseRequest, opts ...grpc.CallOption) (*rootcoordpb.DescribeDatabaseResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) AlterDatabase(ctx context.Context, request *rootcoordpb.AlterDatabaseRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) BackupRBAC(ctx context.Context, in *milvuspb.BackupRBACMetaRequest, opts ...grpc.CallOption) (*milvuspb.BackupRBACMetaResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) RestoreRBAC(ctx context.Context, in *milvuspb.RestoreRBACMetaRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) CreatePrivilegeGroup(ctx context.Context, in *milvuspb.CreatePrivilegeGroupRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DropPrivilegeGroup(ctx context.Context, in *milvuspb.DropPrivilegeGroupRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ListPrivilegeGroups(ctx context.Context, in *milvuspb.ListPrivilegeGroupsRequest, opts ...grpc.CallOption) (*milvuspb.ListPrivilegeGroupsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) OperatePrivilegeGroup(ctx context.Context, in *milvuspb.OperatePrivilegeGroupRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// Flush flushes a collection's data
|
|
func (c *MockMixCoordClientInterface) Flush(ctx context.Context, req *datapb.FlushRequest, opts ...grpc.CallOption) (*datapb.FlushResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// AssignSegmentID applies allocations for specified Coolection/Partition and related Channel Name(Virtial Channel)
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the requester's info(id and role) and the list of Assignment Request,
|
|
// which contains the specified collection, partitaion id, the related VChannel Name and row count it needs
|
|
//
|
|
// response struct `AssignSegmentIDResponse` contains the assignment result for each request
|
|
// error is returned only when some communication issue occurs
|
|
// if some error occurs in the process of `AssignSegmentID`, it will be recorded and returned in `Status` field of response
|
|
//
|
|
// `AssignSegmentID` will applies current configured allocation policies for each request
|
|
// if the VChannel is newly used, `WatchDmlChannels` will be invoked to notify a `DataNode`(selected by policy) to watch it
|
|
// if there is anything make the allocation impossible, the response will not contain the corresponding result
|
|
func (c *MockMixCoordClientInterface) AssignSegmentID(ctx context.Context, req *datapb.AssignSegmentIDRequest, opts ...grpc.CallOption) (*datapb.AssignSegmentIDResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) AllocSegment(ctx context.Context, in *datapb.AllocSegmentRequest, opts ...grpc.CallOption) (*datapb.AllocSegmentResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetSegmentStates requests segment state information
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the list of segment id to query
|
|
//
|
|
// response struct `GetSegmentStatesResponse` contains the list of each state query result
|
|
//
|
|
// when the segment is not found, the state entry will has the field `Status` to identify failure
|
|
// otherwise the Segment State and Start position information will be returned
|
|
//
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetSegmentStates(ctx context.Context, req *datapb.GetSegmentStatesRequest, opts ...grpc.CallOption) (*datapb.GetSegmentStatesResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetInsertBinlogPaths requests binlog paths for specified segment
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the segment id to query
|
|
//
|
|
// response struct `GetInsertBinlogPathsResponse` contains the fields list
|
|
//
|
|
// and corresponding binlog path list
|
|
//
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetInsertBinlogPaths(ctx context.Context, req *datapb.GetInsertBinlogPathsRequest, opts ...grpc.CallOption) (*datapb.GetInsertBinlogPathsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetCollectionStatistics requests collection statistics
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the collection id to query
|
|
//
|
|
// response struct `GetCollectionStatisticsResponse` contains the key-value list fields returning related data
|
|
//
|
|
// only row count for now
|
|
//
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetCollectionStatistics(ctx context.Context, req *datapb.GetCollectionStatisticsRequest, opts ...grpc.CallOption) (*datapb.GetCollectionStatisticsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetPartitionStatistics requests partition statistics
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the collection and partition id to query
|
|
//
|
|
// response struct `GetPartitionStatisticsResponse` contains the key-value list fields returning related data
|
|
//
|
|
// only row count for now
|
|
//
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetPartitionStatistics(ctx context.Context, req *datapb.GetPartitionStatisticsRequest, opts ...grpc.CallOption) (*datapb.GetPartitionStatisticsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetSegmentInfoChannel DEPRECATED
|
|
// legacy api to get SegmentInfo Channel name
|
|
func (c *MockMixCoordClientInterface) GetSegmentInfoChannel(ctx context.Context, _ *datapb.GetSegmentInfoChannelRequest, opts ...grpc.CallOption) (*milvuspb.StringResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetSegmentInfo requests segment info
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the list of segment ids to query
|
|
//
|
|
// response struct `GetSegmentInfoResponse` contains the list of segment info
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetSegmentInfo(ctx context.Context, req *datapb.GetSegmentInfoRequest, opts ...grpc.CallOption) (*datapb.GetSegmentInfoResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// SaveBinlogPaths updates segments binlogs(including insert binlogs, stats logs and delta logs)
|
|
//
|
|
// and related message stream positions
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the collection/partition id to query
|
|
//
|
|
// response status contains the status/error code and failing reason if any
|
|
// error is returned only when some communication issue occurs
|
|
//
|
|
// there is a constraint that the `SaveBinlogPaths` requests of same segment shall be passed in sequence
|
|
//
|
|
// the root reason is each `SaveBinlogPaths` will overwrite the checkpoint position
|
|
// if the constraint is broken, the checkpoint position will not be monotonically increasing and the integrity will be compromised
|
|
func (c *MockMixCoordClientInterface) SaveBinlogPaths(ctx context.Context, req *datapb.SaveBinlogPathsRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetRecoveryInfo request segment recovery info of collection/partition
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the collection/partition id to query
|
|
//
|
|
// response struct `GetRecoveryInfoResponse` contains the list of segments info and corresponding vchannel info
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetRecoveryInfo(ctx context.Context, req *datapb.GetRecoveryInfoRequest, opts ...grpc.CallOption) (*datapb.GetRecoveryInfoResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetRecoveryInfoV2 request segment recovery info of collection/partitions
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the collection/partitions id to query
|
|
//
|
|
// response struct `GetRecoveryInfoResponseV2` contains the list of segments info and corresponding vchannel info
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetRecoveryInfoV2(ctx context.Context, req *datapb.GetRecoveryInfoRequestV2, opts ...grpc.CallOption) (*datapb.GetRecoveryInfoResponseV2, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetChannelRecoveryInfo returns the corresponding vchannel info.
|
|
func (c *MockMixCoordClientInterface) GetChannelRecoveryInfo(ctx context.Context, req *datapb.GetChannelRecoveryInfoRequest, opts ...grpc.CallOption) (*datapb.GetChannelRecoveryInfoResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetFlushedSegments returns flushed segment list of requested collection/parition
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the collection/partition id to query
|
|
//
|
|
// when partition is lesser or equal to 0, all flushed segments of collection will be returned
|
|
//
|
|
// response struct `GetFlushedSegmentsResponse` contains flushed segment id list
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetFlushedSegments(ctx context.Context, req *datapb.GetFlushedSegmentsRequest, opts ...grpc.CallOption) (*datapb.GetFlushedSegmentsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetSegmentsByStates returns segment list of requested collection/partition and segment states
|
|
//
|
|
// ctx is the context to control request deadline and cancellation
|
|
// req contains the collection/partition id and segment states to query
|
|
// when partition is lesser or equal to 0, all segments of collection will be returned
|
|
//
|
|
// response struct `GetSegmentsByStatesResponse` contains segment id list
|
|
// error is returned only when some communication issue occurs
|
|
func (c *MockMixCoordClientInterface) GetSegmentsByStates(ctx context.Context, req *datapb.GetSegmentsByStatesRequest, opts ...grpc.CallOption) (*datapb.GetSegmentsByStatesResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// ManualCompaction triggers a compaction for a collection
|
|
func (c *MockMixCoordClientInterface) ManualCompaction(ctx context.Context, req *milvuspb.ManualCompactionRequest, opts ...grpc.CallOption) (*milvuspb.ManualCompactionResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetCompactionState gets the state of a compaction
|
|
func (c *MockMixCoordClientInterface) GetCompactionState(ctx context.Context, req *milvuspb.GetCompactionStateRequest, opts ...grpc.CallOption) (*milvuspb.GetCompactionStateResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetCompactionStateWithPlans gets the state of a compaction by plan
|
|
func (c *MockMixCoordClientInterface) GetCompactionStateWithPlans(ctx context.Context, req *milvuspb.GetCompactionPlansRequest, opts ...grpc.CallOption) (*milvuspb.GetCompactionPlansResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// WatchChannels notifies DataCoord to watch vchannels of a collection
|
|
func (c *MockMixCoordClientInterface) WatchChannels(ctx context.Context, req *datapb.WatchChannelsRequest, opts ...grpc.CallOption) (*datapb.WatchChannelsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetFlushState gets the flush state of the collection based on the provided flush ts and segment IDs.
|
|
func (c *MockMixCoordClientInterface) GetFlushState(ctx context.Context, req *datapb.GetFlushStateRequest, opts ...grpc.CallOption) (*milvuspb.GetFlushStateResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetFlushAllState checks if all DML messages before `FlushAllTs` have been flushed.
|
|
func (c *MockMixCoordClientInterface) GetFlushAllState(ctx context.Context, req *milvuspb.GetFlushAllStateRequest, opts ...grpc.CallOption) (*milvuspb.GetFlushAllStateResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// DropVirtualChannel drops virtual channel in datacoord.
|
|
func (c *MockMixCoordClientInterface) DropVirtualChannel(ctx context.Context, req *datapb.DropVirtualChannelRequest, opts ...grpc.CallOption) (*datapb.DropVirtualChannelResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// SetSegmentState sets the state of a given segment.
|
|
func (c *MockMixCoordClientInterface) SetSegmentState(ctx context.Context, req *datapb.SetSegmentStateRequest, opts ...grpc.CallOption) (*datapb.SetSegmentStateResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// UpdateSegmentStatistics is the client side caller of UpdateSegmentStatistics.
|
|
func (c *MockMixCoordClientInterface) UpdateSegmentStatistics(ctx context.Context, req *datapb.UpdateSegmentStatisticsRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// UpdateChannelCheckpoint updates channel checkpoint in dataCoord.
|
|
func (c *MockMixCoordClientInterface) UpdateChannelCheckpoint(ctx context.Context, req *datapb.UpdateChannelCheckpointRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) MarkSegmentsDropped(ctx context.Context, req *datapb.MarkSegmentsDroppedRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// BroadcastAlteredCollection is the DataCoord client side code for BroadcastAlteredCollection call.
|
|
func (c *MockMixCoordClientInterface) BroadcastAlteredCollection(ctx context.Context, req *datapb.AlterCollectionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) GcConfirm(ctx context.Context, req *datapb.GcConfirmRequest, opts ...grpc.CallOption) (*datapb.GcConfirmResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// CreateIndex sends the build index request to IndexCoord.
|
|
func (c *MockMixCoordClientInterface) CreateIndex(ctx context.Context, req *indexpb.CreateIndexRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// AlterIndex sends the alter index request to IndexCoord.
|
|
func (c *MockMixCoordClientInterface) AlterIndex(ctx context.Context, req *indexpb.AlterIndexRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetIndexState gets the index states from IndexCoord.
|
|
func (c *MockMixCoordClientInterface) GetIndexState(ctx context.Context, req *indexpb.GetIndexStateRequest, opts ...grpc.CallOption) (*indexpb.GetIndexStateResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetSegmentIndexState gets the index states from IndexCoord.
|
|
func (c *MockMixCoordClientInterface) GetSegmentIndexState(ctx context.Context, req *indexpb.GetSegmentIndexStateRequest, opts ...grpc.CallOption) (*indexpb.GetSegmentIndexStateResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetIndexInfos gets the index file paths from IndexCoord.
|
|
func (c *MockMixCoordClientInterface) GetIndexInfos(ctx context.Context, req *indexpb.GetIndexInfoRequest, opts ...grpc.CallOption) (*indexpb.GetIndexInfoResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// DescribeIndex describe the index info of the collection.
|
|
func (c *MockMixCoordClientInterface) DescribeIndex(ctx context.Context, req *indexpb.DescribeIndexRequest, opts ...grpc.CallOption) (*indexpb.DescribeIndexResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetIndexStatistics get the statistics of the index.
|
|
func (c *MockMixCoordClientInterface) GetIndexStatistics(ctx context.Context, req *indexpb.GetIndexStatisticsRequest, opts ...grpc.CallOption) (*indexpb.GetIndexStatisticsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetIndexBuildProgress describe the progress of the index.
|
|
func (c *MockMixCoordClientInterface) GetIndexBuildProgress(ctx context.Context, req *indexpb.GetIndexBuildProgressRequest, opts ...grpc.CallOption) (*indexpb.GetIndexBuildProgressResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// DropIndex sends the drop index request to IndexCoord.
|
|
func (c *MockMixCoordClientInterface) DropIndex(ctx context.Context, req *indexpb.DropIndexRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ReportDataNodeTtMsgs(ctx context.Context, req *datapb.ReportDataNodeTtMsgsRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) GcControl(ctx context.Context, req *datapb.GcControlRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ImportV2(ctx context.Context, in *internalpb.ImportRequestInternal, opts ...grpc.CallOption) (*internalpb.ImportResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) GetImportProgress(ctx context.Context, in *internalpb.GetImportProgressRequest, opts ...grpc.CallOption) (*internalpb.GetImportProgressResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ListImports(ctx context.Context, in *internalpb.ListImportsRequestInternal, opts ...grpc.CallOption) (*internalpb.ListImportsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ListIndexes(ctx context.Context, in *indexpb.ListIndexesRequest, opts ...grpc.CallOption) (*indexpb.ListIndexesResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ShowLoadCollections(ctx context.Context, req *querypb.ShowCollectionsRequest, opts ...grpc.CallOption) (*querypb.ShowCollectionsResponse, error) {
|
|
if c.showLoadCollections != nil {
|
|
return c.showLoadCollections(ctx, req)
|
|
}
|
|
return &querypb.ShowCollectionsResponse{
|
|
Status: &commonpb.Status{
|
|
ErrorCode: commonpb.ErrorCode_Success,
|
|
Reason: "",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// LoadCollection loads the data of the specified collections in the QueryCoord.
|
|
func (c *MockMixCoordClientInterface) LoadCollection(ctx context.Context, req *querypb.LoadCollectionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// ReleaseCollection release the data of the specified collections in the QueryCoord.
|
|
func (c *MockMixCoordClientInterface) ReleaseCollection(ctx context.Context, req *querypb.ReleaseCollectionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// ShowPartitions shows the partitions in the QueryCoord.
|
|
func (c *MockMixCoordClientInterface) ShowLoadPartitions(ctx context.Context, req *querypb.ShowPartitionsRequest, opts ...grpc.CallOption) (*querypb.ShowPartitionsResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// LoadPartitions loads the data of the specified partitions in the QueryCoord.
|
|
func (c *MockMixCoordClientInterface) LoadPartitions(ctx context.Context, req *querypb.LoadPartitionsRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// ReleasePartitions release the data of the specified partitions in the QueryCoord.
|
|
func (c *MockMixCoordClientInterface) ReleasePartitions(ctx context.Context, req *querypb.ReleasePartitionsRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// SyncNewCreatedPartition notifies QueryCoord to sync new created partition if collection is loaded.
|
|
func (c *MockMixCoordClientInterface) SyncNewCreatedPartition(ctx context.Context, req *querypb.SyncNewCreatedPartitionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetPartitionStates gets the states of the specified partition.
|
|
func (c *MockMixCoordClientInterface) GetPartitionStates(ctx context.Context, req *querypb.GetPartitionStatesRequest, opts ...grpc.CallOption) (*querypb.GetPartitionStatesResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetSegmentInfo gets the information of the specified segment from QueryCoord.
|
|
func (c *MockMixCoordClientInterface) GetLoadSegmentInfo(ctx context.Context, req *querypb.GetSegmentInfoRequest, opts ...grpc.CallOption) (*querypb.GetSegmentInfoResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// LoadBalance migrate the sealed segments on the source node to the dst nodes.
|
|
func (c *MockMixCoordClientInterface) LoadBalance(ctx context.Context, req *querypb.LoadBalanceRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// ShowConfigurations gets specified configurations para of QueryCoord
|
|
// func (c *Client) ShowConfigurations(ctx context.Context, req *internalpb.ShowConfigurationsRequest, opts ...grpc.CallOption) (*internalpb.ShowConfigurationsResponse, error) {
|
|
// req = typeutil.Clone(req)
|
|
// commonpbutil.UpdateMsgBase(
|
|
// req.GetBase(),
|
|
// commonpbutil.FillMsgBaseFromClient(paramtable.GetNodeID(), commonpbutil.WithTargetID(c.grpcClient.GetNodeID())),
|
|
// )
|
|
// return wrapGrpcCall(ctx, c, func(client MixCoordClient) (*internalpb.ShowConfigurationsResponse, error) {
|
|
// return client.ShowConfigurations(ctx, req)
|
|
// })
|
|
// }
|
|
|
|
// GetReplicas gets the replicas of a certain collection.
|
|
func (c *MockMixCoordClientInterface) GetReplicas(ctx context.Context, req *milvuspb.GetReplicasRequest, opts ...grpc.CallOption) (*milvuspb.GetReplicasResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
// GetShardLeaders gets the shard leaders of a certain collection.
|
|
func (c *MockMixCoordClientInterface) GetShardLeaders(ctx context.Context, req *querypb.GetShardLeadersRequest, opts ...grpc.CallOption) (*querypb.GetShardLeadersResponse, error) {
|
|
if c.getShardLeaders != nil {
|
|
return c.getShardLeaders(ctx, req)
|
|
}
|
|
return &querypb.GetShardLeadersResponse{
|
|
Status: &commonpb.Status{
|
|
ErrorCode: commonpb.ErrorCode_UnexpectedError,
|
|
Reason: "not implemented",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) CreateResourceGroup(ctx context.Context, req *milvuspb.CreateResourceGroupRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) UpdateResourceGroups(ctx context.Context, req *querypb.UpdateResourceGroupsRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DropResourceGroup(ctx context.Context, req *milvuspb.DropResourceGroupRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DescribeResourceGroup(ctx context.Context, req *querypb.DescribeResourceGroupRequest, opts ...grpc.CallOption) (*querypb.DescribeResourceGroupResponse, error) {
|
|
if c.describeResourceGroup != nil {
|
|
return c.describeResourceGroup(ctx, req)
|
|
}
|
|
return &querypb.DescribeResourceGroupResponse{
|
|
Status: &commonpb.Status{
|
|
ErrorCode: commonpb.ErrorCode_Success,
|
|
Reason: "",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) TransferNode(ctx context.Context, req *milvuspb.TransferNodeRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) TransferReplica(ctx context.Context, req *querypb.TransferReplicaRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
return &commonpb.Status{
|
|
ErrorCode: commonpb.ErrorCode_Success,
|
|
Reason: "",
|
|
}, nil
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ListResourceGroups(ctx context.Context, req *milvuspb.ListResourceGroupsRequest, opts ...grpc.CallOption) (*milvuspb.ListResourceGroupsResponse, error) {
|
|
if c.listResourceGroups != nil {
|
|
return c.listResourceGroups(ctx, req)
|
|
}
|
|
|
|
return &milvuspb.ListResourceGroupsResponse{
|
|
Status: &commonpb.Status{
|
|
ErrorCode: commonpb.ErrorCode_Success,
|
|
Reason: "",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ListCheckers(ctx context.Context, req *querypb.ListCheckersRequest, opts ...grpc.CallOption) (*querypb.ListCheckersResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ActivateChecker(ctx context.Context, req *querypb.ActivateCheckerRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) DeactivateChecker(ctx context.Context, req *querypb.DeactivateCheckerRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ListQueryNode(ctx context.Context, req *querypb.ListQueryNodeRequest, opts ...grpc.CallOption) (*querypb.ListQueryNodeResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) GetQueryNodeDistribution(ctx context.Context, req *querypb.GetQueryNodeDistributionRequest, opts ...grpc.CallOption) (*querypb.GetQueryNodeDistributionResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) SuspendBalance(ctx context.Context, req *querypb.SuspendBalanceRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ResumeBalance(ctx context.Context, req *querypb.ResumeBalanceRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) CheckBalanceStatus(ctx context.Context, req *querypb.CheckBalanceStatusRequest, opts ...grpc.CallOption) (*querypb.CheckBalanceStatusResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) SuspendNode(ctx context.Context, req *querypb.SuspendNodeRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) ResumeNode(ctx context.Context, req *querypb.ResumeNodeRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) TransferSegment(ctx context.Context, req *querypb.TransferSegmentRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) TransferChannel(ctx context.Context, req *querypb.TransferChannelRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) CheckQueryNodeDistribution(ctx context.Context, req *querypb.CheckQueryNodeDistributionRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) UpdateLoadConfig(ctx context.Context, req *querypb.UpdateLoadConfigRequest, opts ...grpc.CallOption) (*commonpb.Status, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (c *MockMixCoordClientInterface) Close() error {
|
|
panic("implement me")
|
|
}
|
|
|
|
// Simulate the cache path and the
|
|
func TestMetaCache_GetCollection(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
id, err := globalMetaCache.GetCollectionID(ctx, dbName, "collection1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(1))
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 1)
|
|
|
|
// should'nt be accessed to remote root coord.
|
|
schema, err := globalMetaCache.GetCollectionSchema(ctx, dbName, "collection1")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 1)
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection1",
|
|
})
|
|
id, err = globalMetaCache.GetCollectionID(ctx, dbName, "collection2")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 2)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(2))
|
|
schema, err = globalMetaCache.GetCollectionSchema(ctx, dbName, "collection2")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 2)
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection2",
|
|
})
|
|
|
|
// test to get from cache, this should trigger root request
|
|
id, err = globalMetaCache.GetCollectionID(ctx, dbName, "collection1")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 2)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(1))
|
|
schema, err = globalMetaCache.GetCollectionSchema(ctx, dbName, "collection1")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 2)
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection1",
|
|
})
|
|
}
|
|
|
|
func TestMetaCache_GetCollectionByAliasHitsCache(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
id, err := globalMetaCache.GetCollectionID(ctx, dbName, "collection1_alias")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, typeutil.UniqueID(1), id)
|
|
assert.Equal(t, 1, rootCoord.GetAccessCount())
|
|
|
|
schema, err := globalMetaCache.GetCollectionSchema(ctx, dbName, "collection1_alias")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "collection1", schema.GetName())
|
|
assert.Equal(t, 1, rootCoord.GetAccessCount())
|
|
|
|
info, err := globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1_alias", 0)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, typeutil.UniqueID(1), info.collID)
|
|
assert.Equal(t, 1, rootCoord.GetAccessCount())
|
|
|
|
metaCache := globalMetaCache.(*MetaCache)
|
|
metaCache.mu.RLock()
|
|
defer metaCache.mu.RUnlock()
|
|
_, aliasCachedAsCollection := metaCache.nameIdx[dbName]["collection1_alias"]
|
|
assert.False(t, aliasCachedAsCollection, "the alias must not be registered as a collection-name hint")
|
|
assert.Equal(t, "collection1", metaCache.aliasInfo[dbName]["collection1_alias"])
|
|
}
|
|
|
|
func TestMetaCache_GetBasicCollectionInfo(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
// should be no data race.
|
|
wg := sync.WaitGroup{}
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
info, err := globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, info.collID, int64(1))
|
|
_ = info.consistencyLevel
|
|
_ = info.createdTimestamp
|
|
_ = info.createdUtcTimestamp
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
info, err := globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, info.collID, int64(1))
|
|
_ = info.consistencyLevel
|
|
_ = info.createdTimestamp
|
|
_ = info.createdUtcTimestamp
|
|
}()
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestMetaCacheGetCollectionWithUpdate(t *testing.T) {
|
|
cache := globalMetaCache
|
|
defer func() { globalMetaCache = cache }()
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{Status: merr.Success()}, nil)
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
t.Run("update with name", func(t *testing.T) {
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "bar",
|
|
Fields: []*schemapb.FieldSchema{
|
|
{
|
|
FieldID: 1,
|
|
Name: "p",
|
|
},
|
|
{
|
|
FieldID: 100,
|
|
Name: "pk",
|
|
},
|
|
},
|
|
},
|
|
ShardsNum: 1,
|
|
PhysicalChannelNames: []string{"by-dev-rootcoord-dml_1"},
|
|
VirtualChannelNames: []string{"by-dev-rootcoord-dml_1_1v0"},
|
|
}, nil).Once()
|
|
c, err := globalMetaCache.GetCollectionInfo(ctx, "foo", "bar", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, c.collID, int64(1))
|
|
assert.Equal(t, c.schema.Name, "bar")
|
|
})
|
|
|
|
t.Run("update with name", func(t *testing.T) {
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "bar",
|
|
Fields: []*schemapb.FieldSchema{
|
|
{
|
|
FieldID: 1,
|
|
Name: "p",
|
|
},
|
|
{
|
|
FieldID: 100,
|
|
Name: "pk",
|
|
},
|
|
},
|
|
},
|
|
ShardsNum: 1,
|
|
PhysicalChannelNames: []string{"by-dev-rootcoord-dml_1"},
|
|
VirtualChannelNames: []string{"by-dev-rootcoord-dml_1_1v0"},
|
|
}, nil).Once()
|
|
c, err := globalMetaCache.GetCollectionInfo(ctx, "foo", "hoo", 0)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, c.collID, int64(1))
|
|
assert.Equal(t, c.schema.Name, "bar")
|
|
})
|
|
}
|
|
|
|
func TestMetaCache_InitCache(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ShowLoadCollections(mock.Anything, mock.Anything).Return(&querypb.ShowCollectionsResponse{}, nil).Maybe()
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{Status: merr.Success()}, nil).Once()
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("failed to list policy", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything, mock.Anything).Return(
|
|
&internalpb.ListPolicyResponse{Status: merr.Status(errors.New("mock list policy error"))},
|
|
nil).Once()
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("rpc error", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything, mock.Anything).Return(
|
|
nil, errors.New("mock list policy rpc errorr")).Once()
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
// TestMetaCache_ByIDIndexRealDBBucket verifies that by-id lookups are served
|
|
// from the cluster-wide id index and that fills land under the collection's
|
|
// actual database: same-name collections in different databases no longer
|
|
// evict each other, and a by-id fill is shared with later by-name lookups.
|
|
func TestMetaCache_ByIDIndexRealDBBucket(t *testing.T) {
|
|
ctx := context.Background()
|
|
mix := NewMixCoordMock()
|
|
describeCount := int32(0)
|
|
mix.SetDescribeCollectionFunc(func(ctx context.Context, req *milvuspb.DescribeCollectionRequest) (*milvuspb.DescribeCollectionResponse, error) {
|
|
atomic.AddInt32(&describeCount, 1)
|
|
resp := &milvuspb.DescribeCollectionResponse{Status: merr.Success(), RequestTime: 100}
|
|
switch {
|
|
case req.GetCollectionID() == 101 || (req.GetDbName() == "db1" && req.GetCollectionName() == "foo"):
|
|
resp.CollectionID = 101
|
|
resp.DbName = "db1"
|
|
resp.DbId = 1
|
|
resp.Schema = &schemapb.CollectionSchema{Name: "foo"}
|
|
case req.GetCollectionID() == 202 || (req.GetDbName() == "db2" && req.GetCollectionName() == "foo"):
|
|
resp.CollectionID = 202
|
|
resp.DbName = "db2"
|
|
resp.DbId = 2
|
|
resp.Schema = &schemapb.CollectionSchema{Name: "foo"}
|
|
default:
|
|
return nil, merr.WrapErrCollectionNotFound(req.GetCollectionName())
|
|
}
|
|
return resp, nil
|
|
})
|
|
cache, err := NewMetaCache(mix)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// two same-name collections in different databases, both filled by id only
|
|
name, err := cache.GetCollectionName(ctx, "", 101)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "foo", name)
|
|
name, err = cache.GetCollectionName(ctx, "", 202)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "foo", name)
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(&describeCount))
|
|
|
|
// repeated by-id lookups hit the id index; the entries do not evict each
|
|
// other even though the names collide
|
|
for i := 0; i < 3; i++ {
|
|
_, err = cache.GetCollectionName(ctx, "", 101)
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetCollectionName(ctx, "", 202)
|
|
assert.NoError(t, err)
|
|
}
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(&describeCount))
|
|
|
|
// entries live under their real databases; no "" bucket exists
|
|
cache.mu.RLock()
|
|
assert.Nil(t, cache.nameIdx[""])
|
|
assert.NotNil(t, cachedEntryLocked(cache, "db1", "foo"))
|
|
assert.NotNil(t, cachedEntryLocked(cache, "db2", "foo"))
|
|
assert.Same(t, cachedEntryLocked(cache, "db1", "foo"), collByIDLive(cache, 101))
|
|
assert.Same(t, cachedEntryLocked(cache, "db2", "foo"), collByIDLive(cache, 202))
|
|
cache.mu.RUnlock()
|
|
|
|
// a by-name lookup reuses the by-id fill, no extra RPC
|
|
id, err := cache.GetCollectionID(ctx, "db1", "foo")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(101), id)
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(&describeCount))
|
|
|
|
// removal by id drops the index entry, next by-id lookup goes remote
|
|
cache.RemoveCollectionsByID(ctx, 101)
|
|
name, err = cache.GetCollectionName(ctx, "", 101)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "foo", name)
|
|
assert.Equal(t, int32(3), atomic.LoadInt32(&describeCount))
|
|
|
|
// dropping a database drops its entries from the index too
|
|
cache.RemoveDatabase(ctx, "db2")
|
|
cache.mu.RLock()
|
|
assert.Nil(t, collByIDLive(cache, 202))
|
|
assert.NotNil(t, collByIDLive(cache, 101))
|
|
cache.mu.RUnlock()
|
|
}
|
|
|
|
// TestMetaCache_ByIDEmptyDBNameNotCached verifies that when an id-only describe
|
|
// comes back without a real db name (an older rootcoord during a rolling
|
|
// upgrade), the entry is served UNCACHED: its database is unknown, so no
|
|
// database DDL could ever invalidate a cached copy (it would escape every
|
|
// db generation). Repeat lookups re-describe until the rootcoords are upgraded
|
|
// -- correctness over an upgrade-window cache hit.
|
|
func TestMetaCache_ByIDEmptyDBNameNotCached(t *testing.T) {
|
|
ctx := context.Background()
|
|
mix := NewMixCoordMock()
|
|
describeCount := int32(0)
|
|
mix.SetDescribeCollectionFunc(func(ctx context.Context, req *milvuspb.DescribeCollectionRequest) (*milvuspb.DescribeCollectionResponse, error) {
|
|
atomic.AddInt32(&describeCount, 1)
|
|
// older rootcoord: resolves the name but does not report the real db
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 101,
|
|
DbName: "",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo"},
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
cache, err := NewMetaCache(mix)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// the id-only lookup resolves the name from the response
|
|
name, err := cache.GetCollectionName(ctx, "", 101)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "foo", name)
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&describeCount))
|
|
|
|
// repeat by-id lookups RE-DESCRIBE: nothing was cached (the entry's db is
|
|
// unknown, so a cached copy could never be invalidated by database DDL)
|
|
name, err = cache.GetCollectionName(ctx, "", 101)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "foo", name)
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(&describeCount))
|
|
|
|
// nothing in the primary store, no name hint under any guessed bucket
|
|
cache.mu.RLock()
|
|
assert.Nil(t, collByIDLive(cache, 101))
|
|
assert.Nil(t, cachedEntryLocked(cache, "", "foo"))
|
|
assert.Nil(t, cachedEntryLocked(cache, "default", "foo"))
|
|
cache.mu.RUnlock()
|
|
|
|
// a later default.foo BY-NAME lookup issues its own describe (cross-db
|
|
// mis-hit protection preserved)
|
|
_, err = cache.GetCollectionID(ctx, "default", "foo")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int32(3), atomic.LoadInt32(&describeCount))
|
|
}
|
|
|
|
// TestMetaCache_ByIDDefaultedRequestDBNameNotCached covers the case where the
|
|
// request db is not empty but was defaulted by database_interceptor: an
|
|
// external id-only lookup has its empty DbName filled with "default" before it
|
|
// reaches the cache. That db is NOT authoritative for an id lookup, and the
|
|
// describe response carries no real db either -- so the entry is served
|
|
// UNCACHED (its true database is unknown; a cached copy could never be
|
|
// invalidated by database DDL, and a name hint under default.<name> could
|
|
// mis-hit a collection of another database).
|
|
func TestMetaCache_ByIDDefaultedRequestDBNameNotCached(t *testing.T) {
|
|
ctx := context.Background()
|
|
mix := NewMixCoordMock()
|
|
describeCount := int32(0)
|
|
mix.SetDescribeCollectionFunc(func(ctx context.Context, req *milvuspb.DescribeCollectionRequest) (*milvuspb.DescribeCollectionResponse, error) {
|
|
atomic.AddInt32(&describeCount, 1)
|
|
// older rootcoord resolves the name but does not report the real db; the
|
|
// collection actually lives in another database (unknown to the proxy)
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 101,
|
|
DbName: "",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo"},
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
cache, err := NewMetaCache(mix)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// id-only lookup, but the request db arrives as the interceptor-defaulted
|
|
// "default" (not "") — the name still resolves from the response
|
|
name, err := cache.GetCollectionName(ctx, "default", 101)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "foo", name)
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&describeCount))
|
|
|
|
// repeat by-id lookups RE-DESCRIBE: nothing was cached
|
|
name, err = cache.GetCollectionName(ctx, "default", 101)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "foo", name)
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(&describeCount))
|
|
|
|
// nothing in the primary, and the defaulted request db planted no name hint
|
|
cache.mu.RLock()
|
|
assert.Nil(t, collByIDLive(cache, 101))
|
|
assert.Nil(t, cachedEntryLocked(cache, "default", "foo"))
|
|
cache.mu.RUnlock()
|
|
|
|
// a later default.foo BY-NAME lookup issues its own describe (it cannot
|
|
// silently mis-hit a collection of another database)
|
|
_, err = cache.GetCollectionID(ctx, "default", "foo")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int32(3), atomic.LoadInt32(&describeCount))
|
|
}
|
|
|
|
// TestMetaCache_GetCollectionInfoByIDCacheHit verifies GetCollectionInfo serves
|
|
// an id-only lookup straight from the cluster-wide by-id index as a cache HIT,
|
|
// instead of probing id 0 (an unconditional miss) and routing through
|
|
// UpdateByID with a spurious cache-miss metric.
|
|
func TestMetaCache_GetCollectionInfoByIDCacheHit(t *testing.T) {
|
|
ctx := context.Background()
|
|
mix := NewMixCoordMock()
|
|
describeCount := int32(0)
|
|
mix.SetDescribeCollectionFunc(func(ctx context.Context, req *milvuspb.DescribeCollectionRequest) (*milvuspb.DescribeCollectionResponse, error) {
|
|
atomic.AddInt32(&describeCount, 1)
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db1",
|
|
DbId: 3,
|
|
Schema: &schemapb.CollectionSchema{Name: "foo"},
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
cache, err := NewMetaCache(mix)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
hitCounter := metrics.ProxyCacheStatsCounter.WithLabelValues(paramtable.GetStringNodeID(), "GetCollectionInfo", metrics.CacheHitLabel)
|
|
hitBefore := testutil.ToFloat64(hitCounter)
|
|
|
|
// first id-only lookup primes the by-id index (one describe, recorded as a miss)
|
|
info, err := cache.GetCollectionInfo(ctx, "", "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "foo", info.schema.GetName())
|
|
|
|
// a second id-only lookup must be served from the by-id index as a HIT, with
|
|
// no additional describe and no cache-miss metric
|
|
info, err = cache.GetCollectionInfo(ctx, "", "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), info.collID)
|
|
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&describeCount), "cached id-only lookup should not re-describe")
|
|
assert.Equal(t, float64(1), testutil.ToFloat64(hitCounter)-hitBefore,
|
|
"id-only GetCollectionInfo should record exactly one cache hit, not a miss")
|
|
}
|
|
|
|
func TestMetaCache_EmptyDBNameSharesDefaultEntry(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
id, err := globalMetaCache.GetCollectionID(ctx, "", "collection1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), id)
|
|
assert.Equal(t, 1, rootCoord.GetAccessCount())
|
|
|
|
// explicit default db, by-id with empty db, and by-id with an unrelated db
|
|
// (ids are cluster-unique, rootcoord ignores the db for by-id describes)
|
|
// are all served by the same entry without further RPCs
|
|
id, err = globalMetaCache.GetCollectionID(ctx, "default", "collection1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), id)
|
|
name, err := globalMetaCache.GetCollectionName(ctx, "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "collection1", name)
|
|
name, err = globalMetaCache.GetCollectionName(ctx, "some_other_db", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "collection1", name)
|
|
assert.Equal(t, 1, rootCoord.GetAccessCount())
|
|
}
|
|
|
|
func TestMetaCache_GetCollectionName(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
collection, err := globalMetaCache.GetCollectionName(ctx, GetCurDBNameFromContextOrDefault(ctx), 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, collection, "collection1")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 1)
|
|
|
|
// should'nt be accessed to remote root coord.
|
|
schema, err := globalMetaCache.GetCollectionSchema(ctx, dbName, "collection1")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 1)
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection1",
|
|
})
|
|
collection, err = globalMetaCache.GetCollectionName(ctx, GetCurDBNameFromContextOrDefault(ctx), 1)
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, collection, "collection1")
|
|
schema, err = globalMetaCache.GetCollectionSchema(ctx, dbName, "collection2")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 2)
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection2",
|
|
})
|
|
|
|
// test to get from cache, this should trigger root request
|
|
collection, err = globalMetaCache.GetCollectionName(ctx, GetCurDBNameFromContextOrDefault(ctx), 1)
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 2)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, collection, "collection1")
|
|
schema, err = globalMetaCache.GetCollectionSchema(ctx, dbName, "collection1")
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 2)
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection1",
|
|
})
|
|
}
|
|
|
|
func TestMetaCache_GetCollectionFailure(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
rootCoord.Error = true
|
|
|
|
schema, err := globalMetaCache.GetCollectionSchema(ctx, dbName, "collection1")
|
|
assert.Error(t, err)
|
|
assert.Nil(t, schema)
|
|
|
|
rootCoord.Error = false
|
|
|
|
schema, err = globalMetaCache.GetCollectionSchema(ctx, dbName, "collection1")
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection1",
|
|
})
|
|
|
|
rootCoord.Error = true
|
|
// should be cached with no error
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection1",
|
|
})
|
|
}
|
|
|
|
func TestMetaCache_GetNonExistCollection(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
id, err := globalMetaCache.GetCollectionID(ctx, dbName, "collection3")
|
|
assert.Error(t, err)
|
|
assert.Equal(t, id, int64(0))
|
|
schema, err := globalMetaCache.GetCollectionSchema(ctx, dbName, "collection3")
|
|
assert.Error(t, err)
|
|
assert.Nil(t, schema)
|
|
}
|
|
|
|
func TestMetaCache_GetPartitionID(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
id, err := globalMetaCache.GetPartitionID(ctx, dbName, "collection1", "par1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(1))
|
|
id, err = globalMetaCache.GetPartitionID(ctx, dbName, "collection1", "par2")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(2))
|
|
id, err = globalMetaCache.GetPartitionID(ctx, dbName, "collection2", "par1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(3))
|
|
id, err = globalMetaCache.GetPartitionID(ctx, dbName, "collection2", "par2")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(4))
|
|
}
|
|
|
|
func TestMetaCache_ConcurrentTest1(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
var wg sync.WaitGroup
|
|
cnt := 100
|
|
getCollectionCacheFunc := func(wg *sync.WaitGroup) {
|
|
defer wg.Done()
|
|
for i := 0; i < cnt; i++ {
|
|
// GetCollectionSchema will never fail
|
|
schema, err := globalMetaCache.GetCollectionSchema(ctx, dbName, "collection1")
|
|
assert.NoError(t, err)
|
|
EqualSchema(t, schema.CollectionSchema, &schemapb.CollectionSchema{
|
|
AutoID: true,
|
|
Fields: []*schemapb.FieldSchema{},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
Name: "collection1",
|
|
})
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
getPartitionCacheFunc := func(wg *sync.WaitGroup) {
|
|
defer wg.Done()
|
|
for i := 0; i < cnt; i++ {
|
|
// GetPartitions may fail
|
|
globalMetaCache.GetPartitions(ctx, dbName, "collection1")
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
invalidCacheFunc := func(wg *sync.WaitGroup) {
|
|
defer wg.Done()
|
|
for i := 0; i < cnt; i++ {
|
|
// periodically invalid collection cache
|
|
globalMetaCache.RemoveCollection(ctx, dbName, "collection1")
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
wg.Add(1)
|
|
go getCollectionCacheFunc(&wg)
|
|
|
|
wg.Add(1)
|
|
go invalidCacheFunc(&wg)
|
|
|
|
wg.Add(1)
|
|
go getPartitionCacheFunc(&wg)
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestMetaCache_GetPartitionError(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
// Test the case where ShowPartitionsResponse is not aligned
|
|
id, err := globalMetaCache.GetPartitionID(ctx, dbName, "errorCollection", "par1")
|
|
assert.Error(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(0))
|
|
|
|
partitions, err2 := globalMetaCache.GetPartitions(ctx, dbName, "errorCollection")
|
|
assert.NotNil(t, err2)
|
|
assert.Equal(t, len(partitions), 0)
|
|
|
|
// Test non existed tables
|
|
id, err = globalMetaCache.GetPartitionID(ctx, dbName, "nonExisted", "par1")
|
|
assert.Error(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(0))
|
|
|
|
// Test non existed partition
|
|
id, err = globalMetaCache.GetPartitionID(ctx, dbName, "collection1", "par3")
|
|
assert.Error(t, err)
|
|
assert.Equal(t, id, typeutil.UniqueID(0))
|
|
}
|
|
|
|
func TestMetaCache_GetShard(t *testing.T) {
|
|
t.Skip("GetShard has been moved to ShardClientMgr in shardclient package")
|
|
// Test body removed - functionality moved to shardclient package
|
|
}
|
|
|
|
func TestMetaCache_ClearShards(t *testing.T) {
|
|
t.Skip("DeprecateShardCache has been moved to ShardClientMgr in shardclient package")
|
|
// Test body removed - functionality moved to shardclient package
|
|
}
|
|
|
|
func TestMetaCache_PolicyInfo(t *testing.T) {
|
|
client := &MockMixCoordClientInterface{}
|
|
|
|
t.Run("InitMetaCache", func(t *testing.T) {
|
|
client.listPolicy = func(ctx context.Context, in *internalpb.ListPolicyRequest) (*internalpb.ListPolicyResponse, error) {
|
|
return nil, errors.New("mock error")
|
|
}
|
|
err := InitMetaCache(context.Background(), client)
|
|
assert.Error(t, err)
|
|
|
|
client.listPolicy = func(ctx context.Context, in *internalpb.ListPolicyRequest) (*internalpb.ListPolicyResponse, error) {
|
|
return &internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
PolicyInfos: []string{"policy1", "policy2", "policy3"},
|
|
}, nil
|
|
}
|
|
err = InitMetaCache(context.Background(), client)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("GetPrivilegeInfo", func(t *testing.T) {
|
|
client.listPolicy = func(ctx context.Context, in *internalpb.ListPolicyRequest) (*internalpb.ListPolicyResponse, error) {
|
|
return &internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
PolicyInfos: []string{"policy1", "policy2", "policy3"},
|
|
UserRoles: []string{funcutil.EncodeUserRoleCache("foo", "role1"), funcutil.EncodeUserRoleCache("foo", "role2"), funcutil.EncodeUserRoleCache("foo2", "role2")},
|
|
}, nil
|
|
}
|
|
err := InitMetaCache(context.Background(), client)
|
|
assert.NoError(t, err)
|
|
policyInfos := privilege.GetPrivilegeCache().GetPrivilegeInfo(context.Background())
|
|
assert.Equal(t, 3, len(policyInfos))
|
|
roles := privilege.GetPrivilegeCache().GetUserRole("foo")
|
|
assert.Equal(t, 2, len(roles))
|
|
})
|
|
|
|
t.Run("GetPrivilegeInfo", func(t *testing.T) {
|
|
client.listPolicy = func(ctx context.Context, in *internalpb.ListPolicyRequest) (*internalpb.ListPolicyResponse, error) {
|
|
return &internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
PolicyInfos: []string{"policy1", "policy2", "policy3"},
|
|
UserRoles: []string{funcutil.EncodeUserRoleCache("foo", "role1"), funcutil.EncodeUserRoleCache("foo", "role2"), funcutil.EncodeUserRoleCache("foo2", "role2")},
|
|
}, nil
|
|
}
|
|
err := InitMetaCache(context.Background(), client)
|
|
assert.NoError(t, err)
|
|
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: typeutil.CacheGrantPrivilege, OpKey: "policyX"})
|
|
assert.NoError(t, err)
|
|
policyInfos := privilege.GetPrivilegeCache().GetPrivilegeInfo(context.Background())
|
|
assert.Equal(t, 4, len(policyInfos))
|
|
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: typeutil.CacheRevokePrivilege, OpKey: "policyX"})
|
|
assert.NoError(t, err)
|
|
policyInfos = privilege.GetPrivilegeCache().GetPrivilegeInfo(context.Background())
|
|
assert.Equal(t, 3, len(policyInfos))
|
|
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: typeutil.CacheAddUserToRole, OpKey: funcutil.EncodeUserRoleCache("foo", "role3")})
|
|
assert.NoError(t, err)
|
|
roles := privilege.GetPrivilegeCache().GetUserRole("foo")
|
|
assert.Equal(t, 3, len(roles))
|
|
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: typeutil.CacheRemoveUserFromRole, OpKey: funcutil.EncodeUserRoleCache("foo", "role3")})
|
|
assert.NoError(t, err)
|
|
roles = privilege.GetPrivilegeCache().GetUserRole("foo")
|
|
assert.Equal(t, 2, len(roles))
|
|
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: typeutil.CacheGrantPrivilege, OpKey: ""})
|
|
assert.Error(t, err)
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: 100, OpKey: "policyX"})
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("Delete user or drop role", func(t *testing.T) {
|
|
client.listPolicy = func(ctx context.Context, in *internalpb.ListPolicyRequest) (*internalpb.ListPolicyResponse, error) {
|
|
return &internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
PolicyInfos: []string{
|
|
funcutil.PolicyForPrivilege("role2", "Collection", "collection1", "read", "default"),
|
|
"policy2",
|
|
"policy3",
|
|
},
|
|
UserRoles: []string{funcutil.EncodeUserRoleCache("foo", "role1"), funcutil.EncodeUserRoleCache("foo", "role2"), funcutil.EncodeUserRoleCache("foo2", "role2"), funcutil.EncodeUserRoleCache("foo2", "role3")},
|
|
}, nil
|
|
}
|
|
err := InitMetaCache(context.Background(), client)
|
|
assert.NoError(t, err)
|
|
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: typeutil.CacheDeleteUser, OpKey: "foo"})
|
|
assert.NoError(t, err)
|
|
|
|
roles := privilege.GetPrivilegeCache().GetUserRole("foo")
|
|
assert.Len(t, roles, 0)
|
|
|
|
roles = privilege.GetPrivilegeCache().GetUserRole("foo2")
|
|
assert.Len(t, roles, 2)
|
|
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: typeutil.CacheDropRole, OpKey: "role2"})
|
|
assert.NoError(t, err)
|
|
roles = privilege.GetPrivilegeCache().GetUserRole("foo2")
|
|
assert.Len(t, roles, 1)
|
|
assert.Equal(t, "role3", roles[0])
|
|
|
|
client.listPolicy = func(ctx context.Context, in *internalpb.ListPolicyRequest) (*internalpb.ListPolicyResponse, error) {
|
|
return &internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
PolicyInfos: []string{"policy1", "policy2", "policy3"},
|
|
UserRoles: []string{funcutil.EncodeUserRoleCache("foo", "role1"), funcutil.EncodeUserRoleCache("foo", "role2"), funcutil.EncodeUserRoleCache("foo2", "role2"), funcutil.EncodeUserRoleCache("foo2", "role3")},
|
|
}, nil
|
|
}
|
|
err = privilege.GetPrivilegeCache().RefreshPolicyInfo(typeutil.CacheOp{OpType: typeutil.CacheRefresh})
|
|
assert.NoError(t, err)
|
|
roles = privilege.GetPrivilegeCache().GetUserRole("foo")
|
|
assert.Len(t, roles, 2)
|
|
})
|
|
}
|
|
|
|
func TestMetaCache_RemoveCollection(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
rootCoord.showLoadCollections = func(ctx context.Context, in *querypb.ShowCollectionsRequest) (*querypb.ShowCollectionsResponse, error) {
|
|
return &querypb.ShowCollectionsResponse{
|
|
Status: merr.Success(),
|
|
CollectionIDs: []UniqueID{1, 2},
|
|
InMemoryPercentages: []int64{100, 50},
|
|
}, nil
|
|
}
|
|
|
|
_, err = globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1", 1)
|
|
assert.NoError(t, err)
|
|
// no collectionInfo of collection1, should access RootCoord
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 1)
|
|
|
|
_, err = globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1", 1)
|
|
assert.NoError(t, err)
|
|
// shouldn't access RootCoord again
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 1)
|
|
|
|
globalMetaCache.RemoveCollection(ctx, dbName, "collection1")
|
|
// no collectionInfo of collection2, should access RootCoord
|
|
_, err = globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1", 1)
|
|
assert.NoError(t, err)
|
|
// shouldn't access RootCoord again
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 2)
|
|
|
|
globalMetaCache.RemoveCollectionsByID(ctx, UniqueID(1))
|
|
// no collectionInfo of collection2, should access RootCoord
|
|
_, err = globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1", 1)
|
|
assert.NoError(t, err)
|
|
// shouldn't access RootCoord again
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 3)
|
|
|
|
globalMetaCache.RemoveCollectionsByID(ctx, UniqueID(1))
|
|
// no collectionInfo of collection2, should access RootCoord
|
|
_, err = globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1", 1)
|
|
assert.NoError(t, err)
|
|
// no collectionInfo of collection1, should access RootCoord
|
|
assert.Equal(t, rootCoord.GetAccessCount(), 4)
|
|
}
|
|
|
|
func TestGlobalMetaCache_ShuffleShardLeaders(t *testing.T) {
|
|
t.Skip("shardLeaders and nodeInfo have been moved to shardclient package")
|
|
// Test body removed - functionality moved to shardclient package
|
|
}
|
|
|
|
func TestMetaCache_Database(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := &MockMixCoordClientInterface{}
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, globalMetaCache.HasDatabase(ctx, dbName), false)
|
|
|
|
_, err = globalMetaCache.GetCollectionInfo(ctx, dbName, "collection1", 1)
|
|
assert.NoError(t, err)
|
|
_, err = GetCachedCollectionSchema(ctx, dbName, "collection1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, globalMetaCache.HasDatabase(ctx, dbName), true)
|
|
assert.Equal(t, CheckDatabase(ctx, dbName), true)
|
|
}
|
|
|
|
func TestGetDatabaseInfo(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
rootCoord.EXPECT().DescribeDatabase(mock.Anything, mock.Anything).Return(&rootcoordpb.DescribeDatabaseResponse{
|
|
Status: merr.Success(),
|
|
DbID: 1,
|
|
DbName: "default",
|
|
}, nil).Once()
|
|
{
|
|
dbInfo, err := cache.GetDatabaseInfo(ctx, "default")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), dbInfo.dbID)
|
|
}
|
|
|
|
{
|
|
dbInfo, err := cache.GetDatabaseInfo(ctx, "default")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), dbInfo.dbID)
|
|
}
|
|
})
|
|
|
|
t.Run("error", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
rootCoord.EXPECT().DescribeDatabase(mock.Anything, mock.Anything).Return(&rootcoordpb.DescribeDatabaseResponse{
|
|
Status: merr.Status(errors.New("mock error: describe database")),
|
|
}, nil).Once()
|
|
_, err = cache.GetDatabaseInfo(ctx, "default")
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestMetaCache_RemoveDatabaseInfoKeepsCollectionMetadata(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
var altered atomic.Bool
|
|
rootCoord.EXPECT().DescribeDatabase(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *rootcoordpb.DescribeDatabaseRequest, opts ...grpc.CallOption) (*rootcoordpb.DescribeDatabaseResponse, error) {
|
|
value := "old"
|
|
if altered.Load() {
|
|
value = "new"
|
|
}
|
|
return &rootcoordpb.DescribeDatabaseResponse{
|
|
Status: merr.Success(),
|
|
DbID: 1,
|
|
DbName: "db",
|
|
Properties: []*commonpb.KeyValuePair{
|
|
{Key: "k", Value: value},
|
|
},
|
|
}, nil
|
|
}).Twice()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
cache.mu.Lock()
|
|
entry := seedCollection(cache, "db", "foo", 10)
|
|
entry.aliases = []string{"a"}
|
|
cache.setAliasLocked("db", "a", "foo")
|
|
cache.partitionCache[buildPartitionCacheKey(10)] = parsePartitionsInfo([]*partitionInfo{{name: "p", partitionID: 100}}, false)
|
|
cache.mu.Unlock()
|
|
|
|
info, err := cache.GetDatabaseInfo(ctx, "db")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "old", info.properties[0].GetValue())
|
|
|
|
altered.Store(true)
|
|
cache.RemoveDatabaseInfo(ctx, "db")
|
|
|
|
cache.mu.RLock()
|
|
_, hasDBInfo := cache.dbInfo["db"]
|
|
collection := collByIDLive(cache, 10)
|
|
nameEntry := cachedEntryLocked(cache, "db", "foo")
|
|
aliasTarget, hasAlias := cache.aliasInfo["db"]["a"]
|
|
_, hasPartitions := cache.partitionCache[buildPartitionCacheKey(10)]
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasDBInfo)
|
|
assert.Same(t, entry, collection)
|
|
assert.Same(t, entry, nameEntry)
|
|
assert.True(t, hasAlias)
|
|
assert.Equal(t, "foo", aliasTarget)
|
|
assert.True(t, hasPartitions)
|
|
|
|
info, err = cache.GetDatabaseInfo(ctx, "db")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "new", info.properties[0].GetValue())
|
|
}
|
|
|
|
func TestMetaCache_RemoveDatabaseInfoBlocksStaleResurrection(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
var first sync.Once
|
|
var altered atomic.Bool
|
|
rootCoord.EXPECT().DescribeDatabase(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *rootcoordpb.DescribeDatabaseRequest, opts ...grpc.CallOption) (*rootcoordpb.DescribeDatabaseResponse, error) {
|
|
isPostAlter := altered.Load()
|
|
if !isPostAlter {
|
|
first.Do(func() {
|
|
close(entered)
|
|
<-release
|
|
})
|
|
}
|
|
value := "old"
|
|
if isPostAlter {
|
|
value = "new"
|
|
}
|
|
return &rootcoordpb.DescribeDatabaseResponse{
|
|
Status: merr.Success(),
|
|
DbID: 1,
|
|
DbName: "db",
|
|
Properties: []*commonpb.KeyValuePair{
|
|
{Key: "k", Value: value},
|
|
},
|
|
}, nil
|
|
}).Twice()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
fillDone := make(chan *databaseInfo, 1)
|
|
fillErr := make(chan error, 1)
|
|
go func() {
|
|
info, err := cache.GetDatabaseInfo(ctx, "db")
|
|
fillDone <- info
|
|
fillErr <- err
|
|
}()
|
|
<-entered
|
|
|
|
invalidationDone := make(chan struct{})
|
|
go func() {
|
|
defer close(invalidationDone)
|
|
cache.RemoveDatabaseInfo(ctx, "db")
|
|
}()
|
|
select {
|
|
case <-invalidationDone:
|
|
t.Fatal("RemoveDatabaseInfo must drain the pre-alter database fill")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
altered.Store(true)
|
|
close(release)
|
|
oldInfo := <-fillDone
|
|
assert.NoError(t, <-fillErr)
|
|
assert.Equal(t, "old", oldInfo.properties[0].GetValue())
|
|
<-invalidationDone
|
|
assert.Nil(t, cache.safeGetDBInfo("db"), "the pre-alter write-back must be removed")
|
|
|
|
newInfo, err := cache.GetDatabaseInfo(ctx, "db")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "new", newInfo.properties[0].GetValue())
|
|
}
|
|
|
|
func TestMetaCache_AllocID(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("success", func(t *testing.T) {
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().AllocID(mock.Anything, mock.Anything).Return(&rootcoordpb.AllocIDResponse{
|
|
Status: merr.Status(nil),
|
|
ID: 11198,
|
|
Count: 10,
|
|
}, nil)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
PolicyInfos: []string{"policy1", "policy2", "policy3"},
|
|
}, nil)
|
|
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, globalMetaCache.HasDatabase(ctx, dbName), false)
|
|
|
|
id, err := globalMetaCache.AllocID(ctx)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, id, int64(11198))
|
|
})
|
|
|
|
t.Run("error", func(t *testing.T) {
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().AllocID(mock.Anything, mock.Anything).Return(&rootcoordpb.AllocIDResponse{
|
|
Status: merr.Status(nil),
|
|
}, errors.New("mock error"))
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
PolicyInfos: []string{"policy1", "policy2", "policy3"},
|
|
}, nil)
|
|
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, globalMetaCache.HasDatabase(ctx, dbName), false)
|
|
|
|
id, err := globalMetaCache.AllocID(ctx)
|
|
assert.Error(t, err)
|
|
assert.Equal(t, id, int64(0))
|
|
})
|
|
|
|
t.Run("failed", func(t *testing.T) {
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().AllocID(mock.Anything, mock.Anything).Return(&rootcoordpb.AllocIDResponse{
|
|
Status: merr.Status(errors.New("mock failed")),
|
|
}, nil)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
PolicyInfos: []string{"policy1", "policy2", "policy3"},
|
|
}, nil)
|
|
|
|
err := InitMetaCache(ctx, rootCoord)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, globalMetaCache.HasDatabase(ctx, dbName), false)
|
|
|
|
id, err := globalMetaCache.AllocID(ctx)
|
|
assert.Error(t, err)
|
|
assert.Equal(t, id, int64(0))
|
|
})
|
|
}
|
|
|
|
func TestMetaCache_InvalidateShardLeaderCache(t *testing.T) {
|
|
t.Skip("GetShard and InvalidateShardLeaderCache have been moved to ShardClientMgr in shardclient package")
|
|
// Test body removed - functionality moved to shardclient package
|
|
}
|
|
|
|
func TestSchemaInfo_GetLoadFieldIDs(t *testing.T) {
|
|
type testCase struct {
|
|
tag string
|
|
schema *schemapb.CollectionSchema
|
|
loadFields []string
|
|
skipDynamicField bool
|
|
expectResult []int64
|
|
expectErr bool
|
|
}
|
|
|
|
rowIDField := &schemapb.FieldSchema{
|
|
FieldID: common.RowIDField,
|
|
Name: common.RowIDFieldName,
|
|
DataType: schemapb.DataType_Int64,
|
|
}
|
|
timestampField := &schemapb.FieldSchema{
|
|
FieldID: common.TimeStampField,
|
|
Name: common.TimeStampFieldName,
|
|
DataType: schemapb.DataType_Int64,
|
|
}
|
|
pkField := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID,
|
|
Name: "pk",
|
|
DataType: schemapb.DataType_Int64,
|
|
IsPrimaryKey: true,
|
|
}
|
|
scalarField := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 1,
|
|
Name: "text",
|
|
DataType: schemapb.DataType_VarChar,
|
|
}
|
|
scalarFieldSkipLoad := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 1,
|
|
Name: "text",
|
|
DataType: schemapb.DataType_VarChar,
|
|
TypeParams: []*commonpb.KeyValuePair{
|
|
{Key: common.FieldSkipLoadKey, Value: "true"},
|
|
},
|
|
}
|
|
partitionKeyField := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 2,
|
|
Name: "part_key",
|
|
DataType: schemapb.DataType_Int64,
|
|
IsPartitionKey: true,
|
|
}
|
|
vectorField := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 3,
|
|
Name: "vector",
|
|
DataType: schemapb.DataType_FloatVector,
|
|
TypeParams: []*commonpb.KeyValuePair{
|
|
{Key: common.DimKey, Value: "768"},
|
|
},
|
|
}
|
|
dynamicField := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 4,
|
|
Name: common.MetaFieldName,
|
|
DataType: schemapb.DataType_JSON,
|
|
IsDynamic: true,
|
|
}
|
|
clusteringKeyField := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 5,
|
|
Name: "clustering_key",
|
|
DataType: schemapb.DataType_Int32,
|
|
IsClusteringKey: true,
|
|
}
|
|
|
|
subIntField := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 7,
|
|
Name: "sub_int",
|
|
DataType: schemapb.DataType_Array,
|
|
ElementType: schemapb.DataType_Int32,
|
|
}
|
|
subFloatVectorField := &schemapb.FieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 8,
|
|
Name: "sub_float_vector",
|
|
DataType: schemapb.DataType_ArrayOfVector,
|
|
ElementType: schemapb.DataType_FloatVector,
|
|
TypeParams: []*commonpb.KeyValuePair{
|
|
{Key: common.DimKey, Value: "768"},
|
|
},
|
|
}
|
|
structArrayField := &schemapb.StructArrayFieldSchema{
|
|
FieldID: common.StartOfUserFieldID + 6,
|
|
Name: "struct_array",
|
|
Fields: []*schemapb.FieldSchema{
|
|
subIntField,
|
|
subFloatVectorField,
|
|
},
|
|
}
|
|
|
|
testCases := []testCase{
|
|
{
|
|
tag: "default",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
dynamicField,
|
|
},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
},
|
|
loadFields: nil,
|
|
skipDynamicField: false,
|
|
expectResult: []int64{},
|
|
expectErr: false,
|
|
},
|
|
{
|
|
tag: "default_from_schema",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarFieldSkipLoad,
|
|
partitionKeyField,
|
|
vectorField,
|
|
dynamicField,
|
|
clusteringKeyField,
|
|
},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
},
|
|
loadFields: nil,
|
|
skipDynamicField: false,
|
|
expectResult: []int64{common.StartOfUserFieldID, common.StartOfUserFieldID + 2, common.StartOfUserFieldID + 3, common.StartOfUserFieldID + 4, common.StartOfUserFieldID + 5},
|
|
expectErr: false,
|
|
},
|
|
{
|
|
tag: "load_fields",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
dynamicField,
|
|
clusteringKeyField,
|
|
},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
},
|
|
loadFields: []string{"pk", "part_key", "vector", "clustering_key"},
|
|
skipDynamicField: false,
|
|
expectResult: []int64{common.StartOfUserFieldID, common.StartOfUserFieldID + 2, common.StartOfUserFieldID + 3, common.StartOfUserFieldID + 4, common.StartOfUserFieldID + 5},
|
|
expectErr: false,
|
|
},
|
|
{
|
|
tag: "load_fields_skip_dynamic",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
dynamicField,
|
|
},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
},
|
|
loadFields: []string{"pk", "part_key", "vector"},
|
|
skipDynamicField: true,
|
|
expectResult: []int64{common.StartOfUserFieldID, common.StartOfUserFieldID + 2, common.StartOfUserFieldID + 3},
|
|
expectErr: false,
|
|
},
|
|
{
|
|
tag: "pk_not_loaded",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
dynamicField,
|
|
},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
},
|
|
loadFields: []string{"part_key", "vector"},
|
|
skipDynamicField: true,
|
|
expectErr: true,
|
|
},
|
|
{
|
|
tag: "part_key_not_loaded",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
dynamicField,
|
|
},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
},
|
|
loadFields: []string{"pk", "vector"},
|
|
skipDynamicField: true,
|
|
expectErr: true,
|
|
},
|
|
{
|
|
tag: "vector_not_loaded",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
dynamicField,
|
|
},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
},
|
|
loadFields: []string{"pk", "part_key"},
|
|
skipDynamicField: true,
|
|
expectErr: true,
|
|
},
|
|
{
|
|
tag: "clustering_key_not_loaded",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
clusteringKeyField,
|
|
},
|
|
Functions: []*schemapb.FunctionSchema{},
|
|
},
|
|
loadFields: []string{"pk", "part_key", "vector"},
|
|
expectErr: true,
|
|
},
|
|
{
|
|
tag: "struct_array_field_default",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
clusteringKeyField,
|
|
},
|
|
StructArrayFields: []*schemapb.StructArrayFieldSchema{
|
|
structArrayField,
|
|
},
|
|
},
|
|
loadFields: nil,
|
|
skipDynamicField: false,
|
|
expectResult: []int64{},
|
|
expectErr: false,
|
|
},
|
|
{
|
|
tag: "load_struct_array_field",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
clusteringKeyField,
|
|
},
|
|
StructArrayFields: []*schemapb.StructArrayFieldSchema{
|
|
structArrayField,
|
|
},
|
|
},
|
|
loadFields: []string{"pk", "part_key", "clustering_key", "struct_array"},
|
|
skipDynamicField: false,
|
|
expectResult: []int64{common.StartOfUserFieldID, common.StartOfUserFieldID + 2, common.StartOfUserFieldID + 5, common.StartOfUserFieldID + 7, common.StartOfUserFieldID + 8},
|
|
expectErr: false,
|
|
},
|
|
{
|
|
tag: "load_struct_array_field_with_vector",
|
|
schema: &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
rowIDField,
|
|
timestampField,
|
|
pkField,
|
|
scalarField,
|
|
partitionKeyField,
|
|
vectorField,
|
|
clusteringKeyField,
|
|
},
|
|
StructArrayFields: []*schemapb.StructArrayFieldSchema{
|
|
structArrayField,
|
|
},
|
|
},
|
|
loadFields: []string{"pk", "part_key", "clustering_key", "vector", "struct_array"},
|
|
skipDynamicField: false,
|
|
expectResult: []int64{common.StartOfUserFieldID, common.StartOfUserFieldID + 2, common.StartOfUserFieldID + 3, common.StartOfUserFieldID + 5, common.StartOfUserFieldID + 7, common.StartOfUserFieldID + 8},
|
|
expectErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.tag, func(t *testing.T) {
|
|
info := mustNewSchemaInfo(tc.schema)
|
|
|
|
result, err := info.GetLoadFieldIDs(tc.loadFields, tc.skipDynamicField)
|
|
if tc.expectErr {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
|
|
assert.NoError(t, err)
|
|
assert.ElementsMatch(t, tc.expectResult, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMetaCache_FillInvalidateOrdering replaces the old version-floor test
|
|
// (TestMetaCache_Parallel): cache fills are ordered against invalidations by
|
|
// fillMu instead of a per-collection version comparison. A fill whose describe
|
|
// RPC is in flight when an invalidation arrives must be DRAINED by the
|
|
// invalidation — its (possibly pre-DDL) write-back lands before the eviction
|
|
// runs, so the eviction cleans it and a stale snapshot can never outlive the
|
|
// DDL that invalidated it.
|
|
func TestMetaCache_FillInvalidateOrdering(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
entered := make(chan struct{})
|
|
gate := make(chan struct{})
|
|
var once sync.Once
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
// first call parks in the "RPC in flight" state until released
|
|
once.Do(func() {
|
|
close(entered)
|
|
<-gate
|
|
})
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 111,
|
|
DbName: dbName,
|
|
Schema: &schemapb.CollectionSchema{Name: "collection1", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 100, // pre-DDL snapshot
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// start a fill and park it inside the describe RPC
|
|
fillDone := make(chan struct{})
|
|
go func() {
|
|
defer close(fillDone)
|
|
_, err := cache.GetCollectionInfo(ctx, dbName, "collection1", 0)
|
|
assert.NoError(t, err)
|
|
}()
|
|
<-entered
|
|
|
|
// the invalidation must WAIT for the in-flight fill
|
|
invalDone := make(chan struct{})
|
|
go func() {
|
|
defer close(invalDone)
|
|
cache.RemoveCollectionsByID(ctx, 111)
|
|
}()
|
|
select {
|
|
case <-invalDone:
|
|
t.Fatal("invalidation must drain the in-flight fill before evicting")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
// release the fill: its write-back lands first, then the eviction runs
|
|
close(gate)
|
|
<-fillDone
|
|
<-invalDone
|
|
|
|
cache.mu.RLock()
|
|
assert.Nil(t, cachedEntryLocked(cache, dbName, "collection1"), "the drained fill's pre-DDL write-back must not survive the eviction")
|
|
assert.Nil(t, collByIDLive(cache, 111))
|
|
cache.mu.RUnlock()
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
func TestMetaCache_GetShardLeaderList(t *testing.T) {
|
|
t.Skip("GetShardLeaderList has been moved to ShardClientMgr in shardclient package")
|
|
// Test body removed - functionality moved to shardclient package
|
|
}
|
|
|
|
func TestMetaCache_GetPartitionInfo_CacheHit(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// the cold fill costs two describes -- one for the collection resolution and
|
|
// one inside the partition-list filler; the second lookup hits both caches
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "collection", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 1000,
|
|
}, nil).Times(2)
|
|
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).Return(&milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100},
|
|
PartitionNames: []string{"par1"},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil).Once()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
info1, err := cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(100), info1.partitionID)
|
|
assert.Equal(t, "par1", info1.name)
|
|
|
|
info2, err := cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, info1, info2)
|
|
}
|
|
|
|
func TestMetaCache_GetPartitionInfo_DefaultPartition(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// two describes on the cold fill: collection resolution + partition-list filler
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "collection", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 1000,
|
|
}, nil).Times(2)
|
|
|
|
defaultPartitionName := Params.CommonCfg.DefaultPartitionName.GetValue()
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.MatchedBy(func(req *milvuspb.ShowPartitionsRequest) bool {
|
|
return len(req.PartitionNames) == 0
|
|
})).Return(&milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{1},
|
|
PartitionNames: []string{defaultPartitionName},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil).Once()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
info, err := cache.GetPartitionInfo(ctx, "db", "collection", "")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(1), info.partitionID)
|
|
assert.Equal(t, defaultPartitionName, info.name)
|
|
}
|
|
|
|
func TestMetaCache_GetPartitionInfo_Error(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// two describes: collection resolution + the filler's describe, which
|
|
// precedes the failing ShowPartitions inside the fill
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "collection", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 1000,
|
|
}, nil).Times(2)
|
|
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).Return(nil, errors.New("connection failed")).Once()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestMetaCache_GetPartitionInfos_CacheHit(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// one describe for the name resolution (fills the collection cache) and one inside the
|
|
// collection-level partition fill; the second GetPartitionInfos hits both caches
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "collection",
|
|
Fields: []*schemapb.FieldSchema{},
|
|
},
|
|
RequestTime: 1000,
|
|
}, nil).Times(2)
|
|
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).Return(&milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100, 101},
|
|
PartitionNames: []string{"par1", "par2"},
|
|
CreatedTimestamps: []uint64{1000, 1001},
|
|
CreatedUtcTimestamps: []uint64{1000, 1001},
|
|
}, nil).Once()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
infos1, err := cache.GetPartitionInfos(ctx, "db", "collection")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 2, len(infos1.partitionInfos))
|
|
|
|
infos2, err := cache.GetPartitionInfos(ctx, "db", "collection")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, infos1, infos2)
|
|
}
|
|
|
|
func TestMetaCache_RemovePartition(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// Four describes: the initial resolution + the partition-list filler's
|
|
// describe on the cold fill, then both again after the partition DDL staled
|
|
// the collection entry and the partition list.
|
|
describeCalls := 0
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
describeCalls++
|
|
requestTime := uint64(1000)
|
|
if describeCalls > 1 {
|
|
requestTime = 3000
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
// A real rootcoord always reports the collection's db, so this
|
|
// exercises the normal (cached-collection) invalidation path. The
|
|
// older-rootcoord empty-DbName upgrade path is covered by
|
|
// TestMetaCache_RemovePartitionViaAliasInvalidatesRealNameOnEmptyDBName.
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "collection",
|
|
Fields: []*schemapb.FieldSchema{},
|
|
},
|
|
Aliases: []string{"alias"},
|
|
RequestTime: requestTime,
|
|
}, nil
|
|
}).Times(4)
|
|
|
|
// Two fills: the initial one, and the refetch after the partition DDL staled
|
|
// the exact real-name key. The alias lookups resolve to the real name and hit
|
|
// the same entries, so they trigger no extra RPC.
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).Return(&milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100},
|
|
PartitionNames: []string{"par1"},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil).Times(2)
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "alias", "par1")
|
|
assert.NoError(t, err)
|
|
|
|
cache.RemovePartition(ctx, "db", 1, "alias", "par1")
|
|
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "alias", "par1")
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
// TestMetaCache_RemovePartitionViaAliasInvalidatesRealNameOnEmptyDBName is a
|
|
// regression test for the rolling-upgrade case: an older rootcoord omits DbName
|
|
// from describe responses. A partition DDL issued via an alias must still
|
|
// invalidate the partition entries populated through the real name — under the
|
|
// id-keyed partition cache this holds because both spellings resolve to the
|
|
// same collection id, no matter how the entry was primed.
|
|
func TestMetaCache_RemovePartitionViaAliasInvalidatesRealNameOnEmptyDBName(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// older rootcoord: the describe resolves the real name + aliases but reports
|
|
// no db name (for a by-name fill the request db is authoritative instead).
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "",
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "collection",
|
|
Fields: []*schemapb.FieldSchema{},
|
|
},
|
|
Aliases: []string{"alias"},
|
|
RequestTime: 1000,
|
|
}, nil).Maybe()
|
|
|
|
var mu sync.Mutex
|
|
// the fill shows partitions BY the resolved collection id (empty name), so
|
|
// count by id
|
|
showByID := map[int64]int{}
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
mu.Lock()
|
|
showByID[req.GetCollectionID()]++
|
|
mu.Unlock()
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100},
|
|
PartitionNames: []string{"par1"},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// a data-plane access primed the partition cache via the REAL collection
|
|
// name (keyed by the resolved collection id)
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
|
|
// a DropPartition issued via the alias arrives: collectionID is the real id,
|
|
// collectionName is the alias
|
|
cache.RemovePartition(ctx, "db", 1, "alias", "par1")
|
|
|
|
// the real-name partition entry must have been invalidated too, so this
|
|
// re-fetches (a second ShowPartitions for "collection") rather than serving
|
|
// the stale cached partition
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
assert.Equal(t, 2, showByID[1],
|
|
"the collection-id partition cache should be invalidated by an alias-based partition DDL even when the collection is uncached (empty DbName)")
|
|
}
|
|
|
|
func TestMetaCache_RemovePartitionInvalidatesCollectionInfo(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
describeCalls := 0
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
describeCalls++
|
|
numPartitions := int64(1)
|
|
requestTime := uint64(1000)
|
|
if describeCalls > 1 {
|
|
numPartitions = 2
|
|
requestTime = 3000
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
NumPartitions: numPartitions,
|
|
RequestTime: requestTime,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "collection",
|
|
Fields: []*schemapb.FieldSchema{},
|
|
},
|
|
}, nil
|
|
}).Times(2)
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
info, err := cache.GetCollectionInfo(ctx, "db", "collection", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(1), info.numPartitions)
|
|
|
|
cache.RemovePartition(ctx, "db", 1, "collection", "par1")
|
|
|
|
info, err = cache.GetCollectionInfo(ctx, "db", "collection", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(2), info.numPartitions)
|
|
}
|
|
|
|
// TestMetaCache_RemovePartitionStalesCollectionInfoUnconditionally: with fills
|
|
// serialized against invalidations by fillMu, partition DDL no longer compares a
|
|
// version floor — it always stales the collection entry. A duplicate or late
|
|
// broadcast at worst causes one extra re-describe (over-invalidation), never a
|
|
// stale read. (This intentionally replaces the old KeepsCollectionInfoForStale-
|
|
// Version behavior, whose floor was removed together with collectionCacheVersion.)
|
|
func TestMetaCache_RemovePartitionStalesCollectionInfoUnconditionally(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
describeCalls := 0
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
describeCalls++
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
NumPartitions: 1,
|
|
RequestTime: 1000,
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "collection",
|
|
Fields: []*schemapb.FieldSchema{},
|
|
},
|
|
}, nil
|
|
}).Times(2)
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
info, err := cache.GetCollectionInfo(ctx, "db", "collection", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(1), info.numPartitions)
|
|
|
|
// even an older-timestamp partition broadcast evicts the collection entry
|
|
cache.RemovePartition(ctx, "db", 1, "collection", "par1")
|
|
|
|
info, err = cache.GetCollectionInfo(ctx, "db", "collection", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(1), info.numPartitions)
|
|
assert.Equal(t, 2, describeCalls, "the entry was staled, so the lookup re-describes")
|
|
}
|
|
|
|
func TestMetaCache_PartitionCache_Concurrent(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// name resolution before keying; concurrent resolvers merge via the
|
|
// collection singleflight, so no fixed count
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "collection", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 1000,
|
|
}, nil).Maybe()
|
|
|
|
var callCount atomic.Int32
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
callCount.Add(1)
|
|
time.Sleep(50 * time.Millisecond)
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100},
|
|
PartitionNames: []string{"par1"},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
numGoroutines := 10
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < numGoroutines; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, err := cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
assert.Equal(t, int32(1), callCount.Load(), "Singleflight should merge concurrent requests")
|
|
}
|
|
|
|
func TestMetaCache_GetPartitionInfos_SingleflightKeyIncludesDatabase(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
time.Sleep(50 * time.Millisecond)
|
|
// distinct collections in distinct dbs => distinct cluster-unique ids
|
|
id := int64(1)
|
|
if req.GetDbName() == "db2" {
|
|
id = 2
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: req.GetDbName(),
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: req.GetCollectionName(),
|
|
Fields: []*schemapb.FieldSchema{},
|
|
},
|
|
RequestTime: 1000,
|
|
}, nil
|
|
}).Times(4) // per db: one name resolution + one inside the partition fill
|
|
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100},
|
|
PartitionNames: []string{req.GetDbName() + "_par"},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil
|
|
}).Twice()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
type result struct {
|
|
db string
|
|
infos *partitionInfos
|
|
err error
|
|
}
|
|
|
|
results := make(chan result, 2)
|
|
var wg sync.WaitGroup
|
|
start := make(chan struct{})
|
|
|
|
for _, db := range []string{"db1", "db2"} {
|
|
wg.Add(1)
|
|
go func(db string) {
|
|
defer wg.Done()
|
|
<-start
|
|
infos, err := cache.GetPartitionInfos(ctx, db, "collection")
|
|
results <- result{
|
|
db: db,
|
|
infos: infos,
|
|
err: err,
|
|
}
|
|
}(db)
|
|
}
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
for result := range results {
|
|
assert.NoError(t, result.err)
|
|
if assert.NotNil(t, result.infos) && assert.Len(t, result.infos.partitionInfos, 1) {
|
|
assert.Equal(t, result.db+"_par", result.infos.partitionInfos[0].name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMetaCache_GetPartitionInfosNormalizesEmptyDB is a regression test for the
|
|
// collection/partition cache asymmetry: the collection cache normalizes an empty
|
|
// db to "default", so the partition cache must too. Otherwise an empty-db request
|
|
// would key partitions under ""/<coll> while a drop/recreate invalidation keyed
|
|
// on the canonical "default" bucket never sweeps them, leaving stale entries. The
|
|
// Once() expectations assert both lookups share one normalized cache bucket.
|
|
func TestMetaCache_GetPartitionInfosNormalizesEmptyDB(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// two describes on the first call (name resolution + partition fill); the
|
|
// explicit-default call hits the caches, proving both requests share the
|
|
// canonical bucket
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "default",
|
|
Schema: &schemapb.CollectionSchema{
|
|
Name: "collection",
|
|
Fields: []*schemapb.FieldSchema{},
|
|
},
|
|
RequestTime: 1000,
|
|
}, nil).Times(2)
|
|
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).Return(&milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100},
|
|
PartitionNames: []string{"par1"},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil).Once()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// populate the collection-level partition cache via an empty-db request
|
|
infos1, err := cache.GetPartitionInfos(ctx, "", "collection")
|
|
assert.NoError(t, err)
|
|
assert.Len(t, infos1.partitionInfos, 1)
|
|
|
|
// the explicit-default request must hit the same canonical bucket with no
|
|
// extra RPC; without normalization it would miss and exceed the Once() mocks
|
|
infos2, err := cache.GetPartitionInfos(ctx, "default", "collection")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, infos1, infos2)
|
|
}
|
|
|
|
// TestMetaCache_GetPartitionInfoNormalizesEmptyDB is the partition-name-level
|
|
// counterpart: GetPartitionInfo must key its cache under the canonical database
|
|
// too, so an empty-db and an explicit-default lookup share one entry.
|
|
func TestMetaCache_GetPartitionInfoNormalizesEmptyDB(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// two describes on the cold fill (name resolution + partition-list filler);
|
|
// the explicit-default lookup hits the same cached entries
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "default",
|
|
Schema: &schemapb.CollectionSchema{Name: "collection", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 1000,
|
|
}, nil).Times(2)
|
|
|
|
// ShowPartitions must fire exactly once across both lookups when the cache
|
|
// key is normalized.
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).Return(&milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100, 101},
|
|
PartitionNames: []string{"par1", "par2"},
|
|
CreatedTimestamps: []uint64{1000, 1001},
|
|
CreatedUtcTimestamps: []uint64{1000, 1001},
|
|
}, nil).Once()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
p1, err := cache.GetPartitionInfo(ctx, "", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(100), p1.partitionID)
|
|
|
|
// explicit default resolves from the same normalized bucket — no extra RPC
|
|
p2, err := cache.GetPartitionInfo(ctx, "default", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, p1, p2)
|
|
}
|
|
|
|
// getAlias reads an alias hint (test helper; production reads chain through
|
|
// getCollection).
|
|
func getAlias(cache *MetaCache, database, alias string) (string, bool) {
|
|
cache.mu.RLock()
|
|
defer cache.mu.RUnlock()
|
|
if db, ok := cache.aliasInfo[normalizeDBName(database)]; ok {
|
|
if target, ok := db[alias]; ok {
|
|
return target, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// mustNewSchemaInfo builds a schemaInfo for tests, panicking on the schema-helper
|
|
// error that newSchemaInfo now propagates to production. Tests pass valid schemas
|
|
// so it never fires; it keeps call sites a single-value expression usable inside
|
|
// struct literals.
|
|
func mustNewSchemaInfo(schema *schemapb.CollectionSchema) *schemaInfo {
|
|
si, err := newSchemaInfo(schema)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return si
|
|
}
|
|
|
|
// collByIDLive returns the live primary entry for id (nil when absent —
|
|
// liveness is presence). Callers must hold cache.mu (tests take RLock).
|
|
func collByIDLive(cache *MetaCache, id UniqueID) *collectionInfo {
|
|
if e, ok := cache.liveLocked(id); ok {
|
|
return e
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cachedEntryLocked resolves (db, name) exactly like a read would: name hint ->
|
|
// primary, validated (id live, name still matches). nil = a lookup would miss.
|
|
// Callers must hold cache.mu.
|
|
func cachedEntryLocked(cache *MetaCache, db, name string) *collectionInfo {
|
|
db = normalizeDBName(db)
|
|
ids, ok := cache.nameIdx[db]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
id, ok := ids[name]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
e, ok := cache.liveLocked(id)
|
|
if !ok || e.schema.GetName() != name || normalizeDBName(e.dbName) != db {
|
|
return nil
|
|
}
|
|
return e
|
|
}
|
|
|
|
// seedCollection plants a collection into the primary store + name hint the way
|
|
// update() would, for tests that build MetaCache literals. The literal must have
|
|
// collections/nameIdx maps initialized.
|
|
func seedCollection(cache *MetaCache, db, name string, id UniqueID) *collectionInfo {
|
|
info := &collectionInfo{
|
|
collID: id,
|
|
dbName: db,
|
|
schema: mustNewSchemaInfo(&schemapb.CollectionSchema{Name: name}),
|
|
}
|
|
cache.collections[id] = info
|
|
ids, ok := cache.nameIdx[db]
|
|
if !ok {
|
|
ids = make(map[string]UniqueID)
|
|
cache.nameIdx[db] = ids
|
|
}
|
|
ids[name] = id
|
|
return info
|
|
}
|
|
|
|
// assertMetaCacheByIDConsistent verifies the post-unified-cleanup invariant
|
|
// set -- "every hint has a live owner" -- in all four directions:
|
|
// 1. every primary entry has a name hint (its own, or a same-name recreate's)
|
|
// that targets a LIVE entry, and every alias it declares has a hint;
|
|
// 2. every name hint targets a live primary (the name may legally differ --
|
|
// a rename's old-name hint points at the live renamed entry and is
|
|
// rejected by read-time validation -- but a hint to a DEAD id means an
|
|
// eviction bypassed the unified cleanup routine);
|
|
// 3. every alias hint chains alias -> name -> live entry that DECLARES the
|
|
// alias (the alias-resurrection bugs were exactly violations of this);
|
|
// 4. primary keys equal entry ids.
|
|
func assertMetaCacheByIDConsistent(t *testing.T, cache *MetaCache) {
|
|
t.Helper()
|
|
cache.mu.RLock()
|
|
v := metaCacheConsistencyViolations(cache)
|
|
cache.mu.RUnlock()
|
|
assert.Emptyf(t, v, "meta cache consistency violated:\n %s", strings.Join(v, "\n "))
|
|
}
|
|
|
|
// metaCacheConsistencyViolations returns every way the cache breaks the
|
|
// post-unified-cleanup invariant "every hint has a live owner AND exact
|
|
// ownership holds". Pure (no testify) so a test can construct a broken state
|
|
// and prove the checker catches it. Caller holds cache.mu.
|
|
func metaCacheConsistencyViolations(cache *MetaCache) []string {
|
|
var v []string
|
|
// Forward: every live primary entry owns its hints EXACTLY (not merely
|
|
// "some live entry exists"). Exact ownership is what catches a ghost: if
|
|
// A and B both declare alias "a" while aliasInfo["a"] -> B, then A's "a" is
|
|
// a ghost that a by-id Describe of A would still expose -- A's declared "a"
|
|
// resolves to B's name, not A's.
|
|
for id, e := range cache.collections {
|
|
if e == nil {
|
|
v = append(v, fmt.Sprintf("primary id %d has a nil entry", id))
|
|
continue
|
|
}
|
|
if id != e.collID {
|
|
v = append(v, fmt.Sprintf("primary key %d != entry.collID %d", id, e.collID))
|
|
}
|
|
db := normalizeDBName(e.dbName)
|
|
name := e.schema.GetName()
|
|
if gotID, ok := cache.nameIdx[db][name]; !ok {
|
|
v = append(v, fmt.Sprintf("primary %q/%q (id %d) has no name hint", db, name, id))
|
|
} else if gotID != id {
|
|
v = append(v, fmt.Sprintf("primary %q/%q: name hint points to id %d, not %d", db, name, gotID, id))
|
|
}
|
|
for _, a := range e.aliases {
|
|
if gotName, ok := cache.aliasInfo[db][a]; !ok {
|
|
v = append(v, fmt.Sprintf("declared alias %q of %q/%q (id %d) has no hint", a, db, name, id))
|
|
} else if gotName != name {
|
|
v = append(v, fmt.Sprintf("entry %q/%q (id %d) declares alias %q but aliasInfo[%q]=%q (ghost)", db, name, id, a, a, gotName))
|
|
}
|
|
}
|
|
}
|
|
// Reverse name: every name hint targets a live entry whose db AND real name
|
|
// match exactly.
|
|
for db, ids := range cache.nameIdx {
|
|
for name, id := range ids {
|
|
e, live := cache.collections[id]
|
|
if !live {
|
|
v = append(v, fmt.Sprintf("name hint %q/%q -> id %d dangles to a dead entry (eviction bypassed unified cleanup)", db, name, id))
|
|
continue
|
|
}
|
|
if got := normalizeDBName(e.dbName); got != db {
|
|
v = append(v, fmt.Sprintf("name hint %q/%q -> id %d: entry db is %q", db, name, id, got))
|
|
}
|
|
if got := e.schema.GetName(); got != name {
|
|
v = append(v, fmt.Sprintf("name hint %q/%q -> id %d: entry real name is %q", db, name, id, got))
|
|
}
|
|
}
|
|
}
|
|
// Reverse alias: every alias hint chains to a live entry that DECLARES it.
|
|
for db, aliases := range cache.aliasInfo {
|
|
for alias, target := range aliases {
|
|
id, ok := cache.nameIdx[db][target]
|
|
if !ok {
|
|
v = append(v, fmt.Sprintf("alias hint %q/%q -> %q has no name hint for its target", db, alias, target))
|
|
continue
|
|
}
|
|
e, live := cache.collections[id]
|
|
if !live {
|
|
v = append(v, fmt.Sprintf("alias hint %q/%q chains to dead id %d", db, alias, id))
|
|
continue
|
|
}
|
|
declared := false
|
|
for _, a := range e.aliases {
|
|
if a == alias {
|
|
declared = true
|
|
break
|
|
}
|
|
}
|
|
if !declared {
|
|
v = append(v, fmt.Sprintf("alias hint %q/%q resolves to id %d which does NOT declare it (resurrection risk)", db, alias, id))
|
|
}
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// TestMetaCache_RenameOldNameStopsResolving locks in the rename observable:
|
|
// after foo -> bar, caching the new name makes the pre-rename name stop
|
|
// resolving IMMEDIATELY. update() actively deletes the id's previous name hint
|
|
// when it re-fills the same id under a new real name, so the old "foo" hint is
|
|
// gone (not lingering); a later "foo" lookup misses and re-describes. The
|
|
// rename broadcast then only needs to evict the primary entry by id.
|
|
func TestMetaCache_RenameOldNameStopsResolving(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var renamed atomic.Bool
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
name := "foo"
|
|
rt := uint64(100)
|
|
if renamed.Load() {
|
|
name = "bar"
|
|
rt = 200
|
|
}
|
|
if req.GetCollectionID() == 1 || req.GetCollectionName() == name {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: name, Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: rt,
|
|
}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName())),
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
id, err := cache.GetCollectionID(ctx, "db", "foo")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), id)
|
|
|
|
renamed.Store(true)
|
|
id, err = cache.GetCollectionID(ctx, "db", "bar")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), id)
|
|
|
|
// the old name must no longer resolve (the dangling hint fails validation);
|
|
// note: we deliberately do NOT assert on the internal absence of the hint —
|
|
// stale name resolution is impossible either way
|
|
cache.mu.RLock()
|
|
hasFoo := cachedEntryLocked(cache, "db", "foo") != nil
|
|
hasBar := cachedEntryLocked(cache, "db", "bar") != nil
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasFoo, "the old name must stop resolving the moment the rename is observed")
|
|
assert.True(t, hasBar)
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
|
|
// PRODUCTION-path check in the pre-broadcast window: a lookup of the old name
|
|
// must go through the real read path, fail the hint validation, re-describe
|
|
// and report not-found -- NOT serve id 1 through the dangling hint. (The
|
|
// helper assertions above re-implement the validation, so only this catches
|
|
// a production regression in getCollection's hint validation.)
|
|
_, err = cache.GetCollectionID(ctx, "db", "foo")
|
|
assert.Error(t, err, "the pre-rename name must not resolve through the dangling hint")
|
|
|
|
// the rename broadcast evicts the primary entry by id
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
|
|
cache.mu.RLock()
|
|
hasFoo = cachedEntryLocked(cache, "db", "foo") != nil
|
|
hasBar = cachedEntryLocked(cache, "db", "bar") != nil
|
|
hasID := collByIDLive(cache, 1) != nil
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasFoo, "old name must not resolve (no stale read of the pre-rename name)")
|
|
assert.False(t, hasBar, "new name must not resolve either after the eviction")
|
|
assert.False(t, hasID, "the primary entry must be gone")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
|
|
// real stale-read check: the pre-rename name must not resolve any more -- it
|
|
// re-describes, and since the collection was renamed it is now not found
|
|
// (rather than returning the stale id 1 from a lingering cache entry)
|
|
_, err = cache.GetCollectionID(ctx, "db", "foo")
|
|
assert.Error(t, err, "the pre-rename name must not resolve after invalidation")
|
|
// the new name still resolves correctly
|
|
id, err = cache.GetCollectionID(ctx, "db", "bar")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), id)
|
|
}
|
|
|
|
// TestMetaCache_CrossDBSameNameRenameRemovesOldHint verifies that observing a
|
|
// db1/foo -> db2/foo rename moves the id's name hint to the new database even
|
|
// though the collection name did not change. Otherwise a later invalidation of
|
|
// the old db1/foo location could follow the stale hint and evict live db2/foo.
|
|
func TestMetaCache_CrossDBSameNameRenameRemovesOldHint(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var moved atomic.Bool
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
db := "db1"
|
|
if moved.Load() {
|
|
db = "db2"
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: db,
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 100,
|
|
}, nil
|
|
}).Twice()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
id, err := cache.GetCollectionID(ctx, "db1", "foo")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), id)
|
|
|
|
moved.Store(true)
|
|
id, err = cache.GetCollectionID(ctx, "db2", "foo")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), id)
|
|
|
|
cache.mu.RLock()
|
|
_, oldHint := cache.nameIdx["db1"]["foo"]
|
|
newID, newHint := cache.nameIdx["db2"]["foo"]
|
|
entry := collByIDLive(cache, 1)
|
|
cache.mu.RUnlock()
|
|
assert.False(t, oldHint, "the pre-rename db1/foo hint must be removed")
|
|
assert.True(t, newHint)
|
|
assert.Equal(t, UniqueID(1), newID)
|
|
assert.NotNil(t, entry)
|
|
assert.Equal(t, "db2", entry.dbName)
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
|
|
// A delayed invalidation for the old location must not evict the collection
|
|
// from its new database.
|
|
cache.RemoveCollection(ctx, "db1", "foo")
|
|
cache.mu.RLock()
|
|
entry = collByIDLive(cache, 1)
|
|
newID, newHint = cache.nameIdx["db2"]["foo"]
|
|
cache.mu.RUnlock()
|
|
assert.NotNil(t, entry, "old-db invalidation must not evict live db2/foo")
|
|
assert.True(t, newHint)
|
|
assert.Equal(t, UniqueID(1), newID)
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_AliasRepointResolvesToNewTarget verifies an alias re-point is
|
|
// reflected after invalidation: alias -> collA is cached, then AlterAlias points
|
|
// it at collB and broadcasts RemoveAlias; the next resolution must re-fill
|
|
// through DescribeCollection (rootcoord resolves the alias server-side) and
|
|
// return collB, not the stale collA. (Guards the alias-cache correctness the
|
|
// RBAC path depends on for its object-name resolution.)
|
|
func TestMetaCache_AliasRepointResolvesToNewTarget(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// Exactly two fills: one before the re-point, one after the invalidation.
|
|
// The alias-addressed describe returns the CURRENT target collection.
|
|
var repointed atomic.Bool
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
target, id := "collA", int64(1)
|
|
if repointed.Load() {
|
|
target, id = "collB", int64(2)
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: target, Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: []string{req.GetCollectionName()},
|
|
RequestTime: 100,
|
|
}, nil
|
|
}).Twice()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
got, err := cache.ResolveCollectionAlias(ctx, "db", "myalias")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "collA", got)
|
|
|
|
repointed.Store(true)
|
|
cache.RemoveAlias(ctx, "db", "myalias")
|
|
|
|
got, err = cache.ResolveCollectionAlias(ctx, "db", "myalias")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "collB", got, "alias must re-resolve to the new target after RemoveAlias")
|
|
}
|
|
|
|
// TestMetaCache_DropCollectionClearsAllCaches verifies a drop makes the
|
|
// collection unreachable through every access path. Under the id-primary
|
|
// architecture the drop deletes ONLY the primary entry: name/alias hints and
|
|
// partition entries survive physically but dangle — hints fail validation on
|
|
// read, and partition entries are keyed by the (never reused) collection id, so
|
|
// a recreated collection gets a NEW id and refetches everything.
|
|
func TestMetaCache_DropCollectionClearsAllCaches(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
// ids are never reused in production: after the drop, the "recreated"
|
|
// collection must be described with a NEW id
|
|
var collID atomic.Int64
|
|
collID.Store(1)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: collID.Load(),
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "collection", Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: []string{"myalias"},
|
|
RequestTime: 1000,
|
|
}, nil
|
|
}).Maybe()
|
|
var showCount atomic.Int32
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
showCount.Add(1)
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100},
|
|
PartitionNames: []string{"par1"},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
_, err = cache.GetCollectionID(ctx, "db", "collection")
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetPartitionInfos(ctx, "db", "collection")
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
target, err := cache.ResolveCollectionAlias(ctx, "db", "myalias")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "collection", target)
|
|
assert.Equal(t, int32(1), showCount.Load(), "one merged partition cache: the by-name lookup answers from the list fetched once")
|
|
|
|
cache.mu.RLock()
|
|
hasColl := cachedEntryLocked(cache, "db", "collection") != nil
|
|
hasID := collByIDLive(cache, 1) != nil
|
|
aliasTarget, hasAlias := cache.aliasInfo["db"]["myalias"]
|
|
cache.mu.RUnlock()
|
|
assert.True(t, hasColl)
|
|
assert.True(t, hasID)
|
|
assert.True(t, hasAlias)
|
|
assert.Equal(t, "collection", aliasTarget)
|
|
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
|
|
cache.mu.RLock()
|
|
hasColl = cachedEntryLocked(cache, "db", "collection") != nil
|
|
hasID = collByIDLive(cache, 1) != nil
|
|
_, hasAlias = cache.aliasInfo["db"]["myalias"]
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasColl, "a by-name lookup must miss after the drop")
|
|
assert.False(t, hasID, "the primary entry must be deleted on drop")
|
|
// the alias was declared by the entry, so eviction cleaned its hint
|
|
assert.False(t, hasAlias, "a declared alias hint is cleaned at eviction")
|
|
_, ok := cache.getCollection("db", "myalias", 0)
|
|
assert.False(t, ok, "a lookup through the alias must not reach the dropped collection")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
|
|
// the recreated collection has a NEW id, so its partition cache misses and
|
|
// re-fetches (the old id's list was reclaimed at eviction)
|
|
collID.Store(2)
|
|
_, err = cache.GetPartitionInfos(ctx, "db", "collection")
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetPartitionInfo(ctx, "db", "collection", "par1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int32(2), showCount.Load(), "the recreated collection's partition info must be re-fetched, proving the old entry is unreachable")
|
|
}
|
|
|
|
// TestMetaCache_ConcurrentIDPartitionInvalidateConsistency hammers the cache with
|
|
// id-only reads, by-name reads, partition reads, and both invalidation paths
|
|
// concurrently. Run under -race it flags any data race; the final consistency
|
|
// check flags any primary-store/name-hint desync left behind.
|
|
func TestMetaCache_ConcurrentIDPartitionInvalidateConsistency(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
var id int64
|
|
if req.GetCollectionName() != "" {
|
|
fmt.Sscanf(req.GetCollectionName(), "coll%d", &id)
|
|
} else {
|
|
id = req.GetCollectionID()
|
|
}
|
|
if id < 1 || id > 5 {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName())),
|
|
}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: fmt.Sprintf("coll%d", id), Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 1000,
|
|
}, nil
|
|
})
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).Return(&milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(),
|
|
PartitionIDs: []int64{100},
|
|
PartitionNames: []string{"par1"},
|
|
CreatedTimestamps: []uint64{1000},
|
|
CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil).Maybe()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
var wg sync.WaitGroup
|
|
for g := 0; g < 8; g++ {
|
|
wg.Add(1)
|
|
go func(g int) {
|
|
defer wg.Done()
|
|
for i := 0; i < 100; i++ {
|
|
id := int64((i % 5) + 1)
|
|
name := fmt.Sprintf("coll%d", id)
|
|
switch (i + g) % 5 {
|
|
case 0:
|
|
// id-only read must never return another collection's name
|
|
if got, err := cache.GetCollectionName(ctx, "", id); err == nil {
|
|
assert.Equalf(t, name, got, "by-id read returned wrong name for id %d", id)
|
|
}
|
|
case 1:
|
|
// by-name read must never return another collection's id
|
|
if got, err := cache.GetCollectionID(ctx, "db", name); err == nil {
|
|
assert.Equalf(t, id, got, "by-name read returned wrong id for %s", name)
|
|
}
|
|
case 2:
|
|
if infos, err := cache.GetPartitionInfos(ctx, "db", name); err == nil {
|
|
assert.NotNil(t, infos)
|
|
}
|
|
case 3:
|
|
cache.RemovePartition(ctx, "db", id, name, "par1")
|
|
case 4:
|
|
cache.RemoveCollectionsByID(ctx, id)
|
|
}
|
|
}
|
|
}(g)
|
|
}
|
|
wg.Wait()
|
|
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_CrossDBSameNameDropIsolation verifies that dropping a collection
|
|
// in one database never disturbs a same-name collection in another database: the
|
|
// primary entry, name hint, and partition cache of the untouched db must all
|
|
// survive.
|
|
func TestMetaCache_CrossDBSameNameDropIsolation(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
switch {
|
|
case req.GetCollectionID() == 1 || (req.GetDbName() == "db1" && req.GetCollectionName() == "foo"):
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Success(), CollectionID: 1, DbName: "db1", Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}}, RequestTime: 100}, nil
|
|
case req.GetCollectionID() == 2 || (req.GetDbName() == "db2" && req.GetCollectionName() == "foo"):
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Success(), CollectionID: 2, DbName: "db2", Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}}, RequestTime: 100}, nil
|
|
default:
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
}
|
|
})
|
|
var mu sync.Mutex
|
|
showByDB := map[string]int{}
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
mu.Lock()
|
|
showByDB[req.GetDbName()]++
|
|
mu.Unlock()
|
|
return &milvuspb.ShowPartitionsResponse{Status: merr.Success(), PartitionIDs: []int64{100}, PartitionNames: []string{"par1"}, CreatedTimestamps: []uint64{100}, CreatedUtcTimestamps: []uint64{100}}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
for _, db := range []string{"db1", "db2"} {
|
|
_, err = cache.GetCollectionID(ctx, db, "foo")
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetPartitionInfos(ctx, db, "foo")
|
|
assert.NoError(t, err)
|
|
}
|
|
mu.Lock()
|
|
assert.Equal(t, 1, showByDB["db1"])
|
|
assert.Equal(t, 1, showByDB["db2"])
|
|
mu.Unlock()
|
|
|
|
// drop only db1/foo
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
|
|
cache.mu.RLock()
|
|
hasDB1 := cachedEntryLocked(cache, "db1", "foo") != nil
|
|
coll2 := cachedEntryLocked(cache, "db2", "foo")
|
|
live1 := collByIDLive(cache, 1)
|
|
live2 := collByIDLive(cache, 2)
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasDB1, "dropped db1/foo must be gone")
|
|
assert.Nil(t, live1, "dropped id 1 must leave the primary store")
|
|
assert.NotNil(t, coll2, "db2/foo must be untouched")
|
|
assert.NotNil(t, live2)
|
|
assert.Same(t, coll2, live2)
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
|
|
// db2/foo partitions must still be a cache hit (not swept by db1's drop)
|
|
_, err = cache.GetPartitionInfos(ctx, "db2", "foo")
|
|
assert.NoError(t, err)
|
|
mu.Lock()
|
|
assert.Equal(t, 1, showByDB["db2"], "db2 partitions must not be re-fetched after dropping db1/foo")
|
|
mu.Unlock()
|
|
}
|
|
|
|
// TestMetaCache_EmptyDBSharesDefaultForPartitionInvalidation verifies the
|
|
// empty-db/default sharing observable end-to-end: partition entries are keyed
|
|
// by the collection id, so an empty-db request and an explicit-default request
|
|
// resolve to the same collection and share ONE partition cache entry. After a
|
|
// drop, the recreated collection carries a NEW id (ids are never reused), so
|
|
// the partition lookup misses and re-fetches — the old id's entries are
|
|
// unreachable regardless of which db-name spelling populated them.
|
|
func TestMetaCache_EmptyDBSharesDefaultForPartitionInvalidation(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
// ids are never reused in production: after the drop, the "recreated"
|
|
// collection is described with a NEW id
|
|
var collID atomic.Int64
|
|
collID.Store(1)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(), CollectionID: collID.Load(), DbName: "default", Schema: &schemapb.CollectionSchema{Name: "coll", Fields: []*schemapb.FieldSchema{}}, RequestTime: 100,
|
|
}, nil
|
|
}).Maybe()
|
|
var showCount atomic.Int32
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
showCount.Add(1)
|
|
return &milvuspb.ShowPartitionsResponse{Status: merr.Success(), PartitionIDs: []int64{100}, PartitionNames: []string{"par1"}, CreatedTimestamps: []uint64{100}, CreatedUtcTimestamps: []uint64{100}}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// cache the collection (so a later drop can find it) and its partitions via
|
|
// the EMPTY db name; both normalize to "default"
|
|
_, err = cache.GetCollectionID(ctx, "", "coll")
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetPartitionInfos(ctx, "", "coll")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int32(1), showCount.Load())
|
|
|
|
// an explicit "default" request hits the SAME normalized entry (no re-fetch)
|
|
_, err = cache.GetPartitionInfos(ctx, "default", "coll")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int32(1), showCount.Load(), "empty-db and default must share one partition cache entry")
|
|
|
|
// a drop broadcast evicts the primary entry by id; the recreate gets a new id
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
collID.Store(2)
|
|
|
|
// the empty-db lookup must now re-fetch: the recreated collection's new id
|
|
// misses the partition cache, and the old id's entry is unreachable
|
|
_, err = cache.GetPartitionInfos(ctx, "", "coll")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int32(2), showCount.Load(), "the recreated collection's partition info must be re-fetched under its new id")
|
|
}
|
|
|
|
// TestMetaCache_ConcurrentAliasResolveInvalidate exercises the alias path under
|
|
// concurrent resolution and invalidation. Resolution is the collection cache
|
|
// with a DescribeCollection fill on miss (rootcoord resolves the alias
|
|
// server-side and the fill caches the entry plus its declared alias hint);
|
|
// RemoveAlias concurrently drops the hint, forcing re-fills. Run under -race it
|
|
// flags any data race or map corruption in the alias path, and asserts
|
|
// resolution never returns a garbage value.
|
|
func TestMetaCache_ConcurrentAliasResolveInvalidate(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
// the alias flips between two targets, like a concurrent AlterAlias re-point
|
|
var flip atomic.Int64
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
target, id := "collA", int64(1)
|
|
if flip.Add(1)%2 == 0 {
|
|
target, id = "collB", int64(2)
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: target, Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: []string{req.GetCollectionName()},
|
|
RequestTime: uint64(flip.Load()),
|
|
}, nil
|
|
}).Maybe()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
valid := []string{"collA", "collB"}
|
|
var wg sync.WaitGroup
|
|
for g := 0; g < 8; g++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 100; i++ {
|
|
if i%3 == 0 {
|
|
cache.RemoveAlias(ctx, "db", "myalias")
|
|
} else {
|
|
got, err := cache.ResolveCollectionAlias(ctx, "db", "myalias")
|
|
if err == nil {
|
|
assert.Containsf(t, valid, got, "alias resolved to a corrupted value %q", got)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestMetaCache_DropAliasInvalidatesCanonicalCollectionAndByIDIndex is a
|
|
// regression test: an alias DDL broadcasts only the alias name with
|
|
// CollectionID=0, so the proxy sees RemoveCollection(db, alias). The collection
|
|
// is stored under its real name, so the direct lookup misses. Before the fix the
|
|
// canonical entry and its by-id index kept a stale Aliases list that an id-only
|
|
// Describe (via the cluster-wide by-id index) would then serve. The proxy must
|
|
// resolve the alias to its target and evict it.
|
|
func TestMetaCache_DropAliasInvalidatesCanonicalCollectionAndByIDIndex(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var dropped atomic.Bool
|
|
var reqTime atomic.Int64
|
|
reqTime.Store(100)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
var aliases []string
|
|
if !dropped.Load() {
|
|
aliases = []string{"a"}
|
|
}
|
|
if req.GetCollectionID() == 1 || (req.GetDbName() == "db1" && req.GetCollectionName() == "A") {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db1",
|
|
Schema: &schemapb.CollectionSchema{Name: "A", Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: aliases,
|
|
RequestTime: uint64(reqTime.Add(1)),
|
|
}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// cache collection A in the non-default db1 by its real name; its Aliases=[a]
|
|
// must populate the reverse alias index
|
|
info, err := cache.GetCollectionInfo(ctx, "db1", "A", 0)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"a"}, info.aliases)
|
|
|
|
cache.mu.RLock()
|
|
hasID := collByIDLive(cache, 1) != nil
|
|
aliasTarget, hasAlias := cache.aliasInfo["db1"]["a"]
|
|
cache.mu.RUnlock()
|
|
assert.True(t, hasID, "collection must be live in the primary store")
|
|
if assert.True(t, hasAlias, "the collection's alias must be hinted") {
|
|
assert.Equal(t, "A", aliasTarget)
|
|
}
|
|
|
|
// DropAlias(a) -> the proxy sees RemoveCollection(db1, "a") with the alias name
|
|
dropped.Store(true)
|
|
cache.RemoveCollection(ctx, "db1", "a")
|
|
|
|
cache.mu.RLock()
|
|
hasID = collByIDLive(cache, 1) != nil
|
|
hasA := cachedEntryLocked(cache, "db1", "A") != nil
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasID, "DropAlias must evict the target's primary entry")
|
|
assert.False(t, hasA, "the target must no longer resolve by name")
|
|
|
|
// an id-only Describe now re-queries rootcoord and must get the fresh aliases
|
|
// (without the dropped alias), not the stale cached [a]
|
|
info2, err := cache.GetCollectionInfo(ctx, "", "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, info2.aliases, "id-only describe must not serve the dropped alias")
|
|
}
|
|
|
|
// TestMetaCache_AlterAliasInvalidatesBothOldAndNewTarget verifies AlterAlias
|
|
// evicts BOTH the old target (via the proxy resolving the alias name to it) and
|
|
// the new target (via the collection id the rootcoord callback now forwards), so
|
|
// neither serves a stale Aliases list afterwards.
|
|
func TestMetaCache_AlterAliasInvalidatesBothOldAndNewTarget(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var altered atomic.Bool
|
|
var reqTime atomic.Int64
|
|
reqTime.Store(100)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
var id int64
|
|
name := req.GetCollectionName()
|
|
if req.GetCollectionID() != 0 {
|
|
id = req.GetCollectionID()
|
|
switch id {
|
|
case 1:
|
|
name = "A"
|
|
case 2:
|
|
name = "B"
|
|
}
|
|
} else if name == "A" {
|
|
id = 1
|
|
} else if name == "B" {
|
|
id = 2
|
|
}
|
|
if id != 1 && id != 2 {
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
}
|
|
var aliases []string
|
|
// before alter: alias "a" -> A; after: alias "a" -> B
|
|
if (id == 1 && !altered.Load()) || (id == 2 && altered.Load()) {
|
|
aliases = []string{"a"}
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db1",
|
|
Schema: &schemapb.CollectionSchema{Name: name, Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: aliases,
|
|
RequestTime: uint64(reqTime.Add(1)),
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "A", 0)
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "B", 0)
|
|
assert.NoError(t, err)
|
|
cache.mu.RLock()
|
|
aliasTarget, hasAlias := cache.aliasInfo["db1"]["a"]
|
|
cache.mu.RUnlock()
|
|
if assert.True(t, hasAlias, "alias of the old target must be reverse-indexed") {
|
|
assert.Equal(t, "A", aliasTarget)
|
|
}
|
|
|
|
// AlterAlias(a: A -> B): the proxy sees RemoveCollection(db1, "a") for the old
|
|
// target plus RemoveCollectionsByID(2) for the new target (id forwarded by the
|
|
// rootcoord callback).
|
|
altered.Store(true)
|
|
cache.RemoveCollection(ctx, "db1", "a")
|
|
cache.RemoveCollectionsByID(ctx, 2)
|
|
|
|
cache.mu.RLock()
|
|
hasA := collByIDLive(cache, 1) != nil
|
|
hasB := collByIDLive(cache, 2) != nil
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasA, "old target A must be evicted")
|
|
assert.False(t, hasB, "new target B must be evicted")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
|
|
// fresh describes: A no longer reports the alias, B now does
|
|
infoA, err := cache.GetCollectionInfo(ctx, "", "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, infoA.aliases, "old target must no longer report the moved alias")
|
|
infoB, err := cache.GetCollectionInfo(ctx, "", "", 2)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"a"}, infoB.aliases, "new target must report the moved alias")
|
|
}
|
|
|
|
// TestMetaCache_InvalidateCollectionMetaIsAtomic verifies that every O(1)
|
|
// mutation carried by one expiration request shares a single critical section.
|
|
// The hook pauses after both the alias-resolved target and forwarded id have
|
|
// been evicted but before the explicit alias-hint cleanup. A concurrent fill
|
|
// must not reach RootCoord until the whole batch releases its locks.
|
|
func TestMetaCache_InvalidateCollectionMetaIsAtomic(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rpcEntered := make(chan struct{}, 1)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
rpcEntered <- struct{}{}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 2,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "B", Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: []string{"a"},
|
|
RequestTime: 101,
|
|
}, nil
|
|
}).Once()
|
|
|
|
cache := &MetaCache{
|
|
mixCoord: rootCoord,
|
|
collections: map[UniqueID]*collectionInfo{},
|
|
nameIdx: map[string]map[string]UniqueID{},
|
|
aliasInfo: map[string]map[string]string{},
|
|
partitionCache: map[string]*partitionInfos{},
|
|
}
|
|
oldTarget := seedCollection(cache, "db", "A", 1)
|
|
oldTarget.aliases = []string{"a"}
|
|
seedCollection(cache, "db", "B", 2)
|
|
cache.setAliasLocked("db", "a", "A")
|
|
|
|
midMutation := make(chan struct{})
|
|
continueMutation := make(chan struct{})
|
|
cache.testHookInvalidateCollectionMetaMidMutation = func() {
|
|
close(midMutation)
|
|
<-continueMutation
|
|
}
|
|
defer func() { cache.testHookInvalidateCollectionMetaMidMutation = nil }()
|
|
|
|
removedCh := make(chan []string, 1)
|
|
go func() {
|
|
removedCh <- cache.InvalidateCollectionMeta(ctx, "db", "a", 2, true)
|
|
}()
|
|
<-midMutation
|
|
|
|
fillDone := make(chan *collectionInfo, 1)
|
|
fillErr := make(chan error, 1)
|
|
go func() {
|
|
info, err := cache.GetCollectionInfo(ctx, "db", "a", 0)
|
|
fillDone <- info
|
|
fillErr <- err
|
|
}()
|
|
|
|
select {
|
|
case <-rpcEntered:
|
|
t.Fatal("concurrent fill reached RootCoord inside an O(1) invalidation batch")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
close(continueMutation)
|
|
removed := <-removedCh
|
|
assert.ElementsMatch(t, []string{"A", "B"}, removed)
|
|
|
|
select {
|
|
case <-rpcEntered:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("concurrent fill did not resume after the invalidation batch released its locks")
|
|
}
|
|
info := <-fillDone
|
|
assert.NoError(t, <-fillErr)
|
|
if assert.NotNil(t, info) {
|
|
assert.Equal(t, UniqueID(2), info.collID)
|
|
assert.Equal(t, []string{"a"}, info.aliases)
|
|
}
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_CreateAliasInvalidatesTargetViaForwardedID verifies CreateAlias
|
|
// evicts the target collection even though the proxy's reverse alias index has no
|
|
// entry for the brand-new alias (the collection was cached before it existed) --
|
|
// it relies on the collection id forwarded by the rootcoord callback.
|
|
func TestMetaCache_CreateAliasInvalidatesTargetViaForwardedID(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var created atomic.Bool
|
|
var reqTime atomic.Int64
|
|
reqTime.Store(100)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
var aliases []string
|
|
if created.Load() {
|
|
aliases = []string{"a"}
|
|
}
|
|
if req.GetCollectionID() == 1 || (req.GetDbName() == "db1" && req.GetCollectionName() == "A") {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db1",
|
|
Schema: &schemapb.CollectionSchema{Name: "A", Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: aliases,
|
|
RequestTime: uint64(reqTime.Add(1)),
|
|
}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// cache A with no alias yet -> the reverse index has no entry for "a"
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "A", 0)
|
|
assert.NoError(t, err)
|
|
cache.mu.RLock()
|
|
_, hasAliasRev := cache.aliasInfo["db1"]["a"]
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasAliasRev, "A had no alias, so the reverse index has no entry for a")
|
|
|
|
// CreateAlias(a -> A): the proxy's RemoveCollection(db1, "a") cannot resolve a
|
|
// (not reverse-indexed); eviction relies on RemoveCollectionsByID(1) with the
|
|
// forwarded id.
|
|
created.Store(true)
|
|
cache.RemoveCollection(ctx, "db1", "a")
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
|
|
cache.mu.RLock()
|
|
hasID := collByIDLive(cache, 1) != nil
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasID, "CreateAlias must evict the target via the forwarded id")
|
|
|
|
// id-only Describe now reports the newly created alias
|
|
info, err := cache.GetCollectionInfo(ctx, "", "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"a"}, info.aliases, "target must report the newly created alias")
|
|
}
|
|
|
|
// TestMetaCache_DropAliasDefensiveDanglingHintCleanup: under the invariant
|
|
// "an alias hint exists only while its target entry lives and declares it"
|
|
// (hints are written solely from update()'s declared-aliases loop and cleaned
|
|
// at eviction), a dangling hint should be impossible. The dangling branch in
|
|
// removeCollectionByAliasLocked is kept as a one-line defense; seed the
|
|
// impossible state directly to keep it covered.
|
|
func TestMetaCache_DropAliasDefensiveDanglingHintCleanup(t *testing.T) {
|
|
ctx := context.Background()
|
|
mockCoord := mocks.NewMockMixCoordClient(t)
|
|
cache := &MetaCache{
|
|
mixCoord: mockCoord,
|
|
collections: map[UniqueID]*collectionInfo{},
|
|
nameIdx: map[string]map[string]UniqueID{},
|
|
aliasInfo: map[string]map[string]string{},
|
|
partitionCache: map[string]*partitionInfos{},
|
|
}
|
|
// impossible-by-invariant state: a hint whose target is not in the primary
|
|
cache.setAliasLocked("db1", "a", "foo")
|
|
|
|
// DropAlias(a): the target is not in the primary store, so the dangling
|
|
// branch must delete the alias hint itself.
|
|
cache.RemoveCollection(ctx, "db1", "a")
|
|
|
|
cache.mu.RLock()
|
|
_, hasRev := cache.aliasInfo["db1"]["a"]
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasRev, "the dangling alias hint must be cleaned even when the target is uncached")
|
|
}
|
|
|
|
// TestMetaCache_ConcurrentAlterAliasRefreshEvictsOldTarget is the P2-1 regression:
|
|
// under a concurrent AlterAlias(a: A -> B), a Describe of the new target B lands on
|
|
// this proxy AFTER the alter commits (so B reports Aliases=[a]) but BEFORE the
|
|
// alter's expiration arrives. That Describe overwrites the single-valued alias
|
|
// resolution aliasInfo[a] from A to B, while A is still cached holding the stale
|
|
// Aliases=[a] -- so the proxy alone cannot resolve the OLD target from the alias
|
|
// name. The fix is at the source: rootcoord resolves the pre-alter target under
|
|
// its database lock and forwards BOTH ids in the expiration
|
|
// (AlterAliasMessageHeader.old_collection_id), so the proxy evicts A and B by id
|
|
// regardless of its own alias-resolution state. This test simulates that contract:
|
|
// the expiration is RemoveCollection(db, "a") + RemoveCollectionsByID(B) +
|
|
// RemoveCollectionsByID(A).
|
|
func TestMetaCache_ConcurrentAlterAliasRefreshEvictsOldTarget(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var altered atomic.Bool
|
|
var reqTime atomic.Int64
|
|
reqTime.Store(100)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
var id int64
|
|
name := req.GetCollectionName()
|
|
if req.GetCollectionID() != 0 {
|
|
id = req.GetCollectionID()
|
|
switch id {
|
|
case 1:
|
|
name = "A"
|
|
case 2:
|
|
name = "B"
|
|
}
|
|
} else if name == "A" {
|
|
id = 1
|
|
} else if name == "B" {
|
|
id = 2
|
|
}
|
|
if id != 1 && id != 2 {
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
}
|
|
var aliases []string
|
|
// before alter: alias "a" -> A; after: alias "a" -> B
|
|
if (id == 1 && !altered.Load()) || (id == 2 && altered.Load()) {
|
|
aliases = []string{"a"}
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db1",
|
|
Schema: &schemapb.CollectionSchema{Name: name, Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: aliases,
|
|
RequestTime: uint64(reqTime.Add(1)),
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// (1) cache the OLD target A holding alias "a"
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "A", 0)
|
|
assert.NoError(t, err)
|
|
|
|
// (2) the alter commits at rootcoord, then a concurrent Describe(B) lands here
|
|
// BEFORE the expiration and refreshes B with the moved alias. This overwrites the
|
|
// single-valued reverse index aliasInfo[a] from A to B; A stays cached, stale.
|
|
altered.Store(true)
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "B", 0)
|
|
assert.NoError(t, err)
|
|
|
|
cache.mu.RLock()
|
|
singleVal, hasSingle := cache.aliasInfo["db1"]["a"]
|
|
staleA := collByIDLive(cache, 1)
|
|
freshB := collByIDLive(cache, 2)
|
|
cache.mu.RUnlock()
|
|
// the race state: the single-valued alias resolution lost the old target A
|
|
// (the concurrent Describe of B overwrote it to B), yet BOTH A and B are
|
|
// cached, each holding "a" in its own Aliases list -- name-based resolution
|
|
// alone can no longer reach A, which is why rootcoord must forward its id.
|
|
if assert.True(t, hasSingle) {
|
|
assert.Equal(t, "B", singleVal, "the concurrent refresh overwrote the single-valued alias resolution to the new target")
|
|
}
|
|
if assert.NotNil(t, staleA, "old target A is still cached at this point") {
|
|
assert.Contains(t, staleA.aliases, "a", "old target A still holds the stale alias in its own Aliases list")
|
|
}
|
|
if assert.NotNil(t, freshB, "new target B is cached") {
|
|
assert.Contains(t, freshB.aliases, "a", "new target B holds the refreshed alias")
|
|
}
|
|
|
|
// (3) the AlterAlias expiration arrives with the rootcoord-forwarded ids:
|
|
// RemoveCollection(db1, "a") for the alias name, RemoveCollectionsByID(2) for
|
|
// the new target, and RemoveCollectionsByID(1) for the OLD target (from the
|
|
// header's old_collection_id).
|
|
cache.RemoveCollection(ctx, "db1", "a")
|
|
cache.RemoveCollectionsByID(ctx, 2)
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
|
|
cache.mu.RLock()
|
|
hasA := collByIDLive(cache, 1) != nil
|
|
hasB := collByIDLive(cache, 2) != nil
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasA, "old target A must be evicted via the forwarded old-target id (P2-1)")
|
|
assert.False(t, hasB, "new target B must be evicted too")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
|
|
// a fresh id-only Describe of A must no longer report the moved alias
|
|
infoA, err := cache.GetCollectionInfo(ctx, "", "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, infoA.aliases, "old target must no longer serve the stale moved alias")
|
|
}
|
|
|
|
// TestMetaCache_RemoveDatabaseBlocksStaleResurrection: RemoveDatabase must drain
|
|
// an in-flight describe (fillMu) before evicting, so a describe issued BEFORE a
|
|
// DropDatabase broadcast cannot write its pre-DDL response back afterwards and
|
|
// resurrect an entry of a dropped database — with the id
|
|
// index, such a resurrected entry would even serve id-only lookups cluster-wide.
|
|
func TestMetaCache_RemoveDatabaseBlocksStaleResurrection(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
entered := make(chan struct{})
|
|
gate := make(chan struct{})
|
|
var once sync.Once
|
|
var describeCount atomic.Int32
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
describeCount.Add(1)
|
|
// first call parks in flight until released; it is served from a
|
|
// pre-DDL snapshot
|
|
once.Do(func() {
|
|
close(entered)
|
|
<-gate
|
|
})
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 1000,
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// a fill is in flight when the DropDatabase broadcast arrives
|
|
fillDone := make(chan struct{})
|
|
go func() {
|
|
defer close(fillDone)
|
|
_, err := cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
}()
|
|
<-entered
|
|
|
|
invalDone := make(chan struct{})
|
|
go func() {
|
|
defer close(invalDone)
|
|
cache.RemoveDatabase(ctx, "db")
|
|
}()
|
|
select {
|
|
case <-invalDone:
|
|
t.Fatal("RemoveDatabase must drain the in-flight fill before evicting")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
// release: the pre-DDL write-back lands first, the db eviction then cleans it
|
|
close(gate)
|
|
<-fillDone
|
|
<-invalDone
|
|
|
|
// assert via the read-path helpers: RemoveDatabase drained the fill first
|
|
// and then synchronously deleted the db's primary entries and hint buckets,
|
|
// so the written-back pre-DDL entry is gone
|
|
cache.mu.RLock()
|
|
hasFoo := cachedEntryLocked(cache, "db", "foo") != nil
|
|
hasID := collByIDLive(cache, 1) != nil
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasFoo, "a pre-DDL describe must not resurrect the evicted db entry")
|
|
assert.False(t, hasID, "a by-id lookup must not serve the resurrected entry")
|
|
|
|
// PRODUCTION-path check: the helpers above re-implement the read-path
|
|
// resolution, so additionally prove the real read path does not serve the
|
|
// dead entry -- a post-DDL lookup must RE-DESCRIBE (count goes up), not hit
|
|
// the cache.
|
|
countBefore := describeCount.Load()
|
|
_, err = cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
assert.Greater(t, describeCount.Load(), countBefore,
|
|
"the post-DDL lookup must re-describe instead of serving the generation-dead entry")
|
|
cache.mu.RLock()
|
|
hasFoo = cachedEntryLocked(cache, "db", "foo") != nil
|
|
cache.mu.RUnlock()
|
|
assert.True(t, hasFoo, "a post-DDL describe re-caches")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_SingleflightResultCannotEscapeInvalidation covers the gap
|
|
// between a fill callback returning and singleflight removing its completed
|
|
// call. The fill lock must remain held through that cleanup; otherwise an
|
|
// invalidation can finish in the gap and a later caller can join the old flight,
|
|
// receiving pre-DDL metadata directly even though the cache entry was evicted.
|
|
func TestMetaCache_SingleflightResultCannotEscapeInvalidation(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var postDDL atomic.Bool
|
|
var describeCount atomic.Int32
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
describeCount.Add(1)
|
|
id := int64(1)
|
|
requestTime := uint64(100)
|
|
if postDDL.Load() {
|
|
id = 2
|
|
requestTime = 200
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: requestTime,
|
|
UpdateTimestamp: requestTime,
|
|
}, nil
|
|
}).Twice()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
beforeSingleflightReturn := make(chan struct{})
|
|
releaseFirstFill := make(chan struct{})
|
|
var firstFill sync.Once
|
|
cache.testHookBeforeSingleflightReturn = func() {
|
|
firstFill.Do(func() {
|
|
close(beforeSingleflightReturn)
|
|
<-releaseFirstFill
|
|
})
|
|
}
|
|
defer func() { cache.testHookBeforeSingleflightReturn = nil }()
|
|
|
|
firstFillDone := make(chan *collectionInfo, 1)
|
|
firstFillErr := make(chan error, 1)
|
|
go func() {
|
|
info, err := cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
firstFillDone <- info
|
|
firstFillErr <- err
|
|
}()
|
|
<-beforeSingleflightReturn
|
|
|
|
// RootCoord has committed the DDL before broadcasting the invalidation.
|
|
postDDL.Store(true)
|
|
invalidationMidpoint := make(chan struct{})
|
|
releaseInvalidation := make(chan struct{})
|
|
cache.testHookInvalidateCollectionMetaMidMutation = func() {
|
|
close(invalidationMidpoint)
|
|
<-releaseInvalidation
|
|
}
|
|
defer func() { cache.testHookInvalidateCollectionMetaMidMutation = nil }()
|
|
|
|
invalidationDone := make(chan struct{})
|
|
go func() {
|
|
defer close(invalidationDone)
|
|
cache.InvalidateCollectionMeta(ctx, "db", "foo", 1, false)
|
|
}()
|
|
|
|
select {
|
|
case <-invalidationMidpoint:
|
|
close(releaseFirstFill)
|
|
close(releaseInvalidation)
|
|
t.Fatal("invalidation entered while the completed flight was still published")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
// Let singleflight publish the first result and remove its key. Only then may
|
|
// invalidation acquire fillMu.Lock and evict the pre-DDL write-back.
|
|
close(releaseFirstFill)
|
|
info := <-firstFillDone
|
|
assert.NoError(t, <-firstFillErr)
|
|
if assert.NotNil(t, info) {
|
|
assert.Equal(t, UniqueID(1), info.collID)
|
|
}
|
|
|
|
select {
|
|
case <-invalidationMidpoint:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("invalidation did not resume after the old singleflight completed")
|
|
}
|
|
|
|
// A request arriving while invalidation owns fillMu must wait. Once the
|
|
// invalidation finishes, the old flight is gone and this request must issue a
|
|
// fresh describe instead of receiving collection id 1 from singleflight.
|
|
postInvalidationFillDone := make(chan *collectionInfo, 1)
|
|
postInvalidationFillErr := make(chan error, 1)
|
|
go func() {
|
|
info, err := cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
postInvalidationFillDone <- info
|
|
postInvalidationFillErr <- err
|
|
}()
|
|
select {
|
|
case info := <-postInvalidationFillDone:
|
|
close(releaseInvalidation)
|
|
var id UniqueID
|
|
if info != nil {
|
|
id = info.collID
|
|
}
|
|
t.Fatalf("fill returned during invalidation with collection id %d", id)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
assert.Equal(t, int32(1), describeCount.Load(), "fill must not reach RootCoord during invalidation")
|
|
|
|
close(releaseInvalidation)
|
|
<-invalidationDone
|
|
info = <-postInvalidationFillDone
|
|
assert.NoError(t, <-postInvalidationFillErr)
|
|
if assert.NotNil(t, info) {
|
|
assert.Equal(t, UniqueID(2), info.collID)
|
|
assert.Equal(t, uint64(200), info.updateTimestamp)
|
|
}
|
|
assert.Equal(t, int32(2), describeCount.Load(), "post-invalidation lookup must start a fresh flight")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_DropRecreateReusingNameRelinksIDIndex: a drop + recreate reusing
|
|
// the collection name replaces the same name-key with a NEW id. The id index
|
|
// must unlink the old id and serve the new one (invariant I0 holds for both).
|
|
func TestMetaCache_DropRecreateReusingNameRelinksIDIndex(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var collID atomic.Int64
|
|
collID.Store(1)
|
|
var reqTime atomic.Int64
|
|
reqTime.Store(1000)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: collID.Load(),
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: uint64(reqTime.Add(1)),
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
id, err := cache.GetCollectionID(ctx, "db", "foo")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(1), id)
|
|
|
|
// drop (broadcast evicts by id) + recreate under the same name with a new id
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
collID.Store(2)
|
|
|
|
id, err = cache.GetCollectionID(ctx, "db", "foo")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, UniqueID(2), id)
|
|
|
|
cache.mu.RLock()
|
|
hasOld := collByIDLive(cache, 1) != nil
|
|
newLive := collByIDLive(cache, 2)
|
|
sameKey := cachedEntryLocked(cache, "db", "foo")
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasOld, "the old id must not be live after the drop")
|
|
assert.Same(t, sameKey, newLive, "the reused name must resolve to the recreated collection's new id")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_EvictionCleansEverythingSynchronously: there is NO background
|
|
// GC -- an eviction must clean everything the entry owns right there, all O(1)
|
|
// from the entry in hand: its name hint, its alias hints, and its partition
|
|
// list. Living entries and their satellites must be untouched.
|
|
func TestMetaCache_EvictionCleansEverythingSynchronously(t *testing.T) {
|
|
cache := &MetaCache{
|
|
collections: map[UniqueID]*collectionInfo{},
|
|
nameIdx: map[string]map[string]UniqueID{},
|
|
aliasInfo: map[string]map[string]string{},
|
|
dbInfo: map[string]*databaseInfo{},
|
|
partitionCache: map[string]*partitionInfos{},
|
|
}
|
|
doomed := seedCollection(cache, "db1", "foo", 1)
|
|
doomed.aliases = []string{"a"} // as a real fill would record it
|
|
seedCollection(cache, "db2", "bar", 2)
|
|
cache.setAliasLocked("db1", "a", "foo")
|
|
cache.partitionCache[buildPartitionCacheKey(1)] = parsePartitionsInfo([]*partitionInfo{{name: "p1", partitionID: 11}}, false)
|
|
cache.partitionCache[buildPartitionCacheKey(2)] = parsePartitionsInfo([]*partitionInfo{{name: "p1", partitionID: 21}}, false)
|
|
|
|
cache.RemoveCollectionsByID(context.Background(), 1)
|
|
|
|
cache.mu.RLock()
|
|
_, hasEntry := cache.collections[1]
|
|
_, hasNameHint := cache.nameIdx["db1"]["foo"]
|
|
_, hasAliasHint := cache.aliasInfo["db1"]["a"]
|
|
_, hasLive := cache.collections[2]
|
|
liveName := cachedEntryLocked(cache, "db2", "bar")
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasEntry, "primary entry deleted")
|
|
assert.False(t, hasNameHint, "the entry's name hint is cleaned at eviction")
|
|
assert.False(t, hasAliasHint, "the entry's alias hints are cleaned at eviction")
|
|
assert.True(t, hasLive, "living entries untouched")
|
|
assert.NotNil(t, liveName, "living name hints untouched")
|
|
|
|
_, okDead := cache.partitionCache[buildPartitionCacheKey(1)]
|
|
_, okLive := cache.partitionCache[buildPartitionCacheKey(2)]
|
|
assert.False(t, okDead, "the entry's partition list is reclaimed at eviction")
|
|
assert.True(t, okLive, "living collections' partition entries untouched")
|
|
}
|
|
|
|
// TestMetaCache_DroppedCascadeAliasCannotResurrectAfterRecreate: dropping a
|
|
// collection cascade-drops its aliases at rootcoord, but the DropCollection
|
|
// broadcast carries no per-alias expiration. The eviction must drop the
|
|
// entry's own alias hints; otherwise, after a same-name recreate (new id), the
|
|
// stale hint would VALIDATE again (alias -> name -> new id) and permanently
|
|
// resurrect the deleted alias for RBAC and routing.
|
|
func TestMetaCache_DroppedCascadeAliasCannotResurrectAfterRecreate(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var dropped atomic.Bool
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
id := int64(1)
|
|
var aliases []string
|
|
if dropped.Load() {
|
|
id = 2 // recreated under the same name: new id, no aliases
|
|
} else {
|
|
aliases = []string{"a"}
|
|
}
|
|
if req.GetCollectionID() == 0 && req.GetCollectionName() != "foo" {
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: aliases,
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// cache foo (id 1) holding alias "a" -> the hint a->foo exists
|
|
_, err = cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
cache.mu.RLock()
|
|
_, hasHint := cache.aliasInfo["db"]["a"]
|
|
cache.mu.RUnlock()
|
|
assert.True(t, hasHint)
|
|
|
|
// drop foo (cascade-drops alias "a" at rootcoord; broadcast carries only
|
|
// the collection), then recreate foo under a NEW id before any GC ran
|
|
dropped.Store(true)
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
_, err = cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
|
|
// the dead alias must NOT resolve through the stale hint to the new foo
|
|
cache.mu.RLock()
|
|
_, resurrected := cache.getCollection("db", "a", 0)
|
|
cache.mu.RUnlock()
|
|
assert.False(t, resurrected, "a cascade-dropped alias must not resurrect after a same-name recreate")
|
|
got, err := cache.ResolveCollectionAlias(ctx, "db", "a")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "a", got, "resolution must fall through to rootcoord, which reports the alias gone")
|
|
}
|
|
|
|
// TestMetaCache_OldRootcoordAliasScanFallbackEvictsGhostHolder covers the
|
|
// upgrade-window case with no healing event: under an old rootcoord (alias-DDL
|
|
// broadcasts carry no target ids), a concurrent describe re-points the alias
|
|
// hint to the new target before the AlterAlias broadcast arrives, so name-based
|
|
// resolution reaches only the new target and the OLD one keeps a ghost alias in
|
|
// its Aliases list indefinitely (a stable, name-addressed collection never
|
|
// re-fills). The id-fingerprinted fallback (CollectionID==0 in the broadcast)
|
|
// scans for cached holders and evicts them.
|
|
func TestMetaCache_OldRootcoordAliasScanFallbackEvictsGhostHolder(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var altered atomic.Bool
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
var id int64
|
|
name := req.GetCollectionName()
|
|
if req.GetCollectionID() != 0 {
|
|
id = req.GetCollectionID()
|
|
if id == 1 {
|
|
name = "A"
|
|
} else {
|
|
name = "B"
|
|
}
|
|
} else if name == "A" {
|
|
id = 1
|
|
} else if name == "B" {
|
|
id = 2
|
|
}
|
|
if id != 1 && id != 2 {
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
}
|
|
var aliases []string
|
|
if (id == 1 && !altered.Load()) || (id == 2 && altered.Load()) {
|
|
aliases = []string{"a"}
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db1",
|
|
Schema: &schemapb.CollectionSchema{Name: name, Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: aliases,
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// old target A cached holding "a"; then the alter commits and a concurrent
|
|
// Describe(B) re-points the hint to B while A stays cached with the ghost
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "A", 0)
|
|
assert.NoError(t, err)
|
|
altered.Store(true)
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "B", 0)
|
|
assert.NoError(t, err)
|
|
|
|
// old-rootcoord broadcast: alias name only, no ids. Name-based resolution
|
|
// evicts B (the hint's current target); the fallback scan must evict A.
|
|
cache.RemoveCollection(ctx, "db1", "a")
|
|
cache.RemoveAliasHolders(ctx, "db1", "a")
|
|
cache.RemoveAlias(ctx, "db1", "a")
|
|
|
|
cache.mu.RLock()
|
|
ghost := collByIDLive(cache, 1)
|
|
cache.mu.RUnlock()
|
|
assert.Nil(t, ghost, "the fallback scan must evict the ghost holder the hint could no longer reach")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_RemoveAliasHoldersEvictsAllHoldersAndRespectsDB verifies the
|
|
// old-rootcoord scan fallback across MULTIPLE holders and its db scoping: the
|
|
// linear scan must evict EVERY cached entry declaring the alias (not stop at the
|
|
// first), and must never touch a same-named alias holder in another database.
|
|
func TestMetaCache_RemoveAliasHoldersEvictsAllHoldersAndRespectsDB(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// A(1) and B(2) both live in db1 and both declare alias "a" (the ghost state:
|
|
// a concurrent describe re-pointed the hint, leaving two holders). C(3) in db2
|
|
// also declares "a" but must be spared by the db-scoped scan.
|
|
type ent struct {
|
|
id int64
|
|
db string
|
|
}
|
|
byName := map[string]ent{
|
|
"A": {1, "db1"},
|
|
"B": {2, "db1"},
|
|
"C": {3, "db2"},
|
|
}
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
e, ok := byName[req.GetCollectionName()]
|
|
if !ok {
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: e.id,
|
|
DbName: e.db,
|
|
Schema: &schemapb.CollectionSchema{Name: req.GetCollectionName(), Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: []string{"a"},
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "A", 0)
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetCollectionInfo(ctx, "db1", "B", 0)
|
|
assert.NoError(t, err)
|
|
_, err = cache.GetCollectionInfo(ctx, "db2", "C", 0)
|
|
assert.NoError(t, err)
|
|
|
|
// old-rootcoord fallback for db1: must evict BOTH db1 holders, not just one,
|
|
// and must not touch the same-named alias holder in db2.
|
|
cache.RemoveAliasHolders(ctx, "db1", "a")
|
|
|
|
cache.mu.RLock()
|
|
a := collByIDLive(cache, 1)
|
|
b := collByIDLive(cache, 2)
|
|
c := collByIDLive(cache, 3)
|
|
cache.mu.RUnlock()
|
|
assert.Nil(t, a, "db1 holder A must be evicted")
|
|
assert.Nil(t, b, "db1 holder B must be evicted (the scan must reach every holder, not stop at one)")
|
|
assert.NotNil(t, c, "db2 holder C must be spared (RemoveAliasHolders is db-scoped)")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_RemoveDatabaseEvictsAllCollectionsAcrossBatch exercises the
|
|
// database walk over a db holding MORE than one removeBatch (1024) of distinct
|
|
// collections: the completeness invariant (the db's nameIdx bucket reaches every
|
|
// live entry) and the batch-boundary loop must evict all of them.
|
|
func TestMetaCache_RemoveDatabaseEvictsAllCollectionsAcrossBatch(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
const n = 1025
|
|
byName := make(map[string]int64, n)
|
|
for i := 1; i <= n; i++ {
|
|
byName[fmt.Sprintf("c%d", i)] = int64(i)
|
|
}
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
name := req.GetCollectionName()
|
|
id, ok := byName[name]
|
|
if !ok {
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(name))}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: name, Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
for i := 1; i <= n; i++ {
|
|
_, err := cache.GetCollectionInfo(ctx, "db", fmt.Sprintf("c%d", i), 0)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
cache.RemoveDatabase(ctx, "db")
|
|
|
|
cache.mu.RLock()
|
|
_, bucketExists := cache.nameIdx["db"]
|
|
live := 0
|
|
for i := int64(1); i <= n; i++ {
|
|
if collByIDLive(cache, i) != nil {
|
|
live++
|
|
}
|
|
}
|
|
cache.mu.RUnlock()
|
|
assert.False(t, bucketExists, "the db's name-hint bucket must be dropped")
|
|
assert.Equal(t, 0, live, "every collection of the db must be evicted across the batch boundary")
|
|
assertMetaCacheByIDConsistent(t, cache)
|
|
}
|
|
|
|
// TestMetaCache_FillPreservesCallerDeadlineAndOrdersInvalidation verifies that
|
|
// MetaCache does not shorten the API deadline while still ordering a cache fill
|
|
// before a concurrent invalidation. The invalidation must wait until the caller
|
|
// cancels the fill; there is intentionally no MetaCache-specific timeout.
|
|
func TestMetaCache_FillPreservesCallerDeadlineAndOrdersInvalidation(t *testing.T) {
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
entered := make(chan struct{}, 1)
|
|
seenDeadline := make(chan time.Time, 1)
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
deadline, ok := ctx.Deadline()
|
|
assert.True(t, ok, "the caller deadline must reach the coordinator")
|
|
seenDeadline <- deadline
|
|
select {
|
|
case entered <- struct{}{}:
|
|
default:
|
|
}
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
callerDeadline := time.Now().Add(10 * time.Second)
|
|
callerCtx, cancel := context.WithDeadline(context.Background(), callerDeadline)
|
|
defer cancel()
|
|
|
|
fillDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := cache.GetCollectionInfo(callerCtx, "db", "foo", 0)
|
|
fillDone <- err
|
|
}()
|
|
<-entered
|
|
assert.WithinDuration(t, callerDeadline, <-seenDeadline, 100*time.Millisecond,
|
|
"MetaCache must not replace the caller deadline with a shorter internal deadline")
|
|
|
|
invalStarted := make(chan struct{})
|
|
invalDone := make(chan struct{})
|
|
go func() {
|
|
close(invalStarted)
|
|
cache.RemoveDatabase(context.Background(), "db")
|
|
close(invalDone)
|
|
}()
|
|
<-invalStarted
|
|
|
|
select {
|
|
case <-invalDone:
|
|
t.Fatal("invalidation completed before the in-flight fill was canceled")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-fillDone:
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("the fill did not return after its caller was canceled")
|
|
}
|
|
|
|
select {
|
|
case <-invalDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("the invalidation did not proceed after the fill released fillMu")
|
|
}
|
|
}
|
|
|
|
// TestMetaCache_PartitionDDLThenDropCannotResurrectAlias: RemovePartition must
|
|
// evict through the unified routine. A raw primary delete would leave the
|
|
// entry's alias hints behind; the subsequent DropCollection's cleanup then
|
|
// no-ops (entry already gone), and after a same-name recreate the surviving
|
|
// hint (alias -> name -> NEW id) would resurrect the cascade-dropped alias for
|
|
// routing and RBAC.
|
|
func TestMetaCache_PartitionDDLThenDropCannotResurrectAlias(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var dropped atomic.Bool
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
id := int64(1)
|
|
var aliases []string
|
|
if dropped.Load() {
|
|
id = 2 // recreated under the same name: new id, no aliases
|
|
} else {
|
|
aliases = []string{"a"}
|
|
}
|
|
if req.GetCollectionID() == 0 && req.GetCollectionName() != "foo" {
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound(req.GetCollectionName()))}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: aliases,
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// cache foo (id 1) holding alias "a"
|
|
_, err = cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
|
|
// partition DDL evicts the entry; then the drop broadcast arrives with NO
|
|
// intervening access (so the drop's own cleanup finds no entry)
|
|
cache.RemovePartition(ctx, "db", 1, "foo", "p1")
|
|
dropped.Store(true)
|
|
cache.RemoveCollectionsByID(ctx, 1)
|
|
|
|
// same-name recreate under a new id
|
|
_, err = cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
|
|
// the cascade-dropped alias must NOT resolve to the recreated collection
|
|
cache.mu.RLock()
|
|
_, resurrected := cache.getCollection("db", "a", 0)
|
|
cache.mu.RUnlock()
|
|
assert.False(t, resurrected, "partition DDL must not strand alias hints for a later resurrection")
|
|
}
|
|
|
|
// TestMetaCache_RemoveDatabaseRacingFillLeavesNoDanglingAliases stress-tests a
|
|
// defensive live-database race: if a coordinator still serves collections while
|
|
// RemoveDatabase's post-drain walk runs, fills may rebuild entries WITH fresh
|
|
// hints. The walk must evict those rebuilds through the unified routine, so no
|
|
// alias hint dangles afterwards.
|
|
func TestMetaCache_RemoveDatabaseRacingFillLeavesNoDanglingAliases(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: []string{"a"},
|
|
RequestTime: 100,
|
|
}, nil
|
|
}).Maybe()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
for i := 0; i < 50; i++ {
|
|
_, err := cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
// concurrent fill racing the post-drain walk
|
|
_, _ = cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
}()
|
|
cache.RemoveDatabase(ctx, "db")
|
|
<-done
|
|
|
|
// invariant: no dangling alias hint -- if the hint survives, its full
|
|
// chain (alias -> name -> live entry declaring it) must hold
|
|
cache.mu.RLock()
|
|
if target, ok := cache.aliasInfo["db"]["a"]; ok {
|
|
entry := cachedEntryLocked(cache, "db", target)
|
|
if assert.NotNil(t, entry, "iteration %d: alias hint dangles -- target %q not live", i, target) {
|
|
assert.Contains(t, entry.aliases, "a", "iteration %d: surviving hint must be declared by its entry", i)
|
|
}
|
|
}
|
|
cache.mu.RUnlock()
|
|
}
|
|
}
|
|
|
|
// TestMetaCache_RemoveDatabaseWalkEvictsMidWindowFill deterministically hits
|
|
// the window the hammer test above can only hit probabilistically: AFTER
|
|
// RemoveDatabase dropped the hint buckets (fill lock released) and BEFORE its
|
|
// batched primary walk. The hook triggers a fill exactly there; it rebuilds
|
|
// the entry WITH fresh name/alias hints, and the walk must evict it through
|
|
// the unified routine -- a raw primary delete would strand the fresh hints
|
|
// (red-green verified by negative control).
|
|
func TestMetaCache_RemoveDatabaseWalkEvictsMidWindowFill(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).Return(&milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
Aliases: []string{"a"},
|
|
RequestTime: 100,
|
|
}, nil).Maybe()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// prime the cache so the walk has a collected id
|
|
_, err = cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
|
|
filled := false
|
|
cache.testHookRemoveDatabaseMidWindow = func() {
|
|
// exact mid-window fill: buckets are gone, walk has not run yet
|
|
_, err := cache.GetCollectionInfo(ctx, "db", "foo", 0)
|
|
assert.NoError(t, err)
|
|
// the fill rebuilt the entry WITH fresh hints
|
|
cache.mu.RLock()
|
|
_, hasHint := cache.aliasInfo["db"]["a"]
|
|
_, hasEntry := cache.collections[1]
|
|
cache.mu.RUnlock()
|
|
assert.True(t, hasEntry, "mid-window fill must have rebuilt the entry")
|
|
assert.True(t, hasHint, "mid-window fill must have rebuilt the alias hint")
|
|
filled = true
|
|
}
|
|
defer func() { cache.testHookRemoveDatabaseMidWindow = nil }()
|
|
|
|
cache.RemoveDatabase(ctx, "db")
|
|
assert.True(t, filled, "the hook must have run")
|
|
|
|
// the walk must have evicted the mid-window rebuild TOGETHER with its hints
|
|
cache.mu.RLock()
|
|
_, hasEntry := cache.collections[1]
|
|
_, hasHint := cache.aliasInfo["db"]["a"]
|
|
_, hasName := cache.nameIdx["db"]["foo"]
|
|
cache.mu.RUnlock()
|
|
assert.False(t, hasEntry, "the walk must evict the mid-window rebuilt entry")
|
|
assert.False(t, hasHint, "the walk must clean the rebuilt alias hint -- a raw delete would strand it")
|
|
assert.False(t, hasName, "the walk must clean the rebuilt name hint")
|
|
}
|
|
|
|
// TestMetaCache_ConsistencyCheckerCatchesGhostAlias proves the checker itself
|
|
// catches the exact "ghost" the reviewer flagged: two live entries declare the
|
|
// same alias while aliasInfo points at only one of them. Pointing-at-a-live-
|
|
// entry alone would pass; exact ownership must not.
|
|
func TestMetaCache_ConsistencyCheckerCatchesGhostAlias(t *testing.T) {
|
|
cache := &MetaCache{
|
|
collections: map[UniqueID]*collectionInfo{},
|
|
nameIdx: map[string]map[string]UniqueID{},
|
|
aliasInfo: map[string]map[string]string{},
|
|
}
|
|
a := seedCollection(cache, "db", "A", 1)
|
|
b := seedCollection(cache, "db", "B", 2)
|
|
a.aliases = []string{"a"} // A declares "a" (ghost -- stale snapshot)
|
|
b.aliases = []string{"a"} // B declares "a" (current target)
|
|
cache.setAliasLocked("db", "a", "B") // aliasInfo resolves "a" to B only
|
|
|
|
cache.mu.RLock()
|
|
v := metaCacheConsistencyViolations(cache)
|
|
cache.mu.RUnlock()
|
|
found := false
|
|
for _, msg := range v {
|
|
if strings.Contains(msg, "ghost") && strings.Contains(msg, "id 1") {
|
|
found = true
|
|
}
|
|
}
|
|
assert.Truef(t, found, "checker must flag A's ghost declaration of \"a\"; violations: %v", v)
|
|
|
|
// after A is evicted (its declaration gone), the state is consistent
|
|
delete(cache.collections, 1)
|
|
delete(cache.nameIdx["db"], "A")
|
|
cache.mu.RLock()
|
|
v = metaCacheConsistencyViolations(cache)
|
|
cache.mu.RUnlock()
|
|
assert.Empty(t, v, "with only B declaring \"a\", the cache is consistent")
|
|
}
|
|
|
|
// TestMetaCache_PartitionFillDoesNotAttachNewCollectionToOldID: the partition
|
|
// fill resolves the collection id first, then must describe/show BY THAT ID.
|
|
// If a same-name drop+recreate happens between resolve and fill, a by-name
|
|
// describe would return the NEW collection's partitions and cache them under
|
|
// the OLD id (wrong partition context, uncleanable with no GC). By id, the
|
|
// stale old id returns not-found and the fill fails (caller re-resolves)
|
|
// instead of caching a mismatch.
|
|
func TestMetaCache_PartitionFillDoesNotAttachNewCollectionToOldID(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
// id 1 was dropped and "foo" recreated as id 2; the proxy's collection cache
|
|
// still holds the stale foo->1 mapping, so the partition fill resolves id 1.
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
id := req.GetCollectionID()
|
|
if id == 0 { // by-name resolve -> the CURRENT collection (id 2)
|
|
id = 2
|
|
}
|
|
if id == 1 { // by-id describe of the dropped old id -> not found
|
|
return &milvuspb.DescribeCollectionResponse{Status: merr.Status(merr.WrapErrCollectionNotFound("foo"))}, nil
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: id,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "foo", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 100,
|
|
}, nil
|
|
})
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.ShowPartitionsRequest, opts ...grpc.CallOption) (*milvuspb.ShowPartitionsResponse, error) {
|
|
// id 2's partitions -- must NOT be cached under id 1
|
|
return &milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(), PartitionIDs: []int64{201}, PartitionNames: []string{"p_new"},
|
|
CreatedTimestamps: []uint64{100}, CreatedUtcTimestamps: []uint64{100},
|
|
}, nil
|
|
}).Maybe()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// seed the stale collection-cache entry foo->id 1 (as if cached before the drop)
|
|
cache.mu.Lock()
|
|
seedCollection(cache, "db", "foo", 1)
|
|
cache.mu.Unlock()
|
|
|
|
// partition fill resolves id 1, describes BY id 1 -> not-found -> error;
|
|
// NOTHING is cached under id 1.
|
|
_, err = cache.GetPartitionInfos(ctx, "db", "foo")
|
|
assert.Error(t, err, "the fill must fail rather than attach id 2's partitions to id 1")
|
|
|
|
deadKey := buildPartitionCacheKey(1)
|
|
cache.mu.RLock()
|
|
_, ok := cache.partitionCache[deadKey]
|
|
cache.mu.RUnlock()
|
|
assert.False(t, ok, "no partition entry may be cached under the stale old id")
|
|
}
|
|
|
|
// TestMetaCache_PartitionFillSkipsWriteWhenPrimaryEvictedMidFill: the partition
|
|
// fill resolves the collection id BEFORE taking fillMu.RLock; an invalidation in
|
|
// that window evicts the primary (and its partition list). The fill still
|
|
// describes/shows the collection successfully, but must NOT cache the list under
|
|
// the now-owner-less id -- otherwise a later Create/DropPartition invalidation
|
|
// (which routes through evictCollectionEntryLocked, a no-op when the primary is
|
|
// absent) cannot clean it, and it would be re-adopted when the primary re-fills.
|
|
func TestMetaCache_PartitionFillSkipsWriteWhenPrimaryEvictedMidFill(t *testing.T) {
|
|
ctx := context.Background()
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
var cache *MetaCache
|
|
rootCoord.EXPECT().DescribeCollection(mock.Anything, mock.Anything).RunAndReturn(
|
|
func(ctx context.Context, req *milvuspb.DescribeCollectionRequest, opts ...grpc.CallOption) (*milvuspb.DescribeCollectionResponse, error) {
|
|
// The partition fill describes BY ID (empty name). At that exact
|
|
// moment simulate a concurrent invalidation evicting the primary.
|
|
if req.GetCollectionName() == "" && req.GetCollectionID() == 1 {
|
|
cache.mu.Lock()
|
|
delete(cache.collections, 1)
|
|
cache.mu.Unlock()
|
|
}
|
|
return &milvuspb.DescribeCollectionResponse{
|
|
Status: merr.Success(),
|
|
CollectionID: 1,
|
|
DbName: "db",
|
|
Schema: &schemapb.CollectionSchema{Name: "collection", Fields: []*schemapb.FieldSchema{}},
|
|
RequestTime: 1000,
|
|
}, nil
|
|
})
|
|
rootCoord.EXPECT().ShowPartitions(mock.Anything, mock.Anything).Return(&milvuspb.ShowPartitionsResponse{
|
|
Status: merr.Success(), PartitionIDs: []int64{100}, PartitionNames: []string{"par1"},
|
|
CreatedTimestamps: []uint64{1000}, CreatedUtcTimestamps: []uint64{1000},
|
|
}, nil).Maybe()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
defer cache.Close()
|
|
|
|
// the caller still gets the correct list...
|
|
info, err := cache.GetPartitionInfos(ctx, "db", "collection")
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, info)
|
|
|
|
// ...but it must NOT be cached under the now-owner-less id 1.
|
|
cache.mu.RLock()
|
|
_, cached := cache.partitionCache[buildPartitionCacheKey(1)]
|
|
_, primary := cache.collections[1]
|
|
cache.mu.RUnlock()
|
|
assert.False(t, primary, "primary was evicted mid-fill")
|
|
assert.False(t, cached, "an owner-less partition list must not be cached")
|
|
}
|
|
|
|
// TestMetaCache_RemovePartitionEvictsOwnerlessPartitionList: a partition DDL
|
|
// must drop the collection's partition list even when the primary entry is
|
|
// already gone -- removeCollectionByID's evictCollectionEntryLocked no-ops on an
|
|
// absent primary, so RemovePartition stales the id's partition key directly.
|
|
func TestMetaCache_RemovePartitionEvictsOwnerlessPartitionList(t *testing.T) {
|
|
cache := &MetaCache{
|
|
collections: map[UniqueID]*collectionInfo{},
|
|
nameIdx: map[string]map[string]UniqueID{},
|
|
aliasInfo: map[string]map[string]string{},
|
|
partitionCache: map[string]*partitionInfos{},
|
|
}
|
|
// an owner-less partition list: cached under id 1 with NO primary entry.
|
|
cache.partitionCache[buildPartitionCacheKey(1)] = parsePartitionsInfo(
|
|
[]*partitionInfo{{name: "p_old", partitionID: 11}}, false)
|
|
|
|
cache.RemovePartition(context.Background(), "db", 1, "foo", "p_old")
|
|
|
|
cache.mu.RLock()
|
|
_, cached := cache.partitionCache[buildPartitionCacheKey(1)]
|
|
cache.mu.RUnlock()
|
|
assert.False(t, cached, "RemovePartition must evict the partition list even when the primary is absent")
|
|
}
|
|
|
|
func TestMetaCache_Close(t *testing.T) {
|
|
rootCoord := mocks.NewMockMixCoordClient(t)
|
|
|
|
rootCoord.EXPECT().ListPolicy(mock.Anything, mock.Anything).Return(&internalpb.ListPolicyResponse{
|
|
Status: merr.Success(),
|
|
}, nil).Maybe()
|
|
|
|
cache, err := NewMetaCache(rootCoord)
|
|
assert.NoError(t, err)
|
|
|
|
cache.Close()
|
|
cache.Close()
|
|
}
|