// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using OpenTelemetry.Trace; namespace EpicGames.Perforce.Managed { /// /// Exception relating to managed workspace /// public class ManagedWorkspaceException : Exception { /// /// Constructor /// public ManagedWorkspaceException(string message) : base(message) { } } /// /// Exception thrown when there is not enough free space on the drive /// public class InsufficientSpaceException : ManagedWorkspaceException { /// /// Constructor /// /// Error message public InsufficientSpaceException(string message) : base(message) { } } /// /// Information about a populate request /// public class PopulateRequest { /// /// The Perforce connection /// public IPerforceConnection PerforceClient { get; } /// /// Stream to sync it to /// public string StreamName { get; } /// /// View for this client /// public IReadOnlyList View { get; } /// /// Constructor /// /// The perforce connection /// Stream to be synced /// List of filters for the stream public PopulateRequest(IPerforceConnection perforceClient, string streamName, IReadOnlyList view) { PerforceClient = perforceClient; StreamName = streamName; View = view; } } /// /// Extra options for configuring ManagedWorkspace /// /// Maximum number of threads to sync in parallel /// Maximum number of concurrent file system operations (copying, moving, deleting etc) /// Minimum amount of disk space that must be available after a sync (in megabytes) /// /// Use the client's have table when syncing. /// /// When set to false, updates to the have table will be prevented through use of "sync -p". /// Actual files to sync will be gathered through "fstat". This puts less strain on the Perforce server and can improve sync performance. /// /// Whether to allow using partitioned workspaces /// Whether to prefer the native p4 client public record ManagedWorkspaceOptions ( int NumParallelSyncThreads, int MaxFileConcurrency, long MinScratchSpace, bool UseHaveTable, bool Partitioned, bool PreferNativeClient ) { /// public ManagedWorkspaceOptions(int? numParallelSyncThreads = null, int? maxFileConcurrency = null, long minScratchSpace = 50L * 1024, bool useHaveTable = true, bool partitioned = false, bool preferNativeClient = false) : this( numParallelSyncThreads ?? GetDefaultThreadCount(4), maxFileConcurrency ?? GetDefaultThreadCount(4), minScratchSpace, useHaveTable, partitioned, preferNativeClient) { } static int GetDefaultThreadCount(int defaultValue) => Math.Min(defaultValue, Math.Max(Environment.ProcessorCount - 1, 1)); } /// /// Version number for managed workspace cache files /// enum ManagedWorkspaceVersion { /// /// Initial version number /// Initial = 2, /// /// Including stream directory digests in workspace directories /// AddDigest = 3, /// /// Changing hash algorithm from SHA1 to IoHash /// AddDigestIoHash = 4, } /// /// Represents a repository of streams and cached data /// public class ManagedWorkspace { /// /// The current transaction state. Used to determine whether a repository needs to be cleaned on startup. /// enum TransactionState { Dirty, Clean, } /// /// The file signature and version. Update this to introduce breaking changes and ignore old repositories. /// const int CurrentSignature = ('W' << 24) | ('T' << 16) | 2; /// /// The current revision number for cache archives. /// static int CurrentVersion { get; } = Enum.GetValues(typeof(ManagedWorkspaceVersion)).Cast().Max(); /// /// File format version for untracked state file /// private const int UntrackedFileVersion = 1; /// /// Externally configurable options /// private readonly ManagedWorkspaceOptions _options; /// /// Constant for syncing the latest change number /// public const int LatestChangeNumber = -1; /// /// Name of the signature file for a repository. This /// const string SignatureFileName = "Repository.sig"; /// /// Name of the main data file for a repository /// const string DataFileName = "Repository.dat"; /// /// Name of the untracked state file for a repository /// const string UntrackedStateFileName = "Untracked.dat"; /// /// Name of the host /// readonly string _hostName; /// /// Incrementing number assigned to sequential operations that modify files. Used to age out files in the cache. /// uint _nextSequenceNumber; /// /// Whether a repair operation should be run on this workspace. Set whenever the state may be inconsistent. /// bool _requiresRepair; /// /// Tracer /// readonly Tracer _tracer; /// /// The log output device /// readonly ILogger _logger; /// /// The root directory for the stash /// readonly DirectoryReference _baseDir; /// /// Root directory for storing cache files /// readonly DirectoryReference _cacheDir; /// /// Root directory for storing workspace files /// readonly DirectoryReference _workspaceDir; /// /// Set of clients that we're created. Used to avoid updating multiple times during one run. /// readonly Dictionary _createdClients = new Dictionary(); /// /// Set of unique cache entries. We use this to ensure new names in the cache are unique. /// readonly HashSet _cacheEntries = new HashSet(); /// /// List of all the staged files /// WorkspaceDirectoryInfo _workspace; /// /// All the files which are currently being tracked /// Dictionary _contentIdToTrackedFile = new Dictionary(); /// /// Notification that a clean has been performed /// public delegate void OnCleanDelegate(int numFilesDeleted, int numDirsDeleted); /// /// Occurs when a clean operation has been performed. /// public event OnCleanDelegate? OnClean; /// /// Constructor /// /// Name of the current host /// The next sequence number for operations /// The root directory for the stash /// Extra options /// Tracer /// The log output device private ManagedWorkspace(string hostName, uint nextSequenceNumber, DirectoryReference baseDir, ManagedWorkspaceOptions options, Tracer tracer, ILogger logger) { // Save the Perforce settings _hostName = hostName; _nextSequenceNumber = nextSequenceNumber; _tracer = tracer; _logger = logger; _options = options; // Get all the directories _baseDir = baseDir; DirectoryReference.CreateDirectory(baseDir); _cacheDir = DirectoryReference.Combine(baseDir, "Cache"); DirectoryReference.CreateDirectory(_cacheDir); _workspaceDir = DirectoryReference.Combine(baseDir, "Sync"); DirectoryReference.CreateDirectory(_workspaceDir); // Create the workspace _workspace = new WorkspaceDirectoryInfo(_workspaceDir); } /// /// Loads a repository from the given directory, or create it if it doesn't exist /// /// Name of the current machine. Will be automatically detected from the host settings if not present. /// The base directory for the repository /// Whether to allow overwriting a repository that's not up to date /// Extra options /// Tracer /// The logging interface /// Cancellation token for this operation /// public static async Task LoadOrCreateAsync(string hostName, DirectoryReference baseDir, bool overwrite, ManagedWorkspaceOptions options, Tracer tracer, ILogger logger, CancellationToken cancellationToken) { if (Exists(baseDir)) { try { return await LoadAsync(hostName, baseDir, options, tracer, logger, cancellationToken); } catch (Exception ex) { if (overwrite) { logger.LogWarning(ex, "Unable to load existing repository."); } else { throw; } } } return await CreateAsync(hostName, baseDir, options, tracer, logger, cancellationToken); } /* public static PerforceConnection GetPerforceConnection(PerforceConnection Perforce) { if (Perforce.UserName == null || HostName == null) { InfoRecord ServerInfo = await Perforce.GetInfoAsync(InfoOptions.ShortOutput, CancellationToken); if (Perforce.UserName == null) { Perforce = new PerforceConnection(Perforce) { UserName = ServerInfo.UserName }; } if (HostName == null) { if (ServerInfo.ClientHost == null) { throw new Exception("Unable to determine host name"); } else { HostName = ServerInfo.ClientHost; } } } return Perforce; } */ /// /// Creates a repository at the given location /// /// Name of the current machine. /// The base directory for the repository /// Extra options /// Tracer /// The log output device /// Cancellation token for this operation /// New repository instance public static async Task CreateAsync(string hostName, DirectoryReference baseDir, ManagedWorkspaceOptions options, Tracer tracer, ILogger logger, CancellationToken cancellationToken) { using TelemetrySpan span = tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(CreateAsync)}"); logger.LogInformation("Creating repository at {Location}...", baseDir); // Make sure all the fields are valid DirectoryReference.CreateDirectory(baseDir); FileUtils.ForceDeleteDirectoryContents(baseDir); ManagedWorkspace repo = new ManagedWorkspace(hostName, 1, baseDir, options, tracer, logger); await repo.SaveAsync(TransactionState.Clean, cancellationToken); repo.CreateCacheHierarchy(); FileReference signatureFile = FileReference.Combine(baseDir, SignatureFileName); using (BinaryWriter writer = new BinaryWriter(File.Open(signatureFile.FullName, FileMode.Create, FileAccess.Write, FileShare.Read))) { writer.Write(CurrentSignature); } return repo; } /// /// Tests whether a repository exists in the given directory /// /// /// public static bool Exists(DirectoryReference baseDir) { FileReference signatureFile = FileReference.Combine(baseDir, SignatureFileName); if (FileReference.Exists(signatureFile)) { using (BinaryReader reader = new BinaryReader(File.Open(signatureFile.FullName, FileMode.Open, FileAccess.Read, FileShare.Read))) { int signature = reader.ReadInt32(); if (signature == CurrentSignature) { return true; } } } return false; } /// /// Loads a repository from disk /// /// Name of the current host. Will be obtained from a 'p4 info' call if not specified /// The base directory for the repository /// Extra options /// Tracer /// The log output device /// Cancellation token for this command public static async Task LoadAsync(string hostName, DirectoryReference baseDir, ManagedWorkspaceOptions options, Tracer tracer, ILogger logger, CancellationToken cancellationToken) { using TelemetrySpan span = tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(LoadAsync)}"); if (!Exists(baseDir)) { throw new FatalErrorException("No valid repository found at {0}", baseDir); } FileReference dataFile = FileReference.Combine(baseDir, DataFileName); RestoreBackup(dataFile); byte[] data = await FileReference.ReadAllBytesAsync(dataFile, cancellationToken); MemoryReader reader = new MemoryReader(data.AsMemory()); int version = reader.ReadInt32(); if (version > CurrentVersion) { throw new FatalErrorException("Unsupported data format (version {0}, current {1})", version, CurrentVersion); } bool requiresRepair = reader.ReadBoolean(); uint nextSequenceNumber = reader.ReadUInt32(); ManagedWorkspace repo = new(hostName, nextSequenceNumber, baseDir, options, tracer, logger); repo._requiresRepair = requiresRepair; int numTrackedFiles = reader.ReadInt32(); for (int idx = 0; idx < numTrackedFiles; idx++) { CachedFileInfo trackedFile = reader.ReadCachedFileInfo(repo._cacheDir); repo._contentIdToTrackedFile.Add(trackedFile.ContentId, trackedFile); repo._cacheEntries.Add(trackedFile.CacheId); } reader.ReadWorkspaceDirectoryInfo(repo._workspace, (ManagedWorkspaceVersion)version); await repo.RunOptionalCacheRepairAsync(cancellationToken); return repo; } /// /// Save the state of the repository /// private async Task SaveAsync(TransactionState state, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(SaveAsync)}"); // Allocate the buffer for writing int serializedSize = sizeof(int) + sizeof(byte) + sizeof(int) + sizeof(int) + _contentIdToTrackedFile.Values.Sum(x => x.GetSerializedSize()) + _workspace.GetSerializedSize(); byte[] buffer = new byte[serializedSize]; // Write the data to memory MemoryWriter writer = new MemoryWriter(buffer.AsMemory()); writer.WriteInt32(CurrentVersion); writer.WriteBoolean(_requiresRepair || (state != TransactionState.Clean)); writer.WriteUInt32(_nextSequenceNumber); writer.WriteInt32(_contentIdToTrackedFile.Count); foreach (CachedFileInfo trackedFile in _contentIdToTrackedFile.Values) { writer.WriteCachedFileInfo(trackedFile); } writer.WriteWorkspaceDirectoryInfo(_workspace); writer.CheckEmpty(); // Write it to disk FileReference dataFile = FileReference.Combine(_baseDir, DataFileName); BeginTransaction(dataFile); await FileReference.WriteAllBytesAsync(dataFile, buffer, cancellationToken); CompleteTransaction(dataFile); } /// /// Mark whether current repository in combination with P4 client contains untracked files /// Stores data in a file separate to the repository metadata as that can grow large (hundreds of megabytes) /// private async Task SaveUntrackedStateAsync(IPerforceConnection connection, bool containsUntrackedFiles, CancellationToken cancellationToken) { if (connection.Settings.ClientName == null) { throw new ArgumentException("Unable to save untracked state as P4 client name is not set"); } using MemoryStream ms = new(300); await using BinaryWriter bw = new(ms); bw.Write(UntrackedFileVersion); bw.Write(connection.Settings.ClientName); bw.Write(containsUntrackedFiles); // Write it to disk FileReference dataFile = FileReference.Combine(_baseDir, UntrackedStateFileName); BeginTransaction(dataFile); await FileReference.WriteAllBytesAsync(dataFile, ms.ToArray(), cancellationToken); CompleteTransaction(dataFile); } /// /// Check if current repository in combination with P4 client contains untracked files /// If deserialization is not possible, assume the repo contains untracked files /// /// True if repository contains untracked files private async Task LoadUntrackedStateAsync(IPerforceConnection connection, CancellationToken cancellationToken) { try { FileReference dataFile = FileReference.Combine(_baseDir, UntrackedStateFileName); if (!File.Exists(dataFile.FullName)) { return true; } byte[] data = await File.ReadAllBytesAsync(dataFile.FullName, cancellationToken); using MemoryStream ms = new (data); using BinaryReader br = new (ms); int version = br.ReadInt32(); if (version != UntrackedFileVersion) { return true; } string clientName = br.ReadString(); bool containsUntrackedFiles = br.ReadBoolean(); return clientName != connection.Settings.ClientName || containsUntrackedFiles; } catch (Exception) { return true; } } #region Commands /// /// Cleans the current workspace /// /// Optional Perforce client, if not set untracked state file cannot be updated /// Whether to remove untracked files /// Cancellation token public async Task CleanAsync(IPerforceConnection? perforce, bool removeUntracked, CancellationToken cancellationToken) { Stopwatch timer = Stopwatch.StartNew(); _logger.LogInformation("Cleaning workspace..."); using (_logger.BeginIndentScope(" ")) { await CleanInternalAsync(removeUntracked, cancellationToken); } if (perforce != null && removeUntracked) { await SaveUntrackedStateAsync(perforce, false, cancellationToken); } _logger.LogInformation("Completed in {ElapsedTime}s", $"{timer.Elapsed.TotalSeconds:0.0}"); } /// /// Cleans the current workspace /// /// Whether to remove untracked files /// Cancellation token private async Task CleanInternalAsync(bool removeUntracked, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(CleanInternalAsync)}"); FileInfo[] filesToDelete; DirectoryInfo[] directoriesToDelete; using (Trace("FindFilesToClean")) using (ILoggerProgress status = _logger.BeginProgressScope("Finding files to clean...")) { using TelemetrySpan findFilesSpan = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.FindFilesToClean"); Stopwatch timer = Stopwatch.StartNew(); (FileInfo[] filesToDelete, DirectoryInfo[] directoriesToDelete) refreshResult = await _workspace.RefreshAsync(removeUntracked, _options.MaxFileConcurrency); filesToDelete = refreshResult.filesToDelete; directoriesToDelete = refreshResult.directoriesToDelete; findFilesSpan.SetAttribute("horde.mw.num_delete_files", filesToDelete.Length); findFilesSpan.SetAttribute("horde.mw.num_delete_dirs", directoriesToDelete.Length); status.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } if (filesToDelete.Length > 0 || directoriesToDelete.Length > 0) { List paths = new List(); paths.AddRange(directoriesToDelete.Select(x => String.Format("/{0}/...", new DirectoryReference(x).MakeRelativeTo(_workspaceDir).Replace(Path.DirectorySeparatorChar, '/')))); paths.AddRange(filesToDelete.Select(x => String.Format("/{0}", new FileReference(x).MakeRelativeTo(_workspaceDir).Replace(Path.DirectorySeparatorChar, '/')))); const int MaxDisplay = 1000; foreach (string path in paths.OrderBy(x => x).Take(MaxDisplay)) { _logger.LogInformation(" {Path}", path); } if (paths.Count > MaxDisplay) { _logger.LogInformation(" +{NumPaths:n0} more", paths.Count - MaxDisplay); } using (Trace("CleanFiles")) using (ILoggerProgress scope = _logger.BeginProgressScope("Cleaning files...")) { using TelemetrySpan cleanSpan = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.CleaningFilesAndDirs"); Stopwatch timer = Stopwatch.StartNew(); ParallelOptions options = new() { MaxDegreeOfParallelism = _options.MaxFileConcurrency, CancellationToken = cancellationToken }; { using TelemetrySpan cleanFilesSpan = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.CleaningFiles"); await Parallel.ForEachAsync(filesToDelete, options, (fileToDelete, ct) => { FileUtils.ForceDeleteFile(fileToDelete); return ValueTask.CompletedTask; }); } { using TelemetrySpan cleanDirsSpan = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.CleaningDirs"); await Parallel.ForEachAsync(directoriesToDelete, options, (directoryToDelete, ct) => { FileUtils.ForceDeleteDirectory(directoryToDelete); return ValueTask.CompletedTask; }); } scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } await SaveAsync(TransactionState.Clean, cancellationToken); } OnClean?.Invoke(filesToDelete.Length, directoriesToDelete.Length); } /// /// Empties the staging directory of any staged files /// public async Task ClearAsync(CancellationToken cancellationToken) { Stopwatch timer = Stopwatch.StartNew(); _logger.LogInformation("Clearing workspace..."); using (Trace("Clear")) using (_logger.BeginIndentScope(" ")) { await CleanInternalAsync(true, cancellationToken); await RemoveFilesFromWorkspaceAsync(StreamSnapshot.Empty, cancellationToken); await SaveAsync(TransactionState.Clean, cancellationToken); } _logger.LogInformation("Completed in {ElapsedTime}s", $"{timer.Elapsed.TotalSeconds:0.0}"); } /// /// Dumps the contents of the repository to the log for analysis /// public void Dump() { Stopwatch timer = Stopwatch.StartNew(); _logger.LogInformation("Dumping repository to log..."); WorkspaceFileInfo[] workspaceFiles = _workspace.GetFiles().OrderBy(x => x.GetLocation().FullName).ToArray(); if (workspaceFiles.Length > 0) { _logger.LogDebug(" Workspace:"); foreach (WorkspaceFileInfo file in workspaceFiles) { _logger.LogDebug(" {File,-128} [{ContentId,-48}] [{Length,20:n0}] [{LastModified,20}]{Writable}", file.GetClientPath(), file.ContentId, file._length, file._lastModifiedTicks, file._readOnly ? "" : " [ writable ]"); } } if (_contentIdToTrackedFile.Count > 0) { _logger.LogDebug(" Cache:"); foreach (KeyValuePair pair in _contentIdToTrackedFile) { _logger.LogDebug(" {File,-128} [{ContentId,-48}] [{Length,20:n0}] [{LastModified,20}]{Writable}", pair.Value.GetLocation(), pair.Key, pair.Value.Length, pair.Value.LastModifiedTicks, pair.Value.ReadOnly ? "" : "[ writable ]"); } } _logger.LogInformation("Completed in {ElapsedTime}s", $"{timer.Elapsed.TotalSeconds:0.0}"); } /// /// Checks the integrity of the cache /// public async Task RepairCacheAsync(CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(RepairCacheAsync)}"); using (Trace("Repair")) using (ILoggerProgress status = _logger.BeginProgressScope("Checking cache...")) { // Make sure all the folders exist in the cache CreateCacheHierarchy(); List trackedFiles = _contentIdToTrackedFile.Values.ToList(); // Check that all the files in the cache appear as we expect them to const int MaxLoggedMissingFiles = 250; int numMissingFiles = 0; foreach (CachedFileInfo trackedFile in trackedFiles) { if (!trackedFile.CheckIntegrity((numMissingFiles < MaxLoggedMissingFiles) ? _logger : NullLogger.Instance)) { RemoveTrackedFile(trackedFile); numMissingFiles++; } } if (numMissingFiles > MaxLoggedMissingFiles) { _logger.LogWarning("+ {Count} more", numMissingFiles - MaxLoggedMissingFiles); } // Clear the repair flag _requiresRepair = false; await SaveAsync(TransactionState.Clean, cancellationToken); status.Progress = "Done"; } } /// /// Cleans the current workspace /// public async Task RevertAsync(IPerforceConnection perforceClient, CancellationToken cancellationToken) { Stopwatch timer = Stopwatch.StartNew(); await RevertInternalAsync(perforceClient, cancellationToken); _logger.LogInformation("Completed in {ElapsedTime}s", $"{timer.Elapsed.TotalSeconds:0.0}"); } /// /// Checks the flag, and repairs/resets it if set. /// private async Task RunOptionalCacheRepairAsync(CancellationToken cancellationToken) { if (_requiresRepair) { await RepairCacheAsync(cancellationToken); } } /// /// Shrink the size of the cache to the given size /// /// The maximum cache size, in bytes /// Cancellation token public async Task PurgeAsync(long maxSize, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(PurgeAsync)}"); _logger.LogInformation("Purging cache (limit {MaxSize:n0} bytes)...", maxSize); using (Trace("Purge")) using (_logger.BeginIndentScope(" ")) { List cachedFiles = _contentIdToTrackedFile.Values.OrderBy(x => x.SequenceNumber).ToList(); int numRemovedFiles = 0; long totalSize = cachedFiles.Sum(x => x.Length); while (maxSize < totalSize && numRemovedFiles < cachedFiles.Count) { CachedFileInfo file = cachedFiles[numRemovedFiles]; RemoveTrackedFile(file); totalSize -= file.Length; numRemovedFiles++; } await SaveAsync(TransactionState.Clean, cancellationToken); _logger.LogInformation("{NumFilesRemoved} files removed, {NumFilesRemaining} files remaining, new size {NewSize:n0} bytes.", numRemovedFiles, cachedFiles.Count - numRemovedFiles, totalSize); } } /// /// Configures the client for the given stream /// /// The Perforce connection /// Name of the stream /// Cancellation token public async Task SetupAsync(IPerforceConnection perforceClient, string streamName, CancellationToken cancellationToken) { await UpdateClientAsync(perforceClient, streamName, cancellationToken); } /// /// Prints stats showing coherence between different streams /// public async Task StatsAsync(IPerforceConnection perforceClient, List streamNames, List view, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(StatsAsync)}"); _logger.LogInformation("Finding stats for {NumStreams} streams", streamNames.Count); using (_logger.BeginIndentScope(" ")) { // Update the list of files in each stream Tuple[] streamState = new Tuple[streamNames.Count]; for (int idx = 0; idx < streamNames.Count; idx++) { string streamName = streamNames[idx]; _logger.LogInformation("Finding contents of {StreamName}:", streamName); using (_logger.BeginIndentScope(" ")) { _createdClients.Remove(perforceClient.Settings.ClientName!); // Force the client to be updated await UpdateClientAsync(perforceClient, streamName, cancellationToken); int changeNumber = await GetLatestClientChangeAsync(perforceClient, cancellationToken); _logger.LogInformation("Latest change is CL {ChangeNumber}", changeNumber); await RevertInternalAsync(perforceClient, cancellationToken); await ClearClientHaveTableAsync(perforceClient, cancellationToken); await UpdateClientHaveTableAsync(perforceClient, changeNumber, view, cancellationToken); StreamSnapshot contents = await FindClientContentsAsync(perforceClient, changeNumber, cancellationToken); streamState[idx] = Tuple.Create(changeNumber, contents); GC.Collect(); } } // Find stats for using (Trace("Stats")) using (ILoggerProgress scope = _logger.BeginProgressScope("Finding usage stats...")) { Stopwatch timer = Stopwatch.StartNew(); // Find the set of files in each stream HashSet[] filesInStream = new HashSet[streamNames.Count]; for (int idx = 0; idx < streamNames.Count; idx++) { List files = streamState[idx].Item2.GetFiles(); filesInStream[idx] = new HashSet(files.Select(x => x.ContentId)); } // Build a table showing amount of unique content in each stream string[][] cells = new string[streamNames.Count + 1][]; for (int idx = 0; idx < cells.Length; idx++) { cells[idx] = new string[streamNames.Count + 1]; } cells[0][0] = ""; for (int idx = 0; idx < streamNames.Count; idx++) { cells[idx + 1][0] = streamNames[idx]; cells[0][idx + 1] = streamNames[idx]; } // Populate the table for (int rowIdx = 0; rowIdx < streamNames.Count; rowIdx++) { List files = streamState[rowIdx].Item2.GetFiles(); for (int colIdx = 0; colIdx < streamNames.Count; colIdx++) { long diffSize = files.Where(x => !filesInStream[colIdx].Contains(x.ContentId)).Sum(x => x.Length); cells[rowIdx + 1][colIdx + 1] = String.Format("{0:0.0}mb", diffSize / (1024.0 * 1024.0)); } } // Find the width of each row int[] colWidths = new int[streamNames.Count + 1]; for (int colIdx = 0; colIdx < streamNames.Count + 1; colIdx++) { for (int rowIdx = 0; rowIdx < streamNames.Count + 1; rowIdx++) { colWidths[colIdx] = Math.Max(colWidths[colIdx], cells[rowIdx][colIdx].Length); } } // Print the table _logger.LogInformation(""); _logger.LogInformation("Each row shows the size of files in a stream which are unique to that stream compared to each column:"); _logger.LogInformation(""); for (int rowIdx = 0; rowIdx < streamNames.Count + 1; rowIdx++) { StringBuilder row = new StringBuilder(); for (int colIdx = 0; colIdx < streamNames.Count + 1; colIdx++) { string cell = cells[rowIdx][colIdx]; row.Append(' ', colWidths[colIdx] - cell.Length); row.Append(cell); row.Append(" | "); } _logger.LogInformation("{Row}", row.ToString()); } _logger.LogInformation(""); scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } } /// /// Prints information about the repository state /// public async Task StatusAsync() { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(StatusAsync)}"); // Print size stats _logger.LogInformation("Cache contains {NumFiles:n0} files, {TotalSize:n1}mb", _contentIdToTrackedFile.Count, _contentIdToTrackedFile.Values.Sum(x => x.Length) / (1024.0 * 1024.0)); _logger.LogInformation("Stage contains {NumFiles:n0} files, {TotalSize:n1}mb", _workspace.GetFiles().Count, _workspace.GetFiles().Sum(x => x._length) / (1024.0 * 1024.0)); // Print the contents of the workspace string[] differences = await _workspace.FindDifferencesAsync(_options.MaxFileConcurrency); if (differences.Length > 0) { _logger.LogInformation("Local changes:"); foreach (string difference in differences) { if (difference.StartsWith("+", StringComparison.Ordinal)) { Console.ForegroundColor = ConsoleColor.Green; } else if (difference.StartsWith("-", StringComparison.Ordinal)) { Console.ForegroundColor = ConsoleColor.Red; } else if (difference.StartsWith("!", StringComparison.Ordinal)) { Console.ForegroundColor = ConsoleColor.Yellow; } else { Console.ResetColor(); } _logger.LogInformation(" {Line}", difference); } Console.ResetColor(); } } /// /// Switches to the given stream /// /// The perforce connection /// Name of the stream to sync /// Changelist number to sync. -1 to sync to latest. /// View of the workspace /// Whether to remove untracked files from the workspace /// Whether to simulate the syncing operation rather than actually getting files from the server /// If set, uses the given file to cache the contents of the workspace. This can improve sync times when multiple machines sync the same workspace. /// Cancellation token public async Task SyncAsync(IPerforceConnection perforce, string streamName, int changeNumber, IReadOnlyList view, bool removeUntracked, bool fakeSync, FileReference? cacheFile, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(SyncAsync)}"); span.SetAttribute("horde.mw.use_have_table", _options.UseHaveTable); Stopwatch timer = Stopwatch.StartNew(); if (changeNumber == -1) { _logger.LogInformation("Syncing to {StreamName} at latest", streamName); } else { _logger.LogInformation("Syncing to {StreamName} at CL {CL}", streamName, changeNumber); } using (_logger.WithProperty("useHaveTable", _options.UseHaveTable).BeginScope()) using (_logger.BeginIndentScope(" ")) { // Update the client to the current stream await UpdateClientAsync(perforce, streamName, cancellationToken); // Get the latest change number if (changeNumber == -1) { changeNumber = await GetLatestClientChangeAsync(perforce, cancellationToken); } if (_options.UseHaveTable) { // Revert any open files await RevertInternalAsync(perforce, cancellationToken); // Force the P4 metadata to match up Task updateHaveTableTask = Task.Run(() => UpdateClientHaveTableAsync(perforce, changeNumber, view, cancellationToken), cancellationToken); // Check if clean up can be skipped bool containsUntrackedFiles = await LoadUntrackedStateAsync(perforce, cancellationToken); if (containsUntrackedFiles) { await CleanInternalAsync(removeUntracked, cancellationToken); } // Immediately after a potential clean, mark repository having untracked files. // This ensure a clean is run next time even if the sync below is aborted or fails await SaveUntrackedStateAsync(perforce, true, cancellationToken); // Wait for the have table update to finish await updateHaveTableTask; } // Update the state of the current stream, if necessary StreamSnapshot? contents; if (cacheFile == null) { if (_options.UseHaveTable) { contents = await FindClientContentsAsync(perforce, changeNumber, cancellationToken); } else { contents = await FindClientContentsWithoutHaveTableAsync(perforce, streamName, view, changeNumber, cancellationToken); } } else { contents = await TryLoadClientContentsAsync(cacheFile, new Utf8String(streamName), cancellationToken); contents ??= await FindAndSaveClientContentsAsync(perforce, new Utf8String(streamName), view, changeNumber, cacheFile, cancellationToken); } // Sync all the appropriate files await RemoveFilesFromWorkspaceAsync(contents, cancellationToken); await AddFilesToWorkspaceAsync(perforce, contents, fakeSync, cancellationToken); } _logger.LogInformation("Completed in {ElapsedTime}s", $"{timer.Elapsed.TotalSeconds:0.0}"); } /// /// Replays the effects of unshelving a changelist, but clobbering files in the workspace rather than actually unshelving them (to prevent problems with multiple machines locking them) /// /// Async task public async Task UnshelveAsync(IPerforceConnection perforce, int unshelveChangelist, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(UnshelveAsync)}"); // Need to mark those files as dirty - update the workspace with those files // Delete is fine, but need to flag anything added Stopwatch timer = Stopwatch.StartNew(); _logger.LogInformation("Unshelving changelist {Change}...", unshelveChangelist); // query the contents of the shelved changelist List records = await perforce.DescribeAsync(DescribeOptions.Shelved, -1, new int[] { unshelveChangelist }, cancellationToken); if (records.Count != 1) { throw new PerforceException($"Changelist {unshelveChangelist} is not shelved"); } DescribeRecord lastRecord = records[0]; if (lastRecord.Files.Count == 0) { throw new PerforceException($"Changelist {unshelveChangelist} does not contain any shelved files"); } // query the location of each file List> whereResponseList = await perforce.TryWhereAsync(lastRecord.Files.Select(x => x.DepotFile).ToArray(), cancellationToken).ToListAsync(cancellationToken); Dictionary whereRecords = whereResponseList.Where(x => x.Succeeded).Select(x => x.Data).ToDictionary(x => x.DepotFile, x => x, StringComparer.OrdinalIgnoreCase); // parse out all the list of deleted and modified files List deleteFiles = new List(); List writeFiles = new List(); foreach (DescribeFileRecord fileRecord in lastRecord.Files) { WhereRecord? whereRecord = null; if (whereRecords.TryGetValue(fileRecord.DepotFile, out whereRecord) == false) { _logger.LogInformation("Unable to get location of {File} in current workspace; ignoring.", fileRecord.DepotFile); continue; } switch (fileRecord.Action) { case FileAction.Delete: case FileAction.MoveDelete: deleteFiles.Add(whereRecord); break; case FileAction.Add: case FileAction.Edit: case FileAction.MoveAdd: case FileAction.Branch: case FileAction.Integrate: writeFiles.Add(whereRecord); break; default: throw new Exception($"Unknown action '{fileRecord.Action}' for shelved file {fileRecord.DepotFile}"); } } if (!_createdClients.TryGetValue(perforce.Settings.ClientName!, out ClientRecord? perforceClient)) { throw new Exception($"Unknown client {perforce.Settings.ClientName}"); } // Add all the files to be written to the workspace with invalid metadata. This will ensure they're removed on next clean. if (writeFiles.Count > 0) { _logger.LogInformation("Removing {NumFiles} files from tracked workspace", writeFiles.Count); foreach (WhereRecord writeFile in writeFiles) { string path = Regex.Replace(writeFile.ClientFile, "^//[^/]+/", ""); _workspace.AddFile(new Utf8String(path), 0, 0, false, new FileContentId(Md5Hash.Zero, default)); } await SaveAsync(TransactionState.Clean, CancellationToken.None); } // Delete all the files foreach (WhereRecord deleteFile in deleteFiles) { string localPath = deleteFile.Path; if (File.Exists(localPath)) { _logger.LogInformation(" Deleting {LocalPath}", localPath); FileUtils.ForceDeleteFile(localPath); } } // Use common paths with wild cards speed up the print operation with one call instead of many calls to print. _logger.LogInformation("Writing files from shelved changelist {Change}", unshelveChangelist); PerforceResponseList printResponse = await perforce.TryPrintAsync($"{perforceClient.Root}{Path.DirectorySeparatorChar}...", $"//{perforceClient.Name}/...@={unshelveChangelist}", cancellationToken); if (!printResponse.Succeeded) { _logger.LogWarning("Unable to print shelved changelist: {Error}", printResponse.ToString()); } _logger.LogInformation("Completed in {TimeSeconds}s", $"{timer.Elapsed.TotalSeconds:0.0}"); } /// /// Populates the cache with the head revision of the given streams. /// public async Task PopulateAsync(List requests, bool fakeSync, CancellationToken cancellationToken) { _logger.LogInformation("Populating with {NumStreams} streams", requests.Count); using (_logger.BeginIndentScope(" ")) { Tuple[] streamState = await PopulateCleanAsync(requests, cancellationToken); await PopulateSyncAsync(requests, streamState, fakeSync, cancellationToken); } } /// /// Perform the clean part of a populate command /// public async Task[]> PopulateCleanAsync(List requests, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(PopulateCleanAsync)}"); // Revert all changes in each of the unique clients foreach (PopulateRequest request in requests) { using IPerforceConnection perforce = await request.PerforceClient.WithoutClientAsync(); PerforceResponse response = await perforce.TryGetClientAsync(request.PerforceClient.Settings.ClientName!, cancellationToken); if (response.Succeeded) { await RevertInternalAsync(request.PerforceClient, cancellationToken); } } // Clean the current workspace await CleanAsync(null, true, cancellationToken); // Update the list of files in each stream Tuple[] streamState = new Tuple[requests.Count]; for (int idx = 0; idx < requests.Count; idx++) { PopulateRequest request = requests[idx]; string streamName = request.StreamName; _logger.LogInformation("Finding contents of {StreamName}:", streamName); using (_logger.BeginIndentScope(" ")) { await DeleteClientAsync(request.PerforceClient, cancellationToken); await UpdateClientAsync(request.PerforceClient, streamName, cancellationToken); int changeNumber = await GetLatestClientChangeAsync(request.PerforceClient, cancellationToken); _logger.LogInformation("Latest change is CL {CL}", changeNumber); if (_options.UseHaveTable) { await UpdateClientHaveTableAsync(request.PerforceClient, changeNumber, request.View, cancellationToken); StreamSnapshot contents = await FindClientContentsAsync(request.PerforceClient, changeNumber, cancellationToken); streamState[idx] = Tuple.Create(changeNumber, contents); } else { StreamSnapshot contents = await FindClientContentsWithoutHaveTableAsync(request.PerforceClient, streamName, request.View, changeNumber, cancellationToken); streamState[idx] = Tuple.Create(changeNumber, contents); } GC.Collect(); } } // Remove any files from the workspace not referenced by the first stream. This ensures we can purge things from the cache that we no longer need. if (requests.Count > 0) { await RemoveFilesFromWorkspaceAsync(streamState[0].Item2, cancellationToken); } // Shrink the contents of the cache using (Trace("UpdateCache")) using (ILoggerProgress status = _logger.BeginProgressScope("Updating cache...")) { Stopwatch timer = Stopwatch.StartNew(); HashSet commonContentIds = new HashSet(); Dictionary contentIdToLength = new Dictionary(); for (int idx = 0; idx < requests.Count; idx++) { List files = streamState[idx].Item2.GetFiles(); foreach (StreamFile file in files) { contentIdToLength[file.ContentId] = file.Length; } if (idx == 0) { commonContentIds.UnionWith(files.Select(x => x.ContentId)); } else { commonContentIds.IntersectWith(files.Select(x => x.ContentId)); } } List trackedFiles = _contentIdToTrackedFile.Values.ToList(); foreach (CachedFileInfo trackedFile in trackedFiles) { if (!contentIdToLength.ContainsKey(trackedFile.ContentId)) { RemoveTrackedFile(trackedFile); } } GC.Collect(); double totalSize = contentIdToLength.Sum(x => x.Value) / (1024.0 * 1024.0); status.Progress = String.Format("{0:n1}mb total, {1:n1}mb differences ({2:0.0}s)", totalSize, totalSize - commonContentIds.Sum(x => contentIdToLength[x]) / (1024.0 * 1024.0), timer.Elapsed.TotalSeconds); } return streamState; } /// /// Perform the sync part of a populate command /// public async Task PopulateSyncAsync(List requests, Tuple[] streamState, bool fakeSync, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(PopulateSyncAsync)}"); // Sync all the new files for (int idx = 0; idx < requests.Count; idx++) { PopulateRequest request = requests[idx]; string streamName = request.StreamName; _logger.LogInformation("Syncing files for {StreamName}:", streamName); using (_logger.BeginIndentScope(" ")) { if (_options.UseHaveTable) { await DeleteClientAsync(request.PerforceClient, cancellationToken); await UpdateClientAsync(request.PerforceClient, streamName, cancellationToken); int changeNumber = streamState[idx].Item1; await UpdateClientHaveTableAsync(request.PerforceClient, changeNumber, requests[idx].View, cancellationToken); } StreamSnapshot contents = streamState[idx].Item2; await RemoveFilesFromWorkspaceAsync(contents, cancellationToken); await AddFilesToWorkspaceAsync(request.PerforceClient, contents, fakeSync, cancellationToken); } } // Save the new repo state await SaveAsync(TransactionState.Clean, cancellationToken); } #endregion #region Core operations /// /// Deletes a client /// /// The Perforce connection /// Cancellation token /// Async task public async Task DeleteClientAsync(IPerforceConnection perforceClient, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(DeleteClientAsync)}"); async Task DeleteAsync(string clientName) { PerforceResponse response = await perforceClient.TryDeleteClientAsync(DeleteClientOptions.None, clientName, cancellationToken); if (response.Error != null && response.Error.Generic != PerforceGenericCode.Unknown) { if (response.Error.Generic == PerforceGenericCode.NotYet) { await RevertInternalAsync(perforceClient, cancellationToken); response = await perforceClient.TryDeleteClientAsync(DeleteClientOptions.None, clientName, cancellationToken); } response.EnsureSuccess(); } _createdClients.Remove(clientName); } await DeleteAsync(perforceClient.Settings.ClientName!); } private async Task GetOrCreateClientAsync(IPerforceConnection perforceClient, string streamName, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(GetOrCreateClientAsync)}"); string clientName = perforceClient.Settings.ClientName!;//GetClientName(perforceClient, useHaveTable); if (_createdClients.TryGetValue(clientName, out ClientRecord? client) && client.Stream == streamName) { return client; } client = new ClientRecord(clientName, perforceClient.Settings.UserName!, _workspaceDir.FullName); client.Host = _hostName; client.Stream = streamName; if (_options.Partitioned) { // Partitioned and read-only types store their have table separately on the server, compared to normal (writeable) clients // Clients that sync without updating the have table cannot submit so they're marked as read-only. client.Type = _options.UseHaveTable ? "partitioned" : "readonly"; } _logger.LogInformation("Using client {ClientName} (Host: {HostName}, Stream: {StreamName}, Type: {Type}, Root: {Path})", client.Name, client.Host, client.Stream, client.Type ?? "full", client.Root); using (Trace("UpdateClient")) using (ILoggerProgress status = _logger.BeginProgressScope("Updating client...")) { Stopwatch timer = Stopwatch.StartNew(); using IPerforceConnection perforce = await perforceClient.WithoutClientAsync(); PerforceResponse response = await perforce.TryCreateClientAsync(client, cancellationToken); if (!response.Succeeded) { await perforceClient.TryDeleteClientAsync(DeleteClientOptions.None, clientName, cancellationToken); await perforceClient.CreateClientAsync(client, cancellationToken); } if (!_options.UseHaveTable) { // If have table is not used, make client is fully reset as it may have been re-used await UpdateHaveTablePathAsync(perforceClient, $"//{clientName}/...#0", cancellationToken); } status.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } _createdClients[clientName] = client; return client; } /// /// Sets the stream for the current client /// /// The Perforce connection /// New stream for the client /// Cancellation token private async Task UpdateClientAsync(IPerforceConnection perforceClient, string streamName, CancellationToken cancellationToken) { await GetOrCreateClientAsync(perforceClient, streamName, cancellationToken); // Update the config file with the name of the client FileReference configFile = FileReference.Combine(_baseDir, "p4.ini"); using (StreamWriter writer = new StreamWriter(configFile.FullName)) { await writer.WriteLineAsync($"P4PORT={perforceClient.Settings.ServerAndPort}"); await writer.WriteLineAsync($"P4CLIENT={perforceClient.Settings.ClientName}"); } } /// /// Gets the latest change submitted for the given stream /// /// The Perforce connection /// The stream to sync /// Cancellation token /// The latest changelist number public async Task GetLatestChangeAsync(IPerforceConnection perforceClient, string streamName, CancellationToken cancellationToken) { // Update the client to the current stream await UpdateClientAsync(perforceClient, streamName, cancellationToken); // Get the latest change number return await GetLatestClientChangeAsync(perforceClient, cancellationToken); } /// /// Get the latest change number in the current client /// /// The perforce client connection /// Cancellation token /// The latest submitted change number private async Task GetLatestClientChangeAsync(IPerforceConnection perforceClient, CancellationToken cancellationToken) { int changeNumber; using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(GetLatestClientChangeAsync)}"); using (Trace("FindChange")) using (ILoggerProgress status = _logger.BeginProgressScope("Finding latest change...")) { Stopwatch timer = Stopwatch.StartNew(); List changes = await perforceClient.GetChangesAsync(ChangesOptions.None, 1, ChangeStatus.Submitted, new[] { String.Format("//{0}/...", perforceClient.Settings.ClientName) }, cancellationToken); if (changes.Count == 0) { throw new ManagedWorkspaceException($"Unable to find latest change; no changes in view for {perforceClient.Settings.ClientName}."); } changeNumber = changes[0].Number; status.Progress = String.Format("CL {0} ({1:0.0}s)", changeNumber, timer.Elapsed.TotalSeconds); } return changeNumber; } /// /// Revert all files that are open in the current workspace. Does not replace them with valid revisions. /// /// The current client connection /// Cancellation token private async Task RevertInternalAsync(IPerforceConnection perforceClient, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(RevertInternalAsync)}"); using (Trace("Revert")) using (ILoggerProgress status = _logger.BeginProgressScope("Reverting changes...")) { Stopwatch timer = Stopwatch.StartNew(); // Get a list of open files List openedFilesResponse = await perforceClient.OpenedAsync(OpenedOptions.ShortOutput, -1, perforceClient.Settings.ClientName!, null, 1, FileSpecList.Any, cancellationToken).ToListAsync(cancellationToken); // If there are any files, revert them if (openedFilesResponse.Any()) { await perforceClient.RevertAsync(-1, null, RevertOptions.KeepWorkspaceFiles, new[] { "//..." }, cancellationToken); } // Find all the open changes List changes = await perforceClient.GetChangesAsync(ChangesOptions.None, perforceClient.Settings.ClientName!, -1, ChangeStatus.Pending, null, Array.Empty(), cancellationToken); // Delete the changelist foreach (ChangesRecord change in changes) { // Delete the shelved files List describeResponse = await perforceClient.DescribeAsync(DescribeOptions.Shelved, -1, new[] { change.Number }, cancellationToken); foreach (DescribeRecord record in describeResponse) { if (record.Files.Count > 0) { await perforceClient.DeleteShelvedFilesAsync(record.Number, Array.Empty(), cancellationToken); } } // Delete the changelist await perforceClient.DeleteChangeAsync(DeleteChangeOptions.None, change.Number, cancellationToken); } status.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } /// /// Clears the have table. This ensures that we'll always fetch the names of files at head revision, which aren't updated otherwise. /// /// The client connection /// The cancellation token private async Task ClearClientHaveTableAsync(IPerforceConnection perforceClient, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(ClearClientHaveTableAsync)}"); using (Trace("ClearHaveTable")) using (ILoggerProgress scope = _logger.BeginProgressScope("Clearing have table...")) { Stopwatch timer = Stopwatch.StartNew(); await perforceClient.SyncQuietAsync(SyncOptions.KeepWorkspaceFiles, -1, new[] { String.Format("//{0}/...#0", perforceClient.Settings.ClientName!) }, cancellationToken); scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } /// /// Updates the have table to reflect the given stream /// /// The client connection /// The change number to sync. May be -1, for latest. /// View of the stream. Each entry should be a path relative to the stream root, with an optional '-'prefix. /// Cancellation token private async Task UpdateClientHaveTableAsync(IPerforceConnection perforceClient, int changeNumber, IReadOnlyList view, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(UpdateClientHaveTableAsync)}"); using (Trace("UpdateHaveTable")) using (ILoggerProgress scope = _logger.BeginProgressScope("Updating have table...")) { Stopwatch timer = Stopwatch.StartNew(); // Sync an initial set of files. Either start with a full workspace and remove files, or start with nothing and add files. if (view.Count == 0 || view[0].StartsWith("-", StringComparison.Ordinal)) { await UpdateHaveTablePathAsync(perforceClient, $"//{perforceClient.Settings.ClientName}/...@{changeNumber}", cancellationToken); } else { await UpdateHaveTablePathAsync(perforceClient, $"//{perforceClient.Settings.ClientName}/...#0", cancellationToken); } // Update with the contents of each filter foreach (string filter in view) { string syncPath; if (filter.StartsWith("-", StringComparison.Ordinal)) { syncPath = String.Format("//{0}/{1}#0", perforceClient.Settings.ClientName, RemoveLeadingSlash(filter.Substring(1))); } else { syncPath = String.Format("//{0}/{1}@{2}", perforceClient.Settings.ClientName, RemoveLeadingSlash(filter), changeNumber); } await UpdateHaveTablePathAsync(perforceClient, syncPath, cancellationToken); } scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } /// /// Update a path in the have table /// /// The Perforce client /// Path to sync /// Cancellation token /// Async task private static async Task UpdateHaveTablePathAsync(IPerforceConnection perforceClient, string syncPath, CancellationToken cancellationToken) { PerforceResponseList responseList = await perforceClient.TrySyncQuietAsync(SyncOptions.KeepWorkspaceFiles, -1, new[] { syncPath }, cancellationToken); foreach (PerforceResponse response in responseList) { PerforceError? error = response.Error; if (error != null && error.Generic != PerforceGenericCode.Empty) { throw new PerforceException(error); } } } /// /// Optimized record definition for fstat calls when populating a workspace. Since there are so many files in a typical branch, /// the speed of serializing these records is crucial for performance. Rather than deseralizing everything, we filter to just /// the fields we need, and avoid any unnecessary conversions from their primitive data types. /// class FStatIndexedRecord { // Note: This enum is used for indexing an array of fields, and member names much match P4 field names (including case). enum Field { code, depotFile, clientFile, headType, haveRev, fileSize, digest } public static readonly string[] FieldNames = Enum.GetNames(typeof(Field)); public static readonly Utf8String[] Utf8FieldNames = Array.ConvertAll(FieldNames, x => new Utf8String(x)); public PerforceValue[] Values { get; } = new PerforceValue[FieldNames.Length]; public Utf8String DepotFile => Values[(int)Field.depotFile].GetString(); public Utf8String ClientFile => Values[(int)Field.clientFile].GetString(); public Utf8String HeadType => Values[(int)Field.headType].GetString(); public Utf8String HaveRev => Values[(int)Field.haveRev].GetString(); public long FileSize => Values[(int)Field.fileSize].AsLong(); public Utf8String Digest => Values[(int)Field.digest].GetString(); } /// /// Get the contents of the client, as synced. /// /// The client connection /// The change number being synced. This must be specified in order to get the digest at the correct revision. /// Cancellation token private async Task FindClientContentsAsync(IPerforceConnection perforceClient, int changeNumber, CancellationToken cancellationToken) { StreamTreeBuilder builder = new StreamTreeBuilder(); using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(FindClientContentsAsync)}"); using (Trace("FetchMetadata")) using (ILoggerProgress scope = _logger.BeginProgressScope("Fetching metadata...")) { Stopwatch timer = Stopwatch.StartNew(); // Get the expected prefix for any paths in client syntax Utf8String clientPrefix = new Utf8String($"//{perforceClient.Settings.ClientName}/"); // List of the last path fragments. Since file records that are returned are typically sorted by their position in the tree, we can save quite a lot of processing by // reusing as many fragemnts as possible. List<(Utf8String, StreamTreeBuilder)> fragments = new List<(Utf8String, StreamTreeBuilder)>(); // Handler for each returned record FStatIndexedRecord record = new FStatIndexedRecord(); void HandleRecord(PerforceRecord rawRecord) { // Copy into the values array rawRecord.CopyInto(FStatIndexedRecord.Utf8FieldNames, record.Values); // Make sure it has all the fields we're interested in if (record.Digest.IsEmpty) { return; } if (record.ClientFile.IsEmpty) { throw new InvalidDataException("Record returned by Peforce does not have ClientFile set"); } if (!record.ClientFile.StartsWith(clientPrefix)) { throw new InvalidDataException($"Client path returned by Perforce ('{record.ClientFile}') does not begin with client name ('{clientPrefix}')"); } // Duplicate the client path. If we reference into the raw record, we'll prevent all the raw P4 output from being garbage collected. Utf8String clientFile = record.ClientFile.Clone(); // Get the client path after the initial client prefix ReadOnlySpan pathSpan = clientFile.Span; // Parse out the data StreamTreeBuilder lastStreamDirectory = builder; // Try to match up as many fragments from the last file. int fragmentMinIdx = clientPrefix.Length; for (int fragmentIdx = 0; ; fragmentIdx++) { // Find the next directory separator int fragmentMaxIdx = fragmentMinIdx; while (fragmentMaxIdx < pathSpan.Length && pathSpan[fragmentMaxIdx] != '/') { fragmentMaxIdx++; } if (fragmentMaxIdx == pathSpan.Length) { fragments.RemoveRange(fragmentIdx, fragments.Count - fragmentIdx); break; } // Get the fragment text Utf8String fragment = new Utf8String(clientFile.Memory.Slice(fragmentMinIdx, fragmentMaxIdx - fragmentMinIdx)); // If this fragment matches the same fragment from the previous iteration, take the last stream directory straight away if (fragmentIdx < fragments.Count) { if (fragments[fragmentIdx].Item1 == fragment) { lastStreamDirectory = fragments[fragmentIdx].Item2; } else { fragments.RemoveRange(fragmentIdx, fragments.Count - fragmentIdx); } } // Otherwise, find or add a directory for this fragment into the last directory if (fragmentIdx >= fragments.Count) { Utf8String unescapedFragment = PerforceUtils.UnescapePath(fragment); StreamTreeBuilder? nextStreamDirectory; if (!lastStreamDirectory.NameToTreeBuilder.TryGetValue(unescapedFragment, out nextStreamDirectory)) { nextStreamDirectory = new StreamTreeBuilder(); lastStreamDirectory.NameToTreeBuilder.Add(unescapedFragment, nextStreamDirectory); } lastStreamDirectory = nextStreamDirectory; fragments.Add((fragment, lastStreamDirectory)); } // Move to the next fragment fragmentMinIdx = fragmentMaxIdx + 1; } Md5Hash digest = Md5Hash.Parse(record.Digest); FileContentId contentId = new FileContentId(digest, record.HeadType.Clone()); int revision = (int)Utf8String.ParseUnsignedInt(record.HaveRev); // Add a new StreamFileInfo to the last directory object Utf8String fileName = PerforceUtils.UnescapePath(clientFile.Slice(fragmentMinIdx)); lastStreamDirectory.NameToFile.Add(fileName, new StreamFile(record.DepotFile.Clone(), record.FileSize, contentId, revision)); } // Create the workspace, and add records for all the files. Exclude deleted files with digest = null. List arguments = new List(); arguments.Add("-Ol"); // Output fileSize and digest field arguments.Add("-Op"); // Output clientFile field in both server and local path syntax arguments.Add("-Os"); // Shorten output by excluding client workspace data (for instance, the clientFile field). arguments.Add("-Rh"); // Limit output to files on your have list; arguments.Add("-T"); // Include only the fields listed below arguments.Add(String.Join(",", FStatIndexedRecord.FieldNames)); arguments.Add($"//{perforceClient.Settings.ClientName}/...@{changeNumber}"); await perforceClient.RecordCommandAsync("fstat", arguments, null, HandleRecord, cancellationToken); // Output the elapsed time scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } return new StreamSnapshotFromMemory(builder); } class FStatRecordWithoutHaveTable { // Note: This enum is used for indexing an array of fields, and member names much match P4 field names (including case). enum Field { code, depotFile, headType, headRev, fileSize, digest } public static readonly string[] FieldNames = Enum.GetNames(typeof(Field)); public static readonly Utf8String[] Utf8FieldNames = Array.ConvertAll(FieldNames, x => new Utf8String(x)); public PerforceValue[] Values { get; } = new PerforceValue[FieldNames.Length]; public Utf8String DepotFile => Values[(int)Field.depotFile].GetString(); public Utf8String HeadType => Values[(int)Field.headType].GetString(); public int HeadRev => Values[(int)Field.headRev].AsInteger(); public long FileSize => Values[(int)Field.fileSize].AsLong(); public Utf8String Digest => Values[(int)Field.digest].GetString(); } /// /// Get the contents of the client without using the have table /// /// The client connection /// Name of stream /// View of the workspace /// The change number being synced. This must be specified in order to get the digest at the correct revision. /// Cancellation token public async Task FindClientContentsWithoutHaveTableAsync(IPerforceConnection perforceClient, string streamName, IReadOnlyList view, int changeNumber, CancellationToken cancellationToken) { DepotStreamTreeBuilder builder = new(); using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(FindClientContentsWithoutHaveTableAsync)}"); using (Trace("FetchMetadata")) using (ILoggerProgress scope = _logger.BeginProgressScope("Fetching metadata (without have table)...")) { Stopwatch timer = Stopwatch.StartNew(); StreamRecord streamRecord = await perforceClient.GetStreamAsync(streamName, true, cancellationToken); PerforceViewMap viewMap = PerforceViewMap.Parse(streamRecord.View); // Use Horde's additional filtering taking place after stream view mapping PerforceViewFilter viewFilter = PerforceViewFilter.Parse(view); // Re-use a single class instance as there can be millions of records FStatRecordWithoutHaveTable record = new(); void HandleRecord(PerforceRecord rawRecord) { // Copy into the values array rawRecord.CopyInto(FStatRecordWithoutHaveTable.Utf8FieldNames, record.Values); if (record.Digest.IsEmpty) { return; } if (viewMap.TryMapFile(record.DepotFile.ToString(), StringComparison.OrdinalIgnoreCase, out string clientFile)) { if (!viewFilter.IncludeFile(clientFile, StringComparison.OrdinalIgnoreCase)) { return; } Md5Hash md5Hash = Md5Hash.Parse(record.Digest); FileContentId fileContentId = new(md5Hash, record.HeadType); builder.AddFile(clientFile, new StreamFile(record.DepotFile, record.FileSize, fileContentId, record.HeadRev)); } else { _logger.LogError("Failed to view map depot file {DepotFile}", record.DepotFile.ToString()); } } string fileSpec = $"//{perforceClient.Settings.ClientName}/...@{changeNumber}"; List arguments = new(); arguments.Add("-Ol"); // Output fileSize and digest field arguments.Add("-Os"); // Shorten output by excluding client workspace data (for instance, the clientFile field). arguments.Add("-F"); // Filter any files not existing at current revision (filter below) arguments.Add("^headAction=delete&^headAction=move/delete&^headAction=purge"); arguments.Add("-T"); // Include only the fields listed below arguments.Add(String.Join(",", FStatRecordWithoutHaveTable.FieldNames)); arguments.Add(fileSpec); await perforceClient.RecordCommandAsync("fstat", arguments, null, HandleRecord, cancellationToken); scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } return new StreamSnapshotFromMemory(builder); } /// /// Loads the contents of a client from disk /// /// The cache file to read from /// Default path for the stream /// Cancellation token /// Contents of the workspace async Task TryLoadClientContentsAsync(FileReference cacheFile, Utf8String basePath, CancellationToken cancellationToken) { StreamSnapshot? contents = null; if (FileReference.Exists(cacheFile)) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(TryLoadClientContentsAsync)}"); using (Trace("ReadMetadata")) using (ILoggerProgress scope = _logger.BeginProgressScope($"Reading cached metadata from {cacheFile}...")) { Stopwatch timer = Stopwatch.StartNew(); contents = await StreamSnapshotFromMemory.TryLoadAsync(cacheFile, basePath, cancellationToken); scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } return contents; } /// /// Finds the contents of a workspace, and saves it to disk /// /// The client connection /// Base path for the stream /// View of the workspace /// The change number being synced. This must be specified in order to get the digest at the correct revision. /// Location of the file to save the cached contents /// Cancellation token /// Contents of the workspace private async Task FindAndSaveClientContentsAsync(IPerforceConnection perforceClient, Utf8String basePath, IReadOnlyList view, int changeNumber, FileReference cacheFile, CancellationToken cancellationToken) { StreamSnapshotFromMemory contents = _options.UseHaveTable ? await FindClientContentsAsync(perforceClient, changeNumber, cancellationToken) : await FindClientContentsWithoutHaveTableAsync(perforceClient, basePath.ToString(), view, changeNumber, cancellationToken); using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(FindAndSaveClientContentsAsync)}"); using (Trace("WriteMetadata")) using (ILoggerProgress scope = _logger.BeginProgressScope($"Saving metadata to {cacheFile}...")) { Stopwatch timer = Stopwatch.StartNew(); // Handle the case where two machines may try to write to the cache file at once by writing to a temporary file FileReference tempCacheFile = new FileReference(String.Format("{0}.{1}", cacheFile, Guid.NewGuid())); await contents.SaveAsync(tempCacheFile, basePath); // Try to move it into place try { FileReference.Move(tempCacheFile, cacheFile); } catch (IOException) { if (!FileReference.Exists(cacheFile)) { throw; } FileReference.Delete(tempCacheFile); } scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } return contents; } /// /// Remove files from the workspace /// /// Contents of the target stream /// Cancellation token private async Task RemoveFilesFromWorkspaceAsync(StreamSnapshot contents, CancellationToken cancellationToken) { // Make sure the repair flag is clear before we start await RunOptionalCacheRepairAsync(cancellationToken); // Figure out what to remove RemoveTransaction transaction; using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(RemoveFilesFromWorkspaceAsync)}"); using (Trace("GatherFilesToRemove")) using (ILoggerProgress scope = _logger.BeginProgressScope("Gathering files to remove...")) { Stopwatch timer = Stopwatch.StartNew(); transaction = await RemoveTransaction.CreateAsync(_workspace, contents, _contentIdToTrackedFile, _options.MaxFileConcurrency); scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } // Move files into the cache KeyValuePair[] filesToMove = transaction._filesToMove.ToArray(); if (filesToMove.Length > 0) { using (Trace("MoveToCache")) using (ILoggerProgress scope = _logger.BeginProgressScope(String.Format("Moving {0} {1} to cache...", filesToMove.Length, (filesToMove.Length == 1) ? "file" : "files"))) { Stopwatch timer = Stopwatch.StartNew(); // Add any new files to the cache List<(FileContentId ContentId, FileReference Source, FileReference Target)> files = new List<(FileContentId, FileReference, FileReference)>(); foreach (KeyValuePair fileToMove in filesToMove) { ulong cacheId = GetUniqueCacheId(fileToMove.Key); CachedFileInfo newTrackingInfo = new CachedFileInfo(_cacheDir, fileToMove.Key, cacheId, fileToMove.Value._length, fileToMove.Value._lastModifiedTicks, fileToMove.Value._readOnly, _nextSequenceNumber); _contentIdToTrackedFile.Add(fileToMove.Key, newTrackingInfo); files.Add((fileToMove.Key, fileToMove.Value.GetLocation(), newTrackingInfo.GetLocation())); } _nextSequenceNumber++; // Save the current state of the repository as dirty. If we're interrupted, we will have two places to check for each file (the cache and workspace). await SaveAsync(TransactionState.Dirty, cancellationToken); // Execute all the moves and deletes ParallelOptions options = new() { MaxDegreeOfParallelism = _options.MaxFileConcurrency, CancellationToken = cancellationToken }; await Parallel.ForEachAsync(files, options, (file, ctx) => MoveFileToCache(file.ContentId, file.Source, file.Target, _contentIdToTrackedFile, ctx)); scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } // Remove files which are no longer needed WorkspaceFileInfo[] filesToDelete = transaction._filesToDelete.ToArray(); if (filesToDelete.Length > 0) { using (Trace("DeleteFiles")) using (ILoggerProgress scope = _logger.BeginProgressScope(String.Format("Deleting {0} {1}...", filesToDelete.Length, (filesToDelete.Length == 1) ? "file" : "files"))) { Stopwatch timer = Stopwatch.StartNew(); ParallelOptions options = new() { MaxDegreeOfParallelism = _options.MaxFileConcurrency, CancellationToken = cancellationToken }; await Parallel.ForEachAsync(filesToDelete, options, (fileToDelete, ct) => { RemoveFile(fileToDelete); return ValueTask.CompletedTask; }); scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } // Remove directories which are no longer needed WorkspaceDirectoryInfo[] directoriesToDelete = transaction._directoriesToDelete.ToArray(); if (directoriesToDelete.Length > 0) { using (Trace("DeleteDirectories")) using (ILoggerProgress scope = _logger.BeginProgressScope(String.Format("Deleting {0} {1}...", directoriesToDelete.Length, (directoriesToDelete.Length == 1) ? "directory" : "directories"))) { Stopwatch timer = Stopwatch.StartNew(); foreach (string directoryToDelete in directoriesToDelete.Select(x => x.GetFullName()).OrderByDescending(x => x.Length)) { RemoveDirectory(directoryToDelete); } scope.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } // Update the workspace and save the new state _workspace = transaction._newWorkspaceRootDir; await SaveAsync(TransactionState.Clean, cancellationToken); } ValueTask MoveFileToCache(FileContentId contentId, FileReference sourceFile, FileReference targetFile, Dictionary contentIdToTrackedFile, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); try { FileUtils.ForceMoveFile(sourceFile, targetFile); } catch (Exception ex) { Exception innerException = (ex as WrappedFileOrDirectoryException)?.InnerException ?? ex; _logger.LogWarning(KnownLogEvents.Systemic_ManagedWorkspace, innerException, "Unable to move {SourceFile} to {TargetFile}: {Error}", sourceFile, targetFile, innerException.Message); lock (contentIdToTrackedFile) { contentIdToTrackedFile.Remove(contentId); } } return default; } /// /// Helper function to delete a file from the workspace, and output any failure as a warning. /// /// The file to be deleted void RemoveFile(WorkspaceFileInfo fileToDelete) { try { FileUtils.ForceDeleteFile(fileToDelete.GetLocation()); } catch (Exception ex) { _logger.LogWarning(ex, "warning: Unable to delete file {FileName}.", fileToDelete.GetFullName()); _requiresRepair = true; } } /// /// Helper function to delete a directory from the workspace, and output any failure as a warning. /// /// The directory to be deleted void RemoveDirectory(string directoryToDelete) { try { Directory.Delete(directoryToDelete, false); } catch (Exception ex) { _logger.LogWarning(ex, "warning: Unable to delete directory {Directory}", directoryToDelete); _requiresRepair = true; } } /// /// Update the workspace to match the given stream, syncing files and moving to/from the cache as necessary. /// /// The client connection /// Contents of the stream /// Whether to simulate the sync operation, rather than actually syncing files /// Cancellation token private async Task AddFilesToWorkspaceAsync(IPerforceConnection client, StreamSnapshot stream, bool fakeSync, CancellationToken cancellationToken) { // Make sure the repair flag is reset await RunOptionalCacheRepairAsync(cancellationToken); // Figure out what we need to do AddTransaction transaction; using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(AddFilesToWorkspaceAsync)}"); using (Trace("GatherFilesToAdd")) using (ILoggerProgress status = _logger.BeginProgressScope("Gathering files to add...")) { Stopwatch timer = Stopwatch.StartNew(); transaction = await AddTransaction.CreateAsync(_workspace, stream, _contentIdToTrackedFile, _options.MaxFileConcurrency); _workspace = transaction._newWorkspaceRootDir; await SaveAsync(TransactionState.Dirty, cancellationToken); status.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } // Swap files in and out of the cache WorkspaceFileToMove[] filesToMove = transaction._filesToMove.Values.ToArray(); if (filesToMove.Length > 0) { using (Trace("MoveFromCache")) using (ILoggerProgress status = _logger.BeginProgressScope(String.Format("Moving {0} {1} from cache...", filesToMove.Length, (filesToMove.Length == 1) ? "file" : "files"))) { Stopwatch timer = Stopwatch.StartNew(); ParallelOptions options = new() { MaxDegreeOfParallelism = _options.MaxFileConcurrency, CancellationToken = cancellationToken }; await Parallel.ForEachAsync(filesToMove, options, (fileToMove, ct) => { MoveFileFromCache(fileToMove, transaction._filesToSync); return ValueTask.CompletedTask; }); _contentIdToTrackedFile = _contentIdToTrackedFile.Where(x => !transaction._filesToMove.ContainsKey(x.Value)).ToDictionary(x => x.Key, x => x.Value); status.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } // Swap files in and out of the cache WorkspaceFileToCopy[] filesToCopy = transaction._filesToCopy.ToArray(); if (filesToCopy.Length > 0) { using (Trace("CopyFiles")) using (ILoggerProgress status = _logger.BeginProgressScope(String.Format("Copying {0} {1} within workspace...", filesToCopy.Length, (filesToCopy.Length == 1) ? "file" : "files"))) { Stopwatch timer = Stopwatch.StartNew(); ParallelOptions options = new() { MaxDegreeOfParallelism = _options.MaxFileConcurrency, CancellationToken = cancellationToken }; await Parallel.ForEachAsync(filesToCopy, options, (fileToCopy, ct) => { CopyFileWithinWorkspace(fileToCopy, transaction._filesToSync); return ValueTask.CompletedTask; }); status.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)"; } } // Find all the files we want to sync WorkspaceFileToSync[] filesToSync = transaction._filesToSync.ToArray(); if (filesToSync.Length > 0) { long syncSize = filesToSync.Sum(x => x._streamFile.Length); // Make sure there's enough space on this drive if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { long freeSpace = new DriveInfo(Path.GetPathRoot(_baseDir.FullName)!).AvailableFreeSpace; long minScratchSpaceBytes = _options.MinScratchSpace * 1024 * 1024; if (freeSpace - syncSize < minScratchSpaceBytes) { throw new InsufficientSpaceException($"Not enough space to sync new files (free space: {freeSpace / (1024.0 * 1024.0):n1}mb, sync size: {syncSize / (1024.0 * 1024.0):n1}mb, min scratch space: {minScratchSpaceBytes / (1024.0 * 1024.0):n1}mb)"); } } // Sync all the files using (Trace("SyncFiles")) using (ILoggerProgress status = _logger.BeginProgressScope(String.Format("Syncing {0} {1} using {2} threads...", filesToSync.Length, (filesToSync.Length == 1) ? "file" : "files", _options.NumParallelSyncThreads))) { Stopwatch timer = Stopwatch.StartNew(); // Remove all the previous response files foreach (FileReference file in DirectoryReference.EnumerateFiles(_baseDir, "SyncList-*.txt")) { FileUtils.ForceDeleteFile(file); } // Create a list of all the batches that we want to sync List<(int, int)> batches = new List<(int, int)>(); for (int endIdx = 0; endIdx < filesToSync.Length;) { int beginIdx = endIdx; // Figure out the next batch of files to sync long batchSize = 0; for (; endIdx < filesToSync.Length && batchSize < 256 * 1024 * 1024; endIdx++) { batchSize += filesToSync[endIdx]._streamFile.Length; } // Add this batch to the list batches.Add((beginIdx, endIdx)); } // The next batch to be synced int nextBatchIdx = 0; // Total size of synced files long syncedSize = 0; // Spawn some background threads to sync them Dictionary tasks = new Dictionary(); Stack connectionPool = new Stack(); try { while (tasks.Count > 0 || nextBatchIdx < batches.Count) { // Create new tasks while (tasks.Count < _options.NumParallelSyncThreads && nextBatchIdx < batches.Count) { (int batchBeginIdx, int batchEndIdx) = batches[nextBatchIdx]; Task task = Task.Run(() => SyncBatchAsync(client, filesToSync, batchBeginIdx, batchEndIdx, fakeSync, connectionPool, cancellationToken), cancellationToken); tasks[task] = nextBatchIdx++; } // Wait for anything to complete Task completeTask = await Task.WhenAny(tasks.Keys); await completeTask; // Make sure we re-throw any exceptions from the task that completed int batchIdx = tasks[completeTask]; tasks.Remove(completeTask); // Update metadata for the complete batch (int beginIdx, int endIdx) = batches[batchIdx]; int[] indexesToUpdate = Enumerable.Range(beginIdx, endIdx - beginIdx).ToArray(); ParallelOptions options = new() { MaxDegreeOfParallelism = _options.MaxFileConcurrency, CancellationToken = cancellationToken }; await Parallel.ForEachAsync(indexesToUpdate, options, (idx, ct) => { filesToSync[idx]._workspaceFile.UpdateMetadata(); return ValueTask.CompletedTask; }); // Save the current state every minute TimeSpan elapsed = timer.Elapsed; if (elapsed > TimeSpan.FromMinutes(5.0)) { await SaveAsync(TransactionState.Dirty, cancellationToken); _logger.LogInformation("Saved workspace state ({Elapsed:0.0}s)", (timer.Elapsed - elapsed).TotalSeconds); timer.Restart(); } // Update the status for (int idx = beginIdx; idx < endIdx; idx++) { syncedSize += filesToSync[idx]._streamFile.Length; } status.Progress = String.Format("{0:n1}% ({1:n1}mb/{2:n1}mb)", syncedSize * 100.0 / syncSize, syncedSize / (1024.0 * 1024.0), syncSize / (1024.0 * 1024.0)); } } finally { await Task.WhenAll(tasks.Keys); foreach (IPerforceConnection connection in connectionPool) { connection.Dispose(); } } } } // Save the clean state _workspace = transaction._newWorkspaceRootDir; await SaveAsync(TransactionState.Clean, cancellationToken); } /// /// Syncs a batch of files /// /// The client to sync /// List of files to sync /// First file to sync /// Index of the last file to sync (exclusive) /// Whether to fake a sync /// Pool of connection instances /// Cancellation token for the request /// Async task async Task SyncBatchAsync(IPerforceConnection client, WorkspaceFileToSync[] filesToSync, int beginIdx, int endIdx, bool fakeSync, Stack connectionPool, CancellationToken cancellationToken) { using TelemetrySpan span = _tracer.StartActiveSpan($"{nameof(ManagedWorkspace)}.{nameof(SyncBatchAsync)}"); if (fakeSync) { for (int idx = beginIdx; idx < endIdx; idx++) { FileReference localFile = filesToSync[idx]._workspaceFile.GetLocation(); DirectoryReference.CreateDirectory(localFile.Directory); await FileReference.WriteAllBytesAsync(localFile, Array.Empty(), cancellationToken); } } else if (client is NativePerforceConnection) { List files = new List(); for (int idx = beginIdx; idx < endIdx; idx++) { files.Add($"{filesToSync[idx]._streamFile.Path}#{filesToSync[idx]._streamFile.Revision}"); } SyncOptions options = SyncOptions.FullDepotSyntax; if (_options.UseHaveTable) { options |= SyncOptions.Force; } else { options |= SyncOptions.DoNotUpdateHaveList; } // Allocate a connection to use for syncing #pragma warning disable CA2000 IPerforceConnection? connection = null; try { lock (connectionPool) { connectionPool.TryPop(out connection); } connection ??= await PerforceConnection.CreateAsync(client.Settings, client.Logger); // Note: Explicitly disable parallel syncing here; the P4 API attempts to shell out to p4.exe, which may not be installed. await connection.SyncAsync(options, -1, 0, -1, -1, -1, -1, files, cancellationToken).ToListAsync(cancellationToken); } finally { if (connection != null) { lock (connectionPool) { connectionPool.Push(connection); } } } #pragma warning restore CA2000 } else { FileReference syncFileName = FileReference.Combine(_baseDir, $"SyncList-{beginIdx}.txt"); using (StreamWriter writer = new StreamWriter(syncFileName.FullName)) { for (int idx = beginIdx; idx < endIdx; idx++) { await writer.WriteLineAsync($"{filesToSync[idx]._streamFile.Path}#{filesToSync[idx]._streamFile.Revision}"); } } if (_options.UseHaveTable) { using PerforceConnection clientWithFileList = new(client.Settings, client.Logger); clientWithFileList.GlobalOptions.Add($"-x\"{syncFileName}\""); await clientWithFileList.SyncAsync(SyncOptions.Force | SyncOptions.FullDepotSyntax, -1, Array.Empty(), cancellationToken).ToListAsync(cancellationToken); } else { // Ensure a client with an empty have table is used to not interfere with the DoNotUpdateHaveList option. using PerforceConnection clientWithFileList = new(client.Settings, client.Logger); clientWithFileList.ClientName = client.Settings.ClientName!; clientWithFileList.GlobalOptions.Add($"-x\"{syncFileName}\""); await clientWithFileList.SyncAsync(SyncOptions.DoNotUpdateHaveList | SyncOptions.FullDepotSyntax, -1, Array.Empty(), cancellationToken).ToListAsync(cancellationToken); } } } /// /// Helper function to move a file from the cache into the workspace. If it fails, adds the file to a list to be synced. /// /// Information about the file to move /// List of files to be synced. If the move fails, the file will be added to this list of files to sync. void MoveFileFromCache(WorkspaceFileToMove fileToMove, ConcurrentQueue filesToSync) { try { FileReference targetFile = fileToMove._workspaceFile.GetLocation(); FileReference.Move(fileToMove._trackedFile.GetLocation(), targetFile); try { FileReference.SetLastWriteTimeUtc(targetFile, DateTime.UtcNow); fileToMove._workspaceFile.UpdateMetadata(); } catch { _logger.LogWarning("Unable to update timestamp on {TargetFile}", targetFile); } } catch (Exception ex) { _logger.LogWarning(ex, "warning: Unable to move {CacheFile} from cache to {WorkspaceFile}. Syncing instead.", fileToMove._trackedFile.GetLocation(), fileToMove._workspaceFile.GetLocation()); filesToSync.Enqueue(new WorkspaceFileToSync(fileToMove._streamFile, fileToMove._workspaceFile)); _requiresRepair = true; } } /// /// Helper function to copy a file within the workspace. If it fails, adds the file to a list to be synced. /// /// Information about the file to move /// List of files to be synced. If the move fails, the file will be added to this list of files to sync. void CopyFileWithinWorkspace(WorkspaceFileToCopy fileToCopy, ConcurrentQueue filesToSync) { try { FileReference.Copy(fileToCopy._sourceWorkspaceFile.GetLocation(), fileToCopy._targetWorkspaceFile.GetLocation()); fileToCopy._targetWorkspaceFile.UpdateMetadata(); } catch (Exception ex) { _logger.LogWarning(ex, "warning: Unable to copy {SourceFile} to {TargetFile}. Syncing instead.", fileToCopy._sourceWorkspaceFile.GetLocation(), fileToCopy._targetWorkspaceFile.GetLocation()); filesToSync.Enqueue(new WorkspaceFileToSync(fileToCopy._streamFile, fileToCopy._targetWorkspaceFile)); _requiresRepair = true; } } void RemoveTrackedFile(CachedFileInfo trackedFile) { _contentIdToTrackedFile.Remove(trackedFile.ContentId); _cacheEntries.Remove(trackedFile.CacheId); FileUtils.ForceDeleteFile(trackedFile.GetLocation()); } void CreateCacheHierarchy() { for (int idxA = 0; idxA < 16; idxA++) { DirectoryReference dirA = DirectoryReference.Combine(_cacheDir, String.Format("{0:X}", idxA)); DirectoryReference.CreateDirectory(dirA); for (int idxB = 0; idxB < 16; idxB++) { DirectoryReference dirB = DirectoryReference.Combine(dirA, String.Format("{0:X}", idxB)); DirectoryReference.CreateDirectory(dirB); for (int idxC = 0; idxC < 16; idxC++) { DirectoryReference dirC = DirectoryReference.Combine(dirB, String.Format("{0:X}", idxC)); DirectoryReference.CreateDirectory(dirC); } } } } /// /// Determines a unique cache id for a file content id /// /// File content id to get a unique id for /// The unique cache id ulong GetUniqueCacheId(FileContentId contentId) { // Initialize the cache id to the top 16 bytes of the digest, then increment it until we find a unique id ulong cacheId = 0; for (int idx = 0; idx < 8; idx++) { cacheId = (cacheId << 8) | contentId.Digest.Span[idx]; } while (!_cacheEntries.Add(cacheId)) { cacheId++; } return cacheId; } /// /// Removes the leading slash from a path /// /// The path to remove a slash from /// The path without a leading slash static string RemoveLeadingSlash(string path) { if (path.Length > 0 && path[0] == '/') { return path.Substring(1); } else { return path; } } /// /// Gets the path to a backup file used while a new file is being written out /// /// The file being written to /// The path to a backup file private static FileReference GetBackupFile(FileReference targetFile) { return new FileReference(targetFile.FullName + ".transaction"); } /// /// Begins a write transaction on the given file. Assumes only one process will be reading/writing at a time, but the operation can be interrupted. /// /// The file being written to public static void BeginTransaction(FileReference targetFile) { FileReference transactionFile = GetBackupFile(targetFile); if (FileReference.Exists(targetFile)) { FileUtils.ForceMoveFile(targetFile, transactionFile); } else if (FileReference.Exists(transactionFile)) { FileUtils.ForceDeleteFile(transactionFile); } } /// /// Mark a transaction on the given file as complete, and removes the backup file. /// /// The file being written to public static void CompleteTransaction(FileReference targetFile) { FileReference transactionFile = GetBackupFile(targetFile); FileUtils.ForceDeleteFile(transactionFile); } /// /// Restores the backup for a target file, if it exists. This allows recovery from an incomplete transaction. /// /// The file being written to public static void RestoreBackup(FileReference targetFile) { FileReference transactionFile = GetBackupFile(targetFile); if (FileReference.Exists(transactionFile)) { FileUtils.ForceMoveFile(transactionFile, targetFile); } } /// /// Creates a scoped trace object /// /// Name of the operation /// Disposable object for the trace private static IDisposable Trace(string operation) { return TraceSpan.Create(operation, service: "hordeagent_repository"); } #endregion } }