Skip to content

fix(chain): count distinct weight setters with GROUP BY, not COUNT(DISTINCT) - #9261

Merged
JSONbored merged 1 commit into
mainfrom
feat/chain-weight-setters
Aug 3, 2026
Merged

fix(chain): count distinct weight setters with GROUP BY, not COUNT(DISTINCT)#9261
JSONbored merged 1 commit into
mainfrom
feat/chain-weight-setters

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

/api/v1/chain/weights/setters served a card of zeros in production while all three surfaces were
correctly wired to the shared loader (#9249). The reader was declining, not missing.

Root cause

loadChainEventIdentityRollup computed its totals with an ungrouped count(DISTINCT uid), which
R2 SQL rejects outright at this scale — confirmed by running the reader's exact query:

40015: scan budget exceeded: scanning too much data for count(DISTINCT)
without GROUP BY. Reduce data with WHERE clauses, use approximate functions
..., add a GROUP BY to distribute the aggregation, ...

A rejected query makes the reader return null, the handler falls through to its empty builder,
and the route publishes zeros — which read as measured zeros rather than as a decline. This is
the same expression #9252 removed from the sibling netuid rollup; the identity rollup kept it.

The quieter half

Even where the ungrouped form did run it answered the wrong question. A uid is unique only
within a subnet, so count(DISTINCT uid) collapses uid 5 on twenty subnets into one and lands
near the 256 uid ceiling regardless of the truth:

7d window
count(DISTINCT uid), ungrouped 254
distinct (netuid, uid) participants 1,284

Fix

The totals split in two:

  • count(*) stays ungrouped — a plain row count with no distinct in it, so neither problem
    applies, and it is the honest share denominator: the row page is capped by limit, so summing
    the page would make each share depend on the page size.
  • the distinct half becomes a GROUP BY netuid, <identity> subquery — the form the engine
    accepts, and the one that counts the participant rather than the uid number.

The reader still declines when any of the three halves misses, so a caller's own empty shape
stands rather than a partial answer being published as data.

Verification

Both queries run live against the lakehouse over the 7d window:

query result
count(*) weight_sets = 261,163
GROUP BY netuid, uid subquery distinct_setters = 1,284

1,284 matches the figure #9252 verified for the same stream, from an independent query shape.

Four mutations of the new code, each caught:

mutation tests failed
drop the !distinctRows guard 1
drop the !distinct guard 1
revert to ungrouped count(DISTINCT) 4
ignore the subquery, keep the totals value 2

Patch coverage 5/5 = 100%, measured by intersecting the diff's line ranges with v8's uncovered
set. 69 tests pass across the four affected suites; tsc --noEmit clean; prettier and eslint clean.

One test-harness note

The fake engine no longer discriminates queries on "GROUP BY" — the new distinct subquery
contains one too, so that discriminator would have silently answered the row fixture to two
different questions. Each query is now selected on the clause only it can have (ORDER BY for the
ranked page, FROM ( for the subquery).

Closes #9258

…STINCT)

/api/v1/chain/weights/setters served a card of zeros in production while all
three surfaces were correctly wired to the shared loader (#9249). The reader
was declining, not missing.

loadChainEventIdentityRollup computed its totals with an ungrouped
count(DISTINCT uid), which R2 SQL rejects outright at this scale:

  40015: scan budget exceeded: scanning too much data for count(DISTINCT)
  without GROUP BY

A rejected query makes the reader return null, the handler falls through to
its empty builder, and the route publishes zeros -- which read as measured
zeros rather than as a decline. Same expression #9252 removed from the sibling
netuid rollup; the identity rollup kept it.

Even where the ungrouped form did run it answered the wrong question. A uid is
unique only WITHIN a subnet, so count(DISTINCT uid) collapses uid 5 on twenty
subnets into one and lands near the 256 uid ceiling regardless of the truth:
254 reported against 1,284 real (netuid, uid) participants over the same 7d
window.

Split the totals in two. count(*) stays ungrouped -- it is a plain row count
with no distinct in it, and it is the honest share denominator, since the row
page is capped by `limit` and summing the page would make each share depend on
the page size. The distinct half becomes a GROUP BY netuid, <identity>
subquery, which the engine accepts and which counts the pair. The reader still
declines when any of the three halves misses, so a caller's own empty shape
stands rather than a partial answer being published as data.

Verified live against the lakehouse over 7d: weight_sets 261,163,
distinct_setters 1,284.

The test harness no longer discriminates queries on "GROUP BY" -- the distinct
subquery contains one too, so that would have answered the row fixture to two
different questions. Each query is selected on the clause only it can have.

Closes #9258
@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
metagraphed-data-api 8ffeb22 Aug 03 2026, 12:06 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
metagraphed-registry-sync-api 8ffeb22 Aug 03 2026, 12:06 PM

@JSONbored JSONbored self-assigned this Aug 3, 2026
@JSONbored
JSONbored merged commit 958dfbc into main Aug 3, 2026
7 checks passed
@JSONbored
JSONbored deleted the feat/chain-weight-setters branch August 3, 2026 12:12
JSONbored added a commit that referenced this pull request Aug 3, 2026
…rd (#9282)

/api/v1/accounts/{ss58} and get_account_snapshot published event_count 0 and
subnet_count 0 for an address whose own /events feed returned rows. Same
address, same stream, opposite answers -- so not an empty account.

loadAccountSummaryColdTier computed its aggregates with an ungrouped
count(DISTINCT netuid), which R2 SQL rejects outright:

  40015: scan budget exceeded: scanning too much data for count(DISTINCT)
  without GROUP BY

A rejected read declines the whole reader, the handler falls through to its
empty builder, and the card publishes zeros that read as measured fact. Third
instance of this expression: #9252 removed it from the netuid rollup and #9261
from the identity rollup.

A BOUNDED CTE DOES NOT BOUND THE BUDGET, which is why the earlier two passes
did not catch this one. The distinct ran over a `scan` CTE already capped at
ACCOUNT_EVENT_SUMMARY_SCAN_CAP (5,000) rows, so it reads as an aggregate over
5,000 rows and looks obviously safe. The engine costs the distinct against the
underlying scan, not the materialized cap. Wrapping a distinct in a bounded CTE
is not a workaround; only GROUP BY is.

Split the aggregate read in two, as in #9252/#9261: count(*) and the min/max
bounds stay together (no distinct, no budget problem), and the distinct-subnet
count becomes count(*) FROM (SELECT netuid FROM scan GROUP BY netuid). The
reader declines when the new read misses, so a card can never pair a real
event_count with subnet_count 0.

Verified live for the address above: 5,000 events across blocks 8,759,294 to
8,763,565, 129 distinct netuids -- consistent with ~129 active subnets.

The test asserts the SHAPE of the distinct read, not merely the absence of the
word DISTINCT, because the bounded-CTE form is the trap. Its fixture also keeps
the subnet count out of the aggregate row so a regression back to one combined
read is visible here rather than only in production.

Closes #9280
JSONbored added a commit that referenced this pull request Aug 3, 2026
…9304)

/api/v1/subnets/{netuid}/event-summary reported total_events 0 for EVERY
netuid -- 1, 8, 19 and 64 all answered zero -- while /subnets/{netuid}/events
served real rows off the same account_events stream. Two views of one stream,
opposite answers, the same shape as #9260.

All three surfaces ran tryPostgresTier(METAGRAPH_ACCOUNT_EVENTS_SOURCE) ??
buildSubnetEventSummary([], [], ...) and nothing Cloudflare-native replaced the
deleted Postgres tier, so add one shared reader and wire all three at once.

THE OBVIOUS PORT IS REJECTED. One grouped rollup carrying count(*) plus
count(DISTINCT hotkey) and count(DISTINCT coldkey) fails at this route's own
default window:

  40015: scan budget exceeded: scanning too much data for count(DISTINCT),
  count(DISTINCT) with GROUP BY

Note "with GROUP BY". Unlike #9252, #9261 and #9280 -- where the fix was ADDING
a GROUP BY -- adding one is not the fix here: two distincts in a single grouped
scan exceed the budget on their own, and this route also offers 90d, three
times the span that already fails. So each distinct is distributed into its own
nested aggregation, grouping to the (kind, key) pairs and then counting the
pairs per kind.

`hotkey IS NOT NULL` is load-bearing rather than tidy. COUNT(DISTINCT col)
ignores NULLs while GROUP BY col yields a NULL group, so without it every kind
whose rows carry no hotkey -- WeightsSet, and every Balances kind for coldkey --
would report one participant that does not exist.

An empty window is a MEASURED ZERO, not a decline: the query layer returns null
on failure and [] on a successful empty scan, so a quiet subnet can say so
rather than being indistinguishable from the broken tier this fixes. A failed
read still declines, so the card can never pair real counts with zeroed
participants.

Verified live for netuid 64 over 30d: WeightsSet 9,832 events, StakeAdded 8,517
events across 66 distinct hotkeys and 2,109 distinct coldkeys.

Writing the limit test found a real defect in the first draft: `??` only
catches null/undefined, so a literal 0 sailed through the default resolution
and produced LIMIT 0 -- a silently empty recent-events page. parseLimitParam
rejects 0 at the REST edge, but MCP and GraphQL call this reader directly. The
positivity check is now part of resolving an unusable limit rather than a
separate guard.

Three guards that could never fire were removed rather than left as untestable
defensive code: a cap floor the resolution already guarantees, an arithmetic
check on a cutoff derived from a validated window, and a column-name guard over
two string literals.

Closes #9303
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chain/weights/setters serves zeros: the identity rollup's ungrouped count(DISTINCT) is rejected by R2 SQL

1 participant