// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.Horde.Storage; using EpicGames.Horde.Storage.Bundles; using EpicGames.Horde.Storage.Nodes; using Microsoft.Extensions.Logging; namespace UnrealBuildTool.Artifacts { /// /// Horde specific artifact action structure that also contains the file nodes for the outputs /// readonly struct HordeArtifactAction { /// /// Artifact action /// public readonly ArtifactAction ArtifactAction; /// /// Collection of output file references. There should be exactly the same number /// of file references as outputs in the action /// public readonly IHashedBlobRef[] OutputRefs; /// /// Construct a new horde artifact number /// /// Artifact action /// public HordeArtifactAction(ArtifactAction artifactAction) { ArtifactAction = artifactAction; OutputRefs = new IHashedBlobRef[ArtifactAction.Outputs.Length]; } /// /// Construct a new artifact action from the reader /// /// Source reader public HordeArtifactAction(IBlobReader reader) { ArtifactAction = reader.ReadArtifactAction(); OutputRefs = reader.ReadVariableLengthArray(() => reader.ReadBlobRef()); } /// /// Serialize the artifact action /// /// Destination writer public void Serialize(IBlobWriter writer) { writer.WriteArtifactAction(ArtifactAction); writer.WriteVariableLengthArray(OutputRefs, x => writer.WriteBlobRef(x)); } /// /// Write all the files to disk /// /// Destination writer /// Cancellation token /// Task public async Task WriteFilesAsync(IBlobWriter writer, CancellationToken cancellationToken) { LeafChunkedDataNodeOptions leafOptions = new(512 * 1024, 1 * 1024 * 1024, 2 * 1024 * 1024); InteriorChunkedDataNodeOptions interiorOptions = new(1, 10, 20); using LeafChunkedDataWriter fileWriter = new(writer, leafOptions); int index = 0; foreach (ArtifactFile artifact in ArtifactAction.Outputs) { string outputName = artifact.GetFullPath(ArtifactAction.DirectoryMapping); using FileStream stream = new(outputName, FileMode.Open, FileAccess.Read, FileShare.Read); LeafChunkedData leafChunkedData = await fileWriter.CreateAsync(stream, leafOptions.TargetSize, cancellationToken); ChunkedData chunkedData = await InteriorChunkedDataNode.CreateTreeAsync(leafChunkedData, interiorOptions, writer, cancellationToken); OutputRefs[index++] = chunkedData.Root; } } } /// /// Series of helper methods for serialization /// static class HordeArtifactReaderWriterExtensions { /// /// Read a horde artifact action /// /// Source reader /// Created artifact action public static HordeArtifactAction ReadHordeArtifactAction(this IBlobReader reader) { return new HordeArtifactAction(reader); } /// /// Write a horde artifact action /// /// Destination writer /// Artifact action to write public static void WriteHordeArtifactAction(this IBlobWriter writer, HordeArtifactAction artifactAction) { artifactAction.Serialize(writer); } } /// /// Horde node that represents a collection of action nodes /// [BlobConverter(typeof(ArtifactActionCollectionNodeConverter))] class ArtifactActionCollectionNode { /// /// Collection of actions /// public Dictionary ArtifactActions = new(); /// /// Construct a new collection /// public ArtifactActionCollectionNode() { } } class ArtifactActionCollectionNodeConverter : BlobConverter { static readonly BlobType s_blobType = new BlobType("{E8DBCD77-4CAE-861D-0758-7FB733256ED2}", 1); public override ArtifactActionCollectionNode Read(IBlobReader reader, BlobSerializerOptions options) { ArtifactActionCollectionNode node = new ArtifactActionCollectionNode(); node.ArtifactActions = reader.ReadDictionary(() => reader.ReadIoHash(), () => reader.ReadHordeArtifactAction()); return node; } public override BlobType Write(IBlobWriter writer, ArtifactActionCollectionNode value, BlobSerializerOptions options) { writer.WriteDictionary(value.ArtifactActions, (x) => writer.WriteIoHash(x), (x) => writer.WriteHordeArtifactAction(x)); return s_blobType; } } /// /// Class for managing artifacts using horde storage /// public sealed class HordeStorageArtifactCache : IArtifactCache, IDisposable { /// /// Defines the theoretical max number of pending actions to write /// const int MaxPendingSize = 128; /// /// Underlying storage object /// private IStorageNamespace? _store = null; /// /// Task used to wait on ready state /// private Task? _readyTask = null; /// /// Ready state /// private int _state = (int)ArtifactCacheState.Pending; /// /// Collection of actions waiting to be written /// private readonly List _pendingWrites; /// /// Task for any pending flush /// private Task? _pendingWritesFlushTask = null; /// /// Controls access to shared data structures /// private readonly SemaphoreSlim _semaphore = new(1); /// /// Test to see if the cache is ready /// public ArtifactCacheState State { get => (ArtifactCacheState)Interlocked.Add(ref _state, 0); private set => Interlocked.Exchange(ref _state, (int)value); } /// public void Dispose() { _pendingWritesFlushTask?.Dispose(); _pendingWritesFlushTask = null; _semaphore.Dispose(); } /// /// Create a memory only cache /// public static IArtifactCache CreateMemoryCache(ILogger logger) { HordeStorageArtifactCache cache = new(BundleStorageNamespace.CreateInMemory(logger)) { State = ArtifactCacheState.Available }; return cache; } /// /// Create a file based cache /// /// Destination directory /// Cache for memory mapped files /// Logging object /// If true, clean the directory public static IArtifactCache CreateFileCache(DirectoryReference directory, MemoryMappedFileCache memoryMappedFileCache, ILogger logger, bool cleanDirectory) { HordeStorageArtifactCache cache = new(null); cache._readyTask = Task.Run(() => cache.InitFileCache(directory, memoryMappedFileCache, logger, cleanDirectory)); return cache; } /// /// Constructor /// /// Storage object to use private HordeStorageArtifactCache(IStorageNamespace? storage) { _store = storage; _pendingWrites = new(MaxPendingSize); } /// public Task WaitForReadyAsync() { return _readyTask ?? Task.FromResult(State); } /// public async Task QueryArtifactActionsAsync(IoHash[] partialKeys, CancellationToken cancellationToken) { if (State != ArtifactCacheState.Available || _store == null) { return Array.Empty(); } List artifactActions = new(); await _semaphore.WaitAsync(cancellationToken); try { foreach (IoHash key in partialKeys) { lock (_pendingWrites) { artifactActions.AddRange(_pendingWrites.Where(x => x.Key == key)); } ArtifactActionCollectionNode? node = await _store.TryReadRefTargetAsync(GetRefName(key), default, cancellationToken: cancellationToken); if (node != null) { foreach (HordeArtifactAction artifactAction in node.ArtifactActions.Values) { artifactActions.Add(artifactAction.ArtifactAction); } } } } finally { _semaphore.Release(); } return artifactActions.ToArray(); } /// public async Task QueryArtifactOutputsAsync(ArtifactAction[] artifactActions, CancellationToken cancellationToken) { if (State != ArtifactCacheState.Available || _store == null) { return null; } bool[] output = new bool[artifactActions.Length]; Array.Fill(output, false); for (int index = 0; index < artifactActions.Length; index++) { output[index] = false; ArtifactAction artifactAction = artifactActions[index]; ArtifactActionCollectionNode? node = await _store.TryReadRefTargetAsync(GetRefName(artifactAction.Key), default, cancellationToken: cancellationToken); if (node != null) { if (node.ArtifactActions.TryGetValue(artifactAction.ActionKey, out HordeArtifactAction hordeArtifactAction)) { output[index] = true; int refIndex = 0; foreach (IHashedBlobRef artifactRef in hordeArtifactAction.OutputRefs) { if (artifactRef == null) { output[index] = false; break; } try { string outputName = hordeArtifactAction.ArtifactAction.Outputs[refIndex++].GetFullPath(artifactAction.DirectoryMapping); using FileStream stream = new(outputName, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); await artifactRef.CopyToStreamAsync(stream, cancellationToken); } catch (Exception) { output[index] = false; break; } } if (!output[index]) { foreach (ArtifactFile artifact in hordeArtifactAction.ArtifactAction.Outputs) { string outputName = artifact.GetFullPath(artifactAction.DirectoryMapping); if (File.Exists(outputName)) { try { File.Delete(outputName); } catch (Exception) { } } } } } } } return output; } /// public async Task SaveArtifactActionsAsync(ArtifactAction[] artifactActions, CancellationToken cancellationToken) { if (State != ArtifactCacheState.Available || _store == null) { return; } lock (_pendingWrites) { _pendingWrites.AddRange(artifactActions); } Task? task = FlushChangesInternalAsync(false, cancellationToken); if (task != null) { await task; } } /// public async Task FlushChangesAsync(CancellationToken cancellationToken) { if (State != ArtifactCacheState.Available || _store == null) { return; } Task? task = FlushChangesInternalAsync(true, cancellationToken); if (task != null) { await task; } } /// /// Optionally flush all pending writes /// /// If true, force a flush /// Cancellation token private Task? FlushChangesInternalAsync(bool force, CancellationToken cancellationToken) { Task? pendingFlushTask = null; lock (_pendingWrites) { // If any prior flush task has completed, then forget it if (_pendingWritesFlushTask != null && _pendingWritesFlushTask.IsCompleted) { _pendingWritesFlushTask = null; } // We start a new flush under the following condition // // 1) Actions must be pending // 2) Create a new task if force is specified // 3) -OR- Create a new task if there is no current task and we have reached the limit if (_pendingWrites.Count > 0 && (force || (_pendingWrites.Count >= MaxPendingSize && _pendingWritesFlushTask == null))) { ArtifactAction[] artifactActionsToFlush = _pendingWrites.ToArray(); Task? priorTask = _pendingWritesFlushTask; _pendingWrites.Clear(); async Task action() { // When forcing, we might have a prior flush task in progress. Wait for it to complete if (priorTask != null) { await priorTask; } // Block reading while we update the actions await _semaphore.WaitAsync(cancellationToken); try { List tasks = CommitArtifactActions(artifactActionsToFlush, cancellationToken); await Task.WhenAll(tasks); } finally { _semaphore.Release(); } } pendingFlushTask = _pendingWritesFlushTask = new(() => action().Wait(), cancellationToken); } } // Start the task outside of the lock pendingFlushTask?.Start(); return pendingFlushTask; } /// /// Add a group of artifact actions to a new or existing source /// /// New artifact actions to add /// Token to be used to cancel operations /// List of tasks private List CommitArtifactActions(ArtifactAction[] artifactActions, CancellationToken cancellationToken) { List tasks = new(); if (artifactActions.Length == 0) { return tasks; } // Loop through the artifact actions foreach (ArtifactAction artifactAction in artifactActions) { // Create the task to write the files tasks.Add(Task.Run(async () => { RefName refName = GetRefName(artifactAction.Key); // Locate the destination collection for this key ArtifactActionCollectionNode? node = await _store!.TryReadRefTargetAsync(refName, default, cancellationToken: cancellationToken); node ??= new ArtifactActionCollectionNode(); // Update the artifact action collection HordeArtifactAction hordeArtifactAction = new(artifactAction); node.ArtifactActions[artifactAction.ActionKey] = hordeArtifactAction; // Save the artifact action file await using IBlobWriter writer = _store!.CreateBlobWriter(); await hordeArtifactAction.WriteFilesAsync(writer, cancellationToken); IHashedBlobRef nodeRef = await writer.WriteBlobAsync(node); await writer.FlushAsync(); // Save the collection await _store.AddRefAsync(refName, nodeRef, cancellationToken: cancellationToken); }, cancellationToken)); } return tasks; } /// /// Initialize a file based cache /// /// Destination directory /// Cache for memory mapped files /// Logger /// If true, clean the directory /// Cache state private ArtifactCacheState InitFileCache(DirectoryReference directory, MemoryMappedFileCache memoryMappedFileCache, ILogger logger, bool cleanDirectory) { try { if (cleanDirectory) { // Clear the output directory try { Directory.Delete(directory.FullName, true); } catch (Exception) { } } Directory.CreateDirectory(directory.FullName); _store = BundleStorageNamespace.CreateFromDirectory(directory, BundleCache.None, memoryMappedFileCache, logger); State = ArtifactCacheState.Available; return State; } catch (Exception) { State = ArtifactCacheState.Unavailable; throw; } } /// /// Return the ref name for horde storage given a artitfact action collection key /// /// Artifact action collection key /// The reference name private static RefName GetRefName(IoHash key) { return new RefName($"action_artifact_v2_{key}"); } } }