diff --git a/crates/brk_server/src/api/mempool_space/addrs.rs b/crates/brk_server/src/api/mempool_space/addrs.rs index f227123ac..499ae5f18 100644 --- a/crates/brk_server/src/api/mempool_space/addrs.rs +++ b/crates/brk_server/src/api/mempool_space/addrs.rs @@ -30,7 +30,7 @@ impl AddrRoutes for ApiRouter { Path(path): Path, State(state): State | { - 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 { Query(params): Query, State(state): State | { - 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 { Query(params): Query, State(state): State | { - 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 { Path(path): Path, State(state): State | { - 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") diff --git a/crates/brk_server/src/api/mempool_space/blocks.rs b/crates/brk_server/src/api/mempool_space/blocks.rs index ee7660053..193eab65e 100644 --- a/crates/brk_server/src/api/mempool_space/blocks.rs +++ b/crates/brk_server/src/api/mempool_space/blocks.rs @@ -117,7 +117,7 @@ impl BlockRoutes for ApiRouter { headers: HeaderMap, Path(path): Path, State(state): State| { - 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") diff --git a/crates/brk_server/src/api/mempool_space/general.rs b/crates/brk_server/src/api/mempool_space/general.rs index 982ef66da..30fea0901 100644 --- a/crates/brk_server/src/api/mempool_space/general.rs +++ b/crates/brk_server/src/api/mempool_space/general.rs @@ -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 { headers: HeaderMap, Query(params): Query, State(state): State| { + 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 diff --git a/crates/brk_server/src/api/mempool_space/mining.rs b/crates/brk_server/src/api/mempool_space/mining.rs index 44c476f83..2c8fc2869 100644 --- a/crates/brk_server/src/api/mempool_space/mining.rs +++ b/crates/brk_server/src/api/mempool_space/mining.rs @@ -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 { "/api/v1/mining/pool/{slug}/blocks/{height}", get_with( async |uri: Uri, headers: HeaderMap, Path(PoolSlugAndHeightParam {slug, height}): Path, State(state): State| { - 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") diff --git a/crates/brk_server/src/api/mempool_space/transactions.rs b/crates/brk_server/src/api/mempool_space/transactions.rs index 099ddeb87..4ad215eae 100644 --- a/crates/brk_server/src/api/mempool_space/transactions.rs +++ b/crates/brk_server/src/api/mempool_space/transactions.rs @@ -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 { Path(path): Path, State(state): State | { - 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 { Path(param): Path, State(state): State | { - state.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| q.outspends(¶m.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(¶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 }, |op| op .id("get_tx_outspends") @@ -237,7 +256,8 @@ impl TxRoutes for ApiRouter { post_with( async |State(state): State, 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) }, diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index 9b1ac9834..8f9637c18 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -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 { ) .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( diff --git a/crates/brk_server/src/api/series/bulk.rs b/crates/brk_server/src/api/series/bulk.rs index 8cbf9a380..65bc82598 100644 --- a/crates/brk_server/src/api/series/bulk.rs +++ b/crates/brk_server/src/api/series/bulk.rs @@ -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) diff --git a/crates/brk_server/src/api/series/data.rs b/crates/brk_server/src/api/series/data.rs index cdcf25e05..533642b3a 100644 --- a/crates/brk_server/src/api/series/data.rs +++ b/crates/brk_server/src/api/series/data.rs @@ -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) diff --git a/crates/brk_server/src/api/series/legacy.rs b/crates/brk_server/src/api/series/legacy.rs index 8f973b7a9..82eaa4683 100644 --- a/crates/brk_server/src/api/series/legacy.rs +++ b/crates/brk_server/src/api/series/legacy.rs @@ -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) diff --git a/crates/brk_server/src/cache.rs b/crates/brk_server/src/cache.rs index eef737553..c4b96323b 100644 --- a/crates/brk_server/src/cache.rs +++ b/crates/brk_server/src/cache.rs @@ -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 { diff --git a/crates/brk_server/src/extended/header_map.rs b/crates/brk_server/src/extended/header_map.rs index 69d6cc0e8..67898ee22 100644 --- a/crates/brk_server/src/extended/header_map.rs +++ b/crates/brk_server/src/extended/header_map.rs @@ -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()); diff --git a/crates/brk_server/src/extended/response.rs b/crates/brk_server/src/extended/response.rs index a38f98493..82b2635c4 100644 --- a/crates/brk_server/src/extended/response.rs +++ b/crates/brk_server/src/extended/response.rs @@ -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(value: T, params: &CacheParams) -> Self where T: Serialize; fn static_json(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 { - fn new_not_modified() -> Response { + fn new_not_modified(etag: &Etag, cache_control: &str) -> Response { 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 { + let etag = Etag::from(params.etag_str()); + Self::new_not_modified(&etag, ¶ms.cache_control) + } + fn new_json_cached(value: T, params: &CacheParams) -> Self where T: Serialize, @@ -49,8 +64,29 @@ impl ResponseExtended for Response { { let params = CacheParams::static_version(); if params.matches_etag(headers) { - return Self::new_not_modified(); + return Self::new_not_modified_with(¶ms); } Self::new_json_cached(value, ¶ms) } + + 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(¶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(¶ms.cache_control); + if let Some(etag) = ¶ms.etag { + h.insert_etag(etag); + } + response + } } diff --git a/crates/brk_server/src/lib.rs b/crates/brk_server/src/lib.rs index 8d3cb26c0..3b07e7d4a 100644 --- a/crates/brk_server/src/lib.rs +++ b/crates/brk_server/src/lib.rs @@ -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(), }) diff --git a/crates/brk_server/src/state.rs b/crates/brk_server/src/state.rs index 621349613..3a32ed901 100644 --- a/crates/brk_server/src/state.rs +++ b/crates/brk_server/src/state.rs @@ -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>, + pub last_tip: Arc, 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 + 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(¶ms); } let full_key = format!("{}-{}-{}", uri, params.etag_str(), encoding.as_str()); diff --git a/crates/brk_types/src/txout_spend.rs b/crates/brk_types/src/txout_spend.rs index aa1eb2303..3f608fbb8 100644 --- a/crates/brk_types/src/txout_spend.rs +++ b/crates/brk_types/src/txout_spend.rs @@ -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) + } }