Skip to content

fix(rpc): sum both usage stores behind one composer all three surfaces call - #9296

Merged
JSONbored merged 1 commit into
mainfrom
fix/rpc-usage-one-composer
Aug 3, 2026
Merged

fix(rpc): sum both usage stores behind one composer all three surfaces call#9296
JSONbored merged 1 commit into
mainfrom
fix/rpc-usage-one-composer

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

/api/v1/rpc/usage had two defects that are one defect seen from two sides, which is
why they are fixed in one PR rather than two patches that would each have to remember
the other.

The shared cause is that three surfaces each assembled the card, so a fix could land on
one leg and leave the others a version behind — exactly the pattern #9285 documented
and fixed for /accounts/{ss58}.

What Changed

One composer. answerRpcUsage (src/rpc-usage-answer.ts) owns the whole tier
decision — deliberately the same shape as src/account-summary-card.ts's
answerAccountSummary, so this reads as one pattern rather than two solutions to one
problem. REST (workers/request-handlers/rpc-proxy.ts), the get_rpc_usage MCP tool
and the rpc_usage GraphQL resolver all call it and compose nothing themselves.
src/rpc-usage-loader.ts's zeroed floor is now reachable only where it is genuinely
correct: when every store declined.

Merge strategy: full sum, not fall-through. Counts are additive over disjoint
ranges, so total_requests, ok_requests, error_requests, failover_requests,
cache_hits and the per-endpoint / per-network / per-bucket series are summed across
both stores. Two properties keep that sound:

  • The lakehouse read is bounded strictly below the oldest event AE holds
    (loadRpcUsageColdTier's new until), so the two stores can never describe the same
    event twice. That is why the reads are sequential rather than parallel — the cold
    query cannot be bounded until the hot answer is in hand.
  • The lakehouse is only read while AE does not already span the requested window
    (one bucket of tolerance, since AE's own timestamp > now() - N DAY predicate always
    starts a moment after the cutoff). Once AE's retention covers the window — 7d from
    2026-08-10, 30d from 09-02 — the second scan stops being issued and never resumes, so
    this is a transitional cost, not a permanent one.

Percentiles are not merged, and say what they cover. quantileExactWeighted cannot
be combined with a store that has no percentile function at all, so p50/p95 stay
AE-only. Reporting them as the whole window's percentiles would be a claim about a
sub-range, so the sub-range is published instead of implied.

The payload no longer claims a range it did not measure. New top-level coverage:

"coverage": {
  "start": 1785086400000,          // oldest measured event, across contributing stores
  "end":   1785709179000,          // newest (mirrors observed_at)
  "segments": [                    // per store, oldest first
    { "source": "lakehouse",         "start": 1785086400000, "end": 1785664800000 },
    { "source": "analytics-engine",  "start": 1785702000000, "end": 1785709179000 }
  ],
  "latency_percentiles": { "start": 1785702000000, "end": 1785709179000 }
}

Segments are per store rather than one start/end pair on purpose: the two stores are
disjoint and non-adjacent, so the honest picture has a hole in the middle that a
single pair would paper over. latency_percentiles is null when nothing measured
them — null keeps meaning "not measured", exactly as it already does everywhere in
this payload.

Descriptions corrected. The MCP tool description and the GraphQL SDL no longer
claim rpc_proxy_events D1 telemetry (gone since #9228); both now name the two
tiers and the deliberate percentile difference. docs/backend-artifact-contracts.md
likewise.

Requirement 4 of #9269 (audit the other tryPostgresTier call sites): the flag for
this route is "retired" in wrangler.jsonc and workers/data-api.ts no longer has an
rpc/usage read route at all, so the Postgres leg is dead in both directions. It is kept
in the composer, in its historical position ahead of the lakehouse, purely as the
documented kill switch — no behaviour change. src/account-summary-card.ts (#9285)
already closed the other instance of "cold tier wired into REST but not the siblings";
no further instance of the pattern was found for this route family.

Tests

The tests assert across the surfaces, not per surface — per-surface tests are what
let this class of bug land twice:

  • tests/rpc-usage-surface-parity.test.ts — (1) structural: no surface imports a tier
    reader (loadRpcUsageHotTier / loadRpcUsageColdTier / loadRpcUsage), each imports
    the composer; (2) behavioural: one set of Analytics Engine + R2 SQL responses driven
    through the real readers, and REST / MCP / GraphQL come back byte-identical on
    summary, endpoints and coverage.
  • tests/rpc-usage-answer.test.ts — the sum, the weighted latency mean, the refusal to
    merge percentiles, the coverage segments (including the gap), endpoint/network/bucket
    folding, the cascade order, and the floor being reachable only when every store
    declined.
  • Tier tests extended for the new min() selections, the until ceiling (including
    that an unusable ceiling is dropped rather than interpolated — R2 SQL has no bound
    parameters), and each tier's own coverage.

All generated SQL still passes unsupportedAeFunctions (min is documented; no
nullif/ifNull/coalesce/NULL literal anywhere), and no ungrouped count(DISTINCT)
is introduced.

Patch coverage

100% of changed lines in src/** + workers/**, branch-counted, measured by
intersecting the diff's changed lines with v8's uncovered statement + branch set (not a
whole-file percentage):

src/graphql-sdl.ts:                     31 changed lines, 0 uncovered
src/graphql.ts:                         23 changed lines, 0 uncovered
src/health-serving.ts:                  78 changed lines, 0 uncovered
src/mcp-server.ts:                      19 changed lines, 0 uncovered
src/rpc-usage-answer.ts:               345 changed lines, 0 uncovered
src/rpc-usage-cold-tier.ts:             39 changed lines, 0 uncovered
src/rpc-usage-hot-tier.ts:              24 changed lines, 0 uncovered
src/rpc-usage-loader.ts:                 8 changed lines, 0 uncovered
workers/request-handlers/rpc-proxy.ts:  15 changed lines, 0 uncovered
TOTAL: 582 changed lines, 0 uncovered

No v8 ignore was added — two defensive branches that no fixture could reach on both
sides were removed instead (the merge's inputs are formatRpcUsage output, so
re-guarding fields that are unconditionally present was untested branching wearing the
costume of safety).

Note on the base

tests/account-summary-card.test.ts and tests/cross-view-agreement.test.ts fail with
5 assertions on origin/main at b852dbd — verified by stashing this branch and
running them against a clean tree, and confirmed in main's own Validate run
(30817296139, test job: 2 failed | 636 passed). Those are #9285's own tests and are
untouched by this PR; main's ui e2e job is also red there. Nothing in this diff can
turn either green.

Registry Safety

  • Links a tracked, currently-open issue (Closes #<n>) — required.
  • No secrets, PATs, wallet data, private dashboards, private URLs, or
    validator-local state.
  • Generated artifacts were produced by repo scripts, not hand-edited.
  • R2-only/high-churn detail artifacts are not committed
    (public/metagraph/r2-manifest.json auto-reverted by the build;
    public/metagraph/schemas/index.json untouched).
  • Public API/OpenAPI/schema changes are intentional and documented — coverage is
    additive on RpcUsageArtifact and get_rpc_usage's output schema, plus the new
    RpcUsageCoverage / RpcUsageCoverageSegment / RpcUsageCoverageRange GraphQL
    types; openapi.json, packages/contract/index.d.ts, public/metagraph/types.d.ts
    and generated/graphql/types.ts regenerated and committed. The api-reference docs
    generator was re-run from apps/ui and produced no drift.

Validation

  • npm run lint · npm run format:check · npm run typecheck
  • npm run build (artifacts regenerated + committed)
  • npm run validate
  • npm run validate:schemas
  • npm run validate:api
  • npm run validate:openapi
  • npm run validate:types
  • npm run validate:contract-drift
  • npm run validate:schema-enums
  • npm run validate:graphql-types-drift
  • npm run validate:mcp
  • npm run validate:ai
  • npm run validate:docs
  • npm run validate:module-state-resets
  • npm run validate:no-hand-written-mjs
  • npm run scan:public-safety
  • npm run validate:private-boundary
  • npm test (647/649 files pass; the 2 failing files are the pre-existing main
    breakage noted above)
  • patch coverage measured by diff ∩ v8 uncovered (above)
  • git diff --check

Template Used

  • backend-code.md

Closes #9293
Closes #9269

@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 214b37b Aug 03 2026, 01:49 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-data-api 214b37b Aug 03 2026, 01:48 PM

@superagent-security

Copy link
Copy Markdown

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

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

❌ 5 Tests Failed:

Tests completed Failed Passed Skipped
14969 5 14964 0
View the full list of 5 ❄️ flaky test(s)
tests/account-summary-card.test.ts > answerAccountSummary > the card carries BOTH legs — events and current registrations

Flake rate in main: 100.00% (Passed 0 times, Failed 1 times)

Stack Traces | 0.00695s run time
AssertionError: Expected values to be strictly equal:

'gap' !== 'answer'


Expected: "answer"
Received: "gap"

 ❯ tests/account-summary-card.test.ts:140:12
tests/cross-view-agreement.test.ts > an account's summary card and its event feed agree > GraphQL answers with the same card, and raises on the same failure

Flake rate in main: 100.00% (Passed 0 times, Failed 1 times)

Stack Traces | 0.017s run time
TypeError: Cannot read properties of null (reading 'event_count')
 ❯ tests/cross-view-agreement.test.ts:393:23
tests/cross-view-agreement.test.ts > an account's summary card and its event feed agree > a summary is not empty for an account whose /events returns rows

Flake rate in main: 100.00% (Passed 0 times, Failed 1 times)

Stack Traces | 0.00571s run time
AssertionError: Expected values to be strictly equal:

503 !== 200


- Expected
+ Received

- 200
+ 503

 ❯ tests/cross-view-agreement.test.ts:280:12
tests/cross-view-agreement.test.ts > an account's summary card and its event feed agree > the MCP tool serves the SAME card REST does when the tiers answer

Flake rate in main: 100.00% (Passed 0 times, Failed 1 times)

Stack Traces | 0.000667s run time
Error: The event history for 5Fv5t8frGG3MKtahp4WafKPmT5xZDbqWf8aFZpXyvjHTgzzx could not be read right now, so this summary would report zero events, zero event kinds and no registrations for an account that may have many. This is a tier failure, not an account without activity -- retry shortly, or read .../accounts/5Fv5t8frGG3MKtahp4WafKPmT5xZDbqWf8aFZpXyvjHTgzzx/events for the same stream.
 ❯ toolError src/mcp-server.ts:2105:17
 ❯ Object.handler src/mcp-server.ts:7454:15
 ❯ tests/cross-view-agreement.test.ts:372:19

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { toolError: true, code: 'account_summary_unavailable' }
tests/cross-view-agreement.test.ts > an account's summary card and its event feed agree > the card's registrations match what /subnets serves for the same hotkey

Flake rate in main: 100.00% (Passed 0 times, Failed 1 times)

Stack Traces | 0.000762s run time
TypeError: Cannot read properties of null (reading 'registrations')
 ❯ tests/cross-view-agreement.test.ts:312:27

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

…s call

/api/v1/rpc/usage had two defects that are the same defect seen from two
sides, so they are fixed together rather than as two patches that would each
have to remember the other.

MAGNITUDE (#9293). The Analytics Engine hot tier DISPLACED the lakehouse cold
tier instead of joining it. Measured 13:03 UTC: `window=7d` reported
total_requests 3,990 across three buckets spanning two hours of today, where
the same window served 578,506 across 151 buckets the day before -- a 99.3%
under-report carrying a confident `7d` label. It does not resolve itself: the
lakehouse is frozen permanently at 2026-08-02 22:00, so AE only reaches a
rolling 7d from 08-10 and 30d from 09-02, and the older days are ABSENT from
the number rather than diluted.

Counts are additive across disjoint ranges, so they are summed: total, ok,
error, failover and cache_hits, plus the per-endpoint, per-network and
per-bucket series. The lakehouse read is bounded strictly below the oldest
event AE holds, so the two stores can never describe the same event twice, and
it is only issued while AE does not already span the requested window -- once
it does, the second scan stops for good rather than becoming permanent cost.

Percentiles are NOT summed, because they are not additive.
quantileExactWeighted cannot be merged with a store that has no percentile
function at all. They stay AE-only, and the payload now says which sub-range
they describe.

The payload also stops claiming a range it did not measure. A new `coverage`
block reports the span each store actually contributed, per store -- two
non-adjacent segments make the real hole between the frozen lakehouse and the
start of AE capture visible rather than papering over it with a single
start/end pair.

SURFACES (#9269). REST, MCP and GraphQL each ran their own cascade. #9207
wired the cold tier into the REST handler only, so with the Postgres box
destroyed the other two fell through `tryPostgresTier -> loadRpcUsage` to the
schema-stable ZEROED card: REST served 118,309 requests on its top endpoint
while `rpc_usage(window:"7d")` served `total_requests: 0, endpoints: []` at the
same instant. An MCP client cannot tell that apart from an idle proxy.

The cascade is now ONE composer, `answerRpcUsage` in src/rpc-usage-answer.ts --
the same shape as src/account-summary-card.ts's answerAccountSummary, which
exists for the same reason. All three surfaces call it and compose nothing
themselves, so src/rpc-usage-loader.ts's zeroed floor is reachable only where
it is genuinely correct: when every store declined.

The tests assert ACROSS the surfaces, not per surface -- structurally (no
surface imports a tier reader) and behaviourally (one set of store responses,
three identical cards). Per-surface tests are what let this class of bug land
twice.

The MCP tool and GraphQL SDL descriptions no longer claim D1 telemetry, which
has not existed since #9228.

Closes #9293
Closes #9269
@JSONbored
JSONbored force-pushed the fix/rpc-usage-one-composer branch from 5cb40c4 to 214b37b Compare August 3, 2026 13:48
@JSONbored
JSONbored merged commit ecfe3b3 into main Aug 3, 2026
7 checks passed
@JSONbored
JSONbored deleted the fix/rpc-usage-one-composer branch August 3, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant