global: fixes

This commit is contained in:
nym21
2026-05-02 00:42:16 +02:00
parent 6f879a5551
commit 2b8a0a8cf7
99 changed files with 4308 additions and 1525 deletions

View File

@@ -63,9 +63,10 @@ pub fn main() -> Result<()> {
25
));
let _ = dbg!(query.addr_utxos(Addr::from(
"bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38".to_string()
)));
let _ = dbg!(query.addr_utxos(
Addr::from("bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38".to_string()),
1000,
));
// dbg!(query.search_and_format(SeriesSelection {
// index: Index::Height,

View File

@@ -4,16 +4,13 @@ use bitcoin::{Network, PublicKey, ScriptBuf};
use brk_error::{Error, OptionData, Result};
use brk_types::{
Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats,
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Transaction, TxIndex, TxStatus, Txid,
TypeIndex, Unit, Utxo, Vout,
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Timestamp, Transaction, TxIndex, TxStatus,
Txid, TxidPrefix, TypeIndex, Unit, Utxo, Vout,
};
use vecdb::VecIndex;
use crate::Query;
/// Maximum number of mempool txids to return
const MAX_MEMPOOL_TXIDS: usize = 50;
impl Query {
pub fn addr(&self, addr: Addr) -> Result<AddrStats> {
let indexer = self.indexer();
@@ -36,14 +33,12 @@ impl Query {
let Ok(bytes) = AddrBytes::try_from((&script, output_type)) else {
return Err(Error::InvalidAddr);
};
let addr_type = output_type;
let hash = AddrHash::from(&bytes);
let Some(store) = stores.addr_type_to_addr_hash_to_addr_index.get(addr_type) else {
let Some(store) = stores.addr_type_to_addr_hash_to_addr_index.get(output_type) else {
return Err(Error::InvalidAddr);
};
let Ok(Some(type_index)) = store.get(&hash).map(|opt| opt.map(|cow| cow.into_owned()))
else {
let Some(type_index) = store.get(&hash)?.map(|cow| cow.into_owned()) else {
return Err(Error::UnknownAddr);
};
@@ -52,30 +47,32 @@ impl Query {
.any_addr_indexes
.get_once(output_type, type_index)?;
let addr_data = match any_addr_index.to_enum() {
AnyAddrDataIndexEnum::Funded(index) => computer
.distribution
.addrs_data
.funded
.reader()
.get(usize::from(index)),
AnyAddrDataIndexEnum::Empty(index) => computer
.distribution
.addrs_data
.empty
.reader()
.get(usize::from(index))
.into(),
};
let realized_price = match &any_addr_index.to_enum() {
AnyAddrDataIndexEnum::Funded(_) => addr_data.realized_price().to_dollars(),
AnyAddrDataIndexEnum::Empty(_) => Dollars::default(),
let (addr_data, realized_price) = match any_addr_index.to_enum() {
AnyAddrDataIndexEnum::Funded(index) => {
let data = computer
.distribution
.addrs_data
.funded
.reader()
.get(usize::from(index));
let price = data.realized_price().to_dollars();
(data, price)
}
AnyAddrDataIndexEnum::Empty(index) => {
let data = computer
.distribution
.addrs_data
.empty
.reader()
.get(usize::from(index))
.into();
(data, Dollars::default())
}
};
Ok(AddrStats {
addr,
addr_type,
addr_type: output_type,
chain_stats: AddrChainStats {
type_index,
funded_txo_count: addr_data.funded_txo_count,
@@ -85,22 +82,38 @@ impl Query {
tx_count: addr_data.tx_count,
realized_price,
},
mempool_stats: self.mempool().map(|m| {
m.addrs()
.get(&bytes)
.map(|e| e.stats.clone())
.unwrap_or_default()
}),
mempool_stats: self
.mempool()
.and_then(|m| m.addrs().get(&bytes).map(|e| e.stats.clone()))
.unwrap_or_default(),
})
}
/// Esplora `/address/:address/txs` first page: up to `mempool_limit`
/// mempool (newest first) followed by the first `chain_limit`
/// confirmed. Pagination is path-style via `/txs/chain/:after_txid`.
pub fn addr_txs(
&self,
addr: Addr,
mempool_limit: usize,
chain_limit: usize,
) -> Result<Vec<Transaction>> {
let mut out = if self.mempool().is_some() {
self.addr_mempool_txs(&addr, mempool_limit)?
} else {
Vec::new()
};
out.extend(self.addr_txs_chain(&addr, None, chain_limit)?);
Ok(out)
}
pub fn addr_txs_chain(
&self,
addr: &Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<Transaction>> {
let txindices = self.addr_txindices(&addr, after_txid, limit)?;
let txindices = self.addr_txindices(addr, after_txid, limit)?;
self.transactions_by_indices(&txindices)
}
@@ -112,11 +125,10 @@ impl Query {
) -> Result<Vec<Txid>> {
let txindices = self.addr_txindices(&addr, after_txid, limit)?;
let txid_reader = self.indexer().vecs.transactions.txid.reader();
let txids = txindices
Ok(txindices
.into_iter()
.map(|tx_index| txid_reader.get(tx_index.to_usize()))
.collect();
Ok(txids)
.collect())
}
fn addr_txindices(
@@ -125,8 +137,7 @@ impl Query {
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<TxIndex>> {
let indexer = self.indexer();
let stores = &indexer.stores;
let stores = &self.indexer().stores;
let (output_type, type_index) = self.resolve_addr(addr)?;
@@ -137,8 +148,6 @@ impl Query {
if let Some(after_txid) = after_txid {
let after_tx_index = self.resolve_tx_index(&after_txid)?;
// Seek directly to after_tx_index and iterate backward — O(limit)
let min = AddrIndexTxIndex::min_for_addr(type_index);
let bound = AddrIndexTxIndex::from((type_index, after_tx_index));
Ok(store
@@ -148,7 +157,6 @@ impl Query {
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.collect())
} else {
// No pagination — scan from end of prefix
let prefix = u32::from(type_index).to_be_bytes();
Ok(store
.prefix(prefix)
@@ -159,7 +167,7 @@ impl Query {
}
}
pub fn addr_utxos(&self, addr: Addr) -> Result<Vec<Utxo>> {
pub fn addr_utxos(&self, addr: Addr, max_utxos: usize) -> Result<Vec<Utxo>> {
let indexer = self.indexer();
let stores = &indexer.stores;
let vecs = &indexer.vecs;
@@ -173,14 +181,12 @@ impl Query {
let prefix = u32::from(type_index).to_be_bytes();
// Bounds worst-case work and response size, prevents heavy-address DDoS.
const MAX_UTXOS: usize = 1000;
let outpoints: Vec<(TxIndex, Vout)> = store
.prefix(prefix)
.map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout()))
.take(MAX_UTXOS + 1)
.take(max_utxos + 1)
.collect();
if outpoints.len() > MAX_UTXOS {
if outpoints.len() > max_utxos {
return Err(Error::TooManyUtxos);
}
@@ -218,24 +224,38 @@ impl Query {
Ok(utxos)
}
pub fn addr_mempool_hash(&self, addr: &Addr) -> u64 {
let Some(mempool) = self.mempool() else {
return 0;
};
let Ok(bytes) = AddrBytes::from_str(addr) else {
return 0;
};
mempool.addr_state_hash(&bytes)
pub fn addr_mempool_hash(&self, addr: &Addr) -> Option<u64> {
let mempool = self.mempool()?;
let bytes = AddrBytes::from_str(addr).ok()?;
Some(mempool.addr_state_hash(&bytes))
}
pub fn addr_mempool_txids(&self, addr: Addr) -> Result<Vec<Txid>> {
let bytes = AddrBytes::from_str(&addr)?;
pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result<Vec<Transaction>> {
let bytes = AddrBytes::from_str(addr)?;
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
Ok(mempool
.addrs()
.get(&bytes)
.map(|e| e.txids.iter().take(MAX_MEMPOOL_TXIDS).cloned().collect())
.unwrap_or_default())
let addrs = mempool.addrs();
let Some(entry) = addrs.get(&bytes) else {
return Ok(vec![]);
};
let entries = mempool.entries();
let mut ordered: Vec<(Timestamp, &Txid)> = entry
.txids
.iter()
.map(|txid| {
let first_seen = entries
.get(&TxidPrefix::from(txid))
.map(|e| e.first_seen)
.unwrap_or_default();
(first_seen, txid)
})
.collect();
ordered.sort_unstable_by(|a, b| b.0.cmp(&a.0));
let txs = mempool.txs();
Ok(ordered
.into_iter()
.filter_map(|(_, txid)| txs.get(txid).cloned())
.take(limit)
.collect())
}
/// Height of the last on-chain activity for an address (last tx_index → height).
@@ -253,14 +273,9 @@ impl Query {
.next_back()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.ok_or(Error::UnknownAddr)?;
self.computer()
.indexes
.tx_heights
.get_shared(last_tx_index)
.ok_or(Error::UnknownAddr)
self.confirmed_status_height(last_tx_index)
}
/// Resolve an address string to its output type and type_index
fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> {
let stores = &self.indexer().stores;
@@ -268,12 +283,12 @@ impl Query {
let output_type = OutputType::from(&bytes);
let hash = AddrHash::from(&bytes);
let Ok(Some(type_index)) = stores
let Some(type_index) = stores
.addr_type_to_addr_hash_to_addr_index
.get(output_type)
.data()?
.get(&hash)
.map(|opt| opt.map(|cow| cow.into_owned()))
.get(&hash)?
.map(|cow| cow.into_owned())
else {
return Err(Error::UnknownAddr);
};

View File

@@ -94,24 +94,25 @@ impl Query {
Ok(mempool.txs().recent().to_vec())
}
/// CPFP cluster for `txid`. Returns the mempool cluster when the txid is
/// unconfirmed; otherwise reconstructs the confirmed same-block cluster
/// from indexer state. Works even when the mempool feature is off.
pub fn cpfp(&self, txid: &Txid) -> Result<CpfpInfo> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
let prefix = TxidPrefix::from(txid);
Ok(mempool
.cpfp_info(&prefix)
.unwrap_or_else(|| self.confirmed_cpfp(txid)))
let mempool_cluster = self.mempool().and_then(|m| m.cpfp_info(&prefix));
Ok(mempool_cluster.unwrap_or_else(|| self.confirmed_cpfp(txid)))
}
/// CPFP cluster for a confirmed tx: the connected component of
/// same-block parent/child edges, reconstructed by BFS on demand.
/// Walks entirely in `TxIndex` space using direct vec reads (height,
/// weight, fee) - skips full `Transaction` reconstruction and avoids
/// `txid -> tx_index` lookups by reading `OutPoint`'s packed
/// `tx_index` directly. Capped at 25 each side to match Bitcoin
/// Core's default mempool chain limits and mempool.space's own
/// truncation. `effectiveFeePerVsize` is the simple package rate;
/// mempool's `calculateGoodBlockCpfp` chunk-rate algorithm is not
/// ported.
/// same-block parent/child edges, reconstructed by a depth-first
/// walk on demand. Walks entirely in `TxIndex` space using direct
/// vec reads (height, weight, fee) - skips full `Transaction`
/// reconstruction and avoids `txid -> tx_index` lookups by reading
/// `OutPoint`'s packed `tx_index` directly. Capped at 25 each side
/// to match Bitcoin Core's default mempool chain limits and
/// mempool.space's own truncation. `effectiveFeePerVsize` is the
/// simple package rate; mempool's `calculateGoodBlockCpfp`
/// chunk-rate algorithm is not ported.
fn confirmed_cpfp(&self, txid: &Txid) -> CpfpInfo {
const MAX: usize = 25;
let Ok(seed_idx) = self.resolve_tx_index(txid) else {

View File

@@ -1,20 +1,17 @@
use std::{collections::BTreeMap, sync::LazyLock};
use std::sync::LazyLock;
use brk_error::{Error, Result};
use brk_traversable::TreeNode;
use brk_types::{
BlockHashPrefix, CacheClass, Date, DetailedSeriesCount, Epoch, Format, Halving, Height, Index,
IndexInfo, LegacyValue, Limit, Output, OutputLegacy, PaginatedSeries, Pagination,
PaginationIndex, RangeIndex, RangeMap, SearchQuery, SeriesData, SeriesInfo, SeriesName,
SeriesOutput, SeriesOutputLegacy, SeriesSelection, Timestamp, Version,
IndexInfo, LegacyValue, Limit, Output, OutputLegacy, PaginatedSeries, Pagination, RangeIndex,
RangeMap, SearchQuery, SeriesData, SeriesInfo, SeriesName, SeriesOutput, SeriesOutputLegacy,
SeriesSelection, Timestamp, Version,
};
use parking_lot::RwLock;
use vecdb::{AnyExportableVec, ReadableVec};
use crate::{
Query,
vecs::{IndexToVec, SeriesToVec},
};
use crate::Query;
/// Monotonic block timestamps → height. Lazily extended as new blocks are indexed.
static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock<RwLock<RangeMap<Timestamp, Height>>> =
@@ -24,14 +21,17 @@ static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock<RwLock<RangeMap<Timestamp, Height
const CSV_HEADER_BYTES_PER_COL: usize = 10;
/// Estimated bytes per cell value
const CSV_CELL_BYTES: usize = 15;
/// Estimated bytes per JSON cell value
const JSON_CELL_BYTES: usize = 12;
impl Query {
pub fn search_series(&self, query: &SearchQuery) -> Vec<&'static str> {
self.vecs().matches(&query.q, query.limit)
}
/// Returns the error for a missing series: `SeriesUnsupportedIndex` if the name
/// exists at other indexes, else `SeriesNotFound` with fuzzy-match suggestions.
pub fn series_not_found_error(&self, series: &SeriesName) -> Error {
// Check if series exists but with different indexes
if let Some(indexes) = self.vecs().series_to_indexes(series) {
let supported = indexes
.iter()
@@ -44,7 +44,6 @@ impl Query {
};
}
// Series doesn't exist, suggest alternatives
let matches = self
.vecs()
.matches(series, Limit::DEFAULT)
@@ -63,25 +62,8 @@ impl Query {
return Ok(String::new());
}
let from = Some(start as i64);
let to = Some(end as i64);
let num_rows = columns[0].range_count(from, to);
let num_cols = columns.len();
let estimated_size =
num_cols * CSV_HEADER_BYTES_PER_COL + num_rows * num_cols * CSV_CELL_BYTES;
let mut csv = String::with_capacity(estimated_size);
// Single-column fast path: stream directly, no Vec<T> materialization
if num_cols == 1 {
let col = columns[0];
csv.push_str(col.name());
csv.push('\n');
col.write_csv_column(Some(start), Some(end), &mut csv)?;
return Ok(csv);
}
let mut csv = String::with_capacity(num_cols * CSV_HEADER_BYTES_PER_COL);
for (i, col) in columns.iter().enumerate() {
if i > 0 {
csv.push(',');
@@ -90,6 +72,17 @@ impl Query {
}
csv.push('\n');
// Stream a single column without materializing Vec<T>.
if num_cols == 1 {
columns[0].write_csv_column(Some(start), Some(end), &mut csv)?;
return Ok(csv);
}
let from = Some(start as i64);
let to = Some(end as i64);
let num_rows = columns[0].range_count(from, to);
csv.reserve(num_rows * num_cols * CSV_CELL_BYTES);
let mut writers: Vec<_> = columns
.iter()
.map(|col| col.create_writer(from, to))
@@ -108,31 +101,31 @@ impl Query {
Ok(csv)
}
fn get_vec(
&self,
series: &SeriesName,
index: Index,
) -> Result<&'static dyn AnyExportableVec> {
self.vecs()
.get(series, index)
.ok_or_else(|| self.series_not_found_error(series))
}
/// Returns the latest value for a single series as a JSON value.
pub fn latest(&self, series: &SeriesName, index: Index) -> Result<serde_json::Value> {
let vec = self
.vecs()
.get(series, index)
.ok_or_else(|| self.series_not_found_error(series))?;
vec.last_json_value().ok_or(Error::NoData)
self.get_vec(series, index)?
.last_json_value()
.ok_or(Error::NoData)
}
/// Returns the length (total data points) for a single series.
pub fn len(&self, series: &SeriesName, index: Index) -> Result<usize> {
let vec = self
.vecs()
.get(series, index)
.ok_or_else(|| self.series_not_found_error(series))?;
Ok(vec.len())
Ok(self.get_vec(series, index)?.len())
}
/// Returns the version for a single series.
pub fn version(&self, series: &SeriesName, index: Index) -> Result<Version> {
let vec = self
.vecs()
.get(series, index)
.ok_or_else(|| self.series_not_found_error(series))?;
Ok(vec.version())
Ok(self.get_vec(series, index)?.version())
}
/// Search for vecs matching the given series and index.
@@ -141,14 +134,11 @@ impl Query {
if params.series.is_empty() {
return Err(Error::NoSeries);
}
let mut vecs = Vec::with_capacity(params.series.len());
for series in params.series.iter() {
match self.vecs().get(series, params.index) {
Some(vec) => vecs.push(vec),
None => return Err(self.series_not_found_error(series)),
}
}
Ok(vecs)
params
.series
.iter()
.map(|s| self.get_vec(s, params.index))
.collect()
}
/// Calculate total weight of the vecs for the given range.
@@ -165,25 +155,21 @@ impl Query {
let version: Version = vecs.iter().map(|v| v.version()).sum();
let index = params.index;
let resolve_bound = |ri: RangeIndex, fallback: usize| -> Result<usize> {
let i = self.range_index_to_i64(ri, index)?;
Ok(vecs.iter().map(|v| v.i64_to_usize(i)).min().unwrap_or(fallback))
};
let start = match params.start() {
Some(ri) => {
let i = self.range_index_to_i64(ri, index)?;
vecs.iter().map(|v| v.i64_to_usize(i)).min().unwrap_or(0)
}
Some(ri) => resolve_bound(ri, 0)?,
None => 0,
};
let end = match params.end() {
Some(ri) => {
let i = self.range_index_to_i64(ri, index)?;
vecs.iter()
.map(|v| v.i64_to_usize(i))
.min()
.unwrap_or(total)
}
Some(ri) => resolve_bound(ri, total)?,
None => params
.limit()
.map(|l| (start + *l).min(total))
.map(|l| start.saturating_add(*l).min(total))
.unwrap_or(total),
};
@@ -236,33 +222,34 @@ impl Query {
CacheClass::Bucket { margin } => Some(total.saturating_sub(margin)),
CacheClass::Entity => {
let h = Height::from((*tip_height).saturating_sub(6));
let v = &self.indexer().vecs;
let n = match index {
Index::TxIndex => v.transactions.first_tx_index.collect_one(h).map(usize::from),
Index::TxInIndex => v.inputs.first_txin_index.collect_one(h).map(usize::from),
Index::TxOutIndex => v.outputs.first_txout_index.collect_one(h).map(usize::from),
Index::EmptyOutputIndex => v.scripts.empty.first_index.collect_one(h).map(usize::from),
Index::OpReturnIndex => v.scripts.op_return.first_index.collect_one(h).map(usize::from),
Index::P2MSOutputIndex => v.scripts.p2ms.first_index.collect_one(h).map(usize::from),
Index::UnknownOutputIndex => v.scripts.unknown.first_index.collect_one(h).map(usize::from),
Index::P2AAddrIndex => v.addrs.p2a.first_index.collect_one(h).map(usize::from),
Index::P2PK33AddrIndex => v.addrs.p2pk33.first_index.collect_one(h).map(usize::from),
Index::P2PK65AddrIndex => v.addrs.p2pk65.first_index.collect_one(h).map(usize::from),
Index::P2PKHAddrIndex => v.addrs.p2pkh.first_index.collect_one(h).map(usize::from),
Index::P2SHAddrIndex => v.addrs.p2sh.first_index.collect_one(h).map(usize::from),
Index::P2TRAddrIndex => v.addrs.p2tr.first_index.collect_one(h).map(usize::from),
Index::P2WPKHAddrIndex => v.addrs.p2wpkh.first_index.collect_one(h).map(usize::from),
Index::P2WSHAddrIndex => v.addrs.p2wsh.first_index.collect_one(h).map(usize::from),
_ => unreachable!("non-entity index in CacheClass::Entity arm"),
}
.unwrap_or(0)
.min(total);
Some(n)
Some(self.entity_index_at(index, h).unwrap_or(0).min(total))
}
CacheClass::Mutable => None,
}
}
fn entity_index_at(&self, index: Index, h: Height) -> Option<usize> {
let v = &self.indexer().vecs;
match index {
Index::TxIndex => v.transactions.first_tx_index.collect_one(h).map(usize::from),
Index::TxInIndex => v.inputs.first_txin_index.collect_one(h).map(usize::from),
Index::TxOutIndex => v.outputs.first_txout_index.collect_one(h).map(usize::from),
Index::EmptyOutputIndex => v.scripts.empty.first_index.collect_one(h).map(usize::from),
Index::OpReturnIndex => v.scripts.op_return.first_index.collect_one(h).map(usize::from),
Index::P2MSOutputIndex => v.scripts.p2ms.first_index.collect_one(h).map(usize::from),
Index::UnknownOutputIndex => v.scripts.unknown.first_index.collect_one(h).map(usize::from),
Index::P2AAddrIndex => v.addrs.p2a.first_index.collect_one(h).map(usize::from),
Index::P2PK33AddrIndex => v.addrs.p2pk33.first_index.collect_one(h).map(usize::from),
Index::P2PK65AddrIndex => v.addrs.p2pk65.first_index.collect_one(h).map(usize::from),
Index::P2PKHAddrIndex => v.addrs.p2pkh.first_index.collect_one(h).map(usize::from),
Index::P2SHAddrIndex => v.addrs.p2sh.first_index.collect_one(h).map(usize::from),
Index::P2TRAddrIndex => v.addrs.p2tr.first_index.collect_one(h).map(usize::from),
Index::P2WPKHAddrIndex => v.addrs.p2wpkh.first_index.collect_one(h).map(usize::from),
Index::P2WSHAddrIndex => v.addrs.p2wsh.first_index.collect_one(h).map(usize::from),
_ => unreachable!("entity_index_at called for non-Entity Index: {index:?}"),
}
}
/// Format a resolved query (expensive).
/// Call after ETag/cache checks to avoid unnecessary work.
pub fn format(&self, resolved: ResolvedQuery) -> Result<SeriesOutput> {
@@ -281,22 +268,9 @@ impl Query {
Format::CSV => Output::CSV(Self::columns_to_csv(&vecs, start, end)?),
Format::JSON => {
let count = end.saturating_sub(start);
if vecs.len() == 1 {
let mut buf = Vec::with_capacity(count * 12 + 256);
SeriesData::serialize(vecs[0], index, start, end, &mut buf)?;
Output::Json(buf)
} else {
let mut buf = Vec::with_capacity(count * 12 * vecs.len() + 256);
buf.push(b'[');
for (i, vec) in vecs.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
SeriesData::serialize(*vec, index, start, end, &mut buf)?;
}
buf.push(b']');
Output::Json(buf)
}
Output::Json(Self::write_json_array(&vecs, count, 256, |v, buf| {
SeriesData::serialize(v, index, start, end, buf)
})?)
}
};
@@ -309,10 +283,11 @@ impl Query {
})
}
/// Format a resolved query as raw data (just the JSON array, no SeriesData wrapper).
/// Format a resolved query as raw data (just the JSON values, no SeriesData wrapper).
/// Single vec → `[v1,v2,...]`. Multi-vec → `[[v1,v2],[v3,v4],...]`.
/// CSV output is identical to `format` (no wrapper distinction for CSV).
pub fn format_raw(&self, resolved: ResolvedQuery) -> Result<SeriesOutput> {
if resolved.format() == Format::CSV {
if resolved.format == Format::CSV {
return self.format(resolved);
}
@@ -326,8 +301,9 @@ impl Query {
} = resolved;
let count = end.saturating_sub(start);
let mut buf = Vec::with_capacity(count * 12 + 2);
vecs[0].write_json(Some(start), Some(end), &mut buf)?;
let buf = Self::write_json_array(&vecs, count, 2, |v, buf| {
v.write_json(Some(start), Some(end), buf)
})?;
Ok(SeriesOutput {
output: Output::Json(buf),
@@ -338,12 +314,28 @@ impl Query {
})
}
pub fn series_to_index_to_vec(&self) -> &BTreeMap<&str, IndexToVec<'_>> {
&self.vecs().series_to_index_to_vec
}
pub fn index_to_series_to_vec(&self) -> &BTreeMap<Index, SeriesToVec<'_>> {
&self.vecs().index_to_series_to_vec
fn write_json_array(
vecs: &[&dyn AnyExportableVec],
cell_count: usize,
wrapper_overhead: usize,
mut write_one: impl FnMut(&dyn AnyExportableVec, &mut Vec<u8>) -> vecdb::Result<()>,
) -> Result<Vec<u8>> {
let multi = vecs.len() > 1;
let mut buf =
Vec::with_capacity(cell_count * JSON_CELL_BYTES * vecs.len() + wrapper_overhead);
if multi {
buf.push(b'[');
}
for (i, vec) in vecs.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
write_one(*vec, &mut buf)?;
}
if multi {
buf.push(b']');
}
Ok(buf)
}
pub fn series_count(&self) -> DetailedSeriesCount {
@@ -365,25 +357,8 @@ impl Query {
self.vecs().catalog()
}
pub fn index_to_vecids(&self, paginated_index: PaginationIndex) -> Option<&[&str]> {
self.vecs().index_to_ids(paginated_index)
}
pub fn series_info(&self, series: &SeriesName) -> Option<SeriesInfo> {
let index_to_vec = self
.vecs()
.series_to_index_to_vec
.get(series.replace("-", "_").as_str())?;
let value_type = index_to_vec.values().next()?.value_type_to_string();
let indexes = index_to_vec.keys().copied().collect();
Some(SeriesInfo {
indexes,
value_type: value_type.into(),
})
}
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
self.vecs().series_to_indexes(series)
self.vecs().series_info(series)
}
/// Resolve a RangeIndex to an i64 offset for the given index type.
@@ -396,20 +371,16 @@ impl Query {
}
fn date_to_i64(&self, date: Date, index: Index) -> Result<i64> {
// Direct date-based index conversion (day1, week1, month1, etc.)
if let Some(idx) = index.date_to_index(date) {
return Ok(idx as i64);
}
// Fall through to timestamp-based resolution (height, epoch, halving)
self.timestamp_to_i64(Timestamp::from(date), index)
}
fn timestamp_to_i64(&self, ts: Timestamp, index: Index) -> Result<i64> {
// Direct timestamp-based index conversion (minute10, hour1, etc.)
if let Some(idx) = index.timestamp_to_index(ts) {
return Ok(idx as i64);
}
// Height-based indexes: find block height, then convert
let height = Height::from(self.height_for_timestamp(ts));
match index {
Index::Height => Ok(usize::from(height) as i64),
@@ -425,21 +396,22 @@ impl Query {
/// O(log n) binary search. Lazily rebuilt as new blocks arrive.
fn height_for_timestamp(&self, ts: Timestamp) -> usize {
let current_height: usize = self.height().into();
let lookup = |map: &RangeMap<Timestamp, Height>| {
map.ceil(ts).map(usize::from).unwrap_or(current_height)
};
// Fast path: read lock, ceil is &self
{
let map = HEIGHT_BY_MONOTONIC_TIMESTAMP.read();
if map.len() > current_height {
return map.ceil(ts).map(usize::from).unwrap_or(current_height);
return lookup(&map);
}
}
// Slow path: rebuild from computer's precomputed monotonic timestamps
let mut map = HEIGHT_BY_MONOTONIC_TIMESTAMP.write();
if map.len() <= current_height {
*map = RangeMap::from(self.computer().indexes.timestamp.monotonic.collect());
}
map.ceil(ts).map(usize::from).unwrap_or(current_height)
lookup(&map)
}
/// Deprecated - format a resolved query as legacy output (expensive).
@@ -520,10 +492,6 @@ pub struct ResolvedQuery {
}
impl ResolvedQuery {
pub fn format(&self) -> Format {
self.format
}
pub fn csv_filename(&self) -> String {
let names: Vec<_> = self.vecs.iter().map(|v| v.name()).collect();
format!("{}-{}.csv", names.join("_"), self.index)

View File

@@ -1,4 +1,7 @@
use bitcoin::hex::DisplayHex;
use bitcoin::{
hashes::{Hash, sha256d},
hex::DisplayHex,
};
use brk_error::{Error, OptionData, Result};
use brk_types::{
BlockHash, Height, MerkleProof, Timestamp, Transaction, TxInIndex, TxIndex, TxOutIndex,
@@ -17,17 +20,12 @@ impl Query {
self.indexer()
.stores
.txid_prefix_to_tx_index
.get(&TxidPrefix::from(txid))
.map_err(|_| Error::UnknownTxid)?
.get(&TxidPrefix::from(txid))?
.map(|cow| cow.into_owned())
.ok_or(Error::UnknownTxid)
}
pub fn txid_by_index(&self, index: TxIndex) -> Result<Txid> {
let len = self.indexer().vecs.transactions.txid.len();
if index.to_usize() >= len {
return Err(Error::OutOfRange("Transaction index out of range".into()));
}
self.indexer()
.vecs
.transactions
@@ -55,23 +53,11 @@ impl Query {
.data()
}
/// Full confirmed TxStatus from a tx_index.
#[inline]
pub(crate) fn confirmed_status(&self, tx_index: TxIndex) -> Result<TxStatus> {
let height = self.confirmed_status_height(tx_index)?;
self.confirmed_status_at(height)
}
/// Full confirmed TxStatus from a known height.
#[inline]
pub(crate) fn confirmed_status_at(&self, height: Height) -> Result<TxStatus> {
let (block_hash, block_time) = self.block_hash_and_time(height)?;
Ok(TxStatus {
confirmed: true,
block_height: Some(height),
block_hash: Some(block_hash),
block_time: Some(block_time),
})
Ok(TxStatus::confirmed(height, block_hash, block_time))
}
/// Block hash + timestamp for a height (cached vecs, fast).
@@ -85,11 +71,15 @@ impl Query {
// ── Transaction queries ────────────────────────────────────────
/// Map a mempool transaction by txid through `f`, returning `None`
/// if no mempool is attached or the txid is not in mempool.
fn map_mempool_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
self.mempool()?.txs().get(txid).map(f)
}
pub fn transaction(&self, txid: &Txid) -> Result<Transaction> {
if let Some(mempool) = self.mempool()
&& let Some(tx) = mempool.txs().get(txid)
{
return Ok(tx.clone());
if let Some(tx) = self.map_mempool_tx(txid, Transaction::clone) {
return Ok(tx);
}
self.transaction_by_index(self.resolve_tx_index(txid)?)
}
@@ -98,23 +88,20 @@ impl Query {
if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) {
return Ok(TxStatus::UNCONFIRMED);
}
self.confirmed_status(self.resolve_tx_index(txid)?)
let (_, height) = self.resolve_tx(txid)?;
self.confirmed_status_at(height)
}
pub fn transaction_raw(&self, txid: &Txid) -> Result<Vec<u8>> {
if let Some(mempool) = self.mempool()
&& let Some(tx) = mempool.txs().get(txid)
{
return Ok(tx.encode_bytes());
if let Some(bytes) = self.map_mempool_tx(txid, Transaction::encode_bytes) {
return Ok(bytes);
}
self.transaction_raw_by_index(self.resolve_tx_index(txid)?)
}
pub fn transaction_hex(&self, txid: &Txid) -> Result<String> {
if let Some(mempool) = self.mempool()
&& let Some(tx) = mempool.txs().get(txid)
{
return Ok(tx.encode_bytes().to_lower_hex_string());
if let Some(hex) = self.map_mempool_tx(txid, |tx| tx.encode_bytes().to_lower_hex_string()) {
return Ok(hex);
}
self.transaction_hex_by_index(self.resolve_tx_index(txid)?)
}
@@ -123,23 +110,49 @@ impl Query {
pub fn outspend(&self, txid: &Txid, vout: Vout) -> Result<TxOutspend> {
if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) {
return Ok(TxOutspend::UNSPENT);
return Ok(self.mempool_outspend(txid, vout));
}
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
if usize::from(vout) >= output_count {
return Ok(TxOutspend::UNSPENT);
}
self.resolve_outspend(first_txout + vout)
let confirmed = self.resolve_outspend(first_txout + vout)?;
if confirmed.spent {
return Ok(confirmed);
}
Ok(self.mempool_outspend(txid, vout))
}
pub fn outspends(&self, txid: &Txid) -> Result<Vec<TxOutspend>> {
if let Some(mempool) = self.mempool()
&& let Some(tx) = mempool.txs().get(txid)
&& let Some(output_count) = mempool.txs().get(txid).map(|tx| tx.output.len())
{
return Ok(vec![TxOutspend::UNSPENT; tx.output.len()]);
return Ok((0..output_count)
.map(|i| self.mempool_outspend(txid, Vout::from(i)))
.collect());
}
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
self.resolve_outspends(first_txout, output_count)
let mut spends = self.resolve_outspends(first_txout, output_count)?;
for (i, spend) in spends.iter_mut().enumerate() {
if !spend.spent {
*spend = self.mempool_outspend(txid, Vout::from(i));
}
}
Ok(spends)
}
fn mempool_outspend(&self, txid: &Txid, vout: Vout) -> TxOutspend {
let Some((spender_txid, vin)) =
self.mempool().and_then(|m| m.lookup_spender(txid, vout))
else {
return TxOutspend::UNSPENT;
};
TxOutspend {
spent: true,
txid: Some(spender_txid),
vin: Some(vin),
status: Some(TxStatus::UNCONFIRMED),
}
}
/// Resolve spend status for a single output. Minimal reads.
@@ -204,12 +217,7 @@ impl Query {
spent: true,
txid: Some(spending_txid),
vin: Some(vin),
status: Some(TxStatus {
confirmed: true,
block_height: Some(spending_height),
block_hash: Some(block_hash),
block_time: Some(block_time),
}),
status: Some(TxStatus::confirmed(spending_height, block_hash, block_time)),
});
}
@@ -223,7 +231,7 @@ impl Query {
.vecs
.inputs
.tx_index
.collect_one_at(usize::from(txin_index))
.collect_one(txin_index)
.data()?;
let spending_first_txin: TxInIndex = indexer
.vecs
@@ -236,8 +244,8 @@ impl Query {
.vecs
.transactions
.txid
.reader()
.get(spending_tx_index.to_usize());
.collect_one(spending_tx_index)
.data()?;
let spending_height = self.confirmed_status_height(spending_tx_index)?;
let (block_hash, block_time) = self.block_hash_and_time(spending_height)?;
@@ -245,12 +253,7 @@ impl Query {
spent: true,
txid: Some(spending_txid),
vin: Some(vin),
status: Some(TxStatus {
confirmed: true,
block_height: Some(spending_height),
block_hash: Some(block_hash),
block_time: Some(block_time),
}),
status: Some(TxStatus::confirmed(spending_height, block_hash, block_time)),
})
}
@@ -258,26 +261,25 @@ impl Query {
fn resolve_tx_outputs(&self, txid: &Txid) -> Result<(TxIndex, TxOutIndex, usize)> {
let tx_index = self.resolve_tx_index(txid)?;
let indexer = self.indexer();
let first = indexer
.vecs
.transactions
.first_txout_index
.read_once(tx_index)?;
let next = indexer
.vecs
.transactions
.first_txout_index
.read_once(tx_index.incremented())?;
let first_txout_vec = &indexer.vecs.transactions.first_txout_index;
let first = first_txout_vec.read_once(tx_index)?;
let next_tx = tx_index.incremented();
let next = if next_tx.to_usize() < first_txout_vec.len() {
first_txout_vec.read_once(next_tx)?
} else {
TxOutIndex::from(indexer.vecs.outputs.value.len())
};
Ok((tx_index, first, usize::from(next) - usize::from(first)))
}
// === Helper methods ===
pub fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> {
self.transactions_by_indices(&[tx_index])?
fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> {
Ok(self
.transactions_by_indices(&[tx_index])?
.into_iter()
.next()
.ok_or(Error::NotFound("Transaction not found".into()))
.expect("transactions_by_indices returns one tx per input index"))
}
fn transaction_raw_by_index(&self, tx_index: TxIndex) -> Result<Vec<u8>> {
@@ -328,7 +330,7 @@ impl Query {
.transactions
.first_tx_index
.collect_one(height)
.ok_or(Error::NotFound("Block not found".into()))?;
.data()?;
let pos = tx_index.to_usize() - first_tx.to_usize();
let txids = self.block_txids_by_height(height)?;
@@ -341,12 +343,10 @@ impl Query {
}
fn merkle_path(txids: &[Txid], pos: usize) -> Vec<String> {
use bitcoin::hashes::{Hash, sha256d};
// Txid bytes are in internal order (same layout as bitcoin::Txid)
let mut hashes: Vec<[u8; 32]> = txids
.iter()
.map(|t| bitcoin::Txid::from(t).to_byte_array())
.map(|t| <&bitcoin::Txid>::from(t).to_byte_array())
.collect();
let mut proof = Vec::new();
@@ -357,7 +357,7 @@ fn merkle_path(txids: &[Txid], pos: usize) -> Vec<String> {
// Display order: reverse bytes for hex output
let mut display = hashes[sibling];
display.reverse();
proof.push(bitcoin::hex::DisplayHex::to_lower_hex_string(&display));
proof.push(display.to_lower_hex_string());
hashes = hashes
.chunks(2)

View File

@@ -14,20 +14,19 @@ impl Query {
let mut cohorts: Vec<Cohort> = fs::read_dir(states_path)?
.filter_map(|entry| {
let name = entry.ok()?.file_name().into_string().ok()?;
states_path
.join(&name)
.join("urpd")
.exists()
.then(|| Cohort::from(name))
if !states_path.join(&name).join("urpd").exists() {
return None;
}
Cohort::new(name)
})
.collect();
cohorts.sort_by_key(|a| a.to_string());
cohorts.sort_unstable();
Ok(cohorts)
}
pub(crate) fn urpd_dir(&self, cohort: &str) -> Result<PathBuf> {
pub(crate) fn urpd_dir(&self, cohort: &Cohort) -> Result<PathBuf> {
let dir = self
.computer()
.distribution
@@ -59,7 +58,7 @@ impl Query {
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
.collect();
dates.sort();
dates.sort_unstable();
Ok(dates)
}
@@ -79,7 +78,7 @@ impl Query {
/// URPD for a cohort on a specific date.
pub fn urpd_at(&self, cohort: &Cohort, date: Date, agg: UrpdAggregation) -> Result<Urpd> {
let raw = self.urpd_raw(cohort, date)?;
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
let day1 = Day1::try_from(date)?;
let close = self
.computer()
.prices

View File

@@ -4,13 +4,13 @@ use brk_computer::Computer;
use brk_indexer::Indexer;
use brk_traversable::{Traversable, TreeNode};
use brk_types::{
Index, IndexInfo, Limit, PaginatedSeries, Pagination, PaginationIndex, SeriesCount, SeriesName,
Index, IndexInfo, Limit, PaginatedSeries, Pagination, SeriesCount, SeriesInfo, SeriesName,
};
use derive_more::{Deref, DerefMut};
use quickmatch::{QuickMatch, QuickMatchConfig};
use rustc_hash::{FxHashMap, FxHashSet};
use vecdb::{AnyExportableVec, Ro};
#[derive(Default)]
pub struct Vecs<'a> {
pub series_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>,
pub index_to_series_to_vec: BTreeMap<Index, SeriesToVec<'a>>,
@@ -18,10 +18,9 @@ pub struct Vecs<'a> {
pub indexes: Vec<IndexInfo>,
pub counts: SeriesCount,
pub counts_by_db: BTreeMap<String, SeriesCount>,
catalog: Option<TreeNode>,
matcher: Option<QuickMatch<'a>>,
catalog: TreeNode,
matcher: QuickMatch<'a>,
series_to_indexes: BTreeMap<&'a str, Vec<Index>>,
index_to_series: BTreeMap<Index, Vec<&'a str>>,
}
impl<'a> Vecs<'a> {
@@ -49,39 +48,26 @@ impl<'a> Vecs<'a> {
computed_vecs: impl Iterator<Item = (&'static str, &'a dyn AnyExportableVec)>,
computed_tree: TreeNode,
) -> Self {
let mut this = Vecs::default();
indexed_vecs.for_each(|vec| this.insert(vec, "indexed"));
computed_vecs.for_each(|(db, vec)| this.insert(vec, db));
let mut ids = this
.series_to_index_to_vec
.keys()
.copied()
.collect::<Vec<_>>();
let mut builder = Builder::default();
indexed_vecs.for_each(|vec| builder.insert(vec, "indexed"));
computed_vecs.for_each(|(db, vec)| builder.insert(vec, db));
builder.counts.distinct_series = builder.series_to_index_to_vec.len();
let Builder {
series_to_index_to_vec,
index_to_series_to_vec,
counts,
counts_by_db,
..
} = builder;
let sort_ids = |ids: &mut Vec<&str>| {
ids.sort_unstable_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)))
};
sort_ids(&mut ids);
let mut series = series_to_index_to_vec.keys().copied().collect::<Vec<_>>();
sort_ids(&mut series);
this.series = ids;
this.counts.distinct_series = this.series_to_index_to_vec.len();
this.counts.total_endpoints = this
.index_to_series_to_vec
.values()
.map(|tree| tree.len())
.sum::<usize>();
this.counts.lazy_endpoints = this
.index_to_series_to_vec
.values()
.flat_map(|tree| tree.values())
.filter(|vec| vec.region_names().is_empty())
.count();
this.counts.stored_endpoints = this.counts.total_endpoints - this.counts.lazy_endpoints;
this.indexes = this
.index_to_series_to_vec
let indexes = index_to_series_to_vec
.keys()
.map(|i| IndexInfo {
index: *i,
@@ -93,60 +79,35 @@ impl<'a> Vecs<'a> {
})
.collect();
this.series_to_indexes = this
.series_to_index_to_vec
let series_to_indexes = series_to_index_to_vec
.iter()
.map(|(id, index_to_vec)| (*id, index_to_vec.keys().copied().collect::<Vec<_>>()))
.collect();
this.index_to_series = this
.index_to_series_to_vec
.iter()
.map(|(index, id_to_vec)| (*index, id_to_vec.keys().copied().collect::<Vec<_>>()))
.collect();
this.index_to_series.values_mut().for_each(sort_ids);
this.catalog.replace(
TreeNode::Branch(
[
("indexed".to_string(), indexed_tree),
("computed".to_string(), computed_tree),
]
.into_iter()
.collect(),
)
.merge_branches()
.expect("indexed/computed catalog merge: same series leaf with incompatible schemas"),
);
this.matcher = Some(QuickMatch::new(&this.series));
this
}
let catalog = TreeNode::Branch(
[
("indexed".to_string(), indexed_tree),
("computed".to_string(), computed_tree),
]
.into_iter()
.collect(),
)
.merge_branches()
.expect("indexed/computed catalog merge: same series leaf with incompatible schemas");
fn insert(&mut self, vec: &'a dyn AnyExportableVec, db: &str) {
let name = vec.name();
let serialized_index = vec.index_type_to_string();
let index = Index::try_from(serialized_index)
.unwrap_or_else(|_| panic!("Unknown index type: {serialized_index}"));
let matcher = QuickMatch::new(&series);
let prev = self
.series_to_index_to_vec
.entry(name)
.or_default()
.insert(index, vec);
assert!(
prev.is_none(),
"Duplicate series: {name} for index {index:?}"
);
self.index_to_series_to_vec
.entry(index)
.or_default()
.insert(name, vec);
let is_lazy = vec.region_names().is_empty();
self.counts_by_db
.entry(db.to_string())
.or_default()
.add_endpoint(name, is_lazy);
Self {
series_to_index_to_vec,
index_to_series_to_vec,
series,
indexes,
counts,
counts_by_db,
catalog,
matcher,
series_to_indexes,
}
}
pub fn series(&'static self, pagination: Pagination) -> PaginatedSeries {
@@ -170,25 +131,21 @@ impl<'a> Vecs<'a> {
}
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
self.series_to_indexes
.get(series.replace("-", "_").as_str())
self.series_to_indexes.get(series.normalize().as_ref())
}
pub fn index_to_ids(
&self,
PaginationIndex { index, pagination }: PaginationIndex,
) -> Option<&[&'a str]> {
let vec = self.index_to_series.get(&index)?;
let len = vec.len();
let start = pagination.start(len);
let end = pagination.end(len);
Some(&vec[start..end])
pub fn series_info(&self, series: &SeriesName) -> Option<SeriesInfo> {
let index_to_vec = self.series_to_index_to_vec.get(series.normalize().as_ref())?;
let value_type = index_to_vec.values().next()?.value_type_to_string();
let indexes = index_to_vec.keys().copied().collect();
Some(SeriesInfo {
indexes,
value_type: value_type.into(),
})
}
pub fn catalog(&self) -> &TreeNode {
self.catalog.as_ref().expect("catalog not initialized")
&self.catalog
}
pub fn matches(&self, series: &SeriesName, limit: Limit) -> Vec<&'_ str> {
@@ -196,16 +153,13 @@ impl<'a> Vecs<'a> {
return Vec::new();
}
self.matcher
.as_ref()
.expect("matcher not initialized")
.matches_with(series, &QuickMatchConfig::new().with_limit(*limit))
}
/// Look up a vec by series name and index
/// Look up a vec by series name and index. `series` is normalized (`-` → `_`, lowercased).
pub fn get(&self, series: &SeriesName, index: Index) -> Option<&'a dyn AnyExportableVec> {
let series_name = series.replace("-", "_");
self.series_to_index_to_vec
.get(series_name.as_str())
.get(series.normalize().as_ref())
.and_then(|index_to_vec| index_to_vec.get(&index).copied())
}
}
@@ -215,3 +169,48 @@ pub struct IndexToVec<'a>(BTreeMap<Index, &'a dyn AnyExportableVec>);
#[derive(Default, Deref, DerefMut)]
pub struct SeriesToVec<'a>(BTreeMap<&'a str, &'a dyn AnyExportableVec>);
#[derive(Default)]
struct Builder<'a> {
series_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>,
index_to_series_to_vec: BTreeMap<Index, SeriesToVec<'a>>,
counts: SeriesCount,
counts_by_db: BTreeMap<String, SeriesCount>,
seen_by_db: FxHashMap<&'a str, FxHashSet<&'a str>>,
}
impl<'a> Builder<'a> {
fn insert(&mut self, vec: &'a dyn AnyExportableVec, db: &'a str) {
let name = vec.name();
let serialized_index = vec.index_type_to_string();
let index = Index::try_from(serialized_index)
.unwrap_or_else(|_| panic!("Unknown index type: {serialized_index}"));
let prev = self
.series_to_index_to_vec
.entry(name)
.or_default()
.insert(index, vec);
assert!(prev.is_none(), "Duplicate series: {name} for index {index:?}");
self.index_to_series_to_vec
.entry(index)
.or_default()
.insert(name, vec);
let is_lazy = vec.region_names().is_empty();
let by_db = self.counts_by_db.entry(db.to_string()).or_default();
self.counts.total_endpoints += 1;
by_db.total_endpoints += 1;
if is_lazy {
self.counts.lazy_endpoints += 1;
by_db.lazy_endpoints += 1;
} else {
self.counts.stored_endpoints += 1;
by_db.stored_endpoints += 1;
}
if self.seen_by_db.entry(db).or_default().insert(name) {
by_db.distinct_series += 1;
}
}
}