Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion dotnet/EcencyApi/Handlers/WalletApi.Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ private static async Task<JsonArray> FetchEngineTokensWithBalance(string usernam

var tokensTask = FetchEngineTokens(symbols);
var metricsTask = FetchEngineMetrics(symbols);
var unclaimedTask = FetchEngineRewards(username);
// The rewards upstream allows 30s, but this whole fetch must fit
// the portfolioV2 engine leg budget (4.5s) — a slow rewards call
// would otherwise blank the entire engine layer. Rewards are
// decoration (pendingToken badges); degrade to none instead.
var unclaimedTask = WithTimeout(FetchEngineRewards(username), EngineRewardsTimeoutMs, new JsonArray());
Comment thread
greptile-apps[bot] marked this conversation as resolved.
await Task.WhenAll(tokensTask, metricsTask, unclaimedTask);
var tokens = tokensTask.Result;
var metrics = metricsTask.Result;
Expand Down Expand Up @@ -295,6 +299,7 @@ await Task.WhenAll(globalPropsTask, accountTask, marketTask, pointsTask, engineT

private const int FastLegTimeout = 3000;
private const int SlowLegTimeout = 4500;
private const int EngineRewardsTimeoutMs = 2000;

private static async Task<T> WithTimeout<T>(Task<T> task, int ms, T fallback)
{
Expand Down
41 changes: 38 additions & 3 deletions dotnet/EcencyApi/Handlers/WalletApi.Layers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -659,10 +659,28 @@ internal static async Task<List<R>> ProcessWithConcurrencyLimit<T, R>(IReadOnlyL
return results.ToList();
}

// Per-wallet cap on the balance fetch. The chain providers allow 10s per
// attempt, but the portfolioV2 chain leg has a 4.5s budget for the WHOLE
// layer — without a per-wallet cap, one cold/slow provider blows the leg
// timeout and the entire chain layer silently disappears from the response
// (observed intermittently in production). The capped wallet degrades to
// an error item instead of sinking its siblings. The concurrency width
// must keep ceil(n / width) * cap under the leg budget: at width 8, up to
// 16 wallets (2 batches) finish worst-case in 4s.
private const int ChainBalanceTimeoutMs = 2000;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
private const int ChainFetchConcurrency = 8;
// Hard bound so the worst case is exactly two waves (4s < 4.5s leg) no
// matter how many addresses a profile lists. Wallets past the cap are not
// fetched but still appear as error items, so the response acknowledges
// every configured wallet instead of silently omitting the tail.
private const int MaxChainWallets = 16;

internal static async Task<List<JsonObject>> BuildChainLayer(JsonNode? accountData, JsonNode? marketData, string currency, bool? onlyEnabled)
{
var wallets = ExtractExternalWallets(accountData, onlyEnabled == true);
if (wallets.Count == 0) return new List<JsonObject>();
var skipped = wallets.Skip(MaxChainWallets).ToList();
if (skipped.Count > 0) wallets = wallets.Take(MaxChainWallets).ToList();

var items = await ProcessWithConcurrencyLimit(wallets, async wallet =>
{
Expand All @@ -672,7 +690,10 @@ internal static async Task<List<JsonObject>> BuildChainLayer(JsonNode? accountDa

try
{
var data = await PrivateApi.FetchChainBalance(chain, wallet.Address);
var fetchTask = PrivateApi.FetchChainBalance(chain, wallet.Address);
var completed = await Task.WhenAny(fetchTask, Task.Delay(ChainBalanceTimeoutMs));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (completed != fetchTask) throw new Exception("Chain balance request timed out");
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +693 to +695

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep timed-out chain requests inside the concurrency cap

When a wallet fetch takes longer than 2s, this returns an error item but does not cancel or await the PrivateApi.FetchChainBalance task, whose providers can keep running for their own 10s timeouts. For accounts with several slow external wallets, ProcessWithConcurrencyLimit releases its semaphore as soon as this timeout path returns, so the next batch starts while the previous provider calls are still consuming sockets and upstream capacity; the intended limit of 3 active chain balance requests is no longer enforced under the exact slow-provider case this change is handling.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair observation on the semaphore accounting. Two bounds keep it tame: in-flight dedup in FetchChainBalance means at most one live fetch per (chain, address) regardless of how many requests or batches overlap, and the width bump in the latest commit makes realistic pools single-batch so the overlap window mostly disappears. True cancellation would need plumbing through the provider stack; not worth it for a deduplicated background call that also warms the balance cache.

var data = await fetchTask;
var balance = ConvertChainBalanceToAmount(data, decimals);
var price = GetTokenPrice(marketData, wallet.Symbol, currency);
var iconUrl = config.IconUrl ?? Constants.AssetIconUrls["CHAIN_PLACEHOLDER"];
Expand All @@ -695,9 +716,23 @@ internal static async Task<List<JsonObject>> BuildChainLayer(JsonNode? accountDa
new ItemOptions { Address = wallet.Address, Error = errorMessage },
config.IconUrl ?? Constants.AssetIconUrls["CHAIN_PLACEHOLDER"], ChainActions());
}
}, 3);
}, ChainFetchConcurrency);

return items.Where(x => x != null).ToList();
var result = items.Where(x => x != null).ToList();
foreach (var wallet in skipped)
{
var chain = wallet.Chain.ToLowerInvariant();
var config = ChainConfigs[chain];
var decimals = (int)(wallet.Decimals ?? config.Decimals);
var price = GetTokenPrice(marketData, wallet.Symbol, currency);
result.Add(MakePortfolioItem(
!string.IsNullOrEmpty(wallet.Name) ? wallet.Name : config.Name,
!string.IsNullOrEmpty(wallet.Symbol) ? wallet.Symbol : config.Symbol,
"chain", 0, price, currency, decimals,
new ItemOptions { Address = wallet.Address, Error = "Wallet limit reached" },
config.IconUrl ?? Constants.AssetIconUrls["CHAIN_PLACEHOLDER"], ChainActions()));
}
return result;
}

// ---- JS numeric string formatting ------------------------------------
Expand Down
Loading