perf(market): fetch price and market data on a dedicated HTTP lane

The CoinGecko price, tickers and market chart and the exchange candle
GETs ran on the wallet's RPC worker, the same FIFO lane as balance,
history and sends. A slow or blocked endpoint held that lane for up to
10-12 s per price or ticker fetch and 2 x 30 s per chart or candle job,
and a wallet scan in turn delayed the price. At quit a hung GET held
shutdown until the 15 s watchdog cut teardown short.

They now run on their own RPCWorker thread; results are applied on the
main thread and wake the idle loop. A new util::httpGet takes a cancel
flag (checked by libcurl's progress callback), sets CURLOPT_NOSIGNAL and
returns the HTTP status and error; httpGetString wraps it. Shutdown sets
the flag, so queued GETs return at once and one in flight aborts within
about a second. The price uses a plain in-flight flag instead of the
connection-scoped refresh job, so a disconnect no longer throws away a
good in-flight price and leaves it loading until the next tick.

User-visible:
- chart and candle GETs use a 5 s connect / 20 s total timeout (were
  15 s / 30 s);
- the price request goes through the shared GET: User-Agent
  ObsidianDragon/1.0 (was DragonX-Wallet/1.0) and a 16 MiB reply cap;
- market fetches on connect start alongside the wallet refresh instead
  of queuing behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent 93b6871511
commit ad0f60a747
6 changed files with 182 additions and 88 deletions
+47 -57
View File
@@ -3063,52 +3063,41 @@ void App::refreshPeerInfo()
if (!enqueued.enqueued) return;
}
namespace {
// Market-data GET options for the HTTP lane: a short connect timeout (a blocked endpoint must not hold the
// lane for 15 s), a bounded total, and abortable at shutdown through App::http_cancel_. Deliberate change:
// the CoinGecko chart and exchange candle GETs used the updaters' 15 s / 30 s.
util::HttpGetOptions marketHttp(const std::atomic<bool>* cancel, long totalSec)
{
util::HttpGetOptions o;
o.connectTimeoutSec = 5;
o.timeoutSec = totalSec;
o.cancel = cancel;
return o;
}
} // namespace
void App::refreshPrice()
{
// Skip if price fetching is disabled
if (!settings_->getFetchPrices()) return;
if (!worker_) return;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Price, *worker_, [this]() -> rpc::RPCWorker::MainCb {
// --- Worker thread: blocking HTTP GET to CoinGecko ---
if (!http_worker_ || http_cancel_.load(std::memory_order_relaxed)) return;
// A plain in-flight flag, not NetworkRefreshService::enqueue(Job::Price): that one is generation-gated, and
// the resetJobs() of every disconnect would discard a good in-flight price (leaving price_loading set until
// the next tick) for a fetch that has nothing to do with the daemon connection.
if (price_fetch_in_flight_) return;
price_fetch_in_flight_ = true;
NetworkRefreshService::markPriceRefreshStarted(state_);
http_worker_->post([this]() -> rpc::RPCWorker::MainCb {
// --- HTTP lane: blocking GET to CoinGecko. Touches only http_cancel_ and locals. ---
NetworkRefreshService::PriceHttpResult result;
try {
CURL* curl = curl_easy_init();
if (!curl) {
DEBUG_LOGF("Failed to initialize curl for price fetch\n");
result.errorMessage = "Price fetch failed: failed to initialize curl";
} else {
std::string response_data;
const char* url = "https://api.coingecko.com/api/v3/simple/price?ids=dragonx-2&vs_currencies=usd,btc&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true";
auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
size_t totalSize = size * nmemb;
userp->append((char*)contents, totalSize);
return totalSize;
};
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_data);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
CURLcode res = curl_easy_perform(curl);
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
NetworkRefreshService::PriceHttpResponse response{
res == CURLE_OK,
http_code,
std::move(response_data),
res == CURLE_OK ? std::string() : std::string(curl_easy_strerror(res))
};
result = NetworkRefreshService::parsePriceHttpResponse(response, std::time(nullptr));
}
const char* const url = "https://api.coingecko.com/api/v3/simple/price?ids=dragonx-2&vs_currencies=usd,btc&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true";
util::HttpGetResult r = util::httpGet(url, "[price]", marketHttp(&http_cancel_, 10));
NetworkRefreshService::PriceHttpResponse response{
r.transportOk, r.httpStatus, std::move(r.body), r.error};
result = NetworkRefreshService::parsePriceHttpResponse(response, std::time(nullptr));
} catch (const std::exception& e) {
DEBUG_LOGF("Price fetch error: %s\n", e.what());
result.errorMessage = std::string("Price fetch error: ") + e.what();
@@ -3123,7 +3112,9 @@ void App::refreshPrice()
DEBUG_LOGF("%s\n", result.errorMessage.c_str());
}
// Always hand back a callback: it is what clears price_fetch_in_flight_.
return [this, result = std::move(result)]() mutable {
price_fetch_in_flight_ = false;
if (result.price) {
NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now());
if (price_fetch_failed_) {
@@ -3140,9 +3131,7 @@ void App::refreshPrice()
price_fetch_failed_ = true;
}
};
}, 0);
if (!enqueued.enqueued) return;
NetworkRefreshService::markPriceRefreshStarted(state_);
});
}
void App::refreshMarketData()
@@ -3167,7 +3156,8 @@ void App::retryMarketDataNow()
void App::refreshExchangeChart()
{
if (!settings_ || !settings_->getFetchPrices() || !worker_) return;
if (!settings_ || !settings_->getFetchPrices() || !http_worker_ ||
http_cancel_.load(std::memory_order_relaxed)) return;
if (exchange_chart_fetch_in_flight_) return;
// Resolve the selected pair from the same registry the Market tab renders (live tickers, else the
@@ -3225,10 +3215,11 @@ void App::refreshExchangeChart()
const std::string urlIntra = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Intraday, now);
const std::string urlDaily = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Daily, now);
worker_->post([this, identifier, key, urlIntra, urlDaily]() -> rpc::RPCWorker::MainCb {
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]");
std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]");
http_worker_->post([this, identifier, key, urlIntra, urlDaily]() -> rpc::RPCWorker::MainCb {
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series). HTTP lane:
// touches only http_cancel_ and the captures.
std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]", marketHttp(&http_cancel_, 20));
std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]", marketHttp(&http_cancel_, 20));
auto ohlcIntra = data::parseExchangeOHLC(identifier, bIntra);
auto ohlcDaily = data::parseExchangeOHLC(identifier, bDaily);
return [this, key, ohlcIntra = std::move(ohlcIntra), ohlcDaily = std::move(ohlcDaily)]() mutable {
@@ -3278,7 +3269,7 @@ void App::refreshExchangeChart()
void App::refreshMarketChart()
{
if (!settings_ || !settings_->getFetchPrices()) return;
if (!worker_) return;
if (!http_worker_ || http_cancel_.load(std::memory_order_relaxed)) return;
if (chart_fetch_in_flight_) return;
const auto now = std::chrono::steady_clock::now();
// Historical data moves slowly — refresh at most every 30 minutes after the first fetch.
@@ -3289,17 +3280,17 @@ void App::refreshMarketChart()
if (!market_chart_backoff_.due(now)) return;
chart_fetch_in_flight_ = true;
worker_->post([this]() -> rpc::RPCWorker::MainCb {
http_worker_->post([this]() -> rpc::RPCWorker::MainCb {
// Two ranges give real granularity across every sparkline interval:
// days=1 -> 5-minute samples (backs the "hour" interval)
// days=365 -> daily samples (backs "day"/"week"/"month")
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
std::string intradayBody = util::httpGetString(
"https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=1",
"[market-chart]");
"[market-chart]", marketHttp(&http_cancel_, 20));
std::string dailyBody = util::httpGetString(
"https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=365",
"[market-chart]");
"[market-chart]", marketHttp(&http_cancel_, 20));
auto intraday = NetworkRefreshService::parseCoinGeckoMarketChart(intradayBody);
auto daily = NetworkRefreshService::parseCoinGeckoMarketChart(dailyBody);
@@ -3327,19 +3318,18 @@ void App::refreshMarketChart()
void App::refreshExchanges()
{
if (!settings_ || !settings_->getFetchPrices()) return;
if (!worker_) return;
if (!http_worker_ || http_cancel_.load(std::memory_order_relaxed)) return;
if (exchanges_fetch_started_) return; // in flight, or loaded (once per session: venues are near-static)
// The last attempt failed: wait out the backoff. This is called every frame the Market tab is open,
// and a failure used to retry on the very next frame.
if (!exchanges_backoff_.due(std::chrono::steady_clock::now())) return;
exchanges_fetch_started_ = true;
worker_->post([this]() -> rpc::RPCWorker::MainCb {
// httpGetString verifies TLS, caps the response size and returns "" on any failure. Short
// timeouts: this runs on the shared RPC worker.
http_worker_->post([this]() -> rpc::RPCWorker::MainCb {
// httpGetString verifies TLS, caps the response size and returns "" on any failure.
const std::string body = util::httpGetString(
"https://api.coingecko.com/api/v3/coins/dragonx-2/tickers", "[tickers]",
/*connectTimeoutSec=*/5, /*timeoutSec=*/12);
marketHttp(&http_cancel_, 12));
std::vector<data::ExchangeInfo> exchanges;
if (!body.empty()) {
try { exchanges = data::parseCoinGeckoTickers(body); } catch (...) {}