Skip to content

Commit

Permalink
server: make the span stats fan-out more fault tolerant
Browse files Browse the repository at this point in the history
This commit adds improved fault tolerance to the span stats fan-out:

1. Errors encountered during the fan-out will not invalidate the
entire request. Now, a span stats fan-out will always return a
roachpb.SpanStatsResponse that has been updated by values from nodes that
service their requests without error. In the extreme case where there's
a failure encountered on every node, an empty response is returned.

Errors that are encountered are logged, and then appended to the response
in the newly added `Errors` field.

2. Nodes must service requests within the timeout passed to `iterateNodes`.
For span stats, the value comes from a new cluster setting:
'server.span_stats.node.timeout', with a default value of 1 minute.

Resolves #106097
Epic: none
Release note (ops change): Span stats requests will return a partial
result if the request encounters any errors. Errors that would have
previously terminated the request are now included in the response.
  • Loading branch information
zachlite committed Aug 10, 2023
1 parent 4c34b48 commit 30c0898
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 22 deletions.
10 changes: 10 additions & 0 deletions pkg/roachpb/span_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ package roachpb

import (
"fmt"
"time"

"github.com/cockroachdb/cockroach/pkg/settings"
)
Expand All @@ -30,6 +31,15 @@ var SpanStatsBatchLimit = settings.RegisterIntSetting(
settings.PositiveInt,
)

var SpanStatsNodeTimeout = settings.RegisterDurationSetting(
settings.TenantWritable,
"server.span_stats.node.timeout",
"the duration allowed for a single node to return span stats data before"+
" the request is cancelled; if set to 0, there is no timeout",
time.Minute,
settings.NonNegativeDuration,
)

const defaultRangeStatsBatchLimit = 100

// RangeStatsBatchLimit registers the maximum number of ranges to be batched
Expand Down
4 changes: 3 additions & 1 deletion pkg/roachpb/span_stats.proto
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,7 @@ message SpanStatsResponse {

map<string, SpanStats> span_to_stats = 4;

// NEXT ID: 5.
string errors = 5;

// NEXT ID: 6.
}
1 change: 1 addition & 0 deletions pkg/server/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3201,6 +3201,7 @@ func (s *systemAdminServer) EnqueueRange(
if err := timeutil.RunWithTimeout(ctx, "enqueue range", time.Minute, func(ctx context.Context) error {
return s.server.status.iterateNodes(
ctx, fmt.Sprintf("enqueue r%d in queue %s", req.RangeID, req.Queue),
noTimeout,
dialFn, nodeFn, responseFn, errorFn,
)
}); err != nil {
Expand Down
6 changes: 5 additions & 1 deletion pkg/server/api_v2_ranges.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,11 @@ func (a *apiV2Server) listRange(w http.ResponseWriter, r *http.Request) {
}

if err := a.status.iterateNodes(
ctx, fmt.Sprintf("details about range %d", rangeID), dialFn, nodeFn, responseFn, errorFn,
ctx,
fmt.Sprintf("details about range %d", rangeID),
noTimeout,
dialFn, nodeFn,
responseFn, errorFn,
); err != nil {
srverrors.APIV2InternalError(ctx, err, w)
return
Expand Down
2 changes: 2 additions & 0 deletions pkg/server/index_usage_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func (s *statusServer) IndexUsageStatistics(
// yields an incorrect result.
if err := s.iterateNodes(ctx,
"requesting index usage stats",
noTimeout,
dialFn, fetchIndexUsageStats, aggFn, errFn); err != nil {
return nil, err
}
Expand Down Expand Up @@ -196,6 +197,7 @@ func (s *statusServer) ResetIndexUsageStats(

if err := s.iterateNodes(ctx,
"Resetting index usage stats",
noTimeout,
dialFn, resetIndexUsageStats, aggFn, errFn); err != nil {
return nil, err
}
Expand Down
9 changes: 7 additions & 2 deletions pkg/server/key_visualizer_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,13 @@ func (s *KeyVisualizerServer) getSamplesFromFanOut(
}

err := s.status.iterateNodes(ctx,
"iterating nodes for key visualizer samples", dialFn, nodeFn,
responseFn, errorFn)
"iterating nodes for key visualizer samples",
noTimeout,
dialFn,
nodeFn,
responseFn,
errorFn,
)
if err != nil {
return nil, err
}
Expand Down
6 changes: 6 additions & 0 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,11 @@ func NewServer(cfg Config, stopper *stop.Stopper) (serverctl.ServerStartupInterf
}

// Instantiate the status API server.
var serverTestingKnobs *TestingKnobs
if cfg.TestingKnobs.Server != nil {
serverTestingKnobs = cfg.TestingKnobs.Server.(*TestingKnobs)
}

sStatus := newSystemStatusServer(
cfg.AmbientCtx,
st,
Expand All @@ -998,6 +1003,7 @@ func NewServer(cfg Config, stopper *stop.Stopper) (serverctl.ServerStartupInterf
clock,
rangestats.NewFetcher(db),
node,
serverTestingKnobs,
)

keyVisualizerServer := &KeyVisualizerServer{
Expand Down
47 changes: 38 additions & 9 deletions pkg/server/span_stats_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ package server

import (
"context"
"fmt"
"strconv"
"strings"

"github.com/cockroachdb/cockroach/pkg/keys"
"github.com/cockroachdb/cockroach/pkg/kv"
Expand All @@ -37,8 +39,15 @@ func (s *systemStatusServer) spanStatsFanOut(
res := &roachpb.SpanStatsResponse{
SpanToStats: make(map[string]*roachpb.SpanStats),
}
// Response level error
var respErr error

errorMessages := make([]string, 0)

// Populate SpanToStats with empty values for each span,
// so that clients may still access stats for a specific span
// in the extreme case of an error encountered on every node.
for _, sp := range req.Spans {
res.SpanToStats[sp.String()] = &roachpb.SpanStats{}
}

spansPerNode, err := s.getSpansPerNode(ctx, req)
if err != nil {
Expand All @@ -51,13 +60,29 @@ func (s *systemStatusServer) spanStatsFanOut(
ctx context.Context,
nodeID roachpb.NodeID,
) (interface{}, error) {
if s.knobs != nil {
if s.knobs.IterateNodesDialCallback != nil {
if err := s.knobs.IterateNodesDialCallback(nodeID); err != nil {
return nil, err
}
}
}

if _, ok := spansPerNode[nodeID]; ok {
return s.dialNode(ctx, nodeID)
}
return nil, nil
}

nodeFn := func(ctx context.Context, client interface{}, nodeID roachpb.NodeID) (interface{}, error) {
if s.knobs != nil {
if s.knobs.IterateNodesNodeCallback != nil {
if err := s.knobs.IterateNodesNodeCallback(ctx, nodeID); err != nil {
return nil, err
}
}
}

// `smartDial` may skip this node, so check to see if the client is nil.
// If it is, return nil response.
if client == nil {
Expand All @@ -81,23 +106,26 @@ func (s *systemStatusServer) spanStatsFanOut(
nodeResponse := resp.(*roachpb.SpanStatsResponse)

for spanStr, spanStats := range nodeResponse.SpanToStats {
_, exists := res.SpanToStats[spanStr]
if !exists {
res.SpanToStats[spanStr] = spanStats
} else {
res.SpanToStats[spanStr].Add(spanStats)
// We are not counting replicas, so only consider range count
// if it has not been set.
if res.SpanToStats[spanStr].RangeCount == 0 {
res.SpanToStats[spanStr].RangeCount = spanStats.RangeCount
}
res.SpanToStats[spanStr].Add(spanStats)
}
}

errorFn := func(nodeID roachpb.NodeID, err error) {
log.Errorf(ctx, nodeErrorMsgPlaceholder, nodeID, err)
respErr = err
errorMessage := fmt.Sprintf("%v", err)
errorMessages = append(errorMessages, errorMessage)
}

timeout := roachpb.SpanStatsNodeTimeout.Get(&s.st.SV)
if err := s.statusServer.iterateNodes(
ctx,
"iterating nodes for span stats",
timeout,
smartDial,
nodeFn,
responseFn,
Expand All @@ -106,7 +134,8 @@ func (s *systemStatusServer) spanStatsFanOut(
return nil, err
}

return res, respErr
res.Errors = strings.Join(errorMessages, ",")
return res, nil
}

func (s *systemStatusServer) getLocalStats(
Expand Down
58 changes: 58 additions & 0 deletions pkg/server/span_stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/cockroachdb/cockroach/pkg/keys"
"github.com/cockroachdb/cockroach/pkg/kv/kvserver"
"github.com/cockroachdb/cockroach/pkg/roachpb"
"github.com/cockroachdb/cockroach/pkg/server"
"github.com/cockroachdb/cockroach/pkg/server/serverpb"
"github.com/cockroachdb/cockroach/pkg/testutils"
"github.com/cockroachdb/cockroach/pkg/testutils/serverutils"
Expand Down Expand Up @@ -191,6 +192,63 @@ func TestSpanStatsFanOut(t *testing.T) {

}

func TestSpanStatsFanOutFaultTolerance(t *testing.T) {
defer leaktest.AfterTest(t)()
defer log.Scope(t).Close(t)
ctx := context.Background()
const numNodes = 5

serverArgs := base.TestServerArgs{}
serverArgs.Knobs.Server = &server.TestingKnobs{
IterateNodesDialCallback: func(nodeID roachpb.NodeID) error {
// On the 1st and 2nd node, simulate a connection error.
if nodeID == 1 || nodeID == 2 {
return errors.Newf("error dialing node %d", nodeID)
}
return nil
},
IterateNodesNodeCallback: func(ctx context.Context, nodeID roachpb.NodeID) error {
// On the 3rd node, simulate some sort of KV error.
if nodeID == 3 {
return errors.Newf("kv error on node %d", nodeID)
}

// On the 4th and 5th node, simulate a request that takes a very long time.
// In this case, nodeFn will block until the context is cancelled
// i.e. if iterateNodes respects the timeout cluster setting.
if nodeID == 4 || nodeID == 5 {
<-ctx.Done()
// Return an error that mimics the error returned
// when a rpc's context is cancelled:
return errors.Newf("node %d timed out", nodeID)
}
return nil
},
}

tc := testcluster.StartTestCluster(t, numNodes, base.TestClusterArgs{ServerArgs: serverArgs})
defer tc.Stopper().Stop(ctx)

sqlDB := tc.Server(0).SQLConn(t, "defaultdb")
_, err := sqlDB.Exec("SET CLUSTER SETTING server.span_stats.node.timeout = '3s'")
require.NoError(t, err)

res, err := tc.GetStatusClient(t, 0).SpanStats(ctx, &roachpb.SpanStatsRequest{
NodeID: "0",
Spans: []roachpb.Span{keys.EverythingSpan},
})
require.NoError(t, err)
// Expect to still be able to access SpanToStats for keys.EverythingSpan
// without panicking, even though there was a failure on every node.
require.Equal(t, int64(0), res.SpanToStats[keys.EverythingSpan.String()].TotalStats.LiveCount)
require.Contains(t, res.Errors, "error dialing node 1")
require.Contains(t, res.Errors, "error dialing node 2")
require.Contains(t, res.Errors, "kv error on node 3")
require.Contains(t, res.Errors, "node 4 timed out")
require.Contains(t, res.Errors, "node 5 timed out")

}

// BenchmarkSpanStats measures the cost of collecting span statistics.
func BenchmarkSpanStats(b *testing.B) {
skip.UnderShort(b)
Expand Down
1 change: 1 addition & 0 deletions pkg/server/sql_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func (s *statusServer) ResetSQLStats(
var fanoutError error

if err := s.iterateNodes(ctx, "reset SQL statistics",
noTimeout,
dialFn,
resetSQLStats,
func(nodeID roachpb.NodeID, resp interface{}) {
Expand Down
1 change: 1 addition & 0 deletions pkg/server/statements.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func (s *statusServer) Statements(
}

if err := s.iterateNodes(ctx, "statement statistics",
noTimeout,
dialFn,
nodeStatement,
func(nodeID roachpb.NodeID, resp interface{}) {
Expand Down
Loading

0 comments on commit 30c0898

Please sign in to comment.