Describe the problem
The SpanStats RPC returns an ApproximateTotalStats field intended to approximate the post-replication (physical) MVCC stats of a span — i.e. roughly logical_size × replication_factor. Instead, it scales with the number of nodes the span's ranges live on, not the replication factor. For a span that spans more nodes than RF (any large span on a cluster bigger than RF), ApproximateTotalStats over-reports by a factor of numNodesContacted / RF.
Root cause
Two behaviors compound in pkg/server/span_stats_server.go:
-
statsForSpan applies no locality filter. It scans all range descriptors overlapping the span and fetches RangeStats for every range via leaseholder-routed KV requests (span_stats_server.go#L325-L348). So every fanned-out node returns the full logical span size in its TotalStats, not just the portion it holds replicas for.
-
collectSpanStatsResponses sums each node's TotalStats into ApproximateTotalStats unconditionally, once per responding node (span_stats_server.go#L223):
res.SpanToStats[spanStr].ApproximateTotalStats.Add(spanStats.TotalStats)
Result: ApproximateTotalStats ≈ numNodesContacted × logical_size. The bug is masked for small spans confined to exactly RF nodes (where numNodes == RF), and only appears once a span is wide enough to live on more than RF nodes.
To Reproduce
- Create a 5-node cluster, default RF=3 (so node count > RF). (Reproduced on
v26.2.2; the code is identical on master.)
- Seed a table whose ranges spread across all 5 nodes:
CREATE DATABASE repro;
CREATE TABLE repro.t (id INT PRIMARY KEY, v STRING);
INSERT INTO repro.t SELECT g, repeat('x', 256) FROM generate_series(1, 200000) AS g;
ALTER TABLE repro.t SPLIT AT SELECT g*4000 FROM generate_series(1, 49) AS g;
ALTER TABLE repro.t SCATTER;
-- wait ~60s; verify all 5 nodes hold replicas:
-- WITH r AS (SELECT unnest(replicas) AS n FROM [SHOW RANGES FROM TABLE repro.t])
-- SELECT count(DISTINCT n) FROM r; --> 5
- Read the span stats (drives
planner.SpanStats → NodeID="0" fan-out, SkipApproxTotalStats=false):
WITH sp AS (SELECT crdb_internal.table_span('repro.t'::regclass::oid::int) AS s)
SELECT (stats->'total_stats'->>'live_bytes')::int AS logical_live_bytes,
(stats->'approximate_total_stats'->>'live_bytes')::int AS approx_total_live_bytes,
jsonb_array_length(stats->'store_ids') AS nodes
FROM crdb_internal.tenant_span_stats(ARRAY[((SELECT s FROM sp)[1], (SELECT s FROM sp)[2])]);
Observed — with a control span confined to a single range (3 nodes). RF=3 in both cases:
| span |
ranges |
nodes span lives on |
RF |
logical live_bytes |
expected approx (RF×) |
actual ApproximateTotalStats |
multiplier |
repro.t (50 ranges) |
50 |
5 |
3 |
55,597,817 |
166,793,451 |
281,397,937 |
5.06× |
repro.small (1 range) |
1 |
3 |
3 |
1,131,636 |
3,394,908 |
3,394,908 |
3.00× |
Since RF is fixed at 3 and only the node count differs (3 → 5), the multiplier tracks node count, not RF. key_bytes shows the same 5.06× / 3.00× split.
Expected behavior
ApproximateTotalStats should approximate RF × logical (≈ 3× here, ~167 MB), independent of how many nodes the span happens to occupy. It should not grow with cluster size.
Additional context — impact
Consumers of SpanStats / crdb_internal.tenant_span_stats see inflated physical/replicated size for any multi-node span; the error grows with numNodes / RF, so it's worst on large clusters.
Possible fix direction: have each fanned-out node report stats only for ranges it actually holds a replica of (locality filter), so summing across nodes yields the true replicated total; or weight per-range by replica count. Note RF can vary per range.
Environment: CockroachDB v26.2.2 (confirmed identical on master @ dceb771); 5-node AWS cluster, insecure.
Epic: none
Jira issue: CRDB-66508
Describe the problem
The
SpanStatsRPC returns anApproximateTotalStatsfield intended to approximate the post-replication (physical) MVCC stats of a span — i.e. roughlylogical_size × replication_factor. Instead, it scales with the number of nodes the span's ranges live on, not the replication factor. For a span that spans more nodes than RF (any large span on a cluster bigger than RF),ApproximateTotalStatsover-reports by a factor ofnumNodesContacted / RF.Root cause
Two behaviors compound in
pkg/server/span_stats_server.go:statsForSpanapplies no locality filter. It scans all range descriptors overlapping the span and fetchesRangeStatsfor every range via leaseholder-routed KV requests (span_stats_server.go#L325-L348). So every fanned-out node returns the full logical span size in itsTotalStats, not just the portion it holds replicas for.collectSpanStatsResponsessums each node'sTotalStatsintoApproximateTotalStatsunconditionally, once per responding node (span_stats_server.go#L223):Result:
ApproximateTotalStats ≈ numNodesContacted × logical_size. The bug is masked for small spans confined to exactly RF nodes (wherenumNodes == RF), and only appears once a span is wide enough to live on more than RF nodes.To Reproduce
v26.2.2; the code is identical onmaster.)planner.SpanStats→NodeID="0"fan-out,SkipApproxTotalStats=false):Observed — with a control span confined to a single range (3 nodes). RF=3 in both cases:
live_bytesApproximateTotalStatsrepro.t(50 ranges)repro.small(1 range)Since RF is fixed at 3 and only the node count differs (3 → 5), the multiplier tracks node count, not RF.
key_bytesshows the same 5.06× / 3.00× split.Expected behavior
ApproximateTotalStatsshould approximateRF × logical(≈ 3× here, ~167 MB), independent of how many nodes the span happens to occupy. It should not grow with cluster size.Additional context — impact
Consumers of
SpanStats/crdb_internal.tenant_span_statssee inflated physical/replicated size for any multi-node span; the error grows withnumNodes / RF, so it's worst on large clusters.Possible fix direction: have each fanned-out node report stats only for ranges it actually holds a replica of (locality filter), so summing across nodes yields the true replicated total; or weight per-range by replica count. Note RF can vary per range.
Environment: CockroachDB
v26.2.2(confirmed identical onmaster@dceb771); 5-node AWS cluster, insecure.Epic: none
Jira issue: CRDB-66508