From 6f693d7236e6a75064d71fa25f116d998bbca1f1 Mon Sep 17 00:00:00 2001 From: PSMinIO Developer Date: Fri, 11 Jul 2025 17:35:17 -0400 Subject: [PATCH] Implement comprehensive 3-layer progress tracking system ThreadSafeProgressCollector utility: Thread-safe progress data collection from background threads Concurrent queues for progress updates and verbose messages Safe PowerShell method calls only from main thread Automatic completion handling and queue processing MultiLayerProgressReporter for 3-layer progress: Layer 1: Collection progress (overall files with ETA) Layer 2: File progress (current file with size info) Layer 3: Transfer progress (bytes with speed calculation) Hierarchical progress reporting with parent-child relationships Real-time speed and ETA calculations Enhanced New-MinIOObject cmdlet integration: UploadSingleFileWithProgress method with progress integration Multi-file upload with comprehensive progress tracking Background thread safety for progress callbacks Verbose message queuing and processing Graceful error handling with progress completion Progress tracking features: Real-time transfer speed calculation Estimated time remaining (ETA) for collections Hierarchical progress bars in PowerShell Thread-safe verbose logging Automatic progress completion on success/failure Architecture improvements: Separation of concerns: progress collection vs reporting Background thread compatibility Memory-efficient concurrent collections Comprehensive error handling and cleanup This provides enterprise-grade progress tracking for large file operations with detailed visibility into collection, file, and transfer-level progress. --- src/Cmdlets/NewMinIOObjectCmdlet.cs | 124 +++++++++++--- src/Utils/MultiLayerProgressReporter.cs | 199 +++++++++++++++++++++++ src/Utils/ThreadSafeProgressCollector.cs | 155 ++++++++++++++++++ 3 files changed, 454 insertions(+), 24 deletions(-) create mode 100644 src/Utils/MultiLayerProgressReporter.cs create mode 100644 src/Utils/ThreadSafeProgressCollector.cs diff --git a/src/Cmdlets/NewMinIOObjectCmdlet.cs b/src/Cmdlets/NewMinIOObjectCmdlet.cs index 53fe0a3..6818255 100644 --- a/src/Cmdlets/NewMinIOObjectCmdlet.cs +++ b/src/Cmdlets/NewMinIOObjectCmdlet.cs @@ -302,7 +302,7 @@ namespace PSMinIO.Cmdlets } /// - /// Uploads a collection of files + /// Uploads a collection of files with multi-layer progress tracking /// /// Files to upload private void UploadFileCollection(FileInfo[] files) @@ -324,38 +324,48 @@ namespace PSMinIO.Cmdlets WriteVerboseMessage("Uploading {0} files to bucket '{1}'", validFiles.Length, BucketName); - // Calculate total size for overall progress - var totalSize = validFiles.Sum(f => f.Length); - var totalProcessed = 0L; - + // Create multi-layer progress reporter + var progressReporter = new MultiLayerProgressReporter(this, validFiles.Length, "Uploading Files"); var uploadedObjects = new List(); - for (int i = 0; i < validFiles.Length; i++) + try { - var fileInfo = validFiles[i]; - var objectName = GetObjectName(fileInfo); - - try + for (int i = 0; i < validFiles.Length; i++) { - WriteVerboseMessage("Uploading file {0}/{1}: {2} -> {3}", - i + 1, validFiles.Length, fileInfo.Name, objectName); + var fileInfo = validFiles[i]; + var objectName = GetObjectName(fileInfo); - var uploadResult = UploadSingleFile(fileInfo, objectName); - if (uploadResult != null) + try { - uploadedObjects.Add(uploadResult); - totalProcessed += fileInfo.Length; + // Start new file in progress reporter + progressReporter.StartNewFile(fileInfo.Name, fileInfo.Length); + progressReporter.QueueVerboseMessage("Uploading file {0}/{1}: {2} -> {3}", + i + 1, validFiles.Length, fileInfo.Name, objectName); + + var uploadResult = UploadSingleFileWithProgress(fileInfo, objectName, progressReporter); + if (uploadResult != null) + { + uploadedObjects.Add(uploadResult); + progressReporter.CompleteCurrentFile(); + progressReporter.QueueVerboseMessage("Successfully uploaded: {0}", fileInfo.Name); + } + } + catch (Exception ex) + { + var errorRecord = new ErrorRecord(ex, "FileUploadFailed", ErrorCategory.WriteError, fileInfo); + WriteError(errorRecord); + progressReporter.QueueVerboseMessage("Failed to upload {0}: {1}", fileInfo.Name, ex.Message); } - WriteVerboseMessage("Successfully uploaded: {0}", fileInfo.Name); - } - catch (Exception ex) - { - var errorRecord = new ErrorRecord(ex, "FileUploadFailed", ErrorCategory.WriteError, fileInfo); - WriteError(errorRecord); - WriteVerboseMessage("Failed to upload {0}: {1}", fileInfo.Name, ex.Message); + // Process queued progress updates + progressReporter.ProcessQueuedUpdates(); } } + finally + { + // Complete all progress reporting + progressReporter.Complete(); + } // Always return uploaded objects if PassThru is specified if (PassThru.IsPresent) @@ -501,7 +511,73 @@ namespace PSMinIO.Cmdlets } /// - /// Uploads a single file and returns the result + /// Uploads a single file with progress tracking and returns the result + /// + /// File to upload + /// Object name in bucket + /// Multi-layer progress reporter + /// Upload result + private MinIOUploadResult? UploadSingleFileWithProgress(FileInfo fileInfo, string objectName, MultiLayerProgressReporter progressReporter) + { + // Create upload result + var uploadResult = new MinIOUploadResult(BucketName, objectName, string.Empty, fileInfo.Length) + { + SourceFilePath = fileInfo.FullName + }; + uploadResult.MarkStarted(); + + try + { + // Determine content type + var fileContentType = ContentType ?? GetContentType(fileInfo.Extension); + uploadResult.ContentType = fileContentType; + + // Convert metadata + Dictionary? metadata = null; + if (Metadata != null && Metadata.Count > 0) + { + metadata = new Dictionary(); + foreach (var key in Metadata.Keys) + { + if (key != null && Metadata[key] != null) + { + metadata[key.ToString()!] = Metadata[key]!.ToString()!; + } + } + } + + // Upload the file with progress tracking + string etag; + using (var fileStream = fileInfo.OpenRead()) + { + etag = S3Client.PutObject( + BucketName, + objectName, + fileStream, + fileContentType, + metadata, + bytesTransferred => + { + // Update progress reporter from background thread + progressReporter.UpdateTransferProgress(bytesTransferred); + uploadResult.BytesTransferred = bytesTransferred; + }); + } + + uploadResult.ETag = etag; + uploadResult.MarkCompleted(); + + return uploadResult; + } + catch (Exception ex) + { + uploadResult.MarkFailed(ex.Message); + throw; + } + } + + /// + /// Uploads a single file and returns the result (legacy method for backward compatibility) /// /// File to upload /// Object name in bucket diff --git a/src/Utils/MultiLayerProgressReporter.cs b/src/Utils/MultiLayerProgressReporter.cs new file mode 100644 index 0000000..7ce0cba --- /dev/null +++ b/src/Utils/MultiLayerProgressReporter.cs @@ -0,0 +1,199 @@ +using System; +using System.Management.Automation; + +namespace PSMinIO.Utils +{ + /// + /// Multi-layer progress reporter for 3-level progress tracking: + /// Collection progress (overall files) > File progress (current file) > Transfer progress (bytes) + /// + public class MultiLayerProgressReporter + { + private readonly PSCmdlet _cmdlet; + private readonly ThreadSafeProgressCollector _progressCollector; + + // Activity IDs for the three layers + private const int CollectionActivityId = 1; + private const int FileActivityId = 2; + private const int TransferActivityId = 3; + + // Collection-level tracking + private int _totalFiles; + private int _currentFileIndex; + private string _collectionOperation = "Processing Files"; + + // File-level tracking + private string _currentFileName = string.Empty; + private long _currentFileSize; + private long _currentFileBytesTransferred; + + // Transfer-level tracking + private long _totalBytesTransferred; + private DateTime _startTime; + + public MultiLayerProgressReporter(PSCmdlet cmdlet, int totalFiles, string operation = "Processing Files") + { + _cmdlet = cmdlet ?? throw new ArgumentNullException(nameof(cmdlet)); + _progressCollector = new ThreadSafeProgressCollector(cmdlet); + _totalFiles = totalFiles; + _collectionOperation = operation; + _startTime = DateTime.UtcNow; + + // Initialize collection progress + UpdateCollectionProgress(); + } + + /// + /// Starts processing a new file + /// + /// Name of the file being processed + /// Size of the file in bytes + public void StartNewFile(string fileName, long fileSize) + { + _currentFileIndex++; + _currentFileName = fileName; + _currentFileSize = fileSize; + _currentFileBytesTransferred = 0; + + // Update collection progress + UpdateCollectionProgress(); + + // Initialize file progress + UpdateFileProgress(); + + // Initialize transfer progress + UpdateTransferProgress(); + + // Process any queued updates + _progressCollector.ProcessQueuedUpdates(); + } + + /// + /// Updates transfer progress for the current file + /// + /// Bytes transferred for current file + public void UpdateTransferProgress(long bytesTransferred) + { + _currentFileBytesTransferred = bytesTransferred; + UpdateTransferProgress(); + } + + /// + /// Completes the current file and updates overall progress + /// + public void CompleteCurrentFile() + { + _totalBytesTransferred += _currentFileSize; + + // Complete transfer progress + _progressCollector.QueueProgressCompletion(TransferActivityId, "Transfer Progress", FileActivityId); + + // Complete file progress + _progressCollector.QueueProgressCompletion(FileActivityId, "File Progress", CollectionActivityId); + + // Update collection progress + UpdateCollectionProgress(); + + // Process updates + _progressCollector.ProcessQueuedUpdates(); + } + + /// + /// Completes all progress reporting + /// + public void Complete() + { + // Complete collection progress + _progressCollector.QueueProgressCompletion(CollectionActivityId, _collectionOperation); + + // Process final updates and complete + _progressCollector.Complete(); + } + + /// + /// Processes any queued progress updates + /// + public void ProcessQueuedUpdates() + { + _progressCollector.ProcessQueuedUpdates(); + } + + /// + /// Updates collection-level progress + /// + private void UpdateCollectionProgress() + { + var percentage = _totalFiles > 0 ? (int)((double)_currentFileIndex / _totalFiles * 100) : 0; + var status = $"Processing file {_currentFileIndex} of {_totalFiles}"; + + if (_currentFileIndex > 0) + { + var elapsed = DateTime.UtcNow - _startTime; + var avgTimePerFile = elapsed.TotalSeconds / _currentFileIndex; + var remainingFiles = _totalFiles - _currentFileIndex; + var estimatedTimeRemaining = TimeSpan.FromSeconds(avgTimePerFile * remainingFiles); + + status += $" (ETA: {SizeFormatter.FormatDuration(estimatedTimeRemaining)})"; + } + + _progressCollector.QueueProgressUpdate(CollectionActivityId, _collectionOperation, status, percentage); + } + + /// + /// Updates file-level progress + /// + private void UpdateFileProgress() + { + var percentage = _currentFileSize > 0 ? (int)((double)_currentFileBytesTransferred / _currentFileSize * 100) : 0; + var status = $"Processing: {_currentFileName} ({SizeFormatter.FormatBytes(_currentFileBytesTransferred)}/{SizeFormatter.FormatBytes(_currentFileSize)})"; + + _progressCollector.QueueProgressUpdate(FileActivityId, "File Progress", status, percentage, CollectionActivityId); + } + + /// + /// Updates transfer-level progress + /// + private void UpdateTransferProgress() + { + var percentage = _currentFileSize > 0 ? (int)((double)_currentFileBytesTransferred / _currentFileSize * 100) : 0; + var status = $"Transferring: {SizeFormatter.FormatBytes(_currentFileBytesTransferred)}/{SizeFormatter.FormatBytes(_currentFileSize)}"; + + if (_currentFileBytesTransferred > 0) + { + var elapsed = DateTime.UtcNow - _startTime; + if (elapsed.TotalSeconds > 0) + { + var speed = _currentFileBytesTransferred / elapsed.TotalSeconds; + status += $" at {SizeFormatter.FormatSpeed(speed)}"; + } + } + + _progressCollector.QueueProgressUpdate(TransferActivityId, "Transfer Progress", status, percentage, FileActivityId); + } + + /// + /// Queues a verbose message + /// + /// Message to log + /// Message arguments + public void QueueVerboseMessage(string message, params object[] args) + { + _progressCollector.QueueVerboseMessage(message, args); + } + + /// + /// Gets the current collection progress percentage + /// + public int CollectionProgressPercentage => _totalFiles > 0 ? (int)((double)_currentFileIndex / _totalFiles * 100) : 0; + + /// + /// Gets the current file progress percentage + /// + public int FileProgressPercentage => _currentFileSize > 0 ? (int)((double)_currentFileBytesTransferred / _currentFileSize * 100) : 0; + + /// + /// Gets the total bytes transferred across all files + /// + public long TotalBytesTransferred => _totalBytesTransferred + _currentFileBytesTransferred; + } +} diff --git a/src/Utils/ThreadSafeProgressCollector.cs b/src/Utils/ThreadSafeProgressCollector.cs new file mode 100644 index 0000000..bd7ebc9 --- /dev/null +++ b/src/Utils/ThreadSafeProgressCollector.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Concurrent; +using System.Management.Automation; +using System.Threading; + +namespace PSMinIO.Utils +{ + /// + /// Thread-safe progress data collector that accumulates progress updates from background threads + /// and allows the main thread to safely report them to PowerShell + /// + public class ThreadSafeProgressCollector + { + private readonly PSCmdlet _cmdlet; + private readonly ConcurrentQueue _progressQueue = new(); + private readonly ConcurrentQueue _verboseQueue = new(); + private readonly object _lockObject = new(); + private volatile bool _isCompleted = false; + + public ThreadSafeProgressCollector(PSCmdlet cmdlet) + { + _cmdlet = cmdlet ?? throw new ArgumentNullException(nameof(cmdlet)); + } + + /// + /// Queues a progress update from a background thread + /// + public void QueueProgressUpdate(int activityId, string activity, string statusDescription, int percentComplete, int parentActivityId = -1) + { + if (_isCompleted) return; + + _progressQueue.Enqueue(new ProgressUpdate + { + ActivityId = activityId, + Activity = activity, + StatusDescription = statusDescription, + PercentComplete = percentComplete, + ParentActivityId = parentActivityId, + Timestamp = DateTime.UtcNow + }); + } + + /// + /// Queues a progress completion from a background thread + /// + public void QueueProgressCompletion(int activityId, string activity, int parentActivityId = -1) + { + if (_isCompleted) return; + + _progressQueue.Enqueue(new ProgressUpdate + { + ActivityId = activityId, + Activity = activity, + StatusDescription = "Completed", + PercentComplete = 100, + ParentActivityId = parentActivityId, + IsCompleted = true, + Timestamp = DateTime.UtcNow + }); + } + + /// + /// Queues a verbose message from a background thread + /// + public void QueueVerboseMessage(string message, params object[] args) + { + if (_isCompleted) return; + + _verboseQueue.Enqueue(new VerboseMessage + { + Message = args.Length > 0 ? string.Format(message, args) : message, + Timestamp = DateTime.UtcNow + }); + } + + /// + /// Processes all queued updates from the main thread (safe to call PowerShell methods) + /// + public void ProcessQueuedUpdates() + { + // Process verbose messages first + while (_verboseQueue.TryDequeue(out var verboseMessage)) + { + MinIOLogger.WriteVerbose(_cmdlet, verboseMessage.Message); + } + + // Process progress updates + while (_progressQueue.TryDequeue(out var progressUpdate)) + { + var progressRecord = new ProgressRecord( + progressUpdate.ActivityId, + progressUpdate.Activity, + progressUpdate.StatusDescription) + { + PercentComplete = progressUpdate.PercentComplete + }; + + if (progressUpdate.ParentActivityId >= 0) + { + progressRecord.ParentActivityId = progressUpdate.ParentActivityId; + } + + if (progressUpdate.IsCompleted) + { + progressRecord.RecordType = ProgressRecordType.Completed; + } + + _cmdlet.WriteProgress(progressRecord); + } + } + + /// + /// Marks the collector as completed (no more updates will be accepted) + /// + public void Complete() + { + _isCompleted = true; + // Process any remaining updates + ProcessQueuedUpdates(); + } + + /// + /// Gets the number of pending progress updates + /// + public int PendingProgressUpdates => _progressQueue.Count; + + /// + /// Gets the number of pending verbose messages + /// + public int PendingVerboseMessages => _verboseQueue.Count; + + /// + /// Progress update data structure + /// + private class ProgressUpdate + { + public int ActivityId { get; set; } + public string Activity { get; set; } = string.Empty; + public string StatusDescription { get; set; } = string.Empty; + public int PercentComplete { get; set; } + public int ParentActivityId { get; set; } = -1; + public bool IsCompleted { get; set; } + public DateTime Timestamp { get; set; } + } + + /// + /// Verbose message data structure + /// + private class VerboseMessage + { + public string Message { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + } + } +}