You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
cachedFetcher loads all tokens into memory causing excessive memory allocation #1310
The cachedFetcher (used by Mixed strategy) loads ALL tokens from the database into memory, regardless of which user's tokens are actually needed. This causes significant memory allocation visible in pprof.
Empty string parameters result in SQL query without wallet/type filters:
SELECT * FROM tokens WHERE spendable = true
-- Returns ALL tokens from ALL users
Impact
100K tokens: 56.50MB allocated per refresh
1M tokens: 565MB allocated per refresh
Refreshes every 1 second OR every 5 queries
Memory grows unbounded with database size
High GC pressure and system slowdown
Configuration
Set in token/sdk/dig/sdk.go:147:
sherdlock.NewFetcherProvider(..., sherdlock.Mixed)
The current strategy of fetching spendable tokens using cachedFetcher cache is fast however it is not scalable since the cache has no size limits or eviction policy, and loads all tokens even though only one user's tokens are needed per transfer request.
One solution is to replace the current cache unlimited implementation (map) to LRU cache.
// UnspentTokenInWallet models an unspent token owner solely by a given wallet
type UnspentTokenInWallet struct {
// Id is used to uniquely identify the token in the ledger
Id ID
// WalletID is the ID of the wallet owning this token
WalletID string
// Type is the type of the token
Type Type
// Quantity represents the number of units of Type that this unspent token holds.
Quantity string
}
The current cachedFetcher implementation uses an unbounded map that loads ALL tokens from the database into memory, causing excessive memory allocation visible in pprof. This leads to:
Unbounded memory growth as token count increases
Memory pressure on nodes with many tokens
Potential OOM (Out of Memory) issues in production
The current proposed solution is to use Ristretto Cache with LRU Eviction.
Replaced the unbounded map with Ristretto cache providing:
LRU (Least Recently Used) eviction - automatically removes least-used entries when limit reached
Configurable memory limits - prevents unbounded growth
Default: 100M entries - reasonable limit for most deployments
Configuration
token:
selector:
fetcherCacheSize: 100000000 # Max cache entries (0 = use default 100M)
fetcherCacheRefresh: 1s # How often to refresh cache
fetcherCacheMaxQueries: 5 # Queries before triggering refresh
Current Update Strategy: Eager Loading
How It Works
The cache uses eager loading with two update triggers:
Soft Update (Background, Non-blocking)
Trigger: After fetcherCacheMaxQueries queries (default: 5)
Starts background goroutine to refresh cache, fetches ALL tokens from DB: SpendableTokensIteratorBy(ctx, "", "")
User request returns immediately with current cached data
Update happens asynchronously
Hard Update (Blocking)
Trigger: When cache age exceeds fetcherCacheRefresh (default: 1 second)
Blocks the requesting thread
Fetches ALL tokens from DB: SpendableTokensIteratorBy(ctx, "", "")
Clears cache and repopulates with fresh data
Cache Miss Behavior
When a requested key is not in cache:
Returns empty iterator (no tokens found)
Falls back to lazy fetcher which queries DB directly for that specific wallet/currency
A better strategy is to keep following variables per cache key (type/owner combination) and lock/fetch only the 'expired' entry
This can be addressed in the future under: #633
Activity
[-]v[/-][+]cachedFetcher loads all tokens into memory causing excessive memory allocation[/+]AkramBitar commentedon Feb 4, 2026
Memory Analysis Summary
Token Memory: 289 bytes/token (worst case)
Breakdown
Token Data (156 bytes)
// UnspentTokenInWallet models an unspent token owner solely by a given wallet
type UnspentTokenInWallet struct {
// Id is used to uniquely identify the token in the ledger
Id ID
// WalletID is the ID of the wallet owning this token
WalletID string
// Type is the type of the token
Type Type
// Quantity represents the number of units of Type that this unspent token holds.
Quantity string
}
Looking in actual data in database:
The size of each row is:
size per row=723682/76956.75 ≈ 0.1063 KB per row
0.1063 KB×1024≈108.9 bytes per row in storage which is 156 bytes in go.
Go Runtime Overhead (133 bytes)
Total: 156 + 133 = 289 bytes per token
Scale Impact
Refresh Performance (Measured)
Pure Cache Update Time (No Database)
Simulation Code
This test measures ONLY the cache update operations, excluding database I/O:
Pure Cache Update Results without Database Overhead
The question now how much token per node we need to support?
Estimate Database Overhead
Number of rows:
(actual time=4.604..614.442 rows=723682 loops=1)
Total execution time
Execution Time: 635.819 ms
Average time per row
635.819 ms / 723682 ≈ 0.000878ms = 0.879μs per row
Complete Operation Time: Database SELECT + Cache Update
Performance Metrics
AkramBitar commentedon Feb 14, 2026
The current cachedFetcher implementation uses an unbounded map that loads ALL tokens from the database into memory, causing excessive memory allocation visible in pprof. This leads to:
Unbounded memory growth as token count increases
Memory pressure on nodes with many tokens
Potential OOM (Out of Memory) issues in production
The current proposed solution is to use Ristretto Cache with LRU Eviction.
Replaced the unbounded map with Ristretto cache providing:
LRU (Least Recently Used) eviction - automatically removes least-used entries when limit reached
Configurable memory limits - prevents unbounded growth
Default: 100M entries - reasonable limit for most deployments
Configuration
token:
selector:
fetcherCacheSize: 100000000 # Max cache entries (0 = use default 100M)
fetcherCacheRefresh: 1s # How often to refresh cache
fetcherCacheMaxQueries: 5 # Queries before triggering refresh
Current Update Strategy: Eager Loading
How It Works
The cache uses eager loading with two update triggers:
Trigger: After fetcherCacheMaxQueries queries (default: 5)
Starts background goroutine to refresh cache, fetches ALL tokens from DB: SpendableTokensIteratorBy(ctx, "", "")
User request returns immediately with current cached data
Update happens asynchronously
Trigger: When cache age exceeds fetcherCacheRefresh (default: 1 second)
Blocks the requesting thread
Fetches ALL tokens from DB: SpendableTokensIteratorBy(ctx, "", "")
Clears cache and repopulates with fresh data
Cache Miss Behavior
When a requested key is not in cache:
Returns empty iterator (no tokens found)
Falls back to lazy fetcher which queries DB directly for that specific wallet/currency
A better strategy is to keep following variables per cache key (type/owner combination) and lock/fetch only the 'expired' entry
This can be addressed in the future under: #633