// Copyright Epic Games, Inc. All Rights Reserved. #include "FileCache.h" #include "GenericPlatform/GenericPlatformFile.h" #include "HAL/RunnableThread.h" #include "HAL/PlatformFileManager.h" #include "HAL/FileManager.h" #include "HAL/Runnable.h" #include "Misc/ScopeLock.h" #include "Serialization/CustomVersion.h" #include "DirectoryWatcherModule.h" #include "Modules/ModuleManager.h" DEFINE_LOG_CATEGORY_STATIC(LogFileCache, Log, All); namespace DirectoryWatcher { template void ReadWithCustomVersions(FArchive& Ar, T& Data, ECustomVersionSerializationFormat CustomVersionFormat) { int64 CustomVersionsOffset = 0; Ar << CustomVersionsOffset; const int64 DataStart = Ar.Tell(); Ar.Seek(CustomVersionsOffset); // Serialize the custom versions FCustomVersionContainer Vers = Ar.GetCustomVersions(); Vers.Serialize(Ar, CustomVersionFormat); Ar.SetCustomVersions(Vers); Ar.Seek(DataStart); Ar << Data; } template void WriteWithCustomVersions(FArchive& Ar, T& Data) { const int64 CustomVersionsHeader = Ar.Tell(); int64 CustomVersionsOffset = CustomVersionsHeader; // We'll come back later and fill this in Ar << CustomVersionsOffset; // Write out the data Ar << Data; CustomVersionsOffset = Ar.Tell(); // Serialize the custom versions FCustomVersionContainer Vers = Ar.GetCustomVersions(); Vers.Serialize(Ar); // Write out where the custom versions are in our header Ar.Seek(CustomVersionsHeader); Ar << CustomVersionsOffset; } /** Convert a FFileChangeData::EFileChangeAction into an EFileAction */ EFileAction ToFileAction(FFileChangeData::EFileChangeAction InAction) { switch (InAction) { case FFileChangeData::FCA_Added: return EFileAction::Added; case FFileChangeData::FCA_Modified: return EFileAction::Modified; case FFileChangeData::FCA_Removed: return EFileAction::Removed; default: return EFileAction::Modified; } } const FGuid FFileCacheCustomVersion::Key(0x8E7DDCB3, 0x80DA47BB, 0x9FD346A2, 0x93984DF6); FCustomVersionRegistration GRegisterFileCacheVersion(FFileCacheCustomVersion::Key, FFileCacheCustomVersion::Latest, TEXT("FileCacheVersion")); static const uint32 CacheFileMagicNumberOldCustomVersionFormat = 0x03DCCB00; static const uint32 CacheFileMagicNumber = 0x03DCCB03; static ECustomVersionSerializationFormat GetCustomVersionFormatForFileCache(uint32 MagicNumber) { if (MagicNumber == CacheFileMagicNumberOldCustomVersionFormat) { return ECustomVersionSerializationFormat::Guids; } else { return ECustomVersionSerializationFormat::Optimized; } } /** Single runnable thread used to parse file cache directories without blocking the main thread */ struct FAsyncTaskThread : public FRunnable { typedef TArray> FTaskArray; FAsyncTaskThread() : Thread(nullptr) {} /** Add a reader to this thread which will get ticked periodically until complete */ void AddTask(TSharedPtr InTask) { FScopeLock Lock(&TaskArrayMutex); Tasks.Add(InTask); if (!Thread) { static int32 Index = 0; Thread = FRunnableThread::Create(this, *FString::Printf(TEXT("AsyncTaskThread_%d"), ++Index)); } } /** Run this thread */ virtual uint32 Run() { for(;;) { // Copy the array while we tick the readers FTaskArray Dupl; { FScopeLock Lock(&TaskArrayMutex); Dupl = Tasks; } // Tick each one for a second for (auto& Task : Dupl) { auto PinnedTask = Task.Pin(); if (PinnedTask.IsValid()) { PinnedTask->Tick(FTimeLimit(1)); } } // Cleanup dead/finished Tasks FScopeLock Lock(&TaskArrayMutex); for (int32 Index = 0; Index < Tasks.Num(); ) { auto Task = Tasks[Index].Pin(); if (!Task.IsValid() || Task->IsComplete()) { Tasks.RemoveAt(Index); } else { ++Index; } } // Shutdown the thread if we've nothing left to do if (Tasks.Num() == 0) { Thread = nullptr; break; } } return 0; } private: /** We start our own thread if one doesn't already exist. */ FRunnableThread* Thread; /** Array of things that need ticking, and a mutex to protect them */ FCriticalSection TaskArrayMutex; FTaskArray Tasks; }; FAsyncTaskThread AsyncTaskThread; /** Threading strategy for FAsyncFileHasher: * The task is constructed on the main thread with its Data. * The array 'Data' *never* changes size. The task thread moves along setting file hashes, while the main thread * trails behind accessing the completed entries. We should thus never have 2 threads accessing the same memory, * except for the atomic 'CurrentIndex' */ FAsyncFileHasher::FAsyncFileHasher(TArray InFilesThatNeedHashing) : Data(MoveTemp(InFilesThatNeedHashing)), NumReturned(0) { // Read in files in 1MB chunks ScratchBuffer.SetNumUninitialized(1024 * 1024); } TArray FAsyncFileHasher::GetCompletedData() { // Don't need to lock here since the thread will never look at the array before CurrentIndex. TArray Local; const int32 CompletedIndex = CurrentIndex.GetValue(); if (NumReturned < CompletedIndex) { Local.Append(Data.GetData() + NumReturned, CompletedIndex - NumReturned); NumReturned = CompletedIndex; if (CompletedIndex == Data.Num()) { Data.Empty(); CurrentIndex.Set(0); } } return Local; } bool FAsyncFileHasher::IsComplete() const { return CurrentIndex.GetValue() == Data.Num(); } IAsyncFileCacheTask::EProgressResult FAsyncFileHasher::Tick(const FTimeLimit& Limit) { for (; CurrentIndex.GetValue() < Data.Num(); ) { const auto Index = CurrentIndex.GetValue(); Data[Index].FileHash = FMD5Hash::HashFile(*Data[Index].AbsoluteFilename, &ScratchBuffer); CurrentIndex.Increment(); if (Limit.Exceeded()) { return EProgressResult::Pending; } } return EProgressResult::Finished; } /** Threading strategy for FAsyncDirectoryReader: * The directory reader owns the cached and live state until it has completely finished. Once IsComplete() is true, the main thread can * have access to both the cached and farmed data. */ FAsyncDirectoryReader::FAsyncDirectoryReader(const FString& InDirectory, EPathType InPathType) : RootPath(InDirectory), PathType(InPathType) { PendingDirectories.Add(InDirectory); LiveState.Emplace(); StandardRootPath = RootPath; FPaths::MakeStandardFilename(StandardRootPath); StandardRootPath /= TEXT(""); } TOptional FAsyncDirectoryReader::GetLiveState() { TOptional OldState; if (ensureMsgf(IsComplete(), TEXT("Invalid property access from thread before task completion"))) { Swap(OldState, LiveState); } return OldState; } TOptional FAsyncDirectoryReader::GetCachedState() { TOptional OldState; if (ensureMsgf(IsComplete(), TEXT("Invalid property access from thread before task completion"))) { Swap(OldState, CachedState); } return OldState; } bool FAsyncDirectoryReader::IsComplete() const { return bIsComplete; } FAsyncDirectoryReader::EProgressResult FAsyncDirectoryReader::Tick(const FTimeLimit& TimeLimit) { if (IsComplete()) { return EProgressResult::Finished; } TRACE_CPUPROFILER_EVENT_SCOPE(FAsyncDirectoryReader::Tick); auto& FileManager = IFileManager::Get(); // Discover files for (int32 Index = 0; Index < PendingDirectories.Num(); ++Index) { ScanDirectory(PendingDirectories[Index]); if (TimeLimit.Exceeded()) { // We've spent too long, bail PendingDirectories.RemoveAt(0, Index + 1, EAllowShrinking::No); return EProgressResult::Pending; } } PendingDirectories.Empty(); // Process files for (int32 Index = 0; Index < PendingFiles.Num(); ++Index) { const auto& File = PendingFiles[Index]; // Store the file relative or absolute FString Filename = GetPathToStore(File); const auto Timestamp = FileManager.GetTimeStamp(*File); FMD5Hash MD5; if (CachedState.IsSet()) { const FFileData* CachedData = CachedState->Files.Find(Filename); if (CachedData && CachedData->Timestamp == Timestamp && CachedData->FileHash.IsValid()) { // Use the cached MD5 to avoid opening the file MD5 = CachedData->FileHash; } } if (!MD5.IsValid()) { FilesThatNeedHashing.Emplace(File); } LiveState->Files.Emplace(MoveTemp(Filename), FFileData(Timestamp, MD5)); if (TimeLimit.Exceeded()) { // We've spent too long, bail PendingFiles.RemoveAt(0, Index + 1, EAllowShrinking::No); return EProgressResult::Pending; } } PendingFiles.Empty(); bIsComplete = true; UE_LOG(LogFileCache, Log, TEXT("Scanning file cache for directory '%s' took %.2fs"), *RootPath, GetAge()); return EProgressResult::Finished; } void FAsyncDirectoryReader::ScanDirectory(const FString& InDirectory) { TRACE_CPUPROFILER_EVENT_SCOPE(FAsyncDirectoryReader::ScanDirectory); struct FVisitor : public IPlatformFile::FDirectoryVisitor { TArray* PendingFiles; TArray* PendingDirectories; FMatchRules* Rules; int32 RootPathLength; int32 StandardRootPathLength; virtual bool Visit(const TCHAR* FilenameOrDirectory, bool bIsDirectory) { FString FileStr(FilenameOrDirectory); if (bIsDirectory) { PendingDirectories->Add(MoveTemp(FileStr)); } else { const int32 Offset = FPaths::IsRelative(FileStr) ? StandardRootPathLength : RootPathLength; check(Offset <= FileStr.Len()); if (Rules->IsFileApplicable(FilenameOrDirectory + Offset)) { PendingFiles->Add(MoveTemp(FileStr)); } } return true; } }; FVisitor Visitor; Visitor.PendingFiles = &PendingFiles; Visitor.PendingDirectories = &PendingDirectories; Visitor.Rules = &LiveState->Rules; Visitor.RootPathLength = RootPath.Len(); Visitor.StandardRootPathLength = StandardRootPath.Len(); IFileManager::Get().IterateDirectory(*InDirectory, Visitor); } FString FAsyncDirectoryReader::GetPathToStore(const FString& InPath) const { if (PathType == EPathType::Relative) { if (FPaths::IsRelative(InPath)) { return InPath.RightChop(StandardRootPath.Len()); } else { return InPath.RightChop(RootPath.Len()); } } else { // TODO: Convert to absolute if not already absolute return InPath; } } FFileCache::FFileCache(const FFileCacheConfig& InConfig) : Config(InConfig) , bSavedCacheDirty(false) , LastFileHashGetTime(0) { bPendingTransactionsDirty = true; // Ensure the directory has a trailing / Config.Directory /= TEXT(""); // Store standardized copy of the directory ConfigDirectoryStandardized = Config.Directory; FPaths::MakeStandardFilename(ConfigDirectoryStandardized); ConfigDirectoryStandardized /= TEXT(""); // bDetectMoves implies bRequireFileHashes Config.bRequireFileHashes = Config.bRequireFileHashes || Config.bDetectMoves; DirectoryReader = MakeShareable(new FAsyncDirectoryReader(Config.Directory, Config.PathType)); DirectoryReader->SetMatchRules(Config.Rules); // Attempt to load an existing cache file auto ExistingCache = ReadCache(); if (ExistingCache.IsSet()) { DirectoryReader->UseCachedState(MoveTemp(ExistingCache.GetValue())); } AsyncTaskThread.AddTask(DirectoryReader); FDirectoryWatcherModule& Module = FModuleManager::LoadModuleChecked(TEXT("DirectoryWatcher")); if (IDirectoryWatcher* DirectoryWatcher = Module.Get()) { auto Callback = IDirectoryWatcher::FDirectoryChanged::CreateRaw(this, &FFileCache::OnDirectoryChanged); if (!DirectoryWatcher->RegisterDirectoryChangedCallback_Handle(Config.Directory, Callback, WatcherDelegate)) { UE_LOG(LogFileCache, Display, TEXT("Failed registering directory watcher for folder '%s'"), *Config.Directory); } } } FFileCache::~FFileCache() { UnbindWatcher(); WriteCache(); } void FFileCache::Destroy() { // Delete the cache file, and clear out everything bSavedCacheDirty = false; if (!Config.CacheFile.IsEmpty()) { IFileManager::Get().Delete(*Config.CacheFile, false, true, true); } DirectoryReader = nullptr; AsyncFileHasher = nullptr; DirtyFileHasher = nullptr; DirtyFiles.Empty(); CachedDirectoryState = FDirectoryState(); UnbindWatcher(); } bool FFileCache::HasStartedUp() const { return !DirectoryReader.IsValid() || DirectoryReader->IsComplete(); } bool FFileCache::MoveDetectionInitialized() const { if (!HasStartedUp()) { return false; } else if (!Config.bDetectMoves) { return true; } else { // We don't check AsyncFileHasher->IsComplete() here because that doesn't necessarily mean we've harvested the results off the thread return !AsyncFileHasher.IsValid(); } } const FFileData* FFileCache::FindFileData(FImmutableString InFilename) const { if (!ensure(HasStartedUp())) { // It's invalid to call this while the cached state is still being updated on a thread. return nullptr; } return CachedDirectoryState.Files.Find(InFilename); } void FFileCache::UnbindWatcher() { if (!WatcherDelegate.IsValid()) { return; } if (FDirectoryWatcherModule* Module = FModuleManager::GetModulePtr(TEXT("DirectoryWatcher"))) { if (IDirectoryWatcher* DirectoryWatcher = Module->Get()) { DirectoryWatcher->UnregisterDirectoryChangedCallback_Handle(Config.Directory, WatcherDelegate); } } WatcherDelegate.Reset(); } TOptional FFileCache::ReadCache() const { TOptional Optional; if (!Config.CacheFile.IsEmpty()) { FArchive* Ar = IFileManager::Get().CreateFileReader(*Config.CacheFile); if (Ar) { // Serialize the magic number - the first iteration omitted version information, so we have a magic number to ignore this data uint32 MagicNumber = 0; *Ar << MagicNumber; if (MagicNumber == CacheFileMagicNumber || MagicNumber == CacheFileMagicNumberOldCustomVersionFormat) { FDirectoryState Result; ReadWithCustomVersions(*Ar, Result, GetCustomVersionFormatForFileCache(MagicNumber)); Optional.Emplace(MoveTemp(Result)); } Ar->Close(); delete Ar; } } return Optional; } void FFileCache::WriteCache() { if (bSavedCacheDirty && !Config.CacheFile.IsEmpty()) { const FString ParentFolder = FPaths::GetPath(Config.CacheFile); if (!IFileManager::Get().DirectoryExists(*ParentFolder)) { IFileManager::Get().MakeDirectory(*ParentFolder, true); } // Write to a temp file to avoid corruption FString TempFile = Config.CacheFile + TEXT(".tmp"); FArchive* Ar = IFileManager::Get().CreateFileWriter(*TempFile); if (ensureMsgf(Ar, TEXT("Unable to write file-cache for '%s' to '%s'."), *Config.Directory, *Config.CacheFile)) { // Serialize the magic number uint32 MagicNumber = CacheFileMagicNumber; *Ar << MagicNumber; WriteWithCustomVersions(*Ar, CachedDirectoryState); Ar->Close(); delete Ar; CachedDirectoryState.Files.Shrink(); bSavedCacheDirty = false; const bool bMoved = IFileManager::Get().Move(*Config.CacheFile, *TempFile, true, true); if (!bMoved) { uint64 TotalDiskSpace = 0; uint64 FreeDiskSpace = 0; FPlatformMisc::GetDiskTotalAndFreeSpace(Config.CacheFile, TotalDiskSpace, FreeDiskSpace); ensureMsgf(bMoved, TEXT("Unable to move file-cache for '%s' from '%s' to '%s' (free disk space: %llu)"), *Config.Directory, *TempFile, *Config.CacheFile, FreeDiskSpace); } } } } FString FFileCache::GetAbsolutePath(const FString& InTransactionPath) const { if (Config.PathType == EPathType::Relative) { return Config.Directory / InTransactionPath; } else { return InTransactionPath; } } TOptional FFileCache::GetTransactionPath(const FString& InAbsolutePath) const { FString Temp = FPaths::ConvertRelativePathToFull(InAbsolutePath); FString RelativePath(*Temp + Config.Directory.Len()); // If it's a directory or is not applicable, ignore it if (!Temp.StartsWith(Config.Directory) || IFileManager::Get().DirectoryExists(*Temp) || !Config.Rules.IsFileApplicable(*RelativePath)) { return TOptional(); } if (Config.PathType == EPathType::Relative) { return MoveTemp(RelativePath); } else { return MoveTemp(Temp); } } void FFileCache::DiffDirtyFiles(TMap& InDirtyFiles, TArray& OutTransactions, const FDirectoryState* InFileSystemState) const { TMap AddedFiles, ModifiedFiles; TSet RemovedFiles, InvalidDirtyFiles; auto& FileManager = IFileManager::Get(); auto& PlatformFile = FPlatformFileManager::Get().GetPlatformFile(); for (const auto& Pair : InDirtyFiles) { const auto& File = Pair.Key; FString AbsoluteFilename = GetAbsolutePath(File.Get()); const auto* CachedState = CachedDirectoryState.Files.Find(File); const bool bFileExists = InFileSystemState ? InFileSystemState->Files.Find(File) != nullptr : PlatformFile.FileExists(*AbsoluteFilename); if (bFileExists) { FFileData FileData; if (const auto* FoundData = InFileSystemState ? InFileSystemState->Files.Find(File) : nullptr) { FileData = *FoundData; } else { // The dirty file timestamp is the time that the file was dirtied, not necessarily its modification time FileData = FFileData(FileManager.GetTimeStamp(*AbsoluteFilename), Pair.Value.FileHash); } if (Config.bRequireFileHashes && !FileData.FileHash.IsValid()) { // We don't have this file's hash yet. Temporarily ignore it. continue; } // Do we think it exists in the cache? if (CachedState) { // Custom logic overrides everything TOptional CustomResult = Config.CustomChangeLogic ? Config.CustomChangeLogic(File, FileData) : TOptional(); if (CustomResult.IsSet()) { if (CustomResult.GetValue()) { ModifiedFiles.Add(File, FileData); } else { InvalidDirtyFiles.Add(File); } } // A file has changed if its hash is now different else if (Config.bRequireFileHashes && Config.ChangeDetectionBits[FFileCacheConfig::FileHash] && CachedState->FileHash != FileData.FileHash ) { ModifiedFiles.Add(File, FileData); } // or the timestamp has changed else if (Config.ChangeDetectionBits[FFileCacheConfig::Timestamp] && CachedState->Timestamp != FileData.Timestamp) { ModifiedFiles.Add(File, FileData); } else { // File hasn't changed InvalidDirtyFiles.Add(File); } } else { AddedFiles.Add(File, FileData); } } // We only report it as removed if it exists in the cache else if (CachedState) { RemovedFiles.Add(File); } else { // File doesn't exist, and isn't in the cache InvalidDirtyFiles.Add(File); } } // Remove any dirty files that aren't dirty for (auto& Filename : InvalidDirtyFiles) { InDirtyFiles.Remove(Filename); } // Rename / move detection if (Config.bDetectMoves) { bool bHavePendingHashes = false; // Remove any additions that don't have their hash generated yet for (auto AdIt = AddedFiles.CreateIterator(); AdIt; ++AdIt) { if (!AdIt.Value().FileHash.IsValid()) { bHavePendingHashes = true; AdIt.RemoveCurrent(); } } // We can only detect renames or moves for files that have had their file hash harvested. // If we can't find a valid move destination for this file, and we have pending hashes, ignore the removal until we can be sure it's not a move for (auto RemoveIt = RemovedFiles.CreateIterator(); RemoveIt; ++RemoveIt) { const auto* CachedState = CachedDirectoryState.Files.Find(*RemoveIt); if (CachedState && CachedState->FileHash.IsValid()) { for (auto AdIt = AddedFiles.CreateIterator(); AdIt; ++AdIt) { if (AdIt.Value().FileHash == CachedState->FileHash) { // Found a move destination! OutTransactions.Add(FUpdateCacheTransaction(*RemoveIt, AdIt.Key(), AdIt.Value())); AdIt.RemoveCurrent(); RemoveIt.RemoveCurrent(); goto next; } } // We can't be sure this isn't a move (yet) so temporarily ignore this if (bHavePendingHashes) { RemoveIt.RemoveCurrent(); } } next: continue; } } for (auto& RemovedFile : RemovedFiles) { OutTransactions.Add(FUpdateCacheTransaction(MoveTemp(RemovedFile), EFileAction::Removed)); } // RemovedFiles is now bogus for (auto& Pair : AddedFiles) { OutTransactions.Add(FUpdateCacheTransaction(MoveTemp(Pair.Key), EFileAction::Added, Pair.Value)); } // AddedFiles is now bogus for (auto& Pair : ModifiedFiles) { OutTransactions.Add(FUpdateCacheTransaction(MoveTemp(Pair.Key), EFileAction::Modified, Pair.Value)); } // ModifiedFiles is now bogus } void FFileCache::UpdatePendingTransactions() { if (bPendingTransactionsDirty) { PendingTransactions.Reset(); DiffDirtyFiles(DirtyFiles, PendingTransactions); bPendingTransactionsDirty = false; } } void FFileCache::IterateOutstandingChanges(TFunctionRef InIter) const { for (const FUpdateCacheTransaction& Transaction : PendingTransactions) { FFileData FileData = DirtyFiles.FindRef(Transaction.Filename); if (!InIter(Transaction, FileData.Timestamp)) { break; } } } TArray FFileCache::GetOutstandingChanges() { // Harvest hashes first, since that may invalidate our pending transactions HarvestDirtyFileHashes(); UpdatePendingTransactions(); // Clear the set of dirty files since we're returning transactions for them now DirtyFiles.Empty(); TArray ReturnVal = MoveTemp(PendingTransactions); PendingTransactions.Empty(); return ReturnVal; } TArray FFileCache::FilterOutstandingChanges(TFunctionRef InPredicate) { HarvestDirtyFileHashes(); TArray AllTransactions; DiffDirtyFiles(DirtyFiles, AllTransactions, nullptr); // Filter the transactions based on the predicate TArray FilteredTransactions; for (auto& Transaction : AllTransactions) { FFileData FileData = DirtyFiles.FindRef(Transaction.Filename); // Timestamp is the time the file was dirtied, not necessarily the timestamp of the file if (InPredicate(Transaction, FileData.Timestamp)) { DirtyFiles.Remove(Transaction.Filename); if (Transaction.Action == EFileAction::Moved) { DirtyFiles.Remove(Transaction.MovedFromFilename); } FilteredTransactions.Add(MoveTemp(Transaction)); } } bPendingTransactionsDirty = true; // Anything left in AllTransactions is discarded return FilteredTransactions; } void FFileCache::IgnoreNewFile(const FString& Filename) { auto TransactionPath = GetTransactionPath(Filename); if (TransactionPath.IsSet()) { DirtyFiles.Remove(TransactionPath.GetValue()); const FFileData FileData(IFileManager::Get().GetTimeStamp(*Filename), FMD5Hash::HashFile(*Filename)); CompleteTransaction(FUpdateCacheTransaction(MoveTemp(TransactionPath.GetValue()), EFileAction::Added, FileData)); bPendingTransactionsDirty = true; } } void FFileCache::IgnoreFileModification(const FString& Filename) { auto TransactionPath = GetTransactionPath(Filename); if (TransactionPath.IsSet()) { DirtyFiles.Remove(TransactionPath.GetValue()); const FFileData FileData(IFileManager::Get().GetTimeStamp(*Filename), FMD5Hash::HashFile(*Filename)); CompleteTransaction(FUpdateCacheTransaction(MoveTemp(TransactionPath.GetValue()), EFileAction::Modified, FileData)); bPendingTransactionsDirty = true; } } void FFileCache::IgnoreMovedFile(const FString& SrcFilename, const FString& DstFilename) { auto SrcTransactionPath = GetTransactionPath(SrcFilename); auto DstTransactionPath = GetTransactionPath(DstFilename); if (SrcTransactionPath.IsSet() && DstTransactionPath.IsSet()) { DirtyFiles.Remove(SrcTransactionPath.GetValue()); DirtyFiles.Remove(DstTransactionPath.GetValue()); const FFileData FileData(IFileManager::Get().GetTimeStamp(*DstFilename), FMD5Hash::HashFile(*DstFilename)); CompleteTransaction(FUpdateCacheTransaction(MoveTemp(SrcTransactionPath.GetValue()), MoveTemp(DstTransactionPath.GetValue()), FileData)); bPendingTransactionsDirty = true; } } void FFileCache::IgnoreDeletedFile(const FString& Filename) { auto TransactionPath = GetTransactionPath(Filename); if (TransactionPath.IsSet()) { DirtyFiles.Remove(TransactionPath.GetValue()); CompleteTransaction(FUpdateCacheTransaction(MoveTemp(TransactionPath.GetValue()), EFileAction::Removed)); bPendingTransactionsDirty = true; } } void FFileCache::CompleteTransaction(FUpdateCacheTransaction&& Transaction) { auto* CachedData = CachedDirectoryState.Files.Find(Transaction.Filename); switch (Transaction.Action) { case EFileAction::Moved: { CachedDirectoryState.Files.Remove(Transaction.MovedFromFilename); if (!CachedData) { CachedDirectoryState.Files.Add(Transaction.Filename, Transaction.FileData); } else { *CachedData = Transaction.FileData; } bSavedCacheDirty = true; } break; case EFileAction::Modified: if (CachedData) { // Update the timestamp *CachedData = Transaction.FileData; bSavedCacheDirty = true; } break; case EFileAction::Added: if (!CachedData) { // Add the file information to the cache CachedDirectoryState.Files.Emplace(Transaction.Filename, Transaction.FileData); bSavedCacheDirty = true; } break; case EFileAction::Removed: if (CachedData) { // Remove the file information to the cache CachedDirectoryState.Files.Remove(Transaction.Filename); bSavedCacheDirty = true; } break; default: checkf(false, TEXT("Invalid file cached transaction")); break; } } void FFileCache::Tick() { HarvestDirtyFileHashes(); UpdatePendingTransactions(); /** Stage one: wait for the asynchronous directory reader to finish harvesting timestamps for the directory */ if (DirectoryReader.IsValid()) { if (!DirectoryReader->IsComplete()) { return; } else { ReadStateFromAsyncReader(); if (Config.bRequireFileHashes) { auto FilesThatNeedHashing = DirectoryReader->GetFilesThatNeedHashing(); if (FilesThatNeedHashing.Num() > 0) { AsyncFileHasher = MakeShareable(new FAsyncFileHasher(MoveTemp(FilesThatNeedHashing))); AsyncTaskThread.AddTask(AsyncFileHasher); } } // Null out our pointer to the directory reader to indicate that we've finished DirectoryReader = nullptr; } } /** The file cache is now running, and will report changes. */ /** Keep harvesting file hashes from the file hashing task until complete. These are much slower to gather, and only required for rename/move detection. */ else if (AsyncFileHasher.IsValid()) { double Now = FPlatformTime::Seconds(); if (Now - LastFileHashGetTime > 5.f) { LastFileHashGetTime = Now; auto Hashes = AsyncFileHasher->GetCompletedData(); if (Hashes.Num() > 0) { bSavedCacheDirty = true; for (const auto& Data : Hashes) { FImmutableString CachePath = *GetPathToStore(Data); auto* FileData = CachedDirectoryState.Files.Find(CachePath); if (FileData && !FileData->FileHash.IsValid()) { FileData->FileHash = Data.FileHash; } } } if (AsyncFileHasher->IsComplete()) { UE_LOG(LogFileCache, Log, TEXT("Retrieving MD5 hashes for directory '%s' took %.2fs"), *Config.Directory, AsyncFileHasher->GetAge()); AsyncFileHasher = nullptr; } } } } FString FFileCache::GetPathToStore(const FFilenameAndHash& InData) const { if (Config.PathType == EPathType::Relative) { if (FPaths::IsRelative(InData.AbsoluteFilename)) { return InData.AbsoluteFilename.RightChop(ConfigDirectoryStandardized.Len()); } else { return InData.AbsoluteFilename.RightChop(Config.Directory.Len()); } } else { // TODO: Convert to absolute if not already absolute return InData.AbsoluteFilename; } } void FFileCache::ReadStateFromAsyncReader() { // We should only ever get here once. The directory reader has finished scanning, and we can now diff the results with what we had saved in the cache file. check(DirectoryReader->IsComplete()); TOptional LiveState = DirectoryReader->GetLiveState(); TOptional CachedState = DirectoryReader->GetCachedState(); if (!CachedState.IsSet() || !Config.bDetectChangesSinceLastRun) { // If we don't have any cached data yet, just use the file data we just harvested CachedDirectoryState = MoveTemp(LiveState.GetValue()); bSavedCacheDirty = true; return; } else { // Use the cache that we gave to the directory reader CachedDirectoryState = MoveTemp(CachedState.GetValue()); } const FDateTime Now = FDateTime::UtcNow(); // We already have cached data so we need to compare it with the harvested data // to detect additions, modifications, and removals for (const auto& FilenameAndData : LiveState->Files) { const FString& Filename = FilenameAndData.Key.Get(); // If the file we've discovered was not applicable to the old cache, we can't report a change for it as we don't know if it's new or not, just add it straight to the cache. if (!CachedDirectoryState.Rules.IsFileApplicable(*Filename)) { CachedDirectoryState.Files.Add(FilenameAndData.Key, FilenameAndData.Value); bSavedCacheDirty = true; } else { const auto* CachedData = CachedDirectoryState.Files.Find(Filename); if (!CachedData || CachedData->Timestamp != FilenameAndData.Value.Timestamp) { DirtyFiles.Add(FilenameAndData.Key, FFileData(Now, FMD5Hash())); } } } // Check for anything that doesn't exist on disk anymore for (auto It = CachedDirectoryState.Files.CreateIterator(); It; ++It) { const FImmutableString& Filename = It.Key(); if (LiveState->Rules.IsFileApplicable(*Filename.Get()) && !LiveState->Files.Contains(Filename)) { DirtyFiles.Add(Filename, FFileData(Now, FMD5Hash())); } } RescanForDirtyFileHashes(); bPendingTransactionsDirty = true; // Update the applicable extensions now that we've updated the cache CachedDirectoryState.Rules = LiveState->Rules; } void FFileCache::HarvestDirtyFileHashes() { if (!DirtyFileHasher.IsValid()) { return; } else for (FFilenameAndHash& Data : DirtyFileHasher->GetCompletedData()) { FImmutableString CachePath = *GetPathToStore(Data); if (auto* FileData = DirtyFiles.Find(CachePath)) { FileData->FileHash = Data.FileHash; bPendingTransactionsDirty = true; } } if (DirtyFileHasher->IsComplete()) { DirtyFileHasher = nullptr; } } void FFileCache::RescanForDirtyFileHashes() { if (!Config.bRequireFileHashes) { return; } TArray FilesThatNeedHashing; for (const auto& Pair : DirtyFiles) { if (!Pair.Value.FileHash.IsValid()) { FilesThatNeedHashing.Emplace(GetAbsolutePath(Pair.Key.Get())); } } if (FilesThatNeedHashing.Num() > 0) { // Re-create the dirty file hasher with the new data that needs hashing. The old task will clean itself up if it already exists. DirtyFileHasher = MakeShareable(new FAsyncFileHasher(MoveTemp(FilesThatNeedHashing))); AsyncTaskThread.AddTask(DirtyFileHasher); } } void FFileCache::OnDirectoryChanged(const TArray& FileChanges) { // Harvest any completed data from the file hasher before we discard it HarvestDirtyFileHashes(); const FDateTime Now = FDateTime::UtcNow(); for (const auto& ThisEntry : FileChanges) { auto TransactionPath = GetTransactionPath(ThisEntry.Filename); if (TransactionPath.IsSet()) { // Add the file that changed to the dirty files map, potentially invalidating the MD5 hash (we'll need to calculate it again) DirtyFiles.Add(MoveTemp(TransactionPath.GetValue()), FFileData(Now, FMD5Hash())); bPendingTransactionsDirty = true; } } RescanForDirtyFileHashes(); } } // namespace DirectoryWatcher