server: cache fixes

This commit is contained in:
nym21
2026-04-05 22:43:30 +02:00
parent 2b15a24b6d
commit acd3d6f425
15 changed files with 156 additions and 48 deletions

View File

@@ -30,7 +30,7 @@ impl AddrRoutes for ApiRouter<AppState> {
Path(path): Path<AddrParam>,
State(state): State<AppState>
| {
let strategy = state.addr_cache(Version::ONE, &path.addr);
let strategy = state.addr_cache(Version::ONE, &path.addr, false);
state.cached_json(&headers, strategy, &uri, move |q| q.addr(path.addr)).await
}, |op| op
.id("get_address")
@@ -53,7 +53,7 @@ impl AddrRoutes for ApiRouter<AppState> {
Query(params): Query<AddrTxidsParam>,
State(state): State<AppState>
| {
let strategy = state.addr_cache(Version::ONE, &path.addr);
let strategy = state.addr_cache(Version::ONE, &path.addr, false);
state.cached_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, params.after_txid, 50)).await
}, |op| op
.id("get_address_txs")
@@ -76,7 +76,7 @@ impl AddrRoutes for ApiRouter<AppState> {
Query(params): Query<AddrTxidsParam>,
State(state): State<AppState>
| {
let strategy = state.addr_cache(Version::ONE, &path.addr);
let strategy = state.addr_cache(Version::ONE, &path.addr, true);
state.cached_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, params.after_txid, 25)).await
}, |op| op
.id("get_address_confirmed_txs")
@@ -119,7 +119,7 @@ impl AddrRoutes for ApiRouter<AppState> {
Path(path): Path<AddrParam>,
State(state): State<AppState>
| {
let strategy = state.addr_cache(Version::ONE, &path.addr);
let strategy = state.addr_cache(Version::ONE, &path.addr, false);
state.cached_json(&headers, strategy, &uri, move |q| q.addr_utxos(path.addr)).await
}, |op| op
.id("get_address_utxos")

View File

@@ -117,7 +117,7 @@ impl BlockRoutes for ApiRouter<AppState> {
headers: HeaderMap,
Path(path): Path<TimestampParam>,
State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| q.block_by_timestamp(path.timestamp)).await
state.cached_json(&headers, state.timestamp_cache(Version::ONE, path.timestamp), &uri, move |q| q.block_by_timestamp(path.timestamp)).await
},
|op| {
op.id("get_block_by_timestamp")

View File

@@ -3,7 +3,7 @@ use axum::{
extract::{Query, State},
http::{HeaderMap, Uri},
};
use brk_types::{DifficultyAdjustment, HistoricalPrice, Prices, Timestamp};
use brk_types::{DifficultyAdjustment, HistoricalPrice, Prices, Timestamp, Version};
use crate::{
AppState, CacheStrategy, extended::TransformResponseExtended, params::OptionalTimestampParam,
@@ -66,8 +66,12 @@ impl GeneralRoutes for ApiRouter<AppState> {
headers: HeaderMap,
Query(params): Query<OptionalTimestampParam>,
State(state): State<AppState>| {
let strategy = params
.timestamp
.map(|ts| state.timestamp_cache(Version::ONE, ts))
.unwrap_or(CacheStrategy::Tip);
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
.cached_json(&headers, strategy, &uri, move |q| {
q.historical_price(params.timestamp)
})
.await

View File

@@ -8,7 +8,7 @@ use axum::{
use brk_types::{
BlockFeeRatesEntry, BlockFeesEntry, BlockInfoV1, BlockRewardsEntry, BlockSizesWeights,
DifficultyAdjustmentEntry, HashrateSummary, PoolDetail, PoolHashrateEntry, PoolInfo,
PoolsSummary, RewardStats,
PoolsSummary, RewardStats, Version,
};
use crate::{
@@ -154,7 +154,7 @@ impl MiningRoutes for ApiRouter<AppState> {
"/api/v1/mining/pool/{slug}/blocks/{height}",
get_with(
async |uri: Uri, headers: HeaderMap, Path(PoolSlugAndHeightParam {slug, height}): Path<PoolSlugAndHeightParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| q.pool_blocks(slug, Some(height))).await
state.cached_json(&headers, state.height_cache(Version::ONE, height), &uri, move |q| q.pool_blocks(slug, Some(height))).await
},
|op| {
op.id("get_pool_blocks_from")

View File

@@ -10,7 +10,8 @@ use brk_types::{CpfpInfo, MerkleProof, Transaction, TxOutspend, TxStatus, Txid,
use crate::{
AppState, CacheStrategy,
extended::TransformResponseExtended,
cache::CacheParams,
extended::{ResponseExtended, TransformResponseExtended},
params::{TxidParam, TxidVout, TxidsParam},
};
@@ -132,7 +133,16 @@ impl TxRoutes for ApiRouter<AppState> {
Path(path): Path<TxidVout>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| q.outspend(&path.txid, path.vout)).await
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
},
|op| op
.id("get_tx_outspend")
@@ -157,7 +167,16 @@ impl TxRoutes for ApiRouter<AppState> {
Path(param): Path<TxidParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| q.outspends(&param.txid)).await
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
},
|op| op
.id("get_tx_outspends")
@@ -237,7 +256,8 @@ impl TxRoutes for ApiRouter<AppState> {
post_with(
async |State(state): State<AppState>, body: String| {
let hex = body.trim().to_string();
state.sync(|q| q.broadcast_transaction(&hex))
state.run(move |q| q.broadcast_transaction(&hex))
.await
.map(|txid| txid.to_string())
.map_err(crate::Error::from)
},

View File

@@ -6,7 +6,7 @@ use aide::{
};
use axum::{
Extension,
http::{HeaderMap, header},
http::HeaderMap,
response::{Html, Redirect, Response},
routing::get,
};
@@ -78,13 +78,12 @@ impl ApiRoutes for ApiRouter<AppState> {
)
.route("/api", get(Html::from(include_str!("./scalar.html"))))
// Pre-compressed with: brotli -c -q 11 scalar.js > scalar.js.br
.route("/scalar.js", get(|| async {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CONTENT_ENCODING, "br"),
],
.route("/scalar.js", get(|headers: HeaderMap| async move {
Response::static_bytes(
&headers,
include_bytes!("./scalar.js.br").as_slice(),
"application/javascript",
"br",
)
}))
.route(

View File

@@ -4,15 +4,15 @@ use axum::{
Extension,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
http::{HeaderMap, Uri},
response::Response,
};
use brk_types::{Format, Output, SeriesSelection};
use crate::{
Result,
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended},
extended::{ContentEncoding, HeaderMapExtended, ResponseExtended},
};
use super::AppState;
@@ -34,7 +34,7 @@ pub async fn handler(
let csv_filename = resolved.csv_filename();
if headers.has_etag(etag.as_str()) {
return Ok((StatusCode::NOT_MODIFIED, "").into_response());
return Ok(Response::new_not_modified(&etag, CACHE_CONTROL));
}
// Phase 2: Format (expensive, server-side cached)

View File

@@ -4,8 +4,8 @@ use axum::{
Extension,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
http::{HeaderMap, Uri},
response::Response,
};
use brk_error::Result as BrkResult;
use brk_query::{Query as BrkQuery, ResolvedQuery};
@@ -14,7 +14,7 @@ use brk_types::{Format, Output, SeriesOutput, SeriesSelection};
use crate::{
Result,
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended},
extended::{ContentEncoding, HeaderMapExtended, ResponseExtended},
};
use super::AppState;
@@ -57,7 +57,7 @@ async fn format_and_respond(
let csv_filename = resolved.csv_filename();
if headers.has_etag(etag.as_str()) {
return Ok((StatusCode::NOT_MODIFIED, "").into_response());
return Ok(Response::new_not_modified(&etag, CACHE_CONTROL));
}
// Phase 2: Format (expensive, server-side cached)

View File

@@ -4,15 +4,15 @@ use axum::{
Extension,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
http::{HeaderMap, Uri},
response::Response,
};
use brk_types::{Format, OutputLegacy, SeriesSelection};
use crate::{
Result,
api::series::{CACHE_CONTROL, max_weight},
extended::{ContentEncoding, HeaderMapExtended},
extended::{ContentEncoding, HeaderMapExtended, ResponseExtended},
};
pub const SUNSET: &str = "2027-01-01T00:00:00Z";
@@ -36,7 +36,7 @@ pub async fn handler(
let csv_filename = resolved.csv_filename();
if headers.has_etag(etag.as_str()) {
return Ok((StatusCode::NOT_MODIFIED, "").into_response());
return Ok(Response::new_not_modified(&etag, CACHE_CONTROL));
}
// Phase 2: Format (expensive, server-side cached)

View File

@@ -33,6 +33,13 @@ pub struct CacheParams {
}
impl CacheParams {
pub fn immutable(version: Version) -> Self {
Self {
etag: Some(format!("i{version}")),
cache_control: "public, max-age=1, must-revalidate".into(),
}
}
/// Cache params using CARGO_PKG_VERSION as etag (for openapi.json etc.)
pub fn static_version() -> Self {
Self {

View File

@@ -19,6 +19,8 @@ pub trait HeaderMapExtended {
fn insert_content_type_application_json(&mut self);
fn insert_content_type_text_csv(&mut self);
fn insert_vary_accept_encoding(&mut self);
fn insert_deprecation(&mut self, sunset: &'static str);
}
@@ -59,6 +61,7 @@ impl HeaderMapExtended for HeaderMap {
fn insert_content_encoding(&mut self, encoding: ContentEncoding) {
if let Some(value) = encoding.header_value() {
self.insert(header::CONTENT_ENCODING, value);
self.insert_vary_accept_encoding();
}
}
@@ -70,6 +73,10 @@ impl HeaderMapExtended for HeaderMap {
self.insert(header::CONTENT_TYPE, "text/csv".parse().unwrap());
}
fn insert_vary_accept_encoding(&mut self) {
self.insert(header::VARY, "Accept-Encoding".parse().unwrap());
}
fn insert_deprecation(&mut self, sunset: &'static str) {
self.insert("Deprecation", "true".parse().unwrap());
self.insert("Sunset", sunset.parse().unwrap());

View File

@@ -1,8 +1,9 @@
use axum::{
body::Body,
http::{HeaderMap, Response, StatusCode},
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse,
};
use brk_types::Etag;
use serde::Serialize;
use super::header_map::HeaderMapExtended;
@@ -12,22 +13,36 @@ pub trait ResponseExtended
where
Self: Sized,
{
fn new_not_modified() -> Self;
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 static_json<T>(headers: &HeaderMap, value: T) -> Self
where
T: Serialize;
fn static_bytes(
headers: &HeaderMap,
bytes: &'static [u8],
content_type: &'static str,
content_encoding: &'static str,
) -> Self;
}
impl ResponseExtended for Response<Body> {
fn new_not_modified() -> Response<Body> {
fn new_not_modified(etag: &Etag, cache_control: &str) -> Response<Body> {
let mut response = (StatusCode::NOT_MODIFIED, "").into_response();
let _headers = response.headers_mut();
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,
@@ -49,8 +64,29 @@ impl ResponseExtended for Response<Body> {
{
let params = CacheParams::static_version();
if params.matches_etag(headers) {
return Self::new_not_modified();
return Self::new_not_modified_with(&params);
}
Self::new_json_cached(value, &params)
}
fn static_bytes(
headers: &HeaderMap,
bytes: &'static [u8],
content_type: &'static str,
content_encoding: &'static str,
) -> Self {
let params = CacheParams::static_version();
if params.matches_etag(headers) {
return Self::new_not_modified_with(&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);
}
response
}
}

View File

@@ -4,7 +4,10 @@ use std::{
any::Any,
net::SocketAddr,
path::PathBuf,
sync::Arc,
sync::{
Arc,
atomic::AtomicU64,
},
time::{Duration, Instant},
};
@@ -59,6 +62,7 @@ impl Server {
data_path,
website,
cache: Arc::new(Cache::new(1_000)),
last_tip: Arc::new(AtomicU64::new(0)),
started_at: jiff::Timestamp::now(),
started_instant: Instant::now(),
})

View File

@@ -1,7 +1,10 @@
use std::{
future::Future,
path::PathBuf,
sync::Arc,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::{Duration, Instant},
};
@@ -10,7 +13,10 @@ use axum::{
http::{HeaderMap, HeaderValue, Response, Uri, header},
};
use brk_query::AsyncQuery;
use brk_types::{Addr, BlockHash, BlockHashPrefix, Height, Txid, Version};
use brk_types::{
Addr, BlockHash, BlockHashPrefix, Height, ONE_HOUR_IN_SEC, Timestamp as BrkTimestamp, Txid,
Version,
};
use derive_more::Deref;
use jiff::Timestamp;
use quick_cache::sync::{Cache, GuardResult};
@@ -28,6 +34,7 @@ pub struct AppState {
pub data_path: PathBuf,
pub website: Website,
pub cache: Arc<Cache<String, Bytes>>,
pub last_tip: Arc<AtomicU64>,
pub started_at: Timestamp,
pub started_instant: Instant,
}
@@ -43,15 +50,26 @@ impl AppState {
}
}
/// Smart address caching: checks mempool activity first, then on-chain.
/// `Immutable` if timestamp is >6 hours old (block definitely >6 deep), `Tip` otherwise.
pub fn timestamp_cache(&self, version: Version, timestamp: BrkTimestamp) -> CacheStrategy {
if (*BrkTimestamp::now()).saturating_sub(*timestamp) > 6 * ONE_HOUR_IN_SEC {
CacheStrategy::Immutable(version)
} else {
CacheStrategy::Tip
}
}
/// Smart address caching: checks mempool activity first (unless `chain_only`), then on-chain.
/// - Address has mempool txs → `MempoolHash(addr_specific_hash)`
/// - No mempool, has on-chain activity → `BlockBound(last_activity_block)`
/// - Unknown address → `Tip`
pub fn addr_cache(&self, version: Version, addr: &Addr) -> CacheStrategy {
pub fn addr_cache(&self, version: Version, addr: &Addr, chain_only: bool) -> CacheStrategy {
self.sync(|q| {
let mempool_hash = q.addr_mempool_hash(addr);
if mempool_hash != 0 {
return CacheStrategy::MempoolHash(mempool_hash);
if !chain_only {
let mempool_hash = q.addr_mempool_hash(addr);
if mempool_hash != 0 {
return CacheStrategy::MempoolHash(mempool_hash);
}
}
q.addr_last_activity_height(addr)
.and_then(|h| {
@@ -125,9 +143,13 @@ impl AppState {
F: FnOnce(&brk_query::Query, ContentEncoding) -> brk_error::Result<Bytes> + Send + 'static,
{
let encoding = ContentEncoding::negotiate(headers);
let params = CacheParams::resolve(&strategy, || self.sync(|q| q.tip_hash_prefix()));
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();
return ResponseExtended::new_not_modified_with(&params);
}
let full_key = format!("{}-{}-{}", uri, params.etag_str(), encoding.as_str());

View File

@@ -1,7 +1,7 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{TxStatus, Txid, Vin};
use crate::{Height, TxStatus, Txid, Vin};
/// Status of an output indicating whether it has been spent
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -29,4 +29,13 @@ impl TxOutspend {
vin: None,
status: None,
};
pub fn is_deeply_spent(&self, current_height: Height) -> bool {
self.spent
&& self
.status
.as_ref()
.and_then(|s| s.block_height)
.is_some_and(|h| (*current_height).saturating_sub(*h) > 6)
}
}