global: big snapshot

This commit is contained in:
nym21
2026-04-26 23:12:17 +02:00
parent 2210443e37
commit 7a0b4b5890
125 changed files with 3833 additions and 3129 deletions

View File

@@ -23,7 +23,7 @@ brk_indexer = { workspace = true }
brk_logger = { workspace = true }
brk_query = { workspace = true }
brk_reader = { workspace = true }
brk_rpc = { workspace = true, features = ["corepc"] }
brk_rpc = { workspace = true }
brk_types = { workspace = true }
brk_traversable = { workspace = true }
brk_website = { workspace = true }

View File

@@ -6,16 +6,22 @@ HTTP API server for Bitcoin on-chain analytics.
- **OpenAPI spec**: Auto-generated docs at `/api` with full spec at `/openapi.json`
- **LLM-optimized**: Compact spec at `/api.json` for AI tools
- **Response caching**: ETag-based with LRU cache (5000 entries)
- **Response caching**: ETag-based with LRU cache (1000 entries by default, configurable via `ServerConfig::cache_size`)
- **Compression**: Brotli, gzip, deflate, zstd
- **Static files**: Optional web interface hosting
## Usage
```rust,ignore
let server = Server::new(&async_query, data_path, Website::Filesystem(files_path));
// Or Website::Default, or Website::Disabled
server.serve().await?;
let server = Server::new(
&async_query,
ServerConfig {
data_path,
website: Website::Filesystem(files_path),
..Default::default()
},
);
server.serve(None).await?;
```
## Endpoints
@@ -35,10 +41,19 @@ server.serve().await?;
## Caching
Uses ETag-based caching with `must-revalidate`:
- **Height-indexed**: Invalidates when new block arrives
- **Immutable**: 1-year cache for deeply-confirmed blocks/txs (6+ confirmations)
- **Mempool**: Short max-age, no ETag
ETag-based revalidation. Five strategies pick the etag scheme:
- **Tip**: chain-state, etag = tip hash prefix (invalidates per block + reorgs)
- **Immutable**: deeply-confirmed data, etag = format version
- **BlockBound**: data tied to a specific block hash (reorg-safe)
- **Deploy**: catalog/static data, etag = build version
- **MempoolHash**: mempool data, etag = projected next-block hash
Browser sees `Cache-Control: public, no-cache, stale-if-error=86400` (always
revalidate, ETag makes it cheap). CDN sees a separate `CDN-Cache-Control`
directive whose stable tier is selected by `CdnCacheMode` (`Live` revalidates
every request; `Aggressive` caches up to a year as `immutable` and requires a
purge on deploy).
## Configuration

View File

@@ -7,7 +7,7 @@ use brk_mempool::Mempool;
use brk_query::AsyncQuery;
use brk_reader::Reader;
use brk_rpc::{Auth, Client};
use brk_server::{Server, Website};
use brk_server::{Server, ServerConfig, Website};
use tracing::info;
use vecdb::Exit;
@@ -43,7 +43,14 @@ pub fn main() -> Result<()> {
// Option 1: block_on to run and properly propagate errors
runtime.block_on(async move {
let server = Server::new(&query, outputs_dir, Website::Disabled);
let server = Server::new(
&query,
ServerConfig {
data_path: outputs_dir,
website: Website::Disabled,
..Default::default()
},
);
let handle = tokio::spawn(async move { server.serve(None).await });

View File

@@ -142,7 +142,7 @@ impl AddrRoutes for ApiRouter<AppState> {
Path(path): Path<ValidateAddrParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |_q| Ok(AddrValidation::from_addr(&path.addr))).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, move |_q| Ok(AddrValidation::from_addr(&path.addr))).await
}, |op| op
.id("validate_address")
.addrs_tag()

View File

@@ -31,8 +31,8 @@ impl MiningRoutes for ApiRouter<AppState> {
"/api/v1/mining/pools",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
// Pool list is static, only changes on code update
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.all_pools())).await
// Pool list is compiled-in, only changes on deploy
state.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| Ok(q.all_pools())).await
},
|op| {
op.id("get_pools")

View File

@@ -6,12 +6,13 @@ use axum::{
extract::{Path, State},
http::{HeaderMap, Uri},
};
use brk_types::{CpfpInfo, MerkleProof, Transaction, TxOutspend, TxStatus, Txid, Version};
use brk_types::{
CpfpInfo, MerkleProof, RbfResponse, Transaction, TxOutspend, TxStatus, Txid, Version,
};
use crate::{
AppState, CacheStrategy,
cache::CacheParams,
extended::{ResponseExtended, TransformResponseExtended},
extended::TransformResponseExtended,
params::{TxIndexParam, TxidParam, TxidVout, TxidsParam},
};
@@ -58,6 +59,24 @@ impl TxRoutes for ApiRouter<AppState> {
.server_error(),
),
)
.api_route(
"/api/v1/tx/{txid}/rbf",
get_with(
async |uri: Uri, headers: HeaderMap, Path(param): Path<TxidParam>, State(state): State<AppState>| {
state.cached_json(&headers, state.mempool_cache(), &uri, move |q| q.tx_rbf(&param.txid)).await
},
|op| op
.id("get_tx_rbf")
.transactions_tag()
.summary("RBF replacement history")
.description("Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)*")
.json_response::<RbfResponse>()
.not_modified()
.bad_request()
.not_found()
.server_error(),
),
)
.api_route(
"/api/tx/{txid}",
get_with(
@@ -154,15 +173,15 @@ impl TxRoutes for ApiRouter<AppState> {
State(state): State<AppState>
| {
let v = Version::ONE;
let immutable = CacheParams::immutable(v);
if immutable.matches_etag(&headers) {
return ResponseExtended::new_not_modified_with(&immutable);
}
let outspend = state.run(move |q| q.outspend(&path.txid, path.vout)).await;
let height = state.sync(|q| q.height());
let is_deep = outspend.as_ref().is_ok_and(|o| o.is_deeply_spent(height));
let strategy = if is_deep { CacheStrategy::Immutable(v) } else { CacheStrategy::Tip };
state.cached_json(&headers, strategy, &uri, move |_| outspend).await
state.cached_json_optimistic(&headers, CacheStrategy::Immutable(v), &uri, move |q| {
let outspend = q.outspend(&path.txid, path.vout)?;
let strategy = if outspend.is_deeply_spent(q.height()) {
CacheStrategy::Immutable(v)
} else {
CacheStrategy::Tip
};
Ok((outspend, strategy))
}).await
},
|op| op
.id("get_tx_outspend")
@@ -188,15 +207,13 @@ impl TxRoutes for ApiRouter<AppState> {
State(state): State<AppState>
| {
let v = Version::ONE;
let immutable = CacheParams::immutable(v);
if immutable.matches_etag(&headers) {
return ResponseExtended::new_not_modified_with(&immutable);
}
let outspends = state.run(move |q| q.outspends(&param.txid)).await;
let height = state.sync(|q| q.height());
let all_deep = outspends.as_ref().is_ok_and(|os| os.iter().all(|o| o.is_deeply_spent(height)));
let strategy = if all_deep { CacheStrategy::Immutable(v) } else { CacheStrategy::Tip };
state.cached_json(&headers, strategy, &uri, move |_| outspends).await
state.cached_json_optimistic(&headers, CacheStrategy::Immutable(v), &uri, move |q| {
let outspends = q.outspends(&param.txid)?;
let height = q.height();
let all_deep = outspends.iter().all(|o| o.is_deeply_spent(height));
let strategy = if all_deep { CacheStrategy::Immutable(v) } else { CacheStrategy::Tip };
Ok((outspends, strategy))
}).await
},
|op| op
.id("get_tx_outspends")

View File

@@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize};
use crate::{CacheStrategy, Error, extended::TransformResponseExtended};
use super::AppState;
use super::series::legacy;
use super::series_legacy;
/// Legacy path parameter for `/api/metric/{metric}`
#[derive(Deserialize, JsonSchema)]
@@ -47,7 +47,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
"/api/metrics",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.series_catalog().clone())).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| Ok(q.series_catalog().clone())).await
},
|op| op
.id("get_metrics_tree_deprecated")
@@ -70,7 +70,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.series_count())).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| Ok(q.series_count())).await
},
|op| op
.id("get_metrics_count_deprecated")
@@ -93,7 +93,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.indexes().to_vec())).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| Ok(q.indexes().to_vec())).await
},
|op| op
.id("get_indexes_deprecated")
@@ -117,7 +117,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Query(pagination): Query<Pagination>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.series_list(pagination))).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, move |q| Ok(q.series_list(pagination))).await
},
|op| op
.id("list_metrics_deprecated")
@@ -141,7 +141,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Query(query): Query<SearchQuery>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.search_series(&query))).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, move |q| Ok(q.search_series(&query))).await
},
|op| op
.id("search_metrics_deprecated")
@@ -161,7 +161,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
"/api/metrics/bulk",
get_with(
|uri: Uri, headers: HeaderMap, addr: Extension<SocketAddr>, query: Query<SeriesSelection>, state: State<AppState>| async move {
legacy::handler(uri, headers, addr, query, state)
series_legacy::handler(uri, headers, addr, query, state)
.await
.into_response()
},
@@ -189,7 +189,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Path(path): Path<LegacySeriesParam>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
state.cached_json(&headers, CacheStrategy::Deploy, &uri, move |q| {
q.series_info(&path.metric).ok_or_else(|| q.series_not_found_error(&path.metric))
}).await
},
@@ -219,7 +219,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
Query(range): Query<DataRangeFormat>|
-> Response {
let params = SeriesSelection::from((path.index, path.metric, range));
legacy::handler(uri, headers, addr, Query(params), state)
series_legacy::handler(uri, headers, addr, Query(params), state)
.await
.into_response()
},
@@ -249,7 +249,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
Query(range): Query<DataRangeFormat>|
-> Response {
let params = SeriesSelection::from((path.index, path.metric, range));
legacy::handler(uri, headers, addr, Query(params), state)
series_legacy::handler(uri, headers, addr, Query(params), state)
.await
.into_response()
},
@@ -373,7 +373,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
SeriesList::from(split.collect::<Vec<_>>().join(separator)),
range,
));
legacy::handler(uri, headers, addr, Query(params), state)
series_legacy::handler(uri, headers, addr, Query(params), state)
.await
.into_response()
},
@@ -401,7 +401,7 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
state: State<AppState>|
-> Response {
let params: SeriesSelection = params.into();
legacy::handler(uri, headers, addr, Query(params), state)
series_legacy::handler(uri, headers, addr, Query(params), state)
.await
.into_response()
},

View File

@@ -15,7 +15,8 @@ use crate::{
Error,
api::{
mempool_space::MempoolSpaceRoutes, metrics::ApiMetricsLegacyRoutes,
series::ApiSeriesRoutes, server::ServerRoutes, urpd::ApiUrpdRoutes,
series::ApiSeriesRoutes, series_legacy::ApiSeriesLegacyRoutes, server::ServerRoutes,
urpd::ApiUrpdRoutes,
},
extended::{ResponseExtended, TransformResponseExtended},
};
@@ -26,6 +27,7 @@ mod mempool_space;
mod metrics;
mod openapi;
mod series;
mod series_legacy;
mod server;
mod urpd;
@@ -39,6 +41,7 @@ impl ApiRoutes for ApiRouter<AppState> {
fn add_api_routes(self) -> Self {
self.add_server_routes()
.add_series_routes()
.add_series_legacy_routes()
.add_urpd_routes()
.add_metrics_legacy_routes()
.add_mempool_space_routes()

View File

@@ -1,46 +1,108 @@
//! Live `/api/series/*` API: catalog, search, info, single-series, bulk.
//!
//! Holds the shared `serve` helper used by every series endpoint that returns
//! a formatted body (single + raw + bulk + the legacy module's deprecated
//! handler in `series_legacy.rs`).
use std::net::SocketAddr;
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
Extension,
body::Bytes,
extract::{Path, Query, State},
http::{HeaderMap, Uri},
response::{IntoResponse, Response},
};
use brk_error::Result as BrkResult;
use brk_query::{Query as BrkQuery, ResolvedQuery};
use brk_traversable::TreeNode;
use brk_types::{
DataRangeFormat, IndexInfo, PaginatedSeries, Pagination, SearchQuery, SeriesCount, SeriesData,
SeriesInfo, SeriesNameWithIndex, SeriesSelection,
DataRangeFormat, Format, IndexInfo, Output, PaginatedSeries, Pagination, SearchQuery,
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection,
};
use crate::{
CacheStrategy,
cache::CACHE_CONTROL,
extended::TransformResponseExtended,
AppState, CacheParams, CacheStrategy, Result,
extended::{HeaderMapExtended, TransformResponseExtended},
params::SeriesParam,
};
use self::cost_basis::ApiCostBasisLegacyRoutes;
use super::AppState;
/// Shared response pipeline for every series endpoint.
///
/// Resolves the query (which determines the cache key), then delegates to
/// [`AppState::cached_with_params`] for the etag short-circuit, server-side
/// cache lookup, body formatting, and header assembly.
pub(super) async fn serve(
state: AppState,
uri: Uri,
headers: HeaderMap,
addr: SocketAddr,
params: SeriesSelection,
to_bytes: impl FnOnce(&BrkQuery, ResolvedQuery) -> BrkResult<Bytes> + Send + 'static,
) -> Result<Response> {
let max_weight = state.max_weight_for(&addr);
let resolved = state
.run(move |q| q.resolve(params, max_weight))
.await?;
mod bulk;
mod cost_basis;
mod data;
pub mod legacy;
let format = resolved.format();
let csv_filename = resolved.csv_filename();
let cache_params = CacheParams::series(
resolved.version,
resolved.total,
resolved.end,
resolved.hash_prefix,
);
/// Maximum allowed request weight in bytes (320KB)
const MAX_WEIGHT: usize = 4 * 8 * 10_000;
/// Maximum allowed request weight for localhost (50MB)
const MAX_WEIGHT_LOCALHOST: usize = 50 * 1_000_000;
Ok(state
.cached_with_params(
&headers,
&uri,
cache_params,
move |h| match format {
Format::CSV => {
h.insert_content_disposition_attachment(&csv_filename);
h.insert_content_type_text_csv();
}
Format::JSON => h.insert_content_type_application_json(),
},
move |q, enc| Ok(enc.compress(to_bytes(q, resolved)?)),
)
.await)
}
/// Returns the max weight for a request based on the client address.
/// Localhost requests get a generous limit, external requests get a stricter one.
fn max_weight(addr: &SocketAddr) -> usize {
if addr.ip().is_loopback() {
MAX_WEIGHT_LOCALHOST
} else {
MAX_WEIGHT
}
fn output_to_bytes(out: brk_types::SeriesOutput) -> BrkResult<Bytes> {
Ok(match out.output {
Output::CSV(s) => Bytes::from(s),
Output::Json(v) => Bytes::from(v),
})
}
async fn data_handler(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<SeriesSelection>,
State(state): State<AppState>,
) -> Result<Response> {
serve(state, uri, headers, addr, params, |q, r| {
output_to_bytes(q.format(r)?)
})
.await
}
async fn data_raw_handler(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<SeriesSelection>,
State(state): State<AppState>,
) -> Result<Response> {
serve(state, uri, headers, addr, params, |q, r| {
output_to_bytes(q.format_raw(r)?)
})
.await
}
pub trait ApiSeriesRoutes {
@@ -53,7 +115,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
"/api/series",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.series_catalog().clone())).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| Ok(q.series_catalog().clone())).await
},
|op| op
.id("get_series_tree")
@@ -75,7 +137,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.series_count())).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| Ok(q.series_count())).await
},
|op| op
.id("get_series_count")
@@ -94,7 +156,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.indexes().to_vec())).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| Ok(q.indexes().to_vec())).await
},
|op| op
.id("get_indexes")
@@ -116,7 +178,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Query(pagination): Query<Pagination>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.series_list(pagination))).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, move |q| Ok(q.series_list(pagination))).await
},
|op| op
.id("list_series")
@@ -136,7 +198,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Query(query): Query<SearchQuery>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.search_series(&query))).await
state.cached_json(&headers, CacheStrategy::Deploy, &uri, move |q| Ok(q.search_series(&query))).await
},
|op| op
.id("search_series")
@@ -157,7 +219,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
State(state): State<AppState>,
Path(path): Path<SeriesParam>
| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
state.cached_json(&headers, CacheStrategy::Deploy, &uri, move |q| {
q.series_info(&path.series).ok_or_else(|| q.series_not_found_error(&path.series))
}).await
},
@@ -184,7 +246,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
Path(path): Path<SeriesNameWithIndex>,
Query(range): Query<DataRangeFormat>|
-> Response {
data::handler(
data_handler(
uri,
headers,
addr,
@@ -218,7 +280,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
Path(path): Path<SeriesNameWithIndex>,
Query(range): Query<DataRangeFormat>|
-> Response {
data::raw_handler(
data_raw_handler(
uri,
headers,
addr,
@@ -317,7 +379,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
"/api/series/bulk",
get_with(
|uri, headers, addr, query, state| async move {
bulk::handler(uri, headers, addr, query, state).await.into_response()
data_handler(uri, headers, addr, query, state).await.into_response()
},
|op| op
.id("get_series_bulk")
@@ -332,6 +394,5 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
.not_modified(),
),
)
.add_cost_basis_legacy_routes()
}
}

View File

@@ -1,79 +0,0 @@
use std::net::SocketAddr;
use axum::{
Extension,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, Uri},
response::Response,
};
use brk_types::{Format, Output, SeriesSelection};
use crate::{
Result,
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended, ResponseExtended},
};
use super::AppState;
pub async fn handler(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<SeriesSelection>,
State(state): State<AppState>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
let resolved = state
.run(move |q| q.resolve(params, max_weight(&addr)))
.await?;
let format = resolved.format();
let etag = resolved.etag();
let csv_filename = resolved.csv_filename();
if headers.has_etag(etag.as_str()) {
return Ok(Response::new_not_modified(&etag, CACHE_CONTROL));
}
// Phase 2: Format (expensive, server-side cached)
let encoding = ContentEncoding::negotiate(&headers);
let cache_key = format!(
"bulk-{}{}{}-{}",
uri.path(),
uri.query().unwrap_or(""),
etag,
encoding.as_str()
);
let query = &state;
let bytes = state
.get_or_insert(&cache_key, async move {
query
.run(move |q| {
let out = q.format(resolved)?;
let raw = match out.output {
Output::CSV(s) => Bytes::from(s),
Output::Json(v) => Bytes::from(v),
};
Ok(encoding.compress(raw))
})
.await
})
.await?;
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_etag(etag.as_str());
h.insert_cache_control(CACHE_CONTROL);
h.insert_content_encoding(encoding);
match format {
Format::CSV => {
h.insert_content_disposition_attachment(&csv_filename);
h.insert_content_type_text_csv();
}
Format::JSON => h.insert_content_type_application_json(),
}
Ok(response)
}

View File

@@ -1,102 +0,0 @@
use std::net::SocketAddr;
use axum::{
Extension,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, Uri},
response::Response,
};
use brk_error::Result as BrkResult;
use brk_query::{Query as BrkQuery, ResolvedQuery};
use brk_types::{Format, Output, SeriesOutput, SeriesSelection};
use crate::{
Result,
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended, ResponseExtended},
};
use super::AppState;
pub async fn handler(
uri: Uri,
headers: HeaderMap,
addr: Extension<SocketAddr>,
Query(params): Query<SeriesSelection>,
state: State<AppState>,
) -> Result<Response> {
format_and_respond(uri, headers, addr, params, state, |q, r| q.format(r)).await
}
pub async fn raw_handler(
uri: Uri,
headers: HeaderMap,
addr: Extension<SocketAddr>,
Query(params): Query<SeriesSelection>,
state: State<AppState>,
) -> Result<Response> {
format_and_respond(uri, headers, addr, params, state, |q, r| q.format_raw(r)).await
}
async fn format_and_respond(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
params: SeriesSelection,
state: State<AppState>,
formatter: fn(&BrkQuery, ResolvedQuery) -> BrkResult<SeriesOutput>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
let resolved = state
.run(move |q| q.resolve(params, max_weight(&addr)))
.await?;
let format = resolved.format();
let etag = resolved.etag();
let csv_filename = resolved.csv_filename();
if headers.has_etag(etag.as_str()) {
return Ok(Response::new_not_modified(&etag, CACHE_CONTROL));
}
// Phase 2: Format (expensive, server-side cached)
let encoding = ContentEncoding::negotiate(&headers);
let cache_key = format!(
"single-{}{}{}-{}",
uri.path(),
uri.query().unwrap_or(""),
etag,
encoding.as_str()
);
let query = &state;
let bytes = state
.get_or_insert(&cache_key, async move {
query
.run(move |q| {
let out = formatter(q, resolved)?;
let raw = match out.output {
Output::CSV(s) => Bytes::from(s),
Output::Json(v) => Bytes::from(v),
};
Ok(encoding.compress(raw))
})
.await
})
.await?;
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_etag(etag.as_str());
h.insert_cache_control(CACHE_CONTROL);
h.insert_content_encoding(encoding);
match format {
Format::CSV => {
h.insert_content_disposition_attachment(&csv_filename);
h.insert_content_type_text_csv();
}
Format::JSON => h.insert_content_type_application_json(),
}
Ok(response)
}

View File

@@ -1,82 +0,0 @@
use std::net::SocketAddr;
use axum::{
Extension,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, Uri},
response::Response,
};
use brk_types::{Format, OutputLegacy, SeriesSelection};
use crate::{
Result,
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended, ResponseExtended},
};
pub const SUNSET: &str = "2027-01-01T00:00:00Z";
use super::AppState;
pub async fn handler(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<SeriesSelection>,
State(state): State<AppState>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
let resolved = state
.run(move |q| q.resolve(params, max_weight(&addr)))
.await?;
let format = resolved.format();
let etag = resolved.etag();
let csv_filename = resolved.csv_filename();
if headers.has_etag(etag.as_str()) {
return Ok(Response::new_not_modified(&etag, CACHE_CONTROL));
}
// Phase 2: Format (expensive, server-side cached)
let encoding = ContentEncoding::negotiate(&headers);
let cache_key = format!(
"legacy-{}{}{}-{}",
uri.path(),
uri.query().unwrap_or(""),
etag,
encoding.as_str()
);
let query = &state;
let bytes = state
.get_or_insert(&cache_key, async move {
query
.run(move |q| {
let out = q.format_legacy(resolved)?;
let raw = match out.output {
OutputLegacy::CSV(s) => Bytes::from(s),
OutputLegacy::Json(v) => Bytes::from(v.to_vec()),
};
Ok(encoding.compress(raw))
})
.await
})
.await?;
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_etag(etag.as_str());
h.insert_cache_control(CACHE_CONTROL);
h.insert_content_encoding(encoding);
match format {
Format::CSV => {
h.insert_content_disposition_attachment(&csv_filename);
h.insert_content_type_text_csv();
}
Format::JSON => h.insert_content_type_application_json(),
}
h.insert_deprecation(SUNSET);
Ok(response)
}

View File

@@ -1,47 +1,86 @@
//! Deprecated `/api/series/cost-basis/*` routes.
//! Sunset date: 2027-01-01. Delete this file and its registration in `mod.rs` together.
//! Deprecated series-format infrastructure. Sunset date: 2027-01-01.
//!
//! Two responsibilities, deletable as a unit when the sunset arrives:
//! - `handler` / `SUNSET`: the shared legacy series handler used by `/api/series`
//! in legacy mode (registered by metrics endpoints that emit the old format).
//! - `add_series_legacy_routes`: the deprecated `/api/series/cost-basis/*` URLs.
use std::collections::BTreeMap;
use std::{collections::BTreeMap, net::SocketAddr};
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, Query as AxumQuery, State},
http::{HeaderMap, Uri},
Extension,
body::Bytes,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, Uri},
response::Response,
};
use brk_error::{Error, Result as BrkResult};
use brk_query::Query as BrkQuery;
use brk_types::{
Bitcoin, Cents, Cohort, Date, Day1, Dollars, OutputLegacy, Sats, SeriesSelection,
UrpdAggregation, Version,
};
use brk_error::{Error, Result};
use brk_query::Query;
use brk_types::{Bitcoin, Cents, Cohort, Date, Day1, Dollars, Sats, UrpdAggregation, Version};
use rustc_hash::FxHashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::ReadableOptionVec;
use crate::{AppState, CacheStrategy, extended::TransformResponseExtended};
use crate::{
AppState, CacheStrategy, Result,
extended::{HeaderMapExtended, TransformResponseExtended},
};
pub const SUNSET: &str = "2027-01-01T00:00:00Z";
/// Legacy series handler. Emits the pre-2027 `OutputLegacy` format and tags
/// the response with `Deprecation` / `Sunset` headers. Reused by `metrics/*`
/// for endpoints that must stay on the old format until sunset.
pub async fn handler(
uri: Uri,
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<SeriesSelection>,
State(state): State<AppState>,
) -> Result<Response> {
let mut response = super::series::serve(state, uri, headers, addr, params, legacy_bytes).await?;
if response.status() == StatusCode::OK {
response.headers_mut().insert_deprecation(SUNSET);
}
Ok(response)
}
fn legacy_bytes(q: &BrkQuery, r: brk_query::ResolvedQuery) -> BrkResult<Bytes> {
Ok(match q.format_legacy(r)?.output {
OutputLegacy::CSV(s) => Bytes::from(s),
OutputLegacy::Json(v) => Bytes::from(v.to_vec()),
})
}
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisParams {
pub cohort: Cohort,
struct CostBasisParams {
cohort: Cohort,
#[schemars(with = "String", example = &"2024-01-01")]
pub date: Date,
date: Date,
}
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisCohortParam {
pub cohort: Cohort,
struct CostBasisCohortParam {
cohort: Cohort,
}
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisQuery {
struct CostBasisQuery {
#[serde(default)]
pub bucket: UrpdAggregation,
bucket: UrpdAggregation,
#[serde(default)]
pub value: CostBasisValue,
value: CostBasisValue,
}
/// Value type for the deprecated cost-basis distribution output.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub(super) enum CostBasisValue {
enum CostBasisValue {
#[default]
Supply,
Realized,
@@ -53,12 +92,12 @@ pub(super) enum CostBasisValue {
type CostBasisFormatted = BTreeMap<Dollars, f64>;
fn cost_basis_formatted(
q: &Query,
q: &BrkQuery,
cohort: &Cohort,
date: Date,
agg: UrpdAggregation,
value: CostBasisValue,
) -> Result<CostBasisFormatted> {
) -> BrkResult<CostBasisFormatted> {
let raw = q.urpd_raw(cohort, date)?;
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
let spot_cents = q
@@ -102,18 +141,18 @@ fn cost_basis_formatted(
.collect())
}
pub(super) trait ApiCostBasisLegacyRoutes {
fn add_cost_basis_legacy_routes(self) -> Self;
pub trait ApiSeriesLegacyRoutes {
fn add_series_legacy_routes(self) -> Self;
}
impl ApiCostBasisLegacyRoutes for ApiRouter<AppState> {
fn add_cost_basis_legacy_routes(self) -> Self {
impl ApiSeriesLegacyRoutes for ApiRouter<AppState> {
fn add_series_legacy_routes(self) -> Self {
self.api_route(
"/api/series/cost-basis",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.urpd_cohorts())
.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| q.urpd_cohorts())
.await
},
|op| {
@@ -166,7 +205,7 @@ impl ApiCostBasisLegacyRoutes for ApiRouter<AppState> {
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisParams>,
AxumQuery(query): AxumQuery<CostBasisQuery>,
Query(query): Query<CostBasisQuery>,
State(state): State<AppState>| {
let strategy = state.date_cache(Version::ONE, params.date);
state

View File

@@ -57,7 +57,7 @@ impl ServerRoutes for ApiRouter<AppState> {
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |_| {
.cached_json(&headers, CacheStrategy::Deploy, &uri, |_| {
Ok(env!("CARGO_PKG_VERSION"))
})
.await

View File

@@ -24,7 +24,7 @@ impl ApiUrpdRoutes for ApiRouter<AppState> {
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.urpd_cohorts())
.cached_json(&headers, CacheStrategy::Deploy, &uri, |q| q.urpd_cohorts())
.await
},
|op| {

View File

@@ -1,92 +0,0 @@
use axum::http::HeaderMap;
use brk_types::{BlockHashPrefix, Version};
use crate::{VERSION, extended::HeaderMapExtended};
/// Cache strategy for HTTP responses.
pub enum CacheStrategy {
/// Chain-dependent data (addresses, mining stats, txs, outspends).
/// Etag = {tip_hash_prefix:x}. Invalidates on any tip change including reorgs.
Tip,
/// Immutable data identified by hash in the URL (blocks by hash, confirmed tx data).
/// Etag = {version}. Permanent; only bumped when response format changes.
Immutable(Version),
/// Static non-chain data (validate-address, series catalog, pool list).
/// Etag = CARGO_PKG_VERSION. Invalidates on deploy.
Static,
/// Immutable data bound to a specific block (confirmed tx data, block status).
/// Etag = {version}-{block_hash_prefix:x}. Invalidates naturally on reorg.
BlockBound(Version, BlockHashPrefix),
/// Mempool data — etag from next projected block hash.
/// Etag = m{hash:x}. Invalidates on mempool change.
MempoolHash(u64),
}
pub(crate) const CACHE_CONTROL: &str = "public, max-age=1, must-revalidate";
/// Resolved cache parameters
pub struct CacheParams {
pub etag: Option<String>,
pub cache_control: &'static str,
}
impl CacheParams {
pub fn tip(tip: BlockHashPrefix) -> Self {
Self {
etag: Some(format!("t{:x}", *tip)),
cache_control: CACHE_CONTROL,
}
}
pub fn immutable(version: Version) -> Self {
Self {
etag: Some(format!("i{version}")),
cache_control: CACHE_CONTROL,
}
}
pub fn block_bound(version: Version, prefix: BlockHashPrefix) -> Self {
Self {
etag: Some(format!("b{version}-{:x}", *prefix)),
cache_control: CACHE_CONTROL,
}
}
pub fn static_version() -> Self {
Self {
etag: Some(format!("s{VERSION}")),
cache_control: CACHE_CONTROL,
}
}
pub fn mempool_hash(hash: u64) -> Self {
Self {
etag: Some(format!("m{hash:x}")),
cache_control: CACHE_CONTROL,
}
}
pub fn etag_str(&self) -> &str {
self.etag.as_deref().unwrap_or("")
}
pub fn matches_etag(&self, headers: &HeaderMap) -> bool {
self.etag
.as_ref()
.is_some_and(|etag| headers.has_etag(etag))
}
pub fn resolve(strategy: &CacheStrategy, tip: impl FnOnce() -> BlockHashPrefix) -> Self {
match strategy {
CacheStrategy::Tip => Self::tip(tip()),
CacheStrategy::Immutable(v) => Self::immutable(*v),
CacheStrategy::BlockBound(v, prefix) => Self::block_bound(*v, *prefix),
CacheStrategy::Static => Self::static_version(),
CacheStrategy::MempoolHash(hash) => Self::mempool_hash(*hash),
}
}
}

20
crates/brk_server/src/cache/mod.rs vendored Normal file
View File

@@ -0,0 +1,20 @@
//! HTTP cache layer. ETag-based revalidation with separate browser and CDN
//! directives (RFC 9213). Three concepts, one file each:
//!
//! - [`CacheStrategy`] — *what kind of resource* the handler is returning
//! (input enum picked by the route).
//! - [`CacheParams`] — the *resolved* etag + Cache-Control + CDN-Cache-Control,
//! derived from a strategy plus current chain tip.
//! - [`CdnCacheMode`] — operator-level toggle for the CDN cached tier
//! (process-global, set once via [`init`] from `Server::new`).
mod mode;
mod params;
mod strategy;
pub use mode::CdnCacheMode;
pub use params::CacheParams;
pub use strategy::CacheStrategy;
pub(crate) use mode::init;
pub(crate) use params::CC_ERROR;

60
crates/brk_server/src/cache/mode.rs vendored Normal file
View File

@@ -0,0 +1,60 @@
use std::sync::OnceLock;
// CDN-facing (RFC 9213). Two tiers: live (chain-state, changes per block /
// mempool event) and cached (stable, ETag-invalidated).
//
// `Live` is always-revalidate: origin handles every request, cheap via ETag,
// no risk of stale data for self-hosters who don't run a purge step.
// `Aggressive` caches stable responses for up to a year and treats them as
// `immutable` (RFC 8246) — the operator must purge the CDN on every deploy.
pub(super) const CDN_LIVE: &str = "public, max-age=1, stale-if-error=300";
const CDN_AGGRESSIVE: &str = "public, max-age=31536000, immutable";
/// CDN caching strategy for stable responses (immutable / deploy / block-bound /
/// historical series). Live-tier responses (`Tip`, `MempoolHash`, tail series)
/// are unaffected.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum CdnCacheMode {
/// Origin revalidates every response via ETag. No CDN purge required. Safe default.
#[default]
Live,
/// CDN holds stable responses for up to a year and treats them as immutable.
/// Operator must purge on every deploy.
Aggressive,
}
impl CdnCacheMode {
const fn as_str(self) -> &'static str {
match self {
Self::Live => CDN_LIVE,
Self::Aggressive => CDN_AGGRESSIVE,
}
}
}
static CDN_CACHE_MODE: OnceLock<CdnCacheMode> = OnceLock::new();
/// Set once at server startup. Subsequent calls are ignored (first-wins). If a
/// later call conflicts with the existing mode, log a warning so the mismatch
/// is visible in plugin / orchestrator setups that spin up multiple servers in
/// the same process.
pub(crate) fn init(mode: CdnCacheMode) {
if CDN_CACHE_MODE.set(mode).is_err() {
let existing = CDN_CACHE_MODE.get().copied().unwrap_or_default();
if existing != mode {
tracing::warn!(
"cache::init called with {mode:?} but mode is already set to {existing:?}; ignoring"
);
}
}
}
/// Cached-tier directive for stable responses. Defaults to `Live` if [`init`]
/// was never called (tests, library use without a `Server`).
pub(super) fn cdn_cached() -> &'static str {
CDN_CACHE_MODE
.get()
.copied()
.unwrap_or_default()
.as_str()
}

120
crates/brk_server/src/cache/params.rs vendored Normal file
View File

@@ -0,0 +1,120 @@
use axum::http::HeaderMap;
use brk_types::{BlockHashPrefix, Version};
use crate::{VERSION, etag::Etag, extended::HeaderMapExtended};
use super::{
mode::{CDN_LIVE, cdn_cached},
strategy::CacheStrategy,
};
// Browser-facing: always revalidate via ETag. `no-cache` means "cache it but
// check before use" (not "don't cache"); ETag makes the check cheap.
const CC: &str = "public, no-cache, stale-if-error=86400";
// Errors: short, must-revalidate, no `stale-if-error` (we don't want a 24h-old
// error served when origin recovers). Same string for browser and CDN.
pub(crate) const CC_ERROR: &str = "public, max-age=1, must-revalidate";
/// Resolved cache parameters: an ETag plus the two Cache-Control directives.
pub struct CacheParams {
pub etag: Etag,
cache_control: &'static str,
cdn_cache_control: &'static str,
}
impl CacheParams {
fn tip(tip: BlockHashPrefix) -> Self {
Self {
etag: format!("t{:x}", *tip).into(),
cache_control: CC,
cdn_cache_control: CDN_LIVE,
}
}
fn immutable(version: Version) -> Self {
Self {
etag: format!("i{version}").into(),
cache_control: CC,
cdn_cache_control: cdn_cached(),
}
}
fn block_bound(version: Version, prefix: BlockHashPrefix) -> Self {
Self {
etag: format!("b{version}-{:x}", *prefix).into(),
cache_control: CC,
cdn_cache_control: cdn_cached(),
}
}
/// Deploy-tied response: etag from the build version. Used directly
/// by static handlers (OpenAPI spec, scalar bundle) that don't have
/// a [`CacheStrategy`] context.
pub fn deploy() -> Self {
Self {
etag: format!("d{VERSION}").into(),
cache_control: CC,
cdn_cache_control: cdn_cached(),
}
}
fn mempool_hash(hash: u64) -> Self {
Self {
etag: format!("m{hash:x}").into(),
cache_control: CC,
cdn_cache_control: CDN_LIVE,
}
}
/// Series query: tail-bound (`end >= total`) gets LIVE, historical gets CACHED.
/// Etag distinguishes the two: tail uses tip hash (per-block + reorgs),
/// historical uses total length (only changes when new data is appended).
pub fn series(version: Version, total: usize, end: usize, hash: BlockHashPrefix) -> Self {
let v = u32::from(version);
if end >= total {
Self {
etag: format!("s{v}-{:x}", *hash).into(),
cache_control: CC,
cdn_cache_control: CDN_LIVE,
}
} else {
Self {
etag: format!("s{v}-{total}").into(),
cache_control: CC,
cdn_cache_control: cdn_cached(),
}
}
}
/// Error response: keeps the originating ETag (so retries can 304),
/// uses [`CC_ERROR`] for both browser and CDN.
pub fn error(etag: Etag) -> Self {
Self {
etag,
cache_control: CC_ERROR,
cdn_cache_control: CC_ERROR,
}
}
pub fn matches_etag(&self, headers: &HeaderMap) -> bool {
headers.has_etag(self.etag.as_str())
}
/// Write this cache policy (etag + cache-control + cdn-cache-control) onto a response's headers.
pub fn apply_to(&self, headers: &mut HeaderMap) {
headers.insert_etag(self.etag.as_str());
headers.insert_cache_control(self.cache_control);
headers.insert_cdn_cache_control(self.cdn_cache_control);
}
pub fn resolve(strategy: &CacheStrategy, tip: BlockHashPrefix) -> Self {
match strategy {
CacheStrategy::Tip => Self::tip(tip),
CacheStrategy::Immutable(v) => Self::immutable(*v),
CacheStrategy::BlockBound(v, prefix) => Self::block_bound(*v, *prefix),
CacheStrategy::Deploy => Self::deploy(),
CacheStrategy::MempoolHash(hash) => Self::mempool_hash(*hash),
}
}
}

30
crates/brk_server/src/cache/strategy.rs vendored Normal file
View File

@@ -0,0 +1,30 @@
use brk_types::{BlockHashPrefix, Version};
/// Cache strategy for HTTP responses.
///
/// The series strategy is computed directly in `api/series::serve` because
/// its parameters (total / end / hash) only become known after query
/// resolution, so it bypasses this enum and builds a
/// [`CacheParams`](super::CacheParams) via
/// [`CacheParams::series`](super::CacheParams::series).
pub enum CacheStrategy {
/// Chain-dependent data (addresses, mining stats, txs, outspends).
/// Etag = `t{tip_hash_prefix:x}`. Invalidates on any tip change including reorgs.
Tip,
/// Immutable data identified by hash in the URL (blocks by hash, confirmed tx data).
/// Etag = `i{version}`. Permanent, only bumped when response format changes.
Immutable(Version),
/// Non-chain data tied to the deploy (validate-address, series catalog, pool list).
/// Etag = `d{CARGO_PKG_VERSION}`. Invalidates on deploy.
Deploy,
/// Immutable data bound to a specific block (confirmed tx data, block status).
/// Etag = `b{version}-{block_hash_prefix:x}`. Invalidates naturally on reorg.
BlockBound(Version, BlockHashPrefix),
/// Mempool data, etag from next projected block hash.
/// Etag = `m{hash:x}`. Invalidates on mempool change.
MempoolHash(u64),
}

View File

@@ -0,0 +1,39 @@
use std::path::PathBuf;
use brk_website::Website;
use crate::cache::CdnCacheMode;
/// Default max series-query response weight for non-loopback clients.
/// `4 * 8 * 10_000` = 320 KB (4 vecs x 8 bytes x 10k rows).
pub const DEFAULT_MAX_WEIGHT: usize = 4 * 8 * 10_000;
/// Default max series-query response weight for loopback clients.
pub const DEFAULT_MAX_WEIGHT_LOCALHOST: usize = 50 * 1_000_000;
/// Default LRU capacity for the in-process response cache.
pub const DEFAULT_CACHE_SIZE: usize = 1_000;
/// Server-wide configuration set at startup.
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub data_path: PathBuf,
pub website: Website,
pub cdn_cache_mode: CdnCacheMode,
pub max_weight: usize,
pub max_weight_localhost: usize,
pub cache_size: usize,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
data_path: PathBuf::default(),
website: Website::default(),
cdn_cache_mode: CdnCacheMode::default(),
max_weight: DEFAULT_MAX_WEIGHT,
max_weight_localhost: DEFAULT_MAX_WEIGHT_LOCALHOST,
cache_size: DEFAULT_CACHE_SIZE,
}
}
}

View File

@@ -7,7 +7,11 @@ use brk_error::Error as BrkError;
use schemars::JsonSchema;
use serde::Serialize;
use crate::extended::HeaderMapExtended;
use crate::{
cache::{CC_ERROR, CacheParams},
etag::Etag,
extended::HeaderMapExtended,
};
/// Server result type with Error that implements IntoResponse.
pub type Result<T> = std::result::Result<T, Error>;
@@ -139,11 +143,10 @@ impl Error {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", msg)
}
pub(crate) fn into_response_with_etag(self, etag: &str) -> Response {
pub(crate) fn into_response_with_etag(self, etag: Etag) -> Response {
let params = CacheParams::error(etag);
let mut response = self.into_response();
let headers = response.headers_mut();
headers.insert_etag(etag);
headers.insert_cache_control_must_revalidate();
params.apply_to(response.headers_mut());
response
}
}
@@ -165,11 +168,15 @@ impl OperationOutput for Error {
impl IntoResponse for Error {
fn into_response(self) -> Response {
let body = build_error_body(self.status, self.code, self.message);
(
let mut response = (
self.status,
[(header::CONTENT_TYPE, "application/problem+json")],
body,
)
.into_response()
.into_response();
let h = response.headers_mut();
h.insert_cache_control(CC_ERROR);
h.insert_cdn_cache_control(CC_ERROR);
response
}
}

View File

@@ -0,0 +1,25 @@
use std::fmt;
/// Typed entity-tag wrapper. Owns a `String` so values built from `format!`
/// can be passed around without re-allocating, while keeping callsites typed
/// (`Etag` instead of `String`).
#[derive(Clone, Debug)]
pub struct Etag(String);
impl Etag {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for Etag {
fn from(value: String) -> Self {
Self(value)
}
}
impl fmt::Display for Etag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}

View File

@@ -10,7 +10,7 @@ pub trait HeaderMapExtended {
fn insert_etag(&mut self, etag: &str);
fn insert_cache_control(&mut self, value: &str);
fn insert_cache_control_must_revalidate(&mut self);
fn insert_cdn_cache_control(&mut self, value: &str);
fn insert_content_disposition_attachment(&mut self, filename: &str);
@@ -45,8 +45,11 @@ impl HeaderMapExtended for HeaderMap {
self.insert(header::CACHE_CONTROL, value.parse().unwrap());
}
fn insert_cache_control_must_revalidate(&mut self) {
self.insert_cache_control("public, max-age=1, must-revalidate");
fn insert_cdn_cache_control(&mut self, value: &str) {
self.insert(
axum::http::HeaderName::from_static("cdn-cache-control"),
value.parse().unwrap(),
);
}
fn insert_content_disposition_attachment(&mut self, filename: &str) {

View File

@@ -3,21 +3,25 @@ use axum::{
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse,
};
use brk_types::Etag;
use serde::Serialize;
use super::header_map::HeaderMapExtended;
use crate::cache::CacheParams;
fn new_json_cached<T: Serialize>(value: T, params: &CacheParams) -> Response<Body> {
let bytes = serde_json::to_vec(&value).unwrap();
let mut response = Response::builder().body(bytes.into()).unwrap();
let h = response.headers_mut();
h.insert_content_type_application_json();
params.apply_to(h);
response
}
pub trait ResponseExtended
where
Self: Sized,
{
fn new_not_modified(etag: &Etag, cache_control: &str) -> Self;
fn new_not_modified_with(params: &CacheParams) -> Self;
fn new_json_cached<T>(value: T, params: &CacheParams) -> Self
where
T: Serialize;
fn new_not_modified(params: &CacheParams) -> Self;
fn static_json<T>(headers: &HeaderMap, value: T) -> Self
where
T: Serialize;
@@ -30,31 +34,9 @@ where
}
impl ResponseExtended for Response<Body> {
fn new_not_modified(etag: &Etag, cache_control: &str) -> Response<Body> {
fn new_not_modified(params: &CacheParams) -> Response<Body> {
let mut response = (StatusCode::NOT_MODIFIED, "").into_response();
let headers = response.headers_mut();
headers.insert_etag(etag.as_str());
headers.insert_cache_control(cache_control);
response
}
fn new_not_modified_with(params: &CacheParams) -> Response<Body> {
let etag = Etag::from(params.etag_str());
Self::new_not_modified(&etag, params.cache_control)
}
fn new_json_cached<T>(value: T, params: &CacheParams) -> Self
where
T: Serialize,
{
let bytes = serde_json::to_vec(&value).unwrap();
let mut response = Response::builder().body(bytes.into()).unwrap();
let headers = response.headers_mut();
headers.insert_content_type_application_json();
headers.insert_cache_control(params.cache_control);
if let Some(etag) = &params.etag {
headers.insert_etag(etag);
}
params.apply_to(response.headers_mut());
response
}
@@ -62,11 +44,11 @@ impl ResponseExtended for Response<Body> {
where
T: Serialize,
{
let params = CacheParams::static_version();
let params = CacheParams::deploy();
if params.matches_etag(headers) {
return Self::new_not_modified_with(&params);
return Self::new_not_modified(&params);
}
Self::new_json_cached(value, &params)
new_json_cached(value, &params)
}
fn static_bytes(
@@ -75,18 +57,16 @@ impl ResponseExtended for Response<Body> {
content_type: &'static str,
content_encoding: &'static str,
) -> Self {
let params = CacheParams::static_version();
let params = CacheParams::deploy();
if params.matches_etag(headers) {
return Self::new_not_modified_with(&params);
return Self::new_not_modified(&params);
}
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
h.insert(header::CONTENT_ENCODING, content_encoding.parse().unwrap());
h.insert_cache_control(params.cache_control);
if let Some(etag) = &params.etag {
h.insert_etag(etag);
}
h.insert_vary_accept_encoding();
params.apply_to(h);
response
}
}

View File

@@ -3,11 +3,13 @@
use std::{
any::Any,
net::SocketAddr,
path::PathBuf,
sync::{Arc, atomic::AtomicU64},
time::{Duration, Instant},
};
#[cfg(feature = "bindgen")]
use std::path::PathBuf;
use aide::axum::ApiRouter;
use axum::{
Extension, ServiceExt,
@@ -33,17 +35,23 @@ use tower_layer::Layer;
use tracing::{error, info};
mod api;
pub mod cache;
mod cache;
mod config;
mod error;
mod etag;
mod extended;
pub mod params;
mod params;
mod state;
pub use api::ApiRoutes;
use api::*;
pub use brk_types::Port;
pub use brk_website::Website;
pub use cache::{CacheParams, CacheStrategy};
pub use cache::CdnCacheMode;
use cache::{CacheParams, CacheStrategy};
pub use config::{
DEFAULT_CACHE_SIZE, DEFAULT_MAX_WEIGHT, DEFAULT_MAX_WEIGHT_LOCALHOST, ServerConfig,
};
pub use error::{Error, Result};
use state::*;
@@ -52,16 +60,19 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct Server(AppState);
impl Server {
pub fn new(query: &AsyncQuery, data_path: PathBuf, website: Website) -> Self {
website.log();
pub fn new(query: &AsyncQuery, config: ServerConfig) -> Self {
config.website.log();
cache::init(config.cdn_cache_mode);
Self(AppState {
query: query.clone(),
data_path,
website,
cache: Arc::new(Cache::new(1_000)),
data_path: config.data_path,
website: config.website,
cache: Arc::new(Cache::new(config.cache_size)),
last_tip: Arc::new(AtomicU64::new(0)),
started_at: jiff::Timestamp::now(),
started_instant: Instant::now(),
max_weight: config.max_weight,
max_weight_localhost: config.max_weight_localhost,
})
}

View File

@@ -1,5 +1,6 @@
use std::{
future::Future,
net::SocketAddr,
path::PathBuf,
sync::{
Arc,
@@ -38,9 +39,23 @@ pub struct AppState {
pub last_tip: Arc<AtomicU64>,
pub started_at: Timestamp,
pub started_instant: Instant,
pub max_weight: usize,
pub max_weight_localhost: usize,
}
impl AppState {
/// Per-request series weight cap: loopback gets `max_weight_localhost`,
/// everyone else gets `max_weight`. The `connect_info_layer` rewrites the
/// peer to non-loopback when `CF-Connecting-IP` is present, so requests
/// proxied through a tunnel are billed at the external rate.
pub fn max_weight_for(&self, addr: &SocketAddr) -> usize {
if addr.ip().is_loopback() {
self.max_weight_localhost
} else {
self.max_weight
}
}
/// `Immutable` if height is >6 deep, `Tip` otherwise.
pub fn height_cache(&self, version: Version, height: Height) -> CacheStrategy {
let is_deep = self.sync(|q| (*q.height()).saturating_sub(*height) > 6);
@@ -136,10 +151,12 @@ impl AppState {
/// Mempool → `MempoolHash`, confirmed → `BlockBound`, unknown → `Tip`.
pub fn tx_cache(&self, version: Version, txid: &Txid) -> CacheStrategy {
self.sync(|q| {
if q.mempool().is_some_and(|m| m.get_txs().contains(txid)) {
let hash = q.mempool().map(|m| m.next_block_hash()).unwrap_or(0);
return CacheStrategy::MempoolHash(hash);
} else if let Ok((_, height)) = q.resolve_tx(txid)
if let Some(mempool) = q.mempool()
&& mempool.txs().contains(txid)
{
return CacheStrategy::MempoolHash(mempool.next_block_hash());
}
if let Ok((_, height)) = q.resolve_tx(txid)
&& let Ok(block_hash) = q.block_hash_by_height(height)
{
return CacheStrategy::BlockBound(version, BlockHashPrefix::from(&block_hash));
@@ -153,7 +170,53 @@ impl AppState {
CacheStrategy::MempoolHash(hash)
}
/// Cached + pre-compressed response. Compression runs on the blocking thread.
/// Shared response pipeline: tip-clear, etag short-circuit, server-side
/// cache lookup, body computation on a blocking thread, header assembly.
/// Used by [`AppState::cached`] (strategy-driven) and the series endpoint
/// (which builds [`CacheParams`] directly from query resolution).
pub(crate) async fn cached_with_params<F>(
&self,
headers: &HeaderMap,
uri: &Uri,
params: CacheParams,
apply_content_headers: impl FnOnce(&mut HeaderMap),
f: F,
) -> Response<Body>
where
F: FnOnce(&brk_query::Query, ContentEncoding) -> brk_error::Result<Bytes> + Send + 'static,
{
let tip = self.sync(|q| q.tip_hash_prefix());
if self.last_tip.swap(*tip, Ordering::Relaxed) != *tip {
self.cache.clear();
}
if params.matches_etag(headers) {
return ResponseExtended::new_not_modified(&params);
}
let encoding = ContentEncoding::negotiate(headers);
let cache_key = format!("{}-{}-{}", uri, params.etag, encoding.as_str());
let result = self
.get_or_insert(
&cache_key,
async move { self.run(move |q| f(q, encoding)).await },
)
.await;
match result {
Ok(bytes) => {
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
apply_content_headers(h);
params.apply_to(h);
h.insert_content_encoding(encoding);
response
}
Err(e) => Error::from(e).into_response_with_etag(params.etag.clone()),
}
}
/// Strategy-driven cached response. Compression runs on the blocking thread.
async fn cached<F>(
&self,
headers: &HeaderMap,
@@ -165,38 +228,18 @@ impl AppState {
where
F: FnOnce(&brk_query::Query, ContentEncoding) -> brk_error::Result<Bytes> + Send + 'static,
{
let encoding = ContentEncoding::negotiate(headers);
let tip = self.sync(|q| q.tip_hash_prefix());
if self.last_tip.swap(*tip, Ordering::Relaxed) != *tip {
self.cache.clear();
}
let params = CacheParams::resolve(&strategy, || tip);
if params.matches_etag(headers) {
return ResponseExtended::new_not_modified_with(&params);
}
let full_key = format!("{}-{}-{}", uri, params.etag_str(), encoding.as_str());
let result = self
.get_or_insert(
&full_key,
async move { self.run(move |q| f(q, encoding)).await },
)
.await;
match result {
Ok(bytes) => {
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
let params = CacheParams::resolve(&strategy, tip);
self.cached_with_params(
headers,
uri,
params,
|h| {
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
h.insert_cache_control(params.cache_control);
h.insert_content_encoding(encoding);
if let Some(etag) = &params.etag {
h.insert_etag(etag);
}
response
}
Err(e) => Error::from(e).into_response_with_etag(params.etag_str()),
}
},
f,
)
.await
}
/// JSON response with HTTP + server-side caching
@@ -218,6 +261,51 @@ impl AppState {
.await
}
/// JSON response where the strategy depends on the loaded value.
///
/// Clients already holding `optimistic`'s ETag get a 304 before any work
/// is done. Otherwise the closure runs on a blocking thread and returns
/// both the value and the actual strategy (e.g. `Immutable` if deeply
/// confirmed, `Tip` otherwise). Errors fall back to `Tip`. Use for
/// resources whose freshness category depends on the data itself
/// (outspends, threshold-based block status).
pub async fn cached_json_optimistic<T, F>(
&self,
headers: &HeaderMap,
optimistic: CacheStrategy,
uri: &Uri,
f: F,
) -> Response<Body>
where
T: Serialize + Send + 'static,
F: FnOnce(&brk_query::Query) -> brk_error::Result<(T, CacheStrategy)> + Send + 'static,
{
let tip = self.sync(|q| q.tip_hash_prefix());
let optimistic_params = CacheParams::resolve(&optimistic, tip);
if optimistic_params.matches_etag(headers) {
return ResponseExtended::new_not_modified(&optimistic_params);
}
let (value_result, strategy) = match self.run(f).await {
Ok((v, s)) => (Ok(v), s),
Err(e) => (Err(e), CacheStrategy::Tip),
};
let params = CacheParams::resolve(&strategy, tip);
self.cached_with_params(
headers,
uri,
params,
|h| {
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
},
move |_q, enc| {
let value = value_result?;
Ok(enc.compress(Bytes::from(serde_json::to_vec(&value).unwrap())))
},
)
.await
}
/// Text response with HTTP + server-side caching
pub async fn cached_text<T, F>(
&self,
@@ -263,7 +351,7 @@ impl AppState {
}
/// Check server-side cache, compute on miss
pub async fn get_or_insert(
async fn get_or_insert(
&self,
cache_key: &str,
compute: impl Future<Output = brk_error::Result<Bytes>>,