using System.Collections.Concurrent;
using System.Text;
// ReSharper disable StaticMemberInGenericType
namespace CSV_Metrics_Logger;
///
/// The CSV storage class.
///
public sealed class CSVStorage : IAsyncDisposable where T : struct, IConvertToCSV
{
private const char CSV_DEFAULT_DELIMITER = ';';
private static readonly TimeSpan WRITE_INTERVAL = TimeSpan.FromSeconds(8);
private readonly object lockObject = new();
private readonly FileStream fileStream;
private readonly TextWriter writer;
private readonly char csvDelimiter;
private readonly ConcurrentQueue dataQueue = new();
private readonly PeriodicTimer writeTimer = new(WRITE_INTERVAL);
private bool isInitialized;
private bool writerRunning;
private CSVStorage(string filename, char csvDelimiter)
{
this.csvDelimiter = csvDelimiter;
this.isInitialized = File.Exists(filename) && new FileInfo(filename).Length > 0;
this.fileStream = new FileStream(filename, FileMode.Append, FileAccess.Write, FileShare.Read);
this.writer = new StreamWriter(this.fileStream, Encoding.UTF8);
_ = Task.Factory.StartNew(() => this.WriteDataWorker(), TaskCreationOptions.LongRunning);
}
///
/// Create a new CSV storage instance for the given type T and filename.
///
///
/// When the file already exists, added data will be appended to the file.
/// The header will be written only once.
///
/// The filename to store the CSV data.
/// The CSV delimiter to use. Default is a semicolon.
/// The CSV storage instance.
public static CSVStorage Create(string filename, char csvDelimiter = CSV_DEFAULT_DELIMITER) => new(filename, csvDelimiter);
///
/// Write the given data to the CSV file.
///
/// The data to write.
/// The cancellation token.
public void Write(T data, CancellationToken cancellationToken = default)
{
if(!this.isInitialized)
{
lock (this.lockObject)
{
if(!this.isInitialized)
{
this.dataQueue.Enqueue(this.CreateCSVLine(data.GetCSVHeaders(), cancellationToken));
this.isInitialized = true;
}
}
}
this.dataQueue.Enqueue(this.CreateCSVLine(data.ConvertToCSVDataLine(), cancellationToken));
}
private async Task WriteDataWorker(CancellationToken cancellationToken = default)
{
this.writerRunning = true;
while (await this.writeTimer.WaitForNextTickAsync(cancellationToken))
await this.WriteData(cancellationToken);
this.writerRunning = false;
}
private async ValueTask WriteData(CancellationToken cancellationToken = default)
{
if(this.dataQueue.IsEmpty)
return;
while(this.dataQueue.TryDequeue(out var line))
{
if(cancellationToken.IsCancellationRequested)
break;
await this.writer.WriteLineAsync(line);
}
}
private string CreateCSVLine(IEnumerable elements, CancellationToken cancellationToken = default)
{
var sb = new StringBuilder(1_024);
foreach (var element in elements)
{
if(cancellationToken.IsCancellationRequested)
break;
if (sb.Length > 0)
sb.Append(this.csvDelimiter);
if (this.QuotationNeeded(element))
{
sb.Append('"');
sb.Append(element.Replace("\"", "\"\""));
sb.Append('"');
}
else
sb.Append(element);
}
return sb.ToString();
}
private bool QuotationNeeded(string value)
{
// Rules:
// - If the value contains a comma, we need to use double quotes.
// - If the value contains the delimiter, we need to use double quotes.
// - If the value contains the escape character, we need to use double quotes.
// - If the value contains a newline character, we need to use double quotes.
// - If the value contains a double quote, we need to escape it by doubling it.
// - If the value contains spaces, we need to use double quotes.
//
if (value.Contains(',') ||
value.Contains(this.csvDelimiter) ||
value.Contains('"') ||
value.Contains('\n') ||
value.Contains(' '))
{
return true;
}
return false;
}
#region Implementation of IAsyncDisposable
///
public async ValueTask DisposeAsync()
{
// Stop the writer timer:
this.writeTimer.Dispose();
// Now, we wait for the last scheduled write operation to finish, if any:
while (this.writerRunning)
await Task.Delay(TimeSpan.FromMilliseconds(100));
// Ensure that all data is written:
await this.WriteData();
// Flush and dispose the writer:
await this.writer.FlushAsync();
await this.writer.DisposeAsync();
// Dispose the file stream:
await this.fileStream.DisposeAsync();
}
#endregion
}