Files
milvus/internal/proxy/service_provider.go
T
0d2637fd63 fix: keep proxy metric status label stable, split cause into its own label (#51495)
issue: #51493

### What

#50221 split the `status` label values of `milvus_proxy_req_count` /
`milvus_proxy_grpc_latency` in place. Released in **v2.6.19**, that
silently zeroes every existing dashboard panel and alert rule matching
`status="fail"` / `status="rejected"` — an alert that stops firing
rather than erroring.

This keeps the classification but moves it onto its own dimension,
restoring the status domain to what <= v2.6.18 emitted:

```
status: success | fail | rejected | retry | total | abandon    (as before v2.6.19)
cause:  user | system | cancel | na                            (new)
```

| v2.6.19 / v2.6.20 | this PR |
|---|---|
| `status="fail_input"` | `status="fail", cause="user"` |
| `status="fail_system"` | `status="fail", cause="system"` |
| `status="rejected_user"` | `status="rejected", cause="user"` |
| `status="rejected_system"` | `status="rejected", cause="system"` |
| `status="cancel"` | `status="fail"` or `"rejected"`, `cause="cancel"`
|
| `success` / `retry` / `total` / `abandon` | unchanged, `cause="na"` |

### Why a label instead of new status values

- **Old queries work unchanged and count each request once.**
`status="fail"` is again every hard failure; Prometheus aggregates over
`cause` for free. That rollup is what a label dimension *is* — the split
forces every consumer who just wants "how many failures" to hand-write
`status=~"fail_.*"`.
- **Cardinality is unchanged.** `cause` is functionally dependent on the
outcome, so the realized `(status, cause)` pairs are exactly the series
the split already produces. Additive, not multiplicative.
- **New consumers lose nothing**: alert on `status="fail",
cause="system"`; route `cause="user"` to the owning application team.

The alternative — emitting old and new `status` values side by side
during a transition — was rejected: it defers the break rather than
removing it (the old values must still be dropped eventually, forcing
the same migration later), and meanwhile double-counts every request in
unfiltered aggregations.

`cancel` is folded into `cause` for the same reason: before v2.6.19
client cancellations were counted as `fail` (cancel in the response
status) or `rejected` (cancel at the interceptor), so promoting it to a
`status` value quietly makes `status="fail"` under-count versus older
releases. As a cause it stays excludable via `cause!="cancel"` without
redefining `status`.

### Also in this PR

- The 7 `snapshot_impl` sites that emitted a bare `fail` with no
classification now report their real cause via `failMetricLabel(err)`
(e.g. `snapshot_metadata_uri is required` is `cause="user"`, not a
system fault). Their `status` is unchanged.
- `deployments/monitor/grafana/milvus-dashboard.json`: the "Faild
Request Rate" panel goes back to `status="fail"` — a verbatim revert of
what #50221 changed, which is the compatibility claim demonstrated.
- Dev docs and stale comments that named the old label values.

### Verification

- `TestParseMetricLabelStatusDomainIsStable` pins the status domain, so
re-splitting it fails CI rather than shipping.
- Adding a label makes every `WithLabelValues` site arity-sensitive **at
runtime, not compile time** — a missed site panics in production. Unit
tests do not reach all of them (coverage shows
`GetRestoreSnapshotState`, `ListRestoreSnapshotJobs`, `PinSnapshotData`,
`UnpinSnapshotData` at 0%), so all **58 emit sites were verified
statically via AST**, not only the ones tests happen to hit.
- Passing: `pkg/metrics`, `pkg/util/requestutil`, `pkg/util/merr`,
`internal/distributed/proxy` (incl. `httpserver`, which covers the REST
emit path), and the `internal/proxy` snapshot / metric-label tests.
`golangci-lint` clean on every touched package.
- Pre-existing and unrelated: `service_test.go` bind failures (`listen
tcp :19529: address already in use`) reproduce identically on unmodified
master — a local process holds the port.

### Rollout

Should be cherry-picked to 2.6 so the window in which the fine-grained
`status` values exist stays confined to v2.6.19–v2.6.20. Users who
already adopted the new values on those two releases need a one-time
query change (`status="fail_system"` -> `status="fail",
cause="system"`); that is a known, bounded set, and preferable to
leaving every pre-2.6.19 dashboard silently broken.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:18:40 +08:00

312 lines
10 KiB
Go

package proxy
import (
"context"
"fmt"
"strconv"
"github.com/cockroachdb/errors"
"github.com/samber/lo"
"go.opentelemetry.io/otel"
"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/pkg/v3/common"
"github.com/milvus-io/milvus/pkg/v3/metrics"
"github.com/milvus-io/milvus/pkg/v3/mlog"
"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/timerecord"
"github.com/milvus-io/milvus/pkg/v3/util/timestamptz"
"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
)
type Request interface {
GetDbName() string
GetCollectionName() string
}
type Response interface {
GetStatus() *commonpb.Status
}
type ServiceInterceptor[Req Request, Resp Response] interface {
Call(ctx context.Context, request Req) (Resp, error)
}
func NewInterceptor[Req Request, Resp Response](proxy *Proxy, method string) (*InterceptorImpl[Req, Resp], error) {
var provider milvuspb.MilvusServiceServer
cached := paramtable.Get().ProxyCfg.EnableCachedServiceProvider.GetAsBool()
if cached {
provider = &CachedProxyServiceProvider{Proxy: proxy}
} else {
provider = &RemoteProxyServiceProvider{Proxy: proxy}
}
switch method {
case "DescribeCollection":
interceptor := &InterceptorImpl[*milvuspb.DescribeCollectionRequest, *milvuspb.DescribeCollectionResponse]{
proxy: proxy,
method: method,
onCall: provider.DescribeCollection,
onError: func(err error) (*milvuspb.DescribeCollectionResponse, error) {
return &milvuspb.DescribeCollectionResponse{
Status: merr.Status(err),
}, nil
},
}
return interface{}(interceptor).(*InterceptorImpl[Req, Resp]), nil
default:
return nil, merr.WrapErrParameterInvalidMsg("method %s not supported", method)
}
}
type InterceptorImpl[Req Request, Resp Response] struct {
proxy *Proxy
method string
onCall func(ctx context.Context, request Req) (Resp, error)
onError func(err error) (Resp, error)
}
func (i *InterceptorImpl[Req, Resp]) Call(ctx context.Context, request Req,
) (Resp, error) {
if err := merr.CheckHealthy(i.proxy.GetStateCode()); err != nil {
return i.onError(err)
}
ctx, sp := otel.Tracer(typeutil.ProxyRole).Start(ctx, fmt.Sprintf("Proxy-%s", i.method))
defer sp.End()
tr := timerecord.NewTimeRecorder(i.method)
metrics.ProxyFunctionCall.WithLabelValues(strconv.FormatInt(paramtable.GetNodeID(), 10), i.method,
metrics.TotalLabel, metrics.CauseNA, request.GetDbName(), request.GetCollectionName()).Inc()
resp, err := i.onCall(ctx, request)
if err != nil {
return i.onError(err)
}
metrics.ProxyFunctionCall.WithLabelValues(strconv.FormatInt(paramtable.GetNodeID(), 10), i.method,
metrics.SuccessLabel, metrics.CauseNA, request.GetDbName(), request.GetCollectionName()).Inc()
metrics.ProxyReqLatency.WithLabelValues(strconv.FormatInt(paramtable.GetNodeID(), 10), i.method).Observe(float64(tr.ElapseSpan().Milliseconds()))
return resp, err
}
type CachedProxyServiceProvider struct {
*Proxy
}
// cloneStructArrayFields creates a deep copy of struct array fields to avoid modifying cached data
func cloneStructArrayFields(fields []*schemapb.StructArrayFieldSchema) []*schemapb.StructArrayFieldSchema {
if fields == nil {
return nil
}
cloned := make([]*schemapb.StructArrayFieldSchema, len(fields))
for i, field := range fields {
cloned[i] = &schemapb.StructArrayFieldSchema{
FieldID: field.FieldID,
Name: field.Name,
Description: field.Description,
Fields: make([]*schemapb.FieldSchema, len(field.Fields)),
Nullable: field.Nullable,
}
// Deep copy sub-fields
for j, subField := range field.Fields {
cloned[i].Fields[j] = &schemapb.FieldSchema{
FieldID: subField.FieldID,
Name: subField.Name,
IsPrimaryKey: subField.IsPrimaryKey,
Description: subField.Description,
DataType: subField.DataType,
TypeParams: subField.TypeParams,
IndexParams: subField.IndexParams,
AutoID: subField.AutoID,
State: subField.State,
ElementType: subField.ElementType,
DefaultValue: subField.DefaultValue,
IsDynamic: subField.IsDynamic,
IsPartitionKey: subField.IsPartitionKey,
IsClusteringKey: subField.IsClusteringKey,
Nullable: subField.Nullable,
IsFunctionOutput: subField.IsFunctionOutput,
}
}
}
return cloned
}
func (node *CachedProxyServiceProvider) DescribeCollection(ctx context.Context,
request *milvuspb.DescribeCollectionRequest,
) (resp *milvuspb.DescribeCollectionResponse, err error) {
log := mlog.With(
mlog.String("role", typeutil.ProxyRole),
mlog.String("db", request.GetDbName()),
mlog.String("collection", request.GetCollectionName()),
mlog.FieldCollectionID(request.GetCollectionID()),
mlog.Uint64("timestamp", request.GetTimeStamp()),
)
log.Debug(ctx, "DescribeCollection received")
resp = &milvuspb.DescribeCollectionResponse{
Status: merr.Success(),
CollectionName: request.CollectionName,
DbName: request.DbName,
}
wrapErrorStatus := func(err error) *commonpb.Status {
status := &commonpb.Status{}
if errors.Is(err, merr.ErrCollectionNotFound) {
// nolint
status.ErrorCode = commonpb.ErrorCode_CollectionNotExists
// nolint
status.Reason = fmt.Sprintf("can't find collection[database=%s][collection=%s]", request.DbName, request.CollectionName)
status.ExtraInfo = map[string]string{merr.InputErrorFlagKey: "true"}
} else {
status = merr.Status(err)
}
return status
}
if request.CollectionName == "" && request.CollectionID > 0 {
collName, err := globalMetaCache.GetCollectionName(ctx, request.DbName, request.CollectionID)
if err != nil {
resp.Status = wrapErrorStatus(err)
return resp, nil
}
request.CollectionName = collName
}
// validate collection name, ref describeCollectionTask.PreExecute
if err = validateCollectionName(request.CollectionName); err != nil {
resp.Status = wrapErrorStatus(err)
return resp, nil
}
request.CollectionID, err = globalMetaCache.GetCollectionID(ctx, request.DbName, request.CollectionName)
if err != nil {
resp.Status = wrapErrorStatus(err)
return resp, nil
}
c, err := globalMetaCache.GetCollectionInfo(ctx, request.DbName, request.CollectionName, request.CollectionID)
if err != nil {
resp.Status = wrapErrorStatus(err)
return resp, nil
}
// skip dynamic fields, see describeCollectionTask.Execute
resp.Schema = &schemapb.CollectionSchema{
Name: c.schema.Name,
Description: c.schema.Description,
AutoID: c.schema.AutoID,
Fields: lo.Filter(c.schema.Fields, func(field *schemapb.FieldSchema, _ int) bool {
return !field.IsDynamic && field.Name != common.NamespaceFieldName
}),
StructArrayFields: cloneStructArrayFields(c.schema.StructArrayFields),
EnableDynamicField: c.schema.EnableDynamicField,
EnableNamespace: c.schema.EnableNamespace,
Properties: c.schema.Properties,
Functions: c.schema.Functions,
DbName: c.schema.DbName,
ExternalSource: c.schema.ExternalSource,
ExternalSpec: c.schema.ExternalSpec,
Version: c.schema.Version,
}
// Restore struct field names from internal format (structName[fieldName]) to original format
if err := restoreStructFieldNames(resp.Schema); err != nil {
log.Error(ctx, "failed to restore struct field names", mlog.Err(err))
return nil, err
}
err = timestamptz.RewriteTimestampTzDefaultValueToString(resp.Schema)
if err != nil {
log.Info(ctx, "failed to rewrite timestamp value", mlog.Err(err))
return nil, err
}
// prefer the actual database resolved by the coordinator and carried in the
// cache, the request db name may be empty/default when querying by collection id
if c.dbName != "" {
resp.DbName = c.dbName
}
resp.DbId = c.dbID
resp.CollectionID = c.collID
resp.UpdateTimestamp = c.updateTimestamp
resp.UpdateTimestampStr = fmt.Sprintf("%d", c.updateTimestamp)
resp.CreatedTimestamp = c.createdTimestamp
resp.CreatedUtcTimestamp = c.createdUtcTimestamp
resp.ConsistencyLevel = c.consistencyLevel
resp.VirtualChannelNames = c.vChannels
resp.PhysicalChannelNames = c.pChannels
resp.NumPartitions = c.numPartitions
resp.ShardsNum = c.shardsNum
resp.Aliases = c.aliases
resp.Properties = c.properties
log.Debug(ctx, "DescribeCollection done",
mlog.FieldCollectionID(resp.GetCollectionID()),
mlog.Any("schema", resp.GetSchema()),
)
return resp, nil
}
type RemoteProxyServiceProvider struct {
*Proxy
}
func (node *RemoteProxyServiceProvider) DescribeCollection(ctx context.Context,
request *milvuspb.DescribeCollectionRequest,
) (*milvuspb.DescribeCollectionResponse, error) {
dct := &describeCollectionTask{
ctx: ctx,
Condition: NewTaskCondition(ctx),
DescribeCollectionRequest: request,
mixCoord: node.mixCoord,
}
log := mlog.With(
mlog.String("role", typeutil.ProxyRole),
mlog.String("db", request.DbName),
mlog.String("collection", request.CollectionName))
method := "DescribeCollection"
log.Debug(ctx, "DescribeCollection received")
if err := node.sched.ddQueue.Enqueue(dct); err != nil {
log.Warn(ctx, "DescribeCollection failed to enqueue",
mlog.Err(err))
metrics.ProxyFunctionCall.WithLabelValues(strconv.FormatInt(paramtable.GetNodeID(), 10), method,
metrics.AbandonLabel, metrics.CauseNA, request.GetDbName(), request.GetCollectionName()).Inc()
return nil, err
}
log.Debug(ctx, "DescribeCollection enqueued",
mlog.Uint64("BeginTS", dct.BeginTs()),
mlog.Uint64("EndTS", dct.EndTs()))
if err := dct.WaitToFinish(); err != nil {
log.Warn(ctx, "DescribeCollection failed to WaitToFinish",
mlog.Err(err),
mlog.Uint64("BeginTS", dct.BeginTs()),
mlog.Uint64("EndTS", dct.EndTs()))
failStatus, failCause := failMetricLabel(err)
metrics.ProxyFunctionCall.WithLabelValues(strconv.FormatInt(paramtable.GetNodeID(), 10), method,
failStatus, failCause, request.GetDbName(), request.GetCollectionName()).Inc()
return nil, err
}
log.Debug(ctx, "DescribeCollection done",
mlog.Uint64("BeginTS", dct.BeginTs()),
mlog.Uint64("EndTS", dct.EndTs()),
)
return dct.result, nil
}