Skip to content

Commit db9758f

Browse files
jschick04NikTilton
authored andcommitted
Updated event table to batch load and optimized indexing performance
1 parent 6d66daa commit db9758f

File tree

10 files changed

+285
-19
lines changed

10 files changed

+285
-19
lines changed

src/EventLogExpert.UI.Tests/Store/EventTable/EventTableStoreTests.cs

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,123 @@ public void ReduceAddTable_WhenSecondTable_ShouldSetCombinedAsActive()
414414
Assert.Equal(combinedTable.Id, newState.ActiveEventLogId);
415415
}
416416

417+
[Fact]
418+
public void ReduceAppendTableEvents_ShouldAppendEventsToExistingDisplayedEvents()
419+
{
420+
// Arrange
421+
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
422+
var state = new EventTableState();
423+
state = EventTableReducers.ReduceAddTable(state, new EventTableAction.AddTable(logData));
424+
425+
var initialEvents = new List<DisplayEventModel>
426+
{
427+
EventUtils.CreateTestEvent(100, recordId: 1),
428+
EventUtils.CreateTestEvent(200, recordId: 2)
429+
};
430+
431+
state = EventTableReducers.ReduceAppendTableEvents(
432+
state,
433+
new EventTableAction.AppendTableEvents(logData.Id, initialEvents));
434+
435+
var deltaEvents = new List<DisplayEventModel>
436+
{
437+
EventUtils.CreateTestEvent(300, recordId: 3),
438+
EventUtils.CreateTestEvent(400, recordId: 4)
439+
};
440+
441+
var action = new EventTableAction.AppendTableEvents(logData.Id, deltaEvents);
442+
443+
// Act
444+
var newState = EventTableReducers.ReduceAppendTableEvents(state, action);
445+
446+
// Assert
447+
var updatedTable = newState.EventTables.First(t => t.Id == logData.Id);
448+
Assert.Equal(4, updatedTable.DisplayedEvents.Count);
449+
}
450+
451+
[Fact]
452+
public void ReduceAppendTableEvents_ShouldNotChangeIsLoading()
453+
{
454+
// Arrange
455+
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
456+
var state = new EventTableState();
457+
state = EventTableReducers.ReduceAddTable(state, new EventTableAction.AddTable(logData));
458+
459+
// Table should be in loading state after AddTable
460+
Assert.True(state.EventTables.First(t => t.Id == logData.Id).IsLoading);
461+
462+
var action = new EventTableAction.AppendTableEvents(
463+
logData.Id,
464+
new List<DisplayEventModel> { EventUtils.CreateTestEvent(100, recordId: 1) });
465+
466+
// Act
467+
var newState = EventTableReducers.ReduceAppendTableEvents(state, action);
468+
469+
// Assert - IsLoading should still be true (partial update doesn't complete loading)
470+
var updatedTable = newState.EventTables.First(t => t.Id == logData.Id);
471+
Assert.True(updatedTable.IsLoading);
472+
Assert.Single(updatedTable.DisplayedEvents);
473+
}
474+
475+
[Fact]
476+
public void ReduceAppendTableEvents_ShouldPreserveSortOrder()
477+
{
478+
// Arrange
479+
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
480+
var state = new EventTableState();
481+
state = EventTableReducers.ReduceAddTable(state, new EventTableAction.AddTable(logData));
482+
483+
var initialEvents = new List<DisplayEventModel>
484+
{
485+
EventUtils.CreateTestEvent(100, recordId: 10),
486+
EventUtils.CreateTestEvent(200, recordId: 20)
487+
};
488+
489+
state = EventTableReducers.ReduceAppendTableEvents(
490+
state,
491+
new EventTableAction.AppendTableEvents(logData.Id, initialEvents));
492+
493+
// Append events with record IDs that should sort between and after existing
494+
var deltaEvents = new List<DisplayEventModel>
495+
{
496+
EventUtils.CreateTestEvent(300, recordId: 5),
497+
EventUtils.CreateTestEvent(400, recordId: 15)
498+
};
499+
500+
var action = new EventTableAction.AppendTableEvents(logData.Id, deltaEvents);
501+
502+
// Act
503+
var newState = EventTableReducers.ReduceAppendTableEvents(state, action);
504+
505+
// Assert - default sort is by RecordId descending (IsDescending defaults to true)
506+
var displayedEvents = newState.EventTables.First(t => t.Id == logData.Id).DisplayedEvents;
507+
Assert.Equal(4, displayedEvents.Count);
508+
509+
var recordIds = displayedEvents.Select(e => e.RecordId).ToList();
510+
Assert.Equal(recordIds.OrderByDescending(x => x).ToList(), recordIds);
511+
}
512+
513+
[Fact]
514+
public void ReduceAppendTableEvents_WhenTableNotFound_ShouldReturnUnchangedState()
515+
{
516+
// Arrange
517+
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
518+
var state = new EventTableState();
519+
state = EventTableReducers.ReduceAddTable(state, new EventTableAction.AddTable(logData));
520+
521+
var unknownLogId = EventLogId.Create();
522+
523+
var action = new EventTableAction.AppendTableEvents(
524+
unknownLogId,
525+
new List<DisplayEventModel> { EventUtils.CreateTestEvent(100) });
526+
527+
// Act
528+
var newState = EventTableReducers.ReduceAppendTableEvents(state, action);
529+
530+
// Assert
531+
Assert.Same(state, newState);
532+
}
533+
417534
[Fact]
418535
public void ReduceCloseAll_ShouldClearAllTables()
419536
{
@@ -518,7 +635,7 @@ public void ReduceLoadColumnsCompleted_ShouldUpdateColumns()
518635
}
519636

520637
[Fact]
521-
public void ReduceSetActiveTable_WhenTableIsLoading_ShouldNotChangeActive()
638+
public void ReduceSetActiveTable_WhenTableIsLoading_ShouldChangeActive()
522639
{
523640
// Arrange
524641
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
@@ -531,7 +648,7 @@ public void ReduceSetActiveTable_WhenTableIsLoading_ShouldNotChangeActive()
531648
var newState = EventTableReducers.ReduceSetActiveTable(state, action);
532649

533650
// Assert
534-
Assert.Equal(state.ActiveEventLogId, newState.ActiveEventLogId);
651+
Assert.Equal(logData.Id, newState.ActiveEventLogId);
535652
}
536653

537654
[Fact]
@@ -632,7 +749,7 @@ public void ReduceToggleSorting_ShouldToggleIsDescending()
632749
}
633750

634751
[Fact]
635-
public void ReduceUpdateCombinedEvents_WhenAnyTableIsLoading_ShouldReturnSameState()
752+
public void ReduceUpdateCombinedEvents_WhenAllTablesAreLoading_ShouldReturnSameState()
636753
{
637754
// Arrange
638755
var logData1 = new EventLogData(Constants.LogNameLog1, PathType.LogName, []);

src/EventLogExpert.UI/FilterMethods.cs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,47 @@ internal static Comparison<DisplayEventModel> GetComparer(ColumnName? orderBy, b
104104
return isDescending ? (a, b) => comparer(b, a) : comparer;
105105
}
106106

107-
private static int WithTieBreaker(int primaryResult, DisplayEventModel a, DisplayEventModel b) =>
108-
primaryResult != 0 ? primaryResult : FallbackTieBreaker(Nullable.Compare(a.RecordId, b.RecordId), a, b);
107+
/// <summary>
108+
/// Merges an already-sorted existing list with a new batch by sorting only the batch
109+
/// and performing a linear merge. O(n + k log k) where n is existing count and k is batch count.
110+
/// </summary>
111+
internal static IReadOnlyList<DisplayEventModel> MergeSorted(
112+
IReadOnlyList<DisplayEventModel> existing,
113+
IReadOnlyList<DisplayEventModel> batch,
114+
ColumnName? orderBy,
115+
bool isDescending)
116+
{
117+
if (batch.Count == 0) { return existing; }
118+
119+
if (existing.Count == 0) { return batch.SortEvents(orderBy, isDescending); }
120+
121+
var comparer = GetComparer(orderBy, isDescending);
122+
123+
var sortedBatch = new List<DisplayEventModel>(batch);
124+
sortedBatch.Sort(comparer);
125+
126+
var result = new List<DisplayEventModel>(existing.Count + sortedBatch.Count);
127+
int i = 0, j = 0;
128+
129+
while (i < existing.Count && j < sortedBatch.Count)
130+
{
131+
result.Add(comparer(existing[i], sortedBatch[j]) <= 0 ? existing[i++] : sortedBatch[j++]);
132+
}
133+
134+
while (i < existing.Count) { result.Add(existing[i++]); }
135+
136+
while (j < sortedBatch.Count) { result.Add(sortedBatch[j++]); }
137+
138+
return result.AsReadOnly();
139+
}
109140

110141
/// <summary>Falls back to RecordId, then OwningLog (for combined logs) to guarantee a total order for List.Sort stability.</summary>
111142
private static int FallbackTieBreaker(int recordIdResult, DisplayEventModel a, DisplayEventModel b) =>
112143
recordIdResult != 0 ? recordIdResult : string.Compare(a.OwningLog, b.OwningLog, StringComparison.Ordinal);
113144

145+
private static int WithTieBreaker(int primaryResult, DisplayEventModel a, DisplayEventModel b) =>
146+
primaryResult != 0 ? primaryResult : FallbackTieBreaker(Nullable.Compare(a.RecordId, b.RecordId), a, b);
147+
114148
extension(DisplayEventModel? @event)
115149
{
116150
public bool Filter(IEnumerable<FilterModel> filters, bool isXmlEnabled)

src/EventLogExpert.UI/Store/EventLog/EventLogAction.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ public sealed record CloseLog(EventLogId LogId, string LogName);
2222

2323
public sealed record LoadEvents(EventLogData LogData, IReadOnlyList<DisplayEventModel> Events);
2424

25+
public sealed record LoadEventsPartial(EventLogData LogData, IReadOnlyList<DisplayEventModel> Events);
26+
2527
public sealed record LoadNewEvents;
2628

2729
public sealed record OpenLog(string LogName, PathType PathType, CancellationToken Token = default);

src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ public Task HandleLoadEvents(EventLogAction.LoadEvents action, IDispatcher dispa
104104
return Task.CompletedTask;
105105
}
106106

107+
[EffectMethod]
108+
public Task HandleLoadEventsPartial(EventLogAction.LoadEventsPartial action, IDispatcher dispatcher)
109+
{
110+
var filteredEvents = _filterService.GetFilteredEvents(action.Events, _eventLogState.Value.AppliedFilter);
111+
112+
dispatcher.Dispatch(new EventTableAction.AppendTableEvents(action.LogData.Id, filteredEvents));
113+
114+
return Task.CompletedTask;
115+
}
116+
107117
[EffectMethod(typeof(EventLogAction.LoadNewEvents))]
108118
public Task HandleLoadNewEvents(IDispatcher dispatcher)
109119
{
@@ -139,6 +149,8 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa
139149
string? lastEvent;
140150
int failed = 0;
141151
int resolved = 0;
152+
int lastPartialIndex = 0;
153+
int timerTick = 0;
142154

143155
dispatcher.Dispatch(new EventTableAction.AddTable(logData));
144156

@@ -150,8 +162,29 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa
150162

151163
List<DisplayEventModel> events = [];
152164

153-
await using Timer timer = new(
154-
_ => { dispatcher.Dispatch(new StatusBarAction.SetEventsLoading(activityId, Volatile.Read(ref resolved), Volatile.Read(ref failed))); },
165+
await using var timer = new Timer(
166+
_ =>
167+
{
168+
dispatcher.Dispatch(new StatusBarAction.SetEventsLoading(activityId, Volatile.Read(ref resolved), Volatile.Read(ref failed)));
169+
170+
// Skip the immediate first tick (dueTime = 0) so the first partial
171+
// is dispatched after ~3 seconds of loading.
172+
if (Interlocked.Increment(ref timerTick) <= 1) { return; }
173+
174+
List<DisplayEventModel> delta;
175+
176+
lock (events)
177+
{
178+
int fromIndex = Volatile.Read(ref lastPartialIndex);
179+
180+
if (events.Count <= fromIndex) { return; }
181+
182+
delta = events.GetRange(fromIndex, events.Count - fromIndex);
183+
Volatile.Write(ref lastPartialIndex, events.Count);
184+
}
185+
186+
dispatcher.Dispatch(new EventLogAction.LoadEventsPartial(logData, delta.AsReadOnly()));
187+
},
155188
null,
156189
TimeSpan.Zero,
157190
TimeSpan.FromSeconds(3));

src/EventLogExpert.UI/Store/EventLog/EventLogReducers.cs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,26 @@ public static EventLogState ReduceCloseLog(EventLogState state, EventLogAction.C
4545
}
4646

4747
[ReducerMethod]
48-
public static EventLogState ReduceLoadEvents(EventLogState state, EventLogAction.LoadEvents action)
48+
public static EventLogState ReduceLoadEvents(EventLogState state, EventLogAction.LoadEvents action) =>
49+
UpdateActiveLog(state, action.LogData, action.Events);
50+
51+
[ReducerMethod]
52+
public static EventLogState ReduceLoadEventsPartial(EventLogState state, EventLogAction.LoadEventsPartial action)
4953
{
50-
var newLogsCollection = state.ActiveLogs.Remove(action.LogData.Name);
54+
if (!state.ActiveLogs.TryGetValue(action.LogData.Name, out var existingLog))
55+
{
56+
return state;
57+
}
58+
59+
var merged = new List<DisplayEventModel>(existingLog.Events.Count + action.Events.Count);
60+
merged.AddRange(existingLog.Events);
61+
merged.AddRange(action.Events);
5162

5263
return state with
5364
{
54-
ActiveLogs = newLogsCollection.Add(
55-
action.LogData.Name,
56-
action.LogData with { Events = action.Events })
65+
ActiveLogs = state.ActiveLogs
66+
.Remove(action.LogData.Name)
67+
.Add(action.LogData.Name, existingLog with { Events = merged.AsReadOnly() })
5768
};
5869
}
5970

@@ -119,4 +130,15 @@ public static EventLogState ReduceSetFilters(EventLogState state, EventLogAction
119130

120131
private static EventLogData GetEmptyLogData(string logName, PathType pathType) =>
121132
new(logName, pathType, new List<DisplayEventModel>().AsReadOnly());
133+
134+
private static EventLogState UpdateActiveLog(
135+
EventLogState state,
136+
EventLogData logData,
137+
IReadOnlyList<DisplayEventModel> events) =>
138+
state with
139+
{
140+
ActiveLogs = state.ActiveLogs
141+
.Remove(logData.Name)
142+
.Add(logData.Name, logData with { Events = events })
143+
};
122144
}

src/EventLogExpert.UI/Store/EventTable/EventTableAction.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public sealed record EventTableAction
1010
{
1111
public sealed record AddTable(EventLogData LogData);
1212

13+
public sealed record AppendTableEvents(EventLogId LogId, IReadOnlyList<DisplayEventModel> Events);
14+
1315
public sealed record CloseAll;
1416

1517
public sealed record CloseLog(EventLogId LogId);

src/EventLogExpert.UI/Store/EventTable/EventTableEffects.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ public sealed class EventTableEffects(IPreferencesProvider preferencesProvider)
1010
{
1111
private readonly IPreferencesProvider _preferencesProvider = preferencesProvider;
1212

13+
[EffectMethod(typeof(EventTableAction.AppendTableEvents))]
14+
public static Task HandleAppendTableEvents(IDispatcher dispatcher)
15+
{
16+
dispatcher.Dispatch(new EventTableAction.UpdateCombinedEvents());
17+
18+
return Task.CompletedTask;
19+
}
20+
1321
[EffectMethod(typeof(EventTableAction.UpdateDisplayedEvents))]
1422
public static Task HandleUpdateDisplayedEvents(IDispatcher dispatcher)
1523
{

src/EventLogExpert.UI/Store/EventTable/EventTableReducers.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,28 @@ public static EventTableState ReduceAddTable(EventTableState state, EventTableAc
4949
};
5050
}
5151

52+
[ReducerMethod]
53+
public static EventTableState ReduceAppendTableEvents(EventTableState state, EventTableAction.AppendTableEvents action)
54+
{
55+
var table = state.EventTables.FirstOrDefault(t => action.LogId == t.Id);
56+
57+
if (table is null) { return state; }
58+
59+
var merged = FilterMethods.MergeSorted(
60+
table.DisplayedEvents,
61+
action.Events,
62+
state.OrderBy,
63+
state.IsDescending);
64+
65+
return state with
66+
{
67+
EventTables = state.EventTables.Replace(table, table with
68+
{
69+
DisplayedEvents = merged
70+
})
71+
};
72+
}
73+
5274
[ReducerMethod(typeof(EventTableAction.CloseAll))]
5375
public static EventTableState ReduceCloseAll(EventTableState state) =>
5476
state with { EventTables = [], ActiveEventLogId = null };
@@ -103,8 +125,6 @@ public static EventTableState ReduceSetActiveTable(EventTableState state, EventT
103125
{
104126
var activeTable = state.EventTables.First(table => table.Id == action.LogId);
105127

106-
if (activeTable.IsLoading) { return state; }
107-
108128
return state with { ActiveEventLogId = activeTable.Id };
109129
}
110130

@@ -138,6 +158,10 @@ public static EventTableState ReduceUpdateCombinedEvents(EventTableState state)
138158
{
139159
if (state.EventTables.Count <= 1) { return state; }
140160

161+
var nonCombinedTables = state.EventTables.Where(table => !table.IsCombined);
162+
163+
if (nonCombinedTables.All(table => table.IsLoading)) { return state; }
164+
141165
var existingCombinedTable = state.EventTables.FirstOrDefault(table => table.IsCombined);
142166

143167
if (existingCombinedTable is null) { return state; }
@@ -198,7 +222,7 @@ public static EventTableState ReduceUpdateTable(EventTableState state, EventTabl
198222

199223
return state with
200224
{
201-
EventTables = state.EventTables.Remove(table).Add(table with
225+
EventTables = state.EventTables.Replace(table, table with
202226
{
203227
DisplayedEvents = action.Events.SortEvents(state.OrderBy, state.IsDescending),
204228
IsLoading = false

0 commit comments

Comments
 (0)