Skip to content

perf(api): buffer usage-rollup observations in the isolate (#8823) - #8946

Merged
loopover-orb[bot] merged 1 commit into
mainfrom
perf/usage-rollup-buffering-8823
Jul 31, 2026
Merged

perf(api): buffer usage-rollup observations in the isolate (#8823)#8946
loopover-orb[bot] merged 1 commit into
mainfrom
perf/usage-rollup-buffering-8823

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Decision: Option A — isolate-scoped buffering, with a count-or-age flush

Written up as ADR 0026 (the change alters the accounting contract's loss modes, so it warranted one rather than just a PR paragraph). In brief:

  • Flush triggers: 64 observations OR 10 seconds since the first observation in the current buffer, evaluated synchronously as each observation arrives. Workers has no timer that runs outside a request, so the age bound can only fire when a later request arrives — which is the design, not a gap: a flush is affordable exactly when there is a request whose waitUntil can carry it.
  • The flush rides the triggering request's ctx.waitUntil, so it still adds no latency — same posture as the single-observation write.
  • Per-isolate only. Many isolates still means many writers; this makes each writer 64x quieter. Stated as a bound in the ADR rather than claimed as a fix for any scale.
  • Buffered observations are lost on isolate eviction — at most 63. That is the one accuracy cost, and it is why the thresholds are small rather than tuned for maximum batching. It under-counts and never over-counts, so the rollup stays a floor on real traffic, which is the safe direction for a cost estimate.

Why not B (sampling): it turns request_count into an estimate, and the table exists to answer ADR 0022's "does the free tier cost too much" — a figure someone multiplies by a rate. The scale factor would also have to be versioned into the rows to stay interpretable across a threshold change. Buffering gets the same write reduction without giving up exactness.

Why not C (derive it off the request path): Logpush / Analytics Engine / PostHog all carry request volume, but none carries route_family — that label comes from routeFamily() matching the Worker's own dispatch order, which is what makes the rollup attribute a request to the route that actually served it. Reconstructing it from a log stream means reimplementing that matcher in a second place and keeping the two in step forever, plus trading freshness for a scheduled job and adding a dependency to a table that has none. Still a reasonable future option; not worth its cost today.

The other four things the issue required settled

  1. Unmatched paths are still counted — deliberately. They are the entire abuse surface, and "how much 404/scanner volume is this API absorbing" is a real input to the same capacity question the table serves. What made them expensive was not being counted, it was that each one was a write. The single-bucket collapse that made (day, "unmatched", "edge") the worst case is now what makes it the cheapest: 64 distinct junk paths in one batch cost one bucket and one row-lock acquisition. Asserted in a test.
  2. Connection reuse: split out, not fixed hereperf(api): withAccountsSql builds a new postgres() client on every invocation #8944. withAccountsSql building a new postgres() client per invocation is independent of which option was chosen and affects every accounts-tier route, including the awaited handleApiQuotaSpend that fails open. Changing client lifetime inside a usage-rollup PR would silently alter routes this PR does not test. Buffering already removes 63 of every 64 client constructions on this path.
  3. The stale comment is now true. workers/data-api.ts's "BATCHED on purpose. The caller coalesces a request's observations…" described nothing — the caller folded a one-element array. Rewritten to describe the buffer that now exists, including that every bucket in a batch is upserted inside one withAccountsSql call, so one client serves the whole batch.
  4. Data continuity. request_count / keyed_count keep their exact meaning — exact counts of observed requests, batched, never sampled or scaled. Nothing about existing rows changes. The only new behaviour is the eviction loss above, stated here and in the ADR.

Tests

tests/usage-rollup.test.ts — a new recordUsageRollup batches its writes (#8823) block; 6 cases fail against the unbuffered writer and pass with it:

  • N requests ⇒ ceil(N / 64) writes, not N. 63 observations write nothing; the 64th trips one subrequest carrying one bucket with request_count: 64 (not 64 buckets); 128 ⇒ exactly 2. Also asserts the buffer is drained, not copied.
  • The unmatched-path flood: 64 distinct /api/v1/<junk> paths ⇒ one subrequest, one bucket, family === UNMATCHED_FAMILY. This is the abuse case the issue is about.
  • Keyed/keyless stay distinguishable inside a batch (keyed_count exact), and distinct families become distinct buckets in one write.
  • The age trigger fires on the next request after the 10s bound, with Date.now stubbed.
  • End-to-end through handleRequest with a stubbed DATA_API, driving 64 real unmatched /api/v1/* requests — the case the issue asked for explicitly, since that path reaches the generic 404 without passing any rate limiter — plus a check that an OPTIONS preflight is still not counted.

The existing #8597 tests now flush explicitly (they assert what is recorded, not when it is written); flushUsageRollup and usageRollupBufferSize are exported for that. The buffer registers with src/module-state-registry.ts like every other isolate-scoped memo, so a leftover observation cannot shift the next test file's flush boundary.

Registry Safety

  • Links a tracked, currently-open issue (Closes #8823) — required.
  • No secrets, PATs, wallet data, private dashboards, private URLs, or validator-local state.
  • Generated artifacts were produced by repo scripts, not hand-edited. (None — no schema or contract change; the internal rollup endpoint already accepted a buckets array.)
  • R2-only/high-churn detail artifacts are not committed.
  • Public API/OpenAPI/schema changes are intentional and documented. (None — this is entirely internal to the write path.)

Validation

  • npm run lint · npm run format:check · npm run typecheck
  • npm run validate · npm run validate:docs
  • npx vitest run tests/usage-rollup.test.ts — 29 passed
  • npx vitest run tests/api-coverage.test.ts tests/wallet-auth-keys-route.test.ts — 415 passed (the other suites that exercise this hook)
  • git diff --check

Every /api/v1/* request drove one unauthenticated Postgres write on the
self-hosted indexer box. recordUsageRollup called foldObservations with a
single-element array and POSTed immediately, so N requests meant N DATA_API
subrequests, N postgres() clients (withAccountsSql builds a fresh one per
invocation), and N upserts. handleUsageRollupIncrement's own comment claimed
the caller coalesced observations first; nothing did.

Unmatched paths were the worst case: routeFamily collapses every non-matching
path to one "unmatched" bucket, so a flood of /api/v1/<random> 404s serialised
on a single row lock -- reached through the generic 404, which passes no rate
limiter.

Observations now accumulate in a module-scope buffer and flush on 64
observations or 10 seconds, whichever comes first, as one subrequest carrying
one bucket per (day, route_family, cost_shape). The flush rides the triggering
request's waitUntil, so it still adds no latency, and the buffer drains before
the fetch so a concurrent request cannot re-send it.

request_count/keyed_count keep their exact meaning -- batched, never sampled.
The one new loss mode is isolate eviction with a partial buffer, bounded at 63
observations, which under-counts and never over-counts. ADR 0026 records why
sampling and a log-derived rollup were rejected, why unmatched paths are still
counted, and why withAccountsSql's per-invocation client is split out.

Closes #8823
@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 f6ccbe5 Jul 31 2026, 03:48 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-data-api f6ccbe5 Jul 31 2026, 03:47 PM

@loopover-orb

loopover-orb Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Tip

✅ LoopOver review result - approve/merge recommended

Review updated: 2026-07-31 15:58:03 UTC

5 files · 1 AI reviewer · no blockers · readiness 100/100 · CI green · clean

✅ Suggested Action - Approve/Merge

  • safe to merge

Review summary
This closes #8823 by buffering usage-rollup observations in isolate-scoped module state (workers/api.ts:493-560) instead of writing to Postgres on every request, flushing on a 64-count-or-10s-age trigger via the triggering request's ctx.waitUntil. The buffer push/drain logic is fully synchronous (no await between buffer mutation and drain), so there's no race between concurrent in-isolate invocations despite the shared module-level `usageRollupBuffer`/`usageRollupBufferedAtMs` state. The accuracy tradeoff (up to 63 observations lost on isolate eviction) is explicitly documented in ADR 0026 and is an accepted, bounded floor-not-ceiling design choice, not a defect. Tests cover count-trigger, age-trigger, family/keyed folding, and an end-to-end handleRequest path, and the module-state-reset hook is correctly updated to clear the new buffer between test files.

Nits — 5 non-blocking
  • workers/api.ts:497-498 — module-scope `usageRollupBuffer`/`usageRollupBufferedAtMs` are isolate-wide singletons; correctness today relies on there being no `await` between push and drain in `recordUsageRollup`/`flushUsageRollup` — worth a comment guarding future editors against inserting one.
  • The age trigger can only fire on a subsequent request (documented in the ADR), so a low-traffic isolate can hold a partial buffer indefinitely until eviction — acceptable per ADR 0026 but means the 10s bound is aspirational, not guaranteed, which could confuse someone reading only the constant name.
  • Consider surfacing `usageRollupBufferSize()` (or dropped-batch count) as a lightweight PostHog/usage-telemetry event so isolate-eviction loss is observable in production rather than only bounded in theory.
  • workers/data-api.ts:5595 comment update accurately reflects the new batched caller — good, no change needed.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #8823
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 9 registered-repo PR(s), 8 merged, 299 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 9 PR(s), 299 issue(s).
Linked issue satisfaction

Addressed
The PR implements Option A exactly as specified: isolate-scoped buffering with a count(64)-or-age(10s) flush triggered synchronously and riding ctx.waitUntil, records the decision in ADR 0026 with rationale against options B and C, explicitly decides to keep counting unmatched paths, and explicitly defers the withAccountsSql connection-reuse issue to a separate ticket as the issue allowed.

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 9 PR(s), 299 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask &lt;question&gt; answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat &lt;question&gt; answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@superagent-security

Copy link
Copy Markdown

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

@loopover-orb loopover-orb Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LoopOver approves — the gate is satisfied and CI is green.

@loopover-orb
loopover-orb Bot merged commit d2a3c5e into main Jul 31, 2026
7 checks passed
@loopover-orb
loopover-orb Bot deleted the perf/usage-rollup-buffering-8823 branch July 31, 2026 15:58
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.

perf(api): every /api/v1/* request drives one unauthenticated Postgres write on the indexer box

1 participant