Skip to content

Commit d60cddd

Browse files
authored
perf: cache the per-frame recomputation in the explorer render path (#655)
* fix: filter dashboard warning events server-side The cluster dashboard listed every Event and dropped non-warnings client-side. Send fieldSelector type=Warning so the API server filters instead. Cuts per-tick decode work on busy clusters. * perf: cache table layout and visible-items scans off the render hot path RenderTable rescanned every item for hasUnion and category/separator bookkeeping on each call even when TableLayoutCache already held the rest of the layout, and visibleMiddleItems refiltered, rescored, and recollapsed the full list on every render and several times per keystroke. Both are now memoized behind the existing fingerprint/rev invalidation. Fixes two pre-existing gaps that would have made the new memo return stale data: the cluster-color and read-only mutation paths wrote into middleItems by index without bumping middleItemsRev.
1 parent 0fbc0ca commit d60cddd

11 files changed

Lines changed: 462 additions & 16 deletions

internal/app/app.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -388,9 +388,9 @@ type Model struct {
388388
middleItemsRev uint64
389389
// selectionRev is bumped on every change to selectedItems so the row
390390
// cache invalidates and the selection marker on non-cursor rows updates.
391-
selectionRev uint64
392-
393-
middleTableRenderer *ui.TableRenderer
391+
selectionRev uint64
392+
middleItemsVisibleCache *visibleMiddleItemsCacheEntry
393+
middleTableRenderer *ui.TableRenderer
394394

395395
previewDebounceGen uint64
396396

internal/app/cluster_color_overlay.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ func (m Model) applyClusterColorSelection() (Model, bool) {
241241
for i := range m.middleItems {
242242
if m.middleItems[i].Name == ctx {
243243
m.middleItems[i].ClusterColor = newColor
244+
m.middleItemsRev++
244245
break
245246
}
246247
}

internal/app/cursor.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -559,10 +559,9 @@ func (m *Model) selectedItemsList() []model.Item {
559559
return selected
560560
}
561561

562-
// visibleMiddleItems returns the filtered subset of middleItems when a filter
563-
// is active, or all middleItems otherwise. At LevelResourceTypes, it also
564-
// applies collapsible group logic (accordion behavior).
565-
func (m *Model) visibleMiddleItems() []model.Item {
562+
// computeVisibleMiddleItems is the uncached body of visibleMiddleItems (see
563+
// cursor_visible_items_cache.go).
564+
func (m *Model) computeVisibleMiddleItems() []model.Item {
566565
items := m.middleItems
567566

568567
// Apply text filter first.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package app
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/janosmiko/lfk/internal/model"
8+
)
9+
10+
// BenchmarkVisibleMiddleItemsUnchanged simulates repeated render-loop calls
11+
// with the same filter/nav state each time, the case a memo should serve
12+
// from cache instead of re-filtering, re-scoring, and re-collapsing.
13+
func BenchmarkVisibleMiddleItemsUnchanged(b *testing.B) {
14+
b.Run("filtered", func(b *testing.B) {
15+
for _, n := range []int{1000, 5000} {
16+
b.Run(fmt.Sprintf("items=%d", n), func(b *testing.B) {
17+
m := newTestModel()
18+
m.setMiddleItems(benchItems(n))
19+
m.filterText = "row-1"
20+
m.filterBroadMode = true
21+
b.ReportAllocs()
22+
b.ResetTimer()
23+
for range b.N {
24+
_ = m.visibleMiddleItems()
25+
}
26+
})
27+
}
28+
})
29+
30+
b.Run("resourceTypesCollapse", func(b *testing.B) {
31+
for _, n := range []int{1000, 5000} {
32+
b.Run(fmt.Sprintf("items=%d", n), func(b *testing.B) {
33+
m := newTestModel()
34+
m.nav.Level = model.LevelResourceTypes
35+
items := benchItems(n)
36+
categories := []string{"Workloads", "Networking", "Storage"}
37+
for i := range items {
38+
items[i].Category = categories[i%len(categories)]
39+
}
40+
m.setMiddleItems(items)
41+
m.expandedGroup = "Workloads"
42+
b.ReportAllocs()
43+
b.ResetTimer()
44+
for range b.N {
45+
_ = m.visibleMiddleItems()
46+
}
47+
})
48+
}
49+
})
50+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package app
2+
3+
import (
4+
"unsafe"
5+
6+
"github.com/janosmiko/lfk/internal/model"
7+
)
8+
9+
// visibleMiddleItemsKey is the memo key for computeVisibleMiddleItems: every
10+
// field that changes its output.
11+
type visibleMiddleItemsKey struct {
12+
itemsPtr uintptr
13+
itemsLen int
14+
itemsRev uint64
15+
filterText string
16+
filterBroadMode bool
17+
navLevel model.Level
18+
allGroupsExpanded bool
19+
expandedGroup string
20+
}
21+
22+
// middleItemsHeaderPtr backstops itemsRev the same way TableRenderer's
23+
// itemsPtr backstops middleRev, against a reassignment that skips setMiddleItems.
24+
func middleItemsHeaderPtr(items []model.Item) uintptr {
25+
if len(items) == 0 {
26+
return 0
27+
}
28+
return uintptr(unsafe.Pointer(&items[0]))
29+
}
30+
31+
// visibleMiddleItemsCacheEntry must be replaced wholesale, never mutated in
32+
// place: a by-value Model copy shares this pointer.
33+
type visibleMiddleItemsCacheEntry struct {
34+
key visibleMiddleItemsKey
35+
items []model.Item
36+
}
37+
38+
// visibleMiddleItems returns the filtered subset of middleItems when a filter
39+
// is active, or all middleItems otherwise. At LevelResourceTypes, it also
40+
// applies collapsible group logic (accordion behavior). Memoized: callers
41+
// must not mutate the returned slice.
42+
func (m *Model) visibleMiddleItems() []model.Item {
43+
key := visibleMiddleItemsKey{
44+
itemsPtr: middleItemsHeaderPtr(m.middleItems),
45+
itemsLen: len(m.middleItems),
46+
itemsRev: m.middleItemsRev,
47+
filterText: m.filterText,
48+
filterBroadMode: m.filterBroadMode,
49+
navLevel: m.nav.Level,
50+
allGroupsExpanded: m.allGroupsExpanded,
51+
expandedGroup: m.expandedGroup,
52+
}
53+
if c := m.middleItemsVisibleCache; c != nil && c.key == key {
54+
return c.items
55+
}
56+
items := m.computeVisibleMiddleItems()
57+
m.middleItemsVisibleCache = &visibleMiddleItemsCacheEntry{key: key, items: items}
58+
return items
59+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package app
2+
3+
import (
4+
"testing"
5+
"unsafe"
6+
7+
"github.com/janosmiko/lfk/internal/model"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func visibleItemsTestModel() Model {
13+
m := newTestModel()
14+
m.setMiddleItems([]model.Item{
15+
{Name: "alpha", Namespace: "ns1", Kind: "Pod"},
16+
{Name: "beta", Namespace: "ns1", Kind: "Pod"},
17+
{Name: "gamma", Namespace: "ns2", Kind: "Pod"},
18+
})
19+
return m
20+
}
21+
22+
func sameBackingArray(a, b []model.Item) bool {
23+
if len(a) == 0 || len(b) == 0 {
24+
return len(a) == len(b)
25+
}
26+
return unsafe.SliceData(a) == unsafe.SliceData(b)
27+
}
28+
29+
func TestVisibleMiddleItems_MemoReusesSliceWhenInputsUnchanged(t *testing.T) {
30+
m := visibleItemsTestModel()
31+
32+
first := m.visibleMiddleItems()
33+
second := m.visibleMiddleItems()
34+
35+
assert.True(t, sameBackingArray(first, second),
36+
"repeated calls with unchanged filter/items/nav state must reuse the memoized slice")
37+
}
38+
39+
func TestVisibleMiddleItems_InvalidatesOnItemsRevChange(t *testing.T) {
40+
m := visibleItemsTestModel()
41+
first := m.visibleMiddleItems()
42+
43+
m.setMiddleItems([]model.Item{{Name: "delta", Namespace: "ns3", Kind: "Pod"}})
44+
second := m.visibleMiddleItems()
45+
46+
assert.False(t, sameBackingArray(first, second), "setMiddleItems must invalidate the memo")
47+
require.Len(t, second, 1)
48+
assert.Equal(t, "delta", second[0].Name)
49+
}
50+
51+
func TestVisibleMiddleItems_InvalidatesOnFilterTextChange(t *testing.T) {
52+
m := visibleItemsTestModel()
53+
unfiltered := m.visibleMiddleItems()
54+
require.Len(t, unfiltered, 3)
55+
56+
m.filterText = "beta"
57+
filtered := m.visibleMiddleItems()
58+
59+
require.Len(t, filtered, 1)
60+
assert.Equal(t, "beta", filtered[0].Name)
61+
}
62+
63+
func TestVisibleMiddleItems_InvalidatesOnFilterBroadModeChange(t *testing.T) {
64+
m := visibleItemsTestModel()
65+
m.middleItems[0].Columns = []model.KeyValue{{Key: "label", Value: "special-tag"}}
66+
m.setMiddleItems(m.middleItems)
67+
m.filterText = "special-tag"
68+
69+
m.filterBroadMode = false
70+
narrow := m.visibleMiddleItems()
71+
assert.Empty(t, narrow, "name-only filter must not match a column value")
72+
73+
m.filterBroadMode = true
74+
broad := m.visibleMiddleItems()
75+
require.Len(t, broad, 1, "broad mode must invalidate the memo and rescan columns")
76+
}
77+
78+
func TestVisibleMiddleItems_InvalidatesOnExpandedGroupChange(t *testing.T) {
79+
m := newTestModel()
80+
m.nav.Level = model.LevelResourceTypes
81+
m.setMiddleItems([]model.Item{
82+
{Name: "pods", Category: "Workloads"},
83+
{Name: "deployments", Category: "Workloads"},
84+
{Name: "services", Category: "Networking"},
85+
})
86+
m.expandedGroup = "Workloads"
87+
88+
withWorkloadsExpanded := m.visibleMiddleItems()
89+
names := itemNames(withWorkloadsExpanded)
90+
assert.Contains(t, names, "deployments")
91+
92+
m.expandedGroup = "Networking"
93+
withNetworkingExpanded := m.visibleMiddleItems()
94+
names = itemNames(withNetworkingExpanded)
95+
assert.Contains(t, names, "services")
96+
assert.NotContains(t, names, "deployments",
97+
"changing expandedGroup must invalidate the memo and recollapse")
98+
}
99+
100+
func TestVisibleMiddleItems_InvalidatesOnAllGroupsExpandedChange(t *testing.T) {
101+
m := newTestModel()
102+
m.nav.Level = model.LevelResourceTypes
103+
m.setMiddleItems([]model.Item{
104+
{Name: "pods", Category: "Workloads"},
105+
{Name: "deployments", Category: "Workloads"},
106+
})
107+
m.expandedGroup = "Networking" // Workloads stays collapsed
108+
109+
collapsed := m.visibleMiddleItems()
110+
assert.NotContains(t, itemNames(collapsed), "deployments")
111+
112+
m.allGroupsExpanded = true
113+
expanded := m.visibleMiddleItems()
114+
assert.Contains(t, itemNames(expanded), "deployments")
115+
}
116+
117+
func TestVisibleMiddleItems_InvalidatesOnNavLevelChange(t *testing.T) {
118+
m := newTestModel()
119+
m.setMiddleItems([]model.Item{
120+
{Name: "pods", Category: "Workloads"},
121+
{Name: "deployments", Category: "Workloads"},
122+
})
123+
m.expandedGroup = "Networking"
124+
125+
m.nav.Level = model.LevelResources
126+
uncollapsed := m.visibleMiddleItems()
127+
assert.Len(t, uncollapsed, 2, "collapse logic only applies at LevelResourceTypes")
128+
129+
m.nav.Level = model.LevelResourceTypes
130+
collapsed := m.visibleMiddleItems()
131+
assert.NotContains(t, itemNames(collapsed), "deployments")
132+
}
133+
134+
// These two sites mutate m.middleItems[i] by index instead of reassigning
135+
// the slice, so they must bump middleItemsRev themselves or the memo below
136+
// keeps a stale ClusterColor/ReadOnly value.
137+
func TestClusterColorOverlay_EnterApply_BumpsMiddleItemsRev(t *testing.T) {
138+
m := newClusterPickerModel(t)
139+
m.setMiddleItems(m.middleItems) // establish a non-zero baseline rev
140+
before := m.middleItemsRev
141+
142+
ret, _ := m.handleKeyClusterColorPicker()
143+
m = ret.(Model)
144+
m.clusterColorOverlayCursor = 0
145+
146+
ret, _ = m.handleClusterColorOverlayKey(keyMsg("enter"))
147+
result := ret.(Model)
148+
149+
assert.Greater(t, result.middleItemsRev, before,
150+
"applying a cluster color must bump middleItemsRev so the visibleMiddleItems memo invalidates")
151+
}
152+
153+
func TestHandleKeyReadOnlyToggle_AtClusterPicker_BumpsMiddleItemsRev(t *testing.T) {
154+
m := Model{
155+
nav: model.NavigationState{Level: model.LevelClusters},
156+
middleItems: []model.Item{{Name: "prod"}, {Name: "dev"}},
157+
cursors: [5]int{1, 0, 0, 0, 0},
158+
tabs: []TabState{{}},
159+
itemCache: map[string][]model.Item{},
160+
cacheFingerprints: map[string]string{},
161+
width: 80, height: 40,
162+
}
163+
m.setMiddleItems(m.middleItems) // establish a non-zero baseline rev
164+
before := m.middleItemsRev
165+
166+
ret, _ := m.handleKeyReadOnlyToggle()
167+
result := ret.(Model)
168+
169+
assert.Greater(t, result.middleItemsRev, before,
170+
"toggling read-only must bump middleItemsRev so the visibleMiddleItems memo invalidates")
171+
}

internal/app/readonly.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ func (m *Model) refreshContextReadOnlyMarkers() {
269269
for i := range m.middleItems {
270270
m.middleItems[i].ReadOnly = m.effectiveContextReadOnly(m.middleItems[i].Name)
271271
}
272+
m.middleItemsRev++
272273
// Keep the cache aligned so back-navigation re-shows the fresh markers.
273274
m.itemCache[m.navKey()] = m.middleItems
274275
}
@@ -364,6 +365,7 @@ func (m Model) handleKeyReadOnlyToggle() (tea.Model, tea.Cmd) {
364365
for i := range m.middleItems {
365366
if m.middleItems[i].Name == sel.Name {
366367
m.middleItems[i].ReadOnly = newState
368+
m.middleItemsRev++
367369
break
368370
}
369371
}

internal/ui/explorer_highlight.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ type TableLayoutCache struct {
2525
ContextW, NsW, ReadyW, RestartsW, AgeW, StatusW int
2626
AnyRecentRestart bool
2727
ExtraCols []extraColumn
28+
29+
HasUnion bool
30+
31+
HasCategories bool
32+
CategoryForItem []string
33+
HasSepForItem []bool
2834
}
2935

3036
// ActiveSelectedStyle returns SelectedStyle or a nyan rainbow style if nyan

0 commit comments

Comments
 (0)