Skip to content

feat(validators): give the nominator-counts lane a D1 sink so it can advance again - #9302

Merged
JSONbored merged 1 commit into
mainfrom
fix/validator-nominator-counts-d1-sink
Aug 3, 2026
Merged

feat(validators): give the nominator-counts lane a D1 sink so it can advance again#9302
JSONbored merged 1 commit into
mainfrom
fix/validator-nominator-counts-d1-sink

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

nominator_count is served from the lakehouse mirror of chain.validator_nominator_counts (#9276), and that mirror is frozen. Measured live 2026-08-03 via R2 SQL: 112,550 rows, newest captured_at 2026-08-02T01:38Z — the retired box's last scan — covering only 564 of the 1,031 validators the leaderboard serves. 467 have no row at all, and the scan never recorded a zero, so an absent row conflates "no nominators" with "not scanned". Coverage can only fall from here.

This is a sink problem, not a producer problem. metagraphed-infra's poller Container already carries the full SubtensorModule::Alpha scan this table is derived from (src/bin/poller/jobs/validator_nominators.rs, 24h tick). It is one of the five lanes Dockerfile.poller leaves disabled, verbatim: "The five Postgres-backed lanes stay disabled until they have a Cloudflare-native sink." The scan needs no rehosting — only its write target does.

Same shape as #9273/#9292, which gave nominator_positions — the other output of that same scan — its D1 sink.

What changed

  • migrations/d1/0012 adds validator_nominator_counts on D1, upserted on (hotkey) with the usual captured_at <= excluded.captured_at staleness guard. Table and writer column list are asserted against each other in both directions (0007's anti-drift guarantee). No prune — the producer's Alpha pass is exhaustive, but it chunks across requests, so "absent from this batch" ≠ "absent from the scan", and only the latter would license a delete.
  • src/validator-nominator-counts-d1-write.ts reuses neurons-d1-write's statement building, so one place owns the Workers-binding budget of 100 bound parameters per statement (not the 1,200 wrangler d1 execute permits). Asserted as the platform limit in the tests, not as a constant we picked.
  • handleValidatorNominatorCountsSync stops answering 503 hyperdrive binding unavailable (chore(workers): delete the dead Postgres tier now that no Hyperdrive binding exists #9193) and writes the batch to D1.
  • The read is wired into buildGlobalValidators/buildValidatorDetail, which were handed a hardcoded empty map / null count. It joins against neurons inside SQLite rather than inlining a key list: ~1,031 hotkeys against a 100-parameter cap means a correlated subquery costs zero bound parameters and one query, where an IN list would cost a dozen round trips.

On #9276's overlay

Deliberately left in place. validatorHotkeysNeedingCount only collects hotkeys whose count is still null and returns early when there are none — so the overlay covers what D1 hasn't received yet and stops firing on its own once a full scan lands. The cutover is a property of the data, not of a deploy. Removing it is a follow-up once the table is populated, not part of this PR.

Failure posture

Any failed read degrades to null — exactly what this tier served before the table existed. A broken read is a lost enrichment, never a wrong number. A D1 write failure is a 502, never a silent success.

Validation

npm run lint                  # clean
npm run typecheck             # clean
npm run validate              # 129 subnets, 3444 surfaces — passed
npm run validate:contract-drift  # passed
npx vitest run tests/data-api.test.ts tests/data-api-neurons-d1.test.ts \
  tests/data-api-validator-nominator-counts-d1.test.ts \
  tests/validator-nominator-counts-d1-write.test.ts        # 181 passed
npx vitest run tests/validator-nominators.test.ts tests/validator-nominator-summary.test.ts \
  tests/validator-nominator-counts-cold-tier.test.ts tests/account-nominator-positions.test.ts \
  tests/nominator-positions-d1-write.test.ts tests/data-api-nominator-positions-d1.test.ts \
  tests/metagraph-neurons.test.ts                          # 195 passed

Patch coverage verified by diff intersection (not whole-file %): 0 uncovered lines and 0 uncovered branches across the changed lines of workers/data-api.ts and all of src/validator-nominator-counts-d1-write.ts.

Follow-ups (deliberately not in this PR)

  1. The infra half — pointing validator_nominators.rs at this route and enabling the lane in POLLER_ONLY. Until that lands, the table stays empty and the overlay keeps serving the frozen mirror, which is exactly today's behavior.
  2. A staleness watchdog, the way feat(accounts): give nominator_positions a live refresh lane and stop the confident zero #9292 shipped one for nominator_positions. This gap existed because nothing noticed the writer was gone; that applies identically here, but it's a separate concern from restoring the lane and would near-double this diff.

Closes #9301
Part of #9146

…advance again

`nominator_count` on /api/v1/validators and /api/v1/validators/{hotkey} is
served from the lakehouse mirror of `chain.validator_nominator_counts` (#9276),
and that mirror is frozen. Measured live 2026-08-03: 112,550 rows, newest
`captured_at` 2026-08-02T01:38Z -- the retired box's last scan -- covering only
564 of the 1,031 validators the leaderboard serves. Coverage can only fall from
here as new validators register.

This is a SINK problem, not a producer problem. metagraphed-infra's poller
Container already carries the full SubtensorModule::Alpha scan this table is
derived from (src/bin/poller/jobs/validator_nominators.rs, 24h tick); it is one
of the five lanes Dockerfile.poller leaves disabled "until they have a
Cloudflare-native sink", because it writes to a Postgres that no longer exists.
The scan needs no rehosting. Its write target does.

The lane, on the pattern #9273/#9292 used for `nominator_positions` -- the other
output of that same scan:

- migrations/d1/0012 adds `validator_nominator_counts` on D1, upserted on
  (hotkey) with the usual `captured_at <= excluded.captured_at` staleness guard.
  The writer's column list and the table are asserted against each other in both
  directions, the same anti-drift guarantee 0007 has. No prune, and for a
  sharper reason than account_identity's: the producer's pass over Alpha IS
  exhaustive, but it chunks across requests, so "absent from this batch" and
  "absent from the scan" are different statements and only the latter would
  license a delete.
- src/validator-nominator-counts-d1-write.ts reuses neurons-d1-write's statement
  building, so one place owns the Workers-binding budget of 100 bound parameters
  per statement -- asserted as the platform limit in the tests, not as a constant
  we picked. At 3 columns this chunks to 30 rows a statement, so a full scan is
  ~3,752 statements.
- workers/data-api.ts's handleValidatorNominatorCountsSync stops answering
  `503 hyperdrive binding unavailable` and writes that batch to D1.
  `nominator_count` is required to be a non-negative integer rather than
  coerced: the read side already discards anything else, and a value the route
  accepted but every reader silently drops is worse than a 400 the producer can
  see.
- The read is wired into buildGlobalValidators/buildValidatorDetail, which were
  handed a hardcoded empty map / null count. It joins against `neurons` inside
  SQLite rather than inlining a key list -- the leaderboard covers ~1,031
  hotkeys against a 100-parameter cap, so a correlated subquery costs zero bound
  parameters and one query where an IN list would cost a dozen round trips.

#9276's serving-Worker lakehouse overlay is deliberately left in place. It only
collects hotkeys whose count is still null and returns early when there are
none, so it covers what D1 has not received yet and stops firing on its own once
a full scan lands -- the cutover is a property of the data, not of a deploy.

Failure posture is the family's throughout: any failed read degrades to null,
which is exactly what this tier served before the table existed. A broken read
is a lost enrichment, never a wrong number.

Closes #9301
Part of #9146
@superagent-security

Copy link
Copy Markdown

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

@cloudflare-workers-and-pages

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 2b53fe6 Aug 03 2026, 08:26 PM

@cloudflare-workers-and-pages

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 2b53fe6 Aug 03 2026, 08:26 PM

@JSONbored
JSONbored merged commit 54a070c into main Aug 3, 2026
7 checks passed
@JSONbored
JSONbored deleted the fix/validator-nominator-counts-d1-sink branch August 3, 2026 20:45
JSONbored added a commit that referenced this pull request Aug 3, 2026
…sholds to the producer (#9311)

Two gaps in the alarm coverage over the two outputs of the poller's one Alpha
scan.

THE COUNTS LANE HAD NO WATCHDOG. #9302 gave it a D1 sink and metagraphed-infra
#243 re-enabled its producer, but nothing watched it -- which is the exact
shape of the failure that produced the lane's outage in the first place. Its
writer targeted a Postgres that went away, nominator_count degraded to null or
to a frozen 2026-08-02 mirror covering 564 of 1,031 validators, and no probe,
no red check and no exception fired. The read path degrades so gracefully that
the outage was invisible. nominator_positions -- the other output of the same
scan -- got a watchdog in #9273 for precisely this reason; its sibling now has
the matching one, same shape as neurons/positions/chain-detail before it: one
MAX() read, a pure rule, a summary rather than a throw, one exception per stale
tick. An EMPTY table alerts too, since that is the state in which every
nominator_count is still coming from the frozen mirror.

THE POSITIONS THRESHOLD UNDERCUT ITS OWN PRODUCER. It was 6 hours, chosen while
that lane had no producer at all, on the reasoning that six hours was "several
missed passes at any plausible cadence". The producer now feeding it runs on a
24h tick (VALIDATOR_NOMINATORS_POLL_SECS defaults to 24*3600, and one scan
writes both tables), so a healthy lane presents an age anywhere in
[0h, 24h+scan] and a 6h threshold would have alerted for roughly three quarters
of every day. It has not fired yet only because the table is still empty and
takes the no_rows branch instead; it would have started the moment the
re-enabled producer posted. An alarm that always fires is one nobody reads.

Both thresholds are now 30h -- one missed pass plus slack for the scan itself
(~4 min at the measured ~3,100 rows/sec) and cron jitter. Derived from the
cadence, not picked, and stated as such in both headers so the next cadence
change has somewhere to land. Tests pin the regression directly: a capture from
the middle of a 24h cycle must be quiet.

Cron 19,49 collides with nothing in workers/config.ts and stays off the */5
raw-capture and */15 probe grids; the suite asserts both that uniqueness and
that wrangler.jsonc actually declares the trigger, since dispatch keys on the
literal string and an undeclared cron is silently dead code.

Closes #9310
Part of #9146
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.

nominator_count is frozen at 2026-08-02: the counts lane has a producer but no Cloudflare-native sink

1 participant