mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 14:24:47 -07:00
global: big snapshot
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(¶m.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(¶m.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(¶m.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")
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
20
crates/brk_server/src/cache/mod.rs
vendored
Normal 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
60
crates/brk_server/src/cache/mode.rs
vendored
Normal 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
120
crates/brk_server/src/cache/params.rs
vendored
Normal 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
30
crates/brk_server/src/cache/strategy.rs
vendored
Normal 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),
|
||||
}
|
||||
39
crates/brk_server/src/config.rs
Normal file
39
crates/brk_server/src/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
25
crates/brk_server/src/etag.rs
Normal file
25
crates/brk_server/src/etag.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) = ¶ms.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(¶ms);
|
||||
return Self::new_not_modified(¶ms);
|
||||
}
|
||||
Self::new_json_cached(value, ¶ms)
|
||||
new_json_cached(value, ¶ms)
|
||||
}
|
||||
|
||||
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(¶ms);
|
||||
return Self::new_not_modified(¶ms);
|
||||
}
|
||||
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) = ¶ms.etag {
|
||||
h.insert_etag(etag);
|
||||
}
|
||||
h.insert_vary_accept_encoding();
|
||||
params.apply_to(h);
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¶ms);
|
||||
}
|
||||
|
||||
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(¶ms);
|
||||
}
|
||||
|
||||
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) = ¶ms.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>>,
|
||||
|
||||
Reference in New Issue
Block a user