2025-08-22 02:19:48 +01:00
using Dalamud.Utility ;
using K4os.Compression.LZ4.Streams ;
using MareSynchronos.Interop.Ipc ;
using MareSynchronos.MareConfiguration ;
using MareSynchronos.Services.Mediator ;
using MareSynchronos.Utils ;
using Microsoft.Extensions.Hosting ;
using Microsoft.Extensions.Logging ;
using System.Collections.Concurrent ;
using System.Globalization ;
using System.Text ;
namespace MareSynchronos.FileCache ;
public sealed class FileCacheManager : IHostedService
{
public const string CachePrefix = "{cache}" ;
public const string CsvSplit = "|" ;
public const string PenumbraPrefix = "{penumbra}" ;
public const string SubstPrefix = "{subst}" ;
public const string SubstPath = "subst" ;
public string CacheFolder = > _configService . Current . CacheFolder ;
public string SubstFolder = > CacheFolder . IsNullOrEmpty ( ) ? string . Empty : CacheFolder . ToLowerInvariant ( ) . TrimEnd ( '\\' ) + "\\" + SubstPath ;
private readonly MareConfigService _configService ;
private readonly MareMediator _mareMediator ;
private readonly string _csvPath ;
private readonly ConcurrentDictionary < string , List < FileCacheEntity > > _fileCaches = new ( StringComparer . Ordinal ) ;
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new ( 1 , 1 ) ;
private readonly Lock _fileWriteLock = new ( ) ;
private readonly IpcManager _ipcManager ;
private readonly ILogger < FileCacheManager > _logger ;
public FileCacheManager ( ILogger < FileCacheManager > logger , IpcManager ipcManager , MareConfigService configService , MareMediator mareMediator )
{
_logger = logger ;
_ipcManager = ipcManager ;
_configService = configService ;
_mareMediator = mareMediator ;
_csvPath = Path . Combine ( configService . ConfigurationDirectory , "FileCache.csv" ) ;
}
private string CsvBakPath = > _csvPath + ".bak" ;
public FileCacheEntity ? CreateCacheEntry ( string path , string? hash = null )
{
FileInfo fi = new ( path ) ;
if ( ! fi . Exists ) return null ;
_logger . LogTrace ( "Creating cache entry for {path}" , path ) ;
var fullName = fi . FullName . ToLowerInvariant ( ) ;
if ( ! fullName . Contains ( _configService . Current . CacheFolder . ToLowerInvariant ( ) , StringComparison . Ordinal ) ) return null ;
string prefixedPath = fullName . Replace ( _configService . Current . CacheFolder . ToLowerInvariant ( ) , CachePrefix + "\\" , StringComparison . Ordinal ) . Replace ( "\\\\" , "\\" , StringComparison . Ordinal ) ;
if ( hash ! = null )
return CreateFileCacheEntity ( fi , prefixedPath , hash ) ;
else
return CreateFileCacheEntity ( fi , prefixedPath ) ;
}
public FileCacheEntity ? CreateSubstEntry ( string path )
{
FileInfo fi = new ( path ) ;
if ( ! fi . Exists ) return null ;
_logger . LogTrace ( "Creating substitute entry for {path}" , path ) ;
var fullName = fi . FullName . ToLowerInvariant ( ) ;
if ( ! fullName . Contains ( SubstFolder , StringComparison . Ordinal ) ) return null ;
string prefixedPath = fullName . Replace ( SubstFolder , SubstPrefix + "\\" , StringComparison . Ordinal ) . Replace ( "\\\\" , "\\" , StringComparison . Ordinal ) ;
var fakeHash = Path . GetFileNameWithoutExtension ( fi . FullName ) . ToUpperInvariant ( ) ;
var result = CreateFileCacheEntity ( fi , prefixedPath , fakeHash ) ;
return result ;
}
public FileCacheEntity ? CreateFileEntry ( string path )
{
FileInfo fi = new ( path ) ;
if ( ! fi . Exists ) return null ;
_logger . LogTrace ( "Creating file entry for {path}" , path ) ;
var fullName = fi . FullName . ToLowerInvariant ( ) ;
if ( ! fullName . Contains ( _ipcManager . Penumbra . ModDirectory ! . ToLowerInvariant ( ) , StringComparison . Ordinal ) ) return null ;
string prefixedPath = fullName . Replace ( _ipcManager . Penumbra . ModDirectory ! . ToLowerInvariant ( ) , PenumbraPrefix + "\\" , StringComparison . Ordinal ) . Replace ( "\\\\" , "\\" , StringComparison . Ordinal ) ;
return CreateFileCacheEntity ( fi , prefixedPath ) ;
}
public List < FileCacheEntity > GetAllFileCaches ( ) = > _fileCaches . Values . SelectMany ( v = > v ) . ToList ( ) ;
public List < FileCacheEntity > GetAllFileCachesByHash ( string hash , bool ignoreCacheEntries = false , bool validate = true )
{
List < FileCacheEntity > output = [ ] ;
if ( _fileCaches . TryGetValue ( hash , out var fileCacheEntities ) )
{
foreach ( var fileCache in fileCacheEntities . Where ( c = > ignoreCacheEntries ? ( ! c . IsCacheEntry & & ! c . IsSubstEntry ) : true ) . ToList ( ) )
{
if ( ! validate ) output . Add ( fileCache ) ;
else
{
var validated = GetValidatedFileCache ( fileCache ) ;
if ( validated ! = null ) output . Add ( validated ) ;
}
}
}
return output ;
}
public Task < List < FileCacheEntity > > ValidateLocalIntegrity ( IProgress < ( int , int , FileCacheEntity ) > progress , CancellationToken cancellationToken )
{
_mareMediator . Publish ( new HaltScanMessage ( nameof ( ValidateLocalIntegrity ) ) ) ;
_logger . LogInformation ( "Validating local storage" ) ;
var cacheEntries = _fileCaches . SelectMany ( v = > v . Value ) . Where ( v = > v . IsCacheEntry ) . ToList ( ) ;
List < FileCacheEntity > brokenEntities = [ ] ;
int i = 0 ;
foreach ( var fileCache in cacheEntries )
{
if ( cancellationToken . IsCancellationRequested ) break ;
if ( fileCache . IsSubstEntry ) continue ;
_logger . LogInformation ( "Validating {file}" , fileCache . ResolvedFilepath ) ;
progress . Report ( ( i , cacheEntries . Count , fileCache ) ) ;
i + + ;
if ( ! File . Exists ( fileCache . ResolvedFilepath ) )
{
brokenEntities . Add ( fileCache ) ;
continue ;
}
try
{
var computedHash = Crypto . GetFileHash ( fileCache . ResolvedFilepath ) ;
if ( ! string . Equals ( computedHash , fileCache . Hash , StringComparison . Ordinal ) )
{
_logger . LogInformation ( "Failed to validate {file}, got hash {hash}, expected hash {expectedHash}" , fileCache . ResolvedFilepath , computedHash , fileCache . Hash ) ;
brokenEntities . Add ( fileCache ) ;
}
}
catch ( Exception e )
{
_logger . LogWarning ( e , "Error during validation of {file}" , fileCache . ResolvedFilepath ) ;
brokenEntities . Add ( fileCache ) ;
}
}
foreach ( var brokenEntity in brokenEntities )
{
RemoveHashedFile ( brokenEntity . Hash , brokenEntity . PrefixedFilePath ) ;
try
{
File . Delete ( brokenEntity . ResolvedFilepath ) ;
}
catch ( Exception ex )
{
_logger . LogWarning ( ex , "Could not delete {file}" , brokenEntity . ResolvedFilepath ) ;
}
}
_mareMediator . Publish ( new ResumeScanMessage ( nameof ( ValidateLocalIntegrity ) ) ) ;
return Task . FromResult ( brokenEntities ) ;
}
public string GetCacheFilePath ( string hash , string extension )
{
return Path . Combine ( _configService . Current . CacheFolder , hash + "." + extension ) ;
}
public string GetSubstFilePath ( string hash , string extension )
{
return Path . Combine ( SubstFolder , hash + "." + extension ) ;
}
public async Task < ( string , byte [ ] ) > GetCompressedFileData ( string fileHash , CancellationToken uploadToken )
{
var fileCache = GetFileCacheByHash ( fileHash ) ! ;
using var fs = File . OpenRead ( fileCache . ResolvedFilepath ) ;
var ms = new MemoryStream ( 64 * 1024 ) ;
using var encstream = LZ4Stream . Encode ( ms , new LZ4EncoderSettings ( ) { CompressionLevel = K4os . Compression . LZ4 . LZ4Level . L09_HC } ) ;
await fs . CopyToAsync ( encstream , uploadToken ) . ConfigureAwait ( false ) ;
encstream . Close ( ) ;
fileCache . CompressedSize = encstream . Length ;
return ( fileHash , ms . ToArray ( ) ) ;
}
public FileCacheEntity ? GetFileCacheByHash ( string hash , bool preferSubst = false )
{
var caches = GetFileCachesByHash ( hash ) ;
if ( preferSubst & & caches . Subst ! = null )
return caches . Subst ;
return caches . Penumbra ? ? caches . Cache ;
}
public ( FileCacheEntity ? Penumbra , FileCacheEntity ? Cache , FileCacheEntity ? Subst ) GetFileCachesByHash ( string hash )
{
( FileCacheEntity ? Penumbra , FileCacheEntity ? Cache , FileCacheEntity ? Subst ) result = ( null , null , null ) ;
if ( _fileCaches . TryGetValue ( hash , out var hashes ) )
{
result . Penumbra = hashes . Where ( p = > p . PrefixedFilePath . StartsWith ( PenumbraPrefix , StringComparison . Ordinal ) ) . Select ( GetValidatedFileCache ) . FirstOrDefault ( ) ;
result . Cache = hashes . Where ( p = > p . PrefixedFilePath . StartsWith ( CachePrefix , StringComparison . Ordinal ) ) . Select ( GetValidatedFileCache ) . FirstOrDefault ( ) ;
result . Subst = hashes . Where ( p = > p . PrefixedFilePath . StartsWith ( SubstPrefix , StringComparison . Ordinal ) ) . Select ( GetValidatedFileCache ) . FirstOrDefault ( ) ;
}
return result ;
}
private FileCacheEntity ? GetFileCacheByPath ( string path )
{
var cleanedPath = path . Replace ( "/" , "\\" , StringComparison . OrdinalIgnoreCase ) . ToLowerInvariant ( )
. Replace ( _ipcManager . Penumbra . ModDirectory ! . ToLowerInvariant ( ) , "" , StringComparison . OrdinalIgnoreCase ) ;
var entry = _fileCaches . SelectMany ( v = > v . Value ) . FirstOrDefault ( f = > f . ResolvedFilepath . EndsWith ( cleanedPath , StringComparison . OrdinalIgnoreCase ) ) ;
if ( entry = = null )
{
_logger . LogDebug ( "Found no entries for {path}" , cleanedPath ) ;
return CreateFileEntry ( path ) ;
}
var validatedCacheEntry = GetValidatedFileCache ( entry ) ;
return validatedCacheEntry ;
}
public Dictionary < string , FileCacheEntity ? > GetFileCachesByPaths ( string [ ] paths )
{
_getCachesByPathsSemaphore . Wait ( ) ;
try
{
var cleanedPaths = paths . Distinct ( StringComparer . OrdinalIgnoreCase ) . ToDictionary ( p = > p ,
p = > p . Replace ( "/" , "\\" , StringComparison . OrdinalIgnoreCase )
. Replace ( _ipcManager . Penumbra . ModDirectory ! , _ipcManager . Penumbra . ModDirectory ! . EndsWith ( '\\' ) ? PenumbraPrefix + '\\' : PenumbraPrefix , StringComparison . OrdinalIgnoreCase )
. Replace ( SubstFolder , SubstPrefix , StringComparison . OrdinalIgnoreCase )
. Replace ( _configService . Current . CacheFolder , _configService . Current . CacheFolder . EndsWith ( '\\' ) ? CachePrefix + '\\' : CachePrefix , StringComparison . OrdinalIgnoreCase )
. Replace ( "\\\\" , "\\" , StringComparison . Ordinal ) ,
StringComparer . OrdinalIgnoreCase ) ;
Dictionary < string , FileCacheEntity ? > result = new ( StringComparer . OrdinalIgnoreCase ) ;
var dict = _fileCaches . SelectMany ( f = > f . Value )
. ToDictionary ( d = > d . PrefixedFilePath , d = > d , StringComparer . OrdinalIgnoreCase ) ;
foreach ( var entry in cleanedPaths )
{
//_logger.LogDebug("Checking {path}", entry.Value);
if ( dict . TryGetValue ( entry . Value , out var entity ) )
{
var validatedCache = GetValidatedFileCache ( entity ) ;
result . Add ( entry . Key , validatedCache ) ;
}
else
{
if ( entry . Value . StartsWith ( PenumbraPrefix , StringComparison . Ordinal ) )
result . Add ( entry . Key , CreateFileEntry ( entry . Key ) ) ;
else if ( entry . Value . StartsWith ( SubstPrefix , StringComparison . Ordinal ) )
result . Add ( entry . Key , CreateSubstEntry ( entry . Key ) ) ;
else if ( entry . Value . StartsWith ( CachePrefix , StringComparison . Ordinal ) )
result . Add ( entry . Key , CreateCacheEntry ( entry . Key ) ) ;
}
}
return result ;
}
finally
{
_getCachesByPathsSemaphore . Release ( ) ;
}
}
public void RemoveHashedFile ( string hash , string prefixedFilePath )
{
if ( _fileCaches . TryGetValue ( hash , out var caches ) )
{
var removedCount = caches ? . RemoveAll ( c = > string . Equals ( c . PrefixedFilePath , prefixedFilePath , StringComparison . Ordinal ) ) ;
_logger . LogTrace ( "Removed from DB: {count} file(s) with hash {hash} and file cache {path}" , removedCount , hash , prefixedFilePath ) ;
if ( caches ? . Count = = 0 )
{
_fileCaches . Remove ( hash , out var _ ) ;
}
}
}
public void UpdateHashedFile ( FileCacheEntity fileCache , bool computeProperties = true )
{
_logger . LogTrace ( "Updating hash for {path}" , fileCache . ResolvedFilepath ) ;
var oldHash = fileCache . Hash ;
var prefixedPath = fileCache . PrefixedFilePath ;
if ( computeProperties )
{
var fi = new FileInfo ( fileCache . ResolvedFilepath ) ;
fileCache . Size = fi . Length ;
fileCache . CompressedSize = null ;
fileCache . Hash = Crypto . GetFileHash ( fileCache . ResolvedFilepath ) ;
fileCache . LastModifiedDateTicks = fi . LastWriteTimeUtc . Ticks . ToString ( CultureInfo . InvariantCulture ) ;
}
RemoveHashedFile ( oldHash , prefixedPath ) ;
AddHashedFile ( fileCache ) ;
}
public ( FileState State , FileCacheEntity FileCache ) ValidateFileCacheEntity ( FileCacheEntity fileCache )
{
fileCache = ReplacePathPrefixes ( fileCache ) ;
FileInfo fi = new ( fileCache . ResolvedFilepath ) ;
if ( ! fi . Exists )
{
return ( FileState . RequireDeletion , fileCache ) ;
}
if ( ! string . Equals ( fi . LastWriteTimeUtc . Ticks . ToString ( CultureInfo . InvariantCulture ) , fileCache . LastModifiedDateTicks , StringComparison . Ordinal ) )
{
return ( FileState . RequireUpdate , fileCache ) ;
}
return ( FileState . Valid , fileCache ) ;
}
public void WriteOutFullCsv ( )
{
lock ( _fileWriteLock )
{
StringBuilder sb = new ( ) ;
foreach ( var entry in _fileCaches . SelectMany ( k = > k . Value ) . OrderBy ( f = > f . PrefixedFilePath , StringComparer . OrdinalIgnoreCase ) )
{
sb . AppendLine ( entry . CsvEntry ) ;
}
if ( File . Exists ( _csvPath ) )
{
File . Copy ( _csvPath , CsvBakPath , overwrite : true ) ;
}
try
{
File . WriteAllText ( _csvPath , sb . ToString ( ) ) ;
File . Delete ( CsvBakPath ) ;
}
catch
{
File . WriteAllText ( CsvBakPath , sb . ToString ( ) ) ;
}
}
}
internal FileCacheEntity MigrateFileHashToExtension ( FileCacheEntity fileCache , string ext )
{
try
{
RemoveHashedFile ( fileCache . Hash , fileCache . PrefixedFilePath ) ;
var extensionPath = fileCache . ResolvedFilepath . ToUpper ( CultureInfo . InvariantCulture ) + "." + ext ;
File . Move ( fileCache . ResolvedFilepath , extensionPath , overwrite : true ) ;
var newHashedEntity = new FileCacheEntity ( fileCache . Hash , fileCache . PrefixedFilePath + "." + ext , DateTime . UtcNow . Ticks . ToString ( CultureInfo . InvariantCulture ) ) ;
newHashedEntity . SetResolvedFilePath ( extensionPath ) ;
AddHashedFile ( newHashedEntity ) ;
_logger . LogTrace ( "Migrated from {oldPath} to {newPath}" , fileCache . ResolvedFilepath , newHashedEntity . ResolvedFilepath ) ;
return newHashedEntity ;
}
catch ( Exception ex )
{
AddHashedFile ( fileCache ) ;
_logger . LogWarning ( ex , "Failed to migrate entity {entity}" , fileCache . PrefixedFilePath ) ;
return fileCache ;
}
}
private void AddHashedFile ( FileCacheEntity fileCache )
{
if ( ! _fileCaches . TryGetValue ( fileCache . Hash , out var entries ) | | entries is null )
{
_fileCaches [ fileCache . Hash ] = entries = [ ] ;
}
if ( ! entries . Exists ( u = > string . Equals ( u . PrefixedFilePath , fileCache . PrefixedFilePath , StringComparison . OrdinalIgnoreCase ) ) )
{
//_logger.LogTrace("Adding to DB: {hash} => {path}", fileCache.Hash, fileCache.PrefixedFilePath);
entries . Add ( fileCache ) ;
}
}
private FileCacheEntity ? CreateFileCacheEntity ( FileInfo fileInfo , string prefixedPath , string? hash = null )
{
hash ? ? = Crypto . GetFileHash ( fileInfo . FullName ) ;
var entity = new FileCacheEntity ( hash , prefixedPath , fileInfo . LastWriteTimeUtc . Ticks . ToString ( CultureInfo . InvariantCulture ) , fileInfo . Length ) ;
entity = ReplacePathPrefixes ( entity ) ;
AddHashedFile ( entity ) ;
lock ( _fileWriteLock )
{
File . AppendAllLines ( _csvPath , new [ ] { entity . CsvEntry } ) ;
}
var result = GetFileCacheByPath ( fileInfo . FullName ) ;
_logger . LogTrace ( "Creating cache entity for {name} success: {success}" , fileInfo . FullName , ( result ! = null ) ) ;
return result ;
}
private FileCacheEntity ? GetValidatedFileCache ( FileCacheEntity fileCache )
{
var resultingFileCache = ReplacePathPrefixes ( fileCache ) ;
//_logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath);
resultingFileCache = Validate ( resultingFileCache ) ;
return resultingFileCache ;
}
private FileCacheEntity ReplacePathPrefixes ( FileCacheEntity fileCache )
{
if ( fileCache . PrefixedFilePath . StartsWith ( PenumbraPrefix , StringComparison . OrdinalIgnoreCase ) )
{
fileCache . SetResolvedFilePath ( fileCache . PrefixedFilePath . Replace ( PenumbraPrefix , _ipcManager . Penumbra . ModDirectory , StringComparison . Ordinal ) ) ;
}
else if ( fileCache . PrefixedFilePath . StartsWith ( SubstPrefix , StringComparison . OrdinalIgnoreCase ) )
{
fileCache . SetResolvedFilePath ( fileCache . PrefixedFilePath . Replace ( SubstPrefix , SubstFolder , StringComparison . Ordinal ) ) ;
}
else if ( fileCache . PrefixedFilePath . StartsWith ( CachePrefix , StringComparison . OrdinalIgnoreCase ) )
{
fileCache . SetResolvedFilePath ( fileCache . PrefixedFilePath . Replace ( CachePrefix , _configService . Current . CacheFolder , StringComparison . Ordinal ) ) ;
}
return fileCache ;
}
private FileCacheEntity ? Validate ( FileCacheEntity fileCache )
{
var file = new FileInfo ( fileCache . ResolvedFilepath ) ;
if ( ! file . Exists )
{
RemoveHashedFile ( fileCache . Hash , fileCache . PrefixedFilePath ) ;
return null ;
}
if ( ! string . Equals ( file . LastWriteTimeUtc . Ticks . ToString ( CultureInfo . InvariantCulture ) , fileCache . LastModifiedDateTicks , StringComparison . Ordinal ) )
{
UpdateHashedFile ( fileCache ) ;
}
return fileCache ;
}
public Task StartAsync ( CancellationToken cancellationToken )
{
_logger . LogInformation ( "Starting FileCacheManager" ) ;
lock ( _fileWriteLock )
{
try
{
_logger . LogInformation ( "Checking for {bakPath}" , CsvBakPath ) ;
if ( File . Exists ( CsvBakPath ) )
{
_logger . LogInformation ( "{bakPath} found, moving to {csvPath}" , CsvBakPath , _csvPath ) ;
File . Move ( CsvBakPath , _csvPath , overwrite : true ) ;
}
}
catch ( Exception ex )
{
_logger . LogWarning ( ex , "Failed to move BAK to ORG, deleting BAK" ) ;
try
{
if ( File . Exists ( CsvBakPath ) )
File . Delete ( CsvBakPath ) ;
}
catch ( Exception ex1 )
{
_logger . LogWarning ( ex1 , "Could not delete bak file" ) ;
}
}
}
if ( File . Exists ( _csvPath ) )
{
if ( ! _ipcManager . Penumbra . APIAvailable | | string . IsNullOrEmpty ( _ipcManager . Penumbra . ModDirectory ) )
{
_mareMediator . Publish ( new NotificationMessage ( "Penumbra not connected" ,
2025-08-22 21:17:09 +01:00
"Could not load local file cache data. Penumbra is not connected or not properly set up. Please enable and/or configure Penumbra properly to use Snowcloak. After, reload Snowcloak in the Plugin installer." ,
2025-08-22 02:19:48 +01:00
MareConfiguration . Models . NotificationType . Error ) ) ;
}
_logger . LogInformation ( "{csvPath} found, parsing" , _csvPath ) ;
bool success = false ;
string [ ] entries = [ ] ;
int attempts = 0 ;
while ( ! success & & attempts < 10 )
{
try
{
_logger . LogInformation ( "Attempting to read {csvPath}" , _csvPath ) ;
entries = File . ReadAllLines ( _csvPath ) ;
success = true ;
}
catch ( Exception ex )
{
attempts + + ;
_logger . LogWarning ( ex , "Could not open {file}, trying again" , _csvPath ) ;
Thread . Sleep ( 100 ) ;
}
}
if ( ! entries . Any ( ) )
{
_logger . LogWarning ( "Could not load entries from {path}, continuing with empty file cache" , _csvPath ) ;
}
_logger . LogInformation ( "Found {amount} files in {path}" , entries . Length , _csvPath ) ;
Dictionary < string , bool > processedFiles = new ( StringComparer . OrdinalIgnoreCase ) ;
foreach ( var entry in entries )
{
var splittedEntry = entry . Split ( CsvSplit , StringSplitOptions . None ) ;
try
{
var hash = splittedEntry [ 0 ] ;
if ( hash . Length ! = 40 ) throw new InvalidOperationException ( "Expected Hash length of 40, received " + hash . Length ) ;
var path = splittedEntry [ 1 ] ;
var time = splittedEntry [ 2 ] ;
if ( processedFiles . ContainsKey ( path ) )
{
_logger . LogWarning ( "Already processed {file}, ignoring" , path ) ;
continue ;
}
processedFiles . Add ( path , value : true ) ;
long size = - 1 ;
long compressed = - 1 ;
if ( splittedEntry . Length > 3 )
{
if ( long . TryParse ( splittedEntry [ 3 ] , CultureInfo . InvariantCulture , out long result ) )
{
size = result ;
}
if ( long . TryParse ( splittedEntry [ 4 ] , CultureInfo . InvariantCulture , out long resultCompressed ) )
{
compressed = resultCompressed ;
}
}
AddHashedFile ( ReplacePathPrefixes ( new FileCacheEntity ( hash , path , time , size , compressed ) ) ) ;
}
catch ( Exception ex )
{
_logger . LogWarning ( ex , "Failed to initialize entry {entry}, ignoring" , entry ) ;
}
}
if ( processedFiles . Count ! = entries . Length )
{
WriteOutFullCsv ( ) ;
}
}
_logger . LogInformation ( "Started FileCacheManager" ) ;
return Task . CompletedTask ;
}
public Task StopAsync ( CancellationToken cancellationToken )
{
WriteOutFullCsv ( ) ;
return Task . CompletedTask ;
}
}