From bdea99d4f9d083783d347027da5dfde55a80dac4 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Sat, 5 Sep 2026 01:08:24 -0400 Subject: [PATCH] refactor(chat): simplify member search cache --- internal/cache/cache.go | 46 ------------------------------- internal/ui/chat/composer.go | 31 ++++++++++----------- internal/ui/chat/composer_test.go | 41 +++++++++++++++++++++++++++ internal/ui/chat/state.go | 12 +++++++- 4 files changed, 67 insertions(+), 63 deletions(-) delete mode 100644 internal/cache/cache.go create mode 100644 internal/ui/chat/composer_test.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go deleted file mode 100644 index 49616f4..0000000 --- a/internal/cache/cache.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package cache is used for completion to not overflow the gateway with redundant search requests. -package cache - -import ( - "sync" -) - -type Cache struct { - items sync.Map -} - -func New() *Cache { - return &Cache{} -} - -func (c *Cache) Create(query string, value uint) { - c.items.Store(query, value) -} - -func (c *Cache) Get(query string) (uint, bool) { - i, ok := c.items.Load(query) - if !ok { - return 0, false - } - return i.(uint), true -} - -// Invalidate is only needed when a member leaves and the search query reaches -// the search limit. -// "aa", "ab", "ac", ..., "ay" // where length is longer than the limit -// if "ay" leaves, then "az" would not be loaded becaue it would not be -// returned by the search results because of the search limit. -func (c *Cache) Invalidate(name string, limit uint) { - for name != "" { - if v, ok := c.items.Load(name); ok && v.(uint) >= limit { - // This query, and every shorter prefix of it, may now be missing - // results, so drop the whole chain to force a fresh search. - for name != "" { - c.items.Delete(name) - name = name[:len(name)-1] - } - return - } - name = name[:len(name)-1] - } -} diff --git a/internal/ui/chat/composer.go b/internal/ui/chat/composer.go index 1abab7c..d8df7fa 100644 --- a/internal/ui/chat/composer.go +++ b/internal/ui/chat/composer.go @@ -20,7 +20,6 @@ import ( "github.com/ayn2op/arikawa/v3/state" "github.com/ayn2op/arikawa/v3/utils/json/option" "github.com/ayn2op/arikawa/v3/utils/sendpart" - "github.com/ayn2op/discordo/internal/cache" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/ui" @@ -51,11 +50,11 @@ type composer struct { cfg *config.Config - edit bool - sendMessageData *api.SendMessageData - cache *cache.Cache - mentionsList *mentionslist.Model - lastSearch time.Time + edit bool + sendMessageData *api.SendMessageData + memberSearchCache map[string]uint + mentionsList *mentionslist.Model + lastSearch time.Time typingUntil time.Time } @@ -67,12 +66,12 @@ var _ help.KeyMap = (*composer)(nil) func newComposer(cfg *config.Config, chat *Model) *composer { c := &composer{ - TextArea: tview.NewTextArea(), - cfg: cfg, - chat: chat, - sendMessageData: &api.SendMessageData{}, - cache: cache.New(), - mentionsList: mentionslist.NewModel(cfg), + TextArea: tview.NewTextArea(), + cfg: cfg, + chat: chat, + sendMessageData: &api.SendMessageData{}, + memberSearchCache: make(map[string]uint), + mentionsList: mentionslist.NewModel(cfg), } ui.ConfigureBox(c.Box, &cfg.Theme) c. @@ -629,16 +628,16 @@ func (c *composer) searchMember(gID discord.GuildID, name string) tview.Cmd { } key := gID.String() + " " + name - if _, ok := c.cache.Get(key); ok { + if _, ok := c.memberSearchCache[key]; ok { return nil } // If searching for "ab" returns less than SearchLimit, // then "abc" would not return anything new because we already searched // everything starting with "ab". This will still be true even if a new // member joins because arikawa loads new members into the state. - if count, ok := c.cache.Get(key[:len(key)-1]); ok { + if count, ok := c.memberSearchCache[key[:len(key)-1]]; ok { if count < c.chat.state.MemberState.SearchLimit { - c.cache.Create(key, count) + c.memberSearchCache[key] = count return nil } } @@ -671,7 +670,7 @@ func (c *composer) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tv return nil } - c.cache.Create(key, uint(len(event.Members))) + c.memberSearchCache[key] = uint(len(event.Members)) return func() tview.Msg { return tabSuggestMsg{} } diff --git a/internal/ui/chat/composer_test.go b/internal/ui/chat/composer_test.go new file mode 100644 index 0000000..b1fe431 --- /dev/null +++ b/internal/ui/chat/composer_test.go @@ -0,0 +1,41 @@ +package chat + +import ( + "maps" + "path/filepath" + "testing" + + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/arikawa/v3/gateway" + "github.com/ayn2op/discordo/internal/config" +) + +func TestMemberSearchCache(t *testing.T) { + cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml")) + if err != nil { + t.Fatal(err) + } + m := NewModel(cfg, "") + m.state.MemberState.SearchLimit = 2 + c := m.composer + c.onGuildMembersChunk(&gateway.GuildMembersChunkEvent{ + Nonce: memberSearchNonce + "1 ab", + Members: []discord.Member{{}}, + }) + if cmd := c.searchMember(1, "ab"); cmd != nil { + t.Fatal("cached query returned a network command") + } + if cmd := c.searchMember(1, "abc"); cmd != nil || c.memberSearchCache["1 abc"] != 1 { + t.Fatal("incomplete prefix results were not reused") + } + c.memberSearchCache["1 a"] = 2 + c.memberSearchCache["2 a"] = 2 + m.onGuildMemberRemove(&gateway.GuildMemberRemoveEvent{ + GuildID: 1, + User: discord.User{Username: "abc"}, + }) + want := map[string]uint{"1 ab": 1, "1 abc": 1, "2 a": 2} + if !maps.Equal(c.memberSearchCache, want) { + t.Fatalf("cache after invalidation = %v, want %v", c.memberSearchCache, want) + } +} diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 043f49f..b3da4fc 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -170,7 +170,17 @@ func (m *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tview } func (m *Model) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { - m.composer.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, m.state.MemberState.SearchLimit) + memberSearchCache := m.composer.memberSearchCache + for name := event.GuildID.String() + " " + event.User.Username; name != ""; name = name[:len(name)-1] { + if count, ok := memberSearchCache[name]; ok && count >= m.state.MemberState.SearchLimit { + // A full result set may now be missing members; search these prefixes again. + for name != "" { + delete(memberSearchCache, name) + name = name[:len(name)-1] + } + return + } + } } func (m *Model) onTypingStart(event *gateway.TypingStartEvent) {