// Copyright Epic Games, Inc. All Rights Reserved. #include "FileCas.h" #include #include #include #include #include #include #include #include #include #include #include #include #include // clang-format off #include struct IUnknown; // Workaround for "combaseapi.h(229): error C2187: syntax error: 'identifier' was unexpected here" when using /permissive- #include #include // clang-format on namespace zen { using namespace fmt::literals; FileCasStrategy::FileCasStrategy(const CasStoreConfiguration& Config) : m_Config(Config) { } FileCasStrategy::~FileCasStrategy() { } WideStringBuilderBase& FileCasStrategy::MakeShardedPath(WideStringBuilderBase& ShardedPath, const IoHash& ChunkHash, size_t& OutShard2len) { ExtendableStringBuilder<96> HashString; ChunkHash.ToHexString(HashString); const char* str = HashString.c_str(); // Shard into a path with two directory levels containing 12 bits and 8 bits // respectively. // // This results in a maximum of 4096 * 256 directories // // The numbers have been chosen somewhat arbitrarily but are large to scale // to very large chunk repositories. It may or may not make sense to make // this a configurable policy, and it would probably be a good idea to // measure performance for different policies and chunk counts ShardedPath.AppendAsciiRange(str, str + 3); ShardedPath.Append('\\'); ShardedPath.AppendAsciiRange(str + 3, str + 5); OutShard2len = ShardedPath.Size(); ShardedPath.Append('\\'); ShardedPath.AppendAsciiRange(str + 5, str + 64); return ShardedPath; } CasStore::InsertResult FileCasStrategy::InsertChunk(IoBuffer Chunk, const IoHash& ChunkHash) { // File-based chunks have special case handling whereby we move the file into // place in the file store directory, thus avoiding unnecessary copying IoBufferFileReference FileRef; if (Chunk.IsWholeFile() && Chunk.GetFileReference(/* out */ FileRef)) { size_t Shard2len = 0; ExtendableWideStringBuilder<128> ShardedPath; ShardedPath.Append(m_Config.RootDirectory.c_str()); ShardedPath.Append(std::filesystem::path::preferred_separator); MakeShardedPath(ShardedPath, ChunkHash, /* out */ Shard2len); auto DeletePayloadFileOnClose = [&] { // This will cause the file to be deleted when the last handle to it is closed FILE_DISPOSITION_INFO Fdi{}; Fdi.DeleteFile = TRUE; BOOL Success = SetFileInformationByHandle(FileRef.FileHandle, FileDispositionInfo, &Fdi, sizeof Fdi); if (!Success) { ZEN_WARN("Failed to flag temporary payload file for deletion: '{}'", PathFromHandle(FileRef.FileHandle)); } }; // See if file already exists // // Future improvement: maintain Bloom filter to avoid expensive file system probes? RwLock::ExclusiveLockScope _(LockForHash(ChunkHash)); { CAtlFile PayloadFile; if (HRESULT hRes = PayloadFile.Create(ShardedPath.c_str(), GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING); SUCCEEDED(hRes)) { // If we succeeded in opening the target file then we don't need to do anything else because it already exists // and should contain the content we were about to insert // We do need to ensure the source file goes away on close, however DeletePayloadFileOnClose(); return CasStore::InsertResult{.New = false}; } } std::filesystem::path FullPath(ShardedPath.c_str()); std::filesystem::path FilePath = FullPath.parent_path(); std::wstring FileName = FullPath.native(); const DWORD BufferSize = sizeof(FILE_RENAME_INFO) + gsl::narrow(FileName.size() * sizeof(WCHAR)); FILE_RENAME_INFO* RenameInfo = reinterpret_cast(Memory::Alloc(BufferSize)); memset(RenameInfo, 0, BufferSize); RenameInfo->ReplaceIfExists = FALSE; RenameInfo->FileNameLength = gsl::narrow(FileName.size()); memcpy(RenameInfo->FileName, FileName.c_str(), FileName.size() * sizeof(WCHAR)); RenameInfo->FileName[FileName.size()] = 0; // Try to move file into place BOOL Success = SetFileInformationByHandle(FileRef.FileHandle, FileRenameInfo, RenameInfo, BufferSize); if (!Success) { // The rename/move could fail because the target directory does not yet exist. This code attempts // to create it CAtlFile DirHandle; auto InternalCreateDirectoryHandle = [&] { return DirHandle.Create(FilePath.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS); }; // It's possible for several threads to enter this logic trying to create the same // directory. Only one will create the directory of course, but all threads will // make it through okay HRESULT hRes = InternalCreateDirectoryHandle(); if (FAILED(hRes)) { zen::CreateDirectories(FilePath.c_str()); hRes = InternalCreateDirectoryHandle(); } if (FAILED(hRes)) { ThrowSystemException(hRes, "Failed to open shard directory '{}'"_format(FilePath)); } // Retry Success = SetFileInformationByHandle(FileRef.FileHandle, FileRenameInfo, RenameInfo, BufferSize); } Memory::Free(RenameInfo); if (Success) { return CasStore::InsertResult{.New = true}; } ZEN_WARN("rename of CAS payload file failed ('{}'), falling back to regular write for insert of {}", GetLastErrorAsString(), ChunkHash); DeletePayloadFileOnClose(); } return InsertChunk(Chunk.Data(), Chunk.Size(), ChunkHash); } CasStore::InsertResult FileCasStrategy::InsertChunk(const void* const ChunkData, const size_t ChunkSize, const IoHash& ChunkHash) { size_t Shard2len = 0; ExtendableWideStringBuilder<128> ShardedPath; ShardedPath.Append(m_Config.RootDirectory.c_str()); ShardedPath.Append(std::filesystem::path::preferred_separator); MakeShardedPath(ShardedPath, ChunkHash, /* out */ Shard2len); // See if file already exists // // Future improvement: maintain Bloom filter to avoid expensive file system probes? CAtlFile PayloadFile; HRESULT hRes = PayloadFile.Create(ShardedPath.c_str(), GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING); if (SUCCEEDED(hRes)) { // If we succeeded in opening the file then we don't need to do anything else because it already exists and should contain the // content we were about to insert return CasStore::InsertResult{.New = false}; } PayloadFile.Close(); RwLock::ExclusiveLockScope _(LockForHash(ChunkHash)); // For now, use double-checked locking to see if someone else was first hRes = PayloadFile.Create(ShardedPath.c_str(), GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING); if (SUCCEEDED(hRes)) { // If we succeeded in opening the file then we don't need to do anything // else because someone else managed to create the file before we did. Just return. return {.New = false}; } if ((hRes != HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND)) && (hRes != HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND))) { ZEN_WARN("Unexpected error code when opening shard file for read: {:#x}", uint32_t(hRes)); } auto InternalCreateFile = [&] { return PayloadFile.Create(ShardedPath.c_str(), GENERIC_WRITE, FILE_SHARE_DELETE, CREATE_ALWAYS); }; hRes = InternalCreateFile(); if (hRes == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND)) { // Ensure parent directories exist and retry file creation std::filesystem::create_directories(std::wstring_view(ShardedPath.c_str(), Shard2len)); hRes = InternalCreateFile(); } if (FAILED(hRes)) { ThrowSystemException(hRes, "Failed to open shard file '{}'"_format(WideToUtf8(ShardedPath))); } size_t ChunkRemain = ChunkSize; auto ChunkCursor = reinterpret_cast(ChunkData); while (ChunkRemain != 0) { uint32_t ByteCount = uint32_t(std::min(4 * 1024 * 1024ull, ChunkRemain)); PayloadFile.Write(ChunkCursor, ByteCount); ChunkCursor += ByteCount; ChunkRemain -= ByteCount; } // We cannot rely on RAII to close the file handle since it would be closed // *after* the lock is released due to the initialization order PayloadFile.Close(); return {.New = true}; } IoBuffer FileCasStrategy::FindChunk(const IoHash& ChunkHash) { size_t Shard2len = 0; ExtendableWideStringBuilder<128> ShardedPath; ShardedPath.Append(m_Config.RootDirectory.c_str()); ShardedPath.Append(std::filesystem::path::preferred_separator); MakeShardedPath(ShardedPath, ChunkHash, /* out */ Shard2len); RwLock::SharedLockScope _(LockForHash(ChunkHash)); return IoBufferBuilder::MakeFromFile(ShardedPath.c_str()); } bool FileCasStrategy::HaveChunk(const IoHash& ChunkHash) { size_t Shard2len = 0; ExtendableWideStringBuilder<128> ShardedPath; ShardedPath.Append(m_Config.RootDirectory.c_str()); ShardedPath.Append(std::filesystem::path::preferred_separator); MakeShardedPath(ShardedPath, ChunkHash, /* out */ Shard2len); RwLock::SharedLockScope _(LockForHash(ChunkHash)); std::error_code Ec; if (std::filesystem::exists(ShardedPath.c_str(), Ec)) { return true; } return false; } void FileCasStrategy::FilterChunks(CasChunkSet& InOutChunks) { // NOTE: it's not a problem now, but in the future if a GC should happen while this // is in flight, the result could be wrong since chunks could go away in the meantime. // // It would be good to have a pinning mechanism to make this less likely but // given that chunks could go away at any point after the results are returned to // a caller, this is something which needs to be taken into account by anyone consuming // this functionality in any case std::unordered_set HaveSet; for (const IoHash& Hash : InOutChunks.GetChunkSet()) { if (HaveChunk(Hash)) { HaveSet.insert(Hash); } } for (const IoHash& Hash : HaveSet) { InOutChunks.RemoveIfPresent(Hash); } } void FileCasStrategy::IterateChunks(std::function&& Callback) { struct Visitor : public FileSystemTraversal::TreeVisitor { Visitor(const std::filesystem::path& RootDir) : RootDirectory(RootDir) {} virtual void VisitFile(const std::filesystem::path& Parent, const std::wstring_view& File, uint64_t FileSize) override { ZEN_UNUSED(FileSize); std::filesystem::path RelPath = std::filesystem::relative(Parent, RootDirectory); std::wstring PathString = RelPath.native(); if ((PathString.size() == (3 + 2 + 1)) && (File.size() == (40 - 3 - 2))) { if (PathString.at(3) == std::filesystem::path::preferred_separator) { PathString.erase(3, 1); } PathString.append(File); StringBuilder<64> Utf8; WideToUtf8(PathString, Utf8); // TODO: should validate that we're actually dealing with a valid hex string here IoHash NameHash = IoHash::FromHexString({Utf8.Data(), Utf8.Size()}); BasicFile PayloadFile; std::error_code Ec; PayloadFile.Open(Parent / File, false, Ec); if (!Ec) { Callback(NameHash, PayloadFile); } } } virtual bool VisitDirectory([[maybe_unused]] const std::filesystem::path& Parent, [[maybe_unused]] const std::wstring_view& DirectoryName) { return true; } const std::filesystem::path& RootDirectory; std::function Callback; } CasVisitor{m_Config.RootDirectory}; CasVisitor.Callback = std::move(Callback); FileSystemTraversal Traversal; Traversal.TraverseFileSystem(m_Config.RootDirectory, CasVisitor); } void FileCasStrategy::Flush() { // Since we don't keep files open after writing there's nothing specific // to flush here. // // Depending on what semantics we want Flush() to provide, it could be // argued that this should just flush the volume which we are using to // store the CAS files on here, to ensure metadata is flushed along // with file data // // Related: to facilitate more targeted validation during recovery we could // maintain a log of when chunks were created } void FileCasStrategy::Scrub(ScrubContext& Ctx) { std::vector BadHashes; IterateChunks([&](const IoHash& Hash, BasicFile& Payload) { IoHashStream Hasher; Payload.StreamFile([&](const void* Data, size_t Size) { Hasher.Append(Data, Size); }); IoHash ComputedHash = Hasher.GetHash(); if (ComputedHash != Hash) { BadHashes.push_back(Hash); } }); Ctx.ReportBadChunks(BadHashes); } void FileCasStrategy::GarbageCollect(GcContext& GcCtx) { ZEN_UNUSED(GcCtx); } } // namespace zen