Jelajahi Sumber

Merge pull request #14929 from aws-lumberyard-dev/AP_MetadataCreateDelay

Asset Processor - Metadata Create Delay
amzn-mike 2 tahun lalu
induk
melakukan
dff9be8c5d

+ 1 - 0
Code/Tools/AssetProcessor/assetprocessor_static_files.cmake

@@ -114,6 +114,7 @@ set(FILES
     native/utilities/IPathConversion.h
     native/utilities/UuidManager.h
     native/utilities/UuidManager.cpp
+    native/utilities/IMetadataUpdates.h
 )
 
 set(SKIP_UNITY_BUILD_INCLUSION_FILES

+ 2 - 0
Code/Tools/AssetProcessor/assetprocessor_test_files.cmake

@@ -42,6 +42,8 @@ set(FILES
     native/tests/assetmanager/AssetManagerTestingBase.h
     native/tests/assetmanager/IntermediateAssetTests.cpp
     native/tests/assetmanager/IntermediateAssetTests.h
+    native/tests/assetmanager/DelayRelocationTests.cpp
+    native/tests/assetmanager/DelayRelocationTests.h
     native/tests/assetmanager/Validators/LfsPointerFileValidatorTests.cpp
     native/tests/assetmanager/Validators/LfsPointerFileValidatorTests.h
     native/tests/utilities/assetUtilsTest.cpp

+ 10 - 5
Code/Tools/AssetProcessor/native/AssetManager/AssetRequestHandler.cpp

@@ -111,6 +111,11 @@ namespace
                 }
                 case AssetChangeReportRequest::ChangeType::Move:
                 {
+                    auto* metadataUpdates = AZ::Interface<AssetProcessor::IMetadataUpdates>::Get();
+                    AZ_Assert(metadataUpdates, "Programmer Error - IMetadataUpdates interface is not available.");
+
+                    metadataUpdates->PrepareForFileMove(messageData.m_message->m_fromPath.c_str(), messageData.m_message->m_toPath.c_str());
+
                     auto resultMove = relocationInterface->Move(
                         messageData.m_message->m_fromPath, messageData.m_message->m_toPath, RelocationParameters_AllowDependencyBreakingFlag | RelocationParameters_UpdateReferencesFlag);
 
@@ -385,7 +390,7 @@ bool AssetRequestHandler::InvokeHandler(MessageData<AzFramework::AssetSystem::Ba
 }
 
 void AssetRequestHandler::ProcessAssetRequest(MessageData<RequestAssetStatus> messageData)
-{    
+{
     if ((messageData.m_message->m_searchTerm.empty())&&(!messageData.m_message->m_assetId.IsValid()))
     {
         AZ_TracePrintf(AssetProcessor::DebugChannel, "Failed to decode incoming RequestAssetStatus - both path and uuid is empty\n");
@@ -463,12 +468,12 @@ void AssetRequestHandler::OnRequestAssetExistsResponse(NetworkRequestID groupID,
 
     if (located == m_pendingAssetRequests.end())
     {
-        AZ_TracePrintf(AssetProcessor::DebugChannel, "OnRequestAssetExistsResponse: No such compile group found, ignoring.\n");
+        AZ_Trace(AssetProcessor::DebugChannel, "OnRequestAssetExistsResponse: No such compile group found, ignoring.\n");
         return;
     }
-    
-    AZ_TracePrintf(AssetProcessor::DebugChannel, "GetAssetStatus / CompileAssetSync: Asset %s is %s.\n", 
-        located.value().GetDisplayString().toUtf8().constData(), 
+
+    AZ_Trace(AssetProcessor::DebugChannel, "GetAssetStatus / CompileAssetSync: Asset %s is %s.\n",
+        located.value().GetDisplayString().toUtf8().constData(),
         exists ? "compiled already" : "missing" );
 
     SendAssetStatus(groupID, RequestAssetStatus::MessageType, exists ? AssetStatus_Compiled : AssetStatus_Missing);

+ 228 - 3
Code/Tools/AssetProcessor/native/AssetManager/assetProcessorManager.cpp

@@ -1751,6 +1751,9 @@ namespace AssetProcessor
             }
         }
 
+        // Indicates if this CheckSource event was originally started due to a file change from a metadata file
+        bool triggeredByMetadata = false;
+
         // if metadata file change, pretend the actual file changed
         // the fingerprint will be different anyway since metadata file is folded in
 
@@ -1762,6 +1765,7 @@ namespace AssetProcessor
             if (normalizedFilePath.endsWith("." + metaInfo.first, Qt::CaseInsensitive))
             {
                 //its a meta file.  What was the original?
+                triggeredByMetadata = true;
 
                 normalizedFilePath = normalizedFilePath.left(normalizedFilePath.length() - (metaInfo.first.length() + 1));
                 if (!metaInfo.second.isEmpty())
@@ -1811,6 +1815,24 @@ namespace AssetProcessor
                 break;
             }
         }
+
+        // Check if this event is part of an in-process file move.
+        // If so, ignore the event.
+        if (ShouldIgnorePendingMove(normalizedFilePath.toUtf8().constData(), triggeredByMetadata, source.m_isDelete))
+        {
+            AZ_Trace(
+                AssetProcessor::DebugChannel,
+                "Ignoring processing of " AZ_STRING_FORMAT " - file is marked as pending move\n",
+                AZ_STRING_ARG(normalizedFilePath));
+            return;
+        }
+
+        // Check if this event should be delayed for a while to allow time for moving/renaming to be completed
+        if (ShouldDelayProcessingFile(source, normalizedFilePath, triggeredByMetadata))
+        {
+            return;
+        }
+
         // even if the entry already exists,
         // overwrite the entry here, so if you modify, then delete it, its the latest action thats always on the list.
 
@@ -3084,9 +3106,8 @@ namespace AssetProcessor
                 {
                     AssetProcessor::FileStateInfo fileStateInfo;
 
-                    if (fileStateInterface->GetFileInfo(
-                            sourceAssetReference.AbsolutePath().c_str(), &fileStateInfo) &&
-                        fileStateInfo.m_absolutePath.compare(sourceAssetReference.AbsolutePath().c_str()) != 0)
+                    if (fileStateInterface->GetFileInfo(sourceAssetReference.AbsolutePath().c_str(), &fileStateInfo) &&
+                        sourceAssetReference.AbsolutePath().Compare(fileStateInfo.m_absolutePath.toUtf8().constData()) != 0)
                     {
                         // File on disk has different case compared to the file being processed
                         // This usually means a file was renamed and a "change" event was fired for both the old and new name
@@ -5832,4 +5853,208 @@ namespace AssetProcessor
         }
         return filesFound;
     }
+
+    void AssetProcessorManager::SetMetaCreationDelay(AZ::u32 milliseconds)
+    {
+        m_metaCreationDelayMs = milliseconds;
+    }
+
+    void AssetProcessorManager::PrepareForFileMove(AZ::IO::PathView oldPath, AZ::IO::PathView newPath)
+    {
+        // Note - this code is likely not running on the APM thread, be careful with variable access
+        auto* fileStateInterface = AZ::Interface<IFileStateRequests>::Get();
+        AZ_Assert(fileStateInterface, "Programmer Error - IFileStateRequests is not available.");
+
+        AssetProcessor::FileStateInfo fileInfo;
+        QString oldPathQString = QString::fromUtf8(oldPath.Native().data(), azlossy_caster(oldPath.Native().size()));
+        if (fileStateInterface->GetFileInfo(oldPathQString, &fileInfo) && fileInfo.m_isDirectory)
+        {
+            // If we know the old path is a directory just exit out, a directory rename doesn't have any create/delete issues
+            return;
+        }
+
+        AZStd::scoped_lock lock(m_pendingMovesMutex);
+        m_pendingMoves.emplace(oldPath, false);
+        m_pendingMoves.emplace(newPath, true);
+    }
+
+    bool AssetProcessorManager::CheckMetadataIsAvailable(AZ::IO::PathView absolutePath)
+    {
+        auto* fileStateInterface = AZ::Interface<IFileStateRequests>::Get();
+        auto* uuidInterface = AZ::Interface<IUuidRequests>::Get();
+
+        AZ_Assert(fileStateInterface, "Programmer Error - IFileStateRequests is not available.");
+        AZ_Assert(uuidInterface, "Programmer Error - IUuidRequests is not available.");
+
+        return !uuidInterface->IsGenerationEnabledForFile(absolutePath) ||
+            fileStateInterface->Exists(AzToolsFramework::MetadataManager::ToMetadataPath(absolutePath).c_str());
+    }
+
+    bool AssetProcessorManager::ShouldIgnorePendingMove(AZ::IO::PathView absolutePath, bool triggeredByMetadata, bool isDelete)
+    {
+        AZStd::scoped_lock lock(m_pendingMovesMutex);
+        auto itr = m_pendingMoves.find(absolutePath);
+
+        if (itr != m_pendingMoves.end())
+        {
+            const bool isNewFile = itr->second;
+
+            if (!isNewFile)
+            {
+                if (triggeredByMetadata && isDelete)
+                {
+                    // Deletion of the old metadata file typically would cause the metadata file to be recreated.
+                    // Since this file is moving, ignore the deletion event.
+                    m_pendingMoves.erase(itr);
+                    return true;
+                }
+            }
+            else if (!triggeredByMetadata && !isDelete)
+            {
+                // The new file has been created.
+                m_pendingMoves.erase(itr);
+
+                // If the metadata is not available yet, ignore this event.
+                if (!CheckMetadataIsAvailable(absolutePath))
+                {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    bool AssetProcessorManager::HasDelayProcessTimerElapsed(qint64 elapsedTime)
+    {
+        // QTimer is not a precise timer, it could fire several milliseconds before or after the required wait time.
+        // Just check if the elapsed time is relatively close; 30ms is arbitrary but should be sufficient.
+        // Precise timing isn't necessary here anyway.
+        constexpr double ToleranceMs = 30;
+        return elapsedTime + ToleranceMs >= m_metaCreationDelayMs;
+    }
+
+    bool AssetProcessorManager::ShouldDelayProcessingFile(
+        const AssetProcessorManager::FileEntry& source, QString normalizedFilePath, bool triggeredByMetadata)
+    {
+        if (m_metaCreationDelayMs > 0)
+        {
+            AZ::IO::Path absolutePath = normalizedFilePath.toUtf8().constData();
+
+            // There are 7 possible relevant events here:
+            // 1) An existing source file is deleted
+            // 2) An existing metadata file is deleted
+            // 3) A new source file is added
+            // 4) A new metadata file is added
+            // 5) Delay has expired and the file must be proccessed now
+            // 6) A delayed file was deleted
+            // 7) A delayed file is updated
+
+            // Normally Events 2, 3 and 7 would cause a new metadata to be generated.
+
+            // Event 1 requires no action (since an orphan metadata file is harmless).
+            // Event 2 will need to be delayed if Event 1 has not occurred yet.
+            // Event 3 will need to be delayed if Event 4 has not occurred yet.
+            // Event 4 will end a delay early if Event 3 has already occurred.
+            // Event 5 & 6 will remove the file from the queue and start processing.
+            // Event 7 will simply continue waiting.
+
+            // Event 4: Metadata file added, check if Event 3 has already occurred.
+            if (triggeredByMetadata && !source.m_isDelete)
+            {
+                auto itr = m_delayProcessMetadataFiles.find(absolutePath);
+
+                if (itr != m_delayProcessMetadataFiles.end())
+                {
+                    // Events 3 and 4 have occurred, clear to proceed.
+                    m_delayProcessMetadataFiles.erase(itr);
+                    Q_EMIT ProcessingResumed(normalizedFilePath);
+                }
+            }
+            else
+            {
+                // Events 1-3, 5-7
+                if (m_delayProcessMetadataFiles.contains(absolutePath))
+                {
+                    auto duration = m_delayProcessMetadataFiles[absolutePath].msecsTo(QDateTime::currentDateTime());
+                    if (!HasDelayProcessTimerElapsed(duration))
+                    {
+                        // Event 7: Already waiting on file, keep waiting
+                        if (!m_delayProcessMetadataQueued)
+                        {
+                            m_delayProcessMetadataQueued = true;
+                            QTimer::singleShot(m_metaCreationDelayMs, this, SLOT(DelayedMetadataFileCheck()));
+                        }
+
+                        return true;
+                    }
+
+                    // Event 5-6: Times up, process the file
+                    m_delayProcessMetadataFiles.erase(absolutePath);
+                    Q_EMIT ProcessingResumed(normalizedFilePath);
+                }
+                else if ((triggeredByMetadata || !source.m_isDelete) && !CheckMetadataIsAvailable(absolutePath))
+                {
+                    // Events 2-3: File not in queue and invalid metadata, add to queue
+                    AZ_Trace(
+                        AssetProcessor::DebugChannel,
+                        "Source " AZ_STRING_FORMAT " has no metadata file yet, delaying processing to wait for metadata file.\n",
+                        AZ_STRING_ARG(absolutePath.Native()));
+
+                    m_delayProcessMetadataFiles.emplace(absolutePath, QDateTime::currentDateTime());
+
+                    if (!m_delayProcessMetadataQueued)
+                    {
+                        m_delayProcessMetadataQueued = true;
+                        QTimer::singleShot(m_metaCreationDelayMs, this, SLOT(DelayedMetadataFileCheck()));
+                    }
+
+                    Q_EMIT ProcessingDelayed(normalizedFilePath);
+
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    void AssetProcessorManager::DelayedMetadataFileCheck()
+    {
+        m_delayProcessMetadataQueued = false;
+        auto now = QDateTime::currentDateTime();
+        bool rerun = false;
+        int minWaitTime = m_metaCreationDelayMs;
+
+        for (const auto& [file, time] : m_delayProcessMetadataFiles)
+        {
+            auto duration = time.msecsTo(now);
+
+            if (HasDelayProcessTimerElapsed(duration))
+            {
+                // Times up, process it
+                auto* fileStateCache = AZ::Interface<IFileStateRequests>::Get();
+                AZ_Assert(fileStateCache, "Programmer Error - IFileStateRequests is not available.");
+
+                AssessFileInternal(file.c_str(), !fileStateCache->Exists(file.c_str()));
+            }
+            else
+            {
+                rerun = true;
+                // Figure out the shortest amount of time left to wait for the next file.
+                // This avoids waiting the full duration again in the case that a file was added to the wait list after the timer was already started.
+                // Example:
+                // t=0, A is added and the timer is started with 5000ms delay
+                // t=1000, B is added
+                // t=5000, this function runs and A is processed.  Timer is started again with 1000ms wait
+                minWaitTime = AZ::GetMin(minWaitTime, AZ::GetMax(0, int(m_metaCreationDelayMs - duration)));
+            }
+        }
+
+        if (rerun)
+        {
+            m_delayProcessMetadataQueued = true;
+            QTimer::singleShot(minWaitTime, this, SLOT(DelayedMetadataFileCheck()));
+        }
+    }
 } // namespace AssetProcessor

+ 37 - 0
Code/Tools/AssetProcessor/native/AssetManager/assetProcessorManager.h

@@ -43,6 +43,7 @@
 
 #include <AssetManager/ExcludedFolderCache.h>
 #include <AssetManager/ProductAsset.h>
+#include <native/utilities/IMetadataUpdates.h>
 #endif
 
 class FileWatcher;
@@ -98,6 +99,7 @@ namespace AssetProcessor
     class AssetProcessorManager
         : public QObject
         , public AssetProcessor::ProcessingJobInfoBus::Handler
+        , public AZ::Interface<AssetProcessor::IMetadataUpdates>::Registrar
     {
         using BaseAssetProcessorMessage = AzFramework::AssetSystem::BaseAssetProcessorMessage;
         using AssetFingerprintClearRequest = AzToolsFramework::AssetSystem::AssetFingerprintClearRequest;
@@ -242,6 +244,13 @@ namespace AssetProcessor
         //! or the scan folder ID hasn't been set for the platform config.
         AZStd::optional<AZ::s64> GetIntermediateAssetScanFolderId() const;
 
+        //! Sets the maximum amount of time to wait before automatically generating a metadata file for an asset which does not currently have a metadata file.
+        //! Only applies to file types which are using the metadata system.
+        //! This is used to prevent AP generating new metadata files while someone is trying to rename an existing file.
+        void SetMetaCreationDelay(AZ::u32 milliseconds);
+
+        void PrepareForFileMove(AZ::IO::PathView oldPath, AZ::IO::PathView newPath) override;
+
     Q_SIGNALS:
         void NumRemainingJobsChanged(int newNumJobs);
 
@@ -291,6 +300,12 @@ namespace AssetProcessor
         //! count is the number of files remaining waiting for FinishAnalysis to be called
         void FinishedAnalysis(int count);
 
+        //! Fired when processing of a file has been delayed to wait for a metadata file creation event.
+        void ProcessingDelayed(QString filePath);
+
+        //! Fired when a previously-delayed file has begun processing.
+        void ProcessingResumed(QString filePath);
+
     public Q_SLOTS:
         void AssetProcessed(JobEntry jobEntry, AssetBuilderSDK::ProcessJobResponse response);
         void AssetProcessed_Impl();
@@ -346,6 +361,8 @@ namespace AssetProcessor
         void OnBuildersRegistered();
         void OnCatalogReady();
 
+        void DelayedMetadataFileCheck();
+
     private:
         template <class R>
         bool Recv(unsigned int connId, QByteArray payload, R& request);
@@ -482,6 +499,14 @@ namespace AssetProcessor
         //! Check whether the specified file is an LFS pointer file.
         bool IsLfsPointerFile(const AZStd::string& filePath);
 
+        bool CheckMetadataIsAvailable(AZ::IO::PathView absolutePath);
+        bool ShouldIgnorePendingMove(AZ::IO::PathView absolutePath, bool triggeredByMetadata, bool isDelete);
+        bool ShouldDelayProcessingFile(const FileEntry& source, QString normalizedFilePath, bool triggeredByMetadata);
+
+        //! Returns true if elapsed time is close enough to the delay process wait time
+        //! This is not an exact check since QTimer is not precise with its event timing
+        bool HasDelayProcessTimerElapsed(qint64 elapsedTime);
+
         AssetProcessor::PlatformConfiguration* m_platformConfig = nullptr;
 
         bool m_queuedExamination = false;
@@ -495,6 +520,18 @@ namespace AssetProcessor
         typedef QHash<QString, FileEntry> FileExamineContainer;
         FileExamineContainer m_filesToExamine; // order does not actually matter in this (yet)
 
+        // Set of files which are metadata-enabled but don't have a metadata file.
+        // These files will be delayed for processing for a short time to wait for a metadata file to show up.
+        AZStd::unordered_map<AZ::IO::Path, QDateTime> m_delayProcessMetadataFiles;
+        // Indicates if DelayedMetadataFileCheck has already been queued to run or not.
+        bool m_delayProcessMetadataQueued = false;
+        // Max delay time before creating a metadata file.
+        // Avoid setting this too high as it will delay processing of new files.
+        AZ::u32 m_metaCreationDelayMs = 0;
+        // Set of files/folders that have been reported as pending for move.  bool: false = old file path, true = new file path
+        AZStd::unordered_map<AZ::IO::Path, bool> m_pendingMoves;
+        AZStd::recursive_mutex m_pendingMovesMutex;
+
         // this map contains a list of source files that were discovered in the database before asset scanning began.
         // (so files from a previous run).
         // as asset scanning encounters files, it will remove them from this map, and when its done,

+ 39 - 1
Code/Tools/AssetProcessor/native/tests/assetmanager/AssetManagerTestingBase.cpp

@@ -88,6 +88,7 @@ namespace UnitTests
 
         // Create the APM
         m_assetProcessorManager = AZStd::make_unique<TestingAssetProcessorManager>(m_platformConfig.get());
+        m_assetProcessorManager->SetMetaCreationDelay(0);
 
         // Cache the db pointer because the TEST_F generates a subclass which can't access this private member
         m_stateData = m_assetProcessorManager->m_stateData;
@@ -207,7 +208,44 @@ namespace UnitTests
 
         m_assetProcessorManager->CheckActiveFiles(expectedFileCount);
 
-        QCoreApplication::processEvents();
+        AZStd::atomic_bool delayed = false;
+
+        QObject::connect(
+            m_assetProcessorManager.get(),
+            &AssetProcessor::AssetProcessorManager::ProcessingDelayed,
+            [&delayed](QString filePath)
+            {
+                delayed = true;
+            });
+
+        QObject::connect(
+            m_assetProcessorManager.get(),
+            &AssetProcessor::AssetProcessorManager::ProcessingResumed,
+            [&delayed](QString filePath)
+            {
+                delayed = false;
+            });
+
+        QCoreApplication::processEvents(); // execute CheckSource
+
+        if (delayed)
+        {
+            // Wait for the QTimer to elapse.  This should be a very quick, sub 10ms wait.
+            // Add 5ms just to be sure the required time has elapsed.
+            AZStd::this_thread::sleep_for(AZStd::chrono::milliseconds(MetadataProcessingDelayMs + 5));
+
+            ASSERT_TRUE(delayed);
+
+            QCoreApplication::processEvents(); // Process the timer
+
+            // Sometimes the above processEvents runs CheckSource
+            if (delayed)
+            {
+                QCoreApplication::processEvents(); // execute CheckSource again
+            }
+
+            ASSERT_FALSE(delayed);
+        }
 
         m_assetProcessorManager->CheckActiveFiles(0);
         m_assetProcessorManager->CheckFilesToExamine(expectedFileCount + dependencyFileCount);

+ 2 - 1
Code/Tools/AssetProcessor/native/tests/assetmanager/AssetManagerTestingBase.h

@@ -102,6 +102,7 @@ namespace UnitTests
 
         static constexpr int AssetSubId = 1;
         static constexpr int ExtraAssetSubId = 2;
+        static constexpr int MetadataProcessingDelayMs = 1;
 
     protected:
         void RunFile(int expectedJobCount, int expectedFileCount = 1, int dependencyFileCount = 0);
@@ -148,7 +149,7 @@ namespace UnitTests
 
         TraceBusErrorChecker m_errorChecker;
 
-        AssetProcessor::FileStatePassthrough m_fileStateCache;
+        MockFileStateCache m_fileStateCache;
 
         AZStd::unique_ptr<QCoreApplication> m_qApp;
         AZStd::unique_ptr<TestingAssetProcessorManager> m_assetProcessorManager;

+ 1 - 0
Code/Tools/AssetProcessor/native/tests/assetmanager/AssetProcessorManagerTest.cpp

@@ -166,6 +166,7 @@ void AssetProcessorManagerTest::SetUp()
     m_mockApplicationManager->BusConnect();
 
     m_assetProcessorManager.reset(new AssetProcessorManager_Test(m_config.get()));
+    m_assetProcessorManager->SetMetaCreationDelay(0);
     m_errorAbsorber->Clear();
 
     m_isIdling = false;

+ 263 - 0
Code/Tools/AssetProcessor/native/tests/assetmanager/DelayRelocationTests.cpp

@@ -0,0 +1,263 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <QCoreApplication>
+#include <native/tests/assetmanager/DelayRelocationTests.h>
+#include <native/unittests/UnitTestUtils.h>
+#include <AzFramework/IO/LocalFileIO.h>
+
+namespace UnitTests
+{
+    void DelayRelocationTests::SetUp()
+    {
+        AssetManagerTestingBase::SetUp();
+
+        using namespace AssetBuilderSDK;
+
+        m_uuidInterface = AZ::Interface<AssetProcessor::IUuidRequests>::Get();
+        ASSERT_TRUE(m_uuidInterface);
+
+        m_uuidInterface->EnableGenerationForTypes({ ".stage1" });
+
+        m_assetProcessorManager->SetMetaCreationDelay(MetadataProcessingDelayMs);
+
+        CreateBuilder("stage1", "*.stage1", "stage2", false, ProductOutputFlags::ProductAsset);
+        ProcessFileMultiStage(1, true);
+        QCoreApplication::processEvents();
+    }
+
+    TEST_F(DelayRelocationTests, DeleteMetadata_WithDelay_MetadataIsRecreated)
+    {
+        bool delayed = false;
+        QObject::connect(
+            m_assetProcessorManager.get(),
+            &AssetProcessor::AssetProcessorManager::ProcessingDelayed,
+            [&delayed](QString)
+            {
+                delayed = true;
+            });
+
+        auto expectedMetadataPath = AzToolsFramework::MetadataManager::ToMetadataPath(m_testFilePath);
+
+        EXPECT_TRUE(AZ::IO::FileIOBase::GetInstance()->Exists(expectedMetadataPath.c_str())) << expectedMetadataPath.c_str();
+
+        AZ::IO::FileIOBase::GetInstance()->Remove(expectedMetadataPath.c_str());
+        m_uuidInterface->FileRemoved(expectedMetadataPath);
+
+        // Reprocess
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessDeletedFile", Qt::QueuedConnection, Q_ARG(QString, expectedMetadataPath.c_str()));
+        QCoreApplication::processEvents();
+
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        RunFile(0, 1);
+
+        // Check the metadata file has been recreated
+        EXPECT_TRUE(AZ::IO::FileIOBase::GetInstance()->Exists(expectedMetadataPath.c_str())) << expectedMetadataPath.c_str();
+        EXPECT_TRUE(delayed);
+    }
+
+    TEST_F(DelayRelocationTests, RenameSource_WithDelay_MetadataIsCreated)
+    {
+        bool delayed = false;
+        QObject::connect(
+            m_assetProcessorManager.get(),
+            &AssetProcessor::AssetProcessorManager::ProcessingDelayed,
+            [&delayed](QString)
+            {
+                delayed = true;
+            });
+
+        auto oldPath = m_testFilePath;
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "renamed.stage1";
+        AZStd::string newPath = (scanFolderDir / testFilename).AsPosix().c_str();
+
+        AZ::IO::FileIOBase::GetInstance()->Rename(oldPath.c_str(), newPath.c_str());
+        m_uuidInterface->FileRemoved(oldPath.c_str());
+
+        // Process the delete first
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessDeletedFile", Qt::QueuedConnection, Q_ARG(QString, oldPath.c_str()));
+        QCoreApplication::processEvents();
+
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        RunFile(0, 1);
+        EXPECT_FALSE(delayed);
+
+        QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, newPath.c_str()));
+        QCoreApplication::processEvents();
+
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        RunFile(1, 1);
+
+        // Check the metadata file has been created
+        auto expectedMetadataPath = AzToolsFramework::MetadataManager::ToMetadataPath(newPath);
+        EXPECT_TRUE(AZ::IO::SystemFile::Exists(expectedMetadataPath.c_str())) << expectedMetadataPath.c_str();
+        EXPECT_TRUE(delayed);
+    }
+
+    TEST_F(DelayRelocationTests, RenameSource_RenameMetadataDuringDelay_NoMetadataCreated)
+    {
+        auto oldPath = m_testFilePath;
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "renamed.stage1";
+        AZStd::string newPath = (scanFolderDir / testFilename).AsPosix().c_str();
+        bool delayed = false;
+
+        auto originalUuid = AssetUtilities::GetSourceUuid(AssetProcessor::SourceAssetReference(oldPath.c_str()));
+        ASSERT_TRUE(originalUuid);
+
+        QObject::connect(
+            m_assetProcessorManager.get(),
+            &AssetProcessor::AssetProcessorManager::ProcessingDelayed,
+            [&delayed, oldPath, newPath, this](QString)
+            {
+                delayed = true;
+
+                // During the delay period, rename the metadata file
+                AZ::IO::FileIOBase::GetInstance()->Rename(
+                    AzToolsFramework::MetadataManager::ToMetadataPath(oldPath).c_str(),
+                    AzToolsFramework::MetadataManager::ToMetadataPath(newPath).c_str());
+                m_uuidInterface->FileRemoved(AzToolsFramework::MetadataManager::ToMetadataPath(oldPath));
+            });
+
+        AZ::IO::FileIOBase::GetInstance()->Rename(oldPath.c_str(), newPath.c_str());
+        m_uuidInterface->FileRemoved(oldPath.c_str());
+
+        // Process the delete first
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessDeletedFile", Qt::QueuedConnection, Q_ARG(QString, oldPath.c_str()));
+        QCoreApplication::processEvents();
+
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        RunFile(0, 1);
+        EXPECT_FALSE(delayed);
+
+        QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, newPath.c_str()));
+        QCoreApplication::processEvents();
+
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        RunFile(1, 1);
+
+        auto expectedMetadataPath = AzToolsFramework::MetadataManager::ToMetadataPath(newPath);
+        EXPECT_TRUE(AZ::IO::SystemFile::Exists(expectedMetadataPath.c_str())) << expectedMetadataPath.c_str();
+        EXPECT_TRUE(delayed);
+
+        // Verify that the metadata file we renamed didn't get overwritten
+        auto currentUuid = AssetUtilities::GetSourceUuid(AssetProcessor::SourceAssetReference(newPath.c_str()));
+        ASSERT_TRUE(currentUuid);
+
+        EXPECT_EQ(originalUuid.GetValue(), currentUuid.GetValue());
+    }
+
+    TEST_F(DelayRelocationTests, PrepareForFileMove_RenameSourceAndMetadata_MovedWithoutRecreating)
+    {
+        m_assetProcessorManager->SetMetaCreationDelay(0);
+
+        auto* updateInterface = AZ::Interface<AssetProcessor::IMetadataUpdates>::Get();
+
+        ASSERT_TRUE(updateInterface);
+
+        AZ::IO::Path oldPath = m_testFilePath;
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "renamed.stage1";
+        auto newPath = scanFolderDir / testFilename;
+
+        updateInterface->PrepareForFileMove(oldPath, newPath);
+
+        auto oldMetadataPath = AzToolsFramework::MetadataManager::ToMetadataPath(oldPath);
+        auto newMetadataPath = AzToolsFramework::MetadataManager::ToMetadataPath(newPath);
+
+        AZ::IO::FileIOBase::GetInstance()->Rename(oldMetadataPath.c_str(), newMetadataPath.c_str());
+        m_uuidInterface->FileRemoved(oldMetadataPath.c_str());
+
+        // Process the delete first
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessDeletedFile", Qt::QueuedConnection, Q_ARG(QString, oldMetadataPath.c_str()));
+        QCoreApplication::processEvents();
+
+        // For the below tests, since AP is expected to not complete the analysis, don't use RunFile
+        // Just execute CheckSource and make sure it produced no work
+
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        m_assetProcessorManager->CheckActiveFiles(1);
+
+        QCoreApplication::processEvents(); // execute CheckSource
+
+        m_assetProcessorManager->CheckActiveFiles(0);
+        m_assetProcessorManager->CheckFilesToExamine(0);
+
+        QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, newMetadataPath.c_str()));
+        QCoreApplication::processEvents();
+
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        m_assetProcessorManager->CheckActiveFiles(1);
+
+        QCoreApplication::processEvents(); // execute CheckSource
+
+        m_assetProcessorManager->CheckActiveFiles(0);
+        m_assetProcessorManager->CheckFilesToExamine(0);
+
+        EXPECT_FALSE(AZ::IO::FileIOBase::GetInstance()->Exists(oldMetadataPath.c_str()));
+
+        // Now move the source file
+
+        AZ::IO::FileIOBase::GetInstance()->Rename(oldPath.c_str(), newPath.c_str());
+        m_uuidInterface->FileRemoved(oldPath.c_str());
+
+        // Process the delete first
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessDeletedFile", Qt::QueuedConnection, Q_ARG(QString, oldPath.c_str()));
+        QCoreApplication::processEvents();
+
+        RunFile(0, 1);
+
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, newPath.c_str()));
+        QCoreApplication::processEvents();
+
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        RunFile(1, 1);
+
+        EXPECT_FALSE(AZ::IO::FileIOBase::GetInstance()->Exists(oldPath.c_str()));
+        EXPECT_FALSE(AZ::IO::FileIOBase::GetInstance()->Exists(oldMetadataPath.c_str()));
+    }
+} // namespace UnitTests

+ 22 - 0
Code/Tools/AssetProcessor/native/tests/assetmanager/DelayRelocationTests.h

@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#pragma once
+
+#include <native/tests/assetmanager/AssetManagerTestingBase.h>
+
+namespace UnitTests
+{
+    class DelayRelocationTests : public AssetManagerTestingBase
+    {
+    public:
+        void SetUp() override;
+
+        AssetProcessor::IUuidRequests* m_uuidInterface{};
+    };
+} // namespace UnitTests

+ 3 - 2
Code/Tools/AssetProcessor/native/utilities/ApplicationManagerBase.cpp

@@ -867,7 +867,8 @@ void ApplicationManagerBase::InitUuidManager()
     {
         if (settingsRegistry->GetObject(uuidSettings, "/O3DE/AssetProcessor/Settings/Metadata"))
         {
-            AZ::Interface<AssetProcessor::IUuidRequests>::Get()->EnableGenerationForTypes(uuidSettings.m_enabledTypes);
+            m_uuidManager->EnableGenerationForTypes(uuidSettings.m_enabledTypes);
+            m_assetProcessorManager->SetMetaCreationDelay(uuidSettings.m_metaCreationDelayMs);
         }
     }
 }
@@ -1521,7 +1522,7 @@ bool ApplicationManagerBase::Activate()
         m_uuidManager->FileChanged(changedFile.toUtf8().constData());
         m_fileProcessor->AssessAddedFile(changedFile);
     };
-         
+
     QObject::connect(
         m_assetProcessorManager,
         &AssetProcessor::AssetProcessorManager::IntermediateAssetCreated,

+ 8 - 0
Code/Tools/AssetProcessor/native/utilities/BatchApplicationManager.cpp

@@ -107,6 +107,14 @@ void BatchApplicationManager::InitSourceControl()
     }
 }
 
+void BatchApplicationManager::InitUuidManager()
+{
+    m_uuidManager = AZStd::make_unique<AssetProcessor::UuidManager>();
+    m_assetProcessorManager->SetMetaCreationDelay(0);
+
+    // Note that batch does not set any enabled types and has 0 delay because batch mode is not expected to generate metadata files or handle moving/renaming while running.
+}
+
 void BatchApplicationManager::MakeActivationConnections()
 {
     QObject::connect(m_rcController, &AssetProcessor::RCController::FileCompiled,

+ 1 - 0
Code/Tools/AssetProcessor/native/utilities/BatchApplicationManager.h

@@ -40,6 +40,7 @@ private:
     const char* GetLogBaseName() override;
     RegistryCheckInstructions PopupRegistryProblemsMessage(QString warningText) override;
     void InitSourceControl() override;
+    void InitUuidManager() override;
 
     void MakeActivationConnections() override;
 

+ 29 - 0
Code/Tools/AssetProcessor/native/utilities/IMetadataUpdates.h

@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#pragma once
+
+#include <AzCore/RTTI/RTTI.h>
+#include <AzCore/IO/Path/Path.h>
+
+namespace AssetProcessor
+{
+    //! Interface for signalling a pending file move/rename is about to occur
+    struct IMetadataUpdates
+    {
+        AZ_RTTI(IMetadataUpdates, "{8CB894B4-DB4B-4385-BEF5-39505DD951AC}");
+
+        AZ_DISABLE_COPY_MOVE(IMetadataUpdates);
+
+        IMetadataUpdates() = default;
+        virtual ~IMetadataUpdates() = default;
+
+        //! Signal to AP that a file is about to be moved/renamed
+        virtual void PrepareForFileMove(AZ::IO::PathView oldPath, AZ::IO::PathView newPath) = 0;
+    };
+}

+ 4 - 1
Code/Tools/AssetProcessor/native/utilities/UuidManager.cpp

@@ -26,7 +26,10 @@ namespace AssetProcessor
     {
         if (auto* serializeContext = azrtti_cast<AZ::SerializeContext*>(context))
         {
-            serializeContext->Class<UuidSettings>()->Version(1)->Field("EnabledTypes", &UuidSettings::m_enabledTypes);
+            serializeContext->Class<UuidSettings>()
+                ->Version(2)
+                ->Field("EnabledTypes", &UuidSettings::m_enabledTypes)
+                ->Field("MetaCreationDelayMs", &UuidSettings::m_metaCreationDelayMs);
         }
     }
 

+ 1 - 0
Code/Tools/AssetProcessor/native/utilities/UuidManager.h

@@ -56,6 +56,7 @@ namespace AssetProcessor
 
         static void Reflect(AZ::ReflectContext* context);
 
+        AZ::u32 m_metaCreationDelayMs;
         AZStd::unordered_set<AZStd::string> m_enabledTypes;
     };