Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions migrations/d1/0012_validator_nominator_counts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- The validator-nominator-counts family on D1 (box decommission, the
-- #9146/#9157 pattern applied to the last frozen sync lane).
--
-- WHY THIS LANE WAS DEAD RATHER THAN MERELY MIGRATED. Its producer -- a full
-- SubtensorModule::Alpha scan -- was already ported off the box into the
-- poller Container (metagraphed-infra's
-- src/bin/poller/jobs/validator_nominators.rs), but that job writes straight
-- to Postgres, so it is one of the five lanes Dockerfile.poller leaves
-- disabled "until they have a Cloudflare-native sink". This table is that
-- sink. The scan itself needs no rehosting; only its write target does.
--
-- Latest-only, upserted on (hotkey) with the same
-- `captured_at <= excluded.captured_at` staleness guard every other D1 sync
-- lane uses. NO history table: a nominator count is a live gauge, not a fact
-- worth diffing over time (handleValidatorNominatorCountsSync's own header
-- made this call, and nothing about the D1 port changes it).
--
-- NO PRUNE, deliberately -- and for a DIFFERENT reason than account_identity's
-- in 0009. There, a missing account might simply not have been observed. Here
-- the producer's pass over Alpha is exhaustive by construction, so a hotkey
-- absent from a batch genuinely has no stake entries at all. That would argue
-- FOR a prune, except the producer chunks its batches (the sync route caps
-- rows per request), so "absent from this batch" and "absent from the scan"
-- are not the same statement and only the latter licenses a delete. Upsert-only
-- also matches what handleValidatorNominatorCountsSync did against Postgres,
-- so the port changes the store and nothing else.
--
-- Type translation follows 0007_neurons.sql's conventions: counts -> INTEGER,
-- captured_at -> INTEGER epoch-ms, hotkey (SS58) -> TEXT.
--
-- The column set is NOT transcribed by hand: it is exactly the list the writer
-- binds, VALIDATOR_NOMINATOR_COUNT_INSERT_COLUMNS
-- (src/validator-nominator-summary.ts).
-- tests/validator-nominator-counts-d1-write.test.ts asserts that
-- correspondence in both directions -- the same anti-drift guarantee as 0007's
-- tests/neurons-d1-schema.test.ts and 0011's own.
--
-- SIZE: 112,550 rows live-measured against the lakehouse mirror of this same
-- table (2026-08-03), one row per distinct hotkey ever seen holding stake --
-- far wider than the ~1,031 hotkeys that currently carry a validator permit,
-- because every nominated hotkey is scanned, not just permitted ones.
CREATE TABLE IF NOT EXISTS validator_nominator_counts (
hotkey TEXT NOT NULL,
nominator_count INTEGER NOT NULL,
captured_at INTEGER NOT NULL,
PRIMARY KEY (hotkey)
);
55 changes: 55 additions & 0 deletions src/validator-nominator-counts-d1-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// The validator-nominator-counts sync write path, against D1 (#9146).
//
// The last of the five Postgres-backed poller lanes to get a Cloudflare-native
// sink. Its producer (metagraphed-infra's
// src/bin/poller/jobs/validator_nominators.rs) already runs in the poller
// Container and already performs the full SubtensorModule::Alpha scan this
// table is derived from -- it is disabled purely because it writes to a
// Postgres that no longer exists. This module is the store it writes to
// instead; migrations/d1/0011_validator_nominator_counts.sql is the table.
//
// Everything structural is imported from src/neurons-d1-write.ts rather than
// re-derived -- D1_PARAM_BUDGET, chunkStatements, batchInSlices -- so the
// binding's 100-bound-parameter limit is enforced in exactly one place. That
// limit is not theoretical here: at 3 columns this table chunks to 30 rows a
// statement, and a full 112,550-row scan is ~3,752 statements, so a
// hand-rolled batch would have hit the same wall #9157 hit in production.
//
// buildUpsert's trailing `captured_at <= excluded.captured_at` guard is
// exactly right for this table and is why chunkStatements is reused verbatim
// instead of forked: the producer chunks one scan across several requests and
// re-sends on failure, so an out-of-order or replayed batch must be a no-op
// rather than a regression to an older count.

import {
batchInSlices,
chunkStatements,
type D1Like,
type D1PreparedStatement,
} from "./neurons-d1-write.ts";
import { VALIDATOR_NOMINATOR_COUNT_INSERT_COLUMNS } from "./validator-nominator-summary.ts";

type Row = Record<string, unknown>;

/**
* Write one batch of nominator counts to D1: a latest-only upsert on
* (hotkey), nothing else.
*
* NO PRUNE, and no history append -- see the migration's header for both. An
* empty batch issues no statements at all rather than an empty `db.batch([])`,
* matching writeAccountIdentityToD1's own guard.
*/
export async function writeValidatorNominatorCountsToD1(
db: D1Like,
rows: Row[],
): Promise<{ statements: number }> {
const statements: D1PreparedStatement[] = chunkStatements(
db,
"validator_nominator_counts",
VALIDATOR_NOMINATOR_COUNT_INSERT_COLUMNS,
["hotkey"],
rows,
);
if (statements.length) await batchInSlices(db, statements);
return { statements: statements.length };
}
122 changes: 122 additions & 0 deletions tests/data-api-neurons-d1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ const OBSERVATIONS_SCHEMA = fs.readFileSync(
path.join(process.cwd(), "migrations/d1/0002_observations.sql"),
"utf8",
);
// validator_nominator_counts (the nominator_count join target, #9146) -- the
// same reason as subnet_snapshots above: the real database carries it, and the
// leaderboard's read joins against it.
const NOMINATOR_COUNTS_SCHEMA = fs.readFileSync(
path.join(process.cwd(), "migrations/d1/0012_validator_nominator_counts.sql"),
"utf8",
);

let db: InstanceType<typeof DatabaseSync>;

Expand Down Expand Up @@ -249,6 +256,7 @@ beforeEach(() => {
db.exec(NEURONS_SCHEMA);
db.exec(NEURONS_READ_INDEXES);
db.exec(OBSERVATIONS_SCHEMA);
db.exec(NOMINATOR_COUNTS_SCHEMA);
});

// --- POST /api/v1/internal/neurons-sync: the D1 write lane -------------------
Expand Down Expand Up @@ -636,6 +644,120 @@ test("GET /api/v1/validators/:hotkey aggregates one hotkey across subnets with p
assert.equal(body.total_stake_tao, 150);
});

// --- nominator_count, joined from D1 (#9146) ---------------------------------
//
// This field was null on EVERY validator for the whole period between the box
// wipe and migration 0012: the side table was Postgres-only, so both builders
// were handed a hardcoded empty map / null. What is under test is that the
// join now answers, and -- just as important -- that a hotkey with no row
// still reads as UNKNOWN rather than as a confident zero.

/** One row in the counts side table. */
function insertNominatorCount(hotkey: string, count: number, at = 1_000) {
db.prepare(
"INSERT INTO validator_nominator_counts (hotkey, nominator_count, captured_at) VALUES (?, ?, ?)",
).run(hotkey, count, at);
}

test("GET /api/v1/validators fills nominator_count from D1, and leaves an unscanned hotkey null", async () => {
insertNeuron({
netuid: 0,
uid: 0,
hotkey: "5Counted",
stake_tao: 200,
validator_permit: 1,
});
insertNeuron({
netuid: 0,
uid: 1,
hotkey: "5Unscanned",
stake_tao: 100,
validator_permit: 1,
});
insertNominatorCount("5Counted", 42);

const res = await call(req("/api/v1/validators"));
assert.equal(res.status, 200);
const entries = ((await res.json()) as Row).validators as Row[];
const byHotkey = new Map(entries.map((e) => [e.hotkey as string, e]));

assert.equal(byHotkey.get("5Counted")!.nominator_count, 42);
assert.equal(
byHotkey.get("5Unscanned")!.nominator_count,
null,
"a hotkey the scan has not reached is unknown, NOT a confident zero",
);
});

test("GET /api/v1/validators/:hotkey fills nominator_count for that one hotkey", async () => {
insertNeuron({
netuid: 0,
uid: 0,
hotkey: "5Val",
stake_tao: 100,
validator_permit: 1,
});
insertNominatorCount("5Val", 7);
// A second hotkey's row must not leak into this one's answer.
insertNominatorCount("5Other", 999);

const res = await call(req("/api/v1/validators/5Val"));
assert.equal(res.status, 200);
assert.equal(((await res.json()) as Row).nominator_count, 7);
});

test("GET /api/v1/validators/:hotkey reports a zero count as a real answer", async () => {
// 0 is an ANSWER, not an absence -- the one case where the distinction the
// rest of this lane preserves has to survive all the way to the payload.
insertNeuron({
netuid: 0,
uid: 0,
hotkey: "5Val",
stake_tao: 100,
validator_permit: 1,
});
insertNominatorCount("5Val", 0);
const res = await call(req("/api/v1/validators/5Val"));
assert.equal(((await res.json()) as Row).nominator_count, 0);
});

test("GET /api/v1/validators/:hotkey leaves nominator_count null when the hotkey has no row", async () => {
insertNeuron({
netuid: 0,
uid: 0,
hotkey: "5Val",
stake_tao: 100,
validator_permit: 1,
});
const res = await call(req("/api/v1/validators/5Val"));
assert.equal(((await res.json()) as Row).nominator_count, null);
});

test("a failing counts read degrades to null rather than failing the request", async () => {
// The whole lane's failure posture: the leaderboard is the product, the
// count is an enrichment. Dropping the table is the bluntest way to make the
// read throw for real, rather than asserting a mocked rejection.
insertNeuron({
netuid: 0,
uid: 0,
hotkey: "5Val",
stake_tao: 100,
validator_permit: 1,
});
db.exec("DROP TABLE validator_nominator_counts");

const list = await call(req("/api/v1/validators"));
assert.equal(list.status, 200, "the leaderboard still serves");
assert.equal(
(((await list.json()) as Row).validators as Row[])[0]!.nominator_count,
null,
);

const detail = await call(req("/api/v1/validators/5Val"));
assert.equal(detail.status, 200, "the detail card still serves");
assert.equal(((await detail.json()) as Row).nominator_count, null);
});

// --- Live-neurons analytics routes -------------------------------------------

test("GET /api/v1/subnets/:netuid/concentration and /performance read the D1 snapshot", async () => {
Expand Down
Loading