2513 lines
96 KiB
C#
2513 lines
96 KiB
C#
// 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
|
|
{
|
|
/// <summary>
|
|
/// Exception relating to managed workspace
|
|
/// </summary>
|
|
public class ManagedWorkspaceException : Exception
|
|
{
|
|
/// <summary>
|
|
/// Constructor
|
|
/// </summary>
|
|
public ManagedWorkspaceException(string message) : base(message)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exception thrown when there is not enough free space on the drive
|
|
/// </summary>
|
|
public class InsufficientSpaceException : ManagedWorkspaceException
|
|
{
|
|
/// <summary>
|
|
/// Constructor
|
|
/// </summary>
|
|
/// <param name="message">Error message</param>
|
|
public InsufficientSpaceException(string message)
|
|
: base(message)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Information about a populate request
|
|
/// </summary>
|
|
public class PopulateRequest
|
|
{
|
|
/// <summary>
|
|
/// The Perforce connection
|
|
/// </summary>
|
|
public IPerforceConnection PerforceClient { get; }
|
|
|
|
/// <summary>
|
|
/// Stream to sync it to
|
|
/// </summary>
|
|
public string StreamName { get; }
|
|
|
|
/// <summary>
|
|
/// View for this client
|
|
/// </summary>
|
|
public IReadOnlyList<string> View { get; }
|
|
|
|
/// <summary>
|
|
/// Constructor
|
|
/// </summary>
|
|
/// <param name="perforceClient">The perforce connection</param>
|
|
/// <param name="streamName">Stream to be synced</param>
|
|
/// <param name="view">List of filters for the stream</param>
|
|
public PopulateRequest(IPerforceConnection perforceClient, string streamName, IReadOnlyList<string> view)
|
|
{
|
|
PerforceClient = perforceClient;
|
|
StreamName = streamName;
|
|
View = view;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extra options for configuring ManagedWorkspace
|
|
/// </summary>
|
|
/// <param name="NumParallelSyncThreads">Maximum number of threads to sync in parallel</param>
|
|
/// <param name="MaxFileConcurrency">Maximum number of concurrent file system operations (copying, moving, deleting etc)</param>
|
|
/// <param name="MinScratchSpace">Minimum amount of disk space that must be available after a sync (in megabytes)</param>
|
|
/// <param name="UseHaveTable">
|
|
/// 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.
|
|
/// </param>
|
|
/// <param name="Partitioned">Whether to allow using partitioned workspaces</param>
|
|
/// <param name="PreferNativeClient">Whether to prefer the native p4 client</param>
|
|
public record ManagedWorkspaceOptions
|
|
(
|
|
int NumParallelSyncThreads,
|
|
int MaxFileConcurrency,
|
|
long MinScratchSpace,
|
|
bool UseHaveTable,
|
|
bool Partitioned,
|
|
bool PreferNativeClient
|
|
)
|
|
{
|
|
/// <inheritdoc cref="ManagedWorkspaceOptions(Int32, Int32, Int64, Boolean, Boolean, Boolean)"/>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Version number for managed workspace cache files
|
|
/// </summary>
|
|
enum ManagedWorkspaceVersion
|
|
{
|
|
/// <summary>
|
|
/// Initial version number
|
|
/// </summary>
|
|
Initial = 2,
|
|
|
|
/// <summary>
|
|
/// Including stream directory digests in workspace directories
|
|
/// </summary>
|
|
AddDigest = 3,
|
|
|
|
/// <summary>
|
|
/// Changing hash algorithm from SHA1 to IoHash
|
|
/// </summary>
|
|
AddDigestIoHash = 4,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a repository of streams and cached data
|
|
/// </summary>
|
|
public class ManagedWorkspace
|
|
{
|
|
/// <summary>
|
|
/// The current transaction state. Used to determine whether a repository needs to be cleaned on startup.
|
|
/// </summary>
|
|
enum TransactionState
|
|
{
|
|
Dirty,
|
|
Clean,
|
|
}
|
|
|
|
/// <summary>
|
|
/// The file signature and version. Update this to introduce breaking changes and ignore old repositories.
|
|
/// </summary>
|
|
const int CurrentSignature = ('W' << 24) | ('T' << 16) | 2;
|
|
|
|
/// <summary>
|
|
/// The current revision number for cache archives.
|
|
/// </summary>
|
|
static int CurrentVersion { get; } = Enum.GetValues(typeof(ManagedWorkspaceVersion)).Cast<int>().Max();
|
|
|
|
/// <summary>
|
|
/// File format version for untracked state file
|
|
/// </summary>
|
|
private const int UntrackedFileVersion = 1;
|
|
|
|
/// <summary>
|
|
/// Externally configurable options
|
|
/// </summary>
|
|
private readonly ManagedWorkspaceOptions _options;
|
|
|
|
/// <summary>
|
|
/// Constant for syncing the latest change number
|
|
/// </summary>
|
|
public const int LatestChangeNumber = -1;
|
|
|
|
/// <summary>
|
|
/// Name of the signature file for a repository. This
|
|
/// </summary>
|
|
const string SignatureFileName = "Repository.sig";
|
|
|
|
/// <summary>
|
|
/// Name of the main data file for a repository
|
|
/// </summary>
|
|
const string DataFileName = "Repository.dat";
|
|
|
|
/// <summary>
|
|
/// Name of the untracked state file for a repository
|
|
/// </summary>
|
|
const string UntrackedStateFileName = "Untracked.dat";
|
|
|
|
/// <summary>
|
|
/// Name of the host
|
|
/// </summary>
|
|
readonly string _hostName;
|
|
|
|
/// <summary>
|
|
/// Incrementing number assigned to sequential operations that modify files. Used to age out files in the cache.
|
|
/// </summary>
|
|
uint _nextSequenceNumber;
|
|
|
|
/// <summary>
|
|
/// Whether a repair operation should be run on this workspace. Set whenever the state may be inconsistent.
|
|
/// </summary>
|
|
bool _requiresRepair;
|
|
|
|
/// <summary>
|
|
/// Tracer
|
|
/// </summary>
|
|
readonly Tracer _tracer;
|
|
|
|
/// <summary>
|
|
/// The log output device
|
|
/// </summary>
|
|
readonly ILogger _logger;
|
|
|
|
/// <summary>
|
|
/// The root directory for the stash
|
|
/// </summary>
|
|
readonly DirectoryReference _baseDir;
|
|
|
|
/// <summary>
|
|
/// Root directory for storing cache files
|
|
/// </summary>
|
|
readonly DirectoryReference _cacheDir;
|
|
|
|
/// <summary>
|
|
/// Root directory for storing workspace files
|
|
/// </summary>
|
|
readonly DirectoryReference _workspaceDir;
|
|
|
|
/// <summary>
|
|
/// Set of clients that we're created. Used to avoid updating multiple times during one run.
|
|
/// </summary>
|
|
readonly Dictionary<string, ClientRecord> _createdClients = new Dictionary<string, ClientRecord>();
|
|
|
|
/// <summary>
|
|
/// Set of unique cache entries. We use this to ensure new names in the cache are unique.
|
|
/// </summary>
|
|
readonly HashSet<ulong> _cacheEntries = new HashSet<ulong>();
|
|
|
|
/// <summary>
|
|
/// List of all the staged files
|
|
/// </summary>
|
|
WorkspaceDirectoryInfo _workspace;
|
|
|
|
/// <summary>
|
|
/// All the files which are currently being tracked
|
|
/// </summary>
|
|
Dictionary<FileContentId, CachedFileInfo> _contentIdToTrackedFile = new Dictionary<FileContentId, CachedFileInfo>();
|
|
|
|
/// <summary>
|
|
/// Notification that a clean has been performed
|
|
/// </summary>
|
|
public delegate void OnCleanDelegate(int numFilesDeleted, int numDirsDeleted);
|
|
|
|
/// <summary>
|
|
/// Occurs when a clean operation has been performed.
|
|
/// </summary>
|
|
public event OnCleanDelegate? OnClean;
|
|
|
|
/// <summary>
|
|
/// Constructor
|
|
/// </summary>
|
|
/// <param name="hostName">Name of the current host</param>
|
|
/// <param name="nextSequenceNumber">The next sequence number for operations</param>
|
|
/// <param name="baseDir">The root directory for the stash</param>
|
|
/// <param name="options">Extra options</param>
|
|
/// <param name="tracer">Tracer</param>
|
|
/// <param name="logger">The log output device</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads a repository from the given directory, or create it if it doesn't exist
|
|
/// </summary>
|
|
/// <param name="hostName">Name of the current machine. Will be automatically detected from the host settings if not present.</param>
|
|
/// <param name="baseDir">The base directory for the repository</param>
|
|
/// <param name="overwrite">Whether to allow overwriting a repository that's not up to date</param>
|
|
/// <param name="options">Extra options</param>
|
|
/// <param name="tracer">Tracer</param>
|
|
/// <param name="logger">The logging interface</param>
|
|
/// <param name="cancellationToken">Cancellation token for this operation</param>
|
|
/// <returns></returns>
|
|
public static async Task<ManagedWorkspace> 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;
|
|
}
|
|
*/
|
|
|
|
/// <summary>
|
|
/// Creates a repository at the given location
|
|
/// </summary>
|
|
/// <param name="hostName">Name of the current machine.</param>
|
|
/// <param name="baseDir">The base directory for the repository</param>
|
|
/// <param name="options">Extra options</param>
|
|
/// <param name="tracer">Tracer</param>
|
|
/// <param name="logger">The log output device</param>
|
|
/// <param name="cancellationToken">Cancellation token for this operation</param>
|
|
/// <returns>New repository instance</returns>
|
|
public static async Task<ManagedWorkspace> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests whether a repository exists in the given directory
|
|
/// </summary>
|
|
/// <param name="baseDir"></param>
|
|
/// <returns></returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads a repository from disk
|
|
/// </summary>
|
|
/// <param name="hostName">Name of the current host. Will be obtained from a 'p4 info' call if not specified</param>
|
|
/// <param name="baseDir">The base directory for the repository</param>
|
|
/// <param name="options">Extra options</param>
|
|
/// <param name="tracer">Tracer</param>
|
|
/// <param name="logger">The log output device</param>
|
|
/// <param name="cancellationToken">Cancellation token for this command</param>
|
|
public static async Task<ManagedWorkspace> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save the state of the repository
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if current repository in combination with P4 client contains untracked files
|
|
/// If deserialization is not possible, assume the repo contains untracked files
|
|
/// </summary>
|
|
/// <returns>True if repository contains untracked files</returns>
|
|
private async Task<bool> 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
|
|
|
|
/// <summary>
|
|
/// Cleans the current workspace
|
|
/// </summary>
|
|
/// <param name="perforce">Optional Perforce client, if not set untracked state file cannot be updated</param>
|
|
/// <param name="removeUntracked">Whether to remove untracked files</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleans the current workspace
|
|
/// </summary>
|
|
/// <param name="removeUntracked">Whether to remove untracked files</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
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<string> paths = new List<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Empties the staging directory of any staged files
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dumps the contents of the repository to the log for analysis
|
|
/// </summary>
|
|
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<FileContentId, CachedFileInfo> 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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks the integrity of the cache
|
|
/// </summary>
|
|
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<CachedFileInfo> 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";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleans the current workspace
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks the <see cref="_requiresRepair"/> flag, and repairs/resets it if set.
|
|
/// </summary>
|
|
private async Task RunOptionalCacheRepairAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_requiresRepair)
|
|
{
|
|
await RepairCacheAsync(cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shrink the size of the cache to the given size
|
|
/// </summary>
|
|
/// <param name="maxSize">The maximum cache size, in bytes</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
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<CachedFileInfo> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures the client for the given stream
|
|
/// </summary>
|
|
/// <param name="perforceClient">The Perforce connection</param>
|
|
/// <param name="streamName">Name of the stream</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
public async Task SetupAsync(IPerforceConnection perforceClient, string streamName, CancellationToken cancellationToken)
|
|
{
|
|
await UpdateClientAsync(perforceClient, streamName, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prints stats showing coherence between different streams
|
|
/// </summary>
|
|
public async Task StatsAsync(IPerforceConnection perforceClient, List<string> streamNames, List<string> 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<int, StreamSnapshot>[] streamState = new Tuple<int, StreamSnapshot>[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<FileContentId>[] filesInStream = new HashSet<FileContentId>[streamNames.Count];
|
|
for (int idx = 0; idx < streamNames.Count; idx++)
|
|
{
|
|
List<StreamFile> files = streamState[idx].Item2.GetFiles();
|
|
filesInStream[idx] = new HashSet<FileContentId>(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<StreamFile> 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)";
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prints information about the repository state
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Switches to the given stream
|
|
/// </summary>
|
|
/// <param name="perforce">The perforce connection</param>
|
|
/// <param name="streamName">Name of the stream to sync</param>
|
|
/// <param name="changeNumber">Changelist number to sync. -1 to sync to latest.</param>
|
|
/// <param name="view">View of the workspace</param>
|
|
/// <param name="removeUntracked">Whether to remove untracked files from the workspace</param>
|
|
/// <param name="fakeSync">Whether to simulate the syncing operation rather than actually getting files from the server</param>
|
|
/// <param name="cacheFile">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.</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
public async Task SyncAsync(IPerforceConnection perforce, string streamName, int changeNumber, IReadOnlyList<string> 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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
/// <returns>Async task</returns>
|
|
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<DescribeRecord> 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<PerforceResponse<WhereRecord>> whereResponseList = await perforce.TryWhereAsync(lastRecord.Files.Select(x => x.DepotFile).ToArray(), cancellationToken).ToListAsync(cancellationToken);
|
|
Dictionary<string, WhereRecord> 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<WhereRecord> deleteFiles = new List<WhereRecord>();
|
|
List<WhereRecord> writeFiles = new List<WhereRecord>();
|
|
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<PrintRecord> 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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Populates the cache with the head revision of the given streams.
|
|
/// </summary>
|
|
public async Task PopulateAsync(List<PopulateRequest> requests, bool fakeSync, CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("Populating with {NumStreams} streams", requests.Count);
|
|
using (_logger.BeginIndentScope(" "))
|
|
{
|
|
Tuple<int, StreamSnapshot>[] streamState = await PopulateCleanAsync(requests, cancellationToken);
|
|
await PopulateSyncAsync(requests, streamState, fakeSync, cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Perform the clean part of a populate command
|
|
/// </summary>
|
|
public async Task<Tuple<int, StreamSnapshot>[]> PopulateCleanAsync(List<PopulateRequest> 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<ClientRecord> 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<int, StreamSnapshot>[] streamState = new Tuple<int, StreamSnapshot>[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<FileContentId> commonContentIds = new HashSet<FileContentId>();
|
|
Dictionary<FileContentId, long> contentIdToLength = new Dictionary<FileContentId, long>();
|
|
for (int idx = 0; idx < requests.Count; idx++)
|
|
{
|
|
List<StreamFile> 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<CachedFileInfo> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Perform the sync part of a populate command
|
|
/// </summary>
|
|
public async Task PopulateSyncAsync(List<PopulateRequest> requests, Tuple<int, StreamSnapshot>[] 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
|
|
|
|
/// <summary>
|
|
/// Deletes a client
|
|
/// </summary>
|
|
/// <param name="perforceClient">The Perforce connection</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Async task</returns>
|
|
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<ClientRecord> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the stream for the current client
|
|
/// </summary>
|
|
/// <param name="perforceClient">The Perforce connection</param>
|
|
/// <param name="streamName">New stream for the client</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the latest change submitted for the given stream
|
|
/// </summary>
|
|
/// <param name="perforceClient">The Perforce connection</param>
|
|
/// <param name="streamName">The stream to sync</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>The latest changelist number</returns>
|
|
public async Task<int> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the latest change number in the current client
|
|
/// </summary>
|
|
/// <param name="perforceClient">The perforce client connection</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>The latest submitted change number</returns>
|
|
private async Task<int> 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<ChangesRecord> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Revert all files that are open in the current workspace. Does not replace them with valid revisions.
|
|
/// </summary>
|
|
/// <param name="perforceClient">The current client connection</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
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<OpenedRecord> 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<ChangesRecord> changes = await perforceClient.GetChangesAsync(ChangesOptions.None, perforceClient.Settings.ClientName!, -1, ChangeStatus.Pending, null, Array.Empty<string>(), cancellationToken);
|
|
|
|
// Delete the changelist
|
|
foreach (ChangesRecord change in changes)
|
|
{
|
|
// Delete the shelved files
|
|
List<DescribeRecord> 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<string>(), cancellationToken);
|
|
}
|
|
}
|
|
|
|
// Delete the changelist
|
|
await perforceClient.DeleteChangeAsync(DeleteChangeOptions.None, change.Number, cancellationToken);
|
|
}
|
|
|
|
status.Progress = $"({timer.Elapsed.TotalSeconds:0.0}s)";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the have table. This ensures that we'll always fetch the names of files at head revision, which aren't updated otherwise.
|
|
/// </summary>
|
|
/// <param name="perforceClient">The client connection</param>
|
|
/// <param name="cancellationToken">The cancellation token</param>
|
|
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)";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the have table to reflect the given stream
|
|
/// </summary>
|
|
/// <param name="perforceClient">The client connection</param>
|
|
/// <param name="changeNumber">The change number to sync. May be -1, for latest.</param>
|
|
/// <param name="view">View of the stream. Each entry should be a path relative to the stream root, with an optional '-'prefix.</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
private async Task UpdateClientHaveTableAsync(IPerforceConnection perforceClient, int changeNumber, IReadOnlyList<string> 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)";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update a path in the have table
|
|
/// </summary>
|
|
/// <param name="perforceClient">The Perforce client</param>
|
|
/// <param name="syncPath">Path to sync</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Async task</returns>
|
|
private static async Task UpdateHaveTablePathAsync(IPerforceConnection perforceClient, string syncPath, CancellationToken cancellationToken)
|
|
{
|
|
PerforceResponseList<SyncSummaryRecord> responseList = await perforceClient.TrySyncQuietAsync(SyncOptions.KeepWorkspaceFiles, -1, new[] { syncPath }, cancellationToken);
|
|
foreach (PerforceResponse<SyncSummaryRecord> response in responseList)
|
|
{
|
|
PerforceError? error = response.Error;
|
|
if (error != null && error.Generic != PerforceGenericCode.Empty)
|
|
{
|
|
throw new PerforceException(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the contents of the client, as synced.
|
|
/// </summary>
|
|
/// <param name="perforceClient">The client connection</param>
|
|
/// <param name="changeNumber">The change number being synced. This must be specified in order to get the digest at the correct revision.</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
private async Task<StreamSnapshotFromMemory> 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<byte> 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<string> arguments = new List<string>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the contents of the client without using the have table
|
|
/// </summary>
|
|
/// <param name="perforceClient">The client connection</param>
|
|
/// <param name="streamName">Name of stream</param>
|
|
/// <param name="view">View of the workspace</param>
|
|
/// <param name="changeNumber">The change number being synced. This must be specified in order to get the digest at the correct revision.</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
public async Task<StreamSnapshotFromMemory> FindClientContentsWithoutHaveTableAsync(IPerforceConnection perforceClient, string streamName, IReadOnlyList<string> 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<string> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the contents of a client from disk
|
|
/// </summary>
|
|
/// <param name="cacheFile">The cache file to read from</param>
|
|
/// <param name="basePath">Default path for the stream</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Contents of the workspace</returns>
|
|
async Task<StreamSnapshot?> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the contents of a workspace, and saves it to disk
|
|
/// </summary>
|
|
/// <param name="perforceClient">The client connection</param>
|
|
/// <param name="basePath">Base path for the stream</param>
|
|
/// <param name="view">View of the workspace</param>
|
|
/// <param name="changeNumber">The change number being synced. This must be specified in order to get the digest at the correct revision.</param>
|
|
/// <param name="cacheFile">Location of the file to save the cached contents</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Contents of the workspace</returns>
|
|
private async Task<StreamSnapshotFromMemory> FindAndSaveClientContentsAsync(IPerforceConnection perforceClient, Utf8String basePath, IReadOnlyList<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove files from the workspace
|
|
/// </summary>
|
|
/// <param name="contents">Contents of the target stream</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
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<FileContentId, WorkspaceFileInfo>[] 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<FileContentId, WorkspaceFileInfo> 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<FileContentId, CachedFileInfo> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper function to delete a file from the workspace, and output any failure as a warning.
|
|
/// </summary>
|
|
/// <param name="fileToDelete">The file to be deleted</param>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper function to delete a directory from the workspace, and output any failure as a warning.
|
|
/// </summary>
|
|
/// <param name="directoryToDelete">The directory to be deleted</param>
|
|
void RemoveDirectory(string directoryToDelete)
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(directoryToDelete, false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "warning: Unable to delete directory {Directory}", directoryToDelete);
|
|
_requiresRepair = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the workspace to match the given stream, syncing files and moving to/from the cache as necessary.
|
|
/// </summary>
|
|
/// <param name="client">The client connection</param>
|
|
/// <param name="stream">Contents of the stream</param>
|
|
/// <param name="fakeSync">Whether to simulate the sync operation, rather than actually syncing files</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
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<Task, int> tasks = new Dictionary<Task, int>();
|
|
Stack<IPerforceConnection> connectionPool = new Stack<IPerforceConnection>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Syncs a batch of files
|
|
/// </summary>
|
|
/// <param name="client">The client to sync</param>
|
|
/// <param name="filesToSync">List of files to sync</param>
|
|
/// <param name="beginIdx">First file to sync</param>
|
|
/// <param name="endIdx">Index of the last file to sync (exclusive)</param>
|
|
/// <param name="fakeSync">Whether to fake a sync</param>
|
|
/// <param name="connectionPool">Pool of connection instances</param>
|
|
/// <param name="cancellationToken">Cancellation token for the request</param>
|
|
/// <returns>Async task</returns>
|
|
async Task SyncBatchAsync(IPerforceConnection client, WorkspaceFileToSync[] filesToSync, int beginIdx, int endIdx, bool fakeSync, Stack<IPerforceConnection> 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<byte>(), cancellationToken);
|
|
}
|
|
}
|
|
else if (client is NativePerforceConnection)
|
|
{
|
|
List<string> files = new List<string>();
|
|
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<string>(), 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<string>(), cancellationToken).ToListAsync(cancellationToken);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper function to move a file from the cache into the workspace. If it fails, adds the file to a list to be synced.
|
|
/// </summary>
|
|
/// <param name="fileToMove">Information about the file to move</param>
|
|
/// <param name="filesToSync">List of files to be synced. If the move fails, the file will be added to this list of files to sync.</param>
|
|
void MoveFileFromCache(WorkspaceFileToMove fileToMove, ConcurrentQueue<WorkspaceFileToSync> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper function to copy a file within the workspace. If it fails, adds the file to a list to be synced.
|
|
/// </summary>
|
|
/// <param name="fileToCopy">Information about the file to move</param>
|
|
/// <param name="filesToSync">List of files to be synced. If the move fails, the file will be added to this list of files to sync.</param>
|
|
void CopyFileWithinWorkspace(WorkspaceFileToCopy fileToCopy, ConcurrentQueue<WorkspaceFileToSync> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines a unique cache id for a file content id
|
|
/// </summary>
|
|
/// <param name="contentId">File content id to get a unique id for</param>
|
|
/// <returns>The unique cache id</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the leading slash from a path
|
|
/// </summary>
|
|
/// <param name="path">The path to remove a slash from</param>
|
|
/// <returns>The path without a leading slash</returns>
|
|
static string RemoveLeadingSlash(string path)
|
|
{
|
|
if (path.Length > 0 && path[0] == '/')
|
|
{
|
|
return path.Substring(1);
|
|
}
|
|
else
|
|
{
|
|
return path;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the path to a backup file used while a new file is being written out
|
|
/// </summary>
|
|
/// <param name="targetFile">The file being written to</param>
|
|
/// <returns>The path to a backup file</returns>
|
|
private static FileReference GetBackupFile(FileReference targetFile)
|
|
{
|
|
return new FileReference(targetFile.FullName + ".transaction");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="targetFile">The file being written to</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mark a transaction on the given file as complete, and removes the backup file.
|
|
/// </summary>
|
|
/// <param name="targetFile">The file being written to</param>
|
|
public static void CompleteTransaction(FileReference targetFile)
|
|
{
|
|
FileReference transactionFile = GetBackupFile(targetFile);
|
|
FileUtils.ForceDeleteFile(transactionFile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores the backup for a target file, if it exists. This allows recovery from an incomplete transaction.
|
|
/// </summary>
|
|
/// <param name="targetFile">The file being written to</param>
|
|
public static void RestoreBackup(FileReference targetFile)
|
|
{
|
|
FileReference transactionFile = GetBackupFile(targetFile);
|
|
if (FileReference.Exists(transactionFile))
|
|
{
|
|
FileUtils.ForceMoveFile(transactionFile, targetFile);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a scoped trace object
|
|
/// </summary>
|
|
/// <param name="operation">Name of the operation</param>
|
|
/// <returns>Disposable object for the trace</returns>
|
|
private static IDisposable Trace(string operation)
|
|
{
|
|
return TraceSpan.Create(operation, service: "hordeagent_repository");
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|