Refactor GlobalPriceCache and LocalPriceCache to pattern snapshot, fetch, update. Parellelize.
This commit is contained in:
@@ -1,65 +1,70 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using MarketData.MarketDataModel;
|
||||
using MarketData.MarketDataModel;
|
||||
using MarketData.Utils;
|
||||
using MarketData.DataAccess;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
|
||||
// This cache is mainly used by gainloss generator. This cache is intended to be front loaded and then used.
|
||||
// This cache will not attempt to load an item that is not found. It does have a Refresh() that will reload only the most recent pricing data from the database in order to
|
||||
// maintain the most updated pricing.
|
||||
namespace MarketData.Cache
|
||||
{
|
||||
public class LocalPriceCache
|
||||
{
|
||||
private Dictionary<String,PricesByDate> priceCache=new Dictionary<String,PricesByDate>();
|
||||
private static LocalPriceCache instance=null;
|
||||
public class LocalPriceCache
|
||||
{
|
||||
private Dictionary<string, PricesByDate> priceCache = new Dictionary<string, PricesByDate>();
|
||||
private static LocalPriceCache instance = null;
|
||||
private DateTime latestDate = Utility.Epoch;
|
||||
private Thread cacheMonitorThread=null;
|
||||
private volatile bool threadRun=true;
|
||||
private int cacheCycle=300000;
|
||||
private Object thisLock=new Object();
|
||||
private Thread cacheMonitorThread = null;
|
||||
private volatile bool threadRun = true;
|
||||
private int cacheCycle = 300000;
|
||||
private object thisLock = new object();
|
||||
private object fetchLock = new object();
|
||||
|
||||
private LocalPriceCache()
|
||||
{
|
||||
cacheMonitorThread=new Thread(new ThreadStart(ThreadProc));
|
||||
private LocalPriceCache()
|
||||
{
|
||||
cacheMonitorThread = new Thread(new ThreadStart(ThreadProc));
|
||||
cacheMonitorThread.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock(thisLock)
|
||||
lock (thisLock)
|
||||
{
|
||||
priceCache=new Dictionary<String,PricesByDate>();
|
||||
priceCache = new Dictionary<string, PricesByDate>();
|
||||
RefreshLatestDate();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock(thisLock)
|
||||
Thread threadToJoin = null;
|
||||
|
||||
lock (thisLock)
|
||||
{
|
||||
if(null==instance || false==threadRun)return;
|
||||
threadRun=false;
|
||||
if(null!=cacheMonitorThread)
|
||||
{
|
||||
MDTrace.WriteLine(LogLevel.DEBUG,String.Format("[LocalPriceCache:Dispose]Thread state is '{0}'. Joining main thread...",Utility.ThreadStateToString(cacheMonitorThread)));
|
||||
cacheMonitorThread.Join(5000);
|
||||
this.cacheMonitorThread=null;
|
||||
}
|
||||
MDTrace.WriteLine(LogLevel.DEBUG,"[LocalPriceCache:Dispose] End");
|
||||
instance=null;
|
||||
if (instance == null || !threadRun) return;
|
||||
threadRun = false;
|
||||
threadToJoin = cacheMonitorThread;
|
||||
cacheMonitorThread = null;
|
||||
instance = null;
|
||||
}
|
||||
|
||||
if (threadToJoin != null)
|
||||
{
|
||||
MDTrace.WriteLine(LogLevel.DEBUG, $"[LocalPriceCache:Dispose] Thread state is '{Utility.ThreadStateToString(threadToJoin)}'. Joining...");
|
||||
threadToJoin.Join(5000);
|
||||
}
|
||||
|
||||
MDTrace.WriteLine(LogLevel.DEBUG, "[LocalPriceCache:Dispose] End");
|
||||
}
|
||||
|
||||
public static LocalPriceCache GetInstance()
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
lock (typeof(LocalPriceCache))
|
||||
{
|
||||
if(null==instance)
|
||||
if (instance == null)
|
||||
{
|
||||
instance=new LocalPriceCache();
|
||||
instance = new LocalPriceCache();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -67,17 +72,17 @@ namespace MarketData.Cache
|
||||
|
||||
public void RefreshLatestDate()
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
lock (thisLock)
|
||||
{
|
||||
latestDate=PricingDA.GetLatestDate();
|
||||
latestDate = PricingDA.GetLatestDate();
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime GetLatestDate()
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
lock (thisLock)
|
||||
{
|
||||
if(Utility.IsEpoch(latestDate))
|
||||
if (Utility.IsEpoch(latestDate))
|
||||
{
|
||||
RefreshLatestDate();
|
||||
}
|
||||
@@ -87,65 +92,118 @@ namespace MarketData.Cache
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
List<string> symbols;
|
||||
Dictionary<string, DateTime> currentMaxDates;
|
||||
|
||||
lock (thisLock)
|
||||
{
|
||||
List<String> symbols=new List<String>(priceCache.Keys);
|
||||
Dictionary<String, DateTime> maxDbDates = PricingDA.GetLatestDates(symbols);
|
||||
RefreshLatestDate();
|
||||
foreach(String symbol in symbols)
|
||||
symbols = priceCache.Keys.ToList();
|
||||
currentMaxDates = priceCache.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.MaxDate);
|
||||
}
|
||||
|
||||
if (symbols.Count == 0) return;
|
||||
|
||||
ConcurrentDictionary<string, PricesByDate> fullReloads = new ConcurrentDictionary<string, PricesByDate>();
|
||||
ConcurrentDictionary<string, Price> singleUpdates = new ConcurrentDictionary<string, Price>();
|
||||
DateTime latestDateFromDb;
|
||||
|
||||
lock (fetchLock)
|
||||
{
|
||||
Dictionary<string, DateTime> maxDbDates = PricingDA.GetLatestDates(symbols);
|
||||
latestDateFromDb = PricingDA.GetLatestDate();
|
||||
|
||||
Parallel.ForEach(symbols, new ParallelOptions { MaxDegreeOfParallelism = 8 }, symbol =>
|
||||
{
|
||||
PricesByDate symbolPrices=priceCache[symbol];
|
||||
DateTime maxDate=symbolPrices.MaxDate; // get the latest date in the cache
|
||||
if(maxDbDates.ContainsKey(symbol) && !maxDbDates[symbol].Date.Equals(maxDate.Date)) // if the cache date and the database date are not equal then reload the cache
|
||||
if (!currentMaxDates.TryGetValue(symbol, out var cachedMax)) return;
|
||||
|
||||
if (maxDbDates.TryGetValue(symbol, out var dbMax) && dbMax.Date != cachedMax.Date)
|
||||
{
|
||||
MDTrace.WriteLine(LogLevel.DEBUG,$"Cache date and Database date for {symbol} are not equal, reloading cache. Cache Date:{maxDate.ToShortDateString()} Database Date:{maxDbDates[symbol].Date.ToShortDateString()}");
|
||||
Prices prices=PricingDA.GetPrices(symbol,symbolPrices.MinDate); // reload the prices for this symbol using the current minDate in the cache as a lower boundary
|
||||
if(null==prices)continue; // if we can't load any prices for symbol then just continue
|
||||
priceCache.Remove(symbol); // remove the pricing entries in the price cache for the symbol
|
||||
priceCache.Add(symbol,prices.GetPricesByDate()); // reload the cache
|
||||
Prices prices = PricingDA.GetPrices(symbol, cachedMax);
|
||||
if (prices != null) fullReloads[symbol] = prices.GetPricesByDate();
|
||||
}
|
||||
else
|
||||
{
|
||||
MDTrace.WriteLine(LogLevel.DEBUG,$"[LocalPriceCache] Fetching latest price from database for {symbol} on {maxDate.ToShortDateString()}");
|
||||
Price price=PricingDA.GetPrice(symbol,maxDate); // the max date from the cache equals the max date from the database so just reload the latest price from the database
|
||||
if(null==price)continue; // if no latest price then just continue
|
||||
symbolPrices.Remove(maxDate); // remove the current price associated with the max date
|
||||
symbolPrices.Add(maxDate,price); // reload the latest price for maxDate(symbol) into the cache
|
||||
{
|
||||
Price price = PricingDA.GetPrice(symbol, cachedMax);
|
||||
if (price != null) singleUpdates[symbol] = price;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
lock (thisLock)
|
||||
{
|
||||
latestDate = latestDateFromDb;
|
||||
|
||||
foreach (var kvp in fullReloads)
|
||||
{
|
||||
if (priceCache.TryGetValue(kvp.Key, out PricesByDate existing) && existing.MaxDate == currentMaxDates[kvp.Key])
|
||||
{
|
||||
priceCache[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kvp in singleUpdates)
|
||||
{
|
||||
if (priceCache.TryGetValue(kvp.Key, out PricesByDate pricesByDate) && pricesByDate.MaxDate == currentMaxDates[kvp.Key])
|
||||
{
|
||||
// Remove the old price (if any) and add the new price properly
|
||||
if (pricesByDate.ContainsKey(kvp.Value.Date))
|
||||
pricesByDate.Remove(kvp.Value.Date);
|
||||
pricesByDate.Add(kvp.Value.Date, kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MDTrace.WriteLine(LogLevel.DEBUG, $"Full reloads: {fullReloads.Count}, Single updates: {singleUpdates.Count}");
|
||||
}
|
||||
|
||||
// This version of Add(PortfolioTrades) will account for adding multiple lots at different times. So instead of just checking for the existance of the symbol in the cache
|
||||
// we look to see if the symbol is in the cache and what dates are available. If the date range specified in the trade are not available then we load those date ranges.
|
||||
// This is a brute force approach always maintaining the gap between successive TradeDates in th.e portfolio trades and the maximum date for the symbol in the database.
|
||||
// So while it is inefficient in terms of memory usage it alleviates the need for figuring out contiguous price sections
|
||||
public void Add(PortfolioTrades portfolioTrades)
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
List<string> symbols = portfolioTrades.Symbols;
|
||||
Dictionary<string, DateTime> minTradeDates = symbols.ToDictionary(sym => sym, sym => portfolioTrades.GetMinTradeDate(sym));
|
||||
|
||||
Dictionary<string, DateTime> minCacheDates;
|
||||
lock (thisLock)
|
||||
{
|
||||
Profiler profiler=new Profiler();
|
||||
profiler.Start();
|
||||
List<String> symbols=portfolioTrades.Symbols;
|
||||
foreach(String symbol in symbols)
|
||||
minCacheDates = symbols.ToDictionary(sym => sym, sym => priceCache.ContainsKey(sym) ? priceCache[sym].MinDate : DateTime.MaxValue);
|
||||
}
|
||||
|
||||
ConcurrentDictionary<string, Prices> fetchedPrices = new ConcurrentDictionary<string, Prices>();
|
||||
|
||||
Parallel.ForEach(symbols, new ParallelOptions { MaxDegreeOfParallelism = 8 }, symbol =>
|
||||
{
|
||||
DateTime minTradeDate = minTradeDates[symbol];
|
||||
DateTime minCacheDate = minCacheDates[symbol];
|
||||
Prices prices = null;
|
||||
|
||||
try
|
||||
{
|
||||
DateTime minPortfolioTradeDate=portfolioTrades.GetMinTradeDate(symbol);
|
||||
if(!ContainsSymbol(symbol))
|
||||
if (minCacheDate == DateTime.MaxValue)
|
||||
{
|
||||
Prices prices=PricingDA.GetPrices(symbol,minPortfolioTradeDate);
|
||||
if(null==prices)continue;
|
||||
foreach(Price price in prices)Add(price);
|
||||
prices = PricingDA.GetPrices(symbol, minTradeDate);
|
||||
}
|
||||
else
|
||||
else if (minTradeDate < minCacheDate)
|
||||
{
|
||||
DateTime minCacheDate=GetMinCacheDate(symbol);
|
||||
if(minPortfolioTradeDate<minCacheDate)
|
||||
{
|
||||
Prices prices=PricingDA.GetPrices(symbol,minCacheDate,minPortfolioTradeDate); // Fill the gap by retrieving prices starting at minCache date and going back in time to minPortfolioTradeDate
|
||||
if(null==prices)continue;
|
||||
foreach(Price price in prices)Add(price);
|
||||
PricesByDate p=priceCache[symbol];
|
||||
}
|
||||
prices = PricingDA.GetPrices(symbol, minCacheDate, minTradeDate);
|
||||
}
|
||||
|
||||
if (prices != null && prices.Count > 0)
|
||||
{
|
||||
fetchedPrices[symbol] = prices;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MDTrace.WriteLine(LogLevel.DEBUG, $"Error fetching prices for {symbol}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
lock (thisLock)
|
||||
{
|
||||
foreach (var kvp in fetchedPrices)
|
||||
{
|
||||
foreach (var price in kvp.Value)
|
||||
{
|
||||
Add(price);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,131 +211,186 @@ namespace MarketData.Cache
|
||||
|
||||
public void Add(Prices prices)
|
||||
{
|
||||
foreach(Price price in prices)
|
||||
{
|
||||
Add(price);
|
||||
}
|
||||
foreach (Price price in prices)
|
||||
{
|
||||
Add(price);
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(List<String> symbols,DateTime pricingDate)
|
||||
public void Add(List<string> symbols, DateTime pricingDate)
|
||||
{
|
||||
foreach(String symbol in symbols)
|
||||
if (symbols == null || symbols.Count == 0) return;
|
||||
|
||||
ConcurrentDictionary<string, Price> fetchedPrices = new ConcurrentDictionary<string, Price>();
|
||||
|
||||
Parallel.ForEach(symbols, new ParallelOptions { MaxDegreeOfParallelism = 8 }, symbol =>
|
||||
{
|
||||
if(ContainsPrice(symbol,pricingDate))continue;
|
||||
Price price=PricingDA.GetPrice(symbol,pricingDate);
|
||||
if(null==price)continue;
|
||||
Add(price);
|
||||
lock (thisLock)
|
||||
{
|
||||
if (ContainsPrice(symbol, pricingDate)) return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Price price = PricingDA.GetPrice(symbol, pricingDate);
|
||||
if (price != null) fetchedPrices[symbol] = price;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MDTrace.WriteLine(LogLevel.DEBUG, $"Error fetching price for {symbol} on {pricingDate:yyyy-MM-dd}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
lock (thisLock)
|
||||
{
|
||||
foreach (var kvp in fetchedPrices)
|
||||
{
|
||||
Add(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(Price price)
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
if (price == null) return;
|
||||
|
||||
lock (thisLock)
|
||||
{
|
||||
if(null==price)return;
|
||||
if(ContainsPrice(price.Symbol,price.Date))return;
|
||||
PricesByDate pricesByDate=null;
|
||||
if(!priceCache.ContainsKey(price.Symbol))
|
||||
if (!priceCache.TryGetValue(price.Symbol, out var pricesByDate))
|
||||
{
|
||||
pricesByDate=new PricesByDate();
|
||||
pricesByDate.Add(price.Date,price);
|
||||
priceCache.Add(price.Symbol,pricesByDate);
|
||||
return;
|
||||
pricesByDate = new PricesByDate();
|
||||
priceCache[price.Symbol] = pricesByDate;
|
||||
}
|
||||
if (!pricesByDate.ContainsKey(price.Date))
|
||||
{
|
||||
pricesByDate.Add(price.Date, price); // must use Add() to update MinDate/MaxDate
|
||||
}
|
||||
pricesByDate=priceCache[price.Symbol];
|
||||
if(pricesByDate.ContainsKey(price.Date))return;
|
||||
pricesByDate.Add(price.Date,price);
|
||||
}
|
||||
}
|
||||
public DateTime GetMinCacheDate(String symbol)
|
||||
|
||||
public DateTime GetMinCacheDate(string symbol)
|
||||
{
|
||||
if(!ContainsSymbol(symbol))return Utility.Epoch;
|
||||
PricesByDate symbolPrices=priceCache[symbol];
|
||||
return symbolPrices.MinDate;
|
||||
lock (thisLock)
|
||||
{
|
||||
if (!priceCache.TryGetValue(symbol, out var symbolPrices) || symbolPrices.Count == 0)
|
||||
{
|
||||
return Utility.Epoch;
|
||||
}
|
||||
return symbolPrices.MinDate;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveDate(DateTime date)
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
lock (thisLock)
|
||||
{
|
||||
List<String> symbols=new List<String>(priceCache.Keys);
|
||||
foreach(String key in symbols)
|
||||
foreach (var kvp in priceCache)
|
||||
{
|
||||
PricesByDate pricesByDate=priceCache[key];
|
||||
if(pricesByDate.ContainsKey(date))pricesByDate.Remove(date);
|
||||
kvp.Value.Remove(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
public Price GetPrice(String symbol,DateTime date)
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
{
|
||||
if(!priceCache.ContainsKey(symbol))return null;
|
||||
PricesByDate pricesByDate=priceCache[symbol];
|
||||
if(!pricesByDate.ContainsKey(date))return null;
|
||||
return pricesByDate[date];
|
||||
}
|
||||
}
|
||||
public bool ContainsPrice(String symbol,DateTime date)
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
{
|
||||
if(!priceCache.ContainsKey(symbol))return false;
|
||||
PricesByDate pricesByDate=priceCache[symbol];
|
||||
if(!pricesByDate.ContainsKey(date))return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
public bool ContainsPrice(List<String> symbols,DateTime date)
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
{
|
||||
foreach(String symbol in symbols)if(!ContainsPrice(symbol,date))return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ContainsSymbol(String symbol)
|
||||
public Prices GetPrices(string symbol, DateTime endDate, int dayCount)
|
||||
{
|
||||
lock(typeof(LocalPriceCache))
|
||||
lock (thisLock)
|
||||
{
|
||||
if(priceCache.ContainsKey(symbol))return true;
|
||||
return false;
|
||||
if (!priceCache.TryGetValue(symbol, out var pricesByDate)) return new Prices();
|
||||
|
||||
DateGenerator dateGenerator = new DateGenerator();
|
||||
List<DateTime> historicalDates = dateGenerator.GenerateHistoricalDates(endDate, dayCount);
|
||||
|
||||
Prices result = new Prices();
|
||||
foreach (DateTime date in historicalDates)
|
||||
{
|
||||
if (pricesByDate.ContainsKey(date))
|
||||
{
|
||||
result.Add(pricesByDate[date]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public long Count()
|
||||
public Price GetPrice(string symbol, DateTime date)
|
||||
{
|
||||
long count=0;
|
||||
List<String> symbols=priceCache.Keys.ToList();
|
||||
foreach(String symbol in symbols)
|
||||
lock (thisLock)
|
||||
{
|
||||
PricesByDate pricesByDate=priceCache[symbol];
|
||||
count+=pricesByDate.Count;
|
||||
if (!priceCache.TryGetValue(symbol, out var pricesByDate)) return null;
|
||||
return pricesByDate.TryGetValue(date, out var price) ? price : null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ContainsPrice(string symbol, DateTime date)
|
||||
{
|
||||
lock (thisLock)
|
||||
{
|
||||
if (!priceCache.TryGetValue(symbol, out var pricesByDate)) return false;
|
||||
return pricesByDate.ContainsKey(date);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ContainsPrice(List<string> symbols, DateTime date)
|
||||
{
|
||||
if (symbols == null || symbols.Count == 0) return false;
|
||||
|
||||
lock (thisLock)
|
||||
{
|
||||
foreach (string symbol in symbols)
|
||||
{
|
||||
if (!priceCache.TryGetValue(symbol, out var pricesByDate) || !pricesByDate.ContainsKey(date))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ContainsSymbol(string symbol)
|
||||
{
|
||||
lock (thisLock)
|
||||
{
|
||||
return priceCache.ContainsKey(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
private long Count()
|
||||
{
|
||||
lock (thisLock)
|
||||
{
|
||||
long count = 0;
|
||||
foreach (var pricesByDate in priceCache.Values)
|
||||
{
|
||||
count += pricesByDate.Count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void ThreadProc()
|
||||
{
|
||||
int quantums=0;
|
||||
int quantumInterval=1000;
|
||||
long lastCount=0;
|
||||
while(threadRun)
|
||||
int quantums = 0;
|
||||
int quantumInterval = 1000;
|
||||
long lastCount = 0;
|
||||
|
||||
while (threadRun)
|
||||
{
|
||||
Thread.Sleep(quantumInterval);
|
||||
quantums+=quantumInterval;
|
||||
if(quantums>cacheCycle)
|
||||
quantums += quantumInterval;
|
||||
if (quantums > cacheCycle)
|
||||
{
|
||||
quantums=0;
|
||||
lock(thisLock)
|
||||
quantums = 0;
|
||||
lock (thisLock)
|
||||
{
|
||||
lastCount=Count();
|
||||
MDTrace.WriteLine(LogLevel.DEBUG,String.Format("[LocalPriceCache:ThreadProc] Symbols: {0}. Items in cache: {1}.",priceCache.Keys.Count,Utility.FormatNumber(lastCount,0,true)));
|
||||
lastCount = Count();
|
||||
MDTrace.WriteLine(LogLevel.DEBUG, $"[LocalPriceCache:ThreadProc] Symbols: {priceCache.Keys.Count}. Items in cache: {Utility.FormatNumber(lastCount,0,true)}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
MDTrace.WriteLine(LogLevel.DEBUG,String.Format("[LocalPriceCache:ThreadProc] Thread ended. Items in cache:{0}",Utility.FormatNumber(lastCount,0,true)));
|
||||
|
||||
MDTrace.WriteLine(LogLevel.DEBUG, $"[LocalPriceCache:ThreadProc] Thread ended. Items in cache:{Utility.FormatNumber(lastCount,0,true)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user