Files
UnrealEngine/Engine/Source/Developer/DirectoryWatcher/Private/FileCache.cpp
2025-05-18 13:04:45 +08:00

1179 lines
31 KiB
C++

// 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<typename T>
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<typename T>
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<TWeakPtr<IAsyncFileCacheTask, ESPMode::ThreadSafe>> FTaskArray;
FAsyncTaskThread() : Thread(nullptr) {}
/** Add a reader to this thread which will get ticked periodically until complete */
void AddTask(TSharedPtr<IAsyncFileCacheTask, ESPMode::ThreadSafe> 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<FFilenameAndHash> InFilesThatNeedHashing)
: Data(MoveTemp(InFilesThatNeedHashing)), NumReturned(0)
{
// Read in files in 1MB chunks
ScratchBuffer.SetNumUninitialized(1024 * 1024);
}
TArray<FFilenameAndHash> FAsyncFileHasher::GetCompletedData()
{
// Don't need to lock here since the thread will never look at the array before CurrentIndex.
TArray<FFilenameAndHash> 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<FDirectoryState> FAsyncDirectoryReader::GetLiveState()
{
TOptional<FDirectoryState> OldState;
if (ensureMsgf(IsComplete(), TEXT("Invalid property access from thread before task completion")))
{
Swap(OldState, LiveState);
}
return OldState;
}
TOptional<FDirectoryState> FAsyncDirectoryReader::GetCachedState()
{
TOptional<FDirectoryState> 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<FString>* PendingFiles;
TArray<FString>* 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<FDirectoryWatcherModule>(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<FDirectoryWatcherModule>(TEXT("DirectoryWatcher")))
{
if (IDirectoryWatcher* DirectoryWatcher = Module->Get())
{
DirectoryWatcher->UnregisterDirectoryChangedCallback_Handle(Config.Directory, WatcherDelegate);
}
}
WatcherDelegate.Reset();
}
TOptional<FDirectoryState> FFileCache::ReadCache() const
{
TOptional<FDirectoryState> 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<FString> 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<FString>();
}
if (Config.PathType == EPathType::Relative)
{
return MoveTemp(RelativePath);
}
else
{
return MoveTemp(Temp);
}
}
void FFileCache::DiffDirtyFiles(TMap<FImmutableString, FFileData>& InDirtyFiles, TArray<FUpdateCacheTransaction>& OutTransactions, const FDirectoryState* InFileSystemState) const
{
TMap<FImmutableString, FFileData> AddedFiles, ModifiedFiles;
TSet<FImmutableString> 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<bool> CustomResult = Config.CustomChangeLogic ? Config.CustomChangeLogic(File, FileData) : TOptional<bool>();
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<bool(const FUpdateCacheTransaction&, const FDateTime&)> InIter) const
{
for (const FUpdateCacheTransaction& Transaction : PendingTransactions)
{
FFileData FileData = DirtyFiles.FindRef(Transaction.Filename);
if (!InIter(Transaction, FileData.Timestamp))
{
break;
}
}
}
TArray<FUpdateCacheTransaction> 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<FUpdateCacheTransaction> ReturnVal = MoveTemp(PendingTransactions);
PendingTransactions.Empty();
return ReturnVal;
}
TArray<FUpdateCacheTransaction> FFileCache::FilterOutstandingChanges(TFunctionRef<bool(const FUpdateCacheTransaction&, const FDateTime&)> InPredicate)
{
HarvestDirtyFileHashes();
TArray<FUpdateCacheTransaction> AllTransactions;
DiffDirtyFiles(DirtyFiles, AllTransactions, nullptr);
// Filter the transactions based on the predicate
TArray<FUpdateCacheTransaction> 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<FDirectoryState> LiveState = DirectoryReader->GetLiveState();
TOptional<FDirectoryState> 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<FFilenameAndHash> 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<FFileChangeData>& 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