diff --git a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerOptions.cs b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerOptions.cs index 92d165f..53c273a 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerOptions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerOptions.cs @@ -18,6 +18,7 @@ public class FileLoggerOptions : BatchingLoggerOptions private int _maxRolloverFiles = 10; private int _retainedFileCountLimit = 50; public bool EnableCategoryRouting { get; set; } + public LogOverflowStrategy OverflowStrategy { get; set; } = LogOverflowStrategy.Wait; public ELogType MinimumLogLevel { get; set; } = ELogType.INFO; public string Category { get; set; } public byte[] EncryptionKey { get; set; } diff --git a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs index 3e9c6e2..777c1e5 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs @@ -8,12 +8,20 @@ using System.Buffers; using System.Collections.Generic; using System.IO; using System.IO.Compression; +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +public enum LogOverflowStrategy +{ + Wait, + DropNewest, + DropOldest, +} + [ProviderAlias("EonaCatFileLogger")] public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable { @@ -24,7 +32,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable private readonly Channel _channel; private readonly Task _writerTask; - private readonly SemaphoreSlim _flushSemaphore = new(1, 1); + private int _flushRequested; private string _filePath; private readonly int _maxFileSize; @@ -39,9 +47,11 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable private readonly Aes? _aes; public event Action? OnError; + public event Action? OnFileRolled; public bool IncludeCorrelationId { get; } public bool EnableCategoryRouting { get; } + public CompressionLevel CompressionLevel { get; set; } = CompressionLevel.Optimal; public string LogFile => _filePath; @@ -54,6 +64,9 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable private static readonly TimeSpan FlushInterval = TimeSpan.FromMilliseconds(500); private long _lastFlushTicks = DateTime.UtcNow.Ticks; + private DateTime _currentRollDate = DateTime.UtcNow.Date; + private readonly Channel _compressQueue = Channel.CreateUnbounded(); + public FileLoggerProvider(IOptions options) : base(options) { @@ -96,12 +109,70 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable { SingleReader = true, SingleWriter = false, - FullMode = BoundedChannelFullMode.Wait + FullMode = o.OverflowStrategy switch + { + LogOverflowStrategy.DropNewest => BoundedChannelFullMode.DropWrite, + LogOverflowStrategy.DropOldest => BoundedChannelFullMode.DropOldest, + _ => BoundedChannelFullMode.Wait + } }); + StartCompressionWorker(); _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) { try @@ -114,7 +185,8 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable } _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) { @@ -124,11 +196,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable _size = _fileStream.Length; return true; } - catch (Exception ex) - { - RaiseError(ex); - return false; - } + catch (Exception ex) { RaiseError(ex); return false; } } internal override Task WriteMessagesAsync(IReadOnlyList messages, CancellationToken token) @@ -146,25 +214,31 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable return Task.CompletedTask; } + private bool NeedsTimeRoll() + { + return DateTime.UtcNow.Date > _currentRollDate; + } + private async Task WriterLoopAsync() { var reader = _channel.Reader; - List batch = new(); + var batch = new List(256); - while (_running || reader.Count > 0) + while (await reader.WaitToReadAsync()) { batch.Clear(); while (reader.TryRead(out var msg)) - { batch.Add(msg); - } if (batch.Count > 0) { + if (NeedsTimeRoll()) + { + _currentRollDate = DateTime.UtcNow.Date; + await RollFileAsync(); + } await WriteBatchAsync(batch); } - - await Task.Delay(50); // idle wait } await FlushFinalAsync(); @@ -186,20 +260,31 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable sb.Append(kv.Key).Append('=').Append(kv.Value).Append(' '); } - sb.Length--; // trim + sb.Length--; sb.Append(']'); } } sb.Append(' ').Append(msg.Message).AppendLine(); } + // Directly encode to pooled byte buffer int maxBytes = Utf8.GetMaxByteCount(sb.Length); - byte[] rented = ArrayPool.Shared.Rent(maxBytes); - int byteCount = Utf8.GetBytes(sb.ToString(), 0, sb.Length, rented, 0); - WriteToBuffer(rented, byteCount); - ArrayPool.Shared.Return(rented, true); + if (maxBytes > _buffer.Length - _position) + { + await FlushInternalAsync(); + } + + int bytesWritten = Utf8.GetBytes(sb.ToString(), 0, sb.Length, _buffer, _position); + _position += bytesWritten; + _size += bytesWritten; + ReleaseStringBuilder(sb); + if (_maxFileSize > 0 && _size >= _maxFileSize) + { + await RollFileAsync(); + } + await FlushIfNeededAsync(); } @@ -208,30 +293,15 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable long now = DateTime.UtcNow.Ticks; if (_position >= FlushThreshold || now - _lastFlushTicks >= FlushInterval.Ticks) { - await _flushSemaphore.WaitAsync(); - try + if (Interlocked.Exchange(ref _flushRequested, 1) == 0) { - await FlushInternalAsync(); - _lastFlushTicks = now; + try + { + await FlushInternalAsync(); + _lastFlushTicks = now; + } + finally { _flushRequested = 0; } } - finally { _flushSemaphore.Release(); } - } - } - - 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); } } @@ -244,6 +314,11 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable try { + if (NeedsReopen()) + { + await ReopenFileAsync(); + } + if (_cryptoStream != null) { await _cryptoStream.WriteAsync(_buffer, 0, _position); @@ -260,60 +335,163 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable _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() { await FlushInternalAsync(); - _cryptoStream?.FlushFinalBlock(); - await _fileStream.FlushAsync(); } - private async Task RollFileAsync() { try { - await _flushSemaphore.WaitAsync(); - FlushInternalAsync().GetAwaiter().GetResult(); + await FlushInternalAsync(); + _cryptoStream?.FlushFinalBlock(); _cryptoStream?.Dispose(); - _fileStream.Dispose(); + _cryptoStream = null; + _fileStream?.Dispose(); - string tempArchive = _filePath + ".rolling"; - File.Move(_filePath, tempArchive); + string directory = Path.GetDirectoryName(_filePath)!; + string baseName = Path.GetFileNameWithoutExtension(_filePath); + string extension = Path.GetExtension(_filePath); - // Compression in background - await Task.Run(() => + // Shift existing rollover files + for (int i = _maxRolloverFiles - 1; i >= 1; i--) { - string dest = _filePath.Replace(".log", "_1.log.gz"); - using var input = File.OpenRead(tempArchive); - using var output = File.Create(dest); - using var gzip = new GZipStream(output, CompressionLevel.Fastest); - input.CopyTo(gzip); - File.Delete(tempArchive); - }); + string src = Path.Combine(directory, $"{baseName}_{i}{extension}"); + string dest = Path.Combine(directory, $"{baseName}_{i + 1}{extension}"); + if (File.Exists(dest)) + { + // Compress oldest if it exceeds max + _compressQueue.Writer.TryWrite(dest); + File.Delete(dest); + } - _fileStream = new FileStream(_filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan); - if (_encryptionEnabled && _aes != null) - { - _cryptoStream = new CryptoStream(_fileStream, _aes.CreateEncryptor(), CryptoStreamMode.Write); + if (File.Exists(src)) + { + File.Move(src, dest); + } } - _size = 0; + // 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); + + if (_encryptionEnabled && _aes != null) + { + _cryptoStream = new CryptoStream(_fileStream, _aes.CreateEncryptor(), CryptoStreamMode.Write); + } + + _size = 0; + } + + 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); + } + } } - finally { _flushSemaphore.Release(); } } private static StringBuilder AcquireStringBuilder() { var sb = _cachedStringBuilder; - if (sb == null) { sb = new StringBuilder(256); _cachedStringBuilder = sb; } + if (sb == null) + { + sb = new StringBuilder(256); + } else { sb.Clear(); } + _cachedStringBuilder = sb; return sb; } @@ -344,7 +522,15 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable protected override async Task OnShutdownFlushAsync() { _running = false; - _channel.Writer.Complete(); + + try + { + _channel.Writer.Complete(); + } + catch + { + // Channel closed before we could complete, ignore + } await _writerTask; ArrayPool.Shared.Return(_buffer, true);