Cloudbreak is a Postgres-backed Solana RPC implementation focused on serving getProgramAccounts-style endpoints. It maintains a filtered segment of Solana account state by program owner, sourced from Yellowstone gRPC streams and optional full/incremental snapshots.
The API server exposes the following JSON-RPC methods:
| Method | Description |
|---|---|
getProgramAccounts |
Returns all accounts owned by a program. Supports memcmp, dataSize, and dataSlice filters. |
getTokenAccountsByOwner |
Returns SPL token accounts owned by a specific wallet. |
getTokenAccountsByDelegate |
Returns SPL token accounts delegated to a specific address. |
getTokenAccountsByMint |
Returns all token accounts for a given mint. Generic implementation backed by getProgramAccounts + a memcmp(offset=0, mint) filter; the SPL Token program is used by default, override with programId in the config (e.g. for Token-2022). Only SPL Token and Token-2022 are accepted; any other programId returns -32602 Invalid param: unrecognized Token program id. Streamed response shape, same as getProgramAccounts. |
getAccountInfo |
Returns the latest version of a single account. Supports base58, base64, base64+zstd, and jsonParsed encodings, dataSlice, and minContextSlot. |
getMultipleAccounts |
Batched getAccountInfo for up to [server].max-multiple-accounts pubkeys per request (default 100). Returns null per position for missing or indexer-filter-excluded accounts. |
getBalance |
Returns the lamport balance of an account. Returns 0 for missing or closed accounts (Agave-compatible). |
getTokenAccountBalance |
Returns the UiTokenAmount of an SPL Token / Token-2022 account, including mint-aware decimals and UI amounts. WSOL native mint is recognised explicitly. |
getTokenSupply |
Returns the total supply of an SPL Token / Token-2022 mint as a UiTokenAmount, with mint-aware decimals and UI amounts. |
getTokenLargestAccounts |
Returns the 20 largest token accounts of a mint. Optional; requires [token-largest-accounts] on both indexer and API. Only mints in the indexer's tracked-mints are served, from a maintained top-N record; other mints get a fast "mint not tracked" error. See Largest Accounts. |
getSlot |
Returns the current slot at the requested commitment level. |
getHealth |
Returns the health status of the service. |
getVersion |
Returns the cluster version, Agave-compatible ({"solana-core": "<string>"}). See note below for the composite-string format Cloudbreak uses. |
getGenesisHash |
Returns the cluster genesis hash as a base58 string. |
getVoteAccounts |
Returns the cluster's current and delinquent vote accounts with per-voter activated stake, commission, last vote, and recent epoch credits. Optional; only available when the Vote and Stake programs are indexed. See Vote Accounts. |
simulateTransaction |
Simulates a transaction against indexed account state at the requested slot and returns logs, compute units, return data, requested account state, and balance changes. Supports sigVerify, replaceRecentBlockhash, accounts, innerInstructions, and minContextSlot. Optional; only available on a full unfiltered index. See Simulate Transaction. |
getLargestAccounts |
Returns the 20 largest accounts by lamport balance, with filter: circulating|nonCirculating support. Optional; requires [largest-accounts] on both indexer and API. See Largest Accounts. |
getSupply |
Returns the total and circulating supply in lamports plus the non-circulating account list. Optional; requires the [supply] section on the indexer and the [supply] section on the API. See Supply. |
Confirmed and finalized commitment levels are supported for every method. With the optional [processed-accounts] section, getAccountInfo, getMultipleAccounts, getBalance, getTokenAccountBalance, getTokenSupply and getSlot also serve processed commitment from an in-memory block feed. Every other method handles processed through the processed-commitment option, which rejects it by default (see API Configuration).
Note on
getVersion. Thesolana-corefield returned by Cloudbreak is a composite string of the form"<upstream-solana-core>-cloudbreak<cloudbreak-version>"(e.g."2.0.21-cloudbreak0.1.0"). The upstream half is thesolana-coreversion reported by the gRPC source the indexer is subscribed to (persisted to theenvironment_infotable on indexer startup); the suffix is Cloudbreak's own crate version. This lets clients see both what cluster they're effectively talking to and which Cloudbreak build is serving them. If the indexer has never written an upstream version, the prefix falls back to"unknown". The response is cached in-process for 10 minutes.
Support for paginated responses to queries is planned, enabling clients to efficiently iterate over large result sets without loading all accounts into a single response.
┌────────────────┐
│ Solana Node │
│ (Yellowstone │
│ gRPC plugin) │
└───────┬────────┘
│
▼
┌──────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ Cluster │───────────▶│ Indexer │──────────▶│ PostgreSQL │
│ Tracker │ │ (gRPC consumer) │ │ (accounts/slots) │
└──────┬───────┘ └───────────────────┘ └────────┬──────────┘
│ scrapes │
▼ ▼
┌──────────────┐ ┌───────────────────┐
│ Snapshot │ │ Query Tracker │◀──┐
│ sources │ │ (auto-indexing) │ │
│ (RPC nodes │ └───────────────────┘ │
│ / sidecars)│ │
└──────────────┘ ┌───────────────────┐ │
│ API Server │───┘
│ (JSON-RPC) │
└───────────────────┘
The cluster tracker is a Blockdaemon solcluster tracker instance that scrapes one or more snapshot sources — either Solana RPC nodes that serve snapshot archives or solcluster sidecar agents running next to validators — and exposes a unified /v1/snapshots API listing the available full and incremental snapshot pairs. The indexer asks the tracker which snapshots cover a given slot and downloads the archives directly from the source the tracker reports. A tracker is provided in docker-compose.yaml for local development; see Cluster Tracker.
| Crate | Package | Description |
|---|---|---|
crates/command/ |
cloudbreak |
Main binary. Dispatches to api, index, snapshot, and query-tracker subcommands. Initializes OpenTelemetry tracing. Uses jemalloc as the global allocator on non-MSVC targets. |
crates/api/ |
cloudbreak-api |
HTTP JSON-RPC server (Hyper). Serves getProgramAccounts, getTokenAccountsByOwner, getTokenAccountsByDelegate, getTokenAccountsByMint, getAccountInfo, getMultipleAccounts, getBalance, getTokenAccountBalance, getSlot, getHealth, getVersion, getGenesisHash, and (when the Vote and Stake programs are indexed) getVoteAccounts. Streams getProgramAccounts, getTokenAccountsByOwner, getTokenAccountsByDelegate and getTokenAccountsByMint responses as chunked JSON-RPC body frames, applies per-request statement_timeout and a total request timeout, supports batch requests with bounded concurrency, optionally caches results in memory ([gpa-cache]), and reports query usage to the query tracker. |
crates/index/ |
cloudbreak-index |
Live indexer. Subscribes to Yellowstone gRPC, applies program filters, writes account/slot state to Postgres. Includes finalize-slot pipeline, self-healing for slot gaps. Uses the snapshot crate to optionally load a full snapshot on startup for fast bootstrapping. |
crates/snapshot/ |
cloudbreak-snapshot |
Batch loads Solana full and incremental snapshots. Queries a cluster tracker (/v1/snapshots) to find a covering snapshot pair, downloads the archives from the source reported by the tracker, unpacks them, reads Solana AccountsFile entries, bulk-upserts into Postgres, handles deduplication, optional partition clustering, and index creation. |
crates/query-tracker/ |
cloudbreak-query-tracker |
HTTP sidecar service (Hyper). Records getProgramAccounts-shaped demand in a durable index_patterns table, ranks candidates by a configurable priority mode, and optionally drives automatic CREATE INDEX/eviction on Postgres, reconciling API demand against Postgres idx_scan supply. Fully restartable; exposes /track, /debug/*, /metrics, /health on a single port. |
crates/core/ |
cloudbreak-core |
Shared library. Contains all configuration struct definitions (TOML deserialization), database connection helpers, the AccountOwnerMap abstraction, the AccountSelectorConfig (include/exclude program filter logic synced to DB), the shared service_health writer/reader, cross-crate metrics, and the self-contained getLargestAccounts and getSupply feature modules (largest_accounts top-N tracker with persistence and pruning, non_circulating stake map and real-time membership, and supply running total). The indexer drives these through a few thin calls. |
crates/entity/ |
cloudbreak-entity |
SeaORM entity definitions for Postgres tables: accounts, snapshot_accounts, slots, service_health. Also defines the CommitmentLevel enum and account conversion types. (The environment_info table is accessed via raw SQL in cloudbreak-core, not as a SeaORM entity.) |
crates/migration/ |
cloudbreak-migration |
Schema evolution via sea-orm-migration. Defines accounts/snapshot_accounts DDL helpers with TOML-configurable owner partitioning (none / HASH / LIST / LIST+HASH) and per-index toggles, plus bs58 encode/decode PL/pgSQL functions, the service_health table, and the environment_info table (program filter + Solana version). See crates/migration/README.md. |
crates/dbtools/ |
cloudbreak-dbtools |
Standalone operator CLI for running analytics queries and toggling Postgres configuration across one or more servers. Does not depend on other cloudbreak crates. See crates/dbtools/README.md. |
crates/integration_tests/ |
integration_tests |
Black-box benchmarking and correctness testing tool. Constant-rate load generation, dual-endpoint comparison with slot compensation, VictoriaLogs integration, and optional DB forensics. See crates/integration_tests/README.md. |
Everything for local development is included in this repository except the following external data sources that you must provide:
- A Yellowstone gRPC endpoint — for streaming live account and slot updates from a Solana validator. This is the primary data source for the indexer.
- (Optional) Snapshot sources for the cluster tracker — by default, the tracker shipped in
docker-compose.yamlis pre-configured (viatracker-config.yml) to scrape the public Solana mainnet endpointhttps://api.mainnet.solana.com, so snapshots work out of the box with no extra setup. The only caveat is that the public endpoint can be rate-limited and may slow down or refuse downloads under load. For production or heavy local use, pointtracker-config.ymlat your ownsolcluster sidecarinstances (or other Solana RPC nodes that expose snapshot archives) or either use your own valid tracker endpoint — see Cluster Tracker.
Everything else (PostgreSQL, Prometheus, Grafana, Tempo, the cluster tracker — including a working default snapshot source) is provided via Docker Compose, and the example config files are pre-configured for local development.
Heads up: the very first
cargo build(or firstcargo run) of this workspace can take some time depending on your machine — there are a lot of crates to compile from scratch. The terminal will look idle for long stretches; that's normal. Subsequent builds are incremental and finish in seconds.
# 1. Start all infrastructure (Postgres, Prometheus, Grafana, Tempo, cluster tracker)
docker compose up -d
# 2. Run database migrations (point the binary at the example migration config)
export DATABASE_URL="postgres://cloudbreak:cloudbreak@localhost:5432/cloudbreak"
cp example.cloudbreak.migration.toml cloudbreak.migration.toml
export CLOUDBREAK_MIGRATION_CONFIG=./cloudbreak.migration.toml
cargo run -p cloudbreak-migration
# 3. Copy example configs and set your gRPC + (optionally) tracker endpoint
cp example.cloudbreak.index.toml cloudbreak.index.toml
cp example.cloudbreak.api.toml cloudbreak.api.toml
cp example.cloudbreak.query-tracker.toml cloudbreak.query-tracker.toml
# Edit cloudbreak.index.toml → set [grpc] endpoint and x-token
# (the default [snapshot.tracker_endpoint] already points at the local Compose tracker,
# which by default scrapes https://api.mainnet.solana.com — works out of the box, rate-limited)
# (Optional) Edit tracker-config.yml → swap the public endpoint for your own sidecars/RPC nodes
# 4. Start the services (in separate terminals)
cargo run -p cloudbreak -- -c ./cloudbreak.index.toml index
cargo run -p cloudbreak -- -c ./cloudbreak.api.toml api
cargo run -p cloudbreak -- -c ./cloudbreak.query-tracker.toml query-trackerThe API will be available at http://localhost:4000, Grafana at http://localhost:3000 (admin/admin), and Prometheus at http://localhost:9090.
The docker-compose.yaml starts everything you need for local development:
docker compose up -dFirst-run note: The
solana-trackerservice is built from a Git context (rpcpool/solana-cluster#triton), so the firstdocker compose upwill clone the repo and pull the Go toolchain to build the image. This can add several minutes on top of the normal Compose start. Subsequent runs reuse the cached image. See Troubleshooting:solana-trackerbuild fails if you ever need to force a rebuild.
This creates:
| Service | Port | Details |
|---|---|---|
| PostgreSQL | 5432 | User/password/database: cloudbreak / cloudbreak / cloudbreak |
| Prometheus | 9090 | Pre-configured to scrape cloudbreak services |
| Pushgateway | 9091 | Push-based metrics |
| Tempo | 4317 (OTLP gRPC), 4318 (OTLP HTTP), 3200 | Distributed tracing backend |
| Grafana | 3000 | admin/admin, Tempo + Prometheus datasources pre-configured |
| Cluster Tracker | 8458 (public API), 8457 (metrics) | solcluster tracker from rpcpool/solana-cluster#triton. Configured via ./tracker-config.yml. |
The database connection URL for all configs is: postgres://cloudbreak:cloudbreak@localhost:5432/cloudbreak
The migration CLI requires a TOML config (partitioning shape + per-index toggles) pointed at by CLOUDBREAK_MIGRATION_CONFIG, plus a database URL via DATABASE_URL or the -u <url> flag:
cp example.cloudbreak.migration.toml cloudbreak.migration.toml
export CLOUDBREAK_MIGRATION_CONFIG=./cloudbreak.migration.toml
export DATABASE_URL="postgres://cloudbreak:cloudbreak@localhost:5432/cloudbreak"
cargo run -p cloudbreak-migrationFirst build can take a while. This is the first
cargo runin the workspace, so Cargo will compile every dependency from scratch. The same applies to the firstcargo runof the indexer / API / query-tracker in Step 4 (each binary triggers another compile of its own dependency graph the first time). All subsequent runs are incremental.
Migrations create the accounts and snapshot_accounts tables (with the partitioning shape and indexes defined in cloudbreak.migration.toml), PL/pgSQL helper functions, and the service_health and environment_info tables.
Note: The migrations attempt to load the
pg_tracingPostgres extension. This is optional and will be skipped with a warning if the extension is not installed (the stock Docker Postgres image does not include it). Everything works without it. The compose file uses PostgreSQL 16, which is the version compatible withpg_tracingif you want to install it later.
Other migration commands:
cargo run -p cloudbreak-migration -- status # Check migration status
cargo run -p cloudbreak-migration -- up # Apply all pending
cargo run -p cloudbreak-migration -- up -n 1 # Apply next N
cargo run -p cloudbreak-migration -- down # Rollback last
cargo run -p cloudbreak-migration -- fresh # Drop all, reapply
cargo run -p cloudbreak-migration -- refresh # Rollback all, reapply
⚠️ The database is ephemeral, and each run expects a clean, empty schema. The main data tables (accounts,snapshot_accounts, and their partitions) are created as PostgresUNLOGGEDtables — they trade crash-durability for write throughput, so a Postgres crash or restart truncates them. Cloudbreak is designed around this: the indexer rebuilds state from the snapshot + live gRPC stream, it does not treat the database as durable storage. Because of that, do not reuse a dirty database between runs — start each run against a freshly-created, empty schema. Reset withcargo run -p cloudbreak-migration -- fresh(drop all + reapply) orrefresh(rollback all + reapply), or drop and recreate the database, rather than pointing a new run at leftover tables from a previous one.
See crates/migration/README.md for the full config reference (partitioning shapes, per-index toggles, env vars, and CLI flags).
The accounts and snapshot_accounts tables can be partitioned by owner (HASH, LIST, both, or not at all), and each migration-time index on accounts can be individually toggled. Both are controlled by the TOML file pointed at by CLOUDBREAK_MIGRATION_CONFIG. The default example.cloudbreak.migration.toml enables HASH partitioning with 10 partitions plus the standard composite + token indexes — sensible defaults for most setups.
See crates/migration/README.md for the four supported partitioning shapes, the full set of index toggles, and worked examples.
Copy the example configs:
cp example.cloudbreak.index.toml cloudbreak.index.toml
cp example.cloudbreak.api.toml cloudbreak.api.toml
cp example.cloudbreak.query-tracker.toml cloudbreak.query-tracker.tomlThe example configs are pre-configured for local development — database URL, metrics ports, and all defaults are already set. The only values you need to fill in are in cloudbreak.index.toml:
| Setting | Section | What to set |
|---|---|---|
endpoint |
[grpc] |
Your Yellowstone gRPC endpoint URL |
x-token |
[grpc] |
Your authentication token (if required) |
endpoint |
[snapshot.tracker_endpoint] |
Cluster tracker URL. Defaults to http://localhost:8458 (the Compose tracker). Remove the [snapshot] section entirely if you don't want snapshot bootstrap/heal. |
include |
[programs] |
Base58 pubkeys of programs you want to index |
Startup order matters — start the services in this order, each in its own terminal:
- Indexer first. It populates the
environment_infotable that the API reads on startup. The API panics on launch if this row does not exist (see Troubleshooting: API returns "environment_info row not found"). Wait until the indexer has finished its own startup (you'll see it begin consuming gRPC blocks in the logs) before moving on.- API server. Once the indexer is up and the
environment_inforow is populated, start the API.- Query tracker (optional). Only needed if you want automatic DB index creation; safe to skip for a basic dev loop.
Run each service in a separate terminal:
# 1. Start the indexer (streams account updates from gRPC into Postgres)
cargo run -p cloudbreak -- --config ./cloudbreak.index.toml index
# 2. Start the API server (serves JSON-RPC queries from Postgres)
cargo run -p cloudbreak -- --config ./cloudbreak.api.toml api
# 3. (Optional) Start the query tracker — enables automatic DB index creation
cargo run -p cloudbreak -- --config ./cloudbreak.query-tracker.toml query-trackerFirst startup notes:
- If
[snapshot]is configured, the indexer will download a full Solana snapshot on first start. This can be very large (100+ GB for mainnet) and take significant time. With the defaulttracker-config.yml(which uses the public, rate-limitedhttps://api.mainnet.solana.comendpoint), the download can also be throttled, so allow extra time or pointtracker-config.ymlat your own snapshot source — see Cluster Tracker. For a lighter local setup, either remove the[snapshot]section to skip snapshot loading entirely (the indexer will begin from live gRPC data only), or index a small program likeStake11111111111111111111111111111111111111. - The correct, healthy steady state requires the snapshot to be loaded. When
[snapshot]is configured, the indexer stays unhealthy until snapshot processing finishes — the database holds only the partial data streamed in live from gRPC until then, and theservice_healthrow (andgetHealth) stays unhealthy. This is expected:getSlotworks immediately as data flows in, but health is intentionally the last thing to clear, and while it is unhealthy the slot-gated account methods (getAccountInfo,getMultipleAccounts,getProgramAccounts, the token methods,simulateTransaction) return JSON-RPC error-32005(Node is unhealthy). Running without[snapshot]is only meant for a quick smoke test of the full setup, or for iterating on a code change that doesn't need a complete dataset — in that mode there is no startup snapshot to wait on, so the node reports healthy as soon as it begins processing blocks and serves those account methods against the partial live dataset (do not treat a no-snapshot node as a source of complete state). See Troubleshooting:getHealthreturns-32005 Node is unhealthyfor details. - The API example config has
[tracing] enabled = true, which sends traces to the Tempo instance from Docker Compose. If you're not running the compose stack, setenabled = falseor remove the[tracing]section to avoid connection errors in logs.
If you prefer not to use Docker, set up Postgres manually:
CREATE USER cloudbreak WITH PASSWORD 'cloudbreak';
CREATE DATABASE cloudbreak OWNER cloudbreak;Then follow steps 2-4 above. The pg_tracing extension is optional and will be skipped if not installed.
For snapshot bootstrap and self-healing, the indexer queries a cluster tracker (solcluster tracker from Blockdaemon's solana-cluster, built from the rpcpool/solana-cluster triton branch) on its /v1/snapshots endpoint. The tracker periodically scrapes one or more snapshot sources (Solana RPC nodes or solcluster sidecar instances running next to validators), aggregates the available snapshot archives, and tells the indexer which source to download each archive from.
For local development you have two options:
-
Use the tracker provided by Docker Compose (the default). The
solana-trackerservice indocker-compose.yamlbuilds a container directly from therpcpool/solana-cluster#tritonGit context, runs it locally, and exposes::8458— public API (/v1/snapshots,/v1/snapshot/<filename>). The indexer talks to this port.:8457— internal/metrics endpoint. The Compose healthcheck uses this.- Config mounted from
./tracker-config.yml.
This is what the example configs point at out of the box and requires no extra setup.
-
Point the indexer at any tracker endpoint you already have access to. Simply set
[snapshot.tracker_endpoint].endpointincloudbreak.index.toml(and[tracker_endpoint].endpointincloudbreak.snapshot.tomlif you use the standalone snapshot subcommand) to the URL of an existingsolcluster trackerinstance, and remove or ignore thesolana-trackerCompose service.
The config file controls how often the tracker scrapes its targets and which targets it scrapes. The default file shipped in the repo is:
scrape_interval: 30s
target_groups:
- group: mainnet
http_targets:
targets:
- https://api.mainnet.solana.com| Key | Description |
|---|---|
scrape_interval |
How often the tracker polls each target for snapshot metadata. |
target_groups[].group |
A label for the group (free-form). |
target_groups[].http_targets.targets |
List of HTTP base URLs to scrape. Each target can be either a solcluster sidecar instance or a regular Solana RPC node that serves snapshot archives. |
Default works out of the box. The
tritontracker fork can scrape regular Solana RPC nodes that expose snapshot archives at the standard paths (/snapshot.tar.bz2,/incremental-snapshot-<base>-<slot>-<hash>.tar.zst), not just dedicatedsolcluster sidecaragents. The default targethttps://api.mainnet.solana.comis a real, working snapshot source — the indexer will be able to bootstrap from it without any further configuration. The trade-off is that it's a public, rate-limited endpoint: downloads can be slow, throttled, or temporarily refused under load. For production or heavy local use, replace thetargets:list with your ownsolcluster sidecarinstances (typicallyhttp://<validator-host>:13080) or your own RPC nodes that serve snapshots.
After changing tracker-config.yml, restart the service:
docker compose restart solana-trackerThe compose file also publishes the internal port, so you can hot-reload via POST http://localhost:8457/reload instead of restarting.
In cloudbreak.index.toml:
[snapshot]
accounts-file-concurency = 32
[snapshot.tracker_endpoint]
endpoint = "http://localhost:8458"In cloudbreak.snapshot.toml (only when running the standalone snapshot subcommand):
[tracker_endpoint]
endpoint = "http://localhost:8458"If you want to run a tracker outside Compose, point endpoint at it.
| Key | Type | Default | Description |
|---|---|---|---|
finalize-slot-buffer-size |
usize |
1000 |
Buffer size for queuing finalize-slot events. |
accounts-owner-map-enabled |
bool |
false |
Enables in-memory account-to-owner map for closed-account handling and owner-change tracking. Increases memory usage. |
cleanup-interval-slots |
u64 |
1 |
Finalized slots between cleanup drains. 1 drains every slot. Higher values coalesce repeat touches of an account into one delete, at the cost of extra account versions every latest-version read scans past. |
Maintains the running total-supply figure served by getSupply from the non-circulating stake map and a bounded hot-accounts cache. Requires an empty [programs] filter, accounts-owner-map-enabled = false, owner partitioning off, and the [snapshot] section. from_config validates all of it against the config and the database catalog and panics on any unmet requirement. Run on a dedicated supply-only instance. See Supply.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
false |
Enables the supply tracker. |
hot-accounts |
usize |
1000000 |
Accounts kept resident in the hot-accounts cache (~65 bytes each). Stake accounts live in the non-circulating stake map instead. |
Fills and maintains the mints table that the API [mint-cache] loads. See Mint Cache. Requires the [snapshot] section and both token programs in [programs]; the indexer panics at startup otherwise.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
false |
Enable the indexer half of the mint cache. |
db-write-interval-slots |
u64 |
1000 |
Slots between two writes of the changed mints to mints. A larger value keeps the API reading mutable mints from Postgres longer after a gap. |
When this section is present, the indexer downloads and processes snapshots on startup for fast bootstrapping.
| Key | Type | Default | Description |
|---|---|---|---|
accounts-file-concurency |
usize |
(none) | Max number of AccountsFile entries to process simultaneously. |
gap-fill-max-snapshot-retries |
u32 |
10 |
Max consecutive gap-filling iterations (~30 s each) allowed to fail fetching a covering snapshot pair from the tracker before the self-healing task gives up and fails the indexer. Resets on the first successful fetch. |
| Key | Type | Description |
|---|---|---|
endpoint |
String |
URL of the cluster tracker HTTP API (e.g. http://localhost:8458). The tracker is provided by Docker Compose for local dev — see Cluster Tracker for how to point it at your own snapshot sources. |
If the [snapshot] section is omitted entirely, the indexer skips snapshot processing.
Shared across all services. Controls the SeaORM/SQLx connection pool and query timeouts.
| Key | Type | Default | Description |
|---|---|---|---|
url |
String |
required | Postgres connection string. |
max-connections |
u32 |
(SQLx default) | Maximum pool connections. |
min-connections |
u32 |
(SQLx default) | Minimum pool connections. |
connect-timeout |
Duration |
(SQLx default) | Connection establishment timeout (e.g. "30s"). |
idle-timeout |
Duration |
(SQLx default) | How long idle connections are kept alive (e.g. "10m"). |
acquire-timeout |
Duration |
(SQLx default) | Max wait time to acquire a connection from the pool (e.g. "60s"). |
max-lifetime |
Duration |
(SQLx default) | Max total lifetime of a connection (e.g. "30m"). |
sqlx-logging |
bool |
(SQLx default) | Enable SQLx statement logging. |
schema-search-path |
String |
(none) | Postgres search_path (e.g. "public"). |
test-before-acquire |
bool |
(none) | Ping connection before returning from pool. |
connect-lazy |
bool |
(none) | Defer connection until first use. |
partition-clustering-threshold |
u64 |
(none) | Partition size in bytes above which CLUSTER is skipped during snapshot processing. |
save-block-queries-timeout |
u64 |
30 |
Timeout in seconds for save-block queries. |
finalize-slot-queries-timeout |
u64 |
300 |
Timeout in seconds for finalize-slot queries. |
api-queries-timeout |
u64 |
10 |
Timeout in seconds for API-facing queries. |
server-side-timeout |
u64 |
300000 |
Server-side statement timeout in milliseconds. |
max-db-errors-threshold |
f64 |
100 |
Number of accumulated DB errors before the process exits. |
| Key | Type | Default | Description |
|---|---|---|---|
endpoint |
String |
required | Yellowstone gRPC server URL. |
x-token |
String |
(none) | Authentication token for the gRPC connection. |
timeout |
u64 |
required | Connection timeout in seconds. |
worker-count |
usize |
(none) | Number of workers handling subscription events simultaneously. |
channel-size |
usize |
1000 |
Buffer size for queuing subscription events from gRPC. |
chunk-size |
usize |
1000 |
Chunk size for subscription events. |
max-chunk-bytes-data |
usize |
2097152 (2 MiB) |
Max bytes per data chunk. |
max-grpc-errors |
usize |
required | Max gRPC errors before attempting reconnection (always reconnects on stream None). |
reconnect-give-up |
Duration |
"600s" |
How long to keep retrying (re)connection/subscription before giving up and panicking. |
reconnect-backoff |
Duration |
"5s" |
Delay between (re)connection/subscription attempts. |
reconnect-from-slot-retain |
Duration |
"300s" |
How long a reconnection keeps replaying from the last received slot before dropping from_slot (the server may no longer have it buffered). |
Controls which program-owned accounts are indexed. The filter is synced to the environment_info table in Postgres so the API can read it.
| Key | Type | Default | Description |
|---|---|---|---|
include |
Vec<String> |
[] |
Base58-encoded program pubkeys to index. If non-empty, only these programs are indexed. |
exclude |
Vec<String> |
[] |
Base58-encoded program pubkeys to exclude. Only used when include is empty. |
Use either include or exclude, not both. If include is non-empty, exclude is ignored.
| Key | Type | Default | Description |
|---|---|---|---|
host |
String |
"0.0.0.0" |
Prometheus metrics server bind address. |
port |
u16 |
8875 |
Prometheus metrics server port. |
subscription-id-key |
String |
"x-subscription-id" |
HTTP header name for subscription ID. Labels the per-subscription metrics and is recorded on the json_encoding span. |
request-id-key |
String |
"x-request-id" |
HTTP header name for the caller's request ID. Recorded on the json_encoding span so traces can be correlated with upstream request logs; never used as a metric label. |
client-ip-bandwidth-enabled |
bool |
false |
Enable the per-client-IP egress bandwidth metrics (peak gauge + throughput histogram). |
client-ip-key |
Option<String> |
(unset) | HTTP header name carrying the client IP. Groups the bandwidth metrics and is recorded on the json_encoding span. When unset, or absent on a request, both report unconfigured instead of reading any client-settable/PII header. |
The last three keys only take effect in the API server.
| Key | Type | Default | Description |
|---|---|---|---|
host |
String |
"0.0.0.0" |
Bind address for the JSON-RPC server. |
port |
u16 |
4000 |
Listen port. |
max-connections |
u32 |
100 |
Maximum concurrent HTTP connections. |
batch-handling-max-concurrency |
usize |
5 |
Maximum concurrent requests within a single batch JSON-RPC call. |
gpa-stream-batch-size |
usize (non-zero) |
(unset) | Unset (default): getProgramAccounts, getTokenAccountsByOwner, getTokenAccountsByDelegate and getTokenAccountsByMint fetch and encode every account as one batch before the response starts, so every failure is a JSON-RPC error. Set it to opt in to streaming in batches of this size, which lowers peak memory and time to first byte but aborts the connection on a mid-stream failure. See Streamed Responses. |
request-timeout |
Duration |
"60s" |
Total per-request wall-clock budget (handler + body transport). When exceeded, the response stream is truncated and the request is counted under the timeout status. See Streamed Responses. |
max-multiple-accounts |
usize |
100 |
Maximum number of pubkeys accepted per getMultipleAccounts request. Requests exceeding this limit are rejected with an InvalidParams error. |
getProgramAccounts, getTokenAccountsByOwner, getTokenAccountsByDelegate and getTokenAccountsByMint responses are written as a Transfer-Encoding: chunked JSON-RPC body. The three token methods are served as a getProgramAccounts on the token program, see Token account methods.
Pipeline:
- DB fetch — Postgres rows are read by
sqlx. By default all rows form a single batch. Withgpa-stream-batch-sizeset, rows are grouped into batches of that size. - Unbounded channel — completed batches are pushed onto an unbounded
tokio::mpscchannel so the database connection can close as fast as possible (no head-of-line blocking from slow encoding/clients). - Encoding — each batch is decoded into
UiAccounts (per-batchspawn_blockingto keep the runtime responsive). - JSON serialization — encoded accounts are serialized into a 64 KB pre-allocated
BytesMut. Whenever the buffer reaches 32 KB, the filled portion is frozen into aByteschunk and yielded as a single HTTP body frame. Accounts larger than the chunk threshold cause the buffer to grow naturally and are flushed as a single oversized frame.
The first batch is fetched, encoded, and serialized before the response status line is committed. Any failure up to that point is a proper JSON-RPC error response.
Default (single batch). The first batch is the whole result, so SQL errors, query timeouts, and encoding errors (for example base58 data over 128 bytes) are always JSON-RPC errors. This matches Agave, which builds the full result before it responds. The cost is that peak memory per request holds every encoded account, and the first byte waits for the full query and encoding.
Opt-in streaming (gpa-stream-batch-size set). Accounts are fetched and encoded in batches, which lowers peak memory and time to first byte for large responses. The trade-off: a failure after the first batch cannot be reported as a JSON-RPC error, because HTTP 200 and part of the body are already sent. The server aborts the connection instead (HTTP/1: no final chunk; HTTP/2: stream reset). Clients see a transport error, such as an incomplete body, and must treat the request as failed and retry. A proxy in front of the API must pass the abort through, not buffer the body and end it normally. JSON-RPC batch requests are not affected: they buffer each entry, and a mid-stream failure becomes a -32603 Internal error entry.
getTokenAccountsByOwner and getTokenAccountsByDelegate build Agave's filters and run them as a getProgramAccounts on the token program. They share its streaming, its cache and its SQL.
| Method | Filters |
|---|---|
getTokenAccountsByOwner |
memcmp(32, owner), tokenAccountState, plus memcmp(0, mint) with { "mint" } |
getTokenAccountsByDelegate |
memcmp(72, [1,0,0,0]) (delegate is set), memcmp(76, delegate), tokenAccountState, plus memcmp(0, mint) with { "mint" } |
{ "mint" }reads the mint first. Its token program becomes the program. A missing or closed mint returnsInvalid param: could not find mint, and a mint that does not unpack returnsInvalid param: Token mint could not be unpacked.{ "programId" }must be a token program.- The response always has a context.
- For a token program, the SQL uses the token indexes:
memcmp(32)becomestoken_owner,memcmp(0)becomestoken_mint, andmemcmp(72, [1,0,0,0])is written as the delegate index predicate. An owner and a mint filter together use the(token_owner, token_mint)index onsnapshot_accounts.tokenAccountStatematches Agave'sAccount::valid_account_data: initialized, 165 bytes, or longer with account type 2 and not a 355-byte multisig. - A token-program
getProgramAccountsmust filter on owner, mint or delegate (memcmpof 32 bytes at 32, 0 or 76). Otherwise it returnsInvalid params. Agave accepts it as a full scan. - With
jsonParsed, each account is encoded with its own mint, as in Agave. Each batch of fresh rows looks up its distinct mints in one query by pubkey. A token account whose mint is missing or does not unpack has no decimals, does not parse, and is left out, as in Agave. This applies togetProgramAccountson a token program too.
Known difference: a token-program getProgramAccounts with memcmp(76, delegate) and no filter at offset 72 gets memcmp(72, [1,0,0,0]) added, so the delegate partial index serves it. Agave would also return accounts whose delegation was revoked: revoke clears the delegate tag and leaves the old delegate bytes in place.
request-timeout is enforced by a TrackedBody wrapping the response body. On expiry the body is truncated, the cloudbreak_api_request_duration_ms http_with_transport observation is not recorded, and a cloudbreak_api_requests_total{method="http",status="timeout"} counter is incremented instead.
Optional in-memory cache for getProgramAccounts responses. Omitting this section disables the module entirely.
| Key | Type | Default | Description |
|---|---|---|---|
max-total-bytes |
usize |
required | Maximum cache size in bytes. When inserting a new query would exceed this, the oldest cached queries (by slot) are evicted until enough room exists. |
min-bytes-per-query |
usize |
required | Minimum serialized size of a query for it to be cached at all. Smaller queries are skipped so they don't dilute room available for heavier ones. |
max-bytes-query-cleanup |
usize |
(unset) | Optional upper bound on the size of a query that is eligible for eviction. Queries larger than this are pinned: they are normally skipped by cleanup and stay cached (until replaced by a newer version of the same query). The pin is soft — see max-pinned-bytes-ratio. Omit to make every cached query evictable. Note: when the oldest slots are mostly pinned (and under the pinned cap), a new insert may fail to free enough room and be skipped. |
max-pinned-bytes-ratio |
f64 |
(unset) | Cap on how much space pinned queries may collectively occupy, expressed as a fraction of max-total-bytes (e.g. 0.5 = at most half the cache). When pinned usage exceeds max-total-bytes × ratio, the oldest pinned queries are evicted via the normal cleanup process until usage drops back under the cap — so the pin set can never grow unbounded. Required when max-bytes-query-cleanup is set (config load fails otherwise); ignored when it is unset (nothing is pinned). Must be in the range (0.0, 1.0]. |
The cache is keyed by (program, sorted filters, encoding, data_slice, commitment) and holds the pre-serialized JSON bytes of each account, ref-counted via bytes::Bytes. Concretely:
- Postgres only sends what's missing. When a cached entry exists for a query, the SQL returns full row data only for accounts whose slot is newer than the cached slot; for everything else it returns just
(pubkey, slot)to confirm the cached version is still valid. Less data over the wire, less server-side decoding. - Cache hits are zero-copy and zero-allocation. Cached account bytes are appended into the outgoing response buffer by ref-counting an existing
Bytesslice — no clone, no re-encode, no extra allocation. - No JSON re-serialization. Cached entries store the exact JSON fragment that already left the encoder once; on subsequent serves the same bytes are emitted verbatim.
A jsonParsed token-program response embeds data from each account's mint, and a cache entry is only invalidated when an account changes. So such a response is cached only when every mint it uses is immutable: a Tokenkeg mint, the native mint, or a Token-2022 mint without MintCloseAuthority, InterestBearingConfig and ScaledUiAmount. A response with a mutable mint, a missing mint or a left-out account is not cached. cloudbreak_gpa_cache_skipped_total counts these by method, size and reason.
Eviction is age-ordered by slot (oldest cached query goes first), skipping any query pinned by max-bytes-query-cleanup. Pinning is bounded: once pinned queries collectively exceed max-total-bytes × max-pinned-bytes-ratio, the oldest pinned queries are evicted in the same age order until usage is back under the cap. Current pinned usage, the configured ratio, and the resulting threshold are reported by /debug/modules/gpa_cache (stats.pinned_size_bytes, config.max_pinned_bytes_ratio, stats.pinned_threshold_bytes).
Same as the indexer [database] section. For the API, api-queries-timeout and server-side-timeout are the most relevant timeout settings.
Same as indexer [metrics].
Connects the API to the query tracker service for reporting GPA query patterns.
| Key | Type | Default | Description |
|---|---|---|---|
endpoint |
String |
required | Query tracker RPC endpoint (e.g. http://localhost:4001). |
timeout |
Duration |
(none) | Request timeout (e.g. "5s"). |
flush-interval |
Duration |
(none) | How often batched query counts are flushed to the tracker (e.g. "1m"). |
Keeps the confirmed and finalized slots and the service health in memory. The indexer's slots_notify trigger sends every real change of a slots row on the Postgres channel cloudbreak_slots, and the API applies it as it arrives. A lost listener connection, a bad payload, or 60 s without a notification panics. Until the first notifications arrive after a start, the slots are 0 and the node reads as unhealthy. Enabled by default; omit this section or set enabled = false to disable.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
true |
Enable/disable slot synchronization. |
Controls how the API handles requests that specify the processed commitment level. This is a top-level key (not inside any section).
| Value | Description |
|---|---|
"reject" |
(default) Return JSON-RPC -32602 Processed commitment level is not supported when a client requests processed commitment. |
"use-confirmed" |
Silently respond with confirmed data instead of rejecting the request. |
Example:
processed-commitment = "use-confirmed"The API gRPC feed: one Yellowstone subscription to blocks with accounts at processed commitment. It runs when [processed-accounts] or [mint-cache] is enabled, and feeds both. It uses the indexer's subscription code (cloudbreak_core::modules::grpc): the same reconnection, from_slot replay, and panic after reconnect-give-up. The connection fields default to the values the indexer runs with.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
false |
Enable the feed. |
endpoint |
string |
"" |
Yellowstone gRPC endpoint that allows processed commitment. |
x-token |
string |
none | Yellowstone gRPC access token. |
timeout |
u64 |
60 |
Connection timeout in seconds. |
max-grpc-errors |
usize |
1 |
Max gRPC errors before attempting reconnection (always reconnects on stream None). |
reconnect-give-up |
Duration |
"600s" |
How long to keep retrying (re)connection/subscription before the API panics. |
reconnect-backoff |
Duration |
"5s" |
Delay between (re)connection/subscription attempts. |
reconnect-from-slot-retain |
Duration |
"300s" |
How long a reconnection keeps replaying from the last received slot. |
Keeps the mint data jsonParsed needs in memory. See Mint Cache. Requires [grpc] and [slot-syncronizer], both with enabled = true, and the indexer's [mint-cache]. Startup fails without [grpc] and [slot-syncronizer].
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
false |
Enable the mint cache. |
Serves processed commitment for getAccountInfo, getMultipleAccounts, getBalance, getTokenAccountBalance, getTokenSupply and getSlot. The API subscribes to Yellowstone blocks at processed commitment and keeps the blocks around the Postgres confirmed slot in memory. A key written in those blocks is answered from memory. Any other key reads Postgres at the confirmed slot. When the blocks cannot be linked to the confirmed slot, a processed request answers exactly as a confirmed request would, even with processed-commitment = "reject". Other methods keep following processed-commitment.
Requires [grpc] and [slot-syncronizer], both with enabled = true. Startup fails without them. Each API instance carries its own block feed.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
false |
Enable processed commitment for the methods above. |
Example:
[grpc]
enabled = true
endpoint = "https://grpc.example:443"
x-token = "..."
[processed-accounts]
enabled = trueControls how the API responds to requests while the node is unhealthy (the slots.health flag is unset / the slot syncronizer reports unhealthy). This is a top-level key (not inside any section).
| Value | Description |
|---|---|
"json-rpc-error" |
(default) Return a JSON-RPC error -32005 (Node is unhealthy) with HTTP 200 OK. |
"http-unavailable" |
Return an HTTP 503 Service Unavailable response instead. |
Note:
http-unavailableonly applies to single requests. Batch requests always return HTTP200 OKwith per-item JSON-RPC errors, since an HTTP status cannot be expressed per batch item.
Example:
unhealthy-response = "http-unavailable"Serves getSupply from the supply ring the indexer maintains. The section carries only enabled (default false). Set it together with the indexer's [supply] section; a node with the section absent or disabled answers getSupply with method-not-found. See Supply.
OpenTelemetry tracing configuration. If this section is omitted, OTel is disabled and only tracing-subscriber is used.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
(none) | Enable OTel export. |
endpoint |
String |
"http://localhost:4317" |
OTLP gRPC collector endpoint. |
sample-ratio |
f64 |
(none) | Trace sampling ratio (0.0 to 1.0). |
export-interval |
u64 |
(none) | Export interval in seconds. |
max-batch-size |
u64 |
(none) | Max spans per export batch. |
max-queue-size |
u64 |
(none) | Max queued spans before dropping. |
track-idle-time |
bool |
(none) | Include idle time in spans. |
span-filter |
Vec<String> |
(none) | Span names to export. See example.cloudbreak.api.toml for the current full list (covers gPA, gTABO/gTABD, JSON encoding, HTTP transport, mint lookups, the cache finalize span, and the processed and account reads). |
| Key | Type | Default | Description |
|---|---|---|---|
host |
String |
"0.0.0.0" |
Bind address. |
port |
u16 |
4001 |
Listen port for the single HTTP server, which serves both the functional endpoints (/track, /debug/*) and the operational endpoints (/metrics, /health). |
max-connections |
u32 |
100 |
Max concurrent connections. |
Same as other services.
Controls demand recording, automatic index creation, usage-based eviction and
discrepancy detection. All state lives in the index_patterns table, so the
service is fully restartable; these keys only tune behavior. Defaults reproduce
the previous behavior.
| Key | Type | Default | Description |
|---|---|---|---|
create-database-indexes |
bool |
false |
Enable automatic CREATE INDEX from recorded demand. When off, demand is still recorded. |
index-generation-threshold |
u32 |
10 |
Minimum demand count before a pattern becomes a creation candidate. |
index-creation-delay |
Duration |
"10s" |
Poll interval of the creation loop. |
priority-mode |
String | table |
"frequency" |
Ranking criterion shared by creation (highest first) and eviction (lowest first). Bare strings rank on lifetime totals: frequency, cost, cost-per-hit. weighted is a table that ranks on recent windowed activity and carries its own weights + measurement window: priority-mode = { weighted = { demand-weight = 1.0, supply-weight = 0.0, failure-weight = 0.0, latency-weight = 0.0, rate-window = "1h" } }. Score = (avg_cost · gain) · (1 + demand-weight·demand + supply-weight·(supply/2) + failure-weight·failed), each count being the value in the last rate-window (default 1h); supply is halved (a GPA scans both tables). gain = 1 + latency-weight·ln((without-index-compensation-factor·without)/with) scales avg_cost by the measured latency win from the index (neutral until the pattern has served requests both with and without the index). All weights default 0.0 = plain average cost-per-hit (no rate roll). |
cost-eligibility-threshold-us |
u64? |
(none) | Only patterns whose average cost-per-hit reaches this many µs are eligible. |
included-programs |
Vec<String> |
[] |
Only create indexes for these program pubkeys. Empty means all programs are eligible. |
excluded-programs |
Vec<String> |
[] |
Never create indexes for these program pubkeys. |
indexer-metrics |
String |
required | host:port of the indexer's Prometheus metrics endpoint. Used to defer DDL (create/drop) when the indexer is behind. |
indexer-metrics-threshold |
u64 |
5 |
cloudbreak_finalize_slot_handler_queue_size threshold above which DDL is deferred. |
indexer-cleanup-lag-threshold |
u64 |
32 |
cloudbreak_cleanup_lag_slots threshold above which DDL is deferred. An indexer that does not publish the gauge reads as no pressure. |
max-auto-indexes |
usize? |
(none) | Optional cap on total indexes on the snapshot_accounts table. |
index-eviction-enabled |
bool |
false |
Enable usage-based eviction of idle auto-indexes. |
use-supply-for-eviction |
bool |
false |
When true, also require supply-idle (last_seen_used / no idx_scan growth) for eviction eligibility. Off = demand-idle + age-grace only. Enabling is more conservative but the eviction-pass supply refresh can collapse the candidates list for up to index-min-idle afterward on high-rotation DBs (mitigate with more frequent eviction passes). |
index-eviction-interval |
Duration |
"1h" |
How often the eviction pass runs. |
index-min-age-grace |
Duration |
"1h" |
Minimum age before an index is eligible for eviction. |
index-min-idle |
Duration |
"24h" |
How long a pattern must be idle to be droppable. Demand-idle is always required; supply-idle only when use-supply-for-eviction is on. |
eviction-fill-threshold |
f64 |
0.9 |
Fill target as a fraction of max-auto-indexes: creation is unguarded below it, value-guarded in the buffer band above it, and eviction trims back down to it (needs max-auto-indexes). |
value-guard-creation-bias |
f64 |
1.0 |
Multiplier on a creation candidate's score in the creation-time value guard, tuning stickiness toward existing indexes (which carry gain/idx_scan signal new candidates lack). > 1.0 builds new indexes more readily (less sticky), < 1.0 favors incumbents (stickier), 1.0 compares as-is. Only consulted in the buffer band. |
drop-lock-timeout |
Duration |
"5s" |
lock_timeout for each DROP INDEX; on timeout the drop is skipped and retried next pass. |
drop-retries |
u32 |
1 |
Extra DROP INDEX attempts on lock timeout within a pass. |
index-regression-guard |
String |
"off" |
What to do when an index measures slower than the pattern was without it. off ignores it; warn logs + reports the count via query_tracker_regressed_indexes; evict drops the pair and marks the pattern rejected (not rebuilt until proven slower without the index). Runs in the eviction pass (requires index-eviction-enabled) but ignores the fill threshold. |
index-regression-ratio |
f64 |
1.2 |
How many times slower with the index than without before it counts as a regression (1.2 = ≥20% slower). Also the hysteresis a rejected pattern must clear before it is retried. |
index-regression-retry-delay |
Duration |
"6h" |
How long a pattern rejected for a regression must stay rejected — accumulating fresh without-index samples — before it may be retried (and only if its without-index average has climbed past index-regression-ratio × the with-index average recorded at rejection). Guards against rebuild churn. Detection needs no separate window: an index is judged once it is older than index-min-age-grace. |
without-index-compensation-factor |
f64 |
1.0 |
Multiplier on the without-index average cost wherever it is compared to the with-index average — the weighted latency gain and the regression guard's detection/recovery. Compensates for pg settings that keep the two sides from being strictly comparable (e.g. a without-index GPA fans out across parallel workers, so its wall-clock looks cheap while it burns far more CPU/IO than a single-worker index scan). > 1 inflates the measured without-index cost so the index earns proportional credit and is less likely to be judged a regression; 1.0 compares raw wall-clock as-is. |
discrepancy-enabled |
bool |
true |
Flag/metric when demand (API) and supply (idx_scan) diverge (e.g. Postgres ignoring an index). |
discrepancy-delta |
f64 |
0.10 |
Relative gap between demand and supply beyond which a pattern is flagged. |
explain-enabled |
bool |
false |
Optional third signal: periodic EXPLAIN (plan only) sampling of each index, on both tables, using the stored example_request values. |
explain-interval |
Duration |
"6h" |
How often the EXPLAIN sampling pass runs when enabled. |
Note: Snapshot processing is already built into the normal indexer operation (when
[snapshot]is present in the indexer config). The standalonesnapshotsubcommand exists for special cases where you need to run snapshot ingestion independently from the indexer — for example, to bulk-load account state into an existing database without starting a gRPC subscription. In most setups you do not need to run this separately.
| Key | Type | Description |
|---|---|---|
accounts-file-concurency |
usize |
Max number of AccountsFile entries to process simultaneously. |
| Key | Type | Description |
|---|---|---|
endpoint |
String |
Cluster tracker HTTP API URL (e.g. http://localhost:8458). See Cluster Tracker. |
Same as other services. The partition-clustering-threshold is particularly relevant here (skips CLUSTER on partitions larger than this size in bytes).
Same include/exclude program filter as the indexer.
Same as other services.
| Variable | Where | Description |
|---|---|---|
RUST_LOG |
All binaries | Tracing filter directive. Default: info,sqlx=error. Supports per-target filtering (e.g. debug,hyper=warn). |
DATABASE_URL |
Migration CLI | Standard SeaORM/SQLx convention for the migration tool. Can also be supplied via the -u <url> CLI flag (which takes precedence). |
CLOUDBREAK_MIGRATION_CONFIG |
Migration CLI | Required. Filesystem path to the migration TOML config (partitioning shape + per-index toggles). See crates/migration/README.md for the schema. |
The query tracker enables automatic creation of database indexes based on observed query patterns. All decision state is persisted in the durable index_patterns table, so the service is fully restartable and never loses recorded demand. This is how it works:
- The API server reports
getProgramAccounts-shaped query demand (request counts, cost, failures) to the query tracker at the/trackendpoint, which folds it into each pattern's row inindex_patterns. - When a pattern's cumulative demand reaches
index-generation-threshold, it becomes a creation candidate. Demand is cumulative and persisted — there is no reset interval. - The creation loop wakes every
index-creation-delay, ranks the candidates on read according topriority-mode(frequency,cost,cost-per-hit, orweighted), and builds the highest-priority one. Eviction uses the same ranking in reverse (drops the lowest), so "most worth building" and "least worth keeping" are two ends of one order. Indexes are always created as a pair on theaccountsandsnapshot_accountstables. - Only one index pair is created per tick, to avoid overloading the database.
- Before creating (or dropping) an index, the query tracker checks two indexer gauges:
cloudbreak_finalize_slot_handler_queue_sizeagainstindexer-metrics-threshold, andcloudbreak_cleanup_lag_slotsagainstindexer-cleanup-lag-threshold. Either one over its threshold defers DDL until the indexer catches up. - If
max-auto-indexesis set, creation is bounded by two sizes: the fill targetfloor(eviction-fill-threshold × max-auto-indexes)and the hard capmax-auto-indexes. Below the target the top candidate is built freely; in the buffer band(target, cap]a creation-time value guard (see below) only builds a candidate that out-values the index it would displace — unless nothing is reclaimable to displace, in which case it builds anyway up to the cap and lets eviction reclaim later; at the cap creation is paused until eviction reclaims the buffer. Candidates are kept queued inindex_patternswith no loss of data. - Use
included-programsandexcluded-programsto control which programs are eligible for automatic indexing.
When index-eviction-enabled is set, a periodic pass (index-eviction-interval) reclaims index budget, but conservatively. An index pair is only ever dropped when all of these hold (any one failing defers the drop, never losing the pattern):
track_countsis on in Postgres — otherwiseidx_scanis frozen and the whole pass is skipped, so stale stats can never drive a drop;max-auto-indexesis set and thesnapshot_accountstable is above the fill target (eviction-fill-threshold) — at or below the target nothing is dropped;- the pair is demand-idle for
index-min-idle(and supply-idle too whenuse-supply-for-evictionis on) and older thanindex-min-age-grace— demand-idle alone stops a still-wanted index from being dropped and avoids the drop→rebuild churn loop; - the indexer is not under backpressure.
When above the fill target (eviction-fill-threshold), eligible pairs are dropped in ascending priority-mode order (least worth keeping first) just until the table is back at the target. The trim is unconditional — the value trade-off already happened at creation (below), so eviction simply reclaims the buffer.
Value guard (at creation time). The value trade-off lives at creation, not eviction: the fill target is the operating size, and the buffer band up to max-auto-indexes is entered only by indexes worth more than what they displace. When a new index would land in the buffer it is scored by priority-mode and compared against the index it would push out — which is not the single worst index. over = current − target indexes are already destined for the next trim, so the candidate competes with the first index that would survive that trim: the one at position over in eviction (ascending-score) order. Building the candidate raises the overflow to over + 1, the next trim drops positions 0..=over, and the candidate keeps a lasting slot only if it out-scores that boundary index. Example (max-auto-indexes = 200, eviction-fill-threshold = 0.7 ⇒ target 140): at current = 140 (over = 0) the candidate must beat the single worst eligible index (a straight swap-the-worst); at current = 176 (over = 36) the 36 worst are leaving anyway, so it must beat the 37th-worst to earn a durable slot. If it cannot beat that boundary, creation is deferred and the candidate stays queued. If nothing is currently reclaimable to displace (fewer eligible indexes than the overflow), the candidate is built anyway up to the hard cap — the cap is the real ceiling, and eviction reclaims later once indexes go idle, rather than stalling creation behind indexes that are not yet droppable. When eviction is disabled or no cap is set, the guard is inactive and creation is cap-only.
Stickiness (value-guard-creation-bias). Both sides of the guard use the same priority-mode score, but a created index carries realized signal a fresh candidate cannot — its with-index latency gain and its idx_scan supply — which structurally favors incumbents. That is usually desirable (proven indexes should not yield to unproven ones), but value-guard-creation-bias tunes it: the factor multiplies the candidate's score in the comparison, so > 1.0 lets new indexes win more easily (less sticky, more churn) and < 1.0 favors incumbents (stickier); 1.0 (default) compares as-is. The guard inherits the mode's notion of value — lifetime-total modes let a once-hot idle index resist displacement, while weighted weighs recent windowed activity so decisions track current throughput.
Latency regression guard. The tracker records each pattern's average query latency with the index versus without it (cost is routed into a with- or without-index bucket by the pattern's status at record time). When index-eviction-enabled is on and index-regression-guard is not off, the eviction pass — before the supply refresh, and regardless of the fill threshold, since a harmful index should go even when there is room — flags any created index that is slower with the index than without by more than index-regression-ratio, once the index is older than index-min-age-grace (long enough to have gathered with-index latency to compare against its frozen without-index baseline). In warn mode it logs and reports the current count via the query_tracker_regressed_indexes gauge; in evict mode it also drops the pair and marks the pattern rejected (so that gauge stays near zero and the count shows up under query_tracker_patterns{status="rejected"} instead). A rejected pattern is not rebuilt by demand alone — only after it has been rejected for at least index-regression-retry-delay (time spent gathering fresh without-index samples) and its without-index average since rejection has climbed past the with-index average captured at rejection (times index-regression-ratio) does the creation loop return it to candidate. Both the detection and the recovery comparison first scale the without-index average by without-index-compensation-factor, so a without-index scan whose wall-clock is deflated by parallel workers (fast in elapsed time, but far heavier in CPU/IO) is not mistaken for a cheaper alternative and does not push a genuinely useful index into a false regression. The same latency signal optionally feeds the weighted score via latency-weight, and the per-index averages are visible on /debug/created.
Discrepancy detection (discrepancy-enabled) and EXPLAIN sampling (explain-enabled) are observational only: they flag/log when API demand and Postgres idx_scan diverge, or when the planner would not use an index, surfacing problems on /debug/* and /metrics. To keep the logs quiet, each pass emits one summary line instead of a message per index: the discrepancy pass reports N checked — X ok, Y starved, Z over_scanned (naming the worst offenders, warn when anything is starved), and the EXPLAIN pass reports the per-state counts plus the fully unused indexes with their compensated_idx_scan (warn when any index is unused or a probe errored). The EXPLAIN probe is built from the row's stored example_request — a real request captured on first sight — so its memcmp constants are realistic values the planner has statistics for (it falls back to zeros per column when no example is stored yet), and it runs on both physical tables (accounts and snapshot_accounts). Because those tables are partitioned, the probe matches the plan against the index's partition-tree members (the auto-named per-partition child indexes that EXPLAIN actually prints), not the partitioned parent's name — which is never scanned directly and would otherwise make every index look unused. Each pass folds the two per-table verdicts into one explain_state (none / accounts_table / snapshot_accounts_table / both), persists it to the pattern (surfaced on /debug/created, filterable via filter=explain_incomplete/explain_none/explain_partial for the detailed per-index view), and refreshes the query_tracker_explain_state{state} gauge with the per-state counts of the latest run. That example request is also surfaced as example_request on the /debug/created and /debug/candidates views (with ?example=true) for documentation/sampling. They never create or drop anything and do not gate eviction. Note a served getProgramAccounts is a UNION ALL over accounts + snapshot_accounts, so it scans both indexes of a pair — raw idx_scan runs ~2× the request count. The tracker divides supply by that factor (SCANS_PER_REQUEST = 2) before comparing it to demand, so a healthy index sits near a 1:1 ratio.
The indexer includes a self-healing mechanism that automatically detects and repairs gaps in the slot stream. This is critical for maintaining data consistency when the gRPC stream drops slots due to network issues, reconnections, or transient failures.
-
Gap detection (no RPC): As each block arrives from the gRPC stream, the indexer checks that it builds directly on the last block it received, using the block's own
parent_slot/parent_blockhashagainst the in-memory blocks map. If it does, any slots in between were simply empty/skipped by the validator and are ignored. If it does not, at least one real block was missed: the whole intervening range is queued for repair ingaps_list. This replaces the previousgetBlocksWithLimitRPC confirmation — gaps are confirmed purely from the chain. -
Pause + mark unhealthy: The instant a gap is confirmed, finalization is paused and the service is flagged unhealthy via the
service_healthtable. Live finalized notifications keep buffering (bounded byfinalize-slot-buffer-size, then back-pressuring the stream) but are not applied until the gap is repaired, preserving in-order finalization. -
Gap filling via incremental snapshots: Every 30 s a background task processes confirmed gaps by asking the cluster tracker for an incremental snapshot pair covering the newest missing slot, downloading it (into a timestamped directory), and processing only the gap slots. Repaired accounts are written to the database and enqueued for finalization directly (snapshot data is already finalized). Gap slots that have no accounts in the snapshot are empty/skipped slots: they are logged (target
self_healing_empty_slots) and dropped from the list. If the tracker has no covering pair yet, the task retries on the next tick, up togap-fill-max-snapshot-retries(default 10) consecutive attempts — past that the indexer gives up and exits with an error rather than staying unhealthy forever. The counter resets as soon as a snapshot pair is fetched successfully. -
Missed finalized notifications: A reconnect can also drop finalized notifications for slots just below a large gap. When finalizing a live slot the finalizer walks its ancestor chain (hash-checked) to finalize any ancestors whose notification was missed; additionally the slot just before each repaired gap (
gap_start - 1) is seeded so its ancestors are caught even though repaired slots carry no chain data to bridge the walk. -
Recovery: Once
gaps_listdrains, finalization is resumed, which restores the service to healthy. Out-of-order slots that arrive late (e.g. gRPC buffering) are removed from the gaps list when received.
A confirmed gap is only expected after startup has finished. During startup the (now-paused) finalizer worker is what completes startup, so a gap there can never be repaired; the fill task fails fast (panics) rather than stalling forever.
Use the indexer's operational debug endpoints to inspect gap and finalizer state:
/debug/modules/self_healing— chain tips, still-missing slots grouped into gaps (with boundary, length, and distance behind confirmed), and summary counts./debug/modules/finalizer— the confirmed-but-not-finalized blocks map, the ordered pending queue, and pause state./debug/modules/supply— the supply tracker status, total, slot, and hot-accounts cache counters, plus a single cache entry lookup./debug/modules/non_circulating— the non-circulating tracker status, clock, member count, stake map size, running non-circulating lamports, the heads of the two lockup-expiry sets, plus a single stake map entry lookup.
The [snapshot] section (with [snapshot.tracker_endpoint]) must be present in the indexer config for gap filling to work, since it relies on the cluster tracker to discover and download incremental snapshots.
Programs are configured in two different places, each serving a different purpose. Understanding how they relate helps avoid confusion:
| Config | Location | Purpose |
|---|---|---|
[programs] section |
Indexer config (cloudbreak.index.toml) |
Controls which accounts are indexed. Only accounts owned by included programs (or all except excluded programs) are written to the database. This filter is synced to the environment_info table so the API knows which programs are available. |
included-programs / excluded-programs |
Query tracker config (cloudbreak.query-tracker.toml) |
Controls which programs are eligible for automatic index creation. This is a subset concern — it only makes sense to auto-index programs that are already being indexed. Use excluded-programs to skip programs like SPL Token that have too many accounts for useful indexes. |
These two are independent but related: the indexer [programs] determines what data exists, and the query tracker programs control which of that data gets automatic database indexes.
Note (storage layout): If you want certain programs to get dedicated LIST partitions in the
accounts/snapshot_accountstables (a schema-level storage optimization, separate from the indexing filter above), configure[pg-owner-partitions].programs-for-list-partitionin the migration TOML. This must be set before running migrations; changing it later requires re-running migrations (e.g.fresh). Seecrates/migration/README.md.
getVoteAccounts is optional and only served when the indexer's [programs] filter includes both the Vote (Vote111111111111111111111111111111111111111) and Stake (Stake11111111111111111111111111111111111111) programs. Cloudbreak checks this at startup; if either is missing, the method returns JSON-RPC -32601 Method not found and the supporting background tasks are not started.
commitment selects the slot the Vote accounts are read at, and the same slot is the reference for delinquentSlotDistance. Activated stake comes from the epoch cache below, which is per epoch, so commitment does not change it. processed follows the processed-commitment option; this method has no native processed support.
The response combines two sources. The per-account fields (commission, last vote, root slot, recent epoch credits, node pubkey) are read from the indexed Vote accounts. The per-voter activated stake and epoch-set membership (epochVoteAccount) come from a separate epoch_stakes table, since effective stake cannot be derived from a single account.
The epoch_stakes table is seeded from snapshot metadata. When the indexer loads a snapshot, it extracts each voter's activated stake and epoch-set membership from the snapshot's bank stakes and versioned epoch-stakes data and upserts them into the table. The API loads the latest epoch's rows into an in-memory cache on startup and polls the table every 30 s, refreshing when the epoch advances or the in-epoch stake total changes. Before the indexer has completed a snapshot pass with stake data the cache is empty, and getVoteAccounts returns a node-unhealthy error rather than partial results.
A snapshot captures stake at a single point in time, but activated stake drifts within an epoch as stake warms up or cools down and as rewards are distributed at the epoch boundary. To track this, the indexer runs a background recomputer that recalculates each voter's effective stake for the current epoch from the indexed Stake accounts (using the same warmup/cooldown rules as the runtime), upserts the result into epoch_stakes, and keeps only the two most recent epochs.
The recomputer detects drift by comparing the total activated stake across runs. It recomputes every 60 s and treats an epoch as converged once the total is unchanged for three consecutive polls and the indexed EpochRewards sysvar reports that reward distribution has finished. If that sysvar is not indexed, it converges on stability alone after roughly 30 minutes. After converging it keeps recomputing on a slower 600 s heartbeat, returning to the 60 s cadence if a late reward write or a healed ingestion gap moves the total again.
simulateTransaction is optional and only served on a full, unfiltered index (empty [programs] include and exclude lists). Simulation must be able to load any account a transaction touches — including program, lookup-table, sysvar, and feature-gate accounts — so a filtered index cannot serve it. Cloudbreak checks this at startup; if the index is filtered, the method returns JSON-RPC -32601 Method not found.
The transaction is executed read-only against the indexed account state at the requested slot; nothing is committed. Cloudbreak reconstructs the cluster's actually-activated feature set at that slot from the on-chain feature accounts (rather than enabling all features), so compute-unit accounting and execution behaviour match mainnet. replaceRecentBlockhash substitutes the latest recorded blockhash before execution and reports it with its lastValidBlockHeight; sigVerify verifies signatures; accounts returns post-simulation state for the requested addresses; innerInstructions includes decoded inner instructions.
Cloudbreak accepts legacy, v0, and v1 transactions. A v1 transaction can be larger than the legacy 1232-byte packet, so the decoder applies the version's own size bound and rejects anything larger with an invalid-params error.
getSupply is optional. It needs a full, unfiltered index (empty [programs] include and exclude lists), accounts-owner-map-enabled = false, owner partitioning off (single accounts and snapshot_accounts tables), and the [snapshot] section. Enable the [supply] section on the indexer and the [supply] section on the API. The two must be set consistently; a node with the API section absent or disabled answers getSupply with method-not-found. Run it on a dedicated supply-only instance: de-partitioning removes the owner pruning that getProgramAccounts relies on.
The indexer seeds the total supply from the snapshot bank's capitalization on every (re)start. The previous balance for a block's delta comes from two in-memory sources, and each pubkey belongs to exactly one of them. Stake accounts belong to the non-circulating stake map, which holds every stake account and reports the summed stake delta per block, so the once-per-epoch reward burst is all memory reads. Every other account belongs to a bounded hot-accounts cache keyed by pubkey, holding the last lamports and the slot of the last write: a cache hit subtracts the stored lamports from the new balance in memory, and a miss falls to one batched by-pubkey read against the de-partitioned tables, with no owner routing, which a live block runs alongside its own row inserts. The cache is bounded: entries are capped (hot-accounts, default 1,000,000) and swept by a slot-window off the block path. The cache is about 136 MB and the stake map about 153 MB (both estimated from the record sizes), against the ~185 GiB of the owner-map delta. While the snapshot is still ingesting, accounts touched by live blocks are recorded in a separate unbounded touched-account set; once the snapshot pass completes, those touches are reconciled against the startup-slot balances read back by pubkey from snapshot_accounts and the tracker goes live. One supply row carrying the total and the running non-circulating lamports is persisted per confirmed block (pruned to the last 128 slots).
The non-circulating set is maintained in real time by the stake map. It is Agave's pinned non-circulating account list plus every stake account whose lockup is in force or whose withdrawer is a listed authority, evaluated against the Clock sysvar carried by each block. Members whose lockup can expire sit in two ordered sets keyed by their expiry epoch and expiry timestamp, and the head of each set is popped as the clock passes it, so a lockup expiry moves the account out of the set in the block whose clock reached it. The member list is persisted on the bootstrap flip and again on every block that changed it. Membership is evaluated against the confirmed block's clock while Agave evaluates against the bank clock at the requested commitment, so an account whose lockup expires inside the confirmed-to-finalized window is classified differently by the two for up to about 32 slots.
The API serves from a polled cache. Requests return a node-unhealthy error until the bootstrap pass has completed and the non-circulating set has been computed. On a detected slot gap the tracker keeps applying block deltas but stops publishing totals until every gap slot has been repaired (requests fail as node-unhealthy in the meantime). A failed account write while live pins the block's touched accounts so the cache stays authoritative and the total stays exact; if the failure-pin budget is exceeded the tracker marks stale and stays fail-closed until the indexer restarts. For finalized commitment the response picks the newest persisted total at or below the finalized slot; confirmed returns the newest total. context.slot is the slot of the served row; the non-circulating account list may lag it by one block.
The response matches Agave's getSupply exactly: the RpcSupply shape (total, circulating, nonCirculating, nonCirculatingAccounts), total is the bank capitalization, nonCirculating is the summed pinned-list-plus-locked-stake lamports, circulating is total - nonCirculating, and excludeNonCirculatingAccountsList = true returns the same numbers with an empty account list.
These methods are served from maintained top-N holder sets rather than live table scans, so they stay cheap at any index size and work even where upstream RPC nodes disable them. Each method is enabled independently by its own config section: [largest-accounts] for getLargestAccounts, and [token-largest-accounts] for getTokenLargestAccounts. The section must be present with enabled = true on both the indexer and the API config of a co-deployed node. An absent section, or enabled = false, turns the method off. There is no shared enablement state in the database. A request for a method that is not enabled on the API gets a JSON-RPC -32601 Method not found.
The two methods have different prerequisites, which is why they are enabled separately. getLargestAccounts needs a full unfiltered index and the [snapshot] section. getTokenLargestAccounts needs the token programs (Tokenkeg / Token-2022) indexed, because the mint-metadata lookup and the reservoir read token accounts. That means an unfiltered [programs], or an include-list containing both. It also needs the [snapshot] section. It does not need the owner map, the non-circulating tracker, or the GLA sentinels, so a token-filtered node can serve token tops alone.
When enabled, the indexer keeps an in-memory top-N per tracked entity: the native-SOL top and the circulating/non-circulating class tops under [largest-accounts], and one token top per mint listed in tracked-mints. tracked-mints is the set of mints that get served, so pick the ones whose getTokenLargestAccounts traffic matters. When a top-N changes, its top 20 rows are persisted as a single packed record per (mint, slot). Reads pick the newest record at or below the requested commitment's slot. Old generations are pruned every prune-interval-slots finalized slots, off the finalize critical path.
The API routes getTokenLargestAccounts purely on record presence. The indexer persists a record for exactly the mints it tracks, so a persisted record is served and an absent record returns a fast "mint not tracked" error. This keeps the API in lockstep with the indexer without any mint-list handshake, and there is no fallback table scan. During the indexer's bootstrap window a tracked mint has no record yet, so it gets the same "mint not tracked" error until its first record persists. Clients cannot distinguish "warming up" from "never tracked" for getTokenLargestAccounts. getLargestAccounts with no record yet (the indexer is still bootstrapping) returns a retriable "not available yet" error instead of an internal error.
The tracker is seeded from the startup snapshot pass and then advanced per confirmed block. It keeps accounts-per-mint holders in memory (default 100) so that evictions from the top 20 can be backfilled without rescanning. If churn ever exhausts that reservoir, the affected mint is marked stale and its persisted rows are dropped rather than serving a possibly-wrong answer. The non-circulating membership set behind the filter option comes from the same real-time stake map that serves getSupply (see Supply): each block's accounts are classified by the membership after that block, and a member whose lockup expired without a write is moved to the circulating top in the block whose clock passed the expiry.
Config keys: [largest-accounts] (indexer) takes enabled, accounts-per-mint (default 100), and prune-interval-slots (default 20). [token-largest-accounts] (indexer) takes enabled, tracked-mints (an array of mint addresses), accounts-per-mint, and prune-interval-slots. The API sections carry only enabled. See example.cloudbreak.index.toml and example.cloudbreak.api.toml.
When the [snapshot] section (with [snapshot.tracker_endpoint]) is present in the indexer config, the indexer queries the cluster tracker, downloads the latest covering snapshot pair from the source the tracker reports, and processes the archives before beginning gRPC streaming. This provides fast bootstrapping of account state. Omit the entire [snapshot] section to skip this.
Set accounts-owner-map-enabled = true in the indexer config to maintain an in-memory map of account pubkey to owner + slot. This is used for tracking owner changes and closed-account handling. It increases memory usage but improves correctness for owner-change scenarios.
The map also speeds up the finalize-slot cleanup. The accounts and snapshot_accounts tables are hash-partitioned by owner, so a cleanup DELETE that only knows the pubkey has to probe all 64 partitions. When the map is enabled, the indexer captures each account's owner at save time and the cleanup carries (pubkey, owner) pairs, letting Postgres prune each delete to a single partition. For an account whose owner changed, the old-owner pair recorded at the change routes the deletion of its old rows and closed-account mask to the old owner's partition. When the map is disabled the cleanup runs the no-owner SQL, with no owner routing.
The API server's [slot-syncronizer] section keeps the latest slots in memory from Postgres LISTEN/NOTIFY on cloudbreak_slots. Enabled by default. Disable with enabled = false if not needed.
The API can operate with or without the query tracker. To disable it, remove the [query-tracker-client] section from the API config. Without it, no query patterns are tracked and automatic index creation is unavailable.
Set [gpa-cache] in the API config to enable an in-memory cache for getProgramAccounts responses. The cache stores pre-serialized JSON bytes (ref-counted, zero-copy on hits) and changes the SQL to skip re-sending the body of already-cached accounts. See [gpa-cache] (optional) for the config keys and a benefits breakdown. Omit the section to disable.
jsonParsed token responses need each account's mint decimals and the mint's interest-bearing and scaled-UI configs. The mint cache keeps that data in memory. getTokenAccountsByOwner, getTokenAccountsByDelegate, getTokenAccountsByMint and token-program getProgramAccounts then read it without a Postgres lookup. Enable [mint-cache] on the indexer and on the API.
Indexer
- The indexer keeps every mint in memory, immutable ones included (without them, every mint or burn of one would look like a new mint and rewrite its row). While the startup snapshot loads, each snapshot mint and close goes into the in-memory copy, and every 50,000 changed keys are written to
mintsin the background. Confirmed blocks that arrive meanwhile go into the same copy. Per mint, the higher slot wins, so files and blocks can arrive in any order. - A close can arrive before the mint it closes (in a snapshot file or block read earlier). Until the bootstrap ends, such a close waits in
closed_pendingand is applied at the end. - When the snapshot is loaded, the indexer writes every change not yet in
mints, thenenvironment_info.mints_saved_slot(last_db_slot): the slot the table is complete through. The node turns healthy only after this. - For each confirmed block, the indexer compares every mint write and every zero-lamport write with its copy, and keeps the real changes for the next write: a new mint, a decimals or config change, a close, a re-init. A supply-only change is skipped.
- Every
db-write-interval-slotsslots, a background task writes the changed mints, thenlast_db_slot.last_db_slotnever passes a slot that self-healing still has to repair: an API that loads or reloads during an indexer gap then reads the repaired rows once the gap is filled. One write runs at a time. A failed write is retried with the next block. An upsert never overwrites a row with a newer slot.
The mints table
One row per mint: pubkey, slot (the slot of the last change), decimals, mutable, closed, interest_bearing_config, scaled_ui_amount_config, and an index on slot.
A Tokenkeg mint, or a Token-2022 mint without MintCloseAuthority, InterestBearingConfig and ScaledUiAmount, is immutable: nothing jsonParsed reads can change. The cache keeps only its decimals, with no slot. Every other mint is mutable and keeps the slot of its last change.
Tombstones
A close is stored as a row with closed = true and the close slot. The row is never deleted, for four reasons:
- The API reload
WHERE slot > applied_throughmust see a close the API missed. A deleted row returns nothing. The API would then keep the closed mint and return decimals where Agave drops the account. - At API startup, a close the API applied from its buffer must beat an older live row of the table. The tombstone keeps its slot, so the higher slot wins.
- A re-init at the same address updates the tombstone row with a higher slot. It needs no special case.
- A loaded tombstone answers "closed" from memory, without a Postgres lookup.
Tombstones are not pruned. If pruning is added later, the API must know the pruning horizon and do a full reload when its applied_through is below it.
API
- The
[grpc]feed buffers the mint changes of every processed block, by slot and blockhash. - On each confirmed
slotsnotification, the slot syncronizer first applies the buffered changes of that block and its unapplied ancestors, then publishes the slot. The cache is current through every slot a request can pick. - When a block is missing from the buffer, the slot syncronizer asks a background task for a reload and goes on. The task reads
last_db_slotevery 10 s. Once it covers the slot, the task readsWHERE slot > applied_throughfrommints, and the cache is current throughlast_db_slot. Until then, requests pastapplied_throughread mutable mints from Postgres. - At startup, the background task waits until
last_db_slotis set (the indexer bootstrap wrote the table), reads it, and only then loads the whole table in pages of 100,000 rows (each page one short query, soapi-queries-timeoutapplies). The loaded rows hold at least every change through that slot, and the buffer holds the blocks after it. Until the load ends, every lookup reads Postgres. - When the indexer turns healthy again after a gap repair, the API reloads the rows changed since it turned unhealthy, in the same background task, once
last_db_slotcovers the confirmed slot.
Lookups
Each request picks its slot once and uses it for its accounts and its mints.
| Cache entry | Answer |
|---|---|
| Immutable | Memory. |
| Mutable or tombstone, slot at or below request slot | Memory. A tombstone answers "closed": the account gets no decimals, as in Agave. |
| Mutable or tombstone, slot above the request slot | Postgres at the request slot. The mint changed after the request picked its slot. |
| Missing | Postgres, with a warning. While live this means a bug or an indexer gap. |
| Cache behind the request slot, or still loading | Postgres for mutable and missing mints. |
Add a [tracing] section to any service config to enable OTLP trace export. When omitted, only standard tracing-subscriber logging is used.
On the API server the json_encoding span additionally carries request_id, subscription_id and client_ip, read from the headers configured in [metrics] (request-id-key, subscription-id-key, client-ip-key). request_id is there to join a span to the corresponding upstream request log entry.
Each service exposes HTTP endpoints on its metrics port (or server port for the API and query tracker). Below is a breakdown per service.
| Endpoint | Method | Description |
|---|---|---|
/ |
POST | JSON-RPC endpoint for all RPC methods (getProgramAccounts, getTokenAccountsByOwner, etc.). Supports batch requests. |
/metrics |
GET | Prometheus metrics. |
/debug/log_filter |
GET | Returns the current tracing log filter. |
/debug/log_filter?filter=<value> |
GET | Dynamically updates the log filter at runtime without restarting the service. Example: curl "http://localhost:4000/debug/log_filter?filter=debug,hyper=warn" |
/debug/modules/gpa_cache |
GET | Inspects the live GPA cache. Supports ?detail=summary|queries|full, ?program=<pubkey>, ?with_pubkeys=true, ?min_size_bytes, ?max_size_bytes, ?limit. Returns {"enabled": false} when [gpa-cache] is not configured. See the rustdoc on gpa_cache_handler in crates/api/src/http/operational_endpoints.rs for the full query-parameter and response-body reference. |
/debug/modules/mint_cache |
GET | Inspects the API mint cache: status, applied_through, unhealthy_since, the reload waiting for last_db_slot (pending_reload), entry counts and the buffer. Load time and reload counts are in the cloudbreak_mint_cache_* metrics. ?pubkey=<base58> adds one mint's map (immutable, mutable, tombstone, missing), slot (null for an immutable entry), closed state and decimals. Returns {"enabled": false} without [mint-cache]. See the rustdoc on mint_cache_handler in crates/api/src/http/operational_endpoints.rs. |
| Endpoint | Method | Description |
|---|---|---|
/metrics |
GET | Prometheus metrics (block processing, chunk processing, slot finalization, gRPC stats, etc.). |
/debug/modules/finalizer |
GET | Inspects the live slot finalizer (confirmed-but-not-finalized blocks map, ordered pending queue, pause state). Returns JSON with the DB chain tips (confirmed/finalized + lag). Supports ?detail=summary|slots|full, ?kind=all|live|repaired, ?min_slot, ?max_slot, ?limit, ?with_pubkeys=true. See the rustdoc on handle in crates/index/src/operational_endpoints/finalizer.rs for the full reference. |
/debug/modules/self_healing |
GET | Inspects self-healing gap state. Returns JSON with the DB chain tips and the still-missing slots grouped into gaps (each with boundary_slot, start/end, len, and slots_behind_confirmed). Supports ?detail=summary|slots|full (summary returns only the stats counts, omitting the gap list), plus ?min_slot, ?max_slot, ?limit to filter the gap view. See the rustdoc on handle in crates/index/src/operational_endpoints/self_healing.rs. |
/debug/modules/supply |
GET | Inspects the live supply tracker: status, total, slot, startup slot, gap-close count, and the hot-accounts cache counters (hot, pinned, capacity, cap, last sweep slot). Supports ?detail=full&pubkey=<base58> for one cache entry. Returns {"enabled": false} when [supply] is not configured. See the rustdoc on handle in crates/index/src/operational_endpoints/supply.rs. |
/debug/modules/mint_cache |
GET | Inspects the indexer half of the mint cache: last_db_slot (null until the bootstrap wrote mints), last_slot, entry counts, closes waiting for their mint (closed_pending, null after the bootstrap), keys waiting for the next write (pending_to_db), whether a write runs, and the last block's changes by kind. The bootstrap write time is the cloudbreak_mint_cache_startup_seconds{side="indexer"} metric. ?pubkey=<base58> adds one mint's map, slot, closed state and decimals. Returns {"enabled": false} without [mint-cache]. See the rustdoc on handle in crates/index/src/operational_endpoints/mint_cache.rs. |
/debug/modules/non_circulating |
GET | Inspects the live non-circulating tracker: status, slot, clock, member count, running non-circulating lamports, stake map size, the sizes and heads of the two lockup-expiry sets (?limit= heads each, default 10). Supports ?detail=full&pubkey=<base58> for one stake map entry. Returns {"enabled": false} when neither [largest-accounts] nor [supply] is configured. See the rustdoc on handle in crates/index/src/operational_endpoints/non_circulating.rs. |
/debug/accounts_owner_map |
GET | Returns debug info about the in-memory account-to-owner map. Only populated when accounts-owner-map-enabled = true in the indexer config. |
All endpoints — functional and operational — are served on the single server port.
| Endpoint | Method | Description |
|---|---|---|
/track |
POST | Ingest a batch of observed getProgramAccounts-shaped demand (body is a TrackBatch). |
/debug/candidates |
GET | The ranked creation queue, each candidate annotated with its priority score and the with/without-index latency behind it. |
/debug/created |
GET | Currently active auto-created indexes with demand (API) vs. supply (compensated idx_scan), size, latency gain and explain_state. |
/debug/discrepancies |
GET | Created indexes where demand and supply disagree (e.g. planner ignoring an index). |
/metrics |
GET | Prometheus metrics. |
/health |
GET | Returns 200 OK if the service is running. |
The three /debug/* endpoints share query-string conventions. Latencies are rendered in ms (2 dp) (*_ms), index size in MiB (2 dp) (index_mb), and supply as compensated_idx_scan (raw idx_scan ÷ 2, since a served GPA scans both tables of the pair). Each row's score (the shared priority-mode ranking) is shown in thousands, 1 dp (e.g. "37.2K") — the raw score derives from microsecond costs, so ÷ 1000 makes it proportional to the *_ms figures. Under the weighted mode the field also carries, in parentheses, the three factors whose product is the score — avg (mean cost/request in ms) × gain (latency multiplier, 1 = neutral) × counts (windowed demand/supply/failure volume multiplier, in K), e.g. "37.2K (avg 12.34ms × gain 1.05 × counts 13.0K)". Every response repeats these notes in a top-level docs field. Every response is { "total", "count", "limit", "docs", "<items>": [...] }. Common parameters:
limit=N— cap returned rows (default: all);order=<key>+dir=asc|desc— sort.example=true/pattern_id=true(orverbose=truefor both) — include the heavier fields, omitted by default.- Order keys:
/candidates→score(default),demand_count,avg_cost_ms,variety_estimate;/created→created_at(default, newest first),index_mb,demand_count,idx_scan,avg_cost_with_index_ms,with_without_idx_ratio,variety_estimate;/discrepancies→discrepancy_ratio(default,dir=asc= most-starved first) withmin_ratio/max_ratiobounds. /createdshows each index'sscore— the same sharedpriority-moderanking value as/debug/candidates(creation builds highest, eviction drops lowest) — plus a human-readablecreated_at, and can be ordered byscorein any view. It also acceptsmin_ratio/max_ratiobounds onwith_without_idx_ratio(rows without both latency buckets are dropped when a bound is set); since> 1means the index helps,max_ratio=1surfaces indexes that are not helping.filter=eviction_candidatesshows only the indexes the eviction pass would consider (past the idle + age-grace gates), ordered least-useful-first by default (order=score, ascending). The real drop still depends on the table being above the fill target at runtime — eviction only trims the buffer band back to the target./createdalso acceptsfilter=explain_incomplete(planner uses the index on neither or only one table),filter=explain_none(neither table), andfilter=explain_partial(exactly one table) — the detailed per-index source of truth behind thequery_tracker_explain_stategauge and theEXPLAINsummary log. Rows with no verdict yet (explain-enabledoff) are excluded.- Unknown keys or malformed values return
400. Example:GET /debug/created?order=index_mb&limit=10&verbose=true.
All metrics are emitted in the Prometheus text exposition format on each service's /metrics endpoint (see HTTP Endpoints). Metric names are stable and intended to be referenced from Grafana panels and alert rules. Labels are described per-metric below; when label values are enumerated, they are the complete set currently emitted by the code.
| Metric | Type | Labels | Description |
|---|---|---|---|
cloudbreak_api_requests_total |
Counter | method, status |
Count of RPC method invocations grouped by outcome. method ∈ {gPA, gTABO, gTABD, gTABM, gAI, getBalance, getMultipleAccounts, getTokenAccountBalance, http}: gPA = getProgramAccounts, gTABO = getTokenAccountsByOwner, gTABD = getTokenAccountsByDelegate, gTABM = getTokenAccountsByMint, gAI = getAccountInfo, http is connection-level. status ∈ {success, error, timeout}: error is incremented on RPC-level failures (bad params, DB failure, stream-mid-error); timeout is incremented on http when the total request-timeout fires. The point-lookup methods (gAI, getBalance, getMultipleAccounts, getTokenAccountBalance) emit both success and error. The streaming methods (gPA, gTABO, gTABD, gTABM) emit error only — for their total throughput use cloudbreak_api_request_duration_ms instead. Note: getSlot, getHealth, getVersion, and getGenesisHash are not currently surfaced under this counter. |
cloudbreak_api_request_duration_ms |
Histogram | method, bytes |
Per-stage request latency in milliseconds. bytes is the response-size bucket (0-1KB, 1-10KB, 10-100KB, 100KB-1MB, 1MB-10MB, 10MB-50MB, 50MB-100MB, 100MB-200MB, 200MB-500MB, 500MB+). method values: gpa (total in-handler time for getProgramAccounts; on a token program it carries the indexed key it filters on: gpa_mint, gpa_delegate or gpa_token), gpa_db (Postgres query time), gpa_db_first_row_time (time-to-first-row), gpa_encode (account-encoding time), gpa_json (JSON serialization time); analogous gtabo* / gtabd* / gtabm* for the token-account methods; gAI / getBalance / getMultipleAccounts / getTokenAccountBalance / getTokenSupply (single observation per request — total handler + serialization time for the point-lookup methods, in fractional milliseconds); http_with_transport (end-to-end including body transport, label bytes reflects response size); http_connection (per-TCP-connection lifetime, label bytes="0"). |
cloudbreak_api_requests_by_subscription_id |
Counter | subscription_id_key |
Per-client request counter, attributed via the HTTP header configured by [metrics].subscription-id-key (default x-subscription-id). Only the four heavy methods report here: getProgramAccounts, getTokenAccountsByMint, getTokenAccountsByOwner, getTokenAccountsByDelegate — the point-lookup methods (getAccountInfo, getBalance, …) are not counted. Requests with the header absent fold into unknown-subscription-id. Batch requests count once per batch entry. |
cloudbreak_api_data_fetched_by_subscription_id |
Counter | subscription_id_key |
Per-client cumulative response size in bytes (JSON-encoded payload). Same method scope as cloudbreak_api_requests_by_subscription_id. |
cloudbreak_api_duration_us_by_subscription_id |
Counter | subscription_id_key |
Per-client cumulative request handling time in microseconds — divide by 1000 for milliseconds. Measures handler entry through the last encoded JSON chunk (the same duration the json_encoding span reports as total_wall_time), excluding HTTP body transport. Same method scope as cloudbreak_api_requests_by_subscription_id, so dividing the two yields a mean latency over those methods only. |
cloudbreak_api_inflight_requests |
Gauge | method |
Currently in-flight requests per stage. Emitted values: http_connection (live TCP connections), http (active HTTP requests), gpa, gtabo, gtabd, gai, gma, getBalance, getTokenAccountBalance. The special label max is set once at startup to [server].max-connections; plot against the live gauges to visualize saturation. |
cloudbreak_api_batch_requests_total |
Counter | batch_size |
Counter incremented once per batch JSON-RPC request, bucketed by batch size: 1-5, 6-10, 11-20, 21-50, 51-100, 100+. |
cloudbreak_api_cache_hit_percent |
Histogram | method, bytes |
Per-request GPA cache hit percentage (0-100), bucketed for both ends so fully-cached (100) and fully-fresh (0) requests stand out. method matches the getProgramAccounts/token-account method labels used by cloudbreak_api_request_duration_ms and bytes is the same response-size bucket. Reports 0 when the cache is inactive or the response has no accounts. |
cloudbreak_api_response_bytes_total |
Counter | method, kind |
Response bytes served. kind ∈ {total (all response bytes), cached (subset served from the GPA cache; 0 on paths that don't use the cache)}. Effective cache utilization is kind="cached" / kind="total" in Grafana. method matches the GPA/token-account method labels. |
cloudbreak_api_db_pool_connections |
Gauge | origin |
SQLx database connection-pool state, sampled at scrape time. origin ∈ {max (configured [database].max-connections), idle (connections idle and immediately available), total (established connections, i.e. idle + in-use)}. In-use is total - idle; headroom before queries start waiting is max - (total - idle), both computable in Grafana. |
cloudbreak_gpa_cache_size_bytes |
IntGauge | — | Current size of the GPA cache in bytes. Pair with cloudbreak_gpa_cache_max_bytes to compute utilization (size / max). |
cloudbreak_gpa_cache_max_bytes |
IntGauge | — | Configured maximum size of the GPA cache in bytes ([gpa-cache].max-total-bytes). |
cloudbreak_gpa_cache_evictions_total |
Counter | used |
GPA cache entries evicted by cleanup to make room for a different query. used ∈ {used, unused} indicating whether the evicted entry had ever served a cache hit. A high rate of unused evictions indicates cache churn (e.g. min-bytes-per-query set too low). |
cloudbreak_gpa_cache_evicted_bytes_total |
Counter | used |
Total bytes evicted from the GPA cache by cleanup, with the same used labelling as cloudbreak_gpa_cache_evictions_total. |
cloudbreak_gpa_cache_skipped_total |
Counter | method, bytes, reason |
Responses large enough to cache (min-bytes-per-query) that were not cached. method is the request label (gpa, gpa_mint, gpa_delegate, gpa_token, gtabo, gtabd, gtabm), bytes the response-size bucket. reason is mutable_mint (a jsonParsed token account uses a mint with close authority, interest-bearing or scaled-UI config, or a missing mint) or dropped_account (a jsonParsed token account did not parse and was left out). |
cloudbreak_api_processed_requests_total |
Counter | method, route, reason |
Processed commitment requests for the methods [processed-accounts] serves. route is view when the processed blocks answer, degraded when the request reads as confirmed. reason is none, no_blocks, unhealthy, head_behind or finalized_above_anchor. Registered only when [processed-accounts] is enabled. |
cloudbreak_api_processed_confirm_latency_ms |
Histogram | — | Time from receiving a processed block to the Postgres confirmed slot reaching it, in milliseconds. Registered only when [processed-accounts] is enabled. |
cloudbreak_mint_cache_entries |
IntGaugeVec | side, map |
Mint cache entries: immutable, mutable, tombstone. side is api here and indexer on the indexer. Registered only with [mint-cache]. |
cloudbreak_mint_cache_changes_per_block |
HistogramVec | side, kind |
Mint changes applied per confirmed block: create, update, close, reinit. |
cloudbreak_mint_cache_block_microseconds |
HistogramVec | side, stage |
Mint cache time in microseconds. API stages: ingest (feed thread), apply (confirmed notification). Reloads run in a background task. |
cloudbreak_mint_cache_startup_seconds |
GaugeVec | side |
API: duration of the full mints load at startup. |
cloudbreak_mint_cache_lookups_total |
IntCounterVec | source, reason |
Mint lookups. source="memory" with reason hit or closed; source="postgres" with loading, newer_than_request, behind or missing. missing while live also logs a warning. |
cloudbreak_mint_cache_reloads_total |
IntCounterVec | reason, result |
Reloads of WHERE slot > applied_through, run by a background task once last_db_slot covers the slot. reason: block_missing (the feed lagged or had a gap), health_restored (after an indexer gap). result: ok, error. |
cloudbreak_mint_cache_buffer_blocks |
IntGauge | — | Blocks waiting in the mint buffer for their confirmed notification. |
cloudbreak_mint_cache_applied_through |
IntGauge | — | Last confirmed slot applied to the mint cache. |
cloudbreak_grpc_errors, cloudbreak_grpc_timeout_errors, cloudbreak_grpc_total_updates_received, cloudbreak_grpc_buffer_channel_size_sender |
Counter / IntGauge | — | The [grpc] feed, with the same meaning as the indexer metrics of the same name. Registered only when [grpc] is enabled. |
| Metric | Type | Labels | Description |
|---|---|---|---|
cloudbreak_block_processing |
Histogram | origin |
Total time in seconds to process a full gRPC block (parse + per-chunk insert). origin = block. Buckets: 10 ms – 10 s. |
cloudbreak_chunk_processing |
Histogram | origin |
Per-chunk DB insert latency in seconds. origin = block. Buckets: 10 ms – 10 s. |
cloudbreak_block_size |
Histogram | origin |
Block size in bytes. origin = block. Buckets: 100 KB – 30 MB. |
cloudbreak_chunk_size |
Histogram | origin |
Chunk size in bytes. origin = chunk. Buckets: 50 KB – 2 MB. |
cloudbreak_finalize_slot |
Histogram | origin |
Finalize-slot stage latency in seconds. origin values: total (one drain of a cleanup slot window, every chunk on both tables), cleanup_accounts, cleanup_snapshot_accounts (every chunk of one drain on that table, wall clock), cleanup_accounts_batch, cleanup_snapshot_accounts_batch (one drainer statement of at most cleanup-batch-size keys), cleanup_startup_snapshot_accounts_batch (one statement of the startup one-shot). Buckets: 5 ms – 60 s. |
cloudbreak_finalize_slot_deleted_accounts |
Histogram | — | Number of older account versions deleted per cleanup_accounts call. Useful to spot slots that churn an unusually large number of rows. |
cloudbreak_new_accounts_in_slot |
Histogram | origin |
Accounts seen in a slot. origin = new_accounts_in_slot (accounts net-new to the database after finalize-time deduplication) or block_accounts_total (raw count of account updates in the block, including overwrites). The ratio gives a sense of write churn per slot. |
cloudbreak_grpc_buffer_channel_size |
Histogram | origin |
gRPC ingestion buffer depth sampled per push. origin = grpc_buffer_channel_size. Buckets: 1 – 10 000. Rising values indicate the indexer is falling behind the stream. |
cloudbreak_grpc_buffer_channel_size_sender |
IntGauge | — | Latest gRPC buffer channel depth (last sample, gauge form for easy alerting). |
cloudbreak_grpc_timeout_errors |
Counter | — | gRPC stream timeout errors. |
cloudbreak_grpc_errors |
Counter | — | gRPC errors other than timeouts (transport, decoding, etc.). |
cloudbreak_grpc_total_updates_received |
Counter | — | Total messages received from the gRPC subscription. Use the rate as the input-side throughput. |
cloudbreak_grpc_gap_errors |
Counter | — | Slot gaps detected on gRPC reconnection. Each increment corresponds to a missing-slot range observed at reconnect. |
cloudbreak_db_errors |
Counter | — | All database errors (insert / cleanup / read) hit by the indexer. When the cumulative count exceeds database.max-db-errors-threshold (default 100), the indexer process exits. |
cloudbreak_closed_accounts_per_slot |
Histogram | — | Number of accounts marked closed per slot. Buckets: 1 – 10 000. |
cloudbreak_insert_closed_accounts_per_slot_ms |
Histogram | — | Latency in milliseconds of inserting closed-account markers for one slot. Buckets: 0.1 ms – 10 s. |
cloudbreak_current_tokio_tasks |
IntGauge | task_type |
Current count of spawned Tokio tasks per category. task_type values: grpc, slot_finalizer, self_healing_fill_gaps, snapshot_processing, insert_accounts_chunk, insert_closed_accounts, startup_snapshot_accounts_cleanup, finalize_cleanup_drainer, metrics_server, epoch_stakes_recomputer, largest_accounts_pruner, supply_cache_sweeper. Counter is guarded by TokioTaskCounterGuard so panicking tasks still decrement. |
cloudbreak_finalize_slot_handler_queue_size |
IntGauge | — | Pending items in the finalize-slot handler channel. The query tracker reads this via the configured indexer-metrics endpoint to pause CREATE INDEX when the indexer is behind (indexer-metrics-threshold). |
cloudbreak_cleanup_lag_slots |
IntGauge | — | Finalized slots since the oldest cleanup key still waiting. Cleanup runs off the finalize worker, so this is the signal that it is falling behind, and the query tracker reads it as the second DDL gate (indexer-cleanup-lag-threshold). |
cloudbreak_largest_accounts_db_errors |
Counter | — | Largest-accounts DB write/prune errors (record persist, stale/cleared-mint deletes, and the pruner). Only emitted when a largest-accounts section ([largest-accounts] / [token-largest-accounts]) is enabled. |
cloudbreak_largest_accounts_stale_mints |
IntGauge | — | Number of tracked mints currently marked stale in the largest-accounts tracker (reservoir exhausted or a persist failure dropped the record). A non-zero value means those mints have no persisted record and getTokenLargestAccounts fails fast for them. Only emitted when a largest-accounts section ([largest-accounts] / [token-largest-accounts]) is enabled. |
cloudbreak_supply_total_lamports |
IntGauge | — | Running total supply in lamports, updated on every committed block while the supply tracker is live. Only emitted when the [supply] section is enabled. |
cloudbreak_supply_slot |
IntGauge | — | Slot of the last committed supply total. |
cloudbreak_supply_status |
IntGauge | — | Supply tracker status: 0 bootstrapping, 1 live, 2 gap filling, 3 stale, 4 bootstrap failed. Set at every transition. |
cloudbreak_supply_cache_entries |
IntGaugeVec | kind |
Hot-accounts cache population, labelled pinned (by a live write failure) and hot (unpinned). |
cloudbreak_supply_cache_hits_total |
Counter | — | Per-block cache hits (previous balance served from memory). |
cloudbreak_supply_cache_misses_total |
Counter | — | Per-block cache misses (previous balance resolved by a DB read). |
cloudbreak_supply_write_failures_total |
Counter | — | Blocks whose account writes failed while live (pinned on failure, or marked stale beyond the pin budget). |
cloudbreak_supply_block_microseconds |
Histogram | — | Per-block supply delta time in microseconds, from apply_block entry (block-writes lock wait included) to the last miss write-back. |
cloudbreak_supply_probe_microseconds |
Histogram | — | Per-block cache probe time in microseconds (phase one, under the state mutex). |
cloudbreak_supply_db_microseconds |
HistogramVec | query |
Supply DB query time in microseconds: prev_balances (the batched miss read), persist_row (the supply row upsert), startup_balances (the bootstrap resolve read). |
cloudbreak_supply_sweep_microseconds |
Histogram | — | One hot-accounts cache sweep pass in microseconds. |
cloudbreak_supply_query_errors |
Counter | — | Supply persistence queries that failed or timed out (the per-block supply-row upsert and the non-circulating list upsert). |
cloudbreak_non_circulating_block_microseconds |
Histogram | — | Per-block non-circulating time in microseconds: the stake map apply plus the lockup-expiry pop. Only emitted when [largest-accounts] or [supply] is enabled. |
cloudbreak_non_circulating_members |
IntGauge | — | Current non-circulating member count, set on the bootstrap flip and on every block that changed membership. |
cloudbreak_non_circulating_changes_total |
IntCounterVec | kind |
Non-circulating membership changes, labelled join and leave. |
cloudbreak_mint_cache_entries |
IntGaugeVec | side, map |
Entries of the indexer's mint store (side="indexer"): immutable, mutable, tombstone. Only with [mint-cache]. |
cloudbreak_mint_cache_changes_per_block |
HistogramVec | side, kind |
Mint changes per confirmed block, kept for the next write to mints: create, update, close, reinit. |
cloudbreak_mint_cache_block_microseconds |
HistogramVec | side, stage |
Mint cache indexer time in microseconds: classify and apply per block, write per write to mints. |
cloudbreak_mint_cache_startup_seconds |
GaugeVec | side |
Indexer: duration of the final mints write of the bootstrap. |
cloudbreak_mint_cache_unchanged_writes_total |
IntCounter | — | Mint writes that changed nothing jsonParsed reads (supply only). |
cloudbreak_mint_cache_db_write_errors_total |
IntCounter | — | Failed writes to mints. The rows are retried with the next write. |
Snapshot metrics are emitted whenever snapshot processing runs — either embedded in the indexer at startup (when [snapshot] is configured) or via the standalone snapshot subcommand. They piggyback on the metrics endpoint of the calling service.
| Metric | Type | Labels | Description |
|---|---|---|---|
cloudbreak_db_snapshot_errors |
Counter | — | Database error counter for snapshot-time inserts and dedup queries. |
cloudbreak_snapshot_batch_insert_time |
Histogram | — | Latency in seconds of a single batched AccountsFile insert. Uses Prometheus default buckets. |
cloudbreak_processed_snapshot_items |
Gauge | type |
Snapshot ingestion progress. type ∈ {accounts_files_total (count of completed AccountsFile entries — incremented as a counter despite the gauge type), accounts_files_percentage (0 – 100), accounts_total (cumulative account count written from the snapshot)}. |
cloudbreak_snapshot_accounts_buffer_size |
Gauge | type |
Snapshot-time intermediate buffer depths. type = cleanup_duplicated (size of the dedup-stage receive channel). |
cloudbreak_snapshot_clean_up_duplicated_accounts_batch_time |
Histogram | — | Latency in seconds of de-duplicating a batch of overlapping snapshot accounts against the live accounts table. Uses Prometheus default buckets. |
cloudbreak_snapshot_clean_up_duplicated_accounts_batch_rows_affected |
Counter | type |
Registered but not currently observed in code; reserved for surfacing the row count touched by each dedup batch. |
The query tracker exposes its metrics on /metrics (same port as /track and /debug/*). Aggregate gauges and the lifecycle counters are refreshed by the eviction pass; the per-index gauges are labelled with the human-readable index name so a dashboard can show demand (API) against supply (idx_scan) per active index. Use /health for liveness probes.
| Metric | Type | Labels | Description |
|---|---|---|---|
query_tracker_snapshot_accounts_indexes_total |
IntGauge | — | Current number of indexes present on the capped snapshot_accounts table (the value the max-auto-indexes cap and eviction-fill-threshold are measured against). |
query_tracker_patterns |
IntGaugeVec | status |
Number of distinct index patterns tracked, partitioned by lifecycle status ∈ {created, candidate, evicted, rejected}. The total across all statuses is sum(query_tracker_patterns) (no total label, to avoid double-counting on aggregation). |
query_tracker_discrepant_indexes |
IntGauge | — | Number of created indexes where demand (API) and supply (idx_scan) currently diverge (see discrepancy-enabled). |
query_tracker_index_created_total |
IntCounter | — | Cumulative number of auto-index pairs created. |
query_tracker_index_evicted_total |
IntCounter | — | Cumulative number of auto-index pairs evicted (idle eviction and regression drops). |
query_tracker_index_create_failures_total |
IntCounter | — | Cumulative number of failed auto-index creation attempts. |
query_tracker_regressed_indexes |
IntGauge | — | Created indexes currently slower with the index than without it (see index-regression-guard). Refreshed each eviction pass; 0 when the guard is off. Near zero in evict mode (drops move them to status="rejected"); reflects tolerated regressions in warn mode. |
query_tracker_observations_total |
IntCounter | — | Cumulative number of accepted demand observations ingested via /track. |
query_tracker_index_demand |
IntGaugeVec | index |
Cumulative API demand (request count) per created index. |
query_tracker_index_compensated_idx_scan |
IntGaugeVec | index |
Compensated Postgres idx_scan (supply, raw idx_scan ÷ 2 since a served GPA scans both tables of the pair) per created index. Lines up ~1:1 with query_tracker_index_demand for a healthy index; the raw counter is never exported. |
query_tracker_index_variety |
IntGaugeVec | index |
Estimated distinct filter values served per created index (HyperLogLog variety estimate). |
query_tracker_explain_state |
IntGaugeVec | state |
Result of the last EXPLAIN sampling pass (see explain-enabled): created indexes in each planner-usage verdict state ∈ {none, accounts_table, snapshot_accounts_table, both}. A snapshot of the latest run, not cumulative; stays at zero when explain-enabled is off. Alert on none. |
The dbtools binary is a standalone operator CLI for running analytics and configuration commands against one or more Postgres servers.
Config file (cloudbreak.dbtools.toml):
[[servers]]
name = "production"
database_url = "postgres://user:pass@host/cloudbreak"
[[servers]]
name = "staging"
database_url = "postgres://user:pass@host2/cloudbreak"cargo run -p cloudbreak-dbtools -- analytics <command>| Command | Description |
|---|---|
get-biggest-programs |
Top program owners by account count across partitions. |
table-size |
Database and table sizes (heap, indexes, total) for accounts and snapshot_accounts. |
indexes-sizes |
Aggregated index sizes across the partition tree. |
partition-sizes |
Recursive partition hierarchy with sizes. |
distinct-owners-count |
Per-partition COUNT(DISTINCT owner) with totals. |
mint-accounts-count |
SPL Token and Token-2022 mint account counts. |
indexes-count |
Total number of indexes on accounts and snapshot_accounts. |
slow-queries |
Currently running queries exceeding 1 minute (from pg_stat_activity). |
get-delegates |
Sample delegated token accounts; outputs benchmark request bodies. |
accounts-count |
Total row counts for accounts and snapshot_accounts. |
See crates/dbtools/README.md for more details.
The integration_tests crate is a standalone binary for benchmarking and correctness testing of any Solana RPC endpoint (not just Cloudbreak). It supports constant-rate load generation, dual-endpoint comparison with slot compensation, multiple request sources (static files, VictoriaLogs, mismatch replays), and optional database forensics.
# Copy and edit the example config
cp example.cloudbreak.integration_tests.toml cloudbreak.integration_tests.toml
# Run a benchmark
cargo run --bin integration_tests -- benchmark gpa
cargo run --bin integration_tests -- benchmark gtabo
cargo run --bin integration_tests -- benchmark gtabd
cargo run --bin integration_tests -- benchmark simulate-transaction| Command | Description |
|---|---|
benchmark <type> |
Main command. Load test with optional dual-endpoint comparison. Types: gpa, gtabo, gtabd, gpa-token-owner, gpa-token-mint, simulate-transaction. |
compare |
(Legacy) Full pubkey set comparison between two endpoints with transaction history checks. |
get-slot |
(Legacy) Polls getSlot on rpc1 every 100ms. |
See crates/integration_tests/README.md for the full documentation including configuration reference, comparison semantics, slot compensation, logging targets, and output format.
Published benchmark and load-testing results live in docs/benchmarks/. All runs replay real production traffic captured from VictoriaLogs archives, measured end-to-end within the same datacenter. Current cases:
- Case 1 — low-RPS correctness + latency comparison against a reference Agave endpoint.
- Case 2 — sustained single-node load on an AMD EPYC Gen3 node (1 API task).
- Case 3 — scale-out load on an AMD EPYC Gen4 node (3 API tasks → 1 shared Postgres).
See docs/benchmarks/README.md for the full write-up, latency tables, and throughput charts.
cargo build
cargo build --releaseNew optional modules follow two rules. The mint cache is the reference: MintCacheConfig in crates/core/src/config.rs, MintCache in crates/core/src/modules/mint_cache/mod.rs, and its halves MintCacheApi in crates/core/src/modules/mint_cache/api.rs and MintCacheIndexer in crates/core/src/modules/mint_cache/indexer.rs.
Config: an Option section with an enabled flag
- The binary's config has a field
x: Option<XConfig>, andXConfighasenabled: booland#[serde(deny_unknown_fields)]. - An
x_enabled() -> boolaccessor on the binary's config gives the switch, for exampleApiConfig::mint_cache_enabled(). - When the module needs other sections,
XConfigchecks them incheck_required_modules(&self, config). The check does nothing while the module is disabled. A section that two binaries share has one check per binary, for examplecheck_required_api_modulesandcheck_required_indexer_modules. - The binary runs these checks at startup, before it builds any module. A failed check stops startup with an error.
Module: a null-object handle
- The handle is
pub struct X(Option<Arc<Inner>>)and derivesCloneandDefault.Defaultis the disabled handle. X::from_config(Option<&XConfig>)returns the disabled handle when the section is absent orenabled = false. It is the only place where config becomes the module.- Each method starts with
let Some(inner) = &self.0 else { return … };and does nothing on the disabled handle. Callers do not check whether the module is enabled. is_enabled()is for the few callers that must act differently when the module is off, for example to reject a method.- A module that runs in two binaries is one handle with a half per binary, for example
MintCachewithapi()andindexer(). Each half is a null-object handle. Each binary builds the handle with its own constructor (MintCache::from_api_config,MintCache::from_indexer_config), which enables only its half, and calls its half by name:mint_cache.api().on_block(..). - A handle with a single owner holds
Option<Inner>withoutArcand does not deriveClone.ProcessedWriterincrates/core/src/modules/processed/writer.rsis the example: it owns the processed block store without a lock, andProcessedAccounts::writer()returns the disabled writer when[processed-accounts]is off.
Some older modules do not follow these rules yet. For example, [gpa-cache] and [query-tracker-client] have no enabled flag, and [slot-syncronizer] is not an Option.
All services use tracing with RUST_LOG environment variable control:
# Default level
RUST_LOG=info cargo run -p cloudbreak -- --config ./cloudbreak.index.toml index
# Debug with SQLx silenced
RUST_LOG=debug,sqlx=error cargo run -p cloudbreak -- --config ./cloudbreak.api.toml api
# Target-specific filtering
RUST_LOG=cloudbreak_api=debug,hyper=warn cargo run -p cloudbreak -- --config ./cloudbreak.api.toml apiAll crates use SeaORM for database access with the sqlx-postgres + runtime-tokio-rustls backend.
See the infrastructure table in Getting Started for all ports and services provided by docker compose up. Prometheus scrape targets are configured in prometheus-config.yaml.
The accounts table migration attempts to load the pg_tracing Postgres extension. If the extension is not installed (e.g. stock Docker Postgres image), a warning is printed but the migration continues successfully. The extension is optional and only needed for detailed query plan tracing via the dbtools pg-tracing-high-detail command.
This happens when the Docker volume contains data from a different Postgres major version (e.g. data initialized by PG17 but the compose file uses PG16). Fix by removing the volume and starting fresh:
docker compose down -v
docker compose up -dThe service_health table is set to unhealthy when confirmed slot gaps are detected. Check the indexer's /debug/modules/self_healing endpoint to see which slots are missing:
curl http://localhost:8875/debug/modules/self_healingIf the [snapshot] section is configured, the self-healing mechanism will automatically attempt to fill gaps via incremental snapshots fetched through the cluster tracker. If it's not configured (or the tracker has no source producing usable snapshots), gaps cannot be repaired automatically and require manual intervention (e.g. re-running a snapshot or restarting the indexer).
getHealth reads the service_health row that the indexer maintains. When the node is unhealthy it returns JSON-RPC error -32005 with message Node is unhealthy and data: {"numSlotsBehind": null}. With unhealthy-response = "http-unavailable", a single request gets HTTP 503 instead of HTTP 200. numSlotsBehind is always null, because the indexer flag does not measure a distance from the cluster tip.
The indexer keeps the node unhealthy while any of these reasons is active:
- Startup snapshot processing. With
[snapshot]configured, the node stays unhealthy until the snapshot is loaded and cleaned up. This can take hours on mainnet and clears on its own. Without[snapshot], this reason clears as soon as the indexer starts processing blocks, so a no-snapshot node reports healthy on a partial dataset (see Start the Services). - Gap filling. A confirmed slot gap pauses finalization and marks the node unhealthy until the gap is repaired. See Indexer shows unhealthy status.
While the node is unhealthy, getAccountInfo, getMultipleAccounts, getProgramAccounts, getTokenAccountsByOwner, getTokenAccountsByDelegate, getTokenAccountsByMint, getTokenAccountBalance, getTokenSupply, getTokenLargestAccounts, getLargestAccounts, and simulateTransaction return the same -32005 error. getSupply does too when the slot syncronizer is enabled. getSupply, getLargestAccounts, and getVoteAccounts also return -32005 while their caches or records are not ready, whatever the health flag says. getSlot, getBalance, getVoteAccounts, getVersion, and getGenesisHash do not check the health flag.
If [snapshot] is configured and getHealth does not clear after the snapshot finished downloading, check the indexer logs for snapshot-processing errors and the service_health row directly:
psql "$DATABASE_URL" -c 'SELECT * FROM service_health;'If the indexer can't keep up with the gRPC stream, the buffer channel fills up. Monitor the cloudbreak_grpc_buffer_channel_size Prometheus metric. Common causes:
- Database writes are too slow — check
slow-queriesvia dbtools or increasemax-connections. - Snapshot processing is running (expected during startup) — wait for it to finish.
- Too many programs selected — narrow the
[programs].includelist.
The query tracker checks the indexer's cloudbreak_finalize_slot_handler_queue_size metric before creating indexes. If the queue exceeds indexer-metrics-threshold (default 5), index creation is paused to avoid overloading the database while the indexer is catching up. This is expected behavior — index creation resumes automatically once the queue drains.
The API loads the program filter from the environment_info table, which is populated by the indexer on startup. This error means the indexer has never run against this database. Start the indexer at least once before starting the API.
If you change the [programs] filter in the indexer config, the environment_info table is updated on the next indexer startup. However, existing account data for previously indexed (now excluded) programs remains in the database. To clean it up, you can either:
- Run
cargo run -p cloudbreak-migration -- freshto drop and recreate all tables (destructive). - Manually delete rows for the unwanted programs.
When accounts-owner-map-enabled = true, the indexer maintains an in-memory map of every tracked account's pubkey to its owner and slot. For large program sets, this can consume significant memory. If memory is a concern and you don't need owner-change tracking, set accounts-owner-map-enabled = false.
The cluster tracker hasn't reported a snapshot pair that covers the slot the indexer asked for. Common causes:
- The targets in
tracker-config.ymlare unreachable, rate-limited (common with the defaulthttps://api.mainnet.solana.com), or haven't produced an incremental snapshot covering that slot yet. - The slot you asked for is newer than any snapshot the tracker currently knows about. The indexer will retry on the next iteration; this is informational, not fatal.
- You replaced the default with custom sidecars but the tracker hasn't completed its first scrape yet (controlled by
scrape_intervalintracker-config.yml).
Inspect what the tracker is exposing with:
curl http://localhost:8458/v1/snapshots | jq .If that response is empty or missing files entries, the issue is upstream of cloudbreak — fix tracker-config.yml and reload.
The solana-tracker Compose service uses a Git build context (https://github.com/rpcpool/solana-cluster.git#triton). The first docker compose up needs network access to clone the repo and pull the Go toolchain. After upstream commits to the triton branch, Compose will reuse the cached image — to pick up new commits run:
docker compose build --no-cache --pull solana-tracker
docker compose up -d solana-trackerHow we validate correctness. Cloudbreak intentionally does not ship synthetic fixtures or a
seedsubcommand for testing query results. The chosen approach is to validate against real Solana state, because the only ground truth we trust is what a Solana validator actually sees. There are two supported, end-to-end ways to do this locally:
- API-vs-Agave comparison (response correctness). Start the indexer with
[snapshot]configured, start the API, and run theintegration_testsbenchmark in dual-endpoint mode against a reference Agave RPC node pointed at the same cluster. The harness compensates for slot skew between the two endpoints and reports per-request mismatches, so any divergence between Cloudbreak's responses and a stock Agave node is surfaced directly.- Hash-checker (database-vs-snapshot correctness). Start only the indexer (with
[snapshot]configured) and enable the[hash-checker]section described below. The indexer streams gRPC until a target slot, then re-fetches a covering snapshot pair and lattice-hashes its own DB against the snapshot. A match is a cryptographic guarantee that every indexed account (address, data, lamports, owner, executable) is identical to what the validator wrote.Both paths require a real upstream — a Yellowstone gRPC endpoint and a working cluster-tracker source — and both require
[snapshot]to be configured on the indexer. Running without[snapshot]is for smoke-testing the setup only and cannot be used to assert correctness.
The indexer can run an automated correctness test by adding a [hash-checker] section to cloudbreak.index.toml. When enabled, the indexer runs normally until a target slot is reached, then asks the cluster tracker for the next covering snapshot pair, downloads the full + incremental archives from the source reported by the tracker, replays buffered updates up to that snapshot slot, computes a lattice hash over both the database and the snapshot, and exits with 0 on match or 1 on mismatch.
A lattice hash is calculated with all accounts addresses, data, lamports, owner and executable. If the two hashes match, it's sure that the indexed data is correct.
Requires the [snapshot] section (with [snapshot.tracker_endpoint]) to be present. Set one of time-limit or slot-limit:
[hash-checker]
# Either:
time-limit = "6h"
# Or:
slot-limit = 405775018time-limit: target slot is computed asfirst_grpc_slot + time_limit / 400ms.slot-limit: target slot is used directly.
Just run the indexer normally:
cargo run -p cloudbreak -- --config ./cloudbreak.index.toml indexThe process stays up until the target slot is finalized and the next covering snapshot pair is available from the tracker, then prints the comparison result and exits.
When the hash-check fails, snapshot-diff shows which pubkeys differ between the indexer DB and the snapshot, instead of just a hash mismatch. It scans the snapshot into per-prefix temp files, then compares them against the DB filtered by slot <= target_slot and the configured program selector.
cargo run -p cloudbreak -- --config ./cloudbreak.index.toml snapshot-diff \
--target-slot 416520592 \
--full-snapshot /path/to/snapshot-...tar.zst \
--incremental-snapshot /path/to/incremental-...tar.zst \
--dump-mismatches ./diff.dump
Optional flags: --prefix 0a1b to scan only a single 16-bit prefix, --keep-tmp to retain ./snapshot_diff_tmp/ after exit.
Output ends with four counters:
match: <pubkeys identical in both>
mismatch: <pubkeys present in both but with different state>
only_in_snapshot: <pubkeys in snapshot but missing/older in DB>
only_in_db: <pubkeys in DB but missing in snapshot — typical leak symptom>
When --dump-mismatches is set, every diverging pubkey is appended to that file (uncapped); stdout shows only the first 50 of each category as a sample.
Copyright 2025-2026 Triton One Limited. All rights reserved.
Licensed under the GNU Affero General Public License v3.0 only. See LICENSE.