This commit is contained in:
2026-02-16 23:54:42 +01:00
parent 1f5d7be1a3
commit 873aea7c61
2 changed files with 253 additions and 66 deletions

View File

@@ -18,6 +18,7 @@ public class FileLoggerOptions : BatchingLoggerOptions
private int _maxRolloverFiles = 10; private int _maxRolloverFiles = 10;
private int _retainedFileCountLimit = 50; private int _retainedFileCountLimit = 50;
public bool EnableCategoryRouting { get; set; } public bool EnableCategoryRouting { get; set; }
public LogOverflowStrategy OverflowStrategy { get; set; } = LogOverflowStrategy.Wait;
public ELogType MinimumLogLevel { get; set; } = ELogType.INFO; public ELogType MinimumLogLevel { get; set; } = ELogType.INFO;
public string Category { get; set; } public string Category { get; set; }
public byte[] EncryptionKey { get; set; } public byte[] EncryptionKey { get; set; }

View File

@@ -8,12 +8,20 @@ using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
public enum LogOverflowStrategy
{
Wait,
DropNewest,
DropOldest,
}
[ProviderAlias("EonaCatFileLogger")] [ProviderAlias("EonaCatFileLogger")]
public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
{ {
@@ -24,7 +32,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
private readonly Channel<LogMessage> _channel; private readonly Channel<LogMessage> _channel;
private readonly Task _writerTask; private readonly Task _writerTask;
private readonly SemaphoreSlim _flushSemaphore = new(1, 1); private int _flushRequested;
private string _filePath; private string _filePath;
private readonly int _maxFileSize; private readonly int _maxFileSize;
@@ -39,9 +47,11 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
private readonly Aes? _aes; private readonly Aes? _aes;
public event Action<Exception>? OnError; public event Action<Exception>? OnError;
public event Action<string>? OnFileRolled;
public bool IncludeCorrelationId { get; } public bool IncludeCorrelationId { get; }
public bool EnableCategoryRouting { get; } public bool EnableCategoryRouting { get; }
public CompressionLevel CompressionLevel { get; set; } = CompressionLevel.Optimal;
public string LogFile => _filePath; public string LogFile => _filePath;
@@ -54,6 +64,9 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
private static readonly TimeSpan FlushInterval = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan FlushInterval = TimeSpan.FromMilliseconds(500);
private long _lastFlushTicks = DateTime.UtcNow.Ticks; private long _lastFlushTicks = DateTime.UtcNow.Ticks;
private DateTime _currentRollDate = DateTime.UtcNow.Date;
private readonly Channel<string> _compressQueue = Channel.CreateUnbounded<string>();
public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options) public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options)
{ {
@@ -96,12 +109,70 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
{ {
SingleReader = true, SingleReader = true,
SingleWriter = false, SingleWriter = false,
FullMode = BoundedChannelFullMode.Wait FullMode = o.OverflowStrategy switch
{
LogOverflowStrategy.DropNewest => BoundedChannelFullMode.DropWrite,
LogOverflowStrategy.DropOldest => BoundedChannelFullMode.DropOldest,
_ => BoundedChannelFullMode.Wait
}
}); });
StartCompressionWorker();
_writerTask = Task.Run(WriterLoopAsync); _writerTask = Task.Run(WriterLoopAsync);
} }
private void StartCompressionWorker()
{
_ = Task.Run(async () =>
{
await foreach (var path in _compressQueue.Reader.ReadAllAsync())
{
try
{
string dest = GetRotatedGzipPath(path);
using var input = File.OpenRead(path);
using var output = File.Create(dest);
using var gzip = new GZipStream(output, CompressionLevel);
await input.CopyToAsync(gzip);
File.Delete(path);
}
catch (Exception ex)
{
RaiseError(ex);
}
}
});
}
// Generates a new rotated .gz filename and shifts older files
private string GetRotatedGzipPath(string originalPath)
{
const int MaxFiles = 5; // maximum number of gz files to keep
string dir = Path.GetDirectoryName(originalPath) ?? "";
string name = Path.GetFileNameWithoutExtension(originalPath);
// Shift existing files: log_4.gz → log_5.gz, log_3.gz → log_4.gz, ...
for (int i = MaxFiles - 1; i >= 1; i--)
{
string oldFile = Path.Combine(dir, $"{name}_{i}.gz");
if (File.Exists(oldFile))
{
string newFile = Path.Combine(dir, $"{name}_{i + 1}.gz");
// Delete the destination if it already exists
if (File.Exists(newFile))
File.Delete(newFile);
File.Move(oldFile, newFile);
}
}
// New file becomes _1.gz
return Path.Combine(dir, $"{name}_1.gz");
}
private bool TryInitializePath(string directory, string fileName) private bool TryInitializePath(string directory, string fileName)
{ {
try try
@@ -114,7 +185,8 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
} }
_filePath = fullPath; _filePath = fullPath;
_fileStream = new FileStream(_filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan | FileOptions.WriteThrough); _fileStream = new FileStream(_filePath, FileMode.Append, FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan | FileOptions.WriteThrough);
if (_encryptionEnabled && _aes != null) if (_encryptionEnabled && _aes != null)
{ {
@@ -124,11 +196,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
_size = _fileStream.Length; _size = _fileStream.Length;
return true; return true;
} }
catch (Exception ex) catch (Exception ex) { RaiseError(ex); return false; }
{
RaiseError(ex);
return false;
}
} }
internal override Task WriteMessagesAsync(IReadOnlyList<LogMessage> messages, CancellationToken token) internal override Task WriteMessagesAsync(IReadOnlyList<LogMessage> messages, CancellationToken token)
@@ -146,25 +214,31 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
private bool NeedsTimeRoll()
{
return DateTime.UtcNow.Date > _currentRollDate;
}
private async Task WriterLoopAsync() private async Task WriterLoopAsync()
{ {
var reader = _channel.Reader; var reader = _channel.Reader;
List<LogMessage> batch = new(); var batch = new List<LogMessage>(256);
while (_running || reader.Count > 0) while (await reader.WaitToReadAsync())
{ {
batch.Clear(); batch.Clear();
while (reader.TryRead(out var msg)) while (reader.TryRead(out var msg))
{
batch.Add(msg); batch.Add(msg);
}
if (batch.Count > 0) if (batch.Count > 0)
{ {
if (NeedsTimeRoll())
{
_currentRollDate = DateTime.UtcNow.Date;
await RollFileAsync();
}
await WriteBatchAsync(batch); await WriteBatchAsync(batch);
} }
await Task.Delay(50); // idle wait
} }
await FlushFinalAsync(); await FlushFinalAsync();
@@ -186,20 +260,31 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
sb.Append(kv.Key).Append('=').Append(kv.Value).Append(' '); sb.Append(kv.Key).Append('=').Append(kv.Value).Append(' ');
} }
sb.Length--; // trim sb.Length--;
sb.Append(']'); sb.Append(']');
} }
} }
sb.Append(' ').Append(msg.Message).AppendLine(); sb.Append(' ').Append(msg.Message).AppendLine();
} }
// Directly encode to pooled byte buffer
int maxBytes = Utf8.GetMaxByteCount(sb.Length); int maxBytes = Utf8.GetMaxByteCount(sb.Length);
byte[] rented = ArrayPool<byte>.Shared.Rent(maxBytes); if (maxBytes > _buffer.Length - _position)
int byteCount = Utf8.GetBytes(sb.ToString(), 0, sb.Length, rented, 0); {
WriteToBuffer(rented, byteCount); await FlushInternalAsync();
ArrayPool<byte>.Shared.Return(rented, true); }
int bytesWritten = Utf8.GetBytes(sb.ToString(), 0, sb.Length, _buffer, _position);
_position += bytesWritten;
_size += bytesWritten;
ReleaseStringBuilder(sb); ReleaseStringBuilder(sb);
if (_maxFileSize > 0 && _size >= _maxFileSize)
{
await RollFileAsync();
}
await FlushIfNeededAsync(); await FlushIfNeededAsync();
} }
@@ -208,31 +293,16 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
long now = DateTime.UtcNow.Ticks; long now = DateTime.UtcNow.Ticks;
if (_position >= FlushThreshold || now - _lastFlushTicks >= FlushInterval.Ticks) if (_position >= FlushThreshold || now - _lastFlushTicks >= FlushInterval.Ticks)
{ {
await _flushSemaphore.WaitAsync(); if (Interlocked.Exchange(ref _flushRequested, 1) == 0)
{
try try
{ {
await FlushInternalAsync(); await FlushInternalAsync();
_lastFlushTicks = now; _lastFlushTicks = now;
} }
finally { _flushSemaphore.Release(); } finally { _flushRequested = 0; }
} }
} }
private void WriteToBuffer(byte[] data, int length)
{
if (_position + length > _buffer.Length)
{
FlushInternalAsync().GetAwaiter().GetResult();
}
Buffer.BlockCopy(data, 0, _buffer, _position, length);
_position += length;
_size += length;
if (_maxFileSize > 0 && _size >= _maxFileSize)
{
Task.Run(RollFileAsync);
}
} }
private async Task FlushInternalAsync() private async Task FlushInternalAsync()
@@ -244,6 +314,11 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
try try
{ {
if (NeedsReopen())
{
await ReopenFileAsync();
}
if (_cryptoStream != null) if (_cryptoStream != null)
{ {
await _cryptoStream.WriteAsync(_buffer, 0, _position); await _cryptoStream.WriteAsync(_buffer, 0, _position);
@@ -260,41 +335,114 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
_position = 0; _position = 0;
} }
private bool NeedsReopen()
{
try
{
if (!File.Exists(_filePath))
{
return true;
}
var info = new FileInfo(_filePath);
return info.Length < _size;
}
catch { return false; }
}
private async Task ReopenFileAsync()
{
_cryptoStream?.Dispose();
_fileStream?.Dispose();
_fileStream = new FileStream(_filePath, FileMode.Append, FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan | FileOptions.WriteThrough);
if (_encryptionEnabled && _aes != null)
{
_cryptoStream = new CryptoStream(_fileStream, _aes.CreateEncryptor(), CryptoStreamMode.Write);
}
_size = _fileStream.Length;
}
private async Task FlushFinalAsync() private async Task FlushFinalAsync()
{ {
await FlushInternalAsync(); await FlushInternalAsync();
_cryptoStream?.FlushFinalBlock(); _cryptoStream?.FlushFinalBlock();
await _fileStream.FlushAsync(); await _fileStream.FlushAsync();
} }
private async Task RollFileAsync() private async Task RollFileAsync()
{ {
try try
{ {
await _flushSemaphore.WaitAsync(); await FlushInternalAsync();
FlushInternalAsync().GetAwaiter().GetResult();
_cryptoStream?.FlushFinalBlock();
_cryptoStream?.Dispose(); _cryptoStream?.Dispose();
_fileStream.Dispose(); _cryptoStream = null;
_fileStream?.Dispose();
string tempArchive = _filePath + ".rolling"; string directory = Path.GetDirectoryName(_filePath)!;
File.Move(_filePath, tempArchive); string baseName = Path.GetFileNameWithoutExtension(_filePath);
string extension = Path.GetExtension(_filePath);
// Compression in background // Shift existing rollover files
await Task.Run(() => for (int i = _maxRolloverFiles - 1; i >= 1; i--)
{ {
string dest = _filePath.Replace(".log", "_1.log.gz"); string src = Path.Combine(directory, $"{baseName}_{i}{extension}");
using var input = File.OpenRead(tempArchive); string dest = Path.Combine(directory, $"{baseName}_{i + 1}{extension}");
using var output = File.Create(dest); if (File.Exists(dest))
using var gzip = new GZipStream(output, CompressionLevel.Fastest); {
input.CopyTo(gzip); // Compress oldest if it exceeds max
File.Delete(tempArchive); _compressQueue.Writer.TryWrite(dest);
}); File.Delete(dest);
}
if (File.Exists(src))
{
File.Move(src, dest);
}
}
// Move current log to FORMAT_1.log
string firstRollover = Path.Combine(directory, $"{baseName}_1{extension}");
if (File.Exists(_filePath))
{
File.Move(_filePath, firstRollover);
}
// Compress if we exceed max rollover
string oldest = Path.Combine(directory, $"{baseName}_{_maxRolloverFiles + 1}{extension}");
if (File.Exists(oldest))
{
_compressQueue.Writer.TryWrite(oldest);
File.Delete(oldest);
}
// Recreate active log
RecreateLogFile();
OnFileRolled?.Invoke(firstRollover);
}
catch (Exception ex)
{
RaiseError(ex);
}
}
private void RecreateLogFile()
{
_fileStream = new FileStream(_filePath,
FileMode.Create,
FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete,
4096,
FileOptions.SequentialScan | FileOptions.WriteThrough);
_fileStream = new FileStream(_filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan);
if (_encryptionEnabled && _aes != null) if (_encryptionEnabled && _aes != null)
{ {
_cryptoStream = new CryptoStream(_fileStream, _aes.CreateEncryptor(), CryptoStreamMode.Write); _cryptoStream = new CryptoStream(_fileStream, _aes.CreateEncryptor(), CryptoStreamMode.Write);
@@ -302,18 +450,48 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
_size = 0; _size = 0;
} }
finally { _flushSemaphore.Release(); }
private void CleanupOldRollovers(string directory, string baseName)
{
if (_maxRolloverFiles <= 0)
return;
var rolledLogs = Directory.GetFiles(directory, $"{baseName}_*.log")
.Where(file => !file.EndsWith(".gz", StringComparison.OrdinalIgnoreCase))
.Select(file => new FileInfo(file))
.OrderByDescending(file => file.CreationTimeUtc)
.ToList();
// If too many .log rollovers → compress oldest
if (rolledLogs.Count > _maxRolloverFiles)
{
foreach (var file in rolledLogs.Skip(_maxRolloverFiles))
{
try
{
_compressQueue.Writer.TryWrite(file.FullName);
}
catch (Exception ex)
{
RaiseError(ex);
}
}
}
} }
private static StringBuilder AcquireStringBuilder() private static StringBuilder AcquireStringBuilder()
{ {
var sb = _cachedStringBuilder; var sb = _cachedStringBuilder;
if (sb == null) { sb = new StringBuilder(256); _cachedStringBuilder = sb; } if (sb == null)
{
sb = new StringBuilder(256);
}
else else
{ {
sb.Clear(); sb.Clear();
} }
_cachedStringBuilder = sb;
return sb; return sb;
} }
@@ -344,7 +522,15 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
protected override async Task OnShutdownFlushAsync() protected override async Task OnShutdownFlushAsync()
{ {
_running = false; _running = false;
try
{
_channel.Writer.Complete(); _channel.Writer.Complete();
}
catch
{
// Channel closed before we could complete, ignore
}
await _writerTask; await _writerTask;
ArrayPool<byte>.Shared.Return(_buffer, true); ArrayPool<byte>.Shared.Return(_buffer, true);