From a4041c29f367fea450f013f494dc01f292ebac82 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 2 Sep 2026 15:23:05 -0400 Subject: [PATCH] fix(chat): mark a page last when it reaches the beginning, cache topic names Split out of the client-side thread window work, which is the pr under this one. patchPaginationLast: a page that reaches message ID 1 - or the nukepoint an expunge records - is the last page whatever the pager says, so the client stops asking for more and stops walking a conversation it has already seen the whole of. The topic-name cache collapses the per-#token fan-out: every message body holding a channel token used to read the inbox and then fetch the METADATA message of every channel in the team, thousands of single-message fetches for one page of a busy channel. The TTL is short because nothing invalidates the entry, and a result missing channels is cached shorter still - a team almost always carries channels the user cannot resolve, so refusing to cache those would mean never caching anything. --- go/chat/convsource.go | 16 +- go/chat/convsource_patchpagination_test.go | 153 +++++++++++++++ go/chat/teamchannelsource.go | 141 +++++++++++++- .../teamchannelsource_topicnamecache_test.go | 182 ++++++++++++++++++ 4 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 go/chat/convsource_patchpagination_test.go create mode 100644 go/chat/teamchannelsource_topicnamecache_test.go diff --git a/go/chat/convsource.go b/go/chat/convsource.go index b3218c0313d9..cdb2e7b9f813 100644 --- a/go/chat/convsource.go +++ b/go/chat/convsource.go @@ -231,14 +231,24 @@ func (s *baseConversationSource) patchPaginationLast(ctx context.Context, conv t page.Last = true return } + end1 := msgs[0].GetMessageID() + end2 := msgs[len(msgs)-1].GetMessageID() + oldest := end1.Min(end2) + // Message IDs start at 1, so a page holding it has reached the beginning of the conversation and + // nothing older can exist. Worth checking before the expunge record because that record is not + // always populated: a conversation whose history was deleted reads back Upto:0 until its inbox + // entry is localized, and until then every page of it looks like there is more to come. + if oldest == 1 { + s.Debug(ctx, "patchPaginationLast: true - reached the first message") + page.Last = true + return + } expunge := conv.GetExpunge() if expunge == nil { s.Debug(ctx, "patchPaginationLast: no expunge info") return } - end1 := msgs[0].GetMessageID() - end2 := msgs[len(msgs)-1].GetMessageID() - if end1.Min(end2) <= expunge.Upto { + if oldest <= expunge.Upto { s.Debug(ctx, "patchPaginationLast: true - hit upto") // If any message is prior to the nukepoint, say this is the last page. page.Last = true diff --git a/go/chat/convsource_patchpagination_test.go b/go/chat/convsource_patchpagination_test.go new file mode 100644 index 000000000000..de45c397a727 --- /dev/null +++ b/go/chat/convsource_patchpagination_test.go @@ -0,0 +1,153 @@ +package chat + +import ( + "context" + "testing" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/types" + "github.com/keybase/client/go/chat/utils" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" + "github.com/stretchr/testify/require" +) + +// patchPaginationConv is the smallest thing satisfying types.UnboxConversationInfo. Only +// GetExpunge is consulted by patchPaginationLast; the rest exist to satisfy the interface. +type patchPaginationConv struct { + expunge *chat1.Expunge +} + +var _ types.UnboxConversationInfo = patchPaginationConv{} + +func (c patchPaginationConv) GetConvID() chat1.ConversationID { return nil } +func (c patchPaginationConv) GetMembersType() chat1.ConversationMembersType { + return chat1.ConversationMembersType_TEAM +} +func (c patchPaginationConv) GetFinalizeInfo() *chat1.ConversationFinalizeInfo { return nil } +func (c patchPaginationConv) GetExpunge() *chat1.Expunge { return c.expunge } +func (c patchPaginationConv) GetMaxDeletedUpTo() chat1.MessageID { return 0 } +func (c patchPaginationConv) IsPublic() bool { return false } +func (c patchPaginationConv) GetMaxMessage(chat1.MessageType) (chat1.MessageSummary, error) { + return chat1.MessageSummary{}, nil +} + +// newPatchPaginationSource builds just enough of a baseConversationSource to call +// patchPaginationLast. It needs no database, network or logged in user - a bare GlobalContext +// already carries a logger, which is all Debug touches. +func newPatchPaginationSource() *baseConversationSource { + g := libkb.NewGlobalContext() + return &baseConversationSource{ + Contextified: globals.NewContextified(globals.NewContext(g, &globals.ChatContext{})), + DebugLabeler: utils.NewDebugLabeler(g, "patchPaginationTest", false), + } +} + +func msgsWithIDs(ids ...chat1.MessageID) []chat1.MessageUnboxed { + res := make([]chat1.MessageUnboxed, 0, len(ids)) + for _, id := range ids { + res = append(res, chat1.NewMessageUnboxedWithPlaceholder(chat1.MessageUnboxedPlaceholder{ + MessageID: id, + })) + } + return res +} + +func TestPatchPaginationLast(t *testing.T) { + ctx := context.Background() + uid := gregor1.UID([]byte{0x01}) + s := newPatchPaginationSource() + + testCases := []struct { + name string + expunge *chat1.Expunge + msgs []chat1.MessageUnboxed + page *chat1.Pagination + want bool + }{ + { + name: "an empty page is the last page", + msgs: nil, + page: &chat1.Pagination{Num: 50}, + want: true, + }, + { + // The regression this guards: after a nuke a conversation whose history was deleted + // reads back Upto:0 until its inbox entry is localized, so the expunge check below + // never fires and Last stays false forever - "Digging ancient messages..." on a fully + // loaded thread. + name: "reaching message ID 1 is last even when expunge reads back Upto:0", + expunge: &chat1.Expunge{Upto: 0}, + msgs: msgsWithIDs(1, 2, 3), + page: &chat1.Pagination{Num: 50}, + want: true, + }, + { + name: "reaching message ID 1 is last even with no expunge record at all", + msgs: msgsWithIDs(1, 2, 3), + page: &chat1.Pagination{Num: 50}, + want: true, + }, + { + // Pages can arrive newest first, and the check is on the oldest ID either way. + name: "message ID 1 is found regardless of page order", + msgs: msgsWithIDs(3, 2, 1), + page: &chat1.Pagination{Num: 50}, + want: true, + }, + { + // The boundary from the other side. An over-eager check here silently truncates a + // thread's history, which is the more damaging direction and the harder one to notice. + name: "a page starting at message ID 2 is not last", + msgs: msgsWithIDs(2, 3, 4), + page: &chat1.Pagination{Num: 50}, + want: false, + }, + { + name: "a page above the beginning with no expunge is not last", + msgs: msgsWithIDs(40, 41, 42), + page: &chat1.Pagination{Num: 50}, + want: false, + }, + { + name: "a page reaching the nukepoint is last", + expunge: &chat1.Expunge{Upto: 40}, + msgs: msgsWithIDs(40, 41, 42), + page: &chat1.Pagination{Num: 50}, + want: true, + }, + { + name: "a page above the nukepoint is not last", + expunge: &chat1.Expunge{Upto: 10}, + msgs: msgsWithIDs(40, 41, 42), + page: &chat1.Pagination{Num: 50}, + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + conv := patchPaginationConv{expunge: tc.expunge} + s.patchPaginationLast(ctx, conv, uid, tc.page, tc.msgs) + require.Equal(t, tc.want, tc.page.Last) + }) + } +} + +func TestPatchPaginationLastLeavesSettledPagesAlone(t *testing.T) { + ctx := context.Background() + uid := gregor1.UID([]byte{0x01}) + s := newPatchPaginationSource() + conv := patchPaginationConv{} + + // A nil page must not panic. + require.NotPanics(t, func() { + s.patchPaginationLast(ctx, conv, uid, nil, msgsWithIDs(1)) + }) + + // Last is only ever turned on, never off. + page := &chat1.Pagination{Num: 50, Last: true} + s.patchPaginationLast(ctx, conv, uid, page, msgsWithIDs(40, 41, 42)) + require.True(t, page.Last) +} diff --git a/go/chat/teamchannelsource.go b/go/chat/teamchannelsource.go index d98e0f36e7e3..989f80baf495 100644 --- a/go/chat/teamchannelsource.go +++ b/go/chat/teamchannelsource.go @@ -129,12 +129,111 @@ func (i *lastActiveAtMemCache) OnDbNuke(mctx libkb.MetaContext) error { return nil } +type topicNameCacheItem struct { + names []chat1.ChannelNameMention + // False when some channel in the team could not be resolved, which shortens the TTL below. + complete bool + mtime gregor1.Time +} + +// Channel-name resolution is charged per message: every message body holding a `#token` sends +// ParseChannelNameMentions here, and the uncached path reads the inbox and then fetches the METADATA +// message of every channel in the team. Unboxing one page of a busy channel in a team with a few +// dozen channels therefore cost thousands of single-message fetches. +// +// There is no invalidation hook, so the TTL is the only thing bounding staleness, and it is kept +// short for that reason - a page's worth of resolutions all land within milliseconds of each other, +// so seconds are enough to collapse them into one. +// +// Note also that an incomplete result is still returned to the caller - so a resolution committed +// during a degraded read (right after a nuke, say) is missing channels no matter what this cache +// does. That is pre-existing, and the same persistence applies: +// +// Be aware of what the TTL does NOT heal. The result of a resolution is stored, not just displayed: +// it becomes MessageUnboxedValid.ChannelNameMentions (see boxer.go) and is written to local storage +// with the message. So a message unboxed during the window that a newly created or renamed channel +// is missing from the cache keeps the stale resolution after the entry expires, until that message +// happens to be unboxed again. Expiry heals later resolutions, not ones already committed. Widening +// this duration widens that hole; if it ever needs to grow, wire up real invalidation first. +const topicNameCacheDuration = 10 * time.Second + +// A result missing some channels is cached too, but only for long enough to collapse the burst one +// page of messages fires. Refusing to cache it at all sounds safer and is not: the inbox read asks +// for every member status, so a team almost always carries channels the user has left or never +// joined, and those fail to resolve on every pass. "Incomplete" is therefore the steady state, and +// under that rule the cache would never hold anything - leaving the fan-out it exists to collapse. +// A channel that becomes resolvable is picked up after this window rather than the one above. +const topicNameCacheIncompleteDuration = time.Second + +type topicNameMemCache struct { + sync.RWMutex + // key: tlfID||topicType||uid + cache map[string]topicNameCacheItem +} + +func newTopicNameMemCache() *topicNameMemCache { + return &topicNameMemCache{ + cache: make(map[string]topicNameCacheItem), + } +} + +func (i *topicNameMemCache) key(tlfID chat1.TLFID, topicType chat1.TopicType, uid gregor1.UID) string { + return fmt.Sprintf("%s:%v:%s", tlfID, topicType, uid) +} + +func (i *topicNameMemCache) Get(tlfID chat1.TLFID, topicType chat1.TopicType, uid gregor1.UID) ([]chat1.ChannelNameMention, bool) { + i.RLock() + defer i.RUnlock() + item, ok := i.cache[i.key(tlfID, topicType, uid)] + if !ok { + return nil, false + } + ttl := topicNameCacheDuration + if !item.complete { + ttl = topicNameCacheIncompleteDuration + } + if time.Since(item.mtime.Time()) > ttl { + return nil, false + } + // Hand back a copy: callers own what they get, and this slice is shared. + return append([]chat1.ChannelNameMention(nil), item.names...), true +} + +func (i *topicNameMemCache) Put(tlfID chat1.TLFID, topicType chat1.TopicType, uid gregor1.UID, + names []chat1.ChannelNameMention, complete bool, +) { + i.Lock() + defer i.Unlock() + i.cache[i.key(tlfID, topicType, uid)] = topicNameCacheItem{ + names: append([]chat1.ChannelNameMention(nil), names...), + complete: complete, + mtime: gregor1.ToTime(time.Now()), + } +} + +func (i *topicNameMemCache) clearCache() { + i.Lock() + defer i.Unlock() + i.cache = make(map[string]topicNameCacheItem) +} + +func (i *topicNameMemCache) OnLogout(mctx libkb.MetaContext) error { + i.clearCache() + return nil +} + +func (i *topicNameMemCache) OnDbNuke(mctx libkb.MetaContext) error { + i.clearCache() + return nil +} + type TeamChannelSource struct { sync.Mutex globals.Contextified utils.DebugLabeler recentJoinsCache *recentJoinsMemCache lastActiveAtCache *lastActiveAtMemCache + topicNameCache *topicNameMemCache } var _ types.TeamChannelSource = (*TeamChannelSource)(nil) @@ -145,6 +244,7 @@ func NewTeamChannelSource(g *globals.Context) *TeamChannelSource { DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "TeamChannelSource", false), recentJoinsCache: newRecentJoinsMemCache(), lastActiveAtCache: newLastActiveAtMemCache(), + topicNameCache: newTopicNameMemCache(), } } @@ -152,6 +252,7 @@ func (c *TeamChannelSource) OnLogout(mctx libkb.MetaContext) error { epick := libkb.FirstErrorPicker{} epick.Push(c.recentJoinsCache.OnLogout(mctx)) epick.Push(c.lastActiveAtCache.OnLogout(mctx)) + epick.Push(c.topicNameCache.OnLogout(mctx)) return epick.Error() } @@ -159,6 +260,7 @@ func (c *TeamChannelSource) OnDbNuke(mctx libkb.MetaContext) error { epick := libkb.FirstErrorPicker{} epick.Push(c.recentJoinsCache.OnDbNuke(mctx)) epick.Push(c.lastActiveAtCache.OnDbNuke(mctx)) + epick.Push(c.topicNameCache.OnDbNuke(mctx)) return epick.Error() } @@ -256,41 +358,56 @@ func (c *TeamChannelSource) GetChannelsFull(ctx context.Context, uid gregor1.UID func (c *TeamChannelSource) GetChannelsTopicName(ctx context.Context, uid gregor1.UID, tlfID chat1.TLFID, topicType chat1.TopicType, ) (res []chat1.ChannelNameMention, err error) { + // Before the trace: this runs once per message body holding a `#token`, which reaches hundreds + // per second while paging a busy channel, and tracing a hit costs two log lines apiece. Safe + // because DebugLabeler.trace is pure logging - no context checks, no error handling. Note the + // misses below still fan out concurrently on a cold cache; this collapses the steady state, not + // the initial burst. + if cached, ok := c.topicNameCache.Get(tlfID, topicType, uid); ok { + return cached, nil + } ctx = globals.CtxModifyUnboxMode(ctx, types.UnboxModeQuick) defer c.Trace(ctx, &err, "GetChannelsTopicName: tlfID: %v, topicType: %v", tlfID, topicType)() - addValidMetadataMsg := func(convID chat1.ConversationID, msg chat1.MessageUnboxed) { + addValidMetadataMsg := func(convID chat1.ConversationID, msg chat1.MessageUnboxed) bool { if !msg.IsValid() { c.Debug(ctx, "GetChannelsTopicName: metadata message invalid: convID, %s", convID) - return + return false } body := msg.Valid().MessageBody typ, err := body.MessageType() if err != nil { c.Debug(ctx, "GetChannelsTopicName: error getting message type: convID, %s", convID, err) - return + return false } if typ != chat1.MessageType_METADATA { c.Debug(ctx, "GetChannelsTopicName: message not a real metadata message: convID, %s msgID: %d", convID, msg.GetMessageID()) - return + return false } res = append(res, chat1.ChannelNameMention{ ConvID: convID, TopicName: body.Metadata().ConversationTitle, }) + return true } convs, err := c.getTLFConversations(ctx, uid, tlfID, topicType) if err != nil { return nil, err } + // A channel we fail to resolve is left out of the result, and the result is then cached under a + // much shorter TTL (topicNameCacheIncompleteDuration) so the missing ones are retried soon. This + // matters most right after a db nuke, when local storage holds no METADATA messages yet and most + // of these fail. + complete := true for _, rc := range convs { conv := rc.Conv msg, err := conv.GetMaxMessage(chat1.MessageType_METADATA) if err != nil { + complete = false continue } unboxeds, err := c.G().ConvSource.GetMessages(ctx, conv.GetConvID(), uid, @@ -298,13 +415,27 @@ func (c *TeamChannelSource) GetChannelsTopicName(ctx context.Context, uid gregor if err != nil { c.Debug(ctx, "GetChannelsTopicName: failed to unbox metadata message for: convID: %s err: %s", conv.GetConvID(), err) + complete = false continue } if len(unboxeds) != 1 { c.Debug(ctx, "GetChannelsTopicName: empty result: convID: %s", conv.GetConvID()) + complete = false continue } - addValidMetadataMsg(conv.GetConvID(), unboxeds[0]) + if !addValidMetadataMsg(conv.GetConvID(), unboxeds[0]) { + complete = false + } + } + // len(convs) == 0 is never a legitimate answer - a chat TLF always has at least #general - so it + // means the inbox read came back degraded, and caching it would pin "this team has no channels" + // for the whole window. + if len(convs) > 0 { + if !complete { + c.Debug(ctx, "GetChannelsTopicName: incomplete result (%d of %d channels), caching briefly", + len(res), len(convs)) + } + c.topicNameCache.Put(tlfID, topicType, uid, res, complete) } return res, nil } diff --git a/go/chat/teamchannelsource_topicnamecache_test.go b/go/chat/teamchannelsource_topicnamecache_test.go new file mode 100644 index 000000000000..2d2b7f54e150 --- /dev/null +++ b/go/chat/teamchannelsource_topicnamecache_test.go @@ -0,0 +1,182 @@ +package chat + +import ( + "testing" + "time" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" + "github.com/stretchr/testify/require" +) + +func topicNameCacheFixture() (chat1.TLFID, chat1.TopicType, gregor1.UID, []chat1.ChannelNameMention) { + tlfID := chat1.TLFID([]byte{0x01, 0x02}) + uid := gregor1.UID([]byte{0x0a}) + names := []chat1.ChannelNameMention{ + {ConvID: chat1.ConversationID([]byte{0x10}), TopicName: "general"}, + {ConvID: chat1.ConversationID([]byte{0x11}), TopicName: "random"}, + } + return tlfID, chat1.TopicType_CHAT, uid, names +} + +func TestTopicNameMemCacheRoundTrip(t *testing.T) { + tlfID, topicType, uid, names := topicNameCacheFixture() + c := newTopicNameMemCache() + + _, ok := c.Get(tlfID, topicType, uid) + require.False(t, ok, "an empty cache must miss") + + c.Put(tlfID, topicType, uid, names, true) + got, ok := c.Get(tlfID, topicType, uid) + require.True(t, ok) + require.Equal(t, names, got) +} + +func TestTopicNameMemCacheKeysAreDistinct(t *testing.T) { + tlfID, topicType, uid, names := topicNameCacheFixture() + c := newTopicNameMemCache() + c.Put(tlfID, topicType, uid, names, true) + + otherTLF := chat1.TLFID([]byte{0x09, 0x09}) + otherUID := gregor1.UID([]byte{0xbb}) + + _, ok := c.Get(otherTLF, topicType, uid) + require.False(t, ok, "a different TLF must not share an entry") + _, ok = c.Get(tlfID, chat1.TopicType_DEV, uid) + require.False(t, ok, "a different topic type must not share an entry") + _, ok = c.Get(tlfID, topicType, otherUID) + require.False(t, ok, "a different uid must not share an entry") + + // The original is still there and untouched by the misses. + got, ok := c.Get(tlfID, topicType, uid) + require.True(t, ok) + require.Equal(t, names, got) +} + +// The cached slice is shared with every caller, so neither side may be able to reach into it. +func TestTopicNameMemCacheCopiesBothWays(t *testing.T) { + tlfID, topicType, uid, names := topicNameCacheFixture() + c := newTopicNameMemCache() + c.Put(tlfID, topicType, uid, names, true) + + // Mutating what the caller passed in must not reach the cache. + names[0].TopicName = "mutated-input" + got, ok := c.Get(tlfID, topicType, uid) + require.True(t, ok) + require.Equal(t, "general", got[0].TopicName) + + // Mutating what the caller got back must not reach the cache either. + got[0].TopicName = "mutated-output" + again, ok := c.Get(tlfID, topicType, uid) + require.True(t, ok) + require.Equal(t, "general", again[0].TopicName) +} + +func TestTopicNameMemCacheExpires(t *testing.T) { + tlfID, topicType, uid, names := topicNameCacheFixture() + c := newTopicNameMemCache() + c.Put(tlfID, topicType, uid, names, true) + + key := c.key(tlfID, topicType, uid) + + // Still inside the window. + c.Lock() + item := c.cache[key] + item.mtime = gregor1.ToTime(time.Now().Add(-topicNameCacheDuration + time.Second)) + c.cache[key] = item + c.Unlock() + _, ok := c.Get(tlfID, topicType, uid) + require.True(t, ok, "an entry inside the TTL must hit") + + // Past it. There is no explicit invalidation, so expiry is the only thing keeping a renamed + // channel from being served forever. + c.Lock() + item = c.cache[key] + item.mtime = gregor1.ToTime(time.Now().Add(-topicNameCacheDuration - time.Second)) + c.cache[key] = item + c.Unlock() + _, ok = c.Get(tlfID, topicType, uid) + require.False(t, ok, "an entry past the TTL must miss") +} + +// An empty conversation list is never a legitimate answer for a chat TLF, so it must not be cached. +// This pins the guard at the cache level; the caller-side guard lives in GetChannelsTopicName. +func TestTopicNameMemCacheEmptyIsStillAValue(t *testing.T) { + tlfID, topicType, uid, _ := topicNameCacheFixture() + c := newTopicNameMemCache() + + // The cache itself stores whatever it is given, including nothing - which is exactly why the + // caller must not hand it a degraded read. + c.Put(tlfID, topicType, uid, nil, true) + got, ok := c.Get(tlfID, topicType, uid) + require.True(t, ok, "an empty slice is a cached value, not a miss") + require.Empty(t, got) +} + +// A team almost always holds channels the user cannot resolve - ones they left or never joined - so +// an incomplete result is the steady state. It is cached anyway, or the fan-out this cache exists to +// collapse would never be collapsed, but only for the shorter window. +func TestTopicNameMemCacheIncompleteExpiresSooner(t *testing.T) { + tlfID, topicType, uid, names := topicNameCacheFixture() + c := newTopicNameMemCache() + c.Put(tlfID, topicType, uid, names, false) + + key := c.key(tlfID, topicType, uid) + got, ok := c.Get(tlfID, topicType, uid) + require.True(t, ok, "an incomplete result is still cached") + require.Equal(t, names, got) + + // Old enough that the complete TTL would still serve it, and the incomplete one does not. + c.Lock() + item := c.cache[key] + item.mtime = gregor1.ToTime(time.Now().Add(-topicNameCacheIncompleteDuration - time.Second)) + c.cache[key] = item + c.Unlock() + require.Less(t, topicNameCacheIncompleteDuration+time.Second, topicNameCacheDuration, + "the fixture only proves anything while the two windows differ by more than this") + _, ok = c.Get(tlfID, topicType, uid) + require.False(t, ok, "an incomplete entry must expire on the shorter window") + + // The same age under a complete entry still hits, so it is the flag doing the work. + c.Put(tlfID, topicType, uid, names, true) + c.Lock() + item = c.cache[key] + item.mtime = gregor1.ToTime(time.Now().Add(-topicNameCacheIncompleteDuration - time.Second)) + c.cache[key] = item + c.Unlock() + _, ok = c.Get(tlfID, topicType, uid) + require.True(t, ok, "a complete entry of the same age must still hit") +} + +// The TTL is the only bound on staleness - there is no invalidation hook - and the comment on the +// constant argues from it being short. Pin the value so widening it is a deliberate act. +func TestTopicNameCacheDurationStaysShort(t *testing.T) { + require.LessOrEqual(t, topicNameCacheDuration, 30*time.Second, + "a longer window widens the hole where a resolution is stored stale into a message") + require.Positive(t, topicNameCacheDuration) + require.Positive(t, topicNameCacheIncompleteDuration) + require.Less(t, topicNameCacheIncompleteDuration, topicNameCacheDuration, + "a result known to be missing channels must not be held as long as a whole one") +} + +func TestTopicNameMemCacheClear(t *testing.T) { + tlfID, topicType, uid, names := topicNameCacheFixture() + c := newTopicNameMemCache() + c.Put(tlfID, topicType, uid, names, true) + + c.clearCache() + _, ok := c.Get(tlfID, topicType, uid) + require.False(t, ok, "clearCache must drop everything") + + // Logout and db nuke both go through the same clear, and both must leave the cache usable. + c.Put(tlfID, topicType, uid, names, true) + require.NoError(t, c.OnLogout(libkb.MetaContext{})) + _, ok = c.Get(tlfID, topicType, uid) + require.False(t, ok, "OnLogout must drop everything") + + c.Put(tlfID, topicType, uid, names, true) + require.NoError(t, c.OnDbNuke(libkb.MetaContext{})) + _, ok = c.Get(tlfID, topicType, uid) + require.False(t, ok, "OnDbNuke must drop everything") +}