Please find below the export performance statistics captured using our utility, comparing the Stream and FilePath approaches across two Aspose.Email versions. Each configuration was run three times to ensure consistency.
All runs completed successfully with 1000/1000 files exported, 0 failures, and an identical output size of 245.59 MB.
Aspose.Email Version 24
- Stream
- Run 1: 00:07:46 | 2.1 files/sec
- Run 2: 00:07:15 | 2.3 files/sec
- Run 3: 00:07:47 | 2.1 files/sec
- FilePath
- Run 1: 00:08:34 | 1.9 files/sec
- Run 2: 00:08:54 | 1.9 files/sec
- Run 3: 00:08:18 | 2.0 files/sec
Aspose.Email Version 22
- Stream
- Run 1: 00:00:56 | 17.8 files/sec
- Run 2: 00:00:56 | 17.8 files/sec
- Run 3: 00:00:55 | 18.0 files/sec
- FilePath
- Run 1: 00:01:14 | 13.4 files/sec
- Run 2: 00:01:12 | 13.8 files/sec
- Run 3: 00:01:14 | 13.5 files/sec
Key Observations
- Version 22 delivers significantly faster export performance than Version 24 in both modes — averaging ~18 files/sec versus ~2.1 files/sec on Stream.
- The Stream approach consistently outperforms FilePath within both versions.
- Reliability and output size remained identical across all configurations (1000/1000 success, 245.59 MB).
Based on these results, Version 22 with the Stream approach offers the best performance for our export workflow.
Please let us know if you have any questions.
Below is the PST builder code from Eml & Msg
using Aspose.Email;
using Aspose.Email.Calendar;
using Aspose.Email.Mapi;
using Aspose.Email.Storage.Pst;
using Aspose.Email.Tools;
using Microsoft.Extensions.Logging;
namespace ExportPerformanceTest.Worker;
///
/// Standalone, file-path-only PST writer adapted from nayaEdge.Export.Builder.Pst.
/// Adds an email artifact (.msg/.eml/.ics/.vcf/TNEF) from a path into a .pst, reusing the
/// proven format-detection, retry, and folder-cache logic — but stripped of ExportData,
/// the IBuilder interface, stream/version handling, and the log4net dependency.
///
public sealed class PstWriter : IDisposable
{
private FolderInfo _inboxFolder = null!;
private FolderInfo _rootFolder = null!;
private PersonalStorage? _pst;
private readonly FileInfo _pstInfo;
private readonly ILogger? _log;
// P1: cache resolved folders so per-Add lookups don't scan GetSubFolders() repeatedly.
private readonly Dictionary<string, FolderInfo> _folderCache = new(StringComparer.OrdinalIgnoreCase);
// B5: retry-format hints as a static (was allocated per AddAgain call in the original).
private static readonly string[] RetryExtensions = { ".ics", ".tnef", "default", ".vcf" };
// P2: Length getter hits disk; refresh only when Add has written something.
private bool _lengthDirty = true;
public long Length
{
get
{
if (_lengthDirty)
{
_pstInfo.Refresh();
_lengthDirty = false;
}
return _pstInfo.Length;
}
}
public PstWriter(string pstFilePath, ILogger? log = null)
{
_log = log;
Init(pstFilePath);
// Standalone note: plain System.IO.FileInfo (not the nayaEdge.Common long-path wrapper).
_pstInfo = new FileInfo(pstFilePath);
}
/// <summary>
/// Adds the file at <paramref name="filePath"/> into the PST. <paramref name="toFolder"/> is a
/// backslash-separated path under the root (created on demand); null/empty targets the Inbox.
/// </summary>
public void Add(string filePath, string? toFolder = null)
{
_lengthDirty = true;
// B4: ToLowerInvariant — culture-insensitive for file extensions. Null-safe.
var ext = Path.GetExtension(filePath)?.ToLowerInvariant();
_log?.LogDebug("PstWriter -> Add, File Path: {FilePath}, Extension: {Ext}, To Folder: {ToFolder}", filePath, ext, toFolder);
try
{
AddMessage(GetFolderFromName(toFolder), filePath, ext);
}
catch (ArgumentException ae)
{
_log?.LogError(ae, "Error in Add, filepath: {FilePath}", filePath);
if (!ae.ToString().Contains("is corrupted")) throw;
if (!AddAgain(GetFolderFromName(toFolder), filePath, ext))
throw;
}
}
/// <summary>
/// Adds the message in <paramref name="stream"/> into the PST. The extension cannot be inferred
/// from a stream, so the caller supplies <paramref name="ext"/> (e.g. ".msg", ".ics") as a format
/// hint. <paramref name="toFolder"/> behaves as in the file-path overload (null/empty = Inbox).
/// </summary>
public void Add(Stream stream, string? ext, string? toFolder = null)
{
_lengthDirty = true;
// B4: ToLowerInvariant — culture-insensitive for file extensions. Null-safe.
ext = ext?.ToLowerInvariant();
// Aspose loaders read from the current position; rewind seekable streams so a partially
// consumed stream (e.g. after format detection upstream) still loads in full.
if (stream.CanSeek) stream.Seek(0, SeekOrigin.Begin);
_log?.LogDebug("PstWriter -> Add (stream), Extension: {Ext}, To Folder: {ToFolder}", ext, toFolder);
try
{
AddMessage(GetFolderFromName(toFolder), stream, ext);
}
catch (ArgumentException ae)
{
_log?.LogError(ae, "Error in Add (stream), extension: {Ext}", ext);
if (!ae.ToString().Contains("is corrupted")) throw;
if (!AddAgain(GetFolderFromName(toFolder), stream, ext))
throw;
}
}
/// <summary>
/// Loads a batch of files from paths and writes them in a single bulk PST insert
/// (FolderInfo.AddMessages) — much faster than per-message Add for large sets. Each file's
/// load is isolated (with corrupted-file retry); a file that cannot be loaded is counted in
/// <c>failed</c> and skipped rather than aborting the batch. ICS/VCF are routed to the
/// Calendar/Contacts folders individually (they cannot be bulk-added to a mail folder).
/// </summary>
public (int added, int failed) AddRange(IReadOnlyList<string> filePaths, string? toFolder = null)
{
_lengthDirty = true;
var folder = GetFolderFromName(toFolder);
var batch = new List<MapiMessage>(filePaths.Count);
int added = 0, failed = 0;
foreach (var filePath in filePaths)
{
var ext = Path.GetExtension(filePath)?.ToLowerInvariant();
if (TryLoadFile(filePath, ext, batch)) added++;
else failed++;
}
FlushBatch(folder, batch);
return (added, failed);
}
/// <summary>
/// Stream counterpart of <see cref="AddRange(IReadOnlyList{string}, string?)"/>. Each item is a
/// stream plus an extension hint (a stream carries no filename). Streams must stay open until
/// this call returns; the caller owns disposing them afterwards.
/// </summary>
public (int added, int failed) AddRange(IReadOnlyList<(Stream Stream, string? Ext)> items, string? toFolder = null)
{
_lengthDirty = true;
var folder = GetFolderFromName(toFolder);
var batch = new List<MapiMessage>(items.Count);
int added = 0, failed = 0;
foreach (var (stream, ext) in items)
{
if (TryLoadStream(stream, ext?.ToLowerInvariant(), batch)) added++;
else failed++;
}
FlushBatch(folder, batch);
return (added, failed);
}
// Writes the accumulated messages to the PST in one call, then releases them.
private void FlushBatch(FolderInfo folder, List<MapiMessage> batch)
{
if (batch.Count == 0) return;
folder.AddMessages(batch);
foreach (var msg in batch) msg.Dispose();
batch.Clear();
}
// Loads one file into the batch (or routes ICS/VCF). Returns false (counted as failed) on an
// unrecoverable load error, mirroring the corrupted-file retry of the single-file Add.
private bool TryLoadFile(string filePath, string? ext, List<MapiMessage> batch)
{
try
{
LoadFileInto(batch, filePath, ext);
return true;
}
catch (ArgumentException ae) when (ae.ToString().Contains("is corrupted"))
{
_log?.LogDebug(ae, "Corrupted on first load, retrying: {File}", filePath);
var failedHint = RetryExtensions.FirstOrDefault(x => x.Equals(ext)) ?? "default";
foreach (var hint in RetryExtensions.Except(new[] { failedHint }))
{
try { LoadFileInto(batch, filePath, hint); return true; }
catch (Exception ex) { _log?.LogDebug(ex, "Retry {Hint} failed: {File}", hint, filePath); }
}
_log?.LogError(ae, "Failed to load (all retries exhausted): {File}", filePath);
return false;
}
catch (Exception ex)
{
_log?.LogError(ex, "Failed to load: {File}", filePath);
return false;
}
}
private bool TryLoadStream(Stream stream, string? ext, List<MapiMessage> batch)
{
try
{
LoadStreamInto(batch, stream, ext);
return true;
}
catch (ArgumentException ae) when (ae.ToString().Contains("is corrupted"))
{
_log?.LogDebug(ae, "Corrupted stream on first load, retrying (ext {Ext})", ext);
var failedHint = RetryExtensions.FirstOrDefault(x => x.Equals(ext)) ?? "default";
foreach (var hint in RetryExtensions.Except(new[] { failedHint }))
{
try { LoadStreamInto(batch, stream, hint); return true; }
catch (Exception ex) { _log?.LogDebug(ex, "Retry {Hint} failed (stream)", hint); }
}
_log?.LogError(ae, "Failed to load stream (all retries exhausted)");
return false;
}
catch (Exception ex)
{
_log?.LogError(ex, "Failed to load stream");
return false;
}
}
// Shared routing: plain/TNEF messages go into the bulk batch; ICS/VCF are added immediately
// to their dedicated folders (they target Calendar/Contacts and use different APIs).
private void LoadFileInto(List<MapiMessage> batch, string filePath, string? ext)
{
switch (DetectFileFormat(filePath, ext))
{
case FileFormatType.Ics:
AddIcsAppointment(Appointment.Load(filePath, new AppointmentLoadOptions { IgnoreSmtpAddressCheck = true }));
break;
case FileFormatType.Vcf:
AddVcfContact(MapiContact.FromVCard(filePath));
break;
case FileFormatType.Tnef:
batch.Add(MapiMessage.LoadFromTnef(filePath));
break;
default:
batch.Add(MapiMessage.Load(filePath));
break;
}
}
private void LoadStreamInto(List<MapiMessage> batch, Stream stream, string? ext)
{
var fmt = DetectFileFormat(stream, ext);
if (stream.CanSeek) stream.Seek(0, SeekOrigin.Begin);
switch (fmt)
{
case FileFormatType.Ics:
AddIcsAppointment(Appointment.Load(stream, new AppointmentLoadOptions { IgnoreSmtpAddressCheck = true }));
break;
case FileFormatType.Vcf:
AddVcfContact(MapiContact.FromVCard(stream));
break;
case FileFormatType.Tnef:
batch.Add(MapiMessage.LoadFromTnef(stream));
break;
default:
batch.Add(MapiMessage.Load(stream));
break;
}
}
private void Init(string pstFilePath)
{
// Q3: dispose the partially-constructed PersonalStorage if any predefined-folder step throws.
try
{
_pst = PersonalStorage.Create(pstFilePath, FileFormatVersion.Unicode);
_rootFolder = _pst.RootFolder;
_inboxFolder = _pst.CreatePredefinedFolder(nameof(StandardIpmFolder.Inbox), StandardIpmFolder.Inbox);
_pst.CreatePredefinedFolder("Calendar", StandardIpmFolder.Appointments);
_pst.CreatePredefinedFolder(nameof(StandardIpmFolder.Contacts), StandardIpmFolder.Contacts);
}
catch
{
_pst?.Dispose();
_pst = null;
throw;
}
}
private void AddMessage(FolderInfo finfo, string filePath, string? ext)
{
var fileFormatType = DetectFileFormat(filePath, ext);
switch (fileFormatType)
{
case FileFormatType.Ics:
AddIcsAppointment(Appointment.Load(filePath, new AppointmentLoadOptions { IgnoreSmtpAddressCheck = true }));
break;
case FileFormatType.Vcf:
AddVcfContact(MapiContact.FromVCard(filePath));
break;
case FileFormatType.Tnef:
finfo.AddMessage(MapiMessage.LoadFromTnef(filePath));
break;
default:
finfo.AddMessage(MapiMessage.Load(filePath));
break;
}
}
private void AddMessage(FolderInfo finfo, Stream stream, string? ext)
{
var fileFormatType = DetectFileFormat(stream, ext);
// FileFormatUtil may advance the stream during detection — rewind before the actual load.
if (stream.CanSeek) stream.Seek(0, SeekOrigin.Begin);
switch (fileFormatType)
{
case FileFormatType.Ics:
AddIcsAppointment(Appointment.Load(stream, new AppointmentLoadOptions { IgnoreSmtpAddressCheck = true }));
break;
case FileFormatType.Vcf:
AddVcfContact(MapiContact.FromVCard(stream));
break;
case FileFormatType.Tnef:
finfo.AddMessage(MapiMessage.LoadFromTnef(stream));
break;
default:
finfo.AddMessage(MapiMessage.Load(stream));
break;
}
}
// Q1: helper extraction — was duplicated inline in both AddMessage overloads in the original.
private void AddIcsAppointment(Appointment appointment)
{
try
{
var calendar = GetFolder(_rootFolder, "Calendar");
var msg = new MailMessage();
msg.AddAlternateView(appointment.RequestApointment());
calendar.AddMessage(MapiMessage.FromMailMessage(msg));
}
catch (Exception ex)
{
_log?.LogError(ex, "Error in Ics file");
}
}
private void AddVcfContact(MapiContact contact)
{
var folder = GetFolder(_rootFolder, nameof(StandardIpmFolder.Contacts));
folder.AddMapiMessageItem(contact);
}
private static FileFormatType DetectFileFormat(string path, string? extensionToUse)
=> ResolveFileFormat(FileFormatUtil.DetectFileFormat(path).FileFormatType, extensionToUse);
private static FileFormatType DetectFileFormat(Stream stream, string? extensionToUse)
=> ResolveFileFormat(FileFormatUtil.DetectFileFormat(stream).FileFormatType, extensionToUse);
private static FileFormatType ResolveFileFormat(FileFormatType detected, string? extensionToUse)
{
if (detected != FileFormatType.Unknown) return detected;
switch (extensionToUse)
{
case ".vcs":
case ".ics":
return FileFormatType.Ics;
case ".vcf": // BUG 149222: blank .vcf detects as Unknown and Aspose throws "MailMessage is corrupted".
return FileFormatType.Vcf;
default:
return FileFormatType.Unknown;
}
}
// Retry cycles through the alternative format hints in RetryExtensions, skipping the one that
// just failed. ext values outside the list are normalised to "default" so the default
// MapiMessage.Load path is the one excluded on retry (it's the path that already failed).
private bool AddAgain(FolderInfo finfo, string filePath, string? ext)
{
var failedHint = RetryExtensions.FirstOrDefault(x => x.Equals(ext)) ?? "default";
foreach (var hint in RetryExtensions.Except(new[] { failedHint }))
{
try
{
_log?.LogDebug("Trying to add with {Hint}, filepath: {FilePath}", hint, filePath);
AddMessage(finfo, filePath, hint);
_log?.LogDebug("Added successfully with {Hint}, filepath: {FilePath}", hint, filePath);
return true;
}
catch (Exception ex) { _log?.LogDebug(ex, "Error while adding with {Hint}, filepath: {FilePath}", hint, filePath); }
}
return false;
}
private bool AddAgain(FolderInfo finfo, Stream stream, string? ext)
{
var failedHint = RetryExtensions.FirstOrDefault(x => x.Equals(ext)) ?? "default";
foreach (var hint in RetryExtensions.Except(new[] { failedHint }))
{
try
{
// Each attempt re-loads from the start; rewind so a consumed stream retries correctly.
if (stream.CanSeek) stream.Seek(0, SeekOrigin.Begin);
_log?.LogDebug("Trying to add (stream) with {Hint}", hint);
AddMessage(finfo, stream, hint);
_log?.LogDebug("Added (stream) successfully with {Hint}", hint);
return true;
}
catch (Exception ex) { _log?.LogDebug(ex, "Error while adding (stream) with {Hint}", hint); }
}
return false;
}
private FolderInfo GetFolderFromName(string? toFolder)
{
if (string.IsNullOrWhiteSpace(toFolder)) return _inboxFolder;
return GetFolder(_rootFolder, toFolder);
}
// P1: iterative walk + folder cache. Each path component is cached, so repeated calls
// resolving paths that share a prefix (typical Inbox/Year/Month layout) become O(1) after
// the first miss.
private FolderInfo GetFolder(FolderInfo rootFolder, string folderPath)
{
_log?.LogDebug("Root Folder: {Root} FolderPath: {FolderPath}", rootFolder.DisplayName, folderPath);
folderPath = folderPath.TrimStart(Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar);
if (string.IsNullOrEmpty(folderPath)) return rootFolder;
// Fast path: full path already resolved on a previous call.
if (_folderCache.TryGetValue(folderPath, out var cached))
return cached;
var current = rootFolder;
string? accumPath = null;
foreach (var component in folderPath.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries))
{
var folderName = component.Trim();
if (folderName.Length == 0) continue;
accumPath = accumPath == null ? folderName : accumPath + Path.DirectorySeparatorChar + folderName;
if (_folderCache.TryGetValue(accumPath, out var cachedSub))
{
current = cachedSub;
continue;
}
FolderInfo? found = null;
foreach (var sub in current.GetSubFolders())
{
if (sub.DisplayName.Equals(folderName, StringComparison.OrdinalIgnoreCase))
{
found = sub;
break;
}
}
if (found == null)
{
_log?.LogDebug("Creating Folder: {FolderName}", folderName);
found = current.AddSubFolder(folderName);
_log?.LogDebug("Created Folder: {FolderName}", folderName);
}
_folderCache[accumPath] = found;
current = found;
}
return current;
}
public void Dispose()
{
_log?.LogInformation("PstWriter disposing. Folder cache size: {CacheSize}", _folderCache.Count);
_pst?.Dispose();
}
}
Worker.cs
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Options;
namespace ExportPerformanceTest.Worker;
public class Worker(
ILogger logger,
IOptions options,
IHostApplicationLifetime lifetime,
ILoggerFactory loggerFactory) : BackgroundService
{
private readonly PstBenchmarkOptions _options = options.Value;
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
RegisterAsposeLicense();
RunBenchmark(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "PST benchmark failed");
}
finally
{
// One-shot benchmark, not a long-running daemon — stop once the run completes.
lifetime.StopApplication();
}
return Task.CompletedTask;
}
private void RegisterAsposeLicense()
{
try
{
var assembly = Assembly.GetExecutingAssembly();
// Resource name = <RootNamespace>.<FileName>; the .lic sits at the project root.
const string resourceName = "ExportPerformanceTest.Worker.Aspose.Total.NET.lic";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
logger.LogWarning("Aspose license resource '{Resource}' not found — running in evaluation mode (PST item count is capped).", resourceName);
return;
}
new Aspose.Email.License().SetLicense(stream);
logger.LogInformation("Aspose license registered");
}
catch (Exception ex)
{
logger.LogError(ex, "Not able to set Aspose license — running in evaluation mode");
}
}
private void RunBenchmark(CancellationToken stoppingToken)
{
if (string.IsNullOrWhiteSpace(_options.SourceFolder))
{
logger.LogWarning("PstBenchmark:SourceFolder is not configured — nothing to do.");
return;
}
if (!Directory.Exists(_options.SourceFolder))
{
logger.LogWarning("PstBenchmark:SourceFolder '{Folder}' does not exist.", _options.SourceFolder);
return;
}
if (string.IsNullOrWhiteSpace(_options.OutputPstPath))
{
logger.LogWarning("PstBenchmark:OutputPstPath is not configured — nothing to do.");
return;
}
var searchOption = _options.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var files = Directory.EnumerateFiles(_options.SourceFolder, _options.SearchPattern, searchOption).ToList();
if (files.Count == 0)
{
logger.LogWarning("No files matched '{Pattern}' under '{Folder}'.", _options.SearchPattern, _options.SourceFolder);
return;
}
var outputPstPath = NormalizeOutputPath(_options.OutputPstPath);
logger.LogInformation("==================== PST BENCHMARK ====================");
logger.LogInformation("Mode : {Mode}", _options.Mode);
logger.LogInformation("Source : {Folder}", _options.SourceFolder);
logger.LogInformation("Pattern : {Pattern} (recursive={Recursive})", _options.SearchPattern, _options.Recursive);
logger.LogInformation("Files : {Count}", files.Count);
logger.LogInformation("Output : {Output}", outputPstPath);
logger.LogInformation("=======================================================");
var results = new List<PassResult>();
if (_options.Mode is PstBenchmarkMode.FilePath or PstBenchmarkMode.Both)
results.Add(RunPass("file-path", outputPstPath, files, useStream: false, stoppingToken));
if (_options.Mode is PstBenchmarkMode.Stream or PstBenchmarkMode.Both)
results.Add(RunPass("stream", StreamOutputPath(outputPstPath), files, useStream: true, stoppingToken));
LogOverallSummary(results);
}
private void LogOverallSummary(IReadOnlyList<PassResult> results)
{
if (results.Count == 0) return;
logger.LogInformation("==================== RESULTS ==========================");
foreach (var r in results)
{
logger.LogInformation(
"{Label,-10} | ok={Ok}/{Total} failed={Failed} | {Elapsed} | {Rate,7:F1} files/sec | {Mb,8:F2} MB",
r.Label, r.Ok, r.Total, r.Failed, FormatHms(TimeSpan.FromMilliseconds(r.ElapsedMs)), r.FilesPerSec, r.PstBytes / 1024d / 1024d);
}
logger.LogInformation("=======================================================");
}
private readonly record struct PassResult(
string Label, int Ok, int Failed, int Total, long ElapsedMs, double FilesPerSec, long PstBytes);
// Accepts either a full ".pst" file path or a folder; when it's a folder (or has no .pst
// extension), the PST is written as "benchmark.pst" inside it.
private static string NormalizeOutputPath(string configured)
{
var looksLikeDir = Directory.Exists(configured)
|| configured.EndsWith(Path.DirectorySeparatorChar)
|| configured.EndsWith(Path.AltDirectorySeparatorChar)
|| !string.Equals(Path.GetExtension(configured), ".pst", StringComparison.OrdinalIgnoreCase);
return looksLikeDir ? Path.Combine(configured, "benchmark.pst") : configured;
}
// In Both mode the stream pass writes to a sibling "<name>_stream.pst" so both PSTs survive for inspection.
private static string StreamOutputPath(string outputPstPath)
{
var dir = Path.GetDirectoryName(outputPstPath) ?? string.Empty;
var name = Path.GetFileNameWithoutExtension(outputPstPath);
var ext = Path.GetExtension(outputPstPath);
return Path.Combine(dir, $"{name}_stream{ext}");
}
private PassResult RunPass(string label, string outputPath, IReadOnlyList<string> files, bool useStream, CancellationToken stoppingToken)
{
// Overwrite any prior run's output so PersonalStorage.Create doesn't fail/append.
var outputDir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDir)) Directory.CreateDirectory(outputDir);
if (File.Exists(outputPath)) File.Delete(outputPath);
var total = files.Count;
var batchSize = Math.Max(1, _options.BatchSize);
var batchCount = (total + batchSize - 1) / batchSize;
logger.LogInformation("[{Label}] START — {Total} file(s) in {Batches} batch(es) of {BatchSize} -> {Output}", label, total, batchCount, batchSize, outputPath);
int ok = 0, failed = 0, processed = 0, batchNo = 0;
var pstLogger = loggerFactory.CreateLogger<PstWriter>();
var sw = Stopwatch.StartNew();
using (var writer = new PstWriter(outputPath, pstLogger))
{
for (var start = 0; start < total; start += batchSize)
{
if (stoppingToken.IsCancellationRequested)
{
logger.LogWarning("[{Label}] CANCELLED after {Processed}/{Total} file(s).", label, processed, total);
break;
}
batchNo++;
var slice = new List<string>(Math.Min(batchSize, total - start));
for (var i = start; i < start + batchSize && i < total; i++) slice.Add(files[i]);
int added, batchFailed;
if (useStream)
{
var streams = new List<FileStream>(slice.Count);
try
{
var items = new List<(Stream, string?)>(slice.Count);
foreach (var f in slice)
{
// Mirror the production export path (Ediscovery Compact/Loose) exactly so
// the stream benchmark reflects real behaviour.
var fs = new FileStream(f, FileMode.Open, FileAccess.Read);
streams.Add(fs);
items.Add((fs, Path.GetExtension(f)));
}
(added, batchFailed) = writer.AddRange(items);
}
finally
{
foreach (var s in streams) s.Dispose();
}
}
else
{
(added, batchFailed) = writer.AddRange(slice);
}
ok += added;
failed += batchFailed;
processed += slice.Count;
var elapsed = sw.Elapsed;
var rate = elapsed.TotalSeconds > 0 ? processed / elapsed.TotalSeconds : 0;
var pct = total > 0 ? processed * 100d / total : 100d;
var eta = rate > 0 ? TimeSpan.FromSeconds((total - processed) / rate) : TimeSpan.Zero;
logger.LogInformation(
"[{Label}] batch {BatchNo}/{Batches} | {Processed}/{Total} ({Pct,5:F1}%) | ok={Ok} failed={Failed} | {Rate,6:F1} files/sec | elapsed {Elapsed} | ETA {Eta} | PST {Mb,7:F2} MB",
label, batchNo, batchCount, processed, total, pct, ok, failed, rate, Format(elapsed), Format(eta), writer.Length / 1024d / 1024d);
}
sw.Stop();
var pstBytes = writer.Length;
var filesPerSec = sw.Elapsed.TotalSeconds > 0 ? ok / sw.Elapsed.TotalSeconds : 0;
var msPerFile = ok > 0 ? sw.Elapsed.TotalMilliseconds / ok : 0;
logger.LogInformation(
"[{Label}] DONE — ok={Ok}, failed={Failed}, total={Total} | {Elapsed} ({ElapsedMs}ms) | {Rate:F1} files/sec, {MsPerFile:F1} ms/file | PST {Bytes:N0} bytes ({Mb:F2} MB)",
label, ok, failed, total, FormatHms(sw.Elapsed), sw.ElapsedMilliseconds, filesPerSec, msPerFile, pstBytes, pstBytes / 1024d / 1024d);
return new PassResult(label, ok, failed, total, sw.ElapsedMilliseconds, filesPerSec, pstBytes);
}
}
private static string Format(TimeSpan t)
=> t.TotalHours >= 1 ? $"{(int)t.TotalHours}h{t.Minutes:D2}m{t.Seconds:D2}s"
: t.TotalMinutes >= 1 ? $"{t.Minutes}m{t.Seconds:D2}s"
: $"{t.TotalSeconds:F1}s";
// HH:MM:SS (hours can exceed 24 for very long runs).
private static string FormatHms(TimeSpan t)
=> $"{(int)t.TotalHours:D2}:{t.Minutes:D2}:{t.Seconds:D2}";
}
Let us know if anything not clear