refactor(chat): simplify member search cache

This commit is contained in:
ayn2op 2026-09-05 01:08:24 -04:00
parent 8ccfd47046
commit bdea99d4f9
4 changed files with 67 additions and 63 deletions

View file

@ -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]
}
}

View file

@ -20,7 +20,6 @@ import (
"github.com/ayn2op/arikawa/v3/state" "github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/arikawa/v3/utils/json/option" "github.com/ayn2op/arikawa/v3/utils/json/option"
"github.com/ayn2op/arikawa/v3/utils/sendpart" "github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/cache"
"github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/consts"
"github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/internal/ui"
@ -53,7 +52,7 @@ type composer struct {
edit bool edit bool
sendMessageData *api.SendMessageData sendMessageData *api.SendMessageData
cache *cache.Cache memberSearchCache map[string]uint
mentionsList *mentionslist.Model mentionsList *mentionslist.Model
lastSearch time.Time lastSearch time.Time
@ -71,7 +70,7 @@ func newComposer(cfg *config.Config, chat *Model) *composer {
cfg: cfg, cfg: cfg,
chat: chat, chat: chat,
sendMessageData: &api.SendMessageData{}, sendMessageData: &api.SendMessageData{},
cache: cache.New(), memberSearchCache: make(map[string]uint),
mentionsList: mentionslist.NewModel(cfg), mentionsList: mentionslist.NewModel(cfg),
} }
ui.ConfigureBox(c.Box, &cfg.Theme) ui.ConfigureBox(c.Box, &cfg.Theme)
@ -629,16 +628,16 @@ func (c *composer) searchMember(gID discord.GuildID, name string) tview.Cmd {
} }
key := gID.String() + " " + name key := gID.String() + " " + name
if _, ok := c.cache.Get(key); ok { if _, ok := c.memberSearchCache[key]; ok {
return nil return nil
} }
// If searching for "ab" returns less than SearchLimit, // If searching for "ab" returns less than SearchLimit,
// then "abc" would not return anything new because we already searched // then "abc" would not return anything new because we already searched
// everything starting with "ab". This will still be true even if a new // everything starting with "ab". This will still be true even if a new
// member joins because arikawa loads new members into the state. // 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 { if count < c.chat.state.MemberState.SearchLimit {
c.cache.Create(key, count) c.memberSearchCache[key] = count
return nil return nil
} }
} }
@ -671,7 +670,7 @@ func (c *composer) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tv
return nil return nil
} }
c.cache.Create(key, uint(len(event.Members))) c.memberSearchCache[key] = uint(len(event.Members))
return func() tview.Msg { return func() tview.Msg {
return tabSuggestMsg{} return tabSuggestMsg{}
} }

View file

@ -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)
}
}

View file

@ -170,7 +170,17 @@ func (m *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tview
} }
func (m *Model) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { 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) { func (m *Model) onTypingStart(event *gateway.TypingStartEvent) {