Query tracker rework - #34
Conversation
… avoid log spamming
|
read through the whole rework. this is a lot of careful engineering and most of it holds up. the scary numeric stuff is all guarded: every divisor is blocker: deleting the idle eviction can run with its own master switch off. the eviction task now also hosts the regression guard, so it's spawned when batch ingest can double-count demand on a partial failure. a rebuilt index reads as zero-supply right when it's most evictable. a create that half-succeeds leaks against the cap. smaller: the none of this touches the core accounting or the served query path, and the carryover from the old tracker is otherwise faithful. the migration and the eviction master-switch are the two that block merge outright. the ingest double-count and the rebuild-supply baseline don't block it, but they skew the demand/supply signal the tracker makes decisions from, so they'll bite once it's actually creating and evicting indexes. |
Thanks for taking the time for reading through it, as you can see this a big topic and is still a work in progress, there are many areas to polish, I expect more updates coming overall to the score related part once we collect more data out of this 1st version. Regarding your comments:
This is a similar comment that in this PR #33, with the same response. We added this clarification already in the docs https://github.com/solana-rpc/cloudbreak/blob/main/README.md?plain=1#L180-L188
You are right, this is something I didn't realize, I'm changing the behavior, the master switch controls everything now 97a205e
You are right about this, this is a better more fair measure of the index being recreated, implemented here c54c62c (for more context, in general this is just the 1st version but scores will require more updates after this 1st iteration probably)
For these 2 I'd argue that the are errors that I would prefer not to handle particularly (maybe only logging is too weak of a response but I don't think they deserve to kill the whole system, so at least logs keep them visible), they are unexpected cases that would require further investigation if they happen
Agreed this could have a better cheking to don't allow crazy inputs (maybe you can indulge me with this for another PR) |
|
Thanks for the detailed response, and I understand it being a 1st iteration with more score tuning to come. on the migration: fair. i was reading it as a persisted-db upgrade, but with the ephemeral-db / unlogged-tables design and a fresh schema each run that's the wrong frame. withdrawn. checked the master-switch and mark_created changes, both look right. on the two i pushed on: the create-half-succeeds leak, agreed, it already logs on the mark_created failure, so investigate-if-it-happens is reasonable. the batch double-count i'd frame a little differently. it's not about killing the system, and the catch is that it's silent: the double-apply lands on the retry, so nothing logs at the point the counters inflate (you get the re-buffering warning, but nothing saying the prefix already committed). one transaction around apply_batch doesn't add a failure mode, it just makes the whole-chunk retry idempotent, which is what the client's re-buffer already assumes. not blocking, your call, just didn't want it landing as an error-handling thing when it's really a consistency one. i can take the weighted-config validation as a separate PR if you want it off your plate. |
Query tracker rework: persistence-first, rate-based prioritization, latency guard
Summary
Full rewrite of the query tracker from an in-memory, volatile design into a
persistence-first service backed by a single
index_patternstable. Everyinput a decision needs now lives in Postgres, so the service is fully
restartable and never loses accumulated history. On that foundation this adds
cost/latency-aware prioritization, windowed rate scoring, a value-guarded
eviction pass, and a latency regression guard.
Motivation
The old tracker kept a 10k-entry queue plus a separate prioritized queue in
memory. Consequences: a restart dropped all accumulated demand; the
!foundpath re-attempted indexes that already existed (create/fail churn); ranking was
frequency-only; and nothing measured whether a created index actually helped.
This rework makes the tracker self-aware of its own state and drives creation
and eviction from one consistent, configurable criterion.
Core model
index_patterns), one row perIndexIdentity. Anidentity is the index-defining subset of a
getProgramAccountsrequest:program+ memcmp(offset, length)columns + optionaldatasize. Everythingelse (commitment, encoding, data slice, and the memcmp values) is
intentionally collapsed, so demand aggregates per index we would actually
build, not per raw query. Removed the now-superseded
auto_index_usagetable.
pattern_idis a fixed-widthblake3hash of the identity (16 hex chars =64 bits), used both as the primary key and as the token embedded in physical
index names. Postgres identifiers are capped at
NAMEDATALEN − 1 = 63bytes,and longer names are silently truncated (which would let two patterns
collide). The hash keeps every generated name (
idx_snapshot_accounts_<hash>≈ 38 bytes) safely and deterministically under that limit — the same
pattern always maps to the same row and the same index across restarts.
human_nameis used in logs,metrics and debug output; the opaque hash is confined to the DB key and index
names.
Demand + supply, made comparable
idx_scan) are tracked as twoindependent signals. A served GPA is a
UNION ALLoveraccounts+snapshot_accounts, so it scans both indexes of a pair — rawidx_scanruns ≈2× the request count. Supply is normalized by
SCANS_PER_REQUEST = 2before any demand/supply comparison (scoring and discrepancy).
an index. Failures are recorded (using the timeout budget as the cost
estimate) so an index can be built before / regardless of success, and
failure-weightcan prioritize exactly these patterns.Prioritization — one criterion, four modes
Creation ranks candidates descending; eviction ranks the same expression
ascending — "what we most want to build" and "what we least mind dropping"
are two ends of one order.
priority-modeselects how a row scores:frequency— lifetime request count (demand_count).cost— lifetime DB cost (total_cost_us).cost-per-hit— average cost per request (total_cost_us / demand_count).weighted— a flexible, tunable table that ranks on recent windowedactivity plus the measured latency win, so decisions track current
throughput rather than all-time popularity:
where
avg_cost = total_cost_us/demand_count,demand/supply/failedareper-
rate-windowrates, andgain = 1 + latency-weight·ln(without/with)scales cost by how much faster the index makes the pattern. All weights default
0.0, which degenerates cleanly to plain average cost-per-hit (and needs norate roll).
Windowed rates + cold-start bootstrap. A background roll task materializes
per-window demand/supply/failure rates every
rate-window; until a pattern'sfirst roll, scoring falls back to its running total so a fresh pattern is still
ranked within its first window.
Eviction — safe by construction
eviction-fill-threshold; below it,creation just slows instead of dropping anything.
index-min-age-grace, which prevents the drop→slow→rebuild churn loop.the paired top creation candidate's; by monotonicity the pass stops at the
first candidate that fails, so we never evict value we have nothing better to
put in its place.
when the indexer is under load) and run under a configurable
drop-lock-timeoutwithdrop-retries.Latency regression guard
Cost is routed into with-index vs without-index buckets by status at record
time. An index older than
index-min-age-gracethat is slower with theindex than without (by
index-regression-ratio) is flagged:warnlogs andreports a gauge;
evictdrops the pair and marks the patternrejected. Arejectedpattern is not rebuilt by demand alone — only afterindex-regression-retry-delayand fresh without-index evidence that the indexwould now help. The same latency signal optionally feeds
weightedvia theln-smoothed gain term.Supporting features
cheaply with the
hyperloglogpluscrate; the client sends a bounded set ofvalue fingerprints that the tracker folds into a per-row sketch. (We track the
variety of index-relevant filter values; all other request dimensions are
aggregated away.)
RpcProgramAccountsConfigisstored once (
example_request) so the optionalEXPLAINprobe uses realfilter values instead of zeros and runs on both tables; it is also shown on
the debug views for documentation/sampling.
flag/log when demand and
idx_scandiverge past a configurable delta, or whenthe planner would not use an index, surfacing on
/debug/*and/metrics.POST /trackbatch ingest endpoint (a singlequery is a batch of length 1, so legacy callers reuse it) plus operational
endpoints (
/debug/candidates,/debug/created,/debug/discrepancies,/metrics,/health) on one port, split intoendpoints/(functional) andoperational/(introspection). Typed request/response structs shared incore.dedups value fingerprints in a
HashSet(bounded), and on a flush failurere-merges the batch instead of dropping it — demand is never silently lost.
target; invalid storedprogram bytes surface an error instead of being silently skipped; large
functions are split into small, documented modules (
ingest,creation,eviction,store,prioritization,stats/*,server/*).Configuration
priority-mode(with theweightedtable:demand-weight,supply-weight,failure-weight,latency-weight,rate-window),index-regression-guard(
off/warn/evict),index-regression-ratio,index-regression-retry-delay,eviction-fill-threshold,drop-lock-timeout,drop-retries, plus the existingeviction knobs. All documented in
README.mdandexample.cloudbreak.query-tracker.toml; defaults preserve prior behavior.Edit:
Update: value guard moved to creation time (c9d3811)
Reworked the index value guard from eviction time to creation time. Previously eviction weighed each drop against the best pending creation (pairing eviction/creation candidates rank-for-rank) and stopped as soon as a swap wasn't worth it. That coupled two loops, left the table sitting above the fill target whenever no creation was queued, and — in practice — meant eviction never fired in staging because the table stayed below the hard cap and nothing was ever queued to justify a drop.
New model — the fill target is the operating size, the cap is the real ceiling:
floor(eviction-fill-threshold × max-auto-indexes)): the top candidate is built freely.(target, cap]: a new index is built only if it out-scores the index it would displace. That boundary is not the single worst index — sinceover = current − targetindexes are already destined for the next trim, the candidate competes with the first index that would survive it (positionoverin ascending-score eviction order). Example (max=200,fill=0.7⇒ target 140): atcurrent=140(over=0) it must beat the single worst eligible index; atcurrent=176(over=36) the 36 worst are leaving anyway, so it must beat the 37th-worst.New config:
value-guard-creation-bias(f64, default1.0). Multiplies the candidate's score in the guard comparison to tune stickiness. Acreatedindex carries realized signal a fresh candidate can't (its latencygainandidx_scansupply), which structurally favors incumbents;>1.0builds new indexes more readily (less sticky),<1.0favors incumbents (stickier). Only consulted in the buffer band.Notable changes
store: addedeviction_boundary_score(...); factored the eligibility gate into a sharedeviction_gate()so the eviction trim and the creation guard can't drift apart; removed the now-unusedtop_candidateswrapper.creation: addedvalue_guard_allows(...)(documents theoverlogic + stickiness).eviction: removed the eviction-time guard (pending_creation_scores+ paired-score stop), now trims unconditionally to target.README, module docs (lib.rs,creation,eviction), andexample.cloudbreak.query-tracker.toml; verified no leftovers from the old approach.