diff --git a/.gitignore b/.gitignore index 28329487..37fba3da 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,8 @@ x86/ bld/ [Bb]in/ [Oo]bj/ -[Ll]og/ -[Ll]ogs/ +/[Ll]og/ +/[Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ diff --git a/src/LogExpert.Benchmarks/BufferIndexBenchmarks.cs b/src/LogExpert.Benchmarks/BufferIndexBenchmarks.cs new file mode 100644 index 00000000..1b808d3e --- /dev/null +++ b/src/LogExpert.Benchmarks/BufferIndexBenchmarks.cs @@ -0,0 +1,191 @@ +using BenchmarkDotNet.Attributes; + +using ColumnizerLib; + +using LogExpert.Benchmarks.Support; +using LogExpert.Core.Classes.Log.Buffers; + +namespace LogExpert.Benchmarks; + +[MemoryDiagnoser] +[RankColumn] +public class BufferIndexBenchmarks : IDisposable +{ + private BufferIndex _index = null!; + private int _totalLines; + + private bool _disposed; + + [Params(100, 1_000, 10_000)] + public int BufferCount { get; set; } + + private const int LINES_PER_BUFFER = 500; + + [GlobalSetup] + public void Setup () + { + _index = new BufferIndex(BufferCount, LINES_PER_BUFFER); + _totalLines = BufferCount * LINES_PER_BUFFER; + + var fakeFileInfo = new FakeLogFileInfo(); + + using (var writeLock = _index.AcquireWriteLock()) + { + for (int i = 0; i < BufferCount; i++) + { + var buffer = new LogBuffer(fakeFileInfo, LINES_PER_BUFFER) + { + StartLine = i * LINES_PER_BUFFER + }; + + for (int j = 0; j < LINES_PER_BUFFER; j++) + { + buffer.AddLine(new LogLine($"line {i * LINES_PER_BUFFER + j}".AsMemory(), i * LINES_PER_BUFFER + j), 0); + } + + _index.Add(buffer); + } + } + + // Validate setup + var snapshot = _index.CreateSnapshot(); + if (snapshot.BufferCount != BufferCount) + { + throw new InvalidOperationException($"Setup failed: expected {BufferCount} buffers, got {snapshot.BufferCount}"); + } + } + + [GlobalCleanup] + public void Cleanup () => _index.Dispose(); + + /// + /// Simulates tail-follow: reading the last 1000 lines sequentially. + /// Should hit Layer 0 (thread-local cache) ~99% of the time. + /// + [Benchmark(Baseline = true)] + public LogBuffer? SequentialAccess () + { + using var readlock = _index.AcquireReadLock(); + LogBuffer? last = null; + var start = Math.Max(0, _totalLines - 1000); + for (int i = start; i < _totalLines; i++) + { + var logBufferEntry = _index.TryFindBuffer(i); + if (logBufferEntry.Found) + { + last = logBufferEntry.Buffer; + } + } + + return last; + } + + /// + /// Simulates search/goto: deterministic stride across the full file. + /// Co-prime stride visits buffers in non-sequential, non-repeating order. + /// Exercises Layers 2 and 3 heavily. + /// + [Benchmark] + public LogBuffer? StrideAccess () + { + using var readLock = _index.AcquireReadLock(); + LogBuffer? last = null; + var stride = _totalLines / 3 + 1; + var lineNum = 0; + for (int i = 0; i < 1000; i++) + { + var logBufferEntry = _index.TryFindBuffer(lineNum); + if (logBufferEntry.Found) + { + last = logBufferEntry.Buffer; + } + + lineNum = (lineNum + stride) % _totalLines; + } + + return last; + } + + /// + /// Worst case for Layer 0: always crossing buffer boundaries. + /// Exercises Layer 1 (adjacent prediction). + /// + [Benchmark] + public LogBuffer? BoundaryAccess () + { + using var readLock = _index.AcquireReadLock(); + LogBuffer? last = null; + + for (int i = 0; i < 1000; i++) + { + int lineNum = i * (_totalLines / 1000); + var logBufferEntry = _index.TryFindBuffer(lineNum); + if (logBufferEntry.Found) + { + last = logBufferEntry.Buffer; + } + } + + return last; + } + + /// + /// Simulates UI scrolling: page-sized jumps forward through the file. + /// 50-line pages with 3x page jumps (fast scroll drag). + /// Exercises Layer 0 within pages and Layers 1-2 on transitions. + /// + [Benchmark] + public LogBuffer? ScrollAccess () + { + using var readLock = _index.AcquireReadLock(); + LogBuffer? last = null; + const int pageSize = 50; + const int pageJump = pageSize * 3; + var pageStart = 0; + + for (int page = 0; page < 20 && pageStart < _totalLines; page++) + { + var pageEnd = Math.Min(pageStart + pageSize, _totalLines); + for (int line = pageStart; line < pageEnd; line++) + { + var logBufferEntry = _index.TryFindBuffer(line); + if (logBufferEntry.Found) + { + last = logBufferEntry.Buffer; + } + } + + pageStart += pageJump; + } + + return last; + } + + /// + /// Measures LRU eviction cost at current scale. + /// + [Benchmark] + public void EvictAndRepopulate () + { + _index.EvictLeastRecentlyUsed(); + } + + public void Dispose () + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose (bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _index?.Dispose(); + } + + _disposed = true; + } + } +} \ No newline at end of file diff --git a/src/LogExpert.Benchmarks/BufferIndexContentionBenchmarks.cs b/src/LogExpert.Benchmarks/BufferIndexContentionBenchmarks.cs new file mode 100644 index 00000000..9885ddeb --- /dev/null +++ b/src/LogExpert.Benchmarks/BufferIndexContentionBenchmarks.cs @@ -0,0 +1,170 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; + +using ColumnizerLib; + +using LogExpert.Benchmarks.Support; +using LogExpert.Core.Classes.Log.Buffers; + +namespace LogExpert.Benchmarks; + +/// +/// Measures ReaderWriterLockSlim contention under concurrent read load. +/// Compares single-threaded throughput against N concurrent readers +/// to determine if RWLS is a bottleneck worth optimizing. +/// +[MemoryDiagnoser] +[ThreadingDiagnoser] // Reports lock contention + thread pool stats +[RankColumn] +public class BufferIndexContentionBenchmarks : IDisposable +{ + private BufferIndex _index = null!; + private int _totalLines; + private bool _disposed; + + private const int BUFFERS = 10_000; + private const int LINES_PER_BUFFER = 500; + private const int READS_PER_TASK = 1_000; + + [GlobalSetup] + public void Setup () + { + _index = new BufferIndex(BUFFERS, LINES_PER_BUFFER); + _totalLines = BUFFERS * LINES_PER_BUFFER; + + var fakeFileInfo = new FakeLogFileInfo(); + using var writeLock = _index.AcquireWriteLock(); + for (int i = 0; i < BUFFERS; i++) + { + var buffer = new LogBuffer(fakeFileInfo, LINES_PER_BUFFER) + { + StartLine = i * LINES_PER_BUFFER + }; + for (int j = 0; j < LINES_PER_BUFFER; j++) + { + buffer.AddLine( + new LogLine($"line {i * LINES_PER_BUFFER + j}".AsMemory(), + i * LINES_PER_BUFFER + j), 0); + } + _index.Add(buffer); + } + } + + /// + /// Single-threaded baseline: sequential reads under one read lock. + /// This is the ideal throughput ceiling. + /// + [Benchmark(Baseline = true)] + public int SingleThreadedReads () + { + int found = 0; + using var readLock = _index.AcquireReadLock(); + var start = Math.Max(0, _totalLines - READS_PER_TASK); + for (int i = start; i < _totalLines; i++) + { + if (_index.TryFindBuffer(i).Found) + { + found++; + } + } + + return found; + } + + /// + /// N concurrent readers each acquiring their own read lock. + /// If RWLS has no contention, throughput ≈ N × single-threaded. + /// + [Benchmark] + [Arguments(2)] + [Arguments(4)] + [Arguments(8)] + [Arguments(12)] + public int ConcurrentReads (int threadCount) + { + var total = 0; + _ = Parallel.For(0, threadCount, _ => + { + int found = 0; + using var readLock = _index.AcquireReadLock(); + var start = Math.Max(0, _totalLines - READS_PER_TASK); + for (int i = start; i < _totalLines; i++) + { + if (_index.TryFindBuffer(i).Found) + { + found++; + } + } + _ = Interlocked.Add(ref total, found); + }); + return total; + } + + /// + /// Simulates production: N readers + 1 writer (tail-follow append). + /// Writer acquires write lock briefly every ~1000 reads. + /// This is the realistic contention scenario. + /// + [Benchmark] + [Arguments(4)] + [Arguments(8)] + public int ConcurrentReadsWithWriter (int readerCount) + { + using var cts = new CancellationTokenSource(); + var total = 0; + + // Writer task: periodically takes write lock (simulates new buffer append) + var writerTask = Task.Run(() => + { + while (!cts.Token.IsCancellationRequested) + { + using var writeLock = _index.AcquireWriteLock(); + // Simulate brief write work (no actual mutation to keep state clean) + Thread.SpinWait(100); + } + }); + + // Reader tasks + _ = Parallel.For(0, readerCount, _ => + { + int found = 0; + using var readLock = _index.AcquireReadLock(); + var start = Math.Max(0, _totalLines - READS_PER_TASK); + for (int i = start; i < _totalLines; i++) + { + if (_index.TryFindBuffer(i).Found) + { + found++; + } + } + + _ = Interlocked.Add(ref total, found); + }); + + cts.Cancel(); + writerTask.Wait(); + return total; + } + + [GlobalCleanup] + public void Cleanup () => _index.Dispose(); + + public void Dispose () + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose (bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _index?.Dispose(); + } + + _disposed = true; + } + } +} \ No newline at end of file diff --git a/src/LogExpert.Benchmarks/LogExpert.Benchmarks.csproj b/src/LogExpert.Benchmarks/LogExpert.Benchmarks.csproj index 1240cbd4..4bfb4225 100644 --- a/src/LogExpert.Benchmarks/LogExpert.Benchmarks.csproj +++ b/src/LogExpert.Benchmarks/LogExpert.Benchmarks.csproj @@ -16,6 +16,7 @@ + diff --git a/src/LogExpert.Benchmarks/Program.cs b/src/LogExpert.Benchmarks/Program.cs new file mode 100644 index 00000000..01954f88 --- /dev/null +++ b/src/LogExpert.Benchmarks/Program.cs @@ -0,0 +1,54 @@ +using BenchmarkDotNet.Running; + +namespace LogExpert.Benchmarks; + +public static class Program +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Benchmarks")] + public static void Main (string[] args) + { + if (args == null || args.Length == 0) + { + Console.WriteLine("No benchmarks specified. Running all benchmarks..."); + + // Run all benchmarks if no arguments are provided + _ = BenchmarkRunner.Run(); + _ = BenchmarkRunner.Run(); + _ = BenchmarkRunner.Run(); + _ = BenchmarkRunner.Run(); + } + else + { + // Run specific benchmarks based on command-line arguments + _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } + + Console.WriteLine("Replace with the name of the benchmark you want to run, e.g. "); + Console.WriteLine("StreamReaderBenchmarks: Benchmarks for stream readers"); + Console.WriteLine("ReadThroughputBenchmarks: Benchmarks for read throughput"); + Console.WriteLine("BufferIndexBenchmarks: Benchmarks for buffer index"); + Console.WriteLine("BufferIndexContentionBenchmarks: Benchmarks for buffer index contention"); + Console.WriteLine("Dry run:"); + Console.WriteLine("dotnet run -c Release -- --filter \"**\" --job Dry --noOverwrite"); + Console.WriteLine("Short run:"); + Console.WriteLine("dotnet run -c Release -- --filter \"**\" --job Short --noOverwrite"); + Console.WriteLine("Full baseline run:"); + Console.WriteLine("dotnet run -c Release -- --filter \"**\" --noOverwrite"); + } +} + +/* + * Comment / Uncommen the benchmark to run, careful some can run longer + * 1.) a dry run + * dotnet run -c Release -- --filter "StreamReaderBenchmarks" --job Dry --noOverwrite + * 2.) a short run + * dotnet run -c Release -- --filter "StreamReaderBenchmarks" --job Short --noOverwrite + * 3.) a full baseline run + * dotnet run -c Release -- --filter "StreamReaderBenchmarks" --noOverwrite + * + * The full baseline run generates a MD file + * BenchmarkDotNet.Artifacts/results/*-report-github.md + * + * If changes are made with the LogfileReader / BufferIndex, always do a Benchmark to + * verify no performance regression is introduced, especially with large files. + */ diff --git a/src/LogExpert.Benchmarks/ReadThroughputBenchmarks.cs b/src/LogExpert.Benchmarks/ReadThroughputBenchmarks.cs new file mode 100644 index 00000000..f9f0cf8f --- /dev/null +++ b/src/LogExpert.Benchmarks/ReadThroughputBenchmarks.cs @@ -0,0 +1,128 @@ +using System.Text; + +using BenchmarkDotNet.Attributes; + +using LogExpert.Core.Classes.Log; +using LogExpert.Core.Entities; +using LogExpert.Core.Enums; + +namespace LogExpert.Benchmarks; + +/// +/// Measures LogfileReader.ReadFiles() throughput with different progress reporters. +/// Uses real temp files to include actual I/O in the measurement. +/// +[MemoryDiagnoser] +[RankColumn] +public class ReadThroughputBenchmarks +{ + private string _tempFile = null!; + + [Params(10_000, 100_000, 1_000_000)] + public int LineCount { get; set; } + + [GlobalSetup] + public void Setup () + { + _tempFile = Path.GetTempFileName(); + GenerateLogFile(_tempFile, LineCount); + + // Initialize PluginRegistry for local file system support + // (or use NullPluginRegistry if constructor doesn't need it) + _ = PluginRegistry.PluginRegistry.Create(Path.GetDirectoryName(_tempFile)!, 500); + } + + /// + /// Baseline: read with NullProgressReporter (zero event overhead). + /// + [Benchmark(Baseline = true)] + public int ReadWithNullReporter () + { + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 500, + linesPerBuffer: 500, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500, + progressReporter: Core.Classes.Log.ProgressReporters.NullProgressReporter.Instance); + + reader.ReadFiles(); + return reader.LineCount; + } + + /// + /// Production path: read with PeriodicProgressReporter (default, no subscribers). + /// + [Benchmark] + public int ReadWithPeriodicReporter () + { + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 500, + linesPerBuffer: 500, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500); + // No progressReporter = default PeriodicProgressReporter + + reader.ReadFiles(); + return reader.LineCount; + } + + /// + /// Post-change: read with block-based allocation (System reader uses CharBlockAllocator). + /// Compare Gen0/Gen1/Gen2 collections vs baseline to validate allocation reduction. + /// This method is identical to ReadWithNullReporter — it exists solely for explicit + /// before/after naming in benchmark reports. + /// + [Benchmark] + public int ReadWithBlockAllocation () + { + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 500, + linesPerBuffer: 500, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500, + progressReporter: Core.Classes.Log.ProgressReporters.NullProgressReporter.Instance); + + reader.ReadFiles(); + return reader.LineCount; + } + + [GlobalCleanup] + public void Cleanup () + { + if (File.Exists(_tempFile)) + { + File.Delete(_tempFile); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5394:Do not use insecure randomness", Justification = "Benchmark data generation")] + private static void GenerateLogFile (string path, int lineCount) + { + var rng = new Random(42); // deterministic seed for reproducibility + using var writer = new StreamWriter(path, false, Encoding.UTF8, bufferSize: 65536); + for (int i = 0; i < lineCount; i++) + { + writer.Write("2026-04-23 12:00:00."); + writer.Write(i % 1000); + writer.Write(" [INFO] Thread-"); + writer.Write(rng.Next(1, 32)); + writer.Write(" SomeNamespace.SomeClass - Log message number "); + writer.WriteLine(i); + } + } +} \ No newline at end of file diff --git a/src/LogExpert.Benchmarks/StreamReaderBenchmarks.cs b/src/LogExpert.Benchmarks/StreamReaderBenchmarks.cs index c8a0b382..a82f0432 100644 --- a/src/LogExpert.Benchmarks/StreamReaderBenchmarks.cs +++ b/src/LogExpert.Benchmarks/StreamReaderBenchmarks.cs @@ -1,9 +1,8 @@ using System.Text; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Running; -using LogExpert.Core.Classes.Log; +using LogExpert.Core.Classes.Log.Streamreaders; using LogExpert.Core.Entities; using LogExpert.Core.Interfaces; @@ -150,12 +149,4 @@ private static void ReadAllLines (ILogStreamReader reader) // Consume the line } } -} - -public static class Program -{ - public static void Main (string[] args) - { - _ = BenchmarkRunner.Run(); - } -} +} \ No newline at end of file diff --git a/src/LogExpert.Benchmarks/Support/FakeLogFileInfo.cs b/src/LogExpert.Benchmarks/Support/FakeLogFileInfo.cs new file mode 100644 index 00000000..bd70a382 --- /dev/null +++ b/src/LogExpert.Benchmarks/Support/FakeLogFileInfo.cs @@ -0,0 +1,34 @@ +using ColumnizerLib; + +namespace LogExpert.Benchmarks.Support; + +/// +/// Minimal ILogFileInfo stub for benchmarks. No filesystem access. +/// Wraps an in-memory byte array as the file content. +/// +internal sealed class FakeLogFileInfo : ILogFileInfo +{ + private readonly byte[] _content; + + public FakeLogFileInfo (string name = "fake.log", byte[]? content = null, long length = 1_000_000) + { + FullName = name; + _content = content ?? []; + Length = content?.Length ?? length; + OriginalLength = Length; + } + + public string FullName { get; } + public string FileName => Path.GetFileName(FullName); + public string DirectoryName => Path.GetDirectoryName(FullName) ?? ""; + public char DirectorySeparatorChar => Path.DirectorySeparatorChar; + public Uri Uri => new($"file:///{FullName}"); + public long Length { get; set; } + public long OriginalLength { get; } + public bool FileExists => true; + public int PollInterval => 250; + + public bool FileHasChanged () => false; + public Stream OpenStream () => new MemoryStream(_content, writable: false); + public ILogFileInfo GetRolloverInfo (string fileName) => new FakeLogFileInfo(fileName); +} \ No newline at end of file diff --git a/src/LogExpert.Benchmarks/Support/NullPluginRegistry.cs b/src/LogExpert.Benchmarks/Support/NullPluginRegistry.cs new file mode 100644 index 00000000..5c815054 --- /dev/null +++ b/src/LogExpert.Benchmarks/Support/NullPluginRegistry.cs @@ -0,0 +1,30 @@ +using ColumnizerLib; + +using LogExpert.Core.Interfaces; + +namespace LogExpert.Benchmarks.Support; + +/// +/// No-op IPluginRegistry for benchmarks. Returns empty columnizer list and +/// a stub file system plugin that handles all URIs via local file system. +/// +internal sealed class NullPluginRegistry : IPluginRegistry +{ + public static readonly NullPluginRegistry Instance = new(); + + public IList RegisteredColumnizers { get; } = []; + + public IFileSystemPlugin FindFileSystemForUri (string fileNameOrUri) => NullFileSystemPlugin.Instance; + + private sealed class NullFileSystemPlugin : IFileSystemPlugin + { + public static readonly NullFileSystemPlugin Instance = new(); + + public string Text => "Null"; + public string Description => "No-op file system for benchmarks"; + public bool CanHandleUri (string uriString) => true; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "For UnitTests")] + public ILogFileInfo GetLogfileInfo (string uriString) => throw new NotSupportedException("NullFileSystemPlugin does not support GetLogfileInfo"); + } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/BatchedProgressReporter.cs b/src/LogExpert.Core/Classes/Log/BatchedProgressReporter.cs deleted file mode 100644 index 03d711bb..00000000 --- a/src/LogExpert.Core/Classes/Log/BatchedProgressReporter.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Collections.Concurrent; - -using LogExpert.Core.EventArguments; - -namespace LogExpert.Core.Classes.Log; - -/// -/// Batches progress updates to reduce UI thread marshalling overhead. -/// Collects updates in a thread-safe queue and processes them on a timer. -/// -//TODO Refactor -public sealed class BatchedProgressReporter : IDisposable -{ - private readonly ConcurrentQueue _progressQueue = new(); - private readonly Timer _timer; - private readonly Action _progressCallback; - private readonly int _updateIntervalMs; - private bool _disposed; - - /// - /// Creates a new batched progress reporter. - /// - /// Callback to invoke with latest progress - /// Update interval in milliseconds (default: 100ms) - public BatchedProgressReporter (Action progressCallback, int updateIntervalMs = 100) - { - _progressCallback = progressCallback ?? throw new ArgumentNullException(nameof(progressCallback)); - _updateIntervalMs = updateIntervalMs; - - // Start timer - _timer = new Timer(ProcessQueue, null, updateIntervalMs, updateIntervalMs); - } - - /// - /// Reports progress (thread-safe, non-blocking) - /// - public void ReportProgress (LoadFileEventArgs args) - { - if (_disposed) - { - return; - } - - // Only keep the latest update - discard old ones - _progressQueue.Enqueue(args); - - // Keep queue size bounded (max 10 items) - while (_progressQueue.Count > 10) - { - _ = _progressQueue.TryDequeue(out _); - } - } - - /// - /// Flushes any pending updates immediately - /// - public void Flush () - { - ProcessQueue(null); - } - - private void ProcessQueue (object state) - { - if (_disposed) - { - return; - } - - // Get only the LATEST update (discard intermediate ones) - LoadFileEventArgs latestUpdate = null; - while (_progressQueue.TryDequeue(out var update)) - { - latestUpdate = update; - } - - // Invoke callback with latest update - if (latestUpdate != null) - { - try - { - _progressCallback(latestUpdate); - } - catch (Exception ex) - { - // Log but don't crash - System.Diagnostics.Debug.WriteLine($"Error in progress callback: {ex.Message}"); - } - } - } - - public void Dispose () - { - if (_disposed) - { - return; - } - - _disposed = true; - - Flush(); - _timer?.Dispose(); - - // Clear queue - _progressQueue.Clear(); - } -} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/Buffers/BufferIndex.cs b/src/LogExpert.Core/Classes/Log/Buffers/BufferIndex.cs new file mode 100644 index 00000000..0e838d23 --- /dev/null +++ b/src/LogExpert.Core/Classes/Log/Buffers/BufferIndex.cs @@ -0,0 +1,621 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +using NLog; + +namespace LogExpert.Core.Classes.Log.Buffers; + +/* + * !IMPORTANT + * Before and after changes are made run the BufferIndexBenchmarks for a baseline, so no performance regression is introduced + * If changes are made to this class, please also review BufferIndexSnapshot and BufferShiftTest to ensure consistency and correctness. + */ + +/// +/// Thread-safe index that maps line numbers to instances with LRU eviction. This is the hot +/// path — every GetLogLine call goes through here. Has zero file-I/O dependencies. Constructable with only integers for +/// benchmarking. +/// +public sealed class BufferIndex : IDisposable +{ + private readonly int _maxBuffers; + private readonly int _maxLinesPerBuffer; + private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion); + private readonly SortedList _bufferList = []; + private readonly ConcurrentDictionary _lruCacheDict; + private readonly ThreadLocal _lastBufferIndex = new(() => -1); + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + private volatile bool _isLineCountDirty = true; + private int _cachedLineCount; + + public BufferIndex (int maxBuffers, int maxLinesPerBuffer) + { + _maxBuffers = maxBuffers; + _maxLinesPerBuffer = maxLinesPerBuffer; + _lruCacheDict = new(Environment.ProcessorCount, maxBuffers + 1); + } + + #region Hot Path Lookup + + /// + /// 4-layer lookup. Caller must hold at least a read lock. Returns false if lineNum is out of range or the index is + /// empty. + /// + public LogBufferEntry TryFindBuffer (int lineNum) + { + return TryFindBufferWithIndex(lineNum); + } + + /// + /// Core buffer lookup returning both buffer and index position. The caller MUST already hold a read, + /// upgradeable-read, or write lock. + /// + internal LogBufferEntry GetBufferForLineWithIndex (int lineNum) + { + return TryFindBufferWithIndex(lineNum); + } + + private LogBufferEntry TryFindBufferWithIndex (int lineNum) + { +#if DEBUG + Util.AssertTrue(_lock.IsReadLockHeld || _lock.IsUpgradeableReadLockHeld || _lock.IsWriteLockHeld, "No lock held for buffer list in TryFindBufferWithIndex"); + long startTime = Environment.TickCount; +#endif + var arr = _bufferList.Values; + var count = arr.Count; + + if (count == 0) + { + return new LogBufferEntry(null, -1, false); + } + + // Layer 0: Last buffer cache — O(1) for sequential access + var lastIdx = _lastBufferIndex.Value; + if (lastIdx >= 0 && lastIdx < count) + { + var buf = arr[lastIdx]; + if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) + { + //dont UpdateLRUCache, the cache has not changed in layer 0 + return new LogBufferEntry(buf, lastIdx, true); + } + + // Layer 1: Adjacent buffer prediction — O(1) for buffer boundary crossings + if (lastIdx + 1 < count) + { + var next = arr[lastIdx + 1]; + if ((uint)(lineNum - next.StartLine) < (uint)next.LineCount) + { + _lastBufferIndex.Value = lastIdx + 1; + UpdateLru(next); + return new LogBufferEntry(next, lastIdx + 1, true); + } + } + + if (lastIdx - 1 >= 0) + { + var prev = arr[lastIdx - 1]; + if ((uint)(lineNum - prev.StartLine) < (uint)prev.LineCount) + { + _lastBufferIndex.Value = lastIdx - 1; + UpdateLru(prev); + return new LogBufferEntry(prev, lastIdx - 1, true); + } + } + } + + // Layer 2: Direct mapping guess — O(1) speculative for uniform buffers + var guess = lineNum / _maxLinesPerBuffer; + if ((uint)guess < (uint)count) + { + var buf = arr[guess]; + if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) + { + _lastBufferIndex.Value = guess; + UpdateLru(buf); + return new LogBufferEntry(buf, guess, true); + } + } + + // Layer 3: Branchless binary search with power-of-two strides + var step = HighestPowerOfTwo(count); + var idx = (arr[step - 1].StartLine <= lineNum) ? count - step : 0; + + for (step >>= 1; step > 0; step >>= 1) + { + var probe = idx + step; + if (probe < count && arr[probe].StartLine <= lineNum) + { + idx = probe; + } + } + + // idx is now the buffer index — verify bounds + if (idx < count) + { + var buf = arr[idx]; + if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) + { + _lastBufferIndex.Value = idx; + UpdateLru(buf); + return new LogBufferEntry(buf, idx, true); + } + } +#if DEBUG + long endTime = Environment.TickCount; + _logger.Debug($"TryFindBufferWithIndex({lineNum}) duration: {endTime - startTime} ms."); +#endif + return new LogBufferEntry(null, -1, false); + } + + #endregion + + #region Navigation: multi-file traversal + + /// + /// Finds the start line of the next file segment after . Caller must hold at least a read + /// lock. + /// + public (bool Found, int StartLine) TryGetNextFileStartLine (int lineNum) + { + var result = -1; + + var foundBufferEntry = TryFindBufferWithIndex(lineNum); + if (!foundBufferEntry.Found) + { + return (foundBufferEntry.Found, result); + } + + for (var i = foundBufferEntry.Index; i < _bufferList.Values.Count; ++i) + { + if (_bufferList.Values[i].FileInfo != foundBufferEntry.Buffer.FileInfo) + { + result = _bufferList.Values[i].StartLine; + break; + } + } + + return (result != -1, result); + } + + /// + /// Finds the start line of the previous file segment before . Caller must hold at least a + /// read lock. + /// + public (bool Found, int StartLine) TryGetPrevFileStartLine (int lineNum) + { + var result = -1; + + var foundBufferEntry = TryFindBufferWithIndex(lineNum); + + if (!foundBufferEntry.Found) + { + return (foundBufferEntry.Found, result); + } + + if (foundBufferEntry.Buffer != null && foundBufferEntry.Index != -1) + { + for (var i = foundBufferEntry.Index; i >= 0; --i) + { + if (_bufferList.Values[i].FileInfo != foundBufferEntry.Buffer.FileInfo) + { + result = _bufferList.Values[i].StartLine + _bufferList.Values[i].LineCount; + break; + } + } + } + + return (result != -1, result); + } + + /// + /// Finds the first buffer belonging to the same file as . Caller must hold at least a + /// read lock. + /// + public LogBuffer? GetFirstBufferForFile (LogBuffer logBuffer, int index) + { + //maybe not necessary + ArgumentNullException.ThrowIfNull(logBuffer, "GetFirstBufferForFile not possible: Buffer is NULL"); + + if (index == -1) + { + return null; + } + + var info = logBuffer.FileInfo; + + var resultBuffer = logBuffer; + while (true) + { + index--; + if (index < 0 || _bufferList.Values[index].FileInfo != info) + { + break; + } + + resultBuffer = _bufferList.Values[index]; + } + + return resultBuffer; + } + + #endregion + + #region Mutation — called during reads and rollover + + /// + /// Adds a buffer to the index and updates LRU tracking. Caller must hold a write lock. + /// + public void Add (LogBuffer buffer) + { +#if DEBUG + _logger.Debug(CultureInfo.InvariantCulture, "AddBufferToList(): {0}/{1}/{2}", buffer.StartLine, buffer.LineCount, buffer.FileInfo.FullName); +#endif + _bufferList[buffer.StartLine] = buffer; + UpdateLru(buffer); + _isLineCountDirty = true; + } + + /// + /// Removes a buffer by its start line key and LRU entry. Caller must hold a write lock. + /// + public bool Remove (LogBuffer buffer) + { + ArgumentNullException.ThrowIfNull(buffer, "Remove not possible: Buffer is NULL"); + + Debug.Assert(_lock.IsWriteLockHeld, "No writer lock for buffer list"); + _ = _lruCacheDict.TryRemove(buffer.StartLine, out _); + _isLineCountDirty = true; + return _bufferList.Remove(buffer.StartLine); + } + + /// + /// Atomically updates a buffer's start line in both the index and LRU cache. Used by ShiftBuffers during rollover. + /// Caller must hold a write lock. + /// + public void UpdateStartLine (LogBuffer buffer, int newStartLine) + { + var hadCache = _lruCacheDict.TryRemove(buffer.StartLine, out var cacheEntry); + + _ = _bufferList.Remove(buffer.StartLine); + buffer.StartLine = newStartLine; + _bufferList[newStartLine] = buffer; + + if (hadCache) + { + _ = _lruCacheDict.TryAdd(buffer.StartLine, cacheEntry); + } + + _isLineCountDirty = true; + } + + /// + /// Clears all buffers and LRU entries. Does NOT dispose buffer content. Caller must hold a write lock. + /// + public void Clear () + { + _bufferList.Clear(); + _lruCacheDict.Clear(); + ResetThreadLocalCache(); + _isLineCountDirty = true; + } + + #endregion + + #region LRU eviction + + /// + /// Removes least-recently-used entries when cache exceeds max size. Evicts content but preserves metadata so + /// buffers remain findable for re-read. Does NOT acquire _lock — only touches _lruCache (ConcurrentDictionary) and + /// individual buffer SpinLocks. + /// + public void EvictLeastRecentlyUsed () + { +#if DEBUG + long startTime = Environment.TickCount; +#endif + _logger.Debug(CultureInfo.InvariantCulture, "Starting garbage collection"); + var threshold = 10; + + if (_lruCacheDict.Count - (_maxBuffers + threshold) > 0) + { + var diff = _lruCacheDict.Count - _maxBuffers; +#if DEBUG + if (diff > 0) + { + _logger.Info(CultureInfo.InvariantCulture, "Removing {0} entries from LRU cache", diff); + } +#endif + // Snapshot values and sort by timestamp (ascending = least recently used first) + var entries = _lruCacheDict.ToArray(); + Array.Sort(entries, static (a, b) => a.Value.LastUseTimeStamp.CompareTo(b.Value.LastUseTimeStamp)); + + for (var i = 0; i < diff && i < entries.Length; ++i) + { + var kvp = entries[i]; + if (_lruCacheDict.TryRemove(kvp.Key, out var removed)) + { + var lockTaken = false; + try + { + removed.LogBuffer.AcquireContentLock(ref lockTaken); + // Evict content but preserve metadata (LineCount, StartLine, etc.) + // so the buffer remains findable in _bufferList lookups. + // Do NOT return to pool — the buffer is still referenced by _bufferList. + removed.LogBuffer.EvictContent(); + } + finally + { + if (lockTaken) + { + removed.LogBuffer.ReleaseContentLock(); + } + } + } + } + } + +#if DEBUG + if (_lruCacheDict.Count - (_maxBuffers + threshold) > 0) + { + long endTime = Environment.TickCount; + _logger.Info(CultureInfo.InvariantCulture, "Garbage collector time: " + (endTime - startTime) + " ms."); + } +#endif + } + + /// + /// Atomically clears the index and returns all LRU-tracked buffers to the pool. Clears the index FIRST under the + /// caller's write lock, THEN returns buffers to pool. This prevents a race where concurrent readers find buffers + /// that have been returned to the pool. Caller must hold a write lock. + /// + public void ClearLru (LogBufferPool pool) + { + _logger.Info(CultureInfo.InvariantCulture, "Clearing LRU cache."); + + // 1. Collect buffer references before clearing + var toReturn = new List(_lruCacheDict.Count); + foreach (var entry in _lruCacheDict.Values) + { + toReturn.Add(entry.LogBuffer); + } + + // 2. Clear index FIRST — no concurrent reader can find these after this + _bufferList.Clear(); + _lruCacheDict.Clear(); + _isLineCountDirty = true; + ResetThreadLocalCache(); + + // 3. Now safe to return to pool + foreach (var entry in toReturn) + { + var lockTaken = false; + try + { + entry.AcquireContentLock(ref lockTaken); + pool.Return(entry); + } + finally + { + if (lockTaken) + { + entry.ReleaseContentLock(); + } + } + } + + _logger.Info(CultureInfo.InvariantCulture, "Clearing done."); + } + + #endregion + + /// + /// Gets the number of buffers. + /// + public int BufferCount => _bufferList.Count; + + /// + /// Returns the buffer at the specified positional index. Caller must hold at least a read lock. + /// + public LogBuffer GetBufferAt (int index) => _bufferList.GetValueAtIndex(index); + + /// + /// Returns the last buffer in the index (highest start line). Caller must hold at least a read lock. + /// + public LogBuffer GetLastBuffer () => _bufferList.GetValueAtIndex(_bufferList.Count - 1); + + /// + /// Returns an enumerable collection of all log buffers managed by the current instance. + /// + /// + /// An containing each in the collection. The + /// enumeration reflects the current state of the buffers at the time of the call. + /// + public IEnumerable EnumerateBuffers () { return [.. _bufferList.Values]; } + + /// + /// Total lines across all buffers. Recalculated on demand when dirty. Caller must hold at least a read lock. + /// + public int TotalLineCount + { + get + { + if (_isLineCountDirty) + { + var total = 0; + foreach (var buffer in _bufferList.Values) + { + total += buffer.LineCount; + } + + _cachedLineCount = total; + _isLineCountDirty = false; + } + + return _cachedLineCount; + } + } + + public void MarkLineCountDirty () => _isLineCountDirty = true; + + /// + /// Gets the number of items currently stored in the least recently used (LRU) cache. + /// + public int LruCacheCount => _lruCacheDict.Count; + + #region Lock management — using-scoped only + + public ReadLockScope AcquireReadLock () => new(_lock); + + public WriteLockScope AcquireWriteLock () => new(_lock); + + public UpgradeableReadLockScope AcquireUpgradeableReadLock () => new(_lock); + + #endregion + + #region Diagnostics + + /// + /// Creates an immutable point-in-time capture of the index state. Acquires its own read lock internally. + /// + public BufferIndexSnapshot CreateSnapshot () + { + using var _ = AcquireReadLock(); + + var buffers = new List(_bufferList.Count); + + foreach (var b in _bufferList.Values) + { + buffers.Add(new BufferIndexSnapshot.BufferInfo + ( + b.StartLine, + b.LineCount, + b.StartPos, + b.Size, + b.IsDisposed, + b.FileInfo.FullName + )); + } + + return new BufferIndexSnapshot + { + BufferCount = _bufferList.Count, + TotalLineCount = TotalLineCount, + LruCacheCount = _lruCacheDict.Count, + Buffers = buffers + }; + } + + #endregion + + #region Internal Helpers + + public void ResetThreadLocalCache () => _lastBufferIndex.Value = -1; + + private void UpdateLru (LogBuffer logBuffer) + { + var cacheEntry = _lruCacheDict.GetOrAdd( + logBuffer.StartLine, + static (_, buf) => new LogBufferCacheEntry { LogBuffer = buf }, + logBuffer); + + cacheEntry.Touch(); + } + + private static int HighestPowerOfTwo (int n) => 1 << (31 - int.LeadingZeroCount(n)); + + public void Dispose () + { + _lastBufferIndex.Dispose(); + _lock.Dispose(); + } + + #endregion +} + +#region Lock scope structs + +public readonly ref struct ReadLockScope +{ + private readonly ReaderWriterLockSlim _lock; + + public ReadLockScope (ReaderWriterLockSlim rwLock) + { + _lock = rwLock; + if (!_lock.TryEnterReadLock(TimeSpan.FromSeconds(10))) + { + //_logger.Warn("Reader lock wait timed out, forcing entry"); + _lock.EnterReadLock(); + } + } + + public void Dispose () => _lock.ExitReadLock(); + +} + +public readonly ref struct WriteLockScope +{ + private readonly ReaderWriterLockSlim _lock; + + public WriteLockScope (ReaderWriterLockSlim rwLock) + { + _lock = rwLock; + if (!_lock.TryEnterWriteLock(TimeSpan.FromSeconds(10))) + { + //_logger.Warn("Writer lock wait timed out, forcing entry"); + _lock.EnterWriteLock(); + } + } + + public void Dispose () => _lock.ExitWriteLock(); +} + +public readonly ref struct UpgradeableReadLockScope +{ + private readonly ReaderWriterLockSlim _lock; + + public UpgradeableReadLockScope (ReaderWriterLockSlim rwLock) + { + _lock = rwLock; + if (!_lock.TryEnterUpgradeableReadLock(TimeSpan.FromSeconds(10))) + { + //_logger.Warn("Upgradeable read lock timed out, forcing entry"); + _lock.EnterUpgradeableReadLock(); + } + } + + public WriteLockUpgradeScope UpgradeToWrite () => new(_lock); + + public void Dispose () => _lock.ExitUpgradeableReadLock(); +} + +public readonly ref struct WriteLockUpgradeScope +{ + private readonly ReaderWriterLockSlim _lock; + + public WriteLockUpgradeScope (ReaderWriterLockSlim rwls) + { + _lock = rwls; + if (!_lock.TryEnterWriteLock(TimeSpan.FromSeconds(10))) + { + //_logger.Warn("Writer lock upgrade timed out, forcing entry"); + _lock.EnterWriteLock(); + } + } + + public void Dispose () => _lock.ExitWriteLock(); +} + +#endregion + +public readonly struct LogBufferEntry (LogBuffer? buffer, int index, bool found) +{ + public LogBuffer? Buffer { get; } = buffer; + + public int Index { get; } = index; + + public bool Found { get; } = found; +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/Buffers/BufferIndexSnapshot.cs b/src/LogExpert.Core/Classes/Log/Buffers/BufferIndexSnapshot.cs new file mode 100644 index 00000000..5b54390b --- /dev/null +++ b/src/LogExpert.Core/Classes/Log/Buffers/BufferIndexSnapshot.cs @@ -0,0 +1,24 @@ +namespace LogExpert.Core.Classes.Log.Buffers; + +/// +/// Immutable point-in-time capture of state. +/// Taken under a single read lock, safe to inspect afterward without locks. +/// +public sealed class BufferIndexSnapshot +{ + public int BufferCount { get; init; } + public int TotalLineCount { get; init; } + public int LruCacheCount { get; init; } + public IReadOnlyList Buffers { get; init; } = []; + + public sealed record BufferInfo ( + int StartLine, + int LineCount, + long StartPos, + long Size, + bool IsDisposed, + string FileName); + + public override string ToString () => + $"Buffers={BufferCount}, Lines={TotalLineCount}, LRU={LruCacheCount}"; +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/Buffers/CharBlockAllocator.cs b/src/LogExpert.Core/Classes/Log/Buffers/CharBlockAllocator.cs new file mode 100644 index 00000000..030437a8 --- /dev/null +++ b/src/LogExpert.Core/Classes/Log/Buffers/CharBlockAllocator.cs @@ -0,0 +1,132 @@ +namespace LogExpert.Core.Classes.Log.Buffers; + +/// +/// Allocates slices from large char[] blocks. +/// Multiple lines are packed into each block to reduce per-line allocation overhead. +/// +/// +/// Blocks are plain arrays (not pooled) because their lifetime extends beyond the allocator: +/// the UI thread may hold slices long after the backing +/// is evicted. Using here would +/// cause use-after-return corruption when evicted blocks are re-rented by new reads. +/// +/// We still get the primary GC benefit: hundreds of short-lived strings from +/// are copied into a few large blocks, keeping +/// the strings Gen0-eligible and reducing Gen1/Gen2 promotions. +/// +/// This class is NOT thread-safe. Each reader/fill operation should use its own instance. +/// +public sealed class CharBlockAllocator : IDisposable +{ + private const int DEFAULT_BLOCK_SIZE = 65_536; // 128 KB in chars (64K chars × 2 bytes) + + private readonly int _blockSize; + private List _blocks = []; + private readonly List _oversizedBlocks = []; + private char[] _currentBlock; + private int _currentOffset; + private bool _disposed; + + public CharBlockAllocator (int blockSize = DEFAULT_BLOCK_SIZE) + { + _blockSize = blockSize; + _currentBlock = new char[_blockSize]; + _blocks.Add(_currentBlock); + _currentOffset = 0; + } + + /// + /// Gets the number of normal (fixed-size) blocks currently rented from the pool. + /// + public int BlockCount => _blocks.Count; + + /// + /// Gets the number of oversized (standalone) blocks currently rented from the pool. + /// Useful for diagnostics — a high count indicates pathological line lengths. + /// + public int OversizedBlockCount => _oversizedBlocks.Count; + + /// + /// Allocates a region of the specified length from the current block. + /// If the current block has insufficient space, a new block is rented. + /// Lines longer than the block size receive a standalone rental tracked separately. + /// + public Memory Rent (int length) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (length <= 0) + { + return Memory.Empty; + } + + // Oversized line: give it its own array, tracked separately + if (length > _blockSize) + { + var oversized = new char[length]; + _oversizedBlocks.Add(oversized); + return oversized.AsMemory(0, length); + } + + // Current block has space + if (_currentOffset + length <= _currentBlock.Length) + { + var memory = _currentBlock.AsMemory(_currentOffset, length); + _currentOffset += length; + return memory; + } + + // Need a new block + _currentBlock = new char[_blockSize]; + _blocks.Add(_currentBlock); + _currentOffset = length; + return _currentBlock.AsMemory(0, length); + } + + /// + /// Detaches and returns the list of all blocks (normal + oversized). After this call, + /// the allocator no longer owns those blocks — the caller (LogBuffer) holds them + /// until GC collects them after all slices are released. + /// + public List DetachBlocks () + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Merge oversized blocks into the main list so the caller owns everything + if (_oversizedBlocks.Count > 0) + { + _blocks.AddRange(_oversizedBlocks); + _oversizedBlocks.Clear(); + } + + // Swap the list — O(1), no copy. Caller owns the old list. + var blocks = _blocks; + _currentBlock = new char[_blockSize]; + _blocks = [_currentBlock]; + _currentOffset = 0; + return blocks; + } + + /// + /// Releases all block references. The actual char[] memory is collected by GC + /// once all slices pointing into them are released. + /// + public void ReturnAll () + { + _blocks.Clear(); + _oversizedBlocks.Clear(); + _currentBlock = null!; + _currentOffset = 0; + } + + public void Dispose () + { + if (_disposed) + { + return; + } + + ReturnAll(); + _disposed = true; + } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/LogBuffer.cs b/src/LogExpert.Core/Classes/Log/Buffers/LogBuffer.cs similarity index 82% rename from src/LogExpert.Core/Classes/Log/LogBuffer.cs rename to src/LogExpert.Core/Classes/Log/Buffers/LogBuffer.cs index a240720b..f600b0b9 100644 --- a/src/LogExpert.Core/Classes/Log/LogBuffer.cs +++ b/src/LogExpert.Core/Classes/Log/Buffers/LogBuffer.cs @@ -4,7 +4,7 @@ using NLog; -namespace LogExpert.Core.Classes.Log; +namespace LogExpert.Core.Classes.Log.Buffers; public class LogBuffer { @@ -19,6 +19,7 @@ public class LogBuffer private LogLine[] _lineArray; private int _lineArrayLength; // capacity of the rented array + private List _charBlocks; private int MAX_LINES = 500; @@ -113,6 +114,8 @@ public void ClearLines () Array.Clear(_lineArray, 0, LineCount); } + ReturnCharBlocks(); + LineCount = 0; #if DEBUG _filePositions.Clear(); @@ -124,6 +127,8 @@ public void ClearLines () /// public void Reinitialise (ILogFileInfo fileInfo, int maxLines) { + ReturnCharBlocks(); + FileInfo = fileInfo; MAX_LINES = maxLines; StartLine = 0; @@ -154,8 +159,10 @@ public void EvictContent () _lineArray = null; } - // Do NOT zero LineCount — it is needed for buffer lookup in GetBufferForLineWithIndex. - // Do NOT zero StartLine, StartPos, Size — they are needed for re-reading from disk. + ReturnCharBlocks(); + + //! Do NOT zero LineCount — it is needed for buffer lookup in GetBufferForLineWithIndex. + //! Do NOT zero StartLine, StartPos, Size — they are needed for re-reading from disk. IsDisposed = true; #if DEBUG DisposeCount++; @@ -176,6 +183,8 @@ public void DisposeContent () LineCount = 0; } + ReturnCharBlocks(); + IsDisposed = true; #if DEBUG DisposeCount++; @@ -205,6 +214,16 @@ public void ReleaseContentLock () _contentLock.Exit(useMemoryBarrier: false); } + /// + /// Attaches pooled char[] blocks that back the ReadOnlyMemory in this buffer's LogLine entries. + /// These blocks will be returned to ArrayPool when the buffer is evicted or disposed. + /// + public void AttachCharBlocks (List blocks) + { + ReturnCharBlocks(); // return any previously held blocks + _charBlocks = blocks; + } + #endregion #if DEBUG @@ -218,4 +237,16 @@ public long GetFilePosForLineOfBlock (int line) } #endif + + #region Private Methods + + private void ReturnCharBlocks () + { + // Just drop the reference — do NOT return to ArrayPool. + // The UI thread may still hold ReadOnlyMemory slices into these blocks. + // GC will collect them once all references (LogLine, UI snapshots) are released. + _charBlocks = null; + } + + #endregion } \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/LogBufferCacheEntry.cs b/src/LogExpert.Core/Classes/Log/Buffers/LogBufferCacheEntry.cs similarity index 91% rename from src/LogExpert.Core/Classes/Log/LogBufferCacheEntry.cs rename to src/LogExpert.Core/Classes/Log/Buffers/LogBufferCacheEntry.cs index b983cfed..7ce51e47 100644 --- a/src/LogExpert.Core/Classes/Log/LogBufferCacheEntry.cs +++ b/src/LogExpert.Core/Classes/Log/Buffers/LogBufferCacheEntry.cs @@ -1,4 +1,4 @@ -namespace LogExpert.Core.Classes.Log; +namespace LogExpert.Core.Classes.Log.Buffers; public class LogBufferCacheEntry { diff --git a/src/LogExpert.Core/Classes/Log/LogBufferPool.cs b/src/LogExpert.Core/Classes/Log/Buffers/LogBufferPool.cs similarity index 95% rename from src/LogExpert.Core/Classes/Log/LogBufferPool.cs rename to src/LogExpert.Core/Classes/Log/Buffers/LogBufferPool.cs index b7bbed59..fb825902 100644 --- a/src/LogExpert.Core/Classes/Log/LogBufferPool.cs +++ b/src/LogExpert.Core/Classes/Log/Buffers/LogBufferPool.cs @@ -2,7 +2,7 @@ using ColumnizerLib; -namespace LogExpert.Core.Classes.Log; +namespace LogExpert.Core.Classes.Log.Buffers; public sealed class LogBufferPool (int maxSize) { diff --git a/src/LogExpert.Core/Classes/Log/CastingPipelineBuilder.cs b/src/LogExpert.Core/Classes/Log/CastingPipelineBuilder.cs new file mode 100644 index 00000000..e0a92007 --- /dev/null +++ b/src/LogExpert.Core/Classes/Log/CastingPipelineBuilder.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; + +using LogExpert.Core.Interfaces; + +namespace LogExpert.Core.Classes.Log; + +public class CastingPipelineBuilder : IPipeline +{ + private readonly List> _pipelineSteps = []; + private BlockingCollection[] _buffers; + + + public event Action Finished; + + public void AddStep (Func stepFunc) + { + _pipelineSteps.Add(stepFunc); + } + + public void Execute (object input) + { + BlockingCollection first = _buffers[0]; + first.Add(input); + } + + public void Complete () + { + if (_buffers.Length > 0) + { + _buffers[0].CompleteAdding(); + } + } + + public IPipeline GetPipeline () + { + //Create Buffers + _buffers = _pipelineSteps.Select(step => new BlockingCollection()).ToArray(); + + int bufferIndex = 0; + foreach (Func pipelineStep in _pipelineSteps) + { + var bufferIndexLocal = bufferIndex; //with this remains same in each thread + _ = Task.Run(() => + { + //GetConsuminEnumerable => is blocking when the collection is empty + foreach (object input in _buffers[bufferIndexLocal].GetConsumingEnumerable().Select(pipelineStep)) + { + bool isLastStep = bufferIndexLocal == _pipelineSteps.Count - 1; + if (isLastStep) + { + //BeginInvoke would be better https://stackoverflow.com/questions/1916095/how-do-i-make-an-eventhandler-run-asynchronously/16336361#16336361 + Finished?.Invoke(input); + } + else + { + BlockingCollection next = _buffers[bufferIndexLocal + 1]; + next.Add(input); + } + } + }); + + bufferIndex++; + } + + return this; + } +} diff --git a/src/LogExpert.Core/Classes/Log/LogfileReader.cs b/src/LogExpert.Core/Classes/Log/LogfileReader.cs index bad4bfdf..35bce3f5 100644 --- a/src/LogExpert.Core/Classes/Log/LogfileReader.cs +++ b/src/LogExpert.Core/Classes/Log/LogfileReader.cs @@ -1,9 +1,11 @@ -using System.Collections.Concurrent; using System.Globalization; using System.Text; using ColumnizerLib; +using LogExpert.Core.Classes.Log.Buffers; +using LogExpert.Core.Classes.Log.ProgressReporters; +using LogExpert.Core.Classes.Log.Streamreaders; using LogExpert.Core.Classes.xml; using LogExpert.Core.Entities; using LogExpert.Core.Enums; @@ -32,17 +34,14 @@ public partial class LogfileReader : IAutoLogLineMemoryColumnizerCallback, IDisp private readonly LogBufferPool _bufferPool; private readonly Lock _logBufferLock = new(); - private readonly ReaderWriterLockSlim _bufferListLock = new(LockRecursionPolicy.SupportsRecursion); + + private readonly ILoadProgressReporter _progressReporter; private readonly MemoryMappedFileReader _mmfReader; - private const int PROGRESS_UPDATE_INTERVAL_MS = 100; private const int WAIT_TIME = 1000; - private SortedList _bufferList; - private bool _contentDeleted; - private long _lastProgressUpdate; private long _fileLength; private Task _garbageCollectorTask; @@ -50,36 +49,81 @@ public partial class LogfileReader : IAutoLogLineMemoryColumnizerCallback, IDisp private bool _isDeleted; private IList _logFileInfoList = []; - private ConcurrentDictionary _lruCacheDict; private bool _shouldStop; private bool _disposed; private ILogFileInfo _watchedILogFileInfo; - private volatile bool _isLineCountDirty = true; - private volatile bool _isFailModeCheckCallPending; private volatile bool _isFastFailOnGetLogLine; - private readonly ThreadLocal _lastBufferIndex = new(() => -1); #endregion #region cTor /// Public constructor for single file. - public LogfileReader (string fileName, EncodingOptions encodingOptions, bool multiFile, int bufferCount, int linesPerBuffer, MultiFileOptions multiFileOptions, ReaderType readerType, IPluginRegistry pluginRegistry, int maximumLineLength) - : this([fileName], encodingOptions, multiFile, bufferCount, linesPerBuffer, multiFileOptions, readerType, pluginRegistry, maximumLineLength) + public LogfileReader ( + string fileName, + EncodingOptions encodingOptions, + bool multiFile, + int bufferCount, + int linesPerBuffer, + MultiFileOptions multiFileOptions, + ReaderType readerType, + IPluginRegistry pluginRegistry, + int maximumLineLength, + ILoadProgressReporter? progressReporter = null) + : this( + [fileName], + encodingOptions, + multiFile, + bufferCount, + linesPerBuffer, + multiFileOptions, + readerType, + pluginRegistry, + maximumLineLength, + progressReporter) { } /// Public constructor for multiple files. - public LogfileReader (string[] fileNames, EncodingOptions encodingOptions, int bufferCount, int linesPerBuffer, MultiFileOptions multiFileOptions, ReaderType readerType, IPluginRegistry pluginRegistry, int maximumLineLength) - : this(fileNames, encodingOptions, true, bufferCount, linesPerBuffer, multiFileOptions, readerType, pluginRegistry, maximumLineLength) + public LogfileReader ( + string[] fileNames, + EncodingOptions encodingOptions, + int bufferCount, + int linesPerBuffer, + MultiFileOptions multiFileOptions, + ReaderType readerType, + IPluginRegistry pluginRegistry, + int maximumLineLength, + ILoadProgressReporter? progressReporter = null) + : this( + fileNames, + encodingOptions, + true, + bufferCount, + linesPerBuffer, + multiFileOptions, + readerType, + pluginRegistry, + maximumLineLength, + progressReporter) { // In this overload, we assume multiFile is always true. } // Single private constructor that contains the common initialization logic. - private LogfileReader (string[] fileNames, EncodingOptions encodingOptions, bool multiFile, int bufferCount, int linesPerBuffer, MultiFileOptions multiFileOptions, ReaderType readerType, IPluginRegistry pluginRegistry, int maximumLineLength) + private LogfileReader ( + string[] fileNames, + EncodingOptions encodingOptions, + bool multiFile, + int bufferCount, + int linesPerBuffer, + MultiFileOptions multiFileOptions, + ReaderType readerType, + IPluginRegistry pluginRegistry, + int maximumLineLength, + ILoadProgressReporter? progressReporter = null) { // Validate input: at least one file must be provided. if (fileNames == null || fileNames.Length < 1) @@ -104,7 +148,7 @@ private LogfileReader (string[] fileNames, EncodingOptions encodingOptions, bool _bufferPool = new LogBufferPool(_max_buffers * 2); - InitLruBuffers(); + BufferIndex = new BufferIndex(_max_buffers, _maxLinesPerBuffer); ILogFileInfo fileInfo = null; @@ -116,6 +160,19 @@ private LogfileReader (string[] fileNames, EncodingOptions encodingOptions, bool ? new RolloverFilenameHandler(GetLogFileInfo(_fileName), _multiFileOptions).GetNameList(_pluginRegistry) : [_fileName]; + if (progressReporter != null) + { + _progressReporter = progressReporter; + } + else + { + var reporter = new PeriodicProgressReporter(); + reporter.LoadFile += (_, e) => OnLoadFile(e); + reporter.LoadingStarted += (_, e) => OnLoadingStarted(e); + reporter.LoadingFinished += (_, _) => OnLoadingFinished(); + _progressReporter = reporter; + } + foreach (var name in names) { fileInfo = AddFile(name); @@ -159,6 +216,9 @@ private LogfileReader (string[] fileNames, EncodingOptions encodingOptions, bool #region Properties + /// For tests and diagnostics. + internal BufferIndex BufferIndex { get; } + /// /// Gets the total number of lines contained in all buffers. /// @@ -170,39 +230,18 @@ public int LineCount { get { - if (_isLineCountDirty) - { - field = 0; - if (_bufferListLock.IsReadLockHeld || _bufferListLock.IsWriteLockHeld) - { - foreach (var buffer in _bufferList.Values) - { - field += buffer.LineCount; - } - } - else - { - AcquireBufferListReaderLock(); - try - { - foreach (var buffer in _bufferList.Values) - { - field += buffer.LineCount; - } - } - finally - { - ReleaseBufferListReaderLock(); - } - } + using var _ = BufferIndex.AcquireReadLock(); + return BufferIndex.TotalLineCount; + } - _isLineCountDirty = false; + private set + { + // Only used for resetting to 0. The actual count is computed by BufferIndex. + if (value == 0) + { + BufferIndex.MarkLineCountDirty(); } - - return field; } - - private set; } /// @@ -270,17 +309,15 @@ private EncodingOptions EncodingOptions //TODO: Make this private public void ReadFiles () { - _lastProgressUpdate = 0; FileSize = 0; - LineCount = 0; _isDeleted = false; - ClearLru(); - AcquireBufferListWriterLock(); - _bufferList.Clear(); - ReleaseBufferListWriterLock(); + try { + using var _ = BufferIndex.AcquireWriteLock(); + BufferIndex.ClearLru(_bufferPool); + foreach (var info in _logFileInfoList) { ReadToBufferList(info, 0, LineCount); @@ -330,21 +367,20 @@ public int ShiftBuffers () { _logger.Info(CultureInfo.InvariantCulture, "ShiftBuffers() begin for {0}{1}", _fileName, IsMultiFile ? " (MultiFile)" : ""); - AcquireBufferListWriterLock(); - - try + using var writeLock = BufferIndex.AcquireWriteLock(); { - ClearBufferState(); + BufferIndex.ResetThreadLocalCache(); var offset = 0; - _isLineCountDirty = true; + BufferIndex.MarkLineCountDirty(); lock (_monitor) { RolloverFilenameHandler rolloverHandler = new(_watchedILogFileInfo, _multiFileOptions); var fileNameList = rolloverHandler.GetNameList(_pluginRegistry); - ResetBufferCache(); + FileSize = 0; + LineCount = 0; IList lostILogFileInfoList = []; IList readNewILogFileInfoList = []; @@ -425,16 +461,16 @@ public int ShiftBuffers () } } - _logger.Info(CultureInfo.InvariantCulture, "Adjusting StartLine values in {0} buffers by offset {1}", _bufferList.Count, offset); - foreach (var buffer in _bufferList.Values.ToList()) + _logger.Info(CultureInfo.InvariantCulture, "Adjusting StartLine values in {0} buffers by offset {1}", BufferIndex.BufferCount, offset); + foreach (var buffer in BufferIndex.EnumerateBuffers()) { - SetNewStartLineForBuffer(buffer, buffer.StartLine - offset); + BufferIndex.UpdateStartLine(buffer, buffer.StartLine - offset); } #if DEBUG - if (_bufferList.Values.Count > 0) + if (BufferIndex.BufferCount > 0) { - _logger.Debug(CultureInfo.InvariantCulture, "First buffer now has StartLine {0}", _bufferList.Values[0].StartLine); + _logger.Debug(CultureInfo.InvariantCulture, "First buffer now has StartLine {0}", BufferIndex.GetBufferAt(0).StartLine); } #endif } @@ -471,68 +507,6 @@ public int ShiftBuffers () return offset; } - finally - { - ReleaseBufferListWriterLock(); - } - } - - /// - /// Acquires a read lock on the buffer list, waiting up to 10 seconds before forcing entry if the lock is not - /// immediately available. - /// - /// - /// If the read lock cannot be acquired within 10 seconds, the method will forcibly enter the lock and log a - /// warning. Callers should ensure that holding the read lock for extended periods does not block other operations. - /// - private void AcquireBufferListReaderLock () - { - if (!_bufferListLock.TryEnterReadLock(TimeSpan.FromSeconds(10))) - { - _logger.Warn("Reader lock wait timed out, forcing entry"); - _bufferListLock.EnterReadLock(); - } - } - - /// - /// Releases the reader lock on the buffer list, allowing other threads to acquire write access. - /// - /// - /// Call this method after completing operations that require read access to the buffer list. Failing to release the - /// reader lock may result in deadlocks or prevent other threads from obtaining write access. - /// - private void ReleaseBufferListReaderLock () - { - _bufferListLock.ExitReadLock(); - } - - /// - /// Releases the writer lock on the buffer list, allowing other threads to acquire the lock. - /// - /// - /// Call this method after completing operations that required exclusive access to the buffer list. Failing to - /// release the writer lock may result in deadlocks or reduced concurrency. - /// - private void ReleaseBufferListWriterLock () - { - _bufferListLock.ExitWriteLock(); - } - - /// - /// Acquires the writer lock for the buffer list, blocking the calling thread until the lock is obtained. - /// - /// - /// If the writer lock cannot be acquired within 10 seconds, a warning is logged and the method will continue to - /// wait until the lock becomes available. This method should be used to ensure exclusive access to the buffer list - /// when performing write operations. - /// - private void AcquireBufferListWriterLock () - { - if (!_bufferListLock.TryEnterWriteLock(TimeSpan.FromSeconds(10))) - { - _logger.Warn("Writer lock wait timed out"); - _bufferListLock.EnterWriteLock(); - } } //TODO Make Task Based @@ -565,15 +539,11 @@ public async Task GetLogLineMemoryWithWait (int lineNum) { // Fast path: if the buffer is in memory, skip the thread-pool hop entirely bool canFastPath = false; - AcquireBufferListReaderLock(); - try - { - var (logBuffer, _) = GetBufferForLineWithIndex(lineNum); - canFastPath = logBuffer is { IsDisposed: false }; - } - finally + + using (BufferIndex.AcquireReadLock()) { - ReleaseBufferListReaderLock(); + var logBufferEntry = BufferIndex.GetBufferForLineWithIndex(lineNum); + canFastPath = logBufferEntry.Buffer is { IsDisposed: false }; } if (canFastPath) @@ -618,8 +588,9 @@ public async Task GetLogLineMemoryWithWait (int lineNum) /// public string GetLogFileNameForLine (int lineNum) { - var logBuffer = GetBufferForLine(lineNum); - return logBuffer?.FileInfo.FullName; + using var _ = BufferIndex.AcquireReadLock(); + var logBufferEntry = BufferIndex.TryFindBuffer(lineNum); + return logBufferEntry.Buffer?.FileInfo.FullName; } /// @@ -629,8 +600,9 @@ public string GetLogFileNameForLine (int lineNum) /// public ILogFileInfo GetLogFileInfoForLine (int lineNum) { - var logBuffer = GetBufferForLine(lineNum); - return logBuffer?.FileInfo; + using var _ = BufferIndex.AcquireReadLock(); + var logBufferEntry = BufferIndex.TryFindBuffer(lineNum); + return logBufferEntry.Buffer?.FileInfo; } /// @@ -640,30 +612,9 @@ public ILogFileInfo GetLogFileInfoForLine (int lineNum) /// public int GetNextMultiFileLine (int lineNum) { - var result = -1; - AcquireBufferListReaderLock(); - - try - { - var (logBuffer, index) = GetBufferForLineWithIndex(lineNum); - if (logBuffer != null && index != -1) - { - for (var i = index; i < _bufferList.Values.Count; ++i) - { - if (_bufferList.Values[i].FileInfo != logBuffer.FileInfo) - { - result = _bufferList.Values[i].StartLine; - break; - } - } - } - } - finally - { - ReleaseBufferListReaderLock(); - } - - return result; + using var _ = BufferIndex.AcquireReadLock(); + var (found, startLine) = BufferIndex.TryGetNextFileStartLine(lineNum); + return found ? startLine : -1; } /// @@ -681,30 +632,9 @@ public int GetNextMultiFileLine (int lineNum) /// The starting line number of the previous file segment if one exists; otherwise, -1. public int GetPrevMultiFileLine (int lineNum) { - var result = -1; - AcquireBufferListReaderLock(); - - try - { - var (logBuffer, index) = GetBufferForLineWithIndex(lineNum); - if (logBuffer != null && index != -1) - { - for (var i = index; i >= 0; --i) - { - if (_bufferList.Values[i].FileInfo != logBuffer.FileInfo) - { - result = _bufferList.Values[i].StartLine + _bufferList.Values[i].LineCount; - break; - } - } - } - } - finally - { - ReleaseBufferListReaderLock(); - } - - return result; + using var _ = BufferIndex.AcquireReadLock(); + var (found, startLine) = BufferIndex.TryGetPrevFileStartLine(lineNum); + return found ? startLine : -1; } /// @@ -718,26 +648,15 @@ public int GetPrevMultiFileLine (int lineNum) /// public int GetRealLineNumForVirtualLineNum (int lineNum) { - var result = -1; - AcquireBufferListReaderLock(); - try - { - var (logBuffer, index) = GetBufferForLineWithIndex(lineNum); - if (logBuffer != null) - { - logBuffer = GetFirstBufferForFileByLogBuffer(logBuffer, index); - if (logBuffer != null) - { - result = lineNum - logBuffer.StartLine; - } - } - } - finally + using var _ = BufferIndex.AcquireReadLock(); + var logBufferEntry = BufferIndex.GetBufferForLineWithIndex(lineNum); + if (!logBufferEntry.Found) { - ReleaseBufferListReaderLock(); + return logBufferEntry.Index; } - return result; + var buffer = BufferIndex.GetFirstBufferForFile(logBufferEntry.Buffer, logBufferEntry.Index); + return buffer != null ? lineNum - buffer.StartLine : -1; } /// @@ -807,11 +726,11 @@ public void DeleteAllContent () } _logger.Info(CultureInfo.InvariantCulture, "Deleting all log buffers for {0}. Used mem: {1:N0}", Util.GetNameFromPath(_fileName), GC.GetTotalMemory(false)); - AcquireBufferListWriterLock(); - ClearBufferState(); - //AcquireDisposeWriterLock(); - foreach (var logBuffer in _bufferList.Values) + using var _ = BufferIndex.AcquireWriteLock(); + BufferIndex.ResetThreadLocalCache(); + + foreach (var logBuffer in BufferIndex.EnumerateBuffers()) { if (!logBuffer.IsDisposed) { @@ -819,23 +738,11 @@ public void DeleteAllContent () } } - _lruCacheDict.Clear(); - _bufferList.Clear(); - - //ReleaseDisposeWriterLock(); - ReleaseBufferListWriterLock(); + BufferIndex.Clear(); _contentDeleted = true; _logger.Info(CultureInfo.InvariantCulture, "Deleting complete. Used mem: {0:N0}", GC.GetTotalMemory(false)); } - /// - /// Clears the Buffer so that no stale buffer references are kept - /// - private void ClearBufferState () - { - _lastBufferIndex.Value = -1; - } - /// /// Explicit change the encoding. /// @@ -844,8 +751,9 @@ public void ChangeEncoding (Encoding encoding) { CurrentEncoding = encoding; EncodingOptions.Encoding = encoding; - ResetBufferCache(); - ClearLru(); + FileSize = 0; + using var _ = BufferIndex.AcquireWriteLock(); + BufferIndex.ClearLru(_bufferPool); } /// @@ -857,15 +765,6 @@ public IList GetLogFileInfoList () return _logFileInfoList; } - /// - /// For unit tests only - /// - /// - public IList GetBufferList () - { - return _bufferList.Values; - } - #endregion #region Internals @@ -882,12 +781,10 @@ public IList GetBufferList () /// The zero-based line number for which buffer information is logged. public void LogBufferInfoForLine (int lineNum) { - AcquireBufferListReaderLock(); - - try + using var readLock = BufferIndex.AcquireReadLock(); { - var (buffer, _) = GetBufferForLineWithIndex(lineNum); - if (buffer == null) + var logBufferEntry = BufferIndex.GetBufferForLineWithIndex(lineNum); + if (!logBufferEntry.Found) { _logger.Error("Cannot find buffer for line {0}, file: {1}{2}", lineNum, _fileName, IsMultiFile ? " (MultiFile)" : ""); return; @@ -895,14 +792,10 @@ public void LogBufferInfoForLine (int lineNum) _logger.Info(CultureInfo.InvariantCulture, "-----------------------------------"); _logger.Info(CultureInfo.InvariantCulture, "Buffer info for line {0}", lineNum); - DumpBufferInfos(buffer); - _logger.Info(CultureInfo.InvariantCulture, "File pos for current line: {0}", buffer.GetFilePosForLineOfBlock(lineNum - buffer.StartLine)); + DumpBufferInfos(logBufferEntry.Buffer); + _logger.Info(CultureInfo.InvariantCulture, "File pos for current line: {0}", logBufferEntry.Buffer.GetFilePosForLineOfBlock(lineNum - logBufferEntry.Buffer.StartLine)); _logger.Info(CultureInfo.InvariantCulture, "-----------------------------------"); } - finally - { - ReleaseBufferListReaderLock(); - } } /// @@ -916,34 +809,35 @@ public void LogBufferInfoForLine (int lineNum) public void LogBufferDiagnostic () { _logger.Info(CultureInfo.InvariantCulture, "-------- Buffer diagnostics -------"); - var cacheCount = _lruCacheDict.Count; + var cacheCount = BufferIndex.LruCacheCount; _logger.Info(CultureInfo.InvariantCulture, "LRU entries: {0}", cacheCount); - AcquireBufferListReaderLock(); - _logger.Info(CultureInfo.InvariantCulture, "File: {0}\r\nBuffer count: {1}\r\nDisposed buffers: {2}", _fileName, _bufferList.Count, _bufferList.Count - cacheCount); - var lineNum = 0; - long disposeSum = 0; - long maxDispose = 0; - long minDispose = int.MaxValue; - - for (var i = 0; i < _bufferList.Values.Count; ++i) + using var readLock = BufferIndex.AcquireReadLock(); { - var buffer = _bufferList.Values[i]; - if (buffer.StartLine != lineNum) + _logger.Info(CultureInfo.InvariantCulture, "File: {0}\r\nBuffer count: {1}\r\nDisposed buffers: {2}", _fileName, BufferIndex.BufferCount, BufferIndex.BufferCount - cacheCount); + var lineNum = 0; + long disposeSum = 0; + long maxDispose = 0; + long minDispose = int.MaxValue; + + for (var i = 0; i < BufferIndex.BufferCount; ++i) { - _logger.Error("Start line of buffer is: {0}, expected: {1}", buffer.StartLine, lineNum); - _logger.Info(CultureInfo.InvariantCulture, "Info of buffer follows:"); - DumpBufferInfos(buffer); + var buffer = BufferIndex.GetBufferAt(i); + if (buffer.StartLine != lineNum) + { + _logger.Error("Start line of buffer is: {0}, expected: {1}", buffer.StartLine, lineNum); + _logger.Info(CultureInfo.InvariantCulture, "Info of buffer follows:"); + DumpBufferInfos(buffer); + } + + lineNum += buffer.LineCount; + disposeSum += buffer.DisposeCount; + maxDispose = Math.Max(maxDispose, buffer.DisposeCount); + minDispose = Math.Min(minDispose, buffer.DisposeCount); } - lineNum += buffer.LineCount; - disposeSum += buffer.DisposeCount; - maxDispose = Math.Max(maxDispose, buffer.DisposeCount); - minDispose = Math.Min(minDispose, buffer.DisposeCount); + _logger.Info(CultureInfo.InvariantCulture, "Dispose count sum is: {0}\r\nMin dispose count is: {1}\r\nMax dispose count is: {2}\r\n-----------------------------------", disposeSum, minDispose, maxDispose); } - - ReleaseBufferListReaderLock(); - _logger.Info(CultureInfo.InvariantCulture, "Dispose count sum is: {0}\r\nMin dispose count is: {1}\r\nMax dispose count is: {2}\r\n-----------------------------------", disposeSum, minDispose, maxDispose); } #endif @@ -985,14 +879,13 @@ public ILogLineMemory[] GetLogLineMemories (int startLine, int count) var result = new ILogLineMemory[count]; var filled = 0; - AcquireBufferListReaderLock(); - try + using var readLock = BufferIndex.AcquireReadLock(); { var lineNum = startLine; while (filled < count) { - var (logBuffer, _) = GetBufferForLineWithIndex(lineNum); - if (logBuffer == null) + var logBufferEntry = BufferIndex.GetBufferForLineWithIndex(lineNum); + if (!logBufferEntry.Found) { break; } @@ -1001,24 +894,24 @@ public ILogLineMemory[] GetLogLineMemories (int startLine, int count) var lockTaken = false; try { - logBuffer.AcquireContentLock(ref lockTaken); + logBufferEntry.Buffer.AcquireContentLock(ref lockTaken); - if (logBuffer.IsDisposed) + if (logBufferEntry.Buffer.IsDisposed) { - lock (logBuffer.FileInfo) + lock (logBufferEntry.Buffer.FileInfo) { - ReReadBuffer(logBuffer); + ReReadBuffer(logBufferEntry.Buffer); } } // Copy lines from this buffer - var bufferOffset = lineNum - logBuffer.StartLine; - var availableInBuffer = logBuffer.LineCount - bufferOffset; + var bufferOffset = lineNum - logBufferEntry.Buffer.StartLine; + var availableInBuffer = logBufferEntry.Buffer.LineCount - bufferOffset; var toCopy = Math.Min(count - filled, availableInBuffer); for (var i = 0; i < toCopy; i++) { - result[filled + i] = logBuffer.GetLineMemoryOfBlock(bufferOffset + i); + result[filled + i] = logBufferEntry.Buffer.GetLineMemoryOfBlock(bufferOffset + i); } filled += toCopy; @@ -1028,15 +921,11 @@ public ILogLineMemory[] GetLogLineMemories (int startLine, int count) { if (lockTaken) { - logBuffer.ReleaseContentLock(); + logBufferEntry.Buffer.ReleaseContentLock(); } } } } - finally - { - ReleaseBufferListReaderLock(); - } // Trim if we got fewer lines than requested if (filled < count) @@ -1071,11 +960,10 @@ private ValueTask GetLogLineMemoryInternal (int lineNum) return new ValueTask(line); } - AcquireBufferListReaderLock(); - try + using var readLock = BufferIndex.AcquireReadLock(); { - var (logBuffer, _) = GetBufferForLineWithIndex(lineNum); - if (logBuffer == null) + var logBufferEntry = BufferIndex.GetBufferForLineWithIndex(lineNum); + if (!logBufferEntry.Found) { _logger.Error("Cannot find buffer for line {0}, file: {1}{2}", lineNum, _fileName, IsMultiFile ? " (MultiFile)" : ""); return default; @@ -1084,17 +972,17 @@ private ValueTask GetLogLineMemoryInternal (int lineNum) var lockTaken = false; try { - logBuffer.AcquireContentLock(ref lockTaken); + logBufferEntry.Buffer.AcquireContentLock(ref lockTaken); - if (logBuffer.IsDisposed) + if (logBufferEntry.Buffer.IsDisposed) { - lock (logBuffer.FileInfo) + lock (logBufferEntry.Buffer.FileInfo) { - ReReadBuffer(logBuffer); + ReReadBuffer(logBufferEntry.Buffer); } } - var line = logBuffer.GetLineMemoryOfBlock(lineNum - logBuffer.StartLine); + var line = logBufferEntry.Buffer.GetLineMemoryOfBlock(lineNum - logBufferEntry.Buffer.StartLine); return line.HasValue ? new ValueTask(line.Value) : default; @@ -1103,28 +991,10 @@ private ValueTask GetLogLineMemoryInternal (int lineNum) { if (lockTaken) { - logBuffer.ReleaseContentLock(); + logBufferEntry.Buffer.ReleaseContentLock(); } } } - finally - { - ReleaseBufferListReaderLock(); - } - } - - /// - /// Initializes the internal data structures used for least recently used (LRU) buffer management. - /// - /// - /// Call this method to reset or prepare the LRU buffer cache before use. This method clears any existing buffer - /// state and sets up the cache to track buffer usage according to the configured maximum buffer count. - /// - private void InitLruBuffers () - { - ClearBufferState(); - _bufferList = []; - _lruCacheDict = new ConcurrentDictionary(concurrencyLevel: Environment.ProcessorCount, capacity: _max_buffers + 1); } /// @@ -1140,19 +1010,6 @@ private void StartGCThread () _garbageCollectorTask = Task.Run(GarbageCollectorThreadProc, _cts.Token); } - /// - /// Resets the internal buffer cache, clearing any stored file size and line count information. - /// - /// - /// Call this method to reinitialize the buffer cache state, typically before reloading or reprocessing file data. - /// After calling this method, any previously cached file size or line count values will be lost. - /// - private void ResetBufferCache () - { - FileSize = 0; - LineCount = 0; - } - /// /// Releases resources associated with open log files and resets related state information. /// @@ -1195,7 +1052,7 @@ private ILogFileInfo GetLogFileInfo (string fileNameOrUri) //TODO: I changed to private void ReplaceBufferInfos (ILogFileInfo oldLogFileInfo, ILogFileInfo newLogFileInfo) { _logger.Debug(CultureInfo.InvariantCulture, "ReplaceBufferInfos() " + oldLogFileInfo.FullName + " -> " + newLogFileInfo.FullName); - foreach (var buffer in _bufferList.Values) + foreach (var buffer in BufferIndex.EnumerateBuffers()) { if (buffer.FileInfo == oldLogFileInfo) { @@ -1223,13 +1080,13 @@ private void ReplaceBufferInfos (ILogFileInfo oldLogFileInfo, ILogFileInfo newLo private (int StartLine, int LineCount)? DeleteBuffersForInfo (ILogFileInfo iLogFileInfo, bool matchNamesOnly) { _logger.Info($"Deleting buffers for file {iLogFileInfo.FullName}"); - ClearBufferState(); + BufferIndex.ResetThreadLocalCache(); (int StartLine, int LineCount)? lastRemovedInfo = null; IList deleteList = []; if (matchNamesOnly) { - foreach (var buffer in _bufferList.Values) + foreach (var buffer in BufferIndex.EnumerateBuffers()) { if (buffer.FileInfo.FullName.Equals(iLogFileInfo.FullName, StringComparison.Ordinal)) { @@ -1240,7 +1097,7 @@ private void ReplaceBufferInfos (ILogFileInfo oldLogFileInfo, ILogFileInfo newLo } else { - foreach (var buffer in _bufferList.Values) + foreach (var buffer in BufferIndex.EnumerateBuffers()) { if (buffer.FileInfo == iLogFileInfo) { @@ -1252,7 +1109,7 @@ private void ReplaceBufferInfos (ILogFileInfo oldLogFileInfo, ILogFileInfo newLo foreach (var buffer in deleteList) { - RemoveFromBufferList(buffer); + _ = BufferIndex.Remove(buffer); var lockTaken = false; try @@ -1281,22 +1138,6 @@ private void ReplaceBufferInfos (ILogFileInfo oldLogFileInfo, ILogFileInfo newLo return lastRemovedInfo; } - /// - /// Removes the specified log buffer from the internal buffer list and associated cache. The caller must have - /// _writer locks for lruCache and buffer list! - /// - /// - /// This method must be called only when the appropriate write locks for both the LRU cache and buffer list are - /// held. Removing a buffer that is not present has no effect. - /// - /// The log buffer to remove from the buffer list and cache. Must not be null. - private void RemoveFromBufferList (LogBuffer buffer) - { - Util.AssertTrue(_bufferListLock.IsWriteLockHeld, "No _writer lock for buffer list"); - _ = _lruCacheDict.TryRemove(buffer.StartLine, out _); - _ = _bufferList.Remove(buffer.StartLine); - } - /// /// Reads log lines from the specified log file starting at the given file position and line number, and populates /// the internal buffer list with the read data. @@ -1326,30 +1167,22 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start var lineNum = startLine; LogBuffer logBuffer; - AcquireBufferListUpgradeableReadLock(); - - try + using var upgradeabelLock = BufferIndex.AcquireUpgradeableReadLock(); { - if (_bufferList.Count == 0) + if (BufferIndex.BufferCount == 0) { logBuffer = _bufferPool.Rent(logFileInfo, _maxLinesPerBuffer); logBuffer.StartLine = startLine; logBuffer.StartPos = filePos; - UpgradeBufferlistLockToWriterLock(); - - try + using (upgradeabelLock.UpgradeToWrite()) { - AddBufferToList(logBuffer); - } - finally - { - DowngradeBufferListLockFromWriterLock(); + BufferIndex.Add(logBuffer); } } else { - logBuffer = _bufferList.Values[_bufferList.Count - 1]; + logBuffer = BufferIndex.GetLastBuffer(); if (!logBuffer.FileInfo.FullName.Equals(logFileInfo.FullName, StringComparison.Ordinal)) { @@ -1357,15 +1190,9 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start logBuffer.StartLine = startLine; logBuffer.StartPos = filePos; - UpgradeBufferlistLockToWriterLock(); - - try + using (upgradeabelLock.UpgradeToWrite()) { - AddBufferToList(logBuffer); - } - finally - { - DowngradeBufferListLockFromWriterLock(); + BufferIndex.Add(logBuffer); } } @@ -1388,10 +1215,6 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start } } } - finally - { - ReleaseBufferListUpgradeableReadLock(); - } Monitor.Enter(logBuffer); try @@ -1419,20 +1242,27 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start lineCount++; + // Add the line to the CURRENT buffer BEFORE any buffer rotation. + // The lineMemory slice is backed by the allocator's current char[] block, + // so it must be added to the buffer that will own those blocks after DetachBlocks(). + var logLine = new LogLine(lineMemory, logBuffer.StartLine + logBuffer.LineCount); + logBuffer.AddLine(logLine, filePos); + filePos = reader.Position; + lineNum++; + if (lineCount > _maxLinesPerBuffer && reader.IsBufferComplete) { - //Rate Limited Progrress - var now = Environment.TickCount64; - bool shouldFireLoadFileEvent = (now - _lastProgressUpdate) >= PROGRESS_UPDATE_INTERVAL_MS; + _progressReporter.ReportProgress(logFileInfo.FullName, filePos, logFileInfo.Length); + + logBuffer.Size = filePos - logBuffer.StartPos; - if (shouldFireLoadFileEvent) + // Detach char blocks from the reader's allocator and attach to the completed buffer. + // Must happen before Monitor.Exit so the buffer is still exclusively owned. + if (reader is PositionAwareStreamReaderSystem systemDetachBlockReader) { - OnLoadFile(new LoadFileEventArgs(logFileInfo.FullName, filePos, false, logFileInfo.Length, false)); - _lastProgressUpdate = now; + logBuffer.AttachCharBlocks(systemDetachBlockReader.BlockAllocator.DetachBlocks()); } - logBuffer.Size = filePos - logBuffer.StartPos; - Monitor.Exit(logBuffer); try { @@ -1441,15 +1271,9 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start newBuffer.StartPos = filePos; newBuffer.PrevBuffersDroppedLinesSum = droppedLines; - AcquireBufferListWriterLock(); - - try + using (upgradeabelLock.UpgradeToWrite()) { - AddBufferToList(newBuffer); - } - finally - { - ReleaseBufferListWriterLock(); + BufferIndex.Add(newBuffer); } logBuffer = newBuffer; @@ -1463,22 +1287,23 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start } } - var logLine = new LogLine(lineMemory, logBuffer.StartLine + logBuffer.LineCount); - logBuffer.AddLine(logLine, filePos); - filePos = reader.Position; - lineNum++; - (success, lineMemory, wasDropped) = ReadLineMemory(reader, logBuffer.StartLine + logBuffer.LineCount, logBuffer.StartLine + logBuffer.LineCount + droppedLines); } logBuffer.Size = filePos - logBuffer.StartPos; + + // Attach remaining blocks to the final buffer + if (reader is PositionAwareStreamReaderSystem systemDetachBlockReader2) + { + logBuffer.AttachCharBlocks(systemDetachBlockReader2.BlockAllocator.DetachBlocks()); + } } finally { Monitor.Exit(logBuffer); } - _isLineCountDirty = true; + BufferIndex.MarkLineCountDirty(); FileSize = reader.Position; // Reader may have detected another encoding @@ -1486,7 +1311,7 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start if (!_shouldStop) { - OnLoadFile(new LoadFileEventArgs(logFileInfo.FullName, filePos, true, _fileLength, false)); + _progressReporter.ReportComplete(logFileInfo.FullName, filePos, _fileLength); } } catch (IOException ioex) @@ -1499,122 +1324,6 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start } } - /// - /// Adds the specified log buffer to the internal buffer list and updates its position in the least recently used - /// (LRU) cache. - /// - /// The log buffer to add to the buffer list. Cannot be null. - private void AddBufferToList (LogBuffer logBuffer) - { -#if DEBUG - _logger.Debug(CultureInfo.InvariantCulture, "AddBufferToList(): {0}/{1}/{2}", logBuffer.StartLine, logBuffer.LineCount, logBuffer.FileInfo.FullName); -#endif - _bufferList[logBuffer.StartLine] = logBuffer; - UpdateLruCache(logBuffer); - } - - /// - /// Updates the least recently used (LRU) cache with the specified log buffer, adding it if it does not already - /// exist or marking it as recently used if it does. - /// - /// - /// If the specified log buffer is not already present in the cache, it is added. If it is present, its usage is - /// updated to reflect recent access. This method is thread-safe and manages cache locks internally. - /// - /// The log buffer to add to or update in the LRU cache. Cannot be null. - private void UpdateLruCache (LogBuffer logBuffer) - { - var cacheEntry = _lruCacheDict.GetOrAdd( - logBuffer.StartLine, - static (_, buf) => new LogBufferCacheEntry { LogBuffer = buf }, - logBuffer); - - cacheEntry.Touch(); - } - - /// - /// Sets a new start line in the given buffer and updates the LRU cache, if the buffer is present in the cache. The - /// caller must have write lock for 'lruCacheDictLock'; - /// - /// - /// - private void SetNewStartLineForBuffer (LogBuffer logBuffer, int newLineNum) - { - var hadCache = _lruCacheDict.TryRemove(logBuffer.StartLine, out var cacheEntry); - - _ = _bufferList.Remove(logBuffer.StartLine); - logBuffer.StartLine = newLineNum; - _bufferList[newLineNum] = logBuffer; - - if (hadCache) - { - _ = _lruCacheDict.TryAdd(logBuffer.StartLine, cacheEntry); - } - } - - /// - /// Removes least recently used entries from the LRU cache to maintain the cache size within the configured limit. - /// - /// - /// This method is intended to be called when the LRU cache exceeds its maximum allowed size. It removes the least - /// recently used entries to free up resources and ensure optimal cache performance. The method is not thread-safe - /// and should be called only when appropriate locks are held to prevent concurrent modifications. - /// - private void GarbageCollectLruCache () - { -#if DEBUG - long startTime = Environment.TickCount; -#endif - _logger.Debug(CultureInfo.InvariantCulture, "Starting garbage collection"); - var threshold = 10; - var diff = 0; - if (_lruCacheDict.Count - (_max_buffers + threshold) > 0) - { - diff = _lruCacheDict.Count - _max_buffers; -#if DEBUG - if (diff > 0) - { - _logger.Info(CultureInfo.InvariantCulture, "Removing {0} entries from LRU cache for {1}", diff, Util.GetNameFromPath(_fileName)); - } -#endif - // Snapshot values and sort by timestamp (ascending = least recently used first) - var entries = _lruCacheDict.ToArray(); - Array.Sort(entries, static (a, b) => a.Value.LastUseTimeStamp.CompareTo(b.Value.LastUseTimeStamp)); - - for (var i = 0; i < diff && i < entries.Length; ++i) - { - var kvp = entries[i]; - if (_lruCacheDict.TryRemove(kvp.Key, out var removed)) - { - var lockTaken = false; - try - { - removed.LogBuffer.AcquireContentLock(ref lockTaken); - // Evict content but preserve metadata (LineCount, StartLine, etc.) - // so the buffer remains findable in _bufferList lookups. - // Do NOT return to pool — the buffer is still referenced by _bufferList. - removed.LogBuffer.EvictContent(); - } - finally - { - if (lockTaken) - { - removed.LogBuffer.ReleaseContentLock(); - } - } - } - } - } - -#if DEBUG - if (diff > 0) - { - long endTime = Environment.TickCount; - _logger.Info(CultureInfo.InvariantCulture, "Garbage collector time: " + (endTime - startTime) + " ms."); - } -#endif - } - /// /// Executes the background thread procedure responsible for periodically triggering garbage collection of the least /// recently used (LRU) cache while the thread is active. @@ -1637,42 +1346,10 @@ private async Task GarbageCollectorThreadProc () break; } - GarbageCollectLruCache(); + BufferIndex.EvictLeastRecentlyUsed(); } } - /// - /// Clears all entries from the least recently used (LRU) cache and releases associated resources. - /// - /// - /// Call this method to remove all items from the LRU cache and dispose of their contents. This operation is - /// typically used to free memory or reset the cache state. The method is not thread-safe and should be called only - /// when appropriate synchronization is ensured. - /// - private void ClearLru () - { - _logger.Info(CultureInfo.InvariantCulture, "Clearing LRU cache."); - foreach (var entry in _lruCacheDict.Values) - { - var lockTaken = false; - try - { - entry.LogBuffer.AcquireContentLock(ref lockTaken); - _bufferPool.Return(entry.LogBuffer); - } - finally - { - if (lockTaken) - { - entry.LogBuffer.ReleaseContentLock(); - } - } - } - - _lruCacheDict.Clear(); - _logger.Info(CultureInfo.InvariantCulture, "Clearing done."); - } - /// /// Re-reads the contents of the specified log buffer from its associated file, updating its lines and dropped line /// count as necessary. @@ -1702,10 +1379,11 @@ private void ReReadBuffer (LogBuffer logBuffer) return; } + ILogStreamReaderMemory reader = null; try { //TODO LogStream Reader has to be changed to ILogStreamReaderMemory - var reader = GetLogStreamReader(fileStream, EncodingOptions) as ILogStreamReaderMemory; + reader = GetLogStreamReader(fileStream, EncodingOptions) as ILogStreamReaderMemory; var filePos = logBuffer.StartPos; reader.Position = logBuffer.StartPos; @@ -1739,6 +1417,12 @@ private void ReReadBuffer (LogBuffer logBuffer) (success, lineMemory, wasDropped) = ReadLineMemory(reader, logBuffer.StartLine + logBuffer.LineCount, logBuffer.StartLine + logBuffer.LineCount + dropCount); } + // Attach char blocks from the reader to the re-read buffer + if (reader is PositionAwareStreamReaderSystem systemReader) + { + logBuffer.AttachCharBlocks(systemReader.BlockAllocator.DetachBlocks()); + } + if (maxLinesCount != logBuffer.LineCount) { _logger.Warn(CultureInfo.InvariantCulture, "LineCount in buffer differs after re-reading. old={0}, new={1}", maxLinesCount, logBuffer.LineCount); @@ -1758,137 +1442,12 @@ private void ReReadBuffer (LogBuffer logBuffer) } finally { + (reader as IDisposable)?.Dispose(); fileStream.Close(); } } } - /// - /// Core buffer lookup without acquiring _bufferListLock. The caller MUST already hold a read or write lock - /// on _bufferListLock. - /// - private (LogBuffer? Buffer, int Index) GetBufferForLineWithIndex (int lineNum) - { -#if DEBUG - Util.AssertTrue( - _bufferListLock.IsReadLockHeld || _bufferListLock.IsUpgradeableReadLockHeld || _bufferListLock.IsWriteLockHeld, - "No lock held for buffer list in GetBufferForLineWithIndex"); - long startTime = Environment.TickCount; -#endif - var arr = _bufferList.Values; - var count = arr.Count; - - if (count == 0) - { - return (null, -1); - } - - // Layer 0: Last buffer cache — O(1) for sequential access - var lastIdx = _lastBufferIndex.Value; - if (lastIdx >= 0 && lastIdx < count) - { - var buf = arr[lastIdx]; - if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) - { - //dont UpdateLRUCache, the cache has not changed in layer 0 - return (buf, lastIdx); - } - - // Layer 1: Adjacent buffer prediction — O(1) for buffer boundary crossings - if (lastIdx + 1 < count) - { - var next = arr[lastIdx + 1]; - if ((uint)(lineNum - next.StartLine) < (uint)next.LineCount) - { - _lastBufferIndex.Value = lastIdx + 1; - UpdateLruCache(next); - return (next, lastIdx + 1); - } - } - - if (lastIdx - 1 >= 0) - { - var prev = arr[lastIdx - 1]; - if ((uint)(lineNum - prev.StartLine) < (uint)prev.LineCount) - { - _lastBufferIndex.Value = lastIdx - 1; - UpdateLruCache(prev); - return (prev, lastIdx - 1); - } - } - } - - // Layer 2: Direct mapping guess — O(1) speculative for uniform buffers - var guess = lineNum / _maxLinesPerBuffer; - if ((uint)guess < (uint)count) - { - var buf = arr[guess]; - if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) - { - _lastBufferIndex.Value = guess; - UpdateLruCache(buf); - return (buf, guess); - } - } - - // Layer 3: Branchless binary search with power-of-two strides - var step = HighestPowerOfTwo(count); - var idx = (arr[step - 1].StartLine <= lineNum) ? count - step : 0; - - for (step >>= 1; step > 0; step >>= 1) - { - var probe = idx + step; - if (probe < count && arr[probe - 1].StartLine <= lineNum) - { - idx = probe; - } - } - - // idx is now the buffer index — verify bounds - if (idx < count) - { - var buf = arr[idx]; - if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) - { - _lastBufferIndex.Value = idx; - UpdateLruCache(buf); - return (buf, idx); - } - } -#if DEBUG - long endTime = Environment.TickCount; - _logger.Debug($"GetBufferForLineWithIndex({lineNum}) duration: {endTime - startTime} ms."); -#endif - return (null, -1); - } - - /// - /// Retrieves the log buffer that contains the specified line number. - /// - /// - /// The zero-based line number for which to retrieve the corresponding log buffer. Must be greater than or equal to - /// zero. - /// - /// - /// The instance that contains the specified line number, or if no - /// such buffer exists. - /// - private LogBuffer GetBufferForLine (int lineNum) - { - AcquireBufferListReaderLock(); - try - { - var (buffer, _) = GetBufferForLineWithIndex(lineNum); - return buffer; - } - finally - { - ReleaseBufferListReaderLock(); - } - } - - private static int HighestPowerOfTwo (int n) => 1 << (31 - int.LeadingZeroCount(n)); - private void GetLineMemoryFinishedCallback (ILogLineMemory line) { _isFailModeCheckCallPending = false; @@ -1901,42 +1460,6 @@ private void GetLineMemoryFinishedCallback (ILogLineMemory line) _logger.Debug(CultureInfo.InvariantCulture, "'isLogLineCallPending' flag was reset."); } - /// - /// Finds the first buffer in the buffer list that is associated with the same file as the specified log buffer, - /// searching backwards from the given buffer. - /// - /// - /// This method searches backwards from the specified buffer in the buffer list to locate the earliest buffer - /// associated with the same file. The search is inclusive of the starting buffer. - /// - /// The log buffer from which to begin the search. Must not be null. - /// - /// The first LogBuffer in the buffer list that is associated with the same file as the specified buffer, searching - /// in reverse order from the given buffer. Returns null if the specified buffer is not found in the buffer list. - /// - private LogBuffer GetFirstBufferForFileByLogBuffer (LogBuffer logBuffer, int index) - { - var info = logBuffer.FileInfo; - if (index == -1) - { - return null; - } - - var resultBuffer = logBuffer; - while (true) - { - index--; - if (index < 0 || _bufferList.Values[index].FileInfo != info) - { - break; - } - - resultBuffer = _bufferList.Values[index]; - } - - return resultBuffer; - } - /// /// Monitors the specified log file for changes and processes updates in a background thread. /// @@ -1954,11 +1477,11 @@ private async Task MonitorThreadProc () try { - OnLoadingStarted(new LoadFileEventArgs(_fileName, 0, false, 0, false)); + _progressReporter.ReportLoadingStarted(_fileName); ReadFiles(); if (!_isDeleted) { - OnLoadingFinished(); + _progressReporter.ReportLoadingFinished(); } } catch (Exception e) @@ -2103,7 +1626,7 @@ private void FireChangeEvent () else { // Trigger "new file" handling (reload) - OnLoadFile(new LoadFileEventArgs(_fileName, 0, true, _fileLength, true)); + _progressReporter.ReportNewFile(_fileName, 0, _fileLength); if (_isDeleted) { @@ -2292,67 +1815,6 @@ private bool ReadLine (ILogStreamReader reader, int lineNum, int realLineNum, ou return (true, lineMemory, false); } - /// - /// Acquires an upgradeable read lock on the buffer list, waiting up to 10 seconds before blocking indefinitely if - /// the lock is not immediately available. - /// - /// - /// This method ensures that the calling thread holds an upgradeable read lock on the buffer list. If the lock - /// cannot be acquired within 10 seconds, a warning is logged and the method blocks until the lock becomes - /// available. Use this method when a read lock is needed with the potential to upgrade to a write lock. - /// - private void AcquireBufferListUpgradeableReadLock () - { - if (!_bufferListLock.TryEnterUpgradeableReadLock(TimeSpan.FromSeconds(10))) - { - _logger.Warn("Upgradeable read lock timed out"); - _bufferListLock.EnterUpgradeableReadLock(); - } - } - - /// - /// Releases the upgradeable read lock on the buffer list, allowing other threads to acquire exclusive or read - /// access. - /// - /// - /// Call this method after completing operations that required an upgradeable read lock on the buffer list. Failing - /// to release the lock may result in deadlocks or reduced concurrency. - /// - private void ReleaseBufferListUpgradeableReadLock () - { - _bufferListLock.ExitUpgradeableReadLock(); - } - - /// - /// Upgrades the buffer list lock from a reader lock to a writer lock, waiting up to 10 seconds before forcing the - /// upgrade if necessary. - /// - /// - /// If the writer lock cannot be acquired within 10 seconds, the method logs a warning and then blocks until the - /// writer lock is obtained. Call this method only when the current thread already holds a reader lock on the buffer - /// list. - /// - private void UpgradeBufferlistLockToWriterLock () - { - if (!_bufferListLock.TryEnterWriteLock(TimeSpan.FromSeconds(10))) - { - _logger.Warn("Writer lock upgrade timed out"); - _bufferListLock.EnterWriteLock(); - } - } - - /// - /// Downgrades the buffer list lock from write mode to allow other threads to acquire read access. - /// - /// - /// Call this method after completing write operations to permit concurrent read access to the buffer list. The - /// calling thread must hold the write lock before invoking this method. - /// - private void DowngradeBufferListLockFromWriterLock () - { - _bufferListLock.ExitWriteLock(); - } - #if DEBUG /// /// Outputs detailed information about the specified log buffer to the trace logger for debugging purposes. @@ -2395,7 +1857,7 @@ private static void DumpBufferInfos (LogBuffer buffer) public void Dispose () { Dispose(true); - GC.SuppressFinalize(this); // Suppress finalization (not needed but best practice) + GC.SuppressFinalize(this); } /// @@ -2415,10 +1877,15 @@ protected virtual void Dispose (bool disposing) { if (disposing) { + //Keep Dispose Order unless otherwise noted. + //For example, the progress reporter waits 2 seconds for the dispatch task + //and DeleteAllContent may trigger final events. DeleteAllContent(); _cts.Dispose(); - _lastBufferIndex.Dispose(); + BufferIndex.Dispose(); + _progressReporter.Dispose(); _mmfReader?.Dispose(); + } _disposed = true; diff --git a/src/LogExpert.Core/Classes/Log/ProgressReporters/NullProgressReporter.cs b/src/LogExpert.Core/Classes/Log/ProgressReporters/NullProgressReporter.cs new file mode 100644 index 00000000..7388f6fe --- /dev/null +++ b/src/LogExpert.Core/Classes/Log/ProgressReporters/NullProgressReporter.cs @@ -0,0 +1,23 @@ +using LogExpert.Core.Interfaces; + +namespace LogExpert.Core.Classes.Log.ProgressReporters; + +/// +/// No-op reporter for benchmarks and unit tests. Zero allocation, zero overhead. +/// +public sealed class NullProgressReporter : ILoadProgressReporter +{ + public static readonly NullProgressReporter Instance = new(); + + public void ReportProgress (string fileName, long position, long fileLength) { } + + public void ReportComplete (string fileName, long position, long fileLength) { } + + public void ReportNewFile (string fileName, long position, long fileLength) { } + + public void ReportLoadingStarted (string fileName) { } + + public void ReportLoadingFinished () { } + + public void Dispose () { } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/ProgressReporters/PeriodicProgressReporter.cs b/src/LogExpert.Core/Classes/Log/ProgressReporters/PeriodicProgressReporter.cs new file mode 100644 index 00000000..bd5735d9 --- /dev/null +++ b/src/LogExpert.Core/Classes/Log/ProgressReporters/PeriodicProgressReporter.cs @@ -0,0 +1,129 @@ +using LogExpert.Core.EventArguments; +using LogExpert.Core.Interfaces; + +namespace LogExpert.Core.Classes.Log.ProgressReporters; + +/// +/// Periodically dispatches coalesced progress events on a background thread. +/// The I/O thread never blocks on subscribers — it writes volatile state and returns. +/// +internal sealed class PeriodicProgressReporter : ILoadProgressReporter +{ + private readonly Task _dispatchTask; + private readonly CancellationTokenSource _cts = new(); + private readonly TimeSpan _dispatchInterval; + + public event EventHandler? LoadFile; + public event EventHandler? LoadingStarted; + public event EventHandler? LoadingFinished; + + // Volatile state: written by I/O thread, read by dispatch loop + private volatile ProgressState _latestProgress = ProgressState.Empty; + private volatile ProgressState _latestComplete = ProgressState.Empty; + + private volatile bool _hasProgress; + private volatile bool _hasComplete; + private volatile bool _hasNewFile; + private volatile bool _hasStarted; + private volatile bool _hasFinished; + + public PeriodicProgressReporter (TimeSpan? dispatchInterval = null) + { + _dispatchInterval = dispatchInterval ?? TimeSpan.FromMilliseconds(200); + _dispatchTask = Task.Run(DispatchLoop); + } + + // I/O thread calls (non-blocking) + public void ReportProgress (string fileName, long position, long fileLength) + { + _latestProgress = new ProgressState(fileName, position, fileLength); + _hasProgress = true; + } + + public void ReportComplete (string fileName, long position, long fileLength) + { + _latestComplete = new ProgressState(fileName, position, fileLength); + _hasComplete = true; + } + + public void ReportNewFile (string fileName, long position, long fileLength) + { + _latestComplete = new ProgressState(fileName, position, fileLength); + _hasNewFile = true; + } + + public void ReportLoadingStarted (string fileName) + { + _latestProgress = new ProgressState(fileName, 0, 0); + _hasStarted = true; + } + + public void ReportLoadingFinished () + { + _hasFinished = true; + } + + // Dispatch loop (fires in lifecycle order: Started → Progress → NewFile → Complete → Finished) + private async Task DispatchLoop () + { + var token = _cts.Token; + + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_dispatchInterval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + if (_hasStarted) + { + _hasStarted = false; + var s = _latestProgress; + LoadingStarted?.Invoke(this, new LoadFileEventArgs(s.FileName, 0, false, 0, false)); + } + + if (_hasProgress) + { + _hasProgress = false; + var s = _latestProgress; + LoadFile?.Invoke(this, new LoadFileEventArgs(s.FileName, s.Position, false, s.FileLength, false)); + } + + if (_hasNewFile) + { + _hasNewFile = false; + var s = _latestComplete; + LoadFile?.Invoke(this, new LoadFileEventArgs(s.FileName, s.Position, false, s.FileLength, true)); + } + + if (_hasComplete) + { + _hasComplete = false; + var s = _latestComplete; + LoadFile?.Invoke(this, new LoadFileEventArgs(s.FileName, s.Position, true, s.FileLength, false)); + } + + if (_hasFinished) + { + _hasFinished = false; + LoadingFinished?.Invoke(this, EventArgs.Empty); + } + } + } + + private sealed record ProgressState (string FileName, long Position, long FileLength) + { + public static readonly ProgressState Empty = new(string.Empty, 0, 0); + } + + public void Dispose () + { + _cts.Cancel(); + _ = _dispatchTask.Wait(TimeSpan.FromSeconds(2)); + _cts.Dispose(); + } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderBase.cs b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderBase.cs similarity index 99% rename from src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderBase.cs rename to src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderBase.cs index 666a8606..e186bc00 100644 --- a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderBase.cs +++ b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderBase.cs @@ -2,7 +2,7 @@ using LogExpert.Core.Entities; -namespace LogExpert.Core.Classes.Log; +namespace LogExpert.Core.Classes.Log.Streamreaders; public abstract class PositionAwareStreamReaderBase : LogStreamReaderBase { diff --git a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderChannel.cs b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderChannel.cs similarity index 99% rename from src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderChannel.cs rename to src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderChannel.cs index 1acc7120..8edc6cf8 100644 --- a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderChannel.cs +++ b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderChannel.cs @@ -7,7 +7,7 @@ using LogExpert.Core.Entities; using LogExpert.Core.Interfaces; -namespace LogExpert.Core.Classes.Log; +namespace LogExpert.Core.Classes.Log.Streamreaders; public class PositionAwareStreamReaderChannel : LogStreamReaderBase, ILogStreamReaderMemory { diff --git a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderLegacy.cs b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderLegacy.cs similarity index 97% rename from src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderLegacy.cs rename to src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderLegacy.cs index b30baefe..033de390 100644 --- a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderLegacy.cs +++ b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderLegacy.cs @@ -1,6 +1,6 @@ using LogExpert.Core.Entities; -namespace LogExpert.Core.Classes.Log; +namespace LogExpert.Core.Classes.Log.Streamreaders; public class PositionAwareStreamReaderLegacy (Stream stream, EncodingOptions encodingOptions, int maximumLineLength) : PositionAwareStreamReaderBase(stream, encodingOptions, maximumLineLength) { diff --git a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderPipeline.cs b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderPipeline.cs similarity index 99% rename from src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderPipeline.cs rename to src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderPipeline.cs index deff3ff1..b85fd82f 100644 --- a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderPipeline.cs +++ b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderPipeline.cs @@ -6,7 +6,7 @@ using LogExpert.Core.Entities; using LogExpert.Core.Interfaces; -namespace LogExpert.Core.Classes.Log; +namespace LogExpert.Core.Classes.Log.Streamreaders; public class PositionAwareStreamReaderPipeline : LogStreamReaderBase, ILogStreamReaderMemory { diff --git a/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderPipelineNew.cs b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderPipelineNew.cs new file mode 100644 index 00000000..e35b5951 --- /dev/null +++ b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderPipelineNew.cs @@ -0,0 +1,702 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.IO.Pipelines; +using System.Text; + +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; + +namespace LogExpert.Core.Classes.Log.Streamreaders; + +/// +/// EXPERIMENTAL: TypedPipeline-based reader for benchmarking comparison. +/// Uses multi-threaded pipeline with BlockingCollection stages. +/// Expected to be 15-25% slower than PositionAwareStreamReaderPipeline due to pipeline overhead. +/// +public class PositionAwareStreamReaderPipelineNew : LogStreamReaderBase, ILogStreamReaderMemory +{ + private const int DEFAULT_BYTE_BUFFER_SIZE = 64 * 1024; // 64 KB + private const int MINIMUM_READ_AHEAD_SIZE = 4 * 1024; // 4 KB + private const int DEFAULT_CHANNEL_CAPACITY = 128; // Number of line segments + + private static readonly Encoding[] _preambleEncodings = + [ + Encoding.UTF8, + Encoding.Unicode, + Encoding.BigEndianUnicode, + Encoding.UTF32 + ]; + + private readonly StreamPipeReaderOptions _streamPipeReaderOptions = new(bufferSize: DEFAULT_BYTE_BUFFER_SIZE, minimumReadSize: MINIMUM_READ_AHEAD_SIZE, leaveOpen: true); + private readonly int _maximumLineLength; + private readonly Lock _reconfigureLock = new(); + private readonly Stream _stream; + private readonly Encoding _encoding; + private readonly int _charBufferSize; + private readonly long _preambleLength; + + private LineSegment? _currentSegment; + private PipeReader _pipeReader; + private CancellationTokenSource _cts; + private Task _producerTask; + private bool _isDisposed; + private long _position; + private BlockingCollection _lineQueue; + private Exception _producerException; + private IPipeline _pipeline; + + public PositionAwareStreamReaderPipelineNew (Stream stream, EncodingOptions encodingOptions, int maximumLineLength) + { + ArgumentNullException.ThrowIfNull(stream); + + if (!stream.CanRead) + { + throw new ArgumentException("Stream must support reading.", nameof(stream)); + } + + if (!stream.CanSeek) + { + throw new ArgumentException("Stream must support seeking.", nameof(stream)); + } + + if (maximumLineLength <= 0) + { + maximumLineLength = 1024; + } + + _maximumLineLength = maximumLineLength; + var (length, detectedEncoding) = DetectPreambleLength(stream); + _preambleLength = length; + _encoding = DetermineEncoding(encodingOptions, detectedEncoding); + + _stream = stream; + _charBufferSize = Math.Max(_encoding.GetMaxCharCount(DEFAULT_BYTE_BUFFER_SIZE), _maximumLineLength + 2); + + RestartPipelineInternal(0); + } + + public override long Position + { + get => Interlocked.Read(ref _position); + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + RestartPipeline(value); + } + } + + public override bool IsBufferComplete => true; + + public override Encoding Encoding => _encoding; + + public override bool IsDisposed + { + get => _isDisposed; + protected set => _isDisposed = value; + } + + public override int ReadChar () + { + throw new NotSupportedException("PipelineLogStreamReader currently supports line-based reads only."); + } + + public override string ReadLine () + { + if (TryReadLine(out var lineMemory)) + { + return new string(lineMemory.Span); + } + + return null; + } + + public bool TryReadLine (out ReadOnlyMemory lineMemory) + { + ObjectDisposedException.ThrowIf(IsDisposed, GetType()); + + var producerEx = Volatile.Read(ref _producerException); + if (producerEx != null) + { + throw new InvalidOperationException("Producer task encountered an error.", producerEx); + } + + var queue = _lineQueue; + var cts = _cts; + + if (queue == null || cts == null) + { + lineMemory = default; + return false; + } + + try + { + // With pre-filled queue, data should be available immediately + if (!queue.TryTake(out var segment, 50, cts.Token)) + { + lineMemory = default; + return false; + } + + _currentSegment?.Dispose(); + _currentSegment = segment; + + if (segment.IsEof) + { + lineMemory = default; + return false; + } + + lineMemory = new ReadOnlyMemory(segment.Buffer, 0, segment.Length); + _ = Interlocked.Exchange(ref _position, segment.ByteOffset + segment.ByteLength); + return true; + } + catch (OperationCanceledException) + { + lineMemory = default; + return false; + } + catch (ObjectDisposedException) + { + lineMemory = default; + return false; + } + } + + public void ReturnMemory (ReadOnlyMemory memory) + { + // No-op for this implementation + } + + protected override void Dispose (bool disposing) + { + if (_isDisposed) + { + return; + } + + if (disposing) + { + using (_reconfigureLock.EnterScope()) + { + CancelPipelineLocked(); + + if (_lineQueue != null) + { + while (_lineQueue.TryTake(out var segment)) + { + segment.Dispose(); + } + + _lineQueue.Dispose(); + } + + _stream?.Dispose(); + } + } + + _isDisposed = true; + } + + private void RestartPipelineInternal (long startPosition) + { + _ = _stream.Seek(_preambleLength + startPosition, SeekOrigin.Begin); + + _pipeReader = PipeReader.Create(_stream, _streamPipeReaderOptions); + _lineQueue = new BlockingCollection(new ConcurrentQueue(), DEFAULT_CHANNEL_CAPACITY); + + Volatile.Write(ref _producerException, null); + + // KEY FIX: Read and enqueue first line synchronously to guarantee immediate data availability + var firstLine = ReadFirstLineSynchronously(startPosition); + long nextByteOffset = startPosition; + + if (firstLine.HasValue) + { + _lineQueue.Add(firstLine.Value); + nextByteOffset = firstLine.Value.ByteOffset + firstLine.Value.ByteLength; + } + + _cts = new CancellationTokenSource(); + + // Build TypedPipeline + var builder = new TypedPipelineBuilder(); + _pipeline = builder.AddStep(ProcessBuffer).Build(); + + _pipeline.Finished += segment => + { + if (segment.Buffer != null || segment.IsEof) + { + EnqueueLine(segment); + } + }; + + _producerTask = Task.Run(() => ProduceAsync(nextByteOffset, _cts.Token), CancellationToken.None); + + _ = Interlocked.Exchange(ref _position, startPosition); + } + + /// + /// Reads the first line from the stream synchronously to pre-fill the queue. + /// This ensures data is immediately available when TryReadLine() is called after a seek. + /// + private LineSegment? ReadFirstLineSynchronously (long startPosition) + { + const int FIRST_LINE_BUFFER_SIZE = 4096; + + var byteBuffer = ArrayPool.Shared.Rent(FIRST_LINE_BUFFER_SIZE); + var charBuffer = ArrayPool.Shared.Rent(_charBufferSize); + + try + { + var bytesRead = _stream.Read(byteBuffer, 0, FIRST_LINE_BUFFER_SIZE); + + if (bytesRead == 0) + { + return LineSegment.CreateEof(startPosition); + } + + var decoder = _encoding.GetDecoder(); + var charsDecoded = decoder.GetChars(byteBuffer, 0, bytesRead, charBuffer, 0, flush: false); + + var (newlineIndex, newlineChars) = FindNewlineIndex(charBuffer, 0, charsDecoded, false); + + if (newlineIndex == -1) + { + return null; + } + + var segment = CreateSegment(charBuffer, 0, newlineIndex, newlineChars, startPosition); + + return segment; + } + catch (Exception ex) when (ex is IOException or + DecoderFallbackException or + ObjectDisposedException) + { + return null; + } + finally + { + ArrayPool.Shared.Return(byteBuffer); + ArrayPool.Shared.Return(charBuffer); + } + } + + private void RestartPipeline (long newPosition) + { + using (_reconfigureLock.EnterScope()) + { + CancelPipelineLocked(); + RestartPipelineInternal(newPosition); + } + } + + private void CancelPipelineLocked () + { + if (_cts == null) + { + return; + } + + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Ignore + } + + try + { + _producerTask?.Wait(); + } + catch (AggregateException ex) when (ex.InnerExceptions.All(e => e is OperationCanceledException)) + { + // Expected + } + finally + { + _pipeline?.Complete(); + _cts.Dispose(); + _cts = null; + } + + if (_lineQueue != null && !_lineQueue.IsAddingCompleted) + { + _lineQueue.CompleteAdding(); + } + + if (_pipeReader != null) + { + try + { + _pipeReader.Complete(); + } + catch (ObjectDisposedException) + { + // Ignore: shutdown race + } + catch (InvalidOperationException) + { + // Ignore: already completed or invalid teardown state + } + } + } + + private async Task ProduceAsync (long startByteOffset, CancellationToken token) + { + var charPool = ArrayPool.Shared; + char[] charBuffer = null; + Decoder decoder = null; + + try + { + charBuffer = charPool.Rent(_charBufferSize); + decoder = _encoding.GetDecoder(); + + var charsInBuffer = 0; + var byteOffset = startByteOffset; + + while (!token.IsCancellationRequested) + { + ReadResult result = await _pipeReader.ReadAsync(token).ConfigureAwait(false); + ReadOnlySequence buffer = result.Buffer; + + if (buffer.Length > 0) + { + // Create buffer data and feed to pipeline + var bufferData = new BufferData(buffer, charBuffer, charsInBuffer, decoder, byteOffset, result.IsCompleted); + + // Process and extract lines + var processResult = ProcessBufferAndExtractLines(bufferData); + + // Update state + charsInBuffer = processResult.RemainingChars; + byteOffset = processResult.NewByteOffset; + + var pipeline = _pipeline ?? throw new InvalidOperationException("Pipeline is not initialized."); + + // Feed each line to pipeline (which will enqueue them) + foreach (var segment in processResult.Lines) + { + + pipeline.Execute(new BufferData(default, segment.Buffer, 0, null, segment.ByteOffset, false) + { + PreExtractedSegment = segment + }); + } + + _pipeReader.AdvanceTo(buffer.End); + } + + if (result.IsCompleted) + { + if (charsInBuffer > 0) + { + var segment = CreateSegment(charBuffer, 0, charsInBuffer, 0, byteOffset); + EnqueueLine(segment); + byteOffset += segment.ByteLength; + } + + EnqueueLine(LineSegment.CreateEof(byteOffset)); + break; + } + } + } + catch (OperationCanceledException) + { + // Expected + } + catch (DecoderFallbackException ex) + { + Volatile.Write(ref _producerException, ex); + } + catch (ObjectDisposedException ex) + { + Volatile.Write(ref _producerException, ex); + } + catch (InvalidOperationException ex) + { + Volatile.Write(ref _producerException, ex); + } + catch + { + throw; + } + finally + { + _pipeline?.Complete(); + + try + { + _lineQueue?.CompleteAdding(); + } + catch (ObjectDisposedException) + { + // Ignore + } + + if (charBuffer != null) + { + charPool.Return(charBuffer); + } + } + } + + private LineSegment ProcessBuffer (BufferData bufferData) + { + // If pre-extracted, just return it (simulating pipeline overhead) + if (bufferData.PreExtractedSegment.HasValue) + { + return bufferData.PreExtractedSegment.Value; + } + + // This shouldn't happen in normal operation, but handle gracefully + // Return an empty line segment + return new LineSegment(null, 0, bufferData.ByteOffset, 0, false, false); + } + + private ProcessResult ProcessBufferAndExtractLines (BufferData bufferData) + { + var lines = new List(); + var localCharsInBuffer = bufferData.CharsInBuffer; + var localByteOffset = bufferData.ByteOffset; + + // Decode bytes to chars + if (bufferData.Buffer.IsSingleSegment) + { + var span = bufferData.Buffer.FirstSpan; + var charsAvailable = _charBufferSize - localCharsInBuffer; + + if (charsAvailable > 10) + { + bufferData.Decoder.Convert( + span, + bufferData.CharBuffer.AsSpan(localCharsInBuffer), + bufferData.IsCompleted, + out var usedBytes, + out var charsProduced, + out _); + + localCharsInBuffer += charsProduced; + localByteOffset += usedBytes; + } + } + + // Extract lines + var searchIndex = 0; + while (true) + { + var (newlineIndex, newlineChars) = FindNewlineIndex(bufferData.CharBuffer, searchIndex, localCharsInBuffer - searchIndex, false); + + if (newlineIndex == -1) + { + break; + } + + var lineLength = newlineIndex - searchIndex; + var segment = CreateSegment(bufferData.CharBuffer, searchIndex, lineLength, newlineChars, localByteOffset); + lines.Add(segment); + localByteOffset += segment.ByteLength; + searchIndex = newlineIndex + newlineChars; + } + + // Calculate remaining chars + var remaining = localCharsInBuffer - searchIndex; + if (remaining > 0 && searchIndex > 0) + { + bufferData.CharBuffer.AsSpan(searchIndex, remaining).CopyTo(bufferData.CharBuffer.AsSpan(0, remaining)); + } + + return new ProcessResult(lines, remaining, localByteOffset); + } + + private void EnqueueLine (LineSegment segment) + { + try + { + // Don't use cancellation token here - let the queue complete naturally + _lineQueue.Add(segment); + } + catch (InvalidOperationException) + { + // Collection was marked as complete, dispose the segment + segment.Dispose(); + } + } + + private static (int newLineIndex, int newLineChars) FindNewlineIndex ( + char[] buffer, + int start, + int available, + bool allowStandaloneCr) + { + var span = buffer.AsSpan(start, available); + + var lfIndex = span.IndexOf('\n'); + if (lfIndex != -1) + { + if (lfIndex > 0 && span[lfIndex - 1] == '\r') + { + return (newLineIndex: start + lfIndex - 1, newLineChars: 2); + } + + return (newLineIndex: start + lfIndex, newLineChars: 1); + } + + var crIndex = span.IndexOf('\r'); + if (crIndex != -1) + { + if (crIndex + 1 >= span.Length) + { + if (allowStandaloneCr) + { + return (newLineIndex: start + crIndex, newLineChars: 1); + } + + return (newLineIndex: -1, newLineChars: 0); + } + + if (span[crIndex + 1] != '\n') + { + return (newLineIndex: start + crIndex, newLineChars: 1); + } + } + + return (newLineIndex: -1, newLineChars: 0); + } + + private LineSegment CreateSegment ( + char[] source, + int start, + int lineLength, + int newlineChars, + long byteOffset) + { + var consumedChars = lineLength + newlineChars; + + var byteLength = consumedChars == 0 + ? 0 + : _encoding.GetByteCount(source, start, consumedChars); + + var logicalLength = Math.Min(lineLength, _maximumLineLength); + var truncated = lineLength > logicalLength; + + var rentalLength = Math.Max(logicalLength, 1); + var buffer = ArrayPool.Shared.Rent(rentalLength); + + if (logicalLength > 0) + { + source.AsSpan(start, logicalLength).CopyTo(buffer.AsSpan(0, logicalLength)); + } + + return new LineSegment(buffer, logicalLength, byteOffset, byteLength, truncated, false); + } + + // Pipeline data structures + private class BufferData + { + public ReadOnlySequence Buffer { get; } + public char[] CharBuffer { get; } + public int CharsInBuffer { get; } + public Decoder Decoder { get; } + public long ByteOffset { get; } + public bool IsCompleted { get; } + public LineSegment? PreExtractedSegment { get; set; } + + public BufferData (ReadOnlySequence buffer, char[] charBuffer, int charsInBuffer, Decoder decoder, long byteOffset, bool isCompleted) + { + Buffer = buffer; + CharBuffer = charBuffer; + CharsInBuffer = charsInBuffer; + Decoder = decoder; + ByteOffset = byteOffset; + IsCompleted = isCompleted; + } + } + + private record ProcessResult (List Lines, int RemainingChars, long NewByteOffset); + private record PipelineInput (ReadOnlySequence Buffer, char[] CharBuffer, int CharsInBuffer, Decoder Decoder, long ByteOffset, bool IsCompleted); + private record DecodeResult (char[] CharBuffer, int CharsInBuffer, long ByteOffset); + private record ExtractResult (List Lines); + + private readonly struct LineSegment : IDisposable + { + public char[] Buffer { get; } + public int Length { get; } + public long ByteOffset { get; } + public int ByteLength { get; } + public bool IsTruncated { get; } + public bool IsEof { get; } + + public LineSegment (char[] buffer, int length, long byteOffset, int byteLength, bool isTruncated, bool isEof) + { + Buffer = buffer; + Length = length; + ByteOffset = byteOffset; + ByteLength = byteLength; + IsTruncated = isTruncated; + IsEof = isEof; + } + + public void Dispose () + { + if (Buffer != null) + { + ArrayPool.Shared.Return(Buffer); + } + } + + public static LineSegment CreateEof (long byteOffset) + { + return new LineSegment(null, 0, byteOffset, 0, false, true); + } + } + + private static Encoding DetermineEncoding (EncodingOptions options, Encoding detectedEncoding) + { + return options?.Encoding != null + ? options.Encoding + : detectedEncoding ?? options?.DefaultEncoding ?? Encoding.Default; + } + + private static (int length, Encoding? detectedEncoding) DetectPreambleLength (Stream stream) + { + if (!stream.CanSeek) + { + return (0, null); + } + + var originalPos = stream.Position; + var buffer = new byte[4]; + _ = stream.Seek(0, SeekOrigin.Begin); + var readBytes = stream.Read(buffer, 0, buffer.Length); + _ = stream.Seek(originalPos, SeekOrigin.Begin); + + if (readBytes >= 2) + { + foreach (var encoding in _preambleEncodings) + { + var preamble = encoding.GetPreamble(); + var fail = false; + for (var i = 0; i < readBytes && i < preamble.Length; ++i) + { + if (buffer[i] != preamble[i]) + { + fail = true; + break; + } + } + + if (!fail) + { + return (preamble.Length, encoding); + } + } + } + + return (0, null); + } +} diff --git a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderSystem.cs b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderSystem.cs similarity index 70% rename from src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderSystem.cs rename to src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderSystem.cs index 41958b4d..fce09075 100644 --- a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderSystem.cs +++ b/src/LogExpert.Core/Classes/Log/Streamreaders/PositionAwareStreamReaderSystem.cs @@ -1,9 +1,10 @@ using System.Text; +using LogExpert.Core.Classes.Log.Buffers; using LogExpert.Core.Entities; using LogExpert.Core.Interfaces; -namespace LogExpert.Core.Classes.Log; +namespace LogExpert.Core.Classes.Log.Streamreaders; /// /// This class is responsible for reading line from the log file. It also decodes characters with the appropriate charset encoding. @@ -32,6 +33,20 @@ public PositionAwareStreamReaderSystem (Stream stream, EncodingOptions encodingO #endregion + #region Properties + + /// + /// Gets or creates the block allocator used by this reader instance. + /// The caller can detach the blocks after reading a buffer's worth of lines. + /// + public CharBlockAllocator BlockAllocator + { + get => field ??= new CharBlockAllocator(); + private set; + } + + #endregion + #region Public methods public override string ReadLine () @@ -73,30 +88,32 @@ public bool TryReadLine (out ReadOnlyMemory lineMemory) var line = reader.ReadLine(); - if (line != null) + if (line is null) { - MovePosition(Encoding.GetByteCount(line) + _newLineSequenceLength); + lineMemory = default; + return false; + } - if (line.Length > MaximumLineLength) - { - line = line[..MaximumLineLength]; - } + MovePosition(Encoding.GetByteCount(line) + _newLineSequenceLength); - // Store line for Memory access - lineMemory = line.AsMemory(); - return true; - } + var length = Math.Min(line.Length, MaximumLineLength); - lineMemory = default; - return false; + // Allocate from block and copy + var allocator = BlockAllocator; + var target = allocator.Rent(length); + line.AsSpan(0, length).CopyTo(target.Span); + lineMemory = target; + return true; } /// - /// Returns the memory buffer. For System reader, this is a no-op since we use string-backed Memory. + /// Returns the memory buffer. For the block-based reader, individual returns are not tracked — blocks are returned + /// in bulk via the BlockAllocator when the LogBuffer is evicted or the reader is disposed. /// public void ReturnMemory (ReadOnlyMemory memory) { - // No-op for System reader - string is already managed by GC + // Bulk return via BlockAllocator.DetachBlocks() or Dispose(). + // Individual per-line return is not needed with block-based allocation. } #endregion @@ -140,5 +157,16 @@ private int GuessNewLineSequenceLength (StreamReader reader) } } + protected override void Dispose (bool disposing) + { + if (disposing) + { + BlockAllocator?.Dispose(); + BlockAllocator = null; + } + + base.Dispose(disposing); + } + #endregion } \ No newline at end of file diff --git a/src/LogExpert.Core/Config/Preferences.cs b/src/LogExpert.Core/Config/Preferences.cs index a63e190c..5343046b 100644 --- a/src/LogExpert.Core/Config/Preferences.cs +++ b/src/LogExpert.Core/Config/Preferences.cs @@ -54,8 +54,8 @@ public List HilightGroupList /// /// /// This property controls line truncation at the I/O reader level before lines are processed by columnizers. - /// It is implemented in and - /// . Related property: + /// It is implemented in and + /// . Related property: /// controls display-level truncation in UI columns, which must not exceed this /// value. Default is 20000 characters. /// diff --git a/src/LogExpert.Core/Interfaces/ILoadProgressReporter.cs b/src/LogExpert.Core/Interfaces/ILoadProgressReporter.cs new file mode 100644 index 00000000..4c82d4dc --- /dev/null +++ b/src/LogExpert.Core/Interfaces/ILoadProgressReporter.cs @@ -0,0 +1,35 @@ +namespace LogExpert.Core.Interfaces; + +/// +/// Decouples file loading progress reporting from event dispatch. +/// The I/O path calls Report*() methods; the implementation decides +/// when and how to fire events. +/// +public interface ILoadProgressReporter : IDisposable +{ + /// + /// Report intermediate loading progress. Called from I/O thread. + /// Implementation may batch/coalesce/drop these. + /// + void ReportProgress (string fileName, long position, long fileLength); + + /// + /// Report that loading of a file segment is complete. + /// + void ReportComplete (string fileName, long position, long fileLength); + + /// + /// Report that a new file was detected (rollover). + /// + void ReportNewFile (string fileName, long position, long fileLength); + + /// + /// Report that a loading operation has started. + /// + void ReportLoadingStarted (string fileName); + + /// + /// Report that a loading operation has finished. + /// + void ReportLoadingFinished (); +} \ No newline at end of file diff --git a/src/LogExpert.Tests/Buffers/BufferIndexSnapshotTests.cs b/src/LogExpert.Tests/Buffers/BufferIndexSnapshotTests.cs new file mode 100644 index 00000000..9b2100f3 --- /dev/null +++ b/src/LogExpert.Tests/Buffers/BufferIndexSnapshotTests.cs @@ -0,0 +1,47 @@ +using LogExpert.Core.Classes.Log.Buffers; + +using NUnit.Framework; + +namespace LogExpert.Tests.Buffers; + +[TestFixture] +internal class BufferIndexSnapshotTests +{ + [Test] + public void ToString_ReturnsFormattedSummary () + { + var snapshot = new BufferIndexSnapshot + { + BufferCount = 5, + TotalLineCount = 2500, + LruCacheCount = 3, + Buffers = [] + }; + + Assert.That(snapshot.ToString(), Is.EqualTo("Buffers=5, Lines=2500, LRU=3")); + } + + [Test] + public void BufferInfo_RecordEquality () + { + var a = new BufferIndexSnapshot.BufferInfo(0, 100, 0, 1000, false, "file.log"); + var b = new BufferIndexSnapshot.BufferInfo(0, 100, 0, 1000, false, "file.log"); + var c = new BufferIndexSnapshot.BufferInfo(100, 100, 1000, 1000, false, "file.log"); + + Assert.That(a, Is.EqualTo(b)); + Assert.That(a, Is.Not.EqualTo(c)); + } + + [Test] + public void DefaultBuffers_IsEmptyList () + { + var snapshot = new BufferIndexSnapshot + { + BufferCount = 0, + TotalLineCount = 0, + LruCacheCount = 0 + }; + + Assert.That(snapshot.Buffers, Is.Empty); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/Buffers/BufferIndexTests.cs b/src/LogExpert.Tests/Buffers/BufferIndexTests.cs new file mode 100644 index 00000000..7df0ecfc --- /dev/null +++ b/src/LogExpert.Tests/Buffers/BufferIndexTests.cs @@ -0,0 +1,578 @@ +using ColumnizerLib; + +using LogExpert.Core.Classes.Log.Buffers; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Buffers; + +/// +/// Unit tests for the extracted class. Uses in-memory LogBuffers with a fake ILogFileInfo — +/// no file I/O. +/// +[TestFixture] +internal class BufferIndexTests : IDisposable +{ + private const int MAX_BUFFERS = 50; + private const int LINES_PER_BUFFER = 500; + + private Mock _fakeFileInfo = null!; + private Mock _fakeFileInfo2 = null!; + private BufferIndex _index = null!; + + private bool _disposed; + + [SetUp] + public void SetUp () + { + _fakeFileInfo = new Mock(); + _ = _fakeFileInfo.Setup(f => f.FullName).Returns("fake1.log"); + _ = _fakeFileInfo.Setup(f => f.FileName).Returns("fake1.log"); + + _fakeFileInfo2 = new Mock(); + _ = _fakeFileInfo2.Setup(f => f.FullName).Returns("fake2.log"); + _ = _fakeFileInfo2.Setup(f => f.FileName).Returns("fake2.log"); + + _index = new BufferIndex(MAX_BUFFERS, LINES_PER_BUFFER); + } + + [TearDown] + public void TearDown () + { + _index.Dispose(); + } + + #region IDisposable Implementation + + public void Dispose () + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose (bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _index?.Dispose(); + } + + _disposed = true; + } + + #endregion + + #region Helper Methods + + /// + /// Creates a LogBuffer with the given startLine and lineCount, populated with dummy LogLines. + /// + private LogBuffer CreateBuffer (int startLine, int lineCount, ILogFileInfo? fileInfo = null) + { + var info = fileInfo ?? _fakeFileInfo.Object; + var buffer = new LogBuffer(info, LINES_PER_BUFFER) + { + StartLine = startLine, + StartPos = startLine * 100 + }; + + for (var j = 0; j < lineCount; j++) + { + buffer.AddLine(new LogLine($"line {startLine + j}".AsMemory(), startLine + j), (startLine + j) * 100); + } + + buffer.Size = lineCount * 100; + return buffer; + } + + /// + /// Populates the index with uniform buffers of lines. + /// + private void PopulateUniform (int count, int linesPerBuffer = LINES_PER_BUFFER, ILogFileInfo? fileInfo = null) + { + using var w = _index.AcquireWriteLock(); + for (var i = 0; i < count; i++) + { + _index.Add(CreateBuffer(i * linesPerBuffer, linesPerBuffer, fileInfo)); + } + } + + #endregion + + #region Lookup — 4-layer strategy + + [Test] + public void TryFindBuffer_EmptyIndex_ReturnsFalse () + { + using var r = _index.AcquireReadLock(); + Assert.That(_index.TryFindBuffer(0).Found, Is.False); + } + + [Test] + public void TryFindBuffer_SingleBuffer_FindsLine () + { + PopulateUniform(1, 10); + + using var r = _index.AcquireReadLock(); + Assert.That(_index.TryFindBuffer(0).Found, Is.True); + Assert.That(_index.TryFindBuffer(0).Buffer, Is.Not.Null); + Assert.That(_index.TryFindBuffer(0).Buffer!.StartLine, Is.EqualTo(0)); + Assert.That(_index.TryFindBuffer(0).Buffer!.LineCount, Is.EqualTo(10)); + } + + [Test] + public void TryFindBuffer_OutOfRange_ReturnsFalse () + { + PopulateUniform(2, 10); // lines 0–19 + + using var r = _index.AcquireReadLock(); + Assert.That(_index.TryFindBuffer(20).Found, Is.False); + Assert.That(_index.TryFindBuffer(-1).Found, Is.False); + } + + [TestCase(0, Description = "First line — Layer 0 or direct map")] + [TestCase(499, Description = "Last line of first buffer")] + [TestCase(500, Description = "First line of second buffer — boundary")] + [TestCase(4999, Description = "Last line overall")] + public void TryFindBuffer_VariousLines_FindsCorrectBuffer (int lineNum) + { + PopulateUniform(10); // 10 buffers × 500 lines = 5000 lines + + using var r = _index.AcquireReadLock(); + Assert.That(_index.TryFindBuffer(lineNum).Found, Is.True); + Assert.That(_index.TryFindBuffer(lineNum).Buffer, Is.Not.Null); + Assert.That(lineNum, Is.GreaterThanOrEqualTo(_index.TryFindBuffer(lineNum).Buffer!.StartLine)); + Assert.That(lineNum, Is.LessThan(_index.TryFindBuffer(lineNum).Buffer!.StartLine + _index.TryFindBuffer(lineNum).Buffer!.LineCount)); + } + + [Test] + public void TryFindBuffer_SequentialAccess_HitsThreadLocalCache () + { + PopulateUniform(5, 100); // 500 lines + + using var r = _index.AcquireReadLock(); + // First call sets thread-local cache + Assert.That(_index.TryFindBuffer(50).Found, Is.True); + // Second call within same buffer should hit Layer 0 + Assert.That(_index.TryFindBuffer(60).Found, Is.True); + Assert.That(_index.TryFindBuffer(60).Buffer!.StartLine, Is.EqualTo(0)); + } + + [Test] + public void TryFindBuffer_AdjacentForward_FindsNextBuffer () + { + PopulateUniform(3, 100); // lines 0–299 + + using var r = _index.AcquireReadLock(); + // Prime thread-local to buffer 0 + Assert.That(_index.TryFindBuffer(50).Found, Is.True); + // Cross boundary into buffer 1 — Layer 1 adjacent prediction + Assert.That(_index.TryFindBuffer(100).Found, Is.True); + Assert.That(_index.TryFindBuffer(100).Buffer!.StartLine, Is.EqualTo(100)); + } + + [Test] + public void TryFindBuffer_AdjacentBackward_FindsPrevBuffer () + { + PopulateUniform(3, 100); // lines 0–299 + + using var r = _index.AcquireReadLock(); + // Prime thread-local to buffer 1 + Assert.That(_index.TryFindBuffer(150).Found, Is.True); + // Cross backward into buffer 0 + Assert.That(_index.TryFindBuffer(50).Found, Is.True); + Assert.That(_index.TryFindBuffer(50).Buffer!.StartLine, Is.EqualTo(0)); + } + + [Test] + public void TryFindBuffer_RandomStride_FindsAllLines () + { + PopulateUniform(20, 100); // 2000 lines + + using var r = _index.AcquireReadLock(); + // Co-prime stride exercises Layers 2 and 3 + var stride = 701; + var lineNum = 0; + for (var i = 0; i < 2000; i++) + { + lineNum = (lineNum + stride) % 2000; + var logBufferEntry = _index.TryFindBuffer(lineNum); + Assert.That(logBufferEntry.Found, Is.True, + $"Failed to find buffer for line {lineNum}"); + Assert.That(lineNum, Is.GreaterThanOrEqualTo(logBufferEntry.Buffer!.StartLine)); + Assert.That(lineNum, Is.LessThan(logBufferEntry.Buffer.StartLine + logBufferEntry.Buffer.LineCount)); + } + } + + #endregion + + #region GetBufferForLineWithIndex + + [Test] + public void GetBufferForLineWithIndex_ReturnsBufferAndPositionalIndex () + { + PopulateUniform(3, 100); + + using var r = _index.AcquireReadLock(); + var logBufferEntry = _index.GetBufferForLineWithIndex(150); + Assert.That(logBufferEntry.Buffer, Is.Not.Null); + Assert.That(logBufferEntry.Buffer!.StartLine, Is.EqualTo(100)); + Assert.That(logBufferEntry.Index, Is.EqualTo(1)); + } + + [Test] + public void GetBufferForLineWithIndex_OutOfRange_ReturnsNull () + { + PopulateUniform(1, 10); + + using var r = _index.AcquireReadLock(); + var logBufferEntry = _index.GetBufferForLineWithIndex(999); + Assert.That(logBufferEntry.Buffer, Is.Null); + Assert.That(logBufferEntry.Index, Is.EqualTo(-1)); + } + + #endregion + + #region Mutation — Add / Remove / UpdateStartLine / Clear + + [Test] + public void Add_IncreasesBufferCount () + { + using var w = _index.AcquireWriteLock(); + Assert.That(_index.BufferCount, Is.EqualTo(0)); + + _index.Add(CreateBuffer(0, 10)); + Assert.That(_index.BufferCount, Is.EqualTo(1)); + + _index.Add(CreateBuffer(10, 10)); + Assert.That(_index.BufferCount, Is.EqualTo(2)); + } + + [Test] + public void Remove_DecreasesBufferCount () + { + var buf = CreateBuffer(0, 10); + using var w = _index.AcquireWriteLock(); + _index.Add(buf); + Assert.That(_index.BufferCount, Is.EqualTo(1)); + + var removed = _index.Remove(buf); + Assert.That(removed, Is.True); + Assert.That(_index.BufferCount, Is.EqualTo(0)); + } + + [Test] + public void Remove_NonExistentBuffer_ReturnsFalse () + { + PopulateUniform(1, 10); + var orphan = CreateBuffer(9999, 10); + + using var w = _index.AcquireWriteLock(); + Assert.That(_index.Remove(orphan), Is.False); + } + + [Test] + public void UpdateStartLine_MovesBuffer () + { + PopulateUniform(3, 100); // 0, 100, 200 + + using var w = _index.AcquireWriteLock(); + var buf = _index.GetBufferAt(1); // startLine=100 + Assert.That(buf.StartLine, Is.EqualTo(100)); + + _index.UpdateStartLine(buf, 50); + Assert.That(buf.StartLine, Is.EqualTo(50)); + + // Old key gone — no buffer starts at 100 anymore + Assert.That(_index.TryFindBuffer(100).Buffer?.StartLine, Is.Not.EqualTo(100)); + // New key present — buffer is reachable via its new start line + Assert.That(_index.TryFindBuffer(50).Found, Is.True); + Assert.That(_index.TryFindBuffer(50).Buffer, Is.SameAs(buf)); + } + + [Test] + public void Clear_RemovesAll () + { + PopulateUniform(5, 100); + + using var w = _index.AcquireWriteLock(); + Assert.That(_index.BufferCount, Is.EqualTo(5)); + + _index.Clear(); + Assert.That(_index.BufferCount, Is.EqualTo(0)); + Assert.That(_index.TotalLineCount, Is.EqualTo(0)); + Assert.That(_index.LruCacheCount, Is.EqualTo(0)); + } + + #endregion + + #region TotalLineCount - dirty/clean caching + + [Test] + public void TotalLineCount_ReflectsBufferContents () + { + PopulateUniform(3, 100); + + using var r = _index.AcquireReadLock(); + Assert.That(_index.TotalLineCount, Is.EqualTo(300)); + } + + [Test] + public void TotalLineCount_AfterMarkDirty_Recalculates () + { + PopulateUniform(2, 100); + + using var r = _index.AcquireReadLock(); + var first = _index.TotalLineCount; + Assert.That(first, Is.EqualTo(200)); + + // Mark dirty and re-read — should recalculate to same value + _index.MarkLineCountDirty(); + var second = _index.TotalLineCount; + Assert.That(second, Is.EqualTo(200)); + } + + #endregion + + #region Multi-file navigation + + [Test] + public void TryGetNextFileStartLine_TwoFiles_FindsBoundary () + { + // File1: buffers at 0, 100. File2: buffers at 200, 300. + using var w = _index.AcquireWriteLock(); + _index.Add(CreateBuffer(0, 100, _fakeFileInfo.Object)); + _index.Add(CreateBuffer(100, 100, _fakeFileInfo.Object)); + _index.Add(CreateBuffer(200, 100, _fakeFileInfo2.Object)); + _index.Add(CreateBuffer(300, 100, _fakeFileInfo2.Object)); + + Assert.That(_index.TryGetNextFileStartLine(50).Found, Is.True); + Assert.That(_index.TryGetNextFileStartLine(50).StartLine, Is.EqualTo(200)); + } + + [Test] + public void TryGetNextFileStartLine_LastFile_ReturnsFalse () + { + using var w = _index.AcquireWriteLock(); + _index.Add(CreateBuffer(0, 100, _fakeFileInfo.Object)); + _index.Add(CreateBuffer(100, 100, _fakeFileInfo2.Object)); + + // Line in second (last) file — no next file + Assert.That(_index.TryGetNextFileStartLine(150).Found, Is.False); + } + + [Test] + public void TryGetPrevFileStartLine_TwoFiles_FindsBoundary () + { + using var w = _index.AcquireWriteLock(); + _index.Add(CreateBuffer(0, 100, _fakeFileInfo.Object)); + _index.Add(CreateBuffer(100, 100, _fakeFileInfo.Object)); + _index.Add(CreateBuffer(200, 100, _fakeFileInfo2.Object)); + _index.Add(CreateBuffer(300, 100, _fakeFileInfo2.Object)); + + // Line 250 is in file2 — prev file ends at line 200 (startLine + lineCount of last file1 buffer) + Assert.That(_index.TryGetPrevFileStartLine(250).Found, Is.True); + Assert.That(_index.TryGetPrevFileStartLine(250).StartLine, Is.EqualTo(200)); + } + + [Test] + public void TryGetPrevFileStartLine_FirstFile_ReturnsFalse () + { + using var w = _index.AcquireWriteLock(); + _index.Add(CreateBuffer(0, 100, _fakeFileInfo.Object)); + _index.Add(CreateBuffer(100, 100, _fakeFileInfo2.Object)); + + Assert.That(_index.TryGetPrevFileStartLine(50).Found, Is.False); + } + + [Test] + public void GetFirstBufferForFile_ReturnsEarliestBuffer () + { + using var w = _index.AcquireWriteLock(); + _index.Add(CreateBuffer(0, 100, _fakeFileInfo.Object)); + _index.Add(CreateBuffer(100, 100, _fakeFileInfo.Object)); + _index.Add(CreateBuffer(200, 100, _fakeFileInfo2.Object)); + + var logBufferEntry = _index.GetBufferForLineWithIndex(150); + var first = _index.GetFirstBufferForFile(logBufferEntry.Buffer!, logBufferEntry.Index); + Assert.That(first!.StartLine, Is.EqualTo(0)); + } + + #endregion + + #region LRU Eviction + + [Test] + public void EvictLeastRecentlyUsed_BelowThreshold_DoesNothing () + { + PopulateUniform(5, 10); // well below MaxBuffers=50 + + using var r = _index.AcquireReadLock(); + // Touch all buffers + for (var i = 0; i < 50; i += 10) + { + _ = _index.TryFindBuffer(i); + } + + _index.EvictLeastRecentlyUsed(); + + // All buffers still have content + for (var i = 0; i < 5; i++) + { + Assert.That(_index.GetBufferAt(i).IsDisposed, Is.False); + } + } + + [Test] + public void EvictLeastRecentlyUsed_AboveThreshold_EvictsOldest () + { + // Use a small maxBuffers so we can exceed it easily + _index.Dispose(); + _index = new BufferIndex(maxBuffers: 3, maxLinesPerBuffer: 10); + + using var w = _index.AcquireWriteLock(); + // Add 20 buffers (way above maxBuffers=3) + for (var i = 0; i < 20; i++) + { + _index.Add(CreateBuffer(i * 10, 10)); + } + + // Touch only the last 3 buffers (make them "recent") + _ = _index.TryFindBuffer(170); + _ = _index.TryFindBuffer(180); + _ = _index.TryFindBuffer(190); + + _index.EvictLeastRecentlyUsed(); + + // LRU cache should be reduced toward maxBuffers + Assert.That(_index.LruCacheCount, Is.LessThanOrEqualTo(3 + 10)); + } + + #endregion + + #region ClearLru + + [Test] + public void ClearLru_ClearsIndexBeforeReturningToPool () + { + var pool = new LogBufferPool(100); + + using var w = _index.AcquireWriteLock(); + _index.Add(CreateBuffer(0, 10)); + _index.Add(CreateBuffer(10, 10)); + + // Touch buffers to populate LRU + _ = _index.TryFindBuffer(0); + _ = _index.TryFindBuffer(10); + + Assert.That(_index.BufferCount, Is.EqualTo(2)); + Assert.That(_index.LruCacheCount, Is.GreaterThan(0)); + + _index.ClearLru(pool); + + // Index is empty — this is the bug fix: index clears FIRST + Assert.That(_index.BufferCount, Is.EqualTo(0)); + Assert.That(_index.LruCacheCount, Is.EqualTo(0)); + Assert.That(_index.TotalLineCount, Is.EqualTo(0)); + + // Verify no lookup succeeds after ClearLru (prevents stale reference bug) + Assert.That(_index.TryFindBuffer(0).Found, Is.False); + Assert.That(_index.TryFindBuffer(10).Found, Is.False); + } + + [Test] + public void ClearLru_BuffersReturnedToPool () + { + var pool = new LogBufferPool(100); + + using var w = _index.AcquireWriteLock(); + var buf1 = CreateBuffer(0, 10); + var buf2 = CreateBuffer(10, 10); + _index.Add(buf1); + _index.Add(buf2); + + // Touch to populate LRU + _ = _index.TryFindBuffer(0); + _ = _index.TryFindBuffer(10); + + _index.ClearLru(pool); + + // Buffers should be disposed (returned to pool disposes content) + Assert.That(buf1.IsDisposed, Is.True); + Assert.That(buf2.IsDisposed, Is.True); + } + + #endregion + + #region SnapShot + + [Test] + public void CreateSnapshot_CapturesCurrentState () + { + PopulateUniform(3, 100); + + var snapshot = _index.CreateSnapshot(); + Assert.That(snapshot.BufferCount, Is.EqualTo(3)); + Assert.That(snapshot.TotalLineCount, Is.EqualTo(300)); + Assert.That(snapshot.Buffers, Has.Count.EqualTo(3)); + + Assert.That(snapshot.Buffers[0].StartLine, Is.EqualTo(0)); + Assert.That(snapshot.Buffers[0].LineCount, Is.EqualTo(100)); + Assert.That(snapshot.Buffers[0].FileName, Is.EqualTo("fake1.log")); + + Assert.That(snapshot.Buffers[1].StartLine, Is.EqualTo(100)); + Assert.That(snapshot.Buffers[2].StartLine, Is.EqualTo(200)); + } + + [Test] + public void CreateSnapshot_ImmutableAfterModification () + { + PopulateUniform(2, 100); + + var snapshot = _index.CreateSnapshot(); + Assert.That(snapshot.BufferCount, Is.EqualTo(2)); + + // Add more buffers after snapshot + using var w = _index.AcquireWriteLock(); + _index.Add(CreateBuffer(200, 100)); + + // Snapshot is unchanged + Assert.That(snapshot.BufferCount, Is.EqualTo(2)); + Assert.That(snapshot.Buffers, Has.Count.EqualTo(2)); + } + + #endregion + + #region LockScopeTests + + [Test] + public void UpgradeableReadLock_CanUpgradeToWrite () + { + using var upgradeable = _index.AcquireUpgradeableReadLock(); + _index.Add(CreateBuffer(0, 10)); // Add requires at least upgradeable + + using (upgradeable.UpgradeToWrite()) + { + _index.Add(CreateBuffer(10, 10)); + } + + // Back to upgradeable-read + Assert.That(_index.BufferCount, Is.EqualTo(2)); + } + + [Test] + public void Dispose_CanBeCalledMultipleTimes () + { + _index.Dispose(); + Assert.DoesNotThrow(_index.Dispose); + } + + #endregion +} \ No newline at end of file diff --git a/src/LogExpert.Tests/BufferShiftTest.cs b/src/LogExpert.Tests/Buffers/BufferShiftTest.cs similarity index 84% rename from src/LogExpert.Tests/BufferShiftTest.cs rename to src/LogExpert.Tests/Buffers/BufferShiftTest.cs index b98367ec..8233da59 100644 --- a/src/LogExpert.Tests/BufferShiftTest.cs +++ b/src/LogExpert.Tests/Buffers/BufferShiftTest.cs @@ -7,7 +7,7 @@ using NUnit.Framework; -namespace LogExpert.Tests; +namespace LogExpert.Tests.Buffers; [TestFixture] internal class BufferShiftTest : RolloverHandlerTestBase @@ -94,12 +94,12 @@ public void TestShiftBuffers1 (ReaderType readerType) enumerator = files.GetEnumerator(); _ = enumerator.MoveNext(); - var logBuffers = reader.GetBufferList(); + var snapshot = reader.BufferIndex.CreateSnapshot(); var startLine = 0; - foreach (var logBuffer in logBuffers) + foreach (var logBuffer in snapshot.Buffers) { - Assert.That(enumerator.Current, Is.EqualTo(logBuffer.FileInfo.FullName)); + Assert.That(enumerator.Current, Is.EqualTo(logBuffer.FileName)); Assert.That(logBuffer.StartLine, Is.EqualTo(startLine)); startLine += 10; _ = enumerator.MoveNext(); @@ -109,36 +109,37 @@ public void TestShiftBuffers1 (ReaderType readerType) enumerator = files.GetEnumerator(); _ = enumerator.MoveNext(); _ = enumerator.MoveNext(); // move to 2nd entry. The first file now contains 2nd file's content (because rollover) - logBuffers = reader.GetBufferList(); + + snapshot = reader.BufferIndex.CreateSnapshot(); int i; - for (i = 0; i < logBuffers.Count - 2; ++i) + for (i = 0; i < snapshot.Buffers.Count - 2; ++i) { - var logBuffer = logBuffers[i]; - var line = logBuffer.GetLineMemoryOfBlock(0); - if (!line.HasValue) + var logBuffer = snapshot.Buffers[i]; + var line = reader.GetLogLineMemory(logBuffer.StartLine); + if (line == null) { Assert.Fail("Expected first block line to be present."); continue; } - Assert.That(line.Value.FullLine.Span.Contains(enumerator.Current.AsSpan(), StringComparison.Ordinal)); + Assert.That(line.FullLine.Span.Contains(enumerator.Current.AsSpan(), StringComparison.Ordinal)); _ = enumerator.MoveNext(); } // the last 2 files now contain the content of the previously watched file - for (; i < logBuffers.Count; ++i) + for (; i < snapshot.Buffers.Count; ++i) { - var logBuffer = logBuffers[i]; - var line = logBuffer.GetLineMemoryOfBlock(0); + var logBuffer = snapshot.Buffers[i]; + var line = reader.GetLogLineMemory(logBuffer.StartLine); - if (!line.HasValue) + if (line == null) { Assert.Fail("Expected first block line to be present."); continue; } - Assert.That(line.Value.FullLine.Span.Contains(enumerator.Current.AsSpan(), StringComparison.Ordinal)); + Assert.That(line.FullLine.Span.Contains(enumerator.Current.AsSpan(), StringComparison.Ordinal)); } oldCount = lil.Count; diff --git a/src/LogExpert.Tests/Buffers/CharBlockAllocatorTests.cs b/src/LogExpert.Tests/Buffers/CharBlockAllocatorTests.cs new file mode 100644 index 00000000..b0004884 --- /dev/null +++ b/src/LogExpert.Tests/Buffers/CharBlockAllocatorTests.cs @@ -0,0 +1,130 @@ +using System.Buffers; + +using LogExpert.Core.Classes.Log.Buffers; + +using NUnit.Framework; + +namespace LogExpert.Tests.Buffers; + +[TestFixture] +public class CharBlockAllocatorTests +{ + [Test] + public void Rent_SmallAllocation_ReturnsMemoryFromSameBlock () + { + using var allocator = new CharBlockAllocator(1024); + + var mem1 = allocator.Rent(100); + var mem2 = allocator.Rent(100); + + Assert.That(allocator.BlockCount, Is.EqualTo(1)); + Assert.That(mem1.Length, Is.EqualTo(100)); + Assert.That(mem2.Length, Is.EqualTo(100)); + } + + [Test] + public void Rent_ExceedsBlock_AllocatesNewBlock () + { + using var allocator = new CharBlockAllocator(128); + + var mem1 = allocator.Rent(100); + var mem2 = allocator.Rent(100); // won't fit in first block + + Assert.That(allocator.BlockCount, Is.EqualTo(2)); + Assert.That(mem1.Length, Is.EqualTo(100)); + Assert.That(mem2.Length, Is.EqualTo(100)); + } + + [Test] + public void Rent_OversizedLine_GetsStandaloneArray () + { + using var allocator = new CharBlockAllocator(128); + + var mem = allocator.Rent(256); + + Assert.That(allocator.BlockCount, Is.EqualTo(1)); // normal blocks unchanged + Assert.That(allocator.OversizedBlockCount, Is.EqualTo(1)); // tracked separately + Assert.That(mem.Length, Is.EqualTo(256)); + } + + [Test] + public void Rent_ZeroLength_ReturnsEmpty () + { + using var allocator = new CharBlockAllocator(128); + + var mem = allocator.Rent(0); + + Assert.That(mem.IsEmpty, Is.True); + } + + [Test] + public void DetachBlocks_ReturnsNormalBlocks_ResetsAllocator () + { + using var allocator = new CharBlockAllocator(128); + + _ = allocator.Rent(100); + _ = allocator.Rent(100); // triggers second block + + var blocks = allocator.DetachBlocks(); + + Assert.That(blocks, Has.Count.EqualTo(2)); + Assert.That(allocator.BlockCount, Is.EqualTo(1)); // fresh block created + } + + [Test] + public void DetachBlocks_ReturnsOversizedImmediately () + { + using var allocator = new CharBlockAllocator(128); + + _ = allocator.Rent(100); // normal + _ = allocator.Rent(256); // oversized + _ = allocator.Rent(50); // normal (fits in second block) + + Assert.That(allocator.OversizedBlockCount, Is.EqualTo(1)); + + var blocks = allocator.DetachBlocks(); + + // All blocks (normal + oversized) are transferred to caller + Assert.That(blocks, Has.Count.EqualTo(3)); // initial + second normal + oversized + Assert.That(allocator.OversizedBlockCount, Is.EqualTo(0)); // transferred to caller + } + + [Test] + public void ReturnAll_ReturnsBlocksToPool () + { + var allocator = new CharBlockAllocator(128); + + _ = allocator.Rent(100); + _ = allocator.Rent(100); + + allocator.ReturnAll(); + + Assert.That(allocator.BlockCount, Is.EqualTo(0)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void SlicesAreIndependent () + { + using var allocator = new CharBlockAllocator(1024); + + var mem1 = allocator.Rent(5); + "Hello".AsSpan().CopyTo(mem1.Span); + + var mem2 = allocator.Rent(5); + "World".AsSpan().CopyTo(mem2.Span); + + Assert.That(mem1.Span.ToString(), Is.EqualTo("Hello")); + Assert.That(mem2.Span.ToString(), Is.EqualTo("World")); + } + + [Test] + public void Dispose_IsIdempotent () + { + var allocator = new CharBlockAllocator(128); + _ = allocator.Rent(64); + + allocator.Dispose(); + allocator.Dispose(); // should not throw + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/Buffers/LogBufferCharBlockTests.cs b/src/LogExpert.Tests/Buffers/LogBufferCharBlockTests.cs new file mode 100644 index 00000000..4f270412 --- /dev/null +++ b/src/LogExpert.Tests/Buffers/LogBufferCharBlockTests.cs @@ -0,0 +1,193 @@ +using System.Buffers; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Log.Buffers; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Buffers; + +[TestFixture] +public class LogBufferCharBlockTests +{ + private Mock _mockFileInfo; + + [SetUp] + public void Setup () + { + _mockFileInfo = new Mock(); + _ = _mockFileInfo.Setup(f => f.FullName).Returns("test.log"); + } + + [Test] + public void AttachCharBlocks_AcceptsBlockList () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + var blocks = new List + { + ArrayPool.Shared.Rent(128), + ArrayPool.Shared.Rent(128) + }; + + // Should not throw + buffer.AttachCharBlocks(blocks); + + // Clean up via eviction + buffer.EvictContent(); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void EvictContent_ReturnsAttachedCharBlocks () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + var block = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([block]); + + // Add a line so there's something to evict + var lineMemory = "test line".AsMemory(); + buffer.AddLine(new LogLine(lineMemory, 0), 0); + + buffer.EvictContent(); + + // After eviction, the block should have been returned to the pool. + // We can't directly assert pool return, but we can verify no exception + // and that re-attaching new blocks works. + var newBlock = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([newBlock]); + buffer.EvictContent(); // clean up + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void DisposeContent_ReturnsAttachedCharBlocks () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + var block = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([block]); + + buffer.AddLine(new LogLine("test".AsMemory(), 0), 0); + + buffer.DisposeContent(); + + // Should be able to reinitialise and attach new blocks + buffer.Reinitialise(_mockFileInfo.Object, 10); + var newBlock = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([newBlock]); + buffer.EvictContent(); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void ClearLines_ReturnsAttachedCharBlocks () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + var block = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([block]); + + buffer.AddLine(new LogLine("test".AsMemory(), 0), 0); + + buffer.ClearLines(); + + Assert.That(buffer.LineCount, Is.EqualTo(0)); + + // Attach new blocks after clear + var newBlock = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([newBlock]); + buffer.EvictContent(); + } + + [Test] + public void Reinitialise_ReturnsAttachedCharBlocks () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + var block = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([block]); + + buffer.Reinitialise(_mockFileInfo.Object, 10); + + // After reinitialise, old blocks should be returned. + // Verify by attaching new blocks (no double-return crash). + var newBlock = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([newBlock]); + buffer.EvictContent(); + } + + [Test] + public void AttachCharBlocks_ReturnsOldBlocks_WhenCalledTwice () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + + var block1 = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([block1]); + + // Attaching new blocks should return the old ones first + var block2 = ArrayPool.Shared.Rent(128); + buffer.AttachCharBlocks([block2]); + + buffer.EvictContent(); + } + + [Test] + public void AttachCharBlocks_Null_DoesNotThrow () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + + buffer.AttachCharBlocks(null); + buffer.EvictContent(); // should not throw + } + + [Test] + public void AttachCharBlocks_EmptyList_DoesNotThrow () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + + buffer.AttachCharBlocks([]); + buffer.EvictContent(); // should not throw + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void EvictContent_WithoutAttachedBlocks_DoesNotThrow () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + buffer.AddLine(new LogLine("test".AsMemory(), 0), 0); + + // No char blocks attached — should evict cleanly (backward compat) + buffer.EvictContent(); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void BlockBackedLines_ContentSurvivesUntilEviction () + { + var buffer = new LogBuffer(_mockFileInfo.Object, 10); + + // Simulate block-based allocation: rent a block, write lines into it + var block = ArrayPool.Shared.Rent(1024); + "Hello World".AsSpan().CopyTo(block.AsSpan(0, 11)); + "Second Line".AsSpan().CopyTo(block.AsSpan(11, 11)); + + var line1Memory = new ReadOnlyMemory(block, 0, 11); + var line2Memory = new ReadOnlyMemory(block, 11, 11); + + buffer.AddLine(new LogLine(line1Memory, 0), 0); + buffer.AddLine(new LogLine(line2Memory, 1), 11); + buffer.AttachCharBlocks([block]); + + // Lines should be readable while buffer is alive + var retrieved1 = buffer.GetLineMemoryOfBlock(0); + var retrieved2 = buffer.GetLineMemoryOfBlock(1); + Assert.That(retrieved1.HasValue, Is.True); + Assert.That(retrieved2.HasValue, Is.True); + Assert.That(retrieved1.Value.FullLine.Span.ToString(), Is.EqualTo("Hello World")); + Assert.That(retrieved2.Value.FullLine.Span.ToString(), Is.EqualTo("Second Line")); + + // After eviction, blocks are returned and lines are no longer accessible + buffer.EvictContent(); + Assert.That(buffer.IsDisposed, Is.True); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/CSVColumnizerTest.cs b/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs similarity index 98% rename from src/LogExpert.Tests/CSVColumnizerTest.cs rename to src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs index 24fae246..35445f9b 100644 --- a/src/LogExpert.Tests/CSVColumnizerTest.cs +++ b/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs @@ -8,7 +8,7 @@ using NUnit.Framework; -namespace LogExpert.Tests; +namespace LogExpert.Tests.ColumnizerTests; [TestFixture] public class CSVColumnizerTest diff --git a/src/LogExpert.Tests/ColumnizerJsonConverterTests.cs b/src/LogExpert.Tests/ColumnizerTests/ColumnizerJsonConverterTests.cs similarity index 99% rename from src/LogExpert.Tests/ColumnizerJsonConverterTests.cs rename to src/LogExpert.Tests/ColumnizerTests/ColumnizerJsonConverterTests.cs index 4645bb7e..e8edb337 100644 --- a/src/LogExpert.Tests/ColumnizerJsonConverterTests.cs +++ b/src/LogExpert.Tests/ColumnizerTests/ColumnizerJsonConverterTests.cs @@ -7,7 +7,7 @@ using NUnit.Framework; -namespace LogExpert.Tests; +namespace LogExpert.Tests.ColumnizerTests; public class MockColumnizer : ILogLineMemoryColumnizer { diff --git a/src/LogExpert.Tests/ColumnizerPickerTest.cs b/src/LogExpert.Tests/ColumnizerTests/ColumnizerPickerTest.cs similarity index 97% rename from src/LogExpert.Tests/ColumnizerPickerTest.cs rename to src/LogExpert.Tests/ColumnizerTests/ColumnizerPickerTest.cs index e6ff80c3..3d69741e 100644 --- a/src/LogExpert.Tests/ColumnizerPickerTest.cs +++ b/src/LogExpert.Tests/ColumnizerTests/ColumnizerPickerTest.cs @@ -1,162 +1,162 @@ -using System.Reflection; - -using ColumnizerLib; - -using LogExpert.Core.Classes.Columnizer; -using LogExpert.Core.Classes.Log; -using LogExpert.Core.Entities; -using LogExpert.Core.Enums; - -using Moq; - -using NUnit.Framework; - -namespace LogExpert.Tests; - -/// -/// Summary description for AutoColumnizerTest -/// -[TestFixture] -public class ColumnizerPickerTest -{ - [SetUp] - public void Setup () - { - // Reset singleton for testing (same pattern as PluginRegistryTests) - ResetPluginRegistrySingleton(); - - // Initialize plugin registry with proper test directory - var testDataPath = Path.Join(Path.GetTempPath(), "LogExpertTests", Guid.NewGuid().ToString()); - _ = Directory.CreateDirectory(testDataPath); - - var pluginRegistry = PluginRegistry.PluginRegistry.Create(testDataPath, 250); - - // Verify the local file system plugin is registered - var localPlugin = pluginRegistry.FindFileSystemForUri(@"C:\test.txt"); - Assert.That(localPlugin, Is.Not.Null, "Local file system plugin not registered!"); - } - - [TearDown] - public void TearDown () - { - ResetPluginRegistrySingleton(); - } - - /// - /// Uses reflection to reset the singleton instance for testing. - /// This ensures each test starts with a fresh PluginRegistry state. - /// - private static void ResetPluginRegistrySingleton () - { - var instanceField = typeof(PluginRegistry.PluginRegistry).GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic); - instanceField?.SetValue(null, null); - } - - [TestCase("Square Bracket Columnizer", "30/08/2018 08:51:42.712 [TRACE] [a] hello", "30/08/2018 08:51:42.712 [DATAIO] [b] world", null, null, null)] - [TestCase("Square Bracket Columnizer", "30/08/2018 08:51:42.712 [TRACE] hello", "30/08/2018 08:51:42.712 [DATAIO][] world", null, null, null)] - [TestCase("Square Bracket Columnizer", "", "30/08/2018 08:51:42.712 [TRACE] hello", "30/08/2018 08:51:42.712 [TRACE] hello", "[DATAIO][b][c] world", null)] - [TestCase("Timestamp Columnizer", "30/08/2018 08:51:42.712 no bracket 1", "30/08/2018 08:51:42.712 no bracket 2", "30/08/2018 08:51:42.712 [TRACE] with bracket 1", "30/08/2018 08:51:42.712 [TRACE] with bracket 2", "no bracket 3")] - public void FindColumnizer_ReturnCorrectColumnizer (string expectedColumnizerName, string line0, string line1, string line2, string line3, string line4) - { - var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "test"); - - Mock autoLogLineColumnizerCallbackMock = new(); - - // Mock GetLogLineMemory() which returns ILogLineMemory - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(0)).Returns(new TestLogLineMemory() - { - FullLine = line0?.AsMemory() ?? ReadOnlyMemory.Empty, - LineNumber = 0 - }); - - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(1)).Returns(new TestLogLineMemory() - { - FullLine = line1?.AsMemory() ?? ReadOnlyMemory.Empty, - LineNumber = 1 - }); - - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(2)).Returns(new TestLogLineMemory() - { - FullLine = line2?.AsMemory() ?? ReadOnlyMemory.Empty, - LineNumber = 2 - }); - - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(3)).Returns(new TestLogLineMemory() - { - FullLine = line3?.AsMemory() ?? ReadOnlyMemory.Empty, - LineNumber = 3 - }); - - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(4)).Returns(new TestLogLineMemory() - { - FullLine = line4?.AsMemory() ?? ReadOnlyMemory.Empty, - LineNumber = 4 - }); - - // Mock for additional sampled lines that ColumnizerPicker checks - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(5)).Returns((ILogLineMemory)null); - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(25)).Returns((ILogLineMemory)null); - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(100)).Returns((ILogLineMemory)null); - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(200)).Returns((ILogLineMemory)null); - _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(400)).Returns((ILogLineMemory)null); - - var result = ColumnizerPicker.FindMemoryColumnizer(path, autoLogLineColumnizerCallbackMock.Object, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); - - Assert.That(result.GetName(), Is.EqualTo(expectedColumnizerName)); - } - - [TestCase(@".\TestData\JsonColumnizerTest_01.txt", typeof(JsonCompactColumnizer.JsonCompactColumnizer), ReaderType.System)] - [TestCase(@".\TestData\SquareBracketColumnizerTest_02.txt", typeof(SquareBracketColumnizer), ReaderType.System)] - public void FindReplacementForAutoColumnizer_ValidTextFile_ReturnCorrectColumnizer (string fileName, Type columnizerType, ReaderType readerType) - { - var pluginRegistry = PluginRegistry.PluginRegistry.Instance; - - var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, fileName); - LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), readerType, pluginRegistry, 500); - reader.ReadFiles(); - - Mock autoColumnizer = new(); - _ = autoColumnizer.Setup(a => a.GetName()).Returns("Auto Columnizer"); - - // TODO: When DI container is ready, we can mock this set up. - PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers.Add(new JsonCompactColumnizer.JsonCompactColumnizer()); - var result = ColumnizerPicker.FindReplacementForAutoMemoryColumnizer(fileName, reader, autoColumnizer.Object, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); - - Assert.That(columnizerType, Is.EqualTo(result.GetType())); - } - - [TestCase(@".\TestData\FileNotExists.txt", typeof(DefaultLogfileColumnizer))] - public void DecideColumnizerByName_WhenReaderIsNotReady_ReturnCorrectColumnizer (string fileName, Type columnizerType) - { - // TODO: When DI container is ready, we can mock this set up. - PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers.Add(new JsonCompactColumnizer.JsonCompactColumnizer()); - var result = ColumnizerPicker.DecideMemoryColumnizerByName(fileName, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); - - Assert.That(columnizerType, Is.EqualTo(result.GetType())); - } - - [TestCase(@"Invalid Name", typeof(DefaultLogfileColumnizer))] - [TestCase(@"JSON Columnizer", typeof(JsonColumnizer.JsonColumnizer))] - public void DecideColumnizerByName_ValidTextFile_ReturnCorrectColumnizer (string columnizerName, Type columnizerType) - { - // TODO: When DI container is ready, we can mock this set up. - PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers.Add(new JsonColumnizer.JsonColumnizer()); - - var result = ColumnizerPicker.DecideMemoryColumnizerByName(columnizerName, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); - - Assert.That(columnizerType, Is.EqualTo(result.GetType())); - } - - /// - /// Test helper class that implements ILogLineMemory for mocking log lines. - /// - private class TestLogLineMemory : ILogLineMemory - { - public ReadOnlyMemory FullLine { get; set; } - - public int LineNumber { get; set; } - - // Explicit implementation for ITextValueMemory.Text (ReadOnlyMemory version) - ReadOnlyMemory ITextValueMemory.Text => FullLine; - } +using System.Reflection; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Columnizer; +using LogExpert.Core.Classes.Log; +using LogExpert.Core.Entities; +using LogExpert.Core.Enums; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.ColumnizerTests; + +/// +/// Summary description for AutoColumnizerTest +/// +[TestFixture] +public class ColumnizerPickerTest +{ + [SetUp] + public void Setup () + { + // Reset singleton for testing (same pattern as PluginRegistryTests) + ResetPluginRegistrySingleton(); + + // Initialize plugin registry with proper test directory + var testDataPath = Path.Join(Path.GetTempPath(), "LogExpertTests", Guid.NewGuid().ToString()); + _ = Directory.CreateDirectory(testDataPath); + + var pluginRegistry = PluginRegistry.PluginRegistry.Create(testDataPath, 250); + + // Verify the local file system plugin is registered + var localPlugin = pluginRegistry.FindFileSystemForUri(@"C:\test.txt"); + Assert.That(localPlugin, Is.Not.Null, "Local file system plugin not registered!"); + } + + [TearDown] + public void TearDown () + { + ResetPluginRegistrySingleton(); + } + + /// + /// Uses reflection to reset the singleton instance for testing. + /// This ensures each test starts with a fresh PluginRegistry state. + /// + private static void ResetPluginRegistrySingleton () + { + var instanceField = typeof(PluginRegistry.PluginRegistry).GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic); + instanceField?.SetValue(null, null); + } + + [TestCase("Square Bracket Columnizer", "30/08/2018 08:51:42.712 [TRACE] [a] hello", "30/08/2018 08:51:42.712 [DATAIO] [b] world", null, null, null)] + [TestCase("Square Bracket Columnizer", "30/08/2018 08:51:42.712 [TRACE] hello", "30/08/2018 08:51:42.712 [DATAIO][] world", null, null, null)] + [TestCase("Square Bracket Columnizer", "", "30/08/2018 08:51:42.712 [TRACE] hello", "30/08/2018 08:51:42.712 [TRACE] hello", "[DATAIO][b][c] world", null)] + [TestCase("Timestamp Columnizer", "30/08/2018 08:51:42.712 no bracket 1", "30/08/2018 08:51:42.712 no bracket 2", "30/08/2018 08:51:42.712 [TRACE] with bracket 1", "30/08/2018 08:51:42.712 [TRACE] with bracket 2", "no bracket 3")] + public void FindColumnizer_ReturnCorrectColumnizer (string expectedColumnizerName, string line0, string line1, string line2, string line3, string line4) + { + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "test"); + + Mock autoLogLineColumnizerCallbackMock = new(); + + // Mock GetLogLineMemory() which returns ILogLineMemory + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(0)).Returns(new TestLogLineMemory() + { + FullLine = line0?.AsMemory() ?? ReadOnlyMemory.Empty, + LineNumber = 0 + }); + + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(1)).Returns(new TestLogLineMemory() + { + FullLine = line1?.AsMemory() ?? ReadOnlyMemory.Empty, + LineNumber = 1 + }); + + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(2)).Returns(new TestLogLineMemory() + { + FullLine = line2?.AsMemory() ?? ReadOnlyMemory.Empty, + LineNumber = 2 + }); + + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(3)).Returns(new TestLogLineMemory() + { + FullLine = line3?.AsMemory() ?? ReadOnlyMemory.Empty, + LineNumber = 3 + }); + + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(4)).Returns(new TestLogLineMemory() + { + FullLine = line4?.AsMemory() ?? ReadOnlyMemory.Empty, + LineNumber = 4 + }); + + // Mock for additional sampled lines that ColumnizerPicker checks + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(5)).Returns((ILogLineMemory)null); + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(25)).Returns((ILogLineMemory)null); + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(100)).Returns((ILogLineMemory)null); + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(200)).Returns((ILogLineMemory)null); + _ = autoLogLineColumnizerCallbackMock.Setup(a => a.GetLogLineMemory(400)).Returns((ILogLineMemory)null); + + var result = ColumnizerPicker.FindMemoryColumnizer(path, autoLogLineColumnizerCallbackMock.Object, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); + + Assert.That(result.GetName(), Is.EqualTo(expectedColumnizerName)); + } + + [TestCase(@".\TestData\JsonColumnizerTest_01.txt", typeof(JsonCompactColumnizer.JsonCompactColumnizer), ReaderType.System)] + [TestCase(@".\TestData\SquareBracketColumnizerTest_02.txt", typeof(SquareBracketColumnizer), ReaderType.System)] + public void FindReplacementForAutoColumnizer_ValidTextFile_ReturnCorrectColumnizer (string fileName, Type columnizerType, ReaderType readerType) + { + var pluginRegistry = PluginRegistry.PluginRegistry.Instance; + + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, fileName); + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), readerType, pluginRegistry, 500); + reader.ReadFiles(); + + Mock autoColumnizer = new(); + _ = autoColumnizer.Setup(a => a.GetName()).Returns("Auto Columnizer"); + + // TODO: When DI container is ready, we can mock this set up. + PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers.Add(new JsonCompactColumnizer.JsonCompactColumnizer()); + var result = ColumnizerPicker.FindReplacementForAutoMemoryColumnizer(fileName, reader, autoColumnizer.Object, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); + + Assert.That(columnizerType, Is.EqualTo(result.GetType())); + } + + [TestCase(@".\TestData\FileNotExists.txt", typeof(DefaultLogfileColumnizer))] + public void DecideColumnizerByName_WhenReaderIsNotReady_ReturnCorrectColumnizer (string fileName, Type columnizerType) + { + // TODO: When DI container is ready, we can mock this set up. + PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers.Add(new JsonCompactColumnizer.JsonCompactColumnizer()); + var result = ColumnizerPicker.DecideMemoryColumnizerByName(fileName, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); + + Assert.That(columnizerType, Is.EqualTo(result.GetType())); + } + + [TestCase(@"Invalid Name", typeof(DefaultLogfileColumnizer))] + [TestCase(@"JSON Columnizer", typeof(JsonColumnizer.JsonColumnizer))] + public void DecideColumnizerByName_ValidTextFile_ReturnCorrectColumnizer (string columnizerName, Type columnizerType) + { + // TODO: When DI container is ready, we can mock this set up. + PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers.Add(new JsonColumnizer.JsonColumnizer()); + + var result = ColumnizerPicker.DecideMemoryColumnizerByName(columnizerName, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); + + Assert.That(columnizerType, Is.EqualTo(result.GetType())); + } + + /// + /// Test helper class that implements ILogLineMemory for mocking log lines. + /// + private class TestLogLineMemory : ILogLineMemory + { + public ReadOnlyMemory FullLine { get; set; } + + public int LineNumber { get; set; } + + // Explicit implementation for ITextValueMemory.Text (ReadOnlyMemory version) + ReadOnlyMemory ITextValueMemory.Text => FullLine; + } } \ No newline at end of file diff --git a/src/LogExpert.Tests/JsonColumnizerTest.cs b/src/LogExpert.Tests/ColumnizerTests/JsonColumnizerTest.cs similarity index 98% rename from src/LogExpert.Tests/JsonColumnizerTest.cs rename to src/LogExpert.Tests/ColumnizerTests/JsonColumnizerTest.cs index 46fb007d..fc344da5 100644 --- a/src/LogExpert.Tests/JsonColumnizerTest.cs +++ b/src/LogExpert.Tests/ColumnizerTests/JsonColumnizerTest.cs @@ -6,7 +6,7 @@ using NUnit.Framework; -namespace LogExpert.Tests; +namespace LogExpert.Tests.ColumnizerTests; [TestFixture] public class JsonColumnizerTest diff --git a/src/LogExpert.Tests/JsonCompactColumnizerTest.cs b/src/LogExpert.Tests/ColumnizerTests/JsonCompactColumnizerTest.cs similarity index 96% rename from src/LogExpert.Tests/JsonCompactColumnizerTest.cs rename to src/LogExpert.Tests/ColumnizerTests/JsonCompactColumnizerTest.cs index 2ac88e03..e16118ab 100644 --- a/src/LogExpert.Tests/JsonCompactColumnizerTest.cs +++ b/src/LogExpert.Tests/ColumnizerTests/JsonCompactColumnizerTest.cs @@ -1,77 +1,77 @@ -using System.Reflection; - -using ColumnizerLib; - -using LogExpert.Core.Classes.Log; -using LogExpert.Core.Entities; -using LogExpert.Core.Enums; - -using NUnit.Framework; - -namespace LogExpert.Tests; - -[TestFixture] -public class JsonCompactColumnizerTest -{ - [SetUp] - public void Setup () - { - // Reset singleton for testing (same pattern as PluginRegistryTests) - ResetPluginRegistrySingleton(); - - // Initialize plugin registry with proper test directory - var testDataPath = Path.Join(Path.GetTempPath(), "LogExpertTests", Guid.NewGuid().ToString()); - _ = Directory.CreateDirectory(testDataPath); - - var pluginRegistry = PluginRegistry.PluginRegistry.Create(testDataPath, 250); - - // Verify the local file system plugin is registered - var localPlugin = pluginRegistry.FindFileSystemForUri(@"C:\test.txt"); - Assert.That(localPlugin, Is.Not.Null, "Local file system plugin not registered!"); - } - - [TearDown] - public void TearDown () - { - ResetPluginRegistrySingleton(); - } - - /// - /// Uses reflection to reset the singleton instance for testing. - /// This ensures each test starts with a fresh PluginRegistry state. - /// - private static void ResetPluginRegistrySingleton () - { - var instanceField = typeof(PluginRegistry.PluginRegistry).GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic); - instanceField?.SetValue(null, null); - } - - // As long as the json file contains one of the pre-defined key, it's perfectly supported. - [TestCase(@".\TestData\JsonCompactColumnizerTest_01.json", Priority.PerfectlySupport, ReaderType.System)] - [TestCase(@".\TestData\JsonCompactColumnizerTest_02.json", Priority.PerfectlySupport, ReaderType.System)] - [TestCase(@".\TestData\JsonCompactColumnizerTest_03.json", Priority.WellSupport, ReaderType.System)] - public void GetPriority_HappyFile_PriorityMatches (string fileName, Priority priority, ReaderType readerType) - { - var jsonCompactColumnizer = new JsonCompactColumnizer.JsonCompactColumnizer(); - var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, fileName); - LogfileReader logFileReader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), readerType, PluginRegistry.PluginRegistry.Instance, 500); - logFileReader.ReadFiles(); - List loglines = - [ - // Sampling a few lines to select the correct columnizer - logFileReader.GetLogLineMemory(0), - logFileReader.GetLogLineMemory(1), - logFileReader.GetLogLineMemory(2), - logFileReader.GetLogLineMemory(3), - logFileReader.GetLogLineMemory(4), - logFileReader.GetLogLineMemory(5), - logFileReader.GetLogLineMemory(25), - logFileReader.GetLogLineMemory(100), - logFileReader.GetLogLineMemory(200), - logFileReader.GetLogLineMemory(400) - ]; - - var result = jsonCompactColumnizer.GetPriority(path, loglines); - Assert.That(result, Is.EqualTo(priority)); - } +using System.Reflection; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Log; +using LogExpert.Core.Entities; +using LogExpert.Core.Enums; + +using NUnit.Framework; + +namespace LogExpert.Tests.ColumnizerTests; + +[TestFixture] +public class JsonCompactColumnizerTest +{ + [SetUp] + public void Setup () + { + // Reset singleton for testing (same pattern as PluginRegistryTests) + ResetPluginRegistrySingleton(); + + // Initialize plugin registry with proper test directory + var testDataPath = Path.Join(Path.GetTempPath(), "LogExpertTests", Guid.NewGuid().ToString()); + _ = Directory.CreateDirectory(testDataPath); + + var pluginRegistry = PluginRegistry.PluginRegistry.Create(testDataPath, 250); + + // Verify the local file system plugin is registered + var localPlugin = pluginRegistry.FindFileSystemForUri(@"C:\test.txt"); + Assert.That(localPlugin, Is.Not.Null, "Local file system plugin not registered!"); + } + + [TearDown] + public void TearDown () + { + ResetPluginRegistrySingleton(); + } + + /// + /// Uses reflection to reset the singleton instance for testing. + /// This ensures each test starts with a fresh PluginRegistry state. + /// + private static void ResetPluginRegistrySingleton () + { + var instanceField = typeof(PluginRegistry.PluginRegistry).GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic); + instanceField?.SetValue(null, null); + } + + // As long as the json file contains one of the pre-defined key, it's perfectly supported. + [TestCase(@".\TestData\JsonCompactColumnizerTest_01.json", Priority.PerfectlySupport, ReaderType.System)] + [TestCase(@".\TestData\JsonCompactColumnizerTest_02.json", Priority.PerfectlySupport, ReaderType.System)] + [TestCase(@".\TestData\JsonCompactColumnizerTest_03.json", Priority.WellSupport, ReaderType.System)] + public void GetPriority_HappyFile_PriorityMatches (string fileName, Priority priority, ReaderType readerType) + { + var jsonCompactColumnizer = new JsonCompactColumnizer.JsonCompactColumnizer(); + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, fileName); + LogfileReader logFileReader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), readerType, PluginRegistry.PluginRegistry.Instance, 500); + logFileReader.ReadFiles(); + List loglines = + [ + // Sampling a few lines to select the correct columnizer + logFileReader.GetLogLineMemory(0), + logFileReader.GetLogLineMemory(1), + logFileReader.GetLogLineMemory(2), + logFileReader.GetLogLineMemory(3), + logFileReader.GetLogLineMemory(4), + logFileReader.GetLogLineMemory(5), + logFileReader.GetLogLineMemory(25), + logFileReader.GetLogLineMemory(100), + logFileReader.GetLogLineMemory(200), + logFileReader.GetLogLineMemory(400) + ]; + + var result = jsonCompactColumnizer.GetPriority(path, loglines); + Assert.That(result, Is.EqualTo(priority)); + } } \ No newline at end of file diff --git a/src/LogExpert.Tests/SquareBracketColumnizerTest.cs b/src/LogExpert.Tests/ColumnizerTests/SquareBracketColumnizerTest.cs similarity index 98% rename from src/LogExpert.Tests/SquareBracketColumnizerTest.cs rename to src/LogExpert.Tests/ColumnizerTests/SquareBracketColumnizerTest.cs index 1b6f017f..01f99022 100644 --- a/src/LogExpert.Tests/SquareBracketColumnizerTest.cs +++ b/src/LogExpert.Tests/ColumnizerTests/SquareBracketColumnizerTest.cs @@ -9,7 +9,7 @@ using NUnit.Framework; -namespace LogExpert.Tests; +namespace LogExpert.Tests.ColumnizerTests; [TestFixture] public class SquareBracketColumnizerTest diff --git a/src/LogExpert.Tests/ConfigManagerPortableModeTests.cs b/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerPortableModeTests.cs similarity index 96% rename from src/LogExpert.Tests/ConfigManagerPortableModeTests.cs rename to src/LogExpert.Tests/ConfigManagerTests/ConfigManagerPortableModeTests.cs index 4f7a3206..bb98741f 100644 --- a/src/LogExpert.Tests/ConfigManagerPortableModeTests.cs +++ b/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerPortableModeTests.cs @@ -1,10 +1,8 @@ using System.Reflection; -using LogExpert.Configuration; - using NUnit.Framework; -namespace LogExpert.Tests; +namespace LogExpert.Tests.ConfigManagerTests; /// /// Unit tests for ConfigManager portable mode functionality. @@ -15,7 +13,7 @@ namespace LogExpert.Tests; public class ConfigManagerPortableModeTests { private string _testDir; - private ConfigManager _configManager; + private Configuration.ConfigManager _configManager; [SetUp] public void SetUp () @@ -25,7 +23,7 @@ public void SetUp () _ = Directory.CreateDirectory(_testDir); // Initialize ConfigManager for testing - _configManager = ConfigManager.Instance; + _configManager = Configuration.ConfigManager.Instance; // Reset the singleton's initialization state using reflection ResetConfigManagerInitialization(); @@ -71,11 +69,11 @@ public void TearDown () /// private void ResetConfigManagerInitialization () { - var isInitializedField = typeof(ConfigManager).GetField("_isInitialized", BindingFlags.NonPublic | BindingFlags.Instance); + var isInitializedField = typeof(Configuration.ConfigManager).GetField("_isInitialized", BindingFlags.NonPublic | BindingFlags.Instance); isInitializedField?.SetValue(_configManager, false); // Reset settings so they reload from the new path - var settingsField = typeof(ConfigManager).GetField("_settings", BindingFlags.NonPublic | BindingFlags.Instance); + var settingsField = typeof(Configuration.ConfigManager).GetField("_settings", BindingFlags.NonPublic | BindingFlags.Instance); settingsField?.SetValue(_configManager, null); } @@ -85,7 +83,7 @@ private void ResetConfigManagerInitialization () [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", Justification = "Unit Tests")] private void InvokePrivateInstanceMethod (string methodName, params object[] parameters) { - MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance) + MethodInfo? method = typeof(Configuration.ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new Exception($"Instance method {methodName} not found"); _ = method.Invoke(_configManager, parameters); @@ -654,7 +652,7 @@ public void MoveFileIfExists_MovesFile () File.WriteAllText(source, "test content"); // Act - var method = typeof(ConfigManager).GetMethod("MoveFileIfExists", BindingFlags.NonPublic | BindingFlags.Static); + var method = typeof(Configuration.ConfigManager).GetMethod("MoveFileIfExists", BindingFlags.NonPublic | BindingFlags.Static); _ = (method?.Invoke(null, [source, target])); // Assert @@ -673,7 +671,7 @@ public void MoveFileIfExists_NonExistentSource_DoesNothing () var target = Path.Join(_testDir, "target.txt"); // Act - var method = typeof(ConfigManager).GetMethod("MoveFileIfExists", BindingFlags.NonPublic | BindingFlags.Static); + var method = typeof(Configuration.ConfigManager).GetMethod("MoveFileIfExists", BindingFlags.NonPublic | BindingFlags.Static); _ = (method?.Invoke(null, [source, target])); // Assert @@ -691,7 +689,7 @@ public void CopyFileIfExists_CopiesFile () File.WriteAllText(source, "copy me"); // Act - var method = typeof(ConfigManager).GetMethod("CopyFileIfExists", BindingFlags.NonPublic | BindingFlags.Static); + var method = typeof(Configuration.ConfigManager).GetMethod("CopyFileIfExists", BindingFlags.NonPublic | BindingFlags.Static); _ = (method?.Invoke(null, [source, target])); // Assert @@ -712,7 +710,7 @@ public void CopyFileIfNotExists_DoesNotOverwrite () File.WriteAllText(target, "existing content"); // Act - var method = typeof(ConfigManager).GetMethod("CopyFileIfNotExists", BindingFlags.NonPublic | BindingFlags.Static); + var method = typeof(Configuration.ConfigManager).GetMethod("CopyFileIfNotExists", BindingFlags.NonPublic | BindingFlags.Static); _ = (method?.Invoke(null, [source, target])); //Assert @@ -736,7 +734,7 @@ public void CopyDirectoryRecursive_CopiesEverything () File.WriteAllText(Path.Join(subDir, "file2.txt"), "content2"); // Act - var method = typeof(ConfigManager).GetMethod("CopyDirectoryRecursive", BindingFlags.NonPublic | BindingFlags.Static); + var method = typeof(Configuration.ConfigManager).GetMethod("CopyDirectoryRecursive", BindingFlags.NonPublic | BindingFlags.Static); _ = (method?.Invoke(null, [sourceDir, targetDir])); // Assert diff --git a/src/LogExpert.Tests/ConfigManagerTest.cs b/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs similarity index 94% rename from src/LogExpert.Tests/ConfigManagerTest.cs rename to src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs index 845b5489..275a1fb8 100644 --- a/src/LogExpert.Tests/ConfigManagerTest.cs +++ b/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs @@ -1,6 +1,5 @@ using System.Reflection; -using LogExpert.Configuration; using LogExpert.Core.Classes.Filter; using LogExpert.Core.Config; using LogExpert.Core.Entities; @@ -9,7 +8,7 @@ using NUnit.Framework; -namespace LogExpert.Tests; +namespace LogExpert.Tests.ConfigManagerTests; /// /// Unit tests for ConfigManager settings loss prevention fixes. @@ -20,10 +19,9 @@ public class ConfigManagerTest { private string _testDir; private FileInfo _testSettingsFile; - private ConfigManager _configManager; + private Configuration.ConfigManager _configManager; [SetUp] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void SetUp () { // Create isolated test directory for each test @@ -32,7 +30,7 @@ public void SetUp () _testSettingsFile = new FileInfo(Path.Join(_testDir, "settings.json")); // Initialize ConfigManager for testing - _configManager = ConfigManager.Instance; + _configManager = Configuration.ConfigManager.Instance; _configManager.Initialize(_testDir, new Rectangle(0, 0, 1920, 1080)); } @@ -65,7 +63,7 @@ public void TearDown () [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", Justification = "Unit Tests")] private static T InvokePrivateStaticMethod (string methodName, params object[] parameters) { - MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + MethodInfo? method = typeof(Configuration.ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); return method == null ? throw new Exception($"Static method {methodName} not found") @@ -78,7 +76,7 @@ private static T InvokePrivateStaticMethod (string methodName, params object[ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", Justification = "Unit Tests")] private T InvokePrivateInstanceMethod (string methodName, params object[] parameters) { - MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); + MethodInfo? method = typeof(Configuration.ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); return method == null ? throw new Exception($"Instance method {methodName} not found") @@ -91,7 +89,7 @@ private T InvokePrivateInstanceMethod (string methodName, params object[] par [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", Justification = "Unit Tests")] private void InvokePrivateInstanceMethod (string methodName, params object[] parameters) { - MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance) + MethodInfo? method = typeof(Configuration.ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new Exception($"Instance method {methodName} not found"); _ = method.Invoke(_configManager, parameters); @@ -553,7 +551,6 @@ public void LoadOrCreateNew_InvalidJSON_HandlesGracefully () [Test] [Category("Import")] [Description("Import should handle null _settings field by using Settings property")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Test")] public void Import_WithUninitializedSettings_ShouldNotThrowNullReference () { @@ -578,7 +575,6 @@ public void Import_WithUninitializedSettings_ShouldNotThrowNullReference () [Test] [Category("Import")] [Description("Import should validate that import file exists")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_WithNonExistentFile_ShouldReturnFailure () { // Arrange @@ -596,7 +592,6 @@ public void Import_WithNonExistentFile_ShouldReturnFailure () [Test] [Category("Import")] [Description("Import should validate that import file is not null")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_WithNullFileInfo_ShouldReturnFailure () { // Act @@ -611,7 +606,6 @@ public void Import_WithNullFileInfo_ShouldReturnFailure () [Test] [Category("Import")] [Description("Import should detect corrupted import files")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_WithCorruptedFile_ShouldReturnFailure () { // Arrange @@ -631,7 +625,6 @@ public void Import_WithCorruptedFile_ShouldReturnFailure () [Test] [Category("Import")] [Description("Import should detect empty/default settings and require confirmation")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_WithEmptySettings_ShouldRequireConfirmation () { // Arrange @@ -652,7 +645,6 @@ public void Import_WithEmptySettings_ShouldRequireConfirmation () [Test] [Category("Import")] [Description("Import should successfully import valid populated settings")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_WithValidPopulatedSettings_ShouldSucceed () { // Arrange @@ -681,7 +673,6 @@ public void Import_WithValidPopulatedSettings_ShouldSucceed () [Test] [Category("Import")] [Description("Import with Other flag should merge preferences correctly")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_WithOtherFlag_ShouldMergePreferences () { // Arrange @@ -713,7 +704,6 @@ public void Import_WithOtherFlag_ShouldMergePreferences () [Test] [Category("Import")] [Description("Import with ColumnizerMasks flag should import columnizer masks")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_WithColumnizerMasksFlag_ShouldImportMasks () { // Arrange @@ -739,7 +729,6 @@ public void Import_WithColumnizerMasksFlag_ShouldImportMasks () [Test] [Category("Import")] [Description("Import with KeepExisting flag should merge rather than replace")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_WithKeepExistingFlag_ShouldMergeSettings () { // Arrange @@ -771,7 +760,6 @@ public void Import_WithKeepExistingFlag_ShouldMergeSettings () [Test] [Category("Import")] [Description("Import should handle null Preferences in import file gracefully")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Test")] public void Import_WithNullPreferences_ShouldHandleGracefully () { @@ -797,7 +785,6 @@ public void Import_WithNullPreferences_ShouldHandleGracefully () [Test] [Category("Import")] [Description("Multiple imports should maintain consistency")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_MultipleImports_ShouldMaintainConsistency () { // Arrange & Act - Multiple imports @@ -823,7 +810,6 @@ public void Import_MultipleImports_ShouldMaintainConsistency () [Test] [Category("Import")] [Description("Import should save settings after successful import")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Unit Test")] public void Import_SuccessfulImport_ShouldSaveSettings () { // Arrange diff --git a/src/LogExpert.Tests/LogStreamReaderTest.cs b/src/LogExpert.Tests/LogStreamReaderTest.cs index bf39b832..43dcd566 100644 --- a/src/LogExpert.Tests/LogStreamReaderTest.cs +++ b/src/LogExpert.Tests/LogStreamReaderTest.cs @@ -1,6 +1,6 @@ using System.Text; -using LogExpert.Core.Classes.Log; +using LogExpert.Core.Classes.Log.Streamreaders; using LogExpert.Core.Entities; using NUnit.Framework; @@ -106,7 +106,7 @@ public void CountLinesWithLegacyNewLine (string text, int expectedLines) [TestCase("Line 1\r\nLine 2\r\nLine 3\r\n", 3)] [TestCase("Line 1\rLine 2\rLine 3", 3)] [TestCase("Line 1\rLine 2\rLine 3\r", 3)] - public void ReadLinesWithPipelineNewLine(string text, int expectedLines) + public void ReadLinesWithPipelineNewLine (string text, int expectedLines) { using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); using var reader = new PositionAwareStreamReaderPipeline(stream, new EncodingOptions(), 500); @@ -131,7 +131,7 @@ public void ReadLinesWithPipelineNewLine(string text, int expectedLines) [TestCase("\n\n\n", 3)] [TestCase("\r\n\r\n\r\n", 3)] [TestCase("\r\r\r", 3)] - public void CountLinesWithPipelineNewLine(string text, int expectedLines) + public void CountLinesWithPipelineNewLine (string text, int expectedLines) { using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); using var reader = new PositionAwareStreamReaderPipeline(stream, new EncodingOptions(), 500); @@ -145,7 +145,7 @@ public void CountLinesWithPipelineNewLine(string text, int expectedLines) } [Test] - public void PipelineReaderShouldTrackPositionCorrectly() + public void PipelineReaderShouldTrackPositionCorrectly () { var text = "Line 1\nLine 2\nLine 3\n"; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); @@ -168,7 +168,7 @@ public void PipelineReaderShouldTrackPositionCorrectly() } [Test] - public void PipelineReaderShouldSupportSeeking() + public void PipelineReaderShouldSupportSeeking () { var text = "Line 1\nLine 2\nLine 3\n"; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); @@ -194,7 +194,7 @@ public void PipelineReaderShouldSupportSeeking() } [Test] - public void PipelineReaderShouldHandleMaximumLineLength() + public void PipelineReaderShouldHandleMaximumLineLength () { var longLine = new string('X', 1000); var text = $"{longLine}\nShort line\n"; @@ -212,7 +212,7 @@ public void PipelineReaderShouldHandleMaximumLineLength() } [Test] - public void PipelineReaderShouldHandleUnicode() + public void PipelineReaderShouldHandleUnicode () { var text = "Hello 世界\nСпасибо\n"; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); @@ -226,7 +226,7 @@ public void PipelineReaderShouldHandleUnicode() } [Test] - public void PipelineReaderShouldHandleEmptyLines() + public void PipelineReaderShouldHandleEmptyLines () { var text = "Line 1\n\nLine 3\n"; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); @@ -244,4 +244,177 @@ public void PipelineReaderShouldHandleEmptyLines() var eof = reader.ReadLine(); Assert.That(eof, Is.Null); } + + [Test] + [TestCase("Line 1\nLine 2\nLine 3", 3)] + [TestCase("Line 1\nLine 2\nLine 3\n", 3)] + [TestCase("Line 1\r\nLine 2\r\nLine 3", 3)] + [TestCase("Line 1\r\nLine 2\r\nLine 3\r\n", 3)] + [TestCase("Line 1\rLine 2\rLine 3", 3)] + [TestCase("Line 1\rLine 2\rLine 3\r", 3)] + public void TryReadLine_ReturnsCorrectContent_SystemReader (string text, int expectedLines) + { + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + var lineCount = 0; + while (reader.TryReadLine(out var lineMemory)) + { + lineCount++; + var line = lineMemory.Span.ToString(); + Assert.That(line.StartsWith($"Line {lineCount}", StringComparison.OrdinalIgnoreCase), $"Invalid line: {line}"); + } + + Assert.That(lineCount, Is.EqualTo(expectedLines)); + } + + [Test] + [TestCase("\n\n\n", 3)] + [TestCase("\r\n\r\n\r\n", 3)] + [TestCase("\r\r\r", 3)] + public void TryReadLine_CountsEmptyLines_SystemReader (string text, int expectedLines) + { + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + var lineCount = 0; + while (reader.TryReadLine(out _)) + { + lineCount++; + } + + Assert.That(lineCount, Is.EqualTo(expectedLines)); + } + + [Test] + public void TryReadLine_TracksPositionCorrectly_SystemReader () + { + var text = "Line 1\nLine 2\nLine 3\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + Assert.That(reader.TryReadLine(out var line1), Is.True); + Assert.That(line1.Span.ToString(), Is.EqualTo("Line 1")); + Assert.That(reader.Position, Is.EqualTo(7)); // "Line 1\n" = 7 bytes + + Assert.That(reader.TryReadLine(out var line2), Is.True); + Assert.That(line2.Span.ToString(), Is.EqualTo("Line 2")); + Assert.That(reader.Position, Is.EqualTo(14)); + + Assert.That(reader.TryReadLine(out var line3), Is.True); + Assert.That(line3.Span.ToString(), Is.EqualTo("Line 3")); + Assert.That(reader.Position, Is.EqualTo(21)); + + Assert.That(reader.TryReadLine(out _), Is.False); // EOF + } + + [Test] + public void TryReadLine_ReturnsBlockBackedMemory_NotStringBacked () + { + var text = "Hello World\nSecond Line\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + Assert.That(reader.TryReadLine(out var lineMemory), Is.True); + + // Verify the memory is backed by a char[] from the block allocator, not a string. + // MemoryMarshal.TryGetArray succeeds for array-backed Memory but fails for string-backed Memory. + var success = System.Runtime.InteropServices.MemoryMarshal.TryGetArray(lineMemory, out var segment); + Assert.That(success, Is.True, "Memory should be backed by a char[] (block-allocated), not a string"); + Assert.That(segment.Array, Is.Not.Null); + } + + [Test] + public void TryReadLine_BlockAllocatorHasBlocks_AfterReading () + { + var text = "Line 1\nLine 2\nLine 3\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + while (reader.TryReadLine(out _)) + { + } + + // The allocator should have at least 1 block + Assert.That(reader.BlockAllocator.BlockCount, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + public void TryReadLine_DetachBlocks_TransfersOwnership () + { + var text = "Line 1\nLine 2\nLine 3\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + while (reader.TryReadLine(out _)) + { + } + + var blocks = reader.BlockAllocator.DetachBlocks(); + Assert.That(blocks, Has.Count.GreaterThanOrEqualTo(1)); + + // After detach, allocator should have a fresh block + Assert.That(reader.BlockAllocator.BlockCount, Is.EqualTo(1)); + } + + [Test] + public void TryReadLine_MatchesReadLine_ContentAndPosition () + { + // Verify TryReadLine produces identical content and position tracking as ReadLine + var text = "2026-04-23 12:00:00 [INFO] Thread-1 SomeClass - Message 1\n" + + "2026-04-23 12:00:01 [WARN] Thread-2 OtherClass - Message 2\n" + + "Short\n" + + "\n" + // empty line + "Last line"; + + using var stream1 = new MemoryStream(Encoding.UTF8.GetBytes(text)); + using var stream2 = new MemoryStream(Encoding.UTF8.GetBytes(text)); + using var readLineReader = new PositionAwareStreamReaderSystem(stream1, new EncodingOptions(), 500); + using var tryReadLineReader = new PositionAwareStreamReaderSystem(stream2, new EncodingOptions(), 500); + + while (true) + { + var stringLine = readLineReader.ReadLine(); + var tryResult = tryReadLineReader.TryReadLine(out var memoryLine); + + if (stringLine == null) + { + Assert.That(tryResult, Is.False, "TryReadLine should return false at EOF when ReadLine returns null"); + break; + } + + Assert.That(tryResult, Is.True); + Assert.That(memoryLine.Span.ToString(), Is.EqualTo(stringLine), "Content mismatch between ReadLine and TryReadLine"); + Assert.That(tryReadLineReader.Position, Is.EqualTo(readLineReader.Position), "Position mismatch between ReadLine and TryReadLine"); + } + } + + [Test] + public void TryReadLine_RespectsMaximumLineLength () + { + var longLine = new string('X', 1000); + var text = $"{longLine}\nShort\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + Assert.That(reader.TryReadLine(out var lineMemory), Is.True); + Assert.That(lineMemory.Length, Is.EqualTo(500), "Line should be truncated to MaximumLineLength"); + + Assert.That(reader.TryReadLine(out var line2), Is.True); + Assert.That(line2.Span.ToString(), Is.EqualTo("Short")); + } + + [Test] + public void TryReadLine_UTF8_MultiByteCharacters () + { + // Japanese characters: 3 bytes each in UTF-8 + var text = "日本語テスト\nLine 2\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text)); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions { Encoding = Encoding.UTF8 }, 500); + + Assert.That(reader.TryReadLine(out var line1), Is.True); + Assert.That(line1.Span.ToString(), Is.EqualTo("日本語テスト")); + Assert.That(reader.Position, Is.EqualTo(Encoding.UTF8.GetByteCount("日本語テスト\n"))); + + Assert.That(reader.TryReadLine(out var line2), Is.True); + Assert.That(line2.Span.ToString(), Is.EqualTo("Line 2")); + } } diff --git a/src/LogExpert.Tests/LogfileReaderBlockAllocationTests.cs b/src/LogExpert.Tests/LogfileReaderBlockAllocationTests.cs new file mode 100644 index 00000000..ef47d802 --- /dev/null +++ b/src/LogExpert.Tests/LogfileReaderBlockAllocationTests.cs @@ -0,0 +1,221 @@ +using System.Text; + +using LogExpert.Core.Classes.Log; +using LogExpert.Core.Entities; +using LogExpert.Core.Enums; + +using NUnit.Framework; + +namespace LogExpert.Tests; + +[TestFixture] +public class LogfileReaderBlockAllocationTests +{ + private string _tempFile; + + [SetUp] + public void Setup () + { + _tempFile = Path.GetTempFileName(); + _ = PluginRegistry.PluginRegistry.Create(Path.GetDirectoryName(_tempFile)!, 500); + } + + [TearDown] + public void Cleanup () + { + if (File.Exists(_tempFile)) + { + File.Delete(_tempFile); + } + } + + [Test] + [TestCase(10)] + [TestCase(100)] + [TestCase(1_000)] + [TestCase(10_000)] + public void ReadFiles_AllLinesCorrect_WithBlockAllocation (int lineCount) + { + GenerateLogFile(_tempFile, lineCount); + + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 100, + linesPerBuffer: 500, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500, + progressReporter: Core.Classes.Log.ProgressReporters.NullProgressReporter.Instance); + + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(lineCount)); + + // Verify first, middle, and last lines + VerifyLine(reader, 0, 0); + VerifyLine(reader, lineCount / 2, lineCount / 2); + VerifyLine(reader, lineCount - 1, lineCount - 1); + } + + [Test] + public void ReadFiles_EmptyLines_PreservedCorrectly () + { + File.WriteAllText(_tempFile, "Line 1\n\nLine 3\n\nLine 5\n", Encoding.UTF8); + + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 100, + linesPerBuffer: 500, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500, + progressReporter: Core.Classes.Log.ProgressReporters.NullProgressReporter.Instance); + + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(5)); + + var line1 = reader.GetLogLineMemory(0); + var line2 = reader.GetLogLineMemory(1); + var line3 = reader.GetLogLineMemory(2); + + Assert.That(line1?.FullLine.Span.ToString(), Is.EqualTo("Line 1")); + Assert.That(line2?.FullLine.IsEmpty, Is.True); // empty line + Assert.That(line3?.FullLine.Span.ToString(), Is.EqualTo("Line 3")); + } + + [Test] + public void ReadFiles_UTF8MultiByte_ContentPreserved () + { + File.WriteAllText(_tempFile, "日本語テスト\nÄÖÜ äöü\nLine 3\n", Encoding.UTF8); + + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 100, + linesPerBuffer: 500, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500, + progressReporter: Core.Classes.Log.ProgressReporters.NullProgressReporter.Instance); + + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(3)); + + var line1 = reader.GetLogLineMemory(0); + var line2 = reader.GetLogLineMemory(1); + + Assert.That(line1?.FullLine.Span.ToString(), Is.EqualTo("日本語テスト")); + Assert.That(line2?.FullLine.Span.ToString(), Is.EqualTo("ÄÖÜ äöü")); + } + + [Test] + public void ReadFiles_LongLine_TruncatedToMaxLength () + { + var longLine = new string('X', 1000); + File.WriteAllText(_tempFile, $"{longLine}\nShort\n", Encoding.UTF8); + + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 100, + linesPerBuffer: 500, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500, + progressReporter: Core.Classes.Log.ProgressReporters.NullProgressReporter.Instance); + + reader.ReadFiles(); + + var line1 = reader.GetLogLineMemory(0); + Assert.That(line1?.FullLine.Length, Is.EqualTo(500)); + } + + [Test] + public void ReadFiles_LineContentBackedByPooledMemory () + { + File.WriteAllText(_tempFile, "Test line content\nSecond line\n", Encoding.UTF8); + + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 100, + linesPerBuffer: 500, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500, + progressReporter: Core.Classes.Log.ProgressReporters.NullProgressReporter.Instance); + + reader.ReadFiles(); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null); + + // Verify the FullLine memory is array-backed (block-allocated), not string-backed + var success = System.Runtime.InteropServices.MemoryMarshal.TryGetArray(line.FullLine, out var segment); + Assert.That(success, Is.True, "LogLine.FullLine should be backed by a char[] block, not a string"); + } + + [Test] + public void ReadFiles_MultipleBufferRotations_AllLinesCorrect () + { + // With linesPerBuffer=50 and 500 lines, this forces 10 buffer rotations + GenerateLogFile(_tempFile, 500); + + using var reader = new LogfileReader( + _tempFile, + new EncodingOptions { Encoding = Encoding.UTF8 }, + multiFile: false, + bufferCount: 100, + linesPerBuffer: 50, + new MultiFileOptions(), + ReaderType.System, + PluginRegistry.PluginRegistry.Instance, + maximumLineLength: 500, + progressReporter: Core.Classes.Log.ProgressReporters.NullProgressReporter.Instance); + + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(500)); + + // Spot-check lines across buffer boundaries + for (var i = 0; i < 500; i += 49) // stride of 49 crosses buffer boundaries + { + VerifyLine(reader, i, i); + } + } + + private static void VerifyLine (LogfileReader reader, int lineNum, int expectedIndex) + { + var line = reader.GetLogLineMemory(lineNum); + Assert.That(line, Is.Not.Null, $"Line {lineNum} should not be null"); + var text = line.FullLine.Span.ToString(); + Assert.That(text, Does.Contain($"message {expectedIndex}"), + $"Line {lineNum} content mismatch: '{text}'"); + } + + private static void GenerateLogFile (string path, int lineCount) + { + using var writer = new StreamWriter(path, false, Encoding.UTF8, bufferSize: 65536); + for (var i = 0; i < lineCount; i++) + { + writer.Write("2026-04-23 12:00:00."); + writer.Write(i % 1000); + writer.Write(" [INFO] Thread-1 SomeClass - log message "); + writer.WriteLine(i); + } + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/ProgressReporterTests/NullProgressReporterTests.cs b/src/LogExpert.Tests/ProgressReporterTests/NullProgressReporterTests.cs new file mode 100644 index 00000000..07a7f8b3 --- /dev/null +++ b/src/LogExpert.Tests/ProgressReporterTests/NullProgressReporterTests.cs @@ -0,0 +1,53 @@ +using LogExpert.Core.Classes.Log.ProgressReporters; + +using NUnit.Framework; + +namespace LogExpert.Tests.ProgressReporterTests; + +/// +/// Unit tests for . +/// Verifies no-op behavior and that it is safe to use in benchmarks/tests. +/// +[TestFixture] +internal sealed class NullProgressReporterTests +{ + [Test] + public void Instance_IsSingleton () + { + var a = NullProgressReporter.Instance; + var b = NullProgressReporter.Instance; + Assert.That(a, Is.SameAs(b)); + } + + [Test] + public void AllMethods_DoNotThrow () + { + var reporter = NullProgressReporter.Instance; + + Assert.DoesNotThrow(() => + { + reporter.ReportProgress("file.log", 100, 1000); + reporter.ReportComplete("file.log", 1000, 1000); + reporter.ReportNewFile("new.log", 0, 5000); + reporter.ReportLoadingStarted("file.log"); + reporter.ReportLoadingFinished(); + }); + } + + [Test] + public void Dispose_DoesNotThrow () + { + Assert.DoesNotThrow(NullProgressReporter.Instance.Dispose); + } + + [Test] + public void Dispose_CanBeCalledMultipleTimes () + { + var reporter = NullProgressReporter.Instance; + Assert.DoesNotThrow(() => + { + reporter.Dispose(); + reporter.Dispose(); + }); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/ProgressReporterTests/PeriodicProgressReporterTests.cs b/src/LogExpert.Tests/ProgressReporterTests/PeriodicProgressReporterTests.cs new file mode 100644 index 00000000..291f7a53 --- /dev/null +++ b/src/LogExpert.Tests/ProgressReporterTests/PeriodicProgressReporterTests.cs @@ -0,0 +1,186 @@ +using LogExpert.Core.Classes.Log.ProgressReporters; +using LogExpert.Core.EventArguments; + +using NUnit.Framework; + +namespace LogExpert.Tests.ProgressReporterTests; + +/// +/// Unit tests for . +/// Uses a short dispatch interval (50ms) to keep tests fast. +/// +[TestFixture] +internal sealed class PeriodicProgressReporterTests +{ + private const int DISPATCH_MS = 50; + private const int WAIT_MS = 300; // enough for several dispatch cycles + + [Test] + public void ReportProgress_FiresLoadFileEvent () + { + using var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + + LoadFileEventArgs? received = null; + reporter.LoadFile += (_, e) => received = e; + + reporter.ReportProgress("test.log", 500, 1000); + + // Wait for dispatch cycle + Thread.Sleep(WAIT_MS); + + Assert.That(received, Is.Not.Null); + Assert.That(received!.FileName, Is.EqualTo("test.log")); + Assert.That(received.ReadPos, Is.EqualTo(500)); + Assert.That(received.FileSize, Is.EqualTo(1000)); + Assert.That(received.Finished, Is.False); + Assert.That(received.NewFile, Is.False); + } + + [Test] + public void ReportComplete_FiresLoadFileEventWithFinished () + { + using var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + + LoadFileEventArgs? received = null; + reporter.LoadFile += (_, e) => + { + if (e.Finished) + { + received = e; + } + }; + + reporter.ReportComplete("test.log", 1000, 1000); + Thread.Sleep(WAIT_MS); + + Assert.That(received, Is.Not.Null); + Assert.That(received!.Finished, Is.True); + } + + [Test] + public void ReportNewFile_FiresLoadFileEventWithNewFile () + { + using var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + + LoadFileEventArgs? received = null; + reporter.LoadFile += (_, e) => + { + if (e.NewFile) + { + received = e; + } + }; + + reporter.ReportNewFile("new.log", 0, 5000); + Thread.Sleep(WAIT_MS); + + Assert.That(received, Is.Not.Null); + Assert.That(received!.NewFile, Is.True); + Assert.That(received.FileName, Is.EqualTo("new.log")); + } + + [Test] + public void ReportLoadingStarted_FiresLoadingStartedEvent () + { + using var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + + LoadFileEventArgs? received = null; + reporter.LoadingStarted += (_, e) => received = e; + + reporter.ReportLoadingStarted("test.log"); + Thread.Sleep(WAIT_MS); + + Assert.That(received, Is.Not.Null); + Assert.That(received!.FileName, Is.EqualTo("test.log")); + } + + [Test] + public void ReportLoadingFinished_FiresLoadingFinishedEvent () + { + using var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + + var fired = false; + reporter.LoadingFinished += (_, _) => fired = true; + + reporter.ReportLoadingFinished(); + Thread.Sleep(WAIT_MS); + + Assert.That(fired, Is.True); + } + + [Test] + public void MultipleProgressReports_CoalescedToLatest () + { + using var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + + LoadFileEventArgs? received = null; + reporter.LoadFile += (_, e) => + { + if (!e.Finished) + { + received = e; + } + }; + + // Fire many progress reports rapidly — only latest should be dispatched + for (var i = 0; i < 100; i++) + { + reporter.ReportProgress("test.log", i * 100, 10000); + } + + Thread.Sleep(WAIT_MS); + + Assert.That(received, Is.Not.Null); + // The dispatched position should be the latest (or near-latest) value + Assert.That(received!.ReadPos, Is.GreaterThanOrEqualTo(9000)); + } + + [Test] + public void DispatchOrder_StartedBeforeProgress () + { + using var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + + var order = new List(); + reporter.LoadingStarted += (_, _) => order.Add("started"); + reporter.LoadFile += (_, _) => order.Add("progress"); + + reporter.ReportLoadingStarted("test.log"); + reporter.ReportProgress("test.log", 500, 1000); + Thread.Sleep(WAIT_MS); + + Assert.That(order, Has.Count.GreaterThanOrEqualTo(2)); + var startedIdx = order.IndexOf("started"); + var progressIdx = order.IndexOf("progress"); + Assert.That(startedIdx, Is.GreaterThanOrEqualTo(0), "LoadingStarted not fired"); + Assert.That(progressIdx, Is.GreaterThanOrEqualTo(0), "LoadFile not fired"); + Assert.That(startedIdx, Is.LessThan(progressIdx), "Started should fire before progress"); + } + + [Test] + public void Dispose_StopsDispatchLoop () + { + var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + reporter.Dispose(); + + // After dispose, reports should not throw but events won't fire + Assert.DoesNotThrow(() => reporter.ReportProgress("test.log", 100, 1000)); + } + + [Test] + public void NoSubscribers_DoesNotThrow () + { + using var reporter = new PeriodicProgressReporter(TimeSpan.FromMilliseconds(DISPATCH_MS)); + + // Report all event types with no subscribers — should not throw + Assert.DoesNotThrow(() => + { + reporter.ReportLoadingStarted("test.log"); + reporter.ReportProgress("test.log", 100, 1000); + reporter.ReportNewFile("new.log", 0, 5000); + reporter.ReportComplete("test.log", 1000, 1000); + reporter.ReportLoadingFinished(); + }); + + Thread.Sleep(WAIT_MS); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/ReaderTest.cs b/src/LogExpert.Tests/ReaderTest.cs index 791555bd..8c71d36e 100644 --- a/src/LogExpert.Tests/ReaderTest.cs +++ b/src/LogExpert.Tests/ReaderTest.cs @@ -1,6 +1,6 @@ using System.Text; -using LogExpert.Core.Classes.Log; +using LogExpert.Core.Classes.Log.Streamreaders; using LogExpert.Core.Entities; using LogExpert.Core.Interfaces; diff --git a/src/LogExpert.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.Tests/Services/FileOperationServiceTests.cs index 114a0c83..39b72151 100644 --- a/src/LogExpert.Tests/Services/FileOperationServiceTests.cs +++ b/src/LogExpert.Tests/Services/FileOperationServiceTests.cs @@ -37,6 +37,13 @@ internal class FileOperationServiceTests : IDisposable private bool _disposed; + [OneTimeSetUp] + public void OneTimeSetUp () + { + var dir = Path.GetDirectoryName(typeof(FileOperationServiceTests).Assembly.Location)!; + _ = PluginRegistry.PluginRegistry.Create(dir, 500); + } + [SetUp] public void Setup () { diff --git a/src/LogExpert.Tests/Services/TabControllerTests.cs b/src/LogExpert.Tests/Services/TabControllerTests.cs index 8c64d8ec..bcd23ac9 100644 --- a/src/LogExpert.Tests/Services/TabControllerTests.cs +++ b/src/LogExpert.Tests/Services/TabControllerTests.cs @@ -1,5 +1,4 @@ using System.Runtime.Versioning; -using System.Windows.Forms; using LogExpert.UI.Controls.LogWindow; using LogExpert.UI.Services.TabControllerService; @@ -12,13 +11,11 @@ namespace LogExpert.Tests.Services; /// /// Unit tests for TabController. -/// -/// Note: Many tests are limited because LogWindow is a complex WinForms control -/// that cannot be easily mocked or subclassed. Tests that require actual LogWindow -/// instances would need to be run as integration tests with full UI infrastructure. -/// -/// These tests focus on the core TabController functionality that can be tested -/// without instantiating LogWindow objects. +/// Note: Many tests are limited because LogWindow is a complex WinForms control that cannot be easily mocked or +/// subclassed. Tests that require actual LogWindow instances would need to be run as integration tests with full UI +/// infrastructure. +/// These tests focus on the core TabController functionality that can be tested without instantiating LogWindow +/// objects. /// [TestFixture] [SupportedOSPlatform("windows")] @@ -31,7 +28,7 @@ internal class TabControllerTests : IDisposable private bool _disposed; [SetUp] - public void Setup() + public void Setup () { // Create a real Form and DockPanel for testing // This is necessary because DockPanel requires WinForms infrastructure @@ -48,20 +45,22 @@ public void Setup() } [TearDown] - public void TearDown() + public void TearDown () { _tabController?.Dispose(); _testForm?.Close(); _testForm?.Dispose(); } - public void Dispose() + #region IDisposable Implementation + + public void Dispose () { Dispose(true); GC.SuppressFinalize(this); } - protected virtual void Dispose(bool disposing) + protected virtual void Dispose (bool disposing) { if (_disposed) { @@ -77,10 +76,12 @@ protected virtual void Dispose(bool disposing) _disposed = true; } + #endregion + #region Constructor Tests [Test] - public void Constructor_WithDockPanel_InitializesSuccessfully() + public void Constructor_WithDockPanel_InitializesSuccessfully () { // Arrange & Act - already done in Setup @@ -90,14 +91,14 @@ public void Constructor_WithDockPanel_InitializesSuccessfully() } [Test] - public void Constructor_WithNullDockPanel_ThrowsArgumentNullException() + public void Constructor_WithNullDockPanel_ThrowsArgumentNullException () { // Arrange & Act & Assert _ = Assert.Throws(() => new TabController(null)); } [Test] - public void Constructor_WithoutDockPanel_CreatesUninitializedController() + public void Constructor_WithoutDockPanel_CreatesUninitializedController () { // Arrange & Act using var controller = new TabController(); @@ -112,7 +113,7 @@ public void Constructor_WithoutDockPanel_CreatesUninitializedController() #region InitializeDockPanel Tests [Test] - public void InitializeDockPanel_WithValidDockPanel_Succeeds() + public void InitializeDockPanel_WithValidDockPanel_Succeeds () { // Arrange using var controller = new TabController(); @@ -125,7 +126,7 @@ public void InitializeDockPanel_WithValidDockPanel_Succeeds() } [Test] - public void InitializeDockPanel_WithNullDockPanel_ThrowsArgumentNullException() + public void InitializeDockPanel_WithNullDockPanel_ThrowsArgumentNullException () { // Arrange using var controller = new TabController(); @@ -135,7 +136,7 @@ public void InitializeDockPanel_WithNullDockPanel_ThrowsArgumentNullException() } [Test] - public void InitializeDockPanel_WhenAlreadyInitialized_ThrowsInvalidOperationException() + public void InitializeDockPanel_WhenAlreadyInitialized_ThrowsInvalidOperationException () { // Arrange using var controller = new TabController(_dockPanel); @@ -154,7 +155,7 @@ public void InitializeDockPanel_WhenAlreadyInitialized_ThrowsInvalidOperationExc #region GetAllWindowsFromDockPanel Tests [Test] - public void GetAllWindowsFromDockPanel_WhenNotInitialized_ReturnsEmptyList() + public void GetAllWindowsFromDockPanel_WhenNotInitialized_ReturnsEmptyList () { // Arrange using var controller = new TabController(); @@ -167,7 +168,7 @@ public void GetAllWindowsFromDockPanel_WhenNotInitialized_ReturnsEmptyList() } [Test] - public void GetAllWindowsFromDockPanel_WhenInitializedButEmpty_ReturnsEmptyList() + public void GetAllWindowsFromDockPanel_WhenInitializedButEmpty_ReturnsEmptyList () { // Arrange - already done in Setup @@ -179,7 +180,7 @@ public void GetAllWindowsFromDockPanel_WhenInitializedButEmpty_ReturnsEmptyList( } [Test] - public void GetAllWindowsFromDockPanel_ReturnsReadOnlyList() + public void GetAllWindowsFromDockPanel_ReturnsReadOnlyList () { // Arrange - already done in Setup @@ -195,7 +196,7 @@ public void GetAllWindowsFromDockPanel_ReturnsReadOnlyList() #region GetAllWindows Tests [Test] - public void GetAllWindows_WhenEmpty_ReturnsEmptyList() + public void GetAllWindows_WhenEmpty_ReturnsEmptyList () { // Arrange - already done in Setup @@ -212,7 +213,7 @@ public void GetAllWindows_WhenEmpty_ReturnsEmptyList() #region GetWindowCount Tests [Test] - public void GetWindowCount_WhenEmpty_ReturnsZero() + public void GetWindowCount_WhenEmpty_ReturnsZero () { // Arrange - already done in Setup @@ -228,7 +229,7 @@ public void GetWindowCount_WhenEmpty_ReturnsZero() #region HasWindow Tests [Test] - public void HasWindow_WithNullWindow_ReturnsFalse() + public void HasWindow_WithNullWindow_ReturnsFalse () { // Arrange - already done in Setup @@ -244,7 +245,7 @@ public void HasWindow_WithNullWindow_ReturnsFalse() #region GetActiveWindow Tests [Test] - public void GetActiveWindow_WhenNoWindowActive_ReturnsNull() + public void GetActiveWindow_WhenNoWindowActive_ReturnsNull () { // Arrange - already done in Setup @@ -260,7 +261,7 @@ public void GetActiveWindow_WhenNoWindowActive_ReturnsNull() #region FindWindowByFileName Tests [Test] - public void FindWindowByFileName_WithNullFileName_ReturnsNull() + public void FindWindowByFileName_WithNullFileName_ReturnsNull () { // Arrange - already done in Setup @@ -272,7 +273,7 @@ public void FindWindowByFileName_WithNullFileName_ReturnsNull() } [Test] - public void FindWindowByFileName_WithEmptyFileName_ReturnsNull() + public void FindWindowByFileName_WithEmptyFileName_ReturnsNull () { // Arrange - already done in Setup @@ -284,7 +285,7 @@ public void FindWindowByFileName_WithEmptyFileName_ReturnsNull() } [Test] - public void FindWindowByFileName_WhenNoWindowsExist_ReturnsNull() + public void FindWindowByFileName_WhenNoWindowsExist_ReturnsNull () { // Arrange - already done in Setup @@ -300,7 +301,7 @@ public void FindWindowByFileName_WhenNoWindowsExist_ReturnsNull() #region AddWindow Tests [Test] - public void AddWindow_WithNullWindow_ThrowsArgumentNullException() + public void AddWindow_WithNullWindow_ThrowsArgumentNullException () { // Arrange - already done in Setup @@ -309,7 +310,7 @@ public void AddWindow_WithNullWindow_ThrowsArgumentNullException() } [Test] - public void AddWindow_WhenNotInitialized_ThrowsInvalidOperationException() + public void AddWindow_WhenNotInitialized_ThrowsInvalidOperationException () { // Arrange using var controller = new TabController(); @@ -327,7 +328,7 @@ public void AddWindow_WhenNotInitialized_ThrowsInvalidOperationException() #region RemoveWindow Tests [Test] - public void RemoveWindow_WithNullWindow_DoesNotThrow() + public void RemoveWindow_WithNullWindow_DoesNotThrow () { // Arrange - already done in Setup @@ -340,7 +341,7 @@ public void RemoveWindow_WithNullWindow_DoesNotThrow() #region CloseWindow Tests [Test] - public void CloseWindow_WithNullWindow_DoesNotThrow() + public void CloseWindow_WithNullWindow_DoesNotThrow () { // Arrange - already done in Setup @@ -353,7 +354,7 @@ public void CloseWindow_WithNullWindow_DoesNotThrow() #region CloseAllWindows Tests [Test] - public void CloseAllWindows_WhenEmpty_DoesNotThrow() + public void CloseAllWindows_WhenEmpty_DoesNotThrow () { // Arrange - already done in Setup @@ -366,7 +367,7 @@ public void CloseAllWindows_WhenEmpty_DoesNotThrow() #region CloseAllExcept Tests [Test] - public void CloseAllExcept_WithNullWindow_DoesNotThrow() + public void CloseAllExcept_WithNullWindow_DoesNotThrow () { // Arrange - already done in Setup @@ -379,7 +380,7 @@ public void CloseAllExcept_WithNullWindow_DoesNotThrow() #region ActivateWindow Tests [Test] - public void ActivateWindow_WithNullWindow_DoesNotThrow() + public void ActivateWindow_WithNullWindow_DoesNotThrow () { // Arrange - already done in Setup @@ -392,7 +393,7 @@ public void ActivateWindow_WithNullWindow_DoesNotThrow() #region SwitchToNextWindow Tests [Test] - public void SwitchToNextWindow_WhenEmpty_DoesNotThrow() + public void SwitchToNextWindow_WhenEmpty_DoesNotThrow () { // Arrange - already done in Setup @@ -405,7 +406,7 @@ public void SwitchToNextWindow_WhenEmpty_DoesNotThrow() #region SwitchToPreviousWindow Tests [Test] - public void SwitchToPreviousWindow_WhenEmpty_DoesNotThrow() + public void SwitchToPreviousWindow_WhenEmpty_DoesNotThrow () { // Arrange - already done in Setup @@ -418,7 +419,7 @@ public void SwitchToPreviousWindow_WhenEmpty_DoesNotThrow() #region Dispose Tests [Test] - public void Dispose_MultipleCallsDoNotThrow() + public void Dispose_MultipleCallsDoNotThrow () { // Arrange using var controller = new TabController(_dockPanel); diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs index 563338dc..1e4bae80 100644 --- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs +++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs @@ -331,8 +331,8 @@ public void LoadStartupFiles (IList lastOpenFiles, string[]? startupFile { _ = AddFileTab(new FileTabRequest { FileName = name }); } - } - _configManager.ClearLastOpenFilesList(); + _configManager.ClearLastOpenFilesList(); + } } } \ No newline at end of file diff --git a/src/LogExpert.sln b/src/LogExpert.sln index 43437357..ba575520 100644 --- a/src/LogExpert.sln +++ b/src/LogExpert.sln @@ -24,6 +24,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DE6375A4-B4C4-4620-8FFB-B9D5A4E21144}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + ..\.gitignore = ..\.gitignore Solution Items\AssemblyInfo.cs = Solution Items\AssemblyInfo.cs ..\CHANGELOG.md = ..\CHANGELOG.md ..\.github\copilot-instructions.md = ..\.github\copilot-instructions.md diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 9164253c..83f102a2 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-04-21 17:29:42 UTC + /// Generated: 2026-04-27 15:18:43 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "8E966C2C6671A03093867E2104C7C4E24F9D866F448AA5401F053F8BED23AFE1", + ["AutoColumnizer.dll"] = "3C6CD1F9C3836B14110D2F9FA89E9E1B5B4A916C7BB8678B7A28CF7AA8A55548", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "DCFCDBB094D328F246FD93809F4512C336B28584C4D1E7F13E73DDA26587572D", - ["CsvColumnizer.dll (x86)"] = "DCFCDBB094D328F246FD93809F4512C336B28584C4D1E7F13E73DDA26587572D", - ["DefaultPlugins.dll"] = "853FFDDD12B3F554E40476CE4E7C8A1543295FFEAFC445F31BD89DCC42597505", - ["FlashIconHighlighter.dll"] = "127F1C2C9E3FA58D24D52487BFBB69A44CDA5DE6A4AEBD2D61A259165C95E2F5", - ["GlassfishColumnizer.dll"] = "ECCE8D6B24DDD1016DF14F5DE8155FD2B92F27EBC877CDE9A7B0148C90AE60C5", - ["JsonColumnizer.dll"] = "C64E0E16204EEC4C012F4D02257A77A165EE1F3CE1A06EA3F525F6D005EF2B50", - ["JsonCompactColumnizer.dll"] = "849D1B630232C66F8C6311B82F0719D1F97714E55951CF38E8D696EFB39ACCD7", - ["Log4jXmlColumnizer.dll"] = "F1D80A33E4B61CCB20184BDA7CB141C4A481C9E780FE11408E09F475F5A2B9C3", - ["LogExpert.Core.dll"] = "1E1655F42A545573A908031FD5BB34FD434ECF377602D79A1CEFA87757746F1B", - ["LogExpert.Resources.dll"] = "E7D15126016C257E5B6E04E65538CDC12809835B6C88FC4E01E1532F7561DD98", + ["CsvColumnizer.dll"] = "3977CA084A13A578416FA76DDE63C299671980210ABD76457A8954F01A7F9664", + ["CsvColumnizer.dll (x86)"] = "3977CA084A13A578416FA76DDE63C299671980210ABD76457A8954F01A7F9664", + ["DefaultPlugins.dll"] = "964CC02209803C490FF8BB3C7B4210FAE157F120F5160912CAE03D6F5EF3C224", + ["FlashIconHighlighter.dll"] = "9ED0E1243DE8B0A9381E8C23D7EC02E74DA1DE1EA3346135022905EC20C4E6E7", + ["GlassfishColumnizer.dll"] = "2324F1D6A906D83372A554F29166E7B18B98CFB0021D4AEC8B1AFB334949602D", + ["JsonColumnizer.dll"] = "96321A424E7B755F35F6FA392BF6EE81BEA85397912936C814227EADBB1F1157", + ["JsonCompactColumnizer.dll"] = "0422A0ACBC66A0E5B9A21C8A3F92B1E823D23919105ED62135252F7271331650", + ["Log4jXmlColumnizer.dll"] = "DB3C8A5E3A025C72AC932EE37327B820AD26282A33702B1BDE75F4E3073EAA03", + ["LogExpert.Core.dll"] = "F801724E66AABE7248C50358583167974A05B520BBC81D868036C274436E366D", + ["LogExpert.Resources.dll"] = "86CBD0E84B649E902ECDACF15446D3E7DB2E89AF7D17CDAC80BC71C8CB1EC3F3", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "E26F22FDA3882B616B30E56446AF39E7563DA846ED7646F350CCB28DF7B01D56", - ["SftpFileSystem.dll"] = "D3069CD5F9F49ABB6912D22408E6812F59B8CF302A8C89D51E9651404CEAC482", - ["SftpFileSystem.dll (x86)"] = "E503DB9BA8770A853D61686605FE8DE265A813A5A22AB33FA4BB675803D10EF2", - ["SftpFileSystem.Resources.dll"] = "D1B38ACA3ADB0326C0D24BB36CEFCE0ADEED22D09B435A4884047DDA724E8901", - ["SftpFileSystem.Resources.dll (x86)"] = "D1B38ACA3ADB0326C0D24BB36CEFCE0ADEED22D09B435A4884047DDA724E8901", + ["RegexColumnizer.dll"] = "CCD16E03BF0973984E21CC4EEA29812D14EE220A687435D0B1CE26E0804BFED7", + ["SftpFileSystem.dll"] = "50AABED28AD4B14BC5196345DAC4D6F2A5B10F8BB426D956AEF614D886B68B55", + ["SftpFileSystem.dll (x86)"] = "35E1B1A03B9B2BE4D9BC16389487FD132CBD8D48CEC41D515A95BB42A2163643", + ["SftpFileSystem.Resources.dll"] = "E8780327E9977CC15B61BFA2A8B02BACB428D5E6727722B432D293E2848FC6B8", + ["SftpFileSystem.Resources.dll (x86)"] = "E8780327E9977CC15B61BFA2A8B02BACB428D5E6727722B432D293E2848FC6B8", }; } diff --git a/src/Solution Items/AssemblyInfo.cs b/src/Solution Items/AssemblyInfo.cs index 2bbf4fba..9b16ee0a 100644 --- a/src/Solution Items/AssemblyInfo.cs +++ b/src/Solution Items/AssemblyInfo.cs @@ -1,4 +1,4 @@ using System.Runtime.InteropServices; [assembly: ComVisible(false)] -[assembly: System.Resources.NeutralResourcesLanguage("en")] \ No newline at end of file +[assembly: System.Resources.NeutralResourcesLanguage("en")]