Fix potential API hangs from write lock contention
- Refactor update_changes_score_volume_and_ranking to use read connections for read/compute phases, only acquiring write connection for final writes - Change first_pool_creation to use try_get() for opportunistic caching, preventing thread exhaustion if write pool is busy - Add linter to flag blocking _w.get() calls in RPC handlers
This commit is contained in:
parent
055d0bee29
commit
e1ebc853f5
4 changed files with 446 additions and 345 deletions
80
linters/rpc_write_lock_check.py
Executable file
80
linters/rpc_write_lock_check.py
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env python3
|
||||
# Copyright (C) 2024-2026 Whiterun LLC,
|
||||
# This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.,
|
||||
# A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
"""
|
||||
Disallow blocking write pool access (_w.get()) in RPC handlers.
|
||||
|
||||
Blocking .get() calls on write pools can exhaust Rocket's thread pool if a
|
||||
background task holds the write lock for an extended period. All threads
|
||||
waiting on .get() will block, causing the entire API to hang.
|
||||
|
||||
Use try_get() instead, which returns None immediately if the lock is unavailable.
|
||||
|
||||
OK (non-blocking):
|
||||
if let Some(cw) = dbp.cauldron_w.try_get() { ... }
|
||||
|
||||
NOT OK (blocks thread, can cause API hang):
|
||||
let cw = dbp.cauldron_w.get()?;
|
||||
"""
|
||||
|
||||
OUR_PATH = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
# Pattern to detect blocking write pool access: something_w.get()
|
||||
FORBIDDEN_PATTERN = re.compile(r"\b\w+_w\.get\(\)")
|
||||
|
||||
|
||||
def check_file_for_blocking_write_lock(file_path):
|
||||
"""Check if the file contains blocking _w.get() calls outside of test modules."""
|
||||
violations = []
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
for line_num, line in enumerate(file, 1):
|
||||
# Stop checking if we enter test module
|
||||
if re.search(r"\bmod tests \{", line):
|
||||
break
|
||||
|
||||
if FORBIDDEN_PATTERN.search(line):
|
||||
violations.append((line_num, line.strip()))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def check_rpc_files():
|
||||
"""
|
||||
Traverse src/rpc and check each .rs file for blocking write lock access.
|
||||
"""
|
||||
rpc_dir = os.path.join(OUR_PATH, "..", "src", "rpc")
|
||||
found_violations = False
|
||||
|
||||
for root, dirs, files in os.walk(rpc_dir):
|
||||
for file in files:
|
||||
if file.endswith('.rs'):
|
||||
file_path = os.path.join(root, file)
|
||||
violations = check_file_for_blocking_write_lock(file_path)
|
||||
|
||||
if violations:
|
||||
found_violations = True
|
||||
rel_path = os.path.relpath(file_path, os.path.join(OUR_PATH, ".."))
|
||||
for line_num, line in violations:
|
||||
print(f"{rel_path}:{line_num}: blocking _w.get() found")
|
||||
print(f" {line}")
|
||||
print(f" hint: use try_get() instead to avoid thread exhaustion")
|
||||
print()
|
||||
|
||||
if found_violations:
|
||||
print("ERROR: Blocking write pool access found in RPC handlers.")
|
||||
print("This can cause API hangs if a background task holds the write lock.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("OK")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_rpc_files()
|
||||
|
|
@ -285,19 +285,105 @@ pub fn recompute_score_ranks(conn: &Connection) -> anyhow::Result<()> {
|
|||
)
|
||||
}
|
||||
|
||||
// ---------- 5 min: price changes (24h/7d), score, volume + ranking ----------
|
||||
pub fn update_changes_score_volume_and_ranking(
|
||||
cauldron_conn: &Connection,
|
||||
bcmr_conn: &Connection,
|
||||
crc20_conn: &Connection,
|
||||
oracle_conn: &Connection,
|
||||
/// Holds computed metrics for a single token, ready to be written to DB.
|
||||
struct TokenMetricsUpdate {
|
||||
token_blob: Vec<u8>,
|
||||
trade_volume: i64,
|
||||
score: i64,
|
||||
price_24h: Option<f64>,
|
||||
price_7d: Option<f64>,
|
||||
change_24h_bp: Option<i64>,
|
||||
change_7d_bp: Option<i64>,
|
||||
price_now_usd: Option<f64>,
|
||||
price_24h_usd: Option<f64>,
|
||||
price_7d_usd: Option<f64>,
|
||||
change_24h_usd_bp: Option<i64>,
|
||||
change_7d_usd_bp: Option<i64>,
|
||||
display_name: Option<String>,
|
||||
display_symbol: Option<String>,
|
||||
}
|
||||
|
||||
/// Flush a batch of token metrics updates in a short transaction.
|
||||
fn flush_metrics_update_batch(
|
||||
conn: &Connection,
|
||||
updates: &[TokenMetricsUpdate],
|
||||
) -> anyhow::Result<()> {
|
||||
with_busy_retry(
|
||||
|| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
{
|
||||
let mut upd = tx.prepare(
|
||||
r#"
|
||||
UPDATE cached_token_metrics
|
||||
SET trade_volume = ?2,
|
||||
score = ?3,
|
||||
price_24h = ?4,
|
||||
price_7d = ?5,
|
||||
change_24h_bp = ?6,
|
||||
change_7d_bp = ?7,
|
||||
price_now_usd = ?8,
|
||||
price_24h_usd = ?9,
|
||||
price_7d_usd = ?10,
|
||||
change_24h_usd_bp = ?11,
|
||||
change_7d_usd_bp = ?12,
|
||||
display_name = ?13,
|
||||
display_symbol = ?14,
|
||||
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
||||
WHERE token_id = ?1
|
||||
"#,
|
||||
)?;
|
||||
for u in updates {
|
||||
upd.execute(params![
|
||||
&u.token_blob,
|
||||
u.trade_volume,
|
||||
u.score,
|
||||
u.price_24h,
|
||||
u.price_7d,
|
||||
u.change_24h_bp,
|
||||
u.change_7d_bp,
|
||||
u.price_now_usd,
|
||||
u.price_24h_usd,
|
||||
u.price_7d_usd,
|
||||
u.change_24h_usd_bp,
|
||||
u.change_7d_usd_bp,
|
||||
&u.display_name,
|
||||
&u.display_symbol,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
},
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 5 min: price changes (24h/7d), score, volume + ranking ----------
|
||||
pub fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<()> {
|
||||
// Acquire read connections for read/compute phases
|
||||
let cauldron_r = db
|
||||
.cauldron_r
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("cauldron_r pool: {e}"))?;
|
||||
let bcmr_r = db
|
||||
.bcmr_r
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("bcmr_r pool: {e}"))?;
|
||||
let crc20_r = db
|
||||
.crc20_r
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("crc20_r pool: {e}"))?;
|
||||
let oracle_r = db
|
||||
.oracle_r
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("oracle_r pool: {e}"))?;
|
||||
|
||||
// 30d volume window
|
||||
let now = time_now();
|
||||
let since = (now - 30 * 24 * 60 * 60).max(0);
|
||||
|
||||
// volume per token
|
||||
let mut vol_stmt = cauldron_conn.prepare(
|
||||
// volume per token (using read connection)
|
||||
let mut vol_stmt = cauldron_r.prepare(
|
||||
r#"
|
||||
SELECT p.token_id, COALESCE(SUM(ABS(phe.sats_delta)), 0) AS vol
|
||||
FROM pool_history_entry phe
|
||||
|
|
@ -320,335 +406,283 @@ pub fn update_changes_score_volume_and_ranking(
|
|||
let ts_24h = now - 86_400;
|
||||
let ts_7d = now - 7 * 86_400;
|
||||
|
||||
// oracle (USD/BCH) → USD per sat
|
||||
// oracle (USD/BCH) → USD per sat (using read connection)
|
||||
let sats_per_bch = Decimal::from_i64(SATS_PER_BCH).unwrap();
|
||||
let usd_per_sat_now = usd_per_bch_at_or_before(oracle_conn, now) / sats_per_bch;
|
||||
let usd_per_sat_24h = usd_per_bch_at_or_before(oracle_conn, ts_24h) / sats_per_bch;
|
||||
let usd_per_sat_7d = usd_per_bch_at_or_before(oracle_conn, ts_7d) / sats_per_bch;
|
||||
let usd_per_sat_now = usd_per_bch_at_or_before(&oracle_r, now) / sats_per_bch;
|
||||
let usd_per_sat_24h = usd_per_bch_at_or_before(&oracle_r, ts_24h) / sats_per_bch;
|
||||
let usd_per_sat_7d = usd_per_bch_at_or_before(&oracle_r, ts_7d) / sats_per_bch;
|
||||
|
||||
// One write tx; scope statements so they drop before commit.
|
||||
with_busy_retry(
|
||||
|| {
|
||||
let tx = cauldron_conn.unchecked_transaction()?;
|
||||
{
|
||||
let mut upd = tx.prepare(
|
||||
r#"
|
||||
UPDATE cached_token_metrics
|
||||
SET trade_volume = ?2,
|
||||
score = ?3,
|
||||
// ========== READ PHASE: collect all token data ==========
|
||||
// Read token_ids and their current TVL (using read connection)
|
||||
let mut tokens_data: Vec<(Vec<u8>, String, u64, u64)> = Vec::new();
|
||||
{
|
||||
let mut stmt = cauldron_r.prepare(
|
||||
"SELECT token_id, tvl_sats, tvl_tokens FROM cached_token_metrics WHERE tvl_sats > 0",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let token_blob: Vec<u8> = row.get(0)?;
|
||||
let token_id = match blob_to_display_hex::<TokenID>(&token_blob) {
|
||||
Ok(h) => h,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let tvl_sats: i64 = row.get(1)?;
|
||||
let tvl_tokens: i64 = row.get(2)?;
|
||||
tokens_data.push((
|
||||
token_blob,
|
||||
token_id,
|
||||
tvl_sats.max(0) as u64,
|
||||
tvl_tokens.max(0) as u64,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
price_24h = ?4,
|
||||
price_7d = ?5,
|
||||
change_24h_bp = ?6,
|
||||
change_7d_bp = ?7,
|
||||
// ========== COMPUTE PHASE: compute all updates (no transaction) ==========
|
||||
let mut updates: Vec<TokenMetricsUpdate> = Vec::with_capacity(tokens_data.len());
|
||||
let mut dec_cache: HashMap<String, u32> = HashMap::new();
|
||||
|
||||
price_now_usd = ?8,
|
||||
price_24h_usd = ?9,
|
||||
price_7d_usd = ?10,
|
||||
change_24h_usd_bp = ?11,
|
||||
change_7d_usd_bp = ?12,
|
||||
for (token_blob, token_id, tvl_sats, tvl_tokens) in tokens_data {
|
||||
// decimals → factor
|
||||
let decimals_u32 = *dec_cache
|
||||
.entry(token_id.clone())
|
||||
.or_insert_with(|| resolve_decimals(&bcmr_r, &crc20_r, &token_id));
|
||||
let factor = pow10_dec(decimals_u32);
|
||||
|
||||
display_name = ?13,
|
||||
display_symbol = ?14,
|
||||
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
||||
WHERE token_id = ?1
|
||||
"#,
|
||||
)?;
|
||||
// price_now (base) & scaled with guards
|
||||
let price_now_dec = price_from_tvl(tvl_sats, tvl_tokens).unwrap_or(Decimal::ZERO);
|
||||
|
||||
// need tvl for price_now and scaling
|
||||
let mut cur_stmt = tx.prepare(
|
||||
"SELECT tvl_sats, tvl_tokens FROM cached_token_metrics WHERE token_id = ?1",
|
||||
)?;
|
||||
// Read token_id as BLOB directly (not via SQL hex())
|
||||
let mut id_stmt =
|
||||
tx.prepare("SELECT token_id FROM cached_token_metrics WHERE tvl_sats > 0")?;
|
||||
let price_now_human_dec = match price_now_dec.checked_mul(factor) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!(
|
||||
"core price_now overflow; token={}, base={}, factor=10^{}, tvl_sats={}, tvl_tokens={}",
|
||||
token_id, price_now_dec, decimals_u32, tvl_sats, tvl_tokens
|
||||
);
|
||||
|
||||
let mut id_rows = id_stmt.query([])?;
|
||||
let mut dec_cache: HashMap<String, u32> = HashMap::new();
|
||||
|
||||
while let Some(row) = id_rows.next()? {
|
||||
// Convert blob to display hex (handles byte reversal for Bitcoin hashes)
|
||||
let token_blob: Vec<u8> = row.get(0)?;
|
||||
let token_id =
|
||||
match crate::db::blob::blob_to_display_hex::<TokenID>(&token_blob) {
|
||||
Ok(h) => h,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// decimals → factor
|
||||
let decimals_u32 = *dec_cache
|
||||
.entry(token_id.clone())
|
||||
.or_insert_with(|| resolve_decimals(bcmr_conn, crc20_conn, &token_id));
|
||||
let factor = pow10_dec(decimals_u32);
|
||||
|
||||
// tvl
|
||||
let (tvl_sats_i64, tvl_tokens_i64): (i64, i64) =
|
||||
cur_stmt.query_row([&token_blob], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
let tvl_sats = tvl_sats_i64.max(0) as u64;
|
||||
let tvl_tokens = tvl_tokens_i64.max(0) as u64;
|
||||
|
||||
// price_now (base) & scaled with guards
|
||||
let price_now_dec =
|
||||
price_from_tvl(tvl_sats, tvl_tokens).unwrap_or(Decimal::ZERO);
|
||||
|
||||
let price_now_human_dec = match price_now_dec.checked_mul(factor) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!(
|
||||
"core price_now overflow; token={}, base={}, factor=10^{}, tvl_sats={}, tvl_tokens={}",
|
||||
token_id, price_now_dec, decimals_u32, tvl_sats, tvl_tokens
|
||||
);
|
||||
|
||||
// Keep existing fallback behavior here.
|
||||
let p_now_human_f = overflow_f64_fallback(price_now_dec, decimals_u32);
|
||||
let p_now_usd_f = {
|
||||
let usd_per_sat_f = dec_to_f64_bounded(usd_per_sat_now);
|
||||
let usd = p_now_human_f * usd_per_sat_f;
|
||||
if usd.is_finite() {
|
||||
usd
|
||||
} else {
|
||||
f64::MAX.copysign(usd)
|
||||
}
|
||||
};
|
||||
|
||||
let vol = vol_by_token.get(&token_id).copied().unwrap_or(0);
|
||||
let score = compute_score(tvl_sats, vol);
|
||||
let labels_opt =
|
||||
resolve_display_labels(bcmr_conn, crc20_conn, &token_id).ok();
|
||||
let (display_name, display_symbol) = labels_opt
|
||||
.map(|(dn, sym)| (Some(dn), Some(sym)))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
upd.execute(params![
|
||||
&token_blob,
|
||||
vol as i64,
|
||||
score,
|
||||
Option::<f64>::None, // price_24h
|
||||
Option::<f64>::None, // price_7d
|
||||
Option::<i64>::None, // change_24h_bp
|
||||
Option::<i64>::None, // change_7d_bp
|
||||
p_now_usd_f, // fallback (finite) on this branch
|
||||
Option::<f64>::None, // price_24h_usd
|
||||
Option::<f64>::None, // price_7d_usd
|
||||
Option::<i64>::None, // change_24h_usd_bp
|
||||
Option::<i64>::None, // change_7d_usd_bp
|
||||
display_name,
|
||||
display_symbol,
|
||||
])?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// NOTE: If multiplying human * usd_per_sat overflows, write NULL (unknown).
|
||||
let price_now_usd_dec_opt = price_now_human_dec.checked_mul(usd_per_sat_now);
|
||||
let price_now_usd_f_opt =
|
||||
price_now_usd_dec_opt.map(|d| dec_to_f64_bounded(dec_round(d, 12)));
|
||||
|
||||
// volume/score
|
||||
let vol = vol_by_token.get(&token_id).copied().unwrap_or(0);
|
||||
let score = compute_score(tvl_sats, vol);
|
||||
|
||||
// labels
|
||||
let labels_opt = resolve_display_labels(bcmr_conn, crc20_conn, &token_id).ok();
|
||||
let (display_name, display_symbol): (Option<String>, Option<String>) =
|
||||
match labels_opt {
|
||||
Some((dn, sym)) => (Some(dn), Some(sym)),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
// We'll need TokenID for historical probes
|
||||
let tid = match TokenID::from_hex(&token_id) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
// still update the "now" fields and basics
|
||||
upd.execute(params![
|
||||
&token_blob,
|
||||
vol as i64,
|
||||
score,
|
||||
Option::<f64>::None,
|
||||
Option::<f64>::None,
|
||||
Option::<i64>::None,
|
||||
Option::<i64>::None,
|
||||
price_now_usd_f_opt, // ← may be NULL if overflowed
|
||||
Option::<f64>::None,
|
||||
Option::<f64>::None,
|
||||
Option::<i64>::None,
|
||||
Option::<i64>::None,
|
||||
display_name,
|
||||
display_symbol,
|
||||
])?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if vol == 0 {
|
||||
// --------- FAST PATH (no trades in 30d) ----------
|
||||
// Only gate by *existence* of a historical point; if none → keep NULLs
|
||||
let p24_exists = matches!(
|
||||
price_at_or_before_2(cauldron_conn, ts_24h, &tid),
|
||||
Ok((_ts, _))
|
||||
);
|
||||
let p7d_exists = matches!(
|
||||
price_at_or_before_2(cauldron_conn, ts_7d, &tid),
|
||||
Ok((_ts, _))
|
||||
);
|
||||
|
||||
let price_24h_human_f = if p24_exists {
|
||||
Some(dec_to_f64_bounded(dec_round(price_now_human_dec, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let price_7d_human_f = if p7d_exists {
|
||||
Some(dec_to_f64_bounded(dec_round(price_now_human_dec, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let d24_bp_opt = if p24_exists { Some(0) } else { None };
|
||||
let d7d_bp_opt = if p7d_exists { Some(0) } else { None };
|
||||
|
||||
let price_24h_usd_f = if p24_exists && !usd_per_sat_24h.is_zero() {
|
||||
price_now_human_dec
|
||||
.checked_mul(usd_per_sat_24h)
|
||||
.map(|d| dec_to_f64_bounded(dec_round(d, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let price_7d_usd_f = if p7d_exists && !usd_per_sat_7d.is_zero() {
|
||||
price_now_human_dec
|
||||
.checked_mul(usd_per_sat_7d)
|
||||
.map(|d| dec_to_f64_bounded(dec_round(d, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let old24_u_dec_opt = price_24h_usd_f.and_then(Decimal::from_f64);
|
||||
let old7d_u_dec_opt = price_7d_usd_f.and_then(Decimal::from_f64);
|
||||
|
||||
let d24_usd_bp_opt = match (price_now_usd_dec_opt, old24_u_dec_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
let d7d_usd_bp_opt = match (price_now_usd_dec_opt, old7d_u_dec_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
upd.execute(params![
|
||||
&token_blob,
|
||||
vol as i64,
|
||||
score,
|
||||
price_24h_human_f,
|
||||
price_7d_human_f,
|
||||
d24_bp_opt,
|
||||
d7d_bp_opt,
|
||||
price_now_usd_f_opt, // ← may be NULL
|
||||
price_24h_usd_f,
|
||||
price_7d_usd_f,
|
||||
d24_usd_bp_opt,
|
||||
d7d_usd_bp_opt,
|
||||
display_name,
|
||||
display_symbol,
|
||||
])?;
|
||||
continue;
|
||||
// Keep existing fallback behavior here.
|
||||
let p_now_human_f = overflow_f64_fallback(price_now_dec, decimals_u32);
|
||||
let p_now_usd_f = {
|
||||
let usd_per_sat_f = dec_to_f64_bounded(usd_per_sat_now);
|
||||
let usd = p_now_human_f * usd_per_sat_f;
|
||||
if usd.is_finite() {
|
||||
usd
|
||||
} else {
|
||||
f64::MAX.copysign(usd)
|
||||
}
|
||||
};
|
||||
|
||||
// --------- FULL PATH (vol > 0) ----------
|
||||
let p24_dec_opt = price_at_or_before_2(cauldron_conn, ts_24h, &tid)
|
||||
.ok()
|
||||
.and_then(|(_, p)| Decimal::from_f64(p));
|
||||
let p7d_dec_opt = price_at_or_before_2(cauldron_conn, ts_7d, &tid)
|
||||
.ok()
|
||||
.and_then(|(_, p)| Decimal::from_f64(p));
|
||||
let vol = vol_by_token.get(&token_id).copied().unwrap_or(0);
|
||||
let score = compute_score(tvl_sats, vol);
|
||||
let labels_opt = resolve_display_labels(&bcmr_r, &crc20_r, &token_id).ok();
|
||||
let (display_name, display_symbol) = labels_opt
|
||||
.map(|(dn, sym)| (Some(dn), Some(sym)))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// scale to “human” with overflow guard (nullable)
|
||||
let price_24h_human_opt = p24_dec_opt.and_then(|p| p.checked_mul(factor));
|
||||
let price_7d_human_opt = p7d_dec_opt.and_then(|p| p.checked_mul(factor));
|
||||
updates.push(TokenMetricsUpdate {
|
||||
token_blob,
|
||||
trade_volume: vol as i64,
|
||||
score,
|
||||
price_24h: None,
|
||||
price_7d: None,
|
||||
change_24h_bp: None,
|
||||
change_7d_bp: None,
|
||||
price_now_usd: Some(p_now_usd_f),
|
||||
price_24h_usd: None,
|
||||
price_7d_usd: None,
|
||||
change_24h_usd_bp: None,
|
||||
change_7d_usd_bp: None,
|
||||
display_name,
|
||||
display_symbol,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// BCH deltas (nullable)
|
||||
let d24_bp_opt = p24_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
||||
let d7d_bp_opt = p7d_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
||||
// NOTE: If multiplying human * usd_per_sat overflows, write NULL (unknown).
|
||||
let price_now_usd_dec_opt = price_now_human_dec.checked_mul(usd_per_sat_now);
|
||||
let price_now_usd_f_opt =
|
||||
price_now_usd_dec_opt.map(|d| dec_to_f64_bounded(dec_round(d, 12)));
|
||||
|
||||
// USD historicals (nullable if oracle missing or overflow)
|
||||
let price_24h_usd_opt = match (price_24h_human_opt, !usd_per_sat_24h.is_zero())
|
||||
{
|
||||
(Some(h), true) => h.checked_mul(usd_per_sat_24h),
|
||||
_ => None,
|
||||
};
|
||||
let price_7d_usd_opt = match (price_7d_human_opt, !usd_per_sat_7d.is_zero()) {
|
||||
(Some(h), true) => h.checked_mul(usd_per_sat_7d),
|
||||
_ => None,
|
||||
};
|
||||
// volume/score
|
||||
let vol = vol_by_token.get(&token_id).copied().unwrap_or(0);
|
||||
let score = compute_score(tvl_sats, vol);
|
||||
|
||||
let price_24h_human_f =
|
||||
p24_dec_opt.map(|p24| to_human_f64_with_fallback(p24, decimals_u32));
|
||||
let price_7d_human_f =
|
||||
p7d_dec_opt.map(|p7d| to_human_f64_with_fallback(p7d, decimals_u32));
|
||||
let price_24h_usd_f = to_usd_f64_with_fallback(
|
||||
price_24h_human_opt,
|
||||
usd_per_sat_24h,
|
||||
decimals_u32,
|
||||
);
|
||||
let price_7d_usd_f =
|
||||
to_usd_f64_with_fallback(price_7d_human_opt, usd_per_sat_7d, decimals_u32);
|
||||
// labels
|
||||
let labels_opt = resolve_display_labels(&bcmr_r, &crc20_r, &token_id).ok();
|
||||
let (display_name, display_symbol): (Option<String>, Option<String>) = match labels_opt {
|
||||
Some((dn, sym)) => (Some(dn), Some(sym)),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
let d24_usd_bp_opt = match (price_now_usd_dec_opt, price_24h_usd_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
let d7d_usd_bp_opt = match (price_now_usd_dec_opt, price_7d_usd_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
// We'll need TokenID for historical probes
|
||||
let tid = match TokenID::from_hex(&token_id) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
// still update the "now" fields and basics
|
||||
updates.push(TokenMetricsUpdate {
|
||||
token_blob,
|
||||
trade_volume: vol as i64,
|
||||
score,
|
||||
price_24h: None,
|
||||
price_7d: None,
|
||||
change_24h_bp: None,
|
||||
change_7d_bp: None,
|
||||
price_now_usd: price_now_usd_f_opt,
|
||||
price_24h_usd: None,
|
||||
price_7d_usd: None,
|
||||
change_24h_usd_bp: None,
|
||||
change_7d_usd_bp: None,
|
||||
display_name,
|
||||
display_symbol,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
upd.execute(params![
|
||||
&token_blob,
|
||||
vol as i64,
|
||||
score,
|
||||
price_24h_human_f,
|
||||
price_7d_human_f,
|
||||
d24_bp_opt,
|
||||
d7d_bp_opt,
|
||||
price_now_usd_f_opt, // ← may be NULL on overflow
|
||||
price_24h_usd_f,
|
||||
price_7d_usd_f,
|
||||
d24_usd_bp_opt,
|
||||
d7d_usd_bp_opt,
|
||||
display_name,
|
||||
display_symbol,
|
||||
])?;
|
||||
} // while
|
||||
} // scope statements
|
||||
if vol == 0 {
|
||||
// --------- FAST PATH (no trades in 30d) ----------
|
||||
// Only gate by *existence* of a historical point; if none → keep NULLs
|
||||
let p24_exists = matches!(
|
||||
price_at_or_before_2(&cauldron_r, ts_24h, &tid),
|
||||
Ok((_ts, _))
|
||||
);
|
||||
let p7d_exists = matches!(price_at_or_before_2(&cauldron_r, ts_7d, &tid), Ok((_ts, _)));
|
||||
|
||||
// ranking (after statements dropped)
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
WITH ranked AS (
|
||||
SELECT token_id,
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY
|
||||
score DESC,
|
||||
trade_volume DESC,
|
||||
tvl_sats DESC,
|
||||
token_id ASC
|
||||
) AS rnk
|
||||
FROM cached_token_metrics
|
||||
WHERE tvl_sats > 0
|
||||
)
|
||||
UPDATE cached_token_metrics
|
||||
SET score_rank = (SELECT rnk
|
||||
FROM ranked
|
||||
WHERE ranked.token_id = cached_token_metrics.token_id)
|
||||
WHERE token_id IN (SELECT token_id FROM ranked);
|
||||
"#,
|
||||
)?;
|
||||
let price_24h_human_f = if p24_exists {
|
||||
Some(dec_to_f64_bounded(dec_round(price_now_human_dec, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
},
|
||||
Duration::from_secs(120),
|
||||
)?;
|
||||
let price_7d_human_f = if p7d_exists {
|
||||
Some(dec_to_f64_bounded(dec_round(price_now_human_dec, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let d24_bp_opt = if p24_exists { Some(0) } else { None };
|
||||
let d7d_bp_opt = if p7d_exists { Some(0) } else { None };
|
||||
|
||||
let price_24h_usd_f = if p24_exists && !usd_per_sat_24h.is_zero() {
|
||||
price_now_human_dec
|
||||
.checked_mul(usd_per_sat_24h)
|
||||
.map(|d| dec_to_f64_bounded(dec_round(d, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let price_7d_usd_f = if p7d_exists && !usd_per_sat_7d.is_zero() {
|
||||
price_now_human_dec
|
||||
.checked_mul(usd_per_sat_7d)
|
||||
.map(|d| dec_to_f64_bounded(dec_round(d, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let old24_u_dec_opt = price_24h_usd_f.and_then(Decimal::from_f64);
|
||||
let old7d_u_dec_opt = price_7d_usd_f.and_then(Decimal::from_f64);
|
||||
|
||||
let d24_usd_bp_opt = match (price_now_usd_dec_opt, old24_u_dec_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
let d7d_usd_bp_opt = match (price_now_usd_dec_opt, old7d_u_dec_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
updates.push(TokenMetricsUpdate {
|
||||
token_blob,
|
||||
trade_volume: vol as i64,
|
||||
score,
|
||||
price_24h: price_24h_human_f,
|
||||
price_7d: price_7d_human_f,
|
||||
change_24h_bp: d24_bp_opt,
|
||||
change_7d_bp: d7d_bp_opt,
|
||||
price_now_usd: price_now_usd_f_opt,
|
||||
price_24h_usd: price_24h_usd_f,
|
||||
price_7d_usd: price_7d_usd_f,
|
||||
change_24h_usd_bp: d24_usd_bp_opt,
|
||||
change_7d_usd_bp: d7d_usd_bp_opt,
|
||||
display_name,
|
||||
display_symbol,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// --------- FULL PATH (vol > 0) ----------
|
||||
let p24_dec_opt = price_at_or_before_2(&cauldron_r, ts_24h, &tid)
|
||||
.ok()
|
||||
.and_then(|(_, p)| Decimal::from_f64(p));
|
||||
let p7d_dec_opt = price_at_or_before_2(&cauldron_r, ts_7d, &tid)
|
||||
.ok()
|
||||
.and_then(|(_, p)| Decimal::from_f64(p));
|
||||
|
||||
// scale to "human" with overflow guard (nullable)
|
||||
let price_24h_human_opt = p24_dec_opt.and_then(|p| p.checked_mul(factor));
|
||||
let price_7d_human_opt = p7d_dec_opt.and_then(|p| p.checked_mul(factor));
|
||||
|
||||
// BCH deltas (nullable)
|
||||
let d24_bp_opt = p24_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
||||
let d7d_bp_opt = p7d_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
||||
|
||||
// USD historicals (nullable if oracle missing or overflow)
|
||||
let price_24h_usd_opt = match (price_24h_human_opt, !usd_per_sat_24h.is_zero()) {
|
||||
(Some(h), true) => h.checked_mul(usd_per_sat_24h),
|
||||
_ => None,
|
||||
};
|
||||
let price_7d_usd_opt = match (price_7d_human_opt, !usd_per_sat_7d.is_zero()) {
|
||||
(Some(h), true) => h.checked_mul(usd_per_sat_7d),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let price_24h_human_f =
|
||||
p24_dec_opt.map(|p24| to_human_f64_with_fallback(p24, decimals_u32));
|
||||
let price_7d_human_f = p7d_dec_opt.map(|p7d| to_human_f64_with_fallback(p7d, decimals_u32));
|
||||
let price_24h_usd_f =
|
||||
to_usd_f64_with_fallback(price_24h_human_opt, usd_per_sat_24h, decimals_u32);
|
||||
let price_7d_usd_f =
|
||||
to_usd_f64_with_fallback(price_7d_human_opt, usd_per_sat_7d, decimals_u32);
|
||||
|
||||
let d24_usd_bp_opt = match (price_now_usd_dec_opt, price_24h_usd_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
let d7d_usd_bp_opt = match (price_now_usd_dec_opt, price_7d_usd_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
updates.push(TokenMetricsUpdate {
|
||||
token_blob,
|
||||
trade_volume: vol as i64,
|
||||
score,
|
||||
price_24h: price_24h_human_f,
|
||||
price_7d: price_7d_human_f,
|
||||
change_24h_bp: d24_bp_opt,
|
||||
change_7d_bp: d7d_bp_opt,
|
||||
price_now_usd: price_now_usd_f_opt,
|
||||
price_24h_usd: price_24h_usd_f,
|
||||
price_7d_usd: price_7d_usd_f,
|
||||
change_24h_usd_bp: d24_usd_bp_opt,
|
||||
change_7d_usd_bp: d7d_usd_bp_opt,
|
||||
display_name,
|
||||
display_symbol,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== WRITE PHASE: batch updates in chunks ==========
|
||||
// Acquire single write connection for all batches + ranking
|
||||
let cauldron_w = db
|
||||
.cauldron_w
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("cauldron_w pool: {e}"))?;
|
||||
|
||||
for chunk in updates.chunks(WRITE_CHUNK) {
|
||||
flush_metrics_update_batch(&cauldron_w, chunk)?;
|
||||
}
|
||||
|
||||
// Ranking update (separate short transaction, same connection)
|
||||
recompute_score_ranks(&cauldron_w)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -801,18 +835,9 @@ pub fn spawn_token_metrics_updater(db: DB, indexing_in_progress: Arc<AtomicBool>
|
|||
continue;
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
if let (Ok(cw), Ok(bcmr), Ok(crc), Ok(orc)) = (
|
||||
db.cauldron_w.get(),
|
||||
db.bcmr_r.get(),
|
||||
db.crc20_r.get(),
|
||||
db.oracle_r.get(),
|
||||
) {
|
||||
match update_changes_score_volume_and_ranking(&cw, &bcmr, &crc, &orc) {
|
||||
Ok(_) => info!("✅ core(5m) in {:.3?}", t0.elapsed()),
|
||||
Err(e) => error!("❌ core(5m) failed after {:.3?}: {e:?}", t0.elapsed()),
|
||||
}
|
||||
} else {
|
||||
error!("Pool checkout failed for core tick");
|
||||
match update_changes_score_volume_and_ranking(&db) {
|
||||
Ok(_) => info!("✅ core(5m) in {:.3?}", t0.elapsed()),
|
||||
Err(e) => error!("❌ core(5m) failed after {:.3?}: {e:?}", t0.elapsed()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -637,9 +637,6 @@ mod tests {
|
|||
fn core_updater_keeps_deltas_null_for_young_token() {
|
||||
let mock = mock_db_pool(|conn| setup_basic_schemas(conn));
|
||||
let cw = mock.cauldron_w.get().unwrap();
|
||||
let bcmr = mock.bcmr_r.get().unwrap();
|
||||
let crc = mock.crc20_r.get().unwrap();
|
||||
let orc = mock.oracle_r.get().unwrap();
|
||||
|
||||
// Seed a YOUNG token with *volume* so we do the full path, but with no 24h anchor.
|
||||
// Make both events within the last hour.
|
||||
|
|
@ -659,7 +656,7 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
// Run core updater; for a "young" token with no historicals, deltas remain NULL
|
||||
update_changes_score_volume_and_ranking(&cw, &bcmr, &crc, &orc).unwrap();
|
||||
update_changes_score_volume_and_ranking(&mock).unwrap();
|
||||
|
||||
let (c24, c7d, c24u, c7du): (Option<i64>, Option<i64>, Option<i64>, Option<i64>) = cw
|
||||
.query_row(
|
||||
|
|
@ -1121,9 +1118,6 @@ mod tests {
|
|||
let mock = mock_db_pool(|conn| setup_basic_schemas(conn));
|
||||
let cw = mock.cauldron_w.get().unwrap();
|
||||
let bcmr_w = mock.bcmr_w.get().unwrap();
|
||||
let bcmr_r = mock.bcmr_r.get().unwrap();
|
||||
let crc_r = mock.crc20_r.get().unwrap();
|
||||
let orc_r = mock.oracle_r.get().unwrap();
|
||||
|
||||
let token = TokenID::from_inner([0xEE; 32]);
|
||||
let utxo = OutPointHash::from_inner([0xCD; 32]);
|
||||
|
|
@ -1184,7 +1178,7 @@ mod tests {
|
|||
ON CONFLICT(token_id) DO NOTHING", rusqlite::params![token.to_blob()]).unwrap();
|
||||
|
||||
// Should not panic
|
||||
update_changes_score_volume_and_ranking(&cw, &bcmr_r, &crc_r, &orc_r).unwrap();
|
||||
update_changes_score_volume_and_ranking(&mock).unwrap();
|
||||
|
||||
// Prices likely NULL after overflow guard
|
||||
let (p_now_usd, p_24h, p_7d): (Option<f64>, Option<f64>, Option<f64>) =
|
||||
|
|
|
|||
|
|
@ -335,11 +335,13 @@ pub fn first_pool_creation(token: &str, dbp: &State<DB>) -> CachedApiResult<Valu
|
|||
|
||||
match db_first_pool_creation_row(&conn, &token_hex) {
|
||||
Ok(Some((creation_utxo, txid, timestamp, block_height))) => {
|
||||
// Opportunistically cache the timestamp forever
|
||||
let cw = dbp.cauldron_w.get().map_err(db_error)?;
|
||||
if let Err(e) = cache_first_pool_ts_if_empty(&cw, &token_hex, timestamp) {
|
||||
// Non-fatal: return the data
|
||||
log::warn!("Failed to cache first_pool_ts for {token_hex}: {e}");
|
||||
// Opportunistically cache the timestamp forever (non-blocking)
|
||||
// Use try_get() to avoid blocking if write pool is busy
|
||||
if let Some(cw) = dbp.cauldron_w.try_get() {
|
||||
if let Err(e) = cache_first_pool_ts_if_empty(&cw, &token_hex, timestamp) {
|
||||
// Non-fatal: return the data
|
||||
log::warn!("Failed to cache first_pool_ts for {token_hex}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cached_ok(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue