Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -165,6 +165,47 @@ delegate name, deleted).
**filtering** — the `FilterSearch` methods in the code belong to the
filter path, not Log Search), "find dialog" (use **Search dialog**).

## Timestamp lookup

- **Timestamp Locator** (`LogExpert.Core.Classes.Timestamp.TimestampLocator`)
— Timestamp lookup over a Logfile Reader and the active Columnizer: what
time is the line at N (`FindBackward`, `FindForward`), and which line
carries time T (`FindLine`, plus the raw binary-search primitive
`FindNearestLine` used directly by the Time Spread calculator). Pure —
no dependency on the Log Window control, only on `ITimestampSource`
(below). Backs both time-sync (drag-to-scroll, "scroll all tabs to
timestamp", the sync-group worker) and the Time Spread bar; the worker
threads, grid selection, and cross-window coordination stay in the Log
Window and `TimeSyncList` — only the lookup was extracted.
- **Timestamp Source** (`ITimestampSource`) — The narrow seam a Timestamp
Locator runs against: a Logfile Reader, the active Columnizer, a
positionable Columnizer callback, and the lock guarding Columnizer
swaps. Every member is read live rather than captured, because a Log
Window replaces its Logfile Reader on load/reload/rollover and swaps
its Columnizer whenever the user picks a different one; a consumer like
the Time Spread calculator outlives both events. Implemented by the Log
Window itself.
- **Negated near-miss** — The miss convention of `FindNearestLine`, the raw
binary-search primitive: when no line carries the exact timestamp searched
for, it returns the nearest line's number *negated*, and callers flip the
sign back themselves (`TimeSpreadCalculator` does). `FindLine`, the
high-level lookup, does **not** expose this: it flips a miss back to a
normal positive line number, so callers scroll to the nearest line instead
of doing nothing — cross-window time-sync compares timestamps at
millisecond precision, making the near-miss its *common* case. (Getting
this wrong silently no-opped "scroll all tabs to timestamp" and time-sync;
regression-pinned in `TimestampLocatorTests.FindLine_NoExactMatchMidFile_*`.)
A near-miss that lands on line 0 is indistinguishable from a hit at line 0
(`-0 == 0`) — a ported quirk of the original binary search, preserved
rather than fixed; see
`TimestampLocatorTests.FindLine_TimestampBeforeTheFirstLine_*`.

*Avoid*: "GetTimestampForLine" / "GetTimestampForLineForward" /
"FindTimestampLineInternal" as concept names — those were the pre-extraction
Log Window method names (candidate 6 of
`docs/improve/logwindow-architecture-review.html`); the Core module is the
**Timestamp Locator**.

## Columnizer selection

- **Columnizer** (`ILogLineMemoryColumnizer`) — A plugin that parses a log
Expand Down
2 changes: 1 addition & 1 deletion src/LogExpert.Core/Callback/ColumnizerCallback.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@

namespace LogExpert.Core.Callback;

public class ColumnizerCallback (ILogWindow logWindow) : ILogLineMemoryColumnizerCallback, IAutoLogLineMemoryColumnizerCallback
public class ColumnizerCallback (ILogWindow logWindow) : IPositionedColumnizerCallback, IAutoLogLineMemoryColumnizerCallback
{
#region Fields
private readonly ILogWindow _logWindow = logWindow;
Expand Down
41 changes: 41 additions & 0 deletions src/LogExpert.Core/Classes/Timestamp/ITimestampSource.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
using System.Threading;

using ColumnizerLib;

using LogExpert.Core.Interfaces;

namespace LogExpert.Core.Classes.Timestamp;

/// <summary>
/// The narrow view of a Log Window that <see cref="TimestampLocator"/> needs: a Logfile Reader,
/// the active Columnizer, a callback to position, and the lock that guards Columnizer swaps.
/// </summary>
/// <remarks>
/// Every member is a property rather than a value captured at construction, deliberately.
/// A Log Window replaces its Logfile Reader on load, reload and rollover, and replaces its
/// Columnizer whenever the user picks a different one; a locator that had captured either
/// would go stale. Consumers such as the Time Spread calculator outlive both events.
/// </remarks>
public interface ITimestampSource
{
/// <summary>
/// The Logfile Reader currently backing the window. Read on every access — never cached.
/// </summary>
ILogfileReader Reader { get; }

/// <summary>
/// The Columnizer currently selected for the window. Read under <see cref="ColumnizerLock"/>.
/// </summary>
ILogLineMemoryColumnizer Columnizer { get; }

/// <summary>
/// The callback handed to the Columnizer. The locator positions it on each line it asks about.
/// </summary>
IPositionedColumnizerCallback Callback { get; }

/// <summary>
/// Guards <see cref="Columnizer"/> against being swapped mid-lookup. Owned by the window —
/// the same lock its Columnizer setter takes — and merely borrowed by the locator.
/// </summary>
Lock ColumnizerLock { get; }
}
220 changes: 220 additions & 0 deletions src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
namespace LogExpert.Core.Classes.Timestamp;

/// <summary>
/// Timestamp lookup over a Logfile Reader and the active Columnizer: what time is the line at N,
/// and which line carries time T.
/// </summary>
public sealed class TimestampLocator (ITimestampSource source)
{
/// <summary>
/// Gets the timestamp for the line at or before <paramref name="lineNum"/>. If that line has
/// no timestamp, the previous line is checked, and so on, until one is found.
/// </summary>
/// <param name="lineNum">The line to start scanning backward from.</param>
/// <param name="lineCount">The number of lines currently available (<c>ILogfileReader.LineCount</c>).</param>
/// <param name="roundToSeconds">If true, the returned timestamp has its millisecond component zeroed.</param>
/// <param name="token">Checked once per line; a cancelled token stops the scan and returns MinValue.</param>
/// <returns>The timestamp found, or <see cref="DateTime.MinValue"/> if none was, and the line
/// number it was found on (unchanged from <paramref name="lineNum"/> if scanning never moved).</returns>
public (DateTime timestamp, int lineNumber) FindBackward (int lineNum, int lineCount, bool roundToSeconds, CancellationToken token = default)
{
lock (source.ColumnizerLock)
{
if (!source.Columnizer.IsTimeshiftImplemented())
{
return (DateTime.MinValue, lineNum);
}

var timestamp = DateTime.MinValue;
var lookedBack = false;

if (lineNum >= 0 && lineNum < lineCount)
{
while (timestamp.CompareTo(DateTime.MinValue) == 0 && lineNum >= 0)
{
if (token.IsCancellationRequested)
{
return (DateTime.MinValue, lineNum);
}

lookedBack = true;
var logLine = source.Reader.GetLogLineMemory(lineNum);
if (logLine == null)
{
return (DateTime.MinValue, lineNum);
}

source.Callback.SetLineNum(lineNum);
timestamp = source.Columnizer.GetTimestamp(source.Callback, logLine);
if (roundToSeconds)
{
timestamp = timestamp.Subtract(TimeSpan.FromMilliseconds(timestamp.Millisecond));
}

lineNum--;
}
}

if (lookedBack)
{
lineNum++;
}

return (timestamp, lineNum);
}
}

/// <summary>
/// Gets the timestamp for the line at or after <paramref name="lineNum"/>. If that line has no
/// timestamp, the next line is checked, and so on, until one is found.
/// </summary>
/// <param name="lineNum">The line to start scanning forward from.</param>
/// <param name="lineCount">The number of lines currently available (<c>ILogfileReader.LineCount</c>).</param>
/// <param name="roundToSeconds">If true, the returned timestamp has its millisecond component zeroed.</param>
/// <returns>The timestamp found, or <see cref="DateTime.MinValue"/> if none was, and the line
/// number it was found on (unchanged from <paramref name="lineNum"/> if scanning never moved).</returns>
public (DateTime timestamp, int lineNumber) FindForward (int lineNum, int lineCount, bool roundToSeconds)
{
lock (source.ColumnizerLock)
{
if (!source.Columnizer.IsTimeshiftImplemented())
{
return (DateTime.MinValue, lineNum);
}

var timestamp = DateTime.MinValue;
var lookedForward = false;

if (lineNum >= 0 && lineNum < lineCount)
{
while (timestamp.CompareTo(DateTime.MinValue) == 0 && lineNum < lineCount)
{
lookedForward = true;
var logLine = source.Reader.GetLogLineMemory(lineNum);
if (logLine == null)
{
timestamp = DateTime.MinValue;
break;
}

source.Callback.SetLineNum(lineNum);
timestamp = source.Columnizer.GetTimestamp(source.Callback, logLine);
if (roundToSeconds)
{
timestamp = timestamp.Subtract(TimeSpan.FromMilliseconds(timestamp.Millisecond));
}

lineNum++;
}
}

if (lookedForward)
{
lineNum--;
}

return (timestamp, lineNum);
}
}

/// <summary>
/// Finds the line carrying <paramref name="timestamp"/> via binary search, then walks backward
/// to the first line of a run sharing that exact timestamp.
/// </summary>
/// <param name="timestamp">The timestamp to search for.</param>
/// <param name="fromLine">Line to start the binary search from.</param>
/// <param name="lineCount">The number of lines currently available (<c>ILogfileReader.LineCount</c>).</param>
/// <param name="roundToSeconds">If true, timestamps are compared with their millisecond component zeroed.</param>
/// <param name="token">Checked by the underlying scans; a cancelled token unwinds the search early.</param>
/// <returns>
/// The line number of the first line carrying <paramref name="timestamp"/>. If no line carries
/// it exactly, returns the line the search converged nearest to, as a normal <em>positive</em>
/// line number — a miss degrades to "scroll here instead", it is not reported. This mirrors the
/// original <c>FindTimestampLine</c>, whose final <c>return -foundLine</c> flipped the
/// internal negated miss back to a scrollable line; cross-window time-sync compares timestamps
/// at millisecond precision, so the nearest-line path is its common case, not its edge case.
/// Callers that need the raw miss signal use <see cref="FindNearestLine"/>.
/// </returns>
public int FindLine (DateTime timestamp, int fromLine, int lineCount, bool roundToSeconds, CancellationToken token = default)
{
var foundLine = FindLineInternal(fromLine, 0, lineCount - 1, timestamp, lineCount, roundToSeconds, token);

if (foundLine < 0)
{
return -foundLine;
}

// Walk backward to the first line of the run sharing this exact timestamp.
var (foundTimestamp, walkedTo) = FindBackward(foundLine, lineCount, roundToSeconds, token);
foundLine = walkedTo;
while (foundTimestamp.CompareTo(timestamp) == 0 && foundLine >= 0)
{
foundLine--;
(foundTimestamp, walkedTo) = FindBackward(foundLine, lineCount, roundToSeconds, token);
foundLine = walkedTo;
}

if (foundLine < 0)
{
return 0;
}

foundLine++;
(_, foundLine) = FindForward(foundLine, lineCount, roundToSeconds); // step to the next valid timestamp
return foundLine;
}

/// <summary>
/// The raw binary-search step, without <see cref="FindLine"/>'s walk-back to the first line of a
/// duplicate-timestamp run. Exposed for <c>TimeSpreadCalculator</c>, which does its own
/// (cheaper) handling of a miss and does not need the run-collapsing behaviour.
/// </summary>
/// <returns>The matching line, or the near-miss line negated — same convention as <see cref="FindLine"/>.</returns>
public int FindNearestLine (DateTime timestamp, int fromLine, int rangeStart, int rangeEnd, int lineCount, bool roundToSeconds, CancellationToken token = default)
{
return FindLineInternal(fromLine, rangeStart, rangeEnd, timestamp, lineCount, roundToSeconds, token);
}

private int FindLineInternal (int lineNum, int rangeStart, int rangeEnd, DateTime timestamp, int lineCount, bool roundToSeconds, CancellationToken token)
{
var (currentTimestamp, foundLine) = FindBackward(lineNum, lineCount, roundToSeconds, token);
if (currentTimestamp.CompareTo(timestamp) == 0)
{
return foundLine;
}

if (timestamp < currentTimestamp)
{
rangeEnd = lineNum;
}
else
{
rangeStart = lineNum;
}

if (rangeEnd - rangeStart <= 0)
{
return -lineNum;
}

lineNum = ((rangeEnd - rangeStart) / 2) + rangeStart;

// Prevent an endless loop when the range can no longer be halved.
if (rangeEnd - rangeStart < 2)
{
(currentTimestamp, rangeStart) = FindBackward(rangeStart, lineCount, roundToSeconds, token);
if (currentTimestamp.CompareTo(timestamp) == 0)
{
return rangeStart;
}

(currentTimestamp, rangeEnd) = FindBackward(rangeEnd, lineCount, roundToSeconds, token);

return currentTimestamp.CompareTo(timestamp) == 0
? rangeEnd
: -lineNum;
}

return FindLineInternal(lineNum, rangeStart, rangeEnd, timestamp, lineCount, roundToSeconds, token);
}
}
61 changes: 0 additions & 61 deletions src/LogExpert.Core/Interfaces/ILogWindow.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,67 +44,6 @@ public interface ILogWindow
/// line's content and associated metadata.</returns>
ILogLineMemory GetLogLineMemoryWithWait (int lineNum);

/// <summary>
/// Gets the timestamp for the line at or after the specified line number,
/// searching forward through the file.
/// </summary>
/// <param name="lineNum">
/// A reference to the line number to start searching from.
/// This value is updated to the line number where the timestamp was found.
/// </param>
/// <param name="roundToSeconds">
/// If <c>true</c>, the returned timestamp is rounded to the nearest second.
/// </param>
/// <returns>
/// The timestamp of the line at or after the specified line number,
/// or <see cref="DateTime.MinValue"/> if no valid timestamp is found.
/// </returns>
/// <remarks>
/// Not all log lines may contain timestamps. This method searches forward
/// from the given line number until it finds a line with a valid timestamp.
/// The <paramref name="lineNum"/> parameter is updated to reflect the line
/// where the timestamp was found.
/// </remarks>
//TODO Find a way to not use a referenced int (https://github.com/LogExperts/LogExpert/issues/404)
DateTime GetTimestampForLineForward (ref int lineNum, bool roundToSeconds);

/// <summary>
/// Gets the timestamp for the line at or before the specified line number,
/// searching backward through the file.
/// second.
/// </summary>
/// <param name="lastLineNum">A reference to the line number to start searching from. This value is updated to the line number where the timestamp was found.</param>
/// <param name="roundToSeconds">true to round the timestamp to the nearest second; otherwise, false to return the precise timestamp.</param>
/// <returns>A tuple containing the timestamp for the specified line and the last line number for which a timestamp is
/// available.</returns>
/// <remarks>
/// Not all log lines may contain timestamps. This method searches backward
/// from the given line number until it finds a line with a valid timestamp.
/// the returned tuple contains the lastLineNumber
/// </remarks>
(DateTime timeStamp, int lastLineNumber) GetTimestampForLine (int lastLineNum, bool roundToSeconds);

/// <summary>
/// Finds the line number that corresponds to the specified timestamp within
/// the given range, using a binary search algorithm.
/// </summary>
/// <param name="lineNum">The starting line number for the search.</param>
/// <param name="rangeStart">The first line number of the search range (inclusive).</param>
/// <param name="rangeEnd">The last line number of the search range (inclusive).</param>
/// <param name="timestamp">The timestamp to search for.</param>
/// <param name="roundToSeconds">
/// If <c>true</c>, timestamps are rounded to seconds for comparison.
/// </param>
/// <returns>
/// The line number of the line with a timestamp closest to the specified timestamp,
/// or -1 if no matching line is found within the range.
/// </returns>
/// <remarks>
/// This method is used for timestamp-based navigation and synchronization between
/// multiple log windows. It performs a binary search for optimal performance.
/// </remarks>
int FindTimestampLineInternal (int lineNum, int rangeStart, int rangeEnd, DateTime timestamp, bool roundToSeconds);

/// <summary>
/// Selects the specified line in the log view and optionally scrolls to make it visible.
/// </summary>
Expand Down
Loading